Aggiunge IMAP bounce e flag email non valide nel CRM.
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:
@@ -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
|
||||
Reference in New Issue
Block a user