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
+202
View File
@@ -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,