Skip to content

feat(messaging): answer a send the checks refuse with why and fix - #579

Draft
Silthus wants to merge 6 commits into
email-v2/06-template-editorfrom
email-v2/08-admission-checks-django
Draft

Silthus wants to merge 6 commits into
email-v2/06-template-editorfrom
email-v2/08-admission-checks-django

Conversation

@Silthus

@Silthus Silthus commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Part of the code-first rebuild of the email send stack; replaces parts of PostHog#121850 (refuse a send the send checks would skip).

Problem

  • A code send on send/email/ can answer 202 and never go out. The email worker skips it later for a suppressed or undeliverable recipient, a paused email, a suspended project, or a sender that can't send. The caller sees success.
  • Contract PostHog/posthog#120921 promises no silent skips: those checks answer before the 202, with why and fix.
  • feat(cdp): refuse an accepted send the send checks would skip #577 has cdp-api refuse such a send. This is the Django half: it passes the refusal on to the caller.

Impact

Pays into Grow Workflows through billed workflows_emails actions per paying org: a developer who sees why a send failed fixes it, rather than losing the email. In scope: Messaging, email API sends.

Merge Danger

Warning

🟡 Merge risk: medium · two-way door: a revert answers a refusal with 503 again

  • send/email/ passes a cdp-api refusal on as 400 with why and fix, behind workflows-email-from-code. The template-by-id route shares the reader, but its cdp-api route never refuses.
  • Merge order: this PR and feat(cdp): refuse an accepted send the send checks would skip #577 (cdp-api) may land in either order. Each service deploys on its own, so either one can be live first.
  • With this PR live and cdp-api still older: a send the checks would refuse answers 202, and the worker skips it, as on master today. A valid send delivers. The live run below shows both.
  • Contract for the SDKs: a named from on no verified domain answers sender_not_allowed. sender_not_verified means the sender can't send: the send names no from and the project has no verified default sender, or the template's or the named address's sender is no longer verified. It names attr: "from" on a send that names a from, and on a code send with rendered content. A send by template without from names no field.

Changes

Kind Files Lines
Production 6 +87 −14
Tests 1 +77 −2
Generated types and snapshots 2 +2 −2

What a caller sees change

  • A send the checks refuse answers 400 with why and fix, and nothing is sent. The codes are invalid_recipient, recipient_suppressed, template_paused, sending_suspended and sender_not_verified.
  • A named from on no verified domain answers sender_not_allowed. That holds for a send by template.key too, which can name from since feat(messaging): send a stored template by key on send/email #574.
  • Django's own refusals on this route, such as { in to or a template that isn't transactional, still answer 400 without why and fix. The 400 help says why and fix come when the answer has them.
  • The 429 help says which case it is: throttled (retry later) or quota_limited (a retry sends nothing).
  • The MCP tool workflows-send-email says a 400 with why and fix means nothing was sent.

How it works

  • Django reads a refusal by its shape (non-empty code, detail, why and fix), not by a list of codes. A code cdp-api adds later still reaches the caller as a 400.
  • Any other 400 from cdp-api still maps to 503.
  • Django takes the refusal's attr from cdp-api when the body has the key, null included. A stored cc or bcc that is suppressed then answers attr: null, since the caller sent no cc. An older cdp-api sends no attr, so Django falls back to its own map from code to field. That map names from for a sender refusal whenever the send names a from, as cdp-api does.

How did you test this code?

Test seams: send/email/ and the template-by-id route through the DRF client with cdp-api mocked (test_transactional_email.py).

Test rationale:

  • The refusal rows: a refusal losing its why and fix, a code Django does not know yet dropped, and the template-by-id route reading a refusal.
  • The sender rows: a named unverified from answers sender_not_allowed, and a missing default sender answers sender_not_verified. A cdp-api sender_not_verified names from on a code send and on a key send that names from, and no field on a template send without one.
  • The attr rows: a cdp-api refusal with attr: null answers null, and one without the key keeps Django's mapped field.

Proofs: live runs saved with fleet proof, curl into maildev. A suppressed recipient is a manual suppression row. The no-mail-server domain is nomx.invalid.

Case Both new (Django a5d494f, cdp-api at #577 1e23a26) Django 8ce1e9b, cdp-api before #577
Valid code send 202, delivered 202, delivered
Suppressed recipient 400 recipient_suppressed, attr: "to", with why and fix; nothing sent 202; nothing delivered in 60 s
Domain with no mail server 400 invalid_recipient, attr: "to"; nothing sent 202; nothing delivered in 60 s
Keyed send whose stored cc is suppressed 400 recipient_suppressed, attr: null, why names the email step's cc; nothing sent not run
Same key after the cc is cleared 202, one email not run
Template by id (control) 202, delivered 202, delivered

CI: the fork has no Depot runners, so those jobs never start, and no secret for the project-board app, so flags-project-board fails; the other GitHub-hosted checks pass. The lane check passes: against master the PR changes only the Python and frontend side.

Release status

  • This change is behind a feature flag and is not available to users

Docs update

None in this PR. The send API docs follow when the flag opens.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)

Question Answer Who Where
Which flag gates the refusals? workflows-email-from-code; the template route is unchanged 👤 Human map #461, 2026-10-25
May the code carry comments? No; names carry the reasons 👤 Human map #461, 2026-10-25
How does Django tell a refusal from another 400? By its shape, not a code list, so new codes pass through 🤖 Agent pending approval
Do the cdp-api and Django halves land as one PR? No: two PRs in either order, each safe against the other side's older version, so CI's lane check passes without a label 🤖 Agent pending approval

Involvement: one brief from Michael with decisions; review rounds 1 and 2 (GPT-6.1 Sol team review, Opus fresh eyes) fixed; a final read by GPT-6.1 Sol found nothing; live and cross-version proof captured.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

🤖 CI report

ℹ️ MCP UI apps size — 34 app(s), 27969.1 KB JS

Built size of each MCP UI app (main.js + styles.css).

App JS CSS
debug 844.3 KB 209.1 KB
action 767.7 KB 209.1 KB
action-list 835.3 KB 209.1 KB
cohort 766.7 KB 209.1 KB
cohort-list 834.3 KB 209.1 KB
email-template 766.6 KB 209.1 KB
error-details 771.6 KB 209.1 KB
error-issue 768.9 KB 209.1 KB
error-issue-list 836.7 KB 209.1 KB
experiment 832.4 KB 209.1 KB
experiment-list 836.1 KB 209.1 KB
experiment-results 834.9 KB 209.1 KB
feature-flag 834.7 KB 209.1 KB
feature-flag-list 838.4 KB 209.1 KB
feature-flag-testing 770.0 KB 209.1 KB
inline-scan 767.2 KB 209.1 KB
insight-actors 833.8 KB 209.1 KB
llm-costs 831.1 KB 209.1 KB
session-recording 768.9 KB 209.1 KB
survey 768.3 KB 209.1 KB
survey-global-stats 831.1 KB 209.1 KB
survey-list 836.0 KB 209.1 KB
survey-stats 831.1 KB 209.1 KB
trace-span 767.1 KB 209.1 KB
trace-span-list 835.2 KB 209.1 KB
vision-observation-list 834.4 KB 209.1 KB
workflow 767.0 KB 209.1 KB
workflow-list 834.7 KB 209.1 KB
workflow-setup-status 766.7 KB 209.1 KB
query-results 1086.5 KB 209.1 KB
render-ui 1162.4 KB 209.1 KB
visual-review-snapshots 771.6 KB 209.1 KB
workflow-data-suggestions 769.4 KB 209.1 KB
workflow-sending-rules 768.0 KB 209.1 KB
ℹ️ MCP agent API — agent-facing tool changes

What this PR changes for agents, from the tool schema snapshots and tool definitions.

Tools changed (1):

Tool Params Scopes Annotations Schema chars
workflows-send-email description changed

@Silthus
Silthus force-pushed the email-v2/06-template-editor branch from bb9ed7b to 4117342 Compare October 25, 2026 14:59
@Silthus
Silthus force-pushed the email-v2/08-admission-checks-django branch from 49cab17 to 24881fa Compare October 25, 2026 15:00
@Silthus
Silthus force-pushed the email-v2/06-template-editor branch from 4117342 to dc7683f Compare October 25, 2026 17:38
@Silthus
Silthus force-pushed the email-v2/08-admission-checks-django branch from 24881fa to 801bdfc Compare October 25, 2026 17:38
@Silthus
Silthus force-pushed the email-v2/06-template-editor branch from dc7683f to d321dd8 Compare October 25, 2026 22:27
@Silthus
Silthus force-pushed the email-v2/08-admission-checks-django branch from a5d494f to 1d7fe4c Compare October 25, 2026 22:28
@Silthus
Silthus force-pushed the email-v2/06-template-editor branch from d321dd8 to 6730e40 Compare October 26, 2026 01:11
@Silthus
Silthus force-pushed the email-v2/08-admission-checks-django branch from 1d7fe4c to 0eeb308 Compare October 26, 2026 01:11
@Silthus
Silthus force-pushed the email-v2/06-template-editor branch from 6730e40 to d3da69f Compare October 26, 2026 02:34
@Silthus
Silthus force-pushed the email-v2/08-admission-checks-django branch from 0eeb308 to 25f064a Compare October 26, 2026 02:35
Silthus and others added 6 commits October 26, 2026 04:10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A template send can't name `from`, so a sender_not_verified refusal on it
answers with no attr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as them

Django's own refusals on send/email/ answer without why and fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send refused because the workflow's stored cc or bcc is suppressed
answered `attr: "to"`, though the caller sent no cc. When the pipeline's
refusal body carries `attr`, null included, the API now answers with it.
A body without `attr` comes from an older pipeline and keeps the mapped
field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send by `template.key` can now name `from`. When an older pipeline
refuses it with `sender_not_verified` and no `attr`, the answer now points
at `from`, as it does for content your code rendered and as the pipeline
does for any send that names a sender.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the email-v2/08-admission-checks-django branch from 25f064a to 0f434a3 Compare October 26, 2026 05:29

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant