From e816aeb09d61b086395f30888afa05dc6ad8a679 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 23 Sep 2026 15:01:24 +0545 Subject: [PATCH 1/5] feat(messaging): carry custom headers on email messages --- .../Exception/InvalidArgumentException.php | 2 + .../src/Utopia/Messaging/Messages/Email.php | 30 +++++++++++++++ .../tests/Messaging/Messages/EmailTest.php | 38 ++++++++++++++++++- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/messaging/src/Utopia/Messaging/Exception/InvalidArgumentException.php b/packages/messaging/src/Utopia/Messaging/Exception/InvalidArgumentException.php index ecfe44e4d..24b572810 100644 --- a/packages/messaging/src/Utopia/Messaging/Exception/InvalidArgumentException.php +++ b/packages/messaging/src/Utopia/Messaging/Exception/InvalidArgumentException.php @@ -28,6 +28,8 @@ class InvalidArgumentException extends \InvalidArgumentException public const string SENDER_MALFORMED = 'sender_malformed'; + public const string HEADER_MALFORMED = 'header_malformed'; + public function __construct( public readonly string $type, string $message, diff --git a/packages/messaging/src/Utopia/Messaging/Messages/Email.php b/packages/messaging/src/Utopia/Messaging/Messages/Email.php index bf09b312a..83c64b37f 100644 --- a/packages/messaging/src/Utopia/Messaging/Messages/Email.php +++ b/packages/messaging/src/Utopia/Messaging/Messages/Email.php @@ -37,6 +37,7 @@ class Email implements Message * @param array>|null $bcc The BCC recipients of the email. Same format as $to. * @param array|null $attachments The attachments of the email. * @param bool $html Whether the message is HTML or not. + * @param array $headers Extra headers written as given, e.g. List-Unsubscribe. */ public function __construct( array $to, @@ -50,6 +51,7 @@ public function __construct( ?array $bcc = null, private readonly ?array $attachments = null, private readonly bool $html = false, + private readonly array $headers = [], ) { $this->to = array_map($this->normalizeRecipient(...), $to); $this->cc = \is_null($cc) ? null : array_map($this->normalizeRecipient(...), $cc); @@ -71,6 +73,10 @@ public function __construct( if (!\in_array($this->replyToEmail, ['', '0'], true)) { $this->assertAddress($this->replyToEmail, InvalidArgumentException::SENDER_MALFORMED); } + + foreach ($this->headers as $name => $value) { + $this->assertHeader($name, $value); + } } /** @@ -114,6 +120,22 @@ private function assertName(string $name): void } } + /** + * A line break in a header lets the rest of the value forge headers of its own. + * + * @throws InvalidArgumentException + */ + private function assertHeader(mixed $name, mixed $value): void + { + if (!\is_string($name) || preg_match('/^[!-9;-~]+$/', $name) !== 1) { + throw new InvalidArgumentException(InvalidArgumentException::HEADER_MALFORMED, 'Header name must be printable ASCII without spaces or colons.', \is_string($name) ? $name : null); + } + + if (!\is_string($value) || preg_match('/[\r\n\x00]/', $value) === 1) { + throw new InvalidArgumentException(InvalidArgumentException::HEADER_MALFORMED, "Header \"{$name}\" must be a single-line string.", $name); + } + } + /** * Normalize a recipient entry to an associative array with 'email' and optional 'name' keys. * @@ -208,6 +230,14 @@ public function isHtml(): bool return $this->html; } + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + public function setOrigin(?string $origin): self { $this->origin = $origin; diff --git a/packages/messaging/tests/Messaging/Messages/EmailTest.php b/packages/messaging/tests/Messaging/Messages/EmailTest.php index 15d6531e2..3b2e3acfd 100644 --- a/packages/messaging/tests/Messaging/Messages/EmailTest.php +++ b/packages/messaging/tests/Messaging/Messages/EmailTest.php @@ -95,11 +95,46 @@ public function testTypedExceptionIsStillAnInvalidArgumentException(): void $this->message(to: ['nope']); } + public function testHeadersAreKept(): void + { + $message = $this->message(to: ['john@appwrite.io'], headers: [ + 'List-Unsubscribe' => '', + 'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click', + ]); + + $this->assertSame([ + 'List-Unsubscribe' => '', + 'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click', + ], $message->getHeaders()); + } + + public function testHeaderValueWithLineBreakIsRefused(): void + { + try { + $this->message(to: ['john@appwrite.io'], headers: ['X-Tag' => "a\r\nBcc: victim@example.com"]); + $this->fail('Expected header failure'); + } catch (InvalidArgumentException $exception) { + $this->assertSame(InvalidArgumentException::HEADER_MALFORMED, $exception->getType()); + $this->assertSame('X-Tag', $exception->getValue()); + } + } + + public function testHeaderNameWithColonIsRefused(): void + { + try { + $this->message(to: ['john@appwrite.io'], headers: ['X-Tag: forged' => 'value']); + $this->fail('Expected header failure'); + } catch (InvalidArgumentException $exception) { + $this->assertSame(InvalidArgumentException::HEADER_MALFORMED, $exception->getType()); + } + } + /** * @param array> $to * @param array>|null $bcc + * @param array $headers */ - private function message(array $to, ?array $bcc = null, string $fromEmail = 'noreply@appwrite.io'): Email + private function message(array $to, ?array $bcc = null, string $fromEmail = 'noreply@appwrite.io', array $headers = []): Email { return new Email( to: $to, @@ -108,6 +143,7 @@ private function message(array $to, ?array $bcc = null, string $fromEmail = 'nor fromName: 'Sender', fromEmail: $fromEmail, bcc: $bcc, + headers: $headers, ); } } From 09c1d1209bf2c17df1a10a8dc2907250a422fd2a Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 23 Sep 2026 15:06:25 +0545 Subject: [PATCH 2/5] feat(messaging): send custom email headers from every email adapter --- .../Messaging/Adapter/Email/Mailgun.php | 4 ++ .../Utopia/Messaging/Adapter/Email/Mime.php | 3 +- .../Utopia/Messaging/Adapter/Email/Resend.php | 4 ++ .../Utopia/Messaging/Adapter/Email/SES.php | 8 +++ .../Messaging/Adapter/Email/Sendgrid.php | 4 ++ .../Adapter/Email/MailgunHeadersTest.php | 60 ++++++++++++++++ .../Messaging/Adapter/Email/MimeTest.php | 14 ++++ .../Adapter/Email/ResendRoutingTest.php | 33 +++++++++ .../Adapter/Email/SESRoutingTest.php | 55 +++++++++++++++ .../Messaging/Adapter/Email/SMTPTest.php | 23 ++++++ .../Adapter/Email/SendgridHeadersTest.php | 70 +++++++++++++++++++ 11 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 packages/messaging/tests/Messaging/Adapter/Email/MailgunHeadersTest.php create mode 100644 packages/messaging/tests/Messaging/Adapter/Email/SendgridHeadersTest.php diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mailgun.php b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mailgun.php index e6f524b8a..2b2827e7a 100644 --- a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mailgun.php +++ b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mailgun.php @@ -68,6 +68,10 @@ protected function process(EmailMessage $message): array 'h:Reply-To' => $this->formatAddress($message->getReplyToEmail(), $message->getReplyToName()), ]; + foreach ($message->getHeaders() as $name => $value) { + $body['h:' . $name] = $value; + } + if (\count($recipients) > 1) { $body['recipient-variables'] = json_encode(array_fill_keys($toEmails, [])); } diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mime.php b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mime.php index 4a307b27c..0ced74b1b 100644 --- a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mime.php +++ b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mime.php @@ -42,7 +42,8 @@ public static function message( ? [] : [new Address($email->getReplyToEmail(), $email->getReplyToName())], attachments: self::attachments($email), - headers: $headers, + // The adapter's own headers, such as X-Mailer, win over the message's. + headers: [...$email->getHeaders(), ...$headers], ); } diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Resend.php b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Resend.php index c4bb2a489..7615bfdc0 100644 --- a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Resend.php +++ b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Resend.php @@ -114,6 +114,10 @@ protected function process(EmailMessage $message): array ); } + if ($message->getHeaders() !== []) { + $email['headers'] = $message->getHeaders(); + } + $emails[] = $email; } diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/Email/SES.php b/packages/messaging/src/Utopia/Messaging/Adapter/Email/SES.php index d19d1d8b0..071ca0939 100644 --- a/packages/messaging/src/Utopia/Messaging/Adapter/Email/SES.php +++ b/packages/messaging/src/Utopia/Messaging/Adapter/Email/SES.php @@ -185,6 +185,14 @@ function (array $to) use ($cc, $bcc): array { 'BulkEmailEntries' => $entries, ]; + if ($message->getHeaders() !== []) { + $body['DefaultContent']['Template']['Headers'] = array_map( + static fn(string $name, string $value): array => ['Name' => $name, 'Value' => $value], + array_keys($message->getHeaders()), + array_values($message->getHeaders()), + ); + } + if (!\in_array($message->getReplyToEmail(), ['', '0'], true)) { $body['ReplyToAddresses'] = [ $this->formatAddress($message->getReplyToEmail(), $message->getReplyToName()), diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Sendgrid.php b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Sendgrid.php index 2766b841d..81fd412d8 100644 --- a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Sendgrid.php +++ b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Sendgrid.php @@ -124,6 +124,10 @@ protected function process(EmailMessage $message): array $body['attachments'] = $attachments; } + if ($message->getHeaders() !== []) { + $body['headers'] = $message->getHeaders(); + } + $response = new Response($this->getType()); $result = $this->request( method: 'POST', diff --git a/packages/messaging/tests/Messaging/Adapter/Email/MailgunHeadersTest.php b/packages/messaging/tests/Messaging/Adapter/Email/MailgunHeadersTest.php new file mode 100644 index 000000000..782092a97 --- /dev/null +++ b/packages/messaging/tests/Messaging/Adapter/Email/MailgunHeadersTest.php @@ -0,0 +1,60 @@ +send(new Email( + to: ['a@example.com'], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + headers: [ + 'List-Unsubscribe' => '', + 'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click', + ], + )); + + $body = $stub->capturedRequests[0]['body']; + $this->assertSame('', $body['h:List-Unsubscribe']); + $this->assertSame('List-Unsubscribe=One-Click', $body['h:List-Unsubscribe-Post']); + } +} + +class MailgunStub extends Mailgun +{ + /** + * @var array, body: mixed}> + */ + public array $capturedRequests = []; + + /** + * @param array $headers + * @param array|null $body + * @return array{url: string, statusCode: int, response: array|string|null, headers: array, error: string|null, errorCode: int} + */ + #[\Override] + protected function request( + string $method, + string $url, + array $headers = [], + ?array $body = null, + int $timeout = 30, + int $connectTimeout = 10, + ): array { + $this->capturedRequests[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $body]; + + return ['url' => $url, 'statusCode' => 200, 'response' => [], 'headers' => [], 'error' => null, 'errorCode' => 0]; + } +} diff --git a/packages/messaging/tests/Messaging/Adapter/Email/MimeTest.php b/packages/messaging/tests/Messaging/Adapter/Email/MimeTest.php index 87dd28ea1..170c1cb57 100644 --- a/packages/messaging/tests/Messaging/Adapter/Email/MimeTest.php +++ b/packages/messaging/tests/Messaging/Adapter/Email/MimeTest.php @@ -21,6 +21,7 @@ final class MimeTest extends TestCase /** * @param list $attachments * @param array> $cc + * @param array $headers */ private function email( string $content = 'Plain body', @@ -28,6 +29,7 @@ private function email( array $attachments = [], array $cc = [], string $subject = 'Test Subject', + array $headers = [], ): Email { return new Email( to: [['email' => 'john@example.test', 'name' => 'John Doe']], @@ -38,6 +40,7 @@ private function email( cc: $cc === [] ? null : $cc, attachments: $attachments === [] ? null : $attachments, html: $html, + headers: $headers, ); } @@ -140,4 +143,15 @@ public function testWeighsAttachmentsBeforeTheyAreEncoded(): void attachments: [new Attachment(name: 'notes.txt', path: '', type: 'text/plain', content: 'the notes')], ))); } + + public function testCarriesTheMessageHeaders(): void + { + $rendered = $this->render($this->email(headers: [ + 'List-Unsubscribe' => '', + 'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click', + ])); + + $this->assertStringContainsString("List-Unsubscribe: \r\n", $rendered); + $this->assertStringContainsString("List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n", $rendered); + } } diff --git a/packages/messaging/tests/Messaging/Adapter/Email/ResendRoutingTest.php b/packages/messaging/tests/Messaging/Adapter/Email/ResendRoutingTest.php index 6c45dc24d..c6973c6e4 100644 --- a/packages/messaging/tests/Messaging/Adapter/Email/ResendRoutingTest.php +++ b/packages/messaging/tests/Messaging/Adapter/Email/ResendRoutingTest.php @@ -191,6 +191,39 @@ public function testAttachmentExceedingMaxSizeThrows(): void $stub->send($message); } + + public function testMessageHeadersAreSentPerEmail(): void + { + $stub = new ResendStub('test-key'); + + $stub->send(new Email( + to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + headers: ['List-Unsubscribe' => ''], + )); + + foreach ($stub->capturedRequests[0]['body'] as $email) { + $this->assertSame(['List-Unsubscribe' => ''], $email['headers']); + } + } + + public function testWithoutMessageHeadersOmitsTheField(): void + { + $stub = new ResendStub('test-key'); + + $stub->send(new Email( + to: ['a@example.com'], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + )); + + $this->assertArrayNotHasKey('headers', $stub->capturedRequests[0]['body'][0]); + } } class ResendStub extends Resend diff --git a/packages/messaging/tests/Messaging/Adapter/Email/SESRoutingTest.php b/packages/messaging/tests/Messaging/Adapter/Email/SESRoutingTest.php index d0273b9cc..b35c1d9f8 100644 --- a/packages/messaging/tests/Messaging/Adapter/Email/SESRoutingTest.php +++ b/packages/messaging/tests/Messaging/Adapter/Email/SESRoutingTest.php @@ -638,6 +638,61 @@ public function testMimeExceedingSesLimitThrows(): void $stub->send($message); } + + public function testBulkSendCarriesTheMessageHeaders(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; + + $stub->send(new Email( + to: [['email' => 'a@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + headers: ['List-Unsubscribe' => ''], + )); + + $this->assertSame( + [['Name' => 'List-Unsubscribe', 'Value' => '']], + $stub->capturedRequests[0]['body']['DefaultContent']['Template']['Headers'], + ); + } + + public function testRawSendCarriesTheMessageHeaders(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'a']]; + + $stub->send(new Email( + to: [['email' => 'a@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + attachments: [new Attachment(name: 'note.txt', path: '', type: 'text/plain', content: 'hello')], + headers: ['List-Unsubscribe' => ''], + )); + + $raw = base64_decode((string) $stub->capturedRequests[0]['body']['Content']['Raw']['Data']); + $this->assertStringContainsString("List-Unsubscribe: \r\n", $raw); + } + + public function testBulkSendWithoutHeadersOmitsTheField(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; + + $stub->send(new Email( + to: [['email' => 'a@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + )); + + $this->assertArrayNotHasKey('Headers', $stub->capturedRequests[0]['body']['DefaultContent']['Template']); + } } /** diff --git a/packages/messaging/tests/Messaging/Adapter/Email/SMTPTest.php b/packages/messaging/tests/Messaging/Adapter/Email/SMTPTest.php index 3199689f3..a5b91e3c2 100644 --- a/packages/messaging/tests/Messaging/Adapter/Email/SMTPTest.php +++ b/packages/messaging/tests/Messaging/Adapter/Email/SMTPTest.php @@ -43,6 +43,29 @@ public function testSendEmail(): void $this->assertSame($content, trim((string) $lastEmail['text'])); } + public function testSendEmailWithHeaders(): void + { + $sender = new SMTP(host: '127.0.0.1', port: 11025); + + $response = $sender->send(new Email( + to: ['tester@localhost.test'], + subject: 'Header Subject', + content: 'Test Content', + fromName: 'Test Sender', + fromEmail: 'sender@localhost.test', + headers: [ + 'List-Unsubscribe' => '', + 'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click', + ], + )); + + $lastEmail = $this->getLastEmail(); + + $this->assertResponse($response); + $this->assertSame('', $lastEmail['headers']['list-unsubscribe']); + $this->assertSame('List-Unsubscribe=One-Click', $lastEmail['headers']['list-unsubscribe-post']); + } + public function testSendEmailWithAttachment(): void { $sender = new SMTP( diff --git a/packages/messaging/tests/Messaging/Adapter/Email/SendgridHeadersTest.php b/packages/messaging/tests/Messaging/Adapter/Email/SendgridHeadersTest.php new file mode 100644 index 000000000..8ff1321ee --- /dev/null +++ b/packages/messaging/tests/Messaging/Adapter/Email/SendgridHeadersTest.php @@ -0,0 +1,70 @@ +send(new Email( + to: ['a@example.com'], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + headers: ['List-Unsubscribe' => ''], + )); + + $this->assertSame(['List-Unsubscribe' => ''], $stub->capturedRequests[0]['body']['headers']); + } + + public function testWithoutMessageHeadersOmitsTheField(): void + { + $stub = new SendgridStub('key'); + + $stub->send(new Email( + to: ['a@example.com'], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + )); + + $this->assertArrayNotHasKey('headers', $stub->capturedRequests[0]['body']); + } +} + +class SendgridStub extends Sendgrid +{ + /** + * @var array, body: mixed}> + */ + public array $capturedRequests = []; + + /** + * @param array $headers + * @param array|null $body + * @return array{url: string, statusCode: int, response: array|string|null, headers: array, error: string|null, errorCode: int} + */ + #[\Override] + protected function request( + string $method, + string $url, + array $headers = [], + ?array $body = null, + int $timeout = 30, + int $connectTimeout = 10, + ): array { + $this->capturedRequests[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $body]; + + return ['url' => $url, 'statusCode' => 202, 'response' => null, 'headers' => [], 'error' => null, 'errorCode' => 0]; + } +} From fab0bec8b1b2f10b6dacb40b9b89f5d5d762d332 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 23 Sep 2026 15:16:22 +0545 Subject: [PATCH 3/5] fix(messaging): refuse owned and duplicate email headers --- .../src/Utopia/Messaging/Messages/Email.php | 21 +++++++++++- .../tests/Messaging/Messages/EmailTest.php | 33 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/messaging/src/Utopia/Messaging/Messages/Email.php b/packages/messaging/src/Utopia/Messaging/Messages/Email.php index 83c64b37f..c1147bb13 100644 --- a/packages/messaging/src/Utopia/Messaging/Messages/Email.php +++ b/packages/messaging/src/Utopia/Messaging/Messages/Email.php @@ -8,6 +8,13 @@ class Email implements Message { + /** Headers the message already writes itself; a caller value would collide at send time. */ + private const array RESERVED_HEADERS = [ + 'date', 'from', 'to', 'cc', 'bcc', 'reply-to', + 'subject', 'message-id', 'mime-version', + 'content-type', 'content-transfer-encoding', + ]; + private ?string $origin = null; /** @@ -37,7 +44,7 @@ class Email implements Message * @param array>|null $bcc The BCC recipients of the email. Same format as $to. * @param array|null $attachments The attachments of the email. * @param bool $html Whether the message is HTML or not. - * @param array $headers Extra headers written as given, e.g. List-Unsubscribe. + * @param array $headers Extra headers written as given, e.g. List-Unsubscribe. Sent to every recipient of this message; a per-recipient value needs one Email per recipient. */ public function __construct( array $to, @@ -74,8 +81,16 @@ public function __construct( $this->assertAddress($this->replyToEmail, InvalidArgumentException::SENDER_MALFORMED); } + $seenHeaders = []; foreach ($this->headers as $name => $value) { $this->assertHeader($name, $value); + + // Two spellings of the same header would silently pick whichever the adapter sends last. + $key = strtolower((string) $name); + if (isset($seenHeaders[$key])) { + throw new InvalidArgumentException(InvalidArgumentException::HEADER_MALFORMED, "Header \"{$name}\" duplicates \"{$seenHeaders[$key]}\" by case.", (string) $name); + } + $seenHeaders[$key] = $name; } } @@ -131,6 +146,10 @@ private function assertHeader(mixed $name, mixed $value): void throw new InvalidArgumentException(InvalidArgumentException::HEADER_MALFORMED, 'Header name must be printable ASCII without spaces or colons.', \is_string($name) ? $name : null); } + if (\in_array(strtolower($name), self::RESERVED_HEADERS, true)) { + throw new InvalidArgumentException(InvalidArgumentException::HEADER_MALFORMED, "The message already owns the \"{$name}\" header.", $name); + } + if (!\is_string($value) || preg_match('/[\r\n\x00]/', $value) === 1) { throw new InvalidArgumentException(InvalidArgumentException::HEADER_MALFORMED, "Header \"{$name}\" must be a single-line string.", $name); } diff --git a/packages/messaging/tests/Messaging/Messages/EmailTest.php b/packages/messaging/tests/Messaging/Messages/EmailTest.php index 3b2e3acfd..b0d70801b 100644 --- a/packages/messaging/tests/Messaging/Messages/EmailTest.php +++ b/packages/messaging/tests/Messaging/Messages/EmailTest.php @@ -129,6 +129,39 @@ public function testHeaderNameWithColonIsRefused(): void } } + /** + * @return \Iterator + */ + public static function reservedHeaderNames(): \Iterator + { + yield 'sender-owned header' => ['From']; + yield 'reserved name in another case' => ['subject']; + } + + #[DataProvider('reservedHeaderNames')] + public function testReservedHeaderNameIsRefused(string $name): void + { + try { + $this->message(to: ['john@appwrite.io'], headers: [$name => 'value']); + $this->fail('Expected header failure'); + } catch (InvalidArgumentException $exception) { + $this->assertSame(InvalidArgumentException::HEADER_MALFORMED, $exception->getType()); + } + } + + public function testDuplicateHeaderNameByCaseIsRefused(): void + { + try { + $this->message(to: ['john@appwrite.io'], headers: [ + 'List-Unsubscribe' => '', + 'list-unsubscribe' => '', + ]); + $this->fail('Expected header failure'); + } catch (InvalidArgumentException $exception) { + $this->assertSame(InvalidArgumentException::HEADER_MALFORMED, $exception->getType()); + } + } + /** * @param array> $to * @param array>|null $bcc From bbf0470200315c1adea953702af16f6780a65f14 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 23 Sep 2026 16:16:34 +0545 Subject: [PATCH 4/5] fix(messaging): let adapter headers win over message headers in any case --- .../src/Utopia/Messaging/Adapter/Email/Mime.php | 7 +++++-- .../tests/Messaging/Adapter/Email/MimeTest.php | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mime.php b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mime.php index 0ced74b1b..b4a0679b3 100644 --- a/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mime.php +++ b/packages/messaging/src/Utopia/Messaging/Adapter/Email/Mime.php @@ -42,8 +42,11 @@ public static function message( ? [] : [new Address($email->getReplyToEmail(), $email->getReplyToName())], attachments: self::attachments($email), - // The adapter's own headers, such as X-Mailer, win over the message's. - headers: [...$email->getHeaders(), ...$headers], + // The adapter's own headers, such as X-Mailer, win over the message's in any case. + headers: [ + ...array_diff_ukey($email->getHeaders(), $headers, strcasecmp(...)), + ...$headers, + ], ); } diff --git a/packages/messaging/tests/Messaging/Adapter/Email/MimeTest.php b/packages/messaging/tests/Messaging/Adapter/Email/MimeTest.php index 170c1cb57..2ad456ae4 100644 --- a/packages/messaging/tests/Messaging/Adapter/Email/MimeTest.php +++ b/packages/messaging/tests/Messaging/Adapter/Email/MimeTest.php @@ -154,4 +154,16 @@ public function testCarriesTheMessageHeaders(): void $this->assertStringContainsString("List-Unsubscribe: \r\n", $rendered); $this->assertStringContainsString("List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n", $rendered); } + + public function testAdapterHeadersWinOverMessageHeadersInAnyCase(): void + { + $rendered = (string) Mime::message( + $this->email(headers: ['x-mailer' => 'Caller']), + [['email' => 'john@example.test']], + headers: ['X-Mailer' => 'Adapter'], + ); + + $this->assertStringContainsString("X-Mailer: Adapter\r\n", $rendered); + $this->assertStringNotContainsString('Caller', $rendered); + } } From 6ce6216e3928f3daf7f27c88927ba798c93d2513 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 23 Sep 2026 16:16:34 +0545 Subject: [PATCH 5/5] fix(messaging): refuse empty email header values --- .../src/Utopia/Messaging/Messages/Email.php | 5 +++++ .../tests/Messaging/Messages/EmailTest.php | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/messaging/src/Utopia/Messaging/Messages/Email.php b/packages/messaging/src/Utopia/Messaging/Messages/Email.php index c1147bb13..60189ebad 100644 --- a/packages/messaging/src/Utopia/Messaging/Messages/Email.php +++ b/packages/messaging/src/Utopia/Messaging/Messages/Email.php @@ -153,6 +153,11 @@ private function assertHeader(mixed $name, mixed $value): void if (!\is_string($value) || preg_match('/[\r\n\x00]/', $value) === 1) { throw new InvalidArgumentException(InvalidArgumentException::HEADER_MALFORMED, "Header \"{$name}\" must be a single-line string.", $name); } + + // The MIME writer drops empty fields while the API adapters send them, so one value would vary by adapter. + if (trim($value) === '') { + throw new InvalidArgumentException(InvalidArgumentException::HEADER_MALFORMED, "Header \"{$name}\" must not be empty.", $name); + } } /** diff --git a/packages/messaging/tests/Messaging/Messages/EmailTest.php b/packages/messaging/tests/Messaging/Messages/EmailTest.php index b0d70801b..110dd62ab 100644 --- a/packages/messaging/tests/Messaging/Messages/EmailTest.php +++ b/packages/messaging/tests/Messaging/Messages/EmailTest.php @@ -149,6 +149,27 @@ public function testReservedHeaderNameIsRefused(string $name): void } } + #[DataProvider('emptyHeaderValues')] + public function testEmptyHeaderValueIsRefused(string $value): void + { + try { + $this->message(to: ['john@appwrite.io'], headers: ['X-Tag' => $value]); + $this->fail('Expected header failure'); + } catch (InvalidArgumentException $exception) { + $this->assertSame(InvalidArgumentException::HEADER_MALFORMED, $exception->getType()); + $this->assertSame('X-Tag', $exception->getValue()); + } + } + + /** + * @return \Iterator + */ + public static function emptyHeaderValues(): \Iterator + { + yield 'empty' => ['']; + yield 'whitespace only' => [' ']; + } + public function testDuplicateHeaderNameByCaseIsRefused(): void { try {