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>
55 lines
1.9 KiB
Python
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"}
|