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/50] 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 00000000..25cd65cf --- /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 a28b2ff8..17587e02 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 2196769f..b1e2ef54 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 c81eb6df..40c8fd72 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 00000000..b71218a7 --- /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 00000000..c858f2cc --- /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 00000000..d156571d --- /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 00000000..80afa669 --- /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 c6b0b777..4ab0272a 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 00000000..0c0ba347 --- /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 00000000..ff5bb202 --- /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 00000000..608a9763 --- /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/50] 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 b1e2ef54..1927e0c5 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 80afa669..c673bd9e 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/50] 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 17587e02..1673bce2 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/50] 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 1673bce2..0163f274 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/50] 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 0163f274..c03b1352 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/50] 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 c673bd9e..2de7fc3d 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 608a9763..98627710 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/50] 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 c03b1352..9bccd2d1 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/50] 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 2de7fc3d..6de4f485 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 98627710..80c4f099 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 00000000..6e5d6c9e --- /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/50] 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 6de4f485..4509eb7b 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/50] 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 00000000..d005acfa --- /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 00000000..10103135 --- /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 00000000..2684b90b --- /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/50] 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 9bccd2d1..77ae82e6 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 4509eb7b..5de765fb 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 00000000..13742922 --- /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 00000000..aace4f0f --- /dev/null +++ b/test/no_curl_mock.php @@ -0,0 +1,11 @@ + Date: Mon, 2 Nov 2026 19:02:13 +0000 Subject: [PATCH 12/50] 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 13742922..238dad84 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/50] 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 5de765fb..55b20983 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 80c4f099..d56d7d07 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/50] 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 55b20983..4f862cac 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 ff5bb202..8f3b53d7 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 3e773a49..feae8073 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/50] 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 4f862cac..67b55ba8 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 d56d7d07..1aa34ed4 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/50] 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 67b55ba8..e9aee359 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 1aa34ed4..787450e8 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/50] 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 e9aee359..2cb696a8 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 787450e8..a0048470 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/50] 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 2cb696a8..c2075ed6 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 a0048470..48409394 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/50] 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 c2075ed6..3951a223 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 48409394..65718efa 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 10103135..00883de1 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 2684b90b..f52213fd 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/50] 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 3951a223..86e62469 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 65718efa..34515076 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 804138904f8ed23c8f139e6550b80af1eaa1255d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:49:18 +0000 Subject: [PATCH 21/50] feat(emails): send cc, bcc and attachments with emails()->send() A send with template_key takes cc and bcc (one address or a list) and attachments (filename, content, content_type, content_id). PHP strings are bytes, so content is the file itself and the client encodes it as Base64 once, before the retry loop. Empty lists are left out. An attachment the client can't send throws invalid_attachment with a field such as attachments__0__content, and never repeats the content. A send with template_id refuses all three fields. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-cc-bcc-attachments.md | 5 + README.md | 13 ++- lib/Emails.php | 127 ++++++++++++++++++++- test/EmailsTest.php | 144 +++++++++++++++++++++++- 4 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 .changeset/emails-cc-bcc-attachments.md diff --git a/.changeset/emails-cc-bcc-attachments.md b/.changeset/emails-cc-bcc-attachments.md new file mode 100644 index 00000000..a239cd1f --- /dev/null +++ b/.changeset/emails-cc-bcc-attachments.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes, which the SDK encodes as Base64. diff --git a/README.md b/README.md index 77ae82e6..ada2db78 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,22 @@ PostHog::emails()->send([ 'distinct_id' => 'user_123', ]); +// With copies and a file: content is the file's bytes, and the SDK encodes them +PostHog::emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'html' => $html, + 'bcc' => 'finance@example.com', + 'attachments' => [['filename' => 'receipt.pdf', 'content' => file_get_contents($receiptPath)]], +]); + // 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, ... ``` -- **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. +- **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`, `cc`, `bcc`, `attachments` 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. +- **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, 512 KB in total, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. - **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. 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. diff --git a/lib/Emails.php b/lib/Emails.php index 86e62469..fa6d1457 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -53,7 +53,10 @@ final class Emails 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 CODE_FIELDS = [ + 'template_key', 'subject', 'html', 'text', 'from', 'reply_to', 'cc', 'bcc', 'attachments', 'capture_engagement', + ]; + private const ATTACHMENT_FIELDS = ['filename', 'content', 'content_type', 'content_id']; 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.'; @@ -129,8 +132,17 @@ 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 + * `reply_to` (one address or a list), `cc` and `bcc` (one address or a list of at most 10; an empty + * list is left out), `attachments` and `capture_engagement` (default true; a project whose admin * turned engagement capture off captures nothing, whatever the send asks). + * - `attachments` is a list of at most 10 files, 512 KB in total. Each file is an array with + * `filename`, `content` (the file's bytes, such as `file_get_contents()` returns; the client encodes + * them as Base64), and optional `content_type` and `content_id`. Reference a `content_id` of `logo` + * in the HTML as `` to show the file inline. PostHog can't fetch a file from a + * path or URL. + * + * A PostHog version that doesn't take a field yet, such as `cc`, refuses the send with an error that + * names the field. It never drops the field and sends the rest. * * 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` @@ -157,6 +169,17 @@ public function __construct( * 'html' => $renderedHtml, * 'distinct_id' => 'user_123', * ]); + * + * $client->emails()->send([ + * 'to' => 'user@example.com', + * 'template_key' => 'receipt', + * 'subject' => 'Your receipt', + * 'html' => $renderedHtml, + * 'bcc' => 'finance@example.com', + * 'attachments' => [ + * ['filename' => 'receipt.pdf', 'content' => file_get_contents($receiptPath)], + * ], + * ]); * ``` * * @param array{ @@ -172,11 +195,21 @@ public function __construct( * text?: string|null, * from?: string|null, * reply_to?: string|list|null, + * cc?: string|list|null, + * bcc?: string|list|null, + * attachments?: 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. + * An attachment the client can't send has the code `invalid_attachment` and a field such as + * `attachments__0__content`, and its message never repeats the file's content. */ public function send(array $email): SendEmailResult { @@ -351,7 +384,16 @@ private static function codeContent(array $email): array $content['from'] = $email['from']; } if (array_key_exists('reply_to', $email)) { - $content['reply_to'] = self::addresses($email['reply_to']); + $content['reply_to'] = self::addresses('reply_to', $email['reply_to']); + } + foreach (['cc', 'bcc'] as $field) { + // An empty list is left out, so a list built per email needs no check before the send. + if (array_key_exists($field, $email) && $email[$field] !== []) { + $content[$field] = self::addresses($field, $email[$field]); + } + } + if (array_key_exists('attachments', $email) && $email['attachments'] !== []) { + $content['attachments'] = self::attachments($email['attachments']); } $captureEngagement = $email['capture_engagement'] ?? true; if (!is_bool($captureEngagement)) { @@ -365,21 +407,94 @@ private static function codeContent(array $email): array /** * @return list */ - private static function addresses(mixed $value): array + private static function addresses(string $field, 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'); + throw self::invalid('invalid_form', "$field must be one address or a list of addresses.", $field); } 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'); + throw self::invalid('invalid_form', "$field must not hold a blank address.", $field); } } return $addresses; } + /** + * @return list> + */ + private static function attachments(mixed $files): array + { + if (!is_array($files) || !array_is_list($files)) { + throw self::invalidAttachment('attachments must be a list of files.', 'attachments'); + } + $attachments = []; + foreach ($files as $index => $file) { + $attachments[] = self::attachment("attachments__$index", $file); + } + + return $attachments; + } + + /** + * @return array + */ + private static function attachment(string $field, mixed $file): array + { + if (!is_array($file) || ($file !== [] && array_is_list($file))) { + throw self::invalidAttachment( + "An attachment is an array with a filename and the file's bytes as its content.", + $field + ); + } + if (array_key_exists('path', $file)) { + throw self::invalidAttachment( + "PostHog can't fetch an attachment from a path or URL. Read the file and pass its bytes as content.", + "{$field}__path" + ); + } + foreach (array_keys($file) as $name) { + if (!in_array($name, self::ATTACHMENT_FIELDS, true)) { + throw self::invalidAttachment( + "An attachment takes filename, content, content_type and content_id, not $name.", + "{$field}__$name" + ); + } + } + if (!is_string($file['filename'] ?? null)) { + throw self::invalidAttachment('An attachment needs its filename as a string.', "{$field}__filename"); + } + if (!is_string($file['content'] ?? null)) { + throw self::invalidAttachment( + "An attachment needs the file's bytes as a string, such as file_get_contents() returns.", + "{$field}__content" + ); + } + // PHP strings are bytes, so the content is always the file itself and is always encoded here. + $attachment = ['filename' => $file['filename'], 'content' => base64_encode($file['content'])]; + foreach (['content_type', 'content_id'] as $name) { + if (!array_key_exists($name, $file)) { + continue; + } + if (!is_string($file[$name])) { + throw self::invalidAttachment("An attachment's $name must be a string.", "{$field}__$name"); + } + $attachment[$name] = $file[$name]; + } + + return $attachment; + } + + /** + * The message never holds the file's content, so the error is safe to log. + */ + private static function invalidAttachment(string $message, string $field): EmailError + { + return new EmailError('invalid_attachment', $message . ' No email was sent.', field: $field); + } + private static function refuseFieldsOutside(array $email, bool $isTemplateForm): void { $formFields = array_merge(self::SHARED_FIELDS, $isTemplateForm ? self::TEMPLATE_FIELDS : self::CODE_FIELDS); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 34515076..ca6d0286 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -17,6 +17,9 @@ class EmailsTest extends TestCase private const TEMPLATE_ID = '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b'; private const MESSAGE_ID = '01a18356-c56c-0001-e494-7666bc915188'; private const SEND_URL = 'https://us.posthog.com/api/projects/@current/messaging/send/email/'; + private const INVITE = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; + private const INVITE_BASE64 = 'QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo='; + private const SECRET_CONTENT = 'secret-file-content'; /** @var list, body: ?string, timeoutMs: int, at: DateTimeImmutable}> */ private array $requests = []; @@ -218,6 +221,9 @@ public function testNullOptionsCountAsLeftOut(): void 'text' => null, 'from' => null, 'reply_to' => null, + 'cc' => null, + 'bcc' => null, + 'attachments' => null, 'capture_engagement' => null, 'distinct_id' => null, 'idempotency_key' => null, @@ -233,6 +239,61 @@ public function testNullOptionsCountAsLeftOut(): void ], $this->body()); } + public function testCodeSendPostsCopiesAndAttachmentsEncodedOnceForEveryAttempt(): void + { + $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); + $png = "\x89PNG\r\n\x1a\n\x00"; + + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'invite', + 'subject' => 'You are invited', + 'html' => '', + 'cc' => 'manager@example.com', + 'bcc' => ['audit@example.com', 'archive@example.com'], + 'attachments' => [ + ['filename' => 'invite.ics', 'content' => self::INVITE, 'content_type' => 'text/calendar'], + ['filename' => 'logo.png', 'content' => $png, 'content_id' => 'logo'], + ], + ]); + + $this->assertSame([ + 'template_key' => 'invite', + 'subject' => 'You are invited', + 'html' => '', + 'cc' => ['manager@example.com'], + 'bcc' => ['audit@example.com', 'archive@example.com'], + 'attachments' => [ + ['filename' => 'invite.ics', 'content' => self::INVITE_BASE64, 'content_type' => 'text/calendar'], + ['filename' => 'logo.png', 'content' => 'iVBORw0KGgoA', 'content_id' => 'logo'], + ], + 'capture_engagement' => true, + 'to' => 'user@example.com', + ], $this->body()); + $this->assertSame($this->requests[0]['body'], $this->requests[1]['body']); + } + + public function testEmptyCopyAndAttachmentListsAreLeftOut(): void + { + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'cc' => [], + 'bcc' => [], + 'attachments' => [], + ]); + + $this->assertSame([ + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'capture_engagement' => true, + 'to' => 'user@example.com', + ], $this->body()); + } + public function testAGeneratedIdempotencyKeyIsAUuidV4ReusedOnEveryAttempt(): void { $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); @@ -330,7 +391,14 @@ public static function argumentErrors(): array 'invalid_form', 'capture_engagement', ], - 'unknown option' => [$template + ['cc' => 'boss@example.com'], 'invalid_form', 'cc'], + 'unknown option' => [$template + ['headers' => ['X-A' => 'b']], 'invalid_form', 'headers'], + 'cc with a template' => [$template + ['cc' => 'boss@example.com'], 'invalid_form', 'cc'], + 'bcc with a template' => [$template + ['bcc' => ['boss@example.com']], 'invalid_form', 'bcc'], + 'attachments with a template' => [ + $template + ['attachments' => [['filename' => 'a.txt', 'content' => 'hi']]], + 'invalid_form', + 'attachments', + ], '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' => [ @@ -346,6 +414,9 @@ public static function argumentErrors(): array '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'], + 'blank cc' => [$code + ['cc' => ' '], 'invalid_form', 'cc'], + 'bcc not text' => [$code + ['bcc' => [123]], 'invalid_form', 'bcc'], + 'cc as a map' => [$code + ['cc' => ['boss' => 'boss@example.com']], 'invalid_form', 'cc'], 'capture_engagement not a bool' => [ $code + ['capture_engagement' => 'no'], 'invalid_form', @@ -379,6 +450,77 @@ public function testAFailedArgumentCheckSendsNothing(array $email, string $code, $this->assertSame([], $this->requests); } + public static function attachmentErrors(): array + { + $file = ['filename' => 'a.txt', 'content' => self::SECRET_CONTENT]; + + return [ + 'one file, not a list' => [$file, 'attachments'], + 'content, not a list' => [self::SECRET_CONTENT, 'attachments'], + 'file not an array' => [[self::SECRET_CONTENT], 'attachments__0'], + 'file as a list' => [[['a.txt', self::SECRET_CONTENT]], 'attachments__0'], + 'no content' => [[['filename' => 'a.txt']], 'attachments__0__content'], + 'content as a list of bytes' => [ + [['filename' => 'a.txt', 'content' => array_map('ord', str_split(self::SECRET_CONTENT))]], + 'attachments__0__content', + ], + 'no filename' => [[['content' => self::SECRET_CONTENT]], 'attachments__0__filename'], + 'filename not text' => [[['filename' => 1] + $file], 'attachments__0__filename'], + 'path' => [[$file + ['path' => '/tmp/a.txt']], 'attachments__0__path'], + 'unknown field' => [[$file + ['contentType' => 'text/plain']], 'attachments__0__contentType'], + 'second file with content_type not text' => [ + [$file, $file + ['content_type' => 1]], + 'attachments__1__content_type', + ], + 'content_id not text' => [[$file + ['content_id' => ['logo']]], 'attachments__0__content_id'], + ]; + } + + #[DataProvider('attachmentErrors')] + public function testAnAttachmentTheClientCannotSendIsRefusedWithoutRepeatingItsContent( + mixed $attachments, + string $field + ): void { + $error = $this->sendError([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => $attachments, + ]); + + $this->assertSame('invalid_attachment', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame($field, $error->getField()); + $this->assertStringNotContainsString(self::SECRET_CONTENT, $error->getMessage() . $error->getFix()); + $this->assertSame([], $this->requests); + } + + public function testAServerThatDoesNotTakeAFieldRefusesTheSendAndNamesIt(): void + { + $this->reply([400, json_encode([ + 'type' => 'invalid_request', + 'code' => 'invalid_request', + 'detail' => "PostHog doesn't take this field yet, so it didn't send the email.", + 'attr' => 'attachments', + 'why' => "This PostHog version doesn't take the field yet.", + 'fix' => 'Send the email without this field.', + ]), []]); + + $error = $this->sendError([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => [['filename' => 'receipt.pdf', 'content' => '%PDF-1.7']], + ]); + + $this->assertSame('attachments', $error->getField()); + $this->assertSame(400, $error->getStatusCode()); + $this->assertSame('Send the email without this field.', $error->getFix()); + $this->assertCount(1, $this->requests); + } + public function testAnOptionOfTheOtherFormSaysHowToSendIt(): void { $error = $this->sendError([ 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 22/50] 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 40c8fd72..efd47960 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 86e62469..1f2161a0 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 8f3b53d7..f933a140 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 34515076..669aec4c 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 23/50] 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 efd47960..fe851db5 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 b71218a7..ffab5634 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 1f2161a0..33da134d 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 f933a140..1765e085 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 14ec0dc3843d82d32ab32ec02eef832213852abf Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:46:07 +0000 Subject: [PATCH 24/50] fix(emails): a null attachment option counts as left out, and non-UTF-8 text names its field A file's content_type or content_id passed as null is left out, like a null top-level option. A filename, content_type or content_id that isn't UTF-8 throws invalid_attachment with its field, not a field-less invalid_form from json_encode. The README, the send() docblock and the change intent say to pass base64_decode($content) when the caller holds Base64, so a Resend payload isn't encoded twice. The test of a server refusal goes: it checked only the generic error mapping that testAnErrorKeepsPostHogsCodeAndFields covers. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-cc-bcc-attachments.md | 2 +- README.md | 2 +- lib/Emails.php | 27 +++++++++------ test/EmailsTest.php | 46 +++++++++++-------------- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/.changeset/emails-cc-bcc-attachments.md b/.changeset/emails-cc-bcc-attachments.md index a239cd1f..643cf940 100644 --- a/.changeset/emails-cc-bcc-attachments.md +++ b/.changeset/emails-cc-bcc-attachments.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes, which the SDK encodes as Base64. +Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes, which the SDK encodes as Base64; pass `base64_decode($content)` when you hold Base64. diff --git a/README.md b/README.md index ada2db78..5a16fd4b 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,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`, `cc`, `bcc`, `attachments` 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. -- **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, 512 KB in total, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. +- **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, 512 KB in total, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The SDK encodes `content` as Base64, so pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. - **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. 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. diff --git a/lib/Emails.php b/lib/Emails.php index 29f3a7dd..87797518 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -141,7 +141,8 @@ public function __construct( * is left out. * - `attachments` is a list of at most 10 files, 512 KB in total. Each file is an array with * `filename`, `content` (the file's bytes, such as `file_get_contents()` returns; the client encodes - * them as Base64), and optional `content_type` and `content_id`. Reference a `content_id` of `logo` + * them as Base64, so pass `base64_decode($content)` when you hold Base64, such as from a Resend + * payload), and optional `content_type` and `content_id`. Reference a `content_id` of `logo` * in the HTML as `` to show the file inline. PostHog can't fetch a file from a * path or URL. * @@ -204,8 +205,8 @@ public function __construct( * attachments?: list|null, * capture_engagement?: bool|null * } $email @@ -442,6 +443,9 @@ private static function attachments(mixed $files): array */ private static function attachment(string $field, mixed $file): array { + if (is_array($file)) { + $file = array_filter($file, static fn($value): bool => $value !== null); + } if (!is_array($file) || ($file !== [] && array_is_list($file))) { throw self::invalidAttachment( "An attachment is an array with a filename and the file's bytes as its content.", @@ -462,8 +466,8 @@ private static function attachment(string $field, mixed $file): array ); } } - if (!is_string($file['filename'] ?? null)) { - throw self::invalidAttachment('An attachment needs its filename as a string.', "{$field}__filename"); + if (!self::isUtf8Text($file['filename'] ?? null)) { + throw self::invalidAttachment('An attachment needs its filename as a UTF-8 string.', "{$field}__filename"); } if (!is_string($file['content'] ?? null)) { throw self::invalidAttachment( @@ -471,14 +475,13 @@ private static function attachment(string $field, mixed $file): array "{$field}__content" ); } - // PHP strings are bytes, so the content is always the file itself and is always encoded here. $attachment = ['filename' => $file['filename'], 'content' => base64_encode($file['content'])]; foreach (['content_type', 'content_id'] as $name) { if (!array_key_exists($name, $file)) { continue; } - if (!is_string($file[$name])) { - throw self::invalidAttachment("An attachment's $name must be a string.", "{$field}__$name"); + if (!self::isUtf8Text($file[$name])) { + throw self::invalidAttachment("An attachment's $name must be a UTF-8 string.", "{$field}__$name"); } $attachment[$name] = $file[$name]; } @@ -486,9 +489,11 @@ private static function attachment(string $field, mixed $file): array return $attachment; } - /** - * The message never holds the file's content, so the error is safe to log. - */ + private static function isUtf8Text(mixed $value): bool + { + return is_string($value) && mb_check_encoding($value, 'UTF-8'); + } + private static function invalidAttachment(string $message, string $field): EmailError { return new EmailError('invalid_attachment', $message . ' No email was sent.', field: $field); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index ccbe2194..8b0a114b 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -273,6 +273,24 @@ public function testCodeSendPostsCopiesAndAttachmentsEncodedOnceForEveryAttempt( $this->assertSame($this->requests[0]['body'], $this->requests[1]['body']); } + public function testANullContentTypeOrContentIdCountsAsLeftOut(): void + { + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => [ + ['filename' => 'invite.ics', 'content' => self::INVITE, 'content_type' => null, 'content_id' => null], + ], + ]); + + $this->assertSame( + [['filename' => 'invite.ics', 'content' => self::INVITE_BASE64]], + $this->body()['attachments'] + ); + } + public function testEmptyCopyAndAttachmentListsAreLeftOut(): void { $this->emails()->send([ @@ -472,6 +490,9 @@ public static function attachmentErrors(): array 'attachments__1__content_type', ], 'content_id not text' => [[$file + ['content_id' => ['logo']]], 'attachments__0__content_id'], + 'filename not UTF-8' => [[['filename' => "Rechnung_M\xE4rz.pdf"] + $file], 'attachments__0__filename'], + 'content_type not UTF-8' => [[$file + ['content_type' => "text/pl\xE4in"]], 'attachments__0__content_type'], + 'content_id not UTF-8' => [[$file + ['content_id' => "l\xF6go"]], 'attachments__0__content_id'], ]; } @@ -495,31 +516,6 @@ public function testAnAttachmentTheClientCannotSendIsRefusedWithoutRepeatingItsC $this->assertSame([], $this->requests); } - public function testAServerThatDoesNotTakeAFieldRefusesTheSendAndNamesIt(): void - { - $this->reply([400, json_encode([ - 'type' => 'invalid_request', - 'code' => 'invalid_request', - 'detail' => "PostHog doesn't take this field yet, so it didn't send the email.", - 'attr' => 'attachments', - 'why' => "This PostHog version doesn't take the field yet.", - 'fix' => 'Send the email without this field.', - ]), []]); - - $error = $this->sendError([ - 'to' => 'user@example.com', - 'template_key' => 'receipt', - 'subject' => 'Your receipt', - 'text' => 'Thanks!', - 'attachments' => [['filename' => 'receipt.pdf', 'content' => '%PDF-1.7']], - ]); - - $this->assertSame('attachments', $error->getField()); - $this->assertSame(400, $error->getStatusCode()); - $this->assertSame('Send the email without this field.', $error->getFix()); - $this->assertCount(1, $this->requests); - } - public function testAnOptionOfTheOtherFormSaysHowToSendIt(): void { $error = $this->sendError([ 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 25/50] 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 33da134d..62dc110e 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 1765e085..91ab6451 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 669aec4c..6cd0e548 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 00883de1..95687ec0 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 6e5d6c9e..ce284fa8 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 aace4f0f..13046048 100644 --- a/test/no_curl_mock.php +++ b/test/no_curl_mock.php @@ -1,8 +1,6 @@ Date: Thu, 5 Nov 2026 12:32:26 +0000 Subject: [PATCH 26/50] feat(emails): name the sending integration with an integration option A mailer integration passes 'integration' => '/' to emails()->send(), and the client sends it as the X-PostHog-Integration header of that send. The value is checked locally and a bad one sends nothing. The User-Agent stays the same. Ticket: https://github.com/PostHog/michaelr-context/issues/42 Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-integration-option.md | 5 +++ lib/Emails.php | 33 +++++++++++++-- test/EmailsTest.php | 56 +++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 .changeset/emails-integration-option.md diff --git a/.changeset/emails-integration-option.md b/.changeset/emails-integration-option.md new file mode 100644 index 00000000..e0ac041a --- /dev/null +++ b/.changeset/emails-integration-option.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Transactional email: `emails()->send()` takes an `integration` option, `/` such as `laravel/1.4.0`, that a mailer integration passes to name itself on the send. The client sends it as the `X-PostHog-Integration` header; the User-Agent stays the same. diff --git a/lib/Emails.php b/lib/Emails.php index 5c308375..ed8150d1 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -51,9 +51,10 @@ final class Emails 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 INTEGRATION_PATTERN = '#^[a-z0-9-]{1,40}/[0-9A-Za-z.+-]{1,40}$#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 SHARED_FIELDS = ['to', 'distinct_id', 'idempotency_key', 'max_retries', 'integration']; private const TEMPLATE_FIELDS = ['template_id', 'template_data']; private const CODE_FIELDS = [ 'template_key', 'subject', 'html', 'text', 'from', 'reply_to', 'cc', 'bcc', 'attachments', 'capture_engagement', @@ -153,6 +154,10 @@ public function __construct( * 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 library that sends email for an app, such as a mailer transport, names itself with `integration`, + * `/` such as `laravel/1.4.0`. The client sends it as the `X-PostHog-Integration` + * header of that send, so PostHog can tell which integration sent the email. Apps leave it 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 @@ -192,6 +197,7 @@ public function __construct( * distinct_id?: string|null, * idempotency_key?: string|null, * max_retries?: int|null, + * integration?: string|null, * template_id?: string, * template_data?: array|null, * template_key?: string, @@ -218,11 +224,12 @@ public function __construct( */ public function send(array $email): SendEmailResult { - [$body, $idempotencyKey, $maxRetries] = $this->prepareSend($email); + [$body, $idempotencyKey, $maxRetries, $integration] = $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, + ...($integration === null ? [] : ['X-PostHog-Integration: ' . $integration]), ]); $result = $this->withRetries($maxRetries, function () use ($appHost, $headers, $body): SendEmailResult { return self::parseSendReply(...$this->request('POST', $appHost . self::SEND_PATH, $headers, $body)); @@ -268,7 +275,7 @@ public function get(string $messageId): EmailStatus } /** - * @return array{string, string, int} + * @return array{string, string, int, ?string} */ private function prepareSend(array $email): array { @@ -304,6 +311,7 @@ private function prepareSend(array $email): array if (!is_int($maxRetries) || $maxRetries < 0) { throw self::invalid('invalid_max_retries', 'max_retries must be a nonnegative integer.', 'max_retries'); } + $integration = self::integration($email['integration'] ?? null); $payload['to'] = $to; if ($distinctId !== null) { $payload['distinct_id'] = $distinctId; @@ -318,7 +326,24 @@ private function prepareSend(array $email): array ); } - return [$body, $idempotencyKey ?? Uuid::v4(), $maxRetries]; + return [$body, $idempotencyKey ?? Uuid::v4(), $maxRetries, $integration]; + } + + private static function integration(mixed $integration): ?string + { + if ($integration === null) { + return null; + } + if (!is_string($integration) || preg_match(self::INTEGRATION_PATTERN, $integration) !== 1) { + throw self::invalid( + 'invalid_integration', + "integration must be /, such as 'laravel/1.4.0': a name of lowercase letters, digits " + . 'and hyphens, and a version of letters, digits, dots, plus signs and hyphens, at most 40 each.', + 'integration' + ); + } + + return $integration; } /** diff --git a/test/EmailsTest.php b/test/EmailsTest.php index d0e80556..57d005b6 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -337,6 +337,37 @@ public function testMigratedFromIsSentOnEveryRequest(): void $this->assertSame('resend', $this->header('X-PostHog-Migrated-From', 1)); } + public function testAnIntegrationNamesItselfOnThatSendOnly(): void + { + $emails = $this->emails(); + + $template = ['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]; + + $emails->send($template + ['integration' => 'laravel/1.4.0']); + $emails->send($template); + + $this->assertSame('laravel/1.4.0', $this->header('X-PostHog-Integration')); + $this->assertSame('posthog-php/9.9.9', $this->header('User-Agent')); + $this->assertArrayNotHasKey('integration', $this->body()); + $this->assertNull($this->header('X-PostHog-Integration', 1)); + } + + public function testAnIntegrationIsSentOnEveryAttemptOfACodeSend(): void + { + $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); + + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'order-shipped', + 'subject' => 'Your order shipped', + 'text' => 'On its way.', + 'integration' => 'posthog-php-mailer/4.21.0-beta.1+build.7', + ]); + + $this->assertSame('posthog-php-mailer/4.21.0-beta.1+build.7', $this->header('X-PostHog-Integration')); + $this->assertSame('posthog-php-mailer/4.21.0-beta.1+build.7', $this->header('X-PostHog-Integration', 1)); + } + public function testAReplayAndWarningsComeBackAndWarningsAreLogged(): void { global $errorMessages; @@ -449,6 +480,31 @@ public static function argumentErrors(): array '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], + 'integration without a version' => [ + $template + ['integration' => 'laravel'], + 'invalid_integration', + 'integration', + ], + 'integration with a capital letter' => [ + $template + ['integration' => 'Laravel/1.4.0'], + 'invalid_integration', + 'integration', + ], + 'integration with a header line break' => [ + $code + ['integration' => "laravel/1.4.0\r\nX-A: b"], + 'invalid_integration', + 'integration', + ], + 'integration name over 40 characters' => [ + $template + ['integration' => str_repeat('a', 41) . '/1.0.0'], + 'invalid_integration', + 'integration', + ], + 'integration not a string' => [ + $template + ['integration' => ['laravel', '1.4.0']], + 'invalid_integration', + 'integration', + ], ]; } From eba05568ab63215efe7eb116fd126ee61655ea90 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:10:08 +0000 Subject: [PATCH 27/50] feat(emails): upload attachments by hash, then send references A send with attachments hashes each file with SHA-256, checks 10 files and the 40 MB message estimate locally, asks the upload route which files PostHog still needs, PUTs those with the returned headers and no secret key, and sends the email with {sha256, filename} references. A failed PUT sends nothing and surfaces the storage's answer. Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-cc-bcc-attachments.md | 2 +- README.md | 5 +- lib/EmailError.php | 9 +- lib/Emails.php | 294 +++++++++++++++++++-- test/EmailsClientTest.php | 53 ++++ test/EmailsTest.php | 338 ++++++++++++++++++++++-- 6 files changed, 652 insertions(+), 49 deletions(-) diff --git a/.changeset/emails-cc-bcc-attachments.md b/.changeset/emails-cc-bcc-attachments.md index 643cf940..48025936 100644 --- a/.changeset/emails-cc-bcc-attachments.md +++ b/.changeset/emails-cc-bcc-attachments.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes, which the SDK encodes as Base64; pass `base64_decode($content)` when you hold Base64. +Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes: the SDK hashes each file, uploads the ones PostHog doesn't have yet, then sends the email with references to them. An email takes at most 10 files and 40 MB after Base64 encoding, about 29 MB of files. diff --git a/README.md b/README.md index f500d49c..eb931830 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ PostHog::emails()->send([ 'distinct_id' => 'user_123', ]); -// With copies and a file: content is the file's bytes, and the SDK encodes them +// With copies and a file: content is the file's bytes, and the SDK uploads them before the send PostHog::emails()->send([ 'to' => 'user@example.com', 'template_key' => 'receipt', @@ -73,7 +73,8 @@ $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`, `cc`, `bcc`, `attachments` 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. -- **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, 512 KB in total, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The SDK encodes `content` as Base64, so pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. +- **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The whole email can be at most 40 MB after Base64 encoding, which leaves about 29 MB for the files; the SDK checks both limits before any request (`too_many_attachments`, `message_too_large`). Pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. +- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. - **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. 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. diff --git a/lib/EmailError.php b/lib/EmailError.php index ffab5634..3604c9d7 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -14,6 +14,8 @@ */ class EmailError extends RuntimeException { + private const REFUSED_ATTACHMENT_CODES = ['too_many_attachments', 'message_too_large', 'field_not_accepted']; + private string $errorName; private string $errorCode; private ?int $statusCode; @@ -68,8 +70,8 @@ public function getErrorName(): string /** * 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. + * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error`, `upload_failed` + * or an `invalid_*` argument code. */ public function getErrorCode(): string { @@ -129,6 +131,9 @@ private static function nameFor(?int $status, string $code): string if ($code === 'missing_secret_key' || $status === 401) { return 'missing_api_key'; } + if (in_array($code, self::REFUSED_ATTACHMENT_CODES, true)) { + return 'validation_error'; + } if ($status === null) { if ($code === 'network_error') { return 'service_unavailable'; diff --git a/lib/Emails.php b/lib/Emails.php index 6cc6780c..eb228dd7 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -27,6 +27,12 @@ * `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. + * + * A send with attachments first uploads each file PostHog doesn't have yet to the storage URL PostHog + * answers, which is on another host than the app host. Each upload gets the request timeout plus 1 ms + * for every 100 bytes of the file. + * + * @phpstan-type Upload array{filename: string, size: int, content: string, field: string} */ final class Emails { @@ -34,6 +40,7 @@ final class Emails public const DEFAULT_MAX_RETRIES = 2; private const SEND_PATH = '/api/projects/@current/messaging/send/email/'; + private const UPLOADS_PATH = '/api/projects/@current/messaging/attachments/uploads/'; 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'; @@ -48,6 +55,7 @@ final class Emails 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 NO_FORM_CONTENT_TYPE = 'Content-Type:'; 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'; @@ -60,6 +68,12 @@ final class Emails 'template_key', 'subject', 'html', 'text', 'from', 'reply_to', 'cc', 'bcc', 'attachments', 'capture_engagement', ]; private const ATTACHMENT_FIELDS = ['filename', 'content', 'content_type', 'content_id']; + private const MAX_ATTACHMENTS = 10; + private const MAX_MESSAGE_BYTES = 40_000_000; + private const MESSAGE_HEADERS_BYTES = 65_536; + private const UPLOAD_BYTES_PER_EXTRA_MS = 100; + private const MESSAGE_LIMIT = '40 MB after Base64 encoding, about 29 MB of files'; + private const HEADER_NAME_PATTERN = "/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/D"; 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.'; @@ -140,12 +154,15 @@ public function __construct( * and `capture_engagement` (default true; a project whose admin turned engagement capture off * captures nothing, whatever the send asks). An empty `reply_to`, `cc`, `bcc` or `attachments` list * is left out. - * - `attachments` is a list of at most 10 files, 512 KB in total. Each file is an array with - * `filename`, `content` (the file's bytes, such as `file_get_contents()` returns; the client encodes - * them as Base64, so pass `base64_decode($content)` when you hold Base64, such as from a Resend - * payload), and optional `content_type` and `content_id`. Reference a `content_id` of `logo` - * in the HTML as `` to show the file inline. PostHog can't fetch a file from a - * path or URL. + * - `attachments` is a list of at most 10 files, and the email can be at most 40 MB after Base64 + * encoding, which leaves about 29 MB for the files. Each file is an array with `filename`, `content` + * (the file's bytes, such as `file_get_contents()` returns; pass `base64_decode($content)` when you + * hold Base64, such as from a Resend payload), and optional `content_type` and `content_id`. + * Reference a `content_id` of `logo` in the HTML as `` to show the file inline. + * PostHog can't fetch a file from a path or URL. The client checks both limits before any request, + * hashes each file with SHA-256, uploads the files PostHog doesn't have yet, and then sends the + * email with each file's hash. A file that fails to upload throws `upload_failed` with the storage's + * answer, and no email is sent. * * A PostHog version that doesn't take a field yet, such as `cc`, refuses the send with an error that * names the field. It never drops the field and sends the rest. @@ -215,12 +232,17 @@ public function __construct( * @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. * An attachment the client can't send has the code `invalid_attachment` and a field such as - * `attachments__0__content`, and its message never repeats the file's content. + * `attachments__0__content`, and its message never repeats the file's content. More than 10 files + * throw `too_many_attachments`, and an email over 40 MB throws `message_too_large`, before any + * request. */ public function send(array $email): SendEmailResult { - [$body, $idempotencyKey, $maxRetries] = $this->prepareSend($email); + [$body, $idempotencyKey, $maxRetries, $files] = $this->prepareSend($email); [$appHost, $secretKey] = $this->connect(self::SEND_NOT_DONE, self::SEND_KEY_NEEDED); + if ($files !== []) { + $this->upload($appHost, $secretKey, $files, $maxRetries); + } $headers = $this->headers($secretKey, [ 'Content-Type: application/json', 'Idempotency-Key: ' . $idempotencyKey, @@ -269,7 +291,8 @@ public function get(string $messageId): EmailStatus } /** - * @return array{string, string, int} + * @return array{string, string, int, array} The body, the idempotency key, the retries + * and the files to upload, by SHA-256. */ private function prepareSend(array $email): array { @@ -289,7 +312,7 @@ private function prepareSend(array $email): array '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); + [$payload, $files] = $hasTemplateId ? [self::templateContent($email), []] : self::codeContent($email); $idempotencyKey = $email['idempotency_key'] ?? null; if ( $idempotencyKey !== null @@ -319,7 +342,7 @@ private function prepareSend(array $email): array ); } - return [$body, $idempotencyKey ?? Uuid::v4(), $maxRetries]; + return [$body, $idempotencyKey ?? Uuid::v4(), $maxRetries, $files]; } /** @@ -348,7 +371,7 @@ private static function templateContent(array $email): array } /** - * @return array + * @return array{array, array} */ private static function codeContent(array $email): array { @@ -393,8 +416,12 @@ private static function codeContent(array $email): array $content[$field] = self::addresses($field, $email[$field]); } } + $files = []; if (array_key_exists('attachments', $email) && $email['attachments'] !== []) { - $content['attachments'] = self::attachments($email['attachments']); + [$content['attachments'], $files] = self::attachments($email['attachments'], [ + $content['html'] ?? '', + $content['text'] ?? '', + ]); } $captureEngagement = $email['capture_engagement'] ?? true; if (!is_bool($captureEngagement)) { @@ -402,7 +429,7 @@ private static function codeContent(array $email): array } $content['capture_engagement'] = $captureEngagement; - return $content; + return [$content, $files]; } /** @@ -424,19 +451,65 @@ private static function addresses(string $field, mixed $value): array } /** - * @return list> + * @param list $bodies + * @return array{list>, array} */ - private static function attachments(mixed $files): array + private static function attachments(mixed $files, array $bodies): array { if (!is_array($files) || !array_is_list($files)) { throw self::invalidAttachment('attachments must be a list of files.', 'attachments'); } + if (count($files) > self::MAX_ATTACHMENTS) { + throw new EmailError( + 'too_many_attachments', + 'An email carries at most ' . self::MAX_ATTACHMENTS . ' files, so no email was sent.', + why: 'An email carries at most ' . self::MAX_ATTACHMENTS . ' files.', + fix: 'Attach at most ' . self::MAX_ATTACHMENTS . ' files, and link to any others from the email body.', + field: 'attachments' + ); + } $attachments = []; foreach ($files as $index => $file) { $attachments[] = self::attachment("attachments__$index", $file); } + $sizes = array_map(static fn(array $file): int => strlen($file['content']), $attachments); + if (self::messageBytes([...$sizes, ...array_map('strlen', $bodies)]) > self::MAX_MESSAGE_BYTES) { + throw new EmailError( + 'message_too_large', + 'These files make the email larger than 40 MB after Base64 encoding, so no email was sent.', + why: 'An email can be at most ' . self::MESSAGE_LIMIT . '.', + fix: 'Send fewer or smaller files, or link to large files from the email body.', + field: 'attachments' + ); + } + $references = []; + $uploads = []; + foreach ($attachments as $index => $attachment) { + $sha256 = hash('sha256', $attachment['content']); + $uploads[$sha256] ??= [ + 'filename' => $attachment['filename'], + 'size' => $sizes[$index], + 'content' => $attachment['content'], + 'field' => "attachments__{$index}__content", + ]; + unset($attachment['content']); + $references[] = ['sha256' => $sha256, ...$attachment]; + } - return $attachments; + return [$references, $uploads]; + } + + /** + * @param list $sizes + */ + private static function messageBytes(array $sizes): float + { + $bytes = (float) self::MESSAGE_HEADERS_BYTES; + foreach ($sizes as $size) { + $bytes += ceil(ceil($size / 3) * 4 * 78 / 76); + } + + return $bytes; } /** @@ -470,13 +543,13 @@ private static function attachment(string $field, mixed $file): array if (!self::isUtf8Text($file['filename'] ?? null)) { throw self::invalidAttachment('An attachment needs its filename as a UTF-8 string.', "{$field}__filename"); } - if (!is_string($file['content'] ?? null)) { + if (!is_string($file['content'] ?? null) || $file['content'] === '') { throw self::invalidAttachment( - "An attachment needs the file's bytes as a string, such as file_get_contents() returns.", + "An attachment needs the file's bytes as a nonempty string, such as file_get_contents() returns.", "{$field}__content" ); } - $attachment = ['filename' => $file['filename'], 'content' => base64_encode($file['content'])]; + $attachment = ['filename' => $file['filename'], 'content' => $file['content']]; foreach (['content_type', 'content_id'] as $name) { if (!array_key_exists($name, $file)) { continue; @@ -531,6 +604,179 @@ private static function invalid(string $code, string $message, string $field): E return new EmailError($code, $message, field: $field); } + /** + * @param array $files + */ + private function upload(string $appHost, string $secretKey, array $files, int $maxRetries): void + { + $request = []; + foreach ($files as $sha256 => $file) { + $request[] = ['sha256' => (string) $sha256, 'size' => $file['size']]; + } + $body = json_encode(['files' => $request], JSON_THROW_ON_ERROR); + $headers = $this->headers($secretKey, ['Content-Type: application/json']); + $url = $appHost . self::UPLOADS_PATH; + try { + $slots = $this->withRetries($maxRetries, function () use ($url, $headers, $body, $files): array { + return self::parseUploadReply(array_keys($files), ...$this->request('POST', $url, $headers, $body)); + }); + } catch (EmailError $error) { + throw self::onAttachmentField($error, array_column(array_values($files), 'field')); + } + foreach ($slots as $sha256 => $slot) { + $file = $files[$sha256]; + $this->withRetries($maxRetries, function () use ($slot, $file): void { + $this->put($slot['url'], $slot['headers'], $file); + }); + } + } + + /** + * @param list $headers + * @param Upload $file + */ + private function put(string $url, array $headers, array $file): void + { + $timeoutMs = $this->requestTimeoutMs + intdiv($file['size'], self::UPLOAD_BYTES_PER_EXTRA_MS); + try { + [$status, $body] = ($this->transport)('PUT', $url, $headers, $file['content'], $timeoutMs); + } catch (EmailError $error) { + if ($error->getErrorCode() !== 'network_error') { + throw $error; + } + throw new EmailError( + 'network_error', + "A file didn't reach PostHog's file storage, so no email was sent: " . $error->getMessage(), + field: $file['field'], + previous: $error + ); + } + if ($status >= 200 && $status < 300) { + return; + } + $answer = "HTTP $status"; + if (preg_match('#([^<]{1,100})#', $body, $code) === 1) { + $answer .= ' ' . html_entity_decode($code[1], ENT_XML1); + if (preg_match('#([^<]{1,300})#', $body, $message) === 1) { + $answer .= ': ' . html_entity_decode($message[1], ENT_XML1); + } + } + throw new EmailError( + 'upload_failed', + self::onOneLogLine( + "PostHog's file storage refused the upload of {$file['filename']}, so no email was sent. " + . "It answered $answer." + ), + $status, + why: 'PostHog sends an email with files only after every file reached its file storage.', + fix: 'Send the email again. The client asks for a new upload URL on every send.', + field: $file['field'] + ); + } + + /** + * @param list $sha256s + * @param list $headers + * @return array}> + */ + private static function parseUploadReply(array $sha256s, int $status, string $body, array $headers): array + { + $fields = self::replyFields($body); + if ($status === 404) { + throw new EmailError( + 'field_not_accepted', + "This PostHog doesn't take attachments yet, so no email was sent.", + $status, + fix: 'Send the email without attachments, or update PostHog.', + field: 'files' + ); + } + if ($status < 200 || $status >= 300) { + throw self::replyError($fields, $status, $headers); + } + $slots = []; + foreach (is_array($fields['files'] ?? null) ? $fields['files'] : [] as $slot) { + if (is_array($slot) && is_string($slot['sha256'] ?? null)) { + $slots[$slot['sha256']] = $slot; + } + } + $uploads = []; + foreach ($sha256s as $sha256) { + $slot = $slots[$sha256] ?? null; + if (($slot['status'] ?? null) === 'present') { + continue; + } + $upload = $slot !== null && $slot['status'] === 'upload' ? self::uploadTarget($slot) : null; + if ($upload === null) { + throw new EmailError( + 'api_error', + "PostHog didn't answer where to upload a file, so no email was sent.", + $status + ); + } + $uploads[$sha256] = $upload; + } + + return $uploads; + } + + /** + * @return array{url: string, headers: list}|null + */ + private static function uploadTarget(array $slot): ?array + { + $url = $slot['url'] ?? null; + $scheme = is_string($url) ? strtolower((string) parse_url($url, PHP_URL_SCHEME)) : ''; + $headers = $slot['headers'] ?? null; + if ( + !in_array($scheme, ['http', 'https'], true) + || !is_string($slot['method'] ?? null) + || strtoupper($slot['method']) !== 'PUT' + || !is_array($headers) + || ($headers !== [] && array_is_list($headers)) + ) { + return null; + } + $lines = []; + foreach ($headers as $name => $value) { + if ( + preg_match(self::HEADER_NAME_PATTERN, (string) $name) !== 1 + || !is_string($value) + || strpbrk($value, "\r\n\0") !== false + ) { + return null; + } + $lines[] = "$name: $value"; + } + + return ['url' => $url, 'headers' => $lines]; + } + + /** + * @param list $attachmentFields The field of each requested file, in request order. + */ + private static function onAttachmentField(EmailError $error, array $attachmentFields): EmailError + { + $field = $error->getField(); + if ($field === null || preg_match('/^files(?:__(\d+))?(?:__|$)/D', $field, $match) !== 1) { + return $error; + } + $index = $match[1] ?? null; + $field = $index === null ? 'attachments' : $attachmentFields[(int) $index] ?? 'attachments'; + + return new EmailError( + $error->getErrorCode(), + $error->getMessage(), + $error->getStatusCode(), + why: $error->getWhy(), + fix: $error->getFix(), + docsUrl: $error->getDocsUrl(), + field: $field, + retryAfterMs: $error->getRetryAfterMs(), + previous: $error + ); + } + /** * @return array{string, string} */ @@ -652,7 +898,7 @@ private static function isRetryable(EmailError $error): bool return $error->getErrorCode() === 'idempotency_key_in_flight'; } if ($status === 429) { - return $error->getErrorCode() !== 'quota_limited'; + return !in_array($error->getErrorCode(), ['quota_limited', 'upload_quota_exceeded'], true); } return $status === 503; @@ -702,11 +948,15 @@ private static function curlTransport( $responseHeaders = []; $responseBody = ''; $tooLarge = false; + $headers[] = self::NO_EXPECT_100_CONTINUE; + if ($body !== null && preg_grep('/^content-type:/i', $headers) === []) { + $headers[] = self::NO_FORM_CONTENT_TYPE; + } $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => $url, CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_HTTPHEADER => [...$headers, self::NO_EXPECT_100_CONTINUE], + CURLOPT_HTTPHEADER => $headers, CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$responseBody, &$tooLarge): int { if (strlen($responseBody) + strlen($chunk) > self::MAX_RESPONSE_BYTES) { $tooLarge = true; diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index 91ab6451..431cec3c 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -13,6 +13,7 @@ class EmailsClientTest extends TestCase private const TEMPLATE_ID = '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b'; private ?LocalHttpServer $server = null; + private ?LocalHttpServer $bucket = null; protected function setUp(): void { @@ -23,6 +24,7 @@ protected function setUp(): void protected function tearDown(): void { $this->server?->stop(); + $this->bucket?->stop(); } private function client(array $options = [], ?string $apiKey = 'phc_project'): Client @@ -73,6 +75,57 @@ public function testACodeSendReachesTheSendRouteOverHttp(): void ], json_decode($requests[0]['body'], true)); } + public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void + { + $file = "%PDF-1.7\n" . str_repeat("0 0 obj << >> endobj\n", 100); + $sha256 = hash('sha256', $file); + $checksum = base64_encode(hash('sha256', $file, true)); + $this->bucket = new LocalHttpServer([['status' => 200, 'body' => '']]); + $bucketUrl = 'http://' . $this->bucket->address() . "/attachments/team/2/$sha256?X-Amz-Signature=abc"; + $this->server = new LocalHttpServer([ + ['status' => 200, 'body' => json_encode(['files' => [[ + 'sha256' => $sha256, + 'status' => 'upload', + 'url' => $bucketUrl, + 'method' => 'PUT', + 'headers' => ['Content-Length' => (string) strlen($file), 'x-amz-checksum-sha256' => $checksum], + 'url_expires_at' => '2026-11-02T12:15:00Z', + ]]])], + ['status' => 202, 'body' => '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}'], + ]); + + $this->client()->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => [['filename' => 'receipt.pdf', 'content' => $file]], + ]); + + [$upload, $send] = $this->server->requests(); + $this->assertSame( + 'POST /api/projects/@current/messaging/attachments/uploads/ HTTP/1.1', + $upload['requestLine'] + ); + $this->assertSame('Bearer phs_secret', $upload['headers']['authorization']); + $this->assertSame( + ['files' => [['sha256' => $sha256, 'size' => strlen($file)]]], + json_decode($upload['body'], true) + ); + [$put] = $this->bucket->requests(); + $this->assertSame("PUT /attachments/team/2/$sha256?X-Amz-Signature=abc HTTP/1.1", $put['requestLine']); + $this->assertSame($file, $put['body']); + $this->assertSame((string) strlen($file), $put['headers']['content-length']); + $this->assertSame($checksum, $put['headers']['x-amz-checksum-sha256']); + $this->assertArrayNotHasKey('authorization', $put['headers']); + $this->assertArrayNotHasKey('content-type', $put['headers']); + $this->assertArrayNotHasKey('expect', $put['headers']); + $this->assertSame( + [['sha256' => $sha256, 'filename' => 'receipt.pdf']], + json_decode($send['body'], true)['attachments'] + ); + } + public function testARetryAfterHeaderComesBackOnTheError(): void { $this->server = new LocalHttpServer([ diff --git a/test/EmailsTest.php b/test/EmailsTest.php index d0e80556..789a90b7 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -18,7 +18,6 @@ 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/'; private const INVITE = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; - private const INVITE_BASE64 = 'QkVHSU46VkNBTEVOREFSDQpFTkQ6VkNBTEVOREFSDQo='; private const SECRET_CONTENT = 'secret-file-content'; /** @var list, body: ?string, timeoutMs: int, at: DateTimeImmutable}> */ @@ -236,12 +235,62 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void ], $this->body()); } - public function testCodeSendPostsCopiesAndAttachmentsEncodedOnceForEveryAttempt(): void + private const UPLOAD_URL = 'https://us.posthog.com/api/projects/@current/messaging/attachments/uploads/'; + private const INVITE_SHA256 = '215efae54e34ac5657da47b49c8d1bd8ea12649ee1efa838c9a47d7233f18afb'; + private const INVITE_CHECKSUM = 'IV765U40rFZX2ke0nI0b2OoSZJ7h76g4yaR9cjPxivs='; + private const PNG = "\x89PNG\r\n\x1a\n\x00"; + private const PNG_SHA256 = '843ac23b1736b4487ec81cf7c07ddd9bb46ae5b7818c2c3843d99d62fa75f3c9'; + private const BUCKET_URL = 'https://attachments.example/team/2/' + . self::INVITE_SHA256 . '?X-Amz-Signature=signature-secret'; + + private static function uploadSlot(string $sha256, int $size, string $url = self::BUCKET_URL): array { - $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); - $png = "\x89PNG\r\n\x1a\n\x00"; + return [ + 'sha256' => $sha256, + 'status' => 'upload', + 'url' => $url, + 'method' => 'PUT', + 'headers' => ['Content-Length' => (string) $size, 'x-amz-checksum-sha256' => self::INVITE_CHECKSUM], + 'url_expires_at' => '2026-11-02T12:15:00Z', + ]; + } - $this->emails()->send([ + private static function presentSlot(string $sha256): array + { + return ['sha256' => $sha256, 'status' => 'present', 'expires_at' => '2026-12-09T12:00:00Z']; + } + + /** @return array{int, string, list} */ + private static function slots(array ...$slots): array + { + return [200, json_encode(['files' => $slots]), []]; + } + + private static function sendWithFiles(array $attachments, array $email = []): array + { + return $email + [ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => $attachments, + ]; + } + + private static function invite(array $file = []): array + { + return $file + ['filename' => 'invite.ics', 'content' => self::INVITE]; + } + + public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void + { + $this->reply( + self::slots(self::uploadSlot(self::INVITE_SHA256, 32), self::presentSlot(self::PNG_SHA256)), + [200, '', []], + [202, '{"message_id":"msg_1"}', []] + ); + + $result = $this->emails(['migratedFrom' => 'resend'])->send([ 'to' => 'user@example.com', 'template_key' => 'invite', 'subject' => 'You are invited', @@ -249,11 +298,28 @@ public function testCodeSendPostsCopiesAndAttachmentsEncodedOnceForEveryAttempt( 'cc' => 'manager@example.com', 'bcc' => ['audit@example.com', 'archive@example.com'], 'attachments' => [ - ['filename' => 'invite.ics', 'content' => self::INVITE, 'content_type' => 'text/calendar'], - ['filename' => 'logo.png', 'content' => $png, 'content_id' => 'logo'], + self::invite(['content_type' => 'text/calendar']), + ['filename' => 'logo.png', 'content' => self::PNG, 'content_id' => 'logo'], ], ]); + $this->assertSame('msg_1', $result->getId()); + $this->assertSame( + ['POST ' . self::UPLOAD_URL, 'PUT ' . self::BUCKET_URL, 'POST ' . self::SEND_URL], + array_map(static fn(array $request): string => $request['method'] . ' ' . $request['url'], $this->requests) + ); + $this->assertSame( + ['files' => [['sha256' => self::INVITE_SHA256, 'size' => 32], ['sha256' => self::PNG_SHA256, 'size' => 9]]], + $this->body(0) + ); + $this->assertSame('Bearer phs_secret', $this->header('Authorization', 0)); + $this->assertSame('resend', $this->header('X-PostHog-Migrated-From', 0)); + $this->assertSame( + ['Content-Length: 32', 'x-amz-checksum-sha256: ' . self::INVITE_CHECKSUM], + $this->requests[1]['headers'], + 'the upload carries only the headers PostHog signed, and never the secret key' + ); + $this->assertSame(self::INVITE, $this->requests[1]['body']); $this->assertSame([ 'template_key' => 'invite', 'subject' => 'You are invited', @@ -261,33 +327,260 @@ public function testCodeSendPostsCopiesAndAttachmentsEncodedOnceForEveryAttempt( 'cc' => ['manager@example.com'], 'bcc' => ['audit@example.com', 'archive@example.com'], 'attachments' => [ - ['filename' => 'invite.ics', 'content' => self::INVITE_BASE64, 'content_type' => 'text/calendar'], - ['filename' => 'logo.png', 'content' => 'iVBORw0KGgoA', 'content_id' => 'logo'], + ['sha256' => self::INVITE_SHA256, 'filename' => 'invite.ics', 'content_type' => 'text/calendar'], + ['sha256' => self::PNG_SHA256, 'filename' => 'logo.png', 'content_id' => 'logo'], ], 'capture_engagement' => true, 'to' => 'user@example.com', - ], $this->body()); - $this->assertSame($this->requests[0]['body'], $this->requests[1]['body']); + ], $this->body(2)); } - public function testANullContentTypeOrContentIdCountsAsLeftOut(): void + public function testTheSameFileTwiceIsUploadedOnceAndAttachedTwice(): void { - $this->emails()->send([ - 'to' => 'user@example.com', - 'template_key' => 'receipt', - 'subject' => 'Your receipt', - 'text' => 'Thanks!', - 'attachments' => [ - ['filename' => 'invite.ics', 'content' => self::INVITE, 'content_type' => null, 'content_id' => null], + $this->reply(self::slots(self::uploadSlot(self::INVITE_SHA256, 32)), [200, '', []]); + + $this->emails()->send(self::sendWithFiles([self::invite(), self::invite(['filename' => 'copy.ics'])])); + + $this->assertSame(['files' => [['sha256' => self::INVITE_SHA256, 'size' => 32]]], $this->body(0)); + $this->assertCount(3, $this->requests); + $this->assertSame( + [ + ['sha256' => self::INVITE_SHA256, 'filename' => 'invite.ics'], + ['sha256' => self::INVITE_SHA256, 'filename' => 'copy.ics'], ], - ]); + $this->body(2)['attachments'] + ); + } + + public function testARetriedSendDoesNotUploadAgain(): void + { + $this->reply( + self::slots(self::uploadSlot(self::INVITE_SHA256, 32)), + [200, '', []], + [503, '{"code":"unavailable"}', []], + [202, '{"message_id":"msg_1"}', []] + ); + + $this->emails()->send(self::sendWithFiles([self::invite()])); + + $this->assertSame(['POST', 'PUT', 'POST', 'POST'], array_column($this->requests, 'method')); + $this->assertSame($this->requests[2]['body'], $this->requests[3]['body']); + $this->assertSame($this->header('Idempotency-Key', 2), $this->header('Idempotency-Key', 3)); + } + + public function testANullContentTypeOrContentIdCountsAsLeftOut(): void + { + $this->reply(self::slots(self::presentSlot(self::INVITE_SHA256))); + + $this->emails()->send(self::sendWithFiles([self::invite(['content_type' => null, 'content_id' => null])])); $this->assertSame( - [['filename' => 'invite.ics', 'content' => self::INVITE_BASE64]], - $this->body()['attachments'] + [['sha256' => self::INVITE_SHA256, 'filename' => 'invite.ics']], + $this->body(1)['attachments'] ); } + public function testAnUploadGetsMoreTimeForALargerFile(): void + { + $file = str_repeat('a', 1_000_000); + $sha256 = hash('sha256', $file); + $this->reply(self::slots(self::uploadSlot($sha256, 1_000_000)), [200, '', []]); + + $this->emails()->send(self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]])); + + $this->assertSame(Emails::DEFAULT_REQUEST_TIMEOUT_MS, $this->requests[0]['timeoutMs']); + $this->assertSame(Emails::DEFAULT_REQUEST_TIMEOUT_MS + 10_000, $this->requests[1]['timeoutMs']); + } + + public static function bucketFailures(): array + { + $xml = 'BadDigest' + . 'The SHA256 you specified did not match the calculated checksum.'; + + return [ + 'refused' => [[400, $xml, []], 400, 'BadDigest: The SHA256 you specified did not match'], + 'expired' => [[403, 'AccessDeniedRequest has expired', []], + 403, 'AccessDenied: Request has expired'], + 'not XML' => [[500, 'upstream exploded', []], 500, 'HTTP 500'], + ]; + } + + /** @param array{int, string, list} $failure */ + #[DataProvider('bucketFailures')] + public function testAFailedUploadSendsNothingAndSaysWhatTheBucketAnswered( + array $failure, + int $status, + string $answer + ): void { + $this->reply( + self::slots(self::presentSlot(self::PNG_SHA256), self::uploadSlot(self::INVITE_SHA256, 32)), + $failure + ); + + $error = $this->sendError(self::sendWithFiles([ + ['filename' => 'logo.png', 'content' => self::PNG], + self::invite(), + ])); + + $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame($status, $error->getStatusCode()); + $this->assertSame('attachments__1__content', $error->getField()); + $this->assertStringContainsString($answer, $error->getMessage()); + $this->assertStringContainsString('invite.ics', $error->getMessage()); + $this->assertStringNotContainsString('signature-secret', $error->getMessage() . $error->getFix()); + $this->assertSame(['POST', 'PUT'], array_column($this->requests, 'method'), 'no email was sent'); + } + + public static function retriedUploadFailures(): array + { + return [ + 'network error' => [new EmailError('network_error', 'The request failed.')], + 'bucket slowing down' => [[503, 'SlowDownSlow down', []]], + ]; + } + + /** @param array{int, string, list}|EmailError $failure */ + #[DataProvider('retriedUploadFailures')] + public function testAnUploadThatFailsOnTheWayIsRetried($failure): void + { + $this->reply(self::slots(self::uploadSlot(self::INVITE_SHA256, 32)), $failure, [200, '', []]); + + $this->emails()->send(self::sendWithFiles([self::invite()])); + + $this->assertSame(['POST', 'PUT', 'PUT', 'POST'], array_column($this->requests, 'method')); + $this->assertSame(self::INVITE, $this->requests[2]['body']); + } + + public function testAnUploadRefusalNamesTheAttachmentAndSendsNothing(): void + { + $this->reply([400, json_encode([ + 'type' => 'invalid_request', + 'code' => 'message_too_large', + 'detail' => 'These files are too large for one email, so PostHog didn\'t prepare the uploads.', + 'attr' => 'files__1__size', + 'why' => 'An email can be at most 40 MB after Base64 encoding, about 29 MB of files.', + 'fix' => 'Send fewer or smaller files, or link to large files from the email body.', + ]), []]); + + $error = $this->sendError(self::sendWithFiles([ + self::invite(), + self::invite(['filename' => 'copy.ics']), + ['filename' => 'logo.png', 'content' => self::PNG], + ])); + + $this->assertSame('message_too_large', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame('attachments__2__content', $error->getField()); + $this->assertSame('Send fewer or smaller files, or link to large files from the email body.', $error->getFix()); + $this->assertCount(1, $this->requests); + } + + public function testTheDailyUploadLimitIsNotRetried(): void + { + $this->reply([429, '{"code":"upload_quota_exceeded","detail":"No more uploads today."}', []]); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('upload_quota_exceeded', $error->getErrorCode()); + $this->assertCount(1, $this->requests); + } + + public function testAPostHogWithoutTheUploadRouteRefusesTheFilesAndSendsNothing(): void + { + $this->reply([404, '{"type":"invalid_request","code":"not_found","detail":"Not found."}', []]); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('field_not_accepted', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame('attachments', $error->getField()); + $this->assertCount(1, $this->requests); + } + + public function testAnUnavailableUploadRouteIsRetried(): void + { + $this->reply([503, '{"code":"delivery_unavailable"}', []], self::slots(self::presentSlot(self::INVITE_SHA256))); + + $this->emails()->send(self::sendWithFiles([self::invite()])); + + $this->assertSame(['POST', 'POST', 'POST'], array_column($this->requests, 'method')); + $this->assertSame(self::SEND_URL, $this->requests[2]['url']); + } + + public static function unusableUploadAnswers(): array + { + $slot = self::uploadSlot(self::INVITE_SHA256, 32); + + return [ + 'no slot for the file' => [['files' => [self::presentSlot(self::PNG_SHA256)]]], + 'no files' => [[]], + 'an unknown status' => [['files' => [['status' => 'later'] + $slot]]], + 'a file URL' => [['files' => [['url' => 'file:///etc/passwd'] + $slot]]], + 'another method' => [['files' => [['method' => 'POST'] + $slot]]], + 'a header with a line break' => [['files' => [['headers' => ['X-A' => "a\r\nAuthorization: b"]] + $slot]]], + 'headers as a list' => [['files' => [['headers' => ['Content-Length: 32']] + $slot]]], + ]; + } + + #[DataProvider('unusableUploadAnswers')] + public function testAnUploadAnswerTheClientCannotFollowSendsNothing(array $answer): void + { + $this->reply([200, json_encode($answer), []]); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('api_error', $error->getErrorCode()); + $this->assertCount(1, $this->requests, 'nothing was uploaded or sent'); + } + + public function testTheLargestFileThatFitsTheMessageIsUploaded(): void + { + $file = str_repeat('a', 29_182_866); + $this->reply(self::slots(self::presentSlot(hash('sha256', $file)))); + + $this->emails()->send(self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]])); + + $this->assertSame(['files' => [['sha256' => hash('sha256', $file), 'size' => 29_182_866]]], $this->body(0)); + } + + public static function messagesTooLarge(): array + { + return [ + 'one byte more than fits' => [[str_repeat('a', 29_182_867)], []], + 'a body that leaves no room' => [[str_repeat('a', 29_000_000)], ['text' => str_repeat('b', 200_000)]], + 'files that only fit alone' => [[str_repeat('a', 15_000_000), str_repeat('b', 15_000_000)], []], + ]; + } + + #[DataProvider('messagesTooLarge')] + public function testAMessageOverFortyMegabytesIsRefusedBeforeAnyRequest(array $contents, array $email): void + { + $files = array_map( + static fn(string $content): array => ['filename' => 'f.pdf', 'content' => $content], + $contents + ); + + $error = $this->sendError(self::sendWithFiles($files, $email)); + + $this->assertSame('message_too_large', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame('attachments', $error->getField()); + $this->assertNull($error->getStatusCode()); + $this->assertStringContainsString('40 MB', $error->getMessage()); + $this->assertStringContainsString('about 29 MB of files', $error->getWhy()); + $this->assertSame([], $this->requests); + } + + public function testMoreThanTenFilesAreRefusedBeforeAnyRequest(): void + { + $error = $this->sendError(self::sendWithFiles(array_fill(0, 11, self::invite()))); + + $this->assertSame('too_many_attachments', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame('attachments', $error->getField()); + $this->assertSame([], $this->requests); + } + public function testEmptyCopyAndAttachmentListsAreLeftOut(): void { $this->emails()->send([ @@ -479,6 +772,7 @@ public static function attachmentErrors(): array 'attachments__0__content', ], 'no filename' => [[['content' => self::SECRET_CONTENT]], 'attachments__0__filename'], + 'empty content' => [[['filename' => 'a.txt', 'content' => '']], 'attachments__0__content'], 'filename not text' => [[['filename' => 1] + $file], 'attachments__0__filename'], 'path' => [[$file + ['path' => '/tmp/a.txt']], 'attachments__0__path'], 'unknown field' => [[$file + ['contentType' => 'text/plain']], 'attachments__0__contentType'], From 72a31745074ad4b45c74fa3038a9c7a1fcabbadd Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:13:43 +0000 Subject: [PATCH 28/50] fix(emails): end an upload refusal with one period Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 2 +- test/EmailsTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Emails.php b/lib/Emails.php index eb228dd7..6675b1d3 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -665,7 +665,7 @@ private function put(string $url, array $headers, array $file): void 'upload_failed', self::onOneLogLine( "PostHog's file storage refused the upload of {$file['filename']}, so no email was sent. " - . "It answered $answer." + . 'It answered ' . rtrim($answer, '.') . '.' ), $status, why: 'PostHog sends an email with files only after every file reached its file storage.', diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 789a90b7..1c04ca73 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -427,6 +427,7 @@ public function testAFailedUploadSendsNothingAndSaysWhatTheBucketAnswered( $this->assertSame('attachments__1__content', $error->getField()); $this->assertStringContainsString($answer, $error->getMessage()); $this->assertStringContainsString('invite.ics', $error->getMessage()); + $this->assertStringEndsNotWith('..', $error->getMessage()); $this->assertStringNotContainsString('signature-secret', $error->getMessage() . $error->getFix()); $this->assertSame(['POST', 'PUT'], array_column($this->requests, 'method'), 'no email was sent'); } From d87ef8b0cca9da089d57b5141467fe005615aec5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:15:24 +0000 Subject: [PATCH 29/50] refactor(emails): name the attachment in an upload refusal before building the error Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 41 +++++++++++++---------------------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index 6675b1d3..8e78dfc3 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -616,13 +616,9 @@ private function upload(string $appHost, string $secretKey, array $files, int $m $body = json_encode(['files' => $request], JSON_THROW_ON_ERROR); $headers = $this->headers($secretKey, ['Content-Type: application/json']); $url = $appHost . self::UPLOADS_PATH; - try { - $slots = $this->withRetries($maxRetries, function () use ($url, $headers, $body, $files): array { - return self::parseUploadReply(array_keys($files), ...$this->request('POST', $url, $headers, $body)); - }); - } catch (EmailError $error) { - throw self::onAttachmentField($error, array_column(array_values($files), 'field')); - } + $slots = $this->withRetries($maxRetries, function () use ($url, $headers, $body, $files): array { + return self::parseUploadReply($files, ...$this->request('POST', $url, $headers, $body)); + }); foreach ($slots as $sha256 => $slot) { $file = $files[$sha256]; $this->withRetries($maxRetries, function () use ($slot, $file): void { @@ -675,11 +671,11 @@ private function put(string $url, array $headers, array $file): void } /** - * @param list $sha256s + * @param array $files * @param list $headers * @return array}> */ - private static function parseUploadReply(array $sha256s, int $status, string $body, array $headers): array + private static function parseUploadReply(array $files, int $status, string $body, array $headers): array { $fields = self::replyFields($body); if ($status === 404) { @@ -688,10 +684,11 @@ private static function parseUploadReply(array $sha256s, int $status, string $bo "This PostHog doesn't take attachments yet, so no email was sent.", $status, fix: 'Send the email without attachments, or update PostHog.', - field: 'files' + field: 'attachments' ); } if ($status < 200 || $status >= 300) { + $fields['attr'] = self::attachmentField(self::text($fields['attr'] ?? null), $files); throw self::replyError($fields, $status, $headers); } $slots = []; @@ -701,7 +698,7 @@ private static function parseUploadReply(array $sha256s, int $status, string $bo } } $uploads = []; - foreach ($sha256s as $sha256) { + foreach (array_keys($files) as $sha256) { $slot = $slots[$sha256] ?? null; if (($slot['status'] ?? null) === 'present') { continue; @@ -753,28 +750,16 @@ private static function uploadTarget(array $slot): ?array } /** - * @param list $attachmentFields The field of each requested file, in request order. + * @param array $files */ - private static function onAttachmentField(EmailError $error, array $attachmentFields): EmailError + private static function attachmentField(?string $uploadField, array $files): ?string { - $field = $error->getField(); - if ($field === null || preg_match('/^files(?:__(\d+))?(?:__|$)/D', $field, $match) !== 1) { - return $error; + if ($uploadField === null || preg_match('/^files(?:__(\d+))?(?:__|$)/D', $uploadField, $match) !== 1) { + return $uploadField; } $index = $match[1] ?? null; - $field = $index === null ? 'attachments' : $attachmentFields[(int) $index] ?? 'attachments'; - return new EmailError( - $error->getErrorCode(), - $error->getMessage(), - $error->getStatusCode(), - why: $error->getWhy(), - fix: $error->getFix(), - docsUrl: $error->getDocsUrl(), - field: $field, - retryAfterMs: $error->getRetryAfterMs(), - previous: $error - ); + return $index === null ? 'attachments' : array_values($files)[(int) $index]['field'] ?? 'attachments'; } /** From 7a720ed1bcc89aae73b321de335a3edbdef5d4d2 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:27:42 +0000 Subject: [PATCH 30/50] fix(emails): stop uploads when the client shuts down, honor storage Retry-After - Check the client before each upload and before the send, so a shutdown mid-flow sends nothing more. - Wait for the storage's Retry-After between upload attempts, and fail at once past the cap. - Name upload_failed by the storage status: service_unavailable for 5xx, application_error otherwise. - Treat an upload answer without a status as one the client cannot follow. - Count the message size in one pass with a per-file helper. Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- lib/EmailError.php | 3 +++ lib/Emails.php | 44 +++++++++++++++++--------------- test/EmailsTest.php | 62 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/lib/EmailError.php b/lib/EmailError.php index 3604c9d7..9f0e022f 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -134,6 +134,9 @@ private static function nameFor(?int $status, string $code): string if (in_array($code, self::REFUSED_ATTACHMENT_CODES, true)) { return 'validation_error'; } + if ($code === 'upload_failed') { + return $status !== null && $status >= 500 ? 'service_unavailable' : 'application_error'; + } if ($status === null) { if ($code === 'network_error') { return 'service_unavailable'; diff --git a/lib/Emails.php b/lib/Emails.php index 8e78dfc3..fcc20c81 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -242,6 +242,7 @@ public function send(array $email): SendEmailResult [$appHost, $secretKey] = $this->connect(self::SEND_NOT_DONE, self::SEND_KEY_NEEDED); if ($files !== []) { $this->upload($appHost, $secretKey, $files, $maxRetries); + $this->refuseWhenClosed(self::SEND_NOT_DONE); } $headers = $this->headers($secretKey, [ 'Content-Type: application/json', @@ -469,11 +470,15 @@ private static function attachments(mixed $files, array $bodies): array ); } $attachments = []; + $messageBytes = self::MESSAGE_HEADERS_BYTES; foreach ($files as $index => $file) { - $attachments[] = self::attachment("attachments__$index", $file); + $attachments[] = $attachment = self::attachment("attachments__$index", $file); + $messageBytes += self::base64TransferBytes(strlen($attachment['content'])); } - $sizes = array_map(static fn(array $file): int => strlen($file['content']), $attachments); - if (self::messageBytes([...$sizes, ...array_map('strlen', $bodies)]) > self::MAX_MESSAGE_BYTES) { + foreach ($bodies as $body) { + $messageBytes += self::base64TransferBytes(strlen($body)); + } + if ($messageBytes > self::MAX_MESSAGE_BYTES) { throw new EmailError( 'message_too_large', 'These files make the email larger than 40 MB after Base64 encoding, so no email was sent.', @@ -488,7 +493,7 @@ private static function attachments(mixed $files, array $bodies): array $sha256 = hash('sha256', $attachment['content']); $uploads[$sha256] ??= [ 'filename' => $attachment['filename'], - 'size' => $sizes[$index], + 'size' => strlen($attachment['content']), 'content' => $attachment['content'], 'field' => "attachments__{$index}__content", ]; @@ -499,17 +504,9 @@ private static function attachments(mixed $files, array $bodies): array return [$references, $uploads]; } - /** - * @param list $sizes - */ - private static function messageBytes(array $sizes): float + private static function base64TransferBytes(int $bytes): float { - $bytes = (float) self::MESSAGE_HEADERS_BYTES; - foreach ($sizes as $size) { - $bytes += ceil(ceil($size / 3) * 4 * 78 / 76); - } - - return $bytes; + return ceil(ceil($bytes / 3) * 4 * 78 / 76); } /** @@ -620,6 +617,7 @@ private function upload(string $appHost, string $secretKey, array $files, int $m return self::parseUploadReply($files, ...$this->request('POST', $url, $headers, $body)); }); foreach ($slots as $sha256 => $slot) { + $this->refuseWhenClosed(self::SEND_NOT_DONE); $file = $files[$sha256]; $this->withRetries($maxRetries, function () use ($slot, $file): void { $this->put($slot['url'], $slot['headers'], $file); @@ -635,7 +633,7 @@ private function put(string $url, array $headers, array $file): void { $timeoutMs = $this->requestTimeoutMs + intdiv($file['size'], self::UPLOAD_BYTES_PER_EXTRA_MS); try { - [$status, $body] = ($this->transport)('PUT', $url, $headers, $file['content'], $timeoutMs); + [$status, $body, $answerHeaders] = ($this->transport)('PUT', $url, $headers, $file['content'], $timeoutMs); } catch (EmailError $error) { if ($error->getErrorCode() !== 'network_error') { throw $error; @@ -666,7 +664,8 @@ private function put(string $url, array $headers, array $file): void $status, why: 'PostHog sends an email with files only after every file reached its file storage.', fix: 'Send the email again. The client asks for a new upload URL on every send.', - field: $file['field'] + field: $file['field'], + retryAfterMs: self::retryAfterMs($answerHeaders) ); } @@ -703,7 +702,7 @@ private static function parseUploadReply(array $files, int $status, string $body if (($slot['status'] ?? null) === 'present') { continue; } - $upload = $slot !== null && $slot['status'] === 'upload' ? self::uploadTarget($slot) : null; + $upload = ($slot['status'] ?? null) === 'upload' ? self::uploadTarget($slot) : null; if ($upload === null) { throw new EmailError( 'api_error', @@ -773,9 +772,7 @@ private function connect(string $notDone, string $keyNeeded): array "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."); - } + $this->refuseWhenClosed($notDone); if ($this->appHost === null) { throw new EmailError( 'missing_app_host', @@ -790,6 +787,13 @@ private function connect(string $notDone, string $keyNeeded): array return [$this->appHost, $this->secretKey]; } + private function refuseWhenClosed(string $notDone): void + { + if (($this->isClosed)()) { + throw new EmailError('client_closed', "The PostHog client is shut down, so $notDone."); + } + } + /** * @param list $extra * @return list diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 1c04ca73..da5db426 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -243,12 +243,12 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void private const BUCKET_URL = 'https://attachments.example/team/2/' . self::INVITE_SHA256 . '?X-Amz-Signature=signature-secret'; - private static function uploadSlot(string $sha256, int $size, string $url = self::BUCKET_URL): array + private static function uploadSlot(string $sha256, int $size): array { return [ 'sha256' => $sha256, 'status' => 'upload', - 'url' => $url, + 'url' => self::BUCKET_URL, 'method' => 'PUT', 'headers' => ['Content-Length' => (string) $size, 'x-amz-checksum-sha256' => self::INVITE_CHECKSUM], 'url_expires_at' => '2026-11-02T12:15:00Z', @@ -398,10 +398,12 @@ public static function bucketFailures(): array . 'The SHA256 you specified did not match the calculated checksum.'; return [ - 'refused' => [[400, $xml, []], 400, 'BadDigest: The SHA256 you specified did not match'], + 'refused' => [ + [400, $xml, []], 400, 'BadDigest: The SHA256 you specified did not match', 'application_error', + ], 'expired' => [[403, 'AccessDeniedRequest has expired', []], - 403, 'AccessDenied: Request has expired'], - 'not XML' => [[500, 'upstream exploded', []], 500, 'HTTP 500'], + 403, 'AccessDenied: Request has expired', 'application_error'], + 'not XML' => [[500, 'upstream exploded', []], 500, 'HTTP 500', 'service_unavailable'], ]; } @@ -410,7 +412,8 @@ public static function bucketFailures(): array public function testAFailedUploadSendsNothingAndSaysWhatTheBucketAnswered( array $failure, int $status, - string $answer + string $answer, + string $name ): void { $this->reply( self::slots(self::presentSlot(self::PNG_SHA256), self::uploadSlot(self::INVITE_SHA256, 32)), @@ -423,6 +426,7 @@ public function testAFailedUploadSendsNothingAndSaysWhatTheBucketAnswered( ])); $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame($name, $error->getErrorName()); $this->assertSame($status, $error->getStatusCode()); $this->assertSame('attachments__1__content', $error->getField()); $this->assertStringContainsString($answer, $error->getMessage()); @@ -452,6 +456,42 @@ public function testAnUploadThatFailsOnTheWayIsRetried($failure): void $this->assertSame(self::INVITE, $this->requests[2]['body']); } + public function testAStorageRetryAfterOverThirtySecondsFailsAtOnce(): void + { + $slowDown = 'SlowDownSlow down'; + $this->reply(self::slots(self::uploadSlot(self::INVITE_SHA256, 32)), [503, $slowDown, ['Retry-After: 60']]); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame(60_000, $error->getRetryAfterMs()); + $this->assertSame(['POST', 'PUT'], array_column($this->requests, 'method')); + } + + public static function shutdownsDuringTheUploads(): array + { + return ['after the upload request' => [1], 'after an upload' => [2]]; + } + + #[DataProvider('shutdownsDuringTheUploads')] + public function testAClientShutDownDuringTheUploadsSendsNothingMore(int $requestsBeforeTheShutdown): void + { + $emails = $this->emails(['isClosed' => fn(): bool => count($this->requests) >= $requestsBeforeTheShutdown]); + $this->reply( + self::slots(self::uploadSlot(self::INVITE_SHA256, 32), self::uploadSlot(self::PNG_SHA256, 9)), + [200, '', []], + [200, '', []] + ); + + $error = $this->sendError( + self::sendWithFiles([self::invite(), ['filename' => 'logo.png', 'content' => self::PNG]]), + $emails + ); + + $this->assertSame('client_closed', $error->getErrorCode()); + $this->assertCount($requestsBeforeTheShutdown, $this->requests); + } + public function testAnUploadRefusalNamesTheAttachmentAndSendsNothing(): void { $this->reply([400, json_encode([ @@ -515,6 +555,7 @@ public static function unusableUploadAnswers(): array return [ 'no slot for the file' => [['files' => [self::presentSlot(self::PNG_SHA256)]]], 'no files' => [[]], + 'no status' => [['files' => [['sha256' => self::INVITE_SHA256]]]], 'an unknown status' => [['files' => [['status' => 'later'] + $slot]]], 'a file URL' => [['files' => [['url' => 'file:///etc/passwd'] + $slot]]], 'another method' => [['files' => [['method' => 'POST'] + $slot]]], @@ -527,8 +568,15 @@ public static function unusableUploadAnswers(): array public function testAnUploadAnswerTheClientCannotFollowSendsNothing(array $answer): void { $this->reply([200, json_encode($answer), []]); + set_error_handler(static function (int $severity, string $message): never { + throw new \ErrorException($message, 0, $severity); + }); - $error = $this->sendError(self::sendWithFiles([self::invite()])); + try { + $error = $this->sendError(self::sendWithFiles([self::invite()])); + } finally { + restore_error_handler(); + } $this->assertSame('api_error', $error->getErrorCode()); $this->assertCount(1, $this->requests, 'nothing was uploaded or sent'); From 55089ad45023b7989cea7ff3abe71d654fe8082e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:31:54 +0000 Subject: [PATCH 31/50] fix(emails): retry an upload after a passing storage fault An upload puts the same bytes at the same key, so a 500, 502, 503 or 504 from the storage is safe to repeat within the email retry budget. Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- lib/Emails.php | 6 +++++- test/EmailsTest.php | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index eb931830..05036b2b 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,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`, `cc`, `bcc`, `attachments` 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. - **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The whole email can be at most 40 MB after Base64 encoding, which leaves about 29 MB for the files; the SDK checks both limits before any request (`too_many_attachments`, `message_too_large`). Pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. -- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. +- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. A storage error in the 5xx range is retried like the send. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. - **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. 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. diff --git a/lib/Emails.php b/lib/Emails.php index fcc20c81..dd16103c 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -30,7 +30,7 @@ * * A send with attachments first uploads each file PostHog doesn't have yet to the storage URL PostHog * answers, which is on another host than the app host. Each upload gets the request timeout plus 1 ms - * for every 100 bytes of the file. + * for every 100 bytes of the file, and a storage error in the 5xx range is retried as above. * * @phpstan-type Upload array{filename: string, size: int, content: string, field: string} */ @@ -889,6 +889,10 @@ private static function isRetryable(EmailError $error): bool if ($status === 429) { return !in_array($error->getErrorCode(), ['quota_limited', 'upload_quota_exceeded'], true); } + if ($error->getErrorCode() === 'upload_failed') { + // An upload puts the same bytes at the same key, so a passing storage fault is safe to retry. + return in_array($status, [500, 502, 503, 504], true); + } return $status === 503; } diff --git a/test/EmailsTest.php b/test/EmailsTest.php index da5db426..ea243540 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -403,7 +403,7 @@ public static function bucketFailures(): array ], 'expired' => [[403, 'AccessDeniedRequest has expired', []], 403, 'AccessDenied: Request has expired', 'application_error'], - 'not XML' => [[500, 'upstream exploded', []], 500, 'HTTP 500', 'service_unavailable'], + 'not XML' => [[501, 'not implemented', []], 501, 'HTTP 501', 'service_unavailable'], ]; } @@ -441,6 +441,11 @@ public static function retriedUploadFailures(): array return [ 'network error' => [new EmailError('network_error', 'The request failed.')], 'bucket slowing down' => [[503, 'SlowDownSlow down', []]], + 'storage internal error' => [ + [500, 'InternalErrorPlease try again.', []], + ], + 'bad gateway' => [[502, 'Bad Gateway', []]], + 'gateway timeout' => [[504, 'Gateway Timeout', []]], ]; } From b7affe735f739fe2b22b987b8adadc46b6dcbaf4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:37:27 +0000 Subject: [PATCH 32/50] fix(emails): name a storage 401 as an upload failure, guard the send after uploads - upload_failed takes its name before the status mapping, so a storage 401 is no missing API key. - A test now covers a shutdown after the last upload, the only case the check before the send stops. Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- lib/EmailError.php | 6 +++--- test/EmailsTest.php | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/EmailError.php b/lib/EmailError.php index 9f0e022f..5671fe71 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -128,15 +128,15 @@ public function getRetryAfterMs(): ?int private static function nameFor(?int $status, string $code): string { + if ($code === 'upload_failed') { + return $status !== null && $status >= 500 ? 'service_unavailable' : 'application_error'; + } if ($code === 'missing_secret_key' || $status === 401) { return 'missing_api_key'; } if (in_array($code, self::REFUSED_ATTACHMENT_CODES, true)) { return 'validation_error'; } - if ($code === 'upload_failed') { - return $status !== null && $status >= 500 ? 'service_unavailable' : 'application_error'; - } if ($status === null) { if ($code === 'network_error') { return 'service_unavailable'; diff --git a/test/EmailsTest.php b/test/EmailsTest.php index ea243540..0d6fca11 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -404,6 +404,8 @@ public static function bucketFailures(): array 'expired' => [[403, 'AccessDeniedRequest has expired', []], 403, 'AccessDenied: Request has expired', 'application_error'], 'not XML' => [[501, 'not implemented', []], 501, 'HTTP 501', 'service_unavailable'], + 'unauthorized' => [[401, 'UnauthorizedNo access', []], + 401, 'Unauthorized: No access', 'application_error'], ]; } @@ -475,7 +477,7 @@ public function testAStorageRetryAfterOverThirtySecondsFailsAtOnce(): void public static function shutdownsDuringTheUploads(): array { - return ['after the upload request' => [1], 'after an upload' => [2]]; + return ['after the upload request' => [1], 'after an upload' => [2], 'after the last upload' => [3]]; } #[DataProvider('shutdownsDuringTheUploads')] From 2d96d4dcf163e413b66d3a79682c90ee7e47feb1 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:41:13 +0000 Subject: [PATCH 33/50] fix(emails): keep the connect timeout at the request timeout for large uploads A large file needs longer to transfer, not to connect, so a stalled storage host now fails after the request timeout instead of minutes. Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 22 ++++++++++++++++------ test/EmailsTest.php | 20 +++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index dd16103c..0b12484e 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -30,7 +30,8 @@ * * A send with attachments first uploads each file PostHog doesn't have yet to the storage URL PostHog * answers, which is on another host than the app host. Each upload gets the request timeout plus 1 ms - * for every 100 bytes of the file, and a storage error in the 5xx range is retried as above. + * for every 100 bytes of the file, but only the request timeout to connect. A storage error in the 5xx + * range is retried as above. * * @phpstan-type Upload array{filename: string, size: int, content: string, field: string} */ @@ -103,7 +104,8 @@ final class Emails * @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. + * int $timeoutMs, int $connectTimeoutMs): 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( @@ -633,7 +635,14 @@ private function put(string $url, array $headers, array $file): void { $timeoutMs = $this->requestTimeoutMs + intdiv($file['size'], self::UPLOAD_BYTES_PER_EXTRA_MS); try { - [$status, $body, $answerHeaders] = ($this->transport)('PUT', $url, $headers, $file['content'], $timeoutMs); + [$status, $body, $answerHeaders] = ($this->transport)( + 'PUT', + $url, + $headers, + $file['content'], + $timeoutMs, + $this->requestTimeoutMs + ); } catch (EmailError $error) { if ($error->getErrorCode() !== 'network_error') { throw $error; @@ -917,7 +926,7 @@ private static function retryDelayMs(EmailError $error, int $attempt, int $maxRe */ private function request(string $method, string $url, array $headers, ?string $body): array { - return ($this->transport)($method, $url, $headers, $body, $this->requestTimeoutMs); + return ($this->transport)($method, $url, $headers, $body, $this->requestTimeoutMs, $this->requestTimeoutMs); } /** @@ -929,7 +938,8 @@ private static function curlTransport( string $url, array $headers, ?string $body, - int $timeoutMs + int $timeoutMs, + int $connectTimeoutMs ): array { if (!function_exists('curl_init')) { throw new EmailError( @@ -960,7 +970,7 @@ private static function curlTransport( }, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT_MS => $timeoutMs, - CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs, + CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs, CURLOPT_NOSIGNAL => true, CURLOPT_HEADERFUNCTION => static function ($curl, string $header) use (&$responseHeaders): int { $responseHeaders[] = trim($header); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 0d6fca11..6f952c57 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -20,7 +20,10 @@ class EmailsTest extends TestCase private const INVITE = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; private const SECRET_CONTENT = 'secret-file-content'; - /** @var list, body: ?string, timeoutMs: int, at: DateTimeImmutable}> */ + /** + * @var list, body: ?string, timeoutMs: int, + * connectTimeoutMs: int, at: DateTimeImmutable}> + */ private array $requests = []; /** @var list}|EmailError> */ private array $replies = []; @@ -61,10 +64,16 @@ private function emails(array $config = []): Emails * @param list $headers * @return array{int, string, list} */ - public function transport(string $method, string $url, array $headers, ?string $body, int $timeoutMs): array - { + public function transport( + string $method, + string $url, + array $headers, + ?string $body, + int $timeoutMs, + int $connectTimeoutMs + ): array { $at = Clock::get()->now(); - $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'at'); + $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'connectTimeoutMs', 'at'); $reply = array_shift($this->replies) ?? [202, '{"message_id":"msg_1"}', []]; if ($reply instanceof EmailError) { throw $reply; @@ -380,7 +389,7 @@ public function testANullContentTypeOrContentIdCountsAsLeftOut(): void ); } - public function testAnUploadGetsMoreTimeForALargerFile(): void + public function testAnUploadGetsMoreTimeToTransferALargerFileButNotToConnect(): void { $file = str_repeat('a', 1_000_000); $sha256 = hash('sha256', $file); @@ -390,6 +399,7 @@ public function testAnUploadGetsMoreTimeForALargerFile(): void $this->assertSame(Emails::DEFAULT_REQUEST_TIMEOUT_MS, $this->requests[0]['timeoutMs']); $this->assertSame(Emails::DEFAULT_REQUEST_TIMEOUT_MS + 10_000, $this->requests[1]['timeoutMs']); + $this->assertSame(Emails::DEFAULT_REQUEST_TIMEOUT_MS, $this->requests[1]['connectTimeoutMs']); } public static function bucketFailures(): array From ff26044545bf0728de7b21f5e8e2b46724395558 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:48:31 +0000 Subject: [PATCH 34/50] docs(emails): name the storage statuses an upload retries Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- lib/Emails.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 05036b2b..852776cd 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,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`, `cc`, `bcc`, `attachments` 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. - **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The whole email can be at most 40 MB after Base64 encoding, which leaves about 29 MB for the files; the SDK checks both limits before any request (`too_many_attachments`, `message_too_large`). Pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. -- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. A storage error in the 5xx range is retried like the send. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. +- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. A storage 500, 502, 503 or 504 is retried like the send. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. - **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. 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. diff --git a/lib/Emails.php b/lib/Emails.php index 0b12484e..73272d52 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -30,8 +30,8 @@ * * A send with attachments first uploads each file PostHog doesn't have yet to the storage URL PostHog * answers, which is on another host than the app host. Each upload gets the request timeout plus 1 ms - * for every 100 bytes of the file, but only the request timeout to connect. A storage error in the 5xx - * range is retried as above. + * for every 100 bytes of the file, but only the request timeout to connect. A storage 500, 502, 503 or + * 504 is retried as above. * * @phpstan-type Upload array{filename: string, size: int, content: string, field: string} */ From 2ff3d91631fe27670b8c5550776ed68056b5585f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:07:04 +0000 Subject: [PATCH 35/50] fix(emails): report PostHog's own answer when the upload route is not found A 404 from the upload route means either a PostHog without the route or a project that can't send email from code, and the SDK can't tell them apart. It now throws PostHog's answer, `not_found` on `attachments` with its reason, instead of `field_not_accepted`, which points at the wrong fix for the second cause. Nothing is sent. Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 14 ++++++-------- test/EmailsTest.php | 24 +++++++++++++++++++----- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index 73272d52..f1eb134d 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -164,7 +164,8 @@ public function __construct( * PostHog can't fetch a file from a path or URL. The client checks both limits before any request, * hashes each file with SHA-256, uploads the files PostHog doesn't have yet, and then sends the * email with each file's hash. A file that fails to upload throws `upload_failed` with the storage's - * answer, and no email is sent. + * answer, and no email is sent. A PostHog without the upload route, or a project that can't send + * email from code, answers `not_found` on `attachments`, and no email is sent. * * A PostHog version that doesn't take a field yet, such as `cc`, refuses the send with an error that * names the field. It never drops the field and sends the rest. @@ -687,13 +688,10 @@ private static function parseUploadReply(array $files, int $status, string $body { $fields = self::replyFields($body); if ($status === 404) { - throw new EmailError( - 'field_not_accepted', - "This PostHog doesn't take attachments yet, so no email was sent.", - $status, - fix: 'Send the email without attachments, or update PostHog.', - field: 'attachments' - ); + // A PostHog without the upload route and a project with code sends off both answer 404. + $fields['code'] = self::text($fields['code'] ?? null) ?? 'not_found'; + $fields['attr'] = 'attachments'; + throw self::replyError($fields, $status, $headers); } if ($status < 200 || $status >= 300) { $fields['attr'] = self::attachmentField(self::text($fields['attr'] ?? null), $files); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 6f952c57..4aa1756d 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -543,16 +543,30 @@ public function testTheDailyUploadLimitIsNotRetried(): void $this->assertCount(1, $this->requests); } - public function testAPostHogWithoutTheUploadRouteRefusesTheFilesAndSendsNothing(): void + public static function uploadRouteNotFound(): array { - $this->reply([404, '{"type":"invalid_request","code":"not_found","detail":"Not found."}', []]); + return [ + "PostHog's answer" => [ + '{"type":"invalid_request","code":"not_found","detail":"Endpoint not found.","attr":null}', + 'Endpoint not found.', + ], + 'an answer without JSON' => ['Not Found', 'PostHog answered HTTP 404.'], + ]; + } + + #[DataProvider('uploadRouteNotFound')] + public function testAnUploadRouteNotFoundReportsPostHogsAnswerAndSendsNothing(string $answer, string $message): void + { + $this->reply([404, $answer, []]); $error = $this->sendError(self::sendWithFiles([self::invite()])); - $this->assertSame('field_not_accepted', $error->getErrorCode()); - $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame('not_found', $error->getErrorCode()); + $this->assertSame('not_found', $error->getErrorName()); + $this->assertSame(404, $error->getStatusCode()); $this->assertSame('attachments', $error->getField()); - $this->assertCount(1, $this->requests); + $this->assertSame($message, $error->getMessage()); + $this->assertCount(1, $this->requests, 'no email was sent'); } public function testAnUnavailableUploadRouteIsRetried(): void From 5934ddf5f10582005f91327f610b3ebe73b1c007 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:24:43 +0000 Subject: [PATCH 36/50] refactor(emails): drop two inline comments the commit messages already carry Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index f1eb134d..267c7f30 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -688,7 +688,6 @@ private static function parseUploadReply(array $files, int $status, string $body { $fields = self::replyFields($body); if ($status === 404) { - // A PostHog without the upload route and a project with code sends off both answer 404. $fields['code'] = self::text($fields['code'] ?? null) ?? 'not_found'; $fields['attr'] = 'attachments'; throw self::replyError($fields, $status, $headers); @@ -897,7 +896,6 @@ private static function isRetryable(EmailError $error): bool return !in_array($error->getErrorCode(), ['quota_limited', 'upload_quota_exceeded'], true); } if ($error->getErrorCode() === 'upload_failed') { - // An upload puts the same bytes at the same key, so a passing storage fault is safe to retry. return in_array($status, [500, 502, 503, 504], true); } From 44987c2e637e6d06c03236398cd4ece846a5f6b4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:18:26 +0000 Subject: [PATCH 37/50] fix(emails): count the subject in the size estimate, and test uploads against faithful storage - The local 40 MB check counts the subject's folded encoded words, as PostHog's estimate now does, so a send that passes locally is never refused after its uploads (PostHog/michaelr-context#84). - A storage 429 is retried like a storage 500, 502, 503 or 504, and the docs say so. - The test storage refuses bytes that don't match the size and checksum PostHog signed, and each upload slot is signed for its own file. - The connect and transfer timeouts are proven over real HTTP instead of by the transport's arguments. - Drop field_not_accepted from the local refusal codes; nothing raises it. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- lib/EmailError.php | 2 +- lib/Emails.php | 24 ++++--- test/EmailsClientTest.php | 77 +++++++++++++++++++++++ test/EmailsTest.php | 114 +++++++++++++++++++++++----------- test/fixtures/http-server.php | 1 + 6 files changed, 174 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 852776cd..0d5f7c91 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,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`, `cc`, `bcc`, `attachments` 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. - **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The whole email can be at most 40 MB after Base64 encoding, which leaves about 29 MB for the files; the SDK checks both limits before any request (`too_many_attachments`, `message_too_large`). Pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. -- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. A storage 500, 502, 503 or 504 is retried like the send. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. +- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. A storage 429, 500, 502, 503 or 504 is retried like the send. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. - **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. 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. diff --git a/lib/EmailError.php b/lib/EmailError.php index 5671fe71..d26c3e10 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -14,7 +14,7 @@ */ class EmailError extends RuntimeException { - private const REFUSED_ATTACHMENT_CODES = ['too_many_attachments', 'message_too_large', 'field_not_accepted']; + private const REFUSED_ATTACHMENT_CODES = ['too_many_attachments', 'message_too_large']; private string $errorName; private string $errorCode; diff --git a/lib/Emails.php b/lib/Emails.php index 267c7f30..a4a7c62d 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -30,8 +30,8 @@ * * A send with attachments first uploads each file PostHog doesn't have yet to the storage URL PostHog * answers, which is on another host than the app host. Each upload gets the request timeout plus 1 ms - * for every 100 bytes of the file, but only the request timeout to connect. A storage 500, 502, 503 or - * 504 is retried as above. + * for every 100 bytes of the file, but only the request timeout to connect. A storage 429, 500, 502, 503 + * or 504 is retried as above. * * @phpstan-type Upload array{filename: string, size: int, content: string, field: string} */ @@ -422,7 +422,7 @@ private static function codeContent(array $email): array } $files = []; if (array_key_exists('attachments', $email) && $email['attachments'] !== []) { - [$content['attachments'], $files] = self::attachments($email['attachments'], [ + [$content['attachments'], $files] = self::attachments($email['attachments'], $content['subject'], [ $content['html'] ?? '', $content['text'] ?? '', ]); @@ -455,10 +455,12 @@ private static function addresses(string $field, mixed $value): array } /** + * Checks the files against PostHog's limits, counting the message as PostHog's own estimate does. + * * @param list $bodies * @return array{list>, array} */ - private static function attachments(mixed $files, array $bodies): array + private static function attachments(mixed $files, string $subject, array $bodies): array { if (!is_array($files) || !array_is_list($files)) { throw self::invalidAttachment('attachments must be a list of files.', 'attachments'); @@ -473,7 +475,7 @@ private static function attachments(mixed $files, array $bodies): array ); } $attachments = []; - $messageBytes = self::MESSAGE_HEADERS_BYTES; + $messageBytes = self::MESSAGE_HEADERS_BYTES + self::encodedSubjectBytes($subject); foreach ($files as $index => $file) { $attachments[] = $attachment = self::attachment("attachments__$index", $file); $messageBytes += self::base64TransferBytes(strlen($attachment['content'])); @@ -512,6 +514,12 @@ private static function base64TransferBytes(int $bytes): float return ceil(ceil($bytes / 3) * 4 * 78 / 76); } + /** The subject's UTF-8 bytes, folded into encoded words of 42 bytes that take 75 bytes each. */ + private static function encodedSubjectBytes(string $subject): float + { + return ceil(strlen($subject) / 42) * 75; + } + /** * @return array */ @@ -889,15 +897,15 @@ private static function isRetryable(EmailError $error): bool if ($status === null) { return $error->getErrorCode() === 'network_error'; } + if ($error->getErrorCode() === 'upload_failed') { + return in_array($status, [429, 500, 502, 503, 504], true); + } if ($status === 409) { return $error->getErrorCode() === 'idempotency_key_in_flight'; } if ($status === 429) { return !in_array($error->getErrorCode(), ['quota_limited', 'upload_quota_exceeded'], true); } - if ($error->getErrorCode() === 'upload_failed') { - return in_array($status, [500, 502, 503, 504], true); - } return $status === 503; } diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index 431cec3c..ff74ecd0 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -126,6 +126,83 @@ public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void ); } + /** PostHog's answer to the upload request: one slot that sends the file to the given storage address. */ + private static function uploadAnswer(string $storage, string $file): array + { + $sha256 = hash('sha256', $file); + + return ['status' => 200, 'body' => json_encode(['files' => [[ + 'sha256' => $sha256, + 'status' => 'upload', + 'url' => "http://$storage/attachments/team/2/$sha256?X-Amz-Signature=abc", + 'method' => 'PUT', + 'headers' => ['Content-Length' => (string) strlen($file)], + 'url_expires_at' => '2026-11-02T12:15:00Z', + ]]])]; + } + + private static function sendWithFile(string $file): array + { + return [ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => [['filename' => 'receipt.pdf', 'content' => $file]], + ]; + } + + public function testStorageMayTakeLongerThanTheRequestTimeoutToTakeALargerFile(): void + { + $file = str_repeat('a', 200_000); + $this->bucket = new LocalHttpServer([['status' => 200, 'body' => '', 'delay_ms' => 1500]]); + $this->server = new LocalHttpServer([ + self::uploadAnswer($this->bucket->address(), $file), + ['status' => 202, 'body' => '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}'], + ]); + $client = $this->client(['email_request_max_retries' => 0, 'email_request_timeout_ms' => 1000]); + + $result = $client->emails()->send(self::sendWithFile($file)); + + $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $result->getId()); + $this->assertSame($file, $this->bucket->requests()[0]['body']); + } + + public function testAnUploadGivesUpConnectingToStorageAtTheRequestTimeout(): void + { + $storage = stream_socket_server( + 'tcp://127.0.0.1:0', + $errno, + $error, + STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, + stream_context_create(['socket' => ['backlog' => 0]]) + ); + $address = stream_socket_get_name($storage, false); + $waiting = []; + for ($i = 0; $i < 4; $i++) { + $waiting[] = @stream_socket_client("tcp://$address", $errno, $error, 1, STREAM_CLIENT_ASYNC_CONNECT); + } + $probe = @stream_socket_client("tcp://$address", $errno, $error, 0.3); + if ($probe !== false) { + $this->markTestSkipped('This system still connects once the listen queue is full.'); + } + $file = str_repeat('a', 500_000); + $this->server = new LocalHttpServer([self::uploadAnswer($address, $file)]); + $client = $this->client(['email_request_max_retries' => 0, 'email_request_timeout_ms' => 1000]); + $started = hrtime(true); + + try { + $client->emails()->send(self::sendWithFile($file)); + $this->fail('The upload connected to a storage host that never accepts.'); + } catch (EmailError $error) { + $this->assertSame('network_error', $error->getErrorCode()); + } + + $this->assertLessThan(3000, (hrtime(true) - $started) / 1e6, 'gave up at the 1 s connect timeout, not 6 s'); + $this->assertCount(1, $this->server->requests(), 'nothing was sent'); + fclose($storage); + } + public function testARetryAfterHeaderComesBackOnTheError(): void { $this->server = new LocalHttpServer([ diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 4aa1756d..688ca0f2 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -22,7 +22,7 @@ class EmailsTest extends TestCase /** * @var list, body: ?string, timeoutMs: int, - * connectTimeoutMs: int, at: DateTimeImmutable}> + * at: DateTimeImmutable}> */ private array $requests = []; /** @var list}|EmailError> */ @@ -73,7 +73,10 @@ public function transport( int $connectTimeoutMs ): array { $at = Clock::get()->now(); - $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'connectTimeoutMs', 'at'); + $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'at'); + if ($method === 'PUT' && !self::storageAccepts($headers, (string) $body)) { + return [400, self::BUCKET_DIGEST_ERROR, []]; + } $reply = array_shift($this->replies) ?? [202, '{"message_id":"msg_1"}', []]; if ($reply instanceof EmailError) { throw $reply; @@ -82,6 +85,28 @@ public function transport( return $reply; } + /** + * Storage refuses an upload whose bytes don't match the size and checksum PostHog signed, so the fake does too. + * + * @param list $headers + */ + private static function storageAccepts(array $headers, string $body): bool + { + foreach ($headers as $header) { + [$name, $value] = array_map('trim', explode(':', $header, 2)); + $matches = match (strtolower($name)) { + 'content-length' => $value === (string) strlen($body), + 'x-amz-checksum-sha256' => $value === base64_encode(hash('sha256', $body, true)), + default => true, + }; + if (!$matches) { + return false; + } + } + + return true; + } + /** @param array{int, string, list}|EmailError ...$replies */ private function reply(...$replies): void { @@ -249,17 +274,25 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void private const INVITE_CHECKSUM = 'IV765U40rFZX2ke0nI0b2OoSZJ7h76g4yaR9cjPxivs='; private const PNG = "\x89PNG\r\n\x1a\n\x00"; private const PNG_SHA256 = '843ac23b1736b4487ec81cf7c07ddd9bb46ae5b7818c2c3843d99d62fa75f3c9'; - private const BUCKET_URL = 'https://attachments.example/team/2/' - . self::INVITE_SHA256 . '?X-Amz-Signature=signature-secret'; + private const BUCKET = 'https://attachments.example/team/2/'; + private const SIGNATURE = '?X-Amz-Signature=signature-secret'; + private const BUCKET_DIGEST_ERROR = 'BadDigest' + . 'The SHA256 you specified did not match the calculated checksum.'; - private static function uploadSlot(string $sha256, int $size): array + /** PostHog's upload slot for a file: a URL and headers signed for that file's own hash, size and checksum. */ + private static function uploadSlot(string $content): array { + $sha256 = hash('sha256', $content); + return [ 'sha256' => $sha256, 'status' => 'upload', - 'url' => self::BUCKET_URL, + 'url' => self::BUCKET . $sha256 . self::SIGNATURE, 'method' => 'PUT', - 'headers' => ['Content-Length' => (string) $size, 'x-amz-checksum-sha256' => self::INVITE_CHECKSUM], + 'headers' => [ + 'Content-Length' => (string) strlen($content), + 'x-amz-checksum-sha256' => base64_encode(hash('sha256', $content, true)), + ], 'url_expires_at' => '2026-11-02T12:15:00Z', ]; } @@ -294,7 +327,7 @@ private static function invite(array $file = []): array public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void { $this->reply( - self::slots(self::uploadSlot(self::INVITE_SHA256, 32), self::presentSlot(self::PNG_SHA256)), + self::slots(self::uploadSlot(self::INVITE), self::presentSlot(self::PNG_SHA256)), [200, '', []], [202, '{"message_id":"msg_1"}', []] ); @@ -314,7 +347,11 @@ public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void $this->assertSame('msg_1', $result->getId()); $this->assertSame( - ['POST ' . self::UPLOAD_URL, 'PUT ' . self::BUCKET_URL, 'POST ' . self::SEND_URL], + [ + 'POST ' . self::UPLOAD_URL, + 'PUT ' . self::BUCKET . self::INVITE_SHA256 . self::SIGNATURE, + 'POST ' . self::SEND_URL, + ], array_map(static fn(array $request): string => $request['method'] . ' ' . $request['url'], $this->requests) ); $this->assertSame( @@ -346,7 +383,7 @@ public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void public function testTheSameFileTwiceIsUploadedOnceAndAttachedTwice(): void { - $this->reply(self::slots(self::uploadSlot(self::INVITE_SHA256, 32)), [200, '', []]); + $this->reply(self::slots(self::uploadSlot(self::INVITE)), [200, '', []]); $this->emails()->send(self::sendWithFiles([self::invite(), self::invite(['filename' => 'copy.ics'])])); @@ -364,7 +401,7 @@ public function testTheSameFileTwiceIsUploadedOnceAndAttachedTwice(): void public function testARetriedSendDoesNotUploadAgain(): void { $this->reply( - self::slots(self::uploadSlot(self::INVITE_SHA256, 32)), + self::slots(self::uploadSlot(self::INVITE)), [200, '', []], [503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []] @@ -389,27 +426,14 @@ public function testANullContentTypeOrContentIdCountsAsLeftOut(): void ); } - public function testAnUploadGetsMoreTimeToTransferALargerFileButNotToConnect(): void - { - $file = str_repeat('a', 1_000_000); - $sha256 = hash('sha256', $file); - $this->reply(self::slots(self::uploadSlot($sha256, 1_000_000)), [200, '', []]); - - $this->emails()->send(self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]])); - - $this->assertSame(Emails::DEFAULT_REQUEST_TIMEOUT_MS, $this->requests[0]['timeoutMs']); - $this->assertSame(Emails::DEFAULT_REQUEST_TIMEOUT_MS + 10_000, $this->requests[1]['timeoutMs']); - $this->assertSame(Emails::DEFAULT_REQUEST_TIMEOUT_MS, $this->requests[1]['connectTimeoutMs']); - } - public static function bucketFailures(): array { - $xml = 'BadDigest' - . 'The SHA256 you specified did not match the calculated checksum.'; - return [ 'refused' => [ - [400, $xml, []], 400, 'BadDigest: The SHA256 you specified did not match', 'application_error', + [400, self::BUCKET_DIGEST_ERROR, []], + 400, + 'BadDigest: The SHA256 you specified did not match', + 'application_error', ], 'expired' => [[403, 'AccessDeniedRequest has expired', []], 403, 'AccessDenied: Request has expired', 'application_error'], @@ -428,7 +452,7 @@ public function testAFailedUploadSendsNothingAndSaysWhatTheBucketAnswered( string $name ): void { $this->reply( - self::slots(self::presentSlot(self::PNG_SHA256), self::uploadSlot(self::INVITE_SHA256, 32)), + self::slots(self::presentSlot(self::PNG_SHA256), self::uploadSlot(self::INVITE)), $failure ); @@ -458,6 +482,9 @@ public static function retriedUploadFailures(): array ], 'bad gateway' => [[502, 'Bad Gateway', []]], 'gateway timeout' => [[504, 'Gateway Timeout', []]], + 'storage throttling' => [ + [429, 'SlowDownReduce your rate', []], + ], ]; } @@ -465,7 +492,7 @@ public static function retriedUploadFailures(): array #[DataProvider('retriedUploadFailures')] public function testAnUploadThatFailsOnTheWayIsRetried($failure): void { - $this->reply(self::slots(self::uploadSlot(self::INVITE_SHA256, 32)), $failure, [200, '', []]); + $this->reply(self::slots(self::uploadSlot(self::INVITE)), $failure, [200, '', []]); $this->emails()->send(self::sendWithFiles([self::invite()])); @@ -476,7 +503,7 @@ public function testAnUploadThatFailsOnTheWayIsRetried($failure): void public function testAStorageRetryAfterOverThirtySecondsFailsAtOnce(): void { $slowDown = 'SlowDownSlow down'; - $this->reply(self::slots(self::uploadSlot(self::INVITE_SHA256, 32)), [503, $slowDown, ['Retry-After: 60']]); + $this->reply(self::slots(self::uploadSlot(self::INVITE)), [503, $slowDown, ['Retry-After: 60']]); $error = $this->sendError(self::sendWithFiles([self::invite()])); @@ -495,7 +522,7 @@ public function testAClientShutDownDuringTheUploadsSendsNothingMore(int $request { $emails = $this->emails(['isClosed' => fn(): bool => count($this->requests) >= $requestsBeforeTheShutdown]); $this->reply( - self::slots(self::uploadSlot(self::INVITE_SHA256, 32), self::uploadSlot(self::PNG_SHA256, 9)), + self::slots(self::uploadSlot(self::INVITE), self::uploadSlot(self::PNG)), [200, '', []], [200, '', []] ); @@ -533,6 +560,20 @@ public function testAnUploadRefusalNamesTheAttachmentAndSendsNothing(): void $this->assertCount(1, $this->requests); } + public function testBytesThatDoNotMatchTheSignedChecksumAreRefusedByStorageAndNothingIsSent(): void + { + $slot = self::uploadSlot(self::INVITE); + $slot['headers']['x-amz-checksum-sha256'] = base64_encode(hash('sha256', self::PNG, true)); + $this->reply(self::slots($slot)); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame(400, $error->getStatusCode()); + $this->assertStringContainsString('BadDigest', $error->getMessage()); + $this->assertSame(['POST', 'PUT'], array_column($this->requests, 'method')); + } + public function testTheDailyUploadLimitIsNotRetried(): void { $this->reply([429, '{"code":"upload_quota_exceeded","detail":"No more uploads today."}', []]); @@ -581,7 +622,7 @@ public function testAnUnavailableUploadRouteIsRetried(): void public static function unusableUploadAnswers(): array { - $slot = self::uploadSlot(self::INVITE_SHA256, 32); + $slot = self::uploadSlot(self::INVITE); return [ 'no slot for the file' => [['files' => [self::presentSlot(self::PNG_SHA256)]]], @@ -615,18 +656,19 @@ public function testAnUploadAnswerTheClientCannotFollowSendsNothing(array $answe public function testTheLargestFileThatFitsTheMessageIsUploaded(): void { - $file = str_repeat('a', 29_182_866); + $file = str_repeat('a', 29_182_812); $this->reply(self::slots(self::presentSlot(hash('sha256', $file)))); $this->emails()->send(self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]])); - $this->assertSame(['files' => [['sha256' => hash('sha256', $file), 'size' => 29_182_866]]], $this->body(0)); + $this->assertSame(['files' => [['sha256' => hash('sha256', $file), 'size' => 29_182_812]]], $this->body(0)); } public static function messagesTooLarge(): array { return [ - 'one byte more than fits' => [[str_repeat('a', 29_182_867)], []], + 'one byte more than fits' => [[str_repeat('a', 29_182_813)], []], + 'a subject that leaves no room' => [[str_repeat('a', 29_182_812)], ['subject' => str_repeat('é', 22)]], 'a body that leaves no room' => [[str_repeat('a', 29_000_000)], ['text' => str_repeat('b', 200_000)]], 'files that only fit alone' => [[str_repeat('a', 15_000_000), str_repeat('b', 15_000_000)], []], ]; diff --git a/test/fixtures/http-server.php b/test/fixtures/http-server.php index feae8073..70e9b226 100644 --- a/test/fixtures/http-server.php +++ b/test/fixtures/http-server.php @@ -38,6 +38,7 @@ ], JSON_THROW_ON_ERROR) . "\n", FILE_APPEND); $response = array_shift($responses) ?? []; + usleep(($response['delay_ms'] ?? 0) * 1000); $closeServer = $response['close_server'] ?? false; if ($closeServer) { fclose($server); From 1d9e602c1a8afcc071a444aa53d625ad1760583f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:29:31 +0000 Subject: [PATCH 38/50] test(emails): run the connect timeout test only where connecting stalls - Skip unless the probe timed out, so a stack that refuses a full listen queue at once can't pass the test whatever the connect timeout is. - Drop the prose docblocks the round added; the names carry the meaning. Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 3 --- test/EmailsClientTest.php | 6 +++--- test/EmailsTest.php | 7 +------ 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index a4a7c62d..e4fcb78b 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -455,8 +455,6 @@ private static function addresses(string $field, mixed $value): array } /** - * Checks the files against PostHog's limits, counting the message as PostHog's own estimate does. - * * @param list $bodies * @return array{list>, array} */ @@ -514,7 +512,6 @@ private static function base64TransferBytes(int $bytes): float return ceil(ceil($bytes / 3) * 4 * 78 / 76); } - /** The subject's UTF-8 bytes, folded into encoded words of 42 bytes that take 75 bytes each. */ private static function encodedSubjectBytes(string $subject): float { return ceil(strlen($subject) / 42) * 75; diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index ff74ecd0..203d7253 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -126,7 +126,6 @@ public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void ); } - /** PostHog's answer to the upload request: one slot that sends the file to the given storage address. */ private static function uploadAnswer(string $storage, string $file): array { $sha256 = hash('sha256', $file); @@ -182,9 +181,10 @@ public function testAnUploadGivesUpConnectingToStorageAtTheRequestTimeout(): voi for ($i = 0; $i < 4; $i++) { $waiting[] = @stream_socket_client("tcp://$address", $errno, $error, 1, STREAM_CLIENT_ASYNC_CONNECT); } + $probeStarted = hrtime(true); $probe = @stream_socket_client("tcp://$address", $errno, $error, 0.3); - if ($probe !== false) { - $this->markTestSkipped('This system still connects once the listen queue is full.'); + if ($probe !== false || (hrtime(true) - $probeStarted) / 1e6 < 250) { + $this->markTestSkipped('Connecting here does not stall once the listen queue is full.'); } $file = str_repeat('a', 500_000); $this->server = new LocalHttpServer([self::uploadAnswer($address, $file)]); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 688ca0f2..5bbc02ea 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -85,11 +85,7 @@ public function transport( return $reply; } - /** - * Storage refuses an upload whose bytes don't match the size and checksum PostHog signed, so the fake does too. - * - * @param list $headers - */ + /** @param list $headers */ private static function storageAccepts(array $headers, string $body): bool { foreach ($headers as $header) { @@ -279,7 +275,6 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void private const BUCKET_DIGEST_ERROR = 'BadDigest' . 'The SHA256 you specified did not match the calculated checksum.'; - /** PostHog's upload slot for a file: a URL and headers signed for that file's own hash, size and checksum. */ private static function uploadSlot(string $content): array { $sha256 = hash('sha256', $content); From 147e4c7ad414600d57bdf87cc66a2f74a2bd1235 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:34:43 +0000 Subject: [PATCH 39/50] test(emails): pin the subject term of the size estimate to node-store's cases Each subject case finds the largest file that fits beside it, and one byte more is refused: 42 ASCII bytes add 75, 43 add 150, 21 two-byte characters add 75, 22 add 150. An empty subject never reaches the estimate: the SDK refuses it before any request (PostHog/michaelr-context#84). Co-Authored-By: Claude Opus 5.5 --- test/EmailsTest.php | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 5bbc02ea..c9480c52 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -649,21 +649,56 @@ public function testAnUploadAnswerTheClientCannotFollowSendsNothing(array $answe $this->assertCount(1, $this->requests, 'nothing was uploaded or sent'); } - public function testTheLargestFileThatFitsTheMessageIsUploaded(): void + public static function subjectsAndTheLargestFileThatFits(): array { - $file = str_repeat('a', 29_182_812); + return [ + 'a 12-byte subject adds 75' => ['Your receipt', 29_182_812], + '42 ASCII bytes add 75' => [str_repeat('s', 42), 29_182_812], + '43 ASCII bytes add 150' => [str_repeat('s', 43), 29_182_758], + '21 two-byte characters add 75' => [str_repeat('é', 21), 29_182_812], + '22 two-byte characters add 150' => [str_repeat('é', 22), 29_182_758], + ]; + } + + #[DataProvider('subjectsAndTheLargestFileThatFits')] + public function testTheLargestFileThatFitsBesideTheSubjectIsUploaded(string $subject, int $largest): void + { + $file = str_repeat('a', $largest); $this->reply(self::slots(self::presentSlot(hash('sha256', $file)))); - $this->emails()->send(self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]])); + $this->emails()->send( + self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]], ['subject' => $subject]) + ); + + $this->assertSame(['files' => [['sha256' => hash('sha256', $file), 'size' => $largest]]], $this->body(0)); + } + + #[DataProvider('subjectsAndTheLargestFileThatFits')] + public function testOneByteMoreThanFitsBesideTheSubjectIsRefused(string $subject, int $largest): void + { + $file = str_repeat('a', $largest + 1); + + $error = $this->sendError( + self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]], ['subject' => $subject]) + ); + + $this->assertSame('message_too_large', $error->getErrorCode()); + $this->assertSame([], $this->requests); + } + + public function testAnEmptySubjectBesideFilesIsRefusedBeforeAnyRequest(): void + { + $error = $this->sendError(self::sendWithFiles([self::invite()], ['subject' => ''])); - $this->assertSame(['files' => [['sha256' => hash('sha256', $file), 'size' => 29_182_812]]], $this->body(0)); + $this->assertSame('invalid_form', $error->getErrorCode()); + $this->assertSame('subject', $error->getField()); + $this->assertSame([], $this->requests); } public static function messagesTooLarge(): array { return [ 'one byte more than fits' => [[str_repeat('a', 29_182_813)], []], - 'a subject that leaves no room' => [[str_repeat('a', 29_182_812)], ['subject' => str_repeat('é', 22)]], 'a body that leaves no room' => [[str_repeat('a', 29_000_000)], ['text' => str_repeat('b', 200_000)]], 'files that only fit alone' => [[str_repeat('a', 15_000_000), str_repeat('b', 15_000_000)], []], ]; From 694a9807ed1b897295f217c82fab54eafab57625 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:39:58 +0000 Subject: [PATCH 40/50] test(emails): drop two tests that repeat another test's input The empty subject is already refused in the argument errors, and the 12-byte subject case covers one byte more than fits. Co-Authored-By: Claude Opus 5.5 --- test/EmailsTest.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test/EmailsTest.php b/test/EmailsTest.php index c9480c52..dca0936c 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -686,19 +686,9 @@ public function testOneByteMoreThanFitsBesideTheSubjectIsRefused(string $subject $this->assertSame([], $this->requests); } - public function testAnEmptySubjectBesideFilesIsRefusedBeforeAnyRequest(): void - { - $error = $this->sendError(self::sendWithFiles([self::invite()], ['subject' => ''])); - - $this->assertSame('invalid_form', $error->getErrorCode()); - $this->assertSame('subject', $error->getField()); - $this->assertSame([], $this->requests); - } - public static function messagesTooLarge(): array { return [ - 'one byte more than fits' => [[str_repeat('a', 29_182_813)], []], 'a body that leaves no room' => [[str_repeat('a', 29_000_000)], ['text' => str_repeat('b', 200_000)]], 'files that only fit alone' => [[str_repeat('a', 15_000_000), str_repeat('b', 15_000_000)], []], ]; From f257fe069994f9062d8a3d10fd097d8018852175 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:47:04 +0000 Subject: [PATCH 41/50] test(emails): make the HTTP storage fixture refuse bytes that miss the signed checksum The real-HTTP storage answered 200 to any body. It now answers 400 BadDigest when the body doesn't match `x-amz-checksum-sha256`, as real storage does, and a test proves the send stops there. Co-Authored-By: Claude Opus 5.5 --- test/EmailsClientTest.php | 20 ++++++++++++++++++++ test/fixtures/http-server.php | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index 203d7253..5745eb5c 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -151,6 +151,26 @@ private static function sendWithFile(string $file): array ]; } + public function testStorageRefusesBytesThatDoNotMatchTheSignedChecksumAndNothingIsSent(): void + { + $file = "%PDF-1.7\n" . str_repeat("0 0 obj << >> endobj\n", 100); + $this->bucket = new LocalHttpServer([['status' => 200, 'body' => '']]); + $answer = self::uploadAnswer($this->bucket->address(), $file); + $slots = json_decode($answer['body'], true); + $slots['files'][0]['headers']['x-amz-checksum-sha256'] = base64_encode(hash('sha256', 'another file', true)); + $this->server = new LocalHttpServer([['status' => 200, 'body' => json_encode($slots)]]); + + try { + $this->client(['email_request_max_retries' => 0])->emails()->send(self::sendWithFile($file)); + $this->fail('The send went out after storage refused the file.'); + } catch (EmailError $error) { + $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertStringContainsString('BadDigest', $error->getMessage()); + } + + $this->assertCount(1, $this->server->requests(), 'nothing was sent'); + } + public function testStorageMayTakeLongerThanTheRequestTimeoutToTakeALargerFile(): void { $file = str_repeat('a', 200_000); diff --git a/test/fixtures/http-server.php b/test/fixtures/http-server.php index 70e9b226..a3510d5f 100644 --- a/test/fixtures/http-server.php +++ b/test/fixtures/http-server.php @@ -37,7 +37,11 @@ 'body' => $body, ], JSON_THROW_ON_ERROR) . "\n", FILE_APPEND); - $response = array_shift($responses) ?? []; + $checksum = $headers['x-amz-checksum-sha256'] ?? null; + $response = $checksum === null || $checksum === base64_encode(hash('sha256', $body, true)) + ? array_shift($responses) ?? [] + : ['status' => 400, 'body' => 'BadDigest' + . 'The SHA256 you specified did not match the calculated checksum.']; usleep(($response['delay_ms'] ?? 0) * 1000); $closeServer = $response['close_server'] ?? false; if ($closeServer) { From 90ca1652769c5e2bbf7709a4b45aecc1dc48f3de Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:08:16 +0000 Subject: [PATCH 42/50] test(emails): assert upload outcomes against storage that enforces its signature The upload tests asserted the signed URL text and header strings. The storage fakes now refuse what real storage refuses (a URL or signed header that doesn't match the slot, bytes that miss the signed checksum) and the PostHog fake refuses another route or key, so the tests assert outcomes: the email is queued, storage holds the file's bytes, and the secret key never reaches storage. Co-Authored-By: Claude Opus 5.5 --- test/EmailsClientTest.php | 66 ++++++++++++++++++++++------------- test/EmailsTest.php | 49 +++++++++++++++++++------- test/fixtures/http-server.php | 14 ++++++-- 3 files changed, 89 insertions(+), 40 deletions(-) diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index 5745eb5c..58878202 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -80,21 +80,44 @@ public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void $file = "%PDF-1.7\n" . str_repeat("0 0 obj << >> endobj\n", 100); $sha256 = hash('sha256', $file); $checksum = base64_encode(hash('sha256', $file, true)); - $this->bucket = new LocalHttpServer([['status' => 200, 'body' => '']]); - $bucketUrl = 'http://' . $this->bucket->address() . "/attachments/team/2/$sha256?X-Amz-Signature=abc"; + $signedPath = "/attachments/team/2/$sha256?X-Amz-Signature=abc"; + $this->bucket = new LocalHttpServer([[ + 'status' => 200, + 'body' => '', + 'accepts' => [ + 'request_line' => "PUT $signedPath HTTP/1.1", + 'content-length' => (string) strlen($file), + 'x-amz-checksum-sha256' => $checksum, + ], + 'refusal' => [ + 'status' => 403, + 'body' => 'SignatureDoesNotMatchNot as signed', + ], + ]]); $this->server = new LocalHttpServer([ - ['status' => 200, 'body' => json_encode(['files' => [[ - 'sha256' => $sha256, - 'status' => 'upload', - 'url' => $bucketUrl, - 'method' => 'PUT', - 'headers' => ['Content-Length' => (string) strlen($file), 'x-amz-checksum-sha256' => $checksum], - 'url_expires_at' => '2026-11-02T12:15:00Z', - ]]])], + [ + 'status' => 200, + 'body' => json_encode(['files' => [[ + 'sha256' => $sha256, + 'status' => 'upload', + 'url' => 'http://' . $this->bucket->address() . $signedPath, + 'method' => 'PUT', + 'headers' => ['Content-Length' => (string) strlen($file), 'x-amz-checksum-sha256' => $checksum], + 'url_expires_at' => '2026-11-02T12:15:00Z', + ]]]), + 'accepts' => [ + 'request_line' => 'POST /api/projects/@current/messaging/attachments/uploads/ HTTP/1.1', + 'authorization' => 'Bearer phs_secret', + ], + 'refusal' => [ + 'status' => 404, + 'body' => '{"type":"invalid_request","code":"not_found","detail":"Not found."}', + ], + ], ['status' => 202, 'body' => '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}'], ]); - $this->client()->emails()->send([ + $result = $this->client()->emails()->send([ 'to' => 'user@example.com', 'template_key' => 'receipt', 'subject' => 'Your receipt', @@ -102,24 +125,19 @@ public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void 'attachments' => [['filename' => 'receipt.pdf', 'content' => $file]], ]); + $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $result->getId()); [$upload, $send] = $this->server->requests(); - $this->assertSame( - 'POST /api/projects/@current/messaging/attachments/uploads/ HTTP/1.1', - $upload['requestLine'] - ); - $this->assertSame('Bearer phs_secret', $upload['headers']['authorization']); $this->assertSame( ['files' => [['sha256' => $sha256, 'size' => strlen($file)]]], json_decode($upload['body'], true) ); - [$put] = $this->bucket->requests(); - $this->assertSame("PUT /attachments/team/2/$sha256?X-Amz-Signature=abc HTTP/1.1", $put['requestLine']); - $this->assertSame($file, $put['body']); - $this->assertSame((string) strlen($file), $put['headers']['content-length']); - $this->assertSame($checksum, $put['headers']['x-amz-checksum-sha256']); - $this->assertArrayNotHasKey('authorization', $put['headers']); - $this->assertArrayNotHasKey('content-type', $put['headers']); - $this->assertArrayNotHasKey('expect', $put['headers']); + [$stored] = $this->bucket->requests(); + $this->assertSame($file, $stored['body']); + $this->assertStringNotContainsString( + 'phs_secret', + json_encode($stored['headers']), + 'storage never gets the secret key' + ); $this->assertSame( [['sha256' => $sha256, 'filename' => 'receipt.pdf']], json_decode($send['body'], true)['attachments'] diff --git a/test/EmailsTest.php b/test/EmailsTest.php index dca0936c..e369d0a1 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -27,6 +27,8 @@ class EmailsTest extends TestCase private array $requests = []; /** @var list}|EmailError> */ private array $replies = []; + /** @var array> the headers storage signed for each upload URL */ + private array $signedUploads = []; private ClockInterface $previousClock; private MockClock $clock; @@ -74,6 +76,9 @@ public function transport( ): array { $at = Clock::get()->now(); $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'at'); + if ($method === 'PUT' && !$this->storageSigned($url, $headers)) { + return [403, 'SignatureDoesNotMatchNot as signed', []]; + } if ($method === 'PUT' && !self::storageAccepts($headers, (string) $body)) { return [400, self::BUCKET_DIGEST_ERROR, []]; } @@ -81,10 +86,36 @@ public function transport( if ($reply instanceof EmailError) { throw $reply; } + $answer = json_decode($reply[1], true); + foreach (is_array($answer) && is_array($answer['files'] ?? null) ? $answer['files'] : [] as $slot) { + if (is_array($slot) && is_string($slot['url'] ?? null) && is_array($slot['headers'] ?? null)) { + $this->signedUploads[$slot['url']] = $slot['headers']; + } + } return $reply; } + /** @param list $headers */ + private function storageSigned(string $url, array $headers): bool + { + if (!isset($this->signedUploads[$url])) { + return false; + } + $sent = []; + foreach ($headers as $header) { + [$name, $value] = array_map('trim', explode(':', $header, 2)); + $sent[strtolower($name)] = $value; + } + foreach ($this->signedUploads[$url] as $name => $value) { + if (($sent[strtolower($name)] ?? null) !== $value) { + return false; + } + } + + return true; + } + /** @param list $headers */ private static function storageAccepts(array $headers, string $body): bool { @@ -267,7 +298,6 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void private const UPLOAD_URL = 'https://us.posthog.com/api/projects/@current/messaging/attachments/uploads/'; private const INVITE_SHA256 = '215efae54e34ac5657da47b49c8d1bd8ea12649ee1efa838c9a47d7233f18afb'; - private const INVITE_CHECKSUM = 'IV765U40rFZX2ke0nI0b2OoSZJ7h76g4yaR9cjPxivs='; private const PNG = "\x89PNG\r\n\x1a\n\x00"; private const PNG_SHA256 = '843ac23b1736b4487ec81cf7c07ddd9bb46ae5b7818c2c3843d99d62fa75f3c9'; private const BUCKET = 'https://attachments.example/team/2/'; @@ -341,24 +371,17 @@ public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void ]); $this->assertSame('msg_1', $result->getId()); - $this->assertSame( - [ - 'POST ' . self::UPLOAD_URL, - 'PUT ' . self::BUCKET . self::INVITE_SHA256 . self::SIGNATURE, - 'POST ' . self::SEND_URL, - ], - array_map(static fn(array $request): string => $request['method'] . ' ' . $request['url'], $this->requests) - ); + $this->assertSame(['POST', 'PUT', 'POST'], array_column($this->requests, 'method')); $this->assertSame( ['files' => [['sha256' => self::INVITE_SHA256, 'size' => 32], ['sha256' => self::PNG_SHA256, 'size' => 9]]], $this->body(0) ); $this->assertSame('Bearer phs_secret', $this->header('Authorization', 0)); $this->assertSame('resend', $this->header('X-PostHog-Migrated-From', 0)); - $this->assertSame( - ['Content-Length: 32', 'x-amz-checksum-sha256: ' . self::INVITE_CHECKSUM], - $this->requests[1]['headers'], - 'the upload carries only the headers PostHog signed, and never the secret key' + $this->assertStringNotContainsString( + 'phs_secret', + implode("\n", $this->requests[1]['headers']), + 'storage never gets the secret key' ); $this->assertSame(self::INVITE, $this->requests[1]['body']); $this->assertSame([ diff --git a/test/fixtures/http-server.php b/test/fixtures/http-server.php index a3510d5f..7b92f7de 100644 --- a/test/fixtures/http-server.php +++ b/test/fixtures/http-server.php @@ -37,11 +37,19 @@ 'body' => $body, ], JSON_THROW_ON_ERROR) . "\n", FILE_APPEND); + $next = $responses[0] ?? []; + $refusal = null; + foreach ($next['accepts'] ?? [] as $name => $expected) { + if (($name === 'request_line' ? $requestLine : $headers[$name] ?? null) !== $expected) { + $refusal = $next['refusal']; + } + } $checksum = $headers['x-amz-checksum-sha256'] ?? null; - $response = $checksum === null || $checksum === base64_encode(hash('sha256', $body, true)) - ? array_shift($responses) ?? [] - : ['status' => 400, 'body' => 'BadDigest' + if ($checksum !== null && $checksum !== base64_encode(hash('sha256', $body, true))) { + $refusal = ['status' => 400, 'body' => 'BadDigest' . 'The SHA256 you specified did not match the calculated checksum.']; + } + $response = $refusal ?? array_shift($responses) ?? []; usleep(($response['delay_ms'] ?? 0) * 1000); $closeServer = $response['close_server'] ?? false; if ($closeServer) { From f7dd93f918e7152634ac64493e828b2daa70813d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:13:35 +0000 Subject: [PATCH 43/50] test(emails): prove storage keeps no form label on an uploaded file Co-Authored-By: Claude Opus 5.5 --- test/EmailsClientTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index 58878202..a922248a 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -138,6 +138,7 @@ public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void json_encode($stored['headers']), 'storage never gets the secret key' ); + $this->assertArrayNotHasKey('content-type', $stored['headers'], 'storage keeps no form label on the file'); $this->assertSame( [['sha256' => $sha256, 'filename' => 'receipt.pdf']], json_decode($send['body'], true)['attachments'] From 379fa80af31a058940adb836636a048f444e9fe5 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:17:35 +0000 Subject: [PATCH 44/50] test(emails): refuse a send whose files storage doesn't hold, instead of asserting the request order Co-Authored-By: Claude Opus 5.5 --- test/EmailsTest.php | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/test/EmailsTest.php b/test/EmailsTest.php index e369d0a1..fdd33ae7 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -29,6 +29,8 @@ class EmailsTest extends TestCase private array $replies = []; /** @var array> the headers storage signed for each upload URL */ private array $signedUploads = []; + /** @var array the files storage holds, by SHA-256; null for one PostHog already had */ + private array $stored = []; private ClockInterface $previousClock; private MockClock $clock; @@ -82,20 +84,48 @@ public function transport( if ($method === 'PUT' && !self::storageAccepts($headers, (string) $body)) { return [400, self::BUCKET_DIGEST_ERROR, []]; } + $missing = $method === 'POST' && str_ends_with($url, '/send/email/') ? $this->notStored((string) $body) : null; + if ($missing !== null) { + return [400, json_encode([ + 'type' => 'validation_error', + 'code' => 'attachment_not_uploaded', + 'detail' => 'This file was never uploaded.', + 'attr' => $missing, + ]), []]; + } $reply = array_shift($this->replies) ?? [202, '{"message_id":"msg_1"}', []]; if ($reply instanceof EmailError) { throw $reply; } + if ($method === 'PUT' && $reply[0] >= 200 && $reply[0] < 300) { + $this->stored[hash('sha256', (string) $body)] = $body; + } $answer = json_decode($reply[1], true); foreach (is_array($answer) && is_array($answer['files'] ?? null) ? $answer['files'] : [] as $slot) { if (is_array($slot) && is_string($slot['url'] ?? null) && is_array($slot['headers'] ?? null)) { $this->signedUploads[$slot['url']] = $slot['headers']; } + if (is_array($slot) && ($slot['status'] ?? null) === 'present' && is_string($slot['sha256'] ?? null)) { + $this->stored[$slot['sha256']] ??= null; + } } return $reply; } + /** The reference of the first attachment storage doesn't hold, as PostHog names it, or null. */ + private function notStored(string $body): ?string + { + $attachments = json_decode($body, true)['attachments'] ?? []; + foreach (is_array($attachments) ? $attachments : [] as $index => $attachment) { + if (!array_key_exists($attachment['sha256'] ?? '', $this->stored)) { + return "attachments__{$index}__sha256"; + } + } + + return null; + } + /** @param list $headers */ private function storageSigned(string $url, array $headers): bool { @@ -371,19 +401,19 @@ public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void ]); $this->assertSame('msg_1', $result->getId()); - $this->assertSame(['POST', 'PUT', 'POST'], array_column($this->requests, 'method')); + $this->assertSame(self::INVITE, $this->stored[self::INVITE_SHA256], 'storage holds the new file'); $this->assertSame( ['files' => [['sha256' => self::INVITE_SHA256, 'size' => 32], ['sha256' => self::PNG_SHA256, 'size' => 9]]], $this->body(0) ); $this->assertSame('Bearer phs_secret', $this->header('Authorization', 0)); $this->assertSame('resend', $this->header('X-PostHog-Migrated-From', 0)); + $toStorage = array_filter($this->requests, fn ($request) => str_starts_with($request['url'], self::BUCKET)); $this->assertStringNotContainsString( 'phs_secret', - implode("\n", $this->requests[1]['headers']), + json_encode(array_column($toStorage, 'headers')), 'storage never gets the secret key' ); - $this->assertSame(self::INVITE, $this->requests[1]['body']); $this->assertSame([ 'template_key' => 'invite', 'subject' => 'You are invited', From 7bfc98bd160814074430519f5cd2ea1ff4dcbe2d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:20:41 +0000 Subject: [PATCH 45/50] fix(emails): drop the blank Content-Type on uploads; storage fakes refuse a second auth Storage ignores unsigned headers, so blanking curl's form type changed nothing a caller can see. Both storage fakes now refuse a presigned PUT that also carries an Authorization header, as S3 does, instead of tests reading header strings. Co-Authored-By: Claude Opus 5.5 --- lib/Emails.php | 7 +------ test/EmailsClientTest.php | 6 ------ test/EmailsTest.php | 11 +++++------ test/fixtures/http-server.php | 4 ++++ 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/lib/Emails.php b/lib/Emails.php index e4fcb78b..f86a541e 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -56,7 +56,6 @@ final class Emails 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 NO_FORM_CONTENT_TYPE = 'Content-Type:'; 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'; @@ -952,15 +951,11 @@ private static function curlTransport( $responseHeaders = []; $responseBody = ''; $tooLarge = false; - $headers[] = self::NO_EXPECT_100_CONTINUE; - if ($body !== null && preg_grep('/^content-type:/i', $headers) === []) { - $headers[] = self::NO_FORM_CONTENT_TYPE; - } $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => $url, CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_HTTPHEADER => $headers, + 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; diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index a922248a..8e08534f 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -133,12 +133,6 @@ public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void ); [$stored] = $this->bucket->requests(); $this->assertSame($file, $stored['body']); - $this->assertStringNotContainsString( - 'phs_secret', - json_encode($stored['headers']), - 'storage never gets the secret key' - ); - $this->assertArrayNotHasKey('content-type', $stored['headers'], 'storage keeps no form label on the file'); $this->assertSame( [['sha256' => $sha256, 'filename' => 'receipt.pdf']], json_decode($send['body'], true)['attachments'] diff --git a/test/EmailsTest.php b/test/EmailsTest.php index fdd33ae7..5f0791f9 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -78,6 +78,9 @@ public function transport( ): array { $at = Clock::get()->now(); $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'at'); + if ($method === 'PUT' && preg_grep('/^authorization:/i', $headers) !== []) { + return [400, self::BUCKET_AUTH_ERROR, []]; + } if ($method === 'PUT' && !$this->storageSigned($url, $headers)) { return [403, 'SignatureDoesNotMatchNot as signed', []]; } @@ -332,6 +335,8 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void private const PNG_SHA256 = '843ac23b1736b4487ec81cf7c07ddd9bb46ae5b7818c2c3843d99d62fa75f3c9'; private const BUCKET = 'https://attachments.example/team/2/'; private const SIGNATURE = '?X-Amz-Signature=signature-secret'; + private const BUCKET_AUTH_ERROR = 'InvalidArgument' + . 'Only one auth mechanism allowed'; private const BUCKET_DIGEST_ERROR = 'BadDigest' . 'The SHA256 you specified did not match the calculated checksum.'; @@ -408,12 +413,6 @@ public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void ); $this->assertSame('Bearer phs_secret', $this->header('Authorization', 0)); $this->assertSame('resend', $this->header('X-PostHog-Migrated-From', 0)); - $toStorage = array_filter($this->requests, fn ($request) => str_starts_with($request['url'], self::BUCKET)); - $this->assertStringNotContainsString( - 'phs_secret', - json_encode(array_column($toStorage, 'headers')), - 'storage never gets the secret key' - ); $this->assertSame([ 'template_key' => 'invite', 'subject' => 'You are invited', diff --git a/test/fixtures/http-server.php b/test/fixtures/http-server.php index 7b92f7de..a66fcc67 100644 --- a/test/fixtures/http-server.php +++ b/test/fixtures/http-server.php @@ -49,6 +49,10 @@ $refusal = ['status' => 400, 'body' => 'BadDigest' . 'The SHA256 you specified did not match the calculated checksum.']; } + if (str_contains($requestLine, 'X-Amz-Signature=') && isset($headers['authorization'])) { + $refusal = ['status' => 400, 'body' => '' + . 'InvalidArgumentOnly one auth mechanism allowed']; + } $response = $refusal ?? array_shift($responses) ?? []; usleep(($response['delay_ms'] ?? 0) * 1000); $closeServer = $response['close_server'] ?? false; From 1041d8b1a76bc772ef4fdc144605d55fcf112607 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:29:19 +0000 Subject: [PATCH 46/50] fix(emails): count the derived text and upload only over https Without text, PostHog sends the text it derives from the html, and cdp-api counts it, so the local estimate counts the html twice. An upload URL that isn't https is refused before any upload, unless the app host is plain http for local development. Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-cc-bcc-attachments.md | 2 +- README.md | 4 +- lib/Emails.php | 56 +++++++++++++++++-------- test/EmailsTest.php | 30 +++++++++++++ 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/.changeset/emails-cc-bcc-attachments.md b/.changeset/emails-cc-bcc-attachments.md index 48025936..84d39e2c 100644 --- a/.changeset/emails-cc-bcc-attachments.md +++ b/.changeset/emails-cc-bcc-attachments.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes: the SDK hashes each file, uploads the ones PostHog doesn't have yet, then sends the email with references to them. An email takes at most 10 files and 40 MB after Base64 encoding, about 29 MB of files. +Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes: the SDK hashes each file, uploads the ones PostHog doesn't have yet, then sends the email with references to them. An email takes at most 10 files and 40 MB after Base64 encoding, about 29 MB of files. Files upload only over https, unless the `app_host` is plain `http://` for local development. diff --git a/README.md b/README.md index 0d5f7c91..97ec68f3 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ $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`, `cc`, `bcc`, `attachments` 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. -- **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The whole email can be at most 40 MB after Base64 encoding, which leaves about 29 MB for the files; the SDK checks both limits before any request (`too_many_attachments`, `message_too_large`). Pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. -- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. A storage 429, 500, 502, 503 or 504 is retried like the send. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. +- **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The whole email can be at most 40 MB after Base64 encoding, which leaves about 29 MB for the files. The 40 MB counts the subject and the bodies, and without `text` it counts the `html` twice, since PostHog sends the text it derives from it. The SDK checks both limits before any request (`too_many_attachments`, `message_too_large`). Pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. +- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. Files go only to an `https` storage URL, unless your `app_host` is plain `http://` for local development. A storage 429, 500, 502, 503 or 504 is retried like the send. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. - **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. 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. diff --git a/lib/Emails.php b/lib/Emails.php index f86a541e..65d3ec5a 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -29,7 +29,8 @@ * at once. A send that is not queued throws EmailError. * * A send with attachments first uploads each file PostHog doesn't have yet to the storage URL PostHog - * answers, which is on another host than the app host. Each upload gets the request timeout plus 1 ms + * answers, which is on another host than the app host. It uploads only to an `https` URL, unless the app + * host is plain `http://` for local development. Each upload gets the request timeout plus 1 ms * for every 100 bytes of the file, but only the request timeout to connect. A storage 429, 500, 502, 503 * or 504 is retried as above. * @@ -155,15 +156,15 @@ public function __construct( * and `capture_engagement` (default true; a project whose admin turned engagement capture off * captures nothing, whatever the send asks). An empty `reply_to`, `cc`, `bcc` or `attachments` list * is left out. - * - `attachments` is a list of at most 10 files, and the email can be at most 40 MB after Base64 - * encoding, which leaves about 29 MB for the files. Each file is an array with `filename`, `content` - * (the file's bytes, such as `file_get_contents()` returns; pass `base64_decode($content)` when you - * hold Base64, such as from a Resend payload), and optional `content_type` and `content_id`. - * Reference a `content_id` of `logo` in the HTML as `` to show the file inline. - * PostHog can't fetch a file from a path or URL. The client checks both limits before any request, - * hashes each file with SHA-256, uploads the files PostHog doesn't have yet, and then sends the - * email with each file's hash. A file that fails to upload throws `upload_failed` with the storage's - * answer, and no email is sent. A PostHog without the upload route, or a project that can't send + * - `attachments` is a list of at most 10 files, and the email can be at most 40 MB after Base64 encoding, which + * leaves about 29 MB for the files. The 40 MB counts the subject and the bodies, and the `html` twice without + * `text`, since PostHog derives the text from it. Each file is an array with `filename`, `content` (the file's + * bytes, such as `file_get_contents()` returns; pass `base64_decode($content)` when you hold Base64, such as from + * a Resend payload), and optional `content_type` and `content_id`. Reference a `content_id` of `logo` in the HTML + * as `` to show the file inline. PostHog can't fetch a file from a path or URL. The client + * checks both limits before any request, hashes each file with SHA-256, uploads the files PostHog doesn't have + * yet, and then sends the email with each file's hash. A file that fails to upload throws `upload_failed` with + * the storage's answer, and no email is sent. A PostHog without the upload route, or a project that can't send * email from code, answers `not_found` on `attachments`, and no email is sent. * * A PostHog version that doesn't take a field yet, such as `cc`, refuses the send with an error that @@ -421,10 +422,13 @@ private static function codeContent(array $email): array } $files = []; if (array_key_exists('attachments', $email) && $email['attachments'] !== []) { - [$content['attachments'], $files] = self::attachments($email['attachments'], $content['subject'], [ - $content['html'] ?? '', - $content['text'] ?? '', - ]); + $html = $content['html'] ?? ''; + $text = ($content['text'] ?? '') === '' ? $html : $content['text']; + [$content['attachments'], $files] = self::attachments( + $email['attachments'], + $content['subject'], + [$html, $text] + ); } $captureEngagement = $email['capture_engagement'] ?? true; if (!is_bool($captureEngagement)) { @@ -620,8 +624,9 @@ private function upload(string $appHost, string $secretKey, array $files, int $m $body = json_encode(['files' => $request], JSON_THROW_ON_ERROR); $headers = $this->headers($secretKey, ['Content-Type: application/json']); $url = $appHost . self::UPLOADS_PATH; - $slots = $this->withRetries($maxRetries, function () use ($url, $headers, $body, $files): array { - return self::parseUploadReply($files, ...$this->request('POST', $url, $headers, $body)); + $plainHttp = str_starts_with(strtolower($appHost), 'http://'); + $slots = $this->withRetries($maxRetries, function () use ($url, $headers, $body, $files, $plainHttp): array { + return self::parseUploadReply($files, $plainHttp, ...$this->request('POST', $url, $headers, $body)); }); foreach ($slots as $sha256 => $slot) { $this->refuseWhenClosed(self::SEND_NOT_DONE); @@ -688,8 +693,13 @@ private function put(string $url, array $headers, array $file): void * @param list $headers * @return array}> */ - private static function parseUploadReply(array $files, int $status, string $body, array $headers): array - { + private static function parseUploadReply( + array $files, + bool $plainHttp, + int $status, + string $body, + array $headers + ): array { $fields = self::replyFields($body); if ($status === 404) { $fields['code'] = self::text($fields['code'] ?? null) ?? 'not_found'; @@ -720,6 +730,16 @@ private static function parseUploadReply(array $files, int $status, string $body $status ); } + if (!$plainHttp && !str_starts_with(strtolower($upload['url']), 'https://')) { + throw new EmailError( + 'api_error', + "PostHog named an upload URL that isn't https, so {$files[$sha256]['filename']} would travel " + . 'unencrypted, and no email was sent.', + $status, + fix: "Fix the file storage's URL. For local development over plain HTTP, set an http:// app_host.", + field: $files[$sha256]['field'] + ); + } $uploads[$sha256] = $upload; } diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 5f0791f9..f7f28c9f 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -701,6 +701,32 @@ public function testAnUploadAnswerTheClientCannotFollowSendsNothing(array $answe $this->assertCount(1, $this->requests, 'nothing was uploaded or sent'); } + public function testAPlainHttpUploadUrlIsRefusedBeforeAnyUpload(): void + { + $plainUrl = 'http://attachments.example/team/2/' . self::INVITE_SHA256 . self::SIGNATURE; + $this->reply(self::slots(['url' => $plainUrl] + self::uploadSlot(self::INVITE))); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('api_error', $error->getErrorCode()); + $this->assertSame('attachments__0__content', $error->getField()); + $this->assertStringContainsString('invite.ics', $error->getMessage()); + $this->assertStringContainsString("Fix the file storage's URL", $error->getFix()); + $this->assertStringNotContainsString('attachments.example', $error->getMessage() . $error->getFix()); + $this->assertCount(1, $this->requests, 'nothing was uploaded or sent'); + } + + public function testAPlainHttpAppHostForLocalDevelopmentUploadsOverPlainHttp(): void + { + $plainUrl = 'http://localhost:8333/team/2/' . self::INVITE_SHA256 . self::SIGNATURE; + $this->reply(self::slots(['url' => $plainUrl] + self::uploadSlot(self::INVITE)), [200, '', []]); + + $result = $this->emails(['appHost' => 'http://localhost:8010'])->send(self::sendWithFiles([self::invite()])); + + $this->assertSame('msg_1', $result->getId()); + $this->assertSame(self::INVITE, $this->stored[self::INVITE_SHA256]); + } + public static function subjectsAndTheLargestFileThatFits(): array { return [ @@ -743,6 +769,10 @@ public static function messagesTooLarge(): array return [ 'a body that leaves no room' => [[str_repeat('a', 29_000_000)], ['text' => str_repeat('b', 200_000)]], 'files that only fit alone' => [[str_repeat('a', 15_000_000), str_repeat('b', 15_000_000)], []], + 'an HTML body that fits once but not again as its derived text' => [ + [str_repeat('a', 29_000_000)], + ['html' => str_repeat('h', 100_000), 'text' => ''], + ], ]; } From 62bcc89828956c6c7efb3808957eed8461458461 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:46:18 +0000 Subject: [PATCH 47/50] fix(emails): name a storage 429 a rate limit; assert outcomes, not request order A storage 429 that outlasts the retries is now rate_limit_exceeded, like every other 429. The test fake keeps what PostHog queued (one email per idempotency key), what storage took, and refuses a request without the secret key, so the attachment tests assert the result, the stored files and the queued email. Co-Authored-By: Claude Opus 5.5 --- lib/EmailError.php | 6 ++- test/EmailsTest.php | 119 +++++++++++++++++++++++++++++++------------- 2 files changed, 89 insertions(+), 36 deletions(-) diff --git a/lib/EmailError.php b/lib/EmailError.php index d26c3e10..81dc230c 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -129,7 +129,11 @@ public function getRetryAfterMs(): ?int private static function nameFor(?int $status, string $code): string { if ($code === 'upload_failed') { - return $status !== null && $status >= 500 ? 'service_unavailable' : 'application_error'; + return match (true) { + $status === 429 => 'rate_limit_exceeded', + $status !== null && $status >= 500 => 'service_unavailable', + default => 'application_error', + }; } if ($code === 'missing_secret_key' || $status === 401) { return 'missing_api_key'; diff --git a/test/EmailsTest.php b/test/EmailsTest.php index f7f28c9f..50f81292 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -31,6 +31,11 @@ class EmailsTest extends TestCase private array $signedUploads = []; /** @var array the files storage holds, by SHA-256; null for one PostHog already had */ private array $stored = []; + /** @var array how many times storage took each file, by SHA-256 */ + private array $storageWrites = []; + /** @var array> the emails PostHog queued, one per idempotency key */ + private array $queued = []; + private string $secretKey = 'phs_secret'; private ClockInterface $previousClock; private MockClock $clock; @@ -51,8 +56,10 @@ protected function tearDown(): void private function emails(array $config = []): Emails { + $this->secretKey = $config['secretKey'] ?? 'phs_secret'; + return new Emails( - $config['secretKey'] ?? 'phs_secret', + $this->secretKey, $config['host'] ?? 'us.i.posthog.com', appHost: $config['appHost'] ?? null, maxRetries: $config['maxRetries'] ?? Emails::DEFAULT_MAX_RETRIES, @@ -87,6 +94,9 @@ public function transport( if ($method === 'PUT' && !self::storageAccepts($headers, (string) $body)) { return [400, self::BUCKET_DIGEST_ERROR, []]; } + if ($method !== 'PUT' && self::headerIn($headers, 'Authorization') !== "Bearer {$this->secretKey}") { + return [401, '{"type":"authentication_error","code":"authentication_failed","detail":"Bad key."}', []]; + } $missing = $method === 'POST' && str_ends_with($url, '/send/email/') ? $this->notStored((string) $body) : null; if ($missing !== null) { return [400, json_encode([ @@ -100,8 +110,15 @@ public function transport( if ($reply instanceof EmailError) { throw $reply; } - if ($method === 'PUT' && $reply[0] >= 200 && $reply[0] < 300) { - $this->stored[hash('sha256', (string) $body)] = $body; + $accepted = $reply[0] >= 200 && $reply[0] < 300; + if ($method === 'PUT' && $accepted) { + $sha256 = hash('sha256', (string) $body); + $this->stored[$sha256] = $body; + $this->storageWrites[$sha256] = ($this->storageWrites[$sha256] ?? 0) + 1; + } + if ($method === 'POST' && str_ends_with($url, '/send/email/') && $accepted) { + $key = self::headerIn($headers, 'Idempotency-Key') ?? (string) count($this->queued); + $this->queued[$key] ??= json_decode((string) $body, true); } $answer = json_decode($reply[1], true); foreach (is_array($answer) && is_array($answer['files'] ?? null) ? $answer['files'] : [] as $slot) { @@ -116,6 +133,18 @@ public function transport( return $reply; } + /** @param list $headers */ + private static function headerIn(array $headers, string $name): ?string + { + foreach ($headers as $header) { + if (stripos($header, $name . ':') === 0) { + return trim(substr($header, strlen($name) + 1)); + } + } + + return null; + } + /** The reference of the first attachment storage doesn't hold, as PostHog names it, or null. */ private function notStored(string $body): ?string { @@ -411,9 +440,7 @@ public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void ['files' => [['sha256' => self::INVITE_SHA256, 'size' => 32], ['sha256' => self::PNG_SHA256, 'size' => 9]]], $this->body(0) ); - $this->assertSame('Bearer phs_secret', $this->header('Authorization', 0)); - $this->assertSame('resend', $this->header('X-PostHog-Migrated-From', 0)); - $this->assertSame([ + $this->assertSame([[ 'template_key' => 'invite', 'subject' => 'You are invited', 'html' => '', @@ -425,7 +452,7 @@ public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void ], 'capture_engagement' => true, 'to' => 'user@example.com', - ], $this->body(2)); + ]], array_values($this->queued)); } public function testTheSameFileTwiceIsUploadedOnceAndAttachedTwice(): void @@ -434,14 +461,13 @@ public function testTheSameFileTwiceIsUploadedOnceAndAttachedTwice(): void $this->emails()->send(self::sendWithFiles([self::invite(), self::invite(['filename' => 'copy.ics'])])); - $this->assertSame(['files' => [['sha256' => self::INVITE_SHA256, 'size' => 32]]], $this->body(0)); - $this->assertCount(3, $this->requests); + $this->assertSame([self::INVITE_SHA256 => 1], $this->storageWrites); $this->assertSame( [ ['sha256' => self::INVITE_SHA256, 'filename' => 'invite.ics'], ['sha256' => self::INVITE_SHA256, 'filename' => 'copy.ics'], ], - $this->body(2)['attachments'] + array_values($this->queued)[0]['attachments'] ); } @@ -454,11 +480,11 @@ public function testARetriedSendDoesNotUploadAgain(): void [202, '{"message_id":"msg_1"}', []] ); - $this->emails()->send(self::sendWithFiles([self::invite()])); + $result = $this->emails()->send(self::sendWithFiles([self::invite()])); - $this->assertSame(['POST', 'PUT', 'POST', 'POST'], array_column($this->requests, 'method')); - $this->assertSame($this->requests[2]['body'], $this->requests[3]['body']); - $this->assertSame($this->header('Idempotency-Key', 2), $this->header('Idempotency-Key', 3)); + $this->assertSame('msg_1', $result->getId()); + $this->assertSame([self::INVITE_SHA256 => 1], $this->storageWrites); + $this->assertCount(1, $this->queued); } public function testANullContentTypeOrContentIdCountsAsLeftOut(): void @@ -469,7 +495,7 @@ public function testANullContentTypeOrContentIdCountsAsLeftOut(): void $this->assertSame( [['sha256' => self::INVITE_SHA256, 'filename' => 'invite.ics']], - $this->body(1)['attachments'] + array_values($this->queued)[0]['attachments'] ); } @@ -516,7 +542,7 @@ public function testAFailedUploadSendsNothingAndSaysWhatTheBucketAnswered( $this->assertStringContainsString('invite.ics', $error->getMessage()); $this->assertStringEndsNotWith('..', $error->getMessage()); $this->assertStringNotContainsString('signature-secret', $error->getMessage() . $error->getFix()); - $this->assertSame(['POST', 'PUT'], array_column($this->requests, 'method'), 'no email was sent'); + $this->assertSame([], $this->queued, 'no email was sent'); } public static function retriedUploadFailures(): array @@ -541,10 +567,23 @@ public function testAnUploadThatFailsOnTheWayIsRetried($failure): void { $this->reply(self::slots(self::uploadSlot(self::INVITE)), $failure, [200, '', []]); - $this->emails()->send(self::sendWithFiles([self::invite()])); + $result = $this->emails()->send(self::sendWithFiles([self::invite()])); - $this->assertSame(['POST', 'PUT', 'PUT', 'POST'], array_column($this->requests, 'method')); - $this->assertSame(self::INVITE, $this->requests[2]['body']); + $this->assertSame('msg_1', $result->getId()); + $this->assertSame(self::INVITE, $this->stored[self::INVITE_SHA256]); + $this->assertCount(1, $this->queued); + } + + public function testStorageThrottlingThatOutlastsTheRetriesIsARateLimit(): void + { + $slowDown = [429, 'SlowDownReduce your rate', []]; + $this->reply(self::slots(self::uploadSlot(self::INVITE)), $slowDown); + + $error = $this->sendError(self::sendWithFiles([self::invite()]), $this->emails(['maxRetries' => 0])); + + $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame('rate_limit_exceeded', $error->getErrorName()); + $this->assertSame([], $this->queued, 'no email was sent'); } public function testAStorageRetryAfterOverThirtySecondsFailsAtOnce(): void @@ -556,17 +595,20 @@ public function testAStorageRetryAfterOverThirtySecondsFailsAtOnce(): void $this->assertSame('upload_failed', $error->getErrorCode()); $this->assertSame(60_000, $error->getRetryAfterMs()); - $this->assertSame(['POST', 'PUT'], array_column($this->requests, 'method')); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued); } public static function shutdownsDuringTheUploads(): array { - return ['after the upload request' => [1], 'after an upload' => [2], 'after the last upload' => [3]]; + return ['after the upload request' => [1, 0], 'after an upload' => [2, 1], 'after the last upload' => [3, 2]]; } #[DataProvider('shutdownsDuringTheUploads')] - public function testAClientShutDownDuringTheUploadsSendsNothingMore(int $requestsBeforeTheShutdown): void - { + public function testAClientShutDownDuringTheUploadsSendsNothingMore( + int $requestsBeforeTheShutdown, + int $filesStored + ): void { $emails = $this->emails(['isClosed' => fn(): bool => count($this->requests) >= $requestsBeforeTheShutdown]); $this->reply( self::slots(self::uploadSlot(self::INVITE), self::uploadSlot(self::PNG)), @@ -580,7 +622,8 @@ public function testAClientShutDownDuringTheUploadsSendsNothingMore(int $request ); $this->assertSame('client_closed', $error->getErrorCode()); - $this->assertCount($requestsBeforeTheShutdown, $this->requests); + $this->assertCount($filesStored, $this->storageWrites); + $this->assertSame([], $this->queued); } public function testAnUploadRefusalNamesTheAttachmentAndSendsNothing(): void @@ -604,7 +647,8 @@ public function testAnUploadRefusalNamesTheAttachmentAndSendsNothing(): void $this->assertSame('validation_error', $error->getErrorName()); $this->assertSame('attachments__2__content', $error->getField()); $this->assertSame('Send fewer or smaller files, or link to large files from the email body.', $error->getFix()); - $this->assertCount(1, $this->requests); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); } public function testBytesThatDoNotMatchTheSignedChecksumAreRefusedByStorageAndNothingIsSent(): void @@ -618,7 +662,8 @@ public function testBytesThatDoNotMatchTheSignedChecksumAreRefusedByStorageAndNo $this->assertSame('upload_failed', $error->getErrorCode()); $this->assertSame(400, $error->getStatusCode()); $this->assertStringContainsString('BadDigest', $error->getMessage()); - $this->assertSame(['POST', 'PUT'], array_column($this->requests, 'method')); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued); } public function testTheDailyUploadLimitIsNotRetried(): void @@ -628,7 +673,8 @@ public function testTheDailyUploadLimitIsNotRetried(): void $error = $this->sendError(self::sendWithFiles([self::invite()])); $this->assertSame('upload_quota_exceeded', $error->getErrorCode()); - $this->assertCount(1, $this->requests); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); } public static function uploadRouteNotFound(): array @@ -654,17 +700,18 @@ public function testAnUploadRouteNotFoundReportsPostHogsAnswerAndSendsNothing(st $this->assertSame(404, $error->getStatusCode()); $this->assertSame('attachments', $error->getField()); $this->assertSame($message, $error->getMessage()); - $this->assertCount(1, $this->requests, 'no email was sent'); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); } public function testAnUnavailableUploadRouteIsRetried(): void { $this->reply([503, '{"code":"delivery_unavailable"}', []], self::slots(self::presentSlot(self::INVITE_SHA256))); - $this->emails()->send(self::sendWithFiles([self::invite()])); + $result = $this->emails()->send(self::sendWithFiles([self::invite()])); - $this->assertSame(['POST', 'POST', 'POST'], array_column($this->requests, 'method')); - $this->assertSame(self::SEND_URL, $this->requests[2]['url']); + $this->assertSame('msg_1', $result->getId()); + $this->assertCount(1, $this->queued); } public static function unusableUploadAnswers(): array @@ -698,7 +745,8 @@ public function testAnUploadAnswerTheClientCannotFollowSendsNothing(array $answe } $this->assertSame('api_error', $error->getErrorCode()); - $this->assertCount(1, $this->requests, 'nothing was uploaded or sent'); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); } public function testAPlainHttpUploadUrlIsRefusedBeforeAnyUpload(): void @@ -713,7 +761,8 @@ public function testAPlainHttpUploadUrlIsRefusedBeforeAnyUpload(): void $this->assertStringContainsString('invite.ics', $error->getMessage()); $this->assertStringContainsString("Fix the file storage's URL", $error->getFix()); $this->assertStringNotContainsString('attachments.example', $error->getMessage() . $error->getFix()); - $this->assertCount(1, $this->requests, 'nothing was uploaded or sent'); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); } public function testAPlainHttpAppHostForLocalDevelopmentUploadsOverPlainHttp(): void @@ -744,11 +793,11 @@ public function testTheLargestFileThatFitsBesideTheSubjectIsUploaded(string $sub $file = str_repeat('a', $largest); $this->reply(self::slots(self::presentSlot(hash('sha256', $file)))); - $this->emails()->send( + $result = $this->emails()->send( self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]], ['subject' => $subject]) ); - $this->assertSame(['files' => [['sha256' => hash('sha256', $file), 'size' => $largest]]], $this->body(0)); + $this->assertSame('msg_1', $result->getId()); } #[DataProvider('subjectsAndTheLargestFileThatFits')] From 27df80d38823ea1dcb1de46d6b2634fe53156d80 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:35:05 +0000 Subject: [PATCH 48/50] test(emails): fakes negotiate uploads; assert stored files and queued emails, not requests The API fake answers the upload route from what storage holds, and the local server records each answer's status, so tests read what storage took and what PostHog queued rather than the requests the SDK made. Co-Authored-By: Claude Opus 5.5 --- test/EmailsClientTest.php | 45 ++++++++++++++++++++--------- test/EmailsTest.php | 54 +++++++++++++++++++++++------------ test/LocalHttpServer.php | 17 +++++++++++ test/fixtures/http-server.php | 19 +++++++----- 4 files changed, 97 insertions(+), 38 deletions(-) diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index 8e08534f..f9512dff 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -108,10 +108,11 @@ public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void 'accepts' => [ 'request_line' => 'POST /api/projects/@current/messaging/attachments/uploads/ HTTP/1.1', 'authorization' => 'Bearer phs_secret', + 'json_body' => ['files' => [['sha256' => $sha256, 'size' => strlen($file)]]], ], 'refusal' => [ - 'status' => 404, - 'body' => '{"type":"invalid_request","code":"not_found","detail":"Not found."}', + 'status' => 400, + 'body' => '{"type":"validation_error","code":"invalid_request","detail":"Not as PostHog signs."}', ], ], ['status' => 202, 'body' => '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}'], @@ -126,19 +127,36 @@ public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void ]); $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $result->getId()); - [$upload, $send] = $this->server->requests(); - $this->assertSame( - ['files' => [['sha256' => $sha256, 'size' => strlen($file)]]], - json_decode($upload['body'], true) - ); - [$stored] = $this->bucket->requests(); - $this->assertSame($file, $stored['body']); + $this->assertSame([$file], $this->storedFiles()); $this->assertSame( [['sha256' => $sha256, 'filename' => 'receipt.pdf']], - json_decode($send['body'], true)['attachments'] + $this->queuedEmails()[0]['attachments'] + ); + } + + /** @return list the files storage took */ + private function storedFiles(): array + { + return array_column(self::accepted($this->bucket, 'PUT '), 'body'); + } + + /** @return list> the emails PostHog queued */ + private function queuedEmails(): array + { + return array_map( + static fn(array $send): array => json_decode($send['body'], true), + self::accepted($this->server, 'POST /api/projects/@current/messaging/send/email/ ') ); } + private static function accepted(LocalHttpServer $server, string $requestLinePrefix): array + { + return array_values(array_filter( + $server->acceptedRequests(), + static fn(array $request): bool => str_starts_with($request['requestLine'], $requestLinePrefix) + )); + } + private static function uploadAnswer(string $storage, string $file): array { $sha256 = hash('sha256', $file); @@ -181,7 +199,8 @@ public function testStorageRefusesBytesThatDoNotMatchTheSignedChecksumAndNothing $this->assertStringContainsString('BadDigest', $error->getMessage()); } - $this->assertCount(1, $this->server->requests(), 'nothing was sent'); + $this->assertSame([], $this->storedFiles()); + $this->assertSame([], $this->queuedEmails(), 'nothing was sent'); } public function testStorageMayTakeLongerThanTheRequestTimeoutToTakeALargerFile(): void @@ -197,7 +216,7 @@ public function testStorageMayTakeLongerThanTheRequestTimeoutToTakeALargerFile() $result = $client->emails()->send(self::sendWithFile($file)); $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $result->getId()); - $this->assertSame($file, $this->bucket->requests()[0]['body']); + $this->assertSame([$file], $this->storedFiles()); } public function testAnUploadGivesUpConnectingToStorageAtTheRequestTimeout(): void @@ -232,7 +251,7 @@ public function testAnUploadGivesUpConnectingToStorageAtTheRequestTimeout(): voi } $this->assertLessThan(3000, (hrtime(true) - $started) / 1e6, 'gave up at the 1 s connect timeout, not 6 s'); - $this->assertCount(1, $this->server->requests(), 'nothing was sent'); + $this->assertSame([], $this->queuedEmails(), 'nothing was sent'); fclose($storage); } diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 50f81292..51bde130 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -25,7 +25,7 @@ class EmailsTest extends TestCase * at: DateTimeImmutable}> */ private array $requests = []; - /** @var list}|EmailError> */ + /** @var list}|EmailError|string> */ private array $replies = []; /** @var array> the headers storage signed for each upload URL */ private array $signedUploads = []; @@ -107,6 +107,9 @@ public function transport( ]), []]; } $reply = array_shift($this->replies) ?? [202, '{"message_id":"msg_1"}', []]; + if ($reply === self::NEGOTIATE) { + $reply = $this->negotiate((string) $body); + } if ($reply instanceof EmailError) { throw $reply; } @@ -133,6 +136,19 @@ public function transport( return $reply; } + /** PostHog's answer to the upload route: present for each file it holds, a slot signed for each other file. */ + private function negotiate(string $body): array + { + $slots = []; + foreach (json_decode($body, true)['files'] ?? [] as $file) { + $slots[] = array_key_exists($file['sha256'], $this->stored) + ? self::presentSlot($file['sha256']) + : self::signedSlot($file['sha256'], $file['size']); + } + + return self::slots(...$slots); + } + /** @param list $headers */ private static function headerIn(array $headers, string $name): ?string { @@ -196,7 +212,7 @@ private static function storageAccepts(array $headers, string $body): bool return true; } - /** @param array{int, string, list}|EmailError ...$replies */ + /** @param array{int, string, list}|EmailError|string ...$replies */ private function reply(...$replies): void { $this->replies = $replies; @@ -364,6 +380,7 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void private const PNG_SHA256 = '843ac23b1736b4487ec81cf7c07ddd9bb46ae5b7818c2c3843d99d62fa75f3c9'; private const BUCKET = 'https://attachments.example/team/2/'; private const SIGNATURE = '?X-Amz-Signature=signature-secret'; + private const NEGOTIATE = 'answer the upload route from what PostHog holds'; private const BUCKET_AUTH_ERROR = 'InvalidArgument' . 'Only one auth mechanism allowed'; private const BUCKET_DIGEST_ERROR = 'BadDigest' @@ -371,16 +388,19 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void private static function uploadSlot(string $content): array { - $sha256 = hash('sha256', $content); + return self::signedSlot(hash('sha256', $content), strlen($content)); + } + private static function signedSlot(string $sha256, int $size): array + { return [ 'sha256' => $sha256, 'status' => 'upload', 'url' => self::BUCKET . $sha256 . self::SIGNATURE, 'method' => 'PUT', 'headers' => [ - 'Content-Length' => (string) strlen($content), - 'x-amz-checksum-sha256' => base64_encode(hash('sha256', $content, true)), + 'Content-Length' => (string) $size, + 'x-amz-checksum-sha256' => base64_encode((string) hex2bin($sha256)), ], 'url_expires_at' => '2026-11-02T12:15:00Z', ]; @@ -415,11 +435,8 @@ private static function invite(array $file = []): array public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void { - $this->reply( - self::slots(self::uploadSlot(self::INVITE), self::presentSlot(self::PNG_SHA256)), - [200, '', []], - [202, '{"message_id":"msg_1"}', []] - ); + $this->stored[self::PNG_SHA256] = null; + $this->reply(self::NEGOTIATE, [200, '', []], [202, '{"message_id":"msg_1"}', []]); $result = $this->emails(['migratedFrom' => 'resend'])->send([ 'to' => 'user@example.com', @@ -436,10 +453,7 @@ public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void $this->assertSame('msg_1', $result->getId()); $this->assertSame(self::INVITE, $this->stored[self::INVITE_SHA256], 'storage holds the new file'); - $this->assertSame( - ['files' => [['sha256' => self::INVITE_SHA256, 'size' => 32], ['sha256' => self::PNG_SHA256, 'size' => 9]]], - $this->body(0) - ); + $this->assertSame([self::INVITE_SHA256 => 1], $this->storageWrites, 'the file PostHog held went nowhere'); $this->assertSame([[ 'template_key' => 'invite', 'subject' => 'You are invited', @@ -810,7 +824,8 @@ public function testOneByteMoreThanFitsBesideTheSubjectIsRefused(string $subject ); $this->assertSame('message_too_large', $error->getErrorCode()); - $this->assertSame([], $this->requests); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); } public static function messagesTooLarge(): array @@ -841,7 +856,8 @@ public function testAMessageOverFortyMegabytesIsRefusedBeforeAnyRequest(array $c $this->assertNull($error->getStatusCode()); $this->assertStringContainsString('40 MB', $error->getMessage()); $this->assertStringContainsString('about 29 MB of files', $error->getWhy()); - $this->assertSame([], $this->requests); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); } public function testMoreThanTenFilesAreRefusedBeforeAnyRequest(): void @@ -851,7 +867,8 @@ public function testMoreThanTenFilesAreRefusedBeforeAnyRequest(): void $this->assertSame('too_many_attachments', $error->getErrorCode()); $this->assertSame('validation_error', $error->getErrorName()); $this->assertSame('attachments', $error->getField()); - $this->assertSame([], $this->requests); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); } public function testEmptyCopyAndAttachmentListsAreLeftOut(): void @@ -1077,7 +1094,8 @@ public function testAnAttachmentTheClientCannotSendIsRefusedWithoutRepeatingItsC $this->assertSame('validation_error', $error->getErrorName()); $this->assertSame($field, $error->getField()); $this->assertStringNotContainsString(self::SECRET_CONTENT, $error->getMessage() . $error->getFix()); - $this->assertSame([], $this->requests); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued); } public function testAnOptionOfTheOtherFormSaysHowToSendIt(): void diff --git a/test/LocalHttpServer.php b/test/LocalHttpServer.php index bebe15c0..f74c188b 100644 --- a/test/LocalHttpServer.php +++ b/test/LocalHttpServer.php @@ -41,6 +41,23 @@ public function address(): string } public function requests(): array + { + return array_map(static function (array $request): array { + unset($request['status']); + return $request; + }, $this->answered()); + } + + /** The requests the server answered with a 2xx status, which it took. */ + public function acceptedRequests(): array + { + return array_values(array_map(static function (array $request): array { + unset($request['status']); + return $request; + }, array_filter($this->answered(), static fn(array $request): bool => intdiv($request['status'], 100) === 2))); + } + + private function answered(): array { return array_map( static fn(string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR), diff --git a/test/fixtures/http-server.php b/test/fixtures/http-server.php index a66fcc67..729f3539 100644 --- a/test/fixtures/http-server.php +++ b/test/fixtures/http-server.php @@ -31,16 +31,15 @@ if (($headers['content-encoding'] ?? '') === 'gzip') { $body = gzdecode($body); } - file_put_contents($argv[1], json_encode([ - 'requestLine' => $requestLine, - 'headers' => $headers, - 'body' => $body, - ], JSON_THROW_ON_ERROR) . "\n", FILE_APPEND); - $next = $responses[0] ?? []; $refusal = null; foreach ($next['accepts'] ?? [] as $name => $expected) { - if (($name === 'request_line' ? $requestLine : $headers[$name] ?? null) !== $expected) { + $actual = match ($name) { + 'request_line' => $requestLine, + 'json_body' => json_decode($body, true), + default => $headers[$name] ?? null, + }; + if ($name === 'json_body' ? $actual != $expected : $actual !== $expected) { $refusal = $next['refusal']; } } @@ -54,6 +53,12 @@ . 'InvalidArgumentOnly one auth mechanism allowed']; } $response = $refusal ?? array_shift($responses) ?? []; + file_put_contents($argv[1], json_encode([ + 'requestLine' => $requestLine, + 'headers' => $headers, + 'body' => $body, + 'status' => $response['status'] ?? 200, + ], JSON_THROW_ON_ERROR) . "\n", FILE_APPEND); usleep(($response['delay_ms'] ?? 0) * 1000); $closeServer = $response['close_server'] ?? false; if ($closeServer) { From 569df7a201b9bad00ac19e3c38703126502f037c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:23:09 +0000 Subject: [PATCH 49/50] fix(emails): never follow an upload's redirect; name upload failures attachment_upload_failed A 3xx answer to an upload PUT fails that attachment without quoting storage's answer, which can carry the redirect URL, and sends nothing. curl already doesn't follow; the test proves a following client would leak the file. The error code matches the attachments spec and the other SDKs. Refs PostHog/michaelr-context#84 Co-Authored-By: Claude Opus 5.5 --- .changeset/emails-cc-bcc-attachments.md | 2 +- README.md | 2 +- lib/EmailError.php | 4 +-- lib/Emails.php | 25 +++++++++++++++---- test/EmailsClientTest.php | 33 ++++++++++++++++++++++++- test/EmailsTest.php | 8 +++--- 6 files changed, 60 insertions(+), 14 deletions(-) diff --git a/.changeset/emails-cc-bcc-attachments.md b/.changeset/emails-cc-bcc-attachments.md index 84d39e2c..f481f172 100644 --- a/.changeset/emails-cc-bcc-attachments.md +++ b/.changeset/emails-cc-bcc-attachments.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes: the SDK hashes each file, uploads the ones PostHog doesn't have yet, then sends the email with references to them. An email takes at most 10 files and 40 MB after Base64 encoding, about 29 MB of files. Files upload only over https, unless the `app_host` is plain `http://` for local development. +Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes: the SDK hashes each file, uploads the ones PostHog doesn't have yet, then sends the email with references to them. An email takes at most 10 files and 40 MB after Base64 encoding, about 29 MB of files. Files upload only over https, unless the `app_host` is plain `http://` for local development, and an upload never follows a redirect. A failed upload throws `attachment_upload_failed`. diff --git a/README.md b/README.md index 97ec68f3..d518be51 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,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`, `cc`, `bcc`, `attachments` 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. - **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The whole email can be at most 40 MB after Base64 encoding, which leaves about 29 MB for the files. The 40 MB counts the subject and the bodies, and without `text` it counts the `html` twice, since PostHog sends the text it derives from it. The SDK checks both limits before any request (`too_many_attachments`, `message_too_large`). Pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. -- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. Files go only to an `https` storage URL, unless your `app_host` is plain `http://` for local development. A storage 429, 500, 502, 503 or 504 is retried like the send. A failed upload throws `upload_failed` with the storage's answer, and no email is sent. +- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. Files go only to an `https` storage URL, unless your `app_host` is plain `http://` for local development. A storage 429, 500, 502, 503 or 504 is retried like the send. A failed upload throws `attachment_upload_failed` with the storage's answer, and no email is sent. An upload never follows a redirect: storage that answers one fails that file the same way, without naming the URL. - **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. 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. diff --git a/lib/EmailError.php b/lib/EmailError.php index 81dc230c..97be9071 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -70,7 +70,7 @@ public function getErrorName(): string /** * 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`, `upload_failed` + * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error`, `attachment_upload_failed` * or an `invalid_*` argument code. */ public function getErrorCode(): string @@ -128,7 +128,7 @@ public function getRetryAfterMs(): ?int private static function nameFor(?int $status, string $code): string { - if ($code === 'upload_failed') { + if ($code === 'attachment_upload_failed') { return match (true) { $status === 429 => 'rate_limit_exceeded', $status !== null && $status >= 500 => 'service_unavailable', diff --git a/lib/Emails.php b/lib/Emails.php index 65d3ec5a..29e614fa 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -163,9 +163,10 @@ public function __construct( * a Resend payload), and optional `content_type` and `content_id`. Reference a `content_id` of `logo` in the HTML * as `` to show the file inline. PostHog can't fetch a file from a path or URL. The client * checks both limits before any request, hashes each file with SHA-256, uploads the files PostHog doesn't have - * yet, and then sends the email with each file's hash. A file that fails to upload throws `upload_failed` with - * the storage's answer, and no email is sent. A PostHog without the upload route, or a project that can't send - * email from code, answers `not_found` on `attachments`, and no email is sent. + * yet, and then sends the email with each file's hash. A file that fails to upload throws + * `attachment_upload_failed` with the storage's answer, and no email is sent. An upload never follows a + * redirect: storage that answers one fails that file the same way. A PostHog without the upload route, or a + * project that can't send email from code, answers `not_found` on `attachments`, and no email is sent. * * A PostHog version that doesn't take a field yet, such as `cc`, refuses the send with an error that * names the field. It never drops the field and sends the rest. @@ -667,6 +668,20 @@ private function put(string $url, array $headers, array $file): void if ($status >= 200 && $status < 300) { return; } + if ($status >= 300 && $status < 400) { + throw new EmailError( + 'attachment_upload_failed', + self::onOneLogLine( + "PostHog's file storage redirected the upload of {$file['filename']} (HTTP $status), and the " + . "client doesn't follow an upload's redirect, so no email was sent." + ), + $status, + why: 'A signed upload never succeeds through a redirect, and following one would send the file to ' + . 'another host, maybe unencrypted.', + fix: "Fix the file storage's URL.", + field: $file['field'] + ); + } $answer = "HTTP $status"; if (preg_match('#([^<]{1,100})#', $body, $code) === 1) { $answer .= ' ' . html_entity_decode($code[1], ENT_XML1); @@ -675,7 +690,7 @@ private function put(string $url, array $headers, array $file): void } } throw new EmailError( - 'upload_failed', + 'attachment_upload_failed', self::onOneLogLine( "PostHog's file storage refused the upload of {$file['filename']}, so no email was sent. " . 'It answered ' . rtrim($answer, '.') . '.' @@ -913,7 +928,7 @@ private static function isRetryable(EmailError $error): bool if ($status === null) { return $error->getErrorCode() === 'network_error'; } - if ($error->getErrorCode() === 'upload_failed') { + if ($error->getErrorCode() === 'attachment_upload_failed') { return in_array($status, [429, 500, 502, 503, 504], true); } if ($status === 409) { diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index f9512dff..0d633c58 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -195,7 +195,7 @@ public function testStorageRefusesBytesThatDoNotMatchTheSignedChecksumAndNothing $this->client(['email_request_max_retries' => 0])->emails()->send(self::sendWithFile($file)); $this->fail('The send went out after storage refused the file.'); } catch (EmailError $error) { - $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); $this->assertStringContainsString('BadDigest', $error->getMessage()); } @@ -203,6 +203,37 @@ public function testStorageRefusesBytesThatDoNotMatchTheSignedChecksumAndNothing $this->assertSame([], $this->queuedEmails(), 'nothing was sent'); } + public function testAnUploadThatStorageRedirectsIsNotFollowedAndNothingIsSent(): void + { + $file = "%PDF-1.7\n" . str_repeat("0 0 obj << >> endobj\n", 100); + $elsewhere = new LocalHttpServer([['status' => 200, 'body' => ''], ['status' => 200, 'body' => '']]); + $redirect = 'http://' . $elsewhere->address() . '/copy'; + $this->bucket = new LocalHttpServer([[ + 'status' => 307, + 'headers' => ['Location' => $redirect], + 'body' => "TemporaryRedirectSend it to $redirect", + ]]); + $this->server = new LocalHttpServer([ + self::uploadAnswer($this->bucket->address(), $file), + ['status' => 202, 'body' => '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}'], + ]); + + try { + $this->client(['email_request_max_retries' => 0])->emails()->send(self::sendWithFile($file)); + $this->fail('The send went out after storage redirected the upload.'); + } catch (EmailError $error) { + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); + $this->assertSame('attachments__0__content', $error->getField()); + $this->assertStringContainsString('receipt.pdf', $error->getMessage()); + $told = $error->getMessage() . $error->getWhy() . $error->getFix(); + $this->assertStringNotContainsString($elsewhere->address(), $told); + $this->assertStringNotContainsString($this->bucket->address(), $told); + } + + $this->assertSame([], $elsewhere->acceptedRequests(), 'the redirect target received nothing'); + $this->assertSame([], $this->queuedEmails(), 'nothing was sent'); + } + public function testStorageMayTakeLongerThanTheRequestTimeoutToTakeALargerFile(): void { $file = str_repeat('a', 200_000); diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 51bde130..6249873b 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -548,7 +548,7 @@ public function testAFailedUploadSendsNothingAndSaysWhatTheBucketAnswered( self::invite(), ])); - $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); $this->assertSame($name, $error->getErrorName()); $this->assertSame($status, $error->getStatusCode()); $this->assertSame('attachments__1__content', $error->getField()); @@ -595,7 +595,7 @@ public function testStorageThrottlingThatOutlastsTheRetriesIsARateLimit(): void $error = $this->sendError(self::sendWithFiles([self::invite()]), $this->emails(['maxRetries' => 0])); - $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); $this->assertSame('rate_limit_exceeded', $error->getErrorName()); $this->assertSame([], $this->queued, 'no email was sent'); } @@ -607,7 +607,7 @@ public function testAStorageRetryAfterOverThirtySecondsFailsAtOnce(): void $error = $this->sendError(self::sendWithFiles([self::invite()])); - $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); $this->assertSame(60_000, $error->getRetryAfterMs()); $this->assertSame([], $this->storageWrites); $this->assertSame([], $this->queued); @@ -673,7 +673,7 @@ public function testBytesThatDoNotMatchTheSignedChecksumAreRefusedByStorageAndNo $error = $this->sendError(self::sendWithFiles([self::invite()])); - $this->assertSame('upload_failed', $error->getErrorCode()); + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); $this->assertSame(400, $error->getStatusCode()); $this->assertStringContainsString('BadDigest', $error->getMessage()); $this->assertSame([], $this->storageWrites); From ec29f52fa03f7941e4bef06371b3b743b4bfca6f Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:44:14 +0000 Subject: [PATCH 50/50] test(emails): an invalid integration on a send with files uploads nothing The integration is checked with the other arguments, before the client asks PostHog which files it needs, so a bad marker never leaves an uploaded file behind. Refs PostHog/michaelr-context#42 Co-Authored-By: Claude Opus 5.5 --- test/EmailsTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/EmailsTest.php b/test/EmailsTest.php index f6333fc2..6c98448b 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -1134,6 +1134,11 @@ public static function argumentErrors(): array 'invalid_integration', 'integration', ], + 'invalid integration on a send with files' => [ + self::sendWithFiles([self::invite()]) + ['integration' => 'laravel'], + 'invalid_integration', + 'integration', + ], ]; }