200 lines
5.7 KiB
Ruby
200 lines
5.7 KiB
Ruby
class MailingRecipient < ApplicationRecord
|
|
belongs_to :mailing
|
|
belongs_to :organization
|
|
belongs_to :contact, optional: true
|
|
|
|
validates :status, inclusion: { in: Catalog::MAILING_RECIPIENT_STATUSES.keys }
|
|
validates :ab_variant, inclusion: { in: Catalog::AB_VARIANTS.keys }, allow_blank: true
|
|
validates :tracking_token, presence: true, uniqueness: true
|
|
|
|
before_validation :assign_tracking_token, on: :create
|
|
|
|
scope :pending, -> { where(status: "pending") }
|
|
scope :skipped, -> { where(status: "skipped") }
|
|
scope :sent, -> { where(status: "sent") }
|
|
scope :failed, -> { where(status: "failed") }
|
|
scope :queued, -> { where(status: "queued") }
|
|
scope :opened, -> { where.not(opened_at: nil) }
|
|
scope :ordered, -> { joins(:organization).order("organizations.list_position ASC NULLS LAST", "organizations.name ASC") }
|
|
|
|
def status_label
|
|
Catalog.label_for(Catalog::MAILING_RECIPIENT_STATUSES, status)
|
|
end
|
|
|
|
def pending?
|
|
status == "pending"
|
|
end
|
|
|
|
def sent?
|
|
status == "sent"
|
|
end
|
|
|
|
def email_ok?
|
|
email.present? && email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
end
|
|
|
|
def opened?
|
|
opened_at.present?
|
|
end
|
|
|
|
def record_open!(at: Time.current)
|
|
return unless sent?
|
|
|
|
first_open = false
|
|
with_lock do
|
|
return unless sent?
|
|
|
|
first_open = opened_at.blank?
|
|
attrs = { last_opened_at: at, open_count: open_count + 1 }
|
|
attrs[:opened_at] = at if first_open
|
|
update!(attrs)
|
|
end
|
|
log_open_activity! if first_open
|
|
end
|
|
|
|
def merge_context
|
|
opportunity = organization.campaign_opportunity(mailing.project)
|
|
{
|
|
organization: organization,
|
|
contact: contact,
|
|
project: mailing.project,
|
|
opportunity: opportunity,
|
|
ab_variant: ab_variant.presence || opportunity&.ab_variant
|
|
}
|
|
end
|
|
|
|
def rendered_html
|
|
MailMerge.render(mailing.body_for(ab_variant), **merge_context)
|
|
end
|
|
|
|
def rendered_subject_line
|
|
MailMerge.render(mailing.subject_for(ab_variant), **merge_context)
|
|
end
|
|
|
|
def queued?
|
|
status == "queued"
|
|
end
|
|
|
|
def enqueue_for_delivery!
|
|
Mailings::OutboundQueue.enqueue(self)
|
|
end
|
|
|
|
def deliver_queued!
|
|
with_lock do
|
|
return if status.in?(%w[sent skipped])
|
|
return abort_delivery!("invio annullato") unless mailing.sending?
|
|
return unless queued?
|
|
end
|
|
|
|
html = rendered_html
|
|
subject_line = rendered_subject_line
|
|
Mailings::SmtpGate.deliver do
|
|
CampaignMailer.outreach(self, html: html, subject: subject_line).deliver_now
|
|
end
|
|
# Solo dopo deliver_now senza eccezioni: un EOF/SMTP error non è mai un invio riuscito.
|
|
record_success!(subject_line)
|
|
:sent
|
|
rescue *Mailings::SmtpGate::RETRYABLE => e
|
|
defer_or_fail!(e)
|
|
rescue StandardError => e
|
|
update!(status: "failed", error_message: e.message.to_s.truncate(500))
|
|
:failed
|
|
ensure
|
|
mailing.mark_finished_if_done!
|
|
end
|
|
|
|
def deliver!
|
|
enqueue_for_delivery!
|
|
deliver_queued! if queued?
|
|
end
|
|
|
|
private
|
|
|
|
def abort_delivery!(reason)
|
|
update!(status: "skipped", skip_reason: reason)
|
|
end
|
|
|
|
MAX_SMTP_DEFERS = 8
|
|
# 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)
|
|
attempt = smtp_defer_count + 1
|
|
message = error.message.to_s.truncate(400)
|
|
if attempt >= MAX_SMTP_DEFERS
|
|
update!(status: "failed", error_message: message)
|
|
:failed
|
|
else
|
|
update!(
|
|
status: "queued",
|
|
error_message: "Da ritentare (#{attempt}/#{MAX_SMTP_DEFERS}): #{message}"
|
|
)
|
|
:deferred
|
|
end
|
|
end
|
|
|
|
def smtp_defer_count
|
|
error_message.to_s[DEFER_PATTERN, 1].to_i
|
|
end
|
|
|
|
def record_success!(subject_line)
|
|
transaction do
|
|
update!(status: "sent", sent_at: Time.current, rendered_subject: subject_line, error_message: nil)
|
|
sync_opportunity!
|
|
log_activity!(subject_line)
|
|
end
|
|
end
|
|
|
|
def sync_opportunity!
|
|
opportunity = organization.campaign_opportunity(mailing.project)
|
|
return unless opportunity
|
|
|
|
attrs = {}
|
|
attrs[:send_status] = "sent" if opportunity.send_status == "to_send"
|
|
attrs[:sent_on] ||= Date.current if opportunity.sent_on.blank?
|
|
attrs[:pipeline_stage] = "contacted" if opportunity.pipeline_stage == "to_contact"
|
|
attrs[:ab_variant] = ab_variant if mailing.ab_test? && ab_variant.present? && opportunity.ab_variant.blank?
|
|
opportunity.update!(attrs) if attrs.any?
|
|
end
|
|
|
|
def assign_tracking_token
|
|
self.tracking_token ||= self.class.generate_tracking_token
|
|
end
|
|
|
|
def self.generate_tracking_token
|
|
loop do
|
|
token = SecureRandom.urlsafe_base64(24)
|
|
break token unless exists?(tracking_token: token)
|
|
end
|
|
end
|
|
|
|
def log_open_activity!
|
|
organization.activities.create!(
|
|
activity_type: "email_opened",
|
|
subject: rendered_subject.presence || mailing.subject_for(ab_variant),
|
|
description: "Apertura rilevata · campagna #{mailing.name}",
|
|
happened_at: opened_at || Time.current,
|
|
user: mailing.created_by,
|
|
contact: contact,
|
|
opportunity: organization.campaign_opportunity(mailing.project)
|
|
)
|
|
end
|
|
|
|
def log_activity!(subject_line)
|
|
variant_note = "variante #{ab_variant}" if mailing.ab_test? && ab_variant.present?
|
|
organization.activities.create!(
|
|
activity_type: "email_sent",
|
|
subject: subject_line.presence || mailing.name,
|
|
description: ["Campagna email: #{mailing.name}", variant_note].compact.join(" · "),
|
|
happened_at: Time.current,
|
|
user: mailing.created_by,
|
|
contact: contact,
|
|
opportunity: organization.campaign_opportunity(mailing.project)
|
|
)
|
|
end
|
|
end
|