Track email opens and clicks on recipient status.
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:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -74,6 +74,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
QUEUED = "queued", "Queued"
|
||||
SENT = "sent", "Sent"
|
||||
DELIVERED = "delivered", "Delivered"
|
||||
OPENED = "opened", "Opened"
|
||||
CLICKED = "clicked", "Clicked"
|
||||
FAILED = "failed", "Failed"
|
||||
BOUNCED = "bounced", "Bounced"
|
||||
SUPPRESSED = "suppressed", "Suppressed"
|
||||
|
||||
@@ -352,8 +352,27 @@ def send_campaign_completion_notify(campaign: Campaign) -> bool:
|
||||
campaign.notify_sent_at = now
|
||||
|
||||
counts = campaign.messages.aggregate(
|
||||
sent=Count("id", filter=Q(status=Message.Status.SENT)),
|
||||
delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)),
|
||||
sent=Count(
|
||||
"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(
|
||||
"id",
|
||||
filter=Q(
|
||||
|
||||
@@ -92,7 +92,8 @@
|
||||
<p class="hint-block" style="margin-top:0">
|
||||
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
|
||||
<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>
|
||||
{% elif campaign.channel == "sms" %}
|
||||
<p class="hint-block" style="margin-top:0">
|
||||
@@ -103,12 +104,24 @@
|
||||
Postcard status updates from PCM Integrations webhooks.
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="chart-placeholder" aria-hidden="true">
|
||||
<div class="bar" style="height:55%"></div>
|
||||
<div class="bar" style="height:70%"></div>
|
||||
<div class="bar" style="height:40%"></div>
|
||||
<div class="bar" style="height:30%"></div>
|
||||
<div class="bar" style="height:20%"></div>
|
||||
<div class="chart-placeholder" id="engagement-chart" role="img"
|
||||
aria-label="Campaign engagement chart">
|
||||
{% for bar in stats.chart_bars %}
|
||||
<div class="chart-bar-col">
|
||||
<div class="bar" style="height:{{ bar.pct }}%"
|
||||
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>
|
||||
@@ -217,10 +230,24 @@
|
||||
badge.className = "badge badge-" + data.status;
|
||||
}
|
||||
Object.keys(data.stats || {}).forEach(function (key) {
|
||||
if (key === "chart_bars") return;
|
||||
document.querySelectorAll('[data-stat="' + key + '"]').forEach(function (el) {
|
||||
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");
|
||||
if (body && data.messages) {
|
||||
if (!data.messages.length) {
|
||||
|
||||
+38
-2
@@ -382,7 +382,7 @@ class Smtp2goEmailWebhookTests(TestCase):
|
||||
).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.save(update_fields=["status"])
|
||||
url = reverse("messaging:email_webhook")
|
||||
@@ -395,13 +395,46 @@ class Smtp2goEmailWebhookTests(TestCase):
|
||||
},
|
||||
)
|
||||
self.message.refresh_from_db()
|
||||
self.assertEqual(self.message.status, Message.Status.DELIVERED)
|
||||
self.assertEqual(self.message.status, Message.Status.OPENED)
|
||||
self.assertTrue(
|
||||
ProviderEvent.objects.filter(
|
||||
message=self.message, event_type="open"
|
||||
).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):
|
||||
url = reverse("messaging:email_webhook")
|
||||
self.client.post(
|
||||
@@ -449,6 +482,9 @@ class Smtp2goEmailWebhookTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
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):
|
||||
import json
|
||||
|
||||
@@ -536,7 +536,13 @@ def campaign_detail(request, pk):
|
||||
Campaign.Status.SENDING,
|
||||
}
|
||||
and campaign.messages.exclude(
|
||||
status__in={"sent", "delivered", "suppressed"}
|
||||
status__in={
|
||||
"sent",
|
||||
"delivered",
|
||||
"opened",
|
||||
"clicked",
|
||||
"suppressed",
|
||||
}
|
||||
).exists(),
|
||||
},
|
||||
)
|
||||
|
||||
+89
-17
@@ -21,7 +21,7 @@ PROVIDER_SMS = "smtp2go_sms"
|
||||
PROVIDER_PCM = "pcm"
|
||||
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 = {
|
||||
Message.Status.DRAFT: 0,
|
||||
Message.Status.SCHEDULED: 1,
|
||||
@@ -29,10 +29,28 @@ _STATUS_RANK = {
|
||||
Message.Status.SENT: 3,
|
||||
Message.Status.FAILED: 3,
|
||||
Message.Status.DELIVERED: 4,
|
||||
Message.Status.BOUNCED: 5,
|
||||
Message.Status.SUPPRESSED: 5,
|
||||
Message.Status.OPENED: 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 = (
|
||||
"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.SENT,
|
||||
Message.Status.DELIVERED,
|
||||
Message.Status.OPENED,
|
||||
Message.Status.CLICKED,
|
||||
Message.Status.FAILED,
|
||||
Message.Status.BOUNCED,
|
||||
],
|
||||
@@ -207,11 +227,12 @@ def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "")
|
||||
Message.Status.FAILED,
|
||||
}:
|
||||
return
|
||||
if (
|
||||
message.status
|
||||
in {Message.Status.BOUNCED, Message.Status.SUPPRESSED}
|
||||
and new_status == Message.Status.DELIVERED
|
||||
):
|
||||
if message.status in {Message.Status.BOUNCED, Message.Status.SUPPRESSED} and new_status in {
|
||||
Message.Status.SENT,
|
||||
Message.Status.DELIVERED,
|
||||
Message.Status.OPENED,
|
||||
Message.Status.CLICKED,
|
||||
}:
|
||||
return
|
||||
|
||||
fields = ["status", "updated_at"]
|
||||
@@ -219,7 +240,7 @@ def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "")
|
||||
if error:
|
||||
message.error = error[:2000]
|
||||
fields.append("error")
|
||||
elif new_status == Message.Status.DELIVERED:
|
||||
elif new_status in _ENGAGED_OR_DELIVERED:
|
||||
message.error = ""
|
||||
fields.append("error")
|
||||
message.save(update_fields=fields)
|
||||
@@ -293,7 +314,17 @@ def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) ->
|
||||
)
|
||||
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:
|
||||
@@ -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."""
|
||||
messages_qs = campaign.messages.all()
|
||||
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)
|
||||
)
|
||||
|
||||
# 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 {
|
||||
"total": len(statuses),
|
||||
"sent": sum(1 for s in statuses if s in {"sent", "delivered"}),
|
||||
"delivered": sum(1 for s in statuses if s == "delivered"),
|
||||
"failed": sum(1 for s in statuses if s in {"failed", "bounced"}),
|
||||
"sent": sent,
|
||||
"delivered": delivered,
|
||||
"failed": failed,
|
||||
"bounced": sum(1 for s in statuses if s == "bounced"),
|
||||
"suppressed": sum(1 for s in statuses if s == "suppressed"),
|
||||
"opens": len(open_message_ids),
|
||||
"clicks": len(click_message_ids),
|
||||
"suppressed": suppressed,
|
||||
"opens": opens,
|
||||
"clicks": clicks,
|
||||
"open_events": events.filter(event_type__iexact="open").count(),
|
||||
"click_events": events.filter(event_type__iexact="click").count(),
|
||||
"chart_bars": _engagement_chart_bars(chart_metrics),
|
||||
}
|
||||
|
||||
@@ -154,6 +154,8 @@ body.portal {
|
||||
.badge-sent { background: #d0eef0; color: #00626c; }
|
||||
.badge-scheduled { background: #ede9fe; color: #5b21b6; }
|
||||
.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-optin { background: #d1fae5; color: #065f46; }
|
||||
.badge-optout { background: #f3f4f6; color: #4b5563; }
|
||||
@@ -199,15 +201,52 @@ body.portal {
|
||||
border: 1px dashed #a8d8dc;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 24px 16px 16px;
|
||||
gap: 12px;
|
||||
padding: 24px 16px 12px;
|
||||
}
|
||||
.chart-placeholder .bar {
|
||||
.chart-placeholder > .bar {
|
||||
flex: 1;
|
||||
background: var(--monica-primary);
|
||||
opacity: 0.75;
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user