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>
243 lines
7.5 KiB
Ruby
243 lines
7.5 KiB
Ruby
# 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
|