diff --git a/README.md b/README.md index 3f41bcb..b5221d2 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,8 @@ 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) +- Thread management (list / search / get / delete) – [`inbound/threads.php`](examples/inbound/threads.php) +- Forward rule management CRUD – [`inbound/forward-rules.php`](examples/inbound/forward-rules.php) Contact management: - Contacts CRUD & listing – [`contacts/all.php`](examples/contacts/all.php) diff --git a/examples/README.md b/examples/README.md index 9c791c6..871d84d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -49,7 +49,8 @@ Central index of runnable example scripts demonstrating Mailtrap PHP SDK feature | 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) | +| Thread list / search / get / delete | [`inbound/threads.php`](inbound/threads.php) | +| Forward rule CRUD / listing | [`inbound/forward-rules.php`](inbound/forward-rules.php) | ### Contact Management diff --git a/examples/inbound/forward-rules.php b/examples/inbound/forward-rules.php new file mode 100644 index 0000000..5688f33 --- /dev/null +++ b/examples/inbound/forward-rules.php @@ -0,0 +1,99 @@ +forwardRules($inboxId); + +/** + * List the inbox's forward rules. + * + * GET https://mailtrap.io/api/inbound/inboxes/{inbox_id}/forward_rules + */ +try { + $response = $forwardRules->getList(); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Create a forward rule. + * + * POST https://mailtrap.io/api/inbound/inboxes/{inbox_id}/forward_rules + */ +try { + $response = $forwardRules->create(new CreateInboundForwardRule( + name: 'Copy billing mail to finance', + conditions: [ + new InboundForwardRuleCondition( + matchType: InboundForwardRule::MATCH_TYPE_SENDER, + operator: InboundForwardRule::OPERATOR_ENDS_WITH, + value: '@billing.example.com' + ), + ], + destinations: [new InboundForwardRuleDestination('finance@example.com')], + )); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Get a forward rule by ID. + * + * GET https://mailtrap.io/api/inbound/inboxes/{inbox_id}/forward_rules/{id} + */ +try { + $response = $forwardRules->getById($ruleId); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Update a forward rule's destinations. + * + * PATCH https://mailtrap.io/api/inbound/inboxes/{inbox_id}/forward_rules/{id} + */ +try { + $response = $forwardRules->update($ruleId, new UpdateInboundForwardRule( + destinations: [ + new InboundForwardRuleDestination('finance@example.com'), + new InboundForwardRuleDestination('accounting@example.com'), + ], + )); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + +/** + * Delete a forward rule. + * + * DELETE https://mailtrap.io/api/inbound/inboxes/{inbox_id}/forward_rules/{id} + */ +try { + $response = $forwardRules->delete($ruleId); + + 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 index 42201d6..c48ac72 100644 --- a/examples/inbound/threads.php +++ b/examples/inbound/threads.php @@ -27,6 +27,19 @@ echo 'Caught exception: ', $e->getMessage(), "\n"; } +/** + * Search threads. + * + * GET https://mailtrap.io/api/inbound/inboxes/{inbox_id}/threads?search=acme + */ +try { + $response = $threads->getList(search: 'acme'); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; +} + /** * Get a single thread with its messages embedded. * diff --git a/src/Api/Inbound/ForwardRule.php b/src/Api/Inbound/ForwardRule.php new file mode 100644 index 0000000..ebf36c3 --- /dev/null +++ b/src/Api/Inbound/ForwardRule.php @@ -0,0 +1,56 @@ +handleResponse($this->httpGet($this->getBasePath())); + } + + public function getById(int $ruleId): ResponseInterface + { + return $this->handleResponse($this->httpGet($this->getBasePath() . '/' . $ruleId)); + } + + public function create(CreateInboundForwardRule $rule): ResponseInterface + { + return $this->handleResponse($this->httpPost($this->getBasePath(), [], $rule->toArray())); + } + + public function update(int $ruleId, UpdateInboundForwardRule $rule): ResponseInterface + { + return $this->handleResponse( + $this->httpPatch($this->getBasePath() . '/' . $ruleId, [], $rule->toArray()) + ); + } + + public function delete(int $ruleId): ResponseInterface + { + return $this->handleResponse($this->httpDelete($this->getBasePath() . '/' . $ruleId)); + } + + private function getBasePath(): string + { + return sprintf('%s/api/inbound/inboxes/%s/forward_rules', $this->getHost(), $this->inboxId); + } +} diff --git a/src/Api/Inbound/Thread.php b/src/Api/Inbound/Thread.php index 6bd82e5..a2c25d5 100644 --- a/src/Api/Inbound/Thread.php +++ b/src/Api/Inbound/Thread.php @@ -23,9 +23,17 @@ public function __construct(ConfigInterface $config, private int $inboxId) /** * List threads. Pass $lastId from a previous response to fetch the next page. */ - public function getList(?string $lastId = null): ResponseInterface + public function getList(?string $lastId = null, ?string $search = null): ResponseInterface { - $parameters = $lastId !== null ? ['last_id' => $lastId] : []; + $parameters = []; + + if ($lastId !== null) { + $parameters['last_id'] = $lastId; + } + + if ($search !== null) { + $parameters['search'] = $search; + } return $this->handleResponse($this->httpGet($this->getBasePath(), $parameters)); } diff --git a/src/DTO/Request/Inbound/CreateInboundForwardRule.php b/src/DTO/Request/Inbound/CreateInboundForwardRule.php new file mode 100644 index 0000000..98522df --- /dev/null +++ b/src/DTO/Request/Inbound/CreateInboundForwardRule.php @@ -0,0 +1,47 @@ + $this->name, + ]; + + if ($this->conditions !== null) { + $payload['conditions'] = array_values(array_map( + static fn (InboundForwardRuleCondition $condition): array => $condition->toArray(), + $this->conditions + )); + } + + if ($this->destinations !== null) { + $payload['destinations'] = array_values(array_map( + static fn (InboundForwardRuleDestination $destination): array => $destination->toArray(), + $this->destinations + )); + } + + return $payload; + } +} diff --git a/src/DTO/Request/Inbound/InboundForwardRule.php b/src/DTO/Request/Inbound/InboundForwardRule.php new file mode 100644 index 0000000..e8bbfe0 --- /dev/null +++ b/src/DTO/Request/Inbound/InboundForwardRule.php @@ -0,0 +1,43 @@ + $this->matchType, + 'operator' => $this->operator, + ]; + + if ($this->value !== null) { + $payload['value'] = $this->value; + } + + if ($this->headerKey !== null) { + $payload['header_key'] = $this->headerKey; + } + + return $payload; + } +} diff --git a/src/DTO/Request/Inbound/InboundForwardRuleDestination.php b/src/DTO/Request/Inbound/InboundForwardRuleDestination.php new file mode 100644 index 0000000..8b3f836 --- /dev/null +++ b/src/DTO/Request/Inbound/InboundForwardRuleDestination.php @@ -0,0 +1,25 @@ + $this->email, + ]; + } +} diff --git a/src/DTO/Request/Inbound/UpdateInboundForwardRule.php b/src/DTO/Request/Inbound/UpdateInboundForwardRule.php new file mode 100644 index 0000000..285e1e3 --- /dev/null +++ b/src/DTO/Request/Inbound/UpdateInboundForwardRule.php @@ -0,0 +1,54 @@ +name !== null) { + $payload['name'] = $this->name; + } + + if ($this->conditions !== null) { + $payload['conditions'] = array_values(array_map( + static fn (InboundForwardRuleCondition $condition): array => $condition->toArray(), + $this->conditions + )); + } + + if ($this->destinations !== null) { + $payload['destinations'] = array_values(array_map( + static fn (InboundForwardRuleDestination $destination): array => $destination->toArray(), + $this->destinations + )); + } + + if ($payload === []) { + throw new InvalidArgumentException('At least one updatable field must be provided to update a forward rule'); + } + + return $payload; + } +} diff --git a/src/MailtrapInboundClient.php b/src/MailtrapInboundClient.php index 0f3b8c0..6545b92 100644 --- a/src/MailtrapInboundClient.php +++ b/src/MailtrapInboundClient.php @@ -5,10 +5,11 @@ namespace Mailtrap; /** - * @method Api\Inbound\Folder folders() - * @method Api\Inbound\Inbox inboxes(int $folderId) - * @method Api\Inbound\Message messages(int $inboxId) - * @method Api\Inbound\Thread threads(int $inboxId) + * @method Api\Inbound\Folder folders() + * @method Api\Inbound\Inbox inboxes(int $folderId) + * @method Api\Inbound\Message messages(int $inboxId) + * @method Api\Inbound\Thread threads(int $inboxId) + * @method Api\Inbound\ForwardRule forwardRules(int $inboxId) * * Class MailtrapInboundClient */ @@ -19,5 +20,6 @@ final class MailtrapInboundClient extends AbstractMailtrapClient 'inboxes' => Api\Inbound\Inbox::class, 'messages' => Api\Inbound\Message::class, 'threads' => Api\Inbound\Thread::class, + 'forwardRules' => Api\Inbound\ForwardRule::class, ]; } diff --git a/tests/Api/Inbound/ForwardRuleTest.php b/tests/Api/Inbound/ForwardRuleTest.php new file mode 100644 index 0000000..107bb0a --- /dev/null +++ b/tests/Api/Inbound/ForwardRuleTest.php @@ -0,0 +1,230 @@ +forwardRule = $this->getMockBuilder(ForwardRuleApi::class) + ->onlyMethods(['httpGet', 'httpPost', 'httpPatch', 'httpDelete']) + ->setConstructorArgs([$this->getConfigMock(), self::FAKE_INBOX_ID]) + ->getMock(); + } + + protected function tearDown(): void + { + $this->forwardRule = null; + parent::tearDown(); + } + + public function testGetList(): void + { + $this->forwardRule->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => [$this->getRuleData(), ['id' => 8, 'name' => 'Archive', 'conditions' => [], 'destinations' => [['email' => 'archive@example.com']]]]]) + )); + + $data = ResponseHelper::toArray($this->forwardRule->getList()); + + $this->assertCount(2, $data['data']); + $this->assertSame(self::FAKE_RULE_ID, $data['data'][0]['id']); + $this->assertSame('ends_with', $data['data'][0]['conditions'][0]['operator']); + $this->assertSame([], $data['data'][1]['conditions']); + } + + public function testGetById(): void + { + $this->forwardRule->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL . '/' . self::FAKE_RULE_ID) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => $this->getRuleData()]) + )); + + $data = ResponseHelper::toArray($this->forwardRule->getById(self::FAKE_RULE_ID)); + + $this->assertSame('Copy billing mail to finance', $data['data']['name']); + $this->assertSame('finance@example.com', $data['data']['destinations'][0]['email']); + } + + public function testCreateSendsFlatBody(): void + { + $this->forwardRule->expects($this->once()) + ->method('httpPost') + ->with(self::BASE_URL, [], [ + 'name' => 'Escalate urgent tickets', + 'conditions' => [ + ['match_type' => 'sender', 'operator' => 'ends_with', 'value' => '@billing.example.com'], + ['match_type' => 'header', 'operator' => 'equal', 'value' => 'high', 'header_key' => 'X-Priority-Level'], + ['match_type' => 'header', 'operator' => 'not_empty', 'header_key' => 'X-Ticket-Id'], + ], + 'destinations' => [ + ['email' => 'oncall@example.com'], + ], + ]) + ->willReturn(new Response( + 201, + ['Content-Type' => 'application/json'], + json_encode(['data' => $this->getRuleData()]) + )); + + $response = $this->forwardRule->create(new CreateInboundForwardRule( + name: 'Escalate urgent tickets', + conditions: [ + new InboundForwardRuleCondition( + InboundForwardRule::MATCH_TYPE_SENDER, + InboundForwardRule::OPERATOR_ENDS_WITH, + '@billing.example.com' + ), + new InboundForwardRuleCondition( + matchType: InboundForwardRule::MATCH_TYPE_HEADER, + operator: InboundForwardRule::OPERATOR_EQUAL, + value: 'high', + headerKey: 'X-Priority-Level' + ), + new InboundForwardRuleCondition( + matchType: InboundForwardRule::MATCH_TYPE_HEADER, + operator: InboundForwardRule::OPERATOR_NOT_EMPTY, + headerKey: 'X-Ticket-Id' + ), + ], + destinations: [new InboundForwardRuleDestination('oncall@example.com')], + )); + + $this->assertSame(201, $response->getStatusCode()); + } + + public function testCreateWithNameOnlyOmitsConditionsAndDestinations(): void + { + $this->forwardRule->expects($this->once()) + ->method('httpPost') + ->with(self::BASE_URL, [], ['name' => 'Catch all']) + ->willReturn(new Response(201, ['Content-Type' => 'application/json'], json_encode(['data' => $this->getRuleData()]))); + + $this->forwardRule->create(new CreateInboundForwardRule('Catch all')); + } + + public function testUpdateSendsOnlyProvidedFields(): void + { + $this->forwardRule->expects($this->once()) + ->method('httpPatch') + ->with(self::BASE_URL . '/' . self::FAKE_RULE_ID, [], [ + 'destinations' => [ + ['email' => 'finance@example.com'], + ['email' => 'accounting@example.com'], + ], + ]) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => $this->getRuleData()]) + )); + + $this->forwardRule->update(self::FAKE_RULE_ID, new UpdateInboundForwardRule( + destinations: [ + new InboundForwardRuleDestination('finance@example.com'), + new InboundForwardRuleDestination('accounting@example.com'), + ], + )); + } + + public function testUpdateSendsEmptyArrayToClearConditions(): void + { + $this->forwardRule->expects($this->once()) + ->method('httpPatch') + ->with(self::BASE_URL . '/' . self::FAKE_RULE_ID, [], ['name' => 'Renamed', 'conditions' => []]) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => $this->getRuleData()]) + )); + + $this->forwardRule->update( + self::FAKE_RULE_ID, + new UpdateInboundForwardRule(name: 'Renamed', conditions: []) + ); + } + + public function testUpdateWithoutFieldsThrows(): void + { + $this->forwardRule->expects($this->never())->method('httpPatch'); + + $this->expectException(InvalidArgumentException::class); + + $this->forwardRule->update(self::FAKE_RULE_ID, new UpdateInboundForwardRule()); + } + + public function testDelete(): void + { + $this->forwardRule->expects($this->once()) + ->method('httpDelete') + ->with(self::BASE_URL . '/' . self::FAKE_RULE_ID) + ->willReturn(new Response(204)); + + $this->assertSame(204, $this->forwardRule->delete(self::FAKE_RULE_ID)->getStatusCode()); + } + + public function testConditionRejectsUnknownMatchType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"matchType" must be one of'); + + new InboundForwardRuleCondition('subject', InboundForwardRule::OPERATOR_EQUAL, 'x'); + } + + public function testConditionRejectsUnknownOperator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"operator" must be one of'); + + new InboundForwardRuleCondition(InboundForwardRule::MATCH_TYPE_SENDER, 'matches', 'x'); + } + + private function getRuleData(): array + { + return [ + 'id' => self::FAKE_RULE_ID, + 'name' => 'Copy billing mail to finance', + 'created_at' => '2026-05-08T10:30:00.000Z', + 'updated_at' => '2026-05-08T10:30:00.000Z', + 'conditions' => [ + ['match_type' => 'sender', 'operator' => 'ends_with', 'value' => '@billing.example.com', 'header_key' => null], + ], + 'destinations' => [ + ['email' => 'finance@example.com'], + ], + ]; + } +} diff --git a/tests/Api/Inbound/MessageTest.php b/tests/Api/Inbound/MessageTest.php index 915d923..dc5d495 100644 --- a/tests/Api/Inbound/MessageTest.php +++ b/tests/Api/Inbound/MessageTest.php @@ -71,6 +71,44 @@ public function testGetListPassesLastIdCursor(): void $this->message->getList('cursor-1'); } + public function testGetListExposesForwards(): 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, + 'forwards' => [ + [ + 'rule_id' => 7, + 'rule_name' => 'Copy to support team', + 'destination' => 'team@example.com', + 'status' => 'rejected', + 'reason' => 'loop_prevention', + 'message_id' => null, + ], + ], + ], + ], + 'total_count' => 1, + 'last_id' => null, + ]) + )); + + $data = ResponseHelper::toArray($this->message->getList()); + + $forward = $data['data'][0]['forwards'][0]; + $this->assertSame('team@example.com', $forward['destination']); + $this->assertSame('rejected', $forward['status']); + $this->assertSame('loop_prevention', $forward['reason']); + $this->assertNull($forward['message_id']); + } + public function testGetById(): void { $this->message->expects($this->once()) diff --git a/tests/Api/Inbound/ThreadTest.php b/tests/Api/Inbound/ThreadTest.php index 06d8e85..1247a31 100644 --- a/tests/Api/Inbound/ThreadTest.php +++ b/tests/Api/Inbound/ThreadTest.php @@ -67,6 +67,34 @@ public function testGetListPassesLastIdCursor(): void $this->thread->getList('cursor-1'); } + public function testGetListPassesSearch(): void + { + $this->thread->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL, ['search' => 'acme']) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => [], 'total_count' => 0, 'last_id' => null]) + )); + + $this->thread->getList(search: 'acme'); + } + + public function testGetListPassesSearchTogetherWithLastIdCursor(): void + { + $this->thread->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_URL, ['last_id' => 'WzE3NzgyNDE5MDAwMDAsIjE3MDAwMDAwMDAwMDAxMjMiXQ==', 'search' => 'acme']) + ->willReturn(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => [], 'total_count' => 0, 'last_id' => null]) + )); + + $this->thread->getList('WzE3NzgyNDE5MDAwMDAsIjE3MDAwMDAwMDAwMDAxMjMiXQ==', 'acme'); + } + public function testGetById(): void { $this->thread->expects($this->once()) @@ -83,6 +111,59 @@ public function testGetById(): void $this->assertSame('inbound', $data['messages'][0]['direction']); } + public function testGetByIdExposesForwardsAndDelivery(): 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', + 'visibility_status' => 'available', + 'forwards' => [ + [ + 'rule_id' => 7, + 'rule_name' => 'Copy to support team', + 'destination' => 'team@example.com', + 'status' => 'forwarded', + 'reason' => null, + 'message_id' => 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + ], + ], + ], + [ + 'direction' => 'outbound', + 'visibility_status' => 'available', + 'delivery' => [ + 'to' => 'customer@example.com', + 'status' => 'delivered', + 'delivered_at' => '2026-05-08T11:40:05.000Z', + 'bounced_at' => null, + ], + ], + ], + ]) + )); + + $data = ResponseHelper::toArray($this->thread->getById(self::FAKE_THREAD_ID)); + + $forward = $data['messages'][0]['forwards'][0]; + $this->assertSame(7, $forward['rule_id']); + $this->assertSame('forwarded', $forward['status']); + $this->assertNull($forward['reason']); + + $delivery = $data['messages'][1]['delivery']; + $this->assertSame('customer@example.com', $delivery['to']); + $this->assertSame('delivered', $delivery['status']); + $this->assertSame('2026-05-08T11:40:05.000Z', $delivery['delivered_at']); + $this->assertNull($delivery['bounced_at']); + } + public function testDelete(): void { $this->thread->expects($this->once()) diff --git a/tests/MailtrapInboundClientTest.php b/tests/MailtrapInboundClientTest.php index cfbd767..93fca8f 100644 --- a/tests/MailtrapInboundClientTest.php +++ b/tests/MailtrapInboundClientTest.php @@ -28,7 +28,7 @@ public function mapInstancesProvider(): iterable { foreach (MailtrapInboundClient::API_MAPPING as $key => $item) { yield match ($key) { - 'inboxes', 'messages', 'threads' => [new $item($this->getConfigMock(), 1)], + 'inboxes', 'messages', 'threads', 'forwardRules' => [new $item($this->getConfigMock(), 1)], default => [new $item($this->getConfigMock())], }; }