vinindkoeb/app/models/mail_log.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

79 lines
2.5 KiB
Python

from datetime import datetime, timezone
from enum import Enum
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Column
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel
from app.models.mail_template import MailEventType
if TYPE_CHECKING:
from app.models.participant import Participant
from app.models.purchase_round import PurchaseRound
class MailLogStatus(str, Enum):
SENT = "sent"
FAILED = "failed"
DELIVERED = "delivered"
BOUNCED = "bounced"
HELD = "held"
DELAYED = "delayed"
class MailLog(SQLModel, table=True):
__tablename__ = "mail_log"
id: Optional[int] = Field(default=None, primary_key=True)
participant_id: int = Field(foreign_key="participant.id", ondelete="CASCADE")
purchase_round_id: int = Field(foreign_key="purchase_round.id", ondelete="CASCADE")
mail_template_id: Optional[int] = Field(default=None, foreign_key="mail_template.id", ondelete="SET NULL")
event_type: MailEventType = Field(
sa_column=Column(
SAEnum(
MailEventType,
name="mail_event_type",
native_enum=False,
values_callable=lambda enum_cls: [member.value for member in enum_cls],
),
nullable=False,
)
)
rendered_subject: str
status: MailLogStatus = Field(
sa_column=Column(
SAEnum(
MailLogStatus,
name="mail_log_status",
native_enum=False,
values_callable=lambda enum_cls: [member.value for member in enum_cls],
),
nullable=False,
)
)
postal_message_id: Optional[int] = Field(default=None)
postal_token: Optional[str] = Field(default=None)
error_message: Optional[str] = Field(default=None)
sent_at: Optional[datetime] = Field(default=None)
opened_at: Optional[datetime] = Field(default=None)
clicked_at: Optional[datetime] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
participant: "Participant" = Relationship(back_populates="mail_logs")
purchase_round: "PurchaseRound" = Relationship(back_populates="mail_logs")
class MailLogPublic(SQLModel):
id: int
participant_id: int
purchase_round_id: int
event_type: MailEventType
rendered_subject: str
status: MailLogStatus
error_message: Optional[str]
sent_at: Optional[datetime]
opened_at: Optional[datetime]
clicked_at: Optional[datetime]
created_at: datetime