Phase 6: Markdown Sending
Overview
Agents and integrations often compose email copy in Markdown rather than raw HTML. Phase 6
adds POST /api/v1/messages/send-markdown, a thin wrapper around the existing send pipeline
that accepts a Markdown body, renders it to email-safe HTML via the emailmd Node.js sidecar
(app_markdown), and then enqueues the message through the standard _enqueue_messages path.
The plain-text fallback (text_body) is derived automatically from the rendered output when
the sidecar returns one. Callers may pass optional render_options to control minification and
supply per-request brand colour tokens so a single API key can produce on-brand emails for
multiple tenants without server-side template management.
The endpoint shares the same auth scope (messages:send), suppression checks, idempotency
key support, rate limit (30 req/min per token), and 202 → message_ids response shape as the
original /messages/send endpoint.
Goal
Accept a Markdown body, render it to email-safe HTML through the emailmd sidecar, and enqueue the result through the standard delivery pipeline — with optional per-request theme overrides.
Dependencies added
app_markdown— Node.js emailmd sidecar service (new container, port 3853 in local dev)httpx(already present) — used byMarkdownRendererServiceto call the sidecar
TDD Acceptance Criteria
pytest tests/services/test_markdown_renderer.pyMUST PASSpytest tests/controllers/markdown_messages/test_routes.pyMUST PASSPOST /api/v1/messages/send-markdownreturns202for a valid Markdown body- Response body is
{"message_ids": ["<uuid>"]}— same shape as/messages/send markdown_bodyis required; omitting it returns422markdown_bodyexceeding 1 MiB returns422subjectwith embedded\ror\nreturns422(CRLF injection prevention)- Total recipient count across
to + cc + bcc > 100returns422 - Sending to a suppressed address returns
422with RFC 9457 Problem JSON render_options.minify: falseis forwarded to the sidecar and acceptedrender_options.theme.brand_colorwith a valid hex value is forwarded to the sidecarrender_options.themewith an unknown key returns422(extra="forbid")- A valid
Idempotency-Keyheader deduplicates within a 24-hour window - Sidecar unavailable returns
502(renderer error propagated correctly) - Request without
Authorizationheader returns401 - Request with a token missing
messages:sendscope returns403 - Rate limit of 30 req/min per token is enforced; excess returns
429 stream: "marketing"is accepted and forwarded correctlystreamdefaults to"transactional"when omitted- Plain-text fallback derived from sidecar output is included in the enqueued message when present
Technical Specifications
Endpoint
POST /api/v1/messages/send-markdown
Authorization: Bearer <jwt> # messages:send scope required
Idempotency-Key: <key> # optional, max 255 chars, 24-hour dedup
Request Schema (SendMarkdownMessageRequest)
| Field | Type | Required | Constraints |
|---|---|---|---|
to | EmailStr[] | yes | 1–100 recipients |
cc | EmailStr[] | no | max 100 |
bcc | EmailStr[] | no | max 100 |
subject | string | yes | 1–998 chars, no \r / \n |
markdown_body | string | yes | 1 B – 1 MiB |
stream | "transactional" | "marketing" | no | default "transactional" |
render_options | MarkdownRenderOptions | no | see below |
Combined to + cc + bcc must not exceed 100.
MarkdownRenderOptions
| Field | Type | Default |
|---|---|---|
minify | bool | true |
theme | MarkdownThemeOptions | null |
MarkdownThemeOptions (all fields optional hex / CSS strings)
brand_color, heading_color, body_color, background_color, content_color,
card_color, button_color, button_text_color, secondary_color,
secondary_text_color, font_family, font_size, line_height,
content_width, border_radius.
Extra keys are rejected (extra="forbid").
Rendering pipeline
- Controller calls
render_markdown(markdown_body, options=render_opts)(async). MarkdownRendererServicePOSTs toMARKDOWN_RENDERER_URL(env var, defaulthttp://localhost:3853).- Sidecar returns
{"html": "...", "text": "..."}. - Controller constructs a
SendMessageRequestwithhtml_body=rendered.htmlandtext_body=rendered.text or None. - Standard
_enqueue_messagespath handles suppression, idempotency, and queuing.
Response
202 Accepted
{"message_ids": ["550e8400-e29b-41d4-a716-446655440000"]}
One UUID per recipient in to.
Error responses (RFC 9457 Problem JSON)
| Condition | Status |
|---|---|
| Validation failure | 422 |
| Suppressed recipient | 422 |
| Sidecar unreachable | 502 |
| Unauthenticated | 401 |
| Insufficient scope | 403 |
| Rate limit exceeded | 429 |