Templates
Reusable message bodies with {{variable}} substitution. Scoped to an org, keyed by slug.
Dashboard CRUD
Internal API: /api/internal/templates (session auth, org-scoped).
Create
POST /api/internal/templates
{
"slug": "welcome-email",
"subject": "Welcome to {{company}}",
"html": "<p>Hi {{name}}, thanks for signing up.</p>",
"text": "Hi {{name}}, thanks for signing up.",
"channel": "email"
}
slug— lowercase letters, digits, dashes; unique per org.channel—emailorsms. SMS templates usetextonly;subjectis ignored by the SMS send path.html/text— either or both. For email, at least one must render non-empty.
409 if slug collides within org.
List / Delete
GET /api/internal/templates
DELETE /api/internal/templates?id=tpl_...
Preview
Render the template with sample variables without sending. Available to any session member.
POST /api/internal/templates/preview
{
"id": "tpl_...", // or "slug": "welcome-email"
"variables": { "name": "Fareed" }
}
Response:
{
"id": "tpl_...",
"slug": "welcome-email",
"channel": "email",
"subject": "Welcome, Fareed",
"html": "<p>Hi Fareed, ...</p>",
"text": "Hi Fareed, ..."
}
Test send
Render + dispatch a real test message via the provider. Tagged template-test and written to messages with metadata.source = "test_send" so test sends are easy to filter out of usage dashboards.
POST /api/internal/templates/test-send
{
"id": "tpl_...",
"from": "hello@yourdomain.com",
"to": "you@example.com",
"variables": { "name": "Fareed" }
}
Response: { "id": "msg_...", "status": "sent" }.
502 is returned if the provider call fails; the error message is surfaced to the client.
Both endpoints are also surfaced in the dashboard (/overview/templates → Preview / test) — HTML renders in a sandboxed iframe.
Test sends reject viewers — test-send is a real provider call and is gated on requireWriteSession.
Using in a send
POST /api/v1/emails
{
"from": "hi@yourdomain.com",
"to": ["user@example.com"],
"template": "welcome-email",
"variables": { "name": "Fareed", "company": "Acme" }
}
Rendering rules (src/lib/api/template.ts):
{{key}}→ variable lookup invariables.- Whitespace allowed:
{{ key }}works. - Missing variable → empty string.
- No dotted paths, no expressions, no conditionals — intentional simplicity.
Overrides
If the send body supplies subject / html / text / body directly, those override the template's rendered versions. This lets you override a single field while keeping the rest.
Schema
templates table (src/lib/db/schema/templates.ts):
| Column | Type | Notes |
|---|---|---|
id |
text PK | tpl_... |
org_id |
FK | scope |
slug |
text | unique per org |
subject |
text | used for email |
html |
text | optional |
text |
text | required for sms if used, optional for email |
channel |
text | email / sms |
created_at/updated_at |
timestamps |