diff --git a/AGENTS.md b/AGENTS.md index 9885a10..a464588 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ Endpoint MCP: `http://localhost:3001/mcp` (Bearer). In produzione usa l’URL pu 1. `list_projects` — ottieni i `project_code` (matchlivetv, riskmeter, cardoo, …). 2. `today` con quel codice — task scaduti / oggi / in arrivo e opportunità ferme. 3. `search` o `get_organization` per il contesto. -4. Agisci con `create_activity`, `complete_task`, `create_task`, `update_opportunity_stage`. +4. Agisci con `create_activity`, `complete_task`, `create_task`, `update_opportunity_stage`, `update_organization_email`, `check_mail_bounces`. +5. Per i bounce della casella mittente: `check_mail_bounces` (IMAP = stesse credenziali SMTP della MailIdentity). ## Limiti diff --git a/README.md b/README.md index 4a98df3..196ef75 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ Config già nel repo (il token sta solo in env, non nei file): Istruzioni per gli agenti: [AGENTS.md](AGENTS.md). -Tool: `list_projects`, `today`, `search`, `get_organization`, `create_task`, `complete_task`, `create_activity`, `update_opportunity_stage`. +Tool: `list_projects`, `today`, `search`, `get_organization`, `create_task`, `complete_task`, `create_activity`, `update_opportunity_stage`, `update_organization_email`, `check_mail_bounces`. Stdio (`mcp/server.rb` / `bin/crm-mcp`) resta come alternativa se un client non parla HTTP. diff --git a/app/controllers/api/v1/mail_bounces_controller.rb b/app/controllers/api/v1/mail_bounces_controller.rb new file mode 100644 index 0000000..8b9ddc4 --- /dev/null +++ b/app/controllers/api/v1/mail_bounces_controller.rb @@ -0,0 +1,24 @@ +module Api + module V1 + class MailBouncesController < BaseController + def index + render_agent Crm::AgentSession.new(current_user).check_mail_bounces( + params[:project_code], + bounce_params + ) + end + + private + + def bounce_params + params.permit( + :mail_identity_id, + :from_email, + :since_days, + :mailbox, + :limit + ).to_h + end + end + end +end diff --git a/app/controllers/api/v1/organization_emails_controller.rb b/app/controllers/api/v1/organization_emails_controller.rb new file mode 100644 index 0000000..d67594d --- /dev/null +++ b/app/controllers/api/v1/organization_emails_controller.rb @@ -0,0 +1,29 @@ +module Api + module V1 + class OrganizationEmailsController < BaseController + def update + render_agent Crm::AgentSession.new(current_user).update_organization_email( + params[:project_code], + email_params + ) + end + + private + + def email_params + params.permit( + :organization_id, + :contact_id, + :email, + :email_invalid, + :bounced_email, + :bounce_reason, + :website, + :activity_subject, + :activity_description, + :update_primary_contact + ).to_h + end + end + end +end diff --git a/app/controllers/contacts_controller.rb b/app/controllers/contacts_controller.rb index 78feef4..c71cc17 100644 --- a/app/controllers/contacts_controller.rb +++ b/app/controllers/contacts_controller.rb @@ -66,7 +66,7 @@ class ContactsController < ApplicationController def contact_params params.require(:contact).permit( - :organization_id, :first_name, :last_name, :role, :email, :phone, :mobile, + :organization_id, :first_name, :last_name, :role, :email, :email_invalid, :bounced_email, :phone, :mobile, :preferred_contact_method, :notes, :primary_contact ) end diff --git a/app/controllers/mail_identities_controller.rb b/app/controllers/mail_identities_controller.rb index 97d08a9..d425c13 100644 --- a/app/controllers/mail_identities_controller.rb +++ b/app/controllers/mail_identities_controller.rb @@ -57,6 +57,7 @@ class MailIdentitiesController < ApplicationController params.require(:mail_identity).permit( :name, :from_name, :from_email, :reply_to, :smtp_host, :smtp_port, :smtp_username, :smtp_password, :smtp_authentication, :encryption, + :imap_host, :imap_port, :imap_enabled, :verify_ssl, :active ) end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index e4e0a9e..0444eae 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -86,7 +86,7 @@ class OrganizationsController < ApplicationController def organization_params params.require(:organization).permit( :name, :legal_name, :organization_type, :sport, :country, :region, :province, :city, - :address, :website, :source_url, :phone, :email, :vat_number, :notes, :status, :lead_source, + :address, :website, :source_url, :phone, :email, :email_invalid, :bounced_email, :vat_number, :notes, :status, :lead_source, :assigned_user_id, :list_position, :team_gender, :streaming_status, :commercial_fit, :verified_at, project_ids: [] ) diff --git a/app/models/mail_identity.rb b/app/models/mail_identity.rb index 7331a88..66a633a 100644 --- a/app/models/mail_identity.rb +++ b/app/models/mail_identity.rb @@ -9,15 +9,30 @@ class MailIdentity < ApplicationRecord ENCRYPTIONS = Catalog::MAIL_ENCRYPTIONS.keys.freeze + # Mapping host SMTP → IMAP tipici dei provider usati in produzione. + IMAP_HOST_HINTS = { + "aruba.it" => "imaps.aruba.it", + "smtps.aruba.it" => "imaps.aruba.it", + "smtp.aruba.it" => "imaps.aruba.it", + "gmail.com" => "imap.gmail.com", + "smtp.gmail.com" => "imap.gmail.com", + "outlook.com" => "outlook.office365.com", + "smtp.office365.com" => "outlook.office365.com" + }.freeze + validates :name, :from_name, :from_email, :smtp_host, :smtp_port, presence: true validates :from_email, format: { with: URI::MailTo::EMAIL_REGEXP } validates :reply_to, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true validates :smtp_port, numericality: { in: 1..65535 } + validates :imap_port, numericality: { in: 1..65535 }, allow_nil: true validates :encryption, inclusion: { in: ENCRYPTIONS } validates :smtp_authentication, inclusion: { in: Catalog::MAIL_AUTH_METHODS.keys } validate :encryption_matches_port + before_validation :apply_imap_defaults + scope :active, -> { where(active: true) } + scope :imap_enabled, -> { where(imap_enabled: true) } scope :ordered, -> { order(:name) } def from_header @@ -42,8 +57,56 @@ class MailIdentity < ApplicationRecord settings end + def imap_username + smtp_username.presence || from_email + end + + def effective_imap_host + imap_host.presence || inferred_imap_host + end + + def effective_imap_port + (imap_port.presence || 993).to_i + end + + def imap_ready? + imap_enabled? && effective_imap_host.present? && effective_imap_port.positive? + end + + def imap_settings + { + address: effective_imap_host, + port: effective_imap_port, + ssl: true, + user_name: imap_username, + password: smtp_password + } + end + + def inferred_imap_host + host = smtp_host.to_s.downcase.strip + return if host.blank? + + IMAP_HOST_HINTS.each do |needle, imap| + return imap if host == needle || host.end_with?(".#{needle}") || host.include?(needle) + end + + # Fallback generico: smtp.X → imap.X, smtps.X → imaps.X + if host.start_with?("smtps.") + host.sub(/\Asmtps\./, "imaps.") + elsif host.start_with?("smtp.") + host.sub(/\Asmtp\./, "imap.") + end + end + private + def apply_imap_defaults + self.imap_port = 993 if imap_port.blank? + self.imap_enabled = true if imap_enabled.nil? + self.imap_host = inferred_imap_host if imap_host.blank? && inferred_imap_host.present? + end + def encryption_matches_port return if smtp_port.blank? || encryption.blank? diff --git a/app/services/crm/agent_session.rb b/app/services/crm/agent_session.rb index fbc5e12..f637426 100644 --- a/app/services/crm/agent_session.rb +++ b/app/services/crm/agent_session.rb @@ -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, diff --git a/app/services/mailings/imap_bounce_reader.rb b/app/services/mailings/imap_bounce_reader.rb new file mode 100644 index 0000000..05cced6 --- /dev/null +++ b/app/services/mailings/imap_bounce_reader.rb @@ -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 diff --git a/app/services/mailings/recipient_builder.rb b/app/services/mailings/recipient_builder.rb index ffbfced..252c75f 100644 --- a/app/services/mailings/recipient_builder.rb +++ b/app/services/mailings/recipient_builder.rb @@ -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 diff --git a/app/views/contacts/_form.html.erb b/app/views/contacts/_form.html.erb index 7c18872..3bfa5e2 100644 --- a/app/views/contacts/_form.html.erb +++ b/app/views/contacts/_form.html.erb @@ -23,6 +23,14 @@ <%= f.email_field :email, placeholder: "Email", class: input_class %> +
+ + <%= f.email_field :bounced_email, placeholder: "Indirizzo non recapitabile", class: input_class %> +
+
<%= f.text_field :phone, placeholder: "Telefono", class: input_class %> diff --git a/app/views/mail_identities/_form.html.erb b/app/views/mail_identities/_form.html.erb index e2a7f1f..85c4d74 100644 --- a/app/views/mail_identities/_form.html.erb +++ b/app/views/mail_identities/_form.html.erb @@ -42,7 +42,24 @@
<%= f.password_field :smtp_password, autocomplete: "new-password", class: input_class %> +

