Compare commits
8
Commits
403fe80d37
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f7794959e | ||
|
|
36b3ffe507 | ||
|
|
353ace4d99 | ||
|
|
1c6b4bbabe | ||
|
|
390cb0e657 | ||
|
|
3c8bfe053f | ||
|
|
4cf34b2033 | ||
|
|
238bbb0c68 |
@@ -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, …).
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
class MailingsController < ApplicationController
|
class MailingsController < ApplicationController
|
||||||
before_action :require_current_project!
|
before_action :require_current_project!
|
||||||
before_action :set_mailing, only: %i[show edit update destroy queue cancel test_send test_preview refresh_recipients update_recipients preview audience update_audience]
|
before_action :set_mailing, only: %i[show edit update destroy queue pause resume cancel test_send test_preview refresh_recipients update_recipients preview audience update_audience]
|
||||||
before_action :load_form_collections, only: %i[new create edit update]
|
before_action :load_form_collections, only: %i[new create edit update]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@@ -11,10 +11,12 @@ class MailingsController < ApplicationController
|
|||||||
@page_title = "Invii email"
|
@page_title = "Invii email"
|
||||||
@mailings = Mailing.for_project(current_project).includes(:mail_identity).recent
|
@mailings = Mailing.for_project(current_project).includes(:mail_identity).recent
|
||||||
@mail_identities_count = MailIdentity.active.count
|
@mail_identities_count = MailIdentity.active.count
|
||||||
@active_mailings = @mailings.select(&:sending?)
|
@active_mailings = @mailings.select { |mailing| mailing.sending? || mailing.paused? }
|
||||||
@paused_mailings = @active_mailings.select { |mailing| mailing.next_send_at.present? && mailing.next_send_at > Time.current }
|
@window_paused_mailings = @mailings.select(&:sending?).select { |mailing| mailing.next_send_at.present? && mailing.next_send_at > Time.current }
|
||||||
|
@paused_mailings = @window_paused_mailings
|
||||||
@completed_today = @mailings.count { |mailing| mailing.sent? && mailing.completed_at&.to_date == Time.zone.today }
|
@completed_today = @mailings.count { |mailing| mailing.sent? && mailing.completed_at&.to_date == Time.zone.today }
|
||||||
@failed_open = @mailings.sum(&:failed_count)
|
@failed_open = @mailings.sum(&:failed_count)
|
||||||
|
@hourly_throughput = Mailings::HourlyThroughput.new(current_project, range: params[:history])
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@@ -180,16 +182,42 @@ class MailingsController < ApplicationController
|
|||||||
redirect_to @mailing, alert: e.message
|
redirect_to @mailing, alert: e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
def cancel
|
def pause
|
||||||
unless @mailing.sending?
|
unless @mailing.sending?
|
||||||
redirect_to @mailing, alert: "Solo un invio in corso può essere fermato."
|
redirect_to @mailing, alert: "Solo un invio in corso può essere messo in pausa."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
remaining = @mailing.pending_count + @mailing.queued_count
|
||||||
|
@mailing.pause_send!
|
||||||
|
redirect_to dashboard_mailings_path,
|
||||||
|
notice: "Invio in pausa. #{@mailing.sent_count} già inviate, #{remaining} pronte per quando riprendi."
|
||||||
|
rescue StandardError => e
|
||||||
|
redirect_to @mailing, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def resume
|
||||||
|
unless @mailing.paused?
|
||||||
|
redirect_to @mailing, alert: "Solo un invio in pausa può essere ripreso."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@mailing.resume_send!
|
||||||
|
redirect_to dashboard_mailings_path, notice: "Invio ripreso da dove era rimasto."
|
||||||
|
rescue StandardError => e
|
||||||
|
redirect_to @mailing, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel
|
||||||
|
unless @mailing.sending? || @mailing.paused?
|
||||||
|
redirect_to @mailing, alert: "Solo un invio in corso o in pausa può essere annullato."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
pending = @mailing.pending_count + @mailing.queued_count
|
pending = @mailing.pending_count + @mailing.queued_count
|
||||||
@mailing.cancel_send!
|
@mailing.cancel_send!
|
||||||
redirect_to dashboard_mailings_path,
|
redirect_to dashboard_mailings_path,
|
||||||
notice: "Invio fermato. #{@mailing.sent_count} già inviate, #{pending} annullate in coda."
|
notice: "Invio annullato. #{@mailing.sent_count} già inviate, #{pending} in coda saltate."
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
redirect_to @mailing, alert: e.message
|
redirect_to @mailing, alert: e.message
|
||||||
end
|
end
|
||||||
@@ -278,6 +306,7 @@ class MailingsController < ApplicationController
|
|||||||
def load_audience_options
|
def load_audience_options
|
||||||
orgs = Organization.for_project(current_project)
|
orgs = Organization.for_project(current_project)
|
||||||
@sport_options = orgs.where.not(sport: [nil, ""]).distinct.order(:sport).pluck(:sport)
|
@sport_options = orgs.where.not(sport: [nil, ""]).distinct.order(:sport).pluck(:sport)
|
||||||
|
@country_options = orgs.where.not(country: [nil, ""]).distinct.order(:country).pluck(:country)
|
||||||
@region_options = orgs.where.not(region: [nil, ""]).distinct.order(:region).pluck(:region)
|
@region_options = orgs.where.not(region: [nil, ""]).distinct.order(:region).pluck(:region)
|
||||||
@province_options = orgs.where.not(province: [nil, ""]).distinct.order(:province).pluck(:province)
|
@province_options = orgs.where.not(province: [nil, ""]).distinct.order(:province).pluck(:province)
|
||||||
@history_mailings = Mailing.for_project(current_project).where.not(id: @mailing.id).recent
|
@history_mailings = Mailing.for_project(current_project).where.not(id: @mailing.id).recent
|
||||||
@@ -301,7 +330,7 @@ class MailingsController < ApplicationController
|
|||||||
ActionController::Parameters.new(copied).permit(
|
ActionController::Parameters.new(copied).permit(
|
||||||
:preset, :list_min, :list_max, :estimated_value, :history_kind, :history_days,
|
:preset, :list_min, :list_max, :estimated_value, :history_kind, :history_days,
|
||||||
:history_mailing_id, :never_contacted, :exclude_customers, :exclude_mailed_within_days,
|
:history_mailing_id, :never_contacted, :exclude_customers, :exclude_mailed_within_days,
|
||||||
sports: [], team_genders: [], regions: [], provinces: [], streaming_statuses: [], statuses: [],
|
sports: [], countries: [], team_genders: [], regions: [], provinces: [], streaming_statuses: [], statuses: [],
|
||||||
send_statuses: [], ab_variants: [], pipeline_stages: [],
|
send_statuses: [], ab_variants: [], pipeline_stages: [],
|
||||||
exclude_received_mailing_ids: [], exclude_opened_mailing_ids: []
|
exclude_received_mailing_ids: [], exclude_opened_mailing_ids: []
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: []
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ module ApplicationHelper
|
|||||||
colors = {
|
colors = {
|
||||||
"draft" => "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200",
|
"draft" => "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200",
|
||||||
"sending" => "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
"sending" => "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
||||||
|
"paused" => "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-200",
|
||||||
"sent" => "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
"sent" => "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
||||||
"cancelled" => "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-200"
|
"cancelled" => "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-200"
|
||||||
}
|
}
|
||||||
@@ -195,6 +196,15 @@ module ApplicationHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def mailing_recipient_delivery_note(recipient)
|
||||||
|
if recipient.status == "failed" && recipient.error_message.present?
|
||||||
|
content_tag :div, recipient.error_message, class: "mt-1 text-xs text-rose-700 dark:text-rose-300"
|
||||||
|
elsif recipient.queued? && recipient.error_message.to_s.match?(/Da ritentare/)
|
||||||
|
content_tag :div, "Non risulta inviata. Verrà ritentata in coda.",
|
||||||
|
class: "mt-1 text-xs text-amber-800 dark:text-amber-200"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def ab_variant_badge(variant)
|
def ab_variant_badge(variant)
|
||||||
return if variant.blank?
|
return if variant.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ module Catalog
|
|||||||
MAILING_STATUSES = {
|
MAILING_STATUSES = {
|
||||||
"draft" => "Bozza",
|
"draft" => "Bozza",
|
||||||
"sending" => "Invio in corso",
|
"sending" => "Invio in corso",
|
||||||
|
"paused" => "In pausa",
|
||||||
"sent" => "Inviata",
|
"sent" => "Inviata",
|
||||||
"cancelled" => "Annullata"
|
"cancelled" => "Annullata"
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -77,6 +77,10 @@ class Mailing < ApplicationRecord
|
|||||||
status == "sending"
|
status == "sending"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def paused?
|
||||||
|
status == "paused"
|
||||||
|
end
|
||||||
|
|
||||||
def sent?
|
def sent?
|
||||||
status == "sent"
|
status == "sent"
|
||||||
end
|
end
|
||||||
@@ -215,8 +219,23 @@ class Mailing < ApplicationRecord
|
|||||||
update!(status: "sent", completed_at: Time.current, next_send_at: nil)
|
update!(status: "sent", completed_at: Time.current, next_send_at: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def pause_send!
|
||||||
|
raise "Solo un invio in corso può essere messo in pausa" unless sending?
|
||||||
|
|
||||||
|
update!(status: "paused", next_send_at: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resume_send!
|
||||||
|
raise "Solo un invio in pausa può essere ripreso" unless paused?
|
||||||
|
raise "Nessun destinatario da inviare" if pending_count.zero? && queued_count.zero?
|
||||||
|
|
||||||
|
update!(status: "sending")
|
||||||
|
enqueue_next_recipient!(mailing_recipients.pending.order(:id).first, from: Time.current) if pending_count.positive?
|
||||||
|
DrainOutboundJob.perform_later if mailing_recipients.queued.exists?
|
||||||
|
end
|
||||||
|
|
||||||
def cancel_send!
|
def cancel_send!
|
||||||
raise "Solo un invio in corso può essere fermato" unless sending?
|
raise "Solo un invio in corso o in pausa può essere annullato" unless sending? || paused?
|
||||||
|
|
||||||
transaction do
|
transaction do
|
||||||
mailing_recipients.where(status: %w[pending queued]).find_each do |recipient|
|
mailing_recipients.where(status: %w[pending queued]).find_each do |recipient|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class MailingRecipient < ApplicationRecord
|
|||||||
Mailings::SmtpGate.deliver do
|
Mailings::SmtpGate.deliver do
|
||||||
CampaignMailer.outreach(self, html: html, subject: subject_line).deliver_now
|
CampaignMailer.outreach(self, html: html, subject: subject_line).deliver_now
|
||||||
end
|
end
|
||||||
|
# Solo dopo deliver_now senza eccezioni: un EOF/SMTP error non è mai un invio riuscito.
|
||||||
record_success!(subject_line)
|
record_success!(subject_line)
|
||||||
:sent
|
:sent
|
||||||
rescue *Mailings::SmtpGate::RETRYABLE => e
|
rescue *Mailings::SmtpGate::RETRYABLE => e
|
||||||
@@ -114,7 +115,12 @@ class MailingRecipient < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
MAX_SMTP_DEFERS = 8
|
MAX_SMTP_DEFERS = 8
|
||||||
DEFER_PATTERN = /tentativo (\d+)\//
|
# Deve combaciare col testo generato sotto ("Da ritentare (N/8): ..."),
|
||||||
|
# altrimenti smtp_defer_count legge sempre 0 e il retry diventa infinito
|
||||||
|
# (bug osservato in produzione: un destinatario con SMTP EOF permanente
|
||||||
|
# veniva ritentato ogni 30s all'infinito, bloccando gli altri destinatari
|
||||||
|
# in errore della stessa mailing).
|
||||||
|
DEFER_PATTERN = /Da ritentare \((\d+)\//
|
||||||
|
|
||||||
def defer_or_fail!(error)
|
def defer_or_fail!(error)
|
||||||
attempt = smtp_defer_count + 1
|
attempt = smtp_defer_count + 1
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ module Mailings
|
|||||||
VALUE_MODES = %w[any present blank].freeze
|
VALUE_MODES = %w[any present blank].freeze
|
||||||
|
|
||||||
ARRAY_KEYS = %w[
|
ARRAY_KEYS = %w[
|
||||||
sports team_genders regions provinces streaming_statuses statuses
|
sports countries team_genders regions provinces streaming_statuses statuses
|
||||||
send_statuses ab_variants pipeline_stages
|
send_statuses ab_variants pipeline_stages
|
||||||
exclude_received_mailing_ids exclude_opened_mailing_ids
|
exclude_received_mailing_ids exclude_opened_mailing_ids
|
||||||
].freeze
|
].freeze
|
||||||
@@ -46,6 +46,7 @@ module Mailings
|
|||||||
def summary_parts
|
def summary_parts
|
||||||
parts = [Catalog.label_for(Catalog::MAILING_AUDIENCES, preset)]
|
parts = [Catalog.label_for(Catalog::MAILING_AUDIENCES, preset)]
|
||||||
parts << "sport: #{Array(@data["sports"]).join(", ")}" if @data["sports"].present?
|
parts << "sport: #{Array(@data["sports"]).join(", ")}" if @data["sports"].present?
|
||||||
|
parts << "nazione: #{Array(@data["countries"]).join(", ")}" if @data["countries"].present?
|
||||||
parts << "M/F: #{Array(@data["team_genders"]).map { |g| Catalog.label_for(Catalog::TEAM_GENDERS, g) }.join(", ")}" if @data["team_genders"].present?
|
parts << "M/F: #{Array(@data["team_genders"]).map { |g| Catalog.label_for(Catalog::TEAM_GENDERS, g) }.join(", ")}" if @data["team_genders"].present?
|
||||||
parts << "regione: #{Array(@data["regions"]).join(", ")}" if @data["regions"].present?
|
parts << "regione: #{Array(@data["regions"]).join(", ")}" if @data["regions"].present?
|
||||||
parts << "provincia: #{Array(@data["provinces"]).join(", ")}" if @data["provinces"].present?
|
parts << "provincia: #{Array(@data["provinces"]).join(", ")}" if @data["provinces"].present?
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ module Mailings
|
|||||||
|
|
||||||
def apply_organization_filters(scope)
|
def apply_organization_filters(scope)
|
||||||
scope = scope.where(sport: @filters["sports"]) if @filters["sports"].present?
|
scope = scope.where(sport: @filters["sports"]) if @filters["sports"].present?
|
||||||
|
scope = scope.where(country: @filters["countries"]) if @filters["countries"].present?
|
||||||
scope = scope.where(team_gender: @filters["team_genders"]) if @filters["team_genders"].present?
|
scope = scope.where(team_gender: @filters["team_genders"]) if @filters["team_genders"].present?
|
||||||
scope = scope.where(region: @filters["regions"]) if @filters["regions"].present?
|
scope = scope.where(region: @filters["regions"]) if @filters["regions"].present?
|
||||||
scope = scope.where(province: @filters["provinces"]) if @filters["provinces"].present?
|
scope = scope.where(province: @filters["provinces"]) if @filters["provinces"].present?
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
class Mailings::HourlyThroughput
|
||||||
|
HOUR = 1.hour
|
||||||
|
RECENT = 15.minutes
|
||||||
|
DEFAULT_RANGE = "12h"
|
||||||
|
RANGES = {
|
||||||
|
"12h" => { grain: :hour, periods: 12, short: "12h", label: "ultime 12 ore" },
|
||||||
|
"24h" => { grain: :hour, periods: 24, short: "24h", label: "ultime 24 ore" },
|
||||||
|
"7d" => { grain: :day, periods: 7, short: "7g", label: "ultima settimana" }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.normalize_range(value)
|
||||||
|
key = value.to_s
|
||||||
|
RANGES.key?(key) ? key : DEFAULT_RANGE
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(project, now: Time.current, range: DEFAULT_RANGE)
|
||||||
|
@project = project
|
||||||
|
@now = now
|
||||||
|
@range_key = self.class.normalize_range(range)
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :range_key
|
||||||
|
|
||||||
|
def range
|
||||||
|
RANGES.fetch(@range_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def sent
|
||||||
|
@sent ||= sent_since(HOUR)
|
||||||
|
end
|
||||||
|
|
||||||
|
def recent_sent
|
||||||
|
@recent_sent ||= sent_since(RECENT)
|
||||||
|
end
|
||||||
|
|
||||||
|
def failed
|
||||||
|
@failed ||= recipients.failed.where(updated_at: (@now - HOUR)..@now).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def drained
|
||||||
|
sent + failed
|
||||||
|
end
|
||||||
|
|
||||||
|
def history
|
||||||
|
@history ||= buckets_for_range
|
||||||
|
end
|
||||||
|
|
||||||
|
def history_total
|
||||||
|
history.sum { |bucket| bucket[:count] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def history_max
|
||||||
|
[history.map { |bucket| bucket[:count] }.max, 1].max
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def buckets_for_range
|
||||||
|
from, step = history_origin
|
||||||
|
keyed = recipients.sent
|
||||||
|
.where(sent_at: from..@now)
|
||||||
|
.pluck(:sent_at)
|
||||||
|
.each_with_object(Hash.new(0)) do |sent_at, counts|
|
||||||
|
counts[bucket_at(sent_at)] += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
range[:periods].times.map do |index|
|
||||||
|
at = from + (step * index)
|
||||||
|
{ at: at, count: keyed[at] || 0, label: bucket_label(at) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def history_origin
|
||||||
|
if range[:grain] == :day
|
||||||
|
from = (@now.to_date - (range[:periods] - 1)).in_time_zone.beginning_of_day
|
||||||
|
[from, 1.day]
|
||||||
|
else
|
||||||
|
from = (@now - (range[:periods] - 1).hours).beginning_of_hour
|
||||||
|
[from, 1.hour]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def bucket_at(sent_at)
|
||||||
|
time = sent_at.in_time_zone
|
||||||
|
range[:grain] == :day ? time.beginning_of_day : time.beginning_of_hour
|
||||||
|
end
|
||||||
|
|
||||||
|
def bucket_label(at)
|
||||||
|
range[:grain] == :day ? at.strftime("%d/%m") : at.strftime("%H:%M")
|
||||||
|
end
|
||||||
|
|
||||||
|
def sent_since(window)
|
||||||
|
recipients.sent.where(sent_at: (@now - window)..@now).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def recipients
|
||||||
|
MailingRecipient.joins(:mailing).merge(Mailing.for_project(@project))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
class Mailings::OutboundQueue
|
class Mailings::OutboundQueue
|
||||||
LOCK_PATH = Rails.root.join("tmp/outbound_smtp.lock")
|
LOCK_PATH = Rails.root.join("tmp/outbound_smtp.lock")
|
||||||
STAMP_PATH = Rails.root.join("tmp/outbound_last_sent")
|
STAMP_PATH = Rails.root.join("tmp/outbound_last_sent")
|
||||||
|
ATTEMPT_PATH = Rails.root.join("tmp/outbound_last_attempt")
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
attr_accessor :min_interval
|
attr_accessor :min_interval, :fail_interval
|
||||||
|
|
||||||
def enqueue(recipient)
|
def enqueue(recipient)
|
||||||
recipient.with_lock do
|
recipient.with_lock do
|
||||||
@@ -29,7 +30,9 @@ class Mailings::OutboundQueue
|
|||||||
begin
|
begin
|
||||||
CampaignMailer.raise_delivery_errors = true
|
CampaignMailer.raise_delivery_errors = true
|
||||||
outcome = recipient.deliver_queued!
|
outcome = recipient.deliver_queued!
|
||||||
stamp!
|
stamp_attempt!
|
||||||
|
stamp! if outcome == :sent
|
||||||
|
Rails.logger.info("[outbound] #{outcome} mailing=#{recipient.mailing_id} to=#{recipient.email}")
|
||||||
outcome == :sent ? :sent : :deferred
|
outcome == :sent ? :sent : :deferred
|
||||||
ensure
|
ensure
|
||||||
CampaignMailer.raise_delivery_errors = false if Rails.env.development?
|
CampaignMailer.raise_delivery_errors = false if Rails.env.development?
|
||||||
@@ -45,7 +48,16 @@ class Mailings::OutboundQueue
|
|||||||
end
|
end
|
||||||
|
|
||||||
def next_queued
|
def next_queued
|
||||||
queued_scope.order(:updated_at, :id).first
|
# Sceglie il destinatario piu vecchio (per priorita/updated_at) TRA le mailing
|
||||||
|
# la cui finestra di invio e attualmente aperta. Prima si sceglieva il piu vecchio
|
||||||
|
# in assoluto: se apparteneva a una mailing con finestra chiusa, drain_one!
|
||||||
|
# restituiva :closed e si fermava, bloccando l'intera coda condivisa anche per
|
||||||
|
# le mailing senza vincoli orari (bug: coda bloccata da mailing con orario chiuso).
|
||||||
|
queued_scope.order(
|
||||||
|
Arel.sql(
|
||||||
|
"CASE WHEN mailing_recipients.error_message IS NULL OR mailing_recipients.error_message = '' THEN 0 ELSE 1 END, mailing_recipients.updated_at ASC, mailing_recipients.id ASC"
|
||||||
|
)
|
||||||
|
).includes(:mailing).find { |r| Mailings::SendClock.new(r.mailing).open? }
|
||||||
end
|
end
|
||||||
|
|
||||||
def queued_scope
|
def queued_scope
|
||||||
@@ -53,18 +65,17 @@ class Mailings::OutboundQueue
|
|||||||
end
|
end
|
||||||
|
|
||||||
def seconds_until_next_slot
|
def seconds_until_next_slot
|
||||||
return 0 if min_interval.to_f <= 0
|
[
|
||||||
|
remaining(last_sent_at, min_interval),
|
||||||
last = last_sent_at
|
remaining(last_attempt_at, fail_interval)
|
||||||
return 0 if last.nil?
|
].max
|
||||||
|
|
||||||
remaining = min_interval - (Time.current - last)
|
|
||||||
remaining.positive? ? remaining : 0
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset!
|
def reset!
|
||||||
@last_sent_at = nil
|
@last_sent_at = nil
|
||||||
|
@last_attempt_at = nil
|
||||||
File.delete(stamp_path) if File.exist?(stamp_path)
|
File.delete(stamp_path) if File.exist?(stamp_path)
|
||||||
|
File.delete(attempt_path) if File.exist?(attempt_path)
|
||||||
rescue Errno::ENOENT
|
rescue Errno::ENOENT
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -74,17 +85,36 @@ class Mailings::OutboundQueue
|
|||||||
File.write(stamp_path, @last_sent_at.to_f.to_s)
|
File.write(stamp_path, @last_sent_at.to_f.to_s)
|
||||||
end
|
end
|
||||||
|
|
||||||
def last_sent_at
|
def stamp_attempt!
|
||||||
return @last_sent_at if @last_sent_at
|
@last_attempt_at = Time.current
|
||||||
return unless File.exist?(stamp_path)
|
File.write(attempt_path, @last_attempt_at.to_f.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
@last_sent_at = Time.zone.at(Float(File.read(stamp_path)))
|
def last_sent_at
|
||||||
rescue ArgumentError, TypeError, Errno::ENOENT
|
@last_sent_at ||= read_time(stamp_path)
|
||||||
nil
|
end
|
||||||
|
|
||||||
|
def last_attempt_at
|
||||||
|
@last_attempt_at ||= read_time(attempt_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def remaining(timestamp, interval)
|
||||||
|
return 0 if interval.to_f <= 0 || timestamp.nil?
|
||||||
|
|
||||||
|
leftover = interval - (Time.current - timestamp)
|
||||||
|
leftover.positive? ? leftover : 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_time(path)
|
||||||
|
return unless File.exist?(path)
|
||||||
|
|
||||||
|
Time.zone.at(Float(File.read(path)))
|
||||||
|
rescue ArgumentError, TypeError, Errno::ENOENT
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def lock_path
|
def lock_path
|
||||||
LOCK_PATH
|
LOCK_PATH
|
||||||
end
|
end
|
||||||
@@ -92,7 +122,12 @@ class Mailings::OutboundQueue
|
|||||||
def stamp_path
|
def stamp_path
|
||||||
STAMP_PATH
|
STAMP_PATH
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def attempt_path
|
||||||
|
ATTEMPT_PATH
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
self.min_interval = Rails.env.test? ? 0.0 : 180.0
|
self.min_interval = Rails.env.test? ? 0.0 : 60.0
|
||||||
|
self.fail_interval = Rails.env.test? ? 0.0 : 30.0
|
||||||
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
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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 all’SMTP (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
|
||||||
|
|||||||
@@ -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" %>
|
||||||
|
|||||||
@@ -31,11 +31,9 @@
|
|||||||
<% elsif mailing.pending_count.positive? %>
|
<% elsif mailing.pending_count.positive? %>
|
||||||
<p class="mt-3 text-xs text-zinc-500">Prossima email in lavorazione.</p>
|
<p class="mt-3 text-xs text-zinc-500">Prossima email in lavorazione.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if mailing.sending? %>
|
<% if mailing.sending? || mailing.paused? %>
|
||||||
<div class="mt-4 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
<div class="mt-4 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||||
<%= button_to "Ferma invio", cancel_mailing_path(mailing), method: :post,
|
<%= render "mailings/send_controls", mailing: mailing %>
|
||||||
class: btn_danger,
|
|
||||||
form: { data: { turbo_confirm: "Fermare l'invio? Restano inviate #{mailing.sent_count} email, #{mailing.pending_count + mailing.queued_count} in coda verranno annullate." } } %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<% if mailing.sending? %>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<%= button_to "Metti in pausa", pause_mailing_path(mailing), method: :post,
|
||||||
|
class: btn_secondary,
|
||||||
|
form: { data: { turbo_confirm: "Mettere in pausa? Le #{mailing.sent_count} già inviate restano. Le #{mailing.pending_count + mailing.queued_count} non ancora partite si riprendono da qui." } } %>
|
||||||
|
<%= button_to "Annulla definitivamente", cancel_mailing_path(mailing), method: :post,
|
||||||
|
class: btn_danger,
|
||||||
|
form: { data: { turbo_confirm: "Annullare l'invio? Le #{mailing.sent_count} già inviate restano, le #{mailing.pending_count + mailing.queued_count} in coda verranno saltate e non si potranno riprendere." } } %>
|
||||||
|
</div>
|
||||||
|
<% elsif mailing.paused? %>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<%= button_to "Riprendi invio", resume_mailing_path(mailing), method: :post, class: btn_primary %>
|
||||||
|
<%= button_to "Annulla definitivamente", cancel_mailing_path(mailing), method: :post,
|
||||||
|
class: btn_danger,
|
||||||
|
form: { data: { turbo_confirm: "Annullare l'invio? Le #{mailing.sent_count} già inviate restano, le #{mailing.pending_count + mailing.queued_count} in coda verranno saltate e non si potranno riprendere." } } %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<section class="<%= card_class %> space-y-4 p-4 md:p-6">
|
<section class="<%= card_class %> space-y-4 p-4 md:p-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-base font-semibold">Chi deve ricevere questa email?</h2>
|
<h2 class="text-base font-semibold">Chi deve ricevere questa email?</h2>
|
||||||
<p class="mt-1 text-sm text-zinc-500">Lista di partenza, sport, regione e chi non ha mai ricevuto una email. Il resto è opzionale.</p>
|
<p class="mt-1 text-sm text-zinc-500">Lista di partenza, sport, nazione, regione e chi non ha mai ricevuto una email. Il resto è opzionale.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -47,12 +47,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-medium">Sport</label>
|
<label class="mb-1 block text-sm font-medium">Sport</label>
|
||||||
<%= render "mailings/audience_choices", name: "filters[sports]",
|
<%= render "mailings/audience_choices", name: "filters[sports]",
|
||||||
choices: @sport_options.map { |sport| [sport, sport] }, selected: @filters["sports"] %>
|
choices: @sport_options.map { |sport| [sport, sport] }, selected: @filters["sports"] %>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Nazione</label>
|
||||||
|
<%= render "mailings/audience_choices", name: "filters[countries]",
|
||||||
|
choices: @country_options.map { |country| [country, country] }, selected: @filters["countries"] %>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-medium">Regione</label>
|
<label class="mb-1 block text-sm font-medium">Regione</label>
|
||||||
<%= render "mailings/audience_choices", name: "filters[regions]",
|
<%= render "mailings/audience_choices", name: "filters[regions]",
|
||||||
|
|||||||
@@ -26,11 +26,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||||
<div class="<%= card_class %> p-4">
|
<div class="<%= card_class %> p-4">
|
||||||
<div class="text-xs uppercase tracking-wide text-zinc-500">In corso</div>
|
<div class="text-xs uppercase tracking-wide text-zinc-500">In corso</div>
|
||||||
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= @active_mailings.size %></div>
|
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= @active_mailings.size %></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="<%= card_class %> p-4">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-zinc-500">Mail / ora</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= @hourly_throughput.sent %></div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-500">
|
||||||
|
<%= @hourly_throughput.recent_sent %> ultimi 15 min
|
||||||
|
<% if @hourly_throughput.failed.positive? %>
|
||||||
|
· <%= @hourly_throughput.failed %> scodate in errore
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="<%= card_class %> p-4">
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-zinc-500">Storico invio</div>
|
||||||
|
<div class="inline-flex items-center gap-1">
|
||||||
|
<% Mailings::HourlyThroughput::RANGES.each do |key, config| %>
|
||||||
|
<% if @hourly_throughput.range_key == key %>
|
||||||
|
<span class="rounded-md bg-zinc-900 px-1.5 py-0.5 text-[10px] font-medium leading-none text-white dark:bg-zinc-100 dark:text-zinc-900"><%= config[:short] %></span>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to config[:short], dashboard_mailings_path(history: key), class: "rounded-md px-1.5 py-0.5 text-[10px] font-medium leading-none text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-end gap-0.5" style="height: 2.5rem" role="img" aria-label="Invii confermati, <%= @hourly_throughput.range[:label] %>">
|
||||||
|
<% @hourly_throughput.history.each do |bucket| %>
|
||||||
|
<% pct = (bucket[:count].to_f / @hourly_throughput.history_max * 100).round %>
|
||||||
|
<div class="flex-1 rounded <%= bucket[:count].positive? ? "bg-emerald-500" : "bg-zinc-200 dark:bg-zinc-700" %>"
|
||||||
|
style="height: <%= bucket[:count].positive? ? [pct, 12].max : 8 %>%"
|
||||||
|
title="<%= bucket[:label] %>: <%= bucket[:count] %>"></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-zinc-500"><%= @hourly_throughput.history_total %> inviate · <%= @hourly_throughput.range[:label] %></div>
|
||||||
|
</div>
|
||||||
<div class="<%= card_class %> p-4">
|
<div class="<%= card_class %> p-4">
|
||||||
<div class="text-xs uppercase tracking-wide text-zinc-500">In pausa (fascia)</div>
|
<div class="text-xs uppercase tracking-wide text-zinc-500">In pausa (fascia)</div>
|
||||||
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= @paused_mailings.size %></div>
|
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= @paused_mailings.size %></div>
|
||||||
@@ -77,6 +110,8 @@
|
|||||||
<%= mailing.sent_count %> inviate · <%= mailing.opened_count %> aperte · <%= mailing.pending_count %> in coda
|
<%= mailing.sent_count %> inviate · <%= mailing.opened_count %> aperte · <%= mailing.pending_count %> in coda
|
||||||
<% if mailing.sending? && mailing.next_send_at.present? && mailing.next_send_at > Time.current %>
|
<% if mailing.sending? && mailing.next_send_at.present? && mailing.next_send_at > Time.current %>
|
||||||
· Riparte <%= l(mailing.next_send_at, format: :short) %>
|
· Riparte <%= l(mailing.next_send_at, format: :short) %>
|
||||||
|
<% elsif mailing.paused? %>
|
||||||
|
· In pausa, si può riprendere
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -34,15 +34,13 @@
|
|||||||
<%= link_to "Raffina destinatari", audience_mailing_path(@mailing), class: btn_secondary %>
|
<%= link_to "Raffina destinatari", audience_mailing_path(@mailing), class: btn_secondary %>
|
||||||
<%= button_to "Rigenera lista", refresh_recipients_mailing_path(@mailing), method: :post, class: btn_secondary %>
|
<%= button_to "Rigenera lista", refresh_recipients_mailing_path(@mailing), method: :post, class: btn_secondary %>
|
||||||
<%= render "mailings/delete_draft_button", mailing: @mailing, extra_class: "flex-1 sm:flex-none" %>
|
<%= render "mailings/delete_draft_button", mailing: @mailing, extra_class: "flex-1 sm:flex-none" %>
|
||||||
<% elsif @mailing.sending? %>
|
<% elsif @mailing.sending? || @mailing.paused? %>
|
||||||
<%= button_to "Ferma invio", cancel_mailing_path(@mailing), method: :post,
|
<%= render "mailings/send_controls", mailing: @mailing %>
|
||||||
class: btn_danger,
|
|
||||||
form: { data: { turbo_confirm: "Fermare l'invio? Le #{@mailing.sent_count} già inviate restano, #{@mailing.pending_count + @mailing.queued_count} in coda verranno annullate." } } %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @mailing.sending? || @mailing.sent? %>
|
<% if @mailing.sending? || @mailing.paused? || @mailing.sent? %>
|
||||||
<%= render "mailings/progress_card", mailing: @mailing %>
|
<%= render "mailings/progress_card", mailing: @mailing %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
@@ -65,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @mailing.sending? || @mailing.sent? || @mailing.cancelled? %>
|
<% if @mailing.sending? || @mailing.paused? || @mailing.sent? || @mailing.cancelled? %>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="<%= card_class %> p-4">
|
<div class="<%= card_class %> p-4">
|
||||||
<div class="text-xs uppercase tracking-wide text-zinc-500">Aperte</div>
|
<div class="text-xs uppercase tracking-wide text-zinc-500">Aperte</div>
|
||||||
@@ -179,9 +177,7 @@
|
|||||||
<% if recipient.skip_reason.present? && !recipient.pending? %>
|
<% if recipient.skip_reason.present? && !recipient.pending? %>
|
||||||
<div class="mt-1 text-xs text-zinc-500"><%= recipient.skip_reason %></div>
|
<div class="mt-1 text-xs text-zinc-500"><%= recipient.skip_reason %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if recipient.error_message.present? %>
|
<%= mailing_recipient_delivery_note(recipient) %>
|
||||||
<div class="mt-1 text-xs text-rose-700"><%= recipient.error_message %></div>
|
|
||||||
<% end %>
|
|
||||||
<% if recipient.sent_at.present? %>
|
<% if recipient.sent_at.present? %>
|
||||||
<div class="mt-1 text-xs text-zinc-500">
|
<div class="mt-1 text-xs text-zinc-500">
|
||||||
Inviata <%= format_dt(recipient.sent_at) %>
|
Inviata <%= format_dt(recipient.sent_at) %>
|
||||||
@@ -238,9 +234,7 @@
|
|||||||
<% if recipient.skip_reason.present? && !recipient.pending? %>
|
<% if recipient.skip_reason.present? && !recipient.pending? %>
|
||||||
<div class="text-xs text-zinc-500"><%= recipient.skip_reason %></div>
|
<div class="text-xs text-zinc-500"><%= recipient.skip_reason %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if recipient.error_message.present? %>
|
<%= mailing_recipient_delivery_note(recipient) %>
|
||||||
<div class="text-xs text-rose-700"><%= recipient.error_message %></div>
|
|
||||||
<% end %>
|
|
||||||
<% if recipient.sent_at.present? %>
|
<% if recipient.sent_at.present? %>
|
||||||
<div class="text-xs text-zinc-500">Inviata <%= format_dt(recipient.sent_at) %></div>
|
<div class="text-xs text-zinc-500">Inviata <%= format_dt(recipient.sent_at) %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -74,6 +76,8 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
member do
|
member do
|
||||||
post :queue
|
post :queue
|
||||||
|
post :pause
|
||||||
|
post :resume
|
||||||
post :cancel
|
post :cancel
|
||||||
post :test_send
|
post :test_send
|
||||||
get :test_preview
|
get :test_preview
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -16,6 +16,52 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_match(/Invii email/, response.body)
|
assert_match(/Invii email/, response.body)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "dashboard shows hourly send throughput" do
|
||||||
|
login_as users(:admin)
|
||||||
|
follow_redirect! if response.redirect?
|
||||||
|
|
||||||
|
mailing = create_mailing(identity: @identity, audience: "to_send")
|
||||||
|
org = organizations(:acme)
|
||||||
|
mailing.mailing_recipients.create!(
|
||||||
|
organization: org,
|
||||||
|
contact: org.contacts.first,
|
||||||
|
email: org.email,
|
||||||
|
status: "sent",
|
||||||
|
sent_at: 8.minutes.ago
|
||||||
|
)
|
||||||
|
|
||||||
|
get dashboard_mailings_path(project_code: @project.code)
|
||||||
|
assert_response :success
|
||||||
|
assert_select "div", text: "Mail / ora"
|
||||||
|
assert_match(/1 ultimi 15 min/, response.body)
|
||||||
|
assert_select "div", text: "Storico invio"
|
||||||
|
assert_match(/1 inviate · ultime 12 ore/, response.body)
|
||||||
|
assert_select "span", text: "12h"
|
||||||
|
assert_select "a", text: "24h"
|
||||||
|
assert_select "a", text: "7g"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "dashboard history range can switch to the last week" do
|
||||||
|
login_as users(:admin)
|
||||||
|
follow_redirect! if response.redirect?
|
||||||
|
|
||||||
|
mailing = create_mailing(identity: @identity, audience: "to_send")
|
||||||
|
org = organizations(:acme)
|
||||||
|
mailing.mailing_recipients.create!(
|
||||||
|
organization: org,
|
||||||
|
contact: org.contacts.first,
|
||||||
|
email: org.email,
|
||||||
|
status: "sent",
|
||||||
|
sent_at: 2.days.ago
|
||||||
|
)
|
||||||
|
|
||||||
|
get dashboard_mailings_path(project_code: @project.code, history: "7d")
|
||||||
|
assert_response :success
|
||||||
|
assert_match(/1 inviate · ultima settimana/, response.body)
|
||||||
|
assert_select "span", text: "7g"
|
||||||
|
assert_select "a[href=?]", dashboard_mailings_path(project_code: @project.code, history: "24h")
|
||||||
|
end
|
||||||
|
|
||||||
test "creates a draft and opens audience filters" do
|
test "creates a draft and opens audience filters" do
|
||||||
login_as users(:admin)
|
login_as users(:admin)
|
||||||
follow_redirect! if response.redirect?
|
follow_redirect! if response.redirect?
|
||||||
@@ -303,6 +349,32 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert mailing.reload.sending?
|
assert mailing.reload.sending?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "pause keeps remaining recipients and resume continues from there" do
|
||||||
|
login_as users(:admin)
|
||||||
|
follow_redirect! if response.redirect?
|
||||||
|
create_campaign_org(name: "Seconda societa", email: "seconda@example.com")
|
||||||
|
mailing = create_mailing(identity: @identity, audience: "to_send")
|
||||||
|
mailing.rebuild_recipients!
|
||||||
|
recipients = mailing.mailing_recipients.order(:id).to_a
|
||||||
|
assert recipients.size >= 2
|
||||||
|
recipients.first.update!(status: "sent", sent_at: Time.current)
|
||||||
|
recipients.second.update!(status: "queued")
|
||||||
|
mailing.update!(status: "sending", queued_at: Time.current, test_sent_at: Time.current, test_sent_to: "test@example.com")
|
||||||
|
|
||||||
|
post pause_mailing_path(mailing, project_code: @project.code)
|
||||||
|
|
||||||
|
assert_redirected_to dashboard_mailings_path(project_code: @project.code)
|
||||||
|
assert_equal "paused", mailing.reload.status
|
||||||
|
assert_equal "sent", recipients.first.reload.status
|
||||||
|
assert_equal "queued", recipients.second.reload.status
|
||||||
|
|
||||||
|
post resume_mailing_path(mailing, project_code: @project.code)
|
||||||
|
|
||||||
|
assert_redirected_to dashboard_mailings_path(project_code: @project.code)
|
||||||
|
assert_equal "sending", mailing.reload.status
|
||||||
|
assert_equal "queued", recipients.second.reload.status
|
||||||
|
end
|
||||||
|
|
||||||
test "cancel stops pending recipients and keeps sent ones" do
|
test "cancel stops pending recipients and keeps sent ones" do
|
||||||
login_as users(:admin)
|
login_as users(:admin)
|
||||||
follow_redirect! if response.redirect?
|
follow_redirect! if response.redirect?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -29,4 +29,19 @@ class ApplicationHelperTest < ActionView::TestCase
|
|||||||
assert_includes html, "title=\"Società affiliata FIPAV con un testo molto lungo da accorciare\""
|
assert_includes html, "title=\"Società affiliata FIPAV con un testo molto lungo da accorciare\""
|
||||||
assert_includes html, "…"
|
assert_includes html, "…"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "queued SMTP retries are not shown as delivery failures" do
|
||||||
|
recipient = MailingRecipient.new(status: "queued", error_message: "Da ritentare (1/8): end of file reached")
|
||||||
|
html = mailing_recipient_delivery_note(recipient)
|
||||||
|
|
||||||
|
assert_includes html, "Non risulta inviata"
|
||||||
|
assert_not_includes html, "end of file reached"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "failed recipients still show the SMTP error" do
|
||||||
|
recipient = MailingRecipient.new(status: "failed", error_message: "end of file reached")
|
||||||
|
html = mailing_recipient_delivery_note(recipient)
|
||||||
|
|
||||||
|
assert_includes html, "end of file reached"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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,124 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Mailings::HourlyThroughputTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@project = projects(:matchlivetv)
|
||||||
|
@identity = create_mail_identity
|
||||||
|
@mailing = create_mailing(identity: @identity, audience: "all")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "counts confirmed sends in the last hour and last 15 minutes" do
|
||||||
|
add_recipient status: "sent", sent_at: 10.minutes.ago
|
||||||
|
add_recipient status: "sent", sent_at: 40.minutes.ago, mailing: extra_mailing
|
||||||
|
add_recipient status: "sent", sent_at: 2.hours.ago, mailing: extra_mailing("Vecchia")
|
||||||
|
add_recipient status: "queued", mailing: extra_mailing("In coda")
|
||||||
|
|
||||||
|
stats = Mailings::HourlyThroughput.new(@project)
|
||||||
|
|
||||||
|
assert_equal 2, stats.sent
|
||||||
|
assert_equal 1, stats.recent_sent
|
||||||
|
assert_equal 0, stats.failed
|
||||||
|
assert_equal 2, stats.drained
|
||||||
|
end
|
||||||
|
|
||||||
|
test "counts failed recipients that left the queue in the last hour" do
|
||||||
|
recipient = add_recipient(status: "failed")
|
||||||
|
recipient.update_columns(updated_at: 5.minutes.ago)
|
||||||
|
|
||||||
|
stale = add_recipient(status: "failed", mailing: extra_mailing)
|
||||||
|
stale.update_columns(updated_at: 2.hours.ago)
|
||||||
|
|
||||||
|
stats = Mailings::HourlyThroughput.new(@project)
|
||||||
|
|
||||||
|
assert_equal 0, stats.sent
|
||||||
|
assert_equal 1, stats.failed
|
||||||
|
assert_equal 1, stats.drained
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores sends from other projects" do
|
||||||
|
other = create_mailing(project: projects(:riskmeter), identity: @identity, audience: "all")
|
||||||
|
add_recipient status: "sent", sent_at: 5.minutes.ago, mailing: other
|
||||||
|
add_recipient status: "sent", sent_at: 5.minutes.ago
|
||||||
|
|
||||||
|
stats = Mailings::HourlyThroughput.new(@project)
|
||||||
|
|
||||||
|
assert_equal 1, stats.sent
|
||||||
|
assert_equal 1, stats.recent_sent
|
||||||
|
end
|
||||||
|
|
||||||
|
test "builds twelve hourly buckets of confirmed sends" do
|
||||||
|
now = Time.zone.parse("2026-09-07 12:40")
|
||||||
|
travel_to now do
|
||||||
|
add_recipient status: "sent", sent_at: now - 20.minutes
|
||||||
|
add_recipient status: "sent", sent_at: now - 70.minutes, mailing: extra_mailing("Ore 11")
|
||||||
|
add_recipient status: "sent", sent_at: now - 80.minutes, mailing: extra_mailing("Ore 11 bis")
|
||||||
|
add_recipient status: "sent", sent_at: now - 13.hours, mailing: extra_mailing("Fuori finestra")
|
||||||
|
|
||||||
|
stats = Mailings::HourlyThroughput.new(@project, now: now)
|
||||||
|
history = stats.history
|
||||||
|
|
||||||
|
assert_equal 12, history.size
|
||||||
|
assert_equal now.beginning_of_hour, history.last[:at]
|
||||||
|
assert_equal 1, history.last[:count]
|
||||||
|
assert_equal 2, history[-2][:count]
|
||||||
|
assert_equal 0, history.first[:count]
|
||||||
|
assert_equal 3, stats.history_total
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes the previous day when the range is 24 hours" do
|
||||||
|
now = Time.zone.parse("2026-09-07 12:40")
|
||||||
|
travel_to now do
|
||||||
|
add_recipient status: "sent", sent_at: now - 20.hours
|
||||||
|
|
||||||
|
twelve = Mailings::HourlyThroughput.new(@project, now: now, range: "12h")
|
||||||
|
day = Mailings::HourlyThroughput.new(@project, now: now, range: "24h")
|
||||||
|
|
||||||
|
assert_equal 0, twelve.history_total
|
||||||
|
assert_equal 24, day.history.size
|
||||||
|
assert_equal 1, day.history_total
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "groups the last week by day" do
|
||||||
|
now = Time.zone.parse("2026-09-07 12:40")
|
||||||
|
travel_to now do
|
||||||
|
add_recipient status: "sent", sent_at: now - 3.days
|
||||||
|
add_recipient status: "sent", sent_at: now - 8.days, mailing: extra_mailing("Vecchia")
|
||||||
|
|
||||||
|
stats = Mailings::HourlyThroughput.new(@project, now: now, range: "7d")
|
||||||
|
history = stats.history
|
||||||
|
|
||||||
|
assert_equal 7, history.size
|
||||||
|
assert_equal now.beginning_of_day, history.last[:at]
|
||||||
|
assert_equal 1, history[-4][:count]
|
||||||
|
assert_equal 0, history.last[:count]
|
||||||
|
assert_equal 1, stats.history_total
|
||||||
|
assert_equal "7d", stats.range_key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to 12 hours for an unknown range" do
|
||||||
|
stats = Mailings::HourlyThroughput.new(@project, range: "nope")
|
||||||
|
|
||||||
|
assert_equal "12h", stats.range_key
|
||||||
|
assert_equal 12, stats.history.size
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extra_mailing(name = "Altra campagna")
|
||||||
|
create_mailing(identity: @identity, audience: "all", name: name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_recipient(status:, sent_at: nil, mailing: @mailing)
|
||||||
|
org = organizations(:acme)
|
||||||
|
mailing.mailing_recipients.create!(
|
||||||
|
organization: org,
|
||||||
|
contact: org.contacts.first,
|
||||||
|
email: org.email,
|
||||||
|
status: status,
|
||||||
|
sent_at: sent_at
|
||||||
|
)
|
||||||
|
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
|
||||||
@@ -5,6 +5,7 @@ class Mailings::OutboundQueueTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
setup do
|
setup do
|
||||||
Mailings::OutboundQueue.min_interval = 0
|
Mailings::OutboundQueue.min_interval = 0
|
||||||
|
Mailings::OutboundQueue.fail_interval = 0
|
||||||
Mailings::OutboundQueue.reset!
|
Mailings::OutboundQueue.reset!
|
||||||
Mailings::SmtpGate.min_gap = 0
|
Mailings::SmtpGate.min_gap = 0
|
||||||
Mailings::SmtpGate.backoff_base = 0
|
Mailings::SmtpGate.backoff_base = 0
|
||||||
@@ -13,6 +14,7 @@ class Mailings::OutboundQueueTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
teardown do
|
teardown do
|
||||||
Mailings::OutboundQueue.min_interval = 0
|
Mailings::OutboundQueue.min_interval = 0
|
||||||
|
Mailings::OutboundQueue.fail_interval = 0
|
||||||
Mailings::OutboundQueue.reset!
|
Mailings::OutboundQueue.reset!
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -83,6 +85,33 @@ class Mailings::OutboundQueueTest < ActiveSupport::TestCase
|
|||||||
assert_equal "queued", recipient_a.status
|
assert_equal "queued", recipient_a.status
|
||||||
assert_match(/Da ritentare \(1\/8\)/, recipient_a.error_message)
|
assert_match(/Da ritentare \(1\/8\)/, recipient_a.error_message)
|
||||||
assert_equal recipient_b.id, Mailings::OutboundQueue.next_queued.id
|
assert_equal recipient_b.id, Mailings::OutboundQueue.next_queued.id
|
||||||
|
|
||||||
|
# Il contatore deve avanzare sullo stesso destinatario (prima il regex
|
||||||
|
# non matchava "Da ritentare (N/8)" e restava sempre tentativo 1).
|
||||||
|
begin
|
||||||
|
gate.define_singleton_method(:deliver) { raise EOFError, "end of file reached" }
|
||||||
|
assert_equal :deferred, recipient_a.deliver_queued!
|
||||||
|
ensure
|
||||||
|
gate.define_singleton_method(:deliver, original)
|
||||||
|
end
|
||||||
|
assert_match(/Da ritentare \(2\/8\)/, recipient_a.reload.error_message)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "next_queued skips recipients whose send window is closed" do
|
||||||
|
open_org = create_campaign_org(name: "Club Aperto", email: "aperto@example.com")
|
||||||
|
closed_org = create_campaign_org(name: "Club Chiuso", email: "chiuso@example.com")
|
||||||
|
open_mailing = create_sending_mailing(name: "Finestra aperta")
|
||||||
|
closed_mailing = create_sending_mailing(name: "Finestra chiusa")
|
||||||
|
closed_mailing.update!(send_window_enabled: true, send_window_start_minutes: 8 * 60, send_window_end_minutes: 9 * 60)
|
||||||
|
open_mailing.update!(send_window_enabled: false)
|
||||||
|
|
||||||
|
closed_recipient = enqueue_org(closed_mailing, closed_org)
|
||||||
|
open_recipient = enqueue_org(open_mailing, open_org)
|
||||||
|
closed_recipient.update_columns(updated_at: 1.hour.ago)
|
||||||
|
|
||||||
|
travel_to Time.zone.local(2026, 9, 10, 20, 0, 0) do
|
||||||
|
assert_equal open_recipient.id, Mailings::OutboundQueue.next_queued.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ class MailingsAudienceQueryTest < ActiveSupport::TestCase
|
|||||||
assert_not_includes ids, @volley.id
|
assert_not_includes ids, @volley.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "filters by country" do
|
||||||
|
@volley.update!(country: "Francia")
|
||||||
|
@acme.update!(country: "Italia")
|
||||||
|
ids = query({ "preset" => "all", "countries" => ["Francia"] }).relation.pluck(:id)
|
||||||
|
assert_includes ids, @volley.id
|
||||||
|
assert_not_includes ids, @acme.id
|
||||||
|
end
|
||||||
|
|
||||||
test "filters by list position range" do
|
test "filters by list position range" do
|
||||||
ids = query({ "preset" => "all", "list_min" => 1, "list_max" => 5 }).relation.pluck(:id)
|
ids = query({ "preset" => "all", "list_min" => 1, "list_max" => 5 }).relation.pluck(:id)
|
||||||
assert_includes ids, @acme.id
|
assert_includes ids, @acme.id
|
||||||
|
|||||||
Reference in New Issue
Block a user