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
+2 -1
View File
@@ -19,7 +19,8 @@ Endpoint MCP: `http://localhost:3001/mcp` (Bearer). In produzione usa lURL pu
1. `list_projects` — ottieni i `project_code` (matchlivetv, riskmeter, cardoo, …). 1. `list_projects` — ottieni i `project_code` (matchlivetv, riskmeter, cardoo, …).
2. `today` con quel codice — task scaduti / oggi / in arrivo e opportunità ferme. 2. `today` con quel codice — task scaduti / oggi / in arrivo e opportunità ferme.
3. `search` o `get_organization` per il contesto. 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 ## Limiti
+1 -1
View File
@@ -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). 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. Stdio (`mcp/server.rb` / `bin/crm-mcp`) resta come alternativa se un client non parla HTTP.
@@ -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
@@ -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
+1 -1
View File
@@ -66,7 +66,7 @@ class ContactsController < ApplicationController
def contact_params def contact_params
params.require(:contact).permit( 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 :preferred_contact_method, :notes, :primary_contact
) )
end end
@@ -57,6 +57,7 @@ class MailIdentitiesController < ApplicationController
params.require(:mail_identity).permit( params.require(:mail_identity).permit(
:name, :from_name, :from_email, :reply_to, :smtp_host, :smtp_port, :name, :from_name, :from_email, :reply_to, :smtp_host, :smtp_port,
:smtp_username, :smtp_password, :smtp_authentication, :encryption, :smtp_username, :smtp_password, :smtp_authentication, :encryption,
:imap_host, :imap_port, :imap_enabled,
:verify_ssl, :active :verify_ssl, :active
) )
end end
+1 -1
View File
@@ -86,7 +86,7 @@ class OrganizationsController < ApplicationController
def organization_params def organization_params
params.require(:organization).permit( params.require(:organization).permit(
:name, :legal_name, :organization_type, :sport, :country, :region, :province, :city, :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, :assigned_user_id, :list_position, :team_gender, :streaming_status, :commercial_fit, :verified_at,
project_ids: [] project_ids: []
) )
+63
View File
@@ -9,15 +9,30 @@ class MailIdentity < ApplicationRecord
ENCRYPTIONS = Catalog::MAIL_ENCRYPTIONS.keys.freeze 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 :name, :from_name, :from_email, :smtp_host, :smtp_port, presence: true
validates :from_email, format: { with: URI::MailTo::EMAIL_REGEXP } validates :from_email, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :reply_to, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true validates :reply_to, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
validates :smtp_port, numericality: { in: 1..65535 } validates :smtp_port, numericality: { in: 1..65535 }
validates :imap_port, numericality: { in: 1..65535 }, allow_nil: true
validates :encryption, inclusion: { in: ENCRYPTIONS } validates :encryption, inclusion: { in: ENCRYPTIONS }
validates :smtp_authentication, inclusion: { in: Catalog::MAIL_AUTH_METHODS.keys } validates :smtp_authentication, inclusion: { in: Catalog::MAIL_AUTH_METHODS.keys }
validate :encryption_matches_port validate :encryption_matches_port
before_validation :apply_imap_defaults
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
scope :imap_enabled, -> { where(imap_enabled: true) }
scope :ordered, -> { order(:name) } scope :ordered, -> { order(:name) }
def from_header def from_header
@@ -42,8 +57,56 @@ class MailIdentity < ApplicationRecord
settings settings
end 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 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 def encryption_matches_port
return if smtp_port.blank? || encryption.blank? return if smtp_port.blank? || encryption.blank?
+202
View File
@@ -147,8 +147,206 @@ module Crm
end end
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 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) def with_project(code)
project = Project.active.find_by(code: code.to_s) project = Project.active.find_by(code: code.to_s)
return err("Progetto non trovato", status: :not_found) if project.nil? return err("Progetto non trovato", status: :not_found) if project.nil?
@@ -230,6 +428,8 @@ module Crm
region: org.region, region: org.region,
country: org.country, country: org.country,
email: org.email, email: org.email,
email_invalid: org.email_invalid,
bounced_email: org.bounced_email,
phone: org.phone, phone: org.phone,
website: org.website, website: org.website,
lead_source: org.lead_source, lead_source: org.lead_source,
@@ -259,6 +459,8 @@ module Crm
full_name: contact.full_name, full_name: contact.full_name,
role: contact.role, role: contact.role,
email: contact.email, email: contact.email,
email_invalid: contact.email_invalid,
bounced_email: contact.bounced_email,
phone: contact.phone, phone: contact.phone,
mobile: contact.mobile, mobile: contact.mobile,
primary_contact: contact.primary_contact, primary_contact: contact.primary_contact,
+242
View File
@@ -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) elsif !email.match?(URI::MailTo::EMAIL_REGEXP)
attrs[:status] = "skipped" attrs[:status] = "skipped"
attrs[:skip_reason] = "email non valida" attrs[:skip_reason] = "email non valida"
elsif email_marked_invalid?(org, contact, email)
attrs[:status] = "skipped"
attrs[:skip_reason] = "email bounce / non valida"
else else
attrs[:status] = "pending" attrs[:status] = "pending"
end end
@@ -54,4 +57,17 @@ class Mailings::RecipientBuilder
end end
attrs[:ab_variant] = variant attrs[:ab_variant] = variant
end 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 end
+8
View File
@@ -23,6 +23,14 @@
<label class="block text-sm font-medium">Email</label> <label class="block text-sm font-medium">Email</label>
<%= f.email_field :email, placeholder: "Email", class: input_class %> <%= f.email_field :email, placeholder: "Email", class: input_class %>
</div> </div>
<div class="space-y-1">
<label class="block text-sm font-medium">Email bounce</label>
<%= f.email_field :bounced_email, placeholder: "Indirizzo non recapitabile", class: input_class %>
</div>
<label class="flex min-h-11 items-center gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm dark:border-zinc-700 sm:col-span-2">
<%= f.check_box :email_invalid, class: "size-5" %>
Email non valida / bounce
</label>
<div class="space-y-1"> <div class="space-y-1">
<label class="block text-sm font-medium">Telefono</label> <label class="block text-sm font-medium">Telefono</label>
<%= f.text_field :phone, placeholder: "Telefono", class: input_class %> <%= f.text_field :phone, placeholder: "Telefono", class: input_class %>
+17
View File
@@ -42,7 +42,24 @@
<div> <div>
<label class="mb-1 block text-sm font-medium">Password SMTP<%= " (lascia vuoto per non cambiare)" unless mail_identity.new_record? %></label> <label class="mb-1 block text-sm font-medium">Password SMTP<%= " (lascia vuoto per non cambiare)" unless mail_identity.new_record? %></label>
<%= f.password_field :smtp_password, autocomplete: "new-password", class: input_class %> <%= f.password_field :smtp_password, autocomplete: "new-password", class: input_class %>
<p class="mt-1 text-xs text-zinc-500">Stessa password usata anche per IMAP (lettura bounce).</p>
</div> </div>
<div class="md:col-span-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
<h2 class="text-sm font-semibold">IMAP (lettura casella / bounce)</h2>
<p class="mt-1 text-xs text-zinc-500">Di solito host e porta si compilano da soli in base allSMTP (es. Aruba → imaps.aruba.it:993).</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Host IMAP</label>
<%= f.text_field :imap_host, placeholder: mail_identity.inferred_imap_host || "imaps.aruba.it", class: input_class %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Porta IMAP</label>
<%= f.number_field :imap_port, min: 1, max: 65535, class: input_class %>
</div>
<label class="flex min-h-11 items-center gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm dark:border-zinc-700 md:col-span-2">
<%= f.check_box :imap_enabled, class: "size-5" %>
Abilita lettura IMAP (bounce) con le stesse credenziali SMTP
</label>
<label class="flex min-h-11 items-center gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm dark:border-zinc-700"> <label class="flex min-h-11 items-center gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm dark:border-zinc-700">
<%= f.check_box :verify_ssl, class: "size-5" %> <%= f.check_box :verify_ssl, class: "size-5" %>
Verifica il certificato SSL del server Verifica il certificato SSL del server
+18 -2
View File
@@ -1,10 +1,10 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-3"> <div class="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 class="text-xl font-semibold tracking-tight md:text-2xl">Account email / SMTP</h1> <h1 class="text-xl font-semibold tracking-tight md:text-2xl">Account email / SMTP + IMAP</h1>
<p class="mt-1 text-sm text-zinc-500"> <p class="mt-1 text-sm text-zinc-500">
<%= link_to "Impostazioni", admin_path, class: "hover:underline" %> <%= link_to "Impostazioni", admin_path, class: "hover:underline" %>
· da questi indirizzi partono le campagne. Le password SMTP sono cifrate. · SMTP per gli invii, IMAP (stesse credenziali) per leggere i bounce. Password cifrate.
</p> </p>
</div> </div>
<%= link_to "Nuovo account", new_mail_identity_path, class: "#{btn_primary} w-full sm:w-auto" %> <%= link_to "Nuovo account", new_mail_identity_path, class: "#{btn_primary} w-full sm:w-auto" %>
@@ -26,6 +26,14 @@
<%= link_to "Modifica", edit_mail_identity_path(identity), class: "text-sm text-zinc-700 hover:underline dark:text-zinc-300" %> <%= link_to "Modifica", edit_mail_identity_path(identity), class: "text-sm text-zinc-700 hover:underline dark:text-zinc-300" %>
</div> </div>
<div class="mt-2 text-sm text-zinc-600 dark:text-zinc-300"><%= identity.smtp_host %>:<%= identity.smtp_port %> · <%= Catalog.label_for(Catalog::MAIL_ENCRYPTIONS, identity.encryption) %></div> <div class="mt-2 text-sm text-zinc-600 dark:text-zinc-300"><%= identity.smtp_host %>:<%= identity.smtp_port %> · <%= Catalog.label_for(Catalog::MAIL_ENCRYPTIONS, identity.encryption) %></div>
<div class="mt-1 text-sm text-zinc-600 dark:text-zinc-300">
IMAP:
<% if identity.imap_ready? %>
<%= identity.effective_imap_host %>:<%= identity.effective_imap_port %>
<% else %>
off
<% end %>
</div>
<div class="mt-1 text-sm text-zinc-500"><%= identity.active? ? "Attivo" : "Disattivo" %></div> <div class="mt-1 text-sm text-zinc-500"><%= identity.active? ? "Attivo" : "Disattivo" %></div>
</div> </div>
<% end %> <% end %>
@@ -38,6 +46,7 @@
<th class="px-4 py-3">Nome</th> <th class="px-4 py-3">Nome</th>
<th class="px-4 py-3">Mittente</th> <th class="px-4 py-3">Mittente</th>
<th class="px-4 py-3">SMTP</th> <th class="px-4 py-3">SMTP</th>
<th class="px-4 py-3">IMAP</th>
<th class="px-4 py-3">Stato</th> <th class="px-4 py-3">Stato</th>
<th class="px-4 py-3"></th> <th class="px-4 py-3"></th>
</tr> </tr>
@@ -54,6 +63,13 @@
<%= identity.smtp_host %>:<%= identity.smtp_port %> <%= identity.smtp_host %>:<%= identity.smtp_port %>
<div class="text-xs text-zinc-500"><%= Catalog.label_for(Catalog::MAIL_ENCRYPTIONS, identity.encryption) %></div> <div class="text-xs text-zinc-500"><%= Catalog.label_for(Catalog::MAIL_ENCRYPTIONS, identity.encryption) %></div>
</td> </td>
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">
<% if identity.imap_ready? %>
<%= identity.effective_imap_host %>:<%= identity.effective_imap_port %>
<% else %>
<span class="text-zinc-400">off</span>
<% end %>
</td>
<td class="px-4 py-3"><%= identity.active? ? "Attivo" : "Disattivo" %></td> <td class="px-4 py-3"><%= identity.active? ? "Attivo" : "Disattivo" %></td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<%= link_to "Modifica", edit_mail_identity_path(identity), class: "text-zinc-700 hover:underline dark:text-zinc-300" %> <%= link_to "Modifica", edit_mail_identity_path(identity), class: "text-zinc-700 hover:underline dark:text-zinc-300" %>
+7
View File
@@ -34,6 +34,13 @@
<div class="sm:col-span-2"><%= f.label :source_url, class: "mb-1 block text-sm font-medium" %><%= f.text_field :source_url, class: input_class %></div> <div class="sm:col-span-2"><%= f.label :source_url, class: "mb-1 block text-sm font-medium" %><%= f.text_field :source_url, class: input_class %></div>
<div><%= f.label :phone, class: "mb-1 block text-sm font-medium" %><%= f.text_field :phone, class: input_class %></div> <div><%= f.label :phone, class: "mb-1 block text-sm font-medium" %><%= f.text_field :phone, class: input_class %></div>
<div><%= f.label :email, class: "mb-1 block text-sm font-medium" %><%= f.email_field :email, class: input_class %></div> <div><%= f.label :email, class: "mb-1 block text-sm font-medium" %><%= f.email_field :email, class: input_class %></div>
<div class="flex items-end gap-2 pb-1">
<label class="inline-flex items-center gap-2 text-sm">
<%= f.check_box :email_invalid, class: "rounded border-zinc-300" %>
Email non valida / bounce
</label>
</div>
<div><%= f.label :bounced_email, "Email bounce", class: "mb-1 block text-sm font-medium" %><%= f.email_field :bounced_email, class: input_class %></div>
<div><%= f.label :vat_number, class: "mb-1 block text-sm font-medium" %><%= f.text_field :vat_number, class: input_class %></div> <div><%= f.label :vat_number, class: "mb-1 block text-sm font-medium" %><%= f.text_field :vat_number, class: input_class %></div>
<div><%= f.label :verified_at, class: "mb-1 block text-sm font-medium" %><%= f.date_field :verified_at, class: input_class %></div> <div><%= f.label :verified_at, class: "mb-1 block text-sm font-medium" %><%= f.date_field :verified_at, class: input_class %></div>
<div class="sm:col-span-2"><%= f.label :commercial_fit, class: "mb-1 block text-sm font-medium" %><%= f.text_area :commercial_fit, rows: 3, class: input_class %></div> <div class="sm:col-span-2"><%= f.label :commercial_fit, class: "mb-1 block text-sm font-medium" %><%= f.text_area :commercial_fit, rows: 3, class: input_class %></div>
+18 -2
View File
@@ -74,7 +74,18 @@
<div><dt class="text-slate-500">Sport</dt><dd class="font-medium"><%= [@organization.sport.presence, @organization.team_gender_label.presence].compact.join(" · ").presence || "—" %></dd></div> <div><dt class="text-slate-500">Sport</dt><dd class="font-medium"><%= [@organization.sport.presence, @organization.team_gender_label.presence].compact.join(" · ").presence || "—" %></dd></div>
<div><dt class="text-slate-500">Località</dt><dd class="font-medium"><%= [@organization.city, @organization.province, @organization.region, @organization.country].compact_blank.join(", ").presence || "—" %></dd></div> <div><dt class="text-slate-500">Località</dt><dd class="font-medium"><%= [@organization.city, @organization.province, @organization.region, @organization.country].compact_blank.join(", ").presence || "—" %></dd></div>
<div><dt class="text-slate-500">Lista campagna</dt><dd class="font-medium"><%= @organization.list_position || "—" %></dd></div> <div><dt class="text-slate-500">Lista campagna</dt><dd class="font-medium"><%= @organization.list_position || "—" %></dd></div>
<div><dt class="text-slate-500">Email</dt><dd class="font-medium break-anywhere"><%= mailto_link(@organization.email, class: "text-sky-700 hover:underline dark:text-sky-400") %></dd></div> <div>
<dt class="text-slate-500">Email</dt>
<dd class="font-medium break-anywhere">
<%= mailto_link(@organization.email, class: "text-sky-700 hover:underline dark:text-sky-400") %>
<% if @organization.email_invalid? %>
<span class="ml-1 rounded bg-rose-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-rose-800 dark:bg-rose-950 dark:text-rose-200">Non valida</span>
<% end %>
<% if @organization.bounced_email.present? && @organization.bounced_email != @organization.email %>
<div class="mt-1 text-xs text-rose-600 dark:text-rose-300">Bounce: <%= @organization.bounced_email %></div>
<% end %>
</dd>
</div>
<div><dt class="text-slate-500">Telefono</dt><dd class="font-medium"><%= tel_link(@organization.phone, class: "text-sky-700 hover:underline dark:text-sky-400") %></dd></div> <div><dt class="text-slate-500">Telefono</dt><dd class="font-medium"><%= tel_link(@organization.phone, class: "text-sky-700 hover:underline dark:text-sky-400") %></dd></div>
<div><dt class="text-slate-500">Sito / profilo</dt><dd class="font-medium"><%= external_link(@organization.website, class: "text-sky-700 hover:underline dark:text-sky-400") %></dd></div> <div><dt class="text-slate-500">Sito / profilo</dt><dd class="font-medium"><%= external_link(@organization.website, class: "text-sky-700 hover:underline dark:text-sky-400") %></dd></div>
<div><dt class="text-slate-500">Fonte ricerca</dt><dd class="font-medium"><%= external_link(@organization.source_url, class: "text-sky-700 hover:underline dark:text-sky-400") %></dd></div> <div><dt class="text-slate-500">Fonte ricerca</dt><dd class="font-medium"><%= external_link(@organization.source_url, class: "text-sky-700 hover:underline dark:text-sky-400") %></dd></div>
@@ -98,7 +109,12 @@
<%= contact.full_name %> <%= contact.full_name %>
<% if contact.primary_contact? %><span class="ml-1 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">Principale</span><% end %> <% if contact.primary_contact? %><span class="ml-1 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">Principale</span><% end %>
</div> </div>
<div class="break-anywhere text-sm text-slate-500"><%= [contact.role, contact.email, contact.phone.presence || contact.mobile].compact_blank.join(" · ") %></div> <div class="break-anywhere text-sm text-slate-500">
<%= [contact.role, contact.email, contact.phone.presence || contact.mobile].compact_blank.join(" · ") %>
<% if contact.email_invalid? %>
<span class="ml-1 rounded bg-rose-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-rose-800 dark:bg-rose-950 dark:text-rose-200">Email non valida</span>
<% end %>
</div>
</div> </div>
<%= link_to "Modifica", edit_contact_path(contact), class: "text-sm text-zinc-600 hover:underline dark:text-zinc-300" %> <%= link_to "Modifica", edit_contact_path(contact), class: "text-sm text-zinc-600 hover:underline dark:text-zinc-300" %>
</div> </div>
+2
View File
@@ -33,6 +33,8 @@ Rails.application.routes.draw do
get "today", to: "today#show" get "today", to: "today#show"
get "search", to: "search#show" get "search", to: "search#show"
get "organizations/:id", to: "organizations#show", as: :organization 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", to: "tasks#create"
post "tasks/:id/complete", to: "tasks#complete", as: :complete_task post "tasks/:id/complete", to: "tasks#complete", as: :complete_task
post "activities", to: "activities#create" post "activities", to: "activities#create"
@@ -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
@@ -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
Generated
+10 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@@ -79,9 +79,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do
end end
create_table "contacts", force: :cascade do |t| create_table "contacts", force: :cascade do |t|
t.string "bounced_email"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_id" t.bigint "created_by_id"
t.string "email" t.string "email"
t.boolean "email_invalid", default: false, null: false
t.string "first_name", null: false t.string "first_name", null: false
t.string "last_name", null: false t.string "last_name", null: false
t.string "mobile" 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.datetime "updated_at", null: false
t.bigint "updated_by_id" t.bigint "updated_by_id"
t.index ["email"], name: "index_contacts_on_email" 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 ["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 ["organization_id"], name: "index_contacts_on_organization_id"
t.index ["phone"], name: "index_contacts_on_phone" 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 "encryption", default: "starttls", null: false
t.string "from_email", null: false t.string "from_email", null: false
t.string "from_name", 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 "name", null: false
t.string "reply_to" t.string "reply_to"
t.string "smtp_authentication", default: "plain", null: false 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| create_table "organizations", force: :cascade do |t|
t.string "address" t.string "address"
t.bigint "assigned_user_id" t.bigint "assigned_user_id"
t.string "bounced_email"
t.string "city" t.string "city"
t.text "commercial_fit" t.text "commercial_fit"
t.string "country", default: "Italia", null: false t.string "country", default: "Italia", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_id" t.bigint "created_by_id"
t.string "email" t.string "email"
t.boolean "email_invalid", default: false, null: false
t.string "lead_source" t.string "lead_source"
t.string "legal_name" t.string "legal_name"
t.integer "list_position" 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 ["city"], name: "index_organizations_on_city"
t.index ["country"], name: "index_organizations_on_country" t.index ["country"], name: "index_organizations_on_country"
t.index ["email"], name: "index_organizations_on_email" 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 ["lead_source"], name: "index_organizations_on_lead_source"
t.index ["list_position"], name: "index_organizations_on_list_position" t.index ["list_position"], name: "index_organizations_on_list_position"
t.index ["name"], name: "index_organizations_on_name" t.index ["name"], name: "index_organizations_on_name"
+78 -2
View File
@@ -9,8 +9,9 @@ module EminuxCrmMcp
1. list_projects per i project_code (matchlivetv, riskmeter, cardoo, …). 1. list_projects per i project_code (matchlivetv, riskmeter, cardoo, …).
2. today per capire cosa fare oggi. 2. today per capire cosa fare oggi.
3. search / get_organization per il contesto. 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. 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 TEXT
module_function module_function
@@ -50,7 +51,9 @@ module EminuxCrmMcp
CreateTask, CreateTask,
CompleteTask, CompleteTask,
CreateActivity, CreateActivity,
UpdateOpportunityStage UpdateOpportunityStage,
UpdateOrganizationEmail,
CheckMailBounces
] ]
) )
end end
@@ -254,4 +257,77 @@ module EminuxCrmMcp
end end
end 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 end
+78 -1
View File
@@ -254,6 +254,81 @@ module EminuxCrmMcp
end end
end 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 end
server = MCP::Server.new( server = MCP::Server.new(
@@ -267,7 +342,9 @@ server = MCP::Server.new(
EminuxCrmMcp::CreateTask, EminuxCrmMcp::CreateTask,
EminuxCrmMcp::CompleteTask, EminuxCrmMcp::CompleteTask,
EminuxCrmMcp::CreateActivity, EminuxCrmMcp::CreateActivity,
EminuxCrmMcp::UpdateOpportunityStage EminuxCrmMcp::UpdateOpportunityStage,
EminuxCrmMcp::UpdateOrganizationEmail,
EminuxCrmMcp::CheckMailBounces
] ]
) )
+20
View File
@@ -110,6 +110,26 @@ class Api::V1::ApiTest < ActionDispatch::IntegrationTest
assert_equal "demo_trial", @opportunity.pipeline_stage assert_equal "demo_trial", @opportunity.pipeline_stage
end 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 test "rejects lost stage without reason" do
patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage", patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage",
params: { pipeline_stage: "lost" }, params: { pipeline_stage: "lost" },
+1
View File
@@ -27,6 +27,7 @@ class McpControllerTest < ActionDispatch::IntegrationTest
assert_includes listed, "list_projects" assert_includes listed, "list_projects"
assert_includes listed, "today" assert_includes listed, "today"
assert_includes listed, "create_activity" assert_includes listed, "create_activity"
assert_includes listed, "check_mail_bounces"
assert names || listed.any? assert names || listed.any?
end end
+25
View File
@@ -51,4 +51,29 @@ class MailIdentityTest < ActiveSupport::TestCase
assert_not identity.valid? assert_not identity.valid?
assert_includes identity.errors[:smtp_port], "con SSL/TLS va usata la porta 465 (es. smtps.aruba.it)" assert_includes identity.errors[:smtp_port], "con SSL/TLS va usata la porta 465 (es. smtps.aruba.it)"
end 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 end
@@ -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