Skip to content

feat(scheduled): scheduled emails get their own type, a list method, and getScheduled works again - #19

Merged
voj-tech-j merged 1 commit into
mainfrom
feat/scheduled-emails-2.8.0
Sep 20, 2026
Merged

voj-tech-j merged 1 commit into
mainfrom
feat/scheduled-emails-2.8.0

Conversation

@voj-tech-j

Copy link
Copy Markdown
Contributor

Catches the SDK up with the scheduled-email API change. getScheduled() throws against the live API today, so this is a fix first and a feature second.

What broke

Two things in the new response could not fit the old TransmissionDetail:

transmissionId: $data['transmission_id'],        // now null until the email is sent → TypeError
state: TransmissionState::from($data['state']),  // 'sending' / 'sent' / 'cancelled' → ValueError

I confirmed this by running the released 2.7.0 code against the exact payloads the API now returns — a TypeError for an unsent email and a ValueError for a cancelled one. Every call to getScheduled() fails; there is no state that works.

schedule() and cancelScheduled() kept working, because they only read fields that still exist.

Why a new type instead of widening the old one

TransmissionDetail is also what find() returns for sent emails, and that endpoint is unchanged. Making its transmissionId nullable and adding enum cases would have pushed a string|null and new match arms onto users who never schedule anything.

So scheduled emails get their own ScheduledEmail and ScheduledEmailState. TransmissionDetail, TransmissionState and find() are untouched — anyone not scheduling emails sees no change.

Changes

New

  • Dto\Email\ScheduledEmail — both ids, state, accepted/rejected, tag, failureReason, events, plus isCancellable(), isSent(), isCancelled()
  • Enums\ScheduledEmailState — Scheduled, Sending, Sent, Cancelled, Failed
  • EmailService::listScheduled() for the new GET /emails/scheduled, with the filter, response, collection and pagination classes the other list endpoints use
  • Contracts\SupportsDeleteWithResponse

Changed — only the three scheduling methods. schedule(), getScheduled() and cancelScheduled() now return ScheduledEmail. cancelScheduled() previously returned void; callers ignoring the return value are unaffected.

The two ids

They answer different questions, and conflating them is the likeliest integration bug:

  • requestId (sch_...) identifies the scheduled email for its whole life. This is what getScheduled() and cancelScheduled() take.
  • transmissionId is the provider's id. null until the email is sent, and the value that appears on webhook events.

Anyone who stored the id from schedule() to match webhooks needs transmissionId from a later read. The changelog says so prominently.

One design note for review

Cancelling returns the cancelled email, but TransporterContract::delete() returns void and widening it would break every custom transporter. So this follows the pattern the package already documents for SupportsRequestHeaders: a separate optional interface, with a fallback that cancels and then reads the email back in a second request. Both paths have a test.

Verification

  • 303 tests pass, PHPStan and Pint clean.
  • Parses the OpenAPI spec's own examples for POST, GET and DELETE.
  • Verified end to end against a live API: schedule → read back → list → cancel → read back → second cancel refused with 409.
  • The example-php suite passes against production: schedule(), listScheduled() and getScheduled() all green (its cancel test is marked destructive and stays skipped). Test emails created during that run were cancelled afterwards.

Not in this PR

lettr-laravel allows ^2.7.0, so publishing 2.8.0 will pull it in there. Its test fake returns the old three-field array and would throw. That fix is ready separately and should land before this is tagged. Its own users are unaffected — /tests is export-ignore.

🤖 Generated with Claude Code

…fix for getScheduled

The API changed shape for scheduled emails and this catches the SDK up.
`getScheduled()` currently throws against the live API for every scheduled
email, so this is a fix first and a feature second.

Two things in the new response broke the old `TransmissionDetail`:

- `transmission_id` is null until the email is actually sent, which a
  non-nullable `string` property cannot hold (TypeError).
- `state` now uses `scheduled`, `sending`, `sent`, `cancelled` and `failed`.
  `sending`, `sent` and `cancelled` are not in `TransmissionState`
  (ValueError).

Rather than widen `TransmissionDetail`, which `find()` also returns and which
real users consume for sent emails, scheduled emails get their own
`ScheduledEmail` type and `ScheduledEmailState` enum. `TransmissionDetail`,
`TransmissionState` and `find()` are untouched, so anyone who does not
schedule emails sees no change at all.

`schedule()`, `getScheduled()` and `cancelScheduled()` now return
`ScheduledEmail`. `cancelScheduled()` previously returned void; callers that
ignore the return value are unaffected.

Also adds `listScheduled()` for the new `GET /emails/scheduled`, with the
filter, response, collection and pagination classes the other list endpoints
use.

Cancelling returns the cancelled email, which `TransporterContract::delete()`
cannot express because it returns void. Widening that method would break every
custom transporter, so this follows the `SupportsRequestHeaders` pattern: a
separate optional `SupportsDeleteWithResponse` interface, with a fallback that
cancels and then reads the email back.

The two ids are deliberately distinct on the DTO. `requestId` (`sch_...`)
identifies the scheduled email for its whole life; `transmissionId` is the
provider's id, null until the email is sent, and the value that appears on
webhook events.

Verified end to end against a live API: schedule, read back, list, cancel,
read back again, and a second cancel refused with 409.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@voj-tech-j
voj-tech-j merged commit 76b715f into main Sep 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant