Skip to content

feat: send cc, bcc and attachments with emails()->send() - #292

Open
Silthus wants to merge 5 commits into
feat/emails-sendfrom
feat/emails-cc-bcc-attachments
Open

Silthus wants to merge 5 commits into
feat/emails-sendfrom
feat/emails-cc-bcc-attachments

Conversation

@Silthus

@Silthus Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown

💡 Motivation and Context

Problem: a PHP app moving its email from Resend to PostHog can't send a receipt with a PDF, or copy finance on an invoice. emails()->send() takes rendered html/text only (#288).

Change: a send with template_key also takes cc, bcc and attachments, as the send contract defines them.

PostHog::emails()->send([
    'to' => 'user@example.com',
    'template_key' => 'receipt',
    'subject' => 'Your receipt',
    'html' => $html,
    'cc' => ['manager@example.com'],
    'bcc' => 'finance@example.com',
    'attachments' => [
        ['filename' => 'receipt.pdf', 'content' => file_get_contents($receiptPath)],
        ['filename' => 'logo.png', 'content' => $png, 'content_id' => 'logo'], // <img src="cid:logo">
    ],
]);

Impact: Grow Workflows. It removes a blocker for Resend migrations, seen as code sends with migrated_from in project 2.

Caution

Merge risk: high, one-way door. A sent email, with its copies and files, can't be taken back, and a release makes "content is the file's bytes" a public contract. The fields are optional and stacked on #288: a shadow diff shows a send without them goes out byte for byte as before. Don't merge or release until code sends with these fields are live and usable for customers, not only deployed (the server half is PostHog/posthog#127988 and the cdp chain to PostHog/posthog#127986). Until the server takes them, a send with these fields gets a 400 that names the field, never a silent drop.

Changes

  • cc and bcc: one address or a list. A string becomes a one-item list. An empty list is left out, as posthog-node and posthog-python do, so a list built per email needs no check.
  • attachments: a list of ['filename' => ..., 'content' => ..., 'content_type' => ..., 'content_id' => ...]. content is the file's bytes, such as file_get_contents() returns. PHP strings are bytes, so the client always encodes content as Base64, once, before the retry loop; a caller holding Base64, such as from a Resend payload, passes base64_decode($content), as the README and docblock say. A null content_type or content_id counts as left out, like a null option. An empty list is left out.
  • Refused before any request as invalid_attachment, with a field such as attachments__0__content: a non-list, a file that isn't an array, a path (Resend style), an unknown key such as contentType, a missing filename or content, a non-string content_type/content_id, or a filename, content_type or content_id that isn't UTF-8. No error repeats the file's content.
  • Template form: template_id with any of the three throws invalid_form, like the other code-form options.
  • The server holds the limits (10 addresses per field, 10 files, 512 KB in total, blocked file types) and answers with why and fix, which EmailError already carries.
  • README, the send() docblock and its array shape, and a minor change intent.

Divergence from posthog-node and posthog-python: they send a string content as is, as already Base64, and encode only a Buffer/bytes. PHP has no separate bytes type, so a string is always the file. A caller holding Base64 passes base64_decode($content). The emails spec in PostHog/sdk-specs covers the template form only, so it says nothing on these fields.

