Phase 2: Outbound API & HTML Parsing

Overview

POST /api/v1/messages/send is the primary entry point for AI agents and applications to queue outbound email. Each recipient gets its own row and UUID, enabling per-recipient tracking. HTML bodies are automatically enhanced: a 1×1 tracking pixel is injected and all <a href> links are rewritten to route through the tracking gateway before forwarding to their destination. The sender identity is irrevocably bound to the authenticated mailbox — no caller-supplied From, Sender, Reply-To, or Return-Path is ever accepted.

Post-review hardening shipped in this phase:

  • Pydantic caps: ≤100 combined recipients, ≤1 MiB html_body/text_body, ≤998-octet subject, empty-list rejection.
  • Idempotency-Key header support (MR #8): a (token_jti, idempotency_key) lookup with ≥24 h window prevents duplicate sends on network retries.
  • Per-token send_limits and allowed_recipient_domains quotas (MR #9): a leaked token cannot be used as an unlimited spam relay.

Goal

Create the main API endpoint for queuing outbound emails, including automatic parsing and injection of tracking pixels and link rewriting.

Dependencies to add:

  • beautifulsoup4
  • lxml (for fast parsing)

TDD Acceptance Criteria

  1. pytest tests/services/test_html_parser.py::test_injects_pixel_after_body MUST PASS
  2. pytest tests/services/test_html_parser.py::test_rewrites_links_with_url_encoding MUST PASS
  3. pytest tests/services/test_html_parser.py::test_ignores_mailto_links MUST PASS
  4. pytest tests/api/test_outbound_send.py::test_403_missing_messages_send_scope MUST PASS
  5. pytest tests/api/test_outbound_send.py::test_202_accepted_and_db_insertion MUST PASS

Technical Specifications

HTML Parsing Service

  • Function inject_tracking(html: str, message_uuid: UUID) -> str
  • Function rewrite_links(html: str, message_uuid: UUID) -> str
  • Use urllib.parse.quote for URL encoding of original URLs.

API Endpoint: POST /api/v1/messages/send

Payload Validation (Pydantic):

  • to: List[EmailStr]
  • cc: Optional[List[EmailStr]]
  • bcc: Optional[List[EmailStr]]
  • subject: String
  • html_body: String
  • text_body: Optional[String]
  • attachments: Optional[List[AttachmentSchema]]

Action:

  1. Validate messages:send permission in JWT.
  2. For each recipient in to, cc, bcc:
    • Generate UUID for the message.
    • Run HTML Parsing Service.
    • Insert row into outbound_messages with status='queued'.
  3. Return 202 Accepted with a list of generated message UUIDs.

Input validation & injection prevention (architecture.md §4)

Any identifier that could be interpolated into an SMTP command or mail header — mailbox names, hostnames, subject, recipient addresses — must be validated before use. The key rule: reject strings containing \r or \n (CRLF).

  • Pydantic EmailStr handles recipients.
  • subject length is capped at 998 octets (RFC 5322); multi-line subjects are rejected by the \r\n check in the Pydantic validator.
  • smtp_host, smtp_port, and other operator-supplied mailbox fields are validated at mailboxes:create / mailboxes:update time via the CLI layer.

This mirrors the CRLF-injection prevention applied to legacy IMAP SELECT commands (architecture.md §4) and is enforced by the same CONTRIBUTING.md rule: prefer explicit validation for all user-controlled headers, query params, mailbox names, message IDs, and filenames.

Sender binding invariant (FEEDBACK.md §1.4)

From, Sender, Reply-To, Return-Path, and the SMTP envelope sender are always bound to the authenticated mailbox’s configured email address. The payload has no sender-related field and never will.

  • The SendMessageRequest Pydantic model declares model_config = ConfigDict(extra="forbid"). Any unknown key in the request body — from, From, sender, reply_to, return_path, a nested headers bag, anything at all — is a 422 Unprocessable Entity. No sender override is silently ignored.
  • The worker’s MIME construction (src/app/workers/smtp_delivery.py, _bind_sender_headers) sets From, Sender, Reply-To, and Return-Path exclusively from mailbox.email. It never reads sender fields from the OutboundMessage row, so even if a future schema change were to let a caller-controlled string land on the row, it could not influence the sender identity.
  • The From display name (the human-readable label shown before the address in mail clients) is the one operator-level exception: set mailbox.display_name via mailboxes:update --display-name "Acme Corp". When set, the From header reads Acme Corp <hello@example.com>; when NULL it falls back to the IMAP username. This is a CLI-only, operator-level setting — no token or request payload can influence it.
  • Consequence: a stolen messages:send token is scoped to the mailbox it was issued for. It cannot be turned into a spoofing weapon for arbitrary From addresses (no “phish as the CEO” vector).

A future per-token allowed_from override (FEEDBACK.md §1.13, tracked in PLAN.md pre-GA) is the one and only mechanism that can ever relax this; it requires an explicit permission on the token, not a request-body field.

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