Skip to content

feat(cdp): refuse an accepted send the send checks would skip - #577

Draft
Silthus wants to merge 7 commits into
email-v2/07-send-policyfrom
email-v2/08-admission-checks
Draft

Silthus wants to merge 7 commits into
email-v2/07-send-policyfrom
email-v2/08-admission-checks

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.
  • This is the cdp-api half. feat(messaging): answer a send the checks refuse with why and fix #579 makes Django pass 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: CDP email sends.

Merge Danger

Warning

🟡 Merge risk: medium · two-way door: a revert lets the worker skip these sends again behind a 202

  • cdp-api's accepted-send route now refuses with 400 {error, code, detail, why, fix} before it queues, and releases the idempotency key. Only send/email/ uses that route, behind workflows-email-from-code. The template route is unchanged.
  • Merge order: this PR and feat(messaging): answer a send the checks refuse with why and fix #579 (Django) may land in either order. Each service deploys on its own, so either one can be live first.
  • With this PR live and Django still older: a refused send answers 503 "did not confirm the send" and nothing is sent. A valid send delivers. The live run below shows both.
  • Not behind the flag: the worker's input formatter moves into an exported function, so admission renders stored copies the same way. The worker's output is unchanged.
  • Worker skip lines keep their Skipping send: prefix, which the broadcast test-send modal reads.

Changes

Kind Files Lines
Production 9 +256 −51
Tests 5 +333 −20
  • cdp-api refuses an accepted send the worker would skip, before it queues. It runs the send policy from refactor(cdp): move the email send checks into one send policy #576 on the accepted-send route. Nothing is queued and the idempotency key is released.
  • The refusal names a code (invalid_recipient, recipient_suppressed, template_paused, sending_suspended or sender_not_verified) with detail, why and fix.
  • The template route keeps leaving these checks to the worker.
  • Admission checks the recipients the worker checks: to, and the cc and bcc stored on the email step.
  • Admission renders those stored copies through the worker's formatter, with the workflow's default variables merged under the request's. Liquid and Hog copies, display names and apostrophes then match the worker. Copies built from person properties, groups or source globals, and a copy that fails to render, stay with the worker.
  • Every refusal carries attr. A suppressed to names to. A suppressed stored copy answers attr: null, since the caller sent no cc, and its why and fix point at the workflow's email step.
  • A refused send counts one suppressed_hit. The policy's rejection names the suppressed addresses, so the refusal takes its attr and copy from them without a second lookup.
  • The address check (invalid_recipient) runs on to beside the send policy, as the worker's check does. The email validation service returns why an address can't receive email, so the route and the worker share one check.
  • Mechanical: the policy's detail loses its Skipping send: prefix; the worker adds it back to its log line.

How did you test this code?

Test seams: cdp-api's accepted-send route over HTTP (cdp-api.serial.test.ts), the send policy (email-send-policy.test.ts) and the email service (email.service.test.ts).

Test rationale:

  • The send checks rows in cdp-api.serial.test.ts, run red first: a send the worker would skip answering 202.
  • leaves the checks to the worker on the template route: the template route starting to refuse.
  • refuses a keyed send when …, then sends it on the same key once fixed: a refusal that holds its key, for a paused workflow and for a suppressed cc or bcc stored on the workflow, rendered from a display name, an apostrophe, a default variable and Hog.
  • The attr and hit columns of those rows: a stored-copy refusal naming to, and a refusal that counts its suppression hit twice.
  • The skip lines in email-validation.service.test.ts and email.service.test.ts pin the Skipping send: prefix the broadcast test-send modal matches.

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 (8b150c5, Django at #579 a5d494f) 809dec8, Django before #579
Valid code send 202, delivered 202, delivered
Suppressed recipient 400 recipient_suppressed, attr: "to", with why and fix; nothing sent 503 delivery_unavailable; nothing sent
Domain with no mail server 400 invalid_recipient, attr: "to"; nothing sent 503 delivery_unavailable; nothing sent
Keyed send whose stored cc renders a suppressed address 400 recipient_suppressed, attr: null, why names the email step's cc; nothing sent 503 delivery_unavailable; nothing sent
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 nodejs/.

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
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; final read 1 by GPT-6.1 Sol fixed, final read 2 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/07-send-policy branch from 4c04d3c to 5e57bdf Compare October 25, 2026 11:39
@Silthus
Silthus force-pushed the email-v2/08-admission-checks branch from c4fc6e3 to c55794e Compare October 25, 2026 11:39
@Silthus Silthus changed the title feat(messaging): refuse an email send the send checks would skip feat(cdp): refuse an accepted send the send checks would skip Oct 25, 2026
Silthus and others added 4 commits October 25, 2026 17:47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y, not in it

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the email-v2/08-admission-checks branch from c55794e to 66e7f10 Compare October 25, 2026 18:50
Silthus and others added 3 commits October 25, 2026 19:14
…does

Admission rendered the email step's cc and bcc with HTML escaping, with
only the request's variables, and left Hog templates unevaluated. The
worker renders them as plain text, merges the workflow's variable
defaults, and evaluates Hog. A copy that took a display name, an
apostrophe address or a default from a variable, or that came from a Hog
template, could name a suppressed address admission did not see.

Admission now renders the copies through the worker's input formatter
with the invocation's merged variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An accepted send refused as `recipient_suppressed` because of the email
step's stored cc or bcc answered with the copy for a suppressed `to`. The
refusal body now carries `attr`: `to` when the request's `to` is
suppressed, null when only a stored copy is, with a why and fix that name
the workflow's email step. Every send-check refusal body carries `attr`,
so Django can rely on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recipient_suppressed rejection now names the suppressed addresses it
found, so the refusal picks its attr and copy from them instead of looking
the recipients up again and counting each suppressed_hit twice.

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

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