💚 How did you test it?

  • Test seams: emails()->send() on a real Emails with an injected transport. The tests assert the request body (cc as a list, bytes as Base64, content_type/content_id passed through), the same body on the retry, empty lists left out, each invalid_attachment case with its field and without the content, null metadata left out, and the template-form refusals.

  • Local gate at faee0cf: the full PHPUnit suite (2677 tests) on PHP 8.4, EmailsTest on 32-bit PHP 8.2 (123 tests), composer api:check, phpcs (PSR12). CI runs the suite on PHP 8.2 to 8.5, phpcs, the public API check and the compliance runs.

  • Cross-version, live, at 14ec0dc: against a local PostHog on today's server (PostHog/posthog#127123 and #127122), a send with cc, bcc and two files got a 400 invalid_input with getField() attachments, and nothing went out. A plain send to the same server delivered to maildev.

  • Live run at faee0cf against the server half: Django at PostHog/posthog#127988 on #127123, and cdp-api with the hog flow and email workers at the cdp chain #127985, #127995, #127986, delivering over SMTP to a local maildev. A PHP script built the client as a user does, with app_host pointed at a logging proxy.

    Case Result
    to, two cc, one bcc, an 83-byte PDF and a 70-byte PNG with content_id logo 202; one message, envelope to to, both cc and the bcc; the Cc header lists both, no Bcc header
    The files in maildev both byte-identical to the bytes passed (SHA-256 match); the PNG inline with content ID logo, the PDF as an attachment
    A .exe file 400 invalid_request, field attachments__0__filename, with why and fix
    Eleven cc addresses 400 invalid_request, field cc
    A cc with a display name 400 invalid_request, field cc__0
    Over 512 KB of files 400 invalid_request, field attachments
    A path file, a contentType key invalid_attachment with the field, and no request reached the proxy
    get() on the first send 404 for a few seconds, then sent
  • Shadow diff, base #288 at b2a1c5c against 14ec0dc: the same calls on each checkout against a recording server with the same replies. Capture, identify, alias, group identify, flags and flush, and a template send, a code send with a reply_to list, a send with a generated idempotency key and a status read. After normalizing UUIDs, timestamps and ports, the requests and return values are identical.

  • Review: a full qa-swarm panel at 7cefa76: fresh eyes on Claude, and correctness, risk, design, anti-overengineering, systems, sweep and three team perspectives (Michael's, SDK spec conformance, XP reviewer) on GPT-6.1 Sol. Seven lenses found nothing. Fixed in 14ec0dc: the docs now say to pass base64_decode($content) when you hold Base64, such as from a Resend payload, which the client can't tell from bytes; a null content_type or content_id counts as left out; a filename, content_type or content_id that isn't UTF-8 throws invalid_attachment with its field, not a field-less invalid_form; a test that only re-checked the generic error mapping went; comments went. The risk lens graded the merge risk high, so the shadow diff ran. Rejected: a spec extension for code-content sends, which the lens asked for no code change and belongs to the spec owners. Round 2 (fresh eyes and risk on the fix diff) found no code defect; it asked for this description and the live run.

📝 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 pnpm change to generate a change intent file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Written by Claude Opus 5.5 (claude-opus-5-5) in Claude Code, for Michael, as the PHP slice of the cc, bcc and attachments work. It mirrors PostHog/posthog-js#5514 and PostHog/posthog-python#1172.

Question Answer Who Where
Which code-form fields ship in the attachments slice? Attachments, cc and bcc. Headers and tags wait for a later slice 👍 Accepted default comment
What is an attachment's content in PHP? Always the file's bytes, encoded by the client; a caller holding Base64 passes base64_decode($content). The alternative, a string sent as is like node and python, can't be told apart from bytes in PHP 🤖 Agent comment
What does an empty cc, bcc or attachments list do? It is left out and the email sends, as in posthog-node and posthog-python 🤖 Agent comment

Involvement: no questions asked. Reviews: one full ten-lens qa-swarm panel, a narrow second round, a final read, PostHog Review (no findings on faee0cf), StampHog (approved faee0cf). Proof captured: live run, cross-version run, shadow diff.

🤖 Generated with Claude Code

A send with template_key takes cc and bcc (one address or a list) and
attachments (filename, content, content_type, content_id). PHP strings
are bytes, so content is the file itself and the client encodes it as
Base64 once, before the retry loop. Empty lists are left out. An
attachment the client can't send throws invalid_attachment with a field
such as attachments__0__content, and never repeats the content. A send
with template_id refuses all three fields.

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-php-fork_curl Compliance Report

Date: 2026-11-04T08:28:33.697505+00:00
Duration: 116061ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 29ms
Format Validation.Event Has Uuid ✅ 525ms
Format Validation.Event Has Lib Properties ✅ 525ms
Format Validation.Distinct Id Is String ✅ 528ms
Format Validation.Token Is Present ✅ 525ms
Format Validation.Custom Properties Preserved ✅ 545ms
Format Validation.Event Has Timestamp ✅ 524ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 525ms
Retry Behavior.Retries On 503 ✅ 5847ms
Retry Behavior.Does Not Retry On 400 ✅ 2529ms
Retry Behavior.Does Not Retry On 401 ✅ 2527ms
Retry Behavior.Respects Retry After Header ❌ 6338ms
Retry Behavior.Implements Backoff ✅ 16276ms
Retry Behavior.Retries On 500 ✅ 5637ms
Retry Behavior.Retries On 502 ✅ 5635ms
Retry Behavior.Retries On 504 ✅ 5634ms
Retry Behavior.Max Retries Respected ✅ 16267ms
Deduplication.Generates Unique Uuids ✅ 529ms
Deduplication.Preserves Uuid On Retry ✅ 5634ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10852ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5641ms
Deduplication.No Duplicate Events In Batch ✅ 526ms
Deduplication.Different Events Have Different Uuids ✅ 523ms
Compression.Sends Gzip When Enabled ✅ 524ms
Batch Format.Uses Proper Batch Structure ✅ 524ms
Batch Format.Flush With No Events Sends Nothing ✅ 512ms
Batch Format.Multiple Events Batched Together ✅ 518ms
Error Handling.Does Not Retry On 403 ✅ 2527ms
Error Handling.Does Not Retry On 413 ✅ 2526ms
Error Handling.Retries On 408 ✅ 5636ms

Failures

retry_behavior.respects_retry_after_header

Retry delay too short: 810ms < 2500ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 519ms
Request Payload.Flags Request Uses V2 Query Param ✅ 518ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 519ms
Request Payload.Flags Request Omits Authorization Header ✅ 517ms
Request Payload.Token In Flags Body Matches Init ✅ 518ms
Request Payload.Groups Round Trip ✅ 518ms
Request Payload.Groups Default To Empty Object ✅ 518ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 518ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 518ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 518ms
Request Lifecycle.No Flags Request On Init Alone ✅ 514ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 512ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 520ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 519ms
Retry Behavior.Retries Flags On 502 ✅ 619ms
Retry Behavior.Retries Flags On 504 ✅ 619ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 524ms

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-socket Compliance Report

Date: 2026-11-04T08:28:35.030508+00:00
Duration: 115802ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 22ms
Format Validation.Event Has Uuid ✅ 519ms
Format Validation.Event Has Lib Properties ✅ 519ms
Format Validation.Distinct Id Is String ✅ 519ms
Format Validation.Token Is Present ✅ 520ms
Format Validation.Custom Properties Preserved ✅ 519ms
Format Validation.Event Has Timestamp ✅ 519ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 519ms
Retry Behavior.Retries On 503 ✅ 5827ms
Retry Behavior.Does Not Retry On 400 ✅ 2523ms
Retry Behavior.Does Not Retry On 401 ✅ 2522ms
Retry Behavior.Respects Retry After Header ❌ 6326ms
Retry Behavior.Implements Backoff ✅ 16244ms
Retry Behavior.Retries On 500 ✅ 5625ms
Retry Behavior.Retries On 502 ✅ 5631ms
Retry Behavior.Retries On 504 ✅ 5625ms
Retry Behavior.Max Retries Respected ✅ 16245ms
Deduplication.Generates Unique Uuids ✅ 526ms
Deduplication.Preserves Uuid On Retry ✅ 5627ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10838ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5629ms
Deduplication.No Duplicate Events In Batch ✅ 523ms
Deduplication.Different Events Have Different Uuids ✅ 522ms
Compression.Sends Gzip When Enabled ✅ 519ms
Batch Format.Uses Proper Batch Structure ✅ 521ms
Batch Format.Flush With No Events Sends Nothing ✅ 516ms
Batch Format.Multiple Events Batched Together ✅ 511ms
Error Handling.Does Not Retry On 403 ✅ 2521ms
Error Handling.Does Not Retry On 413 ✅ 2521ms
Error Handling.Retries On 408 ✅ 5637ms

Failures

retry_behavior.respects_retry_after_header

Retry delay too short: 802ms < 2500ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 519ms
Request Payload.Flags Request Uses V2 Query Param ✅ 518ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 519ms
Request Payload.Flags Request Omits Authorization Header ✅ 519ms
Request Payload.Token In Flags Body Matches Init ✅ 517ms
Request Payload.Groups Round Trip ✅ 519ms
Request Payload.Groups Default To Empty Object ✅ 517ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 519ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 518ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 518ms
Request Lifecycle.No Flags Request On Init Alone ✅ 514ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 508ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 520ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 520ms
Retry Behavior.Retries Flags On 502 ✅ 621ms
Retry Behavior.Retries Flags On 504 ✅ 620ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 522ms

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-lib_curl Compliance Report

Date: 2026-11-04T08:28:22.190110+00:00
Duration: 116281ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 29ms
Format Validation.Event Has Uuid ✅ 524ms
Format Validation.Event Has Lib Properties ✅ 525ms
Format Validation.Distinct Id Is String ✅ 526ms
Format Validation.Token Is Present ✅ 525ms
Format Validation.Custom Properties Preserved ✅ 526ms
Format Validation.Event Has Timestamp ✅ 526ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 526ms
Retry Behavior.Retries On 503 ✅ 5835ms
Retry Behavior.Does Not Retry On 400 ✅ 2529ms
Retry Behavior.Does Not Retry On 401 ✅ 2529ms
Retry Behavior.Respects Retry After Header ❌ 6337ms
Retry Behavior.Implements Backoff ✅ 16252ms
Retry Behavior.Retries On 500 ✅ 5635ms
Retry Behavior.Retries On 502 ✅ 5633ms
Retry Behavior.Retries On 504 ✅ 5637ms
Retry Behavior.Max Retries Respected ✅ 17050ms
Deduplication.Generates Unique Uuids ✅ 239ms
Deduplication.Preserves Uuid On Retry ✅ 5637ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10838ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5145ms
Deduplication.No Duplicate Events In Batch ✅ 533ms
Deduplication.Different Events Have Different Uuids ✅ 530ms
Compression.Sends Gzip When Enabled ✅ 527ms
Batch Format.Uses Proper Batch Structure ✅ 528ms
Batch Format.Flush With No Events Sends Nothing ✅ 522ms
Batch Format.Multiple Events Batched Together ✅ 515ms
Error Handling.Does Not Retry On 403 ✅ 2530ms
Error Handling.Does Not Retry On 413 ✅ 2530ms
Error Handling.Retries On 408 ✅ 5635ms

Failures

retry_behavior.respects_retry_after_header

Retry delay too short: 804ms < 2500ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 526ms
Request Payload.Flags Request Uses V2 Query Param ✅ 526ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 524ms
Request Payload.Flags Request Omits Authorization Header ✅ 525ms
Request Payload.Token In Flags Body Matches Init ✅ 525ms
Request Payload.Groups Round Trip ✅ 524ms
Request Payload.Groups Default To Empty Object ✅ 524ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 525ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 525ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 525ms
Request Lifecycle.No Flags Request On Init Alone ✅ 519ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 511ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 529ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 526ms
Retry Behavior.Retries Flags On 502 ✅ 628ms
Retry Behavior.Retries Flags On 504 ✅ 627ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 530ms

…ls-cc-bcc-attachments

# Conflicts:
#	lib/Emails.php
#	test/EmailsTest.php
@Silthus

Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions for this PR:

  • 👍 Which code-form fields ship in the attachments slice? Attachments, cc and bcc. Headers and tags wait for a later slice.
  • 🤖 What is an attachment's content in PHP? Always the file's bytes, which the client encodes as Base64. The alternative, sending a string as is like posthog-node and posthog-python do, can't work in PHP: a string is also how PHP holds bytes, so the two can't be told apart. A caller holding Base64, such as from a Resend payload, passes base64_decode($content), as the README and the send() docblock say since 14ec0dc.
  • 🤖 What does an empty cc, bcc or attachments list do? It is left out and the email sends, as posthog-node and posthog-python do, and as reply_to does since feat: send transactional email with emails()->send() #288's 8efe315.

Silthus and others added 2 commits November 4, 2026 07:44
…-8 text names its field

A file's content_type or content_id passed as null is left out, like a
null top-level option. A filename, content_type or content_id that
isn't UTF-8 throws invalid_attachment with its field, not a field-less
invalid_form from json_encode. The README, the send() docblock and the
change intent say to pass base64_decode($content) when the caller
holds Base64, so a Resend payload isn't encoded twice. The test of a
server refusal goes: it checked only the generic error mapping that
testAnErrorKeepsPostHogsCodeAndFields covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 14ec0dc: no findings.

@Silthus
Silthus marked this pull request as ready for review November 4, 2026 08:34
@Silthus
Silthus requested a review from a team as a code owner November 4, 2026 08:34
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed faee0cf: no findings.

@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 public-contract and send-path changes have current-head independent review and STRONG author familiarity, with focused tests covering validation and retry payloads. No unresolved substantive concerns or other refusal grounds were found.

  • Author wrote 100% of the modified lines and has 0 merged PRs in these paths (familiarity STRONG).
  • PostHog Review (Standard) reviewed the current head: no findings.
  • posthog[bot] reviewed the current head.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 133L, 1F substantive, 291L/4F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1d-complex (291L, 4F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ faee0cf · reviewed head faee0cf

Comment thread lib/Emails.php
throw self::invalidAttachment('attachments must be a list of files.', 'attachments');
}
$attachments = [];
foreach ($files as $index => $file) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: Missing attachment resource limits

Attachments are Base64-encoded and then serialized into JSON without enforcing the documented 512 KB aggregate limit; a network-free reproduction with one 40 MiB attachment under memory_limit=128M produced an uncatchable memory-exhaustion error before the transport was called. For integrations that forward user-uploaded files, protect the host worker by checking the ten-file cap and cumulative raw content size before encoding, rejecting oversized input with invalid_attachment rather than relying on server-side validation.

@veria-ai

veria-ai Bot commented Nov 4, 2026

Copy link
Copy Markdown

PR overview

The PR adds support for sending CC and BCC recipients and attachments through emails()->send(). Attachment contents are Base64-encoded and included in the JSON request.

One issue remains open: attachment processing does not enforce the documented aggregate size limit before encoding and serialization. A 40 MiB attachment exhausted a worker's 128 MiB memory limit before transport was called, creating a denial-of-service path for integrations that forward user-uploaded files. No issues have been addressed yet; checking file count and cumulative raw size before encoding would protect the worker.

Open issues (1)

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant