diff --git a/.changeset/emails-cc-bcc-attachments.md b/.changeset/emails-cc-bcc-attachments.md new file mode 100644 index 00000000..f481f172 --- /dev/null +++ b/.changeset/emails-cc-bcc-attachments.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Transactional email: a send with `template_key` also takes `cc`, `bcc` and `attachments`. An attachment's `content` is the file's bytes: the SDK hashes each file, uploads the ones PostHog doesn't have yet, then sends the email with references to them. An email takes at most 10 files and 40 MB after Base64 encoding, about 29 MB of files. Files upload only over https, unless the `app_host` is plain `http://` for local development, and an upload never follows a redirect. A failed upload throws `attachment_upload_failed`. diff --git a/.changeset/emails-integration-option.md b/.changeset/emails-integration-option.md new file mode 100644 index 00000000..e0ac041a --- /dev/null +++ b/.changeset/emails-integration-option.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Transactional email: `emails()->send()` takes an `integration` option, `/` such as `laravel/1.4.0`, that a mailer integration passes to name itself on the send. The client sends it as the `X-PostHog-Integration` header; the User-Agent stays the same. diff --git a/README.md b/README.md index f7ceb09b..b8626652 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,23 @@ PostHog::emails()->send([ 'distinct_id' => 'user_123', ]); +// With copies and a file: content is the file's bytes, and the SDK uploads them before the send +PostHog::emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'html' => $html, + 'bcc' => 'finance@example.com', + 'attachments' => [['filename' => 'receipt.pdf', 'content' => file_get_contents($receiptPath)]], +]); + // Later, such as in a job: for a few seconds after the send, get() throws not_found $status = PostHog::emails()->get($result->getId()); // queued, sent, delivered, bounced, ... ``` -- **Two forms.** Pass `template_id` with optional `template_data`, or `template_key` with `subject` and `html`, `text` or both. The content form also takes `from` (a verified sender), `reply_to`, `sensitive` and `tracking`. PostHog derives a missing `text` from the `html`. To send an email built with a template library such as React Email, render it to HTML in your app and pass `html`. The SDK adds no rendering dependency. +- **Two forms.** Pass `template_id` with optional `template_data`, or `template_key` with `subject` and `html`, `text` or both. The content form also takes `from` (a verified sender), `reply_to`, `cc`, `bcc`, `attachments`, `sensitive` and `tracking`. PostHog derives a missing `text` from the `html`. To send an email built with a template library such as React Email, render it to HTML in your app and pass `html`. The SDK adds no rendering dependency. +- **Copies and files.** `cc` and `bcc` take one address or a list of up to 10. `attachments` takes up to 10 files, each with `filename`, `content` (the file's bytes) and optional `content_type` and `content_id`. The whole email can be at most 40 MB after Base64 encoding, which leaves about 29 MB for the files. The 40 MB counts the subject and the bodies, and without `text` it counts the `html` twice, since PostHog sends the text it derives from it. The SDK checks both limits before any request (`too_many_attachments`, `message_too_large`). Pass the bytes, not Base64: when you hold Base64, such as from a Resend payload, pass `base64_decode($content)`. Show a file inline with `` and `'content_id' => 'logo'`. A PostHog version that doesn't take these fields yet refuses the send with an error naming the field, and never drops it. +- **How files travel.** The SDK hashes each file with SHA-256, asks PostHog which files it still needs, uploads those straight to PostHog's file storage with the URL and headers PostHog answers (never with your secret key), then sends the email with each file's hash. A file the project already uploaded isn't uploaded again. The storage host differs from your app host, so allow it if your servers restrict outbound traffic. Files go only to an `https` storage URL, unless your `app_host` is plain `http://` for local development. A storage 429, 500, 502, 503 or 504 is retried like the send. A failed upload throws `attachment_upload_failed` with the storage's answer, and no email is sent. An upload never follows a redirect: storage that answers one fails that file the same way, without naming the URL. - **Sensitive emails.** Pass `'sensitive' => true` when the email carries login links, reset tokens or codes. A sensitive email keeps its links unchanged unless `tracking` turns clicks on, leaves its subject off engagement events, and keeps its content out of logs. Left out, the email takes the setting of the template made in PostHog whose key matches `template_key`, and is sensitive when no template matches. - **Tracking.** Left out, PostHog counts the email's opens and captures its engagement events, and tracks clicks only when the email isn't sensitive. `'tracking' => true` turns on opens, clicks and engagement events. `'tracking' => false` turns them all off: no open pixel, no rewritten links, no engagement events. An array such as `['clicks' => true]` sets `opens` (default `true`), `clicks` (default: on unless the email is sensitive) and `engagement` (default `true`) one by one. Turn clicks on for a sensitive email only when its links are safe to rewrite. Click tracking needs open tracking. `capture_engagement` is deprecated in favor of `tracking` with `engagement`. It still works, but a send can't set both. - **Pass `distinct_id`.** Without it, the email's engagement events go to a person whose distinct ID is the email address. diff --git a/lib/EmailError.php b/lib/EmailError.php index ffab5634..97be9071 100644 --- a/lib/EmailError.php +++ b/lib/EmailError.php @@ -14,6 +14,8 @@ */ class EmailError extends RuntimeException { + private const REFUSED_ATTACHMENT_CODES = ['too_many_attachments', 'message_too_large']; + private string $errorName; private string $errorCode; private ?int $statusCode; @@ -68,8 +70,8 @@ public function getErrorName(): string /** * PostHog's error code, such as `quota_limited`, or an SDK code such as `missing_secret_key`, - * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error` or an - * `invalid_*` argument code. + * `missing_app_host`, `client_disabled`, `client_closed`, `network_error`, `api_error`, `attachment_upload_failed` + * or an `invalid_*` argument code. */ public function getErrorCode(): string { @@ -126,9 +128,19 @@ public function getRetryAfterMs(): ?int private static function nameFor(?int $status, string $code): string { + if ($code === 'attachment_upload_failed') { + return match (true) { + $status === 429 => 'rate_limit_exceeded', + $status !== null && $status >= 500 => 'service_unavailable', + default => 'application_error', + }; + } if ($code === 'missing_secret_key' || $status === 401) { return 'missing_api_key'; } + if (in_array($code, self::REFUSED_ATTACHMENT_CODES, true)) { + return 'validation_error'; + } if ($status === null) { if ($code === 'network_error') { return 'service_unavailable'; diff --git a/lib/Emails.php b/lib/Emails.php index 4bcfc1cf..e87bd8bc 100644 --- a/lib/Emails.php +++ b/lib/Emails.php @@ -27,6 +27,14 @@ * `email_request_max_retries` times (2 by default) with the same idempotency key, waiting about 1 and * then 2 seconds, or the server's `Retry-After` when it is longer. A `Retry-After` over 30 seconds fails * at once. A send that is not queued throws EmailError. + * + * A send with attachments first uploads each file PostHog doesn't have yet to the storage URL PostHog + * answers, which is on another host than the app host. It uploads only to an `https` URL, unless the app + * host is plain `http://` for local development. Each upload gets the request timeout plus 1 ms + * for every 100 bytes of the file, but only the request timeout to connect. A storage 429, 500, 502, 503 + * or 504 is retried as above. + * + * @phpstan-type Upload array{filename: string, size: int, content: string, field: string} */ final class Emails { @@ -34,6 +42,7 @@ final class Emails public const DEFAULT_MAX_RETRIES = 2; private const SEND_PATH = '/api/projects/@current/messaging/send/email/'; + private const UPLOADS_PATH = '/api/projects/@current/messaging/attachments/uploads/'; private const STATUS_PATH = '/api/projects/@current/messaging/emails/%s/'; private const US_APP_HOST = 'https://us.posthog.com'; private const EU_APP_HOST = 'https://eu.posthog.com'; @@ -52,9 +61,10 @@ final class Emails private const IDEMPOTENCY_KEY_PATTERN = '/^[\x21-\x7e]{1,255}$/D'; private const TEMPLATE_KEY_PATTERN = '/^[a-zA-Z0-9_-]{1,400}$/D'; private const HEADER_TOKEN_PATTERN = '/^[\x21-\x7e]+$/D'; + private const INTEGRATION_PATTERN = '#^[a-z0-9-]{1,40}/[0-9A-Za-z.+-]{1,40}$#D'; private const TIMESTAMP_PATTERN = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/D'; - private const SHARED_FIELDS = ['to', 'distinct_id', 'idempotency_key', 'max_retries']; + private const SHARED_FIELDS = ['to', 'distinct_id', 'idempotency_key', 'max_retries', 'integration']; private const TEMPLATE_FIELDS = ['template_id', 'template_data']; private const CODE_FIELDS = [ 'template_key', @@ -63,10 +73,20 @@ final class Emails 'text', 'from', 'reply_to', + 'cc', + 'bcc', + 'attachments', 'capture_engagement', 'sensitive', 'tracking', ]; + private const ATTACHMENT_FIELDS = ['filename', 'content', 'content_type', 'content_id']; + private const MAX_ATTACHMENTS = 10; + private const MAX_MESSAGE_BYTES = 40_000_000; + private const MESSAGE_HEADERS_BYTES = 65_536; + private const UPLOAD_BYTES_PER_EXTRA_MS = 100; + private const MESSAGE_LIMIT = '40 MB after Base64 encoding, about 29 MB of files'; + private const HEADER_NAME_PATTERN = "/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/D"; private const TEMPLATE_FORM_WHY = "A template made in PostHog sets its own sender, subject, content, tracking " . "and sensitive setting, and follows the project's engagement setting."; private const CODE_FORM_WHY = 'A send with template_key takes its content from your code.'; @@ -96,7 +116,8 @@ final class Emails * @param string|null $appHost The PostHog app host, for a host that is not PostHog Cloud. * @param string|null $userAgent The User-Agent. Defaults to `posthog-php/`. * @param Closure|null $transport `fn(string $method, string $url, list $headers, ?string $body, - * int $timeoutMs): array{int, string, list}` returning the status, body and header lines. + * int $timeoutMs, int $connectTimeoutMs): array{int, string, list}` returning the status, body + * and header lines. * It throws EmailError with the code `network_error` when no answer arrived. */ public function __construct( @@ -143,7 +164,19 @@ public function __construct( * 400), `subject`, and `html`, `text` or both. PostHog derives a missing `text` from the `html`, and * sends the content as is without storing it. To send a React email, render it to HTML in your app * first. This form also takes `from` (a verified sender, such as `Acme `), - * `reply_to` (one address or a list; an empty list is left out), `sensitive` and `tracking`. + * `reply_to`, `cc` and `bcc` (one address or a list; at most 10 for `cc` and `bcc`), `attachments`, + * `sensitive` and `tracking`. An empty `reply_to`, `cc`, `bcc` or `attachments` list is left out. + * - `attachments` is a list of at most 10 files, and the email can be at most 40 MB after Base64 encoding, which + * leaves about 29 MB for the files. The 40 MB counts the subject and the bodies, and the `html` twice without + * `text`, since PostHog derives the text from it. Each file is an array with `filename`, `content` (the file's + * bytes, such as `file_get_contents()` returns; pass `base64_decode($content)` when you hold Base64, such as from + * a Resend payload), and optional `content_type` and `content_id`. Reference a `content_id` of `logo` in the HTML + * as `` to show the file inline. PostHog can't fetch a file from a path or URL. The client + * checks both limits before any request, hashes each file with SHA-256, uploads the files PostHog doesn't have + * yet, and then sends the email with each file's hash. A file that fails to upload throws + * `attachment_upload_failed` with the storage's answer, and no email is sent. An upload never follows a + * redirect: storage that answers one fails that file the same way. A PostHog without the upload route, or a + * project that can't send email from code, answers `not_found` on `attachments`, and no email is sent. * * `sensitive` marks the email as sensitive: set it to true when the email carries login links, reset * tokens or codes. A sensitive email keeps its links unchanged unless `tracking` turns clicks on, @@ -166,10 +199,17 @@ public function __construct( * `capture_engagement` is deprecated: use `tracking` with `engagement` instead. It still works, but a * send can't set both. The client sends `capture_engagement` only when you set it. * + * A PostHog version that doesn't take a field yet, such as `cc`, refuses the send with an error that + * names the field. It never drops the field and sends the rest. + * * Both forms take `to` (one address), `distinct_id` (the recipient's distinct ID in your product; * without it, the email's events go to a person whose distinct ID is the address), `idempotency_key` * and `max_retries`. A null value counts as left out. * + * A library that sends email for an app, such as a mailer transport, names itself with `integration`, + * `/` such as `laravel/1.4.0`. The client sends it as the `X-PostHog-Integration` + * header of that send, so PostHog can tell which integration sent the email. Apps leave it out. + * * A send with an idempotency key PostHog already accepted queues nothing and returns the first send's * result. Without a key, the client makes a UUID that covers its own retries. Pass a key derived from * what the email is for, such as `welcome:user_123`, to cover retries outside this call too, and pass @@ -191,6 +231,17 @@ public function __construct( * 'html' => $renderedHtml, * 'distinct_id' => 'user_123', * ]); + * + * $client->emails()->send([ + * 'to' => 'user@example.com', + * 'template_key' => 'receipt', + * 'subject' => 'Your receipt', + * 'html' => $renderedHtml, + * 'bcc' => 'finance@example.com', + * 'attachments' => [ + * ['filename' => 'receipt.pdf', 'content' => file_get_contents($receiptPath)], + * ], + * ]); * ``` * * @param array{ @@ -198,6 +249,7 @@ public function __construct( * distinct_id?: string|null, * idempotency_key?: string|null, * max_retries?: int|null, + * integration?: string|null, * template_id?: string, * template_data?: array|null, * template_key?: string, @@ -206,6 +258,14 @@ public function __construct( * text?: string|null, * from?: string|null, * reply_to?: string|list|null, + * cc?: string|list|null, + * bcc?: string|list|null, + * attachments?: list|null, * sensitive?: bool|null, * tracking?: bool|array{opens?: bool|null, clicks?: bool|null, engagement?: bool|null}|null, * capture_engagement?: bool|null @@ -213,14 +273,23 @@ public function __construct( * @return SendEmailResult The queued email. It means PostHog queued the email, not that it was delivered. * @throws EmailError PostHog did not queue the email, or the client could not tell whether it did. A * failed argument check has the name `validation_error` and an `invalid_*` code, and sends nothing. + * An attachment the client can't send has the code `invalid_attachment` and a field such as + * `attachments__0__content`, and its message never repeats the file's content. More than 10 files + * throw `too_many_attachments`, and an email over 40 MB throws `message_too_large`, before any + * request. */ public function send(array $email): SendEmailResult { - [$body, $idempotencyKey, $maxRetries] = $this->prepareSend($email); + [$body, $idempotencyKey, $maxRetries, $files, $integration] = $this->prepareSend($email); [$appHost, $secretKey] = $this->connect(self::SEND_NOT_DONE, self::SEND_KEY_NEEDED); + if ($files !== []) { + $this->upload($appHost, $secretKey, $files, $maxRetries); + $this->refuseWhenClosed(self::SEND_NOT_DONE); + } $headers = $this->headers($secretKey, [ 'Content-Type: application/json', 'Idempotency-Key: ' . $idempotencyKey, + ...($integration === null ? [] : ['X-PostHog-Integration: ' . $integration]), ]); $result = $this->withRetries($maxRetries, function () use ($appHost, $headers, $body): SendEmailResult { return self::parseSendReply(...$this->request('POST', $appHost . self::SEND_PATH, $headers, $body)); @@ -266,7 +335,8 @@ public function get(string $messageId): EmailStatus } /** - * @return array{string, string, int} + * @return array{string, string, int, array, ?string} The body, the idempotency key, the + * retries, the files to upload by SHA-256, and the integration that sends the email. */ private function prepareSend(array $email): array { @@ -286,7 +356,7 @@ private function prepareSend(array $email): array 'Send either template_id, or template_key with subject and html or text, so no email was sent.' ); } - $payload = $hasTemplateId ? self::templateContent($email) : self::codeContent($email); + [$payload, $files] = $hasTemplateId ? [self::templateContent($email), []] : self::codeContent($email); $idempotencyKey = $email['idempotency_key'] ?? null; if ( $idempotencyKey !== null @@ -302,6 +372,7 @@ private function prepareSend(array $email): array if (!is_int($maxRetries) || $maxRetries < 0) { throw self::invalid('invalid_max_retries', 'max_retries must be a nonnegative integer.', 'max_retries'); } + $integration = self::integration($email['integration'] ?? null); $payload['to'] = $to; if ($distinctId !== null) { $payload['distinct_id'] = $distinctId; @@ -316,7 +387,24 @@ private function prepareSend(array $email): array ); } - return [$body, $idempotencyKey ?? Uuid::v4(), $maxRetries]; + return [$body, $idempotencyKey ?? Uuid::v4(), $maxRetries, $files, $integration]; + } + + private static function integration(mixed $integration): ?string + { + if ($integration === null) { + return null; + } + if (!is_string($integration) || preg_match(self::INTEGRATION_PATTERN, $integration) !== 1) { + throw self::invalid( + 'invalid_integration', + "integration must be /, such as 'laravel/1.4.0': a name of lowercase letters, digits " + . 'and hyphens, and a version of letters, digits, dots, plus signs and hyphens, at most 40 each.', + 'integration' + ); + } + + return $integration; } /** @@ -345,7 +433,7 @@ private static function templateContent(array $email): array } /** - * @return array + * @return array{array, array} */ private static function codeContent(array $email): array { @@ -385,8 +473,20 @@ private static function codeContent(array $email): array } $content['from'] = $email['from']; } - if (array_key_exists('reply_to', $email) && $email['reply_to'] !== []) { - $content['reply_to'] = self::addresses($email['reply_to']); + foreach (['reply_to', 'cc', 'bcc'] as $field) { + if (array_key_exists($field, $email) && $email[$field] !== []) { + $content[$field] = self::addresses($field, $email[$field]); + } + } + $files = []; + if (array_key_exists('attachments', $email) && $email['attachments'] !== []) { + $html = $content['html'] ?? ''; + $text = ($content['text'] ?? '') === '' ? $html : $content['text']; + [$content['attachments'], $files] = self::attachments( + $email['attachments'], + $content['subject'], + [$html, $text] + ); } $tracking = self::tracking($email); $captureEngagement = $email['capture_engagement'] ?? null; @@ -407,7 +507,7 @@ private static function codeContent(array $email): array $content['tracking'] = $tracking; } - return $content; + return [$content, $files]; } private static function tracking(array $email): bool|object|null @@ -436,21 +536,145 @@ private static function tracking(array $email): bool|object|null /** * @return list */ - private static function addresses(mixed $value): array + private static function addresses(string $field, mixed $value): array { $addresses = is_string($value) ? [$value] : $value; if (!is_array($addresses) || !array_is_list($addresses)) { - throw self::invalid('invalid_form', 'reply_to must be one address or a list of addresses.', 'reply_to'); + throw self::invalid('invalid_form', "$field must be one address or a list of addresses.", $field); } foreach ($addresses as $address) { if (!is_string($address) || trim($address) === '') { - throw self::invalid('invalid_form', 'reply_to must not hold a blank address.', 'reply_to'); + throw self::invalid('invalid_form', "$field must not hold a blank address.", $field); } } return $addresses; } + /** + * @param list $bodies + * @return array{list>, array} + */ + private static function attachments(mixed $files, string $subject, array $bodies): array + { + if (!is_array($files) || !array_is_list($files)) { + throw self::invalidAttachment('attachments must be a list of files.', 'attachments'); + } + if (count($files) > self::MAX_ATTACHMENTS) { + throw new EmailError( + 'too_many_attachments', + 'An email carries at most ' . self::MAX_ATTACHMENTS . ' files, so no email was sent.', + why: 'An email carries at most ' . self::MAX_ATTACHMENTS . ' files.', + fix: 'Attach at most ' . self::MAX_ATTACHMENTS . ' files, and link to any others from the email body.', + field: 'attachments' + ); + } + $attachments = []; + $messageBytes = self::MESSAGE_HEADERS_BYTES + self::encodedSubjectBytes($subject); + foreach ($files as $index => $file) { + $attachments[] = $attachment = self::attachment("attachments__$index", $file); + $messageBytes += self::base64TransferBytes(strlen($attachment['content'])); + } + foreach ($bodies as $body) { + $messageBytes += self::base64TransferBytes(strlen($body)); + } + if ($messageBytes > self::MAX_MESSAGE_BYTES) { + throw new EmailError( + 'message_too_large', + 'These files make the email larger than 40 MB after Base64 encoding, so no email was sent.', + why: 'An email can be at most ' . self::MESSAGE_LIMIT . '.', + fix: 'Send fewer or smaller files, or link to large files from the email body.', + field: 'attachments' + ); + } + $references = []; + $uploads = []; + foreach ($attachments as $index => $attachment) { + $sha256 = hash('sha256', $attachment['content']); + $uploads[$sha256] ??= [ + 'filename' => $attachment['filename'], + 'size' => strlen($attachment['content']), + 'content' => $attachment['content'], + 'field' => "attachments__{$index}__content", + ]; + unset($attachment['content']); + $references[] = ['sha256' => $sha256, ...$attachment]; + } + + return [$references, $uploads]; + } + + private static function base64TransferBytes(int $bytes): float + { + return ceil(ceil($bytes / 3) * 4 * 78 / 76); + } + + private static function encodedSubjectBytes(string $subject): float + { + return ceil(strlen($subject) / 42) * 75; + } + + /** + * @return array + */ + private static function attachment(string $field, mixed $file): array + { + if (is_array($file)) { + $file = array_filter($file, static fn($value): bool => $value !== null); + } + if (!is_array($file) || ($file !== [] && array_is_list($file))) { + throw self::invalidAttachment( + "An attachment is an array with a filename and the file's bytes as its content.", + $field + ); + } + if (array_key_exists('path', $file)) { + throw self::invalidAttachment( + "PostHog can't fetch an attachment from a path or URL. Read the file and pass its bytes as content.", + "{$field}__path" + ); + } + foreach (array_keys($file) as $name) { + if (!in_array($name, self::ATTACHMENT_FIELDS, true)) { + throw self::invalidAttachment( + "An attachment takes filename, content, content_type and content_id, not $name.", + "{$field}__$name" + ); + } + } + if (!self::isUtf8Text($file['filename'] ?? null)) { + throw self::invalidAttachment('An attachment needs its filename as a UTF-8 string.', "{$field}__filename"); + } + if (!is_string($file['content'] ?? null) || $file['content'] === '') { + throw self::invalidAttachment( + "An attachment needs the file's bytes as a nonempty string, such as file_get_contents() returns.", + "{$field}__content" + ); + } + $attachment = ['filename' => $file['filename'], 'content' => $file['content']]; + foreach (['content_type', 'content_id'] as $name) { + if (!array_key_exists($name, $file)) { + continue; + } + if (!self::isUtf8Text($file[$name])) { + throw self::invalidAttachment("An attachment's $name must be a UTF-8 string.", "{$field}__$name"); + } + $attachment[$name] = $file[$name]; + } + + return $attachment; + } + + private static function isUtf8Text(mixed $value): bool + { + return is_string($value) && mb_check_encoding($value, 'UTF-8'); + } + + private static function invalidAttachment(string $message, string $field): EmailError + { + return new EmailError('invalid_attachment', $message . ' No email was sent.', field: $field); + } + private static function refuseFieldsOutside(array $email, bool $isTemplateForm): void { $formFields = array_merge(self::SHARED_FIELDS, $isTemplateForm ? self::TEMPLATE_FIELDS : self::CODE_FIELDS); @@ -482,6 +706,199 @@ private static function invalid(string $code, string $message, string $field): E return new EmailError($code, $message, field: $field); } + /** + * @param array $files + */ + private function upload(string $appHost, string $secretKey, array $files, int $maxRetries): void + { + $request = []; + foreach ($files as $sha256 => $file) { + $request[] = ['sha256' => (string) $sha256, 'size' => $file['size']]; + } + $body = json_encode(['files' => $request], JSON_THROW_ON_ERROR); + $headers = $this->headers($secretKey, ['Content-Type: application/json']); + $url = $appHost . self::UPLOADS_PATH; + $plainHttp = str_starts_with(strtolower($appHost), 'http://'); + $slots = $this->withRetries($maxRetries, function () use ($url, $headers, $body, $files, $plainHttp): array { + return self::parseUploadReply($files, $plainHttp, ...$this->request('POST', $url, $headers, $body)); + }); + foreach ($slots as $sha256 => $slot) { + $this->refuseWhenClosed(self::SEND_NOT_DONE); + $file = $files[$sha256]; + $this->withRetries($maxRetries, function () use ($slot, $file): void { + $this->put($slot['url'], $slot['headers'], $file); + }); + } + } + + /** + * @param list $headers + * @param Upload $file + */ + private function put(string $url, array $headers, array $file): void + { + $timeoutMs = $this->requestTimeoutMs + intdiv($file['size'], self::UPLOAD_BYTES_PER_EXTRA_MS); + try { + [$status, $body, $answerHeaders] = ($this->transport)( + 'PUT', + $url, + $headers, + $file['content'], + $timeoutMs, + $this->requestTimeoutMs + ); + } catch (EmailError $error) { + if ($error->getErrorCode() !== 'network_error') { + throw $error; + } + throw new EmailError( + 'network_error', + "A file didn't reach PostHog's file storage, so no email was sent: " . $error->getMessage(), + field: $file['field'], + previous: $error + ); + } + if ($status >= 200 && $status < 300) { + return; + } + if ($status >= 300 && $status < 400) { + throw new EmailError( + 'attachment_upload_failed', + self::onOneLogLine( + "PostHog's file storage redirected the upload of {$file['filename']} (HTTP $status), and the " + . "client doesn't follow an upload's redirect, so no email was sent." + ), + $status, + why: 'A signed upload never succeeds through a redirect, and following one would send the file to ' + . 'another host, maybe unencrypted.', + fix: "Fix the file storage's URL.", + field: $file['field'] + ); + } + $answer = "HTTP $status"; + if (preg_match('#([^<]{1,100})#', $body, $code) === 1) { + $answer .= ' ' . html_entity_decode($code[1], ENT_XML1); + if (preg_match('#([^<]{1,300})#', $body, $message) === 1) { + $answer .= ': ' . html_entity_decode($message[1], ENT_XML1); + } + } + throw new EmailError( + 'attachment_upload_failed', + self::onOneLogLine( + "PostHog's file storage refused the upload of {$file['filename']}, so no email was sent. " + . 'It answered ' . rtrim($answer, '.') . '.' + ), + $status, + why: 'PostHog sends an email with files only after every file reached its file storage.', + fix: 'Send the email again. The client asks for a new upload URL on every send.', + field: $file['field'], + retryAfterMs: self::retryAfterMs($answerHeaders) + ); + } + + /** + * @param array $files + * @param list $headers + * @return array}> + */ + private static function parseUploadReply( + array $files, + bool $plainHttp, + int $status, + string $body, + array $headers + ): array { + $fields = self::replyFields($body); + if ($status === 404) { + $fields['code'] = self::text($fields['code'] ?? null) ?? 'not_found'; + $fields['attr'] = 'attachments'; + throw self::replyError($fields, $status, $headers); + } + if ($status < 200 || $status >= 300) { + $fields['attr'] = self::attachmentField(self::text($fields['attr'] ?? null), $files); + throw self::replyError($fields, $status, $headers); + } + $slots = []; + foreach (is_array($fields['files'] ?? null) ? $fields['files'] : [] as $slot) { + if (is_array($slot) && is_string($slot['sha256'] ?? null)) { + $slots[$slot['sha256']] = $slot; + } + } + $uploads = []; + foreach (array_keys($files) as $sha256) { + $slot = $slots[$sha256] ?? null; + if (($slot['status'] ?? null) === 'present') { + continue; + } + $upload = ($slot['status'] ?? null) === 'upload' ? self::uploadTarget($slot) : null; + if ($upload === null) { + throw new EmailError( + 'api_error', + "PostHog didn't answer where to upload a file, so no email was sent.", + $status + ); + } + if (!$plainHttp && !str_starts_with(strtolower($upload['url']), 'https://')) { + throw new EmailError( + 'api_error', + "PostHog named an upload URL that isn't https, so {$files[$sha256]['filename']} would travel " + . 'unencrypted, and no email was sent.', + $status, + fix: "Fix the file storage's URL. For local development over plain HTTP, set an http:// app_host.", + field: $files[$sha256]['field'] + ); + } + $uploads[$sha256] = $upload; + } + + return $uploads; + } + + /** + * @return array{url: string, headers: list}|null + */ + private static function uploadTarget(array $slot): ?array + { + $url = $slot['url'] ?? null; + $scheme = is_string($url) ? strtolower((string) parse_url($url, PHP_URL_SCHEME)) : ''; + $headers = $slot['headers'] ?? null; + if ( + !in_array($scheme, ['http', 'https'], true) + || !is_string($slot['method'] ?? null) + || strtoupper($slot['method']) !== 'PUT' + || !is_array($headers) + || ($headers !== [] && array_is_list($headers)) + ) { + return null; + } + $lines = []; + foreach ($headers as $name => $value) { + if ( + preg_match(self::HEADER_NAME_PATTERN, (string) $name) !== 1 + || !is_string($value) + || strpbrk($value, "\r\n\0") !== false + ) { + return null; + } + $lines[] = "$name: $value"; + } + + return ['url' => $url, 'headers' => $lines]; + } + + /** + * @param array $files + */ + private static function attachmentField(?string $uploadField, array $files): ?string + { + if ($uploadField === null || preg_match('/^files(?:__(\d+))?(?:__|$)/D', $uploadField, $match) !== 1) { + return $uploadField; + } + $index = $match[1] ?? null; + + return $index === null ? 'attachments' : array_values($files)[(int) $index]['field'] ?? 'attachments'; + } + /** * @return array{string, string} */ @@ -493,9 +910,7 @@ private function connect(string $notDone, string $keyNeeded): array "The PostHog client is disabled because it has no project API key, so $notDone." ); } - if (($this->isClosed)()) { - throw new EmailError('client_closed', "The PostHog client is shut down, so $notDone."); - } + $this->refuseWhenClosed($notDone); if ($this->appHost === null) { throw new EmailError( 'missing_app_host', @@ -510,6 +925,13 @@ private function connect(string $notDone, string $keyNeeded): array return [$this->appHost, $this->secretKey]; } + private function refuseWhenClosed(string $notDone): void + { + if (($this->isClosed)()) { + throw new EmailError('client_closed', "The PostHog client is shut down, so $notDone."); + } + } + /** * @param list $extra * @return list @@ -599,11 +1021,14 @@ private static function isRetryable(EmailError $error): bool if ($status === null) { return $error->getErrorCode() === 'network_error'; } + if ($error->getErrorCode() === 'attachment_upload_failed') { + return in_array($status, [429, 500, 502, 503, 504], true); + } if ($status === 409) { return $error->getErrorCode() === 'idempotency_key_in_flight'; } if ($status === 429) { - return $error->getErrorCode() !== 'quota_limited'; + return !in_array($error->getErrorCode(), ['quota_limited', 'upload_quota_exceeded'], true); } return $status === 503; @@ -629,7 +1054,7 @@ private static function retryDelayMs(EmailError $error, int $attempt, int $maxRe */ private function request(string $method, string $url, array $headers, ?string $body): array { - return ($this->transport)($method, $url, $headers, $body, $this->requestTimeoutMs); + return ($this->transport)($method, $url, $headers, $body, $this->requestTimeoutMs, $this->requestTimeoutMs); } /** @@ -641,7 +1066,8 @@ private static function curlTransport( string $url, array $headers, ?string $body, - int $timeoutMs + int $timeoutMs, + int $connectTimeoutMs ): array { if (!function_exists('curl_init')) { throw new EmailError( @@ -668,7 +1094,7 @@ private static function curlTransport( }, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT_MS => $timeoutMs, - CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs, + CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs, CURLOPT_NOSIGNAL => true, CURLOPT_HEADERFUNCTION => static function ($curl, string $header) use (&$responseHeaders): int { $responseHeaders[] = trim($header); diff --git a/test/EmailsClientTest.php b/test/EmailsClientTest.php index a06c26a0..9669bb27 100644 --- a/test/EmailsClientTest.php +++ b/test/EmailsClientTest.php @@ -13,6 +13,7 @@ class EmailsClientTest extends TestCase private const TEMPLATE_ID = '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b'; private ?LocalHttpServer $server = null; + private ?LocalHttpServer $bucket = null; protected function setUp(): void { @@ -23,6 +24,7 @@ protected function setUp(): void protected function tearDown(): void { $this->server?->stop(); + $this->bucket?->stop(); } private function client(array $options = [], ?string $apiKey = 'phc_project'): Client @@ -72,6 +74,217 @@ public function testACodeSendReachesTheSendRouteOverHttp(): void ], json_decode($requests[0]['body'], true)); } + public function testAFileIsUploadedWithAPlainPutBeforeTheSendOverHttp(): void + { + $file = "%PDF-1.7\n" . str_repeat("0 0 obj << >> endobj\n", 100); + $sha256 = hash('sha256', $file); + $checksum = base64_encode(hash('sha256', $file, true)); + $signedPath = "/attachments/team/2/$sha256?X-Amz-Signature=abc"; + $this->bucket = new LocalHttpServer([[ + 'status' => 200, + 'body' => '', + 'accepts' => [ + 'request_line' => "PUT $signedPath HTTP/1.1", + 'content-length' => (string) strlen($file), + 'x-amz-checksum-sha256' => $checksum, + ], + 'refusal' => [ + 'status' => 403, + 'body' => 'SignatureDoesNotMatchNot as signed', + ], + ]]); + $this->server = new LocalHttpServer([ + [ + 'status' => 200, + 'body' => json_encode(['files' => [[ + 'sha256' => $sha256, + 'status' => 'upload', + 'url' => 'http://' . $this->bucket->address() . $signedPath, + 'method' => 'PUT', + 'headers' => ['Content-Length' => (string) strlen($file), 'x-amz-checksum-sha256' => $checksum], + 'url_expires_at' => '2026-11-02T12:15:00Z', + ]]]), + 'accepts' => [ + 'request_line' => 'POST /api/projects/@current/messaging/attachments/uploads/ HTTP/1.1', + 'authorization' => 'Bearer phs_secret', + 'json_body' => ['files' => [['sha256' => $sha256, 'size' => strlen($file)]]], + ], + 'refusal' => [ + 'status' => 400, + 'body' => '{"type":"validation_error","code":"invalid_request","detail":"Not as PostHog signs."}', + ], + ], + ['status' => 202, 'body' => '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}'], + ]); + + $result = $this->client()->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => [['filename' => 'receipt.pdf', 'content' => $file]], + ]); + + $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $result->getId()); + $this->assertSame([$file], $this->storedFiles()); + $this->assertSame( + [['sha256' => $sha256, 'filename' => 'receipt.pdf']], + $this->queuedEmails()[0]['attachments'] + ); + } + + /** @return list the files storage took */ + private function storedFiles(): array + { + return array_column(self::accepted($this->bucket, 'PUT '), 'body'); + } + + /** @return list> the emails PostHog queued */ + private function queuedEmails(): array + { + return array_map( + static fn(array $send): array => json_decode($send['body'], true), + self::accepted($this->server, 'POST /api/projects/@current/messaging/send/email/ ') + ); + } + + private static function accepted(LocalHttpServer $server, string $requestLinePrefix): array + { + return array_values(array_filter( + $server->acceptedRequests(), + static fn(array $request): bool => str_starts_with($request['requestLine'], $requestLinePrefix) + )); + } + + private static function uploadAnswer(string $storage, string $file): array + { + $sha256 = hash('sha256', $file); + + return ['status' => 200, 'body' => json_encode(['files' => [[ + 'sha256' => $sha256, + 'status' => 'upload', + 'url' => "http://$storage/attachments/team/2/$sha256?X-Amz-Signature=abc", + 'method' => 'PUT', + 'headers' => ['Content-Length' => (string) strlen($file)], + 'url_expires_at' => '2026-11-02T12:15:00Z', + ]]])]; + } + + private static function sendWithFile(string $file): array + { + return [ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => [['filename' => 'receipt.pdf', 'content' => $file]], + ]; + } + + public function testStorageRefusesBytesThatDoNotMatchTheSignedChecksumAndNothingIsSent(): void + { + $file = "%PDF-1.7\n" . str_repeat("0 0 obj << >> endobj\n", 100); + $this->bucket = new LocalHttpServer([['status' => 200, 'body' => '']]); + $answer = self::uploadAnswer($this->bucket->address(), $file); + $slots = json_decode($answer['body'], true); + $slots['files'][0]['headers']['x-amz-checksum-sha256'] = base64_encode(hash('sha256', 'another file', true)); + $this->server = new LocalHttpServer([['status' => 200, 'body' => json_encode($slots)]]); + + try { + $this->client(['email_request_max_retries' => 0])->emails()->send(self::sendWithFile($file)); + $this->fail('The send went out after storage refused the file.'); + } catch (EmailError $error) { + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); + $this->assertStringContainsString('BadDigest', $error->getMessage()); + } + + $this->assertSame([], $this->storedFiles()); + $this->assertSame([], $this->queuedEmails(), 'nothing was sent'); + } + + public function testAnUploadThatStorageRedirectsIsNotFollowedAndNothingIsSent(): void + { + $file = "%PDF-1.7\n" . str_repeat("0 0 obj << >> endobj\n", 100); + $elsewhere = new LocalHttpServer([['status' => 200, 'body' => ''], ['status' => 200, 'body' => '']]); + $redirect = 'http://' . $elsewhere->address() . '/copy'; + $this->bucket = new LocalHttpServer([[ + 'status' => 307, + 'headers' => ['Location' => $redirect], + 'body' => "TemporaryRedirectSend it to $redirect", + ]]); + $this->server = new LocalHttpServer([ + self::uploadAnswer($this->bucket->address(), $file), + ['status' => 202, 'body' => '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}'], + ]); + + try { + $this->client(['email_request_max_retries' => 0])->emails()->send(self::sendWithFile($file)); + $this->fail('The send went out after storage redirected the upload.'); + } catch (EmailError $error) { + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); + $this->assertSame('attachments__0__content', $error->getField()); + $this->assertStringContainsString('receipt.pdf', $error->getMessage()); + $told = $error->getMessage() . $error->getWhy() . $error->getFix(); + $this->assertStringNotContainsString($elsewhere->address(), $told); + $this->assertStringNotContainsString($this->bucket->address(), $told); + } + + $this->assertSame([], $elsewhere->acceptedRequests(), 'the redirect target received nothing'); + $this->assertSame([], $this->queuedEmails(), 'nothing was sent'); + } + + public function testStorageMayTakeLongerThanTheRequestTimeoutToTakeALargerFile(): void + { + $file = str_repeat('a', 200_000); + $this->bucket = new LocalHttpServer([['status' => 200, 'body' => '', 'delay_ms' => 1500]]); + $this->server = new LocalHttpServer([ + self::uploadAnswer($this->bucket->address(), $file), + ['status' => 202, 'body' => '{"message_id":"01a18356-c56c-0001-e494-7666bc915188"}'], + ]); + $client = $this->client(['email_request_max_retries' => 0, 'email_request_timeout_ms' => 1000]); + + $result = $client->emails()->send(self::sendWithFile($file)); + + $this->assertSame('01a18356-c56c-0001-e494-7666bc915188', $result->getId()); + $this->assertSame([$file], $this->storedFiles()); + } + + public function testAnUploadGivesUpConnectingToStorageAtTheRequestTimeout(): void + { + $storage = stream_socket_server( + 'tcp://127.0.0.1:0', + $errno, + $error, + STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, + stream_context_create(['socket' => ['backlog' => 0]]) + ); + $address = stream_socket_get_name($storage, false); + $waiting = []; + for ($i = 0; $i < 4; $i++) { + $waiting[] = @stream_socket_client("tcp://$address", $errno, $error, 1, STREAM_CLIENT_ASYNC_CONNECT); + } + $probeStarted = hrtime(true); + $probe = @stream_socket_client("tcp://$address", $errno, $error, 0.3); + if ($probe !== false || (hrtime(true) - $probeStarted) / 1e6 < 250) { + $this->markTestSkipped('Connecting here does not stall once the listen queue is full.'); + } + $file = str_repeat('a', 500_000); + $this->server = new LocalHttpServer([self::uploadAnswer($address, $file)]); + $client = $this->client(['email_request_max_retries' => 0, 'email_request_timeout_ms' => 1000]); + $started = hrtime(true); + + try { + $client->emails()->send(self::sendWithFile($file)); + $this->fail('The upload connected to a storage host that never accepts.'); + } catch (EmailError $error) { + $this->assertSame('network_error', $error->getErrorCode()); + } + + $this->assertLessThan(3000, (hrtime(true) - $started) / 1e6, 'gave up at the 1 s connect timeout, not 6 s'); + $this->assertSame([], $this->queuedEmails(), 'nothing was sent'); + fclose($storage); + } + public function testARetryAfterHeaderComesBackOnTheError(): void { $this->server = new LocalHttpServer([ diff --git a/test/EmailsTest.php b/test/EmailsTest.php index 855c089d..94bfe1ab 100644 --- a/test/EmailsTest.php +++ b/test/EmailsTest.php @@ -17,11 +17,25 @@ class EmailsTest extends TestCase private const TEMPLATE_ID = '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b'; private const MESSAGE_ID = '01a18356-c56c-0001-e494-7666bc915188'; private const SEND_URL = 'https://us.posthog.com/api/projects/@current/messaging/send/email/'; + private const INVITE = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; + private const SECRET_CONTENT = 'secret-file-content'; - /** @var list, body: ?string, timeoutMs: int, at: DateTimeImmutable}> */ + /** + * @var list, body: ?string, timeoutMs: int, + * at: DateTimeImmutable}> + */ private array $requests = []; - /** @var list}|EmailError> */ + /** @var list}|EmailError|string> */ private array $replies = []; + /** @var array> the headers storage signed for each upload URL */ + private array $signedUploads = []; + /** @var array the files storage holds, by SHA-256; null for one PostHog already had */ + private array $stored = []; + /** @var array how many times storage took each file, by SHA-256 */ + private array $storageWrites = []; + /** @var array> the emails PostHog queued, one per idempotency key */ + private array $queued = []; + private string $secretKey = 'phs_secret'; private ClockInterface $previousClock; private MockClock $clock; @@ -42,8 +56,10 @@ protected function tearDown(): void private function emails(array $config = []): Emails { + $this->secretKey = $config['secretKey'] ?? 'phs_secret'; + return new Emails( - $config['secretKey'] ?? 'phs_secret', + $this->secretKey, $config['host'] ?? 'us.i.posthog.com', appHost: $config['appHost'] ?? null, maxRetries: $config['maxRetries'] ?? Emails::DEFAULT_MAX_RETRIES, @@ -59,19 +75,144 @@ private function emails(array $config = []): Emails * @param list $headers * @return array{int, string, list} */ - public function transport(string $method, string $url, array $headers, ?string $body, int $timeoutMs): array - { + public function transport( + string $method, + string $url, + array $headers, + ?string $body, + int $timeoutMs, + int $connectTimeoutMs + ): array { $at = Clock::get()->now(); $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs', 'at'); + if ($method === 'PUT' && preg_grep('/^authorization:/i', $headers) !== []) { + return [400, self::BUCKET_AUTH_ERROR, []]; + } + if ($method === 'PUT' && !$this->storageSigned($url, $headers)) { + return [403, 'SignatureDoesNotMatchNot as signed', []]; + } + if ($method === 'PUT' && !self::storageAccepts($headers, (string) $body)) { + return [400, self::BUCKET_DIGEST_ERROR, []]; + } + if ($method !== 'PUT' && self::headerIn($headers, 'Authorization') !== "Bearer {$this->secretKey}") { + return [401, '{"type":"authentication_error","code":"authentication_failed","detail":"Bad key."}', []]; + } + $missing = $method === 'POST' && str_ends_with($url, '/send/email/') ? $this->notStored((string) $body) : null; + if ($missing !== null) { + return [400, json_encode([ + 'type' => 'validation_error', + 'code' => 'attachment_not_uploaded', + 'detail' => 'This file was never uploaded.', + 'attr' => $missing, + ]), []]; + } $reply = array_shift($this->replies) ?? [202, '{"message_id":"msg_1"}', []]; + if ($reply === self::NEGOTIATE) { + $reply = $this->negotiate((string) $body); + } if ($reply instanceof EmailError) { throw $reply; } + $accepted = $reply[0] >= 200 && $reply[0] < 300; + if ($method === 'PUT' && $accepted) { + $sha256 = hash('sha256', (string) $body); + $this->stored[$sha256] = $body; + $this->storageWrites[$sha256] = ($this->storageWrites[$sha256] ?? 0) + 1; + } + if ($method === 'POST' && str_ends_with($url, '/send/email/') && $accepted) { + $key = self::headerIn($headers, 'Idempotency-Key') ?? (string) count($this->queued); + $this->queued[$key] ??= json_decode((string) $body, true); + } + $answer = json_decode($reply[1], true); + foreach (is_array($answer) && is_array($answer['files'] ?? null) ? $answer['files'] : [] as $slot) { + if (is_array($slot) && is_string($slot['url'] ?? null) && is_array($slot['headers'] ?? null)) { + $this->signedUploads[$slot['url']] = $slot['headers']; + } + if (is_array($slot) && ($slot['status'] ?? null) === 'present' && is_string($slot['sha256'] ?? null)) { + $this->stored[$slot['sha256']] ??= null; + } + } return $reply; } - /** @param array{int, string, list}|EmailError ...$replies */ + /** PostHog's answer to the upload route: present for each file it holds, a slot signed for each other file. */ + private function negotiate(string $body): array + { + $slots = []; + foreach (json_decode($body, true)['files'] ?? [] as $file) { + $slots[] = array_key_exists($file['sha256'], $this->stored) + ? self::presentSlot($file['sha256']) + : self::signedSlot($file['sha256'], $file['size']); + } + + return self::slots(...$slots); + } + + /** @param list $headers */ + private static function headerIn(array $headers, string $name): ?string + { + foreach ($headers as $header) { + if (stripos($header, $name . ':') === 0) { + return trim(substr($header, strlen($name) + 1)); + } + } + + return null; + } + + /** The reference of the first attachment storage doesn't hold, as PostHog names it, or null. */ + private function notStored(string $body): ?string + { + $attachments = json_decode($body, true)['attachments'] ?? []; + foreach (is_array($attachments) ? $attachments : [] as $index => $attachment) { + if (!array_key_exists($attachment['sha256'] ?? '', $this->stored)) { + return "attachments__{$index}__sha256"; + } + } + + return null; + } + + /** @param list $headers */ + private function storageSigned(string $url, array $headers): bool + { + if (!isset($this->signedUploads[$url])) { + return false; + } + $sent = []; + foreach ($headers as $header) { + [$name, $value] = array_map('trim', explode(':', $header, 2)); + $sent[strtolower($name)] = $value; + } + foreach ($this->signedUploads[$url] as $name => $value) { + if (($sent[strtolower($name)] ?? null) !== $value) { + return false; + } + } + + return true; + } + + /** @param list $headers */ + private static function storageAccepts(array $headers, string $body): bool + { + foreach ($headers as $header) { + [$name, $value] = array_map('trim', explode(':', $header, 2)); + $matches = match (strtolower($name)) { + 'content-length' => $value === (string) strlen($body), + 'x-amz-checksum-sha256' => $value === base64_encode(hash('sha256', $body, true)), + default => true, + }; + if (!$matches) { + return false; + } + } + + return true; + } + + /** @param array{int, string, list}|EmailError|string ...$replies */ private function reply(...$replies): void { $this->replies = $replies; @@ -257,6 +398,9 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void 'text' => null, 'from' => null, 'reply_to' => [], + 'cc' => null, + 'bcc' => null, + 'attachments' => null, 'capture_engagement' => null, 'sensitive' => null, 'distinct_id' => null, @@ -272,6 +416,522 @@ public function testNullOptionsAndAnEmptyReplyToCountAsLeftOut(): void ], $this->body()); } + private const UPLOAD_URL = 'https://us.posthog.com/api/projects/@current/messaging/attachments/uploads/'; + private const INVITE_SHA256 = '215efae54e34ac5657da47b49c8d1bd8ea12649ee1efa838c9a47d7233f18afb'; + private const PNG = "\x89PNG\r\n\x1a\n\x00"; + private const PNG_SHA256 = '843ac23b1736b4487ec81cf7c07ddd9bb46ae5b7818c2c3843d99d62fa75f3c9'; + private const BUCKET = 'https://attachments.example/team/2/'; + private const SIGNATURE = '?X-Amz-Signature=signature-secret'; + private const NEGOTIATE = 'answer the upload route from what PostHog holds'; + private const BUCKET_AUTH_ERROR = 'InvalidArgument' + . 'Only one auth mechanism allowed'; + private const BUCKET_DIGEST_ERROR = 'BadDigest' + . 'The SHA256 you specified did not match the calculated checksum.'; + + private static function uploadSlot(string $content): array + { + return self::signedSlot(hash('sha256', $content), strlen($content)); + } + + private static function signedSlot(string $sha256, int $size): array + { + return [ + 'sha256' => $sha256, + 'status' => 'upload', + 'url' => self::BUCKET . $sha256 . self::SIGNATURE, + 'method' => 'PUT', + 'headers' => [ + 'Content-Length' => (string) $size, + 'x-amz-checksum-sha256' => base64_encode((string) hex2bin($sha256)), + ], + 'url_expires_at' => '2026-11-02T12:15:00Z', + ]; + } + + private static function presentSlot(string $sha256): array + { + return ['sha256' => $sha256, 'status' => 'present', 'expires_at' => '2026-12-09T12:00:00Z']; + } + + /** @return array{int, string, list} */ + private static function slots(array ...$slots): array + { + return [200, json_encode(['files' => $slots]), []]; + } + + private static function sendWithFiles(array $attachments, array $email = []): array + { + return $email + [ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => $attachments, + ]; + } + + private static function invite(array $file = []): array + { + return $file + ['filename' => 'invite.ics', 'content' => self::INVITE]; + } + + public function testFilesAreHashedUploadedThenSentByReferenceWithCopies(): void + { + $this->stored[self::PNG_SHA256] = null; + $this->reply(self::NEGOTIATE, [200, '', []], [202, '{"message_id":"msg_1"}', []]); + + $result = $this->emails(['migratedFrom' => 'resend'])->send([ + 'to' => 'user@example.com', + 'template_key' => 'invite', + 'subject' => 'You are invited', + 'html' => '', + 'cc' => 'manager@example.com', + 'bcc' => ['audit@example.com', 'archive@example.com'], + 'attachments' => [ + self::invite(['content_type' => 'text/calendar']), + ['filename' => 'logo.png', 'content' => self::PNG, 'content_id' => 'logo'], + ], + ]); + + $this->assertSame('msg_1', $result->getId()); + $this->assertSame(self::INVITE, $this->stored[self::INVITE_SHA256], 'storage holds the new file'); + $this->assertSame([self::INVITE_SHA256 => 1], $this->storageWrites, 'the file PostHog held went nowhere'); + $this->assertSame([[ + 'template_key' => 'invite', + 'subject' => 'You are invited', + 'html' => '', + 'cc' => ['manager@example.com'], + 'bcc' => ['audit@example.com', 'archive@example.com'], + 'attachments' => [ + ['sha256' => self::INVITE_SHA256, 'filename' => 'invite.ics', 'content_type' => 'text/calendar'], + ['sha256' => self::PNG_SHA256, 'filename' => 'logo.png', 'content_id' => 'logo'], + ], + 'to' => 'user@example.com', + ]], array_values($this->queued)); + } + + public function testTheSameFileTwiceIsUploadedOnceAndAttachedTwice(): void + { + $this->reply(self::slots(self::uploadSlot(self::INVITE)), [200, '', []]); + + $this->emails()->send(self::sendWithFiles([self::invite(), self::invite(['filename' => 'copy.ics'])])); + + $this->assertSame([self::INVITE_SHA256 => 1], $this->storageWrites); + $this->assertSame( + [ + ['sha256' => self::INVITE_SHA256, 'filename' => 'invite.ics'], + ['sha256' => self::INVITE_SHA256, 'filename' => 'copy.ics'], + ], + array_values($this->queued)[0]['attachments'] + ); + } + + public function testARetriedSendDoesNotUploadAgain(): void + { + $this->reply( + self::slots(self::uploadSlot(self::INVITE)), + [200, '', []], + [503, '{"code":"unavailable"}', []], + [202, '{"message_id":"msg_1"}', []] + ); + + $result = $this->emails()->send(self::sendWithFiles([self::invite()])); + + $this->assertSame('msg_1', $result->getId()); + $this->assertSame([self::INVITE_SHA256 => 1], $this->storageWrites); + $this->assertCount(1, $this->queued); + } + + public function testANullContentTypeOrContentIdCountsAsLeftOut(): void + { + $this->reply(self::slots(self::presentSlot(self::INVITE_SHA256))); + + $this->emails()->send(self::sendWithFiles([self::invite(['content_type' => null, 'content_id' => null])])); + + $this->assertSame( + [['sha256' => self::INVITE_SHA256, 'filename' => 'invite.ics']], + array_values($this->queued)[0]['attachments'] + ); + } + + public static function bucketFailures(): array + { + return [ + 'refused' => [ + [400, self::BUCKET_DIGEST_ERROR, []], + 400, + 'BadDigest: The SHA256 you specified did not match', + 'application_error', + ], + 'expired' => [[403, 'AccessDeniedRequest has expired', []], + 403, 'AccessDenied: Request has expired', 'application_error'], + 'not XML' => [[501, 'not implemented', []], 501, 'HTTP 501', 'service_unavailable'], + 'unauthorized' => [[401, 'UnauthorizedNo access', []], + 401, 'Unauthorized: No access', 'application_error'], + ]; + } + + /** @param array{int, string, list} $failure */ + #[DataProvider('bucketFailures')] + public function testAFailedUploadSendsNothingAndSaysWhatTheBucketAnswered( + array $failure, + int $status, + string $answer, + string $name + ): void { + $this->reply( + self::slots(self::presentSlot(self::PNG_SHA256), self::uploadSlot(self::INVITE)), + $failure + ); + + $error = $this->sendError(self::sendWithFiles([ + ['filename' => 'logo.png', 'content' => self::PNG], + self::invite(), + ])); + + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); + $this->assertSame($name, $error->getErrorName()); + $this->assertSame($status, $error->getStatusCode()); + $this->assertSame('attachments__1__content', $error->getField()); + $this->assertStringContainsString($answer, $error->getMessage()); + $this->assertStringContainsString('invite.ics', $error->getMessage()); + $this->assertStringEndsNotWith('..', $error->getMessage()); + $this->assertStringNotContainsString('signature-secret', $error->getMessage() . $error->getFix()); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public static function retriedUploadFailures(): array + { + return [ + 'network error' => [new EmailError('network_error', 'The request failed.')], + 'bucket slowing down' => [[503, 'SlowDownSlow down', []]], + 'storage internal error' => [ + [500, 'InternalErrorPlease try again.', []], + ], + 'bad gateway' => [[502, 'Bad Gateway', []]], + 'gateway timeout' => [[504, 'Gateway Timeout', []]], + 'storage throttling' => [ + [429, 'SlowDownReduce your rate', []], + ], + ]; + } + + /** @param array{int, string, list}|EmailError $failure */ + #[DataProvider('retriedUploadFailures')] + public function testAnUploadThatFailsOnTheWayIsRetried($failure): void + { + $this->reply(self::slots(self::uploadSlot(self::INVITE)), $failure, [200, '', []]); + + $result = $this->emails()->send(self::sendWithFiles([self::invite()])); + + $this->assertSame('msg_1', $result->getId()); + $this->assertSame(self::INVITE, $this->stored[self::INVITE_SHA256]); + $this->assertCount(1, $this->queued); + } + + public function testStorageThrottlingThatOutlastsTheRetriesIsARateLimit(): void + { + $slowDown = [429, 'SlowDownReduce your rate', []]; + $this->reply(self::slots(self::uploadSlot(self::INVITE)), $slowDown); + + $error = $this->sendError(self::sendWithFiles([self::invite()]), $this->emails(['maxRetries' => 0])); + + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); + $this->assertSame('rate_limit_exceeded', $error->getErrorName()); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public function testAStorageRetryAfterOverThirtySecondsFailsAtOnce(): void + { + $slowDown = 'SlowDownSlow down'; + $this->reply(self::slots(self::uploadSlot(self::INVITE)), [503, $slowDown, ['Retry-After: 60']]); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); + $this->assertSame(60_000, $error->getRetryAfterMs()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued); + } + + public static function shutdownsDuringTheUploads(): array + { + return ['after the upload request' => [1, 0], 'after an upload' => [2, 1], 'after the last upload' => [3, 2]]; + } + + #[DataProvider('shutdownsDuringTheUploads')] + public function testAClientShutDownDuringTheUploadsSendsNothingMore( + int $requestsBeforeTheShutdown, + int $filesStored + ): void { + $emails = $this->emails(['isClosed' => fn(): bool => count($this->requests) >= $requestsBeforeTheShutdown]); + $this->reply( + self::slots(self::uploadSlot(self::INVITE), self::uploadSlot(self::PNG)), + [200, '', []], + [200, '', []] + ); + + $error = $this->sendError( + self::sendWithFiles([self::invite(), ['filename' => 'logo.png', 'content' => self::PNG]]), + $emails + ); + + $this->assertSame('client_closed', $error->getErrorCode()); + $this->assertCount($filesStored, $this->storageWrites); + $this->assertSame([], $this->queued); + } + + public function testAnUploadRefusalNamesTheAttachmentAndSendsNothing(): void + { + $this->reply([400, json_encode([ + 'type' => 'invalid_request', + 'code' => 'message_too_large', + 'detail' => 'These files are too large for one email, so PostHog didn\'t prepare the uploads.', + 'attr' => 'files__1__size', + 'why' => 'An email can be at most 40 MB after Base64 encoding, about 29 MB of files.', + 'fix' => 'Send fewer or smaller files, or link to large files from the email body.', + ]), []]); + + $error = $this->sendError(self::sendWithFiles([ + self::invite(), + self::invite(['filename' => 'copy.ics']), + ['filename' => 'logo.png', 'content' => self::PNG], + ])); + + $this->assertSame('message_too_large', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame('attachments__2__content', $error->getField()); + $this->assertSame('Send fewer or smaller files, or link to large files from the email body.', $error->getFix()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public function testBytesThatDoNotMatchTheSignedChecksumAreRefusedByStorageAndNothingIsSent(): void + { + $slot = self::uploadSlot(self::INVITE); + $slot['headers']['x-amz-checksum-sha256'] = base64_encode(hash('sha256', self::PNG, true)); + $this->reply(self::slots($slot)); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('attachment_upload_failed', $error->getErrorCode()); + $this->assertSame(400, $error->getStatusCode()); + $this->assertStringContainsString('BadDigest', $error->getMessage()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued); + } + + public function testTheDailyUploadLimitIsNotRetried(): void + { + $this->reply([429, '{"code":"upload_quota_exceeded","detail":"No more uploads today."}', []]); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('upload_quota_exceeded', $error->getErrorCode()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public static function uploadRouteNotFound(): array + { + return [ + "PostHog's answer" => [ + '{"type":"invalid_request","code":"not_found","detail":"Endpoint not found.","attr":null}', + 'Endpoint not found.', + ], + 'an answer without JSON' => ['Not Found', 'PostHog answered HTTP 404.'], + ]; + } + + #[DataProvider('uploadRouteNotFound')] + public function testAnUploadRouteNotFoundReportsPostHogsAnswerAndSendsNothing(string $answer, string $message): void + { + $this->reply([404, $answer, []]); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('not_found', $error->getErrorCode()); + $this->assertSame('not_found', $error->getErrorName()); + $this->assertSame(404, $error->getStatusCode()); + $this->assertSame('attachments', $error->getField()); + $this->assertSame($message, $error->getMessage()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public function testAnUnavailableUploadRouteIsRetried(): void + { + $this->reply([503, '{"code":"delivery_unavailable"}', []], self::slots(self::presentSlot(self::INVITE_SHA256))); + + $result = $this->emails()->send(self::sendWithFiles([self::invite()])); + + $this->assertSame('msg_1', $result->getId()); + $this->assertCount(1, $this->queued); + } + + public static function unusableUploadAnswers(): array + { + $slot = self::uploadSlot(self::INVITE); + + return [ + 'no slot for the file' => [['files' => [self::presentSlot(self::PNG_SHA256)]]], + 'no files' => [[]], + 'no status' => [['files' => [['sha256' => self::INVITE_SHA256]]]], + 'an unknown status' => [['files' => [['status' => 'later'] + $slot]]], + 'a file URL' => [['files' => [['url' => 'file:///etc/passwd'] + $slot]]], + 'another method' => [['files' => [['method' => 'POST'] + $slot]]], + 'a header with a line break' => [['files' => [['headers' => ['X-A' => "a\r\nAuthorization: b"]] + $slot]]], + 'headers as a list' => [['files' => [['headers' => ['Content-Length: 32']] + $slot]]], + ]; + } + + #[DataProvider('unusableUploadAnswers')] + public function testAnUploadAnswerTheClientCannotFollowSendsNothing(array $answer): void + { + $this->reply([200, json_encode($answer), []]); + set_error_handler(static function (int $severity, string $message): never { + throw new \ErrorException($message, 0, $severity); + }); + + try { + $error = $this->sendError(self::sendWithFiles([self::invite()])); + } finally { + restore_error_handler(); + } + + $this->assertSame('api_error', $error->getErrorCode()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public function testAPlainHttpUploadUrlIsRefusedBeforeAnyUpload(): void + { + $plainUrl = 'http://attachments.example/team/2/' . self::INVITE_SHA256 . self::SIGNATURE; + $this->reply(self::slots(['url' => $plainUrl] + self::uploadSlot(self::INVITE))); + + $error = $this->sendError(self::sendWithFiles([self::invite()])); + + $this->assertSame('api_error', $error->getErrorCode()); + $this->assertSame('attachments__0__content', $error->getField()); + $this->assertStringContainsString('invite.ics', $error->getMessage()); + $this->assertStringContainsString("Fix the file storage's URL", $error->getFix()); + $this->assertStringNotContainsString('attachments.example', $error->getMessage() . $error->getFix()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public function testAPlainHttpAppHostForLocalDevelopmentUploadsOverPlainHttp(): void + { + $plainUrl = 'http://localhost:8333/team/2/' . self::INVITE_SHA256 . self::SIGNATURE; + $this->reply(self::slots(['url' => $plainUrl] + self::uploadSlot(self::INVITE)), [200, '', []]); + + $result = $this->emails(['appHost' => 'http://localhost:8010'])->send(self::sendWithFiles([self::invite()])); + + $this->assertSame('msg_1', $result->getId()); + $this->assertSame(self::INVITE, $this->stored[self::INVITE_SHA256]); + } + + public static function subjectsAndTheLargestFileThatFits(): array + { + return [ + 'a 12-byte subject adds 75' => ['Your receipt', 29_182_812], + '42 ASCII bytes add 75' => [str_repeat('s', 42), 29_182_812], + '43 ASCII bytes add 150' => [str_repeat('s', 43), 29_182_758], + '21 two-byte characters add 75' => [str_repeat('é', 21), 29_182_812], + '22 two-byte characters add 150' => [str_repeat('é', 22), 29_182_758], + ]; + } + + #[DataProvider('subjectsAndTheLargestFileThatFits')] + public function testTheLargestFileThatFitsBesideTheSubjectIsUploaded(string $subject, int $largest): void + { + $file = str_repeat('a', $largest); + $this->reply(self::slots(self::presentSlot(hash('sha256', $file)))); + + $result = $this->emails()->send( + self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]], ['subject' => $subject]) + ); + + $this->assertSame('msg_1', $result->getId()); + } + + #[DataProvider('subjectsAndTheLargestFileThatFits')] + public function testOneByteMoreThanFitsBesideTheSubjectIsRefused(string $subject, int $largest): void + { + $file = str_repeat('a', $largest + 1); + + $error = $this->sendError( + self::sendWithFiles([['filename' => 'big.pdf', 'content' => $file]], ['subject' => $subject]) + ); + + $this->assertSame('message_too_large', $error->getErrorCode()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public static function messagesTooLarge(): array + { + return [ + 'a body that leaves no room' => [[str_repeat('a', 29_000_000)], ['text' => str_repeat('b', 200_000)]], + 'files that only fit alone' => [[str_repeat('a', 15_000_000), str_repeat('b', 15_000_000)], []], + 'an HTML body that fits once but not again as its derived text' => [ + [str_repeat('a', 29_000_000)], + ['html' => str_repeat('h', 100_000), 'text' => ''], + ], + ]; + } + + #[DataProvider('messagesTooLarge')] + public function testAMessageOverFortyMegabytesIsRefusedBeforeAnyRequest(array $contents, array $email): void + { + $files = array_map( + static fn(string $content): array => ['filename' => 'f.pdf', 'content' => $content], + $contents + ); + + $error = $this->sendError(self::sendWithFiles($files, $email)); + + $this->assertSame('message_too_large', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame('attachments', $error->getField()); + $this->assertNull($error->getStatusCode()); + $this->assertStringContainsString('40 MB', $error->getMessage()); + $this->assertStringContainsString('about 29 MB of files', $error->getWhy()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public function testMoreThanTenFilesAreRefusedBeforeAnyRequest(): void + { + $error = $this->sendError(self::sendWithFiles(array_fill(0, 11, self::invite()))); + + $this->assertSame('too_many_attachments', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame('attachments', $error->getField()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued, 'no email was sent'); + } + + public function testEmptyCopyAndAttachmentListsAreLeftOut(): void + { + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'cc' => [], + 'bcc' => [], + 'attachments' => [], + ]); + + $this->assertSame([ + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'to' => 'user@example.com', + ], $this->body()); + } + public function testAGeneratedIdempotencyKeyIsAUuidV4ReusedOnEveryAttempt(): void { $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); @@ -300,6 +960,37 @@ public function testMigratedFromIsSentOnEveryRequest(): void $this->assertSame('resend', $this->header('X-PostHog-Migrated-From', 1)); } + public function testAnIntegrationNamesItselfOnThatSendOnly(): void + { + $emails = $this->emails(); + + $template = ['to' => 'user@example.com', 'template_id' => self::TEMPLATE_ID]; + + $emails->send($template + ['integration' => 'laravel/1.4.0']); + $emails->send($template); + + $this->assertSame('laravel/1.4.0', $this->header('X-PostHog-Integration')); + $this->assertSame('posthog-php/9.9.9', $this->header('User-Agent')); + $this->assertArrayNotHasKey('integration', $this->body()); + $this->assertNull($this->header('X-PostHog-Integration', 1)); + } + + public function testAnIntegrationIsSentOnEveryAttemptOfACodeSend(): void + { + $this->reply([503, '{"code":"unavailable"}', []], [202, '{"message_id":"msg_1"}', []]); + + $this->emails()->send([ + 'to' => 'user@example.com', + 'template_key' => 'order-shipped', + 'subject' => 'Your order shipped', + 'text' => 'On its way.', + 'integration' => 'posthog-php-mailer/4.21.0-beta.1+build.7', + ]); + + $this->assertSame('posthog-php-mailer/4.21.0-beta.1+build.7', $this->header('X-PostHog-Integration')); + $this->assertSame('posthog-php-mailer/4.21.0-beta.1+build.7', $this->header('X-PostHog-Integration', 1)); + } + public function testAReplayAndWarningsComeBackAndWarningsAreLogged(): void { global $errorMessages; @@ -371,7 +1062,14 @@ public static function argumentErrors(): array ], 'tracking with a template' => [$template + ['tracking' => false], 'invalid_form', 'tracking'], 'sensitive with a template' => [$template + ['sensitive' => true], 'invalid_form', 'sensitive'], - 'unknown option' => [$template + ['cc' => 'boss@example.com'], 'invalid_form', 'cc'], + 'unknown option' => [$template + ['headers' => ['X-A' => 'b']], 'invalid_form', 'headers'], + 'cc with a template' => [$template + ['cc' => 'boss@example.com'], 'invalid_form', 'cc'], + 'bcc with a template' => [$template + ['bcc' => ['boss@example.com']], 'invalid_form', 'bcc'], + 'attachments with a template' => [ + $template + ['attachments' => [['filename' => 'a.txt', 'content' => 'hi']]], + 'invalid_form', + 'attachments', + ], 'template_data with code' => [$code + ['template_data' => ['a' => 1]], 'invalid_form', 'template_data'], 'bad template key' => [['template_key' => 'pass word'] + $code, 'invalid_template_key', 'template_key'], 'template key too long' => [ @@ -386,6 +1084,9 @@ public static function argumentErrors(): array 'html not a string' => [['html' => ['

