Skip to content

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

Open
Silthus wants to merge 17 commits into
mainfrom
feat/emails-attachments
Open

Silthus wants to merge 17 commits into
mainfrom
feat/emails-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

💡 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 from #236 takes only rendered html and text. This PR adds cc, bcc and attachments to a send with template_key, matching posthog-node (PostHog/posthog-js#5514) and posthog-python (PostHog/posthog-python#1172). The server side is PostHog/posthog#127988.

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
)

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)

  • New public options cc, bcc and attachments, and the attachment() type. A merge to main releases them, and once released they can't change without a breaking release.
  • Release order: these fields need feat(messaging): send cc, bcc and attachments on a code send posthog#127988 and the cdp-api chain #127985 → #127995 → #127986 deployed. Until then, a send with them gets a 400 that names the field, and nothing is sent. Hold the release until the server deploys; the cross-version run shows what an older server does.
  • Shared change: an empty reply_to list is now left out of the request, as the other SDKs do. A send without the new fields is otherwise unchanged.
  • Revert: git revert <merge commit> before a release. After a release, deprecate instead.

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. 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.
  • Refusals happen before any request, with invalid_attachment and a :field that names the problem: attachments for a value that isn't a list, attachments__0 for an entry that isn't a map, attachments__0__path for 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 on attachments__0), and attachments__0__filename, __content, __content_type or __content_id for a value of the wrong type. An error never repeats the file's content.
  • Empty lists for reply_to, cc, bcc and attachments are left out, so a server that doesn't take a field yet still sends a plain email.
  • A template send refuses all three before any request.
  • Limits stay on the server: 10 cc, 10 bcc, 10 files, 512 KB decoded, and blocked file types. A server that doesn't take a field returns its 400, with field naming 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, and mix test (935 passed). CI: see the checks on 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 cc, bcc and 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 content Always the file's raw bytes, encoded by the SDK; Node and Python also take a Base64 string, which Elixir can't tell from bytes 🤖 Agent comment
Attachment shape A plain map with atom keys; string keys refused with a message asking for atom keys 🤖 Agent comment
Limits Stay on the server, which names the refused field; the SDK checks shape only 🤖 Agent comment
Empty lists Left out of the request, as the other SDKs do 🤖 Agent comment
Template send Refuses cc, bcc and attachments before any request, with the server's reason 🤖 Agent comment
Merge risk High: public API released on merge; live run, cross-version run and shadow diff owed 🤖 Agent comment

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

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-04T07:16:35.466149+00:00
Duration: 108640ms

⚠️ 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 ✅ 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

⚠️ 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 ✅ 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

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

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant