diff --git a/server/Dockerfile b/server/Dockerfile
index 8c718e5..5f936d7 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -23,6 +23,7 @@ RUN pip install -r /app/requirements.txt
COPY src/license /app/datatools_license
COPY server/app /app/app
+COPY server/config /app/config
COPY server/alembic /app/alembic
COPY server/alembic.ini /app/alembic.ini
diff --git a/server/app/adapters/gumroad.py b/server/app/adapters/gumroad.py
new file mode 100644
index 0000000..17a0911
--- /dev/null
+++ b/server/app/adapters/gumroad.py
@@ -0,0 +1,173 @@
+"""Gumroad adapter.
+
+Receives "Ping" notifications from Gumroad — form-encoded POSTs sent
+when a sale occurs. Gumroad's Ping URL is configured in the seller
+dashboard (Settings → Advanced → Ping URL).
+
+Authentication
+--------------
+
+Gumroad does not HMAC-sign the body. Their recommended pattern is
+to put a secret in the URL itself::
+
+ https://licenses.datatools.unalogix.com/webhooks/gumroad?secret=...
+
+The webhook receiver pulls the secret from the query string and
+:meth:`GumroadAdapter.verify_webhook` constant-time-compares it
+against the configured value. If they don't match, the request is
+dropped with 404 (so a probing attacker can't tell whether the
+endpoint exists, much less that it's the wrong secret).
+
+The "test" field
+----------------
+
+Gumroad sends ``test=true`` on test pings fired from the dashboard.
+We treat test pings as real sales (they create licenses just like
+production sales), but tag them with ``notes='gumroad test ping'``
+so the operator can filter / delete them later. Refusing test pings
+would block the standard "Send Test Ping" verification flow.
+
+Refunds, disputes, cancellations
+--------------------------------
+
+Stubbed for now (``parse_refund`` returns None). Gumroad doesn't
+include refund signals in the standard sale Ping — refunds arrive
+via the separate "Resource subscriptions" mechanism. Wiring that
+in is PR 2.1; until then, refunds are handled by the operator
+running ``datatools-admin revoke``.
+"""
+
+from __future__ import annotations
+
+import hmac
+from decimal import Decimal
+from typing import Any, Optional
+
+from app.adapters.base import RefundEvent, SaleEvent
+from app.products import lookup as product_lookup
+
+
+class GumroadAdapter:
+ source_name = "gumroad"
+
+ def __init__(self, secret: Optional[str]) -> None:
+ self._secret = secret
+
+ # --- Auth ----------------------------------------------------------------
+
+ def verify_webhook(self, *, body: bytes, headers: dict[str, str]) -> bool:
+ """Not used — Gumroad authentication is via URL query param,
+ which only the route handler has direct access to. Call
+ :meth:`verify_secret` instead."""
+ return False
+
+ def verify_secret(self, presented: Optional[str]) -> bool:
+ """Constant-time compare against the configured secret.
+
+ Returns False (not an exception) so the route handler can
+ decide the response code — we return 404 to avoid signaling
+ endpoint existence to an unauthenticated prober.
+ """
+ if not self._secret or not presented:
+ return False
+ return hmac.compare_digest(presented, self._secret)
+
+ # --- Parsing -------------------------------------------------------------
+
+ def parse_sale(self, payload: dict[str, Any]) -> Optional[SaleEvent]:
+ """Parse a Gumroad Ping form-encoded payload into a SaleEvent.
+
+ Returns None if the payload isn't a sale (e.g. some future
+ event type we don't yet handle). Returns None *with no row
+ side-effect* if the product_id is unmapped — the caller
+ should treat that as an error and record it in the audit
+ row, not silently drop.
+ """
+ # Sale pings always include sale_id (the order ID) and email.
+ sale_id = payload.get("sale_id")
+ email = payload.get("email")
+ product_id = (
+ payload.get("product_id")
+ or payload.get("product_permalink")
+ or payload.get("permalink")
+ )
+ if not (sale_id and email and product_id):
+ return None
+
+ mapping = product_lookup(self.source_name, str(product_id))
+ if mapping is None:
+ # Unmapped — surface to caller as a SaleEvent with no tier.
+ # We deliberately don't raise here so the caller can
+ # log it to gumroad_events with error info and still
+ # return 200 (no Gumroad retry storm).
+ raise UnmappedProductError(
+ f"Gumroad product_id {product_id!r} has no entry in "
+ "config/products.yaml. Add a mapping and replay this "
+ f"sale (sale_id={sale_id})."
+ )
+
+ name = (payload.get("full_name") or "").strip() or _email_local(email)
+
+ price_cents = _to_int(payload.get("price"))
+ amount_paid = Decimal(price_cents) / Decimal(100) if price_cents is not None else None
+ currency = (payload.get("currency") or "USD").upper()
+ promotion = (payload.get("offer_code") or "").strip() or None
+
+ notes = None
+ if _is_truthy(payload.get("test")):
+ notes = "gumroad test ping"
+
+ return SaleEvent(
+ source=self.source_name,
+ source_order_id=str(sale_id),
+ buyer_name=name,
+ buyer_email=email.strip(),
+ tier=mapping.tier,
+ years=mapping.years,
+ promotion=promotion,
+ amount_paid=amount_paid,
+ currency=currency,
+ notes=notes,
+ raw_payload=dict(payload),
+ )
+
+ def parse_refund(self, payload: dict[str, Any]) -> Optional[RefundEvent]:
+ # PR 2.1.
+ return None
+
+
+class UnmappedProductError(ValueError):
+ """Raised when a sale arrives for a product not in products.yaml.
+
+ Caller catches and logs into ``gumroad_events.error`` so the
+ operator can fix the mapping and replay.
+ """
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _email_local(email: str) -> str:
+ """Fallback display name when ``full_name`` is missing — the part
+ of the email before the ``@``, capitalized. Better than 'Unknown'
+ for support tickets and the buyer's own delivery email."""
+ local = email.split("@", 1)[0]
+ return local.replace(".", " ").title()
+
+
+def _to_int(v: Any) -> Optional[int]:
+ if v is None or v == "":
+ return None
+ try:
+ return int(v)
+ except (TypeError, ValueError):
+ return None
+
+
+def _is_truthy(v: Any) -> bool:
+ if isinstance(v, bool):
+ return v
+ if v is None:
+ return False
+ return str(v).strip().lower() in {"1", "true", "yes", "on"}
diff --git a/server/app/email.py b/server/app/email.py
new file mode 100644
index 0000000..93086fe
--- /dev/null
+++ b/server/app/email.py
@@ -0,0 +1,214 @@
+"""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 (
+ "
"
+ f"Hi {_html_escape(msg.to_name)},
"
+ "Thanks for your DataTools purchase. Your license is below.
"
+ ""
+ f"| License key | {_html_escape(msg.license_key)} |
"
+ f"| Tier | {_html_escape(msg.tier)} |
"
+ f"| Expires | {_html_escape(msg.expires_at_iso[:10])} |
"
+ "
"
+ "To activate, paste the blob below into the Activate "
+ "screen on first launch.
"
+ ""
+ f"{_html_escape(msg.blob)}"
+ "Keep this email — you'll "
+ "need the blob if you move to a new computer. Questions: just reply.
"
+ "— DataTools
"
+ )
+
+
+def _rfc_addr(name: str, email: str) -> str:
+ # Postmark accepts "Name " 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("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ )
diff --git a/server/app/main.py b/server/app/main.py
index 994949f..f40297f 100644
--- a/server/app/main.py
+++ b/server/app/main.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from fastapi import FastAPI
-from app.routes import internal, public
+from app.routes import internal, public, webhooks
app = FastAPI(
title="DataTools License Server",
@@ -16,3 +16,4 @@ app = FastAPI(
app.include_router(public.router)
app.include_router(internal.router)
+app.include_router(webhooks.router)
diff --git a/server/app/models.py b/server/app/models.py
index cc36e57..9fff4d1 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -16,6 +16,7 @@ from sqlalchemy import (
BigInteger,
DateTime,
Index,
+ Integer,
Numeric,
String,
UniqueConstraint,
@@ -29,6 +30,11 @@ from sqlalchemy.orm import Mapped, mapped_column
# (SQLite for tests). Same Python interface either way.
_JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
+# SQLite only auto-increments INTEGER PRIMARY KEY (not BIGINT).
+# Postgres can autoincrement either, so the variant keeps the
+# production migration on BigInteger while tests use Integer.
+_PK_TYPE = BigInteger().with_variant(Integer(), "sqlite")
+
from app.db import Base
@@ -77,7 +83,7 @@ class GumroadEvent(Base):
__tablename__ = "gumroad_events"
- id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+ id: Mapped[int] = mapped_column(_PK_TYPE, primary_key=True, autoincrement=True)
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
event_type: Mapped[str] = mapped_column(String, nullable=False)
order_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
diff --git a/server/app/products.py b/server/app/products.py
new file mode 100644
index 0000000..ce17ac4
--- /dev/null
+++ b/server/app/products.py
@@ -0,0 +1,71 @@
+"""Storefront product → license tier mapping.
+
+The mapping lives in ``server/config/products.yaml`` (gitignored
+for secrets it isn't — it's a routine catalog file) so adding a
+new SKU is one yaml edit plus a container restart. The lookup is
+``(source, product_id) -> (tier, years)``.
+
+Cached at module import. The runtime cost of reloading on every
+webhook would be trivial, but caching keeps the hot path
+allocation-free and makes the "edit yaml, restart api" idiom
+explicit — operators always know exactly when their changes go
+live.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+from typing import Optional
+
+import yaml
+
+
+@dataclass(frozen=True)
+class ProductMapping:
+ tier: str
+ years: int
+
+
+def _config_path() -> Path:
+ """Resolve the products config.
+
+ Container layout puts the config at ``/app/config/products.yaml``
+ (the Dockerfile COPYs ``server/config`` to ``/app/config``).
+ For local pytest runs we walk up from this file to ``server/``.
+ """
+ in_container = Path("/app/config/products.yaml")
+ if in_container.exists():
+ return in_container
+ return Path(__file__).resolve().parent.parent / "config" / "products.yaml"
+
+
+@lru_cache(maxsize=1)
+def _table() -> dict[tuple[str, str], ProductMapping]:
+ raw = yaml.safe_load(_config_path().read_text(encoding="utf-8")) or {}
+ table: dict[tuple[str, str], ProductMapping] = {}
+ for source, entries in raw.items():
+ for entry in entries or []:
+ key = (source, str(entry["product_id"]))
+ table[key] = ProductMapping(
+ tier=entry["tier"],
+ years=int(entry.get("years", 1)),
+ )
+ return table
+
+
+def lookup(source: str, product_id: str) -> Optional[ProductMapping]:
+ """Return the mapping for *(source, product_id)*, or None if unmapped.
+
+ Returning None (rather than raising) lets the webhook layer
+ decide whether to surface the failure as an audit row vs a
+ user-visible error — we want unmapped sales to be logged, not
+ to crash the handler and trigger Gumroad retry storms.
+ """
+ return _table().get((source, product_id))
+
+
+def reload_for_tests() -> None:
+ """Drop the cache. Tests that mutate the yaml call this."""
+ _table.cache_clear()
diff --git a/server/app/routes/webhooks.py b/server/app/routes/webhooks.py
new file mode 100644
index 0000000..ff90a32
--- /dev/null
+++ b/server/app/routes/webhooks.py
@@ -0,0 +1,121 @@
+"""Storefront webhook receivers.
+
+PR 2 wires Gumroad. Future storefronts each get their own route
+(``/webhooks/lemonsqueezy``, ``/webhooks/stripe``, ...). All share
+the same downstream flow: audit-log the raw payload, parse via
+adapter, mint, send email, mark processed.
+
+Handler contract
+----------------
+
+We **always** return 200 once a request authenticates, even on
+downstream failures. Gumroad retries non-2xx for ~3 days, which
+would turn a single broken sale into hours of duplicate webhook
+storms. Our idempotency keys (``UNIQUE(source, source_order_id)``)
+make at-least-once handling safe; the storefront retries on
+network errors only.
+
+When something downstream fails (unmapped product, DB error, email
+failure), we record the cause in ``gumroad_events.error`` so the
+operator can fix and replay.
+
+Unauthenticated requests return 404 — we don't want to signal
+endpoint existence or "wrong secret" to a prober.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
+from sqlalchemy.orm import Session
+
+from app.adapters.gumroad import GumroadAdapter, UnmappedProductError
+from app.config import get_settings
+from app.db import get_session
+from app.email import EmailDeliveryError, LicenseEmail, get_email_service
+from app.mint import mint_from_sale
+from app.models import GumroadEvent
+
+router = APIRouter(prefix="/webhooks")
+log = logging.getLogger(__name__)
+
+
+def _gumroad_adapter() -> GumroadAdapter:
+ settings = get_settings()
+ return GumroadAdapter(secret=settings.resolve_gumroad_secret())
+
+
+@router.post("/gumroad", status_code=200)
+async def gumroad(
+ request: Request,
+ secret: Optional[str] = Query(default=None),
+ session: Session = Depends(get_session),
+) -> dict:
+ adapter = _gumroad_adapter()
+
+ if not adapter.verify_secret(secret):
+ # 404 — no information leak about endpoint existence.
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
+
+ # Gumroad's Ping is form-encoded; FastAPI doesn't auto-parse
+ # without a Form() dependency, and we want the raw map for the
+ # audit log regardless of schema.
+ raw_form = await request.form()
+ payload = {k: str(v) for k, v in raw_form.items()}
+
+ # Audit row FIRST — any later failure leaves us a replayable record.
+ event = GumroadEvent(
+ event_type="sale",
+ order_id=payload.get("sale_id"),
+ raw_payload=payload,
+ )
+ session.add(event)
+ session.flush()
+
+ try:
+ sale = adapter.parse_sale(payload)
+ except UnmappedProductError as e:
+ event.error = str(e)
+ log.warning("Gumroad sale with unmapped product: %s", e)
+ return {"status": "logged-no-mint", "reason": "unmapped_product"}
+ except Exception as e: # pragma: no cover — defensive
+ event.error = f"parse error: {e!r}"
+ log.exception("Gumroad parse failure")
+ return {"status": "logged-no-mint", "reason": "parse_error"}
+
+ if sale is None:
+ event.error = "payload did not parse as a sale"
+ return {"status": "logged-no-mint", "reason": "not_a_sale"}
+
+ try:
+ row = mint_from_sale(session, sale)
+ session.flush()
+ except Exception as e: # pragma: no cover — defensive
+ event.error = f"mint error: {e!r}"
+ log.exception("mint_from_sale failed")
+ return {"status": "logged-no-mint", "reason": "mint_error"}
+
+ try:
+ get_email_service().send_license(
+ LicenseEmail(
+ to_name=row.name,
+ to_email=row.email,
+ tier=row.tier,
+ license_key=row.license_key,
+ expires_at_iso=row.expires_at.isoformat(),
+ blob=row.blob,
+ )
+ )
+ except EmailDeliveryError as e:
+ event.error = f"email error: {e}"
+ log.warning("Email delivery failed (license already minted): %s", e)
+ # The buyer can still be served from the DB via the renewal
+ # portal in PR 3 / a manual resend, so we don't fail the
+ # webhook over an email hiccup.
+ event.processed = True
+ return {"status": "minted-email-failed", "license_key": row.license_key}
+
+ event.processed = True
+ return {"status": "ok", "license_key": row.license_key}
diff --git a/server/compose.test.yml b/server/compose.test.yml
index 6fc5fdf..7eefde5 100644
--- a/server/compose.test.yml
+++ b/server/compose.test.yml
@@ -30,8 +30,10 @@ services:
environment:
DATABASE_URL: postgresql+psycopg://dt_test:test_pw@postgres:5432/dt_test
DATATOOLS_ADMIN_TOKEN: test-admin-token
+ GUMROAD_WEBHOOK_SECRET: test-gumroad-secret
# No DATATOOLS_LICENSE_PRIVKEY — falls back to the in-tree
# dev keypair, matching what the desktop dev build expects.
+ # No POSTMARK_TOKEN — falls back to LoggingEmailService.
ports:
- "127.0.0.1:18090:8000"
healthcheck:
diff --git a/server/config/products.yaml b/server/config/products.yaml
new file mode 100644
index 0000000..3049d59
--- /dev/null
+++ b/server/config/products.yaml
@@ -0,0 +1,34 @@
+# Storefront product → license tier mapping.
+#
+# Each storefront has its own product/variant IDs. The webhook
+# handler looks up (source, product_id) in this file to decide
+# what to mint. Unknown product IDs are an error (audit row gets
+# error="unmapped product", no license created — the operator
+# fixes the mapping and replays).
+#
+# After editing this file, `docker compose restart api` to reload.
+
+gumroad:
+ # Fill in real Gumroad product_ids once SKUs exist. Until then the
+ # examples below are placeholders that the test suite uses.
+ - product_id: "datatools-lite"
+ tier: lite
+ years: 1
+ - product_id: "datatools-core"
+ tier: core
+ years: 1
+ - product_id: "datatools-pro"
+ tier: pro
+ years: 1
+
+# Future storefronts slot in as siblings:
+#
+# lemonsqueezy:
+# - product_id: "12345"
+# tier: core
+# years: 1
+#
+# stripe:
+# - product_id: "prod_xxx"
+# tier: pro
+# years: 1
diff --git a/server/requirements.txt b/server/requirements.txt
index 67bccef..4391dc1 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -1,5 +1,6 @@
fastapi>=0.115,<0.120
uvicorn[standard]>=0.32,<0.40
+python-multipart>=0.0.20,<1
sqlalchemy>=2.0,<3
psycopg[binary]>=3.2,<4
alembic>=1.14,<2
@@ -8,3 +9,4 @@ pydantic-settings>=2.6,<3
email-validator>=2.2,<3
cryptography>=43,<46
httpx>=0.27,<1
+pyyaml>=6,<7
diff --git a/server/scripts/smoke.sh b/server/scripts/smoke.sh
index 4e310de..850b01e 100755
--- a/server/scripts/smoke.sh
+++ b/server/scripts/smoke.sh
@@ -78,6 +78,70 @@ echo "--- Verifying DB row ---"
"SELECT license_key, email, tier, source FROM licenses;" \
| grep -q smoke@example.com
+echo "--- POST /webhooks/gumroad (synthetic Ping payload) ---"
+WEBHOOK_RESP=$(curl -s -w "\nHTTP=%{http_code}" -X POST \
+ "http://127.0.0.1:18090/webhooks/gumroad?secret=test-gumroad-secret" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ --data-urlencode "sale_id=GUM-SMOKE-001" \
+ --data-urlencode "email=webhook@example.com" \
+ --data-urlencode "full_name=Webhook Tester" \
+ --data-urlencode "product_id=datatools-core" \
+ --data-urlencode "price=9900" \
+ --data-urlencode "currency=usd" \
+ --data-urlencode "test=true")
+WEBHOOK_CODE=$(echo "$WEBHOOK_RESP" | tail -n1 | sed 's/HTTP=//')
+WEBHOOK_BODY=$(echo "$WEBHOOK_RESP" | sed '$d')
+echo "$WEBHOOK_BODY" | python3 -m json.tool
+if [ "$WEBHOOK_CODE" != "200" ] || ! echo "$WEBHOOK_BODY" | grep -q '"status":"ok"'; then
+ echo "WEBHOOK FAILED (HTTP $WEBHOOK_CODE)"
+ "${COMPOSE[@]}" logs --tail 30 api
+ exit 1
+fi
+
+echo "--- Verifying Gumroad mint landed in DB ---"
+"${COMPOSE[@]}" exec -T postgres \
+ psql -U dt_test -d dt_test -t -c \
+ "SELECT license_key, email, tier, source, source_order_id FROM licenses WHERE source='gumroad';" \
+ | tee /dev/stderr | grep -q GUM-SMOKE-001
+
+echo "--- Verifying gumroad_events audit row ---"
+PROCESSED=$("${COMPOSE[@]}" exec -T postgres \
+ psql -U dt_test -d dt_test -At -c \
+ "SELECT processed FROM gumroad_events WHERE order_id='GUM-SMOKE-001' ORDER BY id LIMIT 1;")
+if [ "$PROCESSED" != "t" ]; then
+ echo "FAIL: gumroad_events.processed=$PROCESSED (expected 't')"
+ exit 1
+fi
+echo "audit row processed=true"
+
+echo "--- Verifying wrong-secret returns 404 ---"
+WRONG_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
+ "http://127.0.0.1:18090/webhooks/gumroad?secret=wrong-secret" \
+ --data-urlencode "sale_id=should-not-mint")
+if [ "$WRONG_CODE" != "404" ]; then
+ echo "FAIL: wrong-secret should return 404, got $WRONG_CODE"
+ exit 1
+fi
+echo "wrong-secret correctly rejected"
+
+echo "--- Verifying webhook idempotency on retry ---"
+curl -s -o /dev/null -X POST \
+ "http://127.0.0.1:18090/webhooks/gumroad?secret=test-gumroad-secret" \
+ --data-urlencode "sale_id=GUM-SMOKE-001" \
+ --data-urlencode "email=webhook@example.com" \
+ --data-urlencode "full_name=Webhook Tester" \
+ --data-urlencode "product_id=datatools-core" \
+ --data-urlencode "price=9900" \
+ --data-urlencode "currency=usd"
+ROW_COUNT=$("${COMPOSE[@]}" exec -T postgres psql -U dt_test -d dt_test -t -c \
+ "SELECT COUNT(*) FROM licenses WHERE source_order_id='GUM-SMOKE-001';" \
+ | tr -d ' ')
+if [ "$ROW_COUNT" != "1" ]; then
+ echo "FAIL: duplicate webhook produced $ROW_COUNT rows (expected 1)"
+ exit 1
+fi
+echo "idempotency OK: still 1 license row after retry"
+
echo
echo "===================================="
echo " SMOKE TEST PASSED"
diff --git a/server/tests/test_email.py b/server/tests/test_email.py
new file mode 100644
index 0000000..245be4c
--- /dev/null
+++ b/server/tests/test_email.py
@@ -0,0 +1,103 @@
+"""EmailService — Postmark client + dev-mode logging fallback."""
+
+from __future__ import annotations
+
+import logging
+from unittest.mock import patch
+
+import httpx
+import pytest
+
+from app.email import (
+ EmailDeliveryError,
+ LicenseEmail,
+ LoggingEmailService,
+ PostmarkEmailService,
+ _render_html,
+ _render_text,
+ get_email_service,
+)
+
+
+def _msg() -> LicenseEmail:
+ return LicenseEmail(
+ to_name="Jane Doe",
+ to_email="jane@example.com",
+ tier="core",
+ license_key="DT1-CORE-aaaa-bbbb",
+ expires_at_iso="2027-05-14T01:00:00Z",
+ blob="DTLIC2:placeholder",
+ )
+
+
+def test_render_text_contains_essentials():
+ body = _render_text(_msg())
+ assert "Jane Doe" in body
+ assert "DTLIC2:placeholder" in body
+ assert "DT1-CORE-aaaa-bbbb" in body
+ assert "core" in body
+ assert "2027-05-14" in body
+
+
+def test_render_html_escapes_user_input():
+ msg = LicenseEmail(
+ to_name="",
+ to_email="x@y.com",
+ tier="core",
+ license_key="K",
+ expires_at_iso="2030-01-01T00:00:00Z",
+ blob="DTLIC2:x",
+ )
+ html = _render_html(msg)
+ assert "