']] + $code, 'invalid_form', 'html'], 'empty from' => [$code + ['from' => ''], 'invalid_form', 'from'], 'blank reply_to' => [$code + ['reply_to' => ['a@example.com', ' ']], 'invalid_form', 'reply_to'], + 'blank cc' => [$code + ['cc' => ' '], 'invalid_form', 'cc'], + 'bcc not text' => [$code + ['bcc' => [123]], 'invalid_form', 'bcc'], + 'cc as a map' => [$code + ['cc' => ['boss' => 'boss@example.com']], 'invalid_form', 'cc'], 'capture_engagement not a bool' => [ $code + ['capture_engagement' => 'no'], 'invalid_form', @@ -408,6 +1109,36 @@ public static function argumentErrors(): array 'negative max_retries' => [$template + ['max_retries' => -1], 'invalid_max_retries', 'max_retries'], 'max_retries as a string' => [$template + ['max_retries' => '1'], 'invalid_max_retries', 'max_retries'], 'html with invalid UTF-8' => [['html' => "\xB1\x31"] + $code, 'invalid_form', null], + 'integration without a version' => [ + $template + ['integration' => 'laravel'], + 'invalid_integration', + 'integration', + ], + 'integration with a capital letter' => [ + $template + ['integration' => 'Laravel/1.4.0'], + 'invalid_integration', + 'integration', + ], + 'integration with a header line break' => [ + $code + ['integration' => "laravel/1.4.0\r\nX-A: b"], + 'invalid_integration', + 'integration', + ], + 'integration name over 40 characters' => [ + $template + ['integration' => str_repeat('a', 41) . '/1.0.0'], + 'invalid_integration', + 'integration', + ], + 'integration not a string' => [ + $template + ['integration' => ['laravel', '1.4.0']], + 'invalid_integration', + 'integration', + ], + 'invalid integration on a send with files' => [ + self::sendWithFiles([self::invite()]) + ['integration' => 'laravel'], + 'invalid_integration', + 'integration', + ], ]; } @@ -423,6 +1154,84 @@ public function testAFailedArgumentCheckSendsNothing(array $email, string $code, $this->assertSame([], $this->requests); } + public static function attachmentErrors(): array + { + $file = ['filename' => 'a.txt', 'content' => self::SECRET_CONTENT]; + + return [ + 'one file, not a list' => [$file, 'attachments'], + 'content, not a list' => [self::SECRET_CONTENT, 'attachments'], + 'file not an array' => [[self::SECRET_CONTENT], 'attachments__0'], + 'file as a list' => [[['a.txt', self::SECRET_CONTENT]], 'attachments__0'], + 'no content' => [[['filename' => 'a.txt']], 'attachments__0__content'], + 'content as a list of bytes' => [ + [['filename' => 'a.txt', 'content' => array_map('ord', str_split(self::SECRET_CONTENT))]], + 'attachments__0__content', + ], + 'no filename' => [[['content' => self::SECRET_CONTENT]], 'attachments__0__filename'], + 'empty content' => [[['filename' => 'a.txt', 'content' => '']], 'attachments__0__content'], + 'filename not text' => [[['filename' => 1] + $file], 'attachments__0__filename'], + 'path' => [[$file + ['path' => '/tmp/a.txt']], 'attachments__0__path'], + 'unknown field' => [[$file + ['contentType' => 'text/plain']], 'attachments__0__contentType'], + 'second file with content_type not text' => [ + [$file, $file + ['content_type' => 1]], + 'attachments__1__content_type', + ], + 'content_id not text' => [[$file + ['content_id' => ['logo']]], 'attachments__0__content_id'], + 'filename not UTF-8' => [[['filename' => "Rechnung_M\xE4rz.pdf"] + $file], 'attachments__0__filename'], + 'content_type not UTF-8' => [[$file + ['content_type' => "text/pl\xE4in"]], 'attachments__0__content_type'], + 'content_id not UTF-8' => [[$file + ['content_id' => "l\xF6go"]], 'attachments__0__content_id'], + ]; + } + + public static function invalidOptionsBesideFiles(): array + { + return [ + 'sensitive not a bool' => [['sensitive' => 'yes'], 'sensitive'], + 'tracking not a bool or array' => [['tracking' => 'off'], 'tracking'], + ]; + } + + #[DataProvider('invalidOptionsBesideFiles')] + public function testAnInvalidOptionOnASendWithFilesUploadsNothing(array $option, string $field): void + { + $this->reply(self::NEGOTIATE, [200, '', []], [202, '{"message_id":"msg_1"}', []]); + + $error = $this->sendError([ + 'to' => 'user@example.com', + 'template_key' => 'invite', + 'subject' => 'You are invited', + 'text' => 'See you there', + 'attachments' => [['filename' => 'invite.ics', 'content' => self::INVITE]], + ] + $option); + + $this->assertSame('invalid_form', $error->getErrorCode()); + $this->assertSame($field, $error->getField()); + $this->assertSame([], $this->storageWrites, 'no file was uploaded'); + $this->assertSame([], $this->queued, 'nothing was sent'); + } + + #[DataProvider('attachmentErrors')] + public function testAnAttachmentTheClientCannotSendIsRefusedWithoutRepeatingItsContent( + mixed $attachments, + string $field + ): void { + $error = $this->sendError([ + 'to' => 'user@example.com', + 'template_key' => 'receipt', + 'subject' => 'Your receipt', + 'text' => 'Thanks!', + 'attachments' => $attachments, + ]); + + $this->assertSame('invalid_attachment', $error->getErrorCode()); + $this->assertSame('validation_error', $error->getErrorName()); + $this->assertSame($field, $error->getField()); + $this->assertStringNotContainsString(self::SECRET_CONTENT, $error->getMessage() . $error->getFix()); + $this->assertSame([], $this->storageWrites); + $this->assertSame([], $this->queued); + } + public function testAnOptionOfTheOtherFormSaysHowToSendIt(): void { $error = $this->sendError([ diff --git a/test/LocalHttpServer.php b/test/LocalHttpServer.php index bebe15c0..f74c188b 100644 --- a/test/LocalHttpServer.php +++ b/test/LocalHttpServer.php @@ -41,6 +41,23 @@ public function address(): string } public function requests(): array + { + return array_map(static function (array $request): array { + unset($request['status']); + return $request; + }, $this->answered()); + } + + /** The requests the server answered with a 2xx status, which it took. */ + public function acceptedRequests(): array + { + return array_values(array_map(static function (array $request): array { + unset($request['status']); + return $request; + }, array_filter($this->answered(), static fn(array $request): bool => intdiv($request['status'], 100) === 2))); + } + + private function answered(): array { return array_map( static fn(string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR), diff --git a/test/fixtures/http-server.php b/test/fixtures/http-server.php index feae8073..729f3539 100644 --- a/test/fixtures/http-server.php +++ b/test/fixtures/http-server.php @@ -31,13 +31,35 @@ if (($headers['content-encoding'] ?? '') === 'gzip') { $body = gzdecode($body); } + $next = $responses[0] ?? []; + $refusal = null; + foreach ($next['accepts'] ?? [] as $name => $expected) { + $actual = match ($name) { + 'request_line' => $requestLine, + 'json_body' => json_decode($body, true), + default => $headers[$name] ?? null, + }; + if ($name === 'json_body' ? $actual != $expected : $actual !== $expected) { + $refusal = $next['refusal']; + } + } + $checksum = $headers['x-amz-checksum-sha256'] ?? null; + if ($checksum !== null && $checksum !== base64_encode(hash('sha256', $body, true))) { + $refusal = ['status' => 400, 'body' => 'BadDigest' + . 'The SHA256 you specified did not match the calculated checksum.']; + } + if (str_contains($requestLine, 'X-Amz-Signature=') && isset($headers['authorization'])) { + $refusal = ['status' => 400, 'body' => '' + . 'InvalidArgumentOnly one auth mechanism allowed']; + } + $response = $refusal ?? array_shift($responses) ?? []; file_put_contents($argv[1], json_encode([ 'requestLine' => $requestLine, 'headers' => $headers, 'body' => $body, + 'status' => $response['status'] ?? 200, ], JSON_THROW_ON_ERROR) . "\n", FILE_APPEND); - - $response = array_shift($responses) ?? []; + usleep(($response['delay_ms'] ?? 0) * 1000); $closeServer = $response['close_server'] ?? false; if ($closeServer) { fclose($server);