Skip to content

feat(emails): send cc, bcc and attachments - #239

Merged
Silthus merged 43 commits into
mainfrom
feat/emails-attachments
Nov 13, 2026
Merged

Silthus merged 43 commits into
mainfrom
feat/emails-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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/2 takes only rendered html and text. This PR adds cc, bcc and attachments to a send with template_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.

PostHog.Emails.send(
  to: "jane@example.com",
  cc: "finance@example.com",
  template_key: "receipt",
  subject: "Your receipt",
  html: rendered_html,
  attachments: [
    %{filename: "receipt.pdf", content: File.read!(pdf_path), content_type: "application/pdf"},
    %{filename: "logo.png", content: logo_png, content_id: "logo"}
  ],
  distinct_id: user.id
)

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, bcc and attachments, the attachment() type, and the error codes attachment_upload_failed, too_many_attachments and message_too_large become a public contract apps build on

  • Code sends with attachments, in both regions: base refuses the option with invalid_form; HEAD hashes the files, uploads the ones PostHog lacks with a PUT to the storage host PostHog signs (not api_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_host is https, returns an error and sends nothing.
  • Code sends with cc or bcc: base refuses with invalid_form; HEAD posts them as lists. Covered by the shadow diff.
  • Code sends with an empty reply_to list: base posts "reply_to": []; HEAD leaves the field out, as the other SDKs do. Covered by the shadow diff.
  • Template sends with cc, bcc or attachments still get invalid_form, now with the template reason. Covered by the shadow diff.
  • Shared request path, every send and status read: the instance-running check now runs before each request, the first one included, not only before a retry. An instance stopped mid-call returns client_closed and 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's tracking, sensitive and capture_engagement. Covered by the shadow diff at this head against v2.21.0.
  • Uncovered: no older or newer real PostHog can answer the new requests yet (accepted cross-version gap). Its residual incident: an app that upgrades early gets an error on attachments and nothing is sent.
  • Switch-off: none in the SDK. Apps opt in by upgrading, and PostHog's server-side attachments allowlist refuses attachment sends, which then return an error and send nothing.
  • Tier: critical (b: high; c: one-way once released; f: a Hex release ships the public API)

Rollout

  • Stages: the release workflow publishes to Hex after Client Libraries approval in the Release environment; docs follow the release.
  • Decided: the release waited for the real-send train at this head and Michael's merge word.
  • Watch: the release workflow's failure alert in the approvals channel; no runtime alert in the SDK.

What changes

  • cc and bcc take 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 with invalid_form on that field.
  • attachments is a list of maps with :filename, :content, and optionally :content_type and :content_id. :content is the file's raw bytes, such as File.read!/1 returns.
  • Before any request, the SDK refuses more than 10 files with too_many_attachments, and files that make the email larger than PostHog's 40 MB estimate with message_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 :html and 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 with invalid_attachment and a :field that names it, such as attachments__0__path for a Resend-style :path attachments__0__content for empty content, or attachments__0__filename for a blank filename. An error never repeats the file's content.
  • Three steps for a send with files:
    1. POST /api/projects/@current/messaging/attachments/uploads/ with each distinct file's SHA-256 and size. PostHog answers per file: present, or upload with a URL and headers.
    2. Only over https: a plain-HTTP upload URL returns api_error on that attachment before any PUT, with no URL in the error, and nothing is sent. An :app_host on plain HTTP, as in local development, allows plain-HTTP uploads too; there is no new option. Then a plain PUT of each upload file to its URL, carrying only the bytes and the headers PostHog signed. It's a bare Req request: it skips :api_client_module, Req's steps and the app's Req.default_options, so no PostHog key or client header reaches the storage. present files 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 failed PUT returns attachment_upload_failed on that attachment's field, with the storage's status and error code, and no email is sent.
    3. The send, with attachments as references: sha256, filename, and content_type and content_id when given.
  • Retries: the upload request retries as the send does, and upload_quota_exceeded is never retried. A PUT also 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.
  • Refusals of the upload request name the attachment the caller passed: the server's files__0__size comes back as attachments__2__content. A key, scope, rate limit or outage error names no option.
  • Empty lists for reply_to, cc, bcc and attachments count as left out, on both forms.
  • A template send refuses cc, bcc and attachments before 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
Loading

💚 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, and mix 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 seam: test/posthog/emails_test.exs drives PostHog.Emails.send/2 against a fake PostHog that behaves like the real one. Its upload route signs a PUT for each file it lacks. Its storage keeps a file only when the PUT passes the checks S3 makes on a presigned URL: the signed query and headers, no second credential or unsigned x-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 the PUT, then the send with references; an app client's own authorization and headers kept off the PUT; a present file sent by reference; a file attached twice sent as two references to one stored file; a failed PUT that 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 the PUT with 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 to attachments, 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_host is plain HTTP; and a 40 MB body without files, which goes to PostHog whole and comes back with PostHog's own message_too_large. PostHog.EmailsReqDefaultsTest sets app-wide Req.default_options that S3 would refuse on an upload, params and compress_body, and checks over a real socket that the file is stored and the email accepted.
  • End to end against real storage at d4dabd7 (the commits since change only docs, a comment and tests) on the locked Req 0.7.5, and at f059d04 also with Req pinned to 0.6.1, the oldest mix.exs allows (the adapter path hasn't changed since). The SDK's own HTTP client runs against a stand-in for PostHog's two routes that signs PUT URLs 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_host is plain HTTP, the local-development case. Both runs give the same results:
    • A send with two new files and a cc uploads both, and the stored bytes have the same SHA-256 as the source.
    • Sending the same files again finds both present and uploads nothing.
    • A PUT whose signed checksum doesn't match the bytes returns attachment_upload_failed with the store's 400 BadDigest, and nothing is sent.
    • The largest file that fits uploads and sends; one byte more returns message_too_large, and 11 files too_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.
    • With a custom client that adds an authorization header, and again with app-wide Req.default_options setting auth, compression and a header, the storage host gets only content-length, host, user-agent and x-amz-checksum-sha256. At 57bbab8, before the review fixes, it got the client's authorization header.
    • At d4dabd7 only: 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 returns message_too_large before any request, and the error says to pass a short :text. The same send with text "Thanks" uploads and sends.
    • At d4dabd7 only: an instance whose :app_host is https gets a plain-HTTP upload URL. It returns api_error on attachments__0, naming the file and no URL. The stand-in's log shows the upload request only: no PUT and no send.
  • Shadow diff against main at cfb156a: 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 with cc and bcc now sent, and an empty attachments list now sent. The run has no uploads, so the later upload-only commits carry it forward.
  • Req 0.6.1 at d4dabd7: the suite's real-socket tests pass on the oldest Req that mix.exs allows, 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.
  • Cross-version at d4dabd7: this SDK against today's PostHog Cloud in US and EU, the older server, with a dummy key. The upload route answers 404. A send with a file returns not_found on attachments, with no PUT and no send. A cc send, a bcc send, and a cc with a bcc list, 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.
  • Gap: no run against PostHog's real upload and send routes, which aren't live yet. The suite fakes their mixed-rollout answers at the HTTP boundary. The opening slice of PostHog/michaelr-context#86's map proves the whole path end to end once they are.
Earlier evidence, for the inline Base64 version at 3d04164
  • A live run against a local PostHog with the inline Base64 server side delivered two cc, one bcc, a PDF and an inline PNG; both files decoded from the delivered .eml matched the source SHA-256.
  • A cross-version run against a server without the new fields returned a 400 naming the field and sent nothing.

That server side was replaced by upload first, then send, so these runs no longer cover this head.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written by Claude Code, Claude Opus 5.5 (claude-opus-5-5), for Michael, as the Elixir part of the attachments slice of the transactional-email SDK ports.
Question Answer Who Where
Scope cc, bcc and attachments on the code form, matching posthog-node and posthog-python; headers and tags wait 👤 Human comment
Attachment delivery Hash, upload the files PostHog lacks, then send references by hash; replaces inline Base64 content 👤 Human comment
Limits Checked locally: 10 files and the 40 MB estimate, no cap per file; replaces server-only limits 👤 Human comment
Merge gate Ready for review now; merge waits until both routes are live in both regions, the allowlist is at *, and a real send through this SDK version is delivered and confirmed; replaces holding it as a draft 👤 Human comment
Attachment shape A plain map with atom keys; string keys refused with a message asking for atom keys 🤖 Agent comment
Empty lists Count as left out on both forms, as the other SDKs do 🤖 Agent comment
Template send Refuses cc, bcc and attachments before any request, with the server's reason 🤖 Agent comment
Local refusal codes Reuse the server's too_many_attachments and message_too_large 🤖 Agent comment
Failed upload attachment_upload_failed, an :application_error whatever the storage's status, with its answer and no secret; nothing sent 🤖 Agent comment
Upload transport A bare Req request with only the signed headers, skipping the client, Req's steps and app defaults; replaces going through the configured client 🤖 Agent comment
Upload retries Network errors and storage 429, 500, 502, 503, 504 🤖 Agent comment
Stopped instance Every request checks the instance runs; a stopped send sends nothing more 🤖 Agent comment
Size before hashing Count and size checked before any file is hashed 🤖 Agent comment
Upload timeout Request timeout plus ten seconds per MB, as sdk-php; replaces one second per MB 🤖 Agent comment
Subject in the size check 75 bytes per 42 started, as PostHog counts it 🤖 Agent comment
When the size check runs Only for a send with files; a send without files is left to PostHog 🤖 Agent comment
Blank filename Refused before any request 🤖 Agent comment
HTML-only size Counted twice without :text, as PostHog derives the text from it; the refusal says to pass a short :text 🤖 Agent comment
Plain-HTTP uploads Refused before any upload, unless :app_host is plain HTTP too, as in local development; no new option 🤖 Agent comment
Upload redirects Never followed; a 307 or 308 fails the upload on the attachment, with no URL 🤖 Agent comment
Storage answer size Read at most 16 KB, enough for an S3 error; replaces reading it whole, after StampHog held the PR on it 🤖 Agent comment
Uploads through a proxy Not supported, and documented; an option is a follow-up 🤖 Agent comment
Duplicate files Asked about and uploaded once 🤖 Agent comment
Refusal fields Named after the attachment the caller passed; none for a key, scope, rate limit or outage error 🤖 Agent comment
Upload route 404 not_found on attachments, nothing sent; the new route also answers 404 when sending from code is off, so field_not_accepted would misname it 🤖 Agent comment
Upload quota Never retried 🤖 Agent comment
Empty content Refused before any request 🤖 Agent comment
Merge risk High; two-way at merge, one-way once Hex publishes the public API 🤖 Agent comment

Involvement: 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

Silthus and others added 14 commits November 2, 2026 15:48
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>
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

posthog-elixir Compliance Report

Date: 2026-11-13T06:08:50.390132+00:00
Duration: 108526ms

⚠️ Some Tests Failed

40/47 tests passed, 7 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
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

⚠️ 11/17 tests passed, 6 failed

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

Silthus and others added 3 commits November 4, 2026 07:10
…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>
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decision record for cc, bcc and attachments.

  • 👤 Scope Add cc, bcc and attachments to a send with template_key, matching posthog-node and posthog-python, as a PR stacked on feat(emails): send transactional email and read its status #236 and ready for review. Headers and tags wait.
  • 🤖 Attachment content Always the file's raw bytes, such as File.read!/1 returns; the SDK encodes them as Base64 once. Node and Python also take a Base64 string, but Elixir can't tell a Base64 string from bytes, so taking both would encode some files twice.
  • 🤖 Attachment shape A plain map with atom keys :filename, :content, :content_type and :content_id, not a struct, so it reads like the other options. String keys are refused with a message that asks for atom keys, rather than accepted, so one spelling holds everywhere.
  • 🤖 Limits The 10 cc, 10 bcc, 10 files, 512 KB and blocked file types stay on the server, which names the field it refuses. The SDK checks only shape, so a server change to a limit needs no SDK release.
  • 🤖 Empty lists An empty reply_to, cc, bcc or attachments list is left out of the request, as the other SDKs do, so a server that doesn't take a field yet still sends a plain email.
  • 🤖 Template send cc, bcc and attachments on a send with template_id are refused before any request, with the server's reason: the template sets the recipients and content.
  • 🤖 Merge risk High: the new options are public API, and a merge to main releases them. The proof owes a live run against a server with feat(messaging): send cc, bcc and attachments on a code send posthog#127988, a cross-version run against one without it, and a shadow diff against feat(emails): send transactional email and read its status #236.

@Silthus
Silthus marked this pull request as ready for review November 4, 2026 07:21
@Silthus
Silthus requested a review from a team as a code owner November 4, 2026 07:21
stamphog[bot]

This comment was marked as outdated.

Comment thread lib/posthog/emails.ex Resolved
@veria-ai

veria-ai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

PR overview

The 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

@Silthus
Silthus changed the base branch from feat/emails-send to main November 5, 2026 12:09
@stamphog
stamphog Bot dismissed their stale review November 5, 2026 12:09

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
@Silthus
Silthus marked this pull request as draft November 9, 2026 03:25
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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed d4dabd7: no findings.

stamphog[bot]

This comment was marked as outdated.

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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown

@Silthus PostHog Review is reviewing this pull request.

@stamphog
stamphog Bot dismissed their stale review November 10, 2026 08:59

A new stamphog review started for this PR — the fresh verdict replaces this approval.

@posthog posthog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 3ffb13d: no findings.

stamphog[bot]

This comment was marked as outdated.

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>
@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown

@Silthus PostHog Review is reviewing this pull request.

@stamphog
stamphog Bot dismissed their stale review November 10, 2026 09:24

A new stamphog review started for this PR — the fresh verdict replaces this approval.

@posthog posthog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed f02dc0b: no findings.

stamphog[bot]

This comment was marked as outdated.

@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decision record for a rule the other SDKs share, from PostHog/michaelr-context#86.

  • 🤖 Does an upload follow the storage's redirect? No. A redirect would carry the file to another URL, even from https to plain HTTP. The upload is a bare Req request without Req's steps, so it never follows one: a 307 or 308 fails the upload with attachment_upload_failed on the attachment, names no URL, and sends nothing. A signed upload never succeeds through a redirect, so nothing that works today breaks.

@Silthus Silthus added stamphog and removed stamphog labels Nov 10, 2026
@stamphog
stamphog Bot dismissed their stale review November 10, 2026 09:39

A new stamphog review started for this PR — the fresh verdict replaces this approval.

stamphog[bot]

This comment was marked as outdated.

@Silthus

Silthus commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Nov 10, 2026

Copy link
Copy Markdown

@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>
@stamphog
stamphog Bot dismissed their stale review November 13, 2026 06:05

A new stamphog review started for this PR — the fresh verdict replaces this approval.

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@Silthus
Silthus merged commit eef3c7f into main Nov 13, 2026
31 checks passed
@Silthus
Silthus deleted the feat/emails-attachments branch November 13, 2026 07:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant