pal-e-mail
Notes
Plan 1
-
Plan: pal-e-mail — Centralized Email Service
plan-pal-e-mailVision
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 http://pal-e-mail.pal-e-mail.svc:8000. 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 {{key}}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: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)
Project Page 1
-
Project: pal-e-mail
project-pal-e-mailVision
Centralized, multi-tenant email service for the Pal-E platform. Any project sends branded emails via one API call. Email content lives here — not in app repos. Sending infrastructure (Gmail OAuth, token refresh, delivery logging) is platform-level, not duplicated per project. Templates compile from MJML repos and deploy to MinIO CDN. Custom composed emails wrap in project-specific brand layouts. Self-hosted, zero SaaS dependencies.
User Stories
Role Key Story Success Metric Platform Admin (Lucas) trigger-send Trigger bulk emails from admin panels (jersey reminders, tryout announcements, roster exports) without managing Gmail OAuth in each app repo One POST /send call per recipient. Zero email code in app repos. Platform Admin (Lucas) delivery-log See who got what email, when, and whether it failed — across all projects from one log GET /log with project, recipient, template, timestamp, status filters Platform Admin (Lucas) schedule-send Schedule an email for future delivery so I can prepare now and send later POST /send with send_at queues for delivery. GET /scheduled to view/cancel pending. Platform Admin (Lucas) sender-management See which Gmail senders are healthy vs expired and re-authorize from a browser — no SSH or kubectl GET /senders shows status. Browser-based re-auth flow when token expires. Platform Admin (Lucas) template-update Update email templates without redeploying app code CI compiles MJML → uploads to MinIO CDN → pal-e-mail fetches at send time App Service (automated) api-send Send registration confirmations, password resets, and notifications via centralized service App calls POST /send. No Gmail logic, no OAuth, no HTML rendering in app repo. End User (parents) branded-email Receive professional, mobile-responsive emails that render in Gmail/Outlook/Apple Mail MJML-compiled HTML with brand wrapper renders correctly across all major clients Consumer Projects
Projects that send email through pal-e-mail:
Project Sender Email Types Status Westside Basketball westsidebasketball@gmail.com Jersey reminders, tryout announcements, roster exports, profile reminders, registration confirmations, password resets Phase 4 migration (currently in basketball-api) mcd-tracker (future) Receipt confirmations, weekly summaries Backlog pal-e-docs (future) System notifications, share invitations Backlog Plan
Active:
plan-pal-e-mail(to be created)Board
board-pal-e-mail(to be created)Status
Phase 2 complete. Core Send API merged (PR #4). POST /send supports template mode (CDN fetch + {{var}} replacement), custom HTML mode (brand wrapper), and plain text. GET /log provides cross-project queryable email history with filtering and pagination. 28 tests, Woodpecker CI green. Phase 1 deployed: pod healthy, ArgoCD synced, Postgres with email_log table, Gmail OAuth PVC. MinIO network policy applied (pal-e-platform#144 — merged). Template CDN fetching unblocked.
Milestones
None yet.
Architecture
System Overview
Any App API pal-e-mail service POST /send ──────────────────────► FastAPI {sender, to, template, data} │ ├─► Fetch template from MinIO CDN (public URL) │ https://minio-api.ts.net/assets/email-templates/{project}/ │ ├─► String replace {{variables}} │ ├─► Load Gmail OAuth creds for sender │ /secrets/gmail/{sender}.json │ ├─► Gmail API send │ └─► Log to email_log table (project, to, template, status)Key Architectural Decisions
- Templates on MinIO CDN, not ConfigMaps — MJML CI compiles and uploads to
assets/email-templates/{project}/. Public-read. Service fetches via HTTP at send time. No k8s volume mounts, no pod restarts for template changes. - minio-api for uploads, CDN for reads — CI uploads templates through authenticated minio-api. Service reads via public CDN URL. Clean separation.
- Multi-tenant Gmail OAuth — one token file per sender identity. Service loads the right credentials based on
senderfield. Tokens on writable volume for refresh. - Template OR custom HTML — POST /send accepts either
template(fetched from CDN) orhtml(raw HTML, wrapped in brand layout). Supports both templated and ad-hoc emails. - No Jinja2, no template engine — simple
{{variable}}string replacement. MJML handles the rendering. Python handles the data injection. - Westside-emails migrates in — existing
westside-emailsrepo continues to own MJML source. CI uploads compiled HTML to MinIO. pal-e-mail fetches from there.
Repos
Repo Platform Role Status pal-e-mailForgejo FastAPI email sending service — multi-tenant, template or custom, Gmail OAuth planned westside-emailsForgejo MJML templates for Westside Basketball (compiles to HTML, uploads to MinIO) active (migrates to CDN delivery) Inbox
Query:
list_board_items(board_slug="board-pal-e-mail", column="backlog") - Templates on MinIO CDN, not ConfigMaps — MJML CI compiles and uploads to