Repository navigation
Conversation
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>
posthog-php-fork_curl Compliance ReportDate: 2026-11-04T08:28:33.697505+00:00
|
| 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 |
posthog-php-socket Compliance ReportDate: 2026-11-04T08:28:35.030508+00:00
|
| 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 |
posthog-php-lib_curl Compliance ReportDate: 2026-11-04T08:28:22.190110+00:00
|
| 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
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions for this PR:
|
…ls-cc-bcc-attachments
…-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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
…ls-cc-bcc-attachments
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
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 |
| throw self::invalidAttachment('attachments must be a list of files.', 'attachments'); | ||
| } | ||
| $attachments = []; | ||
| foreach ($files as $index => $file) { |
There was a problem hiding this comment.
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.
PR overviewThe 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 |
💡 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 renderedhtml/textonly (#288).Change: a send with
template_keyalso takescc,bccandattachments, as the send contract defines them.Impact: Grow Workflows. It removes a blocker for Resend migrations, seen as code sends with
migrated_fromin 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 "
contentis 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 a400that names the field, never a silent drop.Changes
ccandbcc: 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' => ...].contentis the file's bytes, such asfile_get_contents()returns. PHP strings are bytes, so the client always encodescontentas Base64, once, before the retry loop; a caller holding Base64, such as from a Resend payload, passesbase64_decode($content), as the README and docblock say. A nullcontent_typeorcontent_idcounts as left out, like a null option. An empty list is left out.invalid_attachment, with a field such asattachments__0__content: a non-list, a file that isn't an array, apath(Resend style), an unknown key such ascontentType, a missing filename or content, a non-stringcontent_type/content_id, or a filename,content_typeorcontent_idthat isn't UTF-8. No error repeats the file's content.template_idwith any of the three throwsinvalid_form, like the other code-form options.whyandfix, whichEmailErroralready carries.send()docblock and its array shape, and aminorchange intent.Divergence from posthog-node and posthog-python: they send a string
contentas is, as already Base64, and encode only aBuffer/bytes. PHP has no separate bytes type, so a string is always the file. A caller holding Base64 passesbase64_decode($content). Theemailsspec 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 realEmailswith an injected transport. The tests assert the request body (cc as a list, bytes as Base64,content_type/content_idpassed through), the same body on the retry, empty lists left out, eachinvalid_attachmentcase 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,EmailsTeston 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 a400invalid_inputwithgetField()attachments, and nothing went out. A plain send to the same server delivered to maildev.Live run at
faee0cfagainst 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, withapp_hostpointed at a logging proxy.to, twocc, onebcc, an 83-byte PDF and a 70-byte PNG withcontent_idlogo202; one message, envelope toto, bothccand thebcc; the Cc header lists both, no Bcc headerlogo, the PDF as an attachment.exefile400 invalid_request, fieldattachments__0__filename, withwhyandfixccaddresses400 invalid_request, fieldccccwith a display name400 invalid_request, fieldcc__0400 invalid_request, fieldattachmentspathfile, acontentTypekeyinvalid_attachmentwith the field, and no request reached the proxyget()on the first send404for a few seconds, thensentShadow diff, base #288 at
b2a1c5cagainst14ec0dc: 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 areply_tolist, 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 in14ec0dc: the docs now say to passbase64_decode($content)when you hold Base64, such as from a Resend payload, which the client can't tell from bytes; a nullcontent_typeorcontent_idcounts as left out; a filename,content_typeorcontent_idthat isn't UTF-8 throwsinvalid_attachmentwith its field, not a field-lessinvalid_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
If releasing new changes
pnpm changeto 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.contentin PHP?base64_decode($content). The alternative, a string sent as is like node and python, can't be told apart from bytes in PHPcc,bccorattachmentslist do?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 (approvedfaee0cf). Proof captured: live run, cross-version run, shadow diff.🤖 Generated with Claude Code