Repository navigation
Conversation
Takes the flat send body from #1155, and sends a flat distinct_id from the local-template form too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parks the local-template form (template_data on a code send, the content hash, the upload by key and its retry) and the cc, bcc, headers, tags and attachments fields, to match posthog-node. A code send now takes rendered html and/or text only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A code send takes cc and bcc as one address or a list, and attachments as files with bytes or Base64 content. The client encodes bytes once, before any retry. A file it can't send raises EmailError with the code invalid_attachment and a field such as attachments__0__content, and the error never repeats the file's content. A template_id send refuses all three fields before any request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note 🤖 Claude Opus 5.5 writing on behalf of Michael Decisions for the attachments slice:
|
posthog-python Compliance ReportDate: 2026-11-12T17:14:17.426807+00:00 ✅ All Tests Passed!270/270 tests passed Capture_V1 Tests✅ 95/95 tests passed View Details
Capture_Ai Tests✅ 5/5 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
Feature_Flags_V4 Tests✅ 149/149 tests passed View Details
Feature_Flags_Local_Evaluation Tests✅ 4/4 tests passed View Details
|
next(..., None) used None as its no-match value, so a None key hid the unknown keys after it. 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>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
StampHog can't approve this pull request automatically. Two gates refused it. The deny-list gate flagged a public API change: posthog/emails.py adds the EmailAttachment type and new cc, bcc and attachments fields on CodeEmailArgs, and references/public_api_snapshot.txt records them. The tier gate classified the change as T2-never (336 lines across 5 files, cross-cutting, a feature), a tier that is never auto-approved.
The size gate passed, so splitting the pull request won't change the outcome. Ask a human maintainer to review it, since a public API addition needs that sign-off.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 124L, 2F substantive, 336L/5F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (336L, 5F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head a080862 |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/emails-code-attachments # Conflicts: # posthog/emails.py # posthog/test/test_emails.py
A text file passed as a str was sent unchecked as if it were Base64. The client now raises invalid_attachment before any request and hints to pass the file's bytes instead. 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>
The base PR owns emails-code-form.md, so this PR no longer edits it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… upload tests The redirect and cut-short storage tests compared the server's request list, which held the storage object path. They now assert the caller's error, that no email was sent, and that the redirect was never followed. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g fake The cc refusal test now sends to a PostHog that doesn't take cc and asserts the caller's error and that nothing was delivered, instead of counting requests on a canned transport. The two oversized-email tests assert that nothing was stored or delivered. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog won't auto-review this pull request, and a human will need to look at it. Two gates refused it. The deny-list gate matched public_api, because the change adds new public API surface (the cc, bcc and attachments fields and EmailAttachment) to posthog/emails.py and references/public_api_snapshot.txt. The tier gate classified it as T2-never: a cross-cutting feature of 2366 lines across 6 files, including tests and docs.
The size gate passed, so splitting is optional. Ask a human reviewer to take it, or break out the pieces (for example cc/bcc separately from the attachment upload flow) so each is easier to review.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 686L, 3F substantive, 2366L/6F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (2366L, 6F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head 3db6c98 |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 3db6c98: 1 Should fix.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
A client that follows a 307 or 308 on the PUT sends the file again to the new URL, even over plain HTTP, and reports success. Both upload paths already refuse redirects. New sync and async tests have the storage answer 307 to a plain-HTTP server and assert upload_failed on the attachment without either URL, that the other server received nothing, and that no email was sent. Without the fix, requests and an httpx client with follow_redirects deliver the file there. The docs and changeset say the client never follows a redirect. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test shows a send retried after a lost reply replays with its idempotency key. It can't observe whether uploads are asked for again, so the name no longer promises that. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog won't review this pull request, because two policy gates refused it. The deny-list gate flagged it for public_api: it adds cc, bcc and attachments to the public posthog.emails types, which shows up in references/public_api_snapshot.txt. The tier gate classified it as T2-never, since it is a cross-cutting feature touching 6 files and 2438 lines.
A human reviewer needs to look at this one. You can ask a maintainer to review it. You could also make it easier to review by splitting it up, for example separating the cc/bcc support from the attachment upload flow.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 687L, 3F substantive, 2438L/6F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (2438L, 6F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head f3f5fa3 |
The size check walked back over any number of trailing "=", so a short Base64 string followed by tens of millions of "=" passed as a few bytes, and b64decode then copied and scanned all of it, blocking the async client's event loop. Standard Base64 has at most two "=", so a third now raises invalid_attachment with the Base64 advice during that walk, before any copy. The extra-padding size case is refused this way now. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog can't auto-approve this pull request because two gates refused it. The deny-list gate flagged it for public_api, since references/public_api_snapshot.txt changes and new public attributes are added to posthog.emails (cc, bcc, attachments, EmailAttachment). The tier gate classified it as T2-never: a cross-cutting feature of about 2,461 lines across 6 files, including a large rewrite of posthog/emails.py.
A human reviewer needs to look at this one, so please ask a maintainer to review it. Splitting it into smaller pieces, such as the cc/bcc fields separately from the attachment upload flow, may make that review easier, though the public API changes will still need a human.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 693L, 3F substantive, 2461L/6F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (2461L, 6F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head b32cc2d |
|
No review started. stamphog started a review of this pull request less than 10 minutes ago, and a label can request at most one review per 10 minutes. The |
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog can't approve this pull request, because two gates refused it. The deny-list gate matched public_api: references/public_api_snapshot.txt and posthog/emails.py add new public attributes (cc, bcc, attachments on CodeEmailArgs, and the new EmailAttachment type). The tier gate classified the change as T2-never, since it is a cross-cutting feature touching 2461 lines across 6 files. The size gate passed, so size was not the reason.
A human reviewer will need to look at this one. Ask a maintainer for review. You could also split the work into smaller pull requests, but the public API additions will still need a human reviewer.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 693L, 3F substantive, 2461L/6F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (2461L, 6F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head b32cc2d |
Go, JS, Ruby and PHP raise attachment_upload_failed for a refused or redirected upload, and the docs list one code for every SDK. The code was upload_failed only in this unreleased change, so it takes the shared name in the error, the tests, the docstrings and the changeset. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Not approved — this change needs a human reviewer.
Re-adding the stamphog label gives the same result unless the changed files change.
stamphog can't auto-review this pull request because two policy gates refused it. The deny-list gate flagged it for touching the public API: references/public_api_snapshot.txt gains new CodeEmailArgs fields (cc, bcc, attachments) and a new EmailAttachment type. The tier gate classified it as T2-never, meaning a cross-cutting feature of about 2,461 lines across 6 files, which is never eligible for automatic approval.
Please ask a human reviewer to take this one. If it's practical, you could also split it, for example by landing the cc/bcc support separately from the attachment upload flow, so each piece is smaller and easier to review.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✗ | matches: public_api |
| size | ✓ | 693L, 3F substantive, 2461L/6F incl. docs/generated/snapshots — within ceiling |
| tier | ✗ | classified as T2-never: T2-never (2461L, 6F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head 7de6993 |
marandaneto
left a comment
There was a problem hiding this comment.
The code looks good; no qualifying findings. Verdict: correct.
Keeps both main's tracking and sensitive fields for code sends and this branch's cc, bcc and attachments. The test fake accepts the new fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two cross-server redirect tests now run against a local PostHog whose storage answers the upload with a 307 to a second local server. They assert the second server receives nothing and no email is sent, instead of injecting the redirect through mocked transports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge gate: node-store (#77) and django-facade (#78) live in both regions, the allowlist at * in both regions, and a real send through this SDK version with a real project key, delivered and confirmed by Michael. Then the repo's normal release.
Ticket: PostHog/michaelr-context#81
💡 Motivation and Context
Problem: an app moving its email from Resend to PostHog can't send a receipt with a PDF, or copy finance on an invoice.
posthog.emails.sendtakes renderedhtml/textonly, and files sent inline in the send request would cap a message far below what senders need.Change: the code form takes
cc,bccandattachments. The SDK hashes each file, asks PostHog where to upload the files it lacks, uploads them straight to file storage, then sends the email with each file's hash in place of its bytes.cc/bcc: one address or a list. A string becomes a one-item list on the wire. An empty list is left out, asreply_tois.attachments:{filename, content, content_type?, content_id?}. Content is bytes, any bytes-like, or a Base64 string. The SDK hashes it withhashlib.sha256.too_many_attachments) and a message over 40 MB after Base64 encoding, about 29 MB of files (message_too_large). There is no per-file cap. It counts and sizes the files before it copies or hashes any, so an oversized email costs no memory. A Base64 string is sized by its data, and more than two trailing=is refused asinvalid_attachmentbefore the string is copied, so padding can neither slip a file past the limit nor cost memory. The estimate matches the server's byte for byte: each file and each ofhtmlandtext(as UTF-8 bytes) count as Base64 with line breaks, the subject adds 75 bytes per 42 UTF-8 bytes, plus 64 KiB. Withouttext, the HTML counts twice, because PostHog derives the text from it and the derived text is never longer. Missing or unknown keys and empty content raiseinvalid_attachmentwith afieldsuch asattachments__0__content, and the error never repeats the content.POST /messaging/attachments/uploads/with each distinct file's hash and size, so the same bytes in two attachments upload once. A file PostHog already has comes backpresentand is skipped. For the rest, the SDK sends a plainPUTto the returned URL with exactly the returned headers: no bearer token, none of the client's default params or headers, and no redirect followed: a 3xx answer raisesattachment_upload_failedon that attachment, naming no URL, and sends nothing, since following a 307 or 308 would send the file again to the new address, even over plain HTTP. It uploads only to anhttpsURL: a plain-HTTP upload URL raisesapi_errornaming the attachment before any upload, unless the client's own host is plain HTTP, as in local development. The environment's CA bundle and proxy settings still apply. A refusal from the upload route namesattachments, or the first attachment holding the file it names, never the wire fieldfiles.PUTraisesattachment_upload_failedwith the HTTP status and the bucket's own error code and message, and no email is sent. A storage that is unreachable or answers 429, 500, 502, 503 or 504 is retried like a send. Out of retries, an unreachable storage raisesnetwork_errorwithfieldnaming the attachment, and the signed URL never reaches a traceback.429 upload_quota_exceeded) is not retried.not_foundonfieldattachmentswith PostHog's own reason, and sends nothing. A 404 means a PostHog without the route or sending email from code off for the project, so the SDK names neither and never turns it intofield_not_accepted.template_id, all three fields are refused before any request.trackingandsensitivefrom #1216 in one send.It mirrors posthog-node in PostHog/posthog-js#5514. Server side: PostHog/posthog#131833 (upload route), #131944 (send by reference).
Spec divergence: the emails spec doesn't cover
cc,bccor attachments yet, and its open draft parks them. Follow-up: spec them from this contract.Impact: Grow Workflows. Attachments are part of Resend parity. The server's send event gains attachment counts and sizes, refusals included, which show whether size refusals fall once teams move over.
Merge Danger
Caution
🔴 Merge risk: high · one-way door once released:
cc,bccandattachmentsonemails.sendbecome a public SDK contractcc,bccorattachmentsgo from a localTypeErrorto a real send with copies and files, in both regions. These rows are new, so no base-versus-HEAD proof covers them: a missed bug there could send a copy or a file to the wrong recipient, or expose a bcc, with no error. The tests, the live run and the merge gate's real send exercise them, and the production diff is over 400 lines.mainand this head.attachmentswithfield_not_acceptedbefore any upload.ccandbcchave none: a fault there is fixed forward with a new SDK version.Rollout
💚 How did you test it?
174a60b, after mergingmain: ruff format and check, mypy,import posthogwith warnings as errors, the strict type smoke, the public API snapshot, and the full test suite (7013 passed) pass locally. CI: see the checks on the head.Authorizationheader, an expired URL, mismatched bytes and a wrong method, as S3 does. The tests assert the delivered email, the stored files and the error, never request shapes. The CA trust, redirect and cut-off-answer tests run over real sockets, the CA test with a real TLS handshake. Each fix was red first. Mutations each fail at least one test: keep the session's or the client's Authorization on the PUT, upload a file marked present, ignore the bodies in the estimate, allow an eleventh file, treat a 4xx PUT as success, upload shared bytes twice, stop retrying a storage 500, give storage refusals PostHog's names, drop the attachment from an unreachable-storage error, count files after reading them, check the size after copying, size Base64 including extra padding, allow more than two=of Base64 padding (a 70 MB peak for 40 MB of=), count body characters instead of UTF-8 bytes, count html and text as one body, count raw body bytes, ignoretext, ignore the subject or count its characters, count 43 bytes per encoded word, size padded Base64 by copying it, follow a storage redirect in the async client, follow a storage 307 to a plain-HTTP server (sync and async; without the fix the file reaches that server), misname a storage 429 or 503, count the HTML once withouttext, skip or always skip the https check, name the wire field of an upload refusal, read a storage answer after the upload on a streaming session, dropccorbcc, leave the upload route's 404 bare, drop PostHog's 404 answer, and turn the 404 intofield_not_accepted.emails.send, sync and async, with PostHog and its storage faked behind the transport: the error and itsfield, the bytes storage holds, and the email PostHog delivers (its attachments,ccandbcc, and whether it was sent at all). That covers the skip onpresent, shared bytes stored once, no email after a refused or redirected PUT, retried storage faults, a lost reply replayed by its idempotency key, the limits at the edge (the largest file that fits and one byte more; a 57-byte body counted as 78 bytes and 57 two-byte characters as 156; a 42-byte subject counted as 75 and 43 bytes as 150), and injected clients that carry their own params, headers and redirect settings.174a60b(a local app server and storage server): a storage 500, 429 or async 503 is retried and the email sent; a 403 raisesattachment_upload_failedwith no send; an unreachable storage raisesnetwork_errornaming the attachment, with no signed URL in the traceback.174a60b: the same 24 calls (template and code sends, sends withmain'strackingandsensitive, retries, refusals, quota, status reads; sync and async) throughmainand this head make the same 32 requests and end the same way.1828040, reused at174a60bbecause no upload request in it changes since (the switch-off refusal now namesattachmentsinstead of the server'sfiles, with the same single request, and sincemain's #1216 a send leavescapture_engagementout unless set, PostHog's default): the server PRs' heads merged as fetched, with no server patch, plus SeaweedFS and maildev. New files upload with only the signed headers and deliver byte-identical, with bcc only in the envelope. A file with an explicitcontent_typeand an inline image withcontent_iddeliver as sent. The same files again answerpresentand upload nothing. The same bytes in two attachments upload once and arrive as two files. A tampered upload raises the storage refusal (then namedupload_failed, nowattachment_upload_failed) and sends nothing. A project off the allowlist getsfield_not_accepted. Line-wrapped Base64 raisesinvalid_attachmentbefore any request. The async client delivers.1828040; at174a60bthe gap for cc, bcc and attachments against a live backend is accepted by Michael until the real-send merge gate passes: againstmaster, a send with a file makes one request, the upload ask, and raisesnot_foundonattachmentswith PostHog's own reason ("Endpoint not found."). A plain send still delivers, and a send withccandbccdelivers with the bcc only in the envelope. Against the server PRs with sending email from code off, a send with a file raisesnot_foundonattachmentswith Django's reason ("Not found."), and nothing uploads or sends.Earlier evidence: inline Base64 attachments (
ac102e0)ac102e0: CI green, review clean, and both owed proofs captured.master, a send with these fields got a 400 namingattachmentsand nothing was sent. A plain send still delivered.📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Written by Claude Opus 5.5 (
claude-opus-5-5) in Claude Code, for Michael.daily_quota_exceededat once; a rate limit is still retriedattachment_upload_failed, the name Go, JS, Ruby and PHP use, for a refused or redirected upload; renamed fromattachment_upload_failed, which no release carriednot_foundwithfieldattachments; not a bare 404Involvement: reviews: design pass, nine rounds (six of them full nine-lens panels), final reads, and fixes for PostHog Review's findings; ReviewHog unavailable. Proof captured: tests, mutation checks, shadow diff, live run and cross-version.
🤖 Generated with Claude Code