diff --git a/app/controllers/mail_opens_controller.rb b/app/controllers/mail_opens_controller.rb new file mode 100644 index 0000000..d4b4a38 --- /dev/null +++ b/app/controllers/mail_opens_controller.rb @@ -0,0 +1,24 @@ +require "base64" + +class MailOpensController < ActionController::Base + PIXEL = Base64.decode64("R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==").freeze + + def show + record_open + expires_now + response.set_header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0") + response.set_header("Pragma", "no-cache") + send_data PIXEL, type: "image/gif", disposition: "inline", filename: "o.gif" + end + + private + + def record_open + token = params[:token].to_s + recipient = MailingRecipient.find_by(tracking_token: token) + recipient&.record_open! + rescue StandardError => e + Rails.logger.warn("[mail_open] #{e.class}: #{e.message}") + raise if Rails.env.test? + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 50725bf..cbd0c82 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -176,6 +176,19 @@ module ApplicationHelper class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[status] || 'bg-zinc-100 text-zinc-700'}" end + def mailing_open_badge(recipient) + return unless recipient.status == "sent" + + if recipient.opened? + content_tag :span, "Aperta", + class: "inline-flex items-center rounded-md bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-800 dark:bg-violet-950 dark:text-violet-200", + title: "Prima apertura #{format_dt(recipient.opened_at)}" + else + content_tag :span, "Non aperta", + class: "inline-flex items-center rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300" + end + end + def ab_variant_badge(variant) return if variant.blank? diff --git a/app/mailers/campaign_mailer.rb b/app/mailers/campaign_mailer.rb index a3405d4..99da984 100644 --- a/app/mailers/campaign_mailer.rb +++ b/app/mailers/campaign_mailer.rb @@ -5,6 +5,8 @@ class CampaignMailer < ApplicationMailer mailing = recipient.mailing identity = mailing.mail_identity html = Mailings::InlineImages.call(self, html) + html = wrap_html(html) + html = Mailings::OpenPixel.call(html, recipient) mailing.files.each do |file| attachments[file.filename.to_s] = { @@ -20,7 +22,7 @@ class CampaignMailer < ApplicationMailer subject: subject, delivery_method_options: identity.smtp_settings ) do |format| - format.html { render html: wrap_html(html).html_safe } + format.html { render html: html.html_safe } end end diff --git a/app/models/catalog.rb b/app/models/catalog.rb index e2d0c25..60394c3 100644 --- a/app/models/catalog.rb +++ b/app/models/catalog.rb @@ -124,6 +124,7 @@ module Catalog ACTIVITY_TYPES = { "note" => "Nota", "email_sent" => "Email inviata", + "email_opened" => "Email aperta", "email_received" => "Email ricevuta", "call" => "Telefonata", "meeting" => "Meeting", diff --git a/app/models/mailing.rb b/app/models/mailing.rb index 18ff949..23846c8 100644 --- a/app/models/mailing.rb +++ b/app/models/mailing.rb @@ -96,6 +96,21 @@ class Mailing < ApplicationRecord 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 diff --git a/app/models/mailing_recipient.rb b/app/models/mailing_recipient.rb index eae4933..3272f3b 100644 --- a/app/models/mailing_recipient.rb +++ b/app/models/mailing_recipient.rb @@ -5,12 +5,16 @@ class MailingRecipient < ApplicationRecord validates :status, inclusion: { in: Catalog::MAILING_RECIPIENT_STATUSES.keys } validates :ab_variant, inclusion: { in: Catalog::AB_VARIANTS.keys }, allow_blank: true + validates :tracking_token, presence: true, uniqueness: true + + before_validation :assign_tracking_token, on: :create scope :pending, -> { where(status: "pending") } scope :skipped, -> { where(status: "skipped") } scope :sent, -> { where(status: "sent") } scope :failed, -> { where(status: "failed") } scope :queued, -> { where(status: "queued") } + scope :opened, -> { where.not(opened_at: nil) } scope :ordered, -> { joins(:organization).order("organizations.list_position ASC NULLS LAST", "organizations.name ASC") } def status_label @@ -21,10 +25,33 @@ class MailingRecipient < ApplicationRecord status == "pending" end + def sent? + status == "sent" + end + def email_ok? email.present? && email.match?(URI::MailTo::EMAIL_REGEXP) end + def opened? + opened_at.present? + end + + def record_open!(at: Time.current) + return unless sent? + + first_open = false + with_lock do + return unless sent? + + first_open = opened_at.blank? + attrs = { last_opened_at: at, open_count: open_count + 1 } + attrs[:opened_at] = at if first_open + update!(attrs) + end + log_open_activity! if first_open + end + def merge_context opportunity = organization.campaign_opportunity(mailing.project) { @@ -90,6 +117,29 @@ class MailingRecipient < ApplicationRecord opportunity.update!(attrs) if attrs.any? end + def assign_tracking_token + self.tracking_token ||= self.class.generate_tracking_token + end + + def self.generate_tracking_token + loop do + token = SecureRandom.urlsafe_base64(24) + break token unless exists?(tracking_token: token) + end + end + + def log_open_activity! + organization.activities.create!( + activity_type: "email_opened", + subject: rendered_subject.presence || mailing.subject_for(ab_variant), + description: "Apertura rilevata · campagna #{mailing.name}", + happened_at: opened_at || Time.current, + user: mailing.created_by, + contact: contact, + opportunity: organization.campaign_opportunity(mailing.project) + ) + end + def log_activity!(subject_line) variant_note = "variante #{ab_variant}" if mailing.ab_test? && ab_variant.present? organization.activities.create!( diff --git a/app/services/mailings/open_pixel.rb b/app/services/mailings/open_pixel.rb new file mode 100644 index 0000000..d39e487 --- /dev/null +++ b/app/services/mailings/open_pixel.rb @@ -0,0 +1,36 @@ +module Mailings + class OpenPixel + def self.call(html, recipient) + new(html, recipient).call + end + + def initialize(html, recipient) + @html = html.to_s + @recipient = recipient + end + + def call + return @html unless trackable? + + url = tracking_url + return @html if url.blank? + + tag = %() + if @html.match?(/<\/body>/i) + @html.sub(/<\/body>/i, "#{tag}") + else + "#{@html}#{tag}" + end + end + + private + + def trackable? + @recipient.respond_to?(:tracking_token) && @recipient.tracking_token.present? + end + + def tracking_url + Rails.application.routes.url_helpers.mail_open_url(@recipient.tracking_token, **PublicUrl.options) + end + end +end diff --git a/app/services/mailings/public_url.rb b/app/services/mailings/public_url.rb new file mode 100644 index 0000000..58c8b6c --- /dev/null +++ b/app/services/mailings/public_url.rb @@ -0,0 +1,24 @@ +module Mailings + module PublicUrl + module_function + + def options + opts = Rails.application.config.action_mailer.default_url_options.to_h.symbolize_keys + opts[:protocol] ||= protocol + port = opts[:port].to_i + opts.delete(:port) if port.zero? || default_port?(port) + opts + end + + def protocol + return "https" if Rails.application.config.force_ssl + return "https" if ENV["ASSUME_SSL"] == "true" + + "http" + end + + def default_port?(port) + (protocol == "https" && port == 443) || (protocol == "http" && port == 80) + end + end +end diff --git a/app/views/mailings/_progress_card.html.erb b/app/views/mailings/_progress_card.html.erb index 9d6f212..4146a40 100644 --- a/app/views/mailings/_progress_card.html.erb +++ b/app/views/mailings/_progress_card.html.erb @@ -17,6 +17,7 @@
<%= progress_bar(mailing.progress_percentage, color: mailing.failed_count.positive? ? "bg-amber-500" : "bg-emerald-500") %>
<%= mailing.sent_count %> inviate + <%= mailing.opened_count %> aperte <%= mailing.pending_count %> in coda <%= mailing.skipped_count %> escluse <% if mailing.failed_count.positive? %> diff --git a/app/views/mailings/dashboard.html.erb b/app/views/mailings/dashboard.html.erb index 479f760..c8944cc 100644 --- a/app/views/mailings/dashboard.html.erb +++ b/app/views/mailings/dashboard.html.erb @@ -73,7 +73,7 @@
<%= progress_bar(mailing.progress_percentage) %>
- <%= mailing.sent_count %> inviate · <%= mailing.pending_count %> in coda + <%= mailing.sent_count %> inviate · <%= mailing.opened_count %> aperte · <%= mailing.pending_count %> in coda <% if mailing.sending? && mailing.next_send_at.present? && mailing.next_send_at > Time.current %> · Riparte <%= l(mailing.next_send_at, format: :short) %> <% end %> @@ -106,6 +106,7 @@ <%= progress_bar(mailing.progress_percentage) %>
<%= mailing.sent_count %> inviate + · <%= mailing.opened_count %> aperte · <%= mailing.pending_count %> in coda <% if mailing.failed_count.positive? %> · <%= mailing.failed_count %> errori diff --git a/app/views/mailings/show.html.erb b/app/views/mailings/show.html.erb index f74afdd..b3935ca 100644 --- a/app/views/mailings/show.html.erb +++ b/app/views/mailings/show.html.erb @@ -62,6 +62,21 @@
+ <% if @mailing.sending? || @mailing.sent? || @mailing.cancelled? %> +
+
+
Aperte
+
<%= @mailing.opened_count %>
+

su <%= @mailing.sent_count %> inviate

+
+
+
Tasso apertura
+
<%= @mailing.open_rate %>%
+

Si rileva quando il client carica le immagini.

+
+
+ <% end %> + <% if @mailing.ab_test? %>
@@ -70,7 +85,11 @@ <%= ab_variant_badge("A") %>
<%= @mailing.variant_sent_count("A") %> inviate
-

<%= @mailing.variant_pending_count("A") %> in coda · <%= @mailing.subject %>

+

+ <%= @mailing.variant_opened_count("A") %> aperte + · <%= @mailing.variant_pending_count("A") %> in coda + · <%= @mailing.subject %> +

@@ -78,7 +97,11 @@ <%= ab_variant_badge("B") %>
<%= @mailing.variant_sent_count("B") %> inviate
-

<%= @mailing.variant_pending_count("B") %> in coda · <%= @mailing.subject_b %>

+

+ <%= @mailing.variant_opened_count("B") %> aperte + · <%= @mailing.variant_pending_count("B") %> in coda + · <%= @mailing.subject_b %> +

<% end %> @@ -131,6 +154,7 @@
<%= link_to recipient.organization.name, recipient.organization, class: "font-medium hover:underline" %> <%= mailing_recipient_status_badge(recipient.status) %> + <%= mailing_open_badge(recipient) %> <% if @mailing.ab_test? %><%= ab_variant_badge(recipient.ab_variant) %><% end %>
@@ -148,6 +172,17 @@ <% if recipient.error_message.present? %>
<%= recipient.error_message %>
<% end %> + <% if recipient.sent_at.present? %> +
+ Inviata <%= format_dt(recipient.sent_at) %> + <% if recipient.opened? %> + · aperta <%= format_dt(recipient.opened_at) %> + <% if recipient.open_count > 1 %> + · <%= recipient.open_count %> rilevamenti + <% end %> + <% end %> +
+ <% end %> <% if recipient.email_ok? %> <%= link_to "Anteprima", mailing_path(@mailing, recipient_id: recipient.id, variant: recipient.ab_variant), class: "mt-2 inline-block text-sm text-zinc-700 hover:underline dark:text-zinc-300" %> <% end %> @@ -167,6 +202,7 @@ Test <% end %> Stato + Apertura @@ -195,11 +231,25 @@ <% if recipient.error_message.present? %>
<%= recipient.error_message %>
<% end %> + <% if recipient.sent_at.present? %> +
Inviata <%= format_dt(recipient.sent_at) %>
+ <% end %> <% if @mailing.ab_test? %> <%= ab_variant_badge(recipient.ab_variant) || "—" %> <% end %> <%= mailing_recipient_status_badge(recipient.status) %> + + <%= mailing_open_badge(recipient) || "—" %> + <% if recipient.opened? %> +
+ <%= format_dt(recipient.opened_at) %> + <% if recipient.open_count > 1 %> + · <%= recipient.open_count %>× + <% end %> +
+ <% end %> + <% if recipient.email_ok? %> <%= link_to "Anteprima", mailing_path(@mailing, recipient_id: recipient.id, variant: recipient.ab_variant), class: "text-zinc-700 hover:underline dark:text-zinc-300" %> diff --git a/config/routes.rb b/config/routes.rb index f74b83f..74b5511 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ Rails.application.routes.draw do get "up" => "rails/health#show", as: :rails_health_check + get "t/o/:token.gif", to: "mail_opens#show", as: :mail_open, constraints: { token: /[A-Za-z0-9_-]+/ } get "login", to: "sessions#new" post "login", to: "sessions#create" diff --git a/db/migrate/20260820110000_add_open_tracking_to_mailing_recipients.rb b/db/migrate/20260820110000_add_open_tracking_to_mailing_recipients.rb new file mode 100644 index 0000000..56dea1d --- /dev/null +++ b/db/migrate/20260820110000_add_open_tracking_to_mailing_recipients.rb @@ -0,0 +1,24 @@ +class AddOpenTrackingToMailingRecipients < ActiveRecord::Migration[8.1] + def change + change_table :mailing_recipients do |t| + t.string :tracking_token + t.datetime :opened_at + t.datetime :last_opened_at + t.integer :open_count, null: false, default: 0 + end + + reversible do |dir| + dir.up do + execute <<~SQL.squish + UPDATE mailing_recipients + SET tracking_token = replace(gen_random_uuid()::text, '-', '') + WHERE tracking_token IS NULL + SQL + change_column_null :mailing_recipients, :tracking_token, false + end + end + + add_index :mailing_recipients, :tracking_token, unique: true + add_index :mailing_recipients, :opened_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 37538f9..a7a595d 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_234500) do +ActiveRecord::Schema[8.1].define(version: 2026_08_20_110000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -126,17 +126,23 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_17_234500) do t.datetime "created_at", null: false t.string "email" t.text "error_message" + t.datetime "last_opened_at" t.bigint "mailing_id", null: false + t.integer "open_count", default: 0, null: false + t.datetime "opened_at" t.bigint "organization_id", null: false t.string "rendered_subject" t.datetime "sent_at" t.string "skip_reason" t.string "status", default: "pending", null: false + t.string "tracking_token", null: false t.datetime "updated_at", null: false t.index ["ab_variant"], name: "index_mailing_recipients_on_ab_variant" t.index ["email"], name: "index_mailing_recipients_on_email" t.index ["mailing_id", "organization_id"], name: "idx_mailing_recipients_unique", unique: true + t.index ["opened_at"], name: "index_mailing_recipients_on_opened_at" t.index ["status"], name: "index_mailing_recipients_on_status" + t.index ["tracking_token"], name: "index_mailing_recipients_on_tracking_token", unique: true end create_table "mailings", force: :cascade do |t| diff --git a/test/controllers/mail_opens_controller_test.rb b/test/controllers/mail_opens_controller_test.rb new file mode 100644 index 0000000..260413f --- /dev/null +++ b/test/controllers/mail_opens_controller_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class MailOpensControllerTest < ActionDispatch::IntegrationTest + setup do + opportunities(:deal).update!(send_status: "to_send") + @mailing = create_mailing(audience: "to_send") + @mailing.rebuild_recipients! + @recipient = @mailing.mailing_recipients.first + @recipient.update!(status: "sent", sent_at: Time.current, rendered_subject: "Ciao ASD Test Calcio") + end + + test "records the first open without login and returns a gif" do + assert_difference -> { Activity.where(activity_type: "email_opened").count }, 1 do + get mail_open_path(@recipient.tracking_token) + end + + assert_response :success + assert_equal "image/gif", response.media_type + @recipient.reload + assert @recipient.opened? + assert_equal 1, @recipient.open_count + assert_equal @recipient.opened_at, @recipient.last_opened_at + end + + test "counts later loads without duplicating the timeline activity" do + get mail_open_path(@recipient.tracking_token) + assert_no_difference -> { Activity.where(activity_type: "email_opened").count } do + get mail_open_path(@recipient.tracking_token) + end + + @recipient.reload + assert_equal 2, @recipient.open_count + assert @recipient.last_opened_at >= @recipient.opened_at + end + + test "unknown token still returns a gif" do + get mail_open_path("missing-token-value-here") + assert_response :success + assert_equal "image/gif", response.media_type + end + + test "does not mark pending emails as opened" do + @recipient.update!(status: "pending", sent_at: nil) + get mail_open_path(@recipient.tracking_token) + + assert_response :success + @recipient.reload + assert_not @recipient.opened? + assert_equal 0, @recipient.open_count + end +end diff --git a/test/controllers/mailings_controller_test.rb b/test/controllers/mailings_controller_test.rb index cbd3edc..8eb710f 100644 --- a/test/controllers/mailings_controller_test.rb +++ b/test/controllers/mailings_controller_test.rb @@ -65,6 +65,8 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest assert_equal "contacted", opportunities(:deal).pipeline_stage assert_equal 2, ActionMailer::Base.deliveries.size assert_equal "Ciao ASD Test Calcio", ActionMailer::Base.deliveries.last.subject + body = (ActionMailer::Base.deliveries.last.html_part || ActionMailer::Base.deliveries.last).body.to_s + assert_match(%r{/t/o/#{Regexp.escape(recipient.reload.tracking_token)}\.gif}, body) end test "queue send requires test email first" do @@ -96,7 +98,9 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest mail = ActionMailer::Base.deliveries.last assert_equal [users(:admin).email], mail.to assert_match(/\[TEST\]/, mail.subject) + body = (mail.html_part || mail).body.to_s assert_match(/Società Prova/, mail.subject) + assert_no_match(%r{/t/o/}, body) assert mailing.reload.tested? assert_equal users(:admin).email, mailing.test_sent_to end diff --git a/test/mailers/campaign_mailer_test.rb b/test/mailers/campaign_mailer_test.rb index af77989..da094e5 100644 --- a/test/mailers/campaign_mailer_test.rb +++ b/test/mailers/campaign_mailer_test.rb @@ -23,6 +23,8 @@ class CampaignMailerTest < ActionMailer::TestCase assert_equal "Ciao ASD Test Calcio", email.subject body = (email.html_part || email).body.to_s assert_match(/Ciao Mario Rossi di ASD Test Calcio/, body) + recipient.reload + assert_match(%r{/t/o/#{Regexp.escape(recipient.tracking_token)}\.gif}, body) assert_equal 1, email.attachments.size assert_equal "brochure.pdf", email.attachments.first.filename end diff --git a/test/services/mailings_open_pixel_test.rb b/test/services/mailings_open_pixel_test.rb new file mode 100644 index 0000000..ff4f925 --- /dev/null +++ b/test/services/mailings_open_pixel_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class Mailings::OpenPixelTest < ActiveSupport::TestCase + test "injects a tracking pixel before body close" do + mailing = create_mailing + mailing.rebuild_recipients! + recipient = mailing.mailing_recipients.first + html = Mailings::OpenPixel.call("

Ciao

", recipient) + + assert_includes html, "/t/o/#{recipient.tracking_token}.gif" + assert_match %r{]*width="1"}, html + assert_includes html, "" + assert html.index("t/o/") < html.index("") + end + + test "skips test recipients without a token" do + html = "

Ciao

" + recipient = Mailings::TestRecipient.new(mailing: create_mailing, email: "test@example.com") + + assert_equal html, Mailings::OpenPixel.call(html, recipient) + end +end