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
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,28 @@ $response = $lettr->emails()->send(
);
```

### Custom Headers

You can add custom email headers (e.g. `X-Custom-ID`, `X-Entity-Ref-ID`) to your emails. Maximum 10 headers, each value up to 998 characters:

```php
$email = $lettr->emails()->create()
->from('sender@example.com')
->to(['recipient@example.com'])
->subject('Hello')
->html('<p>Content</p>')
// Bulk set
->headers(['X-Custom-ID' => 'abc-123', 'X-Entity-Ref-ID' => 'order-456'])
// Or add individually
->addHeader('X-Custom-ID', 'abc-123');
```

> **Note:** Some standard headers (e.g. `List-Unsubscribe` for non-transactional emails) may be overwritten by the email delivery provider. Use custom headers for application-specific headers like `X-Custom-ID`.

### Email Options

Emails are sent as **transactional by default**, matching the API's default behavior. For marketing emails, explicitly set `transactional(false)`:

```php
$email = $lettr->emails()->create()
->from('sender@example.com')
Expand All @@ -204,14 +224,30 @@ $email = $lettr->emails()->create()
// Tracking
->withClickTracking(true)
->withOpenTracking(true)
// Mark as transactional (bypasses unsubscribe lists)
// Mark as marketing (non-transactional)
->transactional(false)
// CSS inlining
->withInlineCss(true)
// Template variable substitution
->withSubstitutions(true);
```

### Marketing Emails & Unsubscribe

When sending marketing emails (`transactional(false)`), the email provider automatically adds `List-Unsubscribe` and `List-Unsubscribe-Post` headers for compliance. To allow recipients to unsubscribe from your marketing emails:

1. **Add an unsubscribe link in your HTML** using the `data-msys-unsubscribe` attribute:

```html
<a data-msys-unsubscribe="1"
href="https://yourapp.com/unsubscribe"
title="Unsubscribe">Unsubscribe from these emails</a>
```

The `href` must use `https://` — when clicked, the user will be redirected to your URL. The actual unsubscribe handling should be done server-side by listening for webhook events.

2. **Listen for unsubscribe events** via webhooks — subscribe to `link_unsubscribe` and `list_unsubscribe` event types to process unsubscribes in your application.

## Domain Management

### List Domains
Expand Down
32 changes: 32 additions & 0 deletions src/Builders/EmailBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Lettr\Collections\AttachmentCollection;
use Lettr\Collections\EmailAddressCollection;
use Lettr\Dto\Email\Attachment;
use Lettr\Dto\Email\CustomHeaders;
use Lettr\Dto\Email\EmailOptions;
use Lettr\Dto\Email\Metadata;
use Lettr\Dto\Email\SendEmailData;
Expand Down Expand Up @@ -51,6 +52,8 @@ final class EmailBuilder

private ?Metadata $metadata = null;

private ?CustomHeaders $headers = null;

private ?SubstitutionData $substitutionData = null;

private ?Tag $tag = null;
Expand Down Expand Up @@ -285,6 +288,34 @@ public function addMetadata(string $key, string $value): self
return $this;
}

/**
* Set custom email headers.
*
* @param array<string, string>|CustomHeaders $headers
*/
public function headers(array|CustomHeaders $headers): self
{
$this->headers = $headers instanceof CustomHeaders
? $headers
: CustomHeaders::from($headers);

return $this;
}

/**
* Add a custom email header.
*/
public function addHeader(string $name, string $value): self
{
if ($this->headers === null) {
$this->headers = CustomHeaders::from([$name => $value]);
} else {
$this->headers = $this->headers->set($name, $value);
}

return $this;
}

