Serializza gli invii SMTP su una coda unica per non far chiudere Aruba.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -205,7 +205,12 @@ class MailingsController < ApplicationController
|
||||
html = @test_preview_html
|
||||
subject = "[TEST#{@mailing.ab_test? ? " #{@test_variant}" : ""}] #{@test_preview_subject}"
|
||||
recipient = Mailings::TestRecipient.new(mailing: @mailing, email: to)
|
||||
CampaignMailer.outreach(recipient, html: html, subject: subject).deliver_now
|
||||
Mailings::OutboundQueue.exclusive do
|
||||
Mailings::SmtpGate.deliver do
|
||||
CampaignMailer.outreach(recipient, html: html, subject: subject).deliver_now
|
||||
end
|
||||
Mailings::OutboundQueue.stamp!
|
||||
end
|
||||
@mailing.update!(test_sent_at: Time.current, test_sent_to: to)
|
||||
redirect_to @mailing, notice: "Prova inviata subito a #{to}. Controlla l'output e, se è corretto, autorizza l'invio di massa."
|
||||
rescue StandardError => e
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
class DrainOutboundJob < ApplicationJob
|
||||
queue_as :mailers
|
||||
|
||||
def perform
|
||||
result = Mailings::OutboundQueue.drain_one!
|
||||
return unless result.in?(%i[sent deferred wait])
|
||||
return unless Mailings::OutboundQueue.queued_scope.exists?
|
||||
|
||||
wait = Mailings::OutboundQueue.seconds_until_next_slot
|
||||
wait = 0 if wait.blank? || wait.negative?
|
||||
max_wait = [ Mailings::OutboundQueue.min_interval.to_f, 2.minutes ].max + 5.seconds
|
||||
return if wait > max_wait
|
||||
|
||||
self.class.set(wait: wait).perform_later
|
||||
end
|
||||
end
|
||||
@@ -1,28 +1,16 @@
|
||||
class ResumeMailingsJob < ApplicationJob
|
||||
queue_as :mailers
|
||||
|
||||
STALE_QUEUED_AFTER = 10.minutes
|
||||
|
||||
def perform
|
||||
recover_stale_queued!
|
||||
Mailing.sending.due_to_send.find_each do |mailing|
|
||||
next unless Mailings::SendClock.new(mailing).open?
|
||||
next if mailing.mailing_recipients.queued.exists?
|
||||
|
||||
recipient = mailing.mailing_recipients.pending.order(:id).first
|
||||
next unless recipient
|
||||
|
||||
SendMailingRecipientJob.perform_later(recipient.id)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recover_stale_queued!
|
||||
MailingRecipient.queued.where("updated_at < ?", STALE_QUEUED_AFTER.ago).find_each do |recipient|
|
||||
next unless recipient.mailing.sending?
|
||||
|
||||
recipient.update!(status: "pending", error_message: nil)
|
||||
end
|
||||
DrainOutboundJob.perform_later if Mailings::OutboundQueue.queued_scope.exists?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,14 +21,11 @@ class SendMailingRecipientJob < ApplicationJob
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
CampaignMailer.raise_delivery_errors = true
|
||||
recipient.deliver!
|
||||
ensure
|
||||
CampaignMailer.raise_delivery_errors = false if Rails.env.development?
|
||||
if recipient.pending? && Mailings::OutboundQueue.enqueue(recipient)
|
||||
nxt = mailing.mailing_recipients.pending.order(:id).first
|
||||
mailing.enqueue_next_recipient!(nxt, from: Time.current + mailing.interval_seconds.seconds)
|
||||
end
|
||||
|
||||
nxt = mailing.mailing_recipients.pending.order(:id).first
|
||||
mailing.enqueue_next_recipient!(nxt, from: Time.current + mailing.interval_seconds.seconds)
|
||||
DrainOutboundJob.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,7 +30,9 @@ class MailIdentity < ApplicationRecord
|
||||
port: smtp_port,
|
||||
enable_starttls_auto: encryption == "starttls",
|
||||
ssl: encryption == "tls",
|
||||
openssl_verify_mode: verify_ssl? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
||||
openssl_verify_mode: verify_ssl? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE,
|
||||
open_timeout: 15,
|
||||
read_timeout: 60
|
||||
}
|
||||
if smtp_username.present?
|
||||
settings[:user_name] = smtp_username
|
||||
|
||||
@@ -71,32 +71,70 @@ class MailingRecipient < ApplicationRecord
|
||||
MailMerge.render(mailing.subject_for(ab_variant), **merge_context)
|
||||
end
|
||||
|
||||
def deliver!
|
||||
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?
|
||||
|
||||
update!(status: "queued")
|
||||
return unless queued?
|
||||
end
|
||||
|
||||
return unless mailing.sending?
|
||||
|
||||
html = rendered_html
|
||||
subject_line = rendered_subject_line
|
||||
CampaignMailer.outreach(self, html: html, subject: subject_line).deliver_now
|
||||
Mailings::SmtpGate.deliver do
|
||||
CampaignMailer.outreach(self, html: html, subject: subject_line).deliver_now
|
||||
end
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
class Mailings::OutboundQueue
|
||||
LOCK_PATH = Rails.root.join("tmp/outbound_smtp.lock")
|
||||
STAMP_PATH = Rails.root.join("tmp/outbound_last_sent")
|
||||
|
||||
class << self
|
||||
attr_accessor :min_interval
|
||||
|
||||
def enqueue(recipient)
|
||||
recipient.with_lock do
|
||||
return false unless recipient.mailing.sending?
|
||||
return false unless recipient.pending?
|
||||
|
||||
recipient.update!(status: "queued", error_message: nil)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def drain_one!
|
||||
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
|
||||
return :busy unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
||||
|
||||
wait = seconds_until_next_slot
|
||||
return :wait if wait.positive?
|
||||
|
||||
recipient = next_queued
|
||||
return :empty if recipient.nil?
|
||||
return :closed unless Mailings::SendClock.new(recipient.mailing).open?
|
||||
|
||||
begin
|
||||
CampaignMailer.raise_delivery_errors = true
|
||||
outcome = recipient.deliver_queued!
|
||||
stamp!
|
||||
outcome == :sent ? :sent : :deferred
|
||||
ensure
|
||||
CampaignMailer.raise_delivery_errors = false if Rails.env.development?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def exclusive
|
||||
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
|
||||
file.flock(File::LOCK_EX)
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def next_queued
|
||||
queued_scope.order(:updated_at, :id).first
|
||||
end
|
||||
|
||||
def queued_scope
|
||||
MailingRecipient.queued.joins(:mailing).merge(Mailing.sending)
|
||||
end
|
||||
|
||||
def seconds_until_next_slot
|
||||
return 0 if min_interval.to_f <= 0
|
||||
|
||||
last = last_sent_at
|
||||
return 0 if last.nil?
|
||||
|
||||
remaining = min_interval - (Time.current - last)
|
||||
remaining.positive? ? remaining : 0
|
||||
end
|
||||
|
||||
def reset!
|
||||
@last_sent_at = nil
|
||||
File.delete(stamp_path) if File.exist?(stamp_path)
|
||||
rescue Errno::ENOENT
|
||||
nil
|
||||
end
|
||||
|
||||
def stamp!
|
||||
@last_sent_at = Time.current
|
||||
File.write(stamp_path, @last_sent_at.to_f.to_s)
|
||||
end
|
||||
|
||||
def last_sent_at
|
||||
return @last_sent_at if @last_sent_at
|
||||
return unless File.exist?(stamp_path)
|
||||
|
||||
@last_sent_at = Time.zone.at(Float(File.read(stamp_path)))
|
||||
rescue ArgumentError, TypeError, Errno::ENOENT
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def lock_path
|
||||
LOCK_PATH
|
||||
end
|
||||
|
||||
def stamp_path
|
||||
STAMP_PATH
|
||||
end
|
||||
end
|
||||
|
||||
self.min_interval = Rails.env.test? ? 0.0 : 180.0
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
require "net/smtp"
|
||||
require "openssl"
|
||||
|
||||
class Mailings::SmtpGate
|
||||
MUTEX = Mutex.new
|
||||
RETRYABLE = [
|
||||
EOFError,
|
||||
IOError,
|
||||
Errno::ECONNRESET,
|
||||
Errno::EPIPE,
|
||||
Errno::ETIMEDOUT,
|
||||
Net::OpenTimeout,
|
||||
Net::ReadTimeout,
|
||||
OpenSSL::SSL::SSLError,
|
||||
Net::SMTPServerBusy
|
||||
].freeze
|
||||
|
||||
class << self
|
||||
attr_accessor :min_gap, :max_attempts, :backoff_base
|
||||
|
||||
def deliver
|
||||
MUTEX.synchronize { deliver_locked { yield } }
|
||||
end
|
||||
|
||||
def reset!
|
||||
MUTEX.synchronize { @last_monotonic = nil }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def deliver_locked
|
||||
attempts = 0
|
||||
begin
|
||||
wait_min_gap
|
||||
result = yield
|
||||
stamp!
|
||||
result
|
||||
rescue *RETRYABLE => e
|
||||
attempts += 1
|
||||
stamp!
|
||||
Rails.logger.warn("[smtp-gate] #{e.class}: #{e.message} attempt=#{attempts}/#{max_attempts}")
|
||||
raise if attempts >= max_attempts
|
||||
|
||||
sleep_wait(backoff_base * (2**(attempts - 1)))
|
||||
retry
|
||||
end
|
||||
end
|
||||
|
||||
def wait_min_gap
|
||||
return if min_gap.to_f <= 0 || @last_monotonic.nil?
|
||||
|
||||
elapsed = now - @last_monotonic
|
||||
remaining = min_gap - elapsed
|
||||
sleep_wait(remaining) if remaining.positive?
|
||||
end
|
||||
|
||||
def stamp!
|
||||
@last_monotonic = now
|
||||
end
|
||||
|
||||
def now
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
|
||||
def sleep_wait(seconds)
|
||||
sleep(seconds) if seconds.to_f.positive?
|
||||
end
|
||||
end
|
||||
|
||||
self.min_gap = 0.0
|
||||
self.max_attempts = 1
|
||||
self.backoff_base = 0.0
|
||||
end
|
||||
Reference in New Issue
Block a user