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>
79 lines
2.5 KiB
Python
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
|