Files
eminuxCRM/app/models/mailing_recipient.rb
T
eminuxandCursor 4cf34b2033
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / scan_ruby (push) Has been cancelled
Non tratta l'EOF SMTP come invio riuscito e toglie quei falsi errori dalla lista.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 12:32:18 +02:00

195 lines
5.4 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
DEFER_PATTERN = /tentativo (\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