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:
@@ -147,8 +147,206 @@ module Crm
|
||||
end
|
||||
end
|
||||
|
||||
# Aggiorna email organizzazione/contatto: bounce, sostituzione, flag non valida + nota timeline.
|
||||
def update_organization_email(project_code, attrs)
|
||||
with_project(project_code) do
|
||||
attrs = attrs.to_h.symbolize_keys
|
||||
organization = organizations_scope.includes(:contacts).find(attrs[:organization_id])
|
||||
contact = nil
|
||||
if attrs[:contact_id].present?
|
||||
contact = organization.contacts.find(attrs[:contact_id])
|
||||
elsif attrs[:update_primary_contact] != false
|
||||
contact = organization.primary_contact || organization.contacts.order(:id).first
|
||||
end
|
||||
|
||||
bounced = attrs[:bounced_email].to_s.strip.downcase.presence
|
||||
new_email = attrs.key?(:email) ? attrs[:email].to_s.strip.downcase.presence : :unchanged
|
||||
mark_invalid = if attrs.key?(:email_invalid)
|
||||
ActiveModel::Type::Boolean.new.cast(attrs[:email_invalid])
|
||||
elsif bounced.present? && (new_email == :unchanged || new_email.nil? || new_email == bounced)
|
||||
true
|
||||
elsif new_email.is_a?(String) && new_email != bounced
|
||||
false
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
previous_org_email = organization.email
|
||||
previous_contact_email = contact&.email
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
org_changes = {}
|
||||
if bounced
|
||||
org_changes[:bounced_email] = bounced
|
||||
end
|
||||
unless new_email == :unchanged
|
||||
org_changes[:email] = new_email
|
||||
end
|
||||
unless mark_invalid.nil?
|
||||
org_changes[:email_invalid] = mark_invalid
|
||||
end
|
||||
if attrs[:website].present?
|
||||
org_changes[:website] = attrs[:website].to_s.strip
|
||||
end
|
||||
organization.update!(org_changes) if org_changes.any?
|
||||
|
||||
if contact
|
||||
contact_changes = {}
|
||||
if bounced
|
||||
contact_changes[:bounced_email] = bounced
|
||||
end
|
||||
unless new_email == :unchanged
|
||||
contact_changes[:email] = new_email
|
||||
end
|
||||
unless mark_invalid.nil?
|
||||
contact_changes[:email_invalid] = mark_invalid
|
||||
end
|
||||
contact.update!(contact_changes) if contact_changes.any?
|
||||
end
|
||||
|
||||
subject = attrs[:activity_subject].presence || begin
|
||||
if new_email.is_a?(String) && new_email.present? && new_email != bounced
|
||||
"Email aggiornata dopo bounce"
|
||||
else
|
||||
"Email non valida (bounce)"
|
||||
end
|
||||
end
|
||||
description = attrs[:activity_description].presence || build_email_update_description(
|
||||
bounced: bounced,
|
||||
new_email: new_email == :unchanged ? nil : new_email,
|
||||
previous_org_email: previous_org_email,
|
||||
previous_contact_email: previous_contact_email,
|
||||
reason: attrs[:bounce_reason]
|
||||
)
|
||||
|
||||
activity = organization.activities.create!(
|
||||
user: @user,
|
||||
contact: contact,
|
||||
activity_type: "note",
|
||||
subject: subject,
|
||||
description: description,
|
||||
happened_at: Time.current
|
||||
)
|
||||
|
||||
ok(
|
||||
organization: organization_json(organization.reload),
|
||||
contact: contact ? contact_json(contact.reload) : nil,
|
||||
activity: activity_json(activity)
|
||||
)
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
err("Organizzazione o contatto non trovato", status: :not_found)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
validation_error(e.record)
|
||||
end
|
||||
end
|
||||
|
||||
# Legge bounce IMAP dall'account SMTP/IMAP (stesse credenziali MailIdentity).
|
||||
def check_mail_bounces(project_code = nil, attrs = {})
|
||||
attrs = attrs.to_h.symbolize_keys
|
||||
if project_code.present?
|
||||
project = Project.active.find_by(code: project_code.to_s)
|
||||
return err("Progetto non trovato", status: :not_found) if project.nil?
|
||||
return err("Progetto non accessibile", status: :forbidden) unless @user.can_access_project?(project)
|
||||
|
||||
@project = project
|
||||
Current.project = project
|
||||
end
|
||||
|
||||
identity = find_mail_identity(attrs)
|
||||
return err("Account email non trovato", status: :not_found) if identity.nil?
|
||||
|
||||
since_days = (attrs[:since_days].presence || 14).to_i.clamp(1, 90)
|
||||
result = Mailings::ImapBounceReader.new(
|
||||
identity,
|
||||
since: since_days.days.ago,
|
||||
mailbox: attrs[:mailbox].presence || "INBOX",
|
||||
limit: (attrs[:limit].presence || 200).to_i.clamp(1, 500)
|
||||
).call
|
||||
|
||||
unless result.ok
|
||||
return err(result.error || "Lettura IMAP fallita", status: :unprocessable_entity, extra: { identity: result.identity })
|
||||
end
|
||||
|
||||
bounces = enrich_bounces_with_crm(result.bounces)
|
||||
ok(
|
||||
identity: result.identity,
|
||||
since_days: since_days,
|
||||
scanned: result.scanned,
|
||||
bounce_count: bounces.size,
|
||||
unique_failed_emails: bounces.flat_map { |b| b[:failed_emails] }.uniq.sort,
|
||||
bounces: bounces
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_mail_identity(attrs)
|
||||
scope = MailIdentity.active
|
||||
if attrs[:mail_identity_id].present?
|
||||
scope.find_by(id: attrs[:mail_identity_id])
|
||||
elsif attrs[:from_email].present?
|
||||
scope.find_by("LOWER(from_email) = ?", attrs[:from_email].to_s.downcase.strip)
|
||||
else
|
||||
scope.imap_enabled.ordered.find_by("LOWER(from_email) = ?", "info@matchlivetv.it") ||
|
||||
scope.imap_enabled.ordered.first
|
||||
end
|
||||
end
|
||||
|
||||
def enrich_bounces_with_crm(bounces)
|
||||
return bounces if @project.nil?
|
||||
|
||||
orgs = organizations_scope.includes(:contacts).to_a
|
||||
bounces.map do |bounce|
|
||||
matches = []
|
||||
Array(bounce[:failed_emails]).each do |email|
|
||||
org = orgs.find { |o| o.email.to_s.downcase == email } ||
|
||||
orgs.find { |o| o.contacts.any? { |c| c.email.to_s.downcase == email } } ||
|
||||
orgs.find { |o| o.bounced_email.to_s.downcase == email }
|
||||
next unless org
|
||||
|
||||
matches << {
|
||||
organization_id: org.id,
|
||||
name: org.name,
|
||||
current_email: org.email,
|
||||
email_invalid: org.email_invalid?,
|
||||
sport: org.sport
|
||||
}
|
||||
end
|
||||
if matches.empty? && bounce[:club_hint].present?
|
||||
hint = bounce[:club_hint].to_s.downcase
|
||||
org = orgs.find { |o| o.name.to_s.downcase == hint } ||
|
||||
orgs.find { |o| o.name.to_s.downcase.include?(hint) || hint.include?(o.name.to_s.downcase) }
|
||||
if org
|
||||
matches << {
|
||||
organization_id: org.id,
|
||||
name: org.name,
|
||||
current_email: org.email,
|
||||
email_invalid: org.email_invalid?,
|
||||
sport: org.sport,
|
||||
matched_by: "club_hint"
|
||||
}
|
||||
end
|
||||
end
|
||||
bounce.merge(crm_matches: matches)
|
||||
end
|
||||
end
|
||||
|
||||
def build_email_update_description(bounced:, new_email:, previous_org_email:, previous_contact_email:, reason: nil)
|
||||
lines = []
|
||||
lines << "Bounce / email non recapitabile: #{bounced}" if bounced.present?
|
||||
lines << "Motivo: #{reason}" if reason.present?
|
||||
lines << "Email organizzazione precedente: #{previous_org_email}" if previous_org_email.present?
|
||||
lines << "Email contatto precedente: #{previous_contact_email}" if previous_contact_email.present?
|
||||
if new_email.present?
|
||||
lines << "Nuova email impostata: #{new_email}"
|
||||
elsif bounced.present?
|
||||
lines << "Nessuna email alternativa trovata; indirizzo marcato come non valido."
|
||||
end
|
||||
lines.join("\n")
|
||||
end
|
||||
|
||||
|
||||
def with_project(code)
|
||||
project = Project.active.find_by(code: code.to_s)
|
||||
return err("Progetto non trovato", status: :not_found) if project.nil?
|
||||
@@ -230,6 +428,8 @@ module Crm
|
||||
region: org.region,
|
||||
country: org.country,
|
||||
email: org.email,
|
||||
email_invalid: org.email_invalid,
|
||||
bounced_email: org.bounced_email,
|
||||
phone: org.phone,
|
||||
website: org.website,
|
||||
lead_source: org.lead_source,
|
||||
@@ -259,6 +459,8 @@ module Crm
|
||||
full_name: contact.full_name,
|
||||
role: contact.role,
|
||||
email: contact.email,
|
||||
email_invalid: contact.email_invalid,
|
||||
bounced_email: contact.bounced_email,
|
||||
phone: contact.phone,
|
||||
mobile: contact.mobile,
|
||||
primary_contact: contact.primary_contact,
|
||||
|
||||
@@ -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
|
||||
@@ -19,6 +19,9 @@ class Mailings::RecipientBuilder
|
||||
elsif !email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
attrs[:status] = "skipped"
|
||||
attrs[:skip_reason] = "email non valida"
|
||||
elsif email_marked_invalid?(org, contact, email)
|
||||
attrs[:status] = "skipped"
|
||||
attrs[:skip_reason] = "email bounce / non valida"
|
||||
else
|
||||
attrs[:status] = "pending"
|
||||
end
|
||||
@@ -54,4 +57,17 @@ class Mailings::RecipientBuilder
|
||||
end
|
||||
attrs[:ab_variant] = variant
|
||||
end
|
||||
|
||||
def email_marked_invalid?(org, contact, email)
|
||||
email = email.to_s.downcase
|
||||
if contact&.email.to_s.downcase == email && contact.email_invalid?
|
||||
true
|
||||
elsif org.email.to_s.downcase == email && org.email_invalid?
|
||||
true
|
||||
elsif contact&.bounced_email.to_s.downcase == email || org.bounced_email.to_s.downcase == email
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user