Plan: pal-e-mail — Centralized Email Service

plan-pal-e-mail Plan

plan deprecated

Vision

Extract all email concerns from app repos into a platform service. Any project calls POST /send. The service handles templates, rendering, Gmail OAuth, and logging. Same tier as Keycloak (auth), MinIO (storage), Woodpecker (CI) — platform infrastructure that all projects consume.

Projects & Repos Touched

Project/Repo Platform Role in this plan
pal-e-mail Forgejo New service repo — FastAPI, Gmail OAuth, template rendering
pal-e-services Forgejo Terraform service registration (var.services)
pal-e-deployments Forgejo Kustomize overlay for ArgoCD
westside-emails Forgejo Template CI pipeline — compile MJML, upload to MinIO
basketball-api Forgejo Migration target — replace 825 lines with HTTP calls

Context

Email sending embedded in basketball-api (825 lines of Python: inline HTML, Gmail OAuth, brand wrappers, logging). March 21 jersey email session hit every anti-pattern: ConfigMap template delivery, read-only secret mount blocking token refresh, IPv6-only DNS breaking Gmail API, manual image deploys. Stopgap built (MJML templates in westside-emails, thin sender in basketball-api) but architecture is wrong — email logic shouldn't live in app repos.
What already exists:
  • gmail-sdk-ldraney v0.1.3 on Forgejo PyPI — OAuth token load/refresh/save, Gmail API send
  • westside-emails repo — MJML templates with partials, build script
  • MinIO CDN — assets bucket, public-read for images
  • minio-api — deployed, upload endpoints
  • _brand_wrapper() in basketball-api — branded email shell
  • EmailLog model in basketball-api — logs sent emails

Previous Plan

None. First plan for pal-e-mail project.

Depends On

No plan dependencies. gmail-sdk and MinIO CDN already deployed.

Decisions Made

Decision Rationale
Internal only — no Tailscale funnel Apps call via <code>http://pal-e-mail.pal-e-mail.svc:8000</code>. No public endpoint, no auth layer needed.
Own Postgres (CNPG in namespace) email_log table queryable via API. Full delivery audit trail.
gmail-sdk-ldraney as core dependency Already published on Forgejo PyPI. Handles OAuth token lifecycle.
Writable PVC for Gmail OAuth tokens Read-only secret mount blocked token refresh in basketball-api. PVC allows automatic refresh.
Simple string replace for templates basketball-api uses <code>{{key}}</code> replacement, not Jinja2. Keep it simple.

Phases

  • Phase 1: Service Scaffold + Deploy (COMPLETED — pod healthy, pipeline green, ArgoCD synced)
  • Phase 2: Core Send API (COMPLETED — PR #4 merged, 28 tests, POST /send + GET /log)
  • Phase 2b: Scheduled Send (NOT STARTED — send_at field, scheduled_emails table, CronJob worker, cancel endpoint)
  • Phase 2c: Sender Registry + Self-Service Re-Auth (NOT STARTED — senders table in Postgres, onboard/status/reauth endpoints, token health monitoring)
  • Phase 3: Template CI Pipeline (NOT STARTED — can parallel with Phase 2b/2c)
  • Phase 4: Basketball-api Migration (NOT STARTED — depends on 2+3)
  • Phase 5: Admin Dashboard + Monitoring (NOT STARTED — depends on 2)

Key Files

Phase File Repo Change
1 src/pal_e_mail/main.py pal-e-mail FastAPI app with /healthz
1 k3s.tfvars pal-e-services Add pal-e-mail to var.services
1 overlays/pal-e-mail/prod/ pal-e-deployments Kustomize overlay
2 src/pal_e_mail/routes/send.py pal-e-mail POST /send endpoint
2 src/pal_e_mail/services/sender.py pal-e-mail Multi-tenant Gmail OAuth client
2 src/pal_e_mail/services/template.py pal-e-mail CDN template fetcher + variable replacement
2 src/pal_e_mail/models.py pal-e-mail EmailLog + Sender config
3 .woodpecker.yaml westside-emails Template CI upload step
4 services/email.py basketball-api Replace with HTTP calls

Verification

  • [ ] curl https://pal-e-mail.tail5b443a.ts.net/healthz → 200
  • [ ] POST /send with template mode → email received with images
  • [ ] POST /send with custom HTML → email received in brand layout
  • [ ] Push to westside-emails → template at CDN URL within 60s
  • [ ] basketball-api sends all email types through pal-e-mail
  • [ ] GET /log?sender=westside returns send history
  • [ ] Gmail OAuth token refreshes automatically
  • [ ] Second sender onboarded without code changes

Epilogue

From Phase 1:
Discovered scope:
  • pal-e-platform#143 — ArgoCD DERP hairpin: all ArgoCD apps should use internal Forgejo URL (http://forgejo-http.forgejo.svc.cluster.local). ArgoCD repo-server can't reach external Tailscale funnel URLs reliably. Image Updater also affected (can't reach Harbor). Patched pal-e-mail app to use internal URL; others still use external. Same class of issue as Woodpecker CI clone fix.
  • Forgejo NetworkPolicy missing argocd namespace in ingress allow list — kubectl patched, terraform fix pending in network-policies.tf
  • First-deploy image tag must be full 40-char SHA (CI pushes full SHA via ${CI_COMMIT_SHA}, not 7-char short)
QA nits (PR #2, non-blocking):
  • Missing [build-system] table in pyproject.toml
  • Module-level engine creation in database.py (standard but noted for test isolation)
  • default vs server_default on EmailLog.status
  • Missing ArgoCD .argocd-source-* path exclude in .woodpecker.yaml
  • Duplicate httpx dep (in both deps and dev deps)

From Phase 2:
Discovered scope:
  • pal-e-platform#144 — MinIO network policy: add pal-e-mail namespace to MinIO ingress allow list. Without this, template CDN fetching from minio.minio.svc.cluster.local:9000 is blocked. Same DERP hairpin class — must use internal URL.
QA nits (PR #4, non-blocking):
  • body_html in brand_wrapper() inserted raw — add docstring clarifying trust boundary (callers responsible for sanitization; current callers are CDN templates and API-provided HTML)
  • html_to_plain_text() doesn't decode HTML entities (&mdash; stays literal). Future improvement for email preview quality.
  • Stale noqa: E501 on brand_wrapper signature (template.py:45) — line is under 120 chars, suppression can be removed
  • pytest-asyncio in dev dependencies (pyproject.toml:19) — not used, all code is sync. Remove.
  • data: dict[str, str] = {} in SendRequest — safe in Pydantic v2 but Field(default_factory=dict) is more explicit
  • service-onboarding-sop — the deployment SOP this plan follows
  • convention-kustomize-overlay — kustomize overlay pattern
  • deployment-lessons — operational lessons from prior service deploys
  • plan-wkq — Westside plan (Phase 11 jersey email was the catalyst)