From c96bb2e3a8a65436c8467bfbaa92a8ed37b5d236 Mon Sep 17 00:00:00 2001 From: gaalferov Date: Sat, 12 Jul 2025 14:27:57 +0200 Subject: [PATCH 1/4] add Contact Imports API functionality with tests and examples --- CHANGELOG.md | 3 + README.md | 1 + examples/general/contacts.php | 46 +++++++ src/Api/General/Contact.php | 30 +++++ src/DTO/Request/Contact/ImportContact.php | 61 +++++++++ tests/Api/General/ContactTest.php | 150 ++++++++++++++++++++++ 6 files changed, 291 insertions(+) create mode 100644 src/DTO/Request/Contact/ImportContact.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e97800c..3967355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [3.6.0] - 2025-07-15 +- Add Contact Imports API functionality + ## [3.5.0] - 2025-07-12 - Add Contact Fields API functionality diff --git a/README.md b/README.md index cc65570..a746630 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Currently with this SDK you can: - Fields CRUD - Contacts CRUD - Lists CRUD + - Import - General - Templates CRUD - Suppressions management (find and delete) diff --git a/examples/general/contacts.php b/examples/general/contacts.php index 5b52f98..8e57590 100644 --- a/examples/general/contacts.php +++ b/examples/general/contacts.php @@ -2,6 +2,7 @@ use Mailtrap\Config; use Mailtrap\DTO\Request\Contact\CreateContact; +use Mailtrap\DTO\Request\Contact\ImportContact; use Mailtrap\DTO\Request\Contact\UpdateContact; use Mailtrap\Helper\ResponseHelper; use Mailtrap\MailtrapGeneralClient; @@ -271,3 +272,48 @@ } catch (Exception $e) { echo 'Caught exception: ', $e->getMessage(), PHP_EOL; } + + +/** + * Import contacts in bulk. + * + * POST https://mailtrap.io/api/accounts/{account_id}/contacts/imports + */ +try { + $contactsToImport = [ + new ImportContact( + email: 'customer1@example.com', + fields: ['first_name' => 'John', 'last_name' => 'Smith', 'zip_code' => 11111], + listIdsIncluded: [1, 2], + listIdsExcluded: [4, 5] + ), + new ImportContact( + email: 'customer2@example.com', + fields: ['first_name' => 'Joe', 'last_name' => 'Doe', 'zip_code' => 22222], + listIdsIncluded: [1], + listIdsExcluded: [4] + ), + ]; + + $response = $contacts->importContacts($contactsToImport); + // print the response body (array) + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; +} + + +/** + * Get the status of a contact import by ID. + * + * GET https://mailtrap.io/api/accounts/{account_id}/contacts/imports/{import_id} + */ +try { + $importId = 1; // Replace 1 with the actual import ID + $response = $contacts->getContactImport($importId); + + // 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 56ff150..c8bb005 100644 --- a/src/Api/General/Contact.php +++ b/src/Api/General/Contact.php @@ -7,6 +7,7 @@ use Mailtrap\Api\AbstractApi; use Mailtrap\ConfigInterface; use Mailtrap\DTO\Request\Contact\CreateContact; +use Mailtrap\DTO\Request\Contact\ImportContact; use Mailtrap\DTO\Request\Contact\UpdateContact; use Psr\Http\Message\ResponseInterface; @@ -246,6 +247,35 @@ public function deleteContactField(int $fieldId): ResponseInterface ); } + /** + * Import contacts in bulk. + * + * @param ImportContact[] $contacts + * @return ResponseInterface + */ + public function importContacts(array $contacts): ResponseInterface + { + return $this->handleResponse( + $this->httpPost( + path: $this->getBasePath() . '/imports', + body: ['contacts' => array_map(fn(ImportContact $contact) => $contact->toArray(), $contacts)] + ) + ); + } + + /** + * Get the status of a contact import by ID. + * + * @param int $importId + * @return ResponseInterface + */ + public function getContactImport(int $importId): ResponseInterface + { + return $this->handleResponse( + $this->httpGet($this->getBasePath() . '/imports/' . $importId) + ); + } + public function getAccountId(): int { return $this->accountId; diff --git a/src/DTO/Request/Contact/ImportContact.php b/src/DTO/Request/Contact/ImportContact.php new file mode 100644 index 0000000..2fad734 --- /dev/null +++ b/src/DTO/Request/Contact/ImportContact.php @@ -0,0 +1,61 @@ +email; + } + + public function getFields(): array + { + return $this->fields; + } + + public function getListIdsIncluded(): array + { + return $this->listIdsIncluded; + } + + public function getListIdsExcluded(): array + { + return $this->listIdsExcluded; + } + + public function toArray(): array + { + return array_filter( + [ + 'email' => $this->getEmail(), + 'fields' => $this->getFields(), + 'list_ids_included' => $this->getListIdsIncluded(), + 'list_ids_excluded' => $this->getListIdsExcluded(), + ], + fn($value) => $value !== null + ); + } +} diff --git a/tests/Api/General/ContactTest.php b/tests/Api/General/ContactTest.php index 1093e3d..28a428c 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\UpdateContact; +use Mailtrap\DTO\Request\Contact\ImportContact; use Mailtrap\Exception\HttpClientException; use Mailtrap\Tests\MailtrapTestCase; use Nyholm\Psr7\Response; @@ -476,6 +477,155 @@ public function testDeleteContactField(): void $this->assertEquals(204, $response->getStatusCode()); } + public function testImportContacts(): void + { + $contacts = [ + new ImportContact( + email: 'customer1@example.com', + fields: ['first_name' => 'John', 'last_name' => 'Smith', 'zip_code' => 11111], + listIdsIncluded: [1, 2, 3], + listIdsExcluded: [4, 5, 6] + ), + new ImportContact( + email: 'customer2@example.com', + fields: ['first_name' => 'Joe', 'last_name' => 'Doe', 'zip_code' => 22222], + listIdsIncluded: [1], + listIdsExcluded: [4] + ), + ]; + + $expectedResponse = [ + 'id' => 1, + 'status' => 'created', + ]; + + $this->contact->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/imports', + [], + ['contacts' => array_map(fn(ImportContact $contact) => $contact->toArray(), $contacts)] + ) + ->willReturn(new Response(201, ['Content-Type' => 'application/json'], json_encode($expectedResponse))); + + $response = $this->contact->importContacts($contacts); + $responseData = ResponseHelper::toArray($response); + + $this->assertInstanceOf(Response::class, $response); + $this->assertArrayHasKey('id', $responseData); + $this->assertEquals(1, $responseData['id']); + $this->assertEquals('created', $responseData['status']); + } + + public function testGetContactImportInProgress(): void + { + $importId = 1; + $expectedResponse = [ + 'id' => $importId, + 'status' => 'created', + ]; + + $this->contact->expects($this->once()) + ->method('httpGet') + ->with(AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/imports/' . $importId) + ->willReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse))); + + $response = $this->contact->getContactImport($importId); + $responseData = ResponseHelper::toArray($response); + + $this->assertInstanceOf(Response::class, $response); + $this->assertArrayHasKey('id', $responseData); + $this->assertEquals($importId, $responseData['id']); + $this->assertEquals('created', $responseData['status']); + } + + public function testGetContactImportFinished(): void + { + $importId = 1; + $expectedResponse = [ + 'id' => $importId, + 'status' => 'finished', + 'created_contacts_count' => 2, + 'updated_contacts_count' => 0, + 'contacts_over_limit_count' => 0, + ]; + + $this->contact->expects($this->once()) + ->method('httpGet') + ->with(AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/imports/' . $importId) + ->willReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse))); + + $response = $this->contact->getContactImport($importId); + $responseData = ResponseHelper::toArray($response); + + $this->assertInstanceOf(Response::class, $response); + $this->assertArrayHasKey('id', $responseData); + $this->assertEquals($importId, $responseData['id']); + $this->assertEquals('finished', $responseData['status']); + $this->assertEquals(2, $responseData['created_contacts_count']); + $this->assertEquals(0, $responseData['updated_contacts_count']); + $this->assertEquals(0, $responseData['contacts_over_limit_count']); + } + + public function testGetContactImportNotFound(): void + { + $importId = 999; + + $this->contact->expects($this->once()) + ->method('httpGet') + ->with(AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/imports/' . $importId) + ->willReturn( + new Response(404, ['Content-Type' => 'application/json'], json_encode(['error' => 'Not Found'])) + ); + + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('Errors: Not Found.'); + + $this->contact->getContactImport($importId); + } + + public function testImportContactsValidationError(): void + { + $contacts = [ + new ImportContact( + email: 'invalid-email', + fields: ['first_name' => 'John'], + listIdsIncluded: [], + listIdsExcluded: [] + ), + ]; + + $expectedResponse = [ + 'errors' => [ + [ + 'email' => 'invalid-email', + 'errors' => [ + 'email' => [ + 'is invalid', + 'top level domain is too short', + ], + ], + ], + ], + ]; + + $this->contact->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/imports', + [], + ['contacts' => array_map(fn(ImportContact $contact) => $contact->toArray(), $contacts)] + ) + ->willReturn( + new Response(422, ['Content-Type' => 'application/json'], json_encode($expectedResponse)) + ); + + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('Errors: email -> invalid-email. errors -> email -> is invalid. top level domain is too short.'); + + $this->contact->importContacts($contacts); + } + private function getExpectedContactFields(): array { return [ From e4cab763955dd4d5bc3ab706a8ce5c5027ae15f5 Mon Sep 17 00:00:00 2001 From: gaalferov Date: Sat, 12 Jul 2025 14:29:47 +0200 Subject: [PATCH 2/4] update readme in example --- examples/general/contacts.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/general/contacts.php b/examples/general/contacts.php index 8e57590..9529fdd 100644 --- a/examples/general/contacts.php +++ b/examples/general/contacts.php @@ -275,7 +275,10 @@ /** - * Import contacts in bulk. + * Import contacts in bulk with support for custom fields and list management. + * Existing contacts with matching email addresses will be updated automatically. + * You can import up to 50,000 contacts per request. + * The import process runs asynchronously - use the returned import ID to check the status and results. * * POST https://mailtrap.io/api/accounts/{account_id}/contacts/imports */ From 76e1cc116f0c5cb0077a437b216f77d6edc90a6d Mon Sep 17 00:00:00 2001 From: gaalferov Date: Sun, 13 Jul 2025 19:19:58 +0200 Subject: [PATCH 3/4] validate input for importContacts method and add corresponding tests --- src/Api/General/Contact.php | 16 +++++++++++++++- tests/Api/General/ContactTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Api/General/Contact.php b/src/Api/General/Contact.php index c8bb005..2d1b338 100644 --- a/src/Api/General/Contact.php +++ b/src/Api/General/Contact.php @@ -9,6 +9,7 @@ use Mailtrap\DTO\Request\Contact\CreateContact; use Mailtrap\DTO\Request\Contact\ImportContact; use Mailtrap\DTO\Request\Contact\UpdateContact; +use Mailtrap\Exception\InvalidArgumentException; use Psr\Http\Message\ResponseInterface; /** @@ -255,10 +256,23 @@ public function deleteContactField(int $fieldId): ResponseInterface */ public function importContacts(array $contacts): ResponseInterface { + return $this->handleResponse( $this->httpPost( path: $this->getBasePath() . '/imports', - body: ['contacts' => array_map(fn(ImportContact $contact) => $contact->toArray(), $contacts)] + body: [ + 'contacts' => array_map( + function ($contact): array { + if (!$contact instanceof ImportContact) { + throw new InvalidArgumentException( + 'Each contact must be an instance of ImportContact.' + ); + } + return $contact->toArray(); + }, + $contacts + ) + ] ) ); } diff --git a/tests/Api/General/ContactTest.php b/tests/Api/General/ContactTest.php index 28a428c..5d0dc6c 100644 --- a/tests/Api/General/ContactTest.php +++ b/tests/Api/General/ContactTest.php @@ -8,6 +8,7 @@ use Mailtrap\DTO\Request\Contact\UpdateContact; use Mailtrap\DTO\Request\Contact\ImportContact; use Mailtrap\Exception\HttpClientException; +use Mailtrap\Exception\InvalidArgumentException; use Mailtrap\Tests\MailtrapTestCase; use Nyholm\Psr7\Response; use Mailtrap\Helper\ResponseHelper; @@ -626,6 +627,32 @@ public function testImportContactsValidationError(): void $this->contact->importContacts($contacts); } + public function testImportContactsThrowsExceptionForInvalidInput(): void + { + $contacts = [ + new ImportContact( + email: 'valid@example.com', + fields: ['first_name' => 'John'], + listIdsIncluded: [1], + listIdsExcluded: [] + ), + // Invalid input + new UpdateContact( + email: 'valid@example.com', + fields: ['first_name' => 'John'], + listIdsIncluded: [1], + listIdsExcluded: [] + ), + ]; + + $this->contact->expects($this->never())->method('httpPost'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each contact must be an instance of ImportContact.'); + + $this->contact->importContacts($contacts); + } + private function getExpectedContactFields(): array { return [ From 7c440501880c6ceb35a5ef9267c7d2341c5aecc6 Mon Sep 17 00:00:00 2001 From: gaalferov Date: Sun, 13 Jul 2025 19:21:53 +0200 Subject: [PATCH 4/4] refactor toArray method in ImportContact to simplify return structure --- src/DTO/Request/Contact/ImportContact.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/DTO/Request/Contact/ImportContact.php b/src/DTO/Request/Contact/ImportContact.php index 2fad734..3122886 100644 --- a/src/DTO/Request/Contact/ImportContact.php +++ b/src/DTO/Request/Contact/ImportContact.php @@ -48,14 +48,11 @@ public function getListIdsExcluded(): array public function toArray(): array { - return array_filter( - [ - 'email' => $this->getEmail(), - 'fields' => $this->getFields(), - 'list_ids_included' => $this->getListIdsIncluded(), - 'list_ids_excluded' => $this->getListIdsExcluded(), - ], - fn($value) => $value !== null - ); + return [ + 'email' => $this->getEmail(), + 'fields' => $this->getFields(), + 'list_ids_included' => $this->getListIdsIncluded(), + 'list_ids_excluded' => $this->getListIdsExcluded(), + ]; } }