diff --git a/CHANGELOG.md b/CHANGELOG.md index 09304fd..e625d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/src/Dto/Email/ScheduledEmail.php b/src/Dto/Email/ScheduledEmail.php index 90e4578..82f49e4 100644 --- a/src/Dto/Email/ScheduledEmail.php +++ b/src/Dto/Email/ScheduledEmail.php @@ -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. @@ -57,10 +60,16 @@ public static function from(array $data): self /** @var array $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, diff --git a/src/Enums/ScheduledEmailState.php b/src/Enums/ScheduledEmailState.php index 162d3ba..dcb303b 100644 --- a/src/Enums/ScheduledEmailState.php +++ b/src/Enums/ScheduledEmailState.php @@ -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 { @@ -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, }; } } diff --git a/src/Lettr.php b/src/Lettr.php index 37d3c6b..ef55e30 100644 --- a/src/Lettr.php +++ b/src/Lettr.php @@ -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. diff --git a/tests/Unit/LettrTest.php b/tests/Unit/LettrTest.php index 8ca9c43..5740a97 100644 --- a/tests/Unit/LettrTest.php +++ b/tests/Unit/LettrTest.php @@ -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 { diff --git a/tests/Unit/Services/EmailServiceTest.php b/tests/Unit/Services/EmailServiceTest.php index a977ea3..553c477 100644 --- a/tests/Unit/Services/EmailServiceTest.php +++ b/tests/Unit/Services/EmailServiceTest.php @@ -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(); +});