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 |
|
| 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) | Auth | Body | Codes (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) | AdminUser | EmailTemplateCreate | 201 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) | AdminUser | EmailTemplateUpdate | 200 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) | AdminUser | SendTestEmailRequest {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) | AdminUser | SendTestEmailWithVariablesRequest {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) | AdminUser | PreviewEmailRequest {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
- Fetch: every mutating and read route resolves the row through
EmailService.get_template/get_template_by_name, a singledb.query(EmailTemplate).filter(...).first()(221-229); no caching, no lock, every request re-queries. - Create (
create_template, 302-314): a case-sensitivenameuniqueness 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 theuq_email_templates_nameconstraint with a rawIntegrityError, not the 409 envelope; not exercised by any test found), thenEmailTemplate(**template_data.dict()),add,commit,refresh. - Update (
update_template, 316-329): fetch-or-404, thenupdate_data.dict(exclude_unset=True)applied field by field withsetattr,commit,refresh-- no re-check of thenameuniqueness 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 rawIntegrityError, not a 409). - Delete (
delete_template, 331-339): fetch-or-404,db.delete,commit; no guard against an in-useStepEmailTriggerrow (Data model). - Render (
render_template, 236-256):subject/body_htmleach start fromtemplate.subject or ""/template.body_html or ""; for every(key, value)in the caller'svariablesdict, a literalstr.replace("{{" + key + "}}", str(value))is applied to both strings, in dict-iteration order -- no regex, no escaping ofvalue(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). - 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'svariable_nameslist; a name not in the dict getsf"[{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. - Preview (
preview_template, 414-444): sample-generates only whenvariablesis falsy (434, an empty{}from the defaultPreviewEmailRequest.variablescounts 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), thenrender_template, then returnsbody_textunrendered (API surface). - Test send (
send_test_email, 374-412): same falsy-custom_variablessample-generation branch (390-395, this one is a plain "if truthy" oncustom_variables, not thePreviewEmailRequestdefault-{}case, sinceSendTestEmailWithVariablesRequest.variablesdefaults 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 ownexcept Exceptionat the router level therefore only ever fires for theHTTPException(404)on an unknown template, not for a send failure, which is swallowed one level deeper and turned into a 200 withstatus: "warning".
Invariants
render_templatenever raises for any input it is given (mock or realEmailTemplate, anyvariablesdict): confirmed bytest_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.py26-125), none of which construct a real DB row -- all pass aMagicMocktemplate, so this invariant is proven against the substitution function in isolation, not against a real fetched row.- A
namecollision 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_templateall requireAdminUser, verified 401 without a token;get_current_admin_useradditionally 403s a non-admin authenticated user (api/dependencies.py364-375), not separately exercised for this router by any test found (test_create_template_requires_authonly checks the anonymous case, and fails at 62ba4fc for a different reason -- see Gotchas).- A missing
template_idon/test,/test-with-variablesand/previewnever reaches the caller as 404 -- it is always a 400, because the router's own broadexcept Exceptionin each of those three handlers is written above the service layer'sHTTPException(404)(API surface; measured live for/testand/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_fieldsasserts"portal.alchemy.zuerich" in samples["magic_link_url"]; the real sample value at_generate_sample_variablesline 274 is"https://labs.alchemy.zuerich/auth/magic-link?token=sample-token"-- the test still expects aportal.subdomain the source no longer uses.TestEmailServiceResendIntegration::test_send_via_resend_with_api_key/test_send_via_resend_without_api_keyboth raiseTypeError: expected string or bytes-like object, got 'MagicMock'inside Python's ownemail.utils.formataddr, because the test'smock_settingsdoes not setEMAIL_FROM_NAME, and_send_via_resend(not read in full for this recipe, out of scope) now appears to format aFromheader 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.py17 +app_fastapi.py875): 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'sinclude_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 intest_critical_endpoints.pyall call the undoubled, intended path and get a404. Enumerated live:for r in app.routes: ...prints eight/api/templates/emails/templates/emails...routes and zero/api/templates/emailsroutes. 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.py59-60, 74-75, 88-89): the three POST handlers that wrap their service call intry/except Exceptionturn the service layer's ownHTTPException(404, "Email template not found.")into a400with the 404's text folded into the message ("Failed to send test email: 404: Email template not found."), becauseHTTPExceptionIS anExceptionand the broadexceptruns before FastAPI's own exception machinery sees it. Confirmed live for/testand/{id}/preview;/test-with-variablesshares the identical code shape and was not separately re-run. - 2026-09-12 (measured at 62ba4fc,
test_email_templates.py144, 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.zuerichvs the reallabs.alchemy.zuerich) and aMagicMock-vs-formataddrincompatibility in two Resend tests that predateEMAIL_FROM_NAMEbeing read at send time. Listed in full under Tests. - 2026-09-12 (measured at 62ba4fc):
preview_template(email_service.py 414-444) returnsbody_textas the template's raw, unrendered column value -- the only one of the three response fieldsrender_templatenever 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-nameguard (306-308) is a SELECT-then-INSERT, not a single atomic constraint check; two concurrent creates of the samenamecan both pass the SELECT and race to theuq_email_templates_nameunique index, and the loser gets a rawIntegrityError(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 singletonemail_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 globalhttp_exception_handlerand 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:ErrorCodeenum, specificallyVALIDATION_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); theemail_templatesCREATE 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) andAdminCommunication.tsx(513 lines): the fetch call sites cited under Frontend.- Tests:
alchemy_labs/backend/tests/unit/test_email_templates.py(495 lines, 23def test_);alchemy_labs/backend/tests/test_critical_endpoints.pyTestEmailTemplatesEndpoints(509-556) and its module-scopedclient/appfixtures (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.
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:cacheproviderprints3 failed, 20 passed.- The same environment variables with
python -m pytest tests/test_critical_endpoints.py::TestEmailTemplatesEndpoints -q --no-cov -o addopts="" -p no:cacheproviderprints2 failed, 2 passed. grep -c "^ def test_" tests/unit/test_email_templates.pyprints23.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/emailsor/api/templates/emails/{template_id}.grep -n 'prefix="/templates/emails"' api/routers/email_templates_router.pyprints line17;grep -n 'templates/emails"' app_fastapi.pyprints line875withprefix="/api/templates/emails"on it -- the two prefixes concatenated is the bug in step 4.grep -n "except Exception as e:" api/routers/email_templates_router.pyprints 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.grep -n "^def render_template" services/email_service.pyprints line236, andgrep -n "template.variables" services/email_service.pyfinds no occurrence inside that function's body (236-256), confirmingrender_templatenever consults the row's ownvariablescolumn (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.