Prima Get an API key

Module kit / Messaging, email and SMS

Email Template Management

messaging-email-sms/email-templates

Kind backend
Domain messaging-email-sms
Maturity broken
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12, resend-2.7, alembic-1.16
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/error-envelope, _shared/alembic-migrations, _shared/test-layout
Frontends messaging-email-sms/ui/email-template-editor
Reference implementation
  • alchemy_labs/backend/services/email_service.py
  • alchemy_labs/backend/api/routers/email_templates_router.py
  • alchemy_labs/backend/models/client_onboarding_model.py
  • alchemy_labs/backend/schemas/communication_schemas.py
  • alchemy_labs/backend/tests/unit/test_email_templates.py
  • alchemy_labs/backend/tests/test_critical_endpoints.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

An admin authors and edits the platform's system emails (magic link, welcome, onboarding, reminders, alerts) as rows in one global email_templates table, with {{variable}} placeholders the editor can fill with generated sample values, preview and test-send before anything reaches a real recipient. The invariant that makes editing safe is that render_template never raises and never silently drops what it does not recognise: a variable the author forgot to add to the template's variables list, or misspelled in the body, is left as the literal {{token}} in the rendered output rather than causing a 500 or vanishing (render_template, email_service.py 236 to 256; test_render_template_with_missing_variables, test_email_templates.py 51 to 67) -- an author can never break the send pipeline by referencing a variable name, only produce a visibly ugly preview.

When to use

Read it before touching EmailService.get_template/get_template_by_name/list_templates/create_template/update_template/delete_template, render_template, _generate_sample_variables, send_test_email, or preview_template (email_service.py 221 to 444); before changing any route under /api/templates/emails (email_templates_router.py); when someone asks "why doesn't the template editor load", "add a new variable to a template", "what does {{name}} resolve to in a test send", or "why did my preview come back with 400 instead of a not-found".

