Skip to content

feat(messaging): point a sender refusal at the from a send names - #127181

Merged
trunk-io[bot] merged 87 commits into
email-v2/03-mcp-send-emailfrom
email-v2/08-admission-checks-django
Nov 4, 2026
Merged

trunk-io[bot] merged 87 commits into
email-v2/03-mcp-send-emailfrom
email-v2/08-admission-checks-django

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Moved from Silthus#579.

Re-cut on 2026-11-02 onto #127167, after the scope cut parked local templates and sends by key. This layer keeps only what still applies to a code send.

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

Problem

  • A developer whose code send names a from that no verified sender covers gets 400 sender_not_verified with attr: null. The answer does not say which field to fix.
  • An SDK or agent that maps attr to the field in error has nothing to point at.

Impact

Pays into Grow Workflows through billed workflows_emails actions per paying org: a developer fixes the right field on the first refused send. In scope: Messaging, email API sends.

Merge Danger

Note

🟢 Merge risk: low · two-way door: a revert answers attr: null again

  • Only the attr of one refusal changes, on send/email/, behind workflows-email-from-code. The code, status and detail stay the same.
  • Template sends by id are unchanged: they cannot name a from.

Changes

Kind Files Lines
Production 3 +10 −4
Tests 1 +10 −2

How did you test this code?

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

Test rationale:

  • test_refuses_a_code_send_from_an_unverified_domain now expects attr: "from". It caught the null before the fix.
  • feat(workflows): send an email with inline html from an MCP tool #127167's parameterized no-from test now also asserts attr: null for sender_required. Without it, a class that always names from would pass.
  • Both sender rows assert type, code and attr: a refusal that turns into validation_error, or skips the status map, fails them.

Proofs at 0b3662a (saved with fleet proof), after the merge of #127167 at fcf42b0, which carries #127123's c81b9e0 and master:

  • Live run: Django at this head, cdp-api at feat(cdp): key a code send on the sender the caller chose #127129's head e88ee89, mail caught locally. Every answer and mail matches the run at the previous head 800fae8.
    • A code send without from on a project with a default sender answers 202, and the mail goes out from the default sender. A named verified from answers 202.
    • A code send from an unverified address answers 400 with {"type": "invalid_request", "code": "sender_not_verified", "attr": "from"}, calls no cdp-api and sends nothing.
    • A send without from on a project with two verified senders and no default answers 400 sender_required with attr: null.
  • Lane gate: the migration checks, repo-wide mypy, and the messaging tests pass on the same Python at b405fef. This head adds only the regenerated MCP send schema from feat(workflows): send an email with inline html from an MCP tool #127167. One GPT-6.1 Sol review round on the base merges found nothing.
Earlier live run at the pre-cut stack, 2026-10-31
  • Live run, 2026-10-31. Django at the top of the stack (3a30f51c529), cdp-api and both CDP workers at feat(cdp): send a template from the workflow version Django graded #127130 (f9abed0841c), mail caught in maildev.

    Send Answer
    Code send from an unverified domain 400 sender_not_verified, attr: "from", nothing sent
    MCP send tool from an unverified domain Tool error that names the from field
  • Prod shape: a gap until read-only production access exists on 2026-11-02.

CI report warnings that do not come from this PR: the backend coverage gaps sit in lines this PR does not change (contracts.py 128 to 134, message_templates.py 533 to 577, transactional_email.py 399, html_text.py and transactional_email_workflows.py). This PR changes contracts.py lines 41 and 106 to 109, message_templates.py lines 600 to 615, and transactional_email.py lines 35 and 561. It changes no TypeScript, so the complexity and bundle warnings come from master.

Release status

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

Docs update

None.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

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

