From 62503da6fe2e9305e46b32c591f5f3c47d576df2 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Wed, 26 Aug 2026 23:59:57 +0200 Subject: [PATCH] feat(api): support scheduled message delivery --- src/Client/HttpClient.php | 10 ++++++++++ src/Endpoints/EmailEndpoint.php | 8 ++++++++ src/Endpoints/Endpoint.php | 10 ++++++++++ src/Endpoints/MessagesEndpoint.php | 12 ++++++++++++ src/Objects/MessageData.php | 15 ++++++++------- src/Objects/MessageEventData.php | 2 +- src/Objects/MessageListData.php | 3 ++- src/Objects/SendMailRequest.php | 1 + src/Responses/ScheduledMessageResponse.php | 15 +++++++++++++++ src/Responses/SendMailResponse.php | 3 ++- src/Types/ApiTypes.php | 14 ++++++++------ tests/Endpoints/MessagesEndpointTest.php | 8 ++++++++ tests/SdkCoverageTest.php | 4 +++- 13 files changed, 88 insertions(+), 17 deletions(-) create mode 100644 src/Responses/ScheduledMessageResponse.php diff --git a/src/Client/HttpClient.php b/src/Client/HttpClient.php index e8b37ce..3457fa2 100644 --- a/src/Client/HttpClient.php +++ b/src/Client/HttpClient.php @@ -78,6 +78,16 @@ public function put(string $path, array $data, array $headers = []): mixed return $this->request('put', $path, ['json' => $data], $headers); } + /** + * @param RequestBody $data + * @param RequestHeaders $headers + * @return ApiResponse + */ + public function patch(string $path, array $data, array $headers = []): mixed + { + return $this->request('patch', $path, ['json' => $data], $headers); + } + /** * @param RequestBody $query Query parameters * @param RequestHeaders $headers Additional headers for this request diff --git a/src/Endpoints/EmailEndpoint.php b/src/Endpoints/EmailEndpoint.php index a248b15..9355149 100644 --- a/src/Endpoints/EmailEndpoint.php +++ b/src/Endpoints/EmailEndpoint.php @@ -105,6 +105,14 @@ public function subject(string $subject): self return $this; } + /** Set the requested delivery time for the email. */ + public function scheduledAt(string $scheduledAt): self + { + $this->payload['scheduled_at'] = $scheduledAt; + + return $this; + } + /** * Set the HTML body of the email. * diff --git a/src/Endpoints/Endpoint.php b/src/Endpoints/Endpoint.php index 226ef58..6cefdd2 100644 --- a/src/Endpoints/Endpoint.php +++ b/src/Endpoints/Endpoint.php @@ -61,6 +61,16 @@ protected function putArray(string $path, array $data = [], array $headers = []) return $this->expectArray($this->httpClient->put($path, $data, $headers)); } + /** + * @param array $data + * @param array $headers + * @return array + */ + protected function patchArray(string $path, array $data = [], array $headers = []): array + { + return $this->expectArray($this->httpClient->patch($path, $data, $headers)); + } + /** * @param array $query * @return array diff --git a/src/Endpoints/MessagesEndpoint.php b/src/Endpoints/MessagesEndpoint.php index 3ed537e..6d0a1de 100644 --- a/src/Endpoints/MessagesEndpoint.php +++ b/src/Endpoints/MessagesEndpoint.php @@ -5,6 +5,7 @@ use Lettermint\Responses\MessageEventsResponse; use Lettermint\Responses\MessageListResponse; use Lettermint\Responses\MessageResponse; +use Lettermint\Responses\ScheduledMessageResponse; class MessagesEndpoint extends Endpoint { @@ -18,6 +19,17 @@ public function retrieve(string $messageId): MessageResponse return $this->hydrate(MessageResponse::class, $this->getArray($this->path('/messages/{messageId}', ['messageId' => $messageId]), [])); } + /** @param array{scheduled_at: string} $data */ + public function reschedule(string $messageId, array $data): ScheduledMessageResponse + { + return $this->hydrate(ScheduledMessageResponse::class, $this->patchArray($this->path('/messages/{messageId}', ['messageId' => $messageId]), $data)); + } + + public function cancel(string $messageId): ScheduledMessageResponse + { + return $this->hydrate(ScheduledMessageResponse::class, $this->postArray($this->path('/messages/{messageId}/cancel', ['messageId' => $messageId]))); + } + public function events(string $messageId, array $query = []): MessageEventsResponse { return $this->hydrate(MessageEventsResponse::class, $this->getArray($this->path('/messages/{messageId}/events', ['messageId' => $messageId]), $query)); diff --git a/src/Objects/MessageData.php b/src/Objects/MessageData.php index 82f1c88..b1fc4fc 100644 --- a/src/Objects/MessageData.php +++ b/src/Objects/MessageData.php @@ -7,26 +7,27 @@ /** * @property string $id * @property 'inbound'|'outbound' $type - * @property 'pending'|'queued'|'suppressed'|'processed'|'delivered'|'opened'|'clicked'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed' $status + * @property 'scheduled'|'pending'|'queued'|'suppressed'|'processed'|'delivered'|'opened'|'clicked'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed'|'canceled' $status * @property string|null $status_changed_at + * @property string|null $scheduled_at * @property string|null $tag * @property string $from_email * @property string|null $from_name * @property list|null $reply_to * @property string|null $subject - * @property list<\Lettermint\Objects\MessageRecipientData>|null $to - * @property list<\Lettermint\Objects\MessageRecipientData>|null $cc - * @property list<\Lettermint\Objects\MessageRecipientData>|null $bcc - * @property list<\Lettermint\Objects\MessageAttachmentData>|null $attachments + * @property list|null $to + * @property list|null $cc + * @property list|null $bcc + * @property list|null $attachments * @property array|null $metadata * @property float|int|null $spam_score - * @property list<\Lettermint\Objects\SpamSymbol> $spam_symbols + * @property list $spam_symbols * @property string $route_id * @property string $created_at */ final class MessageData extends Resource { protected static array $casts = [ - 'spam_symbols' => [\Lettermint\Objects\SpamSymbol::class], + 'spam_symbols' => [SpamSymbol::class], ]; } diff --git a/src/Objects/MessageEventData.php b/src/Objects/MessageEventData.php index fa42513..2410b57 100644 --- a/src/Objects/MessageEventData.php +++ b/src/Objects/MessageEventData.php @@ -6,7 +6,7 @@ /** * @property string $message_id - * @property 'queued'|'processed'|'suppressed'|'delivered'|'auto_replied'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed'|'opened'|'clicked'|'inbound_received'|'inbound_queued'|'inbound_spam_blocked'|'inbound_processed'|'inbound_retry' $event + * @property 'scheduled'|'rescheduled'|'canceled'|'released'|'queued'|'processed'|'suppressed'|'delivered'|'auto_replied'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed'|'opened'|'clicked'|'inbound_received'|'inbound_queued'|'inbound_spam_blocked'|'inbound_processed'|'inbound_retry' $event * @property array|null $metadata * @property string $timestamp */ diff --git a/src/Objects/MessageListData.php b/src/Objects/MessageListData.php index 248f281..b23959f 100644 --- a/src/Objects/MessageListData.php +++ b/src/Objects/MessageListData.php @@ -7,7 +7,8 @@ /** * @property string $id * @property 'inbound'|'outbound' $type - * @property 'pending'|'queued'|'suppressed'|'processed'|'delivered'|'opened'|'clicked'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed' $status + * @property 'scheduled'|'pending'|'queued'|'suppressed'|'processed'|'delivered'|'opened'|'clicked'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed'|'canceled' $status + * @property string|null $scheduled_at * @property float|int|null $spam_score * @property string $from_email * @property string|null $from_name diff --git a/src/Objects/SendMailRequest.php b/src/Objects/SendMailRequest.php index b439e89..4efc4d4 100644 --- a/src/Objects/SendMailRequest.php +++ b/src/Objects/SendMailRequest.php @@ -8,6 +8,7 @@ * @property string $route * @property string $from * @property string $subject + * @property string $scheduled_at * @property string|null $tag * @property string|null $html * @property string|null $text diff --git a/src/Responses/ScheduledMessageResponse.php b/src/Responses/ScheduledMessageResponse.php new file mode 100644 index 0000000..91f5859 --- /dev/null +++ b/src/Responses/ScheduledMessageResponse.php @@ -0,0 +1,15 @@ + * @phpstan-type CursorPage array{data: list, path: string|null, per_page: int, next_cursor: string|null, next_page_url: string|null, prev_cursor: string|null, prev_page_url: string|null} - * @phpstan-type MessageStatus 'pending'|'queued'|'suppressed'|'processed'|'delivered'|'opened'|'clicked'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed' - * @phpstan-type SendMailRequest array{route?: string, from: string, to: non-empty-list, cc?: list, bcc?: list, reply_to?: list, subject: string, headers?: array, metadata?: array, tag?: string|null, tags?: list, settings?: array{track_opens?: bool, track_clicks?: bool, tls?: TlsPolicy}|null, html?: string|null, text?: string|null, attachments?: list} + * @phpstan-type MessageStatus 'scheduled'|'pending'|'queued'|'suppressed'|'processed'|'delivered'|'opened'|'clicked'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed'|'canceled' + * @phpstan-type SendMailRequest array{route?: string, from: string, to: non-empty-list, cc?: list, bcc?: list, reply_to?: list, subject: string, scheduled_at?: string, headers?: array, metadata?: array, tag?: string|null, tags?: list, settings?: array{track_opens?: bool, track_clicks?: bool, tls?: TlsPolicy}|null, html?: string|null, text?: string|null, attachments?: list} * @phpstan-type SendBatchMailRequest list * @phpstan-type TlsPolicy 'opportunistic'|'enforced' * @phpstan-type AttachmentDelivery 'inline'|'url' @@ -24,10 +24,10 @@ * @phpstan-type DomainStatus 'verified'|'partially_verified'|'pending_verification'|'failed_verification' * @phpstan-type InitialRoutes 'both'|'transactional'|'broadcast' * @phpstan-type MessageAttachmentData array{size: int, filename: string, content_id: string|null, content_type: string} - * @phpstan-type MessageData array{id: string, type: MessageType, status: MessageStatus, status_changed_at: string|null, tag: string|null, tags: list, from_email: string, from_name: string|null, reply_to: list|null, subject: string|null, to: list|null, cc: list|null, bcc: list|null, attachments: list|null, metadata: array|null, spam_score?: float|int|null, spam_symbols?: list, route_id: string, created_at: string} + * @phpstan-type MessageData array{id: string, type: MessageType, status: MessageStatus, status_changed_at: string|null, scheduled_at: string|null, tag: string|null, tags: list, from_email: string, from_name: string|null, reply_to: list|null, subject: string|null, to: list|null, cc: list|null, bcc: list|null, attachments: list|null, metadata: array|null, spam_score?: float|int|null, spam_symbols?: list, route_id: string, created_at: string} * @phpstan-type MessageEventData array{message_id: string, event: MessageEventType, tag: string|null, tags: list, metadata: array|null, timestamp: string} - * @phpstan-type MessageEventType 'queued'|'processed'|'suppressed'|'delivered'|'auto_replied'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed'|'opened'|'clicked'|'inbound_received'|'inbound_queued'|'inbound_spam_blocked'|'inbound_processed'|'inbound_retry' - * @phpstan-type MessageListData array{id: string, type: MessageType, status: MessageStatus, spam_score?: float|int|null, from_email: string, from_name: string|null, subject: string|null, to: list|null, cc: list|null, bcc: list|null, reply_to: list|null, tag: string|null, tags: list, status_changed_at: string|null, created_at: string} + * @phpstan-type MessageEventType 'scheduled'|'rescheduled'|'canceled'|'released'|'queued'|'processed'|'suppressed'|'delivered'|'auto_replied'|'soft_bounced'|'hard_bounced'|'spam_complaint'|'failed'|'blocked'|'policy_rejected'|'unsubscribed'|'opened'|'clicked'|'inbound_received'|'inbound_queued'|'inbound_spam_blocked'|'inbound_processed'|'inbound_retry' + * @phpstan-type MessageListData array{id: string, type: MessageType, status: MessageStatus, scheduled_at: string|null, spam_score?: float|int|null, from_email: string, from_name: string|null, subject: string|null, to: list|null, cc: list|null, bcc: list|null, reply_to: list|null, tag: string|null, tags: list, status_changed_at: string|null, created_at: string} * @phpstan-type MessageRecipientData array{email: string, name: string|null} * @phpstan-type MessageStatsData array{messages_transactional: int, messages_broadcast: int, messages_inbound: int, deliverability: float|int} * @phpstan-type MessageType 'inbound'|'outbound' @@ -82,8 +82,10 @@ * @phpstan-type WebhookEvent 'message.created'|'message.sent'|'message.delivered'|'message.auto_replied'|'message.hard_bounced'|'message.soft_bounced'|'message.spam_complaint'|'message.failed'|'message.suppressed'|'message.unsubscribed'|'message.opened'|'message.clicked'|'message.inbound'|'message.policy_rejected'|'suppression.added'|'suppression.removed'|'webhook.test' * @phpstan-type WebhookListData array{id: string, route_id: string, name: string, url: string, events: list, enabled: bool, last_called_at: string|null, created_at: string, updated_at: string} * @phpstan-type StatsQuery StatsRequestData - * @phpstan-type SendMailResponse array{message_id: string, status: MessageStatus} + * @phpstan-type SendMailResponse array{message_id: string|null, status: MessageStatus, scheduled_at?: string} * @phpstan-type SendBatchMailResponse list + * @phpstan-type RescheduleMessageRequest array{scheduled_at: string} + * @phpstan-type RescheduleMessageResponse array{message_id: string, status: MessageStatus|null, scheduled_at: string|null} * @phpstan-type DomainListResponse array{data: list, path: string|null, per_page: int, next_cursor: string|null, next_page_url: string|null, prev_cursor: string|null, prev_page_url: string|null} * @phpstan-type DomainResponse DomainData * @phpstan-type DeleteDomainResponse array{message: string} diff --git a/tests/Endpoints/MessagesEndpointTest.php b/tests/Endpoints/MessagesEndpointTest.php index c1f0044..41fd84c 100644 --- a/tests/Endpoints/MessagesEndpointTest.php +++ b/tests/Endpoints/MessagesEndpointTest.php @@ -25,6 +25,14 @@ expect($this->endpoint->retrieve('message-id')->toArray())->toBe(['data' => ['id' => 'message-id']]); }); +test('it reschedules and cancels scheduled messages', function () { + $this->httpClient->shouldReceive('patch')->once()->with('/v1/messages/message-id', ['scheduled_at' => '2026-08-27T09:00:00Z'], [])->andReturn(['message_id' => 'message-id', 'status' => 'scheduled', 'scheduled_at' => '2026-08-27T09:00:00Z']); + $this->httpClient->shouldReceive('post')->once()->with('/v1/messages/message-id/cancel', [], [])->andReturn(['message_id' => 'message-id', 'status' => 'canceled', 'scheduled_at' => null]); + + expect($this->endpoint->reschedule('message-id', ['scheduled_at' => '2026-08-27T09:00:00Z'])->status)->toBe('scheduled'); + expect($this->endpoint->cancel('message-id')->status)->toBe('canceled'); +}); + test('it retrieves message events', function () { $query = ['include_machine_events' => true]; $this->httpClient->shouldReceive('get')->once()->with('/v1/messages/message-id/events', $query)->andReturn(['data' => []]); diff --git a/tests/SdkCoverageTest.php b/tests/SdkCoverageTest.php index ecf10e7..d6c8452 100644 --- a/tests/SdkCoverageTest.php +++ b/tests/SdkCoverageTest.php @@ -27,6 +27,8 @@ ['team', 'v1.blockedFileTypes', ApiClient::class, 'blockedFileTypes'], ['team', 'message.index', MessagesEndpoint::class, 'list'], ['team', 'message.show', MessagesEndpoint::class, 'retrieve'], + ['team', 'rescheduleMessage', MessagesEndpoint::class, 'reschedule'], + ['team', 'cancelScheduledMessage', MessagesEndpoint::class, 'cancel'], ['team', 'message.events', MessagesEndpoint::class, 'events'], ['team', 'message.source', MessagesEndpoint::class, 'source'], ['team', 'message.html', MessagesEndpoint::class, 'html'], @@ -65,7 +67,7 @@ ['team', 'webhook.showDelivery', WebhooksEndpoint::class, 'delivery'], ]; - expect($operations)->toHaveCount(50); + expect($operations)->toHaveCount(52); $missing = [];