Repository navigation
feat(messaging): point a sender refusal at the from a send names - #127181
trunk-io[bot] merged 87 commits into
Conversation
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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
A new stamphog review started for this PR — the fresh verdict replaces this approval.
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
|
Stacked PR 127187 failed testing in the merge queue. Please investigate the failure and re-submit the stack. |
|
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>
A new stamphog review started for this PR — the fresh verdict replaces this approval.
There was a problem hiding this comment.
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 |
|
This pull request was merged into |
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
fromthat no verified sender covers gets400 sender_not_verifiedwithattr: null. The answer does not say which field to fix.attrto the field in error has nothing to point at.Impact
Pays into Grow Workflows through billed
workflows_emailsactions 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: nullagainattrof one refusal changes, onsend/email/, behindworkflows-email-from-code. The code, status and detail stay the same.from.Changes
fromno verified sender covers answerssender_not_verifiedwithattr: "from". That holds for a code send.fromon a project with no default sender answers feat(workflows): send an email with inline html from an MCP tool #127167'ssender_required, withattr: null, since the caller sent no field to fix.TransactionalEmailRejectedpath and the{type, code, detail, attr}shape, withtype: invalid_requestand the status from master's code map.TransactionalEmailRejectedsubclasses DRF'sValidationError, soexceptions_hogreads its field. A dict detail on a plainAPIExceptionanswers500there.PIPELINE_REFUSALSdecoder for cdp-api's answers stays as it is on master.products/messagingfiles above.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_domainnow expectsattr: "from". It caught thenullbefore the fix.fromtest now also assertsattr: nullforsender_required. Without it, a class that always namesfromwould pass.type,codeandattr: a refusal that turns intovalidation_error, or skips the status map, fails them.Proofs at
0b3662a(saved withfleet proof), after the merge of #127167 atfcf42b0, which carries #127123'sc81b9e0and master:e88ee89, mail caught locally. Every answer and mail matches the run at the previous head800fae8.fromon a project with a default sender answers202, and the mail goes out from the default sender. A named verifiedfromanswers202.400with{"type": "invalid_request", "code": "sender_not_verified", "attr": "from"}, calls no cdp-api and sends nothing.fromon a project with two verified senders and no default answers400sender_requiredwithattr: null.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.sender_not_verified,attr: "from", nothing sentfromfieldProd 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.py128 to 134,message_templates.py533 to 577,transactional_email.py399,html_text.pyandtransactional_email_workflows.py). This PR changescontracts.pylines 41 and 106 to 109,message_templates.pylines 600 to 615, andtransactional_email.pylines 35 and 561. It changes no TypeScript, so the complexity and bundle warnings come frommaster.Release status
Docs update
None.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)
dj3of map Map: send code-managed email templates with one PostHog call Silthus/posthog#461 onto master's refusal code, on feat(workflows): show code-managed email templates read-only #127179 at7b2198c. One GPT-6.1 Sol review round; both findings rejected with reasons in the ledger.qa-swarm,modernize-pr,tdd,writing-tests,improving-drf-endpoints,pr-description,writing-pr-descriptions.PIPELINE_REFUSALSdecodersender_not_allowed?attr: "from"versusattr: nulltells the two cases apart, and no client reads a second codeInvolvement: 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