Skip to content

feat: send transactional email with emails()->send() - #288

Open
Silthus wants to merge 23 commits into
mainfrom
feat/emails-send
Open

Silthus wants to merge 23 commits into
mainfrom
feat/emails-send

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown

💡 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.send and emails.get to PHP, so a Laravel or Symfony app sends a password reset or a receipt with the client it already has.

PostHog::init('phc_...', ['host' => 'https://eu.i.posthog.com'], secretKey: 'phs_...');

// Content your app rendered (React Email, Blade, Twig: render it to HTML first)
$result = PostHog::emails()->send([
    'to' => 'user@example.com',
    'template_key' => 'password-reset',
    'subject' => 'Reset your password',
    'html' => $html,
    'distinct_id' => 'user_123',
]);

// A template made in PostHog, by its ID
PostHog::emails()->send([
    'to' => 'user@example.com',
    'template_id' => '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b',
    'template_data' => ['name' => 'Ada'],
    'distinct_id' => 'user_123',
    'idempotency_key' => 'welcome:user_123',
]);

$status = PostHog::emails()->get($result->getId()); // queued, sent, delivered, bounced, ...

The cross-SDK contract is PostHog/posthog#120921 and the emails spec 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: high, one-way door. A sent email can't be taken back, and a release makes the option keys, the EmailError getters and the four new client options a public contract. The change is additive: a shadow diff shows the capture and flag paths send the same requests as before. Don't merge or release until code sends are live and usable for customers, not only deployed: until the server's code form ships, a template_key send gets a 400. The template form works against today's server.

Changes

  • Client::emails() and PostHog::emails() return an Emails service with send(array $email): SendEmailResult and get(string $messageId): EmailStatus.
  • Two forms, one flat body to POST /api/projects/@current/messaging/send/email/: template_id with template_data (sent as message_data), or template_key with subject and html and/or text. distinct_id is flat in both. A mix of forms, an unknown key, or a field of the other form throws before any request, with a fix that names the right form. An empty reply_to list is left out, as in posthog-node and posthog-python.
  • Auth and host. The client's $secretKey (a phs_ key) goes as a Bearer token, only to the PostHog Cloud app host (us.i.posthog.com maps to us.posthog.com) or to the new app_host option. A custom host without app_host fails with missing_app_host, so an ingestion proxy never sees the secret key.
  • Idempotency and retries. Every send carries Idempotency-Key, a UUID v4 when the caller gives none, the same on every attempt. Network errors, the in-flight 409, 429 throttled and 503 retry with 1 s and 2 s backoff plus jitter, or Retry-After when longer. 429 quota_limited never retries. A Retry-After over 30 s fails at once with getRetryAfterMs(). Waits go through the Symfony clock the SDK already uses.
  • Errors. One EmailError extends RuntimeException, with getErrorCode() (PostHog's code, or an SDK code such as network_error or invalid_template_id), getErrorName() (Resend's names: validation_error, rate_limit_exceeded, monthly_quota_exceeded, ...), getStatusCode(), getWhy(), getFix(), getDocsUrl(), getField() and getRetryAfterMs(). getCode() stays the int of Exception.
  • Client state. A client without a project API key is the SDK's disabled client: it sends no email and throws client_disabled. After shutdown(), client_closed.
  • New client options: app_host, email_request_timeout_ms (15000), email_request_max_retries (2), migrated_from (sent as X-PostHog-Migrated-From). A bad value, such as a zero timeout or a non-integer like '15seconds' or 1.5, throws InvalidArgumentException when the client is built, as flag_definition_cache_provider does. A numeric string such as '5000' is read as its number. An empty migrated_from counts as left out.
  • Docs: a README section. Change intent: minor.
  • Left out on purpose: local templates, template_data on the code form, headers, tags, and any rendering dependency. cc, bcc and attachments come in the stacked #292. The app renders the email and passes html.

💚 How did you test it?

Test seams:

  • test/EmailsTest.php: Emails with its HTTP transport faked and a Symfony MockClock. It pins each body, header, refusal, host mapping, error mapping, retry and wait.
  • test/EmailsClientTest.php: Client::emails() and PostHog::emails() over real cURL to the repo's loopback LocalHttpServer: the bytes on the wire, Retry-After from 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, with app_host pointed at a logging proxy that recorded every exchange. The devbox has no flag service, so Django ran with workflows-email-from-code and workflows-transactional-email-api forced on for the run.

