# 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