Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ 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.1] - 2026-09-20

### Fixed

- **`getScheduled()` threw for a legacy transmission id.** An id handed out before Lettr held scheduled emails itself is answered from delivery events, in a shape that has no `sch_` id, and 2.8.0 required one — so it threw `InvalidValueException: Request ID cannot be empty.` `requestId` now falls back to the transmission id in that response, so it is always the id that addresses the email you asked about.

2.8.0 fixed reading back `sch_` ids and broke this path in the same change. Only that one path is affected; everything else in 2.8.0 is unchanged.

- **`getScheduled()` threw for a legacy transmission id a second way.** The same response reports the *provider's* states, not Lettr's — a delivered email reads back `delivered`, which is not one of the five `ScheduledEmailState` cases, so `ScheduledEmailState::from()` threw `ValueError`. Confirmed against the live API, which answers `state: "delivered"` for an id with delivery events.

`ScheduledEmailState` now also carries `Submitted`, `Generating`, `Delivered` and `Bounced`, marked deprecated because only that legacy path can produce them, plus `Unknown`. An unrecognised value resolves to `Unknown` rather than throwing, so a state the API adds later cannot break reads again.

## [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`.
Expand Down
15 changes: 12 additions & 3 deletions src/Dto/Email/ScheduledEmail.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
* Two ids, and they are not interchangeable:
*
* - `requestId` (`sch_...`) identifies the scheduled email for its whole life
* and is what `getScheduled()` and `cancelScheduled()` take.
* and is what `getScheduled()` and `cancelScheduled()` take. Reading back a
* legacy provider transmission id answers from delivery events, and this
* then carries that transmission id - so it is always the id that addresses
* the email you asked about.
* - `transmissionId` is the sending provider's id. It is `null` until the
* email is actually sent, and it is the value that appears on webhook
* events, so use it to correlate them.
Expand Down Expand Up @@ -57,10 +60,16 @@ public static function from(array $data): self
/** @var array<string> $recipients */
$recipients = $data['recipients'] ?? [];

// Reading back a legacy provider transmission id - one handed out
// before Lettr held scheduled emails itself - is answered from
// delivery events, in a shape that has no `sch_` id. The id the caller
// addressed it with is the transmission id, so fall back to that.
$requestId = (string) ($data['request_id'] ?? $data['transmission_id'] ?? '');

return new self(
requestId: new RequestId((string) $data['request_id']),
requestId: new RequestId($requestId),
transmissionId: isset($data['transmission_id']) ? (string) $data['transmission_id'] : null,
state: ScheduledEmailState::from((string) $data['state']),
state: ScheduledEmailState::fromWire((string) $data['state']),
scheduledAt: (string) $data['scheduled_at'],
from: (string) $data['from'],
fromName: isset($data['from_name']) ? (string) $data['from_name'] : null,
Expand Down
39 changes: 37 additions & 2 deletions src/Enums/ScheduledEmailState.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,39 @@ enum ScheduledEmailState: string
/** Sending was attempted and gave up. See `failureReason`. */
case Failed = 'failed';

/*
* The states below are the sending provider's, not Lettr's. Reading back a
* legacy provider transmission id is answered from delivery events, which
* report the provider's vocabulary - so these arrive on that path only, and
* never on a `sch_` id.
*/

/** @deprecated Legacy provider state. */
case Submitted = 'submitted';

/** @deprecated Legacy provider state. */
case Generating = 'generating';

/** @deprecated Legacy provider state. */
case Delivered = 'delivered';

/** @deprecated Legacy provider state. */
case Bounced = 'bounced';

/** A state this version of the SDK does not know. */
case Unknown = 'unknown';

/**
* Resolve a wire value, without throwing on one we do not know.
*
* A state the API adds later must not turn every read into a `ValueError`,
* so an unrecognised value becomes {@see self::Unknown}.
*/
public static function fromWire(string $value): self
{
return self::tryFrom($value) ?? self::Unknown;
}

/** Whether the email can still be cancelled. */
public function isCancellable(): bool
{
Expand All @@ -39,8 +72,10 @@ public function isCancellable(): bool
public function isTerminal(): bool
{
return match ($this) {
self::Sent, self::Cancelled, self::Failed => true,
self::Scheduled, self::Sending => false,
self::Sent, self::Cancelled, self::Failed,
self::Delivered, self::Bounced => true,
self::Scheduled, self::Sending,
self::Submitted, self::Generating, self::Unknown => false,
};
}
}
2 changes: 1 addition & 1 deletion src/Lettr.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class Lettr
* single source of truth for the version and will be removed in
* a future major release.
*/
public const VERSION = '2.8.0';
public const VERSION = '2.8.1';

/**
* The API base URL.
Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/LettrTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
})->throws(InvalidArgumentException::class, 'Unknown service: unknownService');

test('has correct version constant', function (): void {
expect(Lettr::VERSION)->toBe('2.8.0');
expect(Lettr::VERSION)->toBe('2.8.1');
});

test('has correct base url constant', function (): void {
Expand Down
50 changes: 50 additions & 0 deletions tests/Unit/Services/EmailServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -614,3 +614,53 @@
'GET emails/scheduled/sch_01JQZ3N2K8XW9V6M4TBRC7YHDE',
])->and($cancelled->state)->toBe(ScheduledEmailState::Cancelled);
});

test('getScheduled still reads back a legacy transmission id', function (): void {
// Ids handed out before Lettr held scheduled emails itself are answered
// from delivery events, in the older shape: no request_id, no
// accepted/rejected/tag, and the provider's own states.
$transporter = new MockTransporter;
$transporter->response = [
'transmission_id' => '7686140844331501179',
// A state only that path reports. Lettr never says 'delivered' about a
// scheduled email - once it is sent, delivery lives on the events.
'state' => 'delivered',
'scheduled_at' => '2026-09-16T15:00:00+00:00',
'from' => 'sender@example.com',
'from_name' => 'Sender Name',
'subject' => 'Scheduled Newsletter',
'recipients' => ['r@example.com'],
'num_recipients' => 1,
'events' => [],
];

$scheduled = (new EmailService($transporter))->getScheduled('7686140844331501179');

expect($scheduled->transmissionId)->toBe('7686140844331501179')
// No sch_ id in this shape, so it falls back to the id used to ask.
->and((string) $scheduled->requestId)->toBe('7686140844331501179')
->and($scheduled->state)->toBe(ScheduledEmailState::Delivered)
->and($scheduled->accepted)->toBe(0)
->and($scheduled->tag)->toBeNull();
});

test('an unrecognised state reads back as Unknown rather than throwing', function (): void {
// A state the API adds after this version ships must not turn every read
// into a ValueError.
$transporter = new MockTransporter;
$transporter->response = [
'request_id' => 'sch_01JQZ3N2K8XW9V6M4TBRC7YHDE',
'transmission_id' => null,
'state' => 'a_state_from_the_future',
'scheduled_at' => '2026-09-16T15:00:00+00:00',
'from' => 'sender@example.com',
'recipients' => ['r@example.com'],
'num_recipients' => 1,
'events' => [],
];

$scheduled = (new EmailService($transporter))->getScheduled('sch_01JQZ3N2K8XW9V6M4TBRC7YHDE');

expect($scheduled->state)->toBe(ScheduledEmailState::Unknown)
->and($scheduled->state->isCancellable())->toBeFalse();
});
Loading