Module kit / Messaging, email and SMS
SMS Templates and Twilio Transport
messaging-email-sms/sms-transport
| Kind | backend |
|---|---|
| Domain | messaging-email-sms |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118.2, sqlalchemy-2.0.43, sqlalchemy-sync, pydantic-2.12.0, psycopg2-binary-2.9.10, twilio-9.5.1, postgres |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/alembic-migrations |
| Frontends | messaging-email-sms/ui/email-template-editor |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
Admins CRUD a small library of SMS templates and the platform renders one against a dict of variables to send it through Twilio, logging the outcome; the scheduler also sends a handful of hardcoded (non-template) booking-reminder SMS through the same transport (services/scheduled_jobs.py 1403, 1467). The invariant the recipe scope names, "the inline SMSLog row written on both success and failure", does not hold at this commit: both write sites pass a template_name keyword to SMSLog(...), a column that does not exist on the model, so the write raises TypeError and is silently swallowed by an enclosing except Exception that only logs a warning (sms_service.py 161-174, 184-197; confirmed live against this commit's model, see Gotchas). What is actually trustworthy is narrower: a send either reaches Twilio and returns its message SID, or fails and returns an error string, both without ever raising into the caller.
When to use
Trigger phrases: "SMS template", "SMS_ENABLED", "mock SMS", "Twilio SMS", "{{variable}} SMS", "SMSLog", "booking reminder SMS", "test SMS". Read it before touching SMSService.send_sms, send_from_template, send_test_sms, send_booking_reminder, or the five CRUD routes under /templates/sms.
Not this recipe: platform email templates and Resend/SMTP transport (services/email_service.py, api/routers/mail_router.py, a neighbouring messaging-email-sms recipe); Telegram delivery (domain description names it, no Telegram code was found under services/ or api/routers/ at this commit, see Gotchas); the calendar booking/confirmation-token logic that builds the URL passed into send_booking_reminder (services/scheduled_jobs.py, calendar-booking domain); the cancellation-on-no-confirm flow that calls send_sms directly with an inline hardcoded string (cancel_unconfirmed_bookings, scheduled_jobs.py, cited here for context, not this recipe's subject).
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), twilio==9.5.1 (twilio.rest.Client, lazy-constructed), pydantic==2.12.0, psycopg2-binary==2.9.10. SMSService.create_template/update_template call the Pydantic v1-style .dict() method (sms_service.py 60, 75) rather than model_dump(); it still works under pydantic 2.12 (deprecated alias) but a rebuild on a pydantic version that drops the alias breaks both methods. Foundations assumed: S4 (fastapi-project-layout) for mount_router and the app; S5 (auth-dependency-pattern) for AdminUser/DatabaseSession (api/dependencies.py 364-375, 568, 572); S8 (alembic-migrations) for the baseline-reconciliation pattern the two tables ship under (Data model).
Environment, core/config.py 570-573: TWILIO_ACCOUNT_SID (str, optional), TWILIO_AUTH_TOKEN (SecretStr, optional), TWILIO_PHONE_NUMBER (str, optional, E.164), SMS_ENABLED (bool, default=False). Fail behaviour is entirely soft: get_twilio_client() returns None if either credential is unset or the SDK import/construction raises (sms_service.py 23-36, exception logged, never raised); send_sms never raises for any of these three env gaps, it returns {"status": "failed", "error": "..."} for a missing client or missing phone number (138-148), or {"status": "mock", ...} when SMS_ENABLED is false (129-135, and prints the mock payload to stdout, not just the log).
Data model
Two tables, no workspace/tenant column on either (models/client_onboarding_model.py 284-309; baseline SQL, migrations/versions/baseline_prod_schema_001.py 1798-1829):
sms_templates (
id uuid PRIMARY KEY,
name varchar NOT NULL, -- UNIQUE (uq_sms_templates_name)
message_body text NOT NULL,
category smscategory, -- onboarding | reminder | payment | project_update (core/enums.py 154-159)
character_count integer, -- set by the service, len(message_body), never enforced by a CHECK
is_active boolean,
created_at timestamptz DEFAULT now(),
updated_at timestamptz
);
CREATE INDEX ix_sms_templates_category ON sms_templates (category);
sms_logs (
id uuid PRIMARY KEY,
sms_template_id uuid REFERENCES sms_templates(id), -- FK exists; sms_service.py never sets it (see Gotchas)
recipient_phone varchar NOT NULL,
recipient_name varchar,
message_body text,
status smsstatus, -- queued | sent | delivered | failed (core/enums.py 162-167), DEFAULT queued
twilio_message_sid varchar, -- UNIQUE (uq_sms_logs_twilio_message_sid)
metadata json, -- Python attr meta_data; never populated by sms_service.py
sent_at timestamptz,
delivered_at timestamptz, -- never written by any code found at this commit (no delivery webhook)
failed_at timestamptz, -- never written; the failure path sets no timestamp column at all
failure_reason varchar, -- never written; sms_service.py writes a non-existent `error_message` instead
created_at timestamptz DEFAULT now()
);
CREATE INDEX ix_sms_logs_recipient_phone ON sms_logs (recipient_phone);
CREATE INDEX ix_sms_logs_status ON sms_logs (status);
No CHECK constraint on either table at this commit (only the two UNIQUEs and the two secondary indexes above; the FK from sms_logs.sms_template_id is added separately by deferred_fk_phantom_tables_001.py 733-736, in line with the baseline's "phantom table" reconciliation pattern, S8). What is NOT stored: no per-template variables: List[str] declaration the way EmailTemplateBase has one (schemas/communication_schemas.py 14-21 vs 54-58) - an SMS template's {{placeholders}} are discoverable only by reading message_body; no booking_id column on sms_logs even though send_sms/send_from_template/send_booking_reminder all accept a booking_id keyword (sms_service.py 102, 210, 269) - it is accepted and silently dropped, never referenced again in any of the three functions; no delivery-status webhook wiring exists to ever move a row from queued/sent to delivered or set delivered_at.
API surface
Router sms_templates_router.py 16, declared with its own prefix="/templates/sms", then mounted a second time with prefix="/api/templates/sms" (app_fastapi.py 876) - FastAPI concatenates both prefixes, so every route below actually lives at /api/templates/sms/templates/sms..., not /api/templates/sms/... (confirmed by constructing the same APIRouter+include_router call against this router's exact prefix in a throwaway FastAPI app at this commit; see Gotchas and Acceptance checks).
| Route (as mounted) | Auth | Tenancy | Codes | Notes |
|---|---|---|---|---|
GET /api/templates/sms/templates/sms (19-22) | AdminUser | none (global) | 200 List[SMSTemplateResponse] | ordered by SMSTemplate.name (sms_service.py 49) |
POST /api/templates/sms/templates/sms (24-27) | AdminUser | none | 201 SMSTemplateResponse; 409 if name already exists (create_template, 55-57) | character_count = len(message_body) computed server-side |
GET /api/templates/sms/templates/sms/{template_id} (29-35) | AdminUser | none | 200; 404 | |
PUT /api/templates/sms/templates/sms/{template_id} (37-40) | AdminUser | none | 200; 404 (raised inside update_template, 72-73) | exclude_unset=True partial update; character_count recomputed only if message_body is one of the changed keys (79-81) |
DELETE /api/templates/sms/templates/sms/{template_id} (42-46) | AdminUser | none | 204; 404 | hard delete, no sms_logs.sms_template_id is ever populated so nothing references it anyway |
POST /api/templates/sms/templates/sms/test (48-55) | AdminUser | none | 200 {"status":"success","message":...}; 400 on any exception | except Exception as e: raise HTTPException(400, ...) wraps send_test_sms, which itself raises HTTPException(404, ...) when the template id doesn't exist (sms_service.py 251) - that 404 is an Exception too, so it is re-caught and re-raised as 400, not 404 (measured by reading both call sites; no test exercises this) |
AdminUser = Annotated[User, Depends(get_current_admin_user)] (api/dependencies.py 568, 364-375): 403 if current_user.is_admin is false. The mount also adds a SurfaceGate(LABS_SURFACES) dependency ahead of the route (mount_router, api/surface_gate.py 265-274) - a second, surface-level gate, not detailed further here (S5's scope).
Application logic
send_sms (sms_service.py 97-202), in order:
- Reject if
recipient_phoneis falsy or does not start with"+"(121-126) - this is the entirety of the "E.164 validation"; no length or digit check,+abcpasses. - If
settings.SMS_ENABLEDis false: print a bannered mock payload to stdout, log it, return{"status": "mock", "message": "SMS sending disabled"}(129-135). Nothing is written tosms_logson this path. - Resolve the Twilio client (
get_twilio_client, 23-36, module-level singleton, lazily constructed fromTWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN); missing client or missingTWILIO_PHONE_NUMBERreturns a"failed"dict, no exception (138-148). client.messages.create(body=message, from_=TWILIO_PHONE_NUMBER, to=recipient_phone)(152-156).- On success: attempt to write an
SMSLog(recipient_phone=..., message_body=..., template_name=..., twilio_message_sid=..., status="sent", sent_at=utcnow())(163-170) - raisesTypeError: 'template_name' is an invalid keyword argument for SMSLog, caught byexcept Exception as log_error: logger.warning(...)(173-174); the row is never inserted, only a warning is logged. Returns{"status": "sent", "message_sid": ...}regardless. - On any exception from step 4 (Twilio raising): attempt to write
SMSLog(recipient_phone=..., message_body=..., template_name=..., status="failed", error_message=str(e))(186-192) - sametemplate_nameTypeError(the model also has noerror_messagecolumn,failure_reasonis the real one, but the constructor never reaches that kwarg becausetemplate_nameraises first), caught the same way (196-197). Returns{"status": "failed", "error": str(e)}regardless.
send_from_template (204-243): looks up SMSTemplate by name (227); not found returns a "failed" dict, no exception. Rendering (232-235) is a plain Python loop, body.replace(f"{{{{{key}}}}}", str(value)) per (key, value) in the caller's dict - literal {{key}} substring replace, not a template engine: no escaping, a {{var}} whose key is absent from dynamic_data is left verbatim in the outgoing SMS, and nothing validates that the dict covers every placeholder in message_body.
send_test_sms (245-258): fetches the template by id via get_template (raises HTTPException(404) itself if missing, 251), then calls send_from_template with a second, redundant lookup by name and a hardcoded payload {"client_name": "Test User", "time_remaining": "3 hours"} (256) - a template using any other placeholder name renders with that literal {{placeholder}} left in the test message.
send_booking_reminder (260-308): bypasses SMSTemplate/send_from_template entirely - the 24h and 1h reminder bodies are hardcoded German f-strings built inline (287-300); template_name="booking_reminder_24h"/"booking_reminder_1h" is passed only as the (broken) SMSLog label, never used to look anything up.
Invariants
- A send never raises into its caller:
send_sms's Twilio call is the only network I/O and it is wrapped intry/except Exceptionthat always returns a status dict (150-202). No automated test asserts this; verified by reading. SMS_ENABLED=Falsenever reaches Twilio: the check at 129 is unconditional and precedes client construction. No automated test asserts this either.- Not actually held at this commit: "an
SMSLogrow is written on both success and failure" - both write sites raise and are swallowed (see Application logic steps 5-6, Gotchas). character_countonSMSTemplateis a snapshot taken at write time (create_template61,update_template80-81), never recomputed on read; editingmessage_bodythrough any path other than these two service methods (e.g. a direct SQL update) leaves it stale.
Frontend
messaging-email-sms/ui/email-template-editor the module id this recipe is required to name (task
convention for this domain); at 62ba4fc it renders the
EMAIL side of AdminCommunication.tsx for real (see
below) - no SMS-specific editor module exists in this
repo's frontend to point to instead
Measured, not inferred: frontend/src/pages/AdminCommunication.tsx has an activeTab: 'email' | 'sms' toggle. The email tab calls the real API (api.get('/api/templates/emails') line 63, api.delete('/api/templates/emails/${id}') 117, api.post('.../preview', ...) 127). The SMS tab's fetchTemplates branch never calls the network at all - setSmsTemplates([...]) is a four-item hardcoded array with a comment reading "SMS templates - still using mock data for now" (66-99); there is no create/edit/delete/test call anywhere in the file for SMS. The mock objects even carry a usage_count field (e.g. 42) that does not exist anywhere in SMSTemplateResponse or the sms_templates table. Minimum admin/debug surface for a rebuild: the five CRUD routes above are complete and admin-gated, but at this commit there is no real consumer of them; a rebuild wiring the SMS tab to the real endpoints must also fix the doubled mount prefix first (API surface) or every call 404s.
Security & tenancy
Auth dependency: AdminUser on every route (api/dependencies.py 364-375, 568), 403 if not is_admin; plus the mount-level SurfaceGate(LABS_SURFACES) (api/surface_gate.py 265-274, S5). Tenancy: none - sms_templates/sms_logs carry no workspace/tenant column (Data model), this is a single global template library shared by the whole admin surface, not a per-workspace object; a multi-tenant rebuild must add that scoping, it does not exist to copy. Fail direction: admin gate is closed (403/401 on failure); the transport itself fails open in the sense that every credential or validation gap returns a "failed"/"mock" status rather than throwing, so a misconfigured deploy degrades to "SMS silently not sent" rather than a loud error (Stack assumptions).
Secrets at rest: TWILIO_AUTH_TOKEN is a SecretStr (core/config.py 571), unwrapped only at client construction (get_secret_value(), sms_service.py 31); no token or webhook signature verification exists on this router (it has no inbound webhook, unlike the billing engine's HMAC seam). Rate limits: none dedicated to /templates/sms. Attacker-controlled input: recipient_phone and message_body on every route are admin-only (not public), so the main risk is an admin-authored template with an unescaped {{var}} reaching an SMS recipient verbatim if the caller's dynamic_data omits a key (Application logic).
Tests
None. grep -rl "sms_service\|SMSTemplate\|SMSLog\|sms_templates_router" backend/tests/ (excluding .claude/worktrees/) returns zero files at this commit - no hermetic test, no known-answer vector, no Playwright gate, nothing exercises send_sms, send_from_template, send_test_sms, send_booking_reminder, or any of the five router endpoints. This is a genuine gap, not an omission from this recipe: a rebuild has no existing suite to port, and the TypeError in SMSLog construction (Application logic) would have been caught immediately by even one test that asserted a row was inserted.
Deploy & ops
Config keys per app: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, SMS_ENABLED (all core/config.py 570-573, all optional/default-false, no boot-time validation the way email's SMTP path has one - contrast messaging-email-sms/email-transport's validate_email_smtp_credentials). Migrations: both tables ship in the single baseline-reconciliation migration baseline_prod_schema_001.py (revision baseline_prod_schema_001, down_revision=None) as pg_dump-captured SQL (1798-1829), with the sms_logs → sms_templates FK added later by deferred_fk_phantom_tables_001.py (733-736) per that chain's own documented reason (FKs deferred until every phantom table exists). No scheduler job specific to this recipe; send_booking_reminder/send_sms are called synchronously from scheduled_jobs.py's existing booking-reminder and no-confirm-cancellation jobs (1403, 1467), which are out of this recipe's scope. No Argus-style check was found for this seam.
Rollback: stateless beyond the two tables; neither is ever deleted from by this recipe's code, so rolling back the application code needs no data unwind.
Gotchas
- 2026-09-12 (measured at 62ba4fc,
app_fastapi.py876 +sms_templates_router.py16): the router declaresprefix="/templates/sms"on its ownAPIRouterAND is mounted withprefix="/api/templates/sms". FastAPI'sinclude_routerconcatenates both, so the live path is/api/templates/sms/templates/sms(and.../templates/sms/{id},.../templates/sms/test), not the/api/templates/sms/...the naming clearly intends. Reproduced directly: building the identicalAPIRouter(prefix="/templates/sms")+app.include_router(router, prefix="/api/templates/sms")pair in a throwaway FastAPI app at this commit's dependency versions prints the route/api/templates/sms/templates/sms. Nothing in the frontend calls either path (Frontend), which is likely why this has never been noticed. - 2026-09-12 (measured at 62ba4fc,
sms_service.py163-170 and 186-192): bothSMSLog(...)construction sites passtemplate_name=..., a keyword that is not a column on the model (models/client_onboarding_model.py295-309 hassms_template_id, nottemplate_name). Confirmed live:SMSLog(recipient_phone=..., message_body=..., template_name="x", status="sent")raisesTypeError: 'template_name' is an invalid keyword argument for SMSLogagainst this exact model. Both call sites wrap the construction intry/except Exception: logger.warning(...), so every single send, success or failure, fails to log and only emits a warning line. The failure-path call additionally passeserror_message=..., also not a column (the real one isfailure_reason) - moot, sincetemplate_nameraises first in kwarg-application order. - 2026-09-12 (measured at 62ba4fc,
frontend/src/pages/AdminCommunication.tsx66-99): the SMS tab of the only admin surface for this feature is entirely mock data with an explicit "still using mock data for now" comment; the email tab beside it is fully wired. No frontend code in this repo calls any/templates/smsroute. - 2026-09-12 (measured at 62ba4fc): the
messaging-email-smsdomain description (domains.json) names Telegram via the Telegram Bot API as an in-scope transport; no Telegram-related code was found underbackend/services/orbackend/api/routers/at this commit. If it exists it is not part of this reference implementation. - 2026-09-12 (measured at 62ba4fc,
sms_templates_router.py48-55): the/testendpoint's blanketexcept Exception as e: raise HTTPException(400, ...)re-catches theHTTPException(404)thatsend_test_smsraises for a missing template id and re-raises it as 400, so a client cannot distinguish "template not found" from any other test-send failure by status code alone.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/sms_service.py(311 lines, whole file in scope):get_twilio_client(23-36);SMSService.get_template/list_templates(41-49);create_template(51-66);update_template(68-85);delete_template(87-95);send_sms(97-202);send_from_template(204-243);send_test_sms(245-258);send_booking_reminder(260-308); module singletonsms_service(311).alchemy_labs/backend/api/routers/sms_templates_router.py(54 lines, whole file in scope): router declaration (16);list_sms_templates(19-22);create_sms_template(24-27);get_sms_template(29-35);update_sms_template(37-40);delete_sms_template(42-46);send_test_sms_template(48-55).alchemy_labs/backend/models/client_onboarding_model.py:SMSTemplate(284-293);SMSLog(295-309).alchemy_labs/backend/schemas/communication_schemas.py:SMSTemplateBase(54-58);SMSTemplateCreate/Update/Response(62-76);SendTestSMSRequest(78-80).alchemy_labs/backend/core/enums.py:SMSCategory(154-159);SMSStatus(162-167).alchemy_labs/backend/core/config.py:TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN/TWILIO_PHONE_NUMBER/SMS_ENABLED(570-573).alchemy_labs/backend/app_fastapi.py: import (76); mount (876).alchemy_labs/backend/api/dependencies.py:get_current_admin_user(364-375);AdminUser(568);DatabaseSession(572).alchemy_labs/backend/services/scheduled_jobs.py:send_booking_remindercaller (1403); directsend_smscaller incancel_unconfirmed_bookings(1467), out of this recipe's scope, cited for context.alchemy_labs/backend/migrations/versions/baseline_prod_schema_001.py:sms_logstable (1798-1815);sms_templatestable (1817-1829); indexes (3906-3918); unique constraints (3018-3027); primary keys (2748-2757).alchemy_labs/backend/migrations/versions/deferred_fk_phantom_tables_001.py:sms_logs.sms_template_idFK add (733-736).alchemy_labs/frontend/src/pages/AdminCommunication.tsx:SMSTemplateinterface (35-42); mockfetchTemplatesbranch (66-99); real email calls for contrast (63, 117, 127).
No test files exist for this recipe at this commit (Tests). Deviations from an ideal implementation, all dated 2026-09-12: the double-mounted route prefix; the SMSLog write's invalid template_name/error_message keywords on both paths; the /test endpoint masking 404 as 400; zero frontend wiring for SMS despite email being fully wired in the same file; booking_id accepted by three functions and dropped everywhere.
Acceptance checks
grep -n 'prefix="/templates/sms"' backend/api/routers/sms_templates_router.pyprints line16;grep -n 'sms_templates_router.router' backend/app_fastapi.pyprints line876showingprefix="/api/templates/sms"on the same call - both prefixes present confirms the doubling described in Gotchas.- In a Python shell with
backend/.venvactive frombackend/:python -c "from models.client_onboarding_model import SMSLog; SMSLog(recipient_phone='+41791234567', message_body='hi', template_name='x', status='sent')"raisesTypeError: 'template_name' is an invalid keyword argument for SMSLog. grep -n "still using mock data" frontend/src/pages/AdminCommunication.tsxprints one match (line 66), andgrep -c "api\.\(get\|post\|put\|delete\)" frontend/src/pages/AdminCommunication.tsxprints3, all three referencing/api/templates/emails, none referencing/templates/sms.grep -rl "sms_service\|SMSTemplate\|SMSLog\|sms_templates_router" backend/tests/(run fromalchemy_labs, excluding.claude/worktrees/) prints nothing.grep -n "^ TWILIO_ACCOUNT_SID\|^ TWILIO_AUTH_TOKEN\|^ TWILIO_PHONE_NUMBER\|^ SMS_ENABLED" backend/core/config.pyprints the four lines 570-573,SMS_ENABLEDshowingdefault=False.
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.