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 by MarkdownRendererService to call the sidecar

TDD Acceptance Criteria

  1. pytest tests/services/test_markdown_renderer.py MUST PASS
  2. pytest tests/controllers/markdown_messages/test_routes.py MUST PASS
  3. POST /api/v1/messages/send-markdown returns 202 for a valid Markdown body
  4. Response body is {"message_ids": ["<uuid>"]} — same shape as /messages/send
  5. markdown_body is required; omitting it returns 422
  6. markdown_body exceeding 1 MiB returns 422
  7. subject with embedded \r or \n returns 422 (CRLF injection prevention)
  8. Total recipient count across to + cc + bcc > 100 returns 422
  9. Sending to a suppressed address returns 422 with RFC 9457 Problem JSON
  10. render_options.minify: false is forwarded to the sidecar and accepted
  11. render_options.theme.brand_color with a valid hex value is forwarded to the sidecar
  12. render_options.theme with an unknown key returns 422 (extra="forbid")
  13. A valid Idempotency-Key header deduplicates within a 24-hour window
  14. Sidecar unavailable returns 502 (renderer error propagated correctly)
  15. Request without Authorization header returns 401
  16. Request with a token missing messages:send scope returns 403
  17. Rate limit of 30 req/min per token is enforced; excess returns 429
  18. stream: "marketing" is accepted and forwarded correctly
  19. stream defaults to "transactional" when omitted
  20. 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)

FieldTypeRequiredConstraints
toEmailStr[]yes1–100 recipients
ccEmailStr[]nomax 100
bccEmailStr[]nomax 100
subjectstringyes1–998 chars, no \r / \n
markdown_bodystringyes1 B – 1 MiB
stream"transactional" | "marketing"nodefault "transactional"
render_optionsMarkdownRenderOptionsnosee below

Combined to + cc + bcc must not exceed 100.

MarkdownRenderOptions

FieldTypeDefault
minifybooltrue
themeMarkdownThemeOptionsnull

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

  1. Controller calls render_markdown(markdown_body, options=render_opts) (async).
  2. MarkdownRendererService POSTs to MARKDOWN_RENDERER_URL (env var, default http://localhost:3853).
  3. Sidecar returns {"html": "...", "text": "..."}.
  4. Controller constructs a SendMessageRequest with html_body=rendered.html and text_body=rendered.text or None.
  5. Standard _enqueue_messages path 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)

ConditionStatus
Validation failure422
Suppressed recipient422
Sidecar unreachable502
Unauthenticated401
Insufficient scope403
Rate limit exceeded429

Sourced from docs/features/06_markdown_sending in the repo. Edits go through the same review as code.