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>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed d06b4cf: 1 Must fix, 2 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
There was a problem hiding this comment.
PostHog Review
Found 1 must fix, 2 should fix.
This pull request is large, so the review ran in 4 parts with less depth than usual.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
Findings on the changed lines
lib/Emails.php:246
P1 · Gate code-content sends until the backend supports them
Every template_key send reaches an endpoint whose current SendEmailSerializer requires template_id, so the documented code-content examples receive HTTP 400 and send nothing. The email spec checked under AGENTS.md:11 explicitly excludes this unsupported form; gate or defer codeContent() until backend support ships.
README.md:53
P2 · Handle transient not_found errors in the status example
When a newly queued email is not yet visible to the status endpoint, this immediate get() throws EmailError with not_found. Emails::get() documents this initial delay and does not retry 404 responses, so copying the example produces an uncaught exception after a successful send. Show a deferred status check or bounded recovery for this transient response.
lib/Emails.php:528
P2 · Declare or guard the email transport's cURL dependency
On installations without ext-curl, which composer.json permits and the socket consumer supports, curlTransport() calls an undefined function. Both email operations throw a native Error, bypassing applications' documented EmailError handlers and failing the enclosing request. Declare the extension requirement or guard its absence with an EmailError.
posthog-php-socket Compliance ReportDate: 2026-11-03T10:51:55.724684+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 28ms |
| Format Validation.Event Has Uuid | ✅ | 520ms |
| Format Validation.Event Has Lib Properties | ✅ | 522ms |
| Format Validation.Distinct Id Is String | ✅ | 523ms |
| Format Validation.Token Is Present | ✅ | 523ms |
| Format Validation.Custom Properties Preserved | ✅ | 525ms |
| Format Validation.Event Has Timestamp | ✅ | 523ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 524ms |
| Retry Behavior.Retries On 503 | ✅ | 5831ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2524ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2539ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6331ms |
| Retry Behavior.Implements Backoff | ✅ | 16245ms |
| Retry Behavior.Retries On 500 | ✅ | 5631ms |
| Retry Behavior.Retries On 502 | ✅ | 5633ms |
| Retry Behavior.Retries On 504 | ✅ | 5627ms |
| Retry Behavior.Max Retries Respected | ✅ | 16249ms |
| Deduplication.Generates Unique Uuids | ✅ | 527ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5630ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10838ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5635ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 528ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 523ms |
| Compression.Sends Gzip When Enabled | ✅ | 523ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 523ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 518ms |
| Batch Format.Multiple Events Batched Together | ✅ | 510ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2524ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2523ms |
| Error Handling.Retries On 408 | ✅ | 5630ms |
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 | ✅ | 522ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 520ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 520ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 520ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 519ms |
| Request Payload.Groups Round Trip | ✅ | 521ms |
| Request Payload.Groups Default To Empty Object | ✅ | 520ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 520ms |
| 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 | ✅ | 516ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 508ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 523ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 522ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 623ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 628ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 517ms |
posthog-php-lib_curl Compliance ReportDate: 2026-11-03T10:51:41.201420+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 29ms |
| Format Validation.Event Has Uuid | ✅ | 523ms |
| Format Validation.Event Has Lib Properties | ✅ | 525ms |
| Format Validation.Distinct Id Is String | ✅ | 524ms |
| 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 | ✅ | 525ms |
| Retry Behavior.Retries On 503 | ✅ | 5834ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2527ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2528ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6334ms |
| Retry Behavior.Implements Backoff | ✅ | 16247ms |
| Retry Behavior.Retries On 500 | ✅ | 5633ms |
| Retry Behavior.Retries On 502 | ✅ | 5634ms |
| Retry Behavior.Retries On 504 | ✅ | 5632ms |
| Retry Behavior.Max Retries Respected | ✅ | 17050ms |
| Deduplication.Generates Unique Uuids | ✅ | 232ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5632ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10840ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5636ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 531ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 526ms |
| Compression.Sends Gzip When Enabled | ✅ | 525ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 525ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 521ms |
| Batch Format.Multiple Events Batched Together | ✅ | 513ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2526ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2527ms |
| Error Handling.Retries On 408 | ✅ | 5634ms |
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 | ✅ | 524ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 522ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 523ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 522ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 524ms |
| Request Payload.Groups Round Trip | ✅ | 522ms |
| Request Payload.Groups Default To Empty Object | ✅ | 522ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 522ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 523ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 522ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 517ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 511ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 526ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 524ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 625ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 625ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 526ms |
posthog-php-fork_curl Compliance ReportDate: 2026-11-03T10:51:23.837413+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 38ms |
| Format Validation.Event Has Uuid | ✅ | 532ms |
| Format Validation.Event Has Lib Properties | ✅ | 533ms |
| Format Validation.Distinct Id Is String | ✅ | 534ms |
| Format Validation.Token Is Present | ✅ | 532ms |
| Format Validation.Custom Properties Preserved | ✅ | 532ms |
| Format Validation.Event Has Timestamp | ✅ | 532ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 534ms |
| Retry Behavior.Retries On 503 | ✅ | 5856ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2539ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2534ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6346ms |
| Retry Behavior.Implements Backoff | ✅ | 16266ms |
| Retry Behavior.Retries On 500 | ✅ | 5162ms |
| Retry Behavior.Retries On 502 | ✅ | 5647ms |
| Retry Behavior.Retries On 504 | ✅ | 5646ms |
| Retry Behavior.Max Retries Respected | ✅ | 16282ms |
| Deduplication.Generates Unique Uuids | ✅ | 541ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5648ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10865ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5648ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 541ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 534ms |
| Compression.Sends Gzip When Enabled | ✅ | 534ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 532ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 520ms |
| Batch Format.Multiple Events Batched Together | ✅ | 522ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2533ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2538ms |
| Error Handling.Retries On 408 | ✅ | 5649ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 811ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 524ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 523ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 522ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 522ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 523ms |
| Request Payload.Groups Round Trip | ✅ | 523ms |
| Request Payload.Groups Default To Empty Object | ✅ | 523ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 522ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 523ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 522ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 519ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 517ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 527ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 524ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 625ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 626ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 534ms |
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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 00abb30: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
…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>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed c0cfc6b: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 4 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
…ping it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@PostHog review |
|
@Silthus PostHog Review is reviewing this pull request. |
There was a problem hiding this comment.
Not approved yet — waiting on the conditions below.
Re-add the stamphog label to request another review once you have addressed this.
The size gate refused this pull request: it has 1920 substantive lines of change, and the ceiling for auto-review is 1200. That count leaves out docs, generated files and snapshots; the full diff is 3230 lines across 19 files. The prerequisites, deny-list and tier gates all passed, so size is the only reason for the refusal.
To move this forward, ask a human reviewer to take it. Or split it into smaller pull requests, for example the EmailError, EmailStatus, EmailWarning and SendEmailResult classes in one, and the Emails service with its client wiring and tests in another, so that each falls under the ceiling.
- posthog[bot] reviewed the current head.
Gate mechanics and policy version
| Gate | Result | |
|---|---|---|
| prerequisites | ✓ | all clear |
| deny-list | ✓ | no deny categories matched |
| size | ✗ | too large for auto-review (1920L substantive in global — ceiling is 1200L; 1920L, 8F total, 3230L/19F incl. docs/generated/snapshots) |
| tier | ✓ | T1-agent / T1d-complex (3230L, 19F, cross-cutting, feat) |
| stamphog 2.7.0 | .stamphog/policy.yml @ unknown · reviewed head a123195 |
💡 Motivation and Context
A PHP backend can't send transactional email through PostHog today. posthog-node (PostHog/posthog-js#5509) and posthog-python (PostHog/posthog-python#1171) can. This PR ports their
emails.sendandemails.getto PHP, so a Laravel or Symfony app sends a password reset or a receipt with the client it already has.The cross-SDK contract is PostHog/posthog#120921 and the
emailsspec in PostHog/sdk-specs. The spec covers the template form only. The code form (template_key,subject,html/text,from,reply_to,capture_engagement) follows the contract and the two reference PRs above.Caution
Merge risk: medium, one-way door once released. The change is additive and touches no capture or flag path. A release makes the option keys, the
EmailErrorgetters and the four new client options a public contract. Release after the server's code form is deployed: until then atemplate_keysend gets a400. The template form works against today's server.Changes
Client::emails()andPostHog::emails()return anEmailsservice withsend(array $email): SendEmailResultandget(string $messageId): EmailStatus.POST /api/projects/@current/messaging/send/email/:template_idwithtemplate_data(sent asmessage_data), ortemplate_keywithsubjectandhtmland/ortext.distinct_idis flat in both. A mix of forms, an unknown key, or a field of the other form throws before any request, with afixthat names the right form.$secretKey(aphs_key) goes as a Bearer token, only to the PostHog Cloud app host (us.i.posthog.commaps tous.posthog.com) or to the newapp_hostoption. A customhostwithoutapp_hostfails withmissing_app_host, so an ingestion proxy never sees the secret key.Idempotency-Key, a UUID v4 when the caller gives none, the same on every attempt. Network errors, the in-flight409,429 throttledand503retry with 1 s and 2 s backoff plus jitter, orRetry-Afterwhen longer.429 quota_limitednever retries. ARetry-Afterover 30 s fails at once withgetRetryAfterMs(). Waits go through the Symfony clock the SDK already uses.EmailError extends RuntimeException, withgetErrorCode()(PostHog's code, or an SDK code such asnetwork_errororinvalid_template_id),getErrorName()(Resend's names:validation_error,rate_limit_exceeded,monthly_quota_exceeded, ...),getStatusCode(),getWhy(),getFix(),getDocsUrl(),getField()andgetRetryAfterMs().getCode()stays the int ofException.client_disabled. Aftershutdown(),client_closed.app_host,email_request_timeout_ms(15000),email_request_max_retries(2),migrated_from(sent asX-PostHog-Migrated-From).template_dataon the code form, cc, bcc, headers, tags, attachments, and any rendering dependency. The app renders the email and passeshtml.💚 How did you test it?
Test seams:
test/EmailsTest.php:Emailswith its HTTP transport faked and a SymfonyMockClock. It pins each body, header, refusal, host mapping, error mapping, retry and wait.test/EmailsClientTest.php:Client::emails()andPostHog::emails()over real cURL to the repo's loopbackLocalHttpServer: the bytes on the wire,Retry-Afterfrom a real response, a refused connection, disabled and shut-down clients, and that the service doesn't keep its client alive in a cycle.test/EmailsMonotonicWaitTest.php: in a separate process, Symfony's timer and sleeps are stubbed, so the wall clock stands still while the wait goes on. It pins that the retry wait follows the monotonic timer.Live run at
45341fa, against a real PostHog send route: Django at PostHog/posthog#127123 (51409d9b86b), plus cdp-api and the hog flow and email workers at PostHog/posthog#127122 (b5889c9eec), delivering over SMTP to a local maildev. A PHP script built the client the way a user does, withapp_hostpointed at a logging proxy that recorded every exchange. The devbox has no flag service, so Django ran withworkflows-email-from-codeandworkflows-transactional-email-apiforced on for the run.template_id,template_data{name: Ada}202, mail "Welcome, Ada" with "Hello Ada" arrived202,isReplayed()true, same message IDhtmlonly,reply_toas a string202; PostHog derived the text part,Reply-Toarrived, links carry click trackingtextonly,capture_engagementfalse202, a text-only mail with no trackingto422 idempotency_key_reused, nameinvalid_idempotent_requesttemplate_id404 not_found401, namemissing_api_keyhostwithoutapp_hostmissing_app_host, and no request reached the proxyget()on each sent ID404 not_foundfor about 3 seconds, thensentA status read right after the send answers
404for a few seconds, as the contract says, andget()doesn't retry it. The README says so.The same cases ran again at
a123195, the head, with the same results, once on PHP 8.4 and once on 32-bit PHP 8.2 (the officialphp:8.2-cliimage for linux/386 underqemu-i386user mode, real cURL).Retry waits and replies, at
a123195against a stdlib server that always answers with aRetry-After, using real cURL, the realNativeClockandpcntl_alarm(1). The control is the same checkout withlib/Emails.phpfrom before each fix; the 32-bit control is4676fe0, the in-flight shutdown control00abb30, the float-overflow controlc0cfc6b.$client->shutdown()503,Retry-After: 2send()throws the503429 throttled,Retry-After: 3503,Retry-Afteran HTTP date 3 s ahead503with a body padded to 2 MBresponse_too_large, not retried429 throttled,Retry-After: 9223372036854776getRetryAfterMs()one year429 throttled,Retry-Afterof 309 nines, past the float rangegetRetryAfterMs()one year$client->shutdown()while the request is in flight429 throttled,Retry-After: 20, answered after 2 ssend()throws at 2.0 ssend()throws at 22 s, after the whole wait429 throttled,Retry-After: 2TypeErrorfrom the wait's epoch microseconds503,Retry-Afteran HTTP date 3 s aheadTypeError: epoch milliseconds overflowed503,Retry-After: Fri, 31 Dec 9999 23:59:59 GMTgetRetryAfterMs()2,147,483,000ValueError:getTimestamp()past 2038Without
ext-curl, ata123195withphp -n, which loads no extensions:send()andget()throwEmailErrormissing_curland send nothing. Before the guard, both died withError: Call to undefined function PostHog\curl_init().Note
One exception to the spec's shutdown drain. PHP sends synchronously, so
shutdown()can only run during a send from a signal handler, and that handler can't wait for the send it interrupted. Thenshutdown()returns first. The send makes no further attempt and throws the failure it already has when its current wait ends. Ashutdown()called anywhere else runs after the send has returned.The repo's gate, run locally at
45341fa:composer validate,composer api:check(also after a simulatedbump-version.sh 4.21.0), the Laravel package's API check and its 126 tests, phpcbf and phpcs, and the compliance adapter tests. Ata123195: the 113 email tests on PHP 8.4, andEmailsTeston 32-bit PHP 8.2 (the tests that start a second PHP process can't run under the emulator), the public API snapshot test, phpcs on the changed files andcomposer api:check. Each retry-wait, Retry-After and reply-size test fails without its fix; on 32-bit PHP,EmailsTestfails at4676fe0. CI runs the full PHPUnit suite on PHP 8.2 to 8.5, coverage, phpcs, the public API check and the three compliance runs.Review: GPT-6.1 Sol read the change, then each fix.
45341fa).shutdown()from a signal handler during a retry wait, the retry still went out. A client closed during the wait now sends no retry (b870c10).Retry-After. The wait now sleeps until its deadline (c989580), kept on Symfony'sMonotonicClockso that a wall-clock step can't move it (6dbca5a).EmailsMonotonicWaitTestcovers the step by stubbing Symfony's timer in a separate process (03090d8).Retry-Afteroverflowed the cast to milliseconds and could come out as a short wait. The seconds are now clamped to one year, and to what fits in an int on 32-bit PHP, before converting (6abff5e,4676fe0).Retry-Aftercomputed epoch microseconds and milliseconds, which don't fit in an int, andgetTimestamp()throws for a date after 2038. The wait is now measured from its start, and the date is read as a float and clamped like the seconds form (6c395aa).6abff5e: a send warning's code and message came from the reply and went into the log unescaped, so a newline could forge a log line. Control characters are now escaped (00abb30).00abb30: a client shut down while the request was in flight still slept through the whole retry wait before settling. The wait now checks the closed state before every sleep (c0cfc6b). The gate's judge also noted that the test's fake monotonic timer overflowed on 32-bit PHP after 2 s; it now splits from microseconds (c0cfc6b).c0cfc6b: a numericRetry-Aftertoo long for a float read asINFand was dropped, so the send retried after about a second. It now clamps to the maximum like any other huge value (a123195).d96fa5d: replies were read without a size limit. The transport now stops at 1 MiB and throwsresponse_too_large, never retried (84e9c9a).df1d635: an HTTP-dateRetry-Afterwas read in PHP's default zone, because the format'sGMTis literal text. It is now read in UTC (d96fa5d).ext-curl, an email call died with a PHPErrorinstead of anEmailError. It now throwsmissing_curlbefore any request (c034daa,test/EmailsWithoutCurlTest.php). The README'sget()example now says the status read comes later. Its third finding, to hold back the code form, is answered in the thread: the release waits for the server instead.$secretKeytohostfor flag definitions. That predates this PR, and posthog-node and posthog-python share the key the same way. The README now says so and shows a proxy setup withloadFeatureFlags: false(d5e5a2c,15126f7).📝 Checklist
If releasing new changes
pnpm changeto generate a change intent file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
claude-opus-5-5), directed by Michael, as the PHP slice of the transactional email SDK ports.capture()and Resend's PHP SDK take them; argument errors throwEmailErrorwith aninvalid_*code and the namevalidation_error, as the contract's local checks say, so callers catch one type;getRetryAfterMs()in milliseconds, as the SDK's other time options;emails()builds a new service per call, so the client and the service never form a reference cycle that delays the client's flushing destructor.🤖 Generated with Claude Code