> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Message Templates

> After this ships, a message looks like the tenant's brand, says the member's name correctly, and cannot go out with a variable that does not exist.

## Context

The failure this feature prevents is the one every customer has seen: `Hola {{first_name}},` landing
in an inbox. It happens when templates accept arbitrary variables and nothing checks them against
real data.

Templates here are **React Email components in the repository**, versioned like code and reviewed
like code. That is a deliberate trade against a drag-and-drop builder: it means a template change is
a deploy, and it also means a template cannot be broken by a marketer at 6pm on a Friday. Variables
are validated against `core.attribute_definitions`, so a template referencing a field the tenant
does not have fails at publish rather than at send.

## Scope *(normative)*

* `messaging.message_templates`: versioned, per tenant, per channel.
* React Email components with a declared variable contract.
* Variable validation against `core.attribute_definitions` at publish time.
* Per-tenant branding: logo, colours, sender name, footer.
* Locale variants of the same template.
* Preview with sample data, and a test send to a chosen address.

## Non-scope *(normative)*

* A drag-and-drop editor. Templates are code (see Context). A builder is a possible later product
  surface, not a prerequisite.
* Copy translation. The tenant supplies the text per locale; we do not translate for them.
* A/B testing — F2, and it belongs with campaigns rather than with templates.

## Behaviour *(normative)*

1. A template is **versioned and immutable once published**. A send records the template version it
   used, so a message can be reproduced exactly a year later during a dispute.
2. Every template declares its **variable contract**. Publishing validates each variable against the
   tenant's attribute definitions and the event payload shape; an unknown variable **blocks
   publication**.
3. Rendering with a missing value uses the declared **fallback**, which is mandatory for every
   variable. There is no path that renders an empty placeholder or a raw token.
4. A template belongs to exactly one channel. The same message on email and push is two templates,
   because the constraints are not comparable.
5. Locale variants share a template id; resolution uses the recipient's locale, falling back to the
   tenant's default. A missing variant falls back rather than failing.
6. Marketing templates **must include an unsubscribe link**, injected by the renderer and not
   removable by the template author.
7. Preview and test sends are rate-limited and audit-logged. A test send goes only to an address the
   tester controls.
8. FORBIDDEN: rendering with an unvalidated variable · a marketing template without an unsubscribe ·
   editing a published version in place.

## Data *(normative)*

| Table                         | Key invariants                                                                                                                                                                                                              |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messaging.message_templates` | `tenant_id`; `code` unique per (tenant, channel, locale); `channel_code` FK; `version` integer, immutable once published; `component_ref`; `variables` JSONB contract with mandatory fallbacks; `published_at`; `is_active` |

## API *(normative)*

| Endpoint                                      | Class      | Permission                                   | Budget    |
| --------------------------------------------- | ---------- | -------------------------------------------- | --------- |
| `GET/POST/PATCH /v1/messaging/templates`      | Management | `messaging.templates.{read\|create\|update}` | p95 \<1 s |
| `POST /v1/messaging/templates/{id}/publish`   | Management | `messaging.templates.publish`                | p95 \<1 s |
| `POST /v1/messaging/templates/{id}/preview`   | Management | `messaging.templates.preview`                | p95 \<1 s |
| `POST /v1/messaging/templates/{id}/test-send` | Management | `messaging.templates.test_send`              | p95 \<1 s |

## Events *(normative)*

None. Template changes are administrative and covered by the audit log.

## Acceptance criteria *(normative)*

1. Publishing a template with a variable that does not exist for the tenant is rejected, naming it.
2. A published version cannot be edited; editing creates version + 1.
3. A send records its template version, and that version renders identically a year later.
4. A missing value renders its declared fallback — never an empty placeholder or a raw token.
5. A marketing template renders with an unsubscribe link even when the author omitted it.
6. A recipient with an unsupported locale receives the tenant's default variant.
7. A test send reaches only the address supplied and is audit-logged.
8. **Negative:** no variable without a declared fallback can be published.

## Execution

Single slice, synchronous command. Components live in `packages/email-templates`; the registry and
endpoints in `backend/api`. Rendering happens in the worker, never in the API request.

## Open questions

| # | Question                                                                                 | Decides | By              |
| - | ---------------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Can a tenant override a system template (OTP, invitation), or are those ours to control? | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