Case Result
Template form, uppercase template_id, template_data {name: Ada} 202, mail "Welcome, Ada" with "Hello Ada" arrived
The same call again 202, isReplayed() true, same message ID
Code form, html only, reply_to as a string 202; PostHog derived the text part, Reply-To arrived, links carry click tracking
Code form, text only, capture_engagement false 202, a text-only mail with no tracking
Case 1's idempotency key with another to 422 idempotency_key_reused, name invalid_idempotent_request
Unknown template_id 404 not_found
A wrong secret key 401, name missing_api_key
A custom host without app_host missing_app_host, and no request reached the proxy
get() on each sent ID 404 not_found for about 3 seconds, then sent

A status read right after the send answers 404 for a few seconds, as the contract says, and get() doesn't retry it. The README says so.

The same cases ran again at b2a1c5c (the head 86447e6 changes only comments) with the same results, once on PHP 8.4 and once on 32-bit PHP 8.2 (the official php:8.2-cli image for linux/386 under qemu-i386 user mode, real cURL).

Retry waits and replies, at 8efe315 against a stdlib server that always answers with a Retry-After, using real cURL, the real NativeClock and pcntl_alarm(1). The control is the same checkout with lib/Emails.php from before each fix; the 32-bit control is 4676fe0, the in-flight shutdown control 00abb30, the float-overflow control c0cfc6b.

Run Server answer Attempts at (s), head Before the fixes
The handler calls $client->shutdown() 503, Retry-After: 2 0 only; send() throws the 503 0, 1, 3: two after shutdown
The handler does nothing 429 throttled, Retry-After: 3 0, 3, 6 0, 1, 4: the signal cut the wait short
The handler does nothing; PHP's default zone is Asia/Tokyo 503, Retry-After an HTTP date 3 s ahead 0, 2.1, 5.1: the date kept (whole seconds) 0, 0.9, 3.1: the date read as Tokyo time, so in the past
No signal handler involved 503 with a body padded to 2 MB 0 only: response_too_large, not retried 0, 0.9, 3.0: 2 MB read in full each time
No signal handler involved 429 throttled, Retry-After: 9223372036854776 0 only: fails at once, getRetryAfterMs() one year 0, 1.1, 3.1: the overflowed wait retried
No signal handler involved 429 throttled, Retry-After of 309 nines, past the float range 0 only: fails at once, getRetryAfterMs() one year 0, 1.2, 2.9: the value was dropped, so it retried with backoff
The handler calls $client->shutdown() while the request is in flight 429 throttled, Retry-After: 20, answered after 2 s 0 only; send() throws at 2.0 s 0 only, but send() throws at 22 s, after the whole wait
32-bit PHP, no signal 429 throttled, Retry-After: 2 0, 2, 4 0, then TypeError from the wait's epoch microseconds
32-bit PHP, no signal 503, Retry-After an HTTP date 3 s ahead 0, 2.4, 5.4 0, then TypeError: epoch milliseconds overflowed
32-bit PHP, no signal 503, Retry-After: Fri, 31 Dec 9999 23:59:59 GMT 0 only: fails at once, getRetryAfterMs() 2,147,483,000 0, then ValueError: getTimestamp() past 2038

Shadow diff of the paths every caller runs, merge-base d8285a1 against b2a1c5c: the same capture, identify, alias, group identify, local and remote flag calls and flush on each checkout, each against a recording server with the same replies. After normalizing UUIDs, timestamps and ports, the requests (flag definitions, two /flags calls, one batch of seven events) and the return values are identical. The emails path has no base to diff; the live run covers it.

