class Mailing < ApplicationRecord include Auditable include HtmlBlankable belongs_to :project belongs_to :mail_identity belongs_to :mail_template, optional: true belongs_to :mail_template_b, class_name: "MailTemplate", optional: true has_many :mailing_recipients, dependent: :destroy has_many :organizations, through: :mailing_recipients has_many_attached :files validates :name, :subject, :body_html, presence: true validates :status, inclusion: { in: Catalog::MAILING_STATUSES.keys } validates :audience, inclusion: { in: Catalog::MAILING_AUDIENCES.keys } validates :ab_assignment, inclusion: { in: Catalog::MAILING_AB_ASSIGNMENTS.keys } validates :interval_seconds, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 86_400 } validates :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 before_validation :sync_audience_filters 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) end def audience_label parsed_audience_filters.summary_parts.join(" · ") end def parsed_audience_filters Mailings::AudienceFilters.new(audience_filters.presence || { "preset" => audience.presence || "to_send" }) end def audience_query Mailings::AudienceQuery.new(project, parsed_audience_filters, mailing: self) end def ab_assignment_label Catalog.label_for(Catalog::MAILING_AB_ASSIGNMENTS, ab_assignment) end def ab_from_record? ab_assignment == "from_record" end def subject_for(variant) ab_test? && variant.to_s.upcase == "B" ? subject_b : subject end def body_for(variant) ab_test? && variant.to_s.upcase == "B" ? body_html_b : body_html end def variant_pending_count(variant) mailing_recipients.pending.where(ab_variant: variant).count end def variant_sent_count(variant) mailing_recipients.sent.where(ab_variant: variant).count end def draft? status == "draft" end def sending? status == "sending" end def paused? status == "paused" end def sent? status == "sent" end def cancelled? status == "cancelled" end def editable? draft? end def pending_count mailing_recipients.pending.count end def skipped_count mailing_recipients.skipped.count end def sent_count mailing_recipients.sent.count end def failed_count mailing_recipients.failed.count end def opened_count mailing_recipients.opened.count end def variant_opened_count(variant) mailing_recipients.opened.where(ab_variant: variant).count end def open_rate total = sent_count return 0 if total.zero? ((opened_count * 100.0) / total).round 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, 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, next_send_at: nil) end def pause_send! raise "Solo un invio in corso può essere messo in pausa" unless sending? update!(status: "paused", next_send_at: nil) end def resume_send! raise "Solo un invio in pausa può essere ripreso" unless paused? raise "Nessun destinatario da inviare" if pending_count.zero? && queued_count.zero? update!(status: "sending") enqueue_next_recipient!(mailing_recipients.pending.order(:id).first, from: Time.current) if pending_count.positive? DrainOutboundJob.perform_later if mailing_recipients.queued.exists? end def cancel_send! raise "Solo un invio in corso o in pausa può essere annullato" unless sending? || paused? transaction do mailing_recipients.where(status: %w[pending queued]).find_each do |recipient| recipient.update!(status: "skipped", skip_reason: "invio annullato") end update!(status: "cancelled", next_send_at: nil, completed_at: Time.current) end end private def sync_audience_filters source = stringify_audience_filters(audience_filters) if will_save_change_to_audience? && !will_save_change_to_audience_filters? source["preset"] = audience elsif source["preset"].blank? source["preset"] = audience.presence || "to_send" end filters = Mailings::AudienceFilters.new(source) self.audience_filters = filters.to_h self.audience = filters.preset end def stringify_audience_filters(value) return {} if value.blank? value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h.deep_stringify_keys : value.to_h.deep_stringify_keys end 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