/**
* Set substitution data for templates.
*
Expand Down Expand Up @@ -415,6 +446,7 @@ public function build(): SendEmailData
attachments: $this->attachments,
options: $options,
metadata: $this->metadata,
headers: $this->headers,
substitutionData: $this->substitutionData,
tag: $this->tag,
projectId: $this->projectId,
Expand Down
116 changes: 116 additions & 0 deletions src/Dto/Email/CustomHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

namespace Lettr\Dto\Email;

use Lettr\Contracts\Arrayable;
use Lettr\Exceptions\InvalidValueException;

/**
* Custom email headers (key-value pairs).
*/
final readonly class CustomHeaders implements Arrayable
{
private const MAX_HEADERS = 10;

private const MAX_VALUE_LENGTH = 998;

/**
* @var array<string, string>
*/
private array $data;

/**
* @param array<string, string> $data
*/
public function __construct(array $data = [])
{
self::validate($data);
$this->data = $data;
}

/**
* Create from an array.
*
* @param array<string, string> $data
*/
public static function from(array $data): self
{
return new self($data);
}

/**
* Create empty headers.
*/
public static function empty(): self
{
return new self;
}

/**
* Add a header.
*/
public function set(string $key, string $value): self
{
return new self([...$this->data, $key => $value]);
}

/**
* Get a header value by name.
*/
public function get(string $key, ?string $default = null): ?string
{
return $this->data[$key] ?? $default;
}

/**
* Check if a header exists.
*/
public function has(string $key): bool
{
return isset($this->data[$key]);
}

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

/**
* Check if headers are empty.
*/
public function isEmpty(): bool
{
return count($this->data) === 0;
}

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

/**
* @param array<string, string> $data
*/
private static function validate(array $data): void
{
if (count($data) > self::MAX_HEADERS) {
throw new InvalidValueException('Custom headers cannot exceed '.self::MAX_HEADERS.' entries.');
}

foreach ($data as $value) {
if (strlen($value) > self::MAX_VALUE_LENGTH) {
throw new InvalidValueException('Custom header value cannot exceed '.self::MAX_VALUE_LENGTH.' characters.');
}
}
}
}
7 changes: 7 additions & 0 deletions src/Dto/Email/SendEmailData.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public function __construct(
public ?AttachmentCollection $attachments = null,
public ?EmailOptions $options = null,
public ?Metadata $metadata = null,
public ?CustomHeaders $headers = null,
public ?SubstitutionData $substitutionData = null,
public ?Tag $tag = null,
public ?int $projectId = null,
Expand All @@ -50,6 +51,7 @@ public function __construct(
* attachments?: array<array{name: string, type: string, data: string}>|null,
* options?: array{click_tracking?: bool, open_tracking?: bool, transactional?: bool, inline_css?: bool, perform_substitutions?: bool}|null,
* metadata?: array<string, string>|null,
* headers?: array<string, string>|null,
* substitution_data?: array<string, mixed>|null,
* tag?: string|null,
* project_id?: int|null,
Expand Down Expand Up @@ -77,6 +79,7 @@ public static function from(array $data): self
) : null,
options: isset($data['options']) ? EmailOptions::from($data['options']) : null,
metadata: isset($data['metadata']) ? Metadata::from($data['metadata']) : null,
headers: isset($data['headers']) ? CustomHeaders::from($data['headers']) : null,
substitutionData: isset($data['substitution_data']) ? SubstitutionData::from($data['substitution_data']) : null,
tag: isset($data['tag']) ? new Tag($data['tag']) : null,
projectId: $data['project_id'] ?? null,
Expand Down Expand Up @@ -137,6 +140,10 @@ public function toArray(): array
$data['metadata'] = $this->metadata->toArray();
}

if ($this->headers !== null && ! $this->headers->isEmpty()) {
$data['headers'] = $this->headers->toArray();
}

if ($this->substitutionData !== null && ! $this->substitutionData->isEmpty()) {
$data['substitution_data'] = $this->substitutionData->toArray();
}
Expand Down
79 changes: 79 additions & 0 deletions tests/Unit/Dto/CustomHeadersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

use Lettr\Dto\Email\CustomHeaders;
use Lettr\Exceptions\InvalidValueException;

test('can create custom headers from array', function (): void {
$headers = CustomHeaders::from([
'List-Unsubscribe' => '<mailto:unsub@example.com>',
'X-Custom-ID' => 'abc-123',
]);

expect($headers->all())->toBe([
'List-Unsubscribe' => '<mailto:unsub@example.com>',
'X-Custom-ID' => 'abc-123',
]);
});

test('can create empty custom headers', function (): void {
$headers = CustomHeaders::empty();

expect($headers->isEmpty())->toBeTrue()
->and($headers->all())->toBe([]);
});

test('can set a header', function (): void {
$headers = CustomHeaders::empty()
->set('X-Custom-ID', 'abc-123');

expect($headers->has('X-Custom-ID'))->toBeTrue()
->and($headers->get('X-Custom-ID'))->toBe('abc-123');
});

test('can get a header with default', function (): void {
$headers = CustomHeaders::empty();

expect($headers->get('X-Missing', 'default'))->toBe('default')
->and($headers->get('X-Missing'))->toBeNull();
});

test('toArray returns header data', function (): void {
$headers = CustomHeaders::from(['X-Custom-ID' => 'abc-123']);

expect($headers->toArray())->toBe(['X-Custom-ID' => 'abc-123']);
});

test('throws exception when exceeding max headers', function (): void {
$data = [];
for ($i = 1; $i <= 11; $i++) {
$data["X-Header-{$i}"] = "value-{$i}";
}

CustomHeaders::from($data);
})->throws(InvalidValueException::class, 'Custom headers cannot exceed 10 entries.');

test('throws exception when header value exceeds max length', function (): void {
CustomHeaders::from([
'X-Long' => str_repeat('a', 999),
]);
})->throws(InvalidValueException::class, 'Custom header value cannot exceed 998 characters.');

test('allows header value at exact max length', function (): void {
$headers = CustomHeaders::from([
'X-Long' => str_repeat('a', 998),
]);

expect($headers->has('X-Long'))->toBeTrue();
});

test('set validates after adding header', function (): void {
$headers = CustomHeaders::empty();
for ($i = 1; $i <= 10; $i++) {
$headers = $headers->set("X-Header-{$i}", "value-{$i}");
}

expect(fn () => $headers->set('X-Header-11', 'value-11'))
->toThrow(InvalidValueException::class);
});
Loading
Loading