diff --git a/.gitignore b/.gitignore index 4774310..36a0777 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ testbench.yaml composer.lock openapi.json CLAUDE.md +AGENTS.md +.DS_Store diff --git a/README.md b/README.md index 1bc13be..5026cbe 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,21 @@ composer require lettermint/lettermint-php ## Usage -Initialize the Lettermint client with your API token: +Initialize the Lettermint client with your Sending API token: ```php $lettermint = new Lettermint\Lettermint('your-api-token'); ``` +For new integrations, prefer explicit clients for the two API surfaces: + +```php +$email = Lettermint\Lettermint::email(getenv('LETTERMINT_SENDING_TOKEN')); +$api = Lettermint\Lettermint::api(getenv('LETTERMINT_API_TOKEN')); +``` + +Sending API tokens are project-specific and authenticate with the `x-lettermint-token` header. API tokens are team-scoped and authenticate with `Authorization: Bearer ...`. Keep these tokens separate and never reuse an API token for sending-only workloads. + ### Sending Emails The SDK provides a fluent interface for composing and sending emails: @@ -63,6 +72,36 @@ $lettermint->email ->send(); ``` +You can also send with an array payload: + +```php +$response = $email->send([ + 'from' => 'sender@example.com', + 'to' => ['recipient@example.com'], + 'subject' => 'Hello from Lettermint!', + 'text' => 'Hello! This is a test email.', +]); +``` + +### Batch Sending + +```php +$response = $email->sendBatch([ + [ + 'from' => 'sender@example.com', + 'to' => ['recipient@example.com'], + 'subject' => 'First email', + 'text' => 'Hello!', + ], + [ + 'from' => 'sender@example.com', + 'to' => ['another@example.com'], + 'subject' => 'Second email', + 'text' => 'Hello again!', + ], +]); +``` + #### Inline Attachments You can embed images and other content in your HTML emails using content IDs: @@ -96,6 +135,48 @@ same request with the same idempotency key, the API will return the same respons For more information, refer to the [documentation](https://docs.lettermint.co/platform/emails/idempotency). +### API Client + +Use the API client for team-scoped resources such as projects, domains, routes, suppressions, stats, messages, and webhooks: + +```php +$api = Lettermint\Lettermint::api(getenv('LETTERMINT_API_TOKEN')); + +$projects = $api->projects->list(['filter[search]' => 'production']); + +$project = $api->projects->create([ + 'name' => 'Production', + 'smtp_enabled' => false, +]); + +$api->domains->verifyDnsRecords('domain-id'); + +$stats = $api->stats->retrieve([ + 'from' => '2026-05-01', + 'to' => '2026-05-09', +]); + +$api->suppressions->create([ + 'email' => 'user@example.com', + 'reason' => 'manual', + 'scope' => 'team', +]); + +$api->webhooks->create([ + 'route_id' => 'route-id', + 'name' => 'Production webhook', + 'url' => 'https://example.com/lettermint/webhook', + 'events' => ['message.sent', 'message.delivered'], +]); +``` + +Both API surfaces support `ping()`: + +```php +$email->ping(); +$api->ping(); +``` + ## Testing ```bash @@ -106,6 +187,10 @@ composer test Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. +## Upgrading + +Please see [UPGRADE.md](UPGRADE.md) for guidance on upgrading from v1 to v2. + ## Contributing Please see [CONTRIBUTING](CONTRIBUTING.md) for details. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..14026da --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,156 @@ +# Upgrade Guide + +## Upgrading from v1 to v2 + +Version 2 changes the PHP SDK response model for the sending API. The latest released v1 SDK only exposed email sending, so this guide focuses on migrating existing sending integrations. + +### 1. Update Composer + +```bash +composer require lettermint/lettermint-php:^2.0 +``` + +### 2. Prefer the new email client entry point + +The v1 constructor-based style still maps to the email endpoint, but v2 introduces a clearer sending entry point: + +```php +$email = Lettermint\Lettermint::email($sendingToken); +``` + +Before: + +```php +$lettermint = new Lettermint\Lettermint($sendingToken); + +$response = $lettermint->email + ->from('sender@example.com') + ->to('recipient@example.com') + ->subject('Hello') + ->send(); +``` + +After: + +```php +$email = Lettermint\Lettermint::email($sendingToken); + +$response = $email + ->from('sender@example.com') + ->to('recipient@example.com') + ->subject('Hello') + ->send(); +``` + +Direct payload sending changes the same way: + +```php +$response = $email->send([ + 'from' => 'sender@example.com', + 'to' => ['recipient@example.com'], + 'subject' => 'Hello', +]); +``` + +Batch sending: + +```php +$response = $email->sendBatch([ + [ + 'from' => 'sender@example.com', + 'to' => ['recipient@example.com'], + 'subject' => 'Hello', + ], +]); +``` + +### 3. Update response handling + +Sending responses are now typed resource objects with IDE autocomplete. + +Before: + +```php +$response = $lettermint->email->send(); + +$messageId = $response['message_id']; +$status = $response['status']; +``` + +After: + +```php +$response = $email->send(); + +$messageId = $response->message_id; +$status = $response->status; +``` + +Array access is still available: + +```php +$messageId = $response['message_id']; +``` + +Use `toArray()` when passing responses to existing array-based code: + +```php +$payload = $response->toArray(); +``` + +Batch responses are also typed: + +```php +$response = $email->sendBatch($messages); + +$firstMessageId = $response->data[0]->message_id; +``` + +To keep old array-style processing: + +```php +$response = $email->sendBatch($messages)->toArray(); + +$firstMessageId = $response['data'][0]['message_id']; +``` + +### 4. Update ping checks + +`ping()` now returns the raw API ping response as a string. + +Before: + +```php +if ($lettermint->email->ping() === 200) { + // Sending API reachable +} +``` + +After: + +```php +if ($email->ping() === 'pong') { + // Sending API reachable +} +``` + +### 5. Search and replace checklist + +Search your codebase for: + +```text +new Lettermint\Lettermint( +->email +['message_id'] +['status'] +sendBatch( +ping() === 200 +``` + +Then update response handling to use typed properties or `toArray()`. + +### Notes + +The main migration risk is code that assumes SDK responses are arrays. Most of that code can be migrated by either using property access or appending `->toArray()` at the SDK boundary. + +Version 2 also adds a new full API client via `Lettermint::api($apiToken)`, but this is new functionality rather than a migration requirement from v1. diff --git a/composer.json b/composer.json index 0fa8524..418b9e4 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "analyse": "vendor/bin/phpstan analyse", "test": "vendor/bin/pest", "test-coverage": "vendor/bin/pest --coverage", - "format": "php-cs-fixer fix" + "format": "pint" }, "config": { "sort-packages": true, diff --git a/src/Client/ApiClient.php b/src/Client/ApiClient.php new file mode 100644 index 0000000..c3d7898 --- /dev/null +++ b/src/Client/ApiClient.php @@ -0,0 +1,70 @@ + DomainsEndpoint::class, + 'messages' => MessagesEndpoint::class, + 'projects' => ProjectsEndpoint::class, + 'routes' => RoutesEndpoint::class, + 'stats' => StatsEndpoint::class, + 'suppressions' => SuppressionsEndpoint::class, + 'team' => TeamEndpoint::class, + 'webhooks' => WebhooksEndpoint::class, + ]; + + public function __construct(string $apiToken, ?string $baseUrl = null) + { + $this->httpClient = new HttpClient( + new TeamBearerTokenAuth($apiToken), + $baseUrl ?? 'https://api.lettermint.co/v1' + ); + } + + public function __get($name) + { + if (isset($this->endpoints[$name])) { + return $this->endpoints[$name]; + } + + if (array_key_exists($name, $this->endpointRegistry)) { + $class = $this->endpointRegistry[$name]; + $this->endpoints[$name] = new $class($this->httpClient); + + return $this->endpoints[$name]; + } + + throw new \InvalidArgumentException("Unknown endpoint: $name"); + } + + public function ping(): string + { + return trim($this->httpClient->getRaw('/v1/ping')); + } +} diff --git a/src/Client/Auth/AuthStrategy.php b/src/Client/Auth/AuthStrategy.php new file mode 100644 index 0000000..1c3c0ad --- /dev/null +++ b/src/Client/Auth/AuthStrategy.php @@ -0,0 +1,13 @@ + + */ + public function headers(): array; + + public function token(): string; +} diff --git a/src/Client/Auth/SendingApiTokenAuth.php b/src/Client/Auth/SendingApiTokenAuth.php new file mode 100644 index 0000000..dc353e6 --- /dev/null +++ b/src/Client/Auth/SendingApiTokenAuth.php @@ -0,0 +1,18 @@ + $this->apiToken]; + } + + public function token(): string + { + return $this->apiToken; + } +} diff --git a/src/Client/Auth/TeamBearerTokenAuth.php b/src/Client/Auth/TeamBearerTokenAuth.php new file mode 100644 index 0000000..66d0e4e --- /dev/null +++ b/src/Client/Auth/TeamBearerTokenAuth.php @@ -0,0 +1,18 @@ + 'Bearer '.$this->apiToken]; + } + + public function token(): string + { + return $this->apiToken; + } +} diff --git a/src/Client/HttpClient.php b/src/Client/HttpClient.php index 55b4a45..301488c 100644 --- a/src/Client/HttpClient.php +++ b/src/Client/HttpClient.php @@ -5,30 +5,32 @@ use Composer\InstalledVersions; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; +use Lettermint\Client\Auth\AuthStrategy; +use Lettermint\Client\Auth\SendingApiTokenAuth; /** * @phpstan-type RequestHeaders array - * @phpstan-type RequestBody array - * @phpstan-type ApiResponse array + * @phpstan-type RequestBody array + * @phpstan-type ApiResponse array|int|string|bool|null */ class HttpClient { - private string $apiToken; + private AuthStrategy $auth; private string $baseUrl; private Client $client; - public function __construct(string $apiToken, string $baseUrl) + public function __construct(AuthStrategy|string $auth, string $baseUrl) { - $this->apiToken = $apiToken; + $this->auth = is_string($auth) ? new SendingApiTokenAuth($auth) : $auth; $this->baseUrl = rtrim($baseUrl, '/'); $this->client = new Client([ 'base_uri' => $this->baseUrl, 'timeout' => 15, 'headers' => [ 'Content-Type' => 'application/json', - 'x-lettermint-token' => $this->apiToken, + ...$this->auth->headers(), 'User-Agent' => sprintf( 'Lettermint/%s (PHP; PHP %s)', InstalledVersions::getPrettyVersion('lettermint/lettermint-php') ?? 'unknown', @@ -47,16 +49,95 @@ public function __construct(string $apiToken, string $baseUrl) * * @throws \Exception On HTTP or decode failure */ - public function post(string $path, array $data, array $headers = []): array + public function post(string $path, array $data, array $headers = []): mixed + { + return $this->request('post', $path, ['json' => $data], $headers); + } + + /** + * @param RequestBody $query Query parameters + * @param RequestHeaders $headers Additional headers for this request + * @return ApiResponse Resulting API response + * + * @throws \Exception On HTTP or decode failure + */ + public function get(string $path, array $query = [], array $headers = []): mixed + { + return $this->request('get', $path, ['query' => $query], $headers); + } + + /** + * @param RequestBody $data Request payload + * @param RequestHeaders $headers Additional headers for this request + * @return ApiResponse Resulting API response + * + * @throws \Exception On HTTP or decode failure + */ + public function put(string $path, array $data, array $headers = []): mixed + { + return $this->request('put', $path, ['json' => $data], $headers); + } + + /** + * @param RequestBody $query Query parameters + * @param RequestHeaders $headers Additional headers for this request + * @return ApiResponse Resulting API response + * + * @throws \Exception On HTTP or decode failure + */ + public function delete(string $path, array $query = [], array $headers = []): mixed + { + return $this->request('delete', $path, ['query' => $query], $headers); + } + + /** + * @param RequestBody $query Query parameters + * @param RequestHeaders $headers Additional headers for this request + * + * @throws \Exception On HTTP failure + */ + public function getRaw(string $path, array $query = [], array $headers = []): string { try { - $requestOptions = ['json' => $data]; + $requestOptions = []; - if (! empty($headers)) { - $requestOptions['headers'] = $headers; + if ($query !== []) { + $requestOptions['query'] = $query; } - $response = $this->client->post($path, $requestOptions); + $requestOptions['headers'] = [ + ...$this->withoutAuthHeaders($headers), + ...$this->auth->headers(), + ]; + + return $this->client->get($path, $requestOptions)->getBody()->getContents(); + } catch (GuzzleException $e) { + throw new \Exception('API request failed: '.$this->redactToken($e->getMessage()), 0, $e); + } + } + + /** + * @param array $options + * @param RequestHeaders $headers + * + * @phpstan-return ApiResponse + * + * @throws \Exception + */ + private function request(string $method, string $path, array $options = [], array $headers = []): mixed + { + try { + $requestOptions = array_filter( + $options, + fn (mixed $value): bool => ! is_array($value) || $value !== [] + ); + + $requestOptions['headers'] = [ + ...$this->withoutAuthHeaders($headers), + ...$this->auth->headers(), + ]; + + $response = $this->client->{$method}($path, $requestOptions); $body = $response->getBody()->getContents(); $result = json_decode($body, true); @@ -66,7 +147,27 @@ public function post(string $path, array $data, array $headers = []): array return $result; } catch (GuzzleException $e) { - throw new \Exception('API request failed: '.$e->getMessage(), 0, $e); + throw new \Exception('API request failed: '.$this->redactToken($e->getMessage()), 0, $e); } } + + private function redactToken(string $message): string + { + $token = $this->auth->token(); + + return str_replace([$token, 'Bearer '.$token], '[redacted]', $message); + } + + /** + * @param RequestHeaders $headers + * @return RequestHeaders + */ + private function withoutAuthHeaders(array $headers): array + { + return array_filter( + $headers, + fn (string $name): bool => ! in_array(strtolower($name), ['authorization', 'x-lettermint-token'], true), + ARRAY_FILTER_USE_KEY + ); + } } diff --git a/src/Endpoints/DomainsEndpoint.php b/src/Endpoints/DomainsEndpoint.php new file mode 100644 index 0000000..b833086 --- /dev/null +++ b/src/Endpoints/DomainsEndpoint.php @@ -0,0 +1,61 @@ +hydrate(DomainListResponse::class, $this->getArray($this->path('/domains'), $query)); + } + + /** + * @phpstan-param StoreDomainData $data + */ + public function create(array $data): DomainResponse + { + return $this->hydrate(DomainResponse::class, $this->postArray($this->path('/domains'), $data, [])); + } + + public function retrieve(string $domainId, array $query = []): DomainResponse + { + return $this->hydrate(DomainResponse::class, $this->getArray($this->path('/domains/{domainId}', ['domainId' => $domainId]), $query)); + } + + public function delete(string $domainId): DeleteDomainResponse + { + return $this->hydrate(DeleteDomainResponse::class, $this->deleteArray($this->path('/domains/{domainId}', ['domainId' => $domainId]), [])); + } + + public function verifyDnsRecords(string $domainId): VerifyDnsRecordsResponse + { + return $this->hydrate(VerifyDnsRecordsResponse::class, $this->postArray($this->path('/domains/{domainId}/dns-records/verify', ['domainId' => $domainId]), [], [])); + } + + public function verifyDnsRecord(string $domainId, string $recordId): VerifyDnsRecordResponse + { + return $this->hydrate(VerifyDnsRecordResponse::class, $this->postArray($this->path('/domains/{domainId}/dns-records/{recordId}/verify', [ + 'domainId' => $domainId, + 'recordId' => $recordId, + ]), [], [])); + } + + /** + * @phpstan-param UpdateDomainProjectsData $data + */ + public function updateProjects(string $domainId, array $data): UpdateDomainProjectsResponse + { + return $this->hydrate(UpdateDomainProjectsResponse::class, $this->putArray($this->path('/domains/{domainId}/projects', ['domainId' => $domainId]), $data, [])); + } +} diff --git a/src/Endpoints/EmailEndpoint.php b/src/Endpoints/EmailEndpoint.php index d385e1c..183d4a9 100644 --- a/src/Endpoints/EmailEndpoint.php +++ b/src/Endpoints/EmailEndpoint.php @@ -2,12 +2,23 @@ namespace Lettermint\Endpoints; +use Lettermint\Responses\SendBatchMailResponse; +use Lettermint\Responses\SendMailResponse; + /** + * @phpstan-import-type SendMailRequest from \Lettermint\Types\ApiTypes + * @phpstan-import-type SendBatchMailRequest from \Lettermint\Types\ApiTypes + * * @phpstan-type AttachmentPayload array{ * filename: string, * content: string, + * content_type?: string, * content_id?: string * } + * @phpstan-type EmailSettings array{ + * track_opens?: bool, + * track_clicks?: bool + * } * @phpstan-type EmailPayload array{ * from?: string, * to?: list, @@ -21,12 +32,9 @@ * route?: string, * metadata?: array, * tag?: string|null, + * settings?: EmailSettings|null, * headers?: array * } - * @phpstan-type SendResponse array{ - * message_id: string, - * status: string - * } */ class EmailEndpoint extends Endpoint { @@ -161,8 +169,9 @@ public function replyTo(string ...$emails): self * @param string $filename The attachment filename. * @param string $base64Content The base64-encoded file content. * @param string|null $contentId Optional content ID for inline attachments. + * @param string|null $contentType Optional MIME type for the attachment. */ - public function attach(string $filename, string $base64Content, ?string $contentId = null): self + public function attach(string $filename, string $base64Content, ?string $contentId = null, ?string $contentType = null): self { /** @var AttachmentPayload $attachment */ $attachment = [ @@ -170,6 +179,10 @@ public function attach(string $filename, string $base64Content, ?string $content 'content' => $base64Content, ]; + if ($contentType !== null) { + $attachment['content_type'] = $contentType; + } + if ($contentId !== null) { $attachment['content_id'] = $contentId; } @@ -229,15 +242,48 @@ public function tag(?string $tag): self return $this; } + /** + * Set per-email settings. + * + * @param EmailSettings $settings + */ + public function settings(array $settings): self + { + $this->payload['settings'] = $settings; + + return $this; + } + + /** + * Ping the Sending API. + * + * @throws \Exception On HTTP or API failure. + */ + public function ping(): string + { + return trim($this->getRaw('/v1/ping')); + } + + /** + * Send multiple emails in a batch. + * + * @phpstan-param SendBatchMailRequest $messages + * + * @throws \Exception On HTTP or API failure. + */ + public function sendBatch(array $messages): SendBatchMailResponse + { + return $this->hydrateList(SendBatchMailResponse::class, $this->postArray('/v1/send/batch', $messages, [])); + } + /** * Send the composed email using the current payload. * - * @return array{message_id: string, status: string} The API response as an associative array. - * @phpstan-return SendResponse + * @phpstan-param SendMailRequest|null $payload * * @throws \Exception On HTTP or API failure. */ - public function send(): array + public function send(?array $payload = null): SendMailResponse { $headers = []; @@ -245,11 +291,13 @@ public function send(): array $headers['Idempotency-Key'] = $this->idempotencyKey; } - $result = $this->httpClient->post('/v1/send', $this->payload, $headers); - - $this->payload = []; - $this->idempotencyKey = null; + try { + $result = $this->postArray('/v1/send', $payload ?? $this->payload, $headers); - return $result; + return $this->hydrate(SendMailResponse::class, $result); + } finally { + $this->payload = []; + $this->idempotencyKey = null; + } } } diff --git a/src/Endpoints/Endpoint.php b/src/Endpoints/Endpoint.php index b3640d3..226ef58 100644 --- a/src/Endpoints/Endpoint.php +++ b/src/Endpoints/Endpoint.php @@ -3,6 +3,7 @@ namespace Lettermint\Endpoints; use Lettermint\Client\HttpClient; +use Lettermint\Resource; abstract class Endpoint { @@ -12,4 +13,121 @@ public function __construct(HttpClient $httpClient) { $this->httpClient = $httpClient; } + + /** + * @param array $parameters + */ + protected function path(string $path, array $parameters = []): string + { + $versionedPath = '/v1'.$path; + + return preg_replace_callback('/\{([^}]+)\}/', function (array $matches) use ($parameters): string { + $name = $matches[1]; + + if (! array_key_exists($name, $parameters)) { + throw new \InvalidArgumentException("Missing path parameter: $name"); + } + + return rawurlencode($parameters[$name]); + }, $versionedPath); + } + + /** + * @param array $query + * @return array + */ + protected function getArray(string $path, array $query = []): array + { + return $this->expectArray($this->httpClient->get($path, $query)); + } + + /** + * @param array $data + * @param array $headers + * @return array + */ + protected function postArray(string $path, array $data = [], array $headers = []): array + { + return $this->expectArray($this->httpClient->post($path, $data, $headers)); + } + + /** + * @param array $data + * @param array $headers + * @return array + */ + protected function putArray(string $path, array $data = [], array $headers = []): array + { + return $this->expectArray($this->httpClient->put($path, $data, $headers)); + } + + /** + * @param array $query + * @return array + */ + protected function deleteArray(string $path, array $query = []): array + { + return $this->expectArray($this->httpClient->delete($path, $query)); + } + + /** + * @param array $query + */ + protected function getRaw(string $path, array $query = []): string + { + return $this->httpClient->getRaw($path, $query); + } + + /** + * @param array $query + */ + protected function getInt(string $path, array $query = []): int + { + $response = $this->httpClient->get($path, $query); + + if (! is_int($response)) { + throw new \UnexpectedValueException('Expected API response to be an integer.'); + } + + return $response; + } + + /** + * @return array + */ + private function expectArray(mixed $response): array + { + if (! is_array($response)) { + throw new \UnexpectedValueException('Expected API response to be an array.'); + } + + return $response; + } + + /** + * @template T of Resource + * + * @param class-string $class + * @param array $response + * @return T + */ + protected function hydrate(string $class, array $response): Resource + { + /** @var T $resource */ + $resource = new $class($response); + + return $resource; + } + + /** + * @template T of Resource + * + * @param class-string $class + * @param array $response + * @return T + */ + protected function hydrateList(string $class, array $response): Resource + { + return $this->hydrate($class, array_is_list($response) ? ['data' => $response] : $response); + } } diff --git a/src/Endpoints/MessagesEndpoint.php b/src/Endpoints/MessagesEndpoint.php new file mode 100644 index 0000000..3ed537e --- /dev/null +++ b/src/Endpoints/MessagesEndpoint.php @@ -0,0 +1,40 @@ +hydrateList(MessageListResponse::class, $this->getArray($this->path('/messages'), $query)); + } + + public function retrieve(string $messageId): MessageResponse + { + return $this->hydrate(MessageResponse::class, $this->getArray($this->path('/messages/{messageId}', ['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)); + } + + public function source(string $messageId): string + { + return $this->getRaw($this->path('/messages/{messageId}/source', ['messageId' => $messageId]), []); + } + + public function html(string $messageId): string + { + return $this->getRaw($this->path('/messages/{messageId}/html', ['messageId' => $messageId]), []); + } + + public function text(string $messageId): string + { + return $this->getRaw($this->path('/messages/{messageId}/text', ['messageId' => $messageId]), []); + } +} diff --git a/src/Endpoints/ProjectsEndpoint.php b/src/Endpoints/ProjectsEndpoint.php new file mode 100644 index 0000000..4d1f09a --- /dev/null +++ b/src/Endpoints/ProjectsEndpoint.php @@ -0,0 +1,96 @@ +hydrate(ProjectListResponse::class, $this->getArray($this->path('/projects'), $query)); + } + + /** + * @phpstan-param StoreProjectData $data + */ + public function create(array $data): CreateProjectResponse + { + return $this->hydrate(CreateProjectResponse::class, $this->postArray($this->path('/projects'), $data, [])); + } + + public function retrieve(string $projectId, array $query = []): ProjectResponse + { + return $this->hydrate(ProjectResponse::class, $this->getArray($this->path('/projects/{projectId}', ['projectId' => $projectId]), $query)); + } + + /** + * @phpstan-param UpdateProjectData $data + */ + public function update(string $projectId, array $data): UpdateProjectResponse + { + return $this->hydrate(UpdateProjectResponse::class, $this->putArray($this->path('/projects/{projectId}', ['projectId' => $projectId]), $data, [])); + } + + public function delete(string $projectId): DeleteProjectResponse + { + return $this->hydrate(DeleteProjectResponse::class, $this->deleteArray($this->path('/projects/{projectId}', ['projectId' => $projectId]), [])); + } + + public function rotateToken(string $projectId): RotateProjectTokenResponse + { + return $this->hydrate(RotateProjectTokenResponse::class, $this->postArray($this->path('/projects/{projectId}/rotate-token', ['projectId' => $projectId]), [], [])); + } + + /** + * @phpstan-param UpdateProjectMembersData $data + */ + public function updateMembers(string $projectId, array $data): UpdateProjectMembersResponse + { + return $this->hydrate(UpdateProjectMembersResponse::class, $this->putArray($this->path('/projects/{projectId}/members', ['projectId' => $projectId]), $data, [])); + } + + public function addMember(string $projectId, string $teamMemberId): ProjectMemberResponse + { + return $this->hydrate(ProjectMemberResponse::class, $this->postArray($this->path('/projects/{projectId}/members/{teamMemberId}', [ + 'projectId' => $projectId, + 'teamMemberId' => $teamMemberId, + ]), [], [])); + } + + public function removeMember(string $projectId, string $teamMemberId): ProjectMemberResponse + { + return $this->hydrate(ProjectMemberResponse::class, $this->deleteArray($this->path('/projects/{projectId}/members/{teamMemberId}', [ + 'projectId' => $projectId, + 'teamMemberId' => $teamMemberId, + ]), [])); + } + + public function routes(string $projectId, array $query = []): ProjectRoutesResponse + { + return $this->hydrate(ProjectRoutesResponse::class, $this->getArray($this->path('/projects/{projectId}/routes', ['projectId' => $projectId]), $query)); + } + + /** + * @phpstan-param StoreRouteData $data + */ + public function createRoute(string $projectId, array $data): CreateRouteResponse + { + return $this->hydrate(CreateRouteResponse::class, $this->postArray($this->path('/projects/{projectId}/routes', ['projectId' => $projectId]), $data, [])); + } +} diff --git a/src/Endpoints/RoutesEndpoint.php b/src/Endpoints/RoutesEndpoint.php new file mode 100644 index 0000000..d15d024 --- /dev/null +++ b/src/Endpoints/RoutesEndpoint.php @@ -0,0 +1,37 @@ +hydrate(RouteResponse::class, $this->getArray($this->path('/routes/{routeId}', ['routeId' => $routeId]), $query)); + } + + /** + * @phpstan-param UpdateRouteData $data + */ + public function update(string $routeId, array $data): UpdateRouteResponse + { + return $this->hydrate(UpdateRouteResponse::class, $this->putArray($this->path('/routes/{routeId}', ['routeId' => $routeId]), $data, [])); + } + + public function delete(string $routeId): DeleteRouteResponse + { + return $this->hydrate(DeleteRouteResponse::class, $this->deleteArray($this->path('/routes/{routeId}', ['routeId' => $routeId]), [])); + } + + public function verifyInboundDomain(string $routeId): VerifyInboundDomainResponse + { + return $this->hydrate(VerifyInboundDomainResponse::class, $this->postArray($this->path('/routes/{routeId}/verify-inbound-domain', ['routeId' => $routeId]), [], [])); + } +} diff --git a/src/Endpoints/StatsEndpoint.php b/src/Endpoints/StatsEndpoint.php new file mode 100644 index 0000000..54888c7 --- /dev/null +++ b/src/Endpoints/StatsEndpoint.php @@ -0,0 +1,19 @@ +hydrate(StatsResponse::class, $this->getArray($this->path('/stats'), $query)); + } +} diff --git a/src/Endpoints/SuppressionsEndpoint.php b/src/Endpoints/SuppressionsEndpoint.php new file mode 100644 index 0000000..104e285 --- /dev/null +++ b/src/Endpoints/SuppressionsEndpoint.php @@ -0,0 +1,31 @@ +hydrate(SuppressionListResponse::class, $this->getArray($this->path('/suppressions'), $query)); + } + + /** + * @phpstan-param StoreSuppressionData $data + */ + public function create(array $data): CreateSuppressionResponse + { + return $this->hydrate(CreateSuppressionResponse::class, $this->postArray($this->path('/suppressions'), $data, [])); + } + + public function delete(string $suppressionId): DeleteSuppressionResponse + { + return $this->hydrate(DeleteSuppressionResponse::class, $this->deleteArray($this->path('/suppressions/{suppressionId}', ['suppressionId' => $suppressionId]), [])); + } +} diff --git a/src/Endpoints/TeamEndpoint.php b/src/Endpoints/TeamEndpoint.php new file mode 100644 index 0000000..454e5f4 --- /dev/null +++ b/src/Endpoints/TeamEndpoint.php @@ -0,0 +1,37 @@ +hydrate(TeamResponse::class, $this->getArray($this->path('/team'), $query)); + } + + /** + * @phpstan-param UpdateTeamData $data + */ + public function update(array $data): UpdateTeamResponse + { + return $this->hydrate(UpdateTeamResponse::class, $this->putArray($this->path('/team'), $data, [])); + } + + public function usage(): TeamUsageResponse + { + return $this->hydrate(TeamUsageResponse::class, $this->getArray($this->path('/team/usage'), [])); + } + + public function members(array $query = []): TeamMembersResponse + { + return $this->hydrate(TeamMembersResponse::class, $this->getArray($this->path('/team/members'), $query)); + } +} diff --git a/src/Endpoints/WebhooksEndpoint.php b/src/Endpoints/WebhooksEndpoint.php new file mode 100644 index 0000000..3249de6 --- /dev/null +++ b/src/Endpoints/WebhooksEndpoint.php @@ -0,0 +1,74 @@ +hydrate(WebhookListResponse::class, $this->getArray($this->path('/webhooks'), $query)); + } + + /** + * @phpstan-param StoreWebhookData $data + */ + public function create(array $data): CreateWebhookResponse + { + return $this->hydrate(CreateWebhookResponse::class, $this->postArray($this->path('/webhooks'), $data, [])); + } + + public function retrieve(string $webhookId): WebhookResponse + { + return $this->hydrate(WebhookResponse::class, $this->getArray($this->path('/webhooks/{webhookId}', ['webhookId' => $webhookId]), [])); + } + + /** + * @phpstan-param UpdateWebhookData $data + */ + public function update(string $webhookId, array $data): UpdateWebhookResponse + { + return $this->hydrate(UpdateWebhookResponse::class, $this->putArray($this->path('/webhooks/{webhookId}', ['webhookId' => $webhookId]), $data, [])); + } + + public function delete(string $webhookId): DeleteWebhookResponse + { + return $this->hydrate(DeleteWebhookResponse::class, $this->deleteArray($this->path('/webhooks/{webhookId}', ['webhookId' => $webhookId]), [])); + } + + public function test(string $webhookId): TestWebhookResponse + { + return $this->hydrate(TestWebhookResponse::class, $this->postArray($this->path('/webhooks/{webhookId}/test', ['webhookId' => $webhookId]), [], [])); + } + + public function regenerateSecret(string $webhookId): RegenerateWebhookSecretResponse + { + return $this->hydrate(RegenerateWebhookSecretResponse::class, $this->postArray($this->path('/webhooks/{webhookId}/regenerate-secret', ['webhookId' => $webhookId]), [], [])); + } + + public function deliveries(string $webhookId, array $query = []): WebhookDeliveriesResponse + { + return $this->hydrate(WebhookDeliveriesResponse::class, $this->getArray($this->path('/webhooks/{webhookId}/deliveries', ['webhookId' => $webhookId]), $query)); + } + + public function delivery(string $webhookId, string $deliveryId): WebhookDeliveryResponse + { + return $this->hydrate(WebhookDeliveryResponse::class, $this->getArray($this->path('/webhooks/{webhookId}/deliveries/{deliveryId}', [ + 'webhookId' => $webhookId, + 'deliveryId' => $deliveryId, + ]), [])); + } +} diff --git a/src/Lettermint.php b/src/Lettermint.php index 9664bec..43f4713 100644 --- a/src/Lettermint.php +++ b/src/Lettermint.php @@ -2,6 +2,8 @@ namespace Lettermint; +use Lettermint\Client\ApiClient; +use Lettermint\Client\Auth\SendingApiTokenAuth; use Lettermint\Client\HttpClient; use Lettermint\Endpoints\EmailEndpoint; @@ -29,6 +31,19 @@ public function __construct(string $apiToken, ?string $baseUrl = null) $this->httpClient = new HttpClient($this->apiToken, $this->baseUrl); } + public static function api(string $apiToken, ?string $baseUrl = null): ApiClient + { + return new ApiClient($apiToken, $baseUrl); + } + + public static function email(string $apiToken, ?string $baseUrl = null): EmailEndpoint + { + return new EmailEndpoint(new HttpClient( + new SendingApiTokenAuth($apiToken), + $baseUrl ?? 'https://api.lettermint.co/v1' + )); + } + public function __get($name) { if (isset($this->endpoints[$name])) { diff --git a/src/Objects/DomainData.php b/src/Objects/DomainData.php new file mode 100644 index 0000000..48900d8 --- /dev/null +++ b/src/Objects/DomainData.php @@ -0,0 +1,20 @@ + $dns_records + * @property list> $projects + * @property string $created_at + */ +final class DomainData extends Resource +{ + protected static array $casts = [ + 'dns_records' => [\Lettermint\Objects\DomainDnsRecordData::class], + ]; +} diff --git a/src/Objects/DomainDnsRecordData.php b/src/Objects/DomainDnsRecordData.php new file mode 100644 index 0000000..e5ee22f --- /dev/null +++ b/src/Objects/DomainDnsRecordData.php @@ -0,0 +1,20 @@ +|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 array|null $metadata + * @property float|int|null $spam_score + * @property list<\Lettermint\Objects\SpamSymbol> $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], + ]; +} diff --git a/src/Objects/MessageEventData.php b/src/Objects/MessageEventData.php new file mode 100644 index 0000000..4055874 --- /dev/null +++ b/src/Objects/MessageEventData.php @@ -0,0 +1,16 @@ +|null $metadata + * @property string $timestamp + */ +final class MessageEventData extends Resource +{ + // +} diff --git a/src/Objects/MessageListData.php b/src/Objects/MessageListData.php new file mode 100644 index 0000000..21b1d30 --- /dev/null +++ b/src/Objects/MessageListData.php @@ -0,0 +1,24 @@ +|null $to + * @property list<\Lettermint\Objects\MessageRecipientData>|null $cc + * @property list<\Lettermint\Objects\MessageRecipientData>|null $bcc + * @property list|null $reply_to + * @property string|null $tag + * @property string $created_at + */ +final class MessageListData extends Resource +{ + // +} diff --git a/src/Objects/MessageRecipientData.php b/src/Objects/MessageRecipientData.php new file mode 100644 index 0000000..a9d88e3 --- /dev/null +++ b/src/Objects/MessageRecipientData.php @@ -0,0 +1,14 @@ + $routes + * @property int $routes_count + * @property list<\Lettermint\Objects\DomainData> $domains + * @property int $domains_count + * @property list<\Lettermint\Objects\TeamMemberData> $team_members + * @property int $team_members_count + * @property \Lettermint\Objects\MessageStatsData|mixed $last_28_days + * @property string $created_at + * @property string $updated_at + */ +final class ProjectData extends Resource +{ + protected static array $casts = [ + 'routes' => [\Lettermint\Objects\RouteData::class], + 'domains' => [\Lettermint\Objects\DomainData::class], + 'team_members' => [\Lettermint\Objects\TeamMemberData::class], + ]; +} diff --git a/src/Objects/ProjectListData.php b/src/Objects/ProjectListData.php new file mode 100644 index 0000000..ae33d5d --- /dev/null +++ b/src/Objects/ProjectListData.php @@ -0,0 +1,23 @@ + \Lettermint\Objects\MessageStatsData::class, + ]; +} diff --git a/src/Objects/RouteData.php b/src/Objects/RouteData.php new file mode 100644 index 0000000..94a0523 --- /dev/null +++ b/src/Objects/RouteData.php @@ -0,0 +1,31 @@ +|list<\Lettermint\Objects\RouteStatisticData> $statistics + * @property string $created_at + * @property string $updated_at + */ +final class RouteData extends Resource +{ + protected static array $casts = [ + 'project' => \Lettermint\Objects\ProjectData::class, + ]; +} diff --git a/src/Objects/RouteListData.php b/src/Objects/RouteListData.php new file mode 100644 index 0000000..4680d50 --- /dev/null +++ b/src/Objects/RouteListData.php @@ -0,0 +1,21 @@ + $to + * @property list $cc + * @property list $bcc + * @property list $reply_to + * @property array $headers + * @property array $metadata + * @property array|null $settings + * @property list> $attachments + */ +final class SendMailRequest extends Resource +{ + // +} diff --git a/src/Objects/SpamSymbol.php b/src/Objects/SpamSymbol.php new file mode 100644 index 0000000..2d3cda0 --- /dev/null +++ b/src/Objects/SpamSymbol.php @@ -0,0 +1,16 @@ + $options + * @property string|null $description + */ +final class SpamSymbol extends Resource +{ + // +} diff --git a/src/Objects/StatsDailyData.php b/src/Objects/StatsDailyData.php new file mode 100644 index 0000000..eed5f81 --- /dev/null +++ b/src/Objects/StatsDailyData.php @@ -0,0 +1,27 @@ + \Lettermint\Objects\StatsInboundData::class, + ]; +} diff --git a/src/Objects/StatsData.php b/src/Objects/StatsData.php new file mode 100644 index 0000000..9c29a09 --- /dev/null +++ b/src/Objects/StatsData.php @@ -0,0 +1,19 @@ + $daily + */ +final class StatsData extends Resource +{ + protected static array $casts = [ + 'totals' => \Lettermint\Objects\StatsTotalsData::class, + 'daily' => [\Lettermint\Objects\StatsDailyData::class], + ]; +} diff --git a/src/Objects/StatsInboundData.php b/src/Objects/StatsInboundData.php new file mode 100644 index 0000000..6ff0a83 --- /dev/null +++ b/src/Objects/StatsInboundData.php @@ -0,0 +1,13 @@ + \Lettermint\Objects\StatsInboundData::class, + ]; +} diff --git a/src/Objects/StatsTypeData.php b/src/Objects/StatsTypeData.php new file mode 100644 index 0000000..d7b4203 --- /dev/null +++ b/src/Objects/StatsTypeData.php @@ -0,0 +1,15 @@ +|null $emails + */ +final class StoreSuppressionData extends Resource +{ + // +} diff --git a/src/Objects/StoreWebhookData.php b/src/Objects/StoreWebhookData.php new file mode 100644 index 0000000..ae0222e --- /dev/null +++ b/src/Objects/StoreWebhookData.php @@ -0,0 +1,18 @@ + $events + */ +final class StoreWebhookData extends Resource +{ + // +} diff --git a/src/Objects/SuppressedRecipientData.php b/src/Objects/SuppressedRecipientData.php new file mode 100644 index 0000000..c0d970d --- /dev/null +++ b/src/Objects/SuppressedRecipientData.php @@ -0,0 +1,21 @@ + $features + * @property list<\Lettermint\Objects\TeamAddonData> $addons + * @property string $created_at + * @property int $domains_count + * @property int $projects_count + * @property int $members_count + */ +final class TeamData extends Resource +{ + protected static array $casts = [ + 'addons' => [\Lettermint\Objects\TeamAddonData::class], + ]; +} diff --git a/src/Objects/TeamMemberData.php b/src/Objects/TeamMemberData.php new file mode 100644 index 0000000..e47a6b2 --- /dev/null +++ b/src/Objects/TeamMemberData.php @@ -0,0 +1,18 @@ + \Lettermint\Objects\UserData::class, + ]; +} diff --git a/src/Objects/TeamUsageDetailData.php b/src/Objects/TeamUsageDetailData.php new file mode 100644 index 0000000..8ae96e7 --- /dev/null +++ b/src/Objects/TeamUsageDetailData.php @@ -0,0 +1,17 @@ + $historical_usage + */ +final class TeamUsageDetailData extends Resource +{ + protected static array $casts = [ + 'current_period' => \Lettermint\Objects\TeamUsagePeriodData::class, + 'historical_usage' => [\Lettermint\Objects\TeamUsagePeriodData::class], + ]; +} diff --git a/src/Objects/TeamUsagePeriodData.php b/src/Objects/TeamUsagePeriodData.php new file mode 100644 index 0000000..745d348 --- /dev/null +++ b/src/Objects/TeamUsagePeriodData.php @@ -0,0 +1,16 @@ + $project_ids + */ +final class UpdateDomainProjectsData extends Resource +{ + // +} diff --git a/src/Objects/UpdateProjectData.php b/src/Objects/UpdateProjectData.php new file mode 100644 index 0000000..6fda7b3 --- /dev/null +++ b/src/Objects/UpdateProjectData.php @@ -0,0 +1,15 @@ + $team_member_ids + */ +final class UpdateProjectMembersData extends Resource +{ + // +} diff --git a/src/Objects/UpdateRouteData.php b/src/Objects/UpdateRouteData.php new file mode 100644 index 0000000..da9b204 --- /dev/null +++ b/src/Objects/UpdateRouteData.php @@ -0,0 +1,15 @@ + $settings + * @property array $inbound_settings + */ +final class UpdateRouteData extends Resource +{ + // +} diff --git a/src/Objects/UpdateTeamData.php b/src/Objects/UpdateTeamData.php new file mode 100644 index 0000000..404982e --- /dev/null +++ b/src/Objects/UpdateTeamData.php @@ -0,0 +1,13 @@ + $events + */ +final class UpdateWebhookData extends Resource +{ + // +} diff --git a/src/Objects/UserData.php b/src/Objects/UserData.php new file mode 100644 index 0000000..a9bf9c4 --- /dev/null +++ b/src/Objects/UserData.php @@ -0,0 +1,16 @@ + $events + * @property bool $enabled + * @property bool $include_machine_events + * @property string $secret + * @property string|null $last_called_at + * @property string $created_at + * @property string $updated_at + */ +final class WebhookData extends Resource +{ + // +} diff --git a/src/Objects/WebhookDeliveryData.php b/src/Objects/WebhookDeliveryData.php new file mode 100644 index 0000000..303a84e --- /dev/null +++ b/src/Objects/WebhookDeliveryData.php @@ -0,0 +1,25 @@ + $payload + * @property string|null $response_body + * @property list|null $response_headers + * @property string|null $error_message + * @property string|null $delivered_at + * @property string $timestamp + */ +final class WebhookDeliveryData extends Resource +{ + // +} diff --git a/src/Objects/WebhookDeliveryListData.php b/src/Objects/WebhookDeliveryListData.php new file mode 100644 index 0000000..0422a2a --- /dev/null +++ b/src/Objects/WebhookDeliveryListData.php @@ -0,0 +1,21 @@ + $events + * @property bool $enabled + * @property string|null $last_called_at + * @property string $created_at + * @property string $updated_at + */ +final class WebhookListData extends Resource +{ + // +} diff --git a/src/Resource.php b/src/Resource.php new file mode 100644 index 0000000..3472cb0 --- /dev/null +++ b/src/Resource.php @@ -0,0 +1,136 @@ + + */ +class Resource implements ArrayAccess, JsonSerializable +{ + /** + * @var array + */ + protected array $attributes = []; + + /** @var array */ + protected static array $casts = []; + + /** + * @param array $attributes + */ + public function __construct(array $attributes = []) + { + $this->attributes = $attributes; + } + + public function __get(string $name): mixed + { + return $this->getAttribute($name); + } + + public function getAttribute(string $name): mixed + { + if (! array_key_exists($name, $this->attributes)) { + return null; + } + + return $this->castAttribute($name, $this->attributes[$name]); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->arrayify($this->attributes); + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->toArray(); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + public function offsetExists(mixed $offset): bool + { + return array_key_exists((string) $offset, $this->attributes); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->getAttribute((string) $offset); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new BadMethodCallException('Cannot set resource attributes.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new BadMethodCallException('Cannot unset resource attributes.'); + } + + private function castAttribute(string $name, mixed $value): mixed + { + $cast = static::$casts[$name] ?? null; + + if ($cast === null || $value === null) { + return $value; + } + + if (is_string($cast) && is_a($cast, self::class, true) && is_array($value)) { + return new $cast($value); + } + + if (is_array($cast) && is_string($cast[0] ?? null) && is_a($cast[0], self::class, true) && is_array($value)) { + return array_map( + fn (mixed $item): mixed => is_array($item) ? new $cast[0]($item) : $item, + $value + ); + } + + return $value; + } + + /** + * @param array $values + * @return array + */ + private function arrayify(array $values): array + { + return array_map(function (mixed $value): mixed { + if ($value instanceof self) { + return $value->toArray(); + } + + if (is_array($value)) { + return $this->arrayify($value); + } + + return $value; + }, $values); + } +} diff --git a/src/Responses/CreateProjectResponse.php b/src/Responses/CreateProjectResponse.php new file mode 100644 index 0000000..af6bd43 --- /dev/null +++ b/src/Responses/CreateProjectResponse.php @@ -0,0 +1,17 @@ + \Lettermint\Objects\ProjectData::class, + ]; +} diff --git a/src/Responses/CreateRouteResponse.php b/src/Responses/CreateRouteResponse.php new file mode 100644 index 0000000..aa84404 --- /dev/null +++ b/src/Responses/CreateRouteResponse.php @@ -0,0 +1,16 @@ + \Lettermint\Objects\RouteData::class, + ]; +} diff --git a/src/Responses/CreateSuppressionResponse.php b/src/Responses/CreateSuppressionResponse.php new file mode 100644 index 0000000..c6cb7ad --- /dev/null +++ b/src/Responses/CreateSuppressionResponse.php @@ -0,0 +1,14 @@ + $data + */ +final class CreateSuppressionResponse extends Resource +{ + // +} diff --git a/src/Responses/CreateWebhookResponse.php b/src/Responses/CreateWebhookResponse.php new file mode 100644 index 0000000..d16a058 --- /dev/null +++ b/src/Responses/CreateWebhookResponse.php @@ -0,0 +1,16 @@ + \Lettermint\Objects\WebhookData::class, + ]; +} diff --git a/src/Responses/DeleteDomainResponse.php b/src/Responses/DeleteDomainResponse.php new file mode 100644 index 0000000..fc64ed1 --- /dev/null +++ b/src/Responses/DeleteDomainResponse.php @@ -0,0 +1,13 @@ + $data + * @property string|null $path + * @property int $per_page + * @property string|null $next_cursor + * @property string|null $next_page_url + * @property string|null $prev_cursor + * @property string|null $prev_page_url + */ +final class DomainListResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Objects\DomainListData::class], + ]; +} diff --git a/src/Responses/DomainResponse.php b/src/Responses/DomainResponse.php new file mode 100644 index 0000000..d216fb2 --- /dev/null +++ b/src/Responses/DomainResponse.php @@ -0,0 +1,18 @@ + $dns_records + * @property list> $projects + * @property string $created_at + */ +final class DomainResponse extends Resource +{ + // +} diff --git a/src/Responses/MessageEventsResponse.php b/src/Responses/MessageEventsResponse.php new file mode 100644 index 0000000..3421fc6 --- /dev/null +++ b/src/Responses/MessageEventsResponse.php @@ -0,0 +1,21 @@ + $data + * @property string|null $path + * @property int $per_page + * @property string|null $next_cursor + * @property string|null $next_page_url + * @property string|null $prev_cursor + * @property string|null $prev_page_url + */ +final class MessageEventsResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Objects\MessageEventData::class], + ]; +} diff --git a/src/Responses/MessageListResponse.php b/src/Responses/MessageListResponse.php new file mode 100644 index 0000000..cf58085 --- /dev/null +++ b/src/Responses/MessageListResponse.php @@ -0,0 +1,21 @@ + $data + * @property string|null $path + * @property int $per_page + * @property string|null $next_cursor + * @property string|null $next_page_url + * @property string|null $prev_cursor + * @property string|null $prev_page_url + */ +final class MessageListResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Objects\MessageListData::class], + ]; +} diff --git a/src/Responses/MessageResponse.php b/src/Responses/MessageResponse.php new file mode 100644 index 0000000..c0b8b63 --- /dev/null +++ b/src/Responses/MessageResponse.php @@ -0,0 +1,30 @@ +|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 array|null $metadata + * @property float|int|null $spam_score + * @property list<\Lettermint\Objects\SpamSymbol> $spam_symbols + * @property string $route_id + * @property string $created_at + */ +final class MessageResponse extends Resource +{ + // +} diff --git a/src/Responses/ProjectListResponse.php b/src/Responses/ProjectListResponse.php new file mode 100644 index 0000000..95590ad --- /dev/null +++ b/src/Responses/ProjectListResponse.php @@ -0,0 +1,21 @@ + $data + * @property string|null $path + * @property int $per_page + * @property string|null $next_cursor + * @property string|null $next_page_url + * @property string|null $prev_cursor + * @property string|null $prev_page_url + */ +final class ProjectListResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Objects\ProjectListData::class], + ]; +} diff --git a/src/Responses/ProjectMemberResponse.php b/src/Responses/ProjectMemberResponse.php new file mode 100644 index 0000000..549061b --- /dev/null +++ b/src/Responses/ProjectMemberResponse.php @@ -0,0 +1,13 @@ + $routes + * @property int $routes_count + * @property list<\Lettermint\Objects\DomainData> $domains + * @property int $domains_count + * @property list<\Lettermint\Objects\TeamMemberData> $team_members + * @property int $team_members_count + * @property \Lettermint\Objects\MessageStatsData|mixed $last_28_days + * @property string $created_at + * @property string $updated_at + */ +final class ProjectResponse extends Resource +{ + // +} diff --git a/src/Responses/ProjectRoutesResponse.php b/src/Responses/ProjectRoutesResponse.php new file mode 100644 index 0000000..32bf77c --- /dev/null +++ b/src/Responses/ProjectRoutesResponse.php @@ -0,0 +1,21 @@ + $data + * @property string|null $path + * @property int $per_page + * @property string|null $next_cursor + * @property string|null $next_page_url + * @property string|null $prev_cursor + * @property string|null $prev_page_url + */ +final class ProjectRoutesResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Objects\RouteListData::class], + ]; +} diff --git a/src/Responses/RegenerateWebhookSecretResponse.php b/src/Responses/RegenerateWebhookSecretResponse.php new file mode 100644 index 0000000..2272d23 --- /dev/null +++ b/src/Responses/RegenerateWebhookSecretResponse.php @@ -0,0 +1,16 @@ + \Lettermint\Objects\WebhookData::class, + ]; +} diff --git a/src/Responses/RotateProjectTokenResponse.php b/src/Responses/RotateProjectTokenResponse.php new file mode 100644 index 0000000..50f65d3 --- /dev/null +++ b/src/Responses/RotateProjectTokenResponse.php @@ -0,0 +1,17 @@ + \Lettermint\Objects\ProjectData::class, + ]; +} diff --git a/src/Responses/RouteResponse.php b/src/Responses/RouteResponse.php new file mode 100644 index 0000000..630878b --- /dev/null +++ b/src/Responses/RouteResponse.php @@ -0,0 +1,29 @@ +|list<\Lettermint\Objects\RouteStatisticData> $statistics + * @property string $created_at + * @property string $updated_at + */ +final class RouteResponse extends Resource +{ + // +} diff --git a/src/Responses/SendBatchMailResponse.php b/src/Responses/SendBatchMailResponse.php new file mode 100644 index 0000000..dd2b392 --- /dev/null +++ b/src/Responses/SendBatchMailResponse.php @@ -0,0 +1,15 @@ + $data + */ +final class SendBatchMailResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Responses\SendMailResponse::class], + ]; +} diff --git a/src/Responses/SendMailResponse.php b/src/Responses/SendMailResponse.php new file mode 100644 index 0000000..21db0fd --- /dev/null +++ b/src/Responses/SendMailResponse.php @@ -0,0 +1,14 @@ + $daily + */ +final class StatsResponse extends Resource +{ + // +} diff --git a/src/Responses/SuppressionListResponse.php b/src/Responses/SuppressionListResponse.php new file mode 100644 index 0000000..001e4e4 --- /dev/null +++ b/src/Responses/SuppressionListResponse.php @@ -0,0 +1,21 @@ + $data + * @property string|null $path + * @property int $per_page + * @property string|null $next_cursor + * @property string|null $next_page_url + * @property string|null $prev_cursor + * @property string|null $prev_page_url + */ +final class SuppressionListResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Objects\SuppressedRecipientData::class], + ]; +} diff --git a/src/Responses/TeamMembersResponse.php b/src/Responses/TeamMembersResponse.php new file mode 100644 index 0000000..dec231d --- /dev/null +++ b/src/Responses/TeamMembersResponse.php @@ -0,0 +1,21 @@ + $data + * @property string|null $path + * @property int $per_page + * @property string|null $next_cursor + * @property string|null $next_page_url + * @property string|null $prev_cursor + * @property string|null $prev_page_url + */ +final class TeamMembersResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Objects\TeamMemberData::class], + ]; +} diff --git a/src/Responses/TeamResponse.php b/src/Responses/TeamResponse.php new file mode 100644 index 0000000..fc827cb --- /dev/null +++ b/src/Responses/TeamResponse.php @@ -0,0 +1,24 @@ + $features + * @property list<\Lettermint\Objects\TeamAddonData> $addons + * @property string $created_at + * @property int $domains_count + * @property int $projects_count + * @property int $members_count + */ +final class TeamResponse extends Resource +{ + // +} diff --git a/src/Responses/TeamUsageResponse.php b/src/Responses/TeamUsageResponse.php new file mode 100644 index 0000000..d60570e --- /dev/null +++ b/src/Responses/TeamUsageResponse.php @@ -0,0 +1,14 @@ + $historical_usage + */ +final class TeamUsageResponse extends Resource +{ + // +} diff --git a/src/Responses/TestWebhookResponse.php b/src/Responses/TestWebhookResponse.php new file mode 100644 index 0000000..63e32f3 --- /dev/null +++ b/src/Responses/TestWebhookResponse.php @@ -0,0 +1,14 @@ + \Lettermint\Objects\DomainData::class, + ]; +} diff --git a/src/Responses/UpdateProjectMembersResponse.php b/src/Responses/UpdateProjectMembersResponse.php new file mode 100644 index 0000000..5ae58a6 --- /dev/null +++ b/src/Responses/UpdateProjectMembersResponse.php @@ -0,0 +1,16 @@ + \Lettermint\Objects\ProjectData::class, + ]; +} diff --git a/src/Responses/UpdateProjectResponse.php b/src/Responses/UpdateProjectResponse.php new file mode 100644 index 0000000..93e9fce --- /dev/null +++ b/src/Responses/UpdateProjectResponse.php @@ -0,0 +1,16 @@ + \Lettermint\Objects\ProjectData::class, + ]; +} diff --git a/src/Responses/UpdateRouteResponse.php b/src/Responses/UpdateRouteResponse.php new file mode 100644 index 0000000..10fcc9e --- /dev/null +++ b/src/Responses/UpdateRouteResponse.php @@ -0,0 +1,16 @@ + \Lettermint\Objects\RouteData::class, + ]; +} diff --git a/src/Responses/UpdateTeamResponse.php b/src/Responses/UpdateTeamResponse.php new file mode 100644 index 0000000..bb33cad --- /dev/null +++ b/src/Responses/UpdateTeamResponse.php @@ -0,0 +1,16 @@ + \Lettermint\Objects\TeamData::class, + ]; +} diff --git a/src/Responses/UpdateWebhookResponse.php b/src/Responses/UpdateWebhookResponse.php new file mode 100644 index 0000000..0a9ef15 --- /dev/null +++ b/src/Responses/UpdateWebhookResponse.php @@ -0,0 +1,16 @@ + \Lettermint\Objects\WebhookData::class, + ]; +} diff --git a/src/Responses/VerifyDnsRecordResponse.php b/src/Responses/VerifyDnsRecordResponse.php new file mode 100644 index 0000000..0eea610 --- /dev/null +++ b/src/Responses/VerifyDnsRecordResponse.php @@ -0,0 +1,13 @@ + $data + */ +final class VerifyInboundDomainResponse extends Resource +{ + // +} diff --git a/src/Responses/WebhookDeliveriesResponse.php b/src/Responses/WebhookDeliveriesResponse.php new file mode 100644 index 0000000..4a1ad56 --- /dev/null +++ b/src/Responses/WebhookDeliveriesResponse.php @@ -0,0 +1,21 @@ + $data + * @property string|null $path + * @property int $per_page + * @property string|null $next_cursor + * @property string|null $next_page_url + * @property string|null $prev_cursor + * @property string|null $prev_page_url + */ +final class WebhookDeliveriesResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Objects\WebhookDeliveryListData::class], + ]; +} diff --git a/src/Responses/WebhookDeliveryResponse.php b/src/Responses/WebhookDeliveryResponse.php new file mode 100644 index 0000000..d929312 --- /dev/null +++ b/src/Responses/WebhookDeliveryResponse.php @@ -0,0 +1,25 @@ + $payload + * @property string|null $response_body + * @property list|null $response_headers + * @property string|null $error_message + * @property string|null $delivered_at + * @property string $timestamp + */ +final class WebhookDeliveryResponse extends Resource +{ + // +} diff --git a/src/Responses/WebhookListResponse.php b/src/Responses/WebhookListResponse.php new file mode 100644 index 0000000..0c64ff8 --- /dev/null +++ b/src/Responses/WebhookListResponse.php @@ -0,0 +1,21 @@ + $data + * @property string|null $path + * @property int $per_page + * @property string|null $next_cursor + * @property string|null $next_page_url + * @property string|null $prev_cursor + * @property string|null $prev_page_url + */ +final class WebhookListResponse extends Resource +{ + protected static array $casts = [ + 'data' => [\Lettermint\Objects\WebhookListData::class], + ]; +} diff --git a/src/Responses/WebhookResponse.php b/src/Responses/WebhookResponse.php new file mode 100644 index 0000000..ba69d01 --- /dev/null +++ b/src/Responses/WebhookResponse.php @@ -0,0 +1,23 @@ + $events + * @property bool $enabled + * @property bool $include_machine_events + * @property string $secret + * @property string|null $last_called_at + * @property string $created_at + * @property string $updated_at + */ +final class WebhookResponse extends Resource +{ + // +} diff --git a/src/Types/ApiTypes.php b/src/Types/ApiTypes.php new file mode 100644 index 0000000..193b98c --- /dev/null +++ b/src/Types/ApiTypes.php @@ -0,0 +1,116 @@ + + * @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, subject: string, tag?: string|null, html?: string|null, text?: string|null, to: non-empty-list, cc?: list, bcc?: list, reply_to?: list, headers?: array, metadata?: array, settings?: array{track_opens?: bool, track_clicks?: bool}|null, attachments?: list} + * @phpstan-type SendBatchMailRequest list + * @phpstan-type AttachmentDelivery 'inline'|'url' + * @phpstan-type DnsRecordStatus 'active'|'failed'|'pending' + * @phpstan-type DomainData array{id: string, domain: string, status_changed_at: string|null, dns_records?: list, projects?: list, created_at: string} + * @phpstan-type DomainDnsRecordData array{id: string, type: RecordType, hostname: string, fqdn: string, content: string, status: DnsRecordStatus, verified_at: string|null, last_checked_at: string|null} + * @phpstan-type DomainListData array{id: string, domain: string, status: DomainStatus, status_changed_at: string|null, created_at: string} + * @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, 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, metadata: array|null, timestamp: string} + * @phpstan-type MessageEventType 'queued'|'processed'|'suppressed'|'delivered'|'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, 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, 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' + * @phpstan-type Plan 'free'|'starter'|'growth'|'pro' + * @phpstan-type ProjectData array{id: string, name: string, smtp_enabled: bool, default_route_id: string|null, token_generated_at: string|null, token_last_used_at: string|null, token_last_used_ip: string|null, routes?: list, routes_count?: int, domains?: list, domains_count?: int, team_members?: list, team_members_count?: int, last_28_days?: MessageStatsData|null, created_at: string, updated_at: string} + * @phpstan-type ProjectListData array{id: string, name: string, smtp_enabled: bool, routes_count: int, domains_count: int, team_members_count: int, last_28_days: MessageStatsData, created_at: string, updated_at: string} + * @phpstan-type RecordType 'TXT'|'CNAME'|'MX' + * @phpstan-type RouteData array{id: string, project_id: string, slug: string, name: string, route_type: RouteType, is_default: bool, inbound_address?: string, inbound_domain?: string, inbound_domain_verified_at?: string, inbound_spam_threshold?: float|int, attachment_delivery?: AttachmentDelivery, project?: ProjectListData, webhooks_count?: int, suppressed_recipients_count?: int, statistics?: array|list, created_at: string, updated_at: string} + * @phpstan-type RouteListData array{id: string, slug: string, name: string, route_type: RouteType, is_default: bool, webhooks_count: int, suppressed_recipients_count: int, created_at: string, updated_at: string} + * @phpstan-type RouteStatisticData array{date: string, sent_count: int, delivered_count: int, opened_count: int, clicked_count: int, hard_bounce_count: int, spam_complaint_count: int, inbound_received_count: int, effective_opened_count: int|null, machine_opened_count: int|null, machine_clicked_count: int|null} + * @phpstan-type RouteType 'transactional'|'broadcast'|'inbound' + * @phpstan-type SpamSymbol array{name: string, score: float|int, options: non-empty-list, description: string|null} + * @phpstan-type StatsDailyData array{date: string, sent: int, delivered: int, hard_bounced: int, spam_complaints: int, opened: int|null, clicked: int|null, inbound: StatsInboundData, transactional: StatsTypeData|null, broadcast: StatsTypeData|null, effective_opened: int|null, machine_opened: int|null, machine_clicked: int|null} + * @phpstan-type StatsData array{from: string, to: string, totals: StatsTotalsData, daily: list} + * @phpstan-type StatsInboundData array{received: int} + * @phpstan-type StatsRequestData array{from: string, to: string, project_id?: string|null, include_machine?: bool} + * @phpstan-type StatsTotalsData array{sent: int, delivered: int, hard_bounced: int, spam_complaints: int, opened: int|null, clicked: int|null, inbound: StatsInboundData, transactional: StatsTypeData|null, broadcast: StatsTypeData|null, effective_opened: int|null, machine_opened: int|null, machine_clicked: int|null} + * @phpstan-type StatsTypeData array{sent: int, hard_bounced: int, spam_complaints: int} + * @phpstan-type StoreDomainData array{domain: string} + * @phpstan-type StoreProjectData array{name: string, smtp_enabled?: bool, initial_routes?: InitialRoutes} + * @phpstan-type StoreRouteData array{name: string, route_type: RouteType, slug?: string|null} + * @phpstan-type StoreSuppressionData array{email?: string|null, reason: SuppressionReason, scope: 'team'|'project'|'route', route_id?: string|null, project_id?: string|null, emails?: non-empty-list|null} + * @phpstan-type StoreWebhookData array{route_id: string, name: string, url: string, enabled?: bool|null, include_machine_events?: bool|null, events: non-empty-list} + * @phpstan-type SuppressedRecipientData array{id: string, type: SuppressionType, value: string, reason: SuppressionReason, scope: SuppressionScope, project_id: string|null, route_id: string|null, created_at: string, updated_at: string} + * @phpstan-type SuppressionReason 'spam_complaint'|'hard_bounce'|'unsubscribe'|'manual' + * @phpstan-type SuppressionScope 'global'|'team'|'project'|'route' + * @phpstan-type SuppressionType 'email'|'domain'|'extension' + * @phpstan-type TeamAddonData array{type: string|null, expires_at: string|null} + * @phpstan-type TeamData array{id: string, name: string, type: TeamType, plan: Plan, tier: VolumeTier, verified_at: string|null, features?: list, addons?: list, created_at: string, domains_count?: int, projects_count?: int, members_count?: int} + * @phpstan-type TeamMemberData array{id: string, user?: UserData, role: string|null, joined_at: string|null} + * @phpstan-type TeamType 'personal'|'business' + * @phpstan-type TeamUsageDetailData array{current_period: TeamUsagePeriodData, historical_usage: list} + * @phpstan-type TeamUsagePeriodData array{usage: int, last_incremented_at: string|null, period_start: string, period_end: string} + * @phpstan-type UpdateDomainProjectsData array{project_ids: non-empty-list} + * @phpstan-type UpdateProjectData array{name?: string|null, smtp_enabled?: bool|null, default_route_id?: string|null} + * @phpstan-type UpdateProjectMembersData array{team_member_ids: non-empty-list} + * @phpstan-type UpdateRouteData array{name?: string|null, settings?: array{track_opens?: bool|null, track_clicks?: bool|null, disable_hosted_unsubscribe?: bool|null}, inbound_settings?: array{inbound_domain?: string|null, inbound_spam_threshold?: float|int|null, attachment_delivery?: AttachmentDelivery}} + * @phpstan-type UpdateTeamData array{name?: string|null} + * @phpstan-type UpdateWebhookData array{name?: string, url?: string, enabled?: bool, include_machine_events?: bool, events?: non-empty-list} + * @phpstan-type UserData array{id: string, name: string, email: string, avatar: string|null} + * @phpstan-type VolumeTier 300|10000|50000|125000|500000|750000|1000000|1500000 + * @phpstan-type WebhookData array{id: string, route_id: string, name: string, url: string, events: non-empty-list, enabled: bool, include_machine_events: bool, secret?: string, last_called_at: string|null, created_at: string, updated_at: string} + * @phpstan-type WebhookDeliveryData array{id: string, webhook_id: string, event_type: WebhookEvent, status: WebhookDeliveryStatus, attempt_number: int, http_status_code: int|null, duration_ms: int|null, payload: non-empty-list, response_body: string|null, response_headers: list|null, error_message: string|null, delivered_at: string|null, timestamp: string} + * @phpstan-type WebhookDeliveryListData array{id: string, webhook_id: string, event_type: WebhookEvent, status: WebhookDeliveryStatus, attempt_number: int, http_status_code: int|null, duration_ms: int|null, delivered_at: string|null, created_at: string} + * @phpstan-type WebhookDeliveryStatus 'pending'|'success'|'failed'|'client_error'|'server_error'|'timeout' + * @phpstan-type WebhookEvent 'message.created'|'message.sent'|'message.delivered'|'message.hard_bounced'|'message.soft_bounced'|'message.spam_complaint'|'message.failed'|'message.suppressed'|'message.unsubscribed'|'message.opened'|'message.clicked'|'message.inbound'|'message.policy_rejected'|'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 SendBatchMailResponse list + * @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} + * @phpstan-type VerifyDnsRecordsResponse array{message: string} + * @phpstan-type VerifyDnsRecordResponse array{message: string} + * @phpstan-type UpdateDomainProjectsResponse array{data: DomainData, message: string} + * @phpstan-type MessageListResponse 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}|list + * @phpstan-type MessageResponse MessageData + * @phpstan-type MessageEventsResponse 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 ProjectListResponse 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 CreateProjectResponse array{data: ProjectData, message: string, api_token: string} + * @phpstan-type ProjectResponse ProjectData + * @phpstan-type UpdateProjectResponse array{data: ProjectData, message: string} + * @phpstan-type DeleteProjectResponse array{message: string} + * @phpstan-type RotateProjectTokenResponse array{data: ProjectData, new_token: string, message: string} + * @phpstan-type UpdateProjectMembersResponse array{data: ProjectData, message: string} + * @phpstan-type ProjectMemberResponse array{message: string} + * @phpstan-type ProjectRoutesResponse 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 CreateRouteResponse array{data: RouteData, message: string} + * @phpstan-type RouteResponse RouteData + * @phpstan-type UpdateRouteResponse array{data: RouteData, message: string} + * @phpstan-type DeleteRouteResponse array{message: string} + * @phpstan-type VerifyInboundDomainResponse array{data: array{verified: bool, message: string}} + * @phpstan-type StatsResponse StatsData + * @phpstan-type SuppressionListResponse 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 CreateSuppressionResponse array{message: string|'No emails were added.', data: array{created: list, skipped: list}} + * @phpstan-type DeleteSuppressionResponse array{message: string} + * @phpstan-type TeamResponse TeamData + * @phpstan-type UpdateTeamResponse array{data: TeamData, message: string} + * @phpstan-type TeamUsageResponse TeamUsageDetailData + * @phpstan-type TeamMembersResponse 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 WebhookListResponse 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 CreateWebhookResponse array{data: WebhookData, message: string} + * @phpstan-type WebhookResponse WebhookData + * @phpstan-type UpdateWebhookResponse array{data: WebhookData, message: string} + * @phpstan-type DeleteWebhookResponse array{message: string} + * @phpstan-type TestWebhookResponse array{message: string, delivery_id: string} + * @phpstan-type RegenerateWebhookSecretResponse array{data: WebhookData, message: string} + * @phpstan-type WebhookDeliveriesResponse 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 WebhookDeliveryResponse WebhookDeliveryData + */ +final class ApiTypes {} diff --git a/tests/Client/ApiClientTest.php b/tests/Client/ApiClientTest.php new file mode 100644 index 0000000..ba76f6e --- /dev/null +++ b/tests/Client/ApiClientTest.php @@ -0,0 +1,52 @@ +domains)->toBeInstanceOf(DomainsEndpoint::class); + expect($client->messages)->toBeInstanceOf(MessagesEndpoint::class); + expect($client->projects)->toBeInstanceOf(ProjectsEndpoint::class); + expect($client->routes)->toBeInstanceOf(RoutesEndpoint::class); + expect($client->stats)->toBeInstanceOf(StatsEndpoint::class); + expect($client->suppressions)->toBeInstanceOf(SuppressionsEndpoint::class); + expect($client->team)->toBeInstanceOf(TeamEndpoint::class); + expect($client->webhooks)->toBeInstanceOf(WebhooksEndpoint::class); +}); + +test('it reuses endpoint instances', function () { + $client = new ApiClient('api-token', 'http://api.example.com'); + + expect($client->projects)->toBe($client->projects); +}); + +test('it owns an http client', function () { + $client = new ApiClient('api-token', 'http://api.example.com'); + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('httpClient'); + $property->setAccessible(true); + + expect($property->getValue($client))->toBeInstanceOf(HttpClient::class); +}); + +test('it pings the API as a raw pong response', function () { + $client = new ApiClient('api-token', 'http://api.example.com'); + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('httpClient'); + $property->setAccessible(true); + $httpClient = Mockery::mock(HttpClient::class); + $httpClient->shouldReceive('getRaw')->once()->with('/v1/ping')->andReturn(' pong'); + $property->setValue($client, $httpClient); + + expect($client->ping())->toBe('pong'); +}); diff --git a/tests/Client/HttpClientAuthTest.php b/tests/Client/HttpClientAuthTest.php new file mode 100644 index 0000000..3f320ad --- /dev/null +++ b/tests/Client/HttpClientAuthTest.php @@ -0,0 +1,50 @@ + true])), + ]); + + $handlerStack = HandlerStack::create($mock); + $handlerStack->push(Middleware::history($container)); + + $guzzle = new Client(['handler' => $handlerStack]); + + $client = new HttpClient($auth, 'http://api.example.com'); + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($client, $guzzle); + + return $client; +} + +test('sending auth uses only x-lettermint-token', function () { + $container = []; + $client = makeHttpClientWithHistory(new SendingApiTokenAuth('sending-token'), $container); + + $client->get('/ping'); + + expect($container[0]['request']->getHeaderLine('x-lettermint-token'))->toBe('sending-token'); + expect($container[0]['request']->hasHeader('Authorization'))->toBeFalse(); +}); + +test('team auth uses only bearer authorization', function () { + $container = []; + $client = makeHttpClientWithHistory(new TeamBearerTokenAuth('team-token'), $container); + + $client->get('/ping'); + + expect($container[0]['request']->getHeaderLine('Authorization'))->toBe('Bearer team-token'); + expect($container[0]['request']->hasHeader('x-lettermint-token'))->toBeFalse(); +}); diff --git a/tests/Client/HttpClientTest.php b/tests/Client/HttpClientTest.php index f6d0785..8a4b319 100644 --- a/tests/Client/HttpClientTest.php +++ b/tests/Client/HttpClientTest.php @@ -1,11 +1,11 @@ apiToken = 'test-token'; @@ -40,11 +40,11 @@ test('it properly handles successful API responses', function () { $mockResponse = [ 'message_id' => '123abc', - 'status' => 'pending' + 'status' => 'pending', ]; $mock = new MockHandler([ - new Response(202, [], json_encode($mockResponse)) + new Response(202, [], json_encode($mockResponse)), ]); $handlerStack = HandlerStack::create($mock); @@ -57,7 +57,7 @@ 'headers' => [ 'Content-Type' => 'application/json', 'x-lettermint-token' => $this->apiToken, - ] + ], ]); $client = new HttpClient($this->apiToken, $this->baseUrl); @@ -69,7 +69,7 @@ $result = $client->post('/send', [ 'from' => 'test@example.com', 'to' => ['recipient@example.com'], - 'subject' => 'Test Email' + 'subject' => 'Test Email', ]); expect($result)->toBe($mockResponse); @@ -79,9 +79,43 @@ expect($container[0]['request']->getHeader('Content-Type')[0])->toBe('application/json'); }); +test('it properly handles scalar JSON API responses', function () { + $mock = new MockHandler([ + new Response(200, [], '200'), + ]); + + $handlerStack = HandlerStack::create($mock); + $mockGuzzle = new Client(['handler' => $handlerStack]); + + $client = new HttpClient($this->apiToken, $this->baseUrl); + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($client, $mockGuzzle); + + expect($client->get('/ping'))->toBe(200); +}); + +test('it properly handles raw API responses', function () { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'text/plain'], 'plain message body'), + ]); + + $handlerStack = HandlerStack::create($mock); + $mockGuzzle = new Client(['handler' => $handlerStack]); + + $client = new HttpClient($this->apiToken, $this->baseUrl); + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($client, $mockGuzzle); + + expect($client->getRaw('/messages/message-id/text'))->toBe('plain message body'); +}); + test('it throws exception on invalid JSON response', function () { $mock = new MockHandler([ - new Response(200, [], 'invalid-json') + new Response(200, [], 'invalid-json'), ]); $handlerStack = HandlerStack::create($mock); @@ -93,16 +127,16 @@ $property->setAccessible(true); $property->setValue($client, $mockGuzzle); - expect(fn() => $client->post('/send', [ + expect(fn () => $client->post('/send', [ 'from' => 'test@example.com', 'to' => ['recipient@example.com'], - 'subject' => 'Test Email' + 'subject' => 'Test Email', ]))->toThrow(\Exception::class, 'Could not decode API response'); }); test('it throws exception on API error', function () { $mock = new MockHandler([ - new Response(400, [], json_encode(['error' => 'Bad Request'])) + new Response(400, [], json_encode(['error' => 'Bad Request'])), ]); $handlerStack = HandlerStack::create($mock); @@ -114,10 +148,10 @@ $property->setAccessible(true); $property->setValue($client, $mockGuzzle); - expect(fn() => $client->post('/send', [ + expect(fn () => $client->post('/send', [ 'from' => 'test@example.com', 'to' => ['recipient@example.com'], - 'subject' => 'Test Email' + 'subject' => 'Test Email', ]))->toThrow(\Exception::class, 'API request failed'); }); diff --git a/tests/Endpoints/DomainsEndpointTest.php b/tests/Endpoints/DomainsEndpointTest.php new file mode 100644 index 0000000..c966230 --- /dev/null +++ b/tests/Endpoints/DomainsEndpointTest.php @@ -0,0 +1,59 @@ +httpClient = Mockery::mock(HttpClient::class); + $this->endpoint = new DomainsEndpoint($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('it lists domains', function () { + $query = ['filter[status]' => 'verified']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/domains', $query)->andReturn(['data' => []]); + + expect($this->endpoint->list($query)->toArray())->toBe(['data' => []]); +}); + +test('it creates domains', function () { + $data = ['domain' => 'example.com']; + $this->httpClient->shouldReceive('post')->once()->with('/v1/domains', $data, [])->andReturn(['data' => ['id' => 'domain-id']]); + + expect($this->endpoint->create($data)->toArray())->toBe(['data' => ['id' => 'domain-id']]); +}); + +test('it retrieves domains', function () { + $query = ['include' => 'dns_records']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/domains/domain-id', $query)->andReturn(['data' => ['id' => 'domain-id']]); + + expect($this->endpoint->retrieve('domain-id', $query)->toArray())->toBe(['data' => ['id' => 'domain-id']]); +}); + +test('it deletes domains', function () { + $this->httpClient->shouldReceive('delete')->once()->with('/v1/domains/domain-id', [])->andReturn(['deleted' => true]); + + expect($this->endpoint->delete('domain-id')->toArray())->toBe(['deleted' => true]); +}); + +test('it verifies all dns records', function () { + $this->httpClient->shouldReceive('post')->once()->with('/v1/domains/domain-id/dns-records/verify', [], [])->andReturn(['data' => []]); + + expect($this->endpoint->verifyDnsRecords('domain-id')->toArray())->toBe(['data' => []]); +}); + +test('it verifies a specific dns record', function () { + $this->httpClient->shouldReceive('post')->once()->with('/v1/domains/domain-id/dns-records/record-id/verify', [], [])->andReturn(['data' => []]); + + expect($this->endpoint->verifyDnsRecord('domain-id', 'record-id')->toArray())->toBe(['data' => []]); +}); + +test('it updates domain projects', function () { + $data = ['project_ids' => ['project-id']]; + $this->httpClient->shouldReceive('put')->once()->with('/v1/domains/domain-id/projects', $data, [])->andReturn(['data' => []]); + + expect($this->endpoint->updateProjects('domain-id', $data)->toArray())->toBe(['data' => []]); +}); diff --git a/tests/Endpoints/EmailEndpointTest.php b/tests/Endpoints/EmailEndpointTest.php index 077ed10..7f3a120 100644 --- a/tests/Endpoints/EmailEndpointTest.php +++ b/tests/Endpoints/EmailEndpointTest.php @@ -2,6 +2,8 @@ use Lettermint\Client\HttpClient; use Lettermint\Endpoints\EmailEndpoint; +use Lettermint\Responses\SendBatchMailResponse; +use Lettermint\Responses\SendMailResponse; beforeEach(function () { $this->httpClient = Mockery::mock(HttpClient::class); @@ -12,21 +14,20 @@ Mockery::close(); }); -test('it documents the send response shape', function () { +test('it exposes typed send response classes', function () { $classReflection = new ReflectionClass(EmailEndpoint::class); $classDocComment = $classReflection->getDocComment(); $methodReflection = new ReflectionMethod(EmailEndpoint::class, 'send'); - $methodDocComment = $methodReflection->getDocComment(); + $batchMethodReflection = new ReflectionMethod(EmailEndpoint::class, 'sendBatch'); expect($classDocComment) ->toBeString() - ->toContain('@phpstan-type SendResponse') - ->toContain('message_id: string') - ->toContain('status: string'); + ->toContain('@phpstan-import-type SendMailRequest') + ->toContain('@phpstan-import-type SendBatchMailRequest') + ->not->toContain('@phpstan-type SendResponse'); - expect($methodDocComment) - ->toBeString() - ->toContain('@phpstan-return SendResponse'); + expect($methodReflection->getReturnType()?->getName())->toBe(SendMailResponse::class); + expect($batchMethodReflection->getReturnType()?->getName())->toBe(SendBatchMailResponse::class); }); test('it builds email with basic required fields', function () { @@ -46,7 +47,7 @@ ->subject('Test Subject') ->send(); - expect($response)->toBe(['message_id' => '123', 'status' => 'pending']); + expect($response->toArray())->toBe(['message_id' => '123', 'status' => 'pending']); }); test('it supports multiple recipients', function () { @@ -202,6 +203,101 @@ ->send(); }); +test('it sends direct array payloads', function () { + $payload = [ + 'from' => 'sender@example.com', + 'to' => ['recipient@example.com'], + 'subject' => 'Test Subject', + 'text' => 'Plain text content', + ]; + + $this->httpClient + ->shouldReceive('post') + ->once() + ->with('/v1/send', $payload, []) + ->andReturn(['message_id' => '123', 'status' => 'pending']); + + $response = $this->endpoint->send($payload); + + expect($response->toArray())->toBe(['message_id' => '123', 'status' => 'pending']); +}); + +test('it sends batch payloads', function () { + $messages = [ + [ + 'from' => 'sender@example.com', + 'to' => ['recipient@example.com'], + 'subject' => 'Test Subject', + ], + ]; + + $this->httpClient + ->shouldReceive('post') + ->once() + ->with('/v1/send/batch', $messages, []) + ->andReturn(['data' => [['message_id' => '123', 'status' => 'pending']]]); + + $response = $this->endpoint->sendBatch($messages); + + expect($response->toArray())->toBe(['data' => [['message_id' => '123', 'status' => 'pending']]]); +}); + +test('it pings the sending API', function () { + $this->httpClient + ->shouldReceive('getRaw') + ->once() + ->with('/v1/ping', []) + ->andReturn(' pong'); + + expect($this->endpoint->ping())->toBe('pong'); +}); + +test('it handles per email settings', function () { + $this->httpClient + ->shouldReceive('post') + ->once() + ->with('/v1/send', [ + 'from' => 'sender@example.com', + 'to' => ['recipient@example.com'], + 'subject' => 'Test Subject', + 'settings' => ['track_opens' => false, 'track_clicks' => true], + ], []) + ->andReturn(['message_id' => '123', 'status' => 'pending']); + + $this->endpoint + ->from('sender@example.com') + ->to('recipient@example.com') + ->subject('Test Subject') + ->settings(['track_opens' => false, 'track_clicks' => true]) + ->send(); +}); + +test('it handles attachment content type', function () { + $attachment = [ + 'filename' => 'invite.ics', + 'content' => 'base64encodedcalendar', + 'content_type' => 'text/calendar; method=REQUEST', + ]; + + $this->httpClient + ->shouldReceive('post') + ->once() + ->with('/v1/send', [ + 'from' => 'sender@example.com', + 'to' => ['recipient@example.com'], + 'subject' => 'Test Subject', + 'attachments' => [$attachment], + ], []) + ->andReturn(['message_id' => '123', 'status' => 'pending']); + + $this->endpoint + ->from('sender@example.com') + ->to('recipient@example.com') + ->subject('Test Subject') + ->attach('invite.ics', 'base64encodedcalendar', null, 'text/calendar; method=REQUEST') + ->send(); +}); + test('it handles custom headers', function () { $this->httpClient ->shouldReceive('post') @@ -258,7 +354,57 @@ ->idempotencyKey('unique-key-123') ->send(); - expect($response)->toBe(['message_id' => '123', 'status' => 'pending']); + expect($response->toArray())->toBe(['message_id' => '123', 'status' => 'pending']); +}); + +test('it resets builder state after failed send', function () { + $this->httpClient + ->shouldReceive('post') + ->once() + ->with('/v1/send', [ + 'from' => 'sender@example.com', + 'to' => ['first@example.com'], + 'subject' => 'First', + 'attachments' => [[ + 'filename' => 'secret.pdf', + 'content' => 'base64secret', + ]], + 'metadata' => ['invoice' => '123'], + 'headers' => ['X-Secret' => 'keep-out'], + ], ['Idempotency-Key' => 'first-key']) + ->andThrow(new RuntimeException('API unavailable')); + + try { + $this->endpoint + ->from('sender@example.com') + ->to('first@example.com') + ->subject('First') + ->attach('secret.pdf', 'base64secret') + ->metadata(['invoice' => '123']) + ->headers(['X-Secret' => 'keep-out']) + ->idempotencyKey('first-key') + ->send(); + + throw new RuntimeException('Expected send to fail.'); + } catch (RuntimeException $exception) { + expect($exception->getMessage())->toBe('API unavailable'); + } + + $this->httpClient + ->shouldReceive('post') + ->once() + ->with('/v1/send', [ + 'from' => 'sender@example.com', + 'to' => ['second@example.com'], + 'subject' => 'Second', + ], []) + ->andReturn(['message_id' => '456', 'status' => 'pending']); + + $this->endpoint + ->from('sender@example.com') + ->to('second@example.com') + ->subject('Second') + ->send(); }); test('it sends without idempotency key when not set', function () { diff --git a/tests/Endpoints/EndpointTest.php b/tests/Endpoints/EndpointTest.php index d8e6a9b..30d21ab 100644 --- a/tests/Endpoints/EndpointTest.php +++ b/tests/Endpoints/EndpointTest.php @@ -31,7 +31,8 @@ class TestEndpoint extends Endpoint }); test('it allows access to protected http client in child classes', function () { - $testEndpoint = new class($this->httpClient) extends Endpoint { + $testEndpoint = new class($this->httpClient) extends Endpoint + { public function getHttpClient(): HttpClient { return $this->httpClient; @@ -40,3 +41,29 @@ public function getHttpClient(): HttpClient expect($testEndpoint->getHttpClient())->toBe($this->httpClient); }); + +test('it builds encoded versioned paths', function () { + $testEndpoint = new class($this->httpClient) extends Endpoint + { + public function publicPath(string $path, array $parameters = []): string + { + return $this->path($path, $parameters); + } + }; + + expect($testEndpoint->publicPath('/domains/{domainId}', ['domainId' => 'abc/123'])) + ->toBe('/v1/domains/abc%2F123'); +}); + +test('it requires path parameters', function () { + $testEndpoint = new class($this->httpClient) extends Endpoint + { + public function publicPath(string $path, array $parameters = []): string + { + return $this->path($path, $parameters); + } + }; + + expect(fn () => $testEndpoint->publicPath('/domains/{domainId}')) + ->toThrow(InvalidArgumentException::class, 'Missing path parameter: domainId'); +}); diff --git a/tests/Endpoints/MessagesEndpointTest.php b/tests/Endpoints/MessagesEndpointTest.php new file mode 100644 index 0000000..c1f0044 --- /dev/null +++ b/tests/Endpoints/MessagesEndpointTest.php @@ -0,0 +1,51 @@ +httpClient = Mockery::mock(HttpClient::class); + $this->endpoint = new MessagesEndpoint($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('it lists messages', function () { + $query = ['filter[status]' => 'delivered']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/messages', $query)->andReturn(['data' => []]); + + expect($this->endpoint->list($query)->toArray())->toBe(['data' => []]); +}); + +test('it retrieves messages', function () { + $this->httpClient->shouldReceive('get')->once()->with('/v1/messages/message-id', [])->andReturn(['data' => ['id' => 'message-id']]); + + expect($this->endpoint->retrieve('message-id')->toArray())->toBe(['data' => ['id' => 'message-id']]); +}); + +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' => []]); + + expect($this->endpoint->events('message-id', $query)->toArray())->toBe(['data' => []]); +}); + +test('it retrieves message source', function () { + $this->httpClient->shouldReceive('getRaw')->once()->with('/v1/messages/message-id/source', [])->andReturn('raw'); + + expect($this->endpoint->source('message-id'))->toBe('raw'); +}); + +test('it retrieves message html', function () { + $this->httpClient->shouldReceive('getRaw')->once()->with('/v1/messages/message-id/html', [])->andReturn('

