Plan: pal-e-mail — Centralized Email Service
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-ldraneyv0.1.3 on Forgejo PyPI — OAuth token load/refresh/save, Gmail API sendwestside-emailsrepo — MJML templates with partials, build script- MinIO CDN —
assetsbucket, public-read for images minio-api— deployed, upload endpoints_brand_wrapper()in basketball-api — branded email shellEmailLogmodel 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 /sendwith template mode → email received with images - [ ]
POST /sendwith 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=westsidereturns send history - [ ] Gmail OAuth token refreshes automatically
- [ ] Second sender onboarded without code changes
Epilogue
From Phase 1:
Discovered scope:
From Phase 2:
Discovered scope:
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)
- Missing
[build-system]table in pyproject.toml - Module-level engine creation in database.py (standard but noted for test isolation)
defaultvsserver_defaulton 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: addpal-e-mailnamespace to MinIO ingress allow list. Without this, template CDN fetching fromminio.minio.svc.cluster.local:9000is blocked. Same DERP hairpin class — must use internal URL.
body_htmlinbrand_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 (—stays literal). Future improvement for email preview quality.- Stale
noqa: E501onbrand_wrappersignature (template.py:45) — line is under 120 chars, suppression can be removed pytest-asyncioin dev dependencies (pyproject.toml:19) — not used, all code is sync. Remove.data: dict[str, str] = {}in SendRequest — safe in Pydantic v2 butField(default_factory=dict)is more explicit
Related
service-onboarding-sop— the deployment SOP this plan followsconvention-kustomize-overlay— kustomize overlay patterndeployment-lessons— operational lessons from prior service deploysplan-wkq— Westside plan (Phase 11 jersey email was the catalyst)