Without ext-curl, at 8efe315 with php -n, which loads no extensions: send() and get() throw EmailError missing_curl and send nothing. Before the guard, both died with Error: 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. Then shutdown() returns first. The send makes no further attempt and throws the failure it already has when its current wait ends. A shutdown() 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 simulated bump-version.sh 4.21.0), the Laravel package's API check and its 126 tests, phpcbf and phpcs, and the compliance adapter tests. At b2a1c5c: the full PHPUnit suite (2653 tests) on PHP 8.4, EmailsTest on 32-bit PHP 8.2 (99 tests) and composer api:check; the Laravel package's 126 tests at 8efe315. At 8efe315: and EmailsTest on 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 and composer api:check. Each retry-wait, Retry-After and reply-size test fails without its fix; on 32-bit PHP, EmailsTest fails at 4676fe0. 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: a full qa-swarm panel read the whole change at a123195: 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. Five lenses found nothing. Fixed in 8efe315: a bad email option failed at the first send instead of when the client is built; an empty reply_to threw where the other SDKs leave it out; a test computed its expected value with the code's own formula. Rejected, with reasons in the review record: a different shape for the retry helper. The proof gate's judge then held two of the round's rejections and found one bug, all fixed in b2a1c5c: a send is a one-way door, so the merge risk is high and the shadow diff above ran; the code's comments became method, constant and variable names, per the no-comments standard (the last ones in 86447e6); and an email option was cast with (int), so '15seconds' read as 15 and 1.5 as 1, where it now throws when the client is built. Final reads of b2a1c5c and 86447e6 found nothing.

Earlier, GPT-6.1 Sol read the change, then each fix.

  • Fixed: the User-Agent default put the SDK version into the public API snapshot, which would fail CI after the next release (45341fa).
  • Fixed: after a 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).
  • Fixed: a handled signal cut the retry wait short, so a retry could break Retry-After. The wait now sleeps until its deadline (c989580), kept on Symfony's MonotonicClock so that a wall-clock step can't move it (6dbca5a). EmailsMonotonicWaitTest covers the step by stubbing Symfony's timer in a separate process (03090d8).
  • Fixed, from the proof gate's judge and GPT-6.1 Sol: a huge Retry-After overflowed 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).
  • Fixed, from the proof gate's judge: on 32-bit PHP the retry wait and an HTTP-date Retry-After computed epoch microseconds and milliseconds, which don't fit in an int, and getTimestamp() 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).
  • Fixed, from Veria AI at 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).
  • Fixed, from PostHog Review at 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).
  • Fixed, from PostHog Review at c0cfc6b: a numeric Retry-After too long for a float read as INF and was dropped, so the send retried after about a second. It now clamps to the maximum like any other huge value (a123195).
  • Fixed, from Veria AI at d96fa5d: replies were read without a size limit. The transport now stops at 1 MiB and throws response_too_large, never retried (84e9c9a).
  • Fixed, from PostHog Review at df1d635: an HTTP-date Retry-After was read in PHP's default zone, because the format's GMT is literal text. It is now read in UTC (d96fa5d).
  • Fixed, from PostHog Review: without ext-curl, an email call died with a PHP Error instead of an EmailError. It now throws missing_curl before any request (c034daa, test/EmailsWithoutCurlTest.php). The README's get() 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.
  • Docs fixed, code unchanged: the client sends $secretKey to host for 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 with loadFeatureFlags: false (d5e5a2c, 15126f7).

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm change to generate a change intent file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written by Claude Code, Claude Opus 5.5 (claude-opus-5-5), directed by Michael, as the PHP slice of the transactional email SDK ports.
  • Decisions: options as one array with snake_case keys, as capture() and Resend's PHP SDK take them; argument errors throw EmailError with an invalid_* code and the name validation_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.
Question Answer Who Where
How does this PR get its review after StampHog refused it on size? Human review by team-client-libraries, not a split 👍 Accepted default comment
What does an empty reply_to list do? It is left out and the email sends, as in posthog-node and posthog-python 🤖 Agent comment
When does a bad email option fail? When the client is built, not at the first send; a non-integer value such as '15seconds' is refused, not cast 🤖 Agent comment

Involvement: 1 question answered. Reviews: GPT-6.1 Sol round 1 and 20 final reads, one full ten-lens qa-swarm panel, PostHog Review, Veria AI; StampHog refused on size. Proof captured: live run on 64-bit and 32-bit PHP, shadow diff.

🤖 Generated with Claude Code

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>
@Silthus Silthus self-assigned this Nov 2, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 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.

Comment thread lib/Emails.php Resolved
Comment thread README.md Resolved
Comment thread lib/Emails.php Resolved

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review

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.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-socket Compliance Report

Date: 2026-11-04T08:26:27.367954+00:00
Duration: 116077ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 27ms
Format Validation.Event Has Uuid ✅ 523ms
Format Validation.Event Has Lib Properties ✅ 524ms
Format Validation.Distinct Id Is String ✅ 524ms
Format Validation.Token Is Present ✅ 524ms
Format Validation.Custom Properties Preserved ✅ 525ms
Format Validation.Event Has Timestamp ✅ 524ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 524ms
Retry Behavior.Retries On 503 ✅ 5831ms
Retry Behavior.Does Not Retry On 400 ✅ 2527ms
Retry Behavior.Does Not Retry On 401 ✅ 2526ms
Retry Behavior.Respects Retry After Header ❌ 6333ms
Retry Behavior.Implements Backoff ✅ 16250ms
Retry Behavior.Retries On 500 ✅ 5634ms
Retry Behavior.Retries On 502 ✅ 5631ms
Retry Behavior.Retries On 504 ✅ 5632ms
Retry Behavior.Max Retries Respected ✅ 16248ms
Deduplication.Generates Unique Uuids ✅ 531ms
Deduplication.Preserves Uuid On Retry ✅ 5631ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10838ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5635ms
Deduplication.No Duplicate Events In Batch ✅ 532ms
Deduplication.Different Events Have Different Uuids ✅ 526ms
Compression.Sends Gzip When Enabled ✅ 523ms
Batch Format.Uses Proper Batch Structure ✅ 525ms
Batch Format.Flush With No Events Sends Nothing ✅ 519ms
Batch Format.Multiple Events Batched Together ✅ 514ms
Error Handling.Does Not Retry On 403 ✅ 2525ms
Error Handling.Does Not Retry On 413 ✅ 2525ms
Error Handling.Retries On 408 ✅ 5633ms

Failures

retry_behavior.respects_retry_after_header

Retry delay too short: 803ms < 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 ✅ 522ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 521ms
Request Payload.Flags Request Omits Authorization Header ✅ 522ms
Request Payload.Token In Flags Body Matches Init ✅ 522ms
Request Payload.Groups Round Trip ✅ 522ms
Request Payload.Groups Default To Empty Object ✅ 522ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 521ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 522ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 521ms
Request Lifecycle.No Flags Request On Init Alone ✅ 516ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 510ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 525ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 525ms
Retry Behavior.Retries Flags On 502 ✅ 624ms
Retry Behavior.Retries Flags On 504 ✅ 624ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 526ms

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-lib_curl Compliance Report

Date: 2026-11-04T08:26:21.653760+00:00
Duration: 116768ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 31ms
Format Validation.Event Has Uuid ✅ 524ms
Format Validation.Event Has Lib Properties ✅ 526ms
Format Validation.Distinct Id Is String ✅ 526ms
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 ✅ 5834ms
Retry Behavior.Does Not Retry On 400 ✅ 2530ms
Retry Behavior.Does Not Retry On 401 ✅ 2527ms
Retry Behavior.Respects Retry After Header ❌ 6338ms
Retry Behavior.Implements Backoff ✅ 16250ms
Retry Behavior.Retries On 500 ✅ 5637ms
Retry Behavior.Retries On 502 ✅ 5635ms
Retry Behavior.Retries On 504 ✅ 5633ms
Retry Behavior.Max Retries Respected ✅ 17052ms
Deduplication.Generates Unique Uuids ✅ 236ms
Deduplication.Preserves Uuid On Retry ✅ 5633ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10844ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5638ms
Deduplication.No Duplicate Events In Batch ✅ 535ms
Deduplication.Different Events Have Different Uuids ✅ 527ms
Compression.Sends Gzip When Enabled ✅ 526ms
Batch Format.Uses Proper Batch Structure ✅ 526ms
Batch Format.Flush With No Events Sends Nothing ✅ 523ms
Batch Format.Multiple Events Batched Together ✅ 512ms
Error Handling.Does Not Retry On 403 ✅ 2531ms
Error Handling.Does Not Retry On 413 ✅ 2529ms
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 ✅ 527ms
Request Payload.Flags Request Uses V2 Query Param ✅ 524ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 524ms
Request Payload.Flags Request Omits Authorization Header ✅ 524ms
Request Payload.Token In Flags Body Matches Init ✅ 523ms
Request Payload.Groups Round Trip ✅ 525ms
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 ✅ 524ms
Request Lifecycle.No Flags Request On Init Alone ✅ 519ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 510ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 528ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 526ms
Retry Behavior.Retries Flags On 502 ✅ 630ms
Retry Behavior.Retries Flags On 504 ✅ 627ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 526ms

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-fork_curl Compliance Report

Date: 2026-11-04T08:26:17.076380+00:00
Duration: 116419ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 36ms
Format Validation.Event Has Uuid ✅ 528ms
Format Validation.Event Has Lib Properties ✅ 530ms
Format Validation.Distinct Id Is String ✅ 530ms
Format Validation.Token Is Present ✅ 529ms
Format Validation.Custom Properties Preserved ✅ 530ms
Format Validation.Event Has Timestamp ✅ 529ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 530ms
Retry Behavior.Retries On 503 ✅ 5850ms
Retry Behavior.Does Not Retry On 400 ✅ 2532ms
Retry Behavior.Does Not Retry On 401 ✅ 2533ms
Retry Behavior.Respects Retry After Header ❌ 6344ms
Retry Behavior.Implements Backoff ✅ 16273ms
Retry Behavior.Retries On 500 ✅ 5647ms
Retry Behavior.Retries On 502 ✅ 5646ms
Retry Behavior.Retries On 504 ✅ 5643ms
Retry Behavior.Max Retries Respected ✅ 16272ms
Deduplication.Generates Unique Uuids ✅ 536ms
Deduplication.Preserves Uuid On Retry ✅ 5640ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10858ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5647ms
Deduplication.No Duplicate Events In Batch ✅ 536ms
Deduplication.Different Events Have Different Uuids ✅ 531ms
Compression.Sends Gzip When Enabled ✅ 532ms
Batch Format.Uses Proper Batch Structure ✅ 530ms
Batch Format.Flush With No Events Sends Nothing ✅ 518ms
Batch Format.Multiple Events Batched Together ✅ 519ms
Error Handling.Does Not Retry On 403 ✅ 2532ms
Error Handling.Does Not Retry On 413 ✅ 2533ms
Error Handling.Retries On 408 ✅ 5641ms

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 ✅ 524ms
Request Payload.Flags Request Uses V2 Query Param ✅ 520ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 521ms
Request Payload.Flags Request Omits Authorization Header ✅ 521ms
Request Payload.Token In Flags Body Matches Init ✅ 520ms
Request Payload.Groups Round Trip ✅ 521ms
Request Payload.Groups Default To Empty Object ✅ 523ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 521ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 522ms
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 ✅ 515ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 524ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 522ms
Retry Behavior.Retries Flags On 502 ✅ 623ms
Retry Behavior.Retries Flags On 504 ✅ 624ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 532ms

Silthus and others added 9 commits November 2, 2026 17:59
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>
@Silthus
Silthus marked this pull request as ready for review November 2, 2026 18:58
@Silthus
Silthus requested a review from a team as a code owner November 2, 2026 18:58

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 03090d8: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

Silthus and others added 2 commits November 2, 2026 19:02
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>
@Silthus

Silthus commented Nov 3, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed df1d635: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

Comment thread lib/Emails.php Resolved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread lib/Emails.php Resolved
@veria-ai

veria-ai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

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

Silthus and others added 2 commits November 3, 2026 10:02
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silthus and others added 2 commits November 3, 2026 10:23
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>
@Silthus

Silthus commented Nov 3, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 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.

Comment thread lib/Emails.php Resolved
…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>
@Silthus

Silthus commented Nov 3, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 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.

Comment thread lib/Emails.php Resolved
…ping it

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

Silthus commented Nov 3, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed a123195: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

…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>
@Silthus

Silthus commented Nov 4, 2026 •

Copy link
Copy Markdown
Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

Decisions for this PR:

  • 👍 How does this PR get its review after StampHog refused it on size? Human review by team-client-libraries, not a split into smaller PRs.
  • 🤖 What does an empty reply_to list do? It is left out and the email sends, as posthog-node and posthog-python do. It threw invalid_form before 8efe315.
  • 🤖 When does a bad email option fail? When the client is built, as flag_definition_cache_provider does, not at the first send. An empty migrated_from counts as left out. Since b2a1c5c, a non-integer timeout or retry count, such as '15seconds' or 1.5, is refused there too, not cast; a numeric string such as '5000' is read as its number.

@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 8efe315: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

…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>
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed b2a1c5c: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

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 <noreply@anthropic.com>
@Silthus

Silthus commented Nov 4, 2026

Copy link
Copy Markdown
Author

@PostHog review

@posthog

posthog Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@Silthus PostHog Review is reviewing this pull request.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PostHog Review · Standard · reviewed 86447e6: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant