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.
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.