Skip to content

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

Merged
Silthus merged 25 commits into
mainfrom
feat/emails-send
Nov 5, 2026
Merged

Silthus merged 25 commits into
mainfrom
feat/emails-send

Conversation

@Silthus

@Silthus Silthus commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

💡 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-05T11:03:48.668964+00:00
Duration: 115869ms

⚠️ 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 ✅ 23ms
Format Validation.Event Has Uuid ✅ 519ms
Format Validation.Event Has Lib Properties ✅ 519ms
Format Validation.Distinct Id Is String ✅ 520ms
Format Validation.Token Is Present ✅ 521ms
Format Validation.Custom Properties Preserved ✅ 519ms
Format Validation.Event Has Timestamp ✅ 520ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 520ms
Retry Behavior.Retries On 503 ✅ 5828ms
Retry Behavior.Does Not Retry On 400 ✅ 2544ms
Retry Behavior.Does Not Retry On 401 ✅ 2515ms
Retry Behavior.Respects Retry After Header ❌ 6329ms
Retry Behavior.Implements Backoff ✅ 16239ms
Retry Behavior.Retries On 500 ✅ 5630ms
Retry Behavior.Retries On 502 ✅ 5628ms
Retry Behavior.Retries On 504 ✅ 5625ms
Retry Behavior.Max Retries Respected ✅ 16243ms
Deduplication.Generates Unique Uuids ✅ 524ms
Deduplication.Preserves Uuid On Retry ✅ 5628ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10838ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5632ms
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 ✅ 522ms
Batch Format.Flush With No Events Sends Nothing ✅ 518ms
Batch Format.Multiple Events Batched Together ✅ 512ms
Error Handling.Does Not Retry On 403 ✅ 2524ms
Error Handling.Does Not Retry On 413 ✅ 2524ms
Error Handling.Retries On 408 ✅ 5626ms

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 ✅ 523ms
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 ✅ 521ms
Request Payload.Token In Flags Body Matches Init ✅ 520ms
Request Payload.Groups Round Trip ✅ 520ms
Request Payload.Groups Default To Empty Object ✅ 521ms
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 ✅ 519ms
Request Lifecycle.No Flags Request On Init Alone ✅ 515ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 507ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 523ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 520ms
Retry Behavior.Retries Flags On 502 ✅ 621ms
Retry Behavior.Retries Flags On 504 ✅ 621ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 524ms

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-lib_curl Compliance Report

Date: 2026-11-05T11:03:38.747770+00:00
Duration: 115911ms

⚠️ 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 ✅ 26ms
Format Validation.Event Has Uuid ✅ 532ms
Format Validation.Event Has Lib Properties ✅ 519ms
Format Validation.Distinct Id Is String ✅ 521ms
Format Validation.Token Is Present ✅ 522ms
Format Validation.Custom Properties Preserved ✅ 521ms
Format Validation.Event Has Timestamp ✅ 522ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 521ms
Retry Behavior.Retries On 503 ✅ 5830ms
Retry Behavior.Does Not Retry On 400 ✅ 2524ms
Retry Behavior.Does Not Retry On 401 ✅ 2535ms
Retry Behavior.Respects Retry After Header ❌ 6329ms
Retry Behavior.Implements Backoff ✅ 16237ms
Retry Behavior.Retries On 500 ✅ 5133ms
Retry Behavior.Retries On 502 ✅ 5632ms
Retry Behavior.Retries On 504 ✅ 5628ms
Retry Behavior.Max Retries Respected ✅ 17032ms
Deduplication.Generates Unique Uuids ✅ 240ms
Deduplication.Preserves Uuid On Retry ✅ 5628ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10838ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5631ms
Deduplication.No Duplicate Events In Batch ✅ 526ms
Deduplication.Different Events Have Different Uuids ✅ 522ms
Compression.Sends Gzip When Enabled ✅ 522ms
Batch Format.Uses Proper Batch Structure ✅ 521ms
Batch Format.Flush With No Events Sends Nothing ✅ 518ms
Batch Format.Multiple Events Batched Together ✅ 511ms
Error Handling.Does Not Retry On 403 ✅ 2522ms
Error Handling.Does Not Retry On 413 ✅ 2525ms
Error Handling.Retries On 408 ✅ 5627ms

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 ✅ 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 ✅ 518ms
Request Payload.Token In Flags Body Matches Init ✅ 520ms
Request Payload.Groups Round Trip ✅ 523ms
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 ✅ 520ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 520ms
Request Lifecycle.No Flags Request On Init Alone ✅ 515ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 509ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 522ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 521ms
Retry Behavior.Retries Flags On 502 ✅ 622ms
Retry Behavior.Retries Flags On 504 ✅ 621ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 521ms

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

posthog-php-fork_curl Compliance Report

Date: 2026-11-05T11:03:43.036248+00:00
Duration: 116633ms

⚠️ 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 ✅ 37ms
Format Validation.Event Has Uuid ✅ 530ms
Format Validation.Event Has Lib Properties ✅ 534ms
Format Validation.Distinct Id Is String ✅ 534ms
Format Validation.Token Is Present ✅ 533ms
Format Validation.Custom Properties Preserved ✅ 534ms
Format Validation.Event Has Timestamp ✅ 533ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 534ms
Retry Behavior.Retries On 503 ✅ 5856ms
Retry Behavior.Does Not Retry On 400 ✅ 2540ms
Retry Behavior.Does Not Retry On 401 ✅ 2538ms
Retry Behavior.Respects Retry After Header ❌ 6349ms
Retry Behavior.Implements Backoff ✅ 16280ms
Retry Behavior.Retries On 500 ✅ 5654ms
Retry Behavior.Retries On 502 ✅ 5649ms
Retry Behavior.Retries On 504 ✅ 5649ms
Retry Behavior.Max Retries Respected ✅ 16282ms
Deduplication.Generates Unique Uuids ✅ 541ms
Deduplication.Preserves Uuid On Retry ✅ 5650ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10867ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5653ms
Deduplication.No Duplicate Events In Batch ✅ 542ms
Deduplication.Different Events Have Different Uuids ✅ 537ms
Compression.Sends Gzip When Enabled ✅ 536ms
Batch Format.Uses Proper Batch Structure ✅ 534ms
Batch Format.Flush With No Events Sends Nothing ✅ 520ms
Batch Format.Multiple Events Batched Together ✅ 522ms
Error Handling.Does Not Retry On 403 ✅ 2536ms
Error Handling.Does Not Retry On 413 ✅ 2534ms
Error Handling.Retries On 408 ✅ 5650ms

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 ✅ 526ms
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 ✅ 525ms
Request Payload.Groups Round Trip ✅ 526ms
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 ✅ 523ms
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 ✅ 518ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 528ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 525ms
Retry Behavior.Retries Flags On 502 ✅ 627ms
Retry Behavior.Retries Flags On 504 ✅ 627ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 537ms

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
Contributor 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>
@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
Contributor 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
Contributor 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
Contributor 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
Contributor 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
Contributor 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.

… reads

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

@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.

stamphog declined to auto-review this pull request because the size gate failed. It has 1944 substantive lines across 8 files, which is over the 1200-line ceiling (3287 lines across 19 files once docs, generated files and snapshots are counted). Most of that is the new lib/Emails.php (773 lines) and the large test/EmailsTest.php (839 lines). The other gates passed, but this refusal is final.

To move forward, ask a human reviewer to take this PR. You could also split it into smaller PRs, for example the EmailError, EmailStatus, EmailWarning and SendEmailResult classes first, then the Emails service and client wiring, then the tests and docs.

Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✗ too large for auto-review (1944L substantive in global — ceiling is 1200L; 1944L, 8F total, 3287L/19F incl. docs/generated/snapshots)
tier ✓ T1-agent / T1d-complex (3287L, 19F, cross-cutting, feat)
stamphog 2.7.0 .stamphog/policy.yml @ unknown · reviewed head aa6a0a4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus merged commit 6c0c3c5 into main Nov 5, 2026
34 checks passed
@Silthus
Silthus deleted the feat/emails-send branch November 5, 2026 12:10
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.

2 participants