diff --git a/app/controllers/mailings_controller.rb b/app/controllers/mailings_controller.rb index 5ee2b45..45fe9e4 100644 --- a/app/controllers/mailings_controller.rb +++ b/app/controllers/mailings_controller.rb @@ -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 diff --git a/app/javascript/controllers/auto_reload_controller.js b/app/javascript/controllers/auto_reload_controller.js new file mode 100644 index 0000000..5b05c0c --- /dev/null +++ b/app/javascript/controllers/auto_reload_controller.js @@ -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) + } +} diff --git a/app/javascript/controllers/send_window_controller.js b/app/javascript/controllers/send_window_controller.js new file mode 100644 index 0000000..a19f746 --- /dev/null +++ b/app/javascript/controllers/send_window_controller.js @@ -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)) + } +} diff --git a/app/javascript/controllers/test_mail_controller.js b/app/javascript/controllers/test_mail_controller.js new file mode 100644 index 0000000..4648f52 --- /dev/null +++ b/app/javascript/controllers/test_mail_controller.js @@ -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) + } +} diff --git a/app/jobs/resume_mailings_job.rb b/app/jobs/resume_mailings_job.rb new file mode 100644 index 0000000..ab3cc4d --- /dev/null +++ b/app/jobs/resume_mailings_job.rb @@ -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 diff --git a/app/jobs/send_mailing_recipient_job.rb b/app/jobs/send_mailing_recipient_job.rb index bb25164..295ebff 100644 --- a/app/jobs/send_mailing_recipient_job.rb +++ b/app/jobs/send_mailing_recipient_job.rb @@ -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 diff --git a/app/models/italy/holidays.rb b/app/models/italy/holidays.rb new file mode 100644 index 0000000..60f1b83 --- /dev/null +++ b/app/models/italy/holidays.rb @@ -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 diff --git a/app/models/mailing.rb b/app/models/mailing.rb index 4d4d238..3011f92 100644 --- a/app/models/mailing.rb +++ b/app/models/mailing.rb @@ -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 diff --git a/app/services/mail_merge.rb b/app/services/mail_merge.rb index 38b6e3f..8fa45b9 100644 --- a/app/services/mail_merge.rb +++ b/app/services/mail_merge.rb @@ -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 diff --git a/app/services/mailings/send_clock.rb b/app/services/mailings/send_clock.rb new file mode 100644 index 0000000..549b22e --- /dev/null +++ b/app/services/mailings/send_clock.rb @@ -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 diff --git a/app/services/mailings/test_recipient.rb b/app/services/mailings/test_recipient.rb new file mode 100644 index 0000000..e8fef28 --- /dev/null +++ b/app/services/mailings/test_recipient.rb @@ -0,0 +1,3 @@ +module Mailings + TestRecipient = Struct.new(:mailing, :email, keyword_init: true) +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index c09ea97..228d531 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -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 %> diff --git a/app/views/mail_templates/index.html.erb b/app/views/mail_templates/index.html.erb index 0171ff6..36f2bf0 100644 --- a/app/views/mail_templates/index.html.erb +++ b/app/views/mail_templates/index.html.erb @@ -3,7 +3,7 @@

Template email

- <%= link_to "Invii", mailings_path, class: "hover:underline" %> + <%= link_to "Invii", dashboard_mailings_path, class: "hover:underline" %> · HTML riutilizzabile con variabili della scheda cliente.

diff --git a/app/views/mailings/_form.html.erb b/app/views/mailings/_form.html.erb index c5e1490..5d3d4dd 100644 --- a/app/views/mailings/_form.html.erb +++ b/app/views/mailings/_form.html.erb @@ -32,6 +32,26 @@ +
+ +
" data-send-window-target="panel"> +
+ + <%= f.time_field :send_window_start_tod, include_seconds: false, step: 60, class: input_class %> +
+
+ + <%= f.time_field :send_window_end_tod, include_seconds: false, step: 60, class: input_class %> +
+
+
+
diff --git a/app/views/mailings/_progress_card.html.erb b/app/views/mailings/_progress_card.html.erb new file mode 100644 index 0000000..113e11d --- /dev/null +++ b/app/views/mailings/_progress_card.html.erb @@ -0,0 +1,33 @@ +
+
+
+ <%= link_to mailing.name, mailing, class: "font-semibold hover:underline" %> +

+ <%= 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 %> +

+
+ <%= mailing_status_badge(mailing.status) %> +
+
<%= progress_bar(mailing.progress_percentage, color: mailing.failed_count.positive? ? "bg-amber-500" : "bg-emerald-500") %>
+
+ <%= mailing.sent_count %> inviate + <%= mailing.pending_count %> in coda + <%= mailing.skipped_count %> escluse + <% if mailing.failed_count.positive? %> + <%= mailing.failed_count %> errori + <% end %> +
+ <% if mailing.next_send_at.present? && mailing.next_send_at > Time.current %> +

+ In pausa fino a <%= l(mailing.next_send_at, format: :long) %> +

+ <% elsif mailing.pending_count.positive? %> +

Prossima email in lavorazione.

+ <% end %> +
diff --git a/app/views/mailings/_test_preview.html.erb b/app/views/mailings/_test_preview.html.erb new file mode 100644 index 0000000..6a63344 --- /dev/null +++ b/app/views/mailings/_test_preview.html.erb @@ -0,0 +1,7 @@ +
+

<%= @test_preview_subject.presence || "(oggetto vuoto)" %>

+
+ <%= sanitize_email_html(@test_preview_html) %> +
+

Anteprima con i dati inseriti. L’email di prova parte subito, senza fascia oraria.

+
diff --git a/app/views/mailings/_test_send_form.html.erb b/app/views/mailings/_test_send_form.html.erb new file mode 100644 index 0000000..550ebda --- /dev/null +++ b/app/views/mailings/_test_send_form.html.erb @@ -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 %> +
+ + <%= email_field_tag :to, @test_to, required: true, class: input_class %> +
+ + <% if mailing.ab_test? %> +
+ + <%= select_tag :variant, options_for_select(%w[A B], @test_variant), class: input_class %> +
+ <% end %> + +
+ <% used.each do |token| %> +
+ + <%= text_field_tag "vars[#{token}]", @test_vars[token], class: input_class %> +
+ <% end %> +
+ + <% if extra.any? %> +
+ Altri campi di test +
+ <% extra.each do |token| %> +
+ + <%= text_field_tag "vars[#{token}]", @test_vars[token], class: input_class %> +
+ <% end %> +
+
+ <% 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 %> diff --git a/app/views/mailings/dashboard.html.erb b/app/views/mailings/dashboard.html.erb new file mode 100644 index 0000000..80c7406 --- /dev/null +++ b/app/views/mailings/dashboard.html.erb @@ -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 %> +
+
+

Invii email

+

Stato delle campagne, prove e invii di massa scaglionati.

+
+
+ <%= 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 %> +
+
+ + <% if @mail_identities_count.zero? %> +
+ 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 %> +
+ <% end %> + +
+
+
In corso
+
<%= @active_mailings.size %>
+
+
+
In pausa (fascia)
+
<%= @paused_mailings.size %>
+
+
+
Completati oggi
+
<%= @completed_today %>
+
+
+
Errori aperti
+
<%= @failed_open %>
+
+
+ + <% if @active_mailings.any? %> +
+

Campagne in corso

+
+ <% @active_mailings.each do |mailing| %> + <%= render "mailings/progress_card", mailing: mailing %> + <% end %> +
+

Questa pagina si aggiorna da sola ogni 10 secondi mentre c’è un invio attivo.

+
+ <% end %> + +
+ <% if @mailings.empty? %> +

Nessun invio. Crea una bozza, fai una prova con dati manuali e poi autorizza l’invio di massa.

+ <% else %> + + + + + + + + + + + <% @mailings.each do |mailing| %> + + + + + + + <% end %> + +
InvioAvanzamentoStatoProssimo / creato
+ <%= link_to mailing.name, mailing, class: "font-medium hover:underline" %> +
<%= mailing.mail_identity.from_email %>
+ <% if mailing.ab_test? %> + <%= ab_variant_badge("A") %><%= ab_variant_badge("B") %> + <% end %> +
+ <%= progress_bar(mailing.progress_percentage) %> +
+ <%= mailing.sent_count %> inviate + · <%= mailing.pending_count %> in coda + <% if mailing.failed_count.positive? %> + · <%= mailing.failed_count %> errori + <% end %> +
+
+ <%= mailing_status_badge(mailing.status) %> + <% if mailing.draft? && mailing.tested? %> +
Prova ok
+ <% elsif mailing.draft? %> +
Manca prova
+ <% end %> +
+ <% 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 %> +
+ <% end %> +
+<% end %> diff --git a/app/views/mailings/show.html.erb b/app/views/mailings/show.html.erb index 7846951..e8ef0f7 100644 --- a/app/views/mailings/show.html.erb +++ b/app/views/mailings/show.html.erb @@ -1,4 +1,4 @@ -
+<%= tag.div class: "space-y-6", data: (@mailing.sending? ? { controller: "auto-reload", auto_reload_interval_value: 10_000 } : {}) do %>

<%= @mailing.name %>

@@ -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 %>

+ <% if @mailing.sending? && @mailing.next_send_at.present? && @mailing.next_send_at > Time.current %> +

+ In pausa fino a <%= l(@mailing.next_send_at, format: :long) %> + <% if @mailing.send_window_enabled? %> + (fuori fascia o giorno non lavorativo). + <% end %> +

+ <% end %>
- <%= 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 @@
+ <% if @mailing.sending? || @mailing.sent? %> + <%= render "mailings/progress_card", mailing: @mailing %> + <% end %> +
Da inviare
@@ -158,9 +173,22 @@
+ <% if @mailing.draft? %> +
+

1. Prova con dati manuali

+

Compila i campi, controlla l’anteprima e manda una sola email subito a un indirizzo tuo. Non parte l’invio di massa.

+ <% if @mailing.tested? %> +

+ Ultima prova inviata a <%= @mailing.test_sent_to %> il <%= l(@mailing.test_sent_at, format: :short) %>. +

+ <% end %> + <%= render "mailings/test_send_form", mailing: @mailing %> +
+ <% end %> +
-

Anteprima<%= " #{@preview_variant}" if @mailing.ab_test? %>

+

Anteprima da lista<%= " #{@preview_variant}" if @mailing.ab_test? %>

<% if @preview_recipient %> <%= @preview_recipient.organization.name %> <% end %> @@ -179,20 +207,18 @@
- <% if @mailing.draft? || @mailing.sending? %> + <% if @mailing.draft? %>
- <% 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, +

2. Autorizza l’invio di massa

+ <% if @mailing.tested? && @mailing.pending_count.positive? %> +

Se l’output della prova è corretto, autorizza il job. Partirà rispettando pausa e fascia oraria.

+ <%= 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? %>

Nessun destinatario da inviare. Spunta almeno un contatto con email valida.

+ <% else %> +

L’invio di massa resta bloccato finché non mandi una prova.

<% end %>
<% end %> @@ -204,4 +230,4 @@ <% end %>
-
+<% end %> diff --git a/app/views/mailings/test_preview.html.erb b/app/views/mailings/test_preview.html.erb new file mode 100644 index 0000000..d373174 --- /dev/null +++ b/app/views/mailings/test_preview.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag "mailing_test_preview" do %> + <%= render "mailings/test_preview" %> +<% end %> diff --git a/app/views/settings/show.html.erb b/app/views/settings/show.html.erb index 5dd622b..c640a13 100644 --- a/app/views/settings/show.html.erb +++ b/app/views/settings/show.html.erb @@ -64,7 +64,7 @@
  • <%= link_to "Gestione utenti", users_path, class: "hover:underline" %>
  • <%= link_to "Account email / SMTP", mail_identities_path, class: "hover:underline" %>
  • <% end %> -
  • <%= link_to "Email e campagne", mailings_path, class: "hover:underline" %>
  • +
  • <%= link_to "Email e campagne", dashboard_mailings_path, class: "hover:underline" %>
  • <%= link_to "Import CSV organizzazioni", new_import_path, class: "hover:underline" %>
  • <%= link_to "Cambio password", edit_password_path, class: "hover:underline" %>
  • diff --git a/config/initializers/mailing_poller.rb b/config/initializers/mailing_poller.rb new file mode 100644 index 0000000..a6c9e36 --- /dev/null +++ b/config/initializers/mailing_poller.rb @@ -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 diff --git a/config/locales/it.yml b/config/locales/it.yml index 91c2dbd..70f2cdb 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 9be75bb..e3c4365 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260817233000_add_send_window_to_mailings.rb b/db/migrate/20260817233000_add_send_window_to_mailings.rb new file mode 100644 index 0000000..1eef10e --- /dev/null +++ b/db/migrate/20260817233000_add_send_window_to_mailings.rb @@ -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 diff --git a/db/migrate/20260817234500_add_test_authorization_to_mailings.rb b/db/migrate/20260817234500_add_test_authorization_to_mailings.rb new file mode 100644 index 0000000..5fd12a2 --- /dev/null +++ b/db/migrate/20260817234500_add_test_authorization_to_mailings.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 2a38e46..37538f9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index f8824fe..5f5aa0a 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 81cbe6a..6c33da2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/test/controllers/mailings_controller_test.rb b/test/controllers/mailings_controller_test.rb index 62e536d..8488f95 100644 --- a/test/controllers/mailings_controller_test.rb +++ b/test/controllers/mailings_controller_test.rb @@ -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) diff --git a/test/jobs/send_mailing_recipient_job_test.rb b/test/jobs/send_mailing_recipient_job_test.rb index e582d6d..df0c8d2 100644 --- a/test/jobs/send_mailing_recipient_job_test.rb +++ b/test/jobs/send_mailing_recipient_job_test.rb @@ -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 diff --git a/test/services/mail_merge_test.rb b/test/services/mail_merge_test.rb index accae58..4115949 100644 --- a/test/services/mail_merge_test.rb +++ b/test/services/mail_merge_test.rb @@ -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 diff --git a/test/services/mailings/send_clock_test.rb b/test/services/mailings/send_clock_test.rb new file mode 100644 index 0000000..7bda7f7 --- /dev/null +++ b/test/services/mailings/send_clock_test.rb @@ -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