From d06b4cfc2a4ff810749fc0f183a3fdbae8e799d4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:14:11 +0000 Subject: [PATCH 01/25] feat: send transactional email with emails()->send() 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 --- .changeset/emails-send.md | 5 + README.md | 37 ++ api/public-api.json | 781 +++++++++++++++++++++++++++++++++----- lib/Client.php | 34 ++ lib/EmailError.php | 151 ++++++++ lib/EmailStatus.php | 85 +++++ lib/EmailWarning.php | 38 ++ lib/Emails.php | 694 +++++++++++++++++++++++++++++++++ lib/PostHog.php | 19 + lib/SendEmailResult.php | 49 +++ test/EmailsClientTest.php | 190 ++++++++++ test/EmailsTest.php | 741 ++++++++++++++++++++++++++++++++++++ 12 files changed, 2731 insertions(+), 93 deletions(-) create mode 100644 .changeset/emails-send.md create mode 100644 lib/EmailError.php create mode 100644 lib/EmailStatus.php create mode 100644 lib/EmailWarning.php create mode 100644 lib/Emails.php create mode 100644 lib/SendEmailResult.php create mode 100644 test/EmailsClientTest.php create mode 100644 test/EmailsTest.php diff --git a/.changeset/emails-send.md b/.changeset/emails-send.md new file mode 100644 index 0000000..25cd65c --- /dev/null +++ b/.changeset/emails-send.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Add transactional email: `$client->emails()->send()` and `PostHog::emails()->send()` queue one email from a template made in PostHog (`template_id`, `template_data`) or from content your code rendered (`template_key`, `subject`, `html` and/or `text`), and `emails()->get()` reads its status. Sending needs a project secret API key (`phs_...`) as `$secretKey`. New client options: `app_host`, `email_request_timeout_ms`, `email_request_max_retries` and `migrated_from`. A failed send throws `PostHog\EmailError`. diff --git a/README.md b/README.md index a28b2ff..17587e0 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,43 @@ The registered processor also enables request span enrichment when `withContext( Existing span attributes take precedence. The processor preserves the application's provider, sampling, and exporters. See the [PHP tracing guide](https://posthog.com/docs/distributed-tracing/installation/php) and [Laravel guide](https://posthog.com/docs/libraries/laravel) for setup. +## Transactional email + +`emails()` sends one email to one recipient, from an email template made transactional in PostHog or from content your code rendered, and reads what happened to it. + +The client needs a project secret API key (`phs_...`) as `secretKey`. Sending needs the `hog_flow:write` scope, and reading a status needs `hog_flow:read`. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host, because the proxy must not receive the secret key. + +```php +PostHog::init('phc_...', ['host' => 'https://eu.i.posthog.com'], secretKey: 'phs_...'); + +// A template made in PostHog, by its ID +$result = PostHog::emails()->send([ + 'to' => 'user@example.com', + 'template_id' => '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b', + 'template_data' => ['name' => 'Ada'], // fills {{ variables.name }} + 'distinct_id' => 'user_123', + 'idempotency_key' => 'welcome:user_123', +]); + +// Content your code rendered, named by a key +PostHog::emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'password-reset', + 'subject' => 'Reset your password', + 'html' => $html, + 'distinct_id' => 'user_123', +]); + +$status = PostHog::emails()->get($result->getId()); // queued, sent, delivered, bounced, ... +``` + +- **Two forms.** Pass `template_id` with optional `template_data`, or `template_key` with `subject` and `html`, `text` or both. The content form also takes `from` (a verified sender), `reply_to` and `capture_engagement` (default `true`). PostHog derives a missing `text` from the `html`. To send an email built with a template library such as React Email, render it to HTML in your app and pass `html`. The SDK adds no rendering dependency. +- **Pass `distinct_id`.** Without it, the email's engagement events go to a person whose distinct ID is the email address. +- **A result means queued**, not delivered. `send()` returns a `SendEmailResult` with the message ID. `get()` reads the status for 30 days after the send. +- **Errors.** A send that is not queued throws `PostHog\EmailError`. Branch on `getErrorCode()`, such as `recipient_suppressed` or `missing_app_host`, or on `getErrorName()`, such as `validation_error`. An invalid argument throws before any request, with an `invalid_*` code. +- **Retries.** Network errors, rate limits and an unavailable service are retried up to `email_request_max_retries` times (default 2) with the same idempotency key, so a retry never sends twice. Each request times out after `email_request_timeout_ms` (default 15000). When a job runner already retries the send, pass `'max_retries' => 0` and an `idempotency_key` derived from what the email is for, such as `welcome:user_123`. A generated key does not survive a job retry. +- **Moving from another provider.** Set the `migrated_from` option, such as `'resend'`, so PostHog knows where your sends came from. + ## Questions? ### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ) diff --git a/api/public-api.json b/api/public-api.json index 2196769..b1e2ef5 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -302,6 +302,13 @@ } ] }, + "emails": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\Emails", + "parameters": [] + }, "enterContext": { "static": false, "abstract": false, @@ -1480,95 +1487,558 @@ "returnType": null, "parameters": [ { - "name": "message", - "type": "array", + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "capture": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "enqueue": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "item", + "type": null, + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "flush": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [] + }, + "getConsumer": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [] + }, + "identify": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + } + } + }, + "PostHog\\Consumer\\Socket": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": "PostHog\\QueueConsumer", + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "apiKey", + "type": null, + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "options", + "type": null, + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "flushBatch": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "batch", + "type": null, + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "getConsumer": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [] + } + } + }, + "PostHog\\ContextScope": { + "type": "class", + "abstract": false, + "final": true, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "onClose", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "__destruct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [] + }, + "close": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + } + } + }, + "PostHog\\EmailError": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": "RuntimeException", + "implements": [ + "Stringable", + "Throwable" + ], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "errorCode", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "message", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "statusCode", + "type": "?int", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "why", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "fix", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "docsUrl", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "field", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "retryAfterMs", + "type": "?int", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "previous", + "type": "?Throwable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getDocsUrl": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?string", + "parameters": [] + }, + "getErrorCode": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] + }, + "getErrorName": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] + }, + "getField": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?string", + "parameters": [] + }, + "getFix": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?string", + "parameters": [] + }, + "getRetryAfterMs": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?int", + "parameters": [] + }, + "getStatusCode": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?int", + "parameters": [] + }, + "getWhy": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?string", + "parameters": [] + } + } + }, + "PostHog\\EmailStatus": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "id", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "status", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "createdAt", + "type": "DateTimeImmutable", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "statusAt", + "type": "DateTimeImmutable", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "reason", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getCreatedAt": { + "static": false, + "abstract": false, + "final": false, + "returnType": "DateTimeImmutable", + "parameters": [] + }, + "getId": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] + }, + "getReason": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?string", + "parameters": [] + }, + "getStatus": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] + }, + "getStatusAt": { + "static": false, + "abstract": false, + "final": false, + "returnType": "DateTimeImmutable", + "parameters": [] + } + } + }, + "PostHog\\EmailWarning": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "code", + "type": "string", "byReference": false, "variadic": false, "optional": false, "default": null, "defaultConstant": null, "hasDefault": false - } - ] - }, - "capture": { - "static": false, - "abstract": false, - "final": false, - "returnType": null, - "parameters": [ + }, { "name": "message", - "type": "array", + "type": "string", "byReference": false, "variadic": false, "optional": false, "default": null, "defaultConstant": null, "hasDefault": false - } - ] - }, - "enqueue": { - "static": false, - "abstract": false, - "final": false, - "returnType": null, - "parameters": [ + }, { - "name": "item", - "type": null, + "name": "fix", + "type": "?string", "byReference": false, "variadic": false, - "optional": false, + "optional": true, "default": null, "defaultConstant": null, - "hasDefault": false + "hasDefault": true } ] }, - "flush": { + "getCode": { "static": false, "abstract": false, "final": false, - "returnType": null, + "returnType": "string", "parameters": [] }, - "getConsumer": { + "getFix": { "static": false, "abstract": false, "final": false, - "returnType": null, + "returnType": "?string", "parameters": [] }, - "identify": { + "getMessage": { "static": false, "abstract": false, "final": false, - "returnType": null, - "parameters": [ - { - "name": "message", - "type": "array", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] + "returnType": "string", + "parameters": [] } } }, - "PostHog\\Consumer\\Socket": { + "PostHog\\Emails": { "type": "class", "abstract": false, - "final": false, + "final": true, "readonly": false, - "extends": "PostHog\\QueueConsumer", + "extends": null, "implements": [], - "constants": [], + "constants": { + "DEFAULT_MAX_RETRIES": { + "type": "int", + "value": 2 + }, + "DEFAULT_REQUEST_TIMEOUT_MS": { + "type": "int", + "value": 15000 + } + }, "properties": [], "methods": { "__construct": { @@ -1578,8 +2048,8 @@ "returnType": null, "parameters": [ { - "name": "apiKey", - "type": null, + "name": "secretKey", + "type": "?string", "byReference": false, "variadic": false, "optional": false, @@ -1588,26 +2058,106 @@ "hasDefault": false }, { - "name": "options", - "type": null, + "name": "host", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "appHost", + "type": "?string", "byReference": false, "variadic": false, "optional": true, - "default": [], + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "requestTimeoutMs", + "type": "int", + "byReference": false, + "variadic": false, + "optional": true, + "default": 15000, + "defaultConstant": "self::DEFAULT_REQUEST_TIMEOUT_MS", + "hasDefault": true + }, + { + "name": "maxRetries", + "type": "int", + "byReference": false, + "variadic": false, + "optional": true, + "default": 2, + "defaultConstant": "self::DEFAULT_MAX_RETRIES", + "hasDefault": true + }, + { + "name": "migratedFrom", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "userAgent", + "type": "string", + "byReference": false, + "variadic": false, + "optional": true, + "default": "posthog-php/4.20.0", + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "isDisabled", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "isClosed", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "transport", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, "defaultConstant": null, "hasDefault": true } ] }, - "flushBatch": { + "get": { "static": false, "abstract": false, "final": false, - "returnType": null, + "returnType": "PostHog\\EmailStatus", "parameters": [ { - "name": "batch", - "type": null, + "name": "messageId", + "type": "string", "byReference": false, "variadic": false, "optional": false, @@ -1617,34 +2167,15 @@ } ] }, - "getConsumer": { - "static": false, - "abstract": false, - "final": false, - "returnType": null, - "parameters": [] - } - } - }, - "PostHog\\ContextScope": { - "type": "class", - "abstract": false, - "final": true, - "readonly": false, - "extends": null, - "implements": [], - "constants": [], - "properties": [], - "methods": { - "__construct": { + "send": { "static": false, "abstract": false, "final": false, - "returnType": null, + "returnType": "PostHog\\SendEmailResult", "parameters": [ { - "name": "onClose", - "type": "?Closure", + "name": "email", + "type": "array", "byReference": false, "variadic": false, "optional": false, @@ -1653,20 +2184,6 @@ "hasDefault": false } ] - }, - "__destruct": { - "static": false, - "abstract": false, - "final": false, - "returnType": null, - "parameters": [] - }, - "close": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [] } } }, @@ -3881,6 +4398,13 @@ } ] }, + "emails": { + "static": true, + "abstract": false, + "final": false, + "returnType": "PostHog\\Emails", + "parameters": [] + }, "enterContext": { "static": true, "abstract": false, @@ -4935,6 +5459,77 @@ } } }, + "PostHog\\SendEmailResult": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "id", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "replayed", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "warnings", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getId": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] + }, + "getWarnings": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [] + }, + "isReplayed": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + } + } + }, "PostHog\\SizeLimitedHash": { "type": "class", "abstract": false, diff --git a/lib/Client.php b/lib/Client.php index c81eb6d..40c8fd7 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -271,8 +271,15 @@ class Client implements FeatureFlagEvaluationsHost * `disableGeoip` defaults to false for compatibility with existing releases. It applies to events * and remote flag evaluation. Per-call options override this setting; false allows GeoIP enrichment. * + * Transactional email ({@see emails()}) uses `app_host`, `email_request_timeout_ms` (default 15000), + * `email_request_max_retries` (default 2) and `migrated_from`. + * * @param array{ * host?: string, + * app_host?: string, + * email_request_timeout_ms?: int, + * email_request_max_retries?: int, + * migrated_from?: string, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, @@ -307,6 +314,7 @@ class Client implements FeatureFlagEvaluationsHost * @param string|null $secretKey Credential for fetching local feature flag definitions and remote config. * Accepts either a Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`). * Defaults to null. When both $secretKey and $personalAPIKey are provided, $secretKey wins. + * Transactional email needs a Project Secret API Key. */ public function __construct( ?string $apiKey = null, @@ -476,6 +484,32 @@ public function shutdown(): bool return $flushed; } + /** + * Transactional email: send one email from a template made in PostHog or from content your code + * rendered, and read the status of an email it sent. Needs a Project Secret API Key (`phs_...`) as + * $secretKey. A client on PostHog Cloud sends to its region's app host. For any other host, such as a + * reverse proxy, set the `app_host` option. A client without a project API key sends no email and + * throws EmailError with the code `client_disabled`. + * + * The client captures no event for a send. The send and its engagement events come from PostHog. + */ + public function emails(): Emails + { + // A new service per call: it refers to the client, and the client never refers to it, so dropping + // both still runs the client's destructor, which flushes queued events. + return new Emails( + $this->secretKey, + $this->options['host'], + appHost: $this->options['app_host'] ?? null, + requestTimeoutMs: (int) ($this->options['email_request_timeout_ms'] ?? Emails::DEFAULT_REQUEST_TIMEOUT_MS), + maxRetries: (int) ($this->options['email_request_max_retries'] ?? Emails::DEFAULT_MAX_RETRIES), + migratedFrom: $this->options['migrated_from'] ?? null, + userAgent: $this->options['library_name'] . '/' . $this->options['library_version'], + isDisabled: fn(): bool => !$this->enabled, + isClosed: fn(): bool => $this->shutdownComplete, + ); + } + /** * Captures a user action. * diff --git a/lib/EmailError.php b/lib/EmailError.php new file mode 100644 index 0000000..b71218a --- /dev/null +++ b/lib/EmailError.php @@ -0,0 +1,151 @@ +errorName = self::nameFor($statusCode, $errorCode); + $this->errorCode = $errorCode; + $this->statusCode = $statusCode; + $this->why = $why; + $this->fix = $fix; + $this->docsUrl = $docsUrl; + $this->field = $field; + $this->retryAfterMs = $retryAfterMs; + } + + /** + * A coarse category of the failure: `validation_error`, `missing_api_key`, `invalid_permission`, + * `not_found`, `concurrent_idempotent_requests`, `invalid_idempotent_request`, `rate_limit_exceeded`, + * `monthly_quota_exceeded`, `service_unavailable` or `application_error`. + */ + public function getErrorName(): string + { + return $this->errorName; + } + + /** + * PostHog's error code, such as `quota_limited`, or an SDK code such as `missing_secret_key`, + * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` or an + * `invalid_*` argument code. + */ + public function getErrorCode(): string + { + return $this->errorCode; + } + + /** + * The HTTP status, or null when PostHog did not answer. + */ + public function getStatusCode(): ?int + { + return $this->statusCode; + } + + /** + * Why PostHog refused the request, when it said. + */ + public function getWhy(): ?string + { + return $this->why; + } + + /** + * How to fix the request, when PostHog or the SDK said. + */ + public function getFix(): ?string + { + return $this->fix; + } + + /** + * A page that explains the error, when PostHog sent one. + */ + public function getDocsUrl(): ?string + { + return $this->docsUrl; + } + + /** + * The request field the error is about, such as `subject`, when one was named. + */ + public function getField(): ?string + { + return $this->field; + } + + /** + * How long PostHog asked the client to wait before it sends again, in milliseconds. + */ + public function getRetryAfterMs(): ?int + { + return $this->retryAfterMs; + } + + private static function nameFor(?int $status, string $code): string + { + if ($code === 'missing_secret_key' || $status === 401) { + return 'missing_api_key'; + } + if ($status === null) { + if ($code === 'network_error') { + return 'service_unavailable'; + } + return str_starts_with($code, 'invalid_') ? 'validation_error' : 'application_error'; + } + + return match ($status) { + 400 => 'validation_error', + 403 => 'invalid_permission', + 404 => 'not_found', + 409 => $code === 'template_outdated' ? 'validation_error' : 'concurrent_idempotent_requests', + // The send route also answers 422 for send checks such as recipient_suppressed. + 422 => $code === 'idempotency_key_reused' ? 'invalid_idempotent_request' : 'validation_error', + 429 => $code === 'quota_limited' ? 'monthly_quota_exceeded' : 'rate_limit_exceeded', + 503 => 'service_unavailable', + default => 'application_error', + }; + } +} diff --git a/lib/EmailStatus.php b/lib/EmailStatus.php new file mode 100644 index 0000000..c858f2c --- /dev/null +++ b/lib/EmailStatus.php @@ -0,0 +1,85 @@ +id = $id; + $this->status = $status; + $this->createdAt = $createdAt; + $this->statusAt = $statusAt; + $this->reason = $reason; + } + + /** + * The message ID that Emails::send() returned. + */ + public function getId(): string + { + return $this->id; + } + + /** + * The email's status, such as `delivered` or `bounced`. + */ + public function getStatus(): string + { + return $this->status; + } + + /** + * When PostHog queued the email. + */ + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * When the email reached its current status. + */ + public function getStatusAt(): DateTimeImmutable + { + return $this->statusAt; + } + + /** + * Why a failed email was not sent, such as `provider_error` or `recipient_suppressed`, or the bounce + * type of a bounced email: `hard`, `soft` or `unknown`. Null otherwise. + */ + public function getReason(): ?string + { + return $this->reason; + } +} diff --git a/lib/EmailWarning.php b/lib/EmailWarning.php new file mode 100644 index 0000000..d156571 --- /dev/null +++ b/lib/EmailWarning.php @@ -0,0 +1,38 @@ +code = $code; + $this->message = $message; + $this->fix = $fix; + } + + public function getCode(): string + { + return $this->code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getFix(): ?string + { + return $this->fix; + } +} diff --git a/lib/Emails.php b/lib/Emails.php new file mode 100644 index 0000000..80afa66 --- /dev/null +++ b/lib/Emails.php @@ -0,0 +1,694 @@ +emails()` or `PostHog::emails()`. The client needs a project + * secret API key (`phs_...`) as `$secretKey`: sending needs the `hog_flow:write` scope, reading a status + * needs `hog_flow:read`. A client on PostHog Cloud sends to its region's app host. For any other `host`, + * such as a reverse proxy, set the `app_host` option, because the proxy must not receive the secret key. + * + * Each request times out after `email_request_timeout_ms` (15000 by default). Network errors, rate + * limits, an in-flight idempotency key and an unavailable service are retried up to + * `email_request_max_retries` times (2 by default) with the same idempotency key, waiting about 1 and + * then 2 seconds, or the server's `Retry-After` when it is longer. A `Retry-After` over 30 seconds fails + * at once. A send that is not queued throws EmailError. + */ +final class Emails +{ + public const DEFAULT_REQUEST_TIMEOUT_MS = 15000; + public const DEFAULT_MAX_RETRIES = 2; + + private const SEND_PATH = '/api/projects/@current/messaging/send/email/'; + private const STATUS_PATH = '/api/projects/@current/messaging/emails/%s/'; + private const US_APP_HOST = 'https://us.posthog.com'; + private const EU_APP_HOST = 'https://eu.posthog.com'; + private const CLOUD_APP_HOSTS = [ + 'us.i.posthog.com' => self::US_APP_HOST, + 'us.posthog.com' => self::US_APP_HOST, + 'app.posthog.com' => self::US_APP_HOST, + 'eu.i.posthog.com' => self::EU_APP_HOST, + 'eu.posthog.com' => self::EU_APP_HOST, + ]; + private const MAX_RETRY_DELAY_MS = 30000; + private const UUID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD'; + private const IDEMPOTENCY_KEY_PATTERN = '/^[\x21-\x7e]{1,255}$/D'; + private const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,400}$/D'; + private const HEADER_TOKEN_PATTERN = '/^[\x21-\x7e]+$/D'; + private const TIMESTAMP_PATTERN = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/D'; + + private const SHARED_FIELDS = ['to', 'distinct_id', 'idempotency_key', 'max_retries']; + private const TEMPLATE_FIELDS = ['template_id', 'template_data']; + private const CODE_FIELDS = ['template_key', 'subject', 'html', 'text', 'from', 'reply_to', 'capture_engagement']; + private const TEMPLATE_FORM_WHY = "A template made in PostHog sets its own sender, subject and content, " + . "and follows the project's engagement setting."; + private const CODE_FORM_WHY = 'A send with template_key takes its content from your code.'; + + private const SEND_NOT_DONE = 'no email was sent'; + private const GET_NOT_DONE = 'no email status was read'; + private const SEND_KEY_NEEDED = 'Sending email needs a secret key, a project secret API key with the ' + . 'hog_flow:write scope.'; + private const GET_KEY_NEEDED = 'Reading an email status needs a secret key, a project secret API key with the ' + . 'hog_flow:read or hog_flow:write scope.'; + + private ?string $secretKey; + private ?string $appHost; + private int $requestTimeoutMs; + private int $maxRetries; + private ?string $migratedFrom; + private string $userAgent; + private Closure $isDisabled; + private Closure $isClosed; + private Closure $transport; + + /** + * @internal Use Client::emails() or PostHog::emails(). + * + * @param string|null $secretKey A project secret API key. + * @param string $host The client's host. Used only when it is PostHog Cloud. + * @param string|null $appHost The PostHog app host, for a host that is not PostHog Cloud. + * @param Closure|null $transport `fn(string $method, string $url, list $headers, ?string $body, + * int $timeoutMs): array{int, string, list}` returning the status, body and header lines. + * It throws EmailError with the code `network_error` when no answer arrived. + */ + public function __construct( + ?string $secretKey, + string $host, + ?string $appHost = null, + int $requestTimeoutMs = self::DEFAULT_REQUEST_TIMEOUT_MS, + int $maxRetries = self::DEFAULT_MAX_RETRIES, + ?string $migratedFrom = null, + string $userAgent = PostHog::LIBRARY . '/' . PostHog::VERSION, + ?Closure $isDisabled = null, + ?Closure $isClosed = null, + ?Closure $transport = null + ) { + if ($requestTimeoutMs <= 0) { + throw new InvalidArgumentException('email_request_timeout_ms must be a positive number of milliseconds.'); + } + if ($maxRetries < 0) { + throw new InvalidArgumentException('email_request_max_retries must be a nonnegative integer.'); + } + if ($migratedFrom !== null && preg_match(self::HEADER_TOKEN_PATTERN, $migratedFrom) !== 1) { + throw new InvalidArgumentException("migrated_from must be visible ASCII characters, such as 'resend'."); + } + $this->secretKey = StringNormalizer::normalizeOptional($secretKey); + $this->appHost = self::resolveAppHost($host, $appHost); + $this->requestTimeoutMs = $requestTimeoutMs; + $this->maxRetries = $maxRetries; + $this->migratedFrom = $migratedFrom; + $this->userAgent = $userAgent; + $this->isDisabled = $isDisabled ?? static fn(): bool => false; + $this->isClosed = $isClosed ?? static fn(): bool => false; + $this->transport = $transport ?? Closure::fromCallable([self::class, 'curlTransport']); + } + + /** + * Queue one email, from a template made in PostHog or from content your code rendered. + * + * Pass exactly one form: + * + * - A template made transactional in PostHog: `template_id`, its UUID, and optional `template_data`, + * the values for its `{{ variables.* }}` placeholders. + * - Content your code rendered, named by a key: `template_key` (letters, digits, `-` and `_`, at most + * 400), `subject`, and `html`, `text` or both. PostHog derives a missing `text` from the `html`, and + * sends the content as is without storing it. To send a React email, render it to HTML in your app + * first. This form also takes `from` (a verified sender, such as `Acme `), + * `reply_to` (one address or a list) and `capture_engagement` (default true; a project whose admin + * turned engagement capture off captures nothing, whatever the send asks). + * + * Both forms take `to` (one address), `distinct_id` (the recipient's distinct ID in your product; + * without it, the email's events go to a person whose distinct ID is the address), `idempotency_key` + * and `max_retries`. A null value counts as left out. + * + * A send with an idempotency key PostHog already accepted queues nothing and returns the first send's + * result. Without a key, the client makes a UUID that covers its own retries. Pass a key derived from + * what the email is for, such as `welcome:user_123`, to cover retries outside this call too, and pass + * `max_retries` 0 when a job runner already retries the send. + * + * ```php + * $client->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', + * ]); + * + * $client->emails()->send([ + * 'to' => 'user@example.com', + * 'template_key' => 'password-reset', + * 'subject' => 'Reset your password', + * 'html' => $renderedHtml, + * 'distinct_id' => 'user_123', + * ]); + * ``` + * + * @param array{ + * to: string, + * distinct_id?: string|null, + * idempotency_key?: string|null, + * max_retries?: int|null, + * template_id?: string, + * template_data?: array|null, + * template_key?: string, + * subject?: string, + * html?: string|null, + * text?: string|null, + * from?: string|null, + * reply_to?: string|list|null, + * capture_engagement?: bool|null + * } $email + * @return SendEmailResult The queued email. It means PostHog queued the email, not that it was delivered. + * @throws EmailError PostHog did not queue the email, or the client could not tell whether it did. A + * failed argument check has the name `validation_error` and an `invalid_*` code, and sends nothing. + */ + public function send(array $email): SendEmailResult + { + [$body, $idempotencyKey, $maxRetries] = $this->prepareSend($email); + [$appHost, $secretKey] = $this->connect(self::SEND_NOT_DONE, self::SEND_KEY_NEEDED); + $headers = $this->headers($secretKey, [ + 'Content-Type: application/json', + 'Idempotency-Key: ' . $idempotencyKey, + ]); + $result = $this->withRetries($maxRetries, function () use ($appHost, $headers, $body): SendEmailResult { + return self::parseSendReply(...$this->request('POST', $appHost . self::SEND_PATH, $headers, $body)); + }); + foreach ($result->getWarnings() as $warning) { + error_log('[PostHog][Emails] Email warning ' . $warning->getCode() . ': ' . $warning->getMessage()); + } + + return $result; + } + + /** + * Read what happened to an email that send() queued, for 30 days after the send. + * + * ```php + * $status = $client->emails()->get($result->getId()); + * if ($status->getStatus() === 'bounced') { + * echo $status->getReason(); + * } + * ``` + * + * @param string $messageId The ID of the SendEmailResult that send() returned. + * @throws EmailError PostHog did not answer with a status. The code is `not_found` for an unknown ID, + * an email older than 30 days, or for a few seconds after the send. + */ + public function get(string $messageId): EmailStatus + { + if (preg_match(self::UUID_PATTERN, $messageId) !== 1) { + throw new EmailError( + 'invalid_message_id', + 'The message ID must be the ID that send() returned.', + field: 'message_id' + ); + } + [$appHost, $secretKey] = $this->connect(self::GET_NOT_DONE, self::GET_KEY_NEEDED); + $url = $appHost . sprintf(self::STATUS_PATH, strtolower($messageId)); + $headers = $this->headers($secretKey, []); + + return $this->withRetries($this->maxRetries, function () use ($url, $headers): EmailStatus { + return self::parseStatusReply(...$this->request('GET', $url, $headers, null)); + }); + } + + /** + * @return array{string, string, int} The JSON body, the idempotency key and the retry limit. + */ + private function prepareSend(array $email): array + { + $email = array_filter($email, static fn($value): bool => $value !== null); + $to = $email['to'] ?? null; + if (!is_string($to) || trim($to) === '') { + throw self::invalid('invalid_recipient', 'to must be one email address as a nonempty string.', 'to'); + } + $distinctId = $email['distinct_id'] ?? null; + if ($distinctId !== null && (!is_string($distinctId) || $distinctId === '')) { + throw self::invalid('invalid_distinct_id', 'distinct_id must be a nonempty string.', 'distinct_id'); + } + $hasTemplateId = array_key_exists('template_id', $email); + if ($hasTemplateId === array_key_exists('template_key', $email)) { + throw new EmailError( + 'invalid_form', + 'Send either template_id, or template_key with subject and html or text, so no email was sent.' + ); + } + $payload = $hasTemplateId ? self::templateContent($email) : self::codeContent($email); + $idempotencyKey = $email['idempotency_key'] ?? null; + if ( + $idempotencyKey !== null + && (!is_string($idempotencyKey) || preg_match(self::IDEMPOTENCY_KEY_PATTERN, $idempotencyKey) !== 1) + ) { + throw self::invalid( + 'invalid_idempotency_key', + 'idempotency_key must be 1 to 255 visible ASCII characters without spaces.', + 'idempotency_key' + ); + } + $maxRetries = $email['max_retries'] ?? $this->maxRetries; + if (!is_int($maxRetries) || $maxRetries < 0) { + throw self::invalid('invalid_max_retries', 'max_retries must be a nonnegative integer.', 'max_retries'); + } + $payload['to'] = $to; + if ($distinctId !== null) { + $payload['distinct_id'] = $distinctId; + } + try { + $body = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (JsonException $exception) { + throw new EmailError( + 'invalid_form', + 'The email could not be encoded as JSON, so no email was sent: ' . $exception->getMessage() . '.', + previous: $exception + ); + } + + return [$body, $idempotencyKey ?? Uuid::v4(), $maxRetries]; + } + + /** + * @return array + */ + private static function templateContent(array $email): array + { + $templateId = $email['template_id']; + if (!is_string($templateId) || preg_match(self::UUID_PATTERN, $templateId) !== 1) { + throw self::invalid('invalid_template_id', "template_id must be the template's UUID.", 'template_id'); + } + $templateData = $email['template_data'] ?? []; + if (!is_array($templateData) || ($templateData !== [] && array_is_list($templateData))) { + throw self::invalid( + 'invalid_template_data', + 'template_data must be an array with a value per variable name.', + 'template_data' + ); + } + self::refuseFieldsOutside($email, true); + + return [ + 'template_id' => strtolower($templateId), + 'message_data' => $templateData === [] ? (object) [] : $templateData, + ]; + } + + /** + * @return array + */ + private static function codeContent(array $email): array + { + $templateKey = $email['template_key']; + if (!is_string($templateKey) || preg_match(self::TEMPLATE_KEY_PATTERN, $templateKey) !== 1) { + throw self::invalid( + 'invalid_template_key', + 'template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400.', + 'template_key' + ); + } + self::refuseFieldsOutside($email, false); + $subject = $email['subject'] ?? null; + if (!is_string($subject) || $subject === '') { + throw self::invalid('invalid_form', 'A send with template_key needs a nonempty subject.', 'subject'); + } + $content = ['template_key' => $templateKey, 'subject' => $subject]; + foreach (['html', 'text'] as $field) { + if (!array_key_exists($field, $email)) { + continue; + } + if (!is_string($email[$field])) { + throw self::invalid('invalid_form', "$field must be a string.", $field); + } + $content[$field] = $email[$field]; + } + if (($content['html'] ?? '') === '' && ($content['text'] ?? '') === '') { + throw self::invalid( + 'invalid_form', + 'A send with template_key needs a nonempty html, text or both.', + array_key_exists('html', $content) || !array_key_exists('text', $content) ? 'html' : 'text' + ); + } + if (array_key_exists('from', $email)) { + if (!is_string($email['from']) || trim($email['from']) === '') { + throw self::invalid('invalid_form', 'from must be a nonempty string.', 'from'); + } + $content['from'] = $email['from']; + } + if (array_key_exists('reply_to', $email)) { + $content['reply_to'] = self::addresses($email['reply_to']); + } + $captureEngagement = $email['capture_engagement'] ?? true; + if (!is_bool($captureEngagement)) { + throw self::invalid('invalid_form', 'capture_engagement must be true or false.', 'capture_engagement'); + } + $content['capture_engagement'] = $captureEngagement; + + return $content; + } + + /** + * @return list + */ + private static function addresses(mixed $value): array + { + $addresses = is_string($value) ? [$value] : $value; + if (!is_array($addresses) || !array_is_list($addresses) || $addresses === []) { + throw self::invalid('invalid_form', 'reply_to must be one address or a list of addresses.', 'reply_to'); + } + foreach ($addresses as $address) { + if (!is_string($address) || trim($address) === '') { + throw self::invalid('invalid_form', 'reply_to must not hold a blank address.', 'reply_to'); + } + } + + return $addresses; + } + + private static function refuseFieldsOutside(array $email, bool $isTemplateForm): void + { + $formFields = array_merge(self::SHARED_FIELDS, $isTemplateForm ? self::TEMPLATE_FIELDS : self::CODE_FIELDS); + $otherFields = $isTemplateForm ? self::CODE_FIELDS : self::TEMPLATE_FIELDS; + foreach (array_keys($email) as $field) { + if (in_array($field, $formFields, true)) { + continue; + } + $field = (string) $field; + $formName = $isTemplateForm ? 'template_id' : 'template_key'; + $message = "A send with $formName doesn't take $field, so no email was sent."; + if (!in_array($field, $otherFields, true)) { + throw new EmailError('invalid_form', $message, field: $field, fix: "Remove $field and send again."); + } + throw new EmailError( + 'invalid_form', + $message, + why: $isTemplateForm ? self::TEMPLATE_FORM_WHY : self::CODE_FORM_WHY, + fix: "Remove $field, or " . ($isTemplateForm + ? 'send content from your code with template_key.' + : 'send a template made in PostHog with template_id.'), + field: $field + ); + } + } + + private static function invalid(string $code, string $message, string $field): EmailError + { + return new EmailError($code, $message, field: $field); + } + + /** + * @return array{string, string} The app host and the secret key. + */ + private function connect(string $notDone, string $keyNeeded): array + { + if (($this->isDisabled)()) { + throw new EmailError( + 'client_disabled', + "The PostHog client is disabled because it has no project API key, so $notDone." + ); + } + if (($this->isClosed)()) { + throw new EmailError('client_closed', "The PostHog client is shut down, so $notDone."); + } + if ($this->appHost === null) { + // A custom host is often an ingestion proxy, which must not receive the secret key. + throw new EmailError( + 'missing_app_host', + "The client's host is not PostHog Cloud, so the client can't tell where to send email. " + . 'Set the app_host option to your PostHog app host.' + ); + } + if ($this->secretKey === null) { + throw new EmailError('missing_secret_key', $keyNeeded); + } + + return [$this->appHost, $this->secretKey]; + } + + /** + * @param list $extra + * @return list + */ + private function headers(string $secretKey, array $extra): array + { + $headers = [ + 'Authorization: Bearer ' . $secretKey, + 'Accept: application/json', + 'User-Agent: ' . $this->userAgent, + ...$extra, + ]; + if ($this->migratedFrom !== null) { + $headers[] = 'X-PostHog-Migrated-From: ' . $this->migratedFrom; + } + + return $headers; + } + + /** + * @template T + * @param Closure(): T $attemptOnce + * @return T + */ + private function withRetries(int $maxRetries, Closure $attemptOnce): mixed + { + for ($attempt = 0;; $attempt++) { + try { + return $attemptOnce(); + } catch (EmailError $error) { + $delayMs = self::retryDelayMs($error, $attempt, $maxRetries); + if ($delayMs === null) { + throw $error; + } + Clock::get()->sleep($delayMs / 1000); + } + } + } + + private static function isRetryable(EmailError $error): bool + { + // A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing. + $status = $error->getStatusCode(); + if ($status === null) { + return $error->getErrorCode() === 'network_error'; + } + if ($status === 409) { + return $error->getErrorCode() === 'idempotency_key_in_flight'; + } + if ($status === 429) { + // Waiting doesn't fix a monthly quota. + return $error->getErrorCode() !== 'quota_limited'; + } + + return $status === 503; + } + + private static function retryDelayMs(EmailError $error, int $attempt, int $maxRetries): ?int + { + if ($attempt >= $maxRetries || !self::isRetryable($error)) { + return null; + } + $retryAfterMs = $error->getRetryAfterMs(); + // Retrying before a longer Retry-After would break the server's rate limit. + if ($retryAfterMs !== null && $retryAfterMs > self::MAX_RETRY_DELAY_MS) { + return null; + } + $backoffMs = (int) min(self::MAX_RETRY_DELAY_MS, 1000 * (2 ** $attempt) * random_int(800, 1200) / 1000); + + return max($backoffMs, $retryAfterMs ?? 0); + } + + /** + * @param list $headers + * @return array{int, string, list} + */ + private function request(string $method, string $url, array $headers, ?string $body): array + { + return ($this->transport)($method, $url, $headers, $body, $this->requestTimeoutMs); + } + + /** + * @param list $headers + * @return array{int, string, list} + */ + private static function curlTransport( + string $method, + string $url, + array $headers, + ?string $body, + int $timeoutMs + ): array { + $responseHeaders = []; + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => $method, + // An empty Expect stops cURL from waiting for 100-continue before it sends a large body. + CURLOPT_HTTPHEADER => [...$headers, 'Expect:'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_TIMEOUT_MS => $timeoutMs, + CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs, + CURLOPT_NOSIGNAL => true, + CURLOPT_HEADERFUNCTION => static function ($curl, string $header) use (&$responseHeaders): int { + $responseHeaders[] = trim($header); + return strlen($header); + }, + ]); + if ($body !== null) { + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + $responseBody = curl_exec($curl); + $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + if (!is_string($responseBody) || curl_errno($curl) !== 0 || $status === 0) { + throw new EmailError('network_error', 'The request to PostHog failed: ' . curl_error($curl)); + } + + return [$status, $responseBody, $responseHeaders]; + } + + /** + * @param list $headers + */ + private static function parseSendReply(int $status, string $body, array $headers): SendEmailResult + { + $fields = self::replyFields($body); + if ($status < 200 || $status >= 300) { + throw self::replyError($fields, $status, $headers); + } + $id = self::text($fields['message_id'] ?? null); + if ($id === null) { + throw new EmailError('api_error', 'PostHog answered without a message ID.', $status); + } + $warnings = []; + foreach (is_array($fields['warnings'] ?? null) ? $fields['warnings'] : [] as $warning) { + if (is_array($warning)) { + $warnings[] = new EmailWarning( + self::text($warning['code'] ?? null) ?? 'warning', + self::text($warning['detail'] ?? null) ?? self::text($warning['message'] ?? null) ?? '', + self::text($warning['fix'] ?? null) + ); + } + } + + return new SendEmailResult($id, ($fields['replayed'] ?? false) === true, $warnings); + } + + /** + * @param list $headers + */ + private static function parseStatusReply(int $status, string $body, array $headers): EmailStatus + { + $fields = self::replyFields($body); + if ($status < 200 || $status >= 300) { + throw self::replyError($fields, $status, $headers); + } + $id = self::text($fields['id'] ?? null); + $deliveryStatus = self::text($fields['status'] ?? null); + $createdAt = self::timestamp($fields['created_at'] ?? null); + $statusAt = self::timestamp($fields['status_at'] ?? null); + if ($id === null || $deliveryStatus === null || $createdAt === null || $statusAt === null) { + throw new EmailError('api_error', 'PostHog answered without an email status.', $status); + } + + return new EmailStatus($id, $deliveryStatus, $createdAt, $statusAt, self::text($fields['reason'] ?? null)); + } + + /** + * @param array $fields + * @param list $headers + */ + private static function replyError(array $fields, int $status, array $headers): EmailError + { + return new EmailError( + self::text($fields['code'] ?? null) ?? 'api_error', + self::text($fields['detail'] ?? null) ?? "PostHog answered HTTP $status.", + $status, + why: self::text($fields['why'] ?? null), + fix: self::text($fields['fix'] ?? null), + docsUrl: self::text($fields['docs_url'] ?? null), + field: self::text($fields['attr'] ?? null), + retryAfterMs: self::retryAfterMs($headers) + ); + } + + /** + * @return array + */ + private static function replyFields(string $body): array + { + $payload = json_decode($body, true); + return is_array($payload) && !array_is_list($payload) ? $payload : []; + } + + private static function text(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + private static function timestamp(mixed $value): ?DateTimeImmutable + { + $text = self::text($value); + if ($text === null || preg_match(self::TIMESTAMP_PATTERN, $text) !== 1) { + return null; + } + try { + return new DateTimeImmutable($text); + } catch (\Exception) { + return null; + } + } + + /** + * @param list $headers + */ + private static function retryAfterMs(array $headers): ?int + { + $value = null; + foreach ($headers as $header) { + if (stripos($header, 'retry-after:') === 0) { + $value = trim(substr($header, strlen('retry-after:'))); + } + } + if ($value === null || $value === '') { + return null; + } + if (is_numeric($value)) { + $seconds = (float) $value; + return is_finite($seconds) ? (int) min(max(0.0, $seconds) * 1000, PHP_INT_MAX) : null; + } + $date = DateTimeImmutable::createFromFormat(DATE_RFC7231, $value); + if ($date === false) { + return null; + } + $nowMs = (int) Clock::get()->now()->format('Uv'); + + return max(0, $date->getTimestamp() * 1000 - $nowMs); + } + + /** + * The app host for a send, or null when the client can't tell it. PostHog Cloud ingestion hosts don't + * serve the app API, so they map to their region's app host. + */ + private static function resolveAppHost(string $host, ?string $appHost): ?string + { + $appHost = StringNormalizer::normalizeOptional($appHost); + if ($appHost !== null) { + $appHost = rtrim($appHost, '/'); + $bare = preg_replace('#^https?://#i', '', $appHost); + if (isset(self::CLOUD_APP_HOSTS[strtolower($bare)])) { + return self::CLOUD_APP_HOSTS[strtolower($bare)]; + } + return $bare === $appHost ? 'https://' . $appHost : $appHost; + } + $bare = strtolower(rtrim(preg_replace('#^https?://#i', '', trim($host)), '/')); + + return self::CLOUD_APP_HOSTS[$bare] ?? null; + } +} diff --git a/lib/PostHog.php b/lib/PostHog.php index c6b0b77..4ab0272 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -39,8 +39,15 @@ class PostHog * `disableGeoip` defaults to false for compatibility with existing releases. It applies to events * and remote flag evaluation. Per-call options override this setting; false allows GeoIP enrichment. * + * Transactional email (see Client::emails()) uses `app_host`, `email_request_timeout_ms` (default 15000), + * `email_request_max_retries` (default 2) and `migrated_from`. + * * @param array{ * host?: string, + * app_host?: string, + * email_request_timeout_ms?: int, + * email_request_max_retries?: int, + * migrated_from?: string, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, @@ -609,6 +616,18 @@ public static function shutdown(): bool return self::$client->shutdown(); } + /** + * Transactional email on the default client. See Client::emails(). + * + * @return Emails + */ + public static function emails(): Emails + { + self::checkClient(); + + return self::$client->emails(); + } + /** * Get the underlying client instance. * Useful for accessing client-level functionality like loadFlags() or getFlagsEtag(). diff --git a/lib/SendEmailResult.php b/lib/SendEmailResult.php new file mode 100644 index 0000000..0c0ba34 --- /dev/null +++ b/lib/SendEmailResult.php @@ -0,0 +1,49 @@ + */ + private array $warnings; + + /** + * @internal Created by Emails::send(). + * @param list $warnings + */ + public function __construct(string $id, bool $replayed = false, array $warnings = []) + { + $this->id = $id; + $this->replayed = $replayed; + $this->warnings = $warnings; + } + + /** + * The queued email's message ID. Pass it to Emails::get() to read the email's status. + */ + public function getId(): string + { + return $this->id; + } + + /** + * True when PostHog had already accepted this idempotency key and returned the first send's result. + */ + public function isReplayed(): bool + { + return $this->replayed; + } + + /** + * @return list + */ + public function getWarnings(): array + { + return $this->warnings; + } +} diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php new file mode 100644 index 0000000..ff5bb20 --- /dev/null +++ b/test/EmailsClientTest.php @@ -0,0 +1,190 @@ +server?->stop(); + } + + private function client(array $options = [], ?string $apiKey = 'phc_project'): Client + { + return new Client( + $apiKey, + $options + ['consumer' => 'noop', 'app_host' => 'http://' . $this->server->address()], + loadFeatureFlags: false, + secretKey: ' phs_secret ' + ); + } + + public function testACodeSendReachesTheSendRouteOverHttp(): void + { + $this->server = new LocalHttpServer([ + ['status' => 202, 'body' => '{"message_id":"01a18356-c56c-0001-e494-7666bc915188","replayed":false}'], + ]); + $html = '

