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:
+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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user