Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions examples/general/contacts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -271,3 +272,51 @@
} catch (Exception $e) {
echo 'Caught exception: ', $e->getMessage(), PHP_EOL;
}


/**
* 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
*/
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;
}
44 changes: 44 additions & 0 deletions src/Api/General/Contact.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
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 Mailtrap\Exception\InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;

/**
Expand Down Expand Up @@ -246,6 +248,48 @@ 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(
function ($contact): array {
if (!$contact instanceof ImportContact) {
throw new InvalidArgumentException(
'Each contact must be an instance of ImportContact.'
);
}
return $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;
Expand Down
58 changes: 58 additions & 0 deletions src/DTO/Request/Contact/ImportContact.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Mailtrap\DTO\Request\Contact;

/**
* Class ImportContact
*/
final class ImportContact implements ContactInterface
{
public function __construct(
private string $email,
private array $fields = [],
private array $listIdsIncluded = [],
private array $listIdsExcluded = []
) {
}

public static function init(
string $email,
array $fields = [],
array $listIdsIncluded = [],
array $listIdsExcluded = []
): self {
return new self($email, $fields, $listIdsIncluded, $listIdsExcluded);
}

public function getEmail(): string
{
return $this->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 [
'email' => $this->getEmail(),
'fields' => $this->getFields(),
'list_ids_included' => $this->getListIdsIncluded(),
'list_ids_excluded' => $this->getListIdsExcluded(),
];
}
}
177 changes: 177 additions & 0 deletions tests/Api/General/ContactTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
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\Exception\InvalidArgumentException;
use Mailtrap\Tests\MailtrapTestCase;
use Nyholm\Psr7\Response;
use Mailtrap\Helper\ResponseHelper;
Expand Down Expand Up @@ -476,6 +478,181 @@ 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);
}

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 [
Expand Down