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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).

## [2.8.0] - 2026-09-20

Scheduled emails changed shape on the API side, and this release catches the SDK up. **If you schedule emails, `getScheduled()` currently throws against the live API — upgrade.** Nothing outside scheduled emails is touched: `send()`, `find()`, `list()`, templates, audience and campaigns are all unchanged, as are `TransmissionDetail` and `TransmissionState`.

### Fixed

- **`getScheduled()` threw on every scheduled email.** The API now answers with Lettr's own scheduled-email shape, and the old `TransmissionDetail` could not hold it: the provider's transmission id is `null` until the email is actually sent (a `TypeError` on a non-nullable `string`), and the states `sending` and `cancelled` are not in `TransmissionState` (a `ValueError`). Scheduled emails now have their own type and enum, so neither can happen.

### Added

- **`Dto\Email\ScheduledEmail`** — what `schedule()`, `getScheduled()`, `cancelScheduled()` and `listScheduled()` return. Carries `requestId`, `transmissionId`, `state`, `scheduledAt`, `from`, `fromName`, `subject`, `recipients`, `numRecipients`, `accepted`, `rejected`, `tag`, `failureReason` and `events`, plus `isCancellable()`, `isSent()` and `isCancelled()`.

**Two ids, and they answer different questions.** `requestId` (`sch_...`) identifies the scheduled email for its whole life and is what you pass to `getScheduled()` and `cancelScheduled()`. `transmissionId` is the sending provider's id: `null` until the email is sent, and the value that appears on your **webhook events**. If you were storing the id from `schedule()` to match webhooks, store `transmissionId` from a later read instead — it is not available at scheduling time, because the email has not been handed over yet.

- **`Enums\ScheduledEmailState`** — `Scheduled`, `Sending`, `Sent`, `Cancelled`, `Failed`, with `isCancellable()` and `isTerminal()`. Separate from `TransmissionState`, which still describes a *sent* email from `find()`.

- **`EmailService::listScheduled()`** — lists what is queued, newest delivery time first, with `ListScheduledEmailsFilter` (`status`, `perPage`, `page`) and the usual `pagination`. There was previously no way to ask what was scheduled.

- **`Contracts\SupportsDeleteWithResponse`** — cancelling returns the cancelled email, which `TransporterContract::delete(): void` cannot express. Widening that method would break every custom transporter, so this follows the same optional-capability pattern as `SupportsRequestHeaders`. A transporter that does not implement it still cancels correctly; the SDK reads the email back in a second request.

### Changed

- **`schedule()` returns `ScheduledEmail`** instead of `SendEmailResponse`. It previously reported `accepted`/`rejected` as if the email had been sent, which it has not been — those are now on `ScheduledEmail` alongside the state. Quota headers are not returned for a scheduled email.
- **`getScheduled()` returns `ScheduledEmail`** instead of `TransmissionDetail`. It also accepts a `RequestId`, like `find()`.
- **`cancelScheduled()` returns the cancelled `ScheduledEmail`** instead of `void`, so you can confirm the state without a second call. Existing code that ignores the return value is unaffected.
- **The scheduling window is now 5 minutes to 30 days**, up from 3 days. The old ceiling came from the sending provider, which no longer holds scheduled emails.

## [2.7.0] - 2026-09-09

Two additions: knowing when an imported template is actually ready, and not sending the same email twice. Everything is additive — code written against 2.6.0 keeps compiling and sends byte-identical requests.
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,57 @@ Both extend `ConflictException`, so existing handlers keep catching them.
| Retention | 24 hours |
| Scope | Per team **and** API key — the same string through a different API key is a different key |

### Scheduled Emails

Schedule an email for any time between 5 minutes and 30 days out. Lettr holds it until then, so it can be listed, read back and cancelled right up to the moment it is sent.

```php
use Lettr\Dto\Email\ListScheduledEmailsFilter;
use Lettr\Enums\ScheduledEmailState;

$scheduled = $lettr->emails()->schedule(
$lettr->emails()->create()
->from('sender@yourdomain.com')
->to(['recipient@example.com'])
->subject('Your weekly digest')
->html('<h1>This week</h1>')
->scheduledAt('2026-10-15T09:00:00Z')
);

$scheduled->requestId; // sch_01JQZ3... — use this to read it back or cancel it
$scheduled->state; // ScheduledEmailState::Scheduled
$scheduled->transmissionId; // null until the email is actually sent
```

**The two ids are not interchangeable.** `requestId` identifies the scheduled email for its whole life. `transmissionId` is the sending provider's id: it stays `null` until the email goes out, and it is the value that appears on your **webhook events**, so use that one to correlate them.

```php
// Read it back at any point — including after it was cancelled.
$scheduled = $lettr->emails()->getScheduled($scheduled->requestId);

if ($scheduled->isCancellable()) {
$cancelled = $lettr->emails()->cancelScheduled($scheduled->requestId);
$cancelled->state; // ScheduledEmailState::Cancelled
}

// List what is queued.
$page = $lettr->emails()->listScheduled(
ListScheduledEmailsFilter::create()
->status(ScheduledEmailState::Scheduled)
->perPage(25)
);

foreach ($page->scheduledEmails as $email) {
echo $email->requestId.' → '.$email->scheduledAt.PHP_EOL;
}

$page->hasMore();
```

States are `Scheduled`, `Sending`, `Sent`, `Cancelled` and `Failed`. Cancelling is only possible while `Scheduled`; once it is being sent or has been sent, `cancelScheduled()` throws a `ConflictException`. A `Failed` email carries a `failureReason`.

Once an email has been sent, `events` fills in from its delivery events — empty before that, and for a few minutes afterwards while they are indexed.

### 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:
Expand Down
11 changes: 10 additions & 1 deletion src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use JsonException;
use Lettr\Contracts\SupportsDeleteWithResponse;
use Lettr\Contracts\SupportsRequestHeaders;
use Lettr\Contracts\TransporterContract;
use Lettr\Dto\RateLimit;
Expand All @@ -28,7 +29,7 @@
/**
* HTTP Client for Lettr API.
*/
final class Client implements SupportsRequestHeaders, TransporterContract
final class Client implements SupportsDeleteWithResponse, SupportsRequestHeaders, TransporterContract
{
private readonly ClientInterface $httpClient;

Expand Down Expand Up @@ -127,6 +128,14 @@ public function delete(string $uri): void
$this->request('DELETE', $uri);
}

/**
* {@inheritDoc}
*/
public function deleteReturningBody(string $uri): array
{
return $this->request('DELETE', $uri);
}

/**
* {@inheritDoc}
*/
Expand Down
64 changes: 64 additions & 0 deletions src/Collections/ScheduledEmailCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Lettr\Collections;

use Lettr\Dto\Email\ScheduledEmail;
use Lettr\Enums\ScheduledEmailState;

/**
* Collection of scheduled emails.
*
* @extends Collection<ScheduledEmail>
*/
final readonly class ScheduledEmailCollection extends Collection
{
/**
* Get the first scheduled email in the collection.
*/
public function first(): ?ScheduledEmail
{
return $this->items[0] ?? null;
}

/**
* Find a scheduled email by its request ID (`sch_...`).
*/
public function findByRequestId(string $requestId): ?ScheduledEmail
{
foreach ($this->items as $scheduledEmail) {
if ($scheduledEmail->requestId->value === $requestId) {
return $scheduledEmail;
}
}

return null;
}

/**
* Filter scheduled emails by state.
*/
public function filterByState(ScheduledEmailState $state): self
{
return new self(
array_filter(
$this->items,
static fn (ScheduledEmail $scheduledEmail): bool => $scheduledEmail->state === $state
)
);
}

/**
* Only the emails that can still be cancelled.
*/
public function cancellable(): self
{
return new self(
array_filter(
$this->items,
static fn (ScheduledEmail $scheduledEmail): bool => $scheduledEmail->isCancellable()
)
);
}
}
34 changes: 34 additions & 0 deletions src/Contracts/SupportsDeleteWithResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Lettr\Contracts;

use Lettr\Exceptions\LettrException;

/**
* A transporter that can return the body of a DELETE response.
*
* {@see TransporterContract::delete()} returns void, and widening it would
* break every class implementing that interface — the same reasoning as
* {@see SupportsRequestHeaders}. Some endpoints answer a DELETE with the
* resource they just changed: cancelling a scheduled email returns that email
* with `state: cancelled`.
*
* A transporter that does not implement this keeps working untouched; callers
* fall back to `delete()` and fetch the resource separately, which costs one
* extra request.
*/
interface SupportsDeleteWithResponse
{
/**
* Send a DELETE request and return the decoded response body.
*
* A top-level `data` envelope is unwrapped, as with the other verbs.
*
* @return array<string, mixed>
*
* @throws LettrException
*/
public function deleteReturningBody(string $uri): array;
}
96 changes: 96 additions & 0 deletions src/Dto/Email/ListScheduledEmailsFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace Lettr\Dto\Email;

use Lettr\Contracts\Arrayable;
use Lettr\Enums\ScheduledEmailState;

/**
* Filter parameters for listing scheduled emails.
*/
final readonly class ListScheduledEmailsFilter implements Arrayable
{
public function __construct(
public ?ScheduledEmailState $status = null,
public ?int $perPage = null,
public ?int $page = null,
) {}

/**
* Create a new filter.
*/
public static function create(): self
{
return new self;
}

/**
* Only return scheduled emails in this state.
*/
public function status(ScheduledEmailState $status): self
{
return new self(
status: $status,
perPage: $this->perPage,
page: $this->page,
);
}

/**
* Set items per page (1-100).
*/
public function perPage(int $perPage): self
{
return new self(
status: $this->status,
perPage: $perPage,
page: $this->page,
);
}

/**
* Set the page number.
*/
public function page(int $page): self
{
return new self(
status: $this->status,
perPage: $this->perPage,
page: $page,
);
}

/**
* @return array<string, int|string>
*/
public function toArray(): array
{
$params = [];

if ($this->status !== null) {
$params['status'] = $this->status->value;
}

if ($this->perPage !== null) {
$params['per_page'] = $this->perPage;
}

if ($this->page !== null) {
$params['page'] = $this->page;
}

return $params;
}

/**
* Check if any filters are set.
*/
public function hasFilters(): bool
{
return $this->status !== null
|| $this->perPage !== null
|| $this->page !== null;
}
}
Loading
Loading