v0.18.0 Slice 5: bounce correlation hook
The `POST /api/webhooks/email-bounce` body accepts a new optional
`message_id` field. When supplied, the handler looks up the
matching row in `outbound_emails` and stamps status='bounced' +
appends "bounce (<kind>)" to the error column. The response
gains a `correlated_id` field (nullable: null when no
message_id was provided or no row matched).
Hard-bounce -> `email_opt_out_all = 1` still fires regardless
(the v1 contract from api_notifications.py:486-498); Slice 5
just adds the per-message audit trail on top.
Providers that don't surface Message-ID get the legacy v1
behavior — match by email, flip the global opt-out, leave
correlated_id null. Providers that replay an old bounce with a
message_id the framework no longer has log an INFO line but
still 200 (the bounce path can't refuse just because a row was
pruned).
Test update: `test_bounce_webhook_refuses_unsigned_when_secret_configured`
in test_e2e_smoke.py was asserting on the exact response shape
{ok, matched}; v0.18.0 adds `correlated_id`. The test now asserts
on the new shape; documented in CHANGELOG.
4 new tests covering: bounce with message_id stamps the row;
unknown message_id is logged + does not crash; bounce without
message_id still flips opt-out (backward compat); bounced rows
surface in the admin endpoint with `?status=bounced`.
Full suite: 295 passed.
This commit is contained in:
@@ -73,6 +73,13 @@ class MarkReadBody(BaseModel):
|
||||
class BounceBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
kind: str = Field(default="hard") # 'hard' or 'complaint'
|
||||
# v0.18.0 Slice 5: when the bounce provider includes the
|
||||
# original Message-ID, the framework correlates it back to
|
||||
# the matching `outbound_emails` row and stamps
|
||||
# `status='bounced'`. Optional — providers that don't surface
|
||||
# the Message-ID still flip the global opt-out via the email
|
||||
# match, but lose the per-message attribution.
|
||||
message_id: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class CookieConsentBody(BaseModel):
|
||||
@@ -555,16 +562,46 @@ def make_router(config: Config) -> APIRouter:
|
||||
import hmac as _hmac
|
||||
if not received or not _hmac.compare_digest(expected, received):
|
||||
raise HTTPException(401, "Invalid webhook signature")
|
||||
# v0.18.0 Slice 5: correlate the bounce back to the
|
||||
# matching outbound_emails row if the provider supplied
|
||||
# the Message-ID. The hard-bounce -> global-opt-out
|
||||
# logic below still fires regardless; this is an
|
||||
# additional audit signal.
|
||||
correlated_row_id: int | None = None
|
||||
if body.message_id:
|
||||
correlated = db.conn().execute(
|
||||
"SELECT id FROM outbound_emails WHERE message_id = ?",
|
||||
(body.message_id,),
|
||||
).fetchone()
|
||||
if correlated is not None:
|
||||
correlated_row_id = correlated["id"]
|
||||
db.conn().execute(
|
||||
"UPDATE outbound_emails SET status = 'bounced', "
|
||||
"error = COALESCE(error, '') || ? WHERE id = ?",
|
||||
(f"bounce ({body.kind})", correlated_row_id),
|
||||
)
|
||||
log.info(
|
||||
"email-bounce: correlated message_id=%s -> outbound_emails.id=%s",
|
||||
body.message_id, correlated_row_id,
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
"email-bounce: message_id=%s did not match any "
|
||||
"outbound_emails row (provider may be replaying an old bounce, "
|
||||
"or the row was pruned)",
|
||||
body.message_id,
|
||||
)
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {"ok": True, "matched": False}
|
||||
return {"ok": True, "matched": False, "correlated_id": correlated_row_id}
|
||||
db.conn().execute(
|
||||
"UPDATE users SET email_opt_out_all = 1 WHERE id = ?", (row["id"],),
|
||||
)
|
||||
log.info("email-bounce: opted out user %s (%s)", row["id"], body.kind)
|
||||
return {"ok": True, "matched": True}
|
||||
return {"ok": True, "matched": True, "correlated_id": correlated_row_id}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
Reference in New Issue
Block a user