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
+89 -17
View File
@@ -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),
}