Aggiunge IMAP bounce e flag email non valide nel CRM.
CI / scan_ruby (push) Failing after 13m27s
CI / scan_js (push) Successful in 11m45s
CI / lint (push) Failing after 12m4s

Permette di leggere i bounce dalla stessa MailIdentity SMTP, aggiornare le email in anagrafica e saltare gli indirizzi invalidi nei mailing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-08 19:46:04 +02:00
co-authored by Cursor
parent 353ace4d99
commit 36b3ffe507
26 changed files with 922 additions and 12 deletions
+242
View File
@@ -0,0 +1,242 @@
# frozen_string_literal: true
require "net/imap"
require "mail"
module Mailings
# Legge bounce / DSN dalla casella IMAP di una MailIdentity (stesse credenziali SMTP).
class ImapBounceReader
BOUNCE_SUBJECT = /
Recapito\s+fallito|
Undelivered\s+Mail\s+Returned|
Undeliverable|
Delivery\s+Status|
Mail\s+delivery\s+failed|
Returned\s+mail
/ix
EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/
Result = Struct.new(:ok, :identity, :bounces, :error, :scanned, keyword_init: true)
def initialize(identity, since: 14.days.ago, mailbox: "INBOX", limit: 200)
@identity = identity
@since = since
@mailbox = mailbox
@limit = limit
end
def call
unless @identity.imap_ready?
return Result.new(ok: false, identity: summary, error: "IMAP non configurato o disabilitato per questo account", bounces: [], scanned: 0)
end
if @identity.smtp_password.blank? || @identity.imap_username.blank?
return Result.new(ok: false, identity: summary, error: "Mancano username/password (usa le credenziali SMTP)", bounces: [], scanned: 0)
end
imap = connect!
begin
imap.examine(@mailbox)
ids = search_ids(imap)
bounces = []
ids.last(@limit).each do |id|
raw = imap.fetch(id, "RFC822")&.first&.attr&.fetch("RFC822")
next if raw.blank?
parsed = parse_message(raw, uid: id)
bounces << parsed if parsed
end
Result.new(ok: true, identity: summary, bounces: bounces, error: nil, scanned: ids.size)
ensure
begin
imap.logout
rescue StandardError
nil
end
begin
imap.disconnect
rescue StandardError
nil
end
end
rescue StandardError => e
Result.new(ok: false, identity: summary, error: "#{e.class}: #{e.message}", bounces: [], scanned: 0)
end
private
def summary
{
id: @identity.id,
name: @identity.name,
from_email: @identity.from_email,
imap_host: @identity.effective_imap_host,
imap_port: @identity.effective_imap_port
}
end
def connect!
settings = @identity.imap_settings
imap = Net::IMAP.new(settings[:address], port: settings[:port], ssl: settings[:ssl], open_timeout: 20)
imap.login(settings[:user_name], settings[:password])
imap
end
def search_ids(imap)
keys = [ "OR", "SUBJECT", "Recapito fallito", "OR", "SUBJECT", "Undelivered", "SUBJECT", "Undeliverable" ]
begin
since_key = [ "SINCE", Net::IMAP.format_date(@since.to_date) ]
imap.search(since_key + keys)
rescue Net::IMAP::BadResponseError, Net::IMAP::NoResponseError
imap.search(keys)
end
end
def parse_message(raw, uid:)
mail = Mail.read_from_string(raw)
subject = mail.subject.to_s
return nil unless bounce_like?(subject, mail)
body = full_text(mail)
failed = extract_failed_emails(mail, body)
own = own_addresses
failed.reject! { |e| own.include?(e) || noise_email?(e) }
return nil if failed.empty? && !bounce_like?(subject, mail)
{
uid: uid,
date: mail.date&.iso8601,
bounce_subject: subject,
failed_emails: failed.presence || extract_failed_fallback(body) - own.to_a,
original_subject: extract_original_subject(mail, body, subject),
club_hint: nil,
reason: extract_reason(body)
}.tap do |row|
row[:club_hint] = club_from_subject(row[:original_subject])
row[:failed_emails] = Array(row[:failed_emails]).uniq
end
rescue StandardError
nil
end
def bounce_like?(subject, mail)
return true if subject.match?(BOUNCE_SUBJECT)
from = mail.from.to_a.join(" ").downcase
from.include?("mailer-daemon") || from.include?("mail-daemon") || from.include?("postmaster")
end
def full_text(mail)
parts = []
if mail.multipart?
mail.parts.each { |p| parts << part_text(p) }
else
parts << begin
mail.decoded.to_s
rescue StandardError
mail.body.to_s
end
end
parts.compact.join("\n")
end
def part_text(part)
if part.multipart?
part.parts.map { |p| part_text(p) }.join("\n")
elsif part.content_type.to_s =~ %r{text/|message/delivery-status|message/rfc822}i
part.decoded.to_s
else
""
end
rescue StandardError
""
end
def extract_failed_emails(mail, body)
found = []
body.to_s.scan(/Final-Recipient:\s*rfc822;\s*(#{EMAIL_RE.source})/i) { found << Regexp.last_match(1).downcase }
body.to_s.scan(/Original-Recipient:\s*rfc822;\s*(#{EMAIL_RE.source})/i) { found << Regexp.last_match(1).downcase }
body.to_s.scan(/X-Failed-Recipients:\s*(#{EMAIL_RE.source})/i) { found << Regexp.last_match(1).downcase }
body.to_s.scan(/The mail system\s*<(#{EMAIL_RE.source})>/i) { found << Regexp.last_match(1).downcase }
body.to_s.scan(/Invalid Recipient\s*<(#{EMAIL_RE.source})>/i) { found << Regexp.last_match(1).downcase }
body.to_s.scan(/RCPT TO:<(#{EMAIL_RE.source})>/i) { found << Regexp.last_match(1).downcase }
if (m = body.to_s.match(/Delivery to the following recipients failed permanently:(.*?)(?:Reason:|Reporting-MTA:)/im))
m[1].scan(EMAIL_RE).each { |e| found << e.downcase }
end
found.uniq
end
def extract_failed_fallback(body)
body.to_s.scan(/<(#{EMAIL_RE.source})>/).flatten.map(&:downcase).uniq.reject { |e| noise_email?(e) }.first(3)
end
def extract_original_subject(mail, body, bounce_subject)
if bounce_subject.to_s.match?(/\AUndeliverable:\s*/i)
return bounce_subject.sub(/\AUndeliverable:\s*/i, "").strip
end
if (nested = mail.parts.find { |p| p.content_type.to_s.include?("message/rfc822") })
begin
inner = Mail.read_from_string(nested.body.decoded)
return inner.subject.to_s if inner.subject.present?
rescue StandardError
nil
end
end
if (m = body.to_s.match(/(?:^|\n)Subject:\s*(.+)/i))
raw_subj = m[1].to_s.strip
begin
return Mail::Encodings.value_decode(raw_subj)
rescue StandardError
return raw_subj
end
end
nil
end
def club_from_subject(subject)
return if subject.blank?
[
/Pi[ùu] visibilit[àa] (?:per|alle partite di) (.+)/i,
/Le partite di (.+?) meritano/i,
/alle partite di (.+)$/i
].each do |pat|
m = subject.match(pat)
return m[1].strip if m
end
nil
end
def extract_reason(body)
[
/Reason:\s*(.+)/i,
/Diagnostic-Code:[^\n]+/i,
/550[^\n]{0,160}/,
/User unknown[^\n]{0,80}/i,
/mailbox unavailable[^\n]{0,80}/i
].each do |pat|
m = body.to_s.match(pat)
return m[0].to_s.gsub(/\s+/, " ").strip[0, 220] if m
end
nil
end
def own_addresses
[
@identity.from_email,
@identity.reply_to,
@identity.smtp_username,
@identity.imap_username
].compact.map { |e| e.to_s.downcase.strip }.to_set
end
def noise_email?(email)
email = email.to_s.downcase
return true if email.end_with?(".mail") && email.split("@").last !~ /\./
return true if email.match?(/\A[0-9a-f]{10,}_/)
return true if email.include?("mailer-daemon") || email.start_with?("postmaster@")
return true if email.end_with?("@vmbox")
false
end
end
end