Question Answer Who Where
Does this PR keep its own refusal reader? No; it reuses master's refusal code and keeps the code-aware PIPELINE_REFUSALS decoder 👤 Human map Silthus#461, 2026-10-28
Does Django check that the sender can send before it forwards a code send? Yes, already: on each send Django resolves the sender from the project's email integrations and refuses one that is missing or unverified. No new check 🤖 Agent pending approval
Does a sender refusal get its own code, such as sender_not_allowed? No; attr: "from" versus attr: null tells the two cases apart, and no client reads a second code 🤖 Agent pending approval
May the code carry comments? No; names carry the reasons 👤 Human map Silthus#461, 2026-10-25

Involvement: one brief with Michael's shrink decision; one review round (GPT-6.1 Sol) answered; QA swarm on 2026-11-01 with two panel rounds (GPT-6.1 Sol review, Opus 5.5 fresh eyes) and two final reads, the last clean: 2 findings fixed, none rejected; live run captured.

🤖 Generated with Claude Code

Silthus and others added 30 commits October 31, 2026 11:05
A template uploaded from a repository needs to record who owns its
content, which file it came from, and a hash of what it publishes. This
adds those columns (all nullable) and the content hash function. No
route reads or writes them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`PUT /api/projects/:id/messaging_templates/by_key/:key/` creates or
updates the template with that key from HTML kept in a repository. The
first upload makes it transactional with the project's default verified
sender; later uploads publish changed content at once, answer
`unchanged` for the same content and, with `if_not_superseded`,
`superseded` for content a newer upload replaced. Code sends under a
key move to the template's workflow once an upload claims the key.

The upload sits behind `workflows-email-from-code`, keeps the PSAK
bypass and workflow access checks of the send route, gets its own rate
limits, and reports a usage event. The MCP tool
`workflows-upload-email-template` calls it.

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

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

A template managed by code now refuses edits in PostHog with
`403 immutable` until someone releases it with `managed_by: gui`, which
captures a `message template released` usage event. A template made in
PostHog moves into code when someone gives it a `key`: the next upload
under that key keeps its id, workflow and sender, and takes over the code
sends under the key from then on.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A template uploaded from code opens as a read-only preview with its key
and source. Edits refused by an upload wait in a banner that offers a
duplicate, and the menu can stop managing the template in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`POST /api/projects/:id/messaging/send/email/` now takes
`template: {key, hash?}` for a template uploaded from code. A send by key
may set `from`, `reply_to` and `capture_engagement`; a send by
`template.id` still refuses them. With `template.hash`, a hash PostHog
doesn't hold answers `409 template_outdated` and sends nothing, and a
superseded hash sends the current content with a `template_superseded`
warning. An unknown key answers `404 template_not_found`, or
`409 template_outdated` when the send carries a hash. A code-managed
template without text sends the text its html holds.

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

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

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

A publish refused because an upload took the template back over already
shows the warning banner with "Duplicate with my changes". The global
loader toast also showed the server message, so the user saw two errors.
messageTemplateLogic now owns the `immutable` refusal of saveTemplate.
Every other saveTemplate failure keeps the toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code send, or a send by `template.key`, that names a `from` on no
verified domain now answers `sender_not_verified` with `attr: "from"`, so
an SDK can show which field to fix. A send without `from` whose project
has no verified default sender still names no field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Null marks a template made in PostHog that no upload ever claimed. The
help text said it meant gui, which a later check could read as released
from code.

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

# Conflicts:
#	products/messaging/frontend/generated/api.schemas.ts
#	products/messaging/frontend/generated/api.zod.ts
#	services/mcp/src/api/generated.ts
#	services/mcp/src/generated/email_templates/api.ts
#	services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-send-email.json
…ode sends paused

An upload counts as unchanged only when the template's workflow still sends
the uploaded content, so a re-upload repairs a workflow edited elsewhere.
A first upload under a key whose code sends are paused is refused, because
taking the key over would start those sends again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code send under the key of a template no upload claimed stays on the
key's own workflow, and a test now pins that.

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

# Conflicts:
#	products/messaging/backend/tests/api/test_template_upload.py
…a key change

