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-octetsubject, empty-list rejection. Idempotency-Keyheader support (MR #8): a(token_jti, idempotency_key)lookup with ≥24 h window prevents duplicate sends on network retries.- Per-token
send_limitsandallowed_recipient_domainsquotas (MR #9): a leaked token cannot be used as an unlimited spam relay.
Tags & Metadata (opt-in classification)
Both send endpoints accept two optional objects
(see architecture_decision_records/2026-07-17__outbound_message_tags_and_metadata.md):
tags— up to 10name: valuepairs of[A-Za-z0-9_-]{1,256}that classify the message kind ({"type": "welcome", "campaign": "onboarding-v2"}). Tags are echoed on reads and webhook payloads, filterable everywhere messages are listed (repeatable?tag=name:value, AND semantics, GIN-indexed@>containment), and aggregated byGET /messages/outbound/stats/tags?name=<tag>— per-value sent/failed/bounced/opened/clicked counts, with untagged mail in a singlevalue: nullbucket.metadata— up to 20 opaque key/value entries for client correlation data (order IDs, user IDs). Echoed on reads and webhooks, never indexed or queried.
Omitting both is the default path: the columns stay NULL, and no behaviour
changes. stream remains the compliance axis (unsubscribe, RFC 8058); tags
carry no delivery semantics.
Goal
Create the main API endpoint for queuing outbound emails, including automatic parsing and injection of tracking pixels and link rewriting.
Dependencies to add:
beautifulsoup4lxml(for fast parsing)
TDD Acceptance Criteria
pytest tests/services/test_html_parser.py::test_injects_pixel_after_bodyMUST PASSpytest tests/services/test_html_parser.py::test_rewrites_links_with_url_encodingMUST PASSpytest tests/services/test_html_parser.py::test_ignores_mailto_linksMUST PASSpytest tests/api/test_outbound_send.py::test_403_missing_messages_send_scopeMUST PASSpytest tests/api/test_outbound_send.py::test_202_accepted_and_db_insertionMUST 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.quotefor 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: Stringhtml_body: Stringtext_body: Optional[String]attachments: Optional[List[AttachmentSchema]]
Action:
- Validate
messages:sendpermission in JWT. - For each recipient in
to,cc,bcc:- Generate UUID for the message.
- Run HTML Parsing Service.
- Insert row into
outbound_messageswithstatus='queued'.
- Return
202 Acceptedwith 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
EmailStrhandles recipients. subjectlength is capped at 998 octets (RFC 5322); multi-line subjects are rejected by the\r\ncheck in the Pydantic validator.smtp_host,smtp_port, and other operator-supplied mailbox fields are validated atmailboxes:create/mailboxes:updatetime 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
SendMessageRequestPydantic model declaresmodel_config = ConfigDict(extra="forbid"). Any unknown key in the request body —from,From,sender,reply_to,return_path, a nestedheadersbag, anything at all — is a422 Unprocessable Entity. No sender override is silently ignored. - The worker’s MIME construction (
src/app/workers/smtp_delivery.py,_bind_sender_headers) setsFrom,Sender,Reply-To, andReturn-Pathexclusively frommailbox.email. It never reads sender fields from theOutboundMessagerow, 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
Fromdisplay name (the human-readable label shown before the address in mail clients) is the one operator-level exception: setmailbox.display_nameviamailboxes:update --display-name "Acme Corp". When set, theFromheader readsAcme 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:sendtoken is scoped to the mailbox it was issued for. It cannot be turned into a spoofing weapon for arbitraryFromaddresses (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.