diff --git a/CHANGELOG.md b/CHANGELOG.md index 208c16e..8ddfab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [3.9.0] - 2025-10-14 +- Add Contact Export functionality + ## [3.8.0] - 2025-09-22 - Add Create Contact event API functionality diff --git a/README.md b/README.md index 1f08db7..50cdcfe 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Currently with this SDK you can: - Fields CRUD - Contacts CRUD - Lists CRUD - - Import + - Import/Export - Events - General - Templates CRUD diff --git a/examples/general/contacts.php b/examples/general/contacts.php index aeacb03..5160391 100644 --- a/examples/general/contacts.php +++ b/examples/general/contacts.php @@ -7,6 +7,7 @@ use Mailtrap\DTO\Request\Contact\UpdateContact; use Mailtrap\Helper\ResponseHelper; use Mailtrap\MailtrapGeneralClient; +use Mailtrap\DTO\Request\Contact\ContactExportFilter; require __DIR__ . '/../vendor/autoload.php'; @@ -348,3 +349,40 @@ } catch (Exception $e) { echo 'Caught exception: ', $e->getMessage(), PHP_EOL; } + +/** + * Create a new Contact Export (asynchronous task) + * + * POST https://mailtrap.io/api/accounts/{account_id}/contacts/exports + */ +try { + $filters = [ + // Export contacts that belong to lists 1 or 2 + ContactExportFilter::init('list_id', 'equal', [1, 2]), + // Only subscribed contacts + ContactExportFilter::init('subscription_status', 'equal', 'subscribed'), + ]; + + $response = $contacts->createContactExport($filters); + + // print the response body (array) + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; +} + +/** + * Get Contact Export status / download URL + * (Poll this endpoint until status becomes `finished` and `url` is not null) + * + * GET https://mailtrap.io/api/accounts/{account_id}/contacts/exports/{export_id} + */ +try { + $exportId = 1; // Replace 1 with the actual export ID obtained from createContactExport + $response = $contacts->getContactExport($exportId); + + // print the response body (array) + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; +} diff --git a/src/Api/General/Contact.php b/src/Api/General/Contact.php index 8d276a9..b6323c3 100644 --- a/src/Api/General/Contact.php +++ b/src/Api/General/Contact.php @@ -8,6 +8,7 @@ use Mailtrap\ConfigInterface; use Mailtrap\DTO\Request\Contact\CreateContact; use Mailtrap\DTO\Request\Contact\CreateContactEvent; +use Mailtrap\DTO\Request\Contact\ContactExportFilter; use Mailtrap\DTO\Request\Contact\ImportContact; use Mailtrap\DTO\Request\Contact\UpdateContact; use Mailtrap\Exception\InvalidArgumentException; @@ -308,6 +309,50 @@ public function createContactEvent(string $contactIdentifier, CreateContactEvent ); } + /** + * Create a new Contact Export. + * + * POST https://mailtrap.io/api/accounts/{account_id}/contacts/exports + * + * @param ContactExportFilter[] $filters + * @return ResponseInterface + */ + public function createContactExport(array $filters = []): ResponseInterface + { + return $this->handleResponse( + $this->httpPost( + path: $this->getBasePath() . '/exports', + body: [ + 'filters' => array_map( + function ($filter): array { + if (!$filter instanceof ContactExportFilter) { + throw new InvalidArgumentException('Each filter must be an instance of ContactExportFilter.'); + } + + return $filter->toArray(); + }, + $filters + ) + ] + ) + ); + } + + /** + * Get Contact Export status/info by ID. + * + * GET https://mailtrap.io/api/accounts/{account_id}/contacts/exports/{export_id} + * + * @param int $exportId + * @return ResponseInterface + */ + public function getContactExport(int $exportId): ResponseInterface + { + return $this->handleResponse( + $this->httpGet($this->getBasePath() . '/exports/' . $exportId) + ); + } + public function getAccountId(): int { return $this->accountId; diff --git a/src/DTO/Request/Contact/ContactExportFilter.php b/src/DTO/Request/Contact/ContactExportFilter.php new file mode 100644 index 0000000..64a5107 --- /dev/null +++ b/src/DTO/Request/Contact/ContactExportFilter.php @@ -0,0 +1,50 @@ +name; + } + + public function getOperator(): string + { + return $this->operator; + } + + public function getValue(): mixed + { + return $this->value; + } + + public function toArray(): array + { + return [ + 'name' => $this->getName(), + 'operator' => $this->getOperator(), + 'value' => $this->getValue(), + ]; + } +} + diff --git a/tests/Api/General/ContactTest.php b/tests/Api/General/ContactTest.php index 86b7347..6d82a08 100644 --- a/tests/Api/General/ContactTest.php +++ b/tests/Api/General/ContactTest.php @@ -6,6 +6,7 @@ use Mailtrap\Api\General\Contact; use Mailtrap\DTO\Request\Contact\CreateContact; use Mailtrap\DTO\Request\Contact\CreateContactEvent; +use Mailtrap\DTO\Request\Contact\ContactExportFilter; use Mailtrap\DTO\Request\Contact\UpdateContact; use Mailtrap\DTO\Request\Contact\ImportContact; use Mailtrap\Exception\HttpClientException; @@ -925,6 +926,139 @@ public function testCreateContactEventRateLimitExceeded(): void $this->contact->createContactEvent($contactIdentifier, $eventData); } + /** + * ============================= + * Contact Exports + * ============================= + */ + public function testCreateContactExport(): void + { + $filters = [ + new ContactExportFilter('list_id', 'equal', [101, 102]), + new ContactExportFilter('subscription_status', 'equal', 'subscribed'), + ]; + $expectedResponse = [ + 'id' => 1, + 'status' => 'started', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-05-01T00:00:00Z', + 'url' => null, + ]; + $this->contact->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports', + [], + ['filters' => array_map(fn(ContactExportFilter $f) => $f->toArray(), $filters)] + ) + ->willReturn(new Response(201, ['Content-Type' => 'application/json'], json_encode($expectedResponse))); + $response = $this->contact->createContactExport($filters); + $responseData = ResponseHelper::toArray($response); + $this->assertArrayHasKey('id', $responseData); + } + + public function testCreateContactExportUnauthorized(): void + { + $this->contact->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports', + [], + ['filters' => []] + ) + ->willReturn(new Response(401, ['Content-Type' => 'application/json'], json_encode(['error' => 'Incorrect API token']))); + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('Errors: Incorrect API token.'); + $this->contact->createContactExport(); + } + + public function testCreateContactExportForbidden(): void + { + $this->contact->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports', + [], + ['filters' => []] + ) + ->willReturn(new Response(403, ['Content-Type' => 'application/json'], json_encode(['errors' => 'Access forbidden']))); + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('Errors: Access forbidden.'); + $this->contact->createContactExport(); + } + + public function testCreateContactExportValidationError(): void + { + $filters = [new ContactExportFilter('list_id', 'equal', [1])]; + $errors = [ + 'errors' => [ + 'filters' => 'invalid', + 'base' => [ + 'There is a previous export initiated. You will be notified by email once it is completed.' + ], + ], + ]; + + $this->contact->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports', + [], + ['filters' => array_map(fn(ContactExportFilter $f) => $f->toArray(), $filters)] + ) + ->willReturn(new Response(422, ['Content-Type' => 'application/json'], json_encode($errors))); + + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('Errors: filters -> invalid. base -> There is a previous export initiated. You will be notified by email once it is completed.'); + + $this->contact->createContactExport($filters); + } + + public function testCreateContactExportRateLimitExceeded(): void + { + $this->contact->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports', + [], + ['filters' => []] + ) + ->willReturn(new Response(429, ['Content-Type' => 'application/json'], json_encode(['errors' => 'Rate limit exceeded']))); + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('Errors: Rate limit exceeded.'); + $this->contact->createContactExport(); + } + + public function testGetContactExport(): void + { + $exportId = 1; + $expectedResponse = [ + 'id' => $exportId, + 'status' => 'started', + 'created_at' => '2021-01-01T00:00:00Z', + 'updated_at' => '2021-01-01T00:00:00Z', + 'url' => null, + ]; + $this->contact->expects($this->once()) + ->method('httpGet') + ->with(AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports/' . $exportId) + ->willReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse))); + $response = $this->contact->getContactExport($exportId); + $responseData = ResponseHelper::toArray($response); + $this->assertArrayHasKey('id', $responseData); + } + + public function testGetContactExportNotFound(): void + { + $exportId = 9999; + $this->contact->expects($this->once()) + ->method('httpGet') + ->with(AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports/' . $exportId) + ->willReturn(new Response(404, ['Content-Type' => 'application/json'], json_encode(['error' => 'Not Found']))); + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('Errors: Not Found.'); + $this->contact->getContactExport($exportId); + } private function getExpectedContactFields(): array { return [