Skip to content

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

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

Silthus wants to merge 20 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: medium, one-way door once released. The change is additive and touches no capture or flag path. A release makes the option keys, the EmailError getters and the four new client options a public contract. Release after the server's code form is deployed: until then 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.
  • 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).
  • Docs: a README section. Change intent: minor.
  • Left out on purpose: local templates, template_data on the code form, cc, bcc, headers, tags, attachments, and any rendering dependency. 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 a123195, the head, 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 a123195 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

Without ext-curl, at a123195 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 a123195: the 113 email tests on PHP 8.4, 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: 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.

🤖 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-03T10:51:55.724684+00:00
Duration: 115999ms

⚠️ 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 ✅ 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

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-lib_curl Compliance Report

Date: 2026-11-03T10:51:41.201420+00:00
Duration: 116688ms

⚠️ 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 ✅ 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

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-fork_curl Compliance Report

Date: 2026-11-03T10:51:23.837413+00:00
Duration: 116091ms

⚠️ 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 ✅ 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

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>
Comment thread lib/Emails.php Resolved
Silthus and others added 3 commits November 3, 2026 10:14
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>
@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

@stamphog stamphog Bot removed the stamphog label Nov 3, 2026

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