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-09T23:58:05.846197+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 |
| attachments, (str, bytes, bytearray, memoryview, Mapping) | ||
| ) or not isinstance(attachments, Sequence): | ||
| raise _invalid_attachment("attachments must be a list of files.", "attachments") | ||
| return [_attachment(index, file) for index, file in enumerate(attachments)] |
There was a problem hiding this comment.
Medium: Bound attachments before encoding
The documented 10-file/512 KB limits are not checked before this comprehension encodes every attachment and _encode materializes the JSON body; an isolated preparation check accepted an 8 MiB file and allocated roughly 32 MiB beyond the original input. As defense in depth for applications forwarding user uploads, check file count and aggregate size before encoding, including bounds for Base64 strings, since server-side rejection cannot protect the host process from these allocations.
There was a problem hiding this comment.
Not changing this. The server owns the 10-file and 512 KB limits, so it can raise them without a new SDK release. A copy of the limits here would drift. The caller already holds the file in memory, and Base64 adds a third to its size. That cost is the caller's choice, and the server refuses the send before anything goes out.
PR overviewThe PR extends emails.send to support CC and BCC recipients and email attachments, including encoding attachments for the request body. One issue remains open: attachment count and size limits are not enforced before encoding and JSON serialization, allowing oversized inputs to cause substantial memory allocations in the calling application. This is a defense-in-depth gap for applications forwarding user uploads, since server-side rejection occurs too late to prevent those local allocations. No issues have been addressed yet. Open issues (1)
Fixed/addressed: 0 · PR risk: 4/10 |
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…chments # Conflicts: # .github/scripts/check_strict_types.sh # posthog/emails.py # posthog/test/test_emails.py # references/public_api_snapshot.txt
SDK performance: posthog-pythonBenchmarks completed. No changes flagged for review. Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate. View all results
Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals. Each shard uses its own runner; compare Before PR against With PR within a row, not across shards. Compared 102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in |
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael On hold, and back to draft. The attachments API moves to upload first, then send: the SDK hashes each file, uploads it, and sends a reference instead of Base64 content. This PR gets reworked to that shape, cc and bcc included, and nothing merges until the server side is live. |
A code send hashes each file, asks PostHog which files it lacks, PUTs those straight to PostHog's file storage with the returned headers and without the secret key, then sends the email naming each file by its SHA-256. The client checks 10 files and the 40 MB message estimate before any request. A failed upload sends no email and surfaces the storage's answer. Part of PostHog/michaelr-context#81. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An injected client's defaults leaked into the presigned PUT: httpx params replaced the URL's signature query, a requests session appended its params, and default headers rode along. The upload is now a standalone request with only the returned URL and headers, sent through the same transport and retries. Part of PostHog/michaelr-context#81. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rivate on uploads The standalone storage PUT skipped requests' environment merge, so a custom CA bundle or proxy applied to sends but not to uploads. It now merges the environment's transport settings, without session headers or auth. A storage network error is re-raised without its cause, so the presigned URL never reaches a traceback. Part of PostHog/michaelr-context#81. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aults - The client counts attachments before it reads any file. - Bytes that two attachments share go in the upload request once. - The upload parser returns only the uploads still to make, each with its attachment index. - A storage 500, 502 or 504 is retried like 503. - A refused upload no longer takes the names of PostHog's API statuses. - An unreachable storage raises network_error naming the attachment. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit stopped retrying a storage 429, which the general rate-limit branch had retried. Storage 429 is retried again and named rate_limit_exceeded. Names replace the comments that commit added. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PostHog version without the upload route answered 404, and the send raised a bare not_found with no field. The docs promise that an older PostHog's refusal names the field. It now raises not_found with field attachments, a reason and a fix, and sends nothing. Tests now cover the names a storage fault gets once its retries run out. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client copied and hashed every file before it checked the message size. Ten views of one large buffer could exhaust memory before message_too_large. Each file is now sized from its buffer or its Base64 length, the estimate is checked, and only then are files read and hashed. PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extra trailing padding decodes, so it must not shrink the estimate. Ticket: PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server counts html and text each like a file: UTF-8 bytes, Base64 with line breaks. Pin both bodies, their UTF-8 length and per-body rounding. Ticket: PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 404 can mean a PostHog without the upload route or sending from code off for the project, so keep the server's reason on attachments instead of naming one cause. Ticket: PostHog/michaelr-context#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge gate: node-store (#77) and django-facade (#78) live in both regions, django-facade including PostHog/posthog#131763 at its head that forwards
content_type/content_id, and the allowlist*in both regions (opening #79 PR 3). 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, so extra trailing padding can't slip a file past the limit. The estimate matches the server's byte for byte: each file and each ofhtmlandtext(as UTF-8 bytes) count as Base64 with line breaks, plus 64 KiB. 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, and none of the client's default params or headers. The environment's CA bundle and proxy settings still apply.PUTraisesupload_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.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
Note
🔵 Merge risk: low · one-way door once released:
cc,bccandattachmentsonemails.sendbecome a public SDK contractemails.send: a bug in the shared request path would fail their sends loudly. Every send now runs through the reworked request helper; a shadow diff shows sends without files and status reads unchanged.field_not_acceptedand no upload; apps pin the SDK version.💚 How did you test it?
dfc3f8f: ruff format and check, mypy,import posthogwith warnings as errors, the strict type smoke, the public API snapshot, and the full test suite (6293 passed) pass locally. CI: see the checks on the head.text, misname a storage 429 or 503, leave the upload route's 404 bare, drop PostHog's 404 answer, and turn the 404 intofield_not_accepted.emails.sendmakes, with HTTP faked at the transport: the upload ask, the PUT's exact URL, method, headers and TLS settings, the skip onpresent, shared bytes uploaded once, no send after a refused PUT, retried storage faults, the send body's references, the limits at the edge (the largest file that fits, and three bytes more; a 57-byte body counted as 78 bytes and 57 two-byte characters as 156), and the sync and async clients, with injected clients that carry their own params and headers.dfc3f8f(a local app server and storage server): a storage 500, 429 or async 503 is retried and the email sent; a 403 raisesupload_failedwith no send; an unreachable storage raisesnetwork_errornaming the attachment, with no signed URL in the traceback.dfc3f8f: the same 20 calls (template and code sends, retries, refusals, quota, status reads; sync and async) throughmainand this head make the same 28 requests and end the same way.dfc3f8f, against a local PostHog with the server PRs' current heads merged, SeaweedFS and maildev: new files upload with only the signed headers and deliver byte-identical, with bcc only in the envelope.391a797, same setup: new files upload with only the signed headers and deliver byte-identical, with bcc only in the envelope; the same files again answerpresentand upload nothing; the same bytes in two attachments upload once and arrive as two files; a tampered upload raisesupload_failedand sends nothing; a project off the allowlist getsfield_not_accepted; the async client delivers. An inline image (content_id) and an explicitcontent_typedeliver as sent once the server PRs agree on the reference's field names, proven with a local server patch tracked on the ticket.dfc3f8f: 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. 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 retriednot_foundwithfieldattachments; not a bare 404Involvement: reviews: design pass, four rounds (one a full nine-lens panel), final reads; ReviewHog unavailable. Proof captured: tests, mutation checks, shadow diff, live run and cross-version.
🤖 Generated with Claude Code