Adopting a key now needs editor access to the workflow that already sends
under it, so a later upload can't move those sends to a template the member
controls. A new key on a code-managed template now answers key_already_set
instead of the lock's refusal, whose fix would not have helped.

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

# Conflicts:
#	products/messaging/backend/tests/api/test_template_upload.py
…ate sends

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…answer an unconfirmed key send

A send by template.key now answers an unconfirmed send like a code send and
reports a distinct addressed_by. A send by template.id takes capture_engagement
and points from and reply_to to template.key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edits until the person acts

A failed release toasts once, a publish an upload refused is no longer reported
as an exception, and the refused-edits banner has no close button that drops
the edits.

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

# Conflicts:
#	products/messaging/backend/tests/api/test_transactional_email.py
GitHub sent no pull_request event for 5b3f20d, so no CI ran on it.

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

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 6006214: no findings.

@Silthus Silthus added the stamphog Request AI approval (no full review) label Nov 4, 2026
stamphog[bot]

This comment was marked as outdated.

@stamphog
stamphog Bot dismissed their stale review November 4, 2026 14:42

A new stamphog review started for this PR — the fresh verdict replaces this approval.

stamphog[bot]

This comment was marked as outdated.

@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 0b3662a: no findings.

@trunk-io

trunk-io Bot commented Nov 4, 2026

Copy link
Copy Markdown

This pull request is queued for merge as part of 127187, which will merge 127167, 127181, 127183, 127187.

@trunk-io

trunk-io Bot commented Nov 4, 2026

Copy link
Copy Markdown

Stacked PR 127187 was cancelled: @Silthus cancelled it.

@trunk-io

trunk-io Bot commented Nov 4, 2026

Copy link
Copy Markdown

This pull request is queued for merge as part of 127187, which will merge 127167, 127181, 127183, 127187.

@trunk-io

trunk-io Bot commented Nov 4, 2026

Copy link
Copy Markdown

Stacked PR 127187 failed testing in the merge queue. Please investigate the failure and re-submit the stack.

@trunk-io

trunk-io Bot commented Nov 4, 2026

Copy link
Copy Markdown

This pull request is queued for merge as part of 127187, which will merge 127167, 127181, 127183, 127187.

@trunk-io

trunk-io Bot commented Nov 4, 2026

Copy link
Copy Markdown

Stacked PR 127187 failed testing in the merge queue. Please investigate the failure and re-submit the stack.

Brings in master with the squash of the Django send layer, so this layer merges into master cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stamphog
stamphog Bot dismissed their stale review November 4, 2026 18:59

A new stamphog review started for this PR — the fresh verdict replaces this approval.

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved.

The API and send-path changes have owning-team authorship and targeted regression assertions. No concrete unresolved review concerns or other policy refusal grounds are present; the listed reviews concern older commits.

Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 14L, 3F substantive, 26L/4F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1b-small (26L, 4F, single-area, feat)
stamphog 2.7.0 .stamphog/policy.yml @ 78eb463 · reviewed head 78eb463

@trunk-io

trunk-io Bot commented Nov 4, 2026

Copy link
Copy Markdown

This pull request is queued for merge as part of 127187, which will merge 127167, 127181, 127183, 127187.

@trunk-io
trunk-io Bot merged commit 23f3570 into master Nov 4, 2026
283 checks passed
@trunk-io
trunk-io Bot deleted the email-v2/08-admission-checks-django branch November 4, 2026 20:20
@trunk-io

trunk-io Bot commented Nov 4, 2026

Copy link
Copy Markdown

This pull request was merged into master as part of stacked PR 127187.

@deployment-status-posthog

deployment-status-posthog Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-11-04 21:00 UTC Run
prod-us ✅ Deployed 2026-11-04 21:42 UTC Run
prod-eu ✅ Deployed 2026-11-04 21:35 UTC Run

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

Labels

stamphog Request AI approval (no full review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants