Aggiunge fascia oraria, prova manuale obbligatoria e cruscotto invii email.
CI / lint (push) Failing after 16m2s
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled

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:
2026-08-18 00:04:33 +02:00
co-authored by Cursor
parent 4ddf534a0c
commit 98dcdbbf82
33 changed files with 882 additions and 56 deletions
+75 -16
View File
@@ -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)
}
}
+28
View File
@@ -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
+13 -8
View File
@@ -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
+39
View File
@@ -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
View File
@@ -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}, lunven"
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
+4 -1
View File
@@ -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
+79
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module Mailings
TestRecipient = Struct.new(:mailing, :email, keyword_init: true)
end
+2 -2
View File
@@ -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 %>
+1 -1
View File
@@ -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>
+21 -1
View File
@@ -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 (lunven, esclusi i festivi italiani). Se la fascia finisce, linvio 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. Lemail 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 %>
+113
View File
@@ -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 linvio 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 %>
+41 -15
View File
@@ -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 lanteprima e manda <strong>una sola email subito</strong> a un indirizzo tuo. Non parte linvio 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 linvio di massa</h2>
<% if @mailing.tested? && @mailing.pending_count.positive? %>
<p class="text-sm text-zinc-500">Se loutput 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">Linvio di massa resta bloccato finché non mandi una prova.</p>
<% end %>
</div>
<% end %>
@@ -204,4 +230,4 @@
<% end %>
</section>
</div>
</div>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<%= turbo_frame_tag "mailing_test_preview" do %>
<%= render "mailings/test_preview" %>
<% end %>
+1 -1
View File
@@ -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>