Hello

'); + + expect($this->endpoint->html('message-id'))->toBe('

Hello

'); +}); + +test('it retrieves message text', function () { + $this->httpClient->shouldReceive('getRaw')->once()->with('/v1/messages/message-id/text', [])->andReturn('Hello'); + + expect($this->endpoint->text('message-id'))->toBe('Hello'); +}); diff --git a/tests/Endpoints/ProjectsEndpointTest.php b/tests/Endpoints/ProjectsEndpointTest.php new file mode 100644 index 0000000..ef63655 --- /dev/null +++ b/tests/Endpoints/ProjectsEndpointTest.php @@ -0,0 +1,86 @@ +httpClient = Mockery::mock(HttpClient::class); + $this->endpoint = new ProjectsEndpoint($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('it lists projects', function () { + $query = ['filter[search]' => 'production']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/projects', $query)->andReturn(['data' => []]); + + expect($this->endpoint->list($query)->toArray())->toBe(['data' => []]); +}); + +test('it creates projects', function () { + $data = ['name' => 'Production']; + $this->httpClient->shouldReceive('post')->once()->with('/v1/projects', $data, [])->andReturn(['data' => ['id' => 'project-id']]); + + expect($this->endpoint->create($data)->toArray())->toBe(['data' => ['id' => 'project-id']]); +}); + +test('it retrieves projects', function () { + $query = ['include' => 'routes']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/projects/project-id', $query)->andReturn(['data' => ['id' => 'project-id']]); + + expect($this->endpoint->retrieve('project-id', $query)->toArray())->toBe(['data' => ['id' => 'project-id']]); +}); + +test('it updates projects', function () { + $data = ['name' => 'Production']; + $this->httpClient->shouldReceive('put')->once()->with('/v1/projects/project-id', $data, [])->andReturn(['data' => ['id' => 'project-id']]); + + expect($this->endpoint->update('project-id', $data)->toArray())->toBe(['data' => ['id' => 'project-id']]); +}); + +test('it deletes projects', function () { + $this->httpClient->shouldReceive('delete')->once()->with('/v1/projects/project-id', [])->andReturn(['deleted' => true]); + + expect($this->endpoint->delete('project-id')->toArray())->toBe(['deleted' => true]); +}); + +test('it rotates project tokens', function () { + $this->httpClient->shouldReceive('post')->once()->with('/v1/projects/project-id/rotate-token', [], [])->andReturn(['token' => 'new-token']); + + expect($this->endpoint->rotateToken('project-id')->toArray())->toBe(['token' => 'new-token']); +}); + +test('it updates project members', function () { + $data = ['members' => ['member-id']]; + $this->httpClient->shouldReceive('put')->once()->with('/v1/projects/project-id/members', $data, [])->andReturn(['data' => []]); + + expect($this->endpoint->updateMembers('project-id', $data)->toArray())->toBe(['data' => []]); +}); + +test('it adds project members', function () { + $this->httpClient->shouldReceive('post')->once()->with('/v1/projects/project-id/members/member-id', [], [])->andReturn(['data' => []]); + + expect($this->endpoint->addMember('project-id', 'member-id')->toArray())->toBe(['data' => []]); +}); + +test('it removes project members', function () { + $this->httpClient->shouldReceive('delete')->once()->with('/v1/projects/project-id/members/member-id', [])->andReturn(['data' => []]); + + expect($this->endpoint->removeMember('project-id', 'member-id')->toArray())->toBe(['data' => []]); +}); + +test('it lists project routes', function () { + $query = ['filter[route_type]' => 'outbound']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/projects/project-id/routes', $query)->andReturn(['data' => []]); + + expect($this->endpoint->routes('project-id', $query)->toArray())->toBe(['data' => []]); +}); + +test('it creates project routes', function () { + $data = ['name' => 'Default']; + $this->httpClient->shouldReceive('post')->once()->with('/v1/projects/project-id/routes', $data, [])->andReturn(['data' => ['id' => 'route-id']]); + + expect($this->endpoint->createRoute('project-id', $data)->toArray())->toBe(['data' => ['id' => 'route-id']]); +}); diff --git a/tests/Endpoints/RoutesEndpointTest.php b/tests/Endpoints/RoutesEndpointTest.php new file mode 100644 index 0000000..45f8790 --- /dev/null +++ b/tests/Endpoints/RoutesEndpointTest.php @@ -0,0 +1,39 @@ +httpClient = Mockery::mock(HttpClient::class); + $this->endpoint = new RoutesEndpoint($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('it retrieves routes', function () { + $query = ['include' => 'domain']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/routes/route-id', $query)->andReturn(['data' => ['id' => 'route-id']]); + + expect($this->endpoint->retrieve('route-id', $query)->toArray())->toBe(['data' => ['id' => 'route-id']]); +}); + +test('it updates routes', function () { + $data = ['name' => 'Default']; + $this->httpClient->shouldReceive('put')->once()->with('/v1/routes/route-id', $data, [])->andReturn(['data' => ['id' => 'route-id']]); + + expect($this->endpoint->update('route-id', $data)->toArray())->toBe(['data' => ['id' => 'route-id']]); +}); + +test('it deletes routes', function () { + $this->httpClient->shouldReceive('delete')->once()->with('/v1/routes/route-id', [])->andReturn(['deleted' => true]); + + expect($this->endpoint->delete('route-id')->toArray())->toBe(['deleted' => true]); +}); + +test('it verifies inbound domains', function () { + $this->httpClient->shouldReceive('post')->once()->with('/v1/routes/route-id/verify-inbound-domain', [], [])->andReturn(['data' => []]); + + expect($this->endpoint->verifyInboundDomain('route-id')->toArray())->toBe(['data' => []]); +}); diff --git a/tests/Endpoints/StatsEndpointTest.php b/tests/Endpoints/StatsEndpointTest.php new file mode 100644 index 0000000..263a6ec --- /dev/null +++ b/tests/Endpoints/StatsEndpointTest.php @@ -0,0 +1,20 @@ +httpClient = Mockery::mock(HttpClient::class); + $this->endpoint = new StatsEndpoint($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('it retrieves stats', function () { + $query = ['from' => '2026-05-01', 'to' => '2026-05-09']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/stats', $query)->andReturn(['data' => []]); + + expect($this->endpoint->retrieve($query)->toArray())->toBe(['data' => []]); +}); diff --git a/tests/Endpoints/SuppressionsEndpointTest.php b/tests/Endpoints/SuppressionsEndpointTest.php new file mode 100644 index 0000000..f023d36 --- /dev/null +++ b/tests/Endpoints/SuppressionsEndpointTest.php @@ -0,0 +1,33 @@ +httpClient = Mockery::mock(HttpClient::class); + $this->endpoint = new SuppressionsEndpoint($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('it lists suppressions', function () { + $query = ['filter[value]' => 'user@example.com']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/suppressions', $query)->andReturn(['data' => []]); + + expect($this->endpoint->list($query)->toArray())->toBe(['data' => []]); +}); + +test('it creates suppressions', function () { + $data = ['email' => 'user@example.com', 'reason' => 'manual', 'scope' => 'team']; + $this->httpClient->shouldReceive('post')->once()->with('/v1/suppressions', $data, [])->andReturn(['data' => ['id' => 'suppression-id']]); + + expect($this->endpoint->create($data)->toArray())->toBe(['data' => ['id' => 'suppression-id']]); +}); + +test('it deletes suppressions', function () { + $this->httpClient->shouldReceive('delete')->once()->with('/v1/suppressions/suppression-id', [])->andReturn(['deleted' => true]); + + expect($this->endpoint->delete('suppression-id')->toArray())->toBe(['deleted' => true]); +}); diff --git a/tests/Endpoints/TeamEndpointTest.php b/tests/Endpoints/TeamEndpointTest.php new file mode 100644 index 0000000..8703679 --- /dev/null +++ b/tests/Endpoints/TeamEndpointTest.php @@ -0,0 +1,40 @@ +httpClient = Mockery::mock(HttpClient::class); + $this->endpoint = new TeamEndpoint($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('it retrieves the team', function () { + $query = ['include' => 'billing']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/team', $query)->andReturn(['data' => ['id' => 'team-id']]); + + expect($this->endpoint->retrieve($query)->toArray())->toBe(['data' => ['id' => 'team-id']]); +}); + +test('it updates the team', function () { + $data = ['name' => 'Lettermint']; + $this->httpClient->shouldReceive('put')->once()->with('/v1/team', $data, [])->andReturn(['data' => ['id' => 'team-id']]); + + expect($this->endpoint->update($data)->toArray())->toBe(['data' => ['id' => 'team-id']]); +}); + +test('it retrieves team usage', function () { + $this->httpClient->shouldReceive('get')->once()->with('/v1/team/usage', [])->andReturn(['data' => []]); + + expect($this->endpoint->usage()->toArray())->toBe(['data' => []]); +}); + +test('it retrieves team members', function () { + $query = ['page[size]' => 10]; + $this->httpClient->shouldReceive('get')->once()->with('/v1/team/members', $query)->andReturn(['data' => []]); + + expect($this->endpoint->members($query)->toArray())->toBe(['data' => []]); +}); diff --git a/tests/Endpoints/TypedResponseTest.php b/tests/Endpoints/TypedResponseTest.php new file mode 100644 index 0000000..d2c6b0a --- /dev/null +++ b/tests/Endpoints/TypedResponseTest.php @@ -0,0 +1,82 @@ +shouldReceive('post') + ->once() + ->with('/v1/send', ['from' => 'sender@example.com', 'to' => ['user@example.com'], 'subject' => 'Hi'], []) + ->andReturn(['message_id' => 'message-id', 'status' => 'queued']); + + $response = $endpoint->send([ + 'from' => 'sender@example.com', + 'to' => ['user@example.com'], + 'subject' => 'Hi', + ]); + + expect($response)->toBeInstanceOf(SendMailResponse::class) + ->and($response->message_id)->toBe('message-id') + ->and($response->status)->toBe('queued') + ->and($response->toArray())->toBe(['message_id' => 'message-id', 'status' => 'queued']); +}); + +test('batch sending wraps list responses in typed resources', function () { + $httpClient = Mockery::mock(HttpClient::class); + $endpoint = new EmailEndpoint($httpClient); + + $httpClient + ->shouldReceive('post') + ->once() + ->with('/v1/send/batch', [['from' => 'sender@example.com', 'to' => ['user@example.com'], 'subject' => 'Hi']], []) + ->andReturn([['message_id' => 'message-id', 'status' => 'queued']]); + + $response = $endpoint->sendBatch([ + ['from' => 'sender@example.com', 'to' => ['user@example.com'], 'subject' => 'Hi'], + ]); + + expect($response)->toBeInstanceOf(SendBatchMailResponse::class) + ->and($response->data[0])->toBeInstanceOf(SendMailResponse::class) + ->and($response->data[0]->message_id)->toBe('message-id'); +}); + +test('paginated endpoints hydrate typed data resources', function () { + $httpClient = Mockery::mock(HttpClient::class); + $endpoint = new DomainsEndpoint($httpClient); + + $httpClient + ->shouldReceive('get') + ->once() + ->with('/v1/domains', []) + ->andReturn([ + 'data' => [ + [ + 'id' => 'domain-id', + 'domain' => 'example.com', + 'status' => 'verified', + 'status_changed_at' => null, + 'created_at' => '2026-05-10T00:00:00Z', + ], + ], + 'path' => null, + 'per_page' => 10, + 'next_cursor' => null, + 'next_page_url' => null, + 'prev_cursor' => null, + 'prev_page_url' => null, + ]); + + $response = $endpoint->list(); + + expect($response)->toBeInstanceOf(DomainListResponse::class) + ->and($response->data[0]->domain)->toBe('example.com') + ->and($response->data[0]->status)->toBe('verified'); +}); diff --git a/tests/Endpoints/WebhooksEndpointTest.php b/tests/Endpoints/WebhooksEndpointTest.php new file mode 100644 index 0000000..dcdc909 --- /dev/null +++ b/tests/Endpoints/WebhooksEndpointTest.php @@ -0,0 +1,71 @@ +httpClient = Mockery::mock(HttpClient::class); + $this->endpoint = new WebhooksEndpoint($this->httpClient); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('it lists webhooks', function () { + $query = ['filter[enabled]' => true]; + $this->httpClient->shouldReceive('get')->once()->with('/v1/webhooks', $query)->andReturn(['data' => []]); + + expect($this->endpoint->list($query)->toArray())->toBe(['data' => []]); +}); + +test('it creates webhooks', function () { + $data = ['route_id' => 'route-id', 'name' => 'Webhook', 'url' => 'https://example.com', 'events' => ['message.sent']]; + $this->httpClient->shouldReceive('post')->once()->with('/v1/webhooks', $data, [])->andReturn(['data' => ['id' => 'webhook-id']]); + + expect($this->endpoint->create($data)->toArray())->toBe(['data' => ['id' => 'webhook-id']]); +}); + +test('it retrieves webhooks', function () { + $this->httpClient->shouldReceive('get')->once()->with('/v1/webhooks/webhook-id', [])->andReturn(['data' => ['id' => 'webhook-id']]); + + expect($this->endpoint->retrieve('webhook-id')->toArray())->toBe(['data' => ['id' => 'webhook-id']]); +}); + +test('it updates webhooks', function () { + $data = ['name' => 'Webhook']; + $this->httpClient->shouldReceive('put')->once()->with('/v1/webhooks/webhook-id', $data, [])->andReturn(['data' => ['id' => 'webhook-id']]); + + expect($this->endpoint->update('webhook-id', $data)->toArray())->toBe(['data' => ['id' => 'webhook-id']]); +}); + +test('it deletes webhooks', function () { + $this->httpClient->shouldReceive('delete')->once()->with('/v1/webhooks/webhook-id', [])->andReturn(['deleted' => true]); + + expect($this->endpoint->delete('webhook-id')->toArray())->toBe(['deleted' => true]); +}); + +test('it tests webhooks', function () { + $this->httpClient->shouldReceive('post')->once()->with('/v1/webhooks/webhook-id/test', [], [])->andReturn(['queued' => true]); + + expect($this->endpoint->test('webhook-id')->toArray())->toBe(['queued' => true]); +}); + +test('it regenerates webhook secrets', function () { + $this->httpClient->shouldReceive('post')->once()->with('/v1/webhooks/webhook-id/regenerate-secret', [], [])->andReturn(['secret' => 'secret']); + + expect($this->endpoint->regenerateSecret('webhook-id')->toArray())->toBe(['secret' => 'secret']); +}); + +test('it lists webhook deliveries', function () { + $query = ['filter[status]' => 'failed']; + $this->httpClient->shouldReceive('get')->once()->with('/v1/webhooks/webhook-id/deliveries', $query)->andReturn(['data' => []]); + + expect($this->endpoint->deliveries('webhook-id', $query)->toArray())->toBe(['data' => []]); +}); + +test('it retrieves webhook deliveries', function () { + $this->httpClient->shouldReceive('get')->once()->with('/v1/webhooks/webhook-id/deliveries/delivery-id', [])->andReturn(['data' => ['id' => 'delivery-id']]); + + expect($this->endpoint->delivery('webhook-id', 'delivery-id')->toArray())->toBe(['data' => ['id' => 'delivery-id']]); +}); diff --git a/tests/LettermintTest.php b/tests/LettermintTest.php index 4e70e2f..46d054d 100644 --- a/tests/LettermintTest.php +++ b/tests/LettermintTest.php @@ -1,8 +1,9 @@ apiToken = 'test-token'; @@ -46,3 +47,11 @@ expect($property->getValue($client))->toBe('https://api.lettermint.co/v1'); }); + +test('it creates an api client', function () { + expect(Lettermint::api('api-token', $this->baseUrl))->toBeInstanceOf(ApiClient::class); +}); + +test('it creates a direct email sending builder', function () { + expect(Lettermint::email($this->apiToken, $this->baseUrl))->toBeInstanceOf(EmailEndpoint::class); +}); diff --git a/tests/SdkCoverageTest.php b/tests/SdkCoverageTest.php new file mode 100644 index 0000000..2067da4 --- /dev/null +++ b/tests/SdkCoverageTest.php @@ -0,0 +1,177 @@ +toHaveCount(49); + + $missing = []; + + foreach ($operations as [$api, $operationId, $class, $method]) { + if (! method_exists($class, $method)) { + $missing[] = "$api operation $operationId on $class::$method"; + } + } + + expect($missing)->toBe([]); +}); + +test('it uses concrete response classes for every documented endpoint response', function () { + $typesSource = file_get_contents(__DIR__.'/../src/Types/ApiTypes.php'); + + $endpointFiles = [ + __DIR__.'/../src/Endpoints/EmailEndpoint.php', + __DIR__.'/../src/Endpoints/DomainsEndpoint.php', + __DIR__.'/../src/Endpoints/MessagesEndpoint.php', + __DIR__.'/../src/Endpoints/ProjectsEndpoint.php', + __DIR__.'/../src/Endpoints/RoutesEndpoint.php', + __DIR__.'/../src/Endpoints/StatsEndpoint.php', + __DIR__.'/../src/Endpoints/SuppressionsEndpoint.php', + __DIR__.'/../src/Endpoints/TeamEndpoint.php', + __DIR__.'/../src/Endpoints/WebhooksEndpoint.php', + ]; + + $endpointSource = implode("\n", array_map(fn (string $file): string => file_get_contents($file), $endpointFiles)); + + $expectedTypes = [ + 'SendMailResponse', + 'SendBatchMailResponse', + 'DomainListResponse', + 'DomainResponse', + 'DeleteDomainResponse', + 'VerifyDnsRecordsResponse', + 'VerifyDnsRecordResponse', + 'UpdateDomainProjectsResponse', + 'MessageListResponse', + 'MessageResponse', + 'MessageEventsResponse', + 'ProjectListResponse', + 'CreateProjectResponse', + 'ProjectResponse', + 'UpdateProjectResponse', + 'DeleteProjectResponse', + 'RotateProjectTokenResponse', + 'UpdateProjectMembersResponse', + 'ProjectMemberResponse', + 'ProjectRoutesResponse', + 'CreateRouteResponse', + 'RouteResponse', + 'UpdateRouteResponse', + 'DeleteRouteResponse', + 'VerifyInboundDomainResponse', + 'StatsResponse', + 'SuppressionListResponse', + 'CreateSuppressionResponse', + 'DeleteSuppressionResponse', + 'TeamResponse', + 'UpdateTeamResponse', + 'TeamUsageResponse', + 'TeamMembersResponse', + 'WebhookListResponse', + 'CreateWebhookResponse', + 'WebhookResponse', + 'UpdateWebhookResponse', + 'DeleteWebhookResponse', + 'TestWebhookResponse', + 'RegenerateWebhookSecretResponse', + 'WebhookDeliveriesResponse', + 'WebhookDeliveryResponse', + ]; + + foreach ($expectedTypes as $type) { + $responseSource = file_get_contents(__DIR__."/../src/Responses/{$type}.php"); + + expect($typesSource)->toContain("@phpstan-type {$type}"); + expect($responseSource)->toBeString() + ->toContain('extends Resource') + ->toContain('@property'); + expect($endpointSource)->toContain(": {$type}"); + } + + expect($endpointSource)->not->toContain('@phpstan-return ApiObject'); + expect($endpointSource)->not->toContain('@phpstan-return CursorPage'); + expect($endpointSource)->not->toContain('@phpstan-type SendResponse'); +}); + +test('paginated php response types include concrete data item shapes', function () { + $typesSource = file_get_contents(__DIR__.'/../src/Types/ApiTypes.php'); + + $expectedDataTypes = [ + 'DomainListResponse' => 'DomainListData', + 'MessageListResponse' => 'MessageListData', + 'MessageEventsResponse' => 'MessageEventData', + 'ProjectListResponse' => 'ProjectListData', + 'ProjectRoutesResponse' => 'RouteListData', + 'SuppressionListResponse' => 'SuppressedRecipientData', + 'TeamMembersResponse' => 'TeamMemberData', + 'WebhookListResponse' => 'WebhookListData', + 'WebhookDeliveriesResponse' => 'WebhookDeliveryListData', + ]; + + foreach ($expectedDataTypes as $responseType => $dataType) { + expect($typesSource)->toContain("@phpstan-type {$responseType} array{"); + expect($typesSource)->toContain("data: list<{$dataType}>"); + expect($typesSource)->not->toContain("@phpstan-type {$responseType} CursorPage"); + } +}); diff --git a/tests/Security/AuthBoundaryTest.php b/tests/Security/AuthBoundaryTest.php new file mode 100644 index 0000000..f2ff9db --- /dev/null +++ b/tests/Security/AuthBoundaryTest.php @@ -0,0 +1,53 @@ +push(Middleware::history($container)); + + $guzzle = new Client(['handler' => $handlerStack]); + + $sdkReflection = new ReflectionClass($sdkClient); + $httpClientProperty = $sdkReflection->getProperty('httpClient'); + $httpClientProperty->setAccessible(true); + $httpClient = $httpClientProperty->getValue($sdkClient); + + $httpReflection = new ReflectionClass($httpClient); + $guzzleProperty = $httpReflection->getProperty('client'); + $guzzleProperty->setAccessible(true); + $guzzleProperty->setValue($httpClient, $guzzle); +} + +test('email builder sends only sending auth', function () { + $container = []; + $client = Lettermint::email('sending-secret', 'http://api.example.com'); + replaceWrappedGuzzleClient($client, $container); + + $client->ping(); + + expect($container[0]['request']->getHeaderLine('x-lettermint-token'))->toBe('sending-secret'); + expect($container[0]['request']->hasHeader('Authorization'))->toBeFalse(); +}); + +test('api client sends only bearer auth', function () { + $container = []; + $client = new ApiClient('team-secret', 'http://api.example.com'); + replaceWrappedGuzzleClient($client, $container); + + $client->ping(); + + expect($container[0]['request']->getHeaderLine('Authorization'))->toBe('Bearer team-secret'); + expect($container[0]['request']->hasHeader('x-lettermint-token'))->toBeFalse(); +}); diff --git a/tests/Security/TokenRedactionTest.php b/tests/Security/TokenRedactionTest.php new file mode 100644 index 0000000..4a4d78d --- /dev/null +++ b/tests/Security/TokenRedactionTest.php @@ -0,0 +1,58 @@ + HandlerStack::create($mock)]); + + $client = new HttpClient($auth, 'http://api.example.com'); + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('client'); + $property->setAccessible(true); + $property->setValue($client, $guzzle); + + return $client; +} + +test('it redacts sending tokens from exception messages', function () { + $client = httpClientFailingWith('Request failed with sending-secret', new SendingApiTokenAuth('sending-secret')); + + try { + $client->get('/v1/ping'); + } catch (Exception $exception) { + expect($exception->getMessage())->not->toContain('sending-secret'); + expect($exception->getMessage())->toContain('[redacted]'); + + return; + } + + $this->fail('Expected exception was not thrown.'); +}); + +test('it redacts team bearer tokens from exception messages', function () { + $client = httpClientFailingWith('Request failed with Bearer team-secret', new TeamBearerTokenAuth('team-secret')); + + try { + $client->get('/v1/ping'); + } catch (Exception $exception) { + expect($exception->getMessage())->not->toContain('team-secret'); + expect($exception->getMessage())->toContain('[redacted]'); + + return; + } + + $this->fail('Expected exception was not thrown.'); +}); diff --git a/tests/Types/ResourceTest.php b/tests/Types/ResourceTest.php new file mode 100644 index 0000000..204309f --- /dev/null +++ b/tests/Types/ResourceTest.php @@ -0,0 +1,42 @@ + TestChildResource::class, + 'children' => [TestChildResource::class], + ]; +} + +test('resources expose attributes as properties and arrays', function () { + $resource = new TestParentResource([ + 'id' => 'parent-id', + 'child' => ['id' => 'child-id'], + 'children' => [ + ['id' => 'first-child-id'], + ['id' => 'second-child-id'], + ], + ]); + + expect($resource->id)->toBe('parent-id') + ->and($resource['id'])->toBe('parent-id') + ->and($resource->child)->toBeInstanceOf(TestChildResource::class) + ->and($resource->child->id)->toBe('child-id') + ->and($resource->children[0])->toBeInstanceOf(TestChildResource::class) + ->and($resource->children[1]->id)->toBe('second-child-id') + ->and($resource->toArray())->toBe([ + 'id' => 'parent-id', + 'child' => ['id' => 'child-id'], + 'children' => [ + ['id' => 'first-child-id'], + ['id' => 'second-child-id'], + ], + ]); +});