Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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, []));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ 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 in any case.
headers: [
...array_diff_ukey($email->getHeaders(), $headers, strcasecmp(...)),
...$headers,
],
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ protected function process(EmailMessage $message): array
);
}

if ($message->getHeaders() !== []) {
$email['headers'] = $message->getHeaders();
}

$emails[] = $email;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions packages/messaging/src/Utopia/Messaging/Messages/Email.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -37,6 +44,7 @@ class Email implements Message
* @param array<string|array<string,string>>|null $bcc The BCC recipients of the email. Same format as $to.
* @param array<Attachment>|null $attachments The attachments of the email.
* @param bool $html Whether the message is HTML or not.
* @param array<string, string> $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,
Expand All @@ -50,6 +58,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);
Expand All @@ -71,6 +80,18 @@ public function __construct(
if (!\in_array($this->replyToEmail, ['', '0'], true)) {
$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;
}
}

/**
Expand Down Expand Up @@ -114,6 +135,31 @@ 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) {
Comment thread
Copilot marked this conversation as resolved.
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);
Comment thread
Copilot marked this conversation as resolved.
}

// 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);
}
}

/**
* Normalize a recipient entry to an associative array with 'email' and optional 'name' keys.
*
Expand Down Expand Up @@ -208,6 +254,14 @@ public function isHtml(): bool
return $this->html;
}

/**
* @return array<string, string>
*/
public function getHeaders(): array
{
return $this->headers;
}

public function setOrigin(?string $origin): self
{
$this->origin = $origin;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Utopia\Tests\Adapter\Email;

use PHPUnit\Framework\TestCase;
use Utopia\Messaging\Adapter\Email\Mailgun;
use Utopia\Messaging\Messages\Email;

final class MailgunHeadersTest extends TestCase
{
public function testMessageHeadersAreSentAsHeaderFields(): void
{
$stub = new MailgunStub('key', 'example.com');

$stub->send(new Email(
to: ['a@example.com'],
subject: 'Subject',
content: 'Body',
fromName: 'Sender',
fromEmail: 'from@example.com',
headers: [
'List-Unsubscribe' => '<https://example.test/u>',
'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
],
));

$body = $stub->capturedRequests[0]['body'];
$this->assertSame('<https://example.test/u>', $body['h:List-Unsubscribe']);
$this->assertSame('List-Unsubscribe=One-Click', $body['h:List-Unsubscribe-Post']);
}
}

class MailgunStub extends Mailgun
{
/**
* @var array<array{method: string, url: string, headers: array<string>, body: mixed}>
*/
public array $capturedRequests = [];

/**
* @param array<string> $headers
* @param array<string, mixed>|null $body
* @return array{url: string, statusCode: int, response: array<string, mixed>|string|null, headers: array<string, string>, 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];
}
}
26 changes: 26 additions & 0 deletions packages/messaging/tests/Messaging/Adapter/Email/MimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ final class MimeTest extends TestCase
/**
* @param list<Attachment> $attachments
* @param array<array<string, string>> $cc
* @param array<string, string> $headers
*/
private function email(
string $content = 'Plain body',
bool $html = false,
array $attachments = [],
array $cc = [],
string $subject = 'Test Subject',
array $headers = [],
): Email {
return new Email(
to: [['email' => 'john@example.test', 'name' => 'John Doe']],
Expand All @@ -38,6 +40,7 @@ private function email(
cc: $cc === [] ? null : $cc,
attachments: $attachments === [] ? null : $attachments,
html: $html,
headers: $headers,
);
}

Expand Down Expand Up @@ -140,4 +143,27 @@ 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' => '<https://example.test/u?token=abc>',
'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click',
]));

$this->assertStringContainsString("List-Unsubscribe: <https://example.test/u?token=abc>\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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<https://example.test/u>'],
));

foreach ($stub->capturedRequests[0]['body'] as $email) {
$this->assertSame(['List-Unsubscribe' => '<https://example.test/u>'], $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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<https://example.test/u>'],
));

$this->assertSame(
[['Name' => 'List-Unsubscribe', 'Value' => '<https://example.test/u>']],
$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' => '<https://example.test/u>'],
));

$raw = base64_decode((string) $stub->capturedRequests[0]['body']['Content']['Raw']['Data']);
$this->assertStringContainsString("List-Unsubscribe: <https://example.test/u>\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']);
}
}

/**
Expand Down
Loading
Loading