Aggiunge il rilevamento di invio e apertura delle email di campagna.
CI / scan_ruby (push) Failing after 12m56s
CI / scan_js (push) Successful in 12m23s
CI / lint (push) Failing after 12m15s

Ogni destinatario mostra se la mail è partita e se è stata aperta, con conteggi in dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 12:26:14 +02:00
co-authored by Cursor
parent ab57309f5c
commit 2db7504629
18 changed files with 332 additions and 5 deletions
+24
View File
@@ -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
+13
View File
@@ -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?
+3 -1
View File
@@ -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
+1
View File
@@ -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",
+15
View File
@@ -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
+50
View File
@@ -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!(
+36
View File
@@ -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 = %(<img src="#{ERB::Util.html_escape(url)}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px;opacity:0;" />)
if @html.match?(/<\/body>/i)
@html.sub(/<\/body>/i, "#{tag}</body>")
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
+24
View File
@@ -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
@@ -17,6 +17,7 @@
<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.opened_count %></strong> aperte</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? %>
+2 -1
View File
@@ -73,7 +73,7 @@
</div>
<div class="mt-3"><%= progress_bar(mailing.progress_percentage) %></div>
<div class="mt-2 text-xs text-zinc-500">
<%= 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) %>
<div class="mt-1 text-xs text-zinc-500">
<%= mailing.sent_count %> inviate
· <%= mailing.opened_count %> aperte
· <%= mailing.pending_count %> in coda
<% if mailing.failed_count.positive? %>
· <span class="text-rose-700"><%= mailing.failed_count %> errori</span>
+52 -2
View File
@@ -62,6 +62,21 @@
</div>
</div>
<% if @mailing.sending? || @mailing.sent? || @mailing.cancelled? %>
<div class="grid grid-cols-2 gap-3">
<div class="<%= card_class %> p-4">
<div class="text-xs uppercase tracking-wide text-zinc-500">Aperte</div>
<div class="mt-1 text-2xl font-semibold"><%= @mailing.opened_count %></div>
<p class="mt-1 text-xs text-zinc-500">su <%= @mailing.sent_count %> inviate</p>
</div>
<div class="<%= card_class %> p-4">
<div class="text-xs uppercase tracking-wide text-zinc-500">Tasso apertura</div>
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= @mailing.open_rate %>%</div>
<p class="mt-1 text-xs text-zinc-500">Si rileva quando il client carica le immagini.</p>
</div>
</div>
<% end %>
<% if @mailing.ab_test? %>
<div class="grid gap-4 sm:grid-cols-2">
<div class="<%= card_class %> p-4">
@@ -70,7 +85,11 @@
<%= ab_variant_badge("A") %>
</div>
<div class="mt-1 text-2xl font-semibold"><%= @mailing.variant_sent_count("A") %> inviate</div>
<p class="mt-1 text-xs text-zinc-500"><%= @mailing.variant_pending_count("A") %> in coda · <%= @mailing.subject %></p>
<p class="mt-1 text-xs text-zinc-500">
<%= @mailing.variant_opened_count("A") %> aperte
· <%= @mailing.variant_pending_count("A") %> in coda
· <%= @mailing.subject %>
</p>
</div>
<div class="<%= card_class %> p-4">
<div class="flex items-center justify-between">
@@ -78,7 +97,11 @@
<%= ab_variant_badge("B") %>
</div>
<div class="mt-1 text-2xl font-semibold"><%= @mailing.variant_sent_count("B") %> inviate</div>
<p class="mt-1 text-xs text-zinc-500"><%= @mailing.variant_pending_count("B") %> in coda · <%= @mailing.subject_b %></p>
<p class="mt-1 text-xs text-zinc-500">
<%= @mailing.variant_opened_count("B") %> aperte
· <%= @mailing.variant_pending_count("B") %> in coda
· <%= @mailing.subject_b %>
</p>
</div>
</div>
<% end %>
@@ -131,6 +154,7 @@
<div class="flex flex-wrap items-center gap-2">
<%= 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 %>
</div>
<div class="mt-1 text-sm break-anywhere text-zinc-600 dark:text-zinc-300">
@@ -148,6 +172,17 @@
<% if recipient.error_message.present? %>
<div class="mt-1 text-xs text-rose-700"><%= recipient.error_message %></div>
<% end %>
<% if recipient.sent_at.present? %>
<div class="mt-1 text-xs text-zinc-500">
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 %>
</div>
<% 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 @@
<th class="px-4 py-3">Test</th>
<% end %>
<th class="px-4 py-3">Stato</th>
<th class="px-4 py-3">Apertura</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
@@ -195,11 +231,25 @@
<% if recipient.error_message.present? %>
<div class="text-xs text-rose-700"><%= recipient.error_message %></div>
<% end %>
<% if recipient.sent_at.present? %>
<div class="text-xs text-zinc-500">Inviata <%= format_dt(recipient.sent_at) %></div>
<% end %>
</td>
<% if @mailing.ab_test? %>
<td class="px-4 py-3"><%= ab_variant_badge(recipient.ab_variant) || "—" %></td>
<% end %>
<td class="px-4 py-3"><%= mailing_recipient_status_badge(recipient.status) %></td>
<td class="px-4 py-3">
<%= mailing_open_badge(recipient) || "—" %>
<% if recipient.opened? %>
<div class="mt-1 text-xs text-zinc-500">
<%= format_dt(recipient.opened_at) %>
<% if recipient.open_count > 1 %>
· <%= recipient.open_count %>×
<% end %>
</div>
<% end %>
</td>
<td class="px-4 py-3 text-right text-sm">
<% 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" %>
+1
View File
@@ -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"
@@ -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
Generated
+7 -1
View File
@@ -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|
@@ -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
@@ -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
+2
View File
@@ -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
+22
View File
@@ -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("<html><body><p>Ciao</p></body></html>", recipient)
assert_includes html, "/t/o/#{recipient.tracking_token}.gif"
assert_match %r{<img src="http://example.com/t/o/#{Regexp.escape(recipient.tracking_token)}\.gif"[^>]*width="1"}, html
assert_includes html, "</body>"
assert html.index("t/o/") < html.index("</body>")
end
test "skips test recipients without a token" do
html = "<p>Ciao</p>"
recipient = Mailings::TestRecipient.new(mailing: create_mailing, email: "test@example.com")
assert_equal html, Mailings::OpenPixel.call(html, recipient)
end
end