Aggiunge fascia oraria, prova manuale obbligatoria e cruscotto invii email.
Le campagne rispettano orario e giorni lavorativi, richiedono una mail di test con dati inseriti a mano prima dell'invio massivo e offrono una dashboard per monitorare l'avanzamento. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,20 @@
|
|||||||
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 test_send refresh_recipients update_recipients preview]
|
before_action :set_mailing, only: %i[show edit update destroy queue test_send test_preview refresh_recipients update_recipients preview]
|
||||||
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
|
||||||
@page_title = "Email"
|
redirect_to dashboard_mailings_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def dashboard
|
||||||
|
@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?)
|
||||||
|
@paused_mailings = @active_mailings.select { |mailing| mailing.next_send_at.present? && mailing.next_send_at > Time.current }
|
||||||
|
@completed_today = @mailings.count { |mailing| mailing.sent? && mailing.completed_at&.to_date == Time.zone.today }
|
||||||
|
@failed_open = @mailings.sum(&:failed_count)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@@ -19,6 +27,8 @@ class MailingsController < ApplicationController
|
|||||||
mail_identity: identity,
|
mail_identity: identity,
|
||||||
audience: "to_send",
|
audience: "to_send",
|
||||||
interval_seconds: 0,
|
interval_seconds: 0,
|
||||||
|
send_window_start_minutes: 10 * 60,
|
||||||
|
send_window_end_minutes: 18 * 60,
|
||||||
ab_assignment: "from_record",
|
ab_assignment: "from_record",
|
||||||
name: "Invio #{l(Time.zone.today)}",
|
name: "Invio #{l(Time.zone.today)}",
|
||||||
subject: template&.subject,
|
subject: template&.subject,
|
||||||
@@ -47,6 +57,7 @@ class MailingsController < ApplicationController
|
|||||||
context = @preview_recipient&.merge_context || { project: current_project, ab_variant: @preview_variant }
|
context = @preview_recipient&.merge_context || { project: current_project, ab_variant: @preview_variant }
|
||||||
@preview_html = MailMerge.render(@mailing.body_for(@preview_variant), **context)
|
@preview_html = MailMerge.render(@mailing.body_for(@preview_variant), **context)
|
||||||
@preview_subject = MailMerge.render(@mailing.subject_for(@preview_variant), **context)
|
@preview_subject = MailMerge.render(@mailing.subject_for(@preview_variant), **context)
|
||||||
|
assign_test_preview
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@@ -76,12 +87,12 @@ class MailingsController < ApplicationController
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
unless @mailing.draft?
|
unless @mailing.draft?
|
||||||
redirect_to mailings_path, alert: "Puoi eliminare solo le bozze."
|
redirect_to dashboard_mailings_path, alert: "Puoi eliminare solo le bozze."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@mailing.destroy!
|
@mailing.destroy!
|
||||||
redirect_to mailings_path, notice: "Invio eliminato."
|
redirect_to dashboard_mailings_path, notice: "Invio eliminato."
|
||||||
end
|
end
|
||||||
|
|
||||||
def refresh_recipients
|
def refresh_recipients
|
||||||
@@ -117,31 +128,39 @@ class MailingsController < ApplicationController
|
|||||||
redirect_to @mailing, alert: "Questo invio è già stato concluso."
|
redirect_to @mailing, alert: "Questo invio è già stato concluso."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
unless @mailing.tested?
|
||||||
|
redirect_to @mailing, alert: "Prima di autorizzare l'invio di massa, manda una email di prova con dati di test."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
@mailing.queue_send!
|
@mailing.queue_send!
|
||||||
redirect_to @mailing, notice: "Invio avviato. Le email partono in background#{@mailing.interval_seconds.positive? ? " con pausa di #{@mailing.interval_seconds}s" : ""}."
|
redirect_to dashboard_mailings_path, notice: queue_notice(@mailing)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
redirect_to @mailing, alert: e.message
|
redirect_to @mailing, alert: e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_send
|
def test_send
|
||||||
recipient = preview_recipient
|
assign_test_preview
|
||||||
unless recipient&.email_ok?
|
to = @test_to.to_s.strip
|
||||||
redirect_to @mailing, alert: "Nessun destinatario valido da usare come dati di prova."
|
unless to.match?(URI::MailTo::EMAIL_REGEXP)
|
||||||
|
redirect_to @mailing, alert: "Inserisci un indirizzo email valido per la prova."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
variant = preview_variant
|
html = @test_preview_html
|
||||||
html = MailMerge.render(@mailing.body_for(variant), **recipient.merge_context.merge(ab_variant: variant))
|
subject = "[TEST#{@mailing.ab_test? ? " #{@test_variant}" : ""}] #{@test_preview_subject}"
|
||||||
subject = "[TEST#{@mailing.ab_test? ? " #{variant}" : ""}] #{MailMerge.render(@mailing.subject_for(variant), **recipient.merge_context.merge(ab_variant: variant))}"
|
recipient = Mailings::TestRecipient.new(mailing: @mailing, email: to)
|
||||||
test_recipient = recipient.dup
|
CampaignMailer.outreach(recipient, html: html, subject: subject).deliver_now
|
||||||
test_recipient.email = current_user.email
|
@mailing.update!(test_sent_at: Time.current, test_sent_to: to)
|
||||||
CampaignMailer.outreach(test_recipient, html: html, subject: subject).deliver_now
|
redirect_to @mailing, notice: "Prova inviata subito a #{to}. Controlla l'output e, se è corretto, autorizza l'invio di massa."
|
||||||
redirect_to mailing_path(@mailing, recipient_id: recipient.id, variant: variant), notice: "Email di prova (#{@mailing.ab_test? ? "variante #{variant}" : "unica"}) inviata a #{current_user.email}."
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
redirect_to @mailing, alert: "Invio di prova non riuscito: #{e.message}"
|
redirect_to @mailing, alert: "Invio di prova non riuscito: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_preview
|
||||||
|
assign_test_preview
|
||||||
|
end
|
||||||
|
|
||||||
def preview
|
def preview
|
||||||
redirect_to mailing_path(@mailing, recipient_id: params[:recipient_id])
|
redirect_to mailing_path(@mailing, recipient_id: params[:recipient_id])
|
||||||
end
|
end
|
||||||
@@ -161,10 +180,19 @@ class MailingsController < ApplicationController
|
|||||||
params.require(:mailing).permit(
|
params.require(:mailing).permit(
|
||||||
:name, :mail_identity_id, :mail_template_id, :mail_template_b_id, :audience,
|
:name, :mail_identity_id, :mail_template_id, :mail_template_b_id, :audience,
|
||||||
:subject, :body_html, :subject_b, :body_html_b, :ab_test, :ab_assignment,
|
:subject, :body_html, :subject_b, :body_html_b, :ab_test, :ab_assignment,
|
||||||
:interval_seconds, files: []
|
:interval_seconds, :send_window_enabled, :send_window_start_tod, :send_window_end_tod, files: []
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def queue_notice(mailing)
|
||||||
|
pause = mailing.interval_seconds.positive? ? " con pausa di #{mailing.interval_seconds}s" : ""
|
||||||
|
if mailing.send_window_enabled? && mailing.next_send_at && mailing.next_send_at > Time.current
|
||||||
|
"Invio avviato. La prima email partirà #{I18n.l(mailing.next_send_at, format: :long)} (fascia #{mailing.send_window_label}#{pause})."
|
||||||
|
else
|
||||||
|
"Invio avviato. Le email partono in background#{pause}."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def apply_template_if_needed
|
def apply_template_if_needed
|
||||||
copy_template_if_blank(params.dig(:mailing, :mail_template_id), :subject, :body_html)
|
copy_template_if_blank(params.dig(:mailing, :mail_template_id), :subject, :body_html)
|
||||||
copy_template_if_blank(params.dig(:mailing, :mail_template_b_id), :subject_b, :body_html_b)
|
copy_template_if_blank(params.dig(:mailing, :mail_template_b_id), :subject_b, :body_html_b)
|
||||||
@@ -202,4 +230,35 @@ class MailingsController < ApplicationController
|
|||||||
|
|
||||||
preview_recipient&.ab_variant.presence_in(%w[A B]) || "A"
|
preview_recipient&.ab_variant.presence_in(%w[A B]) || "A"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def assign_test_preview
|
||||||
|
@test_to = test_send_params[:to].presence || current_user.email
|
||||||
|
@test_variant = test_send_params[:variant].to_s.upcase.presence_in(%w[A B]) || preview_variant
|
||||||
|
@test_vars = default_test_vars.merge(test_vars_params)
|
||||||
|
extras = @test_vars
|
||||||
|
@test_preview_html = MailMerge.render(@mailing.body_for(@test_variant), extras: extras, project: current_project, ab_variant: @test_variant)
|
||||||
|
@test_preview_subject = MailMerge.render(@mailing.subject_for(@test_variant), extras: extras, project: current_project, ab_variant: @test_variant)
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_test_vars
|
||||||
|
source = preview_recipient
|
||||||
|
if source
|
||||||
|
MailMerge.variables_for(**source.merge_context.merge(ab_variant: preview_variant))
|
||||||
|
else
|
||||||
|
MailMerge.catalog.keys.index_with { "" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_send_params
|
||||||
|
params.permit(:to, :variant, :recipient_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_vars_params
|
||||||
|
raw = params[:vars]
|
||||||
|
return {} unless raw.respond_to?(:to_unsafe_h) || raw.is_a?(Hash)
|
||||||
|
|
||||||
|
allowed = MailMerge.catalog.keys
|
||||||
|
hash = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
|
||||||
|
hash.stringify_keys.slice(*allowed).transform_values { |value| value.to_s }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { interval: { type: Number, default: 10000 } }
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
if (document.hidden) return
|
||||||
|
if (window.Turbo?.visit) {
|
||||||
|
window.Turbo.visit(window.location.href, { action: "replace" })
|
||||||
|
} else {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}, this.intervalValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
clearInterval(this.timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["checkbox", "panel"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
sync() {
|
||||||
|
if (!this.hasCheckboxTarget) return
|
||||||
|
|
||||||
|
const on = this.checkboxTarget.checked
|
||||||
|
this.panelTargets.forEach((el) => el.classList.toggle("hidden", !on))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["frame"]
|
||||||
|
static values = { previewUrl: String }
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.loadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
preview() {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.timer = setTimeout(() => this.loadPreview(), 350)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreview() {
|
||||||
|
if (!this.hasFrameTarget) return
|
||||||
|
|
||||||
|
const params = new URLSearchParams(new FormData(this.element))
|
||||||
|
params.delete("authenticity_token")
|
||||||
|
params.delete("commit")
|
||||||
|
this.frameTarget.src = `${this.previewUrlValue}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,6 +9,18 @@ class SendMailingRecipientJob < ApplicationJob
|
|||||||
return unless mailing.sending?
|
return unless mailing.sending?
|
||||||
return unless recipient.pending? || recipient.status == "queued"
|
return unless recipient.pending? || recipient.status == "queued"
|
||||||
|
|
||||||
|
clock = Mailings::SendClock.new(mailing)
|
||||||
|
unless clock.open?
|
||||||
|
mailing.enqueue_next_recipient!(recipient, from: clock.next_open_at)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if mailing.next_send_at.present? && mailing.next_send_at > Time.current
|
||||||
|
wait = mailing.next_send_at - Time.current
|
||||||
|
self.class.set(wait: wait).perform_later(recipient.id) if wait <= 2.minutes
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
CampaignMailer.raise_delivery_errors = true
|
CampaignMailer.raise_delivery_errors = true
|
||||||
recipient.deliver!
|
recipient.deliver!
|
||||||
@@ -17,13 +29,6 @@ class SendMailingRecipientJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
nxt = mailing.mailing_recipients.pending.order(:id).first
|
nxt = mailing.mailing_recipients.pending.order(:id).first
|
||||||
return if nxt.nil?
|
mailing.enqueue_next_recipient!(nxt, from: Time.current + mailing.interval_seconds.seconds)
|
||||||
|
|
||||||
wait = mailing.interval_seconds.to_i.seconds
|
|
||||||
if wait.positive?
|
|
||||||
self.class.set(wait: wait).perform_later(nxt.id)
|
|
||||||
else
|
|
||||||
self.class.perform_later(nxt.id)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
module Italy
|
||||||
|
class Holidays
|
||||||
|
FIXED = [
|
||||||
|
[1, 1],
|
||||||
|
[1, 6],
|
||||||
|
[4, 25],
|
||||||
|
[5, 1],
|
||||||
|
[6, 2],
|
||||||
|
[8, 15],
|
||||||
|
[11, 1],
|
||||||
|
[12, 8],
|
||||||
|
[12, 25],
|
||||||
|
[12, 26]
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def self.holiday?(date)
|
||||||
|
date = date.to_date
|
||||||
|
return true if FIXED.include?([date.month, date.day])
|
||||||
|
|
||||||
|
easter = easter_date(date.year)
|
||||||
|
date == easter || date == (easter + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Algoritmo gregoriano anonimo (Pasqua occidentale).
|
||||||
|
def self.easter_date(year)
|
||||||
|
a = year % 19
|
||||||
|
b, c = year.divmod(100)
|
||||||
|
d, e = b.divmod(4)
|
||||||
|
f = (b + 8) / 25
|
||||||
|
g = (b - f + 1) / 3
|
||||||
|
h = (19 * a + b - d - g + 15) % 30
|
||||||
|
i, k = c.divmod(4)
|
||||||
|
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||||
|
m = (a + 11 * h + 22 * l) / 451
|
||||||
|
month, day = (h + l - 7 * m + 114).divmod(31)
|
||||||
|
Date.new(year, month, day + 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+102
-4
@@ -16,10 +16,17 @@ class Mailing < ApplicationRecord
|
|||||||
validates :ab_assignment, inclusion: { in: Catalog::MAILING_AB_ASSIGNMENTS.keys }
|
validates :ab_assignment, inclusion: { in: Catalog::MAILING_AB_ASSIGNMENTS.keys }
|
||||||
validates :interval_seconds, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 86_400 }
|
validates :interval_seconds, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 86_400 }
|
||||||
validates :subject_b, :body_html_b, presence: true, if: :ab_test?
|
validates :subject_b, :body_html_b, presence: true, if: :ab_test?
|
||||||
|
validates :send_window_start_minutes, :send_window_end_minutes,
|
||||||
|
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 24 * 60 },
|
||||||
|
allow_nil: true
|
||||||
|
validates :send_window_start_minutes, :send_window_end_minutes, presence: true, if: :send_window_enabled?
|
||||||
|
validate :send_window_order
|
||||||
clears_blank_html :body_html, :body_html_b
|
clears_blank_html :body_html, :body_html_b
|
||||||
|
|
||||||
scope :recent, -> { order(created_at: :desc) }
|
scope :recent, -> { order(created_at: :desc) }
|
||||||
scope :for_project, ->(project) { where(project_id: project.id) }
|
scope :for_project, ->(project) { where(project_id: project.id) }
|
||||||
|
scope :sending, -> { where(status: "sending") }
|
||||||
|
scope :due_to_send, -> { where("next_send_at IS NOT NULL AND next_send_at <= ?", Time.current) }
|
||||||
|
|
||||||
def status_label
|
def status_label
|
||||||
Catalog.label_for(Catalog::MAILING_STATUSES, status)
|
Catalog.label_for(Catalog::MAILING_STATUSES, status)
|
||||||
@@ -85,23 +92,114 @@ class Mailing < ApplicationRecord
|
|||||||
mailing_recipients.failed.count
|
mailing_recipients.failed.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def queued_count
|
||||||
|
mailing_recipients.queued.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def tracked_count
|
||||||
|
pending_count + queued_count + sent_count + failed_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def progress_percentage
|
||||||
|
total = tracked_count
|
||||||
|
return 0 if total.zero?
|
||||||
|
|
||||||
|
(((sent_count + failed_count) * 100.0) / total).round
|
||||||
|
end
|
||||||
|
|
||||||
|
def tested?
|
||||||
|
test_sent_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def used_merge_tokens
|
||||||
|
blobs = [ subject, body_html ]
|
||||||
|
blobs += [ subject_b, body_html_b ] if ab_test?
|
||||||
|
blobs.join.scan(MailMerge::TOKEN).flatten.map { |token| token.to_s.downcase }.uniq
|
||||||
|
end
|
||||||
|
|
||||||
def rebuild_recipients!
|
def rebuild_recipients!
|
||||||
mailing_recipients.delete_all
|
mailing_recipients.delete_all
|
||||||
Mailings::RecipientBuilder.new(self).call
|
Mailings::RecipientBuilder.new(self).call
|
||||||
end
|
end
|
||||||
|
|
||||||
def queue_send!
|
def queue_send!
|
||||||
|
raise "Invia prima una email di prova con dati di test" if test_sent_at.blank?
|
||||||
raise "Nessun destinatario da inviare" if pending_count.zero?
|
raise "Nessun destinatario da inviare" if pending_count.zero?
|
||||||
|
|
||||||
update!(status: "sending", queued_at: Time.current)
|
update!(status: "sending", queued_at: Time.current, authorized_at: authorized_at || Time.current)
|
||||||
first = mailing_recipients.pending.order(:id).first
|
enqueue_next_recipient!(mailing_recipients.pending.order(:id).first, from: Time.current)
|
||||||
SendMailingRecipientJob.perform_later(first.id)
|
end
|
||||||
|
|
||||||
|
def enqueue_next_recipient!(recipient = nil, from: Time.current)
|
||||||
|
recipient ||= mailing_recipients.pending.order(:id).first
|
||||||
|
if recipient.nil?
|
||||||
|
update!(next_send_at: nil) if next_send_at.present?
|
||||||
|
mark_finished_if_done!
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
at = Mailings::SendClock.new(self, time: from).next_send_at(earliest: from)
|
||||||
|
update!(next_send_at: at)
|
||||||
|
wait = at - Time.current
|
||||||
|
return if wait > 2.minutes
|
||||||
|
|
||||||
|
SendMailingRecipientJob.set(wait: [ wait, 0 ].max).perform_later(recipient.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_clock
|
||||||
|
Mailings::SendClock.new(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_window_label
|
||||||
|
return unless send_window_enabled?
|
||||||
|
|
||||||
|
"#{send_clock.window_label}, lun–ven"
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_window_start_tod
|
||||||
|
minutes_to_tod(send_window_start_minutes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_window_start_tod=(value)
|
||||||
|
self.send_window_start_minutes = self.class.parse_tod_minutes(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_window_end_tod
|
||||||
|
minutes_to_tod(send_window_end_minutes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_window_end_tod=(value)
|
||||||
|
self.send_window_end_minutes = self.class.parse_tod_minutes(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.parse_tod_minutes(value)
|
||||||
|
return if value.blank?
|
||||||
|
return (value.hour * 60) + value.min if value.respond_to?(:hour)
|
||||||
|
|
||||||
|
hours, minutes = value.to_s.split(":").map(&:to_i)
|
||||||
|
(hours * 60) + minutes.to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_finished_if_done!
|
def mark_finished_if_done!
|
||||||
return unless sending?
|
return unless sending?
|
||||||
return if mailing_recipients.pending.exists? || mailing_recipients.queued.exists?
|
return if mailing_recipients.pending.exists? || mailing_recipients.queued.exists?
|
||||||
|
|
||||||
update!(status: "sent", completed_at: Time.current)
|
update!(status: "sent", completed_at: Time.current, next_send_at: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def send_window_order
|
||||||
|
return unless send_window_enabled?
|
||||||
|
return if send_window_start_minutes.blank? || send_window_end_minutes.blank?
|
||||||
|
return if send_window_start_minutes < send_window_end_minutes
|
||||||
|
|
||||||
|
errors.add(:send_window_end_tod, "deve essere successiva all'inizio della fascia")
|
||||||
|
end
|
||||||
|
|
||||||
|
def minutes_to_tod(minutes)
|
||||||
|
return if minutes.nil?
|
||||||
|
|
||||||
|
Time.zone.local(2000, 1, 1, minutes / 60, minutes % 60, 0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ module MailMerge
|
|||||||
}.transform_values { |value| value.to_s }
|
}.transform_values { |value| value.to_s }
|
||||||
end
|
end
|
||||||
|
|
||||||
def render(template, **context)
|
def render(template, extras: {}, **context)
|
||||||
vars = variables_for(**context)
|
vars = variables_for(**context)
|
||||||
|
extras.each do |key, value|
|
||||||
|
vars[key.to_s.downcase] = value.to_s
|
||||||
|
end
|
||||||
template.to_s.gsub(TOKEN) do
|
template.to_s.gsub(TOKEN) do
|
||||||
key = Regexp.last_match(1).to_s.downcase
|
key = Regexp.last_match(1).to_s.downcase
|
||||||
vars[key].to_s
|
vars[key].to_s
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
module Mailings
|
||||||
|
class SendClock
|
||||||
|
def initialize(mailing, time: Time.zone.now)
|
||||||
|
@mailing = mailing
|
||||||
|
@time = time.in_time_zone
|
||||||
|
end
|
||||||
|
|
||||||
|
def open?(at = @time)
|
||||||
|
t = at.in_time_zone
|
||||||
|
return true unless @mailing.send_window_enabled?
|
||||||
|
|
||||||
|
working_day?(t.to_date) && minutes_in_window?(t)
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_send_at(earliest: @time)
|
||||||
|
t = earliest.in_time_zone
|
||||||
|
return t unless @mailing.send_window_enabled?
|
||||||
|
|
||||||
|
next_open_at(from: t)
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_open_at(from: @time)
|
||||||
|
t = from.in_time_zone
|
||||||
|
return t unless @mailing.send_window_enabled?
|
||||||
|
|
||||||
|
0.upto(21) do |offset|
|
||||||
|
day = t.to_date + offset
|
||||||
|
next unless working_day?(day)
|
||||||
|
|
||||||
|
start_at = time_on(day, start_minutes)
|
||||||
|
end_at = time_on(day, end_minutes)
|
||||||
|
if offset.zero?
|
||||||
|
return t if t >= start_at && t < end_at
|
||||||
|
return start_at if t < start_at
|
||||||
|
|
||||||
|
next
|
||||||
|
end
|
||||||
|
return start_at
|
||||||
|
end
|
||||||
|
|
||||||
|
t + 21.days
|
||||||
|
end
|
||||||
|
|
||||||
|
def window_label
|
||||||
|
"#{format_minutes(start_minutes)}–#{format_minutes(end_minutes)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def working_day?(date)
|
||||||
|
date.on_weekday? && !Italy::Holidays.holiday?(date)
|
||||||
|
end
|
||||||
|
|
||||||
|
def minutes_in_window?(time)
|
||||||
|
minutes = (time.hour * 60) + time.min
|
||||||
|
minutes >= start_minutes && minutes < end_minutes
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_minutes
|
||||||
|
@mailing.send_window_start_minutes.presence || (10 * 60)
|
||||||
|
end
|
||||||
|
|
||||||
|
def end_minutes
|
||||||
|
@mailing.send_window_end_minutes.presence || (18 * 60)
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_on(date, minutes)
|
||||||
|
if minutes >= (24 * 60)
|
||||||
|
Time.zone.local(date.year, date.month, date.day).beginning_of_day + 1.day
|
||||||
|
else
|
||||||
|
Time.zone.local(date.year, date.month, date.day, minutes / 60, minutes % 60, 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_minutes(minutes)
|
||||||
|
format("%02d:%02d", minutes / 60, minutes % 60)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module Mailings
|
||||||
|
TestRecipient = Struct.new(:mailing, :email, keyword_init: true)
|
||||||
|
end
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<%= nav_link "Contatti", contacts_path %>
|
<%= nav_link "Contatti", contacts_path %>
|
||||||
<%= nav_link "Pipeline", pipeline_path %>
|
<%= nav_link "Pipeline", pipeline_path %>
|
||||||
<%= nav_link "Task", tasks_path %>
|
<%= nav_link "Task", tasks_path %>
|
||||||
<%= nav_link "Email", mailings_path %>
|
<%= nav_link "Email", dashboard_mailings_path %>
|
||||||
<%= nav_link "Report", reports_path %>
|
<%= nav_link "Report", reports_path %>
|
||||||
<%= nav_link "Impostazioni", settings_path %>
|
<%= nav_link "Impostazioni", settings_path %>
|
||||||
<% if current_user.admin? %>
|
<% if current_user.admin? %>
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<%= link_to "Org", organizations_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
<%= link_to "Org", organizations_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||||
<%= link_to "Pipeline", pipeline_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
<%= link_to "Pipeline", pipeline_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||||
<%= link_to "Task", tasks_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
<%= link_to "Task", tasks_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||||
<%= link_to "Email", mailings_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
<%= link_to "Email", dashboard_mailings_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||||
<% if current_user.admin? %>
|
<% if current_user.admin? %>
|
||||||
<%= link_to "Utenti", users_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
<%= link_to "Utenti", users_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Template email</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Template email</h1>
|
||||||
<p class="mt-1 text-sm text-zinc-500">
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
<%= link_to "Invii", mailings_path, class: "hover:underline" %>
|
<%= link_to "Invii", dashboard_mailings_path, class: "hover:underline" %>
|
||||||
· HTML riutilizzabile con variabili della scheda cliente.
|
· HTML riutilizzabile con variabili della scheda cliente.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700" data-controller="send-window">
|
||||||
|
<label class="flex items-start gap-2 text-sm">
|
||||||
|
<%= f.check_box :send_window_enabled, { data: { send_window_target: "checkbox", action: "send-window#toggle" } } %>
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">Invia solo in fascia oraria</span>
|
||||||
|
<span class="mt-1 block text-xs text-zinc-500">Le email partono solo nei giorni lavorativi (lun–ven, esclusi i festivi italiani). Se la fascia finisce, l’invio riprende dal giorno lavorativo successivo.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="mt-4 grid gap-4 md:grid-cols-2 <%= "hidden" unless mailing.send_window_enabled? %>" data-send-window-target="panel">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Dalle</label>
|
||||||
|
<%= f.time_field :send_window_start_tod, include_seconds: false, step: 60, class: input_class %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Alle</label>
|
||||||
|
<%= f.time_field :send_window_end_tod, include_seconds: false, step: 60, class: input_class %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
|
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
<label class="flex items-start gap-2 text-sm">
|
<label class="flex items-start gap-2 text-sm">
|
||||||
<%= f.check_box :ab_test, { data: { ab_test_target: "checkbox", action: "ab-test#toggle" } } %>
|
<%= f.check_box :ab_test, { data: { ab_test_target: "checkbox", action: "ab-test#toggle" } } %>
|
||||||
@@ -101,7 +121,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
<div class="flex flex-wrap items-center gap-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||||
<%= f.submit mailing.new_record? ? "Crea bozza" : "Salva", class: btn_primary %>
|
<%= f.submit mailing.new_record? ? "Crea bozza" : "Salva", class: btn_primary %>
|
||||||
<%= link_to "Annulla", mailing.new_record? ? mailings_path : mailing_path(mailing), class: btn_secondary %>
|
<%= link_to "Annulla", mailing.new_record? ? dashboard_mailings_path : mailing_path(mailing), class: btn_secondary %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<div class="<%= card_class %> p-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<%= link_to mailing.name, mailing, class: "font-semibold hover:underline" %>
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">
|
||||||
|
<%= mailing.mail_identity.from_email %>
|
||||||
|
<% if mailing.send_window_enabled? %>
|
||||||
|
· fascia <%= mailing.send_window_label %>
|
||||||
|
<% end %>
|
||||||
|
<% if mailing.interval_seconds.positive? %>
|
||||||
|
· pausa <%= mailing.interval_seconds %>s
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<%= mailing_status_badge(mailing.status) %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4"><%= progress_bar(mailing.progress_percentage, color: mailing.failed_count.positive? ? "bg-amber-500" : "bg-emerald-500") %></div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-3 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
<span><strong class="tabular-nums"><%= mailing.sent_count %></strong> inviate</span>
|
||||||
|
<span><strong class="tabular-nums"><%= mailing.pending_count %></strong> in coda</span>
|
||||||
|
<span><strong class="tabular-nums"><%= mailing.skipped_count %></strong> escluse</span>
|
||||||
|
<% if mailing.failed_count.positive? %>
|
||||||
|
<span class="text-rose-700"><strong class="tabular-nums"><%= mailing.failed_count %></strong> errori</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if mailing.next_send_at.present? && mailing.next_send_at > Time.current %>
|
||||||
|
<p class="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||||
|
In pausa fino a <%= l(mailing.next_send_at, format: :long) %>
|
||||||
|
</p>
|
||||||
|
<% elsif mailing.pending_count.positive? %>
|
||||||
|
<p class="mt-3 text-xs text-zinc-500">Prossima email in lavorazione.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium"><%= @test_preview_subject.presence || "(oggetto vuoto)" %></p>
|
||||||
|
<div class="max-h-64 overflow-auto rounded-lg border border-zinc-100 bg-white p-3 text-sm text-zinc-800 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
|
||||||
|
<%= sanitize_email_html(@test_preview_html) %>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-zinc-500">Anteprima con i dati inseriti. L’email di prova parte subito, senza fascia oraria.</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<% used = mailing.used_merge_tokens %>
|
||||||
|
<% extra = MailMerge.catalog.keys - used %>
|
||||||
|
|
||||||
|
<%= form_with url: test_send_mailing_path(mailing), method: :post, class: "space-y-3",
|
||||||
|
data: {
|
||||||
|
controller: "test-mail",
|
||||||
|
test_mail_preview_url_value: test_preview_mailing_path(mailing),
|
||||||
|
action: "input->test-mail#preview change->test-mail#preview"
|
||||||
|
} do %>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Invia la prova a</label>
|
||||||
|
<%= email_field_tag :to, @test_to, required: true, class: input_class %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if mailing.ab_test? %>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Variante</label>
|
||||||
|
<%= select_tag :variant, options_for_select(%w[A B], @test_variant), class: input_class %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<% used.each do |token| %>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300"><%= MailMerge.catalog[token] || token %> <code class="text-zinc-400">{{<%= token %>}}</code></label>
|
||||||
|
<%= text_field_tag "vars[#{token}]", @test_vars[token], class: input_class %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if extra.any? %>
|
||||||
|
<details class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium">Altri campi di test</summary>
|
||||||
|
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<% extra.each do |token| %>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300"><%= MailMerge.catalog[token] %> <code class="text-zinc-400">{{<%= token %>}}</code></label>
|
||||||
|
<%= text_field_tag "vars[#{token}]", @test_vars[token], class: input_class %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= turbo_frame_tag "mailing_test_preview", data: { test_mail_target: "frame" } do %>
|
||||||
|
<%= render "mailings/test_preview" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= submit_tag "Invia prova ora", class: btn_primary %>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<% refresh = @active_mailings.any? %>
|
||||||
|
<%= tag.div class: "space-y-6", data: (refresh ? { controller: "auto-reload", auto_reload_interval_value: 10_000 } : {}) do %>
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight">Invii email</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">Stato delle campagne, prove e invii di massa scaglionati.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<%= link_to "Template", mail_templates_path, class: btn_secondary %>
|
||||||
|
<% if current_user.admin? %>
|
||||||
|
<%= link_to "Account SMTP", mail_identities_path, class: btn_secondary %>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to "Nuovo invio", new_mailing_path, class: btn_primary %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @mail_identities_count.zero? %>
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||||
|
Manca un account mittente SMTP.
|
||||||
|
<% if current_user.admin? %>
|
||||||
|
<%= link_to "Configuralo qui", mail_identities_path, class: "font-medium underline" %>.
|
||||||
|
<% else %>
|
||||||
|
Chiedi a un amministratore di censirlo.
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div class="<%= card_class %> p-4">
|
||||||
|
<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>
|
||||||
|
<div class="<%= card_class %> p-4">
|
||||||
|
<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>
|
||||||
|
<div class="<%= card_class %> p-4">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-zinc-500">Completati oggi</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= @completed_today %></div>
|
||||||
|
</div>
|
||||||
|
<div class="<%= card_class %> p-4">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-zinc-500">Errori aperti</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= @failed_open %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @active_mailings.any? %>
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h2 class="text-lg font-semibold">Campagne in corso</h2>
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<% @active_mailings.each do |mailing| %>
|
||||||
|
<%= render "mailings/progress_card", mailing: mailing %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-zinc-500">Questa pagina si aggiorna da sola ogni 10 secondi mentre c’è un invio attivo.</p>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="overflow-hidden <%= card_class %>">
|
||||||
|
<% if @mailings.empty? %>
|
||||||
|
<p class="p-6 text-sm text-zinc-500">Nessun invio. Crea una bozza, fai una prova con dati manuali e poi autorizza l’invio di massa.</p>
|
||||||
|
<% else %>
|
||||||
|
<table class="<%= table_class %>">
|
||||||
|
<thead class="border-b border-zinc-100 bg-zinc-50 text-left text-xs font-medium uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-800 dark:text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3">Invio</th>
|
||||||
|
<th class="px-4 py-3">Avanzamento</th>
|
||||||
|
<th class="px-4 py-3">Stato</th>
|
||||||
|
<th class="px-4 py-3">Prossimo / creato</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
<% @mailings.each do |mailing| %>
|
||||||
|
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-800/80">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<%= link_to mailing.name, mailing, class: "font-medium hover:underline" %>
|
||||||
|
<div class="text-xs text-zinc-500"><%= mailing.mail_identity.from_email %></div>
|
||||||
|
<% if mailing.ab_test? %>
|
||||||
|
<span class="mt-1 inline-flex gap-1"><%= ab_variant_badge("A") %><%= ab_variant_badge("B") %></span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 min-w-48">
|
||||||
|
<%= progress_bar(mailing.progress_percentage) %>
|
||||||
|
<div class="mt-1 text-xs text-zinc-500">
|
||||||
|
<%= mailing.sent_count %> inviate
|
||||||
|
· <%= mailing.pending_count %> in coda
|
||||||
|
<% if mailing.failed_count.positive? %>
|
||||||
|
· <span class="text-rose-700"><%= mailing.failed_count %> errori</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<%= mailing_status_badge(mailing.status) %>
|
||||||
|
<% if mailing.draft? && mailing.tested? %>
|
||||||
|
<div class="mt-1 text-xs text-emerald-700 dark:text-emerald-300">Prova ok</div>
|
||||||
|
<% elsif mailing.draft? %>
|
||||||
|
<div class="mt-1 text-xs text-zinc-500">Manca prova</div>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-zinc-500">
|
||||||
|
<% if mailing.sending? && mailing.next_send_at.present? %>
|
||||||
|
<%= mailing.next_send_at > Time.current ? "Riparte #{l(mailing.next_send_at, format: :short)}" : "In invio" %>
|
||||||
|
<% else %>
|
||||||
|
<%= format_dt(mailing.created_at) %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="space-y-6">
|
<%= tag.div class: "space-y-6", data: (@mailing.sending? ? { controller: "auto-reload", auto_reload_interval_value: 10_000 } : {}) do %>
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight"><%= @mailing.name %></h1>
|
<h1 class="text-2xl font-semibold tracking-tight"><%= @mailing.name %></h1>
|
||||||
@@ -12,10 +12,21 @@
|
|||||||
<% if @mailing.interval_seconds.positive? %>
|
<% if @mailing.interval_seconds.positive? %>
|
||||||
· pausa <%= @mailing.interval_seconds %>s
|
· pausa <%= @mailing.interval_seconds %>s
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if @mailing.send_window_enabled? %>
|
||||||
|
· fascia <%= @mailing.send_window_label %>
|
||||||
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
|
<% if @mailing.sending? && @mailing.next_send_at.present? && @mailing.next_send_at > Time.current %>
|
||||||
|
<p class="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||||
|
In pausa fino a <%= l(@mailing.next_send_at, format: :long) %>
|
||||||
|
<% if @mailing.send_window_enabled? %>
|
||||||
|
(fuori fascia o giorno non lavorativo).
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<%= link_to "Tutti gli invii", mailings_path, class: btn_secondary %>
|
<%= link_to "Cruscotto invii", dashboard_mailings_path, class: btn_secondary %>
|
||||||
<% if @mailing.editable? %>
|
<% if @mailing.editable? %>
|
||||||
<%= link_to "Modifica contenuto", edit_mailing_path(@mailing), class: btn_secondary %>
|
<%= link_to "Modifica contenuto", edit_mailing_path(@mailing), class: btn_secondary %>
|
||||||
<%= button_to "Aggiorna lista", refresh_recipients_mailing_path(@mailing), method: :post, class: btn_secondary %>
|
<%= button_to "Aggiorna lista", refresh_recipients_mailing_path(@mailing), method: :post, class: btn_secondary %>
|
||||||
@@ -23,6 +34,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if @mailing.sending? || @mailing.sent? %>
|
||||||
|
<%= render "mailings/progress_card", mailing: @mailing %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-4">
|
<div class="grid gap-4 sm:grid-cols-4">
|
||||||
<div class="<%= card_class %> p-4">
|
<div class="<%= card_class %> p-4">
|
||||||
<div class="text-xs uppercase tracking-wide text-zinc-500">Da inviare</div>
|
<div class="text-xs uppercase tracking-wide text-zinc-500">Da inviare</div>
|
||||||
@@ -158,9 +173,22 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
|
<% if @mailing.draft? %>
|
||||||
|
<div class="<%= card_class %> space-y-3 p-4">
|
||||||
|
<h2 class="font-semibold">1. Prova con dati manuali</h2>
|
||||||
|
<p class="text-sm text-zinc-500">Compila i campi, controlla l’anteprima e manda <strong>una sola email subito</strong> a un indirizzo tuo. Non parte l’invio di massa.</p>
|
||||||
|
<% if @mailing.tested? %>
|
||||||
|
<p class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100">
|
||||||
|
Ultima prova inviata a <%= @mailing.test_sent_to %> il <%= l(@mailing.test_sent_at, format: :short) %>.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<%= render "mailings/test_send_form", mailing: @mailing %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="<%= card_class %> p-4">
|
<div class="<%= card_class %> p-4">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h2 class="font-semibold">Anteprima<%= " #{@preview_variant}" if @mailing.ab_test? %></h2>
|
<h2 class="font-semibold">Anteprima da lista<%= " #{@preview_variant}" if @mailing.ab_test? %></h2>
|
||||||
<% if @preview_recipient %>
|
<% if @preview_recipient %>
|
||||||
<span class="text-xs text-zinc-500"><%= @preview_recipient.organization.name %></span>
|
<span class="text-xs text-zinc-500"><%= @preview_recipient.organization.name %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -179,20 +207,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @mailing.draft? || @mailing.sending? %>
|
<% if @mailing.draft? %>
|
||||||
<div class="<%= card_class %> space-y-3 p-4">
|
<div class="<%= card_class %> space-y-3 p-4">
|
||||||
<% if @mailing.ab_test? %>
|
<h2 class="font-semibold">2. Autorizza l’invio di massa</h2>
|
||||||
<%= button_to "Invia prova A a me", test_send_mailing_path(@mailing, recipient_id: @preview_recipient&.id, variant: "A"), method: :post, class: btn_secondary %>
|
<% if @mailing.tested? && @mailing.pending_count.positive? %>
|
||||||
<%= button_to "Invia prova B a me", test_send_mailing_path(@mailing, recipient_id: @preview_recipient&.id, variant: "B"), method: :post, class: btn_secondary %>
|
<p class="text-sm text-zinc-500">Se l’output della prova è corretto, autorizza il job. Partirà rispettando pausa e fascia oraria.</p>
|
||||||
<% else %>
|
<%= button_to "Autorizza invio a #{@mailing.pending_count} destinatari", queue_mailing_path(@mailing), method: :post,
|
||||||
<%= button_to "Invia prova a me (#{current_user.email})", test_send_mailing_path(@mailing), method: :post, class: btn_secondary %>
|
|
||||||
<% end %>
|
|
||||||
<% if @mailing.pending_count.positive? %>
|
|
||||||
<%= button_to "Invia a #{@mailing.pending_count} destinatari", queue_mailing_path(@mailing), method: :post,
|
|
||||||
class: btn_primary,
|
class: btn_primary,
|
||||||
form: { data: { turbo_confirm: "Inviare #{@mailing.pending_count} email da #{@mailing.mail_identity.from_email}?" } } %>
|
form: { data: { turbo_confirm: @mailing.send_window_enabled? ? "Autorizzare l'invio di #{@mailing.pending_count} email da #{@mailing.mail_identity.from_email} nella fascia #{@mailing.send_window_label}?" : "Autorizzare l'invio di #{@mailing.pending_count} email da #{@mailing.mail_identity.from_email}?" } } %>
|
||||||
<% else %>
|
<% elsif @mailing.pending_count.zero? %>
|
||||||
<p class="text-sm text-zinc-500">Nessun destinatario da inviare. Spunta almeno un contatto con email valida.</p>
|
<p class="text-sm text-zinc-500">Nessun destinatario da inviare. Spunta almeno un contatto con email valida.</p>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-zinc-500">L’invio di massa resta bloccato finché non mandi una prova.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -204,4 +230,4 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<%= turbo_frame_tag "mailing_test_preview" do %>
|
||||||
|
<%= render "mailings/test_preview" %>
|
||||||
|
<% end %>
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<li><%= link_to "Gestione utenti", users_path, class: "hover:underline" %></li>
|
<li><%= link_to "Gestione utenti", users_path, class: "hover:underline" %></li>
|
||||||
<li><%= link_to "Account email / SMTP", mail_identities_path, class: "hover:underline" %></li>
|
<li><%= link_to "Account email / SMTP", mail_identities_path, class: "hover:underline" %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<li><%= link_to "Email e campagne", mailings_path, class: "hover:underline" %></li>
|
<li><%= link_to "Email e campagne", dashboard_mailings_path, class: "hover:underline" %></li>
|
||||||
<li><%= link_to "Import CSV organizzazioni", new_import_path, class: "hover:underline" %></li>
|
<li><%= link_to "Import CSV organizzazioni", new_import_path, class: "hover:underline" %></li>
|
||||||
<li><%= link_to "Cambio password", edit_password_path, class: "hover:underline" %></li>
|
<li><%= link_to "Cambio password", edit_password_path, class: "hover:underline" %></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Riprende gli invii in fascia oraria dopo attese lunghe (notte/weekend)
|
||||||
|
# o dopo un riavvio del processo. L'adapter :async non persiste i job.
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
next if Rails.env.test?
|
||||||
|
next unless defined?(Rails::Server) || ENV["MAILING_POLLER"] == "true"
|
||||||
|
|
||||||
|
Thread.new do
|
||||||
|
Rails.logger.info("[mailing-poller] avviato")
|
||||||
|
loop do
|
||||||
|
sleep 30
|
||||||
|
ResumeMailingsJob.perform_now
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[mailing-poller] #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -126,6 +126,9 @@ it:
|
|||||||
ab_test: Test A/B
|
ab_test: Test A/B
|
||||||
ab_assignment: Come assegnare A e B
|
ab_assignment: Come assegnare A e B
|
||||||
interval_seconds: Pausa tra un invio e l'altro (secondi)
|
interval_seconds: Pausa tra un invio e l'altro (secondi)
|
||||||
|
send_window_enabled: Invia solo in fascia oraria
|
||||||
|
send_window_start_tod: Dalle
|
||||||
|
send_window_end_tod: Alle
|
||||||
mail_identity: Account mittente
|
mail_identity: Account mittente
|
||||||
mail_template: Template A
|
mail_template: Template A
|
||||||
mail_template_b: Template B
|
mail_template_b: Template B
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ Rails.application.routes.draw do
|
|||||||
resources :mail_templates, except: %i[show]
|
resources :mail_templates, except: %i[show]
|
||||||
resources :mail_images, only: %i[create]
|
resources :mail_images, only: %i[create]
|
||||||
resources :mailings do
|
resources :mailings do
|
||||||
|
collection do
|
||||||
|
get :dashboard
|
||||||
|
end
|
||||||
member do
|
member do
|
||||||
post :queue
|
post :queue
|
||||||
post :test_send
|
post :test_send
|
||||||
|
get :test_preview
|
||||||
post :refresh_recipients
|
post :refresh_recipients
|
||||||
patch :update_recipients
|
patch :update_recipients
|
||||||
get :preview
|
get :preview
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
class AddSendWindowToMailings < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
change_table :mailings do |t|
|
||||||
|
t.boolean :send_window_enabled, default: false, null: false
|
||||||
|
t.integer :send_window_start_minutes
|
||||||
|
t.integer :send_window_end_minutes
|
||||||
|
t.datetime :next_send_at
|
||||||
|
end
|
||||||
|
add_index :mailings, [:status, :next_send_at]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class AddTestAuthorizationToMailings < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
change_table :mailings do |t|
|
||||||
|
t.datetime :test_sent_at
|
||||||
|
t.string :test_sent_to
|
||||||
|
t.datetime :authorized_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+9
-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_08_17_173000) do
|
ActiveRecord::Schema[8.1].define(version: 2026_08_17_234500) 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"
|
||||||
|
|
||||||
@@ -143,6 +143,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_17_173000) do
|
|||||||
t.string "ab_assignment", default: "from_record", null: false
|
t.string "ab_assignment", default: "from_record", null: false
|
||||||
t.boolean "ab_test", default: false, null: false
|
t.boolean "ab_test", default: false, null: false
|
||||||
t.string "audience", default: "to_send", null: false
|
t.string "audience", default: "to_send", null: false
|
||||||
|
t.datetime "authorized_at"
|
||||||
t.text "body_html", null: false
|
t.text "body_html", null: false
|
||||||
t.text "body_html_b"
|
t.text "body_html_b"
|
||||||
t.datetime "completed_at"
|
t.datetime "completed_at"
|
||||||
@@ -153,16 +154,23 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_17_173000) do
|
|||||||
t.bigint "mail_template_b_id"
|
t.bigint "mail_template_b_id"
|
||||||
t.bigint "mail_template_id"
|
t.bigint "mail_template_id"
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
|
t.datetime "next_send_at"
|
||||||
t.bigint "project_id", null: false
|
t.bigint "project_id", null: false
|
||||||
t.datetime "queued_at"
|
t.datetime "queued_at"
|
||||||
|
t.boolean "send_window_enabled", default: false, null: false
|
||||||
|
t.integer "send_window_end_minutes"
|
||||||
|
t.integer "send_window_start_minutes"
|
||||||
t.string "status", default: "draft", null: false
|
t.string "status", default: "draft", null: false
|
||||||
t.string "subject", null: false
|
t.string "subject", null: false
|
||||||
t.string "subject_b"
|
t.string "subject_b"
|
||||||
|
t.datetime "test_sent_at"
|
||||||
|
t.string "test_sent_to"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.bigint "updated_by_id"
|
t.bigint "updated_by_id"
|
||||||
t.index ["ab_test"], name: "index_mailings_on_ab_test"
|
t.index ["ab_test"], name: "index_mailings_on_ab_test"
|
||||||
t.index ["mail_identity_id"], name: "index_mailings_on_mail_identity_id"
|
t.index ["mail_identity_id"], name: "index_mailings_on_mail_identity_id"
|
||||||
t.index ["project_id"], name: "index_mailings_on_project_id"
|
t.index ["project_id"], name: "index_mailings_on_project_id"
|
||||||
|
t.index ["status", "next_send_at"], name: "index_mailings_on_status_and_next_send_at"
|
||||||
t.index ["status"], name: "index_mailings_on_status"
|
t.index ["status"], name: "index_mailings_on_status"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ services:
|
|||||||
RAILS_ALLOWED_HOSTS: ${RAILS_ALLOWED_HOSTS:-192.168.1.158,localhost}
|
RAILS_ALLOWED_HOSTS: ${RAILS_ALLOWED_HOSTS:-192.168.1.158,localhost}
|
||||||
RAILS_LOG_LEVEL: info
|
RAILS_LOG_LEVEL: info
|
||||||
TZ: Europe/Rome
|
TZ: Europe/Rome
|
||||||
|
MAILING_POLLER: "true"
|
||||||
volumes:
|
volumes:
|
||||||
- storage_data:/rails/storage
|
- storage_data:/rails/storage
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ services:
|
|||||||
APP_HOST: ${APP_HOST:-localhost}
|
APP_HOST: ${APP_HOST:-localhost}
|
||||||
APP_PORT: ${APP_PORT:-3000}
|
APP_PORT: ${APP_PORT:-3000}
|
||||||
TZ: Europe/Rome
|
TZ: Europe/Rome
|
||||||
|
MAILING_POLLER: "true"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/rails
|
- .:/rails
|
||||||
- bundle_cache:/usr/local/bundle
|
- bundle_cache:/usr/local/bundle
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
login_as users(:admin)
|
login_as users(:admin)
|
||||||
follow_redirect! if response.redirect?
|
follow_redirect! if response.redirect?
|
||||||
|
|
||||||
get mailings_path(project_code: @project.code)
|
get dashboard_mailings_path(project_code: @project.code)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_match(/Email/, response.body)
|
assert_match(/Invii email/, response.body)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creates a draft and builds recipients" do
|
test "creates a draft and builds recipients" do
|
||||||
@@ -48,18 +48,39 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_redirected_to mailing_path(mailing, project_code: @project.code)
|
assert_redirected_to mailing_path(mailing, project_code: @project.code)
|
||||||
assert_equal "pending", recipient.reload.status
|
assert_equal "pending", recipient.reload.status
|
||||||
|
|
||||||
|
post test_send_mailing_path(mailing, project_code: @project.code), params: {
|
||||||
|
to: users(:admin).email,
|
||||||
|
vars: { societa: "ASD Test Calcio", contatto_nome: "Mario" }
|
||||||
|
}
|
||||||
|
assert mailing.reload.tested?
|
||||||
|
|
||||||
perform_enqueued_jobs do
|
perform_enqueued_jobs do
|
||||||
post queue_mailing_path(mailing, project_code: @project.code)
|
post queue_mailing_path(mailing, project_code: @project.code)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
assert_redirected_to dashboard_mailings_path(project_code: @project.code)
|
||||||
assert_equal "sent", mailing.reload.status
|
assert_equal "sent", mailing.reload.status
|
||||||
assert_equal "sent", recipient.reload.status
|
assert_equal "sent", recipient.reload.status
|
||||||
assert_equal "sent", opportunities(:deal).reload.send_status
|
assert_equal "sent", opportunities(:deal).reload.send_status
|
||||||
assert_equal "contacted", opportunities(:deal).pipeline_stage
|
assert_equal "contacted", opportunities(:deal).pipeline_stage
|
||||||
assert_equal 1, ActionMailer::Base.deliveries.size
|
assert_equal 2, ActionMailer::Base.deliveries.size
|
||||||
assert_equal "Ciao ASD Test Calcio", ActionMailer::Base.deliveries.last.subject
|
assert_equal "Ciao ASD Test Calcio", ActionMailer::Base.deliveries.last.subject
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "queue send requires test email first" do
|
||||||
|
login_as users(:admin)
|
||||||
|
follow_redirect! if response.redirect?
|
||||||
|
mailing = create_mailing(identity: @identity, audience: "to_send")
|
||||||
|
mailing.rebuild_recipients!
|
||||||
|
recipient = mailing.mailing_recipients.first
|
||||||
|
patch update_recipients_mailing_path(mailing, project_code: @project.code), params: { pending_ids: [recipient.id] }
|
||||||
|
|
||||||
|
post queue_mailing_path(mailing, project_code: @project.code)
|
||||||
|
assert_redirected_to mailing_path(mailing, project_code: @project.code)
|
||||||
|
assert_equal "draft", mailing.reload.status
|
||||||
|
assert_not mailing.tested?
|
||||||
|
end
|
||||||
|
|
||||||
test "test send goes to current user" do
|
test "test send goes to current user" do
|
||||||
login_as users(:admin)
|
login_as users(:admin)
|
||||||
follow_redirect! if response.redirect?
|
follow_redirect! if response.redirect?
|
||||||
@@ -67,11 +88,17 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
mailing.rebuild_recipients!
|
mailing.rebuild_recipients!
|
||||||
|
|
||||||
assert_emails 1 do
|
assert_emails 1 do
|
||||||
post test_send_mailing_path(mailing, project_code: @project.code)
|
post test_send_mailing_path(mailing, project_code: @project.code), params: {
|
||||||
|
to: users(:admin).email,
|
||||||
|
vars: { societa: "Società Prova", contatto_nome: "Anna Test" }
|
||||||
|
}
|
||||||
end
|
end
|
||||||
mail = ActionMailer::Base.deliveries.last
|
mail = ActionMailer::Base.deliveries.last
|
||||||
assert_equal [users(:admin).email], mail.to
|
assert_equal [users(:admin).email], mail.to
|
||||||
assert_match(/\[TEST\]/, mail.subject)
|
assert_match(/\[TEST\]/, mail.subject)
|
||||||
|
assert_match(/Società Prova/, mail.subject)
|
||||||
|
assert mailing.reload.tested?
|
||||||
|
assert_equal users(:admin).email, mailing.test_sent_to
|
||||||
end
|
end
|
||||||
|
|
||||||
test "creates an A/B mailing and sends variant B as test" do
|
test "creates an A/B mailing and sends variant B as test" do
|
||||||
@@ -97,7 +124,11 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal "A", mailing.mailing_recipients.first.ab_variant
|
assert_equal "A", mailing.mailing_recipients.first.ab_variant
|
||||||
|
|
||||||
assert_emails 1 do
|
assert_emails 1 do
|
||||||
post test_send_mailing_path(mailing, project_code: @project.code, variant: "B")
|
post test_send_mailing_path(mailing, project_code: @project.code), params: {
|
||||||
|
to: users(:admin).email,
|
||||||
|
variant: "B",
|
||||||
|
vars: { societa: "ASD Test Calcio" }
|
||||||
|
}
|
||||||
end
|
end
|
||||||
mail = ActionMailer::Base.deliveries.last
|
mail = ActionMailer::Base.deliveries.last
|
||||||
assert_match(/\[TEST B\] Oggetto B ASD Test Calcio/, mail.subject)
|
assert_match(/\[TEST B\] Oggetto B ASD Test Calcio/, mail.subject)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class SendMailingRecipientJobTest < ActiveJob::TestCase
|
class SendMailingRecipientJobTest < ActiveJob::TestCase
|
||||||
|
include ActionMailer::TestHelper
|
||||||
test "sends pending recipients in sequence" do
|
test "sends pending recipients in sequence" do
|
||||||
opportunities(:deal).update!(send_status: "to_send")
|
opportunities(:deal).update!(send_status: "to_send")
|
||||||
mailing = create_mailing(audience: "to_send")
|
mailing = create_mailing(audience: "to_send")
|
||||||
mailing.rebuild_recipients!
|
mailing.rebuild_recipients!
|
||||||
mailing.update!(status: "sending", queued_at: Time.current)
|
mailing.update!(status: "sending", queued_at: Time.current, test_sent_at: Time.current, test_sent_to: "test@example.com")
|
||||||
|
|
||||||
perform_enqueued_jobs do
|
perform_enqueued_jobs do
|
||||||
SendMailingRecipientJob.perform_later(mailing.mailing_recipients.first.id)
|
SendMailingRecipientJob.perform_later(mailing.mailing_recipients.first.id)
|
||||||
@@ -14,4 +15,37 @@ class SendMailingRecipientJobTest < ActiveJob::TestCase
|
|||||||
assert_equal "sent", mailing.reload.status
|
assert_equal "sent", mailing.reload.status
|
||||||
assert_equal "sent", mailing.mailing_recipients.first.status
|
assert_equal "sent", mailing.mailing_recipients.first.status
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "does not send outside the working window and resumes next workday" do
|
||||||
|
opportunities(:deal).update!(send_status: "to_send")
|
||||||
|
mailing = create_mailing(
|
||||||
|
audience: "to_send",
|
||||||
|
send_window_enabled: true,
|
||||||
|
send_window_start_minutes: 10 * 60,
|
||||||
|
send_window_end_minutes: 18 * 60,
|
||||||
|
interval_seconds: 120
|
||||||
|
)
|
||||||
|
mailing.rebuild_recipients!
|
||||||
|
recipient = mailing.mailing_recipients.first
|
||||||
|
mailing.update!(test_sent_at: Time.current, test_sent_to: "test@example.com")
|
||||||
|
|
||||||
|
travel_to Time.zone.local(2026, 4, 3, 18, 30, 0) do
|
||||||
|
mailing.update!(test_sent_at: Time.current, test_sent_to: "test@example.com")
|
||||||
|
assert_no_emails do
|
||||||
|
mailing.queue_send!
|
||||||
|
end
|
||||||
|
assert_equal "sending", mailing.reload.status
|
||||||
|
assert_equal "pending", recipient.reload.status
|
||||||
|
assert_equal Time.zone.local(2026, 4, 7, 10, 0, 0), mailing.next_send_at
|
||||||
|
assert_enqueued_jobs 0, only: SendMailingRecipientJob
|
||||||
|
end
|
||||||
|
|
||||||
|
travel_to Time.zone.local(2026, 4, 7, 10, 0, 0) do
|
||||||
|
perform_enqueued_jobs do
|
||||||
|
ResumeMailingsJob.perform_now
|
||||||
|
end
|
||||||
|
assert_equal "sent", recipient.reload.status
|
||||||
|
assert_equal "sent", mailing.reload.status
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -33,4 +33,14 @@ class MailMergeTest < ActiveSupport::TestCase
|
|||||||
test "unknown tokens become empty string" do
|
test "unknown tokens become empty string" do
|
||||||
assert_equal "X Y", MailMerge.render("X {{sconosciuto}} Y")
|
assert_equal "X Y", MailMerge.render("X {{sconosciuto}} Y")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "extras override variables for manual test data" do
|
||||||
|
html = MailMerge.render(
|
||||||
|
"Ciao {{contatto_nome}} di {{societa}}",
|
||||||
|
organization: organizations(:acme),
|
||||||
|
project: projects(:matchlivetv),
|
||||||
|
extras: { societa: "Override SRL", contatto_nome: "Manual Test" }
|
||||||
|
)
|
||||||
|
assert_equal "Ciao Manual Test di Override SRL", html
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Mailings::SendClockTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@mailing = create_mailing(
|
||||||
|
send_window_enabled: true,
|
||||||
|
send_window_start_minutes: 10 * 60,
|
||||||
|
send_window_end_minutes: 18 * 60,
|
||||||
|
interval_seconds: 120
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is open inside weekday window" do
|
||||||
|
travel_to Time.zone.local(2026, 4, 1, 11, 0, 0) do
|
||||||
|
clock = Mailings::SendClock.new(@mailing)
|
||||||
|
assert clock.open?
|
||||||
|
assert_equal Time.zone.now, clock.next_send_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "waits until window start the same weekday" do
|
||||||
|
travel_to Time.zone.local(2026, 4, 1, 8, 30, 0) do
|
||||||
|
clock = Mailings::SendClock.new(@mailing)
|
||||||
|
assert_not clock.open?
|
||||||
|
assert_equal Time.zone.local(2026, 4, 1, 10, 0, 0), clock.next_open_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "after window end skips weekend and Italian holiday to next workday" do
|
||||||
|
# Venerdì 3 aprile 2026 18:30. Lunedi 6 è Pasquetta → martedì 7 alle 10:00.
|
||||||
|
travel_to Time.zone.local(2026, 4, 3, 18, 30, 0) do
|
||||||
|
clock = Mailings::SendClock.new(@mailing)
|
||||||
|
assert_not clock.open?
|
||||||
|
assert_equal Time.zone.local(2026, 4, 7, 10, 0, 0), clock.next_open_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "interval that would land after 18:00 moves to next workday" do
|
||||||
|
travel_to Time.zone.local(2026, 4, 1, 17, 59, 0) do
|
||||||
|
clock = Mailings::SendClock.new(@mailing)
|
||||||
|
at = clock.next_send_at(earliest: Time.current + 120.seconds)
|
||||||
|
assert_equal Time.zone.local(2026, 4, 2, 10, 0, 0), at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "disabled window sends anytime including weekend" do
|
||||||
|
@mailing.update!(send_window_enabled: false)
|
||||||
|
travel_to Time.zone.local(2026, 4, 4, 21, 0, 0) do
|
||||||
|
clock = Mailings::SendClock.new(@mailing)
|
||||||
|
assert clock.open?
|
||||||
|
assert_equal Time.zone.now, clock.next_send_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Italy::HolidaysTest < ActiveSupport::TestCase
|
||||||
|
test "computes Easter and Italian fixed holidays" do
|
||||||
|
assert_equal Date.new(2026, 4, 5), Italy::Holidays.easter_date(2026)
|
||||||
|
assert Italy::Holidays.holiday?(Date.new(2026, 4, 5))
|
||||||
|
assert Italy::Holidays.holiday?(Date.new(2026, 4, 6))
|
||||||
|
assert Italy::Holidays.holiday?(Date.new(2026, 8, 15))
|
||||||
|
assert_not Italy::Holidays.holiday?(Date.new(2026, 4, 7))
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user