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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is stepped Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…ping it 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 (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-10T09:26:12.582076+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 30ms |
| Format Validation.Event Has Uuid | ✅ | 526ms |
| Format Validation.Event Has Lib Properties | ✅ | 524ms |
| Format Validation.Distinct Id Is String | ✅ | 527ms |
| Format Validation.Token Is Present | ✅ | 527ms |
| 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 | ✅ | 5850ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2528ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2530ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6338ms |
| Retry Behavior.Implements Backoff | ✅ | 16259ms |
| Retry Behavior.Retries On 500 | ✅ | 5154ms |
| Retry Behavior.Retries On 502 | ✅ | 5637ms |
| Retry Behavior.Retries On 504 | ✅ | 5640ms |
| Retry Behavior.Max Retries Respected | ✅ | 16263ms |
| Deduplication.Generates Unique Uuids | ✅ | 36ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5642ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10856ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5644ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 530ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 528ms |
| Compression.Sends Gzip When Enabled | ✅ | 530ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 527ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 516ms |
| Batch Format.Multiple Events Batched Together | ✅ | 516ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2528ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2528ms |
| Error Handling.Retries On 408 | ✅ | 5647ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 809ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 522ms |
| 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 | ✅ | 518ms |
| Request Payload.Groups Round Trip | ✅ | 520ms |
| Request Payload.Groups Default To Empty Object | ✅ | 520ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 518ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 519ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 520ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 516ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 515ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 520ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 521ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 622ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 621ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 528ms |
posthog-php-socket Compliance ReportDate: 2026-11-10T09:26:22.064784+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 27ms |
| Format Validation.Event Has Uuid | ✅ | 521ms |
| Format Validation.Event Has Lib Properties | ✅ | 523ms |
| Format Validation.Distinct Id Is String | ✅ | 524ms |
| Format Validation.Token Is Present | ✅ | 522ms |
| Format Validation.Custom Properties Preserved | ✅ | 524ms |
| Format Validation.Event Has Timestamp | ✅ | 522ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 524ms |
| Retry Behavior.Retries On 503 | ✅ | 5830ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2526ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2525ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6332ms |
| Retry Behavior.Implements Backoff | ✅ | 16234ms |
| Retry Behavior.Retries On 500 | ✅ | 5141ms |
| Retry Behavior.Retries On 502 | ✅ | 5628ms |
| Retry Behavior.Retries On 504 | ✅ | 5631ms |
| Retry Behavior.Max Retries Respected | ✅ | 16246ms |
| Deduplication.Generates Unique Uuids | ✅ | 528ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5630ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10838ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5633ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 527ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 523ms |
| Compression.Sends Gzip When Enabled | ✅ | 522ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 523ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 519ms |
| Batch Format.Multiple Events Batched Together | ✅ | 510ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2525ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2525ms |
| Error Handling.Retries On 408 | ✅ | 5632ms |
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 | ✅ | 545ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 519ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 524ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 520ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 522ms |
| Request Payload.Groups Round Trip | ✅ | 521ms |
| Request Payload.Groups Default To Empty Object | ✅ | 521ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 521ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 520ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 520ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 517ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 508ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 527ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 523ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 624ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 623ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 524ms |
posthog-php-lib_curl Compliance ReportDate: 2026-11-10T09:26:16.130939+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 23ms |
| Format Validation.Event Has Uuid | ✅ | 519ms |
| Format Validation.Event Has Lib Properties | ✅ | 520ms |
| Format Validation.Distinct Id Is String | ✅ | 520ms |
| Format Validation.Token Is Present | ✅ | 520ms |
| Format Validation.Custom Properties Preserved | ✅ | 520ms |
| Format Validation.Event Has Timestamp | ✅ | 522ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 520ms |
| Retry Behavior.Retries On 503 | ✅ | 5828ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2522ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2522ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6329ms |
| Retry Behavior.Implements Backoff | ✅ | 16243ms |
| Retry Behavior.Retries On 500 | ✅ | 5626ms |
| Retry Behavior.Retries On 502 | ✅ | 5627ms |
| Retry Behavior.Retries On 504 | ✅ | 5627ms |
| Retry Behavior.Max Retries Respected | ✅ | 17043ms |
| Deduplication.Generates Unique Uuids | ✅ | 227ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5628ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10872ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5630ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 525ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 522ms |
| Compression.Sends Gzip When Enabled | ✅ | 521ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 521ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 517ms |
| Batch Format.Multiple Events Batched Together | ✅ | 510ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2524ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2522ms |
| Error Handling.Retries On 408 | ✅ | 5629ms |
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 | ✅ | 520ms |
| 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 | ✅ | 518ms |
| Request Payload.Groups Round Trip | ✅ | 519ms |
| 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 | ✅ | 520ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 519ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 515ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 507ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 523ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 519ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 622ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 620ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 521ms |
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…ls-cc-bcc-attachments
…e signed checksum The real-HTTP storage answered 200 to any body. It now answers 400 BadDigest when the body doesn't match `x-amz-checksum-sha256`, as real storage does, and a test proves the send stops there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
…s signature The upload tests asserted the signed URL text and header strings. The storage fakes now refuse what real storage refuses (a URL or signed header that doesn't match the slot, bytes that miss the signed checksum) and the PostHog fake refuses another route or key, so the tests assert outcomes: the email is queued, storage holds the file's bytes, and the secret key never reaches storage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of asserting the request order Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fuse a second auth Storage ignores unsigned headers, so blanking curl's form type changed nothing a caller can see. Both storage fakes now refuse a presigned PUT that also carries an Authorization header, as S3 does, instead of tests reading header strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without text, PostHog sends the text it derives from the html, and cdp-api counts it, so the local estimate counts the html twice. An upload URL that isn't https is refused before any upload, unless the app host is plain http for local development. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quest order A storage 429 that outlasts the retries is now rate_limit_exceeded, like every other 429. The test fake keeps what PostHog queued (one email per idempotency key), what storage took, and refuses a request without the secret key, so the attachment tests assert the result, the stored files and the queued email. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
… emails, not requests The API fake answers the upload route from what storage holds, and the local server records each answer's status, so tests read what storage took and what PostHog queued rather than the requests the SDK made. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
…attachment_upload_failed A 3xx answer to an upload PUT fails that attachment without quoting storage's answer, which can carry the redirect URL, and sends nothing. curl already doesn't follow; the test proves a following client would leak the file. The error code matches the attachments spec and the other SDKs. Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New commits were pushed — dismissing the stamphog approval from an earlier head. Re-add the trigger label to request a fresh review.
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
Approved.
The risky areas have a clean current-head agent review and STRONG author familiarity, with targeted test coverage. No concrete unresolved review concerns or policy-defined refusal grounds are evident.
- Author wrote 94% of the modified lines and has 1 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 | ✓ | 461L, 2F substantive, 1473L/8F incl. docs/generated/snapshots — within ceiling |
| tier | ✓ | T1-agent / T1d-complex (1473L, 8F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ 569df7a · reviewed head 569df7a |
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
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. The caller passes each file's bytes. The SDK hashes each file, asks PostHog where to upload the files it doesn't hold yet, uploads them straight to PostHog's file storage, and then sends the email with references to the files. Ticket: PostHog/michaelr-context#84.Impact: Grow Workflows. It removes a blocker for Resend migrations, seen as code sends with
migrated_from.Caution
Merge risk: high, one-way door. Merging the change intent starts a release, and a release makes upload-then-send the public contract for files: a sent email, with its copies and files, can't be taken back. The fields are optional: a shadow diff at head shows a send without them goes out byte for byte as before. Don't merge until the merge gate on the first line holds. The server half is the upload route (PostHog/posthog#131664), sends by reference (PostHog/posthog#131763) and the cdp attachment store chain (#131638, #131833, #131944). Switch-off: the server's per-team allowlist; a team off it gets a synchronous
400 field_not_acceptedonattachments, never a silent drop. A PostHog without the upload route answers404 not_foundonattachments, and nothing is sent.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.attachments: a list of['filename' => ..., 'content' => ..., 'content_type' => ..., 'content_id' => ...].contentis the file's bytes, such asfile_get_contents()returns. A nullcontent_typeorcontent_idcounts as left out. An empty list is left out.hash(), the standard library) and asksPOST /api/projects/@current/messaging/attachments/uploads/about each distinct file.presentis skipped. Eachuploadfile goes in a plainPUTto the returned URL with the returned headers and no secret key. An upload URL that isn'thttpsis refused before any upload (api_erroron the file's field, with the fix "Fix the file storage's URL", and no URL in the error), unless theapp_hostis plainhttp://, the existing way to run against a local PostHog.attachments: [{sha256, filename, content_type?, content_id?}]. The same file attached twice uploads once.too_many_attachments), and at most 40 MB per message after Base64 encoding, about 29 MB of files (message_too_large). The estimate counts each file and each body at its Base64 transfer size (withouttext, thehtmltwice, since PostHog sends the text it derives from it), the subject at its folded encoded words (ceil(UTF-8 bytes / 42) * 75), plus 64 KiB for headers, as the server does. There is no limit per file. Both refusals carrywhy,fixand the field. The README and thesend()docblock name the limit.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, a missing filename, missing or empty content, a non-stringcontent_type/content_id, or text that isn't UTF-8. No error repeats the file's content.attachment_upload_failed, such asIt answered HTTP 403 AccessDenied: Request has expired., with the file's field. A storage 429 that outlasts the retries is namedrate_limit_exceeded, like every other 429. A network error or a storage 429, 500, 502, 503 or 504 retries within the email retry budget and honorsRetry-After, since an upload puts the same bytes at the same key. A refusal of the upload request (message_too_largewithfiles__1__size, say) names the attachment it refers to,attachments__2__content. The daily upload quota (upload_quota_exceeded) fails at once. A client shut down mid-flow stops before the next upload or the send withclient_closed. An upload never follows a redirect: a 3xx fails that attachment withattachment_upload_failed, naming the attachment but never the URL, and nothing is sent. The PHP transport already set curl not to follow; this change stops the error from quoting storage's answer, which can carry the redirect URL. The code isattachment_upload_failed, as the attachments spec and the other SDKs name it.not_foundonattachments, with its reason. Both a PostHog without the upload route and a project that can't send email from code answer 404, and the client can't tell them apart.send()docblock and its array shape, and aminorchange intent.Divergence from the spec and the other SDKs:
htmlagain for a missingtextis an upper bound, since the derived text is never longer than the HTML.httpsURLs. Go's opt-in isAllowInsecureAppHost; PHP has no such flag, and a plainhttp://app_hostalready means local development, so it is the opt-in here and no option was added.too_many_attachments,message_too_large), so an app handles both the same way.contentas already Base64. PHP has no separate bytes type, so a string is always the file. A caller holding Base64 passesbase64_decode($content).💚 How did you test it?
CI at
569df7a: PHPUnit on PHP 8.2 to 8.5, phpcs, the public API check, the compliance runs and the framework runs; see the checks below.Lane gate at
569df7a, on PHP 8.5 in Docker: the full suite (2769 tests) passes exceptPackageArchiveTest, which needs the git directory inside the container and runs in CI. Alsocomposer api:checkand phpcs on the changed files.Test seams:
emails()->send()on a realEmailswith an injected transport in place of HTTP. Its storage keeps the URL and headers it signed for each upload slot, as real storage does: aPUTto another URL or with other signed headers gets 403 SignatureDoesNotMatch, and bytes that don't match the signed size and checksum get 400 BadDigest. APUTthat also carries anAuthorizationheader gets 400 InvalidArgument, as S3 answers a second auth, and a send that names a file storage doesn't hold gets PostHog's 400attachment_not_uploaded. The fake PostHog refuses a request without the secret key, answers the upload request from what storage holds (a slot to sign for a file it lacks,presentfor one it has), and queues one email per idempotency key. The tests assert outcomes: the result, what storage holds and how often it took each file, the queued email, and the error. The tests cover:httpsrefused before any upload, naming the attachment and holding no URL, and a plainhttp://app host uploading over plain HTTP;presentfiles skipped, the same file uploaded once, and a retried send that doesn't upload again;rate_limit_exceeded;Retry-After; and a shutdown after the upload request, after an upload and after the last upload;not_foundonattachments, with and without a JSON body, and each upload answer the client can't follow (a non-http URL, a non-PUT method, a header with a line break, a missing status);PUTwith a 307 to a second server at anhttp://URL: the second server receives nothing, nothing is sent, and the error names the attachment but neither URL. Letting curl follow redirects fails it, and so does quoting storage's answer, which carries the URL;EmailsClientTestruns the flow over real HTTP against two local servers, an API and a storage host; the storage refuses bytes that miss the signed checksum with 400 BadDigest, and the send stops there. Storage there accepts only the signed path,content-lengthand checksum, and refuses a second auth as S3 does; the API accepts only an upload request with the secret key that names each file's hash and size. The tests assert the result, the bytes storage took and the emails the API queued, never the requests themselves; sending the secret key to storage fails it. Two tests prove the upload timeouts there: storage that answers after 1.5 s still takes a 200 KB file under a 1 s request timeout, and a storage host whose listen queue is full fails the upload withnetwork_errorat the 1 s connect timeout, not the 6 s transfer timeout, with no send. Setting the connect timeout to the transfer timeout fails the second (6 s), and dropping the extra transfer time fails the first.Live run at
569df7aagainst a real S3-compatible store (SeaweedFS), with upload URLs presigned by the AWS SDK the way node-store signs them (content-lengthandx-amz-checksum-sha256signed, 15 minutes). The PostHog API side is a local server answering the upload contract.ccandbccPUT. The store holds 29,000,000 bytes with the SHA-256 checksum, the stored bytes hash to the file's hash, and the email queued with the referenceattachment_upload_failed,HTTP 400 BadDigest, fieldattachments__0__content. One API request, no send, nothing storedattachment_upload_failed,HTTP 403 AccessDenied: Request has expired. One API request, no send, nothing storedPUTfor the same fileattachment_upload_failed, status 307, fieldattachments__0__content, namingreceipt-e.pdfand no URL. OnePUTto the redirector, one API request, no send, nothing stored. A control run with curl set to follow redirects stored the file at the target and sent the emailCross-version at
569df7a: this SDK against today's PostHog Cloud in both regions, which predates the server half. A send with attachments asks the upload route, gets404 Endpoint not found, throws PostHog's answer,not_foundonattachmentswith the messageEndpoint not found., and sends nothing. A send with onlyccreaches today's send route in the same shape. With a dummy key it stops at authentication, so its validation comes from today's server code: the server takes the list, and a team it doesn't accept the field for getsfield_not_accepted, never a silent drop.Shadow diff at
569df7aagainstmain(8726c6f): the same email calls on each side against a recording server with the same replies. The calls are a template send, a code send with areply_tolist, a retried send with a generated key, a 400 refusal, a 429 quota, a status read and an argument check. The requests, results and errors are identical.Gaps:
Review: a design pass before the rework, and a qa-swarm panel at
d87ef8b(fresh eyes on Claude; correctness, risk, design, anti-overengineering, systems and sweep on GPT-6.1 Sol). Fixed in7a720ed:Retry-Afterwas ignored;attachment_upload_failedtook a permission name from a 403;Round 2 on the fixes found three minor issues, fixed in
55089adandb7affe7: a storage 500, 502 or 504 wasn't retried, a storage 401 was namedmissing_api_key, and no test guarded the shutdown check before the send. A final read atb7affe7found nothing. PostHog Review atd87ef8bfound the same shutdown,Retry-Afterand missing-status issues plus the storage 500 retry. Atb7affe7it found that a large upload also stretched curl's connect timeout, fixed in2d96d4d; a curl run against an unreachable address gave up after the 300 ms connect timeout with a 5 s transfer timeout. Each thread is answered. A final read at2d96d4dfound nothing, and PostHog Review at2d96d4dfound nothing.ff26044only names the retried storage statuses in the README and docblock; its final read and PostHog Review found nothing.2ff3d91reports an upload-route 404 as PostHog's ownnot_foundonattachments, in place offield_not_accepted, as the attachments spec's Compatibility section now asks; its final read and PostHog Review found nothing.5934ddfdrops two inline comments; its final read found nothing.Round 3, a full qa-swarm at
5934ddf: the seven lenses plus four team perspectives (the author's lens, SDK spec conformance, engineering systems thinking and an XP reviewer). Fixed in44987c2: the estimate now counts the subject, as the attachments spec does since 2026-11-10 (found while checking the spec for this round); a storage 429 was retried but not documented;field_not_acceptedsat unused in the local refusal codes; the test storage accepted any bytes and two upload slots carried another file's checksum; the timeout test checked transport arguments instead of behavior. Kept as is:upload_quota_exceededstays namedrate_limit_exceeded, as the sdk-specs error names table has it for every 429 butquota_limited. Round 4 on the fixes found a stale description, a connect test that could pass on a stack that refuses a full queue at once, and prose docblocks, fixed in1d9e602.147e4c7pins the subject term to node-store's cases (42 and 43 ASCII bytes, 21 and 22 two-byte characters); round 5 on it found two tests repeating another's input, dropped in694a980.f257fe0makes the real-HTTP storage refuse bytes that miss the signed checksum, as the gate's judge asked. The judge also failed tests that asserted header strings and signed-URL text;90ca165replaces them with outcomes against storage fakes that enforce their signature, Final reads 9 and 10 found two more such assertions (the request order, the secret in storage's headers);379fa80and7bfc98breplace them with a fake API that refuses a send of a file storage doesn't hold and storage that refuses a second auth.7bfc98balso drops the blankContent-Typeon uploads: storage ignores unsigned headers, the spec puts no content type on uploads, and node-store never reads the stored label. Final read 11 asked to restore it; rejected for that reason, under the standing rule.Round 6, a full qa-swarm at
1041d8bwith the same eleven lenses, after the attachments spec's 2026-11-10 revision (from PostHog Review on posthog-go#408):1041d8bcounts the derived text and uploads only over https. Fixed in62bcc89: a storage 429 that outlasted the retries was namedapplication_error, and the remaining attachment tests asserted request order and header strings; the test fake now keeps what PostHog queued and what storage took, and refuses a request without the secret key. A final read at62bcc89found nothing. The gate's judge there still found a test reading the transport's request body and tests counting requests; in27df80dthe fake answers the upload route from what storage holds, and the tests read the stored files and the queued emails instead. Kept out of this PR: shutdown doesn't wait for a send in its retry backoff, as the emails spec asks;waitToRetryis unchanged frommain, so it's a follow-up. The risk lens graded the merge risk high.569df7aadds the attachments spec's revision from the security lens on posthog-js#5514: an upload never follows a redirect. It also renamesupload_failedtoattachment_upload_failed, the spec's name and the other SDKs'. Final read 14 on it found nothing.Superseded: inline Base64 design, tested at
faee0cfand14ec0dcTest 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 attachments work (PostHog/michaelr-context#84). It moves this PR from inline Base64 to upload by hash, as the server contract changed.contentin PHP?base64_decode($content)cc,bccorattachmentslist do?too_many_attachmentsandmessage_too_large, namedvalidation_errornot_foundonattachmentswith its reason, and no send. It replacedfield_not_accepted, which points at the wrong fix when sending email from code is off for the projectinvalid_attachmentattachment_upload_failedcarry?service_unavailablefor a storage 5xx,application_errorotherwiseclient_closedInvolvement: no questions asked in this rework. Reviews: a design pass, a seven-lens qa-swarm panel, a second round on the fixes, a full qa-swarm with four team perspectives on the final code, a round on its fixes, final reads, and PostHog Review. Proof captured: a live run against an S3-compatible store, a cross-version run against today's PostHog Cloud, and a shadow diff, all at head.
🤖 Generated with Claude Code