Files
datatools-dev/server/app/email.py
Michael 2bbaba954b feat(server): Gumroad webhook receiver + Postmark email (PR 2)
Wires the second source-adapter (Gumroad) plus the email delivery
that lets the server fulfill a sale end-to-end without operator
intervention.

Auth model: Gumroad doesn't HMAC the body, so we use their
recommended URL-secret pattern (?secret=...). Wrong/missing secret
returns 404 — no signal to a prober that the endpoint exists.

Webhook flow (server/app/routes/webhooks.py):
  1. audit-log the raw payload (gumroad_events row) BEFORE anything
     else, so a later failure leaves us replayable
  2. parse via GumroadAdapter (server/app/adapters/gumroad.py)
  3. mint_from_sale — UNIQUE(source, source_order_id) dedups
     duplicate webhook retries
  4. send the license email
  5. mark gumroad_events.processed = true

Always returns 200 once auth passes. Non-2xx would trigger Gumroad's
3-day retry storm; we'd rather record the failure on the audit row
and replay manually after fixing whatever surfaced.

Product → tier mapping is per-source YAML at
server/config/products.yaml (lru_cached). Adding a SKU = edit yaml,
restart api. Unmapped product_id is an error on the audit row, not
a crash.

EmailService (server/app/email.py): provider-agnostic interface with
Postmark as the first implementation. When POSTMARK_TOKEN is unset
the factory returns LoggingEmailService instead, so the webhook
exercises end-to-end before Postmark is provisioned.

48 unit tests (was 21) including:
- Gumroad secret verify with constant-time compare
- Sale parsing: amount-in-cents, name fallback from email,
  test=true tagging, missing-required fields, offer codes
- Product mapping lookups
- Email rendering text + HTML, HTML-escapes user input
- Postmark client via httpx.MockTransport (success and 4xx)
- Webhook end-to-end: secret check, audit log, idempotency on
  retry, unmapped product, email failure keeps license

Smoke test (server/scripts/smoke.sh) extended to POST a synthetic
Ping payload, verify the row + audit log, prove wrong-secret is
rejected, prove duplicate sale_id stays one row.

SQLite-test compatibility:
- BigInteger primary key uses with_variant(Integer, "sqlite") since
  SQLite only autoincrements INTEGER PRIMARY KEY.
- python-multipart pulled in for FastAPI Form parsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:33:43 +00:00

215 lines
7.2 KiB
Python

"""Transactional email delivery.
Provider: Postmark. Picked for its transactional-deliverability
reputation and a tiny, no-SDK-needed HTTP API.
Configuration
-------------
- ``POSTMARK_TOKEN`` / ``POSTMARK_TOKEN_FILE`` — server API token.
- ``EMAIL_FROM`` — verified sender address (default
``licenses@datatools.unalogix.com``).
- ``EMAIL_REPLY_TO`` — optional Reply-To (default same as From).
When ``POSTMARK_TOKEN`` is unset the service falls back to
:class:`LoggingEmailService`, which prints the email to stdout
instead of sending. Lets the webhook handler exercise the full
flow before the Postmark account is provisioned.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Optional, Protocol
import httpx
from app.config import get_settings
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class LicenseEmail:
"""Inputs the renderer needs from the caller."""
to_name: str
to_email: str
tier: str
license_key: str
expires_at_iso: str
blob: str
class EmailService(Protocol):
"""Provider-agnostic email surface — keeps Postmark out of the
callers' import graph."""
def send_license(self, msg: LicenseEmail) -> str:
"""Deliver the license-delivery email. Returns a provider
message id (or ``"logged"`` for the dev fallback) so the
caller can record it on the licenses row for audit."""
...
class LoggingEmailService:
"""Stand-in when no real provider is configured. Logs the
rendered message body at INFO so it shows up in ``docker compose
logs api`` — useful during local dev and during the deploy
window before Postmark is wired up."""
def send_license(self, msg: LicenseEmail) -> str:
body = _render_text(msg)
log.info(
"[email-stub] would send to=%s subject=%r\n%s",
msg.to_email,
_subject(msg),
body,
)
return "logged"
class PostmarkEmailService:
"""Postmark transactional API client.
Single endpoint, ~3 fields, no SDK needed. We use a per-call
httpx Client with a tight timeout — webhook handlers run on
the request thread and we never want to block them on a flaky
upstream.
"""
API_URL = "https://api.postmarkapp.com/email"
TIMEOUT_S = 8.0
def __init__(
self,
token: str,
*,
sender: str,
reply_to: Optional[str] = None,
message_stream: str = "outbound",
) -> None:
self._token = token
self._sender = sender
self._reply_to = reply_to or sender
self._stream = message_stream
def send_license(self, msg: LicenseEmail) -> str:
body_text = _render_text(msg)
body_html = _render_html(msg)
payload = {
"From": self._sender,
"To": _rfc_addr(msg.to_name, msg.to_email),
"ReplyTo": self._reply_to,
"Subject": _subject(msg),
"TextBody": body_text,
"HtmlBody": body_html,
"MessageStream": self._stream,
}
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Postmark-Server-Token": self._token,
}
with httpx.Client(timeout=self.TIMEOUT_S) as c:
r = c.post(self.API_URL, json=payload, headers=headers)
if r.status_code >= 400:
raise EmailDeliveryError(
f"Postmark rejected the request: HTTP {r.status_code} "
f"body={r.text[:300]!r}"
)
return str(r.json().get("MessageID", ""))
class EmailDeliveryError(RuntimeError):
"""Provider returned a non-2xx. Caller should record this on the
audit row so the operator can replay after fixing the provider
config (verified sender domain, paid plan, etc.)."""
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def get_email_service() -> EmailService:
"""Choose the real provider if a token is configured, else the
logger. Reads settings fresh — tests can flip env vars between
sends without restarting."""
settings = get_settings()
token = settings.resolve_postmark_token()
if not token:
return LoggingEmailService()
sender = os.environ.get("EMAIL_FROM", "licenses@datatools.unalogix.com")
reply_to = os.environ.get("EMAIL_REPLY_TO")
return PostmarkEmailService(token, sender=sender, reply_to=reply_to)
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def _subject(msg: LicenseEmail) -> str:
return f"Your DataTools license ({msg.tier})"
def _render_text(msg: LicenseEmail) -> str:
return (
f"Hi {msg.to_name},\n\n"
f"Thanks for your DataTools purchase. Your license is below.\n\n"
f"License key: {msg.license_key}\n"
f"Tier: {msg.tier}\n"
f"Expires: {msg.expires_at_iso[:10]}\n\n"
f"To activate, paste the full blob (starting with DTLIC2:) into\n"
f"the Activate screen, or run:\n\n"
f" python -m src.license_cli activate \"{msg.blob}\" \\\n"
f" --name \"{msg.to_name}\" --email {msg.to_email}\n\n"
f"Your blob:\n\n"
f"{msg.blob}\n\n"
f"Keep this email — you'll need the blob if you move to a new\n"
f"computer. Questions: reply to this email.\n\n"
f"— DataTools\n"
)
def _render_html(msg: LicenseEmail) -> str:
return (
"<!doctype html><html><body style=\"font-family:system-ui,sans-serif;"
"max-width:560px;margin:auto;padding:24px;color:#222;\">"
f"<p>Hi {_html_escape(msg.to_name)},</p>"
"<p>Thanks for your DataTools purchase. Your license is below.</p>"
"<table cellpadding=\"4\" style=\"border-collapse:collapse;\">"
f"<tr><td><b>License key</b></td><td><code>{_html_escape(msg.license_key)}</code></td></tr>"
f"<tr><td><b>Tier</b></td><td>{_html_escape(msg.tier)}</td></tr>"
f"<tr><td><b>Expires</b></td><td>{_html_escape(msg.expires_at_iso[:10])}</td></tr>"
"</table>"
"<p>To activate, paste the blob below into the <em>Activate</em> "
"screen on first launch.</p>"
"<pre style=\"background:#f4f4f4;padding:12px;border-radius:6px;"
"white-space:pre-wrap;word-break:break-all;font-size:11px;\">"
f"{_html_escape(msg.blob)}</pre>"
"<p style=\"color:#666;font-size:13px;\">Keep this email — you'll "
"need the blob if you move to a new computer. Questions: just reply.</p>"
"<p>— DataTools</p></body></html>"
)
def _rfc_addr(name: str, email: str) -> str:
# Postmark accepts "Name <addr>" or just "addr". Quote names with
# special chars; otherwise keep it readable in the inbox.
if not name or "@" in name:
return email
if any(c in name for c in ',<>"'):
name = name.replace('"', "").replace(",", "")
return f"{name} <{email}>"
def _html_escape(s: str) -> str:
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)