Skip to content

Commit 9fa8b36

Browse files
authored
Merge pull request #53 from railsware/feature/contacts-export
Add Contact Export functionality with filters and related tests
2 parents 09a4cd5 + 564f025 commit 9fa8b36

File tree

6 files changed

+271
-1
lines changed

6 files changed

+271
-1
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## [3.9.0] - 2025-10-14
2+
- Add Contact Export functionality
3+
14
## [3.8.0] - 2025-09-22
25
- Add Create Contact event API functionality
36

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Currently with this SDK you can:
3434
- Fields CRUD
3535
- Contacts CRUD
3636
- Lists CRUD
37-
- Import
37+
- Import/Export
3838
- Events
3939
- General
4040
- Templates CRUD

examples/general/contacts.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Mailtrap\DTO\Request\Contact\UpdateContact;
88
use Mailtrap\Helper\ResponseHelper;
99
use Mailtrap\MailtrapGeneralClient;
10+
use Mailtrap\DTO\Request\Contact\ContactExportFilter;
1011

1112
require __DIR__ . '/../vendor/autoload.php';
1213

@@ -348,3 +349,40 @@
348349
} catch (Exception $e) {
349350
echo 'Caught exception: ', $e->getMessage(), PHP_EOL;
350351
}
352+
353+
/**
354+
* Create a new Contact Export (asynchronous task)
355+
*
356+
* POST https://mailtrap.io/api/accounts/{account_id}/contacts/exports
357+
*/
358+
try {
359+
$filters = [
360+
// Export contacts that belong to lists 1 or 2
361+
ContactExportFilter::init('list_id', 'equal', [1, 2]),
362+
// Only subscribed contacts
363+
ContactExportFilter::init('subscription_status', 'equal', 'subscribed'),
364+
];
365+
366+
$response = $contacts->createContactExport($filters);
367+
368+
// print the response body (array)
369+
var_dump(ResponseHelper::toArray($response));
370+
} catch (Exception $e) {
371+
echo 'Caught exception: ', $e->getMessage(), PHP_EOL;
372+
}
373+
374+
/**
375+
* Get Contact Export status / download URL
376+
* (Poll this endpoint until status becomes `finished` and `url` is not null)
377+
*
378+
* GET https://mailtrap.io/api/accounts/{account_id}/contacts/exports/{export_id}
379+
*/
380+
try {
381+
$exportId = 1; // Replace 1 with the actual export ID obtained from createContactExport
382+
$response = $contacts->getContactExport($exportId);
383+
384+
// print the response body (array)
385+
var_dump(ResponseHelper::toArray($response));
386+
} catch (Exception $e) {
387+
echo 'Caught exception: ', $e->getMessage(), PHP_EOL;
388+
}

src/Api/General/Contact.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Mailtrap\ConfigInterface;
99
use Mailtrap\DTO\Request\Contact\CreateContact;
1010
use Mailtrap\DTO\Request\Contact\CreateContactEvent;
11+
use Mailtrap\DTO\Request\Contact\ContactExportFilter;
1112
use Mailtrap\DTO\Request\Contact\ImportContact;
1213
use Mailtrap\DTO\Request\Contact\UpdateContact;
1314
use Mailtrap\Exception\InvalidArgumentException;
@@ -308,6 +309,50 @@ public function createContactEvent(string $contactIdentifier, CreateContactEvent
308309
);
309310
}
310311