Not this recipe: the twelve-plus send_* methods in the same file (send_welcome_email, send_password_reset_email, send_onboarding_link, send_magic_link_email, etc.) that render a template row as a side effect of sending one specific kind of platform mail, falling back to a hardcoded Python string template when no active row of that name exists (send_welcome_email, 1119 to 1149, is the clearest example: get_template_by_name(db, "welcome"), and only on a miss does it fall through to the literal HTML at 1137 to 1147); the magic-link name-resolution chain built on top of that pattern, magic_link_template_names/_resolve_magic_link_template (447 to 491, ADR-0023, most-specific-surface-name-wins over magic_link__<surface> / magic_link__prima / magic_link); the transport funnel a rendered template is finally sent through, _send_email/_send_via_smtp_transport/_send_via_resend and the EMAIL_TRANSPORT switch (ADR-0023, module docstring 1 to 20); the per-workspace customer relay send_via_smtp (a clinic's own HIN gateway, a different contract, module docstring 16 to 19); SMSTemplate/SMSTemplateCreate and the SMS send path, the sibling schemas in the same file (communication_schemas.py 56 to 81), a separate table with its own character-count field, not this recipe's row shape; EmailLog, the delivery/open/click tracking table this recipe's own CRUD and test-send paths never write a row into (Gotchas).

Stack assumptions

At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 for the request/response schemas, resend==2.7.0 for one of the two send transports, alembic==1.16.5 for the four migrations under Data model / Deploy & ops. Foundations assumed: S4 (fastapi-project-layout) for app_fastapi.py and mount_router; S10-equivalent (auth-dependency-pattern) for AdminUser (api/dependencies.py 364 to 375, built on get_current_active_user); the platform error envelope (_shared/error-envelope) for every non-2xx body this router and its own try/except emit (Security & tenancy has the exact shapes, measured live). No workspace/tenancy foundation is assumed: EmailTemplate carries no workspace_id/tenant_id column and the router's DB dependency is plain DatabaseSession (api/dependencies.py 572, get_db), never DatabaseSessionWithRLS (573) -- this table is global across the whole Labs install, by construction, not by omission (Security & tenancy).

Environment, all in core/config.py: EMAIL_TRANSPORT (458, default "smtp"), RESEND_API_KEY (437, Optional[SecretStr]), EMAIL_SMTP_USERNAME/EMAIL_SMTP_PASSWORD (464 to 465), EMAIL_FROM_ADDRESS/EMAIL_FROM_NAME (438 to 439). None of these gate the CRUD or preview routes; they only gate whether send_test_email actually delivers (selected_transport()/transport_is_configured(), 48 to 77) -- at this commit, in a bare ENVIRONMENT=development boot with no SMTP credentials set, selected_transport() still reports "smtp" (the config-declared default) but _send_via_smtp_transport cannot authenticate, so send_test_email degrades to the {"status": "warning", ...} branch (email_service.py 403 to 412) rather than raising -- measured live via the warning logged at boot: EMAIL_TRANSPORT defaults to 'smtp' (Infomaniak) but EMAIL_SMTP_USERNAME, EMAIL_SMTP_PASSWORD are empty in ENVIRONMENT=development. Falling back to Resend for this process. (core/config.py 559 to 563).

Data model

email_templates, one row per named system email, global, no tenant column (models/client_onboarding_model.py 246 to 260; baselined as a pre-Alembic "phantom" table from a live staging pg_dump taken 2026-08-02, migrations/versions/baseline_prod_schema_001.py 7 to 28, 1012 to 1029, so this table already exists in the deployed schema independent of any migration in this recipe):

email_templates (
  id                uuid PRIMARY KEY,
  name              varchar NOT NULL,             -- UNIQUE (uq_email_templates_name)
  subject           varchar,
  preview_text      varchar,
  body_html         text,
  body_text         text,
  cta_button_text   varchar,
  cta_button_url    varchar,
  category          emailcategory,                -- onboarding | project_workflow | reminder | invoice | cold_outreach (core/enums.py 134-140); NULLable, and deliberately left NULL for auth mail (no category fits, migrations/versions/20260803_0004_seed_prima_magic_link_template.py 30-32)
  is_active         boolean DEFAULT true,
  variables         json,                         -- list of variable-name strings the editor offers; added by 20260205_add_email_template_variables.py, no DB-level CHECK that it actually holds a list of strings (SQLAlchemy models/client_onboarding_model.py 258: Column(JSON, default=list), an ORM-side default only)
  created_at        timestamptz DEFAULT now(),
  updated_at        timestamptz
);
INDEX ix_email_templates_category (category)
UNIQUE uq_email_templates_name (name)

EmailLog (262 to 282, email_logs) is the sibling delivery/open/click-tracking table, FK'd to email_templates.id; nothing in this recipe's CRUD or test/preview paths inserts a row into it -- the only writer in the codebase is outside the scope grepped for this recipe, and send_test_email/preview_template neither log nor track (Gotchas). StepEmailTrigger (342 to 353) is the one other FK consumer, a many-to-one from project_steps to one email_templates row per trigger, not touched by this recipe's routes.

What is NOT stored: a rendered/sent copy of any email (rendering is always on-the-fly, render_template produces a (subject, body_html) tuple that is never persisted); a distinction between "variables actually used in the body" and "variables listed in the variables column" -- the column is authorial metadata for the editor's variable picker, render_template does not read it at all, it substitutes whatever keys are in the variables dict it is given regardless of what the row's own variables column lists (email_service.py 236-256 never references template.variables).

API surface

Router email_templates_router.py, APIRouter(prefix="/templates/emails", tags=["Email Templates"]) (17), mounted at mount_router(app, email_templates_router.router, prefix="/api/templates/emails", ...) (app_fastapi.py 875). include_router's prefix kwarg is prepended in front of the router's own prefix, not merged with it -- the router already carries /templates/emails, so every route below is actually served at /api/templates/emails/templates/emails/..., not /api/templates/emails/.... Measured live at 62ba4fc by enumerating app.routes: all eight routes below appear with the doubled segment, and GET /api/templates/emails (the path the frontend calls, see Frontend) answers 404 Not Found, not 200. This is the actual bug, not a hypothetical one; see Gotchas.

Route (as actually mounted)AuthBodyCodes (measured live unless noted)Notes
GET /api/templates/emails/templates/emails (list_email_templates, router 20-23)AdminUser--200 List[EmailTemplateResponse]list_templates, ordered by EmailTemplate.name (email_service.py 231-234)
POST /api/templates/emails/templates/emails (create_email_template, 25-28)AdminUserEmailTemplateCreate201 EmailTemplateResponse; 409 {"error":"An email template with this name already exists.","code":"RES_003",...} on a duplicate name (email_service.py 306-308; status->code map, app_fastapi.py 740)create_template does EmailTemplate(**template_data.dict()) -- Pydantic v1-style .dict(), still works under pydantic 2.12 but is the deprecated spelling (.model_dump() is current)
GET /api/templates/emails/templates/emails/{template_id} (30-36)AdminUser--200 EmailTemplateResponse; 404 {"error":"Email template not found.","code":"RES_001",...} (measured live)
PUT /api/templates/emails/templates/emails/{template_id} (38-41)AdminUserEmailTemplateUpdate200 EmailTemplateResponse; 404 same shape as GET (measured live)update_template uses update_data.dict(exclude_unset=True) -- an omitted field is left alone, a field explicitly sent as null overwrites (email_service.py 323-325)
DELETE /api/templates/emails/templates/emails/{template_id} (43-47)AdminUser--204 (no body); 404 same shape (measured live)Hard delete, no soft-delete flag; StepEmailTrigger.email_template has no ondelete, so deleting a row a trigger still references would raise a raw IntegrityError (FK violation) rather than the 404/409 envelope -- not exercised by any test found
POST /api/templates/emails/templates/emails/test (send_test_email_template, 49-60)AdminUserSendTestEmailRequest {recipient_email, template_id}200 `{"status":"success""warning","message":...}on success/soft-fail; **400**{"error":"Failed to send test email: 404: Email template not found.","code":"VAL_001",...}on an unknowntemplate_id` (measured live)
POST /api/templates/emails/templates/emails/test-with-variables (send_test_email_with_variables, 63-75)AdminUserSendTestEmailWithVariablesRequest {recipient_email, template_id, variables: Dict[str,str]}Same shape and same 404-swallowed-into-400 behaviour as /test (74-75 is the identical except Exception pattern)custom_variables bypasses _generate_sample_variables entirely when provided (email_service.py 392-395)
POST /api/templates/emails/templates/emails/{template_id}/preview (preview_email_template, 78-89)AdminUserPreviewEmailRequest {variables: Dict[str,str] = {}}200 PreviewEmailResponse {subject, body_html, body_text}; 400 on an unknown template_id, same swallowed-404 pattern as /test (88-89; measured live: {"error":"Failed to generate preview: 404: Email template not found.",...})body_text in the response is the raw, unsubstituted template.body_text (email_service.py 443) -- render_template is only ever called on subject/body_html (438), so a {{name}} placeholder in the plain-text body is never filled in by this endpoint

Application logic

  1. Fetch: every mutating and read route resolves the row through EmailService.get_template/get_template_by_name, a single db.query(EmailTemplate).filter(...).first() (221-229); no caching, no lock, every request re-queries.
  2. Create (create_template, 302-314): a case-sensitive name uniqueness check via a SELECT before the INSERT (306-308, not a DB-level race-safe check -- two concurrent creates of the same name both pass the SELECT and one loses to the uq_email_templates_name constraint with a raw IntegrityError, not the 409 envelope; not exercised by any test found), then EmailTemplate(**template_data.dict()), add, commit, refresh.
  3. Update (update_template, 316-329): fetch-or-404, then update_data.dict(exclude_unset=True) applied field by field with setattr, commit, refresh -- no re-check of the name uniqueness constraint on the way out (a PUT renaming a template to a name that collides with another row relies on the DB constraint alone, again as a raw IntegrityError, not a 409).
  4. Delete (delete_template, 331-339): fetch-or-404, db.delete, commit; no guard against an in-use StepEmailTrigger row (Data model).
  5. Render (render_template, 236-256): subject/body_html each start from template.subject or "" / template.body_html or ""; for every (key, value) in the caller's variables dict, a literal str.replace("{{" + key + "}}", str(value)) is applied to both strings, in dict-iteration order -- no regex, no escaping of value (HTML injection is possible by design, test_render_template_special_characters_in_values, 86-105, asserts a raw <script> tag passes through unchanged), and a key with no matching {{key}} in the text is simply a no-op replace. Unmatched placeholders in the template text are left as literal {{...}} (see Purpose).
  6. Sample variables (_generate_sample_variables, 258-300): a fixed Python dict of ~19 known variable names (name, first_name, company_name, magic_link_url, reset_url, onboarding_url, dashboard_url, project_name, task_name, deliverable_name, phase_name, phase_description, hours_left, appointment_time, meeting_title, severity, alert_title, alert_message, timestamp), looked up per name in the template's variable_names list; a name not in the dict gets f"[{var_name}]" (298) so the preview/test send always has something human-readable in every slot, never a raw {{token}} when sample generation is the source.
  7. Preview (preview_template, 414-444): sample-generates only when variables is falsy (434, an empty {} from the default PreviewEmailRequest.variables counts as falsy and triggers sample generation, not "render with zero substitutions" -- a caller who genuinely wants to preview with no variables filled in cannot get that through this endpoint as shipped), then render_template, then returns body_text unrendered (API surface).
  8. Test send (send_test_email, 374-412): same falsy-custom_variables sample-generation branch (390-395, this one is a plain "if truthy" on custom_variables, not the PreviewEmailRequest default-{} case, since SendTestEmailWithVariablesRequest.variables defaults to {} too -- same effective behaviour), renders, sends through _send_email (the shared transport funnel), returns a soft {"status": "warning", ...} rather than raising when the transport is unconfigured or errors (403-412) -- the route's own except Exception at the router level therefore only ever fires for the HTTPException(404) on an unknown template, not for a send failure, which is swallowed one level deeper and turned into a 200 with status: "warning".

Invariants

  • render_template never raises for any input it is given (mock or real EmailTemplate, any variables dict): confirmed by test_render_template_basic_substitution, test_render_template_with_missing_variables, test_render_template_empty_variables, test_render_template_special_characters_in_values, test_render_template_url_variables (test_email_templates.py 26-125), none of which construct a real DB row -- all pass a MagicMock template, so this invariant is proven against the substitution function in isolation, not against a real fetched row.
  • A name collision on create is refused with 409 before any write is attempted, by an explicit SELECT (create_template, 306-308) -- not proven by any test found; the check is read-then-write, not atomic (Application logic step 2).
  • create_template/update_template/delete_template/get_template all require AdminUser, verified 401 without a token; get_current_admin_user additionally 403s a non-admin authenticated user (api/dependencies.py 364-375), not separately exercised for this router by any test found (test_create_template_requires_auth only checks the anonymous case, and fails at 62ba4fc for a different reason -- see Gotchas).
  • A missing template_id on /test, /test-with-variables and /preview never reaches the caller as 404 -- it is always a 400, because the router's own broad except Exception in each of those three handlers is written above the service layer's HTTPException(404) (API surface; measured live for /test and /preview, and identical code shape for /test-with-variables).

Frontend

The module that renders this, by id:

messaging-email-sms/ui/email-template-editor   the admin CRUD/preview/test-send screen; at 62ba4fc it is
                                                 alchemy_labs/frontend/src/pages/AdminEmailTemplateEditor.tsx
                                                 (460 lines: create/edit form, variable-chip picker driven by
                                                 the row's `variables` list, "Send test email" action) plus
                                                 alchemy_labs/frontend/src/pages/AdminCommunication.tsx (513
                                                 lines: the list/table view with inline preview and delete).
                                                 Both call the UNDOUBLED path, api.get('/api/templates/emails'),
                                                 api.post('/api/templates/emails', ...), api.put/delete(
                                                 `/api/templates/emails/${id}`), api.post(`.../${id}/preview`,
                                                 ...), api.post('/api/templates/emails/test', ...)
                                                 (AdminEmailTemplateEditor.tsx 118,148,155,180;
                                                 AdminCommunication.tsx 63,117,127) -- every one of those calls
                                                 404s against the real mount (API surface), so this screen is
                                                 currently non-functional end to end at this commit, not merely
                                                 unverified

Minimum admin/debug surface beyond the UI itself: none found -- no dedicated debug route, no admin CLI command grepped for email_templates. A human checking the table by hand runs SELECT id, name, category, is_active, variables FROM email_templates ORDER BY name; directly.

Security & tenancy

Auth dependency: AdminUser = Annotated[User, Depends(get_current_admin_user)] (api/dependencies.py 568), built on get_current_active_user -> get_current_user (187-263), which accepts a JWT bearer token, an httpOnly cookie, or an X-API-Key header, in that priority order, and raises the same credentials_exception (401, "Could not validate credentials") on all three misses (263). get_current_admin_user (364-375) then 403s a non-admin with "Admin access required". Every route in this router carries this dependency; there is no route-level override and no surface_exempt marker (contrast with the public, HMAC-signed seam pattern documented in _shared/hmac-seam-pattern -- this recipe's seam is a plain session, not a signature).

Tenancy: none. EmailTemplate has no workspace_id, the router's DB dependency is DatabaseSession (plain get_db, no RLS context), and any admin on the whole Labs install can read, edit or delete any template regardless of which workspace they are scoped to elsewhere in the product -- this is a deliberate platform-global table (Data model), not a tenancy bug, but it means this recipe is a poor match for a Prima surface that wants per-tenant template ownership; a rebuild wanting that adds a workspace_id column and an RLS policy from _shared/workspace-scoping, which this recipe does not currently need or use.

Fail direction: closed on auth (401/403 before the handler body runs, confirmed live for GET/PUT/DELETE by {template_id} at an unauthenticated request -- not separately proven for POST at 62ba4fc because of the routing 404, see Gotchas); the two-tier 404 (Application logic, API surface) means "not found" fails open in message clarity on /test//test-with-variables//preview (the caller does get told "not found" inside a 400's error string) but closed in machine-readable code (VAL_001, not RES_001) -- a caller branching on the error code rather than parsing the message will not distinguish "bad request" from "template does not exist" on those three routes.

Secrets/PII at rest: none of the six template columns are designed to hold PII (they are copy templates, not sent messages); a template author could still paste a real client's email or name into body_html as static text, which this recipe's schema and DB layer do nothing to prevent or scan for -- purely an authoring-discipline concern, not an enforced invariant. Rate limits: none dedicated to this router found; it rides whatever the global unified_middleware.py rate limiter applies to every authenticated admin route (not read for this recipe). Attacker-controlled input, all behind AdminUser: name/subject/body_html/body_text/cta_button_text/cta_button_url (free-text, unescaped into the rendered HTML -- Application logic step 5); variables dict values on /test-with-variables and /preview (also unescaped, same substitution function).

Tests

Run 2026-09-12 against the main worktree at 62ba4fc, alchemy_labs/backend/.venv (Python 3.13.7, pytest==7.4.3):

ENVIRONMENT=development SECRET_KEY=test-secret-key-min-32-characters-long-xyz \
JWT_SECRET_KEY=test-jwt-secret-key-min-32-characters-xyz \
JWT_REFRESH_SECRET_KEY=test-refresh-key-min-32-characters-xyza \
python -m pytest tests/unit/test_email_templates.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 3 failed, 20 passed (23 def test_ total, grep -c "^ def test_" tests/unit/test_email_templates.py confirms 23). The three failures, all pre-existing source/test drift, none caused by this recipe's own render/CRUD logic:

  • TestEmailServiceSampleVariables::test_generate_sample_variables_common_fields asserts "portal.alchemy.zuerich" in samples["magic_link_url"]; the real sample value at _generate_sample_variables line 274 is "https://labs.alchemy.zuerich/auth/magic-link?token=sample-token" -- the test still expects a portal. subdomain the source no longer uses.
  • TestEmailServiceResendIntegration::test_send_via_resend_with_api_key / test_send_via_resend_without_api_key both raise TypeError: expected string or bytes-like object, got 'MagicMock' inside Python's own email.utils.formataddr, because the test's mock_settings does not set EMAIL_FROM_NAME, and _send_via_resend (not read in full for this recipe, out of scope) now appears to format a From header using it.

Hermetic vs live: every rendering test in TestEmailTemplateRendering and TestEmailServiceSampleVariables (22-172) uses a bare MagicMock() in place of a real EmailTemplate/DB row -- none of them exercise a real Postgres fetch, so no test in this file proves get_template/create_template/update_template/delete_template against a real database. No CRUD-level unit test (create/update/delete business logic, the 409 duplicate-name path, exclude_unset update semantics) was found anywhere under backend/tests/.

test_critical_endpoints.py::TestEmailTemplatesEndpoints (509-556, plain TestClient(app), no dependency overrides -- unauthenticated by construction) is the only router-level (HTTP, not service-method) coverage found:

ENVIRONMENT=development SECRET_KEY=test-secret-key-min-32-characters-long-xyz \
JWT_SECRET_KEY=test-jwt-secret-key-min-32-characters-xyz \
JWT_REFRESH_SECRET_KEY=test-refresh-key-min-32-characters-xyza \
python -m pytest tests/test_critical_endpoints.py::TestEmailTemplatesEndpoints -q --no-cov -o addopts="" -p no:cacheprovider

prints 2 failed, 2 passed. test_update_template_requires_auth and test_delete_template_requires_auth pass (a PUT/DELETE to a made-up path correctly 404s, which happens to satisfy their own assertion list of [401,403,404]/[401,403,404]). test_list_templates_endpoint_exists fails (assert 404 == 200) and test_create_template_requires_auth fails (assert 404 in [401, 403]) -- both because the client hits the real, undoubled /api/templates/emails path, which 404s before FastAPI's routing ever reaches the AdminUser dependency (API surface). These two failures are themselves the clearest possible proof of the doubled-prefix bug: they read as "auth is missing" but the actual cause is "the route does not exist at that path."

What a new app adds and this branch lacks: a hermetic test against a real EmailTemplate row (create/read/update/delete through the service, not a MagicMock); a test asserting the 409 on a duplicate name; a test proving /test, /test-with-variables and /preview answer a genuine 404 (they currently cannot, by design of the router's own except Exception, see Application logic); a Playwright/E2E spec for AdminEmailTemplateEditor.tsx, none found.

Deploy & ops

Config keys: EMAIL_TRANSPORT, RESEND_API_KEY, EMAIL_SMTP_USERNAME, EMAIL_SMTP_PASSWORD, EMAIL_FROM_ADDRESS, EMAIL_FROM_NAME (all core/config.py, Stack assumptions) -- none specific to this recipe's CRUD/preview routes, all shared with every other send_* method in the file.

Migrations, in chain order (backend/migrations/versions/): the table itself is not created by any migration in this repo -- it is one of the ~101 pre-Alembic "phantom" tables baselined verbatim from a 2026-08-02 pg_dump of the live staging database (baseline_prod_schema_001.py 7-28, 1012-1029, revision baseline_prod_schema_001, down_revision=None, the root of the chain). 20260205_add_email_template_variables.py (revision 20260205_email_variables, down-revision 20260205_mcp_audit) idempotently adds the variables JSON column via add_column_if_missing (its own docstring at 27-28 notes the table is a phantom and the column may already be present). prima_magic_link_tpl_001 (20260803_0004_seed_prima_magic_link_template.py, down-revision flex_removal_converge_001, repointed from prima_flex_removal_001 per its own comment 51-56) is the only migration that inserts a row -- one Prima-branded magic_link__prima template, idempotent on name absence, category left NULL because no EmailCategory value fits authentication mail (30-32).

Scheduler/background jobs: none -- this recipe is request/response only, no async job touches email_templates.

Argus-style checks: none found dedicated to this table or router. A workable one given the measured bug: GET /api/templates/emails (the UNDOUBLED path) returning anything other than 404 would indicate the mount has been fixed.

Rollback: no data migration risk beyond the two migrations above, both reversible (drop_column, and a delete of the one seeded row by name). Fixing the doubled-prefix mount (dropping either the router's own prefix="/templates/emails" in email_templates_router.py 17, or the prefix="/api/templates/emails" passed at the mount_router call site in app_fastapi.py 875) is a one-line code change with no migration, but changes every URL the frontend calls, so it ships together with the frontend fix, not independently.

Gotchas

  • 2026-09-12 (measured at 62ba4fc, email_templates_router.py 17 + app_fastapi.py 875): the router is mounted with a doubled path segment. APIRouter(prefix="/templates/emails") already carries the prefix; mount_router(..., prefix="/api/templates/emails", ...) prepends a second, full copy of it via FastAPI's include_router(prefix=...). Every route in this recipe is actually served at /api/templates/emails/templates/emails/.... The frontend (AdminEmailTemplateEditor.tsx, AdminCommunication.tsx) and two tests in test_critical_endpoints.py all call the undoubled, intended path and get a 404. Enumerated live: for r in app.routes: ... prints eight /api/templates/emails/templates/emails... routes and zero /api/templates/emails routes. This is the exact class of bug this authoring pass was warned about for its own file path -- found here in the real source it documents, not manufactured for the example.
  • 2026-09-12 (measured at 62ba4fc, email_templates_router.py 59-60, 74-75, 88-89): the three POST handlers that wrap their service call in try/except Exception turn the service layer's own HTTPException(404, "Email template not found.") into a 400 with the 404's text folded into the message ("Failed to send test email: 404: Email template not found."), because HTTPException IS an Exception and the broad except runs before FastAPI's own exception machinery sees it. Confirmed live for /test and /{id}/preview; /test-with-variables shares the identical code shape and was not separately re-run.
  • 2026-09-12 (measured at 62ba4fc, test_email_templates.py 144, 254-282): three of the 23 tests in the primary rendering test file fail on a clean checkout, independent of anything in this recipe's own logic -- a stale asserted domain (portal.alchemy.zuerich vs the real labs.alchemy.zuerich) and a MagicMock-vs-formataddr incompatibility in two Resend tests that predate EMAIL_FROM_NAME being read at send time. Listed in full under Tests.
  • 2026-09-12 (measured at 62ba4fc): preview_template (email_service.py 414-444) returns body_text as the template's raw, unrendered column value -- the only one of the three response fields render_template never touches. An author previewing a template whose plain-text body uses {{variable}} placeholders sees them un-substituted in the preview, silently, with no error and no difference in the response shape that would hint at it.
  • 2026-09-12 (measured at 62ba4fc): create_template's duplicate-name guard (306-308) is a SELECT-then-INSERT, not a single atomic constraint check; two concurrent creates of the same name can both pass the SELECT and race to the uq_email_templates_name unique index, and the loser gets a raw IntegrityError (a 500 through the generic exception path, not this router's 409 envelope). Not exercised by any test found.

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/email_service.py (2805 lines, module docstring on transports 1-20): EmailService.get_template/get_template_by_name/list_templates (221-234); render_template (236-256); _generate_sample_variables (258-300); create_template (302-314); update_template (316-329); delete_template (331-339); send_test_email (374-412); preview_template (414-444); magic_link_template_names/_resolve_magic_link_template (447-491, cited for the "not this recipe" boundary); send_welcome_email (1119-1149, cited for the same boundary); selected_transport/transport_is_configured/redirect_target/apply_redirect (48-121); _send_email (1305-1332); the module-level singleton email_service = EmailService() (last line of the file).
  • alchemy_labs/backend/api/routers/email_templates_router.py (88 lines, whole file in scope): APIRouter(prefix="/templates/emails", ...) (17); all eight handlers (20-89).
  • alchemy_labs/backend/app_fastapi.py: the mount call (875); the global http_exception_handler and its status->code map (724-745, cited for the exact error envelope shapes measured under API surface and Security & tenancy).
  • alchemy_labs/backend/models/client_onboarding_model.py (403 lines): EmailTemplate (246-260); EmailLog (262-282, cited for what this recipe does NOT write to); StepEmailTrigger (342-353, cited for the FK this recipe's DELETE does not guard against).
  • alchemy_labs/backend/schemas/communication_schemas.py (81 lines, Email Template section in scope): EmailTemplateBase/Create/Update/Response (11-35); SendTestEmailRequest/SendTestEmailWithVariablesRequest/PreviewEmailRequest/PreviewEmailResponse (37-52).
  • alchemy_labs/backend/schemas/error_schemas.py: ErrorCode enum, specifically VALIDATION_ERROR = "VAL_001" (29), RESOURCE_NOT_FOUND = "RES_001" (35), RESOURCE_CONFLICT = "RES_003" (37).
  • alchemy_labs/backend/api/dependencies.py: DatabaseSession (572); get_current_user (187-263); get_current_admin_user (364-375).
  • alchemy_labs/backend/migrations/versions/baseline_prod_schema_001.py: module docstring (1-28); the email_templates CREATE TABLE (1012-1029) and its constraints/index (2472-2475, 2952-2955, 3766-3768).
  • alchemy_labs/backend/migrations/versions/20260205_add_email_template_variables.py (39 lines, whole file in scope).
  • alchemy_labs/backend/migrations/versions/20260803_0004_seed_prima_magic_link_template.py: module docstring (1-40); revision pointers (50-56).
  • alchemy_labs/frontend/src/pages/AdminEmailTemplateEditor.tsx (460 lines) and AdminCommunication.tsx (513 lines): the fetch call sites cited under Frontend.
  • Tests: alchemy_labs/backend/tests/unit/test_email_templates.py (495 lines, 23 def test_); alchemy_labs/backend/tests/test_critical_endpoints.py TestEmailTemplatesEndpoints (509-556) and its module-scoped client/app fixtures (34-44).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: the doubled router-prefix mount makes every route unreachable at its intended path; the three POST handlers cannot surface a real 404; preview_template never renders body_text; the duplicate-name and FK-in-use guards are not atomic/enforced at the application layer; no CRUD-level test exists against a real database row.

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc with the venv at alchemy_labs/backend/.venv (Python 3.13.7). Expected observations are exact, as measured 2026-09-12.

  1. ENVIRONMENT=development SECRET_KEY=test-secret-key-min-32-characters-long-xyz JWT_SECRET_KEY=test-jwt-secret-key-min-32-characters-xyz JWT_REFRESH_SECRET_KEY=test-refresh-key-min-32-characters-xyza python -m pytest tests/unit/test_email_templates.py -q --no-cov -o addopts="" -p no:cacheprovider prints 3 failed, 20 passed.
  2. The same environment variables with python -m pytest tests/test_critical_endpoints.py::TestEmailTemplatesEndpoints -q --no-cov -o addopts="" -p no:cacheprovider prints 2 failed, 2 passed.
  3. grep -c "^ def test_" tests/unit/test_email_templates.py prints 23.
  4. python -c "from app_fastapi import app; print(sorted(r.path for r in app.routes if 'templates/emails' in r.path))" (with the same three env vars set) prints eight paths, every one starting /api/templates/emails/templates/emails, and none equal to /api/templates/emails or /api/templates/emails/{template_id}.
  5. grep -n 'prefix="/templates/emails"' api/routers/email_templates_router.py prints line 17; grep -n 'templates/emails"' app_fastapi.py prints line 875 with prefix="/api/templates/emails" on it -- the two prefixes concatenated is the bug in step 4.
  6. grep -n "except Exception as e:" api/routers/email_templates_router.py prints three lines (59, 74, 88), one per POST route (/test, /test-with-variables, /preview) named in API surface as swallowing the service layer's 404.
  7. grep -n "^def render_template" services/email_service.py prints line 236, and grep -n "template.variables" services/email_service.py finds no occurrence inside that function's body (236-256), confirming render_template never consults the row's own variables column (Data model).

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.