Repository navigation
Conversation
Add Client::emails() and PostHog::emails(). send() queues one email from
a template made in PostHog (template_id, template_data) or from content
the app rendered (template_key, subject, html and/or text, from,
reply_to, capture_engagement), as the flat body of
POST /api/projects/@current/messaging/send/email/. get() reads the
status at /messaging/emails/{message_id}/.
The secret key goes only to the PostHog Cloud app host or app_host,
every attempt carries one idempotency key, and transient failures retry
with backoff and Retry-After. Failures throw EmailError with PostHog's
code and a Resend-style name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A constant expression default put posthog-php/<version> into the public API snapshot, so the release's version bump would fail composer api:check. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…is stepped Co-Authored-By: Claude Opus 5.5 <[email protected]>
Also say in the README example that get() answers not_found for a few seconds after the send. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Measure the retry wait from its start instead of in microseconds since the epoch, read an HTTP-date Retry-After without getTimestamp(), which throws after 2038 on 32-bit PHP, and clamp both Retry-After forms the same way. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…quest Also split the test's fake monotonic timer from microseconds, so it stays an int on 32-bit PHP like the real hrtime(). Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ping it Co-Authored-By: Claude Opus 5.5 <[email protected]>
A send with template_key takes cc and bcc (one address or a list) and attachments (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 <[email protected]>
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 |
…an empty reply_to out A bad email option, such as a zero timeout, passed the Client constructor and threw at the first emails() call. The constructor now builds the email service once, so it throws there, as it does for flag_definition_cache_provider. An empty migrated_from, such as an unset env var, counts as left out, like app_host. An empty reply_to list is left out instead of refused, as posthog-node and posthog-python do. The huge Retry-After test now expects literals instead of the clamp formula. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…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:
|
…hrough names email_request_timeout_ms and email_request_max_retries were cast with (int), so 'two' became zero retries and '15seconds' a 15 ms timeout. They now take an integer or an integer string, and anything else throws InvalidArgumentException when the client is built. The inline comments become names: forwardOnlyClock(), wholeEpochSecondsAsFloat(), httpDateInUtc(), longestRetryAfterSecondsAnIntHoldsInMs(), onOneLogLine() and NO_EXPECT_100_CONTINUE. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…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 <[email protected]>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
The proof gate's judge found explanatory comments left after b2a1c5c: the docblocks of waitToRetry() and resolveAppHost(), prose after two @return types, two test class docblocks, FakeTimer's notes and the mocks' headers. They go. Two tests name what their comment said: $closedOnceTheFirstAttemptIsInFlight and $aYearOrWhatA32BitIntHoldsInMs. Type tags and phpcs directives stay. No behavior changes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ls-cc-bcc-attachments
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
| 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 |
The PR was retargeted to a different base branch, so the approved diff is no longer what was reviewed. Stamphog re-reviews automatically.
💡 Motivation and Context
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