diff --git a/site/messaging/migrations/0005_message_opened_clicked_statuses.py b/site/messaging/migrations/0005_message_opened_clicked_statuses.py new file mode 100644 index 0000000..804ee70 --- /dev/null +++ b/site/messaging/migrations/0005_message_opened_clicked_statuses.py @@ -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), + ), + ] diff --git a/site/messaging/models.py b/site/messaging/models.py index d389f3e..25b1a66 100644 --- a/site/messaging/models.py +++ b/site/messaging/models.py @@ -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" diff --git a/site/messaging/services.py b/site/messaging/services.py index f8a1d79..829d380 100644 --- a/site/messaging/services.py +++ b/site/messaging/services.py @@ -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( diff --git a/site/messaging/templates/messaging/campaign_detail.html b/site/messaging/templates/messaging/campaign_detail.html index 0a3a33a..8d5e004 100644 --- a/site/messaging/templates/messaging/campaign_detail.html +++ b/site/messaging/templates/messaging/campaign_detail.html @@ -92,7 +92,8 @@

Unique recipients: {{ stats.opens }} opened ยท {{ stats.clicks }} clicked - ({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO). + ({{ stats.open_events }} open events / + {{ stats.click_events }} click events from SMTP2GO).

{% elif campaign.channel == "sms" %}

@@ -103,12 +104,24 @@ Postcard status updates from PCM Integrations webhooks.

{% endif %} - @@ -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 '
' + + '
' + + '
' + + '' + esc(bar.value) + '' + + '' + esc(bar.label) + '' + + '
'; + }).join(""); + } var body = document.getElementById("recipients-body"); if (body && data.messages) { if (!data.messages.length) { diff --git a/site/messaging/tests.py b/site/messaging/tests.py index b29d1b3..c6c0787 100644 --- a/site/messaging/tests.py +++ b/site/messaging/tests.py @@ -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 diff --git a/site/messaging/views.py b/site/messaging/views.py index c0c6c93..3c1ec34 100644 --- a/site/messaging/views.py +++ b/site/messaging/views.py @@ -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(), }, ) diff --git a/site/messaging/webhooks.py b/site/messaging/webhooks.py index ffdff72..e492803 100644 --- a/site/messaging/webhooks.py +++ b/site/messaging/webhooks.py @@ -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), } diff --git a/site/monica_site/static/css/portal.css b/site/monica_site/static/css/portal.css index 34bb955..a5df5fd 100644 --- a/site/monica_site/static/css/portal.css +++ b/site/monica_site/static/css/portal.css @@ -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; }