Track email opens and clicks on recipient status.
Deploy Beta / unit-tests (push) Successful in 13s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m39s

SMTP2GO open/click webhooks now advance Message to opened/clicked, and the campaign engagement chart uses live stats instead of placeholder bars.
This commit is contained in:
2026-08-10 10:24:26 -05:00
parent 8680c082fe
commit f7f3174e1c
8 changed files with 252 additions and 33 deletions
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-08-10 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('messaging', '0004_stored_file'),
]
operations = [
migrations.AlterField(
model_name='message',
name='status',
field=models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('opened', 'Opened'), ('clicked', 'Clicked'), ('failed', 'Failed'), ('bounced', 'Bounced'), ('suppressed', 'Suppressed')], default='draft', max_length=16),
),
]
+2
View File
@@ -74,6 +74,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
QUEUED = "queued", "Queued" QUEUED = "queued", "Queued"
SENT = "sent", "Sent" SENT = "sent", "Sent"
DELIVERED = "delivered", "Delivered" DELIVERED = "delivered", "Delivered"
OPENED = "opened", "Opened"
CLICKED = "clicked", "Clicked"
FAILED = "failed", "Failed" FAILED = "failed", "Failed"
BOUNCED = "bounced", "Bounced" BOUNCED = "bounced", "Bounced"
SUPPRESSED = "suppressed", "Suppressed" SUPPRESSED = "suppressed", "Suppressed"
+21 -2
View File
@@ -352,8 +352,27 @@ def send_campaign_completion_notify(campaign: Campaign) -> bool:
campaign.notify_sent_at = now campaign.notify_sent_at = now
counts = campaign.messages.aggregate( counts = campaign.messages.aggregate(
sent=Count("id", filter=Q(status=Message.Status.SENT)), sent=Count(
delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)), "id",
filter=Q(
status__in=[
Message.Status.SENT,
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
]
),
),
delivered=Count(
"id",
filter=Q(
status__in=[
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
]
),
),
failed=Count( failed=Count(
"id", "id",
filter=Q( filter=Q(
@@ -92,7 +92,8 @@
<p class="hint-block" style="margin-top:0"> <p class="hint-block" style="margin-top:0">
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened · Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked <strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO). (<span data-stat="open_events">{{ stats.open_events }}</span> open events /
<span data-stat="click_events">{{ stats.click_events }}</span> click events from SMTP2GO).
</p> </p>
{% elif campaign.channel == "sms" %} {% elif campaign.channel == "sms" %}
<p class="hint-block" style="margin-top:0"> <p class="hint-block" style="margin-top:0">
@@ -103,12 +104,24 @@
Postcard status updates from PCM Integrations webhooks. Postcard status updates from PCM Integrations webhooks.
</p> </p>
{% endif %} {% endif %}
<div class="chart-placeholder" aria-hidden="true"> <div class="chart-placeholder" id="engagement-chart" role="img"
<div class="bar" style="height:55%"></div> aria-label="Campaign engagement chart">
<div class="bar" style="height:70%"></div> {% for bar in stats.chart_bars %}
<div class="bar" style="height:40%"></div> <div class="chart-bar-col">
<div class="bar" style="height:30%"></div> <div class="bar" style="height:{{ bar.pct }}%"
<div class="bar" style="height:20%"></div> title="{{ bar.label }}: {{ bar.value }}"
data-bar-label="{{ bar.label }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-value" data-bar-value="{{ bar.label }}">{{ bar.value }}</span>
<span class="chart-bar-label">{{ bar.label }}</span>
</div>
</div>
{% empty %}
<div class="chart-bar-col">
<div class="bar" style="height:12%"></div>
<div class="chart-bar-meta"><span class="chart-bar-label"></span></div>
</div>
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
@@ -217,10 +230,24 @@
badge.className = "badge badge-" + data.status; badge.className = "badge badge-" + data.status;
} }
Object.keys(data.stats || {}).forEach(function (key) { Object.keys(data.stats || {}).forEach(function (key) {
if (key === "chart_bars") return;
document.querySelectorAll('[data-stat="' + key + '"]').forEach(function (el) { document.querySelectorAll('[data-stat="' + key + '"]').forEach(function (el) {
el.textContent = data.stats[key]; el.textContent = data.stats[key];
}); });
}); });
var chart = document.getElementById("engagement-chart");
if (chart && Array.isArray(data.stats && data.stats.chart_bars)) {
chart.innerHTML = data.stats.chart_bars.map(function (bar) {
return '<div class="chart-bar-col">' +
'<div class="bar" style="height:' + esc(bar.pct) + '%" title="' +
esc(bar.label) + ': ' + esc(bar.value) + '" data-bar-label="' +
esc(bar.label) + '"></div>' +
'<div class="chart-bar-meta">' +
'<span class="chart-bar-value">' + esc(bar.value) + '</span>' +
'<span class="chart-bar-label">' + esc(bar.label) + '</span>' +
'</div></div>';
}).join("");
}
var body = document.getElementById("recipients-body"); var body = document.getElementById("recipients-body");
if (body && data.messages) { if (body && data.messages) {
if (!data.messages.length) { if (!data.messages.length) {
+38 -2
View File
@@ -382,7 +382,7 @@ class Smtp2goEmailWebhookTests(TestCase):
).exists() ).exists()
) )
def test_open_records_event_keeps_delivered(self): def test_open_records_event_sets_opened(self):
self.message.status = Message.Status.DELIVERED self.message.status = Message.Status.DELIVERED
self.message.save(update_fields=["status"]) self.message.save(update_fields=["status"])
url = reverse("messaging:email_webhook") url = reverse("messaging:email_webhook")
@@ -395,13 +395,46 @@ class Smtp2goEmailWebhookTests(TestCase):
}, },
) )
self.message.refresh_from_db() self.message.refresh_from_db()
self.assertEqual(self.message.status, Message.Status.DELIVERED) self.assertEqual(self.message.status, Message.Status.OPENED)
self.assertTrue( self.assertTrue(
ProviderEvent.objects.filter( ProviderEvent.objects.filter(
message=self.message, event_type="open" message=self.message, event_type="open"
).exists() ).exists()
) )
def test_click_sets_clicked_from_sent(self):
url = reverse("messaging:email_webhook")
self.client.post(
url,
data={
"event": "click",
"rcpt": "pat@example.com",
"X-Monica-Message-Id": str(self.message.pk),
},
)
self.message.refresh_from_db()
self.assertEqual(self.message.status, Message.Status.CLICKED)
self.assertTrue(
ProviderEvent.objects.filter(
message=self.message, event_type="click"
).exists()
)
def test_open_does_not_downgrade_clicked(self):
self.message.status = Message.Status.CLICKED
self.message.save(update_fields=["status"])
url = reverse("messaging:email_webhook")
self.client.post(
url,
data={
"event": "open",
"rcpt": "pat@example.com",
"X-Monica-Message-Id": str(self.message.pk),
},
)
self.message.refresh_from_db()
self.assertEqual(self.message.status, Message.Status.CLICKED)
def test_hard_bounce_suppresses_contact(self): def test_hard_bounce_suppresses_contact(self):
url = reverse("messaging:email_webhook") url = reverse("messaging:email_webhook")
self.client.post( self.client.post(
@@ -449,6 +482,9 @@ class Smtp2goEmailWebhookTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
self.assertEqual(data["stats"]["opens"], 1) self.assertEqual(data["stats"]["opens"], 1)
self.assertIn("chart_bars", data["stats"])
labels = [bar["label"] for bar in data["stats"]["chart_bars"]]
self.assertEqual(labels, ["Sent", "Delivered", "Opens", "Clicks", "Failed"])
def test_bearer_authorization_header(self): def test_bearer_authorization_header(self):
import json import json
+7 -1
View File
@@ -536,7 +536,13 @@ def campaign_detail(request, pk):
Campaign.Status.SENDING, Campaign.Status.SENDING,
} }
and campaign.messages.exclude( and campaign.messages.exclude(
status__in={"sent", "delivered", "suppressed"} status__in={
"sent",
"delivered",
"opened",
"clicked",
"suppressed",
}
).exists(), ).exists(),
}, },
) )
+89 -17
View File
@@ -21,7 +21,7 @@ PROVIDER_SMS = "smtp2go_sms"
PROVIDER_PCM = "pcm" PROVIDER_PCM = "pcm"
PROVIDER = PROVIDER_EMAIL # backward-compatible alias PROVIDER = PROVIDER_EMAIL # backward-compatible alias
# Do not move a message backward to a weaker delivery state. # Do not move a message backward to a weaker delivery / engagement state.
_STATUS_RANK = { _STATUS_RANK = {
Message.Status.DRAFT: 0, Message.Status.DRAFT: 0,
Message.Status.SCHEDULED: 1, Message.Status.SCHEDULED: 1,
@@ -29,10 +29,28 @@ _STATUS_RANK = {
Message.Status.SENT: 3, Message.Status.SENT: 3,
Message.Status.FAILED: 3, Message.Status.FAILED: 3,
Message.Status.DELIVERED: 4, Message.Status.DELIVERED: 4,
Message.Status.BOUNCED: 5, Message.Status.OPENED: 5,
Message.Status.SUPPRESSED: 5, Message.Status.CLICKED: 6,
Message.Status.BOUNCED: 7,
Message.Status.SUPPRESSED: 7,
} }
_ENGAGED_OR_DELIVERED = frozenset(
{
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
}
)
_SENT_OR_BETTER = frozenset(
{
Message.Status.SENT,
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
}
)
_MONICA_HEADER_KEYS = ( _MONICA_HEADER_KEYS = (
"X-Monica-Message-Id", "X-Monica-Message-Id",
"x-monica-message-id", "x-monica-message-id",
@@ -188,6 +206,8 @@ def find_message_for_email_event(payload: dict[str, Any]) -> Message | None:
Message.Status.QUEUED, Message.Status.QUEUED,
Message.Status.SENT, Message.Status.SENT,
Message.Status.DELIVERED, Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
Message.Status.FAILED, Message.Status.FAILED,
Message.Status.BOUNCED, Message.Status.BOUNCED,
], ],
@@ -207,11 +227,12 @@ def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "")
Message.Status.FAILED, Message.Status.FAILED,
}: }:
return return
if ( if message.status in {Message.Status.BOUNCED, Message.Status.SUPPRESSED} and new_status in {
message.status Message.Status.SENT,
in {Message.Status.BOUNCED, Message.Status.SUPPRESSED} Message.Status.DELIVERED,
and new_status == Message.Status.DELIVERED Message.Status.OPENED,
): Message.Status.CLICKED,
}:
return return
fields = ["status", "updated_at"] fields = ["status", "updated_at"]
@@ -219,7 +240,7 @@ def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "")
if error: if error:
message.error = error[:2000] message.error = error[:2000]
fields.append("error") fields.append("error")
elif new_status == Message.Status.DELIVERED: elif new_status in _ENGAGED_OR_DELIVERED:
message.error = "" message.error = ""
fields.append("error") fields.append("error")
message.save(update_fields=fields) message.save(update_fields=fields)
@@ -293,7 +314,17 @@ def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) ->
) )
return return
# open / click / resubscribe — event row only (status unchanged) if event == "open":
# Open implies delivery; do not overwrite a stronger click status.
if message.status != Message.Status.CLICKED:
_maybe_upgrade_status(message, Message.Status.OPENED)
return
if event == "click":
_maybe_upgrade_status(message, Message.Status.CLICKED)
return
# resubscribe — event row only (status unchanged)
def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | None: def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
@@ -612,7 +643,20 @@ def process_pcm_postcard_webhook(payload: dict[str, Any]) -> ProviderEvent | Non
) )
def campaign_engagement_stats(campaign) -> dict[str, int]: def _engagement_chart_bars(metrics: list[tuple[str, int]]) -> list[dict]:
"""Build bar heights (percent) for the campaign engagement chart."""
peak = max((value for _, value in metrics), default=0)
bars: list[dict] = []
for label, value in metrics:
if peak <= 0:
pct = 12 if value == 0 else 100
else:
pct = max(12, int(round((value / peak) * 100))) if value else 8
bars.append({"label": label, "value": value, "pct": pct})
return bars
def campaign_engagement_stats(campaign) -> dict:
"""Aggregate delivery + open/click counts for the campaign report.""" """Aggregate delivery + open/click counts for the campaign report."""
messages_qs = campaign.messages.all() messages_qs = campaign.messages.all()
statuses = list(messages_qs.values_list("status", flat=True)) statuses = list(messages_qs.values_list("status", flat=True))
@@ -626,15 +670,43 @@ def campaign_engagement_stats(campaign) -> dict[str, int]:
events.filter(event_type__iexact="click").values_list("message_id", flat=True) events.filter(event_type__iexact="click").values_list("message_id", flat=True)
) )
# Status-based engagement also counts (webhook may set opened/clicked).
status_opened = sum(1 for s in statuses if s in {"opened", "clicked"})
status_clicked = sum(1 for s in statuses if s == "clicked")
opens = max(len(open_message_ids), status_opened)
clicks = max(len(click_message_ids), status_clicked)
sent = sum(1 for s in statuses if s in _SENT_OR_BETTER)
delivered = sum(1 for s in statuses if s in _ENGAGED_OR_DELIVERED)
failed = sum(1 for s in statuses if s in {"failed", "bounced"})
suppressed = sum(1 for s in statuses if s == "suppressed")
if campaign.channel == Channel.EMAIL:
chart_metrics = [
("Sent", sent),
("Delivered", delivered),
("Opens", opens),
("Clicks", clicks),
("Failed", failed),
]
else:
chart_metrics = [
("Sent", sent),
("Delivered", delivered),
("Failed", failed),
("Suppressed", suppressed),
]
return { return {
"total": len(statuses), "total": len(statuses),
"sent": sum(1 for s in statuses if s in {"sent", "delivered"}), "sent": sent,
"delivered": sum(1 for s in statuses if s == "delivered"), "delivered": delivered,
"failed": sum(1 for s in statuses if s in {"failed", "bounced"}), "failed": failed,
"bounced": sum(1 for s in statuses if s == "bounced"), "bounced": sum(1 for s in statuses if s == "bounced"),
"suppressed": sum(1 for s in statuses if s == "suppressed"), "suppressed": suppressed,
"opens": len(open_message_ids), "opens": opens,
"clicks": len(click_message_ids), "clicks": clicks,
"open_events": events.filter(event_type__iexact="open").count(), "open_events": events.filter(event_type__iexact="open").count(),
"click_events": events.filter(event_type__iexact="click").count(), "click_events": events.filter(event_type__iexact="click").count(),
"chart_bars": _engagement_chart_bars(chart_metrics),
} }
+43 -4
View File
@@ -154,6 +154,8 @@ body.portal {
.badge-sent { background: #d0eef0; color: #00626c; } .badge-sent { background: #d0eef0; color: #00626c; }
.badge-scheduled { background: #ede9fe; color: #5b21b6; } .badge-scheduled { background: #ede9fe; color: #5b21b6; }
.badge-delivered { background: #d1fae5; color: #065f46; } .badge-delivered { background: #d1fae5; color: #065f46; }
.badge-opened { background: #dbeafe; color: #1e40af; }
.badge-clicked { background: #ede9fe; color: #5b21b6; }
.badge-failed { background: #fee2e2; color: #991b1b; } .badge-failed { background: #fee2e2; color: #991b1b; }
.badge-optin { background: #d1fae5; color: #065f46; } .badge-optin { background: #d1fae5; color: #065f46; }
.badge-optout { background: #f3f4f6; color: #4b5563; } .badge-optout { background: #f3f4f6; color: #4b5563; }
@@ -199,15 +201,52 @@ body.portal {
border: 1px dashed #a8d8dc; border: 1px dashed #a8d8dc;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: 8px; gap: 12px;
padding: 24px 16px 16px; padding: 24px 16px 12px;
} }
.chart-placeholder .bar { .chart-placeholder > .bar {
flex: 1; flex: 1;
background: var(--monica-primary); background: var(--monica-primary);
opacity: 0.75; opacity: 0.75;
border-radius: 2px 2px 0 0; border-radius: 2px 2px 0 0;
min-height: 20px; min-height: 8px;
}
.chart-placeholder .chart-bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-end;
min-width: 0;
height: 100%;
}
.chart-placeholder .chart-bar-col .bar {
width: 100%;
background: var(--monica-primary);
opacity: 0.75;
border-radius: 2px 2px 0 0;
min-height: 8px;
transition: height 0.25s ease;
}
.chart-placeholder .chart-bar-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-top: 8px;
text-align: center;
}
.chart-placeholder .chart-bar-value {
font-size: 13px;
font-weight: 600;
color: #0f172a;
line-height: 1.2;
}
.chart-placeholder .chart-bar-label {
font-size: 11px;
color: var(--monica-muted);
line-height: 1.2;
white-space: nowrap;
} }
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; } .consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }