diff --git a/.changeset/emails-send.md b/.changeset/emails-send.md new file mode 100644 index 0000000..0f5977c --- /dev/null +++ b/.changeset/emails-send.md @@ -0,0 +1,30 @@ +--- +"posthog-php": minor +--- + +The new `emails()` sends transactional email from your PHP code and reads each email's delivery status. + +```php +PostHog::init('phc_...', ['host' => 'https://us.i.posthog.com'], secretKey: 'phs_...'); + +$result = PostHog::emails()->send([ + 'to' => 'user@example.com', + 'distinct_id' => 'user_123', + 'template_key' => 'password-reset', + 'subject' => 'Reset your password', + 'html' => $renderedHtml, +]); + +// 'delivered', 'bounced', ... +$status = PostHog::emails()->get($result->getId())->getStatus(); +``` + +You can also send a template made in PostHog by its `template_id`. + +You need a project secret API key, the kind that starts with `phs_`. Create one under **Project secret API keys** in your project settings. Set its **Transactional email** scope to Write to send emails, or to Read for a key that only reads statuses. Pass it to the client as `secretKey`. Set the `app_host` option to your PostHog app URL when you self-host PostHog or send through a reverse proxy. The client also loads feature flags with this key when it starts, so give it **Feature flags: Read** too, or it logs an error each time a client starts. + +PostHog records each email's delivery, opens and clicks on the person with that `distinct_id`. It groups every email with the same `template_key`, so you see their metrics and logs in one place. The SDK retries a request that hits a network error, a rate limit or an outage twice by default. Every send carries an idempotency key, so a retry never sends the email twice. Pass your own `idempotency_key`, such as `welcome:user_123`, and your job runner's retries are safe too. + +A send with your own content also takes `from`, `reply_to` and `'capture_engagement' => false`. Set the `migrated_from` option to tell PostHog which email provider you moved from. A failed call throws a `PostHog\EmailError`. Its `getErrorCode()` returns PostHog's code, and `getStatusCode()` returns the HTTP status. + +Docs: [Send transactional emails with PHP](https://posthog.com/docs/workflows/transactional-emails/php) and the [transactional email overview](https://posthog.com/docs/workflows/transactional-emails). diff --git a/README.md b/README.md index a28b2ff..b5a79d4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,52 @@ 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`. Create it with the "Send transactional email" preset (`transactional_email:write`) in your project settings, and add `transactional_email:read` if you read statuses with a key that can't send. Keys made with `hog_flow:write` still work. A client whose `host` is PostHog Cloud sends to its region's app host. For any other `host`, such as a reverse proxy, set the `app_host` option to your PostHog app host: email requests never go to a custom `host`. Loading flag definitions still sends the secret key to `host`, so when your proxy must not see the key, build the client with `loadFeatureFlags: false` and don't call `loadFlags()`: + +```php +$client = new \PostHog\Client('phc_...', [ + '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_...'); + +// 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', +]); + +// 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. +- **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. +- **Retries.** Network errors, rate limits and an unavailable service are retried up to `email_request_max_retries` times (default 2) with the same idempotency key, so a retry never sends twice. Each request times out after `email_request_timeout_ms` (default 15000). When a job runner already retries the send, pass `'max_retries' => 0` and an `idempotency_key` derived from what the email is for, such as `welcome:user_123`. A generated key does not survive a job retry. +- **Moving from another provider.** Set the `migrated_from` option, such as `'resend'`, so PostHog knows where your sends came from. + ## Questions? ### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ) diff --git a/api/public-api.json b/api/public-api.json index 2196769..1927e0c 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": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "isDisabled", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "isClosed", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "transport", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, "defaultConstant": null, "hasDefault": true } ] }, - "flushBatch": { + "get": { "static": false, "abstract": false, "final": false, - "returnType": null, + "returnType": "PostHog\\EmailStatus", "parameters": [ { - "name": "batch", - "type": null, + "name": "messageId", + "type": "string", "byReference": false, "variadic": false, "optional": false, @@ -1617,34 +2167,15 @@ } ] }, - "getConsumer": { - "static": false, - "abstract": false, - "final": false, - "returnType": null, - "parameters": [] - } - } - }, - "PostHog\\ContextScope": { - "type": "class", - "abstract": false, - "final": true, - "readonly": false, - "extends": null, - "implements": [], - "constants": [], - "properties": [], - "methods": { - "__construct": { + "send": { "static": false, "abstract": false, "final": false, - "returnType": null, + "returnType": "PostHog\\SendEmailResult", "parameters": [ { - "name": "onClose", - "type": "?Closure", + "name": "email", + "type": "array", "byReference": false, "variadic": false, "optional": false, @@ -1653,20 +2184,6 @@ "hasDefault": false } ] - }, - "__destruct": { - "static": false, - "abstract": false, - "final": false, - "returnType": null, - "parameters": [] - }, - "close": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [] } } }, @@ -3881,6 +4398,13 @@ } ] }, + "emails": { + "static": true, + "abstract": false, + "final": false, + "returnType": "PostHog\\Emails", + "parameters": [] + }, "enterContext": { "static": true, "abstract": false, @@ -4935,6 +5459,77 @@ } } }, + "PostHog\\SendEmailResult": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "id", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "replayed", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "warnings", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getId": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string", + "parameters": [] + }, + "getWarnings": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [] + }, + "isReplayed": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + } + } + }, "PostHog\\SizeLimitedHash": { "type": "class", "abstract": false, diff --git a/lib/Client.php b/lib/Client.php index c81eb6d..fe851db 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, @@ -362,6 +370,7 @@ public function __construct( $this->flagsEtag = null; $this->flagDefinitionsLoaded = false; $this->minimalFlagCalledEvents = false; + $this->refuseBadEmailOptions(); if ($this->enabled) { ExceptionCapture::configure($this, $options['error_tracking'] ?? []); @@ -476,6 +485,46 @@ 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 + { + return new Emails( + $this->secretKey, + $this->options['host'], + appHost: $this->options['app_host'] ?? null, + 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, + isClosed: fn(): bool => $this->shutdownComplete, + ); + } + + 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 new file mode 100644 index 0000000..ffab563 --- /dev/null +++ b/lib/EmailError.php @@ -0,0 +1,150 @@ +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', + 422 => $code === 'idempotency_key_reused' ? 'invalid_idempotent_request' : 'validation_error', + 429 => $code === 'quota_limited' ? 'monthly_quota_exceeded' : 'rate_limit_exceeded', + 503 => 'service_unavailable', + default => 'application_error', + }; + } +} diff --git a/lib/EmailStatus.php b/lib/EmailStatus.php new file mode 100644 index 0000000..c858f2c --- /dev/null +++ b/lib/EmailStatus.php @@ -0,0 +1,85 @@ +id = $id; + $this->status = $status; + $this->createdAt = $createdAt; + $this->statusAt = $statusAt; + $this->reason = $reason; + } + + /** + * The message ID that Emails::send() returned. + */ + public function getId(): string + { + return $this->id; + } + + /** + * The email's status, such as `delivered` or `bounced`. + */ + public function getStatus(): string + { + return $this->status; + } + + /** + * When PostHog queued the email. + */ + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * When the email reached its current status. + */ + public function getStatusAt(): DateTimeImmutable + { + return $this->statusAt; + } + + /** + * Why a failed email was not sent, such as `provider_error` or `recipient_suppressed`, or the bounce + * type of a bounced email: `hard`, `soft` or `unknown`. Null otherwise. + */ + public function getReason(): ?string + { + return $this->reason; + } +} diff --git a/lib/EmailWarning.php b/lib/EmailWarning.php new file mode 100644 index 0000000..d156571 --- /dev/null +++ b/lib/EmailWarning.php @@ -0,0 +1,38 @@ +code = $code; + $this->message = $message; + $this->fix = $fix; + } + + public function getCode(): string + { + return $this->code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getFix(): ?string + { + return $this->fix; + } +} diff --git a/lib/Emails.php b/lib/Emails.php new file mode 100644 index 0000000..28fad04 --- /dev/null +++ b/lib/Emails.php @@ -0,0 +1,773 @@ +emails()` or `PostHog::emails()`. The client needs a project + * secret API key (`phs_...`) as `$secretKey`: sending needs the `transactional_email:write` scope, reading + * a status needs `transactional_email:read` or `transactional_email:write`. Keys made with `hog_flow:write` + * still work. A client on PostHog Cloud sends to its region's app host. For any other `host`, + * such as a reverse proxy, set the `app_host` option, because the proxy must not receive the secret key. + * + * Each request times out after `email_request_timeout_ms` (15000 by default). Network errors, rate + * 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 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'; + 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 ' + . 'transactional_email:write scope.'; + private const GET_KEY_NEEDED = 'Reading an email status needs a secret key, a project secret API key with the ' + . 'transactional_email:read or transactional_email: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 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. + */ + 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 = null, + ?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.'); + } + $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'."); + } + $this->secretKey = StringNormalizer::normalizeOptional($secretKey); + $this->appHost = self::resolveAppHost($host, $appHost); + $this->requestTimeoutMs = $requestTimeoutMs; + $this->maxRetries = $maxRetries; + $this->migratedFrom = $migratedFrom; + $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']); + } + + /** + * 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; 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` + * 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) { + $line = $warning->getCode() . ': ' . $warning->getMessage(); + error_log('[PostHog][Emails] Email warning ' . self::onOneLogLine($line)); + } + + 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} + */ + 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) && $email['reply_to'] !== []) { + $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)) { + 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} + */ + 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) { + 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; + } + $this->waitToRetry($delayMs, $error); + } + } + } + + private function waitToRetry(int $delayMs, EmailError $error): void + { + $clock = self::forwardOnlyClock(); + $start = $clock->now(); + 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 forwardOnlyClock(): ClockInterface + { + $clock = Clock::get(); + + return $clock instanceof NativeClock ? new MonotonicClock() : $clock; + } + + private static function microsecondsBetween(DateTimeImmutable $start, DateTimeImmutable $end): int + { + $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 + { + $status = $error->getStatusCode(); + if ($status === null) { + return $error->getErrorCode() === 'network_error'; + } + if ($status === 409) { + return $error->getErrorCode() === 'idempotency_key_in_flight'; + } + if ($status === 429) { + 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(); + 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 { + 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 = []; + $responseBody = ''; + $tooLarge = false; + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => $method, + 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; + return 0; + } + $responseBody .= $chunk; + return strlen($chunk); + }, + 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); + } + $succeeded = curl_exec($curl); + $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + 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)); + } + + 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; + } else { + $date = self::httpDateInUtc($value); + if ($date === null) { + return null; + } + $seconds = self::epochSecondsAsFloat($date) - self::epochSecondsAsFloat(Clock::get()->now()); + } + + 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)); + } + + private static function resolveAppHost(string $host, ?string $appHost): ?string + { + $appHost = StringNormalizer::normalizeOptional($appHost); + if ($appHost !== null) { + $appHost = rtrim($appHost, '/'); + $bare = preg_replace('#^https?://#i', '', $appHost); + if (isset(self::CLOUD_APP_HOSTS[strtolower($bare)])) { + return self::CLOUD_APP_HOSTS[strtolower($bare)]; + } + return $bare === $appHost ? 'https://' . $appHost : $appHost; + } + $bare = strtolower(rtrim(preg_replace('#^https?://#i', '', trim($host)), '/')); + + return self::CLOUD_APP_HOSTS[$bare] ?? null; + } +} diff --git a/lib/PostHog.php b/lib/PostHog.php index c6b0b77..4ab0272 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -39,8 +39,15 @@ class PostHog * `disableGeoip` defaults to false for compatibility with existing releases. It applies to events * and remote flag evaluation. Per-call options override this setting; false allows GeoIP enrichment. * + * Transactional email (see Client::emails()) uses `app_host`, `email_request_timeout_ms` (default 15000), + * `email_request_max_retries` (default 2) and `migrated_from`. + * * @param array{ * host?: string, + * app_host?: string, + * email_request_timeout_ms?: int, + * email_request_max_retries?: int, + * migrated_from?: string, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, @@ -609,6 +616,18 @@ public static function shutdown(): bool return self::$client->shutdown(); } + /** + * Transactional email on the default client. See Client::emails(). + * + * @return Emails + */ + public static function emails(): Emails + { + self::checkClient(); + + return self::$client->emails(); + } + /** * Get the underlying client instance. * Useful for accessing client-level functionality like loadFlags() or getFlagsEtag(). diff --git a/lib/SendEmailResult.php b/lib/SendEmailResult.php new file mode 100644 index 0000000..0c0ba34 --- /dev/null +++ b/lib/SendEmailResult.php @@ -0,0 +1,49 @@ + */ + private array $warnings; + + /** + * @internal Created by Emails::send(). + * @param list $warnings + */ + public function __construct(string $id, bool $replayed = false, array $warnings = []) + { + $this->id = $id; + $this->replayed = $replayed; + $this->warnings = $warnings; + } + + /** + * The queued email's message ID. Pass it to Emails::get() to read the email's status. + */ + public function getId(): string + { + return $this->id; + } + + /** + * True when PostHog had already accepted this idempotency key and returned the first send's result. + */ + public function isReplayed(): bool + { + return $this->replayed; + } + + /** + * @return list + */ + public function getWarnings(): array + { + return $this->warnings; + } +} diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php new file mode 100644 index 0000000..91ab645 --- /dev/null +++ b/test/EmailsClientTest.php @@ -0,0 +1,268 @@ +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 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(); + $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 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]], + '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']], + ]; + } + + #[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 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, + ]); + + $this->assertArrayNotHasKey('x-posthog-migrated-from', $this->server->requests()[0]['headers']); + } + + 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/EmailsMonotonicWaitTest.php b/test/EmailsMonotonicWaitTest.php new file mode 100644 index 0000000..d005acf --- /dev/null +++ b/test/EmailsMonotonicWaitTest.php @@ -0,0 +1,36 @@ +send([ + 'to' => 'user@example.com', + 'template_id' => '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b', + ]); + + $this->assertSame('msg_1', $result->getId()); + $this->assertSame([0, 3_000_000], $sentAtUs); + } +} diff --git a/test/EmailsTest.php b/test/EmailsTest.php new file mode 100644 index 0000000..6cd0e54 --- /dev/null +++ b/test/EmailsTest.php @@ -0,0 +1,839 @@ +, body: ?string, timeoutMs: int, at: DateTimeImmutable}> */ + 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 + { + $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; + } + + 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 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 { + ($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 testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void + { + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'html' => '