312+
/**
313+
* Create a new Contact Export.
314+
*
315+
* POST https://mailtrap.io/api/accounts/{account_id}/contacts/exports
316+
*
317+
* @param ContactExportFilter[] $filters
318+
* @return ResponseInterface
319+
*/
320+
public function createContactExport(array $filters = []): ResponseInterface
321+
{
322+
return $this->handleResponse(
323+
$this->httpPost(
324+
path: $this->getBasePath() . '/exports',
325+
body: [
326+
'filters' => array_map(
327+
function ($filter): array {
328+
if (!$filter instanceof ContactExportFilter) {
329+
throw new InvalidArgumentException('Each filter must be an instance of ContactExportFilter.');
330+
}
331+
332+
return $filter->toArray();
333+
},
334+
$filters
335+
)
336+
]
337+
)
338+
);
339+
}
340+
341+
/**
342+
* Get Contact Export status/info by ID.
343+
*
344+
* GET https://mailtrap.io/api/accounts/{account_id}/contacts/exports/{export_id}
345+
*
346+
* @param int $exportId
347+
* @return ResponseInterface
348+
*/
349+
public function getContactExport(int $exportId): ResponseInterface
350+
{
351+
return $this->handleResponse(
352+
$this->httpGet($this->getBasePath() . '/exports/' . $exportId)
353+
);
354+
}
355+
311356
public function getAccountId(): int
312357
{
313358
return $this->accountId;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mailtrap\DTO\Request\Contact;
6+
7+
use Mailtrap\DTO\Request\RequestInterface;
8+
9+
/**
10+
* Represents a single filter for Contact Export.
11+
*/
12+
final class ContactExportFilter implements RequestInterface
13+
{
14+
public function __construct(
15+
private string $name,
16+
private string $operator,
17+
private mixed $value
18+
) {
19+
}
20+
21+
public static function init(string $name, string $operator, mixed $value): self
22+
{
23+
return new self($name, $operator, $value);
24+
}
25+
26+
public function getName(): string
27+
{
28+
return $this->name;
29+
}
30+
31+
public function getOperator(): string
32+
{
33+
return $this->operator;
34+
}
35+
36+
public function getValue(): mixed
37+
{
38+
return $this->value;
39+
}
40+
41+
public function toArray(): array
42+
{
43+
return [
44+
'name' => $this->getName(),
45+
'operator' => $this->getOperator(),
46+
'value' => $this->getValue(),
47+
];
48+
}
49+
}
50+

tests/Api/General/ContactTest.php

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Mailtrap\Api\General\Contact;
77
use Mailtrap\DTO\Request\Contact\CreateContact;
88
use Mailtrap\DTO\Request\Contact\CreateContactEvent;
9+
use Mailtrap\DTO\Request\Contact\ContactExportFilter;
910
use Mailtrap\DTO\Request\Contact\UpdateContact;
1011
use Mailtrap\DTO\Request\Contact\ImportContact;
1112
use Mailtrap\Exception\HttpClientException;
@@ -925,6 +926,139 @@ public function testCreateContactEventRateLimitExceeded(): void
925926
$this->contact->createContactEvent($contactIdentifier, $eventData);
926927
}
927928

