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
|
||||
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]
|
||||
|
||||
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
|
||||
@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
|
||||
|
||||
def new
|
||||
@@ -19,6 +27,8 @@ class MailingsController < ApplicationController
|
||||
mail_identity: identity,
|
||||
audience: "to_send",
|
||||
interval_seconds: 0,
|
||||
send_window_start_minutes: 10 * 60,
|
||||
send_window_end_minutes: 18 * 60,
|
||||
ab_assignment: "from_record",
|
||||
name: "Invio #{l(Time.zone.today)}",
|
||||
subject: template&.subject,
|
||||
@@ -47,6 +57,7 @@ class MailingsController < ApplicationController
|
||||
context = @preview_recipient&.merge_context || { project: current_project, ab_variant: @preview_variant }
|
||||
@preview_html = MailMerge.render(@mailing.body_for(@preview_variant), **context)
|
||||
@preview_subject = MailMerge.render(@mailing.subject_for(@preview_variant), **context)
|
||||
assign_test_preview
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -76,12 +87,12 @@ class MailingsController < ApplicationController
|
||||
|
||||
def destroy
|
||||
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
|
||||
end
|
||||
|
||||
@mailing.destroy!
|
||||
redirect_to mailings_path, notice: "Invio eliminato."
|
||||
redirect_to dashboard_mailings_path, notice: "Invio eliminato."
|
||||
end
|
||||
|
||||
def refresh_recipients
|
||||
@@ -117,31 +128,39 @@ class MailingsController < ApplicationController
|
||||
redirect_to @mailing, alert: "Questo invio è già stato concluso."
|
||||
return
|
||||
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!
|
||||
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
|
||||
redirect_to @mailing, alert: e.message
|
||||
end
|
||||
|
||||
def test_send
|
||||
recipient = preview_recipient
|
||||
unless recipient&.email_ok?
|
||||
redirect_to @mailing, alert: "Nessun destinatario valido da usare come dati di prova."
|
||||
assign_test_preview
|
||||
to = @test_to.to_s.strip
|
||||
unless to.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
redirect_to @mailing, alert: "Inserisci un indirizzo email valido per la prova."
|
||||
return
|
||||
end
|
||||
|
||||
variant = preview_variant
|
||||
html = MailMerge.render(@mailing.body_for(variant), **recipient.merge_context.merge(ab_variant: variant))
|
||||
subject = "[TEST#{@mailing.ab_test? ? " #{variant}" : ""}] #{MailMerge.render(@mailing.subject_for(variant), **recipient.merge_context.merge(ab_variant: variant))}"
|
||||
test_recipient = recipient.dup
|
||||
test_recipient.email = current_user.email
|
||||
CampaignMailer.outreach(test_recipient, html: html, subject: subject).deliver_now
|
||||
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}."
|
||||
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
|
||||
@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
|
||||
redirect_to @mailing, alert: "Invio di prova non riuscito: #{e.message}"
|
||||
end
|
||||
|
||||
def test_preview
|
||||
assign_test_preview
|
||||
end
|
||||
|
||||
def preview
|
||||
redirect_to mailing_path(@mailing, recipient_id: params[:recipient_id])
|
||||
end
|
||||
@@ -161,10 +180,19 @@ class MailingsController < ApplicationController
|
||||
params.require(:mailing).permit(
|
||||
:name, :mail_identity_id, :mail_template_id, :mail_template_b_id, :audience,
|
||||
: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
|
||||
|
||||
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
|
||||
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)
|
||||
@@ -202,4 +230,35 @@ class MailingsController < ApplicationController
|
||||
|
||||
preview_recipient&.ab_variant.presence_in(%w[A B]) || "A"
|
||||
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
|
||||
|
||||
@@ -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 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
|
||||
CampaignMailer.raise_delivery_errors = true
|
||||
recipient.deliver!
|
||||
@@ -17,13 +29,6 @@ class SendMailingRecipientJob < ApplicationJob
|
||||
end
|
||||
|
||||
nxt = mailing.mailing_recipients.pending.order(:id).first
|
||||
return if nxt.nil?
|
||||
|
||||
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
|
||||
mailing.enqueue_next_recipient!(nxt, from: Time.current + mailing.interval_seconds.seconds)
|
||||
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 :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 :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
|
||||
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
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
|
||||
Catalog.label_for(Catalog::MAILING_STATUSES, status)
|
||||
@@ -85,23 +92,114 @@ class Mailing < ApplicationRecord
|
||||
mailing_recipients.failed.count
|
||||
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!
|
||||
mailing_recipients.delete_all
|
||||
Mailings::RecipientBuilder.new(self).call
|
||||
end
|
||||
|
||||
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?
|
||||
|
||||
update!(status: "sending", queued_at: Time.current)
|
||||
first = mailing_recipients.pending.order(:id).first
|
||||
SendMailingRecipientJob.perform_later(first.id)
|
||||
update!(status: "sending", queued_at: Time.current, authorized_at: authorized_at || Time.current)
|
||||
enqueue_next_recipient!(mailing_recipients.pending.order(:id).first, from: Time.current)
|
||||
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
|
||||
|
||||
def mark_finished_if_done!
|
||||
return unless sending?
|
||||
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
|
||||
|
||||
@@ -51,8 +51,11 @@ module MailMerge
|
||||
}.transform_values { |value| value.to_s }
|
||||
end
|
||||
|
||||
def render(template, **context)
|
||||
def render(template, extras: {}, **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
|
||||
key = Regexp.last_match(1).to_s.downcase
|
||||
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 "Pipeline", pipeline_path %>
|
||||
<%= nav_link "Task", tasks_path %>
|
||||
<%= nav_link "Email", mailings_path %>
|
||||
<%= nav_link "Email", dashboard_mailings_path %>
|
||||
<%= nav_link "Report", reports_path %>
|
||||
<%= nav_link "Impostazioni", settings_path %>
|
||||
<% 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 "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 "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? %>
|
||||
<%= 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 %>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Template email</h1>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,26 @@
|
||||
</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">
|
||||
<label class="flex items-start gap-2 text-sm">
|
||||
<%= 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">
|
||||
<%= 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
<h1 class="text-2xl font-semibold tracking-tight"><%= @mailing.name %></h1>
|
||||
@@ -12,10 +12,21 @@
|
||||
<% if @mailing.interval_seconds.positive? %>
|
||||
· pausa <%= @mailing.interval_seconds %>s
|
||||
<% end %>
|
||||
<% if @mailing.send_window_enabled? %>
|
||||
· fascia <%= @mailing.send_window_label %>
|
||||
<% end %>
|
||||
</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 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? %>
|
||||
<%= 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 %>
|
||||
@@ -23,6 +34,10 @@
|
||||
</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="<%= card_class %> p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-zinc-500">Da inviare</div>
|
||||
@@ -158,9 +173,22 @@
|
||||
</section>
|
||||
|
||||
<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="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 %>
|
||||
<span class="text-xs text-zinc-500"><%= @preview_recipient.organization.name %></span>
|
||||
<% end %>
|
||||
@@ -179,20 +207,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @mailing.draft? || @mailing.sending? %>
|
||||
<% if @mailing.draft? %>
|
||||
<div class="<%= card_class %> space-y-3 p-4">
|
||||
<% if @mailing.ab_test? %>
|
||||
<%= button_to "Invia prova A a me", test_send_mailing_path(@mailing, recipient_id: @preview_recipient&.id, variant: "A"), method: :post, class: btn_secondary %>
|
||||
<%= button_to "Invia prova B a me", test_send_mailing_path(@mailing, recipient_id: @preview_recipient&.id, variant: "B"), method: :post, class: btn_secondary %>
|
||||
<% else %>
|
||||
<%= 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,
|
||||
<h2 class="font-semibold">2. Autorizza l’invio di massa</h2>
|
||||
<% if @mailing.tested? && @mailing.pending_count.positive? %>
|
||||
<p class="text-sm text-zinc-500">Se l’output della prova è corretto, autorizza il job. Partirà rispettando pausa e fascia oraria.</p>
|
||||
<%= button_to "Autorizza invio a #{@mailing.pending_count} destinatari", queue_mailing_path(@mailing), method: :post,
|
||||
class: btn_primary,
|
||||
form: { data: { turbo_confirm: "Inviare #{@mailing.pending_count} email da #{@mailing.mail_identity.from_email}?" } } %>
|
||||
<% else %>
|
||||
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}?" } } %>
|
||||
<% elsif @mailing.pending_count.zero? %>
|
||||
<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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -204,4 +230,4 @@
|
||||
<% end %>
|
||||
</section>
|
||||
</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 "Account email / SMTP", mail_identities_path, class: "hover:underline" %></li>
|
||||
<% 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 "Cambio password", edit_password_path, class: "hover:underline" %></li>
|
||||
</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_assignment: Come assegnare A e B
|
||||
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_template: Template A
|
||||
mail_template_b: Template B
|
||||
|
||||
@@ -50,9 +50,13 @@ Rails.application.routes.draw do
|
||||
resources :mail_templates, except: %i[show]
|
||||
resources :mail_images, only: %i[create]
|
||||
resources :mailings do
|
||||
collection do
|
||||
get :dashboard
|
||||
end
|
||||
member do
|
||||
post :queue
|
||||
post :test_send
|
||||
get :test_preview
|
||||
post :refresh_recipients
|
||||
patch :update_recipients
|
||||
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.
|
||||
|
||||
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
|
||||
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.boolean "ab_test", default: false, null: false
|
||||
t.string "audience", default: "to_send", null: false
|
||||
t.datetime "authorized_at"
|
||||
t.text "body_html", null: false
|
||||
t.text "body_html_b"
|
||||
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_id"
|
||||
t.string "name", null: false
|
||||
t.datetime "next_send_at"
|
||||
t.bigint "project_id", null: false
|
||||
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 "subject", null: false
|
||||
t.string "subject_b"
|
||||
t.datetime "test_sent_at"
|
||||
t.string "test_sent_to"
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "updated_by_id"
|
||||
t.index ["ab_test"], name: "index_mailings_on_ab_test"
|
||||
t.index ["mail_identity_id"], name: "index_mailings_on_mail_identity_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"
|
||||
end
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ services:
|
||||
RAILS_ALLOWED_HOSTS: ${RAILS_ALLOWED_HOSTS:-192.168.1.158,localhost}
|
||||
RAILS_LOG_LEVEL: info
|
||||
TZ: Europe/Rome
|
||||
MAILING_POLLER: "true"
|
||||
volumes:
|
||||
- storage_data:/rails/storage
|
||||
networks:
|
||||
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
APP_HOST: ${APP_HOST:-localhost}
|
||||
APP_PORT: ${APP_PORT:-3000}
|
||||
TZ: Europe/Rome
|
||||
MAILING_POLLER: "true"
|
||||
volumes:
|
||||
- .:/rails
|
||||
- bundle_cache:/usr/local/bundle
|
||||
|
||||
@@ -11,9 +11,9 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
get mailings_path(project_code: @project.code)
|
||||
get dashboard_mailings_path(project_code: @project.code)
|
||||
assert_response :success
|
||||
assert_match(/Email/, response.body)
|
||||
assert_match(/Invii email/, response.body)
|
||||
end
|
||||
|
||||
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_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
|
||||
post queue_mailing_path(mailing, project_code: @project.code)
|
||||
end
|
||||
|
||||
assert_redirected_to dashboard_mailings_path(project_code: @project.code)
|
||||
assert_equal "sent", mailing.reload.status
|
||||
assert_equal "sent", recipient.reload.status
|
||||
assert_equal "sent", opportunities(:deal).reload.send_status
|
||||
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
|
||||
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
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
@@ -67,11 +88,17 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest
|
||||
mailing.rebuild_recipients!
|
||||
|
||||
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
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
assert_equal [users(:admin).email], mail.to
|
||||
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
|
||||
|
||||
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_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
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
assert_match(/\[TEST B\] Oggetto B ASD Test Calcio/, mail.subject)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
require "test_helper"
|
||||
|
||||
class SendMailingRecipientJobTest < ActiveJob::TestCase
|
||||
include ActionMailer::TestHelper
|
||||
test "sends pending recipients in sequence" do
|
||||
opportunities(:deal).update!(send_status: "to_send")
|
||||
mailing = create_mailing(audience: "to_send")
|
||||
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
|
||||
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.mailing_recipients.first.status
|
||||
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
|
||||
|
||||
@@ -33,4 +33,14 @@ class MailMergeTest < ActiveSupport::TestCase
|
||||
test "unknown tokens become empty string" do
|
||||
assert_equal "X Y", MailMerge.render("X {{sconosciuto}} Y")
|
||||
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
|
||||
|
||||
@@ -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