Stessa password usata anche per IMAP (lettura bounce).

+
+

IMAP (lettura casella / bounce)

+

Di solito host e porta si compilano da soli in base all’SMTP (es. Aruba → imaps.aruba.it:993).

+
+
+ + <%= f.text_field :imap_host, placeholder: mail_identity.inferred_imap_host || "imaps.aruba.it", class: input_class %> +
+
+ + <%= f.number_field :imap_port, min: 1, max: 65535, class: input_class %> +
+
-
<%= [contact.role, contact.email, contact.phone.presence || contact.mobile].compact_blank.join(" · ") %>
+
+ <%= [contact.role, contact.email, contact.phone.presence || contact.mobile].compact_blank.join(" · ") %> + <% if contact.email_invalid? %> + Email non valida + <% end %> +
<%= link_to "Modifica", edit_contact_path(contact), class: "text-sm text-zinc-600 hover:underline dark:text-zinc-300" %> diff --git a/config/routes.rb b/config/routes.rb index 6026d03..bb5f320 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,8 @@ Rails.application.routes.draw do get "today", to: "today#show" get "search", to: "search#show" get "organizations/:id", to: "organizations#show", as: :organization + patch "organizations/:organization_id/email", to: "organization_emails#update", as: :organization_email + get "mail_bounces", to: "mail_bounces#index", as: :mail_bounces post "tasks", to: "tasks#create" post "tasks/:id/complete", to: "tasks#complete", as: :complete_task post "activities", to: "activities#create" diff --git a/db/migrate/20260908160000_add_email_invalid_to_organizations_and_contacts.rb b/db/migrate/20260908160000_add_email_invalid_to_organizations_and_contacts.rb new file mode 100644 index 0000000..f6ce4b6 --- /dev/null +++ b/db/migrate/20260908160000_add_email_invalid_to_organizations_and_contacts.rb @@ -0,0 +1,11 @@ +class AddEmailInvalidToOrganizationsAndContacts < ActiveRecord::Migration[8.1] + def change + add_column :organizations, :email_invalid, :boolean, default: false, null: false + add_column :organizations, :bounced_email, :string + add_column :contacts, :email_invalid, :boolean, default: false, null: false + add_column :contacts, :bounced_email, :string + + add_index :organizations, :email_invalid + add_index :contacts, :email_invalid + end +end diff --git a/db/migrate/20260908184500_add_imap_settings_to_mail_identities.rb b/db/migrate/20260908184500_add_imap_settings_to_mail_identities.rb new file mode 100644 index 0000000..91ac689 --- /dev/null +++ b/db/migrate/20260908184500_add_imap_settings_to_mail_identities.rb @@ -0,0 +1,7 @@ +class AddImapSettingsToMailIdentities < ActiveRecord::Migration[8.1] + def change + add_column :mail_identities, :imap_host, :string + add_column :mail_identities, :imap_port, :integer, default: 993, null: false + add_column :mail_identities, :imap_enabled, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 90b62d3..1261f3e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do +ActiveRecord::Schema[8.1].define(version: 2026_09_08_184500) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -79,9 +79,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do end create_table "contacts", force: :cascade do |t| + t.string "bounced_email" t.datetime "created_at", null: false t.bigint "created_by_id" t.string "email" + t.boolean "email_invalid", default: false, null: false t.string "first_name", null: false t.string "last_name", null: false t.string "mobile" @@ -94,6 +96,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do t.datetime "updated_at", null: false t.bigint "updated_by_id" t.index ["email"], name: "index_contacts_on_email" + t.index ["email_invalid"], name: "index_contacts_on_email_invalid" t.index ["last_name", "first_name"], name: "index_contacts_on_last_name_and_first_name" t.index ["organization_id"], name: "index_contacts_on_organization_id" t.index ["phone"], name: "index_contacts_on_phone" @@ -107,6 +110,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do t.string "encryption", default: "starttls", null: false t.string "from_email", null: false t.string "from_name", null: false + t.boolean "imap_enabled", default: true, null: false + t.string "imap_host" + t.integer "imap_port", default: 993, null: false t.string "name", null: false t.string "reply_to" t.string "smtp_authentication", default: "plain", null: false @@ -247,12 +253,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do create_table "organizations", force: :cascade do |t| t.string "address" t.bigint "assigned_user_id" + t.string "bounced_email" t.string "city" t.text "commercial_fit" t.string "country", default: "Italia", null: false t.datetime "created_at", null: false t.bigint "created_by_id" t.string "email" + t.boolean "email_invalid", default: false, null: false t.string "lead_source" t.string "legal_name" t.integer "list_position" @@ -276,6 +284,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do t.index ["city"], name: "index_organizations_on_city" t.index ["country"], name: "index_organizations_on_country" t.index ["email"], name: "index_organizations_on_email" + t.index ["email_invalid"], name: "index_organizations_on_email_invalid" t.index ["lead_source"], name: "index_organizations_on_lead_source" t.index ["list_position"], name: "index_organizations_on_list_position" t.index ["name"], name: "index_organizations_on_name" diff --git a/lib/eminux_crm_mcp.rb b/lib/eminux_crm_mcp.rb index f82c34f..2b34ef2 100644 --- a/lib/eminux_crm_mcp.rb +++ b/lib/eminux_crm_mcp.rb @@ -9,8 +9,9 @@ module EminuxCrmMcp 1. list_projects per i project_code (matchlivetv, riskmeter, cardoo, …). 2. today per capire cosa fare oggi. 3. search / get_organization per il contesto. - 4. create_activity, complete_task, create_task, update_opportunity_stage per agire. + 4. create_activity, complete_task, create_task, update_opportunity_stage, update_organization_email, check_mail_bounces per agire. Non inviare mailing, non cancellare record, non gestire utenti. + Per i bounce usa check_mail_bounces (legge IMAP dalle stesse credenziali SMTP della MailIdentity). TEXT module_function @@ -50,7 +51,9 @@ module EminuxCrmMcp CreateTask, CompleteTask, CreateActivity, - UpdateOpportunityStage + UpdateOpportunityStage, + UpdateOrganizationEmail, + CheckMailBounces ] ) end @@ -254,4 +257,77 @@ module EminuxCrmMcp end end end + + class UpdateOrganizationEmail < MCP::Tool + description "Aggiorna l'email di un'organizzazione (e del contatto principale): marca bounce/non valida, sostituisci con un indirizzo trovato, annota in timeline." + input_schema( + properties: { + project_code: { type: "string" }, + organization_id: { type: "integer" }, + email: { type: "string", description: "Nuova email (se trovata). Lascia vuoto per solo marcare non valida." }, + email_invalid: { type: "boolean", description: "true = non usare in mailing; false se hai messo un'email nuova" }, + bounced_email: { type: "string", description: "Indirizzo che ha generato il bounce" }, + bounce_reason: { type: "string" }, + contact_id: { type: "integer", description: "Contatto specifico; default = primario" }, + website: { type: "string" }, + activity_subject: { type: "string" }, + activity_description: { type: "string" } + }, + required: %w[project_code organization_id] + ) + + class << self + def call(project_code:, organization_id:, email: nil, email_invalid: nil, bounced_email: nil, + bounce_reason: nil, contact_id: nil, website: nil, activity_subject: nil, + activity_description: nil, server_context: nil) + attrs = { + organization_id: organization_id, + email: email, + email_invalid: email_invalid, + bounced_email: bounced_email, + bounce_reason: bounce_reason, + contact_id: contact_id, + website: website, + activity_subject: activity_subject, + activity_description: activity_description + }.compact + # Permetti email: null esplicito solo se chiave presente via email="" + attrs[:email] = email if !email.nil? + EminuxCrmMcp.json_response( + EminuxCrmMcp.session_from(server_context).update_organization_email(project_code, attrs) + ) + end + end + end + + class CheckMailBounces < MCP::Tool + description "Legge i bounce/DSN dalla casella IMAP dell'account email CRM (stesse credenziali SMTP della MailIdentity). Arricchisce con match alle organizzazioni del progetto." + input_schema( + properties: { + project_code: { type: "string", description: "Progetto per matchare le società (es. matchlivetv)" }, + from_email: { type: "string", description: "Mittente account, es. info@matchlivetv.it (default)" }, + mail_identity_id: { type: "integer" }, + since_days: { type: "integer", description: "Giorni indietro da controllare (default 14)" }, + mailbox: { type: "string", description: "Cartella IMAP, default INBOX" }, + limit: { type: "integer", description: "Max messaggi bounce da analizzare" } + }, + required: %w[project_code] + ) + + class << self + def call(project_code:, from_email: nil, mail_identity_id: nil, since_days: nil, + mailbox: nil, limit: nil, server_context: nil) + attrs = { + from_email: from_email, + mail_identity_id: mail_identity_id, + since_days: since_days, + mailbox: mailbox, + limit: limit + }.compact + EminuxCrmMcp.json_response( + EminuxCrmMcp.session_from(server_context).check_mail_bounces(project_code, attrs) + ) + end + end + end end diff --git a/mcp/server.rb b/mcp/server.rb index c636544..31664ab 100644 --- a/mcp/server.rb +++ b/mcp/server.rb @@ -254,6 +254,81 @@ module EminuxCrmMcp end end end + + class UpdateOrganizationEmail < MCP::Tool + description "Aggiorna email organizzazione/contatto: marca bounce, sostituisci indirizzo, annota timeline." + input_schema( + properties: { + project_code: { type: "string" }, + organization_id: { type: "integer" }, + email: { type: "string", description: "Nuova email se trovata" }, + email_invalid: { type: "boolean" }, + bounced_email: { type: "string" }, + bounce_reason: { type: "string" }, + contact_id: { type: "integer" }, + website: { type: "string" }, + activity_subject: { type: "string" }, + activity_description: { type: "string" } + }, + required: %w[project_code organization_id] + ) + + class << self + def call(project_code:, organization_id:, email: nil, email_invalid: nil, bounced_email: nil, + bounce_reason: nil, contact_id: nil, website: nil, activity_subject: nil, + activity_description: nil, server_context: nil) + body = Client.compact( + organization_id: organization_id, + email: email, + bounced_email: bounced_email, + bounce_reason: bounce_reason, + contact_id: contact_id, + website: website, + activity_subject: activity_subject, + activity_description: activity_description + ) + body[:email_invalid] = email_invalid unless email_invalid.nil? + Client.request( + :patch, + "/api/v1/p/#{Today.encode(project_code)}/organizations/#{organization_id}/email", + body: body + ) + end + end + end + + class CheckMailBounces < MCP::Tool + description "Legge bounce IMAP dall'account email CRM (credenziali SMTP). Matcha le società del progetto." + input_schema( + properties: { + project_code: { type: "string" }, + from_email: { type: "string", description: "es. info@matchlivetv.it" }, + mail_identity_id: { type: "integer" }, + since_days: { type: "integer" }, + mailbox: { type: "string" }, + limit: { type: "integer" } + }, + required: %w[project_code] + ) + + class << self + def call(project_code:, from_email: nil, mail_identity_id: nil, since_days: nil, + mailbox: nil, limit: nil, server_context: nil) + query = URI.encode_www_form( + Client.compact( + from_email: from_email, + mail_identity_id: mail_identity_id, + since_days: since_days, + mailbox: mailbox, + limit: limit + ) + ) + path = "/api/v1/p/#{Today.encode(project_code)}/mail_bounces" + path = "#{path}?#{query}" if query.present? + Client.request(:get, path) + end + end + end end server = MCP::Server.new( @@ -267,7 +342,9 @@ server = MCP::Server.new( EminuxCrmMcp::CreateTask, EminuxCrmMcp::CompleteTask, EminuxCrmMcp::CreateActivity, - EminuxCrmMcp::UpdateOpportunityStage + EminuxCrmMcp::UpdateOpportunityStage, + EminuxCrmMcp::UpdateOrganizationEmail, + EminuxCrmMcp::CheckMailBounces ] ) diff --git a/test/controllers/api/v1/api_test.rb b/test/controllers/api/v1/api_test.rb index 7cc2cf1..3a58053 100644 --- a/test/controllers/api/v1/api_test.rb +++ b/test/controllers/api/v1/api_test.rb @@ -110,6 +110,26 @@ class Api::V1::ApiTest < ActionDispatch::IntegrationTest assert_equal "demo_trial", @opportunity.pipeline_stage end + test "updates organization email after bounce" do + patch "/api/v1/p/matchlivetv/organizations/#{@org.id}/email", + params: { + organization_id: @org.id, + bounced_email: "vecchia@example.com", + email: "nuova@example.com", + email_invalid: false, + bounce_reason: "User unknown", + activity_subject: "Email aggiornata dopo bounce" + }, + headers: bearer(@admin_token.plaintext), + as: :json + assert_response :success + @org.reload + assert_equal "nuova@example.com", @org.email + assert_equal "vecchia@example.com", @org.bounced_email + assert_not @org.email_invalid? + assert json_body["activity"].present? + end + test "rejects lost stage without reason" do patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage", params: { pipeline_stage: "lost" }, diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index 16f7731..083cc6b 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -27,6 +27,7 @@ class McpControllerTest < ActionDispatch::IntegrationTest assert_includes listed, "list_projects" assert_includes listed, "today" assert_includes listed, "create_activity" + assert_includes listed, "check_mail_bounces" assert names || listed.any? end diff --git a/test/models/mail_identity_test.rb b/test/models/mail_identity_test.rb index 5863c7f..ae42391 100644 --- a/test/models/mail_identity_test.rb +++ b/test/models/mail_identity_test.rb @@ -51,4 +51,29 @@ class MailIdentityTest < ActiveSupport::TestCase assert_not identity.valid? assert_includes identity.errors[:smtp_port], "con SSL/TLS va usata la porta 465 (es. smtps.aruba.it)" end + + test "infers aruba imap host from smtp" do + identity = MailIdentity.new( + name: "Aruba", + from_name: "Info", + from_email: "info@example.com", + smtp_host: "smtps.aruba.it", + smtp_port: 465, + encryption: "tls", + smtp_authentication: "plain", + smtp_username: "info@example.com", + smtp_password: "secret", + active: true + ) + assert identity.valid? + assert_equal "imaps.aruba.it", identity.effective_imap_host + assert_equal 993, identity.effective_imap_port + assert identity.imap_ready? + settings = identity.imap_settings + assert_equal "imaps.aruba.it", settings[:address] + assert_equal 993, settings[:port] + assert settings[:ssl] + assert_equal "info@example.com", settings[:user_name] + assert_equal "secret", settings[:password] + end end diff --git a/test/services/mailings/imap_bounce_reader_test.rb b/test/services/mailings/imap_bounce_reader_test.rb new file mode 100644 index 0000000..77b8a95 --- /dev/null +++ b/test/services/mailings/imap_bounce_reader_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Mailings::ImapBounceReaderTest < ActiveSupport::TestCase + test "parses aruba-style delivery failure from raw rfc822" do + identity = create_mail_identity( + from_email: "info@matchlivetv.it", + smtp_username: "info@matchlivetv.it", + smtp_host: "smtps.aruba.it", + smtp_port: 465, + encryption: "tls" + ) + + raw = <<~EML + From: mail-daemon@example.com + To: info@matchlivetv.it + Subject: Recapito fallito + Date: Mon, 07 Sep 2026 08:14:05 +0200 + MIME-Version: 1.0 + Content-Type: text/plain; charset=UTF-8 + + Delivery to the following recipients failed permanently: + + * broken@club.it + + Reason: 550 5.1.1 User unknown + + Final-Recipient: rfc822; broken@club.it + Diagnostic-Code: smtp; 550 5.1.1 User unknown + + Subject: Più visibilità per ASD Test Club + EML + + reader = Mailings::ImapBounceReader.new(identity) + parsed = reader.send(:parse_message, raw, uid: 42) + assert_not_nil parsed + assert_includes parsed[:failed_emails], "broken@club.it" + assert_equal "ASD Test Club", parsed[:club_hint] + assert_match(/550|User unknown/i, parsed[:reason].to_s) + end +end