929+
/**
930+
* =============================
931+
* Contact Exports
932+
* =============================
933+
*/
934+
public function testCreateContactExport(): void
935+
{
936+
$filters = [
937+
new ContactExportFilter('list_id', 'equal', [101, 102]),
938+
new ContactExportFilter('subscription_status', 'equal', 'subscribed'),
939+
];
940+
$expectedResponse = [
941+
'id' => 1,
942+
'status' => 'started',
943+
'created_at' => '2025-01-01T00:00:00Z',
944+
'updated_at' => '2025-05-01T00:00:00Z',
945+
'url' => null,
946+
];
947+
$this->contact->expects($this->once())
948+
->method('httpPost')
949+
->with(
950+
AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports',
951+
[],
952+
['filters' => array_map(fn(ContactExportFilter $f) => $f->toArray(), $filters)]
953+
)
954+
->willReturn(new Response(201, ['Content-Type' => 'application/json'], json_encode($expectedResponse)));
955+
$response = $this->contact->createContactExport($filters);
956+
$responseData = ResponseHelper::toArray($response);
957+
$this->assertArrayHasKey('id', $responseData);
958+
}
959+
960+
public function testCreateContactExportUnauthorized(): void
961+
{
962+
$this->contact->expects($this->once())
963+
->method('httpPost')
964+
->with(
965+
AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports',
966+
[],
967+
['filters' => []]
968+
)
969+
->willReturn(new Response(401, ['Content-Type' => 'application/json'], json_encode(['error' => 'Incorrect API token'])));
970+
$this->expectException(HttpClientException::class);
971+
$this->expectExceptionMessage('Errors: Incorrect API token.');
972+
$this->contact->createContactExport();
973+
}
974+
975+
public function testCreateContactExportForbidden(): void
976+
{
977+
$this->contact->expects($this->once())
978+
->method('httpPost')
979+
->with(
980+
AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports',
981+
[],
982+
['filters' => []]
983+
)
984+
->willReturn(new Response(403, ['Content-Type' => 'application/json'], json_encode(['errors' => 'Access forbidden'])));
985+
$this->expectException(HttpClientException::class);
986+
$this->expectExceptionMessage('Errors: Access forbidden.');
987+
$this->contact->createContactExport();
988+
}
989+
990+
public function testCreateContactExportValidationError(): void
991+
{
992+
$filters = [new ContactExportFilter('list_id', 'equal', [1])];
993+
$errors = [
994+
'errors' => [
995+
'filters' => 'invalid',
996+
'base' => [
997+
'There is a previous export initiated. You will be notified by email once it is completed.'
998+
],
999+
],
1000+
];
1001+
1002+
$this->contact->expects($this->once())
1003+
->method('httpPost')
1004+
->with(
1005+
AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports',
1006+
[],
1007+
['filters' => array_map(fn(ContactExportFilter $f) => $f->toArray(), $filters)]
1008+
)
1009+
->willReturn(new Response(422, ['Content-Type' => 'application/json'], json_encode($errors)));
1010+
1011+
$this->expectException(HttpClientException::class);
1012+
$this->expectExceptionMessage('Errors: filters -> invalid. base -> There is a previous export initiated. You will be notified by email once it is completed.');
1013+
1014+
$this->contact->createContactExport($filters);
1015+
}
1016+
1017+
public function testCreateContactExportRateLimitExceeded(): void
1018+
{
1019+
$this->contact->expects($this->once())
1020+
->method('httpPost')
1021+
->with(
1022+
AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports',
1023+
[],
1024+
['filters' => []]
1025+
)
1026+
->willReturn(new Response(429, ['Content-Type' => 'application/json'], json_encode(['errors' => 'Rate limit exceeded'])));
1027+
$this->expectException(HttpClientException::class);
1028+
$this->expectExceptionMessage('Errors: Rate limit exceeded.');
1029+
$this->contact->createContactExport();
1030+
}
1031+
1032+
public function testGetContactExport(): void
1033+
{
1034+
$exportId = 1;
1035+
$expectedResponse = [
1036+
'id' => $exportId,
1037+
'status' => 'started',
1038+
'created_at' => '2021-01-01T00:00:00Z',
1039+
'updated_at' => '2021-01-01T00:00:00Z',
1040+
'url' => null,
1041+
];
1042+
$this->contact->expects($this->once())
1043+
->method('httpGet')
1044+
->with(AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports/' . $exportId)
1045+
->willReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse)));
1046+
$response = $this->contact->getContactExport($exportId);
1047+
$responseData = ResponseHelper::toArray($response);
1048+
$this->assertArrayHasKey('id', $responseData);
1049+
}
1050+
1051+
public function testGetContactExportNotFound(): void
1052+
{
1053+
$exportId = 9999;
1054+
$this->contact->expects($this->once())
1055+
->method('httpGet')
1056+
->with(AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/contacts/exports/' . $exportId)
1057+
->willReturn(new Response(404, ['Content-Type' => 'application/json'], json_encode(['error' => 'Not Found'])));
1058+
$this->expectException(HttpClientException::class);
1059+
$this->expectExceptionMessage('Errors: Not Found.');
1060+
$this->contact->getContactExport($exportId);
1061+
}
9281062
private function getExpectedContactFields(): array
9291063
{
9301064
return [

0 commit comments

Comments
 (0)