Repository navigation
feat(emails): send cc, bcc and attachments - #239
Conversation
Add PostHog.Emails.send/2 for a template made in PostHog (template_id, template_data) or content the app rendered (template_key, subject, html and/or text), and PostHog.Emails.get/2 for an email's status. Both use the project secret key against the app host, with one idempotency key across retries. New config options: app_host, email_request_timeout_ms, email_request_max_retries and migrated_from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… absolute app_host - Turn off Req's body decoding, so a 2xx with malformed JSON is an api_error with its status instead of a retried network_error. - Run each request in a task stopped at email_request_timeout_ms, so the timeout bounds the whole request, not only each socket read. - Validate app_host as an absolute http(s) URL, so a relative value can't resolve against the ingestion host and send it the secret key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A linked Task left {:EXIT, pid, :normal} in the mailbox of a caller that
traps exits, which crashes a GenServer without a matching handle_info/2.
The worker is now spawned with a monitor only, and the deadline path waits
for its DOWN message and drops a late reply, so the caller's mailbox stays
clean after a reply and after a timeout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unlinked worker outlived a caller killed mid-request, such as by a job timeout, so the request could still go out after cancellation. The worker is now spawned linked and monitored in one step. It unlinks before it replies, and the caller unlinks before it kills the worker at the deadline, so a caller that traps exits still gets no exit message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A linked worker killed its caller when it crashed. The worker is now only monitored, so its death returns network_error, and a watchdog kills it when the caller dies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Other async modules start the default PostHog instance, so asserting it was not running flaked on CI. The test now starts the instance in the PostHog group and checks that send/1 and get/1 reach it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A secret key starts the flag definition loader, whose requests could take the mock's expectations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…down Finch raises when no pooled connection frees up in time, which skipped the retry loop as an unexpected error; it is now a retryable network error. A call waiting to retry now checks the PostHog instance first and returns client_closed, without a request, when the instance has stopped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Email requests never send the secret key to an ingestion proxy, but local flag evaluation fetches definitions from api_host with the same key. Tell users behind such a proxy to turn local evaluation off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send with template_key takes cc and bcc, one address or a list, and attachments, maps of filename, content bytes, content_type and content_id. The SDK encodes the bytes as Base64 once, before the retry loop, and refuses a path or unknown key with invalid_attachment and a field that names it. Empty address and attachment lists stay out of the request, so an older server still sends the email. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client that exits inside the request worker, as Finch does when its pool restarts, came back to the caller as an exit and crashed it. It is now a retryable network_error, and a throw is unexpected_error. Also reuse UUIDv7's encode and decode, read the email timeout and retries straight from the validated config, and name the config helper's argument for what it takes. 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>
posthog-elixir Compliance ReportDate: 2026-11-13T06:08:50.390132+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 609ms |
| Format Validation.Event Has Uuid | ✅ | 608ms |
| Format Validation.Event Has Lib Properties | ✅ | 610ms |
| Format Validation.Distinct Id Is String | ✅ | 609ms |
| Format Validation.Token Is Present | ✅ | 608ms |
| Format Validation.Custom Properties Preserved | ✅ | 609ms |
| Format Validation.Event Has Timestamp | ✅ | 609ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ❌ | 608ms |
| Retry Behavior.Retries On 503 | ✅ | 5614ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2612ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2611ms |
| Retry Behavior.Respects Retry After Header | ✅ | 5615ms |
| Retry Behavior.Implements Backoff | ✅ | 15626ms |
| Retry Behavior.Retries On 500 | ✅ | 5614ms |
| Retry Behavior.Retries On 502 | ✅ | 5613ms |
| Retry Behavior.Retries On 504 | ✅ | 5615ms |
| Retry Behavior.Max Retries Respected | ✅ | 15626ms |
| Deduplication.Generates Unique Uuids | ✅ | 620ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5614ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10620ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5616ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 614ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 610ms |
| Compression.Sends Gzip When Enabled | ✅ | 609ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 609ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 606ms |
| Batch Format.Multiple Events Batched Together | ✅ | 613ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2611ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2611ms |
| Error Handling.Retries On 408 | ✅ | 5611ms |
Failures
format_validation.non_utc_event_timestamp_is_converted_to_utc
Event 0 field 'timestamp' instant '2026-11-13T06:07:06.359009Z' != expected '2025-01-02T03:04:05Z'
Feature_Flags Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 8ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 6ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 6ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 6ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 6ms |
| Request Payload.Groups Round Trip | ✅ | 5ms |
| Request Payload.Groups Default To Empty Object | ❌ | 6ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 6ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 6ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 6ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 3ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 608ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 10ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ❌ | 6ms |
| Retry Behavior.Retries Flags On 502 | ❌ | 309ms |
| Retry Behavior.Retries Flags On 504 | ❌ | 310ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ❌ | 609ms |
Failures
request_payload.groups_default_to_empty_object
Field 'groups' not found in /flags request body at path 'groups'. Available keys: ['distinct_id', 'api_key', 'flag_keys_to_evaluate']
request_payload.disable_geoip_omitted_defaults_to_false
Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'api_key', 'flag_keys_to_evaluate']
request_lifecycle.mock_response_value_is_returned_to_caller
Last action result missing field 'value'. Keys: ['error', 'success']
retry_behavior.retries_flags_on_502
Last action result missing field 'value'. Keys: ['error', 'success']
retry_behavior.retries_flags_on_504
Last action result missing field 'value'. Keys: ['error', 'success']
side_effect_events.get_feature_flag_captures_feature_flag_called_event
Expected 1 events with name '$feature_flag_called', got 0
…refuses copies An attachment with string keys now asks for atom keys, and a key with no string form is refused as invalid_attachment rather than raising. A template send refusing cc, bcc or attachments says the template sets its recipients and content. The two new comments become names: put_if_any/3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…field A string-keyed attachment with an extra key that sorts first, such as "cid", reported that key instead of the string keys, so a user fixed one thing and then hit the real cause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decision record for cc, bcc and attachments.
|
PR overviewThe PR adds support for CC and BCC recipients and email attachments, including storage upload handling. Two issues have been addressed, leaving one open concern in storage response handling. Responses are buffered in full, so an oversized response from a compromised storage endpoint could exhaust host memory; attachment-size limits and request timeouts do not bound that memory use. Open issues (1)
Fixed/addressed: 2 · PR risk: 4/10 Reviews |
The PR was retargeted to a different base branch, so the approved diff is no longer what was reviewed. Stamphog re-reviews automatically.
# Conflicts: # lib/posthog/config_schema.ex # lib/posthog/emails.ex # test/posthog/emails_test.exs
An upload now keeps at most 16 KB of the storage's answer, enough for an S3 error, and stops reading there. A storage that answers without end can no longer fill the app's memory or hold the upload until its deadline. Refs PostHog/michaelr-context#86 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
The upload is a bare Req request without Req's steps, so it never follows a redirect, which would carry the file to another URL. A test has the storage answer the PUT with a 307 and a 308 to another host. The other host gets nothing, the upload fails on the attachment without naming the new URL, and nothing is sent. With Req's redirect step, the test fails. The docs say so. Refs PostHog/michaelr-context#86 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 charges the daily upload quota when it signs the uploads, not when the storage keeps a file, so a fake that charged stored files proved "uploads once" through behaviour PostHog doesn't have. The fake now has no quota. The tests assert what a caller sees: a file PostHog has goes by reference, a file attached twice is two references to one stored file, and a retried send goes out once. Refs PostHog/michaelr-context#86 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.
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decision record for a rule the other SDKs share, from PostHog/michaelr-context#86.
|
A new stamphog review started for this PR — the fresh verdict replaces this approval.
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
Keeps main's tracking and sensitive choices for code sends next to cc, bcc and attachments. PostHog/michaelr-context#86 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 risky changes have assurance through the author's STRONG familiarity and accompanying tests. Veria's response-buffering concern is addressed by a streaming response cap and a regression test; no current-head reviews or remaining substantive concerns are reported.
- Author wrote 100% of the modified lines and has 2 merged PRs in these paths (familiarity STRONG).
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✓ | 695L, 4F substantive, 2002L/6F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1d-complex (2002L, 6F, cross-cutting, feat) |
| stamphog 2.8.0 | .stamphog/policy.yml @ c67d894 · reviewed head c67d894 |
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.
💡 Motivation and Context
An Elixir app moving its email to PostHog can't attach a receipt PDF or copy finance on an invoice:
PostHog.Emails.send/2takes only renderedhtmlandtext. This PR addscc,bccandattachmentsto a send withtemplate_key, matching posthog-node and posthog-python.Attachments go up before the send, not inside it: the SDK hashes each file, uploads the files PostHog doesn't hold yet straight to storage, and sends references by hash. A large file no longer rides in a JSON body, and a retried or repeated send doesn't upload the same file again.
Refs PostHog/michaelr-context#86.
Impact
Pays into Grow Workflows, Messaging scope, through emails sent from code. It removes a blocker for apps migrating from Resend, which attach files and copy addresses. Expected move: small, up. Run cost: n/a.
Merge Danger
Caution
🔴 Merge risk: high · two-way at merge, one-way door once Hex publishes the release: the options
cc,bccandattachments, theattachment()type, and the error codesattachment_upload_failed,too_many_attachmentsandmessage_too_largebecome a public contract apps build onattachments, in both regions: base refuses the option withinvalid_form; HEAD hashes the files, uploads the ones PostHog lacks with aPUTto the storage host PostHog signs (notapi_host; an egress allowlist must allow it), and sends references. Covered by the live run against real SigV4 storage and by the real-send train at this head. A failed or redirected upload, or a plain-HTTP storage URL while:app_hostis https, returns an error and sends nothing.ccorbcc: base refuses withinvalid_form; HEAD posts them as lists. Covered by the shadow diff.reply_tolist: base posts"reply_to": []; HEAD leaves the field out, as the other SDKs do. Covered by the shadow diff.cc,bccorattachmentsstill getinvalid_form, now with the template reason. Covered by the shadow diff.client_closedand sends nothing more. Covered by tests. Each request adds one in-memory registry lookup, measured in microseconds at this head, next to a network round trip in milliseconds; base already ran it once per send and before each retry. Every other send and status read is unchanged, including main'stracking,sensitiveandcapture_engagement. Covered by the shadow diff at this head against v2.21.0.attachmentsand nothing is sent.Rollout
Releaseenvironment; docs follow the release.What changes
ccandbcctake one address or a list. A string becomes a one-item list on the wire. A blank or non-string address is refused before any request withinvalid_formon that field.attachmentsis a list of maps with:filename,:content, and optionally:content_typeand:content_id.:contentis the file's raw bytes, such asFile.read!/1returns.too_many_attachments, and files that make the email larger than PostHog's 40 MB estimate withmessage_too_large. The estimate counts each file and body as Base64, the subject as 75 bytes per 42 started, and 64 KiB for headers, as PostHog does. Without:text, PostHog derives the text from:htmland counts it too, so the SDK counts the HTML twice, an upper bound, and its refusal then says to pass a short:text. A send without files is left to PostHog to size. The docs name the limit: 10 files and 40 MB per email after Base64 encoding, about 29 MB of files. There is no cap per file. A malformed entry is refused withinvalid_attachmentand a:fieldthat names it, such asattachments__0__pathfor a Resend-style:pathattachments__0__contentfor empty content, orattachments__0__filenamefor a blank filename. An error never repeats the file's content.POST /api/projects/@current/messaging/attachments/uploads/with each distinct file's SHA-256 and size. PostHog answers per file:present, oruploadwith a URL and headers.api_erroron that attachment before anyPUT, with no URL in the error, and nothing is sent. An:app_hoston plain HTTP, as in local development, allows plain-HTTP uploads too; there is no new option. Then a plainPUTof eachuploadfile to its URL, carrying only the bytes and the headers PostHog signed. It's a bareReqrequest: it skips:api_client_module, Req's steps and the app'sReq.default_options, so no PostHog key or client header reaches the storage.presentfiles are skipped. It never follows a redirect, which would carry the file to another URL: a 307 or 308 fails the upload. The SDK reads at most 16 KB of the storage's answer, enough for an S3 error. A failedPUTreturnsattachment_upload_failedon that attachment's field, with the storage's status and error code, and no email is sent.attachmentsas references:sha256,filename, andcontent_typeandcontent_idwhen given.upload_quota_exceededis never retried. APUTalso retries a storage 429, 500, 502, 503 or 504, and gets the request timeout plus ten seconds per MB of the file. It doesn't go through a proxy, so the app must reach the storage host directly. Every request first checks that the PostHog instance still runs, so a send stopped between its steps sends nothing more.files__0__sizecomes back asattachments__2__content. A key, scope, rate limit or outage error names no option.reply_to,cc,bccandattachmentscount as left out, on both forms.cc,bccandattachmentsbefore any request, with one reason: a template made in PostHog sets its own sender, copies, subject, content and attachments.sequenceDiagram participant App participant SDK as PostHog.Emails participant PH as PostHog API participant S3 as Storage App->>SDK: send(attachments: [...]) SDK->>SDK: check count and size, hash each file SDK->>PH: POST uploads/ {sha256, size} per distinct file PH-->>SDK: present | upload {url, headers} loop each file to upload SDK->>S3: PUT bytes with the returned headers S3-->>SDK: 200, or an error: stop, send nothing end SDK->>PH: POST send/email/ with attachments by sha256 PH-->>SDK: 202 message_id💚 How did you test it?
Head
f02dc0b: local gate on Elixir 1.20 / OTP 29:mix format --check-formatted,mix compile --warnings-as-errors(dev and test),mix credo --strict(no issues),mix posthog.public_api --check, andmix test(1,007 passed). CI: green on this head, 30 checks. ReviewHog: no findings on this head. No sdk-specs capability covers transactional email, so there is no spec to diverge from.test/posthog/emails_test.exsdrivesPostHog.Emails.send/2against a fake PostHog that behaves like the real one. Its upload route signs aPUTfor each file it lacks. Its storage keeps a file only when thePUTpasses the checks S3 makes on a presigned URL: the signed query and headers, no second credential or unsignedx-amz-header, and bytes that match the signed checksum. Its send route accepts an email only when PostHog has every file it names and the email fits PostHog's 40 MB estimate. The SDK's real client reaches it through Req.Test, and over a real socket to a local Bandit server with Req's default adapter. The tests assert the caller's result, the stored files and the accepted emails, not request internals. "send/2 with cc and bcc" covers the copies. "send/2 with attachments" covers: the upload request, then thePUT, then the send with references; an app client's ownauthorizationand headers kept off thePUT; apresentfile sent by reference; a file attached twice sent as two references to one stored file; a failedPUTthat returns the storage's error, sends nothing, and leaks neither the signature nor the content; a storage answer that never ends, of which the SDK reads only the start and returns the storage's error; a storage that redirects thePUTwith a 307 or a 308 to another host, which gets nothing, while the error names the attachment and not the new URL; retries after a storage 503, 429, 500 and a transport error, and none after a 403; a storage 401 that doesn't blame the PostHog key; a retried send that goes out once; an instance stopped between steps; refusal fields mapped back toattachments, and none for a bad key or the quota; a PostHog without the upload route; an unusable answer; 11 files; the 40 MB boundary, where the largest file that fits passes and one more byte, or any body, is refused; a subject of 42 ASCII bytes, 43 bytes, 21 two-byte characters and none, each at a size that fits and one that doesn't; a blank filename; ten seconds per MB of upload time; an HTML-only email that fits with the HTML counted once but not twice, refused before any request with a hint to pass:text, and sent with one; a plain-HTTP upload URL, refused before any upload with no URL in the error, and allowed when:app_hostis plain HTTP; and a 40 MB body without files, which goes to PostHog whole and comes back with PostHog's ownmessage_too_large.PostHog.EmailsReqDefaultsTestsets app-wideReq.default_optionsthat S3 would refuse on an upload,paramsandcompress_body, and checks over a real socket that the file is stored and the email accepted.d4dabd7(the commits since change only docs, a comment and tests) on the locked Req 0.7.5, and atf059d04also with Req pinned to 0.6.1, the oldestmix.exsallows (the adapter path hasn't changed since). The SDK's own HTTP client runs against a stand-in for PostHog's two routes that signsPUTURLs as PostHog's attachment store does, backed by a real S3-compatible store that checks SigV4 and the signed SHA-256. Both run on plain HTTP, so the main instance's:app_hostis plain HTTP, the local-development case. Both runs give the same results:ccuploads both, and the stored bytes have the same SHA-256 as the source.presentand uploads nothing.PUTwhose signed checksum doesn't match the bytes returnsattachment_upload_failedwith the store's400 BadDigest, and nothing is sent.message_too_large, and 11 filestoo_many_attachments, before any request. At this head, with a 12-byte subject, that file is 29,182,821 bytes, and the same file with a 43-byte subject is refused before any request.authorizationheader, and again with app-wideReq.default_optionssetting auth, compression and a header, the storage host gets onlycontent-length,host,user-agentandx-amz-checksum-sha256. At57bbab8, before the review fixes, it got the client'sauthorizationheader.d4dabd7only: an HTML-only send with a 1 MB HTML body and a 28 MB file fits with the HTML counted once but not twice. It returnsmessage_too_largebefore any request, and the error says to pass a short:text. The same send with text"Thanks"uploads and sends.d4dabd7only: an instance whose:app_hostis https gets a plain-HTTP upload URL. It returnsapi_erroronattachments__0, naming the file and no URL. The stand-in's log shows the upload request only: noPUTand no send.mainatcfb156a: 47 sends and a status read through a capturing client on both heads. The 6 that differ are all intended: three template-send refusals that now give one reason,reply_to: []left out, a code send withccandbccnow sent, and an emptyattachmentslist now sent. The run has no uploads, so the later upload-only commits carry it forward.d4dabd7: the suite's real-socket tests pass on the oldest Req thatmix.exsallows, including the endless storage answer, which fails there without the 16 KB bound. The tests that run the storage through Req's in-process plug adapter need Req 0.7; a CI leg on Req 0.6 is a follow-up.d4dabd7: this SDK against today's PostHog Cloud in US and EU, the older server, with a dummy key. The upload route answers404. A send with a file returnsnot_foundonattachments, with noPUTand no send. Accsend, abccsend, and accwith abcclist, all without files, each take today's single send request and stop at authentication. Whether an older server delivers the copies needs a real key, which the merge gate's real send covers.Earlier evidence, for the inline Base64 version at
3d04164cc, onebcc, a PDF and an inline PNG; both files decoded from the delivered.emlmatched the source SHA-256.400naming the field and sent nothing.That server side was replaced by upload first, then send, so these runs no longer cover this head.
📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5), for Michael, as the Elixir part of the attachments slice of the transactional-email SDK ports.too_many_attachmentsandmessage_too_largeattachment_upload_failed, an:application_errorwhatever the storage's status, with its answer and no secret; nothing sentReqrequest with only the signed headers, skipping the client, Req's steps and app defaults; replaces going through the configured client:text, as PostHog derives the text from it; the refusal says to pass a short:text:app_hostis plain HTTP too, as in local development; no new optionnot_foundonattachments, nothing sent; the new route also answers 404 when sending from code is off, sofield_not_acceptedwould misname itInvolvement: no grilling rounds, no prototypes; a design pass on the rework, a two-family qa-swarm panel (ten lenses, three of them team perspectives), five rounds on its fixes, a second full ten-lens panel on the final code, two rounds on its fixes, the second with all ten lenses after the rules for derived text and https uploads, and thirteen final reads, the last seven past Fleet's cap of six by the lane's call; ReviewHog on each push, and StampHog; end-to-end proof against real storage on two Req versions, a shadow diff, and a cross-version run against today's PostHog Cloud, captured.
🤖 Generated with Claude Code