diff --git a/README.md b/README.md index fc5eef2..b92cbc3 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,12 @@ Email Sandbox (Testing): - Project management CRUD – [`testing/projects.php`](examples/testing/projects.php) - Attachments operations – [`testing/attachments.php`](examples/testing/attachments.php) +Inbound Email API: +- Folder management CRUD – [`inbound/folders.php`](examples/inbound/folders.php) +- Inbox management CRUD – [`inbound/inboxes.php`](examples/inbound/inboxes.php) +- Message management (list / get / reply / reply-all / forward / delete) – [`inbound/messages.php`](examples/inbound/messages.php) +- Thread management (list / get / delete) – [`inbound/threads.php`](examples/inbound/threads.php) + Contact management: - Contacts CRUD & listing – [`contacts/all.php`](examples/contacts/all.php) - Contact lists CRUD – [`contact-lists/all.php`](examples/contact-lists/all.php) diff --git a/examples/README.md b/examples/README.md index 72c1633..9c66a1e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,7 +8,8 @@ Central index of runnable example scripts demonstrating Mailtrap PHP SDK feature ## Contents -### 1. Sending (Transactional / Bulk Streams) +### Sending (Transactional / Bulk Streams) + | Purpose | File | |---------|------| | Minimal transactional send | [`sending/minimal.php`](sending/minimal.php) | @@ -18,7 +19,8 @@ Central index of runnable example scripts demonstrating Mailtrap PHP SDK feature | Bulk API single send (stream selection) | [`bulk/bulk.php`](bulk/bulk.php) | | Bulk API template send | [`bulk/bulk_template.php`](bulk/bulk_template.php) | -### 2. Batch Sending (multiple messages in one call) +### Batch Sending (multiple messages in one call) + | Purpose | File | |---------|------| | Transactional batch send | [`batch/transactional.php`](batch/transactional.php) | @@ -28,7 +30,8 @@ Central index of runnable example scripts demonstrating Mailtrap PHP SDK feature | Sandbox batch send | [`batch/sandbox.php`](batch/sandbox.php) | | Sandbox batch send (template) | [`batch/sandbox_template.php`](batch/sandbox_template.php) | -### 3. Sandbox (Email Testing) +### Sandbox (Email Testing) + | Purpose | File | |---------|------| | Sandbox transactional send | [`testing/send-mail.php`](testing/send-mail.php) | @@ -38,20 +41,32 @@ Central index of runnable example scripts demonstrating Mailtrap PHP SDK feature | Message CRUD / listing | [`testing/messages.php`](testing/messages.php) | | Project CRUD / listing | [`testing/projects.php`](testing/projects.php) | -### 4. Contact Management +### Inbound Email + +| Purpose | File | +|---------|------| +| Folder CRUD / listing | [`inbound/folders.php`](inbound/folders.php) | +| Inbox CRUD / listing | [`inbound/inboxes.php`](inbound/inboxes.php) | +| Message list / get / reply / reply-all / forward / delete | [`inbound/messages.php`](inbound/messages.php) | +| Thread list / get / delete | [`inbound/threads.php`](inbound/threads.php) | + +### Contact Management + | Purpose | File | |---------|------| | Contacts CRUD + list | [`contacts/all.php`](contacts/all.php) | | Contact lists CRUD | [`contact-lists/all.php`](contact-lists/all.php) | | Custom fields CRUD | [`contact-fields/all.php`](contact-fields/all.php) | -### 5. Templates & Domains +### Templates & Domains + | Purpose | File | |---------|------| | Templates CRUD | [`templates/all.php`](templates/all.php) | | Sending domains CRUD | [`sending-domains/all.php`](sending-domains/all.php) | -### 6. General API +### General API + | Purpose | File | |---------|------| | Accounts info | [`general/accounts.php`](general/accounts.php) | @@ -59,13 +74,15 @@ Central index of runnable example scripts demonstrating Mailtrap PHP SDK feature | Permissions listing | [`general/permissions.php`](general/permissions.php) | | Users listing | [`general/users.php`](general/users.php) | -### 7. Framework Bridges +### Framework Bridges + | Framework | Files | |-----------|-------| | Laravel (transactional, sandbox, template, bulk) | [`laravel/transactional.php`](laravel/transactional.php), [`laravel/sandbox.php`](laravel/sandbox.php), [`laravel/template.php`](laravel/template.php), [`laravel/bulk.php`](laravel/bulk.php) | | Symfony (transactional, sandbox, template, bulk) | [`symfony/transactional.php`](symfony/transactional.php), [`symfony/sandbox.php`](symfony/sandbox.php), [`symfony/template.php`](symfony/template.php), [`symfony/bulk.php`](symfony/bulk.php) | -### 8. Configuration Utilities +### Configuration Utilities + | Purpose | File | |---------|------| | Showcase of combined config usage / initialization patterns | [`config/all.php`](config/all.php) | diff --git a/examples/inbound/folders.php b/examples/inbound/folders.php new file mode 100644 index 0000000..fc34473 --- /dev/null +++ b/examples/inbound/folders.php @@ -0,0 +1,79 @@ +folders(); + +$folderId = (int) ($_ENV['MAILTRAP_INBOUND_FOLDER_ID'] ?? 0); + +/** + * List all inbound folders. + * + * GET https://mailtrap.io/api/inbound/folders + */ +try { + $response = $folders->getList(); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Create an inbound folder. + * + * POST https://mailtrap.io/api/inbound/folders + */ +try { + $response = $folders->create(new CreateInboundFolder('Support')); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Get an inbound folder by ID. + * + * GET https://mailtrap.io/api/inbound/folders/{folder_id} + */ +try { + $response = $folders->getById($folderId); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Rename an inbound folder. + * + * PATCH https://mailtrap.io/api/inbound/folders/{folder_id} + */ +try { + $response = $folders->update($folderId, new UpdateInboundFolder('Support (renamed)')); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Delete an inbound folder (removes the folder and all of its inboxes). + * + * DELETE https://mailtrap.io/api/inbound/folders/{folder_id} + */ +try { + $response = $folders->delete($folderId); + + var_dump($response->getStatusCode()); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} diff --git a/examples/inbound/inboxes.php b/examples/inbound/inboxes.php new file mode 100644 index 0000000..e00d3a6 --- /dev/null +++ b/examples/inbound/inboxes.php @@ -0,0 +1,85 @@ +inboxes($folderId); + +/** + * List inboxes in the folder. + * + * GET https://mailtrap.io/api/inbound/folders/{folder_id}/inboxes + */ +try { + $response = $inboxes->getList(); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Create an inbox. Omit the domain id for a Mailtrap-hosted inbox; pass it to + * create a custom-domain (catch-all) inbox. + * + * POST https://mailtrap.io/api/inbound/folders/{folder_id}/inboxes + */ +try { + $response = $inboxes->create( + new CreateInboundInbox('Tickets', $_ENV['MAILTRAP_INBOUND_DOMAIN_ID'] ?? null) + ); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Get an inbox by ID. + * + * GET https://mailtrap.io/api/inbound/folders/{folder_id}/inboxes/{inbox_id} + */ +try { + $response = $inboxes->getById($inboxId); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Rename an inbox. + * + * PATCH https://mailtrap.io/api/inbound/folders/{folder_id}/inboxes/{inbox_id} + */ +try { + $response = $inboxes->update($inboxId, new UpdateInboundInbox('Tickets (renamed)')); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Delete an inbox. + * + * DELETE https://mailtrap.io/api/inbound/folders/{folder_id}/inboxes/{inbox_id} + */ +try { + $response = $inboxes->delete($inboxId); + + var_dump($response->getStatusCode()); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} diff --git a/examples/inbound/messages.php b/examples/inbound/messages.php new file mode 100644 index 0000000..bc18005 --- /dev/null +++ b/examples/inbound/messages.php @@ -0,0 +1,106 @@ +messages($inboxId); + +/** + * List received messages. Pass the previous page's last id to paginate. + * + * GET https://mailtrap.io/api/inbound/inboxes/{inbox_id}/messages + */ +try { + $response = $messages->getList(); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Get a single message with its body and attachment download URLs. + * + * GET https://mailtrap.io/api/inbound/inboxes/{inbox_id}/messages/{message_id} + */ +try { + $response = $messages->getById($messageId); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Reply to a message (sends a real email to the original sender). + * reply/replyAll/forward accept a Symfony Mime Email, just like the send API. + * + * POST https://mailtrap.io/api/inbound/inboxes/{inbox_id}/messages/{message_id}/reply + */ +try { + $email = (new Email()) + ->text('Thanks for reaching out!') + ->html('
Thanks for reaching out!
'); + + $response = $messages->reply($messageId, $email); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Reply to a message and copy the original's other recipients. + * + * POST https://mailtrap.io/api/inbound/inboxes/{inbox_id}/messages/{message_id}/reply_all + */ +try { + $email = (new Email())->text('Looping everyone in.'); + + $response = $messages->replyAll($messageId, $email); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Forward a message to new recipients (at least one is required). + * + * POST https://mailtrap.io/api/inbound/inboxes/{inbox_id}/messages/{message_id}/forward + */ +try { + $email = (new Email()) + ->to(new Address('colleague@example.com')) + ->text('Please take a look.'); + + $response = $messages->forward($messageId, $email); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Delete a message. + * + * DELETE https://mailtrap.io/api/inbound/inboxes/{inbox_id}/messages/{message_id} + */ +try { + $response = $messages->delete($messageId); + + var_dump($response->getStatusCode()); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} diff --git a/examples/inbound/threads.php b/examples/inbound/threads.php new file mode 100644 index 0000000..42201d6 --- /dev/null +++ b/examples/inbound/threads.php @@ -0,0 +1,54 @@ +threads($inboxId); + +/** + * List conversation threads. Pass the previous page's last id to paginate. + * + * GET https://mailtrap.io/api/inbound/inboxes/{inbox_id}/threads + */ +try { + $response = $threads->getList(); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Get a single thread with its messages embedded. + * + * GET https://mailtrap.io/api/inbound/inboxes/{inbox_id}/threads/{thread_id} + */ +try { + $response = $threads->getById($threadId); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Delete a thread. + * + * DELETE https://mailtrap.io/api/inbound/inboxes/{inbox_id}/threads/{thread_id} + */ +try { + $response = $threads->delete($threadId); + + var_dump($response->getStatusCode()); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} diff --git a/src/Api/AbstractEmails.php b/src/Api/AbstractEmails.php index 72c3abb..dc0f97d 100644 --- a/src/Api/AbstractEmails.php +++ b/src/Api/AbstractEmails.php @@ -5,95 +5,14 @@ namespace Mailtrap\Api; use Mailtrap\Exception\LogicException; -use Mailtrap\Exception\RuntimeException; -use Mailtrap\EmailHeader\CategoryHeader; -use Mailtrap\EmailHeader\CustomVariableHeader; -use Mailtrap\EmailHeader\Template\TemplateUuidHeader; -use Mailtrap\EmailHeader\Template\TemplateVariableHeader; -use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; -use Symfony\Component\Mime\Header\Headers; -use Symfony\Component\Mime\Header\MailboxListHeader; /** * Class AbstractEmails */ abstract class AbstractEmails extends AbstractApi implements EmailsSendApiInterface { - protected function getPayload(Email $email): array - { - $payload = []; - - if (null !== $this->getSender($email->getHeaders())) { - $payload['from'] = $this->getStringifierAddress($this->getSender($email->getHeaders())); - } - - if (!empty($this->getRecipients($email->getHeaders(), $email))) { - $payload['to'] = array_map([$this, 'getStringifierAddress'], $this->getRecipients($email->getHeaders(), $email)); - } - - if (null !== $email->getSubject()) { - $payload['subject'] = $email->getSubject(); - } - - if (null !== $email->getTextBody()) { - $payload['text'] = $email->getTextBody(); - } - - if (null !== $email->getHtmlBody()) { - $payload['html'] = $email->getHtmlBody(); - } - - if ($ccEmails = array_map([$this, 'getStringifierAddress'], $email->getCc())) { - $payload['cc'] = $ccEmails; - } - - if ($bccEmails = array_map([$this, 'getStringifierAddress'], $email->getBcc())) { - $payload['bcc'] = $bccEmails; - } - - if ($email->getAttachments()) { - $payload['attachments'] = $this->getAttachments($email); - } - - $headersToBypass = ['received', 'from', 'to', 'cc', 'bcc', 'subject', 'content-type']; - foreach ($email->getHeaders()->all() as $name => $header) { - if (in_array($name, $headersToBypass, true)) { - continue; - } - - switch(true) { - case $header instanceof CustomVariableHeader: - $payload[CustomVariableHeader::VAR_NAME][$header->getNameWithoutPrefix()] = $header->getValue(); - break; - case $header instanceof TemplateVariableHeader: - $payload[TemplateVariableHeader::VAR_NAME][$header->getNameWithoutPrefix()] = $header->getValue(); - break; - case $header instanceof CategoryHeader: - if (!empty($payload[CategoryHeader::VAR_NAME])) { - throw new RuntimeException( - sprintf('Too many "%s" instances present in the email headers. Mailtrap does not accept more than 1 category in the email.', CategoryHeader::class) - ); - } - - $payload[CategoryHeader::VAR_NAME] = $header->getValue(); - break; - case $header instanceof TemplateUuidHeader: - if (!empty($payload[TemplateUuidHeader::VAR_NAME])) { - throw new RuntimeException( - sprintf('Too many "%s" instances present in the email headers. Mailtrap does not accept more than 1 template UUID in the email.', TemplateUuidHeader::class) - ); - } - - $payload[TemplateUuidHeader::VAR_NAME] = $header->getValue(); - break; - default: - $payload['headers'][$header->getName()] = $header->getBodyAsString(); - } - } - - return $payload; - } + use EmailPayloadTrait; protected function getBatchBasePayload(Email $email): array { @@ -127,97 +46,4 @@ protected function getBatchBody(array $recipientEmails, ?Email $baseEmail = null return $body; } - - private function getAttachments(Email $email): array - { - $attachments = []; - foreach ($email->getAttachments() as $attachment) { - $headers = $attachment->getPreparedHeaders(); - $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); - $disposition = $headers->getHeaderBody('Content-Disposition'); - - $att = [ - 'content' => str_replace("\r\n", '', $attachment->bodyToString()), - 'type' => $headers->get('Content-Type')->getBody(), - 'filename' => $filename, - 'disposition' => $disposition, - ]; - - if ('inline' === $disposition) { - $att['content_id'] = $filename; - } - - $attachments[] = $att; - } - - return $attachments; - } - - private function getStringifierAddress(Address $address): array - { - $res = ['email' => $address->getAddress()]; - - if ($address->getName()) { - $res['name'] = $address->getName(); - } - - return $res; - } - - private function getSender(Headers $headers): ?Address - { - if ($sender = $headers->get('Sender')) { - return $sender->getAddress(); - } - if ($return = $headers->get('Return-Path')) { - return $return->getAddress(); - } - if ($from = $headers->get('From')) { - return $from->getAddresses()[0]; - } - - return null; - } - - /** - * @param Headers $headers - * @param Email $email - * - * @return Address[] - */ - private function getRecipients(Headers $headers, Email $email): array - { - $recipients = []; - foreach (['to', 'cc', 'bcc'] as $name) { - foreach ($headers->all($name) as $header) { - foreach ($header->getAddresses() as $address) { - $recipients[] = $address; - } - } - } - - return array_filter( - $recipients, - static fn (Address $address) => false === in_array($address, array_merge($email->getCc(), $email->getBcc()), true) - ); - } - - /** - * Returns the first address from the 'Reply-To' header, if it exists. - * - * @param Headers $headers - * - * @return Address|null - */ - private function getFirstReplyTo(Headers $headers): ?Address - { - /** @var MailboxListHeader|null $replyToHeader */ - $replyToHeader = $headers->get('Reply-To'); - - if (empty($replyToHeader) || empty($replyToHeader->getAddresses())) { - return null; - } - - return $replyToHeader->getAddresses()[0]; - } } diff --git a/src/Api/EmailPayloadTrait.php b/src/Api/EmailPayloadTrait.php new file mode 100644 index 0000000..8ee70d0 --- /dev/null +++ b/src/Api/EmailPayloadTrait.php @@ -0,0 +1,192 @@ +getSender($email->getHeaders())) { + $payload['from'] = $this->getStringifierAddress($this->getSender($email->getHeaders())); + } + + if (!empty($this->getRecipients($email->getHeaders(), $email))) { + $payload['to'] = array_map([$this, 'getStringifierAddress'], $this->getRecipients($email->getHeaders(), $email)); + } + + if (null !== $email->getSubject()) { + $payload['subject'] = $email->getSubject(); + } + + if (null !== $email->getTextBody()) { + $payload['text'] = $email->getTextBody(); + } + + if (null !== $email->getHtmlBody()) { + $payload['html'] = $email->getHtmlBody(); + } + + if ($ccEmails = array_map([$this, 'getStringifierAddress'], $email->getCc())) { + $payload['cc'] = $ccEmails; + } + + if ($bccEmails = array_map([$this, 'getStringifierAddress'], $email->getBcc())) { + $payload['bcc'] = $bccEmails; + } + + if ($email->getAttachments()) { + $payload['attachments'] = $this->getAttachments($email); + } + + $headersToBypass = ['received', 'from', 'to', 'cc', 'bcc', 'subject', 'content-type']; + foreach ($email->getHeaders()->all() as $name => $header) { + if (in_array($name, $headersToBypass, true)) { + continue; + } + + switch (true) { + case $header instanceof CustomVariableHeader: + $payload[CustomVariableHeader::VAR_NAME][$header->getNameWithoutPrefix()] = $header->getValue(); + break; + case $header instanceof TemplateVariableHeader: + $payload[TemplateVariableHeader::VAR_NAME][$header->getNameWithoutPrefix()] = $header->getValue(); + break; + case $header instanceof CategoryHeader: + if (!empty($payload[CategoryHeader::VAR_NAME])) { + throw new RuntimeException( + sprintf('Too many "%s" instances present in the email headers. Mailtrap does not accept more than 1 category in the email.', CategoryHeader::class) + ); + } + + $payload[CategoryHeader::VAR_NAME] = $header->getValue(); + break; + case $header instanceof TemplateUuidHeader: + if (!empty($payload[TemplateUuidHeader::VAR_NAME])) { + throw new RuntimeException( + sprintf('Too many "%s" instances present in the email headers. Mailtrap does not accept more than 1 template UUID in the email.', TemplateUuidHeader::class) + ); + } + + $payload[TemplateUuidHeader::VAR_NAME] = $header->getValue(); + break; + default: + $payload['headers'][$header->getName()] = $header->getBodyAsString(); + } + } + + return $payload; + } + + private function getAttachments(Email $email): array + { + $attachments = []; + foreach ($email->getAttachments() as $attachment) { + $headers = $attachment->getPreparedHeaders(); + $filename = $headers->getHeaderParameter('Content-Disposition', 'filename'); + $disposition = $headers->getHeaderBody('Content-Disposition'); + + $att = [ + 'content' => str_replace("\r\n", '', $attachment->bodyToString()), + 'type' => $headers->get('Content-Type')->getBody(), + 'filename' => $filename, + 'disposition' => $disposition, + ]; + + if ('inline' === $disposition) { + $att['content_id'] = $filename; + } + + $attachments[] = $att; + } + + return $attachments; + } + + private function getStringifierAddress(Address $address): array + { + $res = ['email' => $address->getAddress()]; + + if ($address->getName()) { + $res['name'] = $address->getName(); + } + + return $res; + } + + private function getSender(Headers $headers): ?Address + { + if ($sender = $headers->get('Sender')) { + return $sender->getAddress(); + } + if ($return = $headers->get('Return-Path')) { + return $return->getAddress(); + } + if ($from = $headers->get('From')) { + return $from->getAddresses()[0]; + } + + return null; + } + + /** + * @param Headers $headers + * @param Email $email + * + * @return Address[] + */ + private function getRecipients(Headers $headers, Email $email): array + { + $recipients = []; + foreach (['to', 'cc', 'bcc'] as $name) { + foreach ($headers->all($name) as $header) { + foreach ($header->getAddresses() as $address) { + $recipients[] = $address; + } + } + } + + return array_filter( + $recipients, + static fn (Address $address) => false === in_array($address, array_merge($email->getCc(), $email->getBcc()), true) + ); + } + + /** + * Returns the first address from the 'Reply-To' header, if it exists. + * + * @param Headers $headers + * + * @return Address|null + */ + private function getFirstReplyTo(Headers $headers): ?Address + { + /** @var MailboxListHeader|null $replyToHeader */ + $replyToHeader = $headers->get('Reply-To'); + + if (empty($replyToHeader) || empty($replyToHeader->getAddresses())) { + return null; + } + + return $replyToHeader->getAddresses()[0]; + } +} diff --git a/src/Api/Inbound/Folder.php b/src/Api/Inbound/Folder.php new file mode 100644 index 0000000..99e455f --- /dev/null +++ b/src/Api/Inbound/Folder.php @@ -0,0 +1,50 @@ +handleResponse($this->httpGet($this->getBasePath())); + } + + public function getById(int $folderId): ResponseInterface + { + return $this->handleResponse($this->httpGet($this->getBasePath() . '/' . $folderId)); + } + + public function create(CreateInboundFolder $folder): ResponseInterface + { + return $this->handleResponse($this->httpPost($this->getBasePath(), [], $folder->toArray())); + } + + public function update(int $folderId, UpdateInboundFolder $folder): ResponseInterface + { + return $this->handleResponse( + $this->httpPatch($this->getBasePath() . '/' . $folderId, [], $folder->toArray()) + ); + } + + public function delete(int $folderId): ResponseInterface + { + return $this->handleResponse($this->httpDelete($this->getBasePath() . '/' . $folderId)); + } + + private function getBasePath(): string + { + return sprintf('%s/api/inbound/folders', $this->getHost()); + } +} diff --git a/src/Api/Inbound/InboundInterface.php b/src/Api/Inbound/InboundInterface.php new file mode 100644 index 0000000..3104482 --- /dev/null +++ b/src/Api/Inbound/InboundInterface.php @@ -0,0 +1,9 @@ +handleResponse($this->httpGet($this->getBasePath())); + } + + public function getById(int $inboxId): ResponseInterface + { + return $this->handleResponse($this->httpGet($this->getBasePath() . '/' . $inboxId)); + } + + public function create(CreateInboundInbox $inbox): ResponseInterface + { + return $this->handleResponse($this->httpPost($this->getBasePath(), [], $inbox->toArray())); + } + + public function update(int $inboxId, UpdateInboundInbox $inbox): ResponseInterface + { + return $this->handleResponse( + $this->httpPatch($this->getBasePath() . '/' . $inboxId, [], $inbox->toArray()) + ); + } + + public function delete(int $inboxId): ResponseInterface + { + return $this->handleResponse($this->httpDelete($this->getBasePath() . '/' . $inboxId)); + } + + private function getBasePath(): string + { + return sprintf('%s/api/inbound/folders/%s/inboxes', $this->getHost(), $this->folderId); + } +} diff --git a/src/Api/Inbound/Message.php b/src/Api/Inbound/Message.php new file mode 100644 index 0000000..590cb4c --- /dev/null +++ b/src/Api/Inbound/Message.php @@ -0,0 +1,94 @@ + $lastId] : []; + + return $this->handleResponse($this->httpGet($this->getBasePath(), $parameters)); + } + + public function getById(string $messageId): ResponseInterface + { + return $this->handleResponse($this->httpGet($this->getBasePath() . '/' . $messageId)); + } + + public function delete(string $messageId): ResponseInterface + { + return $this->handleResponse($this->httpDelete($this->getBasePath() . '/' . $messageId)); + } + + /** + * Reply to a message (to the original sender). Sends a real email. + */ + public function reply(string $messageId, Email $email): ResponseInterface + { + return $this->handleResponse($this->httpPost( + $this->getBasePath() . '/' . $messageId . '/reply', + [], + $this->getPayload($email) + )); + } + + /** + * Reply to a message and copy the original's other recipients. Sends a real email. + */ + public function replyAll(string $messageId, Email $email): ResponseInterface + { + return $this->handleResponse($this->httpPost( + $this->getBasePath() . '/' . $messageId . '/reply_all', + [], + $this->getPayload($email) + )); + } + + /** + * Forward a message to new recipients (at least one "to" is required). Sends a real email. + */ + public function forward(string $messageId, Email $email): ResponseInterface + { + if (empty($email->getTo())) { + throw new InvalidArgumentException('Forwarding a message requires at least one "to" recipient.'); + } + + return $this->handleResponse($this->httpPost( + $this->getBasePath() . '/' . $messageId . '/forward', + [], + $this->getPayload($email) + )); + } + + private function getBasePath(): string + { + return sprintf('%s/api/inbound/inboxes/%s/messages', $this->getHost(), $this->inboxId); + } +} diff --git a/src/Api/Inbound/Thread.php b/src/Api/Inbound/Thread.php new file mode 100644 index 0000000..6bd82e5 --- /dev/null +++ b/src/Api/Inbound/Thread.php @@ -0,0 +1,47 @@ + $lastId] : []; + + return $this->handleResponse($this->httpGet($this->getBasePath(), $parameters)); + } + + public function getById(string $threadId): ResponseInterface + { + return $this->handleResponse($this->httpGet($this->getBasePath() . '/' . $threadId)); + } + + public function delete(string $threadId): ResponseInterface + { + return $this->handleResponse($this->httpDelete($this->getBasePath() . '/' . $threadId)); + } + + private function getBasePath(): string + { + return sprintf('%s/api/inbound/inboxes/%s/threads', $this->getHost(), $this->inboxId); + } +} diff --git a/src/DTO/Request/Inbound/CreateInboundFolder.php b/src/DTO/Request/Inbound/CreateInboundFolder.php new file mode 100644 index 0000000..d188fea --- /dev/null +++ b/src/DTO/Request/Inbound/CreateInboundFolder.php @@ -0,0 +1,25 @@ + $this->name, + ]; + } +} diff --git a/src/DTO/Request/Inbound/CreateInboundInbox.php b/src/DTO/Request/Inbound/CreateInboundInbox.php new file mode 100644 index 0000000..00a5492 --- /dev/null +++ b/src/DTO/Request/Inbound/CreateInboundInbox.php @@ -0,0 +1,35 @@ + $this->name, + ]; + + if ($this->domainId !== null) { + $payload['domain_id'] = $this->domainId; + } + + return $payload; + } +} diff --git a/src/DTO/Request/Inbound/UpdateInboundFolder.php b/src/DTO/Request/Inbound/UpdateInboundFolder.php new file mode 100644 index 0000000..8906325 --- /dev/null +++ b/src/DTO/Request/Inbound/UpdateInboundFolder.php @@ -0,0 +1,25 @@ + $this->name, + ]; + } +} diff --git a/src/DTO/Request/Inbound/UpdateInboundInbox.php b/src/DTO/Request/Inbound/UpdateInboundInbox.php new file mode 100644 index 0000000..b1430e2 --- /dev/null +++ b/src/DTO/Request/Inbound/UpdateInboundInbox.php @@ -0,0 +1,25 @@ + $this->name, + ]; + } +} diff --git a/src/DTO/Request/Webhook/CreateWebhook.php b/src/DTO/Request/Webhook/CreateWebhook.php index 0d3477e..f24f322 100644 --- a/src/DTO/Request/Webhook/CreateWebhook.php +++ b/src/DTO/Request/Webhook/CreateWebhook.php @@ -19,6 +19,8 @@ final class CreateWebhook implements WebhookInterface * @param string|null $sendingStream One of Webhook::SENDING_STREAM_* (required for email_sending) * @param int|null $domainId Scope to a specific domain id (null = all account domains) * @param bool|null $active Defaults to true on the server side + * @param int|null $inboundInboxId Scope an inbound_receiving webhook to a specific + * inbound inbox (null = all inboxes in the account) */ public function __construct( private string $url, @@ -28,12 +30,19 @@ public function __construct( private ?string $sendingStream = null, private ?int $domainId = null, private ?bool $active = null, + private ?int $inboundInboxId = null, ) { - if (!in_array($webhookType, [Webhook::TYPE_EMAIL_SENDING, Webhook::TYPE_AUDIT_LOG], true)) { + $allowedTypes = [ + Webhook::TYPE_EMAIL_SENDING, + Webhook::TYPE_AUDIT_LOG, + Webhook::TYPE_INBOUND_RECEIVING, + ]; + if (!in_array($webhookType, $allowedTypes, true)) { throw new InvalidArgumentException(sprintf( - '"webhookType" must be one of "%s" or "%s", "%s" given', + '"webhookType" must be one of "%s", "%s", or "%s", "%s" given', Webhook::TYPE_EMAIL_SENDING, Webhook::TYPE_AUDIT_LOG, + Webhook::TYPE_INBOUND_RECEIVING, $webhookType )); } @@ -72,6 +81,10 @@ public function toArray(): array $payload['active'] = $this->active; } + if ($this->inboundInboxId !== null) { + $payload['inbound_inbox_id'] = $this->inboundInboxId; + } + return $payload; } } diff --git a/src/DTO/Request/Webhook/UpdateWebhook.php b/src/DTO/Request/Webhook/UpdateWebhook.php index 029a6d2..67bf519 100644 --- a/src/DTO/Request/Webhook/UpdateWebhook.php +++ b/src/DTO/Request/Webhook/UpdateWebhook.php @@ -9,24 +9,28 @@ /** * Class UpdateWebhook * - * Only `url`, `active`, `payload_format`, and `event_types` can be updated after creation. - * `webhook_type`, `sending_stream`, and `domain_id` are immutable. + * Only `url`, `active`, `payload_format`, `event_types`, and `inbound_inbox_id` + * can be updated after creation. `webhook_type`, `sending_stream`, and + * `domain_id` are immutable. */ final class UpdateWebhook implements WebhookInterface { /** * @param string|null $url * @param bool|null $active - * @param string|null $payloadFormat One of Webhook::PAYLOAD_FORMAT_* - * @param string[]|null $eventTypes Subset of Webhook::EVENT_*. Replaces the current - * event_types list entirely (server-side replacement, - * not merge). Pass the full desired set. + * @param string|null $payloadFormat One of Webhook::PAYLOAD_FORMAT_* + * @param string[]|null $eventTypes Subset of Webhook::EVENT_*. Replaces the current + * event_types list entirely (server-side replacement, + * not merge). Pass the full desired set. + * @param int|null $inboundInboxId Inbound inbox to link the webhook to + * (inbound_receiving webhooks) */ public function __construct( private ?string $url = null, private ?bool $active = null, private ?string $payloadFormat = null, private ?array $eventTypes = null, + private ?int $inboundInboxId = null, ) { } @@ -50,6 +54,10 @@ public function toArray(): array $payload['event_types'] = $this->eventTypes; } + if ($this->inboundInboxId !== null) { + $payload['inbound_inbox_id'] = $this->inboundInboxId; + } + if ($payload === []) { throw new InvalidArgumentException('At least one updatable field must be provided to update a webhook'); } diff --git a/src/DTO/Request/Webhook/Webhook.php b/src/DTO/Request/Webhook/Webhook.php index d0ea3c9..ee06807 100644 --- a/src/DTO/Request/Webhook/Webhook.php +++ b/src/DTO/Request/Webhook/Webhook.php @@ -11,6 +11,7 @@ final class Webhook { public const TYPE_EMAIL_SENDING = 'email_sending'; public const TYPE_AUDIT_LOG = 'audit_log'; + public const TYPE_INBOUND_RECEIVING = 'inbound_receiving'; public const PAYLOAD_FORMAT_JSON = 'json'; public const PAYLOAD_FORMAT_JSONLINES = 'jsonlines'; diff --git a/src/MailtrapClient.php b/src/MailtrapClient.php index e2f70a0..3e56d13 100644 --- a/src/MailtrapClient.php +++ b/src/MailtrapClient.php @@ -14,6 +14,7 @@ * @method MailtrapSandboxClient sandbox * @method MailtrapSendingClient sending * @method MailtrapBulkSendingClient bulkSending + * @method MailtrapInboundClient inbound * * Class MailtrapClient */ @@ -23,12 +24,14 @@ class MailtrapClient extends AbstractMailtrapClient public const LAYER_SANDBOX = 'sandbox'; public const LAYER_TRANSACTIONAL_SENDING = 'sending'; public const LAYER_BULK_SENDING = 'bulkSending'; + public const LAYER_INBOUND = 'inbound'; public const API_MAPPING = [ self::LAYER_GENERAL => MailtrapGeneralClient::class, self::LAYER_SANDBOX => MailtrapSandboxClient::class, self::LAYER_TRANSACTIONAL_SENDING => MailtrapSendingClient::class, self::LAYER_BULK_SENDING => MailtrapBulkSendingClient::class, + self::LAYER_INBOUND => MailtrapInboundClient::class, ]; public static function initSendingEmails( diff --git a/src/MailtrapInboundClient.php b/src/MailtrapInboundClient.php new file mode 100644 index 0000000..0f3b8c0 --- /dev/null +++ b/src/MailtrapInboundClient.php @@ -0,0 +1,23 @@ + Api\Inbound\Folder::class, + 'inboxes' => Api\Inbound\Inbox::class, + 'messages' => Api\Inbound\Message::class, + 'threads' => Api\Inbound\Thread::class, + ]; +} diff --git a/tests/Api/Inbound/FolderTest.php b/tests/Api/Inbound/FolderTest.php new file mode 100644 index 0000000..a93ab6a --- /dev/null +++ b/tests/Api/Inbound/FolderTest.php @@ -0,0 +1,118 @@ +folder = $this->getMockBuilder(FolderApi::class) + ->onlyMethods(['httpGet', 'httpPost', 'httpPatch', 'httpDelete']) + ->setConstructorArgs([$this->getConfigMock()]) + ->getMock(); + } + + protected function tearDown(): void + { + $this->folder = null; + parent::tearDown(); + } + + public function testGetList(): void + { + $this->folder->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([['id' => self::FAKE_FOLDER_ID, 'name' => 'Support']]) + )); + + $data = ResponseHelper::toArray($this->folder->getList()); + + $this->assertSame(self::FAKE_FOLDER_ID, $data[0]['id']); + $this->assertSame('Support', $data[0]['name']); + } + + public function testGetById(): void + { + $this->folder->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL . '/' . self::FAKE_FOLDER_ID) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['id' => self::FAKE_FOLDER_ID, 'name' => 'Support']) + )); + + $data = ResponseHelper::toArray($this->folder->getById(self::FAKE_FOLDER_ID)); + + $this->assertSame(self::FAKE_FOLDER_ID, $data['id']); + } + + public function testCreateSendsFlatBody(): void + { + $this->folder->expects($this->once()) + ->method('httpPost') + ->with(self::BASE_URL, [], ['name' => 'Support']) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['id' => self::FAKE_FOLDER_ID, 'name' => 'Support']) + )); + + $data = ResponseHelper::toArray($this->folder->create(new CreateInboundFolder('Support'))); + + $this->assertSame('Support', $data['name']); + } + + public function testUpdate(): void + { + $this->folder->expects($this->once()) + ->method('httpPatch') + ->with(self::BASE_URL . '/' . self::FAKE_FOLDER_ID, [], ['name' => 'Renamed']) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['id' => self::FAKE_FOLDER_ID, 'name' => 'Renamed']) + )); + + $data = ResponseHelper::toArray( + $this->folder->update(self::FAKE_FOLDER_ID, new UpdateInboundFolder('Renamed')) + ); + + $this->assertSame('Renamed', $data['name']); + } + + public function testDelete(): void + { + $this->folder->expects($this->once()) + ->method('httpDelete') + ->with(self::BASE_URL . '/' . self::FAKE_FOLDER_ID) + ->willReturn(new Response(204)); + + $this->assertSame(204, $this->folder->delete(self::FAKE_FOLDER_ID)->getStatusCode()); + } +} diff --git a/tests/Api/Inbound/InboxTest.php b/tests/Api/Inbound/InboxTest.php new file mode 100644 index 0000000..c967e6a --- /dev/null +++ b/tests/Api/Inbound/InboxTest.php @@ -0,0 +1,144 @@ +inbox = $this->getMockBuilder(InboxApi::class) + ->onlyMethods(['httpGet', 'httpPost', 'httpPatch', 'httpDelete']) + ->setConstructorArgs([$this->getConfigMock(), self::FAKE_FOLDER_ID]) + ->getMock(); + } + + protected function tearDown(): void + { + $this->inbox = null; + parent::tearDown(); + } + + private function inboxResponseBody(): array + { + return [ + 'id' => self::INBOX_ID, + 'name' => 'Tickets', + 'address' => 'tickets@inbound.example.com', + 'domain_id' => 3, + ]; + } + + public function testGetList(): void + { + $this->inbox->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([$this->inboxResponseBody()]) + )); + + $data = ResponseHelper::toArray($this->inbox->getList()); + + $this->assertSame(self::INBOX_ID, $data[0]['id']); + } + + public function testGetById(): void + { + $this->inbox->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL . '/' . self::INBOX_ID) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode($this->inboxResponseBody()) + )); + + $data = ResponseHelper::toArray($this->inbox->getById(self::INBOX_ID)); + + $this->assertSame('tickets@inbound.example.com', $data['address']); + } + + public function testCreateWithDomainId(): void + { + $this->inbox->expects($this->once()) + ->method('httpPost') + ->with(self::BASE_URL, [], ['name' => 'Tickets', 'domain_id' => 3]) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode($this->inboxResponseBody()) + )); + + $data = ResponseHelper::toArray( + $this->inbox->create(new CreateInboundInbox('Tickets', 3)) + ); + + $this->assertSame(self::INBOX_ID, $data['id']); + } + + public function testCreateOmitsDomainIdWhenNull(): void + { + $this->inbox->expects($this->once()) + ->method('httpPost') + ->with(self::BASE_URL, [], ['name' => 'Tickets']) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode($this->inboxResponseBody()) + )); + + $this->inbox->create(new CreateInboundInbox('Tickets')); + } + + public function testUpdate(): void + { + $this->inbox->expects($this->once()) + ->method('httpPatch') + ->with(self::BASE_URL . '/' . self::INBOX_ID, [], ['name' => 'Renamed']) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['id' => self::INBOX_ID, 'name' => 'Renamed']) + )); + + $data = ResponseHelper::toArray( + $this->inbox->update(self::INBOX_ID, new UpdateInboundInbox('Renamed')) + ); + + $this->assertSame('Renamed', $data['name']); + } + + public function testDelete(): void + { + $this->inbox->expects($this->once()) + ->method('httpDelete') + ->with(self::BASE_URL . '/' . self::INBOX_ID) + ->willReturn(new Response(204)); + + $this->assertSame(204, $this->inbox->delete(self::INBOX_ID)->getStatusCode()); + } +} diff --git a/tests/Api/Inbound/MessageTest.php b/tests/Api/Inbound/MessageTest.php new file mode 100644 index 0000000..915d923 --- /dev/null +++ b/tests/Api/Inbound/MessageTest.php @@ -0,0 +1,176 @@ +message = $this->getMockBuilder(MessageApi::class) + ->onlyMethods(['httpGet', 'httpPost', 'httpDelete']) + ->setConstructorArgs([$this->getConfigMock(), self::FAKE_INBOX_ID]) + ->getMock(); + } + + protected function tearDown(): void + { + $this->message = null; + parent::tearDown(); + } + + public function testGetList(): void + { + $this->message->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL, []) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => [['id' => self::MESSAGE_ID]], 'total_count' => 1, 'last_id' => self::MESSAGE_ID]) + )); + + $data = ResponseHelper::toArray($this->message->getList()); + + $this->assertSame(1, $data['total_count']); + $this->assertSame(self::MESSAGE_ID, $data['data'][0]['id']); + } + + public function testGetListPassesLastIdCursor(): void + { + $this->message->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL, ['last_id' => 'cursor-1']) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => [], 'total_count' => 0, 'last_id' => null]) + )); + + $this->message->getList('cursor-1'); + } + + public function testGetById(): void + { + $this->message->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL . '/' . self::MESSAGE_ID) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['id' => self::MESSAGE_ID, 'html_body' => 'Hi
']) + )); + + $data = ResponseHelper::toArray($this->message->getById(self::MESSAGE_ID)); + + $this->assertSame('Hi
', $data['html_body']); + } + + public function testDelete(): void + { + $this->message->expects($this->once()) + ->method('httpDelete') + ->with(self::BASE_URL . '/' . self::MESSAGE_ID) + ->willReturn(new Response(204)); + + $this->assertSame(204, $this->message->delete(self::MESSAGE_ID)->getStatusCode()); + } + + public function testReplyMapsEmailToPayload(): void + { + $email = (new Email()) + ->from(new Address('support@example.com')) + ->text('Thanks!'); + + $this->message->expects($this->once()) + ->method('httpPost') + ->with( + self::BASE_URL . '/' . self::MESSAGE_ID . '/reply', + [], + ['from' => ['email' => 'support@example.com'], 'text' => 'Thanks!'] + ) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['message_ids' => ['s1']]) + )); + + $data = ResponseHelper::toArray($this->message->reply(self::MESSAGE_ID, $email)); + + $this->assertSame(['s1'], $data['message_ids']); + } + + public function testReplyAll(): void + { + $email = (new Email())->text('All'); + + $this->message->expects($this->once()) + ->method('httpPost') + ->with(self::BASE_URL . '/' . self::MESSAGE_ID . '/reply_all', [], ['text' => 'All']) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['message_ids' => ['s1', 's2']]) + )); + + $this->message->replyAll(self::MESSAGE_ID, $email); + } + + public function testForwardSerializesRecipients(): void + { + $email = (new Email())->to(new Address('colleague@example.com'))->text('FYI'); + + $this->message->expects($this->once()) + ->method('httpPost') + ->with( + self::BASE_URL . '/' . self::MESSAGE_ID . '/forward', + [], + ['to' => [['email' => 'colleague@example.com']], 'text' => 'FYI'] + ) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['message_ids' => ['s1']]) + )); + + $this->message->forward(self::MESSAGE_ID, $email); + } + + public function testForwardWithoutRecipientThrows(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->message->forward(self::MESSAGE_ID, (new Email())->text('FYI')); + } + + public function testForwardRequiresToRecipientNotCcOrBcc(): void + { + $this->expectException(InvalidArgumentException::class); + + // cc / bcc alone are not enough — forward needs at least one "to". + $email = (new Email())->cc(new Address('cc@example.com'))->text('FYI'); + + $this->message->forward(self::MESSAGE_ID, $email); + } +} diff --git a/tests/Api/Inbound/ThreadTest.php b/tests/Api/Inbound/ThreadTest.php new file mode 100644 index 0000000..06d8e85 --- /dev/null +++ b/tests/Api/Inbound/ThreadTest.php @@ -0,0 +1,95 @@ +thread = $this->getMockBuilder(ThreadApi::class) + ->onlyMethods(['httpGet', 'httpDelete']) + ->setConstructorArgs([$this->getConfigMock(), self::FAKE_INBOX_ID]) + ->getMock(); + } + + protected function tearDown(): void + { + $this->thread = null; + parent::tearDown(); + } + + public function testGetList(): void + { + $this->thread->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL, []) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => [['id' => self::FAKE_THREAD_ID, 'message_count' => 2]], 'total_count' => 1, 'last_id' => self::FAKE_THREAD_ID]) + )); + + $data = ResponseHelper::toArray($this->thread->getList()); + + $this->assertSame(2, $data['data'][0]['message_count']); + } + + public function testGetListPassesLastIdCursor(): void + { + $this->thread->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL, ['last_id' => 'cursor-1']) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => [], 'total_count' => 0, 'last_id' => null]) + )); + + $this->thread->getList('cursor-1'); + } + + public function testGetById(): void + { + $this->thread->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL . '/' . self::FAKE_THREAD_ID) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['id' => self::FAKE_THREAD_ID, 'messages' => [['direction' => 'inbound']]]) + )); + + $data = ResponseHelper::toArray($this->thread->getById(self::FAKE_THREAD_ID)); + + $this->assertSame('inbound', $data['messages'][0]['direction']); + } + + public function testDelete(): void + { + $this->thread->expects($this->once()) + ->method('httpDelete') + ->with(self::BASE_URL . '/' . self::FAKE_THREAD_ID) + ->willReturn(new Response(204)); + + $this->assertSame(204, $this->thread->delete(self::FAKE_THREAD_ID)->getStatusCode()); + } +} diff --git a/tests/Api/Sending/WebhookTest.php b/tests/Api/Sending/WebhookTest.php index 8fd3376..b2788cb 100644 --- a/tests/Api/Sending/WebhookTest.php +++ b/tests/Api/Sending/WebhookTest.php @@ -126,6 +126,47 @@ public function testCreateWebhook(): void $this->assertArrayHasKey('signing_secret', $responseData['data']); } + public function testCreateInboundReceivingWebhook(): void + { + $createDto = new CreateWebhook( + url: 'https://example.com/mailtrap/webhooks', + webhookType: Webhook::TYPE_INBOUND_RECEIVING, + payloadFormat: Webhook::PAYLOAD_FORMAT_JSON, + inboundInboxId: 665, + ); + + $this->assertSame( + [ + 'url' => 'https://example.com/mailtrap/webhooks', + 'webhook_type' => 'inbound_receiving', + 'event_types' => [], + 'payload_format' => 'json', + 'inbound_inbox_id' => 665, + ], + $createDto->toArray() + ); + } + + public function testCreateInboundReceivingWebhookWithoutInboxAppliesToAllInboxes(): void + { + $createDto = new CreateWebhook( + url: 'https://example.com/mailtrap/webhooks', + webhookType: Webhook::TYPE_INBOUND_RECEIVING, + ); + + $payload = $createDto->toArray(); + + $this->assertSame('inbound_receiving', $payload['webhook_type']); + $this->assertArrayNotHasKey('inbound_inbox_id', $payload); + } + + public function testUpdateWebhookWithInboundInboxId(): void + { + $updateDto = new UpdateWebhook(inboundInboxId: 665); + + $this->assertSame(['inbound_inbox_id' => 665], $updateDto->toArray()); + } + public function testCreateWebhookFailsWithValidationError(): void { $invalidDto = new CreateWebhook( @@ -155,7 +196,7 @@ public function testCreateWebhookFailsWithValidationError(): void public function testCreateWebhookRejectsUnknownWebhookType(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('"webhookType" must be one of "email_sending" or "audit_log", "spam" given'); + $this->expectExceptionMessage('"webhookType" must be one of "email_sending", "audit_log", or "inbound_receiving", "spam" given'); new CreateWebhook( url: 'https://example.com/mailtrap/webhooks', diff --git a/tests/MailtrapInboundClientTest.php b/tests/MailtrapInboundClientTest.php new file mode 100644 index 0000000..cfbd767 --- /dev/null +++ b/tests/MailtrapInboundClientTest.php @@ -0,0 +1,36 @@ + $item) { + yield match ($key) { + 'inboxes', 'messages', 'threads' => [new $item($this->getConfigMock(), 1)], + default => [new $item($this->getConfigMock())], + }; + } + } +}