' . str_repeat('Hello ', 400) . '

'; + + $result = $this->client(['migrated_from' => 'resend'])->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'welcome', + 'subject' => 'Welcome', + 'html' => $html, + 'distinct_id' => 'user_123', + 'idempotency_key' => 'welcome:user_123', + ]); + + $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $result->getId()); + $requests = $this->server->requests(); + $this->assertCount(1, $requests); + $this->assertSame('POST /api/projects/@current/messaging/send/email/ HTTP/1.1', $requests[0]['requestLine']); + $headers = $requests[0]['headers']; + $this->assertSame('Bearer phs_secret', $headers['authorization']); + $this->assertSame('application/json', $headers['content-type']); + $this->assertSame('application/json', $headers['accept']); + $this->assertSame('welcome:user_123', $headers['idempotency-key']); + $this->assertSame('resend', $headers['x-posthog-migrated-from']); + $this->assertSame('posthog-php/' . PostHog::VERSION, $headers['user-agent']); + $this->assertArrayNotHasKey('expect', $headers); + $this->assertSame([ + 'template_key' => 'welcome', + 'subject' => 'Welcome', + 'html' => $html, + 'capture_engagement' => true, + 'to' => 'user@example.com', + 'distinct_id' => 'user_123', + ], json_decode($requests[0]['body'], true)); + } + + public function testARetryAfterHeaderComesBackOnTheError(): void + { + $this->server = new LocalHttpServer([ + [ + 'status' => 429, + 'headers' => ['Retry-After' => '45', 'Content-Type' => 'application/json'], + 'body' => '{"code":"throttled","detail":"Slow down."}', + ], + ]); + + try { + $this->client()->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + $this->fail('send() did not throw'); + } catch (EmailError $error) { + $this->assertSame('throttled', $error->getErrorCode()); + $this->assertSame('rate_limit_exceeded', $error->getErrorName()); + $this->assertSame(429, $error->getStatusCode()); + $this->assertSame(45000, $error->getRetryAfterMs()); + } + $this->assertCount(1, $this->server->requests()); + } + + public function testAClosedPortIsANetworkError(): void + { + $this->server = new LocalHttpServer(); + $client = $this->client(['email_request_max_retries' => 0, 'email_request_timeout_ms' => 2000]); + $this->server->stop(); + + try { + $client->emails()->get('01a18356-c56c-0001-e494-7666bc915188'); + $this->fail('get() did not throw'); + } catch (EmailError $error) { + $this->assertSame('network_error', $error->getErrorCode()); + $this->assertSame('service_unavailable', $error->getErrorName()); + $this->assertNull($error->getStatusCode()); + $this->assertStringNotContainsString('phs_secret', $error->getMessage()); + } + } + + public function testTheClientWithoutAProjectKeyIsDisabledForEmail(): void + { + $this->server = new LocalHttpServer(); + + try { + $this->client([], null)->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + $this->fail('send() did not throw'); + } catch (EmailError $error) { + $this->assertSame('client_disabled', $error->getErrorCode()); + } + $this->assertSame([], $this->server->requests()); + } + + public function testAShutDownClientSendsNothing(): void + { + $this->server = new LocalHttpServer(); + $client = $this->client(); + $emails = $client->emails(); + $client->shutdown(); + + try { + $emails->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + $this->fail('send() did not throw'); + } catch (EmailError $error) { + $this->assertSame('client_closed', $error->getErrorCode()); + } + $this->assertSame([], $this->server->requests()); + } + + public function testACustomHostWithoutAnAppHostKeepsTheSecretKey(): void + { + $this->server = new LocalHttpServer(); + $client = new Client( + 'phc_project', + ['consumer' => 'noop', 'host' => $this->server->address(), 'ssl' => false], + loadFeatureFlags: false, + secretKey: 'phs_secret' + ); + + try { + $client->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + $this->fail('send() did not throw'); + } catch (EmailError $error) { + $this->assertSame('missing_app_host', $error->getErrorCode()); + } + $this->assertSame([], $this->server->requests()); + } + + public function testTheEmailsServiceKeepsItsClientWithoutACycle(): void + { + $this->server = new LocalHttpServer([['status' => 202, 'body' => '{"message_id":"msg_1"}']]); + $client = $this->client(); + $reference = \WeakReference::create($client); + $emails = $client->emails(); + unset($client); + + $result = $emails->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + $this->assertSame('msg_1', $result->getId()); + unset($emails); + + $this->assertNull($reference->get(), 'dropping the service freed the client, so its destructor ran'); + } + + public function testTheFacadeUsesTheDefaultClient(): void + { + $this->server = new LocalHttpServer([['status' => 202, 'body' => '{"message_id":"msg_1"}']]); + PostHog::init(null, null, $this->client()); + + $result = PostHog::emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame('msg_1', $result->getId()); + $this->assertCount(1, $this->server->requests()); + } +} diff --git a/test/EmailsTest.php b/test/EmailsTest.php new file mode 100644 index 0000000..608a976 --- /dev/null +++ b/test/EmailsTest.php @@ -0,0 +1,741 @@ +, body: ?string, timeoutMs: int}> */ + private array $requests = []; + /** @var list}|EmailError> */ + private array $replies = []; + private ClockInterface $previousClock; + private MockClock $clock; + + protected function setUp(): void + { + require_once __DIR__ . '/error_log_mock.php'; + global $errorMessages; + $errorMessages = []; + $this->previousClock = Clock::get(); + $this->clock = new MockClock('2026-11-02 12:00:00'); + Clock::set($this->clock); + } + + protected function tearDown(): void + { + Clock::set($this->previousClock); + } + + private function emails(array $config = []): Emails + { + return new Emails( + $config['secretKey'] ?? 'phs_secret', + $config['host'] ?? 'us.i.posthog.com', + appHost: $config['appHost'] ?? null, + maxRetries: $config['maxRetries'] ?? Emails::DEFAULT_MAX_RETRIES, + migratedFrom: $config['migratedFrom'] ?? null, + userAgent: 'posthog-php/9.9.9', + isDisabled: $config['isDisabled'] ?? null, + isClosed: $config['isClosed'] ?? null, + transport: Closure::fromCallable([$this, 'transport']) + ); + } + + /** + * @param list $headers + * @return array{int, string, list} + */ + public function transport(string $method, string $url, array $headers, ?string $body, int $timeoutMs): array + { + $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs'); + $reply = array_shift($this->replies) ?? [202, '{"message_id":"msg_1"}', []]; + if ($reply instanceof EmailError) { + throw $reply; + } + + return $reply; + } + + /** @param array{int, string, list}|EmailError ...$replies */ + private function reply(...$replies): void + { + $this->replies = $replies; + } + + private function body(int $index = 0): array + { + return json_decode($this->requests[$index]['body'], true, 512, JSON_THROW_ON_ERROR); + } + + private function header(string $name, int $index = 0): ?string + { + foreach ($this->requests[$index]['headers'] as $header) { + if (stripos($header, $name . ':') === 0) { + return trim(substr($header, strlen($name) + 1)); + } + } + + return null; + } + + private function sendError(array $email, ?Emails $emails = null): EmailError + { + try { + ($emails ?? $this->emails())->send($email); + } catch (EmailError $error) { + return $error; + } + $this->fail('send() did not throw EmailError'); + } + + private function getError(string $messageId, ?Emails $emails = null): EmailError + { + try { + ($emails ?? $this->emails())->get($messageId); + } catch (EmailError $error) { + return $error; + } + $this->fail('get() did not throw EmailError'); + } + + public function testTemplateSendPostsTheFlatBody(): void + { + $result = $this->emails()->send([ + 'to' => 'user@example.com', + 'template_id' => strtoupper(self::TEMPLATE_ID), + 'template_data' => ['name' => 'Ada'], + 'distinct_id' => 'user_123', + 'idempotency_key' => 'welcome:user_123', + ]); + + $this->assertSame('msg_1', $result->getId()); + $this->assertFalse($result->isReplayed()); + $this->assertSame([], $result->getWarnings()); + $this->assertCount(1, $this->requests); + $this->assertSame('POST', $this->requests[0]['method']); + $this->assertSame(self::SEND_URL, $this->requests[0]['url']); + $this->assertSame( + '{"template_id":"' . self::TEMPLATE_ID . '","message_data":{"name":"Ada"},' + . '"to":"user@example.com","distinct_id":"user_123"}', + $this->requests[0]['body'] + ); + $this->assertSame('Bearer phs_secret', $this->header('Authorization')); + $this->assertSame('application/json', $this->header('Content-Type')); + $this->assertSame('application/json', $this->header('Accept')); + $this->assertSame('posthog-php/9.9.9', $this->header('User-Agent')); + $this->assertSame('welcome:user_123', $this->header('Idempotency-Key')); + $this->assertNull($this->header('X-PostHog-Migrated-From')); + $this->assertSame(15000, $this->requests[0]['timeoutMs']); + } + + public function testTemplateSendWithoutDataSendsAnEmptyObject(): void + { + $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame( + '{"template_id":"' . self::TEMPLATE_ID . '","message_data":{},"to":"user@example.com"}', + $this->requests[0]['body'] + ); + } + + public function testCodeSendPostsRenderedContent(): void + { + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'password-reset', + 'subject' => 'Reset your password', + 'html' => '

Reset it here

', + 'text' => 'Reset it here: https://example.com/r', + 'from' => 'Acme ', + 'reply_to' => 'help@example.com', + 'distinct_id' => 'user_123', + ]); + + $this->assertSame([ + 'template_key' => 'password-reset', + 'subject' => 'Reset your password', + 'html' => '

Reset it here

', + 'text' => 'Reset it here: https://example.com/r', + 'from' => 'Acme ', + 'reply_to' => ['help@example.com'], + 'capture_engagement' => true, + 'to' => 'user@example.com', + 'distinct_id' => 'user_123', + ], $this->body()); + } + + public function testCodeSendKeepsAReplyToListAndCaptureEngagementOff(): void + { + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'reply_to' => ['a@example.com', 'b@example.com'], + 'capture_engagement' => false, + ]); + + $this->assertSame([ + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'reply_to' => ['a@example.com', 'b@example.com'], + 'capture_engagement' => false, + 'to' => 'user@example.com', + ], $this->body()); + } + + public function testNullOptionsCountAsLeftOut(): void + { + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'html' => '

Thanks!

', + 'text' => null, + 'from' => null, + 'reply_to' => null, + 'capture_engagement' => null, + 'distinct_id' => null, + 'idempotency_key' => null, + 'template_id' => null, + ]); + + $this->assertSame([ + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'html' => '

Thanks!

', + 'capture_engagement' => true, + 'to' => 'user@example.com', + ], $this->body()); + } + + public function testAGeneratedIdempotencyKeyIsAUuidV4ReusedOnEveryAttempt(): void + { + $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); + + $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertCount(2, $this->requests); + $key = $this->header('Idempotency-Key'); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $key + ); + $this->assertSame($key, $this->header('Idempotency-Key', 1)); + $this->assertSame($this->requests[0]['body'], $this->requests[1]['body']); + } + + public function testMigratedFromIsSentOnEveryRequest(): void + { + $emails = $this->emails(['migratedFrom' => 'resend']); + $this->reply([202, '{"message_id":"msg_1"}', []], [404, '{"code":"not_found"}', []]); + + $emails->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + $this->getError(self::MESSAGE_ID, $emails); + + $this->assertSame('resend', $this->header('X-PostHog-Migrated-From')); + $this->assertSame('resend', $this->header('X-PostHog-Migrated-From', 1)); + } + + public function testAReplayAndWarningsComeBackAndWarningsAreLogged(): void + { + global $errorMessages; + $this->reply([202, json_encode([ + 'message_id' => 'msg_1', + 'replayed' => true, + 'warnings' => [ + ['code' => 'unverified_link', 'detail' => 'A link points to an unknown domain.', 'fix' => 'Check it.'], + 'not a warning', + ], + ]), []]); + + $result = $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertTrue($result->isReplayed()); + $this->assertCount(1, $result->getWarnings()); + $warning = $result->getWarnings()[0]; + $this->assertSame('unverified_link', $warning->getCode()); + $this->assertSame('A link points to an unknown domain.', $warning->getMessage()); + $this->assertSame('Check it.', $warning->getFix()); + $this->assertSame( + ['[PostHog][Emails] Email warning unverified_link: A link points to an unknown domain.'], + $errorMessages + ); + } + + public static function argumentErrors(): array + { + $template = ['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]; + $code = ['to' => 'user@example.com', 'template_key' => 'welcome', 'subject' => 'Hi', 'html' => '

Hi

']; + + return [ + 'no form' => [['to' => 'user@example.com'], 'invalid_form', null], + 'both forms' => [$template + ['template_key' => 'welcome'], 'invalid_form', null], + 'no recipient' => [['template_id' => self::TEMPLATE_ID], 'invalid_recipient', 'to'], + 'blank recipient' => [['to' => ' '] + $template, 'invalid_recipient', 'to'], + 'list of recipients' => [['to' => ['a@example.com']] + $template, 'invalid_recipient', 'to'], + 'empty distinct ID' => [$template + ['distinct_id' => ''], 'invalid_distinct_id', 'distinct_id'], + 'integer distinct ID' => [$template + ['distinct_id' => 42], 'invalid_distinct_id', 'distinct_id'], + 'template ID not a UUID' => [ + ['template_id' => 'welcome'] + $template, + 'invalid_template_id', + 'template_id', + ], + 'UUID with a trailing newline' => [ + ['template_id' => self::TEMPLATE_ID . "\n"] + $template, + 'invalid_template_id', + 'template_id', + ], + 'template data as a list' => [ + $template + ['template_data' => ['Ada']], + 'invalid_template_data', + 'template_data', + ], + 'template data as a string' => [ + $template + ['template_data' => 'Ada'], + 'invalid_template_data', + 'template_data', + ], + 'subject with a template' => [$template + ['subject' => 'Hi'], 'invalid_form', 'subject'], + 'capture_engagement with a template' => [ + $template + ['capture_engagement' => false], + 'invalid_form', + 'capture_engagement', + ], + 'unknown option' => [$template + ['cc' => 'boss@example.com'], 'invalid_form', 'cc'], + 'template_data with code' => [$code + ['template_data' => ['a' => 1]], 'invalid_form', 'template_data'], + 'bad template key' => [['template_key' => 'pass word'] + $code, 'invalid_template_key', 'template_key'], + 'template key too long' => [ + ['template_key' => str_repeat('a', 401)] + $code, + 'invalid_template_key', + 'template_key', + ], + 'no subject' => [array_diff_key($code, ['subject' => 0]), 'invalid_form', 'subject'], + 'empty subject' => [['subject' => ''] + $code, 'invalid_form', 'subject'], + 'no html or text' => [array_diff_key($code, ['html' => 0]), 'invalid_form', 'html'], + 'empty html and text' => [['html' => ''] + $code + ['text' => ''], 'invalid_form', 'html'], + 'html not a string' => [['html' => ['

']] + $code, 'invalid_form', 'html'], + 'empty from' => [$code + ['from' => ''], 'invalid_form', 'from'], + 'blank reply_to' => [$code + ['reply_to' => ['a@example.com', ' ']], 'invalid_form', 'reply_to'], + 'empty reply_to list' => [$code + ['reply_to' => []], 'invalid_form', 'reply_to'], + 'capture_engagement not a bool' => [ + $code + ['capture_engagement' => 'no'], + 'invalid_form', + 'capture_engagement', + ], + 'idempotency key with a space' => [ + $template + ['idempotency_key' => 'welcome user'], + 'invalid_idempotency_key', + 'idempotency_key', + ], + 'idempotency key too long' => [ + $template + ['idempotency_key' => str_repeat('k', 256)], + 'invalid_idempotency_key', + 'idempotency_key', + ], + 'negative max_retries' => [$template + ['max_retries' => -1], 'invalid_max_retries', 'max_retries'], + 'max_retries as a string' => [$template + ['max_retries' => '1'], 'invalid_max_retries', 'max_retries'], + 'html with invalid UTF-8' => [['html' => "\xB1\x31"] + $code, 'invalid_form', null], + ]; + } + + #[DataProvider('argumentErrors')] + public function testAFailedArgumentCheckSendsNothing(array $email, string $code, ?string $field): void + { + $error = $this->sendError($email); + + $this->assertSame($code, $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame($field, $error->getField()); + $this->assertNull($error->getStatusCode()); + $this->assertSame([], $this->requests); + } + + public function testAnOptionOfTheOtherFormSaysHowToSendIt(): void + { + $error = $this->sendError([ + 'to' => 'user@example.com', + 'template_id' => self::TEMPLATE_ID, + 'html' => '

Hi

', + ]); + + $this->assertSame("A send with template_id doesn't take html, so no email was sent.", $error->getMessage()); + $this->assertSame('Remove html, or send content from your code with template_key.', $error->getFix()); + $this->assertNotNull($error->getWhy()); + } + + public static function setupErrors(): array + { + return [ + 'disabled client' => [['isDisabled' => static fn(): bool => true], 'client_disabled', 'application_error'], + 'shut down client' => [['isClosed' => static fn(): bool => true], 'client_closed', 'application_error'], + 'no secret key' => [['secretKey' => ' '], 'missing_secret_key', 'missing_api_key'], + 'custom host' => [['host' => 'https://proxy.example.com'], 'missing_app_host', 'application_error'], + ]; + } + + #[DataProvider('setupErrors')] + public function testASetupErrorSendsAndReadsNothing(array $config, string $code, string $name): void + { + $emails = $this->emails($config); + + $sendError = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID], $emails); + $getError = $this->getError(self::MESSAGE_ID, $emails); + + $this->assertSame($code, $sendError->getErrorCode()); + $this->assertSame($name, $sendError->getErrorName()); + $this->assertSame($code, $getError->getErrorCode()); + $this->assertSame([], $this->requests); + } + + public function testTheSecretKeyNeverReachesAnErrorMessage(): void + { + $this->reply([401, '{"code":"authentication_failed","detail":"Invalid key."}', []]); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame('missing_api_key', $error->getErrorName()); + $this->assertStringNotContainsString('phs_secret', $error->getMessage()); + } + + public static function hosts(): array + { + return [ + 'US ingestion host' => ['us.i.posthog.com', null, 'https://us.posthog.com'], + 'EU ingestion host with scheme' => ['https://eu.i.posthog.com/', null, 'https://eu.posthog.com'], + 'EU app host' => ['eu.posthog.com', null, 'https://eu.posthog.com'], + 'legacy app host' => ['https://app.posthog.com', null, 'https://us.posthog.com'], + 'app host wins over a custom host' => [ + 'proxy.example.com', + 'https://posthog.example.com/', + 'https://posthog.example.com', + ], + 'app host without a scheme' => ['proxy.example.com', 'posthog.example.com', 'https://posthog.example.com'], + 'http app host' => ['proxy.example.com', 'http://localhost:8010', 'http://localhost:8010'], + 'cloud ingestion host as app host' => [ + 'proxy.example.com', + 'https://eu.i.posthog.com', + 'https://eu.posthog.com', + ], + ]; + } + + #[DataProvider('hosts')] + public function testRequestsGoToTheAppHost(string $host, ?string $appHost, string $expected): void + { + $this->emails(['host' => $host, 'appHost' => $appHost]) + ->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame($expected . '/api/projects/@current/messaging/send/email/', $this->requests[0]['url']); + } + + public static function responseErrors(): array + { + return [ + '400' => [400, 'invalid_recipient', 'validation_error'], + '401' => [401, 'authentication_failed', 'missing_api_key'], + '403' => [403, 'permission_denied', 'invalid_permission'], + '404' => [404, 'not_found', 'not_found'], + '409 template outdated' => [409, 'template_outdated', 'validation_error'], + '409 in flight' => [409, 'idempotency_key_in_flight', 'concurrent_idempotent_requests'], + '422 suppressed' => [422, 'recipient_suppressed', 'validation_error'], + '422 key reused' => [422, 'idempotency_key_reused', 'invalid_idempotent_request'], + '429 throttled' => [429, 'throttled', 'rate_limit_exceeded'], + '429 quota' => [429, 'quota_limited', 'monthly_quota_exceeded'], + '503' => [503, 'unavailable', 'service_unavailable'], + '500' => [500, 'server_error', 'application_error'], + ]; + } + + #[DataProvider('responseErrors')] + public function testAnErrorKeepsPostHogsCodeAndFields(int $status, string $code, string $name): void + { + $body = json_encode([ + 'type' => 'validation_error', + 'code' => $code, + 'detail' => 'Nope.', + 'attr' => 'to', + 'why' => 'Because.', + 'fix' => 'Do this.', + 'docs_url' => 'https://posthog.com/docs/x', + ]); + $this->reply(...array_fill(0, 3, [$status, $body, ['HTTP/1.1 ' . $status . ' X', 'Retry-After: 2']])); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID, 'max_retries' => 0]); + + $this->assertSame($code, $error->getErrorCode()); + $this->assertSame($name, $error->getErrorName()); + $this->assertSame('Nope.', $error->getMessage()); + $this->assertSame($status, $error->getStatusCode()); + $this->assertSame('to', $error->getField()); + $this->assertSame('Because.', $error->getWhy()); + $this->assertSame('Do this.', $error->getFix()); + $this->assertSame('https://posthog.com/docs/x', $error->getDocsUrl()); + $this->assertSame(2000, $error->getRetryAfterMs()); + } + + public function testAnAnswerWithoutACodeTakesTheNameOfItsStatus(): void + { + $this->reply(...array_fill(0, 3, [503, 'Service Unavailable', []])); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame('api_error', $error->getErrorCode()); + $this->assertSame('service_unavailable', $error->getErrorName()); + $this->assertSame('PostHog answered HTTP 503.', $error->getMessage()); + $this->assertCount(3, $this->requests, 'a 503 without a code is retried'); + } + + public function testAnAcceptanceWithoutAMessageIdIsAnErrorAndNotRetried(): void + { + $this->reply([202, '{}', []]); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame('api_error', $error->getErrorCode()); + $this->assertSame(202, $error->getStatusCode()); + $this->assertCount(1, $this->requests); + } + + public static function retriedFailures(): array + { + return [ + 'network error' => [new EmailError('network_error', 'The request to PostHog failed.')], + '409 in flight' => [[409, '{"code":"idempotency_key_in_flight"}', []]], + '429 throttled' => [[429, '{"code":"throttled"}', []]], + '503' => [[503, '{"code":"unavailable"}', []]], + ]; + } + + /** @param array{int, string, list}|EmailError $failure */ + #[DataProvider('retriedFailures')] + public function testATransientFailureIsRetriedTwiceByDefault($failure): void + { + $this->reply($failure, $failure, $failure); + $start = $this->clock->now(); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertCount(3, $this->requests); + $waitedMs = (int) $this->clock->now()->format('Uv') - (int) $start->format('Uv'); + $this->assertGreaterThanOrEqual(800 + 1600, $waitedMs); + $this->assertLessThanOrEqual(1200 + 2400, $waitedMs); + $this->assertSame( + $failure instanceof EmailError ? 'network_error' : json_decode($failure[1], true)['code'], + $error->getErrorCode() + ); + } + + public static function failuresNotRetried(): array + { + return [ + '400' => [400, 'invalid_recipient'], + '404' => [404, 'not_found'], + '409 other' => [409, 'template_outdated'], + '422' => [422, 'recipient_suppressed'], + '429 quota' => [429, 'quota_limited'], + '500' => [500, 'server_error'], + ]; + } + + #[DataProvider('failuresNotRetried')] + public function testAFailureThatWaitingDoesNotFixIsNotRetried(int $status, string $code): void + { + $this->reply([$status, json_encode(['code' => $code]), []]); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame($code, $error->getErrorCode()); + $this->assertCount(1, $this->requests); + } + + public function testASendCanTurnRetriesOff(): void + { + $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID, 'max_retries' => 0]); + + $this->assertSame('unavailable', $error->getErrorCode()); + $this->assertCount(1, $this->requests); + } + + public function testTheClientRetryLimitApplies(): void + { + $this->reply(...array_fill(0, 5, [503, '{"code":"unavailable"}', []])); + + $emails = $this->emails(['maxRetries' => 4]); + + $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID], $emails); + + $this->assertCount(5, $this->requests); + } + + public function testALongerRetryAfterIsWaitedFor(): void + { + $this->reply([429, '{"code":"throttled"}', ['Retry-After: 7']], [202, '{"message_id":"msg_1"}', []]); + $start = (int) $this->clock->now()->format('Uv'); + + $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame(7000, (int) $this->clock->now()->format('Uv') - $start); + $this->assertCount(2, $this->requests); + } + + public function testARetryAfterAsAnHttpDateIsWaitedFor(): void + { + $at = $this->clock->now()->modify('+10 seconds')->format(DATE_RFC7231); + $this->reply([503, '{"code":"unavailable"}', ['retry-after: ' . $at]], [202, '{"message_id":"msg_1"}', []]); + $start = (int) $this->clock->now()->format('Uv'); + + $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame(10000, (int) $this->clock->now()->format('Uv') - $start); + } + + public function testARetryAfterOverThirtySecondsFailsAtOnce(): void + { + $this->reply([429, '{"code":"throttled"}', ['Retry-After: 60']]); + $start = $this->clock->now(); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame('throttled', $error->getErrorCode()); + $this->assertSame(60000, $error->getRetryAfterMs()); + $this->assertCount(1, $this->requests); + $this->assertEquals($start, $this->clock->now()); + } + + public function testGetReadsTheStatus(): void + { + $this->reply([200, json_encode([ + 'id' => self::MESSAGE_ID, + 'status' => 'bounced', + 'created_at' => '2026-10-28T16:27:44.364000Z', + 'status_at' => '2026-10-28T16:30:02.100000Z', + 'reason' => 'hard', + ]), []]); + + $status = $this->emails()->get(strtoupper(self::MESSAGE_ID)); + + $this->assertSame('GET', $this->requests[0]['method']); + $this->assertSame( + 'https://us.posthog.com/api/projects/@current/messaging/emails/' . self::MESSAGE_ID . '/', + $this->requests[0]['url'] + ); + $this->assertNull($this->requests[0]['body']); + $this->assertNull($this->header('Idempotency-Key')); + $this->assertNull($this->header('Content-Type')); + $this->assertSame('Bearer phs_secret', $this->header('Authorization')); + $this->assertSame(self::MESSAGE_ID, $status->getId()); + $this->assertSame('bounced', $status->getStatus()); + $this->assertSame('hard', $status->getReason()); + $this->assertSame('2026-10-28T16:27:44.364+00:00', $status->getCreatedAt()->format('Y-m-d\TH:i:s.vP')); + $this->assertSame('2026-10-28T16:30:02.100+00:00', $status->getStatusAt()->format('Y-m-d\TH:i:s.vP')); + } + + public function testGetPassesAnUnknownStatusThrough(): void + { + $this->reply([200, json_encode([ + 'id' => self::MESSAGE_ID, + 'status' => 'archived', + 'created_at' => '2026-10-28T16:27:44Z', + 'status_at' => '2026-10-28T16:27:44+00:00', + ]), []]); + + $status = $this->emails()->get(self::MESSAGE_ID); + + $this->assertSame('archived', $status->getStatus()); + $this->assertNull($status->getReason()); + } + + public static function incompleteStatuses(): array + { + $record = [ + 'id' => self::MESSAGE_ID, + 'status' => 'sent', + 'created_at' => '2026-10-28T16:27:44Z', + 'status_at' => '2026-10-28T16:27:44Z', + ]; + + return [ + 'no id' => [array_diff_key($record, ['id' => 0])], + 'no status' => [array_diff_key($record, ['status' => 0])], + 'no created_at' => [array_diff_key($record, ['created_at' => 0])], + 'status_at not a time' => [['status_at' => 'tomorrow'] + $record], + ]; + } + + #[DataProvider('incompleteStatuses')] + public function testGetRefusesAnIncompleteStatus(array $record): void + { + $this->reply([200, json_encode($record), []]); + + $error = $this->getError(self::MESSAGE_ID); + + $this->assertSame('api_error', $error->getErrorCode()); + $this->assertSame(200, $error->getStatusCode()); + } + + public function testGetDoesNotRetryAnUnknownId(): void + { + $this->reply([404, '{"code":"not_found","detail":"No email with this ID."}', []]); + + $error = $this->getError(self::MESSAGE_ID); + + $this->assertSame('not_found', $error->getErrorCode()); + $this->assertCount(1, $this->requests); + } + + public function testGetRetriesAnUnavailableService(): void + { + $this->reply([503, '{"code":"unavailable"}', []], [200, json_encode([ + 'id' => self::MESSAGE_ID, + 'status' => 'sent', + 'created_at' => '2026-10-28T16:27:44Z', + 'status_at' => '2026-10-28T16:27:44Z', + ]), []]); + + $this->assertSame('sent', $this->emails()->get(self::MESSAGE_ID)->getStatus()); + $this->assertCount(2, $this->requests); + } + + public function testGetRefusesAnIdThatIsNotAMessageId(): void + { + $error = $this->getError('msg_1'); + + $this->assertSame('invalid_message_id', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame([], $this->requests); + } + + public function testBadClientOptionsAreRefused(): void + { + foreach ( + [ + ['maxRetries' => -1], + ['migratedFrom' => "resend\r\nX-Evil: 1"], + ] as $config + ) { + try { + $this->emails($config); + $this->fail('Expected InvalidArgumentException for ' . json_encode($config)); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } +} From 45341fa8426f381f7c709b3ad3d65fc506d1b4e6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:59:27 +0000 Subject: [PATCH 02/25] fix: resolve the email User-Agent default inside the constructor A constant expression default put posthog-php/ into the public API snapshot, so the release's version bump would fail composer api:check. Co-Authored-By: Claude Opus 5.5 --- api/public-api.json | 4 ++-- lib/Emails.php | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/api/public-api.json b/api/public-api.json index b1e2ef5..1927e0c 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -2109,11 +2109,11 @@ }, { "name": "userAgent", - "type": "string", + "type": "?string", "byReference": false, "variadic": false, "optional": true, - "default": "posthog-php/4.20.0", + "default": null, "defaultConstant": null, "hasDefault": true }, diff --git a/lib/Emails.php b/lib/Emails.php index 80afa66..c673bd9 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -76,6 +76,7 @@ final class Emails * @param string|null $secretKey A project secret API key. * @param string $host The client's host. Used only when it is PostHog Cloud. * @param string|null $appHost The PostHog app host, for a host that is not PostHog Cloud. + * @param string|null $userAgent The User-Agent. Defaults to `posthog-php/`. * @param Closure|null $transport `fn(string $method, string $url, list $headers, ?string $body, * int $timeoutMs): array{int, string, list}` returning the status, body and header lines. * It throws EmailError with the code `network_error` when no answer arrived. @@ -87,7 +88,7 @@ public function __construct( int $requestTimeoutMs = self::DEFAULT_REQUEST_TIMEOUT_MS, int $maxRetries = self::DEFAULT_MAX_RETRIES, ?string $migratedFrom = null, - string $userAgent = PostHog::LIBRARY . '/' . PostHog::VERSION, + ?string $userAgent = null, ?Closure $isDisabled = null, ?Closure $isClosed = null, ?Closure $transport = null @@ -106,7 +107,7 @@ public function __construct( $this->requestTimeoutMs = $requestTimeoutMs; $this->maxRetries = $maxRetries; $this->migratedFrom = $migratedFrom; - $this->userAgent = $userAgent; + $this->userAgent = $userAgent ?? PostHog::LIBRARY . '/' . PostHog::VERSION; $this->isDisabled = $isDisabled ?? static fn(): bool => false; $this->isClosed = $isClosed ?? static fn(): bool => false; $this->transport = $transport ?? Closure::fromCallable([self::class, 'curlTransport']); From 6a732b95714b88f934bfbcafc8fb6ba1a36e99f1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:00:56 +0000 Subject: [PATCH 03/25] docs: say a status read answers not_found right after a send Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 17587e0..1673bce 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ $status = PostHog::emails()->get($result->getId()); // queued, sent, delivered, - **Two forms.** Pass `template_id` with optional `template_data`, or `template_key` with `subject` and `html`, `text` or both. The content form also takes `from` (a verified sender), `reply_to` and `capture_engagement` (default `true`). PostHog derives a missing `text` from the `html`. To send an email built with a template library such as React Email, render it to HTML in your app and pass `html`. The SDK adds no rendering dependency. - **Pass `distinct_id`.** Without it, the email's engagement events go to a person whose distinct ID is the email address. -- **A result means queued**, not delivered. `send()` returns a `SendEmailResult` with the message ID. `get()` reads the status for 30 days after the send. +- **A result means queued**, not delivered. `send()` returns a `SendEmailResult` with the message ID. `get()` reads the status for 30 days after the send. For a few seconds after the send, it throws `not_found`. - **Errors.** A send that is not queued throws `PostHog\EmailError`. Branch on `getErrorCode()`, such as `recipient_suppressed` or `missing_app_host`, or on `getErrorName()`, such as `validation_error`. An invalid argument throws before any request, with an `invalid_*` code. - **Retries.** Network errors, rate limits and an unavailable service are retried up to `email_request_max_retries` times (default 2) with the same idempotency key, so a retry never sends twice. Each request times out after `email_request_timeout_ms` (default 15000). When a job runner already retries the send, pass `'max_retries' => 0` and an `idempotency_key` derived from what the email is for, such as `welcome:user_123`. A generated key does not survive a job retry. - **Moving from another provider.** Set the `migrated_from` option, such as `'resend'`, so PostHog knows where your sends came from. From 181ed05abd5be87d86590a71fd67f6482dab9c7f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:17:27 +0000 Subject: [PATCH 04/25] docs: say flag loading still sends the secret key to host Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1673bce..0163f27 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Existing span attributes take precedence. The processor preserves the applicatio `emails()` sends one email to one recipient, from an email template made transactional in PostHog or from content your code rendered, and reads what happened to it. -The client needs a project secret API key (`phs_...`) as `secretKey`. Sending needs the `hog_flow:write` scope, and reading a status needs `hog_flow:read`. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host, because the proxy must not receive the secret key. +The client needs a project secret API key (`phs_...`) as `secretKey`. Sending needs the `hog_flow:write` scope, and reading a status needs `hog_flow:read`. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host: email requests never go to a custom `host`. Loading flag definitions still sends the secret key to `host`, so when your proxy must not see the key, also pass `loadFeatureFlags: false` and don't call `loadFlags()`. ```php PostHog::init('phc_...', ['host' => 'https://eu.i.posthog.com'], secretKey: 'phs_...'); From d5e5a2c04fa76a5f74b7b16b19f565b39eeec098 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:18:59 +0000 Subject: [PATCH 05/25] docs: show the proxy setup that keeps the secret key off host Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0163f27..c03b135 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,15 @@ Existing span attributes take precedence. The processor preserves the applicatio `emails()` sends one email to one recipient, from an email template made transactional in PostHog or from content your code rendered, and reads what happened to it. -The client needs a project secret API key (`phs_...`) as `secretKey`. Sending needs the `hog_flow:write` scope, and reading a status needs `hog_flow:read`. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host: email requests never go to a custom `host`. Loading flag definitions still sends the secret key to `host`, so when your proxy must not see the key, also pass `loadFeatureFlags: false` and don't call `loadFlags()`. +The client needs a project secret API key (`phs_...`) as `secretKey`. Sending needs the `hog_flow:write` scope, and reading a status needs `hog_flow:read`. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host: email requests never go to a custom `host`. Loading flag definitions still sends the secret key to `host`, so when your proxy must not see the key, build the client with `loadFeatureFlags: false` and don't call `loadFlags()`: + +```php +$client = new PostHog\Client('phc_...', [ + 'host' => 'ph.example.com', // your proxy + 'app_host' => 'https://eu.posthog.com', +], loadFeatureFlags: false, secretKey: 'phs_...'); +PostHog::init(client: $client); +``` ```php PostHog::init('phc_...', ['host' => 'https://eu.i.posthog.com'], secretKey: 'phs_...'); From b870c10047332b8c4f2e1a1b1e2393a2ef295c3d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:19:50 +0000 Subject: [PATCH 06/25] fix: send no retry after the client shut down during the wait Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 5 +++++ test/EmailsTest.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/Emails.php b/lib/Emails.php index c673bd9..2de7fc3 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -468,6 +468,11 @@ private function withRetries(int $maxRetries, Closure $attemptOnce): mixed throw $error; } Clock::get()->sleep($delayMs / 1000); + // A signal handler can shut the client down during the wait. A closed client sends nothing, + // so the operation settles with the failure it already has. + if (($this->isClosed)()) { + throw $error; + } } } } diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 608a976..9862771 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -584,6 +584,24 @@ public function testTheClientRetryLimitApplies(): void $this->assertCount(5, $this->requests); } + public function testAClientShutDownDuringTheRetryWaitSendsNothingMore(): void + { + $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); + // Closed once the first attempt is made, as when a signal handler shuts the client down during the wait. + $emails = $this->emails(['isClosed' => fn(): bool => $this->requests !== []]); + + $sendError = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID], $emails); + $sendRequests = count($this->requests); + $this->reply([503, '{"code":"unavailable"}', []], [200, '{}', []]); + $this->requests = []; + $getError = $this->getError(self::TEMPLATE_ID, $emails); + + $this->assertSame('unavailable', $sendError->getErrorCode()); + $this->assertSame(1, $sendRequests); + $this->assertSame('unavailable', $getError->getErrorCode()); + $this->assertCount(1, $this->requests); + } + public function testALongerRetryAfterIsWaitedFor(): void { $this->reply([429, '{"code":"throttled"}', ['Retry-After: 7']], [202, '{"message_id":"msg_1"}', []]); From 15126f74e89b274b790d1ec82a8cabeea1b613de Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:21:43 +0000 Subject: [PATCH 07/25] docs: qualify the Client class in the proxy example Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c03b135..9bccd2d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Existing span attributes take precedence. The processor preserves the applicatio The client needs a project secret API key (`phs_...`) as `secretKey`. Sending needs the `hog_flow:write` scope, and reading a status needs `hog_flow:read`. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host: email requests never go to a custom `host`. Loading flag definitions still sends the secret key to `host`, so when your proxy must not see the key, build the client with `loadFeatureFlags: false` and don't call `loadFlags()`: ```php -$client = new PostHog\Client('phc_...', [ +$client = new \PostHog\Client('phc_...', [ 'host' => 'ph.example.com', // your proxy 'app_host' => 'https://eu.posthog.com', ], loadFeatureFlags: false, secretKey: 'phs_...'); From c9895800b7cf49bb13a8df4c52d481609f6ca6f0 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:37:23 +0000 Subject: [PATCH 08/25] fix: wait out the full retry delay when a signal cuts the sleep short Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 32 ++++++++++++++++++++++------ test/EmailsTest.php | 39 ++++++++++++++++++++++++++------- test/InterruptedClock.php | 45 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 test/InterruptedClock.php diff --git a/lib/Emails.php b/lib/Emails.php index 2de7fc3..6de4f48 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use JsonException; use Symfony\Component\Clock\Clock; +use Symfony\Component\Clock\ClockInterface; /** * Sends transactional email, from an email template made in PostHog or from content your code rendered, @@ -467,16 +468,35 @@ private function withRetries(int $maxRetries, Closure $attemptOnce): mixed if ($delayMs === null) { throw $error; } - Clock::get()->sleep($delayMs / 1000); - // A signal handler can shut the client down during the wait. A closed client sends nothing, - // so the operation settles with the failure it already has. - if (($this->isClosed)()) { - throw $error; - } + $this->waitToRetry($delayMs, $error); } } } + /** + * A handled signal cuts a sleep short, so this sleeps again until the retry is due. A signal handler can also + * shut the client down during the wait; a closed client sends nothing, so the operation settles with the + * failure it already has. + */ + private function waitToRetry(int $delayMs, EmailError $error): void + { + $clock = Clock::get(); + $retryAtUs = self::microseconds($clock) + $delayMs * 1000; + while (($remainingUs = $retryAtUs - self::microseconds($clock)) > 0) { + $clock->sleep($remainingUs / 1_000_000); + if (($this->isClosed)()) { + throw $error; + } + } + } + + private static function microseconds(ClockInterface $clock): int + { + $now = $clock->now(); + + return (int) $now->format('U') * 1_000_000 + (int) $now->format('u'); + } + private static function isRetryable(EmailError $error): bool { // A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing. diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 9862771..80c4f09 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -17,7 +17,7 @@ class EmailsTest extends TestCase private const MESSAGE_ID = '01a18356-c56c-0001-e494-7666bc915188'; private const SEND_URL = 'https://us.posthog.com/api/projects/@current/messaging/send/email/'; - /** @var list, body: ?string, timeoutMs: int}> */ + /** @var list, body: ?string, timeoutMs: int, atMs: int}> */ private array $requests = []; /** @var list}|EmailError> */ private array $replies = []; @@ -60,7 +60,8 @@ private function emails(array $config = []): Emails */ public function transport(string $method, string $url, array $headers, ?string $body, int $timeoutMs): array { - $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs'); + $atMs = (int) Clock::get()->now()->format('Uv'); + $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'atMs'); $reply = array_shift($this->replies) ?? [202, '{"message_id":"msg_1"}', []]; if ($reply instanceof EmailError) { throw $reply; @@ -584,17 +585,26 @@ public function testTheClientRetryLimitApplies(): void $this->assertCount(5, $this->requests); } - public function testAClientShutDownDuringTheRetryWaitSendsNothingMore(): void + public function testAClientShutDownByASignalDuringTheRetryWaitSendsNothingMore(): void { - $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); - // Closed once the first attempt is made, as when a signal handler shuts the client down during the wait. - $emails = $this->emails(['isClosed' => fn(): bool => $this->requests !== []]); + $closed = false; + $emails = $this->emails(['isClosed' => function () use (&$closed): bool { + return $closed; + }]); + $shutDownBySignal = function () use (&$closed): void { + $closed = true; + }; + Clock::set(new InterruptedClock($this->clock, 0.5, $shutDownBySignal)); + $this->reply([503, '{"code":"unavailable"}', ['Retry-After: 2']], [202, '{"message_id":"msg_1"}', []]); $sendError = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID], $emails); $sendRequests = count($this->requests); - $this->reply([503, '{"code":"unavailable"}', []], [200, '{}', []]); + + $closed = false; + Clock::set(new InterruptedClock($this->clock, 0.5, $shutDownBySignal)); + $this->reply([503, '{"code":"unavailable"}', ['Retry-After: 2']], [200, '{}', []]); $this->requests = []; - $getError = $this->getError(self::TEMPLATE_ID, $emails); + $getError = $this->getError(self::MESSAGE_ID, $emails); $this->assertSame('unavailable', $sendError->getErrorCode()); $this->assertSame(1, $sendRequests); @@ -602,6 +612,19 @@ public function testAClientShutDownDuringTheRetryWaitSendsNothingMore(): void $this->assertCount(1, $this->requests); } + public function testASignalDoesNotCutTheRetryWaitShort(): void + { + Clock::set(new InterruptedClock($this->clock, 1, static function (): void { + })); + $this->reply([429, '{"code":"throttled"}', ['Retry-After: 3']], [202, '{"message_id":"msg_1"}', []]); + + $result = $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame('msg_1', $result->getId()); + $this->assertCount(2, $this->requests); + $this->assertSame(3000, $this->requests[1]['atMs'] - $this->requests[0]['atMs']); + } + public function testALongerRetryAfterIsWaitedFor(): void { $this->reply([429, '{"code":"throttled"}', ['Retry-After: 7']], [202, '{"message_id":"msg_1"}', []]); diff --git a/test/InterruptedClock.php b/test/InterruptedClock.php new file mode 100644 index 0000000..6e5d6c9 --- /dev/null +++ b/test/InterruptedClock.php @@ -0,0 +1,45 @@ +clock->now(); + } + + public function sleep(float|int $seconds): void + { + if ($this->interrupted || $seconds <= $this->interruptAfterSeconds) { + $this->clock->sleep($seconds); + return; + } + $this->interrupted = true; + $this->clock->sleep($this->interruptAfterSeconds); + ($this->handler)(); + } + + public function withTimeZone(DateTimeZone|string $timezone): static + { + return new self($this->clock->withTimeZone($timezone), $this->interruptAfterSeconds, $this->handler); + } +} From 6dbca5a9108c3058b6a276fa839e63e4577e69d0 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:41:02 +0000 Subject: [PATCH 09/25] fix: keep the retry deadline on a monotonic clock Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/Emails.php b/lib/Emails.php index 6de4f48..4509eb7 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -8,6 +8,8 @@ use JsonException; use Symfony\Component\Clock\Clock; use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Clock\MonotonicClock; +use Symfony\Component\Clock\NativeClock; /** * Sends transactional email, from an email template made in PostHog or from content your code rendered, @@ -481,6 +483,10 @@ private function withRetries(int $maxRetries, Closure $attemptOnce): mixed private function waitToRetry(int $delayMs, EmailError $error): void { $clock = Clock::get(); + if ($clock instanceof NativeClock) { + // The wall clock can jump during the wait, so the deadline is kept on a clock that only moves forward. + $clock = new MonotonicClock(); + } $retryAtUs = self::microseconds($clock) + $delayMs * 1000; while (($remainingUs = $retryAtUs - self::microseconds($clock)) > 0) { $clock->sleep($remainingUs / 1_000_000); From 03090d820a9f7c059d9606a2352c7ac674873829 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:52:11 +0000 Subject: [PATCH 10/25] test: the retry wait follows the monotonic timer when the wall clock is stepped Co-Authored-By: Claude Opus 5.5 --- test/EmailsMonotonicWaitTest.php | 36 +++++++++++++++++++++++++++++++ test/FakeTimer.php | 37 ++++++++++++++++++++++++++++++++ test/symfony_clock_time_mock.php | 25 +++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 test/EmailsMonotonicWaitTest.php create mode 100644 test/FakeTimer.php create mode 100644 test/symfony_clock_time_mock.php diff --git a/test/EmailsMonotonicWaitTest.php b/test/EmailsMonotonicWaitTest.php new file mode 100644 index 0000000..d005acf --- /dev/null +++ b/test/EmailsMonotonicWaitTest.php @@ -0,0 +1,36 @@ +send([ + 'to' => 'user@example.com', + 'template_id' => '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b', + ]); + + $this->assertSame('msg_1', $result->getId()); + $this->assertSame([0, 3_000_000], $sentAtUs); + } +} diff --git a/test/FakeTimer.php b/test/FakeTimer.php new file mode 100644 index 0000000..1010313 --- /dev/null +++ b/test/FakeTimer.php @@ -0,0 +1,37 @@ + self::MAX_SLEEPS) { + throw new RuntimeException('The retry wait never ended.'); + } + if (self::$sleeps === 1 && self::$interruptFirstSleepAfterUs !== null) { + $us = min($us, self::$interruptFirstSleepAfterUs); + } + self::$nowUs += $us; + } +} diff --git a/test/symfony_clock_time_mock.php b/test/symfony_clock_time_mock.php new file mode 100644 index 0000000..2684b90 --- /dev/null +++ b/test/symfony_clock_time_mock.php @@ -0,0 +1,25 @@ + Date: Mon, 2 Nov 2026 19:02:01 +0000 Subject: [PATCH 11/25] fix: throw an EmailError when the curl extension is missing 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 --- README.md | 1 + lib/Emails.php | 7 ++++++ test/EmailsWithoutCurlTest.php | 39 ++++++++++++++++++++++++++++++++++ test/no_curl_mock.php | 11 ++++++++++ 4 files changed, 58 insertions(+) create mode 100644 test/EmailsWithoutCurlTest.php create mode 100644 test/no_curl_mock.php diff --git a/README.md b/README.md index 9bccd2d..77ae82e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ PostHog::emails()->send([ 'distinct_id' => 'user_123', ]); +// Later, such as in a job: for a few seconds after the send, get() throws not_found $status = PostHog::emails()->get($result->getId()); // queued, sent, delivered, bounced, ... ``` diff --git a/lib/Emails.php b/lib/Emails.php index 4509eb7..5de765f 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -556,6 +556,13 @@ private static function curlTransport( ?string $body, int $timeoutMs ): array { + if (!function_exists('curl_init')) { + throw new EmailError( + 'missing_curl', + 'Sending email and reading its status need the PHP curl extension, which is not loaded.', + fix: 'Install or enable ext-curl.' + ); + } $responseHeaders = []; $curl = curl_init(); curl_setopt_array($curl, [ diff --git a/test/EmailsWithoutCurlTest.php b/test/EmailsWithoutCurlTest.php new file mode 100644 index 0000000..1374292 --- /dev/null +++ b/test/EmailsWithoutCurlTest.php @@ -0,0 +1,39 @@ + 'ph.example.com', 'app_host' => 'http://127.0.0.1:9', 'consumer' => 'noop']; + $emails = (new Client('phc_key', $options, loadFeatureFlags: false, secretKey: 'phs_secret'))->emails(); + $errors = []; + + $calls = [ + fn() => $emails->send(['to' => 'user@example.com', 'template_id' => '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b']), + fn() => $emails->get('01a18356-c56c-0001-e494-7666bc915188'), + ]; + + foreach ($calls as $call) { + try { + $call(); + $this->fail('No EmailError was thrown.'); + } catch (EmailError $error) { + $errors[] = [$error->getErrorCode(), $error->getErrorName(), $error->getFix()]; + } + } + + $expected = ['missing_curl', 'application_error', 'Install or enable ext-curl.']; + $this->assertSame([$expected, $expected], $errors); + } +} diff --git a/test/no_curl_mock.php b/test/no_curl_mock.php new file mode 100644 index 0000000..aace4f0 --- /dev/null +++ b/test/no_curl_mock.php @@ -0,0 +1,11 @@ + Date: Mon, 2 Nov 2026 19:02:13 +0000 Subject: [PATCH 12/25] test: keep the no-curl test within the line limit Co-Authored-By: Claude Opus 5.5 --- test/EmailsWithoutCurlTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/EmailsWithoutCurlTest.php b/test/EmailsWithoutCurlTest.php index 1374292..238dad8 100644 --- a/test/EmailsWithoutCurlTest.php +++ b/test/EmailsWithoutCurlTest.php @@ -19,8 +19,9 @@ public function testWithoutTheCurlExtensionSendAndGetThrowAnEmailError(): void $emails = (new Client('phc_key', $options, loadFeatureFlags: false, secretKey: 'phs_secret'))->emails(); $errors = []; + $email = ['to' => 'user@example.com', 'template_id' => '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b']; $calls = [ - fn() => $emails->send(['to' => 'user@example.com', 'template_id' => '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b']), + fn() => $emails->send($email), fn() => $emails->get('01a18356-c56c-0001-e494-7666bc915188'), ]; From d96fa5d711516b2ee3912b907a519884ee0587c7 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:51:50 +0000 Subject: [PATCH 13/25] fix: read an HTTP-date Retry-After in UTC Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 4 +++- test/EmailsTest.php | 24 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index 5de765f..55b2098 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -4,6 +4,7 @@ use Closure; use DateTimeImmutable; +use DateTimeZone; use InvalidArgumentException; use JsonException; use Symfony\Component\Clock\Clock; @@ -702,7 +703,8 @@ private static function retryAfterMs(array $headers): ?int $seconds = (float) $value; return is_finite($seconds) ? (int) min(max(0.0, $seconds) * 1000, PHP_INT_MAX) : null; } - $date = DateTimeImmutable::createFromFormat(DATE_RFC7231, $value); + // The format's GMT is literal text, so without a zone PHP would read the time in its default zone. + $date = DateTimeImmutable::createFromFormat(DATE_RFC7231, $value, new DateTimeZone('UTC')); if ($date === false) { return null; } diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 80c4f09..d56d7d0 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -636,15 +636,27 @@ public function testALongerRetryAfterIsWaitedFor(): void $this->assertCount(2, $this->requests); } - public function testARetryAfterAsAnHttpDateIsWaitedFor(): void + public static function defaultTimezones(): array { - $at = $this->clock->now()->modify('+10 seconds')->format(DATE_RFC7231); - $this->reply([503, '{"code":"unavailable"}', ['retry-after: ' . $at]], [202, '{"message_id":"msg_1"}', []]); - $start = (int) $this->clock->now()->format('Uv'); + return [['UTC'], ['Asia/Tokyo'], ['America/Los_Angeles']]; + } - $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + #[DataProvider('defaultTimezones')] + public function testARetryAfterAsAnHttpDateIsWaitedForInAnyDefaultTimezone(string $timezone): void + { + $previousTimezone = date_default_timezone_get(); + date_default_timezone_set($timezone); + try { + $at = gmdate(DATE_RFC7231, $this->clock->now()->getTimestamp() + 10); + $this->reply([503, '{"code":"unavailable"}', ['retry-after: ' . $at]], [202, '{"message_id":"msg_1"}', []]); + $start = (int) $this->clock->now()->format('Uv'); + + $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); - $this->assertSame(10000, (int) $this->clock->now()->format('Uv') - $start); + $this->assertSame(10000, (int) $this->clock->now()->format('Uv') - $start); + } finally { + date_default_timezone_set($previousTimezone); + } } public function testARetryAfterOverThirtySecondsFailsAtOnce(): void From 84e9c9a92b772dd1e43b4521ad7ba3c6753b5cb4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:02:19 +0000 Subject: [PATCH 14/25] fix: stop reading an email reply over 1 MiB Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 23 ++++++++++++++++++++--- test/EmailsClientTest.php | 27 +++++++++++++++++++++++++++ test/fixtures/http-server.php | 2 +- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index 55b2098..4f862ca 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -44,6 +44,7 @@ final class Emails 'eu.posthog.com' => self::EU_APP_HOST, ]; private const MAX_RETRY_DELAY_MS = 30000; + private const MAX_RESPONSE_BYTES = 1_048_576; private const UUID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD'; private const IDEMPOTENCY_KEY_PATTERN = '/^[\x21-\x7e]{1,255}$/D'; private const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,400}$/D'; @@ -565,13 +566,22 @@ private static function curlTransport( ); } $responseHeaders = []; + $responseBody = ''; + $tooLarge = false; $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => $url, CURLOPT_CUSTOMREQUEST => $method, // An empty Expect stops cURL from waiting for 100-continue before it sends a large body. CURLOPT_HTTPHEADER => [...$headers, 'Expect:'], - CURLOPT_RETURNTRANSFER => true, + CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$responseBody, &$tooLarge): int { + if (strlen($responseBody) + strlen($chunk) > self::MAX_RESPONSE_BYTES) { + $tooLarge = true; + return 0; + } + $responseBody .= $chunk; + return strlen($chunk); + }, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT_MS => $timeoutMs, CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs, @@ -584,9 +594,16 @@ private static function curlTransport( if ($body !== null) { curl_setopt($curl, CURLOPT_POSTFIELDS, $body); } - $responseBody = curl_exec($curl); + $succeeded = curl_exec($curl); $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); - if (!is_string($responseBody) || curl_errno($curl) !== 0 || $status === 0) { + if ($tooLarge) { + $limit = self::MAX_RESPONSE_BYTES; + throw new EmailError( + 'response_too_large', + "PostHog answered with more than $limit bytes, so the SDK stopped reading." + ); + } + if ($succeeded === false || curl_errno($curl) !== 0 || $status === 0) { throw new EmailError('network_error', 'The request to PostHog failed: ' . curl_error($curl)); } diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index ff5bb20..8f3b53d 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -97,6 +97,33 @@ public function testARetryAfterHeaderComesBackOnTheError(): void $this->assertCount(1, $this->server->requests()); } + public function testAnOversizedReplyIsNotReadOrRetried(): void + { + $this->server = new LocalHttpServer([ + ['status' => 503, 'body' => '{"code":"unavailable"}', 'pad_body_bytes' => 2_000_000], + ['status' => 200, 'body' => '{"id":"01a18356-c56c-0001-e494-7666bc915188"}', 'pad_body_bytes' => 2_000_000], + ]); + $emails = $this->client()->emails(); + $errors = []; + + $calls = [ + fn() => $emails->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]), + fn() => $emails->get('01a18356-c56c-0001-e494-7666bc915188'), + ]; + foreach ($calls as $call) { + try { + $call(); + $this->fail('No EmailError was thrown.'); + } catch (EmailError $error) { + $errors[] = [$error->getErrorCode(), $error->getErrorName(), $error->getStatusCode()]; + } + } + + $expected = ['response_too_large', 'application_error', null]; + $this->assertSame([$expected, $expected], $errors); + $this->assertCount(2, $this->server->requests()); + } + public function testAClosedPortIsANetworkError(): void { $this->server = new LocalHttpServer(); diff --git a/test/fixtures/http-server.php b/test/fixtures/http-server.php index 3e773a4..feae807 100644 --- a/test/fixtures/http-server.php +++ b/test/fixtures/http-server.php @@ -43,7 +43,7 @@ fclose($server); } $status = $response['status'] ?? 200; - $body = $response['body'] ?? '{"status":1}'; + $body = ($response['body'] ?? '{"status":1}') . str_repeat(' ', $response['pad_body_bytes'] ?? 0); $headers = ''; foreach ($response['headers'] ?? [] as $name => $value) { $headers .= "$name: $value\r\n"; From 6abff5e8ef408ce9d53a2b2080ca125e120e9578 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:09:33 +0000 Subject: [PATCH 15/25] fix: clamp a numeric Retry-After before converting it to milliseconds Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 4 +++- test/EmailsTest.php | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/Emails.php b/lib/Emails.php index 4f862ca..67b55ba 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -45,6 +45,7 @@ final class Emails ]; private const MAX_RETRY_DELAY_MS = 30000; private const MAX_RESPONSE_BYTES = 1_048_576; + private const MAX_RETRY_AFTER_SECONDS = 31_536_000.0; private const UUID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD'; private const IDEMPOTENCY_KEY_PATTERN = '/^[\x21-\x7e]{1,255}$/D'; private const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,400}$/D'; @@ -718,7 +719,8 @@ private static function retryAfterMs(array $headers): ?int } if (is_numeric($value)) { $seconds = (float) $value; - return is_finite($seconds) ? (int) min(max(0.0, $seconds) * 1000, PHP_INT_MAX) : null; + // Clamped in seconds first: a huge value times 1000 would overflow the int cast. + return is_finite($seconds) ? (int) (min(max(0.0, $seconds), self::MAX_RETRY_AFTER_SECONDS) * 1000) : null; } // The format's GMT is literal text, so without a zone PHP would read the time in its default zone. $date = DateTimeImmutable::createFromFormat(DATE_RFC7231, $value, new DateTimeZone('UTC')); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index d56d7d0..1aa34ed 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -672,6 +672,17 @@ public function testARetryAfterOverThirtySecondsFailsAtOnce(): void $this->assertEquals($start, $this->clock->now()); } + public function testAHugeRetryAfterFailsAtOnceWithoutOverflowing(): void + { + $this->reply([429, '{"code":"throttled"}', ['Retry-After: 9223372036854776']]); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame('throttled', $error->getErrorCode()); + $this->assertSame(31_536_000_000, $error->getRetryAfterMs()); + $this->assertCount(1, $this->requests); + } + public function testGetReadsTheStatus(): void { $this->reply([200, json_encode([ From 4676fe09b99d7643f9d207448b86fd44294c7c37 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:14:15 +0000 Subject: [PATCH 16/25] fix(emails): keep a huge Retry-After inside an int on 32-bit PHP Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 11 ++++++++--- test/EmailsTest.php | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index 67b55ba..e9aee35 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -45,7 +45,7 @@ final class Emails ]; private const MAX_RETRY_DELAY_MS = 30000; private const MAX_RESPONSE_BYTES = 1_048_576; - private const MAX_RETRY_AFTER_SECONDS = 31_536_000.0; + private const MAX_RETRY_AFTER_SECONDS = 31_536_000; private const UUID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD'; private const IDEMPOTENCY_KEY_PATTERN = '/^[\x21-\x7e]{1,255}$/D'; private const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,400}$/D'; @@ -719,8 +719,13 @@ private static function retryAfterMs(array $headers): ?int } if (is_numeric($value)) { $seconds = (float) $value; - // Clamped in seconds first: a huge value times 1000 would overflow the int cast. - return is_finite($seconds) ? (int) (min(max(0.0, $seconds), self::MAX_RETRY_AFTER_SECONDS) * 1000) : null; + if (!is_finite($seconds)) { + return null; + } + // Clamped in seconds first: a huge value times 1000 would overflow the int cast, on 32-bit PHP too. + $maxSeconds = min(self::MAX_RETRY_AFTER_SECONDS, intdiv(PHP_INT_MAX, 1000)); + + return (int) (min(max(0.0, $seconds), $maxSeconds) * 1000); } // The format's GMT is literal text, so without a zone PHP would read the time in its default zone. $date = DateTimeImmutable::createFromFormat(DATE_RFC7231, $value, new DateTimeZone('UTC')); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 1aa34ed..787450e 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -679,7 +679,8 @@ public function testAHugeRetryAfterFailsAtOnceWithoutOverflowing(): void $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); $this->assertSame('throttled', $error->getErrorCode()); - $this->assertSame(31_536_000_000, $error->getRetryAfterMs()); + // A year, or what fits in an int on 32-bit PHP. + $this->assertSame(min(31_536_000, intdiv(PHP_INT_MAX, 1000)) * 1000, $error->getRetryAfterMs()); $this->assertCount(1, $this->requests); } From 6c395aab962d4379d641aad53adfbba87f3293a6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:23:58 +0000 Subject: [PATCH 17/25] fix(emails): keep retry timing inside an int on 32-bit PHP 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 --- lib/Emails.php | 34 ++++++++++++++++++---------------- test/EmailsTest.php | 40 +++++++++++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index e9aee35..2cb696a 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -8,7 +8,6 @@ use InvalidArgumentException; use JsonException; use Symfony\Component\Clock\Clock; -use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\MonotonicClock; use Symfony\Component\Clock\NativeClock; @@ -490,8 +489,9 @@ private function waitToRetry(int $delayMs, EmailError $error): void // The wall clock can jump during the wait, so the deadline is kept on a clock that only moves forward. $clock = new MonotonicClock(); } - $retryAtUs = self::microseconds($clock) + $delayMs * 1000; - while (($remainingUs = $retryAtUs - self::microseconds($clock)) > 0) { + // Measured from the start of the wait: microseconds since the epoch don't fit in an int on 32-bit PHP. + $start = $clock->now(); + while (($remainingUs = $delayMs * 1000 - self::microsecondsBetween($start, $clock->now())) > 0) { $clock->sleep($remainingUs / 1_000_000); if (($this->isClosed)()) { throw $error; @@ -499,11 +499,12 @@ private function waitToRetry(int $delayMs, EmailError $error): void } } - private static function microseconds(ClockInterface $clock): int + private static function microsecondsBetween(DateTimeImmutable $start, DateTimeImmutable $end): int { - $now = $clock->now(); + // Read as floats: on 32-bit PHP an epoch after 2038 doesn't fit in an int. + $seconds = (int) ((float) $end->format('U') - (float) $start->format('U')); - return (int) $now->format('U') * 1_000_000 + (int) $now->format('u'); + return $seconds * 1_000_000 + (int) $end->format('u') - (int) $start->format('u'); } private static function isRetryable(EmailError $error): bool @@ -719,22 +720,23 @@ private static function retryAfterMs(array $headers): ?int } if (is_numeric($value)) { $seconds = (float) $value; - if (!is_finite($seconds)) { + } else { + // The format's GMT is literal text, so without a zone PHP would read the time in its default zone. + $date = DateTimeImmutable::createFromFormat(DATE_RFC7231, $value, new DateTimeZone('UTC')); + if ($date === false) { return null; } - // Clamped in seconds first: a huge value times 1000 would overflow the int cast, on 32-bit PHP too. - $maxSeconds = min(self::MAX_RETRY_AFTER_SECONDS, intdiv(PHP_INT_MAX, 1000)); - - return (int) (min(max(0.0, $seconds), $maxSeconds) * 1000); + // getTimestamp() throws on 32-bit PHP for a date after 2038, so the epoch is read as a float. + $seconds = (float) $date->format('U') - (float) Clock::get()->now()->format('U.u'); } - // The format's GMT is literal text, so without a zone PHP would read the time in its default zone. - $date = DateTimeImmutable::createFromFormat(DATE_RFC7231, $value, new DateTimeZone('UTC')); - if ($date === false) { + if (!is_finite($seconds)) { return null; } - $nowMs = (int) Clock::get()->now()->format('Uv'); + // Clamped in seconds first: milliseconds since the epoch, or a huge value times 1000, overflow an int, + // on 32-bit PHP soonest. + $maxSeconds = min(self::MAX_RETRY_AFTER_SECONDS, intdiv(PHP_INT_MAX, 1000)); - return max(0, $date->getTimestamp() * 1000 - $nowMs); + return (int) (min(max(0.0, $seconds), $maxSeconds) * 1000); } /** diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 787450e..a004847 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -3,6 +3,7 @@ namespace PostHog\Test; use Closure; +use DateTimeImmutable; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\EmailError; @@ -17,7 +18,7 @@ class EmailsTest extends TestCase private const MESSAGE_ID = '01a18356-c56c-0001-e494-7666bc915188'; private const SEND_URL = 'https://us.posthog.com/api/projects/@current/messaging/send/email/'; - /** @var list, body: ?string, timeoutMs: int, atMs: int}> */ + /** @var list, body: ?string, timeoutMs: int, at: DateTimeImmutable}> */ private array $requests = []; /** @var list}|EmailError> */ private array $replies = []; @@ -60,8 +61,8 @@ private function emails(array $config = []): Emails */ public function transport(string $method, string $url, array $headers, ?string $body, int $timeoutMs): array { - $atMs = (int) Clock::get()->now()->format('Uv'); - $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'atMs'); + $at = Clock::get()->now(); + $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'at'); $reply = array_shift($this->replies) ?? [202, '{"message_id":"msg_1"}', []]; if ($reply instanceof EmailError) { throw $reply; @@ -92,6 +93,14 @@ private function header(string $name, int $index = 0): ?string return null; } + /** + * Epoch milliseconds overflow an int on 32-bit PHP, so a wait is measured as a difference of floats. + */ + private static function msBetween(DateTimeImmutable $start, DateTimeImmutable $end): int + { + return (int) round(((float) $end->format('U.u') - (float) $start->format('U.u')) * 1000); + } + private function sendError(array $email, ?Emails $emails = null): EmailError { try { @@ -532,7 +541,7 @@ public function testATransientFailureIsRetriedTwiceByDefault($failure): void $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); $this->assertCount(3, $this->requests); - $waitedMs = (int) $this->clock->now()->format('Uv') - (int) $start->format('Uv'); + $waitedMs = self::msBetween($start, $this->clock->now()); $this->assertGreaterThanOrEqual(800 + 1600, $waitedMs); $this->assertLessThanOrEqual(1200 + 2400, $waitedMs); $this->assertSame( @@ -622,17 +631,17 @@ public function testASignalDoesNotCutTheRetryWaitShort(): void $this->assertSame('msg_1', $result->getId()); $this->assertCount(2, $this->requests); - $this->assertSame(3000, $this->requests[1]['atMs'] - $this->requests[0]['atMs']); + $this->assertSame(3000, self::msBetween($this->requests[0]['at'], $this->requests[1]['at'])); } public function testALongerRetryAfterIsWaitedFor(): void { $this->reply([429, '{"code":"throttled"}', ['Retry-After: 7']], [202, '{"message_id":"msg_1"}', []]); - $start = (int) $this->clock->now()->format('Uv'); + $start = $this->clock->now(); $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); - $this->assertSame(7000, (int) $this->clock->now()->format('Uv') - $start); + $this->assertSame(7000, self::msBetween($start, $this->clock->now())); $this->assertCount(2, $this->requests); } @@ -649,11 +658,11 @@ public function testARetryAfterAsAnHttpDateIsWaitedForInAnyDefaultTimezone(strin try { $at = gmdate(DATE_RFC7231, $this->clock->now()->getTimestamp() + 10); $this->reply([503, '{"code":"unavailable"}', ['retry-after: ' . $at]], [202, '{"message_id":"msg_1"}', []]); - $start = (int) $this->clock->now()->format('Uv'); + $start = $this->clock->now(); $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); - $this->assertSame(10000, (int) $this->clock->now()->format('Uv') - $start); + $this->assertSame(10000, self::msBetween($start, $this->clock->now())); } finally { date_default_timezone_set($previousTimezone); } @@ -672,9 +681,18 @@ public function testARetryAfterOverThirtySecondsFailsAtOnce(): void $this->assertEquals($start, $this->clock->now()); } - public function testAHugeRetryAfterFailsAtOnceWithoutOverflowing(): void + public static function hugeRetryAfters(): array + { + return [ + 'seconds' => ['9223372036854776'], + 'an HTTP date' => ['Fri, 31 Dec 9999 23:59:59 GMT'], + ]; + } + + #[DataProvider('hugeRetryAfters')] + public function testAHugeRetryAfterFailsAtOnceWithoutOverflowing(string $retryAfter): void { - $this->reply([429, '{"code":"throttled"}', ['Retry-After: 9223372036854776']]); + $this->reply([429, '{"code":"throttled"}', ["Retry-After: $retryAfter"]]); $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); From 00abb306222c104e501709753c0ad469d28e7c5a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:28:32 +0000 Subject: [PATCH 18/25] fix(emails): escape control characters in logged send warnings Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 4 +++- test/EmailsTest.php | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index 2cb696a..c2075ed 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -190,7 +190,9 @@ public function send(array $email): SendEmailResult return self::parseSendReply(...$this->request('POST', $appHost . self::SEND_PATH, $headers, $body)); }); foreach ($result->getWarnings() as $warning) { - error_log('[PostHog][Emails] Email warning ' . $warning->getCode() . ': ' . $warning->getMessage()); + // The fields come from the reply, so control characters are escaped to keep each warning on one log line. + $line = $warning->getCode() . ': ' . $warning->getMessage(); + error_log('[PostHog][Emails] Email warning ' . addcslashes($line, "\0..\37\177\\")); } return $result; diff --git a/test/EmailsTest.php b/test/EmailsTest.php index a004847..4840939 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -270,19 +270,23 @@ public function testAReplayAndWarningsComeBackAndWarningsAreLogged(): void 'warnings' => [ ['code' => 'unverified_link', 'detail' => 'A link points to an unknown domain.', 'fix' => 'Check it.'], 'not a warning', + ['code' => "odd\ncode", 'detail' => "Two\nlines\\"], ], ]), []]); $result = $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); $this->assertTrue($result->isReplayed()); - $this->assertCount(1, $result->getWarnings()); + $this->assertCount(2, $result->getWarnings()); $warning = $result->getWarnings()[0]; $this->assertSame('unverified_link', $warning->getCode()); $this->assertSame('A link points to an unknown domain.', $warning->getMessage()); $this->assertSame('Check it.', $warning->getFix()); $this->assertSame( - ['[PostHog][Emails] Email warning unverified_link: A link points to an unknown domain.'], + [ + '[PostHog][Emails] Email warning unverified_link: A link points to an unknown domain.', + '[PostHog][Emails] Email warning odd\\ncode: Two\\nlines\\\\', + ], $errorMessages ); } From c0cfc6b0ba3bdbade12a56bbe8357895901d0f8b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:38:36 +0000 Subject: [PATCH 19/25] fix(emails): skip the retry wait when the client closed during the request 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 --- lib/Emails.php | 15 +++++++++------ test/EmailsTest.php | 14 ++++++++++++++ test/FakeTimer.php | 9 ++++++--- test/symfony_clock_time_mock.php | 2 +- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index c2075ed..3951a22 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -481,8 +481,8 @@ private function withRetries(int $maxRetries, Closure $attemptOnce): mixed /** * A handled signal cuts a sleep short, so this sleeps again until the retry is due. A signal handler can also - * shut the client down during the wait; a closed client sends nothing, so the operation settles with the - * failure it already has. + * shut the client down, during the request or the wait; a closed client neither waits nor sends, so the + * operation settles with the failure it already has. */ private function waitToRetry(int $delayMs, EmailError $error): void { @@ -493,12 +493,15 @@ private function waitToRetry(int $delayMs, EmailError $error): void } // Measured from the start of the wait: microseconds since the epoch don't fit in an int on 32-bit PHP. $start = $clock->now(); - while (($remainingUs = $delayMs * 1000 - self::microsecondsBetween($start, $clock->now())) > 0) { - $clock->sleep($remainingUs / 1_000_000); - if (($this->isClosed)()) { - throw $error; + while (!($this->isClosed)()) { + $remainingUs = $delayMs * 1000 - self::microsecondsBetween($start, $clock->now()); + if ($remainingUs <= 0) { + return; } + $clock->sleep($remainingUs / 1_000_000); } + + throw $error; } private static function microsecondsBetween(DateTimeImmutable $start, DateTimeImmutable $end): int diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 4840939..65718ef 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -598,6 +598,20 @@ public function testTheClientRetryLimitApplies(): void $this->assertCount(5, $this->requests); } + public function testAClientShutDownDuringTheRequestDoesNotWaitToRetry(): void + { + // As if a signal handler called shutdown() while the first attempt was in flight. + $emails = $this->emails(['isClosed' => fn(): bool => $this->requests !== []]); + $this->reply([429, '{"code":"throttled"}', ['Retry-After: 30']], [202, '{"message_id":"msg_1"}', []]); + $start = $this->clock->now(); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID], $emails); + + $this->assertSame('throttled', $error->getErrorCode()); + $this->assertCount(1, $this->requests); + $this->assertSame(0, self::msBetween($start, $this->clock->now())); + } + public function testAClientShutDownByASignalDuringTheRetryWaitSendsNothingMore(): void { $closed = false; diff --git a/test/FakeTimer.php b/test/FakeTimer.php index 1010313..00883de 100644 --- a/test/FakeTimer.php +++ b/test/FakeTimer.php @@ -17,11 +17,14 @@ final class FakeTimer public static ?int $interruptFirstSleepAfterUs = null; private static int $sleeps = 0; - public static function hrtime(bool $asNumber): array|int + public static function hrtime(bool $asNumber): array|int|float { - $ns = self::$nowUs * 1000; + // Split from microseconds: nanoseconds pass PHP_INT_MAX after about 2 s on 32-bit PHP. + $seconds = intdiv(self::$nowUs, 1_000_000); + $ns = self::$nowUs % 1_000_000 * 1000; - return $asNumber ? $ns : [intdiv($ns, 1_000_000_000), $ns % 1_000_000_000]; + // Like the real hrtime(true), the number is a float on 32-bit PHP. + return $asNumber ? $seconds * 1_000_000_000 + $ns : [$seconds, $ns]; } public static function sleep(int $us): void diff --git a/test/symfony_clock_time_mock.php b/test/symfony_clock_time_mock.php index 2684b90..f52213f 100644 --- a/test/symfony_clock_time_mock.php +++ b/test/symfony_clock_time_mock.php @@ -7,7 +7,7 @@ use PostHog\Test\FakeTimer; -function hrtime(bool $asNumber = false): array|int +function hrtime(bool $asNumber = false): array|int|float { return FakeTimer::hrtime($asNumber); } From a123195c11326c4dc2e0bc6ccd73b0b541591df9 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:48:31 +0000 Subject: [PATCH 20/25] fix(emails): clamp a Retry-After too long for a float instead of dropping it Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 5 +---- test/EmailsTest.php | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index 3951a22..86e6246 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -734,11 +734,8 @@ private static function retryAfterMs(array $headers): ?int // getTimestamp() throws on 32-bit PHP for a date after 2038, so the epoch is read as a float. $seconds = (float) $date->format('U') - (float) Clock::get()->now()->format('U.u'); } - if (!is_finite($seconds)) { - return null; - } // Clamped in seconds first: milliseconds since the epoch, or a huge value times 1000, overflow an int, - // on 32-bit PHP soonest. + // on 32-bit PHP soonest. A value too long for a float reads as INF and clamps to the maximum too. $maxSeconds = min(self::MAX_RETRY_AFTER_SECONDS, intdiv(PHP_INT_MAX, 1000)); return (int) (min(max(0.0, $seconds), $maxSeconds) * 1000); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 65718ef..3451507 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -703,6 +703,7 @@ public static function hugeRetryAfters(): array { return [ 'seconds' => ['9223372036854776'], + 'seconds past the float range' => [str_repeat('9', 309)], 'an HTTP date' => ['Fri, 31 Dec 9999 23:59:59 GMT'], ]; } From 8efe31551d74205221f66697caa73d8a5efcd871 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:31:30 +0000 Subject: [PATCH 21/25] fix(emails): check email options when the client is built, and leave 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 --- lib/Client.php | 2 ++ lib/Emails.php | 9 +++++---- test/EmailsClientTest.php | 33 +++++++++++++++++++++++++++++++++ test/EmailsTest.php | 9 ++++----- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index 40c8fd7..efd4796 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -370,6 +370,8 @@ public function __construct( $this->flagsEtag = null; $this->flagDefinitionsLoaded = false; $this->minimalFlagCalledEvents = false; + // The email service checks its options when it is built, so a bad one fails here, not at the first send. + $this->emails(); if ($this->enabled) { ExceptionCapture::configure($this, $options['error_tracking'] ?? []); diff --git a/lib/Emails.php b/lib/Emails.php index 86e6246..1f2161a 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -104,6 +104,7 @@ public function __construct( if ($maxRetries < 0) { throw new InvalidArgumentException('email_request_max_retries must be a nonnegative integer.'); } + $migratedFrom = StringNormalizer::normalizeOptional($migratedFrom); if ($migratedFrom !== null && preg_match(self::HEADER_TOKEN_PATTERN, $migratedFrom) !== 1) { throw new InvalidArgumentException("migrated_from must be visible ASCII characters, such as 'resend'."); } @@ -129,8 +130,8 @@ public function __construct( * 400), `subject`, and `html`, `text` or both. PostHog derives a missing `text` from the `html`, and * sends the content as is without storing it. To send a React email, render it to HTML in your app * first. This form also takes `from` (a verified sender, such as `Acme `), - * `reply_to` (one address or a list) and `capture_engagement` (default true; a project whose admin - * turned engagement capture off captures nothing, whatever the send asks). + * `reply_to` (one address or a list; an empty list is left out) and `capture_engagement` (default + * true; a project whose admin turned engagement capture off captures nothing, whatever the send asks). * * Both forms take `to` (one address), `distinct_id` (the recipient's distinct ID in your product; * without it, the email's events go to a person whose distinct ID is the address), `idempotency_key` @@ -350,7 +351,7 @@ private static function codeContent(array $email): array } $content['from'] = $email['from']; } - if (array_key_exists('reply_to', $email)) { + if (array_key_exists('reply_to', $email) && $email['reply_to'] !== []) { $content['reply_to'] = self::addresses($email['reply_to']); } $captureEngagement = $email['capture_engagement'] ?? true; @@ -368,7 +369,7 @@ private static function codeContent(array $email): array private static function addresses(mixed $value): array { $addresses = is_string($value) ? [$value] : $value; - if (!is_array($addresses) || !array_is_list($addresses) || $addresses === []) { + if (!is_array($addresses) || !array_is_list($addresses)) { throw self::invalid('invalid_form', 'reply_to must be one address or a list of addresses.', 'reply_to'); } foreach ($addresses as $address) { diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index 8f3b53d..f933a14 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -2,6 +2,7 @@ namespace PostHog\Test; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\Client; use PostHog\EmailError; @@ -141,6 +142,38 @@ public function testAClosedPortIsANetworkError(): void } } + public static function badEmailOptions(): array + { + return [ + 'zero timeout' => [['email_request_timeout_ms' => 0]], + 'timeout not a number' => [['email_request_timeout_ms' => 'soon']], + 'negative retries' => [['email_request_max_retries' => -1]], + 'migrated_from with a space' => [['migrated_from' => 'Resend Inc']], + ]; + } + + #[DataProvider('badEmailOptions')] + public function testABadEmailOptionIsRefusedWhenTheClientIsBuilt(array $options): void + { + $this->expectException(\InvalidArgumentException::class); + + $options += ['consumer' => 'noop']; + + new Client('phc_project', $options, loadFeatureFlags: false, secretKey: 'phs_secret'); + } + + public function testAnEmptyMigratedFromCountsAsLeftOut(): void + { + $this->server = new LocalHttpServer([['status' => 202, 'body' => '{"message_id":"msg_1"}']]); + + $this->client(['migrated_from' => ''])->emails()->send([ + 'to' => 'user@example.com', + 'template_id' => self::TEMPLATE_ID, + ]); + + $this->assertArrayNotHasKey('x-posthog-migrated-from', $this->server->requests()[0]['headers']); + } + public function testTheClientWithoutAProjectKeyIsDisabledForEmail(): void { $this->server = new LocalHttpServer(); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 3451507..669aec4 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -208,7 +208,7 @@ public function testCodeSendKeepsAReplyToListAndCaptureEngagementOff(): void ], $this->body()); } - public function testNullOptionsCountAsLeftOut(): void + public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void { $this->emails()->send([ 'to' => 'user@example.com', @@ -217,7 +217,7 @@ public function testNullOptionsCountAsLeftOut(): void 'html' => '

Thanks!

', 'text' => null, 'from' => null, - 'reply_to' => null, + 'reply_to' => [], 'capture_engagement' => null, 'distinct_id' => null, 'idempotency_key' => null, @@ -345,7 +345,6 @@ public static function argumentErrors(): array 'html not a string' => [['html' => ['

']] + $code, 'invalid_form', 'html'], 'empty from' => [$code + ['from' => ''], 'invalid_form', 'from'], 'blank reply_to' => [$code + ['reply_to' => ['a@example.com', ' ']], 'invalid_form', 'reply_to'], - 'empty reply_to list' => [$code + ['reply_to' => []], 'invalid_form', 'reply_to'], 'capture_engagement not a bool' => [ $code + ['capture_engagement' => 'no'], 'invalid_form', @@ -716,8 +715,8 @@ public function testAHugeRetryAfterFailsAtOnceWithoutOverflowing(string $retryAf $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); $this->assertSame('throttled', $error->getErrorCode()); - // A year, or what fits in an int on 32-bit PHP. - $this->assertSame(min(31_536_000, intdiv(PHP_INT_MAX, 1000)) * 1000, $error->getRetryAfterMs()); + // A year, or on 32-bit PHP the most whole seconds of milliseconds an int holds. + $this->assertSame(PHP_INT_SIZE === 8 ? 31_536_000_000 : 2_147_483_000, $error->getRetryAfterMs()); $this->assertCount(1, $this->requests); } From b2a1c5c51e9bdb5c2bf682cb548114d2f12d9c8b Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:44:16 +0000 Subject: [PATCH 22/25] fix(emails): refuse a non-integer email option, and say the reasons through 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 --- lib/Client.php | 25 +++++++++++---- lib/EmailError.php | 1 - lib/Emails.php | 67 ++++++++++++++++++++++++--------------- test/EmailsClientTest.php | 25 +++++++++++++-- 4 files changed, 84 insertions(+), 34 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index efd4796..fe851db 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -370,8 +370,7 @@ public function __construct( $this->flagsEtag = null; $this->flagDefinitionsLoaded = false; $this->minimalFlagCalledEvents = false; - // The email service checks its options when it is built, so a bad one fails here, not at the first send. - $this->emails(); + $this->refuseBadEmailOptions(); if ($this->enabled) { ExceptionCapture::configure($this, $options['error_tracking'] ?? []); @@ -497,14 +496,12 @@ public function shutdown(): bool */ public function emails(): Emails { - // A new service per call: it refers to the client, and the client never refers to it, so dropping - // both still runs the client's destructor, which flushes queued events. return new Emails( $this->secretKey, $this->options['host'], appHost: $this->options['app_host'] ?? null, - requestTimeoutMs: (int) ($this->options['email_request_timeout_ms'] ?? Emails::DEFAULT_REQUEST_TIMEOUT_MS), - maxRetries: (int) ($this->options['email_request_max_retries'] ?? Emails::DEFAULT_MAX_RETRIES), + requestTimeoutMs: $this->integerOption('email_request_timeout_ms', Emails::DEFAULT_REQUEST_TIMEOUT_MS), + maxRetries: $this->integerOption('email_request_max_retries', Emails::DEFAULT_MAX_RETRIES), migratedFrom: $this->options['migrated_from'] ?? null, userAgent: $this->options['library_name'] . '/' . $this->options['library_version'], isDisabled: fn(): bool => !$this->enabled, @@ -512,6 +509,22 @@ public function emails(): Emails ); } + private function refuseBadEmailOptions(): void + { + $this->emails(); + } + + private function integerOption(string $name, int $default): int + { + $value = $this->options[$name] ?? $default; + $integer = is_string($value) ? filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE) : $value; + if (!is_int($integer)) { + throw new InvalidArgumentException("$name must be a whole number, such as $default."); + } + + return $integer; + } + /** * Captures a user action. * diff --git a/lib/EmailError.php b/lib/EmailError.php index b71218a..ffab563 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -141,7 +141,6 @@ private static function nameFor(?int $status, string $code): string 403 => 'invalid_permission', 404 => 'not_found', 409 => $code === 'template_outdated' ? 'validation_error' : 'concurrent_idempotent_requests', - // The send route also answers 422 for send checks such as recipient_suppressed. 422 => $code === 'idempotency_key_reused' ? 'invalid_idempotent_request' : 'validation_error', 429 => $code === 'quota_limited' ? 'monthly_quota_exceeded' : 'rate_limit_exceeded', 503 => 'service_unavailable', diff --git a/lib/Emails.php b/lib/Emails.php index 1f2161a..33da134 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use JsonException; use Symfony\Component\Clock\Clock; +use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\MonotonicClock; use Symfony\Component\Clock\NativeClock; @@ -45,6 +46,7 @@ final class Emails private const MAX_RETRY_DELAY_MS = 30000; private const MAX_RESPONSE_BYTES = 1_048_576; private const MAX_RETRY_AFTER_SECONDS = 31_536_000; + private const NO_EXPECT_100_CONTINUE = 'Expect:'; private const UUID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD'; private const IDEMPOTENCY_KEY_PATTERN = '/^[\x21-\x7e]{1,255}$/D'; private const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,400}$/D'; @@ -191,9 +193,8 @@ public function send(array $email): SendEmailResult return self::parseSendReply(...$this->request('POST', $appHost . self::SEND_PATH, $headers, $body)); }); foreach ($result->getWarnings() as $warning) { - // The fields come from the reply, so control characters are escaped to keep each warning on one log line. $line = $warning->getCode() . ': ' . $warning->getMessage(); - error_log('[PostHog][Emails] Email warning ' . addcslashes($line, "\0..\37\177\\")); + error_log('[PostHog][Emails] Email warning ' . self::onOneLogLine($line)); } return $result; @@ -427,7 +428,6 @@ private function connect(string $notDone, string $keyNeeded): array throw new EmailError('client_closed', "The PostHog client is shut down, so $notDone."); } if ($this->appHost === null) { - // A custom host is often an ingestion proxy, which must not receive the secret key. throw new EmailError( 'missing_app_host', "The client's host is not PostHog Cloud, so the client can't tell where to send email. " @@ -487,12 +487,7 @@ private function withRetries(int $maxRetries, Closure $attemptOnce): mixed */ private function waitToRetry(int $delayMs, EmailError $error): void { - $clock = Clock::get(); - if ($clock instanceof NativeClock) { - // The wall clock can jump during the wait, so the deadline is kept on a clock that only moves forward. - $clock = new MonotonicClock(); - } - // Measured from the start of the wait: microseconds since the epoch don't fit in an int on 32-bit PHP. + $clock = self::forwardOnlyClock(); $start = $clock->now(); while (!($this->isClosed)()) { $remainingUs = $delayMs * 1000 - self::microsecondsBetween($start, $clock->now()); @@ -505,17 +500,37 @@ private function waitToRetry(int $delayMs, EmailError $error): void throw $error; } + private static function forwardOnlyClock(): ClockInterface + { + $clock = Clock::get(); + + return $clock instanceof NativeClock ? new MonotonicClock() : $clock; + } + private static function microsecondsBetween(DateTimeImmutable $start, DateTimeImmutable $end): int { - // Read as floats: on 32-bit PHP an epoch after 2038 doesn't fit in an int. - $seconds = (int) ((float) $end->format('U') - (float) $start->format('U')); + $seconds = (int) (self::wholeEpochSecondsAsFloat($end) - self::wholeEpochSecondsAsFloat($start)); return $seconds * 1_000_000 + (int) $end->format('u') - (int) $start->format('u'); } + private static function wholeEpochSecondsAsFloat(DateTimeImmutable $time): float + { + return (float) $time->format('U'); + } + + private static function epochSecondsAsFloat(DateTimeImmutable $time): float + { + return (float) $time->format('U.u'); + } + + private static function onOneLogLine(string $text): string + { + return addcslashes($text, "\0..\37\177\\"); + } + private static function isRetryable(EmailError $error): bool { - // A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing. $status = $error->getStatusCode(); if ($status === null) { return $error->getErrorCode() === 'network_error'; @@ -524,7 +539,6 @@ private static function isRetryable(EmailError $error): bool return $error->getErrorCode() === 'idempotency_key_in_flight'; } if ($status === 429) { - // Waiting doesn't fix a monthly quota. return $error->getErrorCode() !== 'quota_limited'; } @@ -537,7 +551,6 @@ private static function retryDelayMs(EmailError $error, int $attempt, int $maxRe return null; } $retryAfterMs = $error->getRetryAfterMs(); - // Retrying before a longer Retry-After would break the server's rate limit. if ($retryAfterMs !== null && $retryAfterMs > self::MAX_RETRY_DELAY_MS) { return null; } @@ -580,8 +593,7 @@ private static function curlTransport( curl_setopt_array($curl, [ CURLOPT_URL => $url, CURLOPT_CUSTOMREQUEST => $method, - // An empty Expect stops cURL from waiting for 100-continue before it sends a large body. - CURLOPT_HTTPHEADER => [...$headers, 'Expect:'], + CURLOPT_HTTPHEADER => [...$headers, self::NO_EXPECT_100_CONTINUE], CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$responseBody, &$tooLarge): int { if (strlen($responseBody) + strlen($chunk) > self::MAX_RESPONSE_BYTES) { $tooLarge = true; @@ -727,19 +739,24 @@ private static function retryAfterMs(array $headers): ?int if (is_numeric($value)) { $seconds = (float) $value; } else { - // The format's GMT is literal text, so without a zone PHP would read the time in its default zone. - $date = DateTimeImmutable::createFromFormat(DATE_RFC7231, $value, new DateTimeZone('UTC')); - if ($date === false) { + $date = self::httpDateInUtc($value); + if ($date === null) { return null; } - // getTimestamp() throws on 32-bit PHP for a date after 2038, so the epoch is read as a float. - $seconds = (float) $date->format('U') - (float) Clock::get()->now()->format('U.u'); + $seconds = self::epochSecondsAsFloat($date) - self::epochSecondsAsFloat(Clock::get()->now()); } - // Clamped in seconds first: milliseconds since the epoch, or a huge value times 1000, overflow an int, - // on 32-bit PHP soonest. A value too long for a float reads as INF and clamps to the maximum too. - $maxSeconds = min(self::MAX_RETRY_AFTER_SECONDS, intdiv(PHP_INT_MAX, 1000)); - return (int) (min(max(0.0, $seconds), $maxSeconds) * 1000); + return (int) (min(max(0.0, $seconds), self::longestRetryAfterSecondsAnIntHoldsInMs()) * 1000); + } + + private static function httpDateInUtc(string $value): ?DateTimeImmutable + { + return DateTimeImmutable::createFromFormat(DATE_RFC7231, $value, new DateTimeZone('UTC')) ?: null; + } + + private static function longestRetryAfterSecondsAnIntHoldsInMs(): int + { + return min(self::MAX_RETRY_AFTER_SECONDS, intdiv(PHP_INT_MAX, 1000)); } /** diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index f933a14..1765e08 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -148,6 +148,9 @@ public static function badEmailOptions(): array 'zero timeout' => [['email_request_timeout_ms' => 0]], 'timeout not a number' => [['email_request_timeout_ms' => 'soon']], 'negative retries' => [['email_request_max_retries' => -1]], + 'retries as a word' => [['email_request_max_retries' => 'two']], + 'timeout with a unit' => [['email_request_timeout_ms' => '15seconds']], + 'timeout as a fraction' => [['email_request_timeout_ms' => 1.5]], 'migrated_from with a space' => [['migrated_from' => 'Resend Inc']], ]; } @@ -162,13 +165,31 @@ public function testABadEmailOptionIsRefusedWhenTheClientIsBuilt(array $options) new Client('phc_project', $options, loadFeatureFlags: false, secretKey: 'phs_secret'); } + public function testNumericStringEmailOptionsAreRead(): void + { + $this->server = new LocalHttpServer([ + ['status' => 503, 'body' => '{"code":"unavailable"}'], + ['status' => 202, 'body' => '{"message_id":"msg_1"}'], + ]); + + $error = null; + try { + $client = $this->client(['email_request_max_retries' => '0', 'email_request_timeout_ms' => '5000']); + $client->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + } catch (EmailError $error) { + } + + $this->assertSame('unavailable', $error?->getErrorCode()); + $this->assertCount(1, $this->server->requests()); + } + public function testAnEmptyMigratedFromCountsAsLeftOut(): void { $this->server = new LocalHttpServer([['status' => 202, 'body' => '{"message_id":"msg_1"}']]); $this->client(['migrated_from' => ''])->emails()->send([ - 'to' => 'user@example.com', - 'template_id' => self::TEMPLATE_ID, + 'to' => 'user@example.com', + 'template_id' => self::TEMPLATE_ID, ]); $this->assertArrayNotHasKey('x-posthog-migrated-from', $this->server->requests()[0]['headers']); From 86447e683ceb124012240e19f7ba929c012ae885 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:23:16 +0000 Subject: [PATCH 23/25] fix(emails): drop the remaining private and test comments 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 --- lib/Emails.php | 13 ++----------- test/EmailsClientTest.php | 3 --- test/EmailsTest.php | 11 ++++------- test/FakeTimer.php | 7 ------- test/InterruptedClock.php | 3 --- test/no_curl_mock.php | 2 -- test/symfony_clock_time_mock.php | 2 -- 7 files changed, 6 insertions(+), 35 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index 33da134..62dc110 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -233,7 +233,7 @@ public function get(string $messageId): EmailStatus } /** - * @return array{string, string, int} The JSON body, the idempotency key and the retry limit. + * @return array{string, string, int} */ private function prepareSend(array $email): array { @@ -414,7 +414,7 @@ private static function invalid(string $code, string $message, string $field): E } /** - * @return array{string, string} The app host and the secret key. + * @return array{string, string} */ private function connect(string $notDone, string $keyNeeded): array { @@ -480,11 +480,6 @@ private function withRetries(int $maxRetries, Closure $attemptOnce): mixed } } - /** - * A handled signal cuts a sleep short, so this sleeps again until the retry is due. A signal handler can also - * shut the client down, during the request or the wait; a closed client neither waits nor sends, so the - * operation settles with the failure it already has. - */ private function waitToRetry(int $delayMs, EmailError $error): void { $clock = self::forwardOnlyClock(); @@ -759,10 +754,6 @@ private static function longestRetryAfterSecondsAnIntHoldsInMs(): int return min(self::MAX_RETRY_AFTER_SECONDS, intdiv(PHP_INT_MAX, 1000)); } - /** - * The app host for a send, or null when the client can't tell it. PostHog Cloud ingestion hosts don't - * serve the app API, so they map to their region's app host. - */ private static function resolveAppHost(string $host, ?string $appHost): ?string { $appHost = StringNormalizer::normalizeOptional($appHost); diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index 1765e08..91ab645 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -8,9 +8,6 @@ use PostHog\EmailError; use PostHog\PostHog; -/** - * Sends through Client::emails() over real HTTP to a loopback server. - */ class EmailsClientTest extends TestCase { private const TEMPLATE_ID = '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b'; diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 669aec4..6cd0e54 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -93,9 +93,6 @@ private function header(string $name, int $index = 0): ?string return null; } - /** - * Epoch milliseconds overflow an int on 32-bit PHP, so a wait is measured as a difference of floats. - */ private static function msBetween(DateTimeImmutable $start, DateTimeImmutable $end): int { return (int) round(((float) $end->format('U.u') - (float) $start->format('U.u')) * 1000); @@ -599,8 +596,8 @@ public function testTheClientRetryLimitApplies(): void public function testAClientShutDownDuringTheRequestDoesNotWaitToRetry(): void { - // As if a signal handler called shutdown() while the first attempt was in flight. - $emails = $this->emails(['isClosed' => fn(): bool => $this->requests !== []]); + $closedOnceTheFirstAttemptIsInFlight = fn(): bool => $this->requests !== []; + $emails = $this->emails(['isClosed' => $closedOnceTheFirstAttemptIsInFlight]); $this->reply([429, '{"code":"throttled"}', ['Retry-After: 30']], [202, '{"message_id":"msg_1"}', []]); $start = $this->clock->now(); @@ -715,8 +712,8 @@ public function testAHugeRetryAfterFailsAtOnceWithoutOverflowing(string $retryAf $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); $this->assertSame('throttled', $error->getErrorCode()); - // A year, or on 32-bit PHP the most whole seconds of milliseconds an int holds. - $this->assertSame(PHP_INT_SIZE === 8 ? 31_536_000_000 : 2_147_483_000, $error->getRetryAfterMs()); + $aYearOrWhatA32BitIntHoldsInMs = PHP_INT_SIZE === 8 ? 31_536_000_000 : 2_147_483_000; + $this->assertSame($aYearOrWhatA32BitIntHoldsInMs, $error->getRetryAfterMs()); $this->assertCount(1, $this->requests); } diff --git a/test/FakeTimer.php b/test/FakeTimer.php index 00883de..95687ec 100644 --- a/test/FakeTimer.php +++ b/test/FakeTimer.php @@ -4,26 +4,19 @@ use RuntimeException; -/** - * The monotonic timer and the sleeps that symfony_clock_time_mock.php gives Symfony's clocks. The wall clock keeps - * real time, so next to this timer it looks stepped back by everything slept. - */ final class FakeTimer { private const MAX_SLEEPS = 50; public static int $nowUs = 0; - /** A signal cuts the first sleep short after this long. */ public static ?int $interruptFirstSleepAfterUs = null; private static int $sleeps = 0; public static function hrtime(bool $asNumber): array|int|float { - // Split from microseconds: nanoseconds pass PHP_INT_MAX after about 2 s on 32-bit PHP. $seconds = intdiv(self::$nowUs, 1_000_000); $ns = self::$nowUs % 1_000_000 * 1000; - // Like the real hrtime(true), the number is a float on 32-bit PHP. return $asNumber ? $seconds * 1_000_000_000 + $ns : [$seconds, $ns]; } diff --git a/test/InterruptedClock.php b/test/InterruptedClock.php index 6e5d6c9..ce284fa 100644 --- a/test/InterruptedClock.php +++ b/test/InterruptedClock.php @@ -8,9 +8,6 @@ use Symfony\Component\Clock\DatePoint; use Symfony\Component\Clock\MockClock; -/** - * A mock clock whose first sleep a signal cuts short: it ends after $interruptAfterSeconds and runs the handler. - */ final class InterruptedClock implements ClockInterface { private bool $interrupted = false; diff --git a/test/no_curl_mock.php b/test/no_curl_mock.php index aace4f0..1304604 100644 --- a/test/no_curl_mock.php +++ b/test/no_curl_mock.php @@ -1,8 +1,6 @@ Date: Thu, 5 Nov 2026 07:44:37 +0000 Subject: [PATCH 24/25] fix(emails): name the transactional_email scopes for sends and status reads Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- lib/Emails.php | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 77ae82e..b5a79d4 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Existing span attributes take precedence. The processor preserves the applicatio `emails()` sends one email to one recipient, from an email template made transactional in PostHog or from content your code rendered, and reads what happened to it. -The client needs a project secret API key (`phs_...`) as `secretKey`. Sending needs the `hog_flow:write` scope, and reading a status needs `hog_flow:read`. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host: email requests never go to a custom `host`. Loading flag definitions still sends the secret key to `host`, so when your proxy must not see the key, build the client with `loadFeatureFlags: false` and don't call `loadFlags()`: +The client needs a project secret API key (`phs_...`) as `secretKey`. Create it with the "Send transactional email" preset (`transactional_email:write`) in your project settings, and add `transactional_email:read` if you read statuses with a key that can't send. Keys made with `hog_flow:write` still work. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host: email requests never go to a custom `host`. Loading flag definitions still sends the secret key to `host`, so when your proxy must not see the key, build the client with `loadFeatureFlags: false` and don't call `loadFlags()`: ```php $client = new \PostHog\Client('phc_...', [ diff --git a/lib/Emails.php b/lib/Emails.php index 62dc110..28fad04 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -17,8 +17,9 @@ * and reads the status of each email it sent. * * Get it from the client with `$client->emails()` or `PostHog::emails()`. The client needs a project - * secret API key (`phs_...`) as `$secretKey`: sending needs the `hog_flow:write` scope, reading a status - * needs `hog_flow:read`. A client on PostHog Cloud sends to its region's app host. For any other `host`, + * secret API key (`phs_...`) as `$secretKey`: sending needs the `transactional_email:write` scope, reading + * a status needs `transactional_email:read` or `transactional_email:write`. Keys made with `hog_flow:write` + * still work. A client on PostHog Cloud sends to its region's app host. For any other `host`, * such as a reverse proxy, set the `app_host` option, because the proxy must not receive the secret key. * * Each request times out after `email_request_timeout_ms` (15000 by default). Network errors, rate @@ -63,9 +64,9 @@ final class Emails private const SEND_NOT_DONE = 'no email was sent'; private const GET_NOT_DONE = 'no email status was read'; private const SEND_KEY_NEEDED = 'Sending email needs a secret key, a project secret API key with the ' - . 'hog_flow:write scope.'; + . 'transactional_email:write scope.'; private const GET_KEY_NEEDED = 'Reading an email status needs a secret key, a project secret API key with the ' - . 'hog_flow:read or hog_flow:write scope.'; + . 'transactional_email:read or transactional_email:write scope.'; private ?string $secretKey; private ?string $appHost; From ccc3a5457f1ebb9de350fbc54373696e1a54a563 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:59:50 +0000 Subject: [PATCH 25/25] docs(emails): rewrite the release note for transactional email Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-send.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.changeset/emails-send.md b/.changeset/emails-send.md index 25cd65c..0f5977c 100644 --- a/.changeset/emails-send.md +++ b/.changeset/emails-send.md @@ -2,4 +2,29 @@ "posthog-php": minor --- -Add transactional email: `$client->emails()->send()` and `PostHog::emails()->send()` queue one email from a template made in PostHog (`template_id`, `template_data`) or from content your code rendered (`template_key`, `subject`, `html` and/or `text`), and `emails()->get()` reads its status. Sending needs a project secret API key (`phs_...`) as `$secretKey`. New client options: `app_host`, `email_request_timeout_ms`, `email_request_max_retries` and `migrated_from`. A failed send throws `PostHog\EmailError`. +The new `emails()` sends transactional email from your PHP code and reads each email's delivery status. + +```php +PostHog::init('phc_...', ['host' => 'https://us.i.posthog.com'], secretKey: 'phs_...'); + +$result = PostHog::emails()->send([ + 'to' => 'user@example.com', + 'distinct_id' => 'user_123', + 'template_key' => 'password-reset', + 'subject' => 'Reset your password', + 'html' => $renderedHtml, +]); + +// 'delivered', 'bounced', ... +$status = PostHog::emails()->get($result->getId())->getStatus(); +``` + +You can also send a template made in PostHog by its `template_id`. + +You need a project secret API key, the kind that starts with `phs_`. Create one under **Project secret API keys** in your project settings. Set its **Transactional email** scope to Write to send emails, or to Read for a key that only reads statuses. Pass it to the client as `secretKey`. Set the `app_host` option to your PostHog app URL when you self-host PostHog or send through a reverse proxy. The client also loads feature flags with this key when it starts, so give it **Feature flags: Read** too, or it logs an error each time a client starts. + +PostHog records each email's delivery, opens and clicks on the person with that `distinct_id`. It groups every email with the same `template_key`, so you see their metrics and logs in one place. The SDK retries a request that hits a network error, a rate limit or an outage twice by default. Every send carries an idempotency key, so a retry never sends the email twice. Pass your own `idempotency_key`, such as `welcome:user_123`, and your job runner's retries are safe too. + +A send with your own content also takes `from`, `reply_to` and `'capture_engagement' => false`. Set the `migrated_from` option to tell PostHog which email provider you moved from. A failed call throws a `PostHog\EmailError`. Its `getErrorCode()` returns PostHog's code, and `getStatusCode()` returns the HTTP status. + +Docs: [Send transactional emails with PHP](https://posthog.com/docs/workflows/transactional-emails/php) and the [transactional email overview](https://posthog.com/docs/workflows/transactional-emails).