Goal
Datamailer currently has three overlapping "don't really send" mechanisms used
for testing. They do essentially the same job in three different ways, each with its
own settings, endpoints, storage, and quirks. This is confusing and hard to maintain.
Replace all of them with one fake, synchronous, drop-in transactional endpoint
built for e2e tests: you POST the same payload you'd POST to the real send endpoint,
and you get the rendered email back inline in a single response — no real
delivery, no queue, no separate inbox to poll.
Why (the current mess)
| Mechanism |
How it works |
Config |
Read path |
| Capture / testbed |
DATAMAILER_DELIVERY_MODE=capture makes the worker store a CapturedEmail row instead of calling SES |
DATAMAILER_DELIVERY_MODE, DATAMAILER_CAPTURE_UI |
GET /api/testbed/runs, .../runs/{id}, .../runs/{id}/messages/{id} (disabled on deployed: capture_api_disabled) |
| Mock inbox |
Send to a mock address (*@mailbox.test or +e2e); worker marks it sent as mock-inbox:{id} and skips SES |
MOCK_INBOX_ENABLED, MOCK_INBOX_DOMAIN, MOCK_INBOX_PLUS_TAG |
GET /api/mock-inbox/messages?address=..., .../messages/{id} |
| Real inbox |
Send to *@mailer.dtcdev.click; goes through real SES, received back from an inbound S3 bucket |
REAL_INBOX_ENABLED, REAL_INBOX_DOMAIN, REAL_INBOX_S3_BUCKET, REAL_INBOX_S3_PREFIX, REAL_INBOX_MAX_SCAN_OBJECTS |
GET /api/inbox/messages, .../messages/{key} |
All three require the send-then-poll dance: submit, then hit a second endpoint to
read what was rendered. That's slow and awkward in e2e tests.
Proposed endpoint
POST /api/transactional/test-send (final name TBD — see open decisions)
-
Auth: same client Bearer token as the real endpoint.
-
Request body: byte-for-byte identical to POST /api/transactional/send
(email, template_key, context, metadata, from_email, reply_to, cc,
bcc, headers, message_parts, category_tag, idempotency_key). Reuses the
exact same validation (validate_transactional_send_payload).
-
Behavior: resolves the template + sender, renders subject / html / text with the
provided context, evaluates the delivery decision (suppression / category
preferences) — but never enqueues, never calls SES, and persists nothing.
-
Response: 200 with the rendered email inline, e.g.
{
"rendered": {
"to": "alexey+e2e@datatalks.club",
"from_email": "...",
"from_email_address": "...",
"reply_to": "", "cc": [], "bcc": [], "headers": {},
"subject": "Welcome to AI Dev Tools",
"html_body": "<...rendered...>",
"text_body": "...rendered..."
},
"template_key": "registration-confirmation",
"would_deliver": true,
"delivery_decision": { "allowed": true, "reason": "" }
}
would_deliver / delivery_decision let e2e tests assert suppression and category
logic without a second request. Rendering itself always happens regardless of the
decision, so you can inspect the output even for suppressed contacts.
This is the whole test surface: one request in, rendered email out, nothing sent.
Remove (consolidation)
Delete these entirely once the new endpoint replaces them:
Capture / testbed
mailing/services/capture.py
CapturedEmail model (mailing/models.py) + a migration dropping the captured_emails table
- Views:
api_testbed_runs, api_testbed_run_detail, api_testbed_run_message, _capture_api_disabled_response
- URLs: the three
api/testbed/... routes
- Settings:
DATAMAILER_DELIVERY_MODE, DATAMAILER_CAPTURE_UI
- Capture branches in
transactional_sender.py (capture_mode_enabled / _mark_captured) and campaign_sender.py (see wrinkle below)
mailing/tests/test_capture_mode.py, api_docs entries, docs/api.md capture section
Mock inbox
mailing/services/mock_inbox.py
- Views:
api_mock_inbox_messages, api_mock_inbox_message_detail, _mock_inbox_disabled_response
- URLs: the two
api/mock-inbox/... routes
- Settings:
MOCK_INBOX_ENABLED, MOCK_INBOX_DOMAIN, MOCK_INBOX_PLUS_TAG
- Mock branch in
transactional_sender.py (is_mock_address)
mailing/tests/test_mock_inbox.py, docs/api.md mock-inbox section
Real inbox
mailing/services/real_inbox.py
- Views:
api_real_inbox_messages, api_real_inbox_message_detail
- URLs: the two
api/inbox/... routes
- Settings: all
REAL_INBOX_*
- Real-inbox branch in
transactional_sender.py (is_real_inbox_address)
mailing/tests/test_real_inbox.py
Net effect: transactional_sender.py and campaign_sender.py lose all their
test-mode branches and only ever do the real SES path.
e2e usage
Replace the current send-then-poll pattern:
POST /api/transactional/send (to alexey+e2e@datatalks.club)
GET /api/mock-inbox/messages?address=alexey+e2e@datatalks.club # poll
with a single call:
POST /api/transactional/test-send -> assert rendered.subject / rendered.html_body inline
No special addresses, no global env mode, no second request.
Acceptance criteria
Open decisions
- Endpoint name:
test-send vs preview vs render vs fake.
- Campaign wrinkle: capture mode is also the e2e path for campaign sends
(campaign_sender.py). Removing capture removes campaign e2e coverage. Options:
(a) accept the loss, (b) add a parallel POST /api/campaigns/{key}/test-send
render endpoint. Note there is already an api_campaign_preview / api_campaign_test_send
pair — clarify how those relate.
- Real-inbox removal: it's the only mechanism that exercises the real SES
send + inbound-receive path, so removing it drops true end-to-end delivery
coverage. Confirm that's acceptable (the new fake never touches SES).
Goal
Datamailer currently has three overlapping "don't really send" mechanisms used
for testing. They do essentially the same job in three different ways, each with its
own settings, endpoints, storage, and quirks. This is confusing and hard to maintain.
Replace all of them with one fake, synchronous, drop-in transactional endpoint
built for e2e tests: you POST the same payload you'd POST to the real send endpoint,
and you get the rendered email back inline in a single response — no real
delivery, no queue, no separate inbox to poll.
Why (the current mess)
DATAMAILER_DELIVERY_MODE=capturemakes the worker store aCapturedEmailrow instead of calling SESDATAMAILER_DELIVERY_MODE,DATAMAILER_CAPTURE_UIGET /api/testbed/runs,.../runs/{id},.../runs/{id}/messages/{id}(disabled on deployed:capture_api_disabled)*@mailbox.testor+e2e); worker marks itsentasmock-inbox:{id}and skips SESMOCK_INBOX_ENABLED,MOCK_INBOX_DOMAIN,MOCK_INBOX_PLUS_TAGGET /api/mock-inbox/messages?address=...,.../messages/{id}*@mailer.dtcdev.click; goes through real SES, received back from an inbound S3 bucketREAL_INBOX_ENABLED,REAL_INBOX_DOMAIN,REAL_INBOX_S3_BUCKET,REAL_INBOX_S3_PREFIX,REAL_INBOX_MAX_SCAN_OBJECTSGET /api/inbox/messages,.../messages/{key}All three require the send-then-poll dance: submit, then hit a second endpoint to
read what was rendered. That's slow and awkward in e2e tests.
Proposed endpoint
POST /api/transactional/test-send(final name TBD — see open decisions)Auth: same client Bearer token as the real endpoint.
Request body: byte-for-byte identical to
POST /api/transactional/send(
email,template_key,context,metadata,from_email,reply_to,cc,bcc,headers,message_parts,category_tag,idempotency_key). Reuses theexact same validation (
validate_transactional_send_payload).Behavior: resolves the template + sender, renders subject / html / text with the
provided context, evaluates the delivery decision (suppression / category
preferences) — but never enqueues, never calls SES, and persists nothing.
Response:
200with the rendered email inline, e.g.{ "rendered": { "to": "alexey+e2e@datatalks.club", "from_email": "...", "from_email_address": "...", "reply_to": "", "cc": [], "bcc": [], "headers": {}, "subject": "Welcome to AI Dev Tools", "html_body": "<...rendered...>", "text_body": "...rendered..." }, "template_key": "registration-confirmation", "would_deliver": true, "delivery_decision": { "allowed": true, "reason": "" } }would_deliver/delivery_decisionlet e2e tests assert suppression and categorylogic without a second request. Rendering itself always happens regardless of the
decision, so you can inspect the output even for suppressed contacts.
This is the whole test surface: one request in, rendered email out, nothing sent.
Remove (consolidation)
Delete these entirely once the new endpoint replaces them:
Capture / testbed
mailing/services/capture.pyCapturedEmailmodel (mailing/models.py) + a migration dropping thecaptured_emailstableapi_testbed_runs,api_testbed_run_detail,api_testbed_run_message,_capture_api_disabled_responseapi/testbed/...routesDATAMAILER_DELIVERY_MODE,DATAMAILER_CAPTURE_UItransactional_sender.py(capture_mode_enabled/_mark_captured) andcampaign_sender.py(see wrinkle below)mailing/tests/test_capture_mode.py, api_docs entries,docs/api.mdcapture sectionMock inbox
mailing/services/mock_inbox.pyapi_mock_inbox_messages,api_mock_inbox_message_detail,_mock_inbox_disabled_responseapi/mock-inbox/...routesMOCK_INBOX_ENABLED,MOCK_INBOX_DOMAIN,MOCK_INBOX_PLUS_TAGtransactional_sender.py(is_mock_address)mailing/tests/test_mock_inbox.py,docs/api.mdmock-inbox sectionReal inbox
mailing/services/real_inbox.pyapi_real_inbox_messages,api_real_inbox_message_detailapi/inbox/...routesREAL_INBOX_*transactional_sender.py(is_real_inbox_address)mailing/tests/test_real_inbox.pyNet effect:
transactional_sender.pyandcampaign_sender.pylose all theirtest-mode branches and only ever do the real SES path.
e2e usage
Replace the current send-then-poll pattern:
with a single call:
No special addresses, no global env mode, no second request.
Acceptance criteria
POST /api/transactional/test-sendaccepts the identical payload/validation as/api/transactional/sendand returns renderedsubject/html_body/text_bodyinline.captured_emailstable.docs/api.mdand the api-docs registry document only the new endpoint.Open decisions
test-sendvspreviewvsrendervsfake.(
campaign_sender.py). Removing capture removes campaign e2e coverage. Options:(a) accept the loss, (b) add a parallel
POST /api/campaigns/{key}/test-sendrender endpoint. Note there is already an
api_campaign_preview/api_campaign_test_sendpair — clarify how those relate.
send + inbound-receive path, so removing it drops true end-to-end delivery
coverage. Confirm that's acceptable (the new fake never touches SES).