Thanks!

', + 'text' => null, + 'from' => null, + 'reply_to' => [], + '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', + ['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(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 odd\\ncode: Two\\nlines\\\\', + ], + $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'], + '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 = self::msBetween($start, $this->clock->now()); + $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 testAClientShutDownDuringTheRequestDoesNotWaitToRetry(): void + { + $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(); + + $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; + $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); + + $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::MESSAGE_ID, $emails); + + $this->assertSame('unavailable', $sendError->getErrorCode()); + $this->assertSame(1, $sendRequests); + $this->assertSame('unavailable', $getError->getErrorCode()); + $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, 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 = $this->clock->now(); + + $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame(7000, self::msBetween($start, $this->clock->now())); + $this->assertCount(2, $this->requests); + } + + public static function defaultTimezones(): array + { + return [['UTC'], ['Asia/Tokyo'], ['America/Los_Angeles']]; + } + + #[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 = $this->clock->now(); + + $this->emails()->send(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame(10000, self::msBetween($start, $this->clock->now())); + } finally { + date_default_timezone_set($previousTimezone); + } + } + + 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 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'], + ]; + } + + #[DataProvider('hugeRetryAfters')] + public function testAHugeRetryAfterFailsAtOnceWithoutOverflowing(string $retryAfter): void + { + $this->reply([429, '{"code":"throttled"}', ["Retry-After: $retryAfter"]]); + + $error = $this->sendError(['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]); + + $this->assertSame('throttled', $error->getErrorCode()); + $aYearOrWhatA32BitIntHoldsInMs = PHP_INT_SIZE === 8 ? 31_536_000_000 : 2_147_483_000; + $this->assertSame($aYearOrWhatA32BitIntHoldsInMs, $error->getRetryAfterMs()); + $this->assertCount(1, $this->requests); + } + + 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); + } + } + } +} diff --git a/test/EmailsWithoutCurlTest.php b/test/EmailsWithoutCurlTest.php new file mode 100644 index 0000000..238dad8 --- /dev/null +++ b/test/EmailsWithoutCurlTest.php @@ -0,0 +1,40 @@ + '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 = []; + + $email = ['to' => 'user@example.com', 'template_id' => '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b']; + $calls = [ + fn() => $emails->send($email), + 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/FakeTimer.php b/test/FakeTimer.php new file mode 100644 index 0000000..95687ec --- /dev/null +++ b/test/FakeTimer.php @@ -0,0 +1,33 @@ + 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/InterruptedClock.php b/test/InterruptedClock.php new file mode 100644 index 0000000..ce284fa --- /dev/null +++ b/test/InterruptedClock.php @@ -0,0 +1,42 @@ +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); + } +} diff --git a/test/fixtures/http-server.php b/test/fixtures/http-server.php index 3e773a4..feae807 100644 --- a/test/fixtures/http-server.php +++ b/test/fixtures/http-server.php @@ -43,7 +43,7 @@ fclose($server); } $status = $response['status'] ?? 200; - $body = $response['body'] ?? '{"status":1}'; + $body = ($response['body'] ?? '{"status":1}') . str_repeat(' ', $response['pad_body_bytes'] ?? 0); $headers = ''; foreach ($response['headers'] ?? [] as $name => $value) { $headers .= "$name: $value\r\n"; diff --git a/test/no_curl_mock.php b/test/no_curl_mock.php new file mode 100644 index 0000000..1304604 --- /dev/null +++ b/test/no_curl_mock.php @@ -0,0 +1,9 @@ +