Repository navigation
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 <[email protected]>
… 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
A secret key starts the flag definition loader, whose requests could take the mock's expectations. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
posthog-elixir Compliance ReportDate: 2026-11-04T07:16:35.466149+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 609ms |
| Format Validation.Event Has Uuid | ✅ | 610ms |
| Format Validation.Event Has Lib Properties | ✅ | 609ms |
| Format Validation.Distinct Id Is String | ✅ | 610ms |
| Format Validation.Token Is Present | ✅ | 610ms |
| Format Validation.Custom Properties Preserved | ✅ | 610ms |
| Format Validation.Event Has Timestamp | ✅ | 610ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ❌ | 610ms |
| Retry Behavior.Retries On 503 | ✅ | 5611ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2613ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2613ms |
| Retry Behavior.Respects Retry After Header | ✅ | 5616ms |
| Retry Behavior.Implements Backoff | ✅ | 15613ms |
| Retry Behavior.Retries On 500 | ✅ | 5617ms |
| Retry Behavior.Retries On 502 | ✅ | 5616ms |
| Retry Behavior.Retries On 504 | ✅ | 5615ms |
| Retry Behavior.Max Retries Respected | ✅ | 15617ms |
| Deduplication.Generates Unique Uuids | ✅ | 621ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5612ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10621ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5618ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 615ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 614ms |
| Compression.Sends Gzip When Enabled | ✅ | 610ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 611ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 606ms |
| Batch Format.Multiple Events Batched Together | ✅ | 615ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2612ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2612ms |
| Error Handling.Retries On 408 | ✅ | 5616ms |
Failures
format_validation.non_utc_event_timestamp_is_converted_to_utc
Event 0 field 'timestamp' instant '2026-11-04T07:14:51.411265Z' != 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 | ✅ | 7ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 7ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 7ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 7ms |
| Request Payload.Groups Round Trip | ✅ | 7ms |
| Request Payload.Groups Default To Empty Object | ❌ | 7ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 7ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ❌ | 8ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 6ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 4ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 608ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 12ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ❌ | 7ms |
| Retry Behavior.Retries Flags On 502 | ❌ | 310ms |
| Retry Behavior.Retries Flags On 504 | ❌ | 311ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ❌ | 610ms |
Failures
request_payload.groups_default_to_empty_object
Field 'groups' not found in /flags request body at path 'groups'. Available keys: ['api_key', 'distinct_id', '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: ['api_key', 'distinct_id', '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 <[email protected]>
…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 <[email protected]>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decision record for cc, bcc and attachments.
|
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
The PR was retargeted to a different base branch, so the approved diff is no longer what was reviewed. Stamphog re-reviews automatically.
💡 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/2from #236 takes only renderedhtmlandtext. This PR addscc,bccandattachmentsto a send withtemplate_key, matching posthog-node (PostHog/posthog-js#5514) and posthog-python (PostHog/posthog-python#1172). The server side is PostHog/posthog#127988.Stacked on #236.
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 · one-way door (merge-risk rubric)
cc,bccandattachments, and theattachment()type. A merge tomainreleases them, and once released they can't change without a breaking release.400that names the field, and nothing is sent. Hold the release until the server deploys; the cross-version run shows what an older server does.reply_tolist is now left out of the request, as the other SDKs do. A send without the new fields is otherwise unchanged.git revert <merge commit>before a release. After a release, deprecate instead.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. The SDK encodes them as Base64 once, before the retry loop. Elixir can't tell bytes from a Base64 string, unlike Node and Python, so content is always bytes.invalid_attachmentand a:fieldthat names the problem:attachmentsfor a value that isn't a list,attachments__0for an entry that isn't a map,attachments__0__pathfor a Resend-style:path,attachments__0__<key>for an unknown key (string keys such as"content"get a message asking for atom keys; a key with no string form, such as a tuple, is named in the message onattachments__0), andattachments__0__filename,__content,__content_typeor__content_idfor a value of the wrong type. An error never repeats the file's content.reply_to,cc,bccandattachmentsare left out, so a server that doesn't take a field yet still sends a plain email.cc, 10bcc, 10 files, 512 KB decoded, and blocked file types. A server that doesn't take a field returns its400, withfieldnaming it.💚 How did you test it?
Head
3d04164: local gate on Elixir 1.20 / OTP 29:mix format --check-formatted,mix compile --warnings-as-errors,mix credo --strict(no issues),mix posthog.public_api --check, andmix test(935 passed). CI: see the checks on this head.test/posthog/emails_test.exs, "send/2 with cc, bcc and attachments", drivesPostHog.Emails.send/2through thePostHog.API.Clientmock. It checks the posted body (string to list, bytes to Base64,content_typeandcontent_idmapped), empty lists andnilfields left out, the template form's refusals and their reason, everyinvalid_attachmentfield (string keys and keys with no string form included), that an error never holds the content, and a server's400for a field it doesn't take.3d04164against a local PostHog running feat(messaging): send cc, bcc and attachments on a code send posthog#127988 on feat(messaging): send an email with inline html from code posthog#127123 and the cdp-api chain feat(cdp): send cc and bcc on a transactional code send posthog#127985 → feat(cdp): add the email attachments table to cyclotron posthog#127995 → feat(cdp): deliver transactional email attachments from a side table posthog#127986, with Maildev as the sink and no reply scripted:cc, onebcc, a PDF and an inline PNG returns{:ok, %SendResult{replayed: false}}. One message arrives: bothccaddresses are in itsCcheader, thebccaddress is only an envelope recipient, andlogo.pngis inline withContent-ID <logo>, which the HTML'scid:logoshows..emlhave the same SHA-256 as the source files, so the content was encoded once.get/2returns{:ok, %Status{status: "sent"}}.validation_errorwithwhyandfix: a.exeonattachments__0__filename, 600 KB of files onattachments, and 11cconcc.ccreturnsinvalid_formoncc, and Django logs no request.3d04164against the same stack with Django at the feat(messaging): send an email with inline html from code posthog#127123 head, without feat(messaging): send cc, bcc and attachments on a code send posthog#127988: a send withcc,bccorattachmentsreturns a400naming that field and sends nothing. A plain send with emptycc,bcc,reply_toandattachmentslists sends; Maildev holds exactly that one message.74dd12e, carried forward from99a76e4(3d04164changes only which key a string-key error names): 47 sends without the new fields, 30 local refusals and a status read. The only difference is that a code send withreply_to: []no longer posts"reply_to": []. Every other body, header and error is identical.📝 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 cc, bcc and attachments slice of the transactional-email SDK ports.Involvement: no grilling rounds, no prototypes; a two-family qa-swarm panel (round 1 with six lenses, round 2 on its fixes, and a final read that found nothing); live-run, cross-version and shadow-diff proof captured.
🤖 Generated with Claude Code