vinindkoeb/app/routers/webhooks.py
carsten 4eb8800872 Add Postal webhook receiver for delivery status (task 7c)
POST /webhooks/postal (unauthenticated — RSA signature is the auth)
verifies the X-Postal-Signature-256 header: RSA-SHA256/PKCS1v15 over
the raw request body, using the same keypair as DKIM signing. Verified
directly against Postal's own source (lib/postal/http.rb, signer.rb)
rather than guessed, after the user pointed out the mechanism and that
their instance is new enough to use the -256 (SHA256) header over the
legacy SHA1 one. The public key is stored as the raw base64 DER blob
from the domain's DKIM DNS TXT record (dig TXT
postal-eWHeqb._domainkey.vinindkoeb.dk) — no PEM wrapping needed,
cryptography.load_der_public_key takes it directly.

Events are correlated to MailLog via postal_message_id. MessageSent
(actual delivery confirmation, not to be confused with task 7b's
synchronous "Postal accepted the request") maps to the DELIVERED
status already reserved for it; MessageDeliveryFailed/MessageBounced/
MessageHeld/MessageDelayed map to new terminal/transient statuses.
MessageLoaded/MessageLinkClicked set separate opened_at/clicked_at
timestamps rather than overwriting status, since engagement can happen
after delivery and shouldn't regress it. DomainDNSError and any
unrecognized event are acknowledged (200) and ignored — no message to
correlate.

Discovered along the way: the native_enum=False enum columns are
plain length-capped VARCHARs with no IN-list CHECK constraint, so
adding "held"/"delayed" needed no constraint migration, just the two
new opened_at/clicked_at columns Alembic did autogenerate correctly.

Verified: signature logic in isolation against a self-generated RSA
keypair (valid data/signature accepted, tampered data and garbage
signatures rejected), the real DKIM key parses correctly (1024-bit
RSA), the live endpoint rejects missing/invalid signatures with 401,
and the event-to-MailLog mapping logic was exercised directly (not
over HTTP, since a validly Postal-signed payload can't be forged
without their private key) against an isolated throwaway sandbox —
all cleaned up afterward, real route/participant data confirmed
unaffected throughout.

True end-to-end verification (a real Postal-originated webhook call)
requires the app to be deployed somewhere Postal can reach, plus
configuring the webhook URL in Postal's admin UI — both are deployment
steps outside this coding task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 20:50:24 +02:00

55 lines
1.9 KiB
Python

import json
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, status
from sqlmodel import select
from app.db import SessionDep
from app.models.mail_log import MailLog, MailLogStatus
from app.services.postal_webhook import verify_signature
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
_STATUS_BY_EVENT = {
"MessageSent": MailLogStatus.DELIVERED,
"MessageDelayed": MailLogStatus.DELAYED,
"MessageDeliveryFailed": MailLogStatus.FAILED,
"MessageBounced": MailLogStatus.BOUNCED,
"MessageHeld": MailLogStatus.HELD,
}
@router.post("/postal")
async def postal_webhook(request: Request, session: SessionDep) -> dict:
raw_body = await request.body()
signature = request.headers.get("X-Postal-Signature-256")
if not signature or not verify_signature(raw_body, signature):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature")
payload = json.loads(raw_body)
event = payload.get("event")
event_payload = payload.get("payload", {})
message_info = event_payload.get("message")
if not message_info or "id" not in message_info:
return {"status": "ignored"} # fx DomainDNSError — intet at korrelere mod
mail_log = session.exec(
select(MailLog).where(MailLog.postal_message_id == message_info["id"])
).first()
if mail_log is None:
return {"status": "ignored"}
if event == "MessageLoaded":
mail_log.opened_at = datetime.now(timezone.utc)
elif event == "MessageLinkClicked":
mail_log.clicked_at = datetime.now(timezone.utc)
elif event in _STATUS_BY_EVENT:
mail_log.status = _STATUS_BY_EVENT[event]
if mail_log.status == MailLogStatus.FAILED:
mail_log.error_message = event_payload.get("output") or event_payload.get("details")
else:
return {"status": "ignored"}
session.add(mail_log)
session.commit()
return {"status": "ok"}