diff --git a/README.md b/README.md index 021fa1a..b41d78f 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,11 @@ Currently the following endpoints are covered: - [x] Get unsubscribed contacts since date - [x] Unsubscribe contact - [x] Resubscribe contact + - [x] Resubscribe contact with no challenge + - [x] Get suppressed contacts since date + - [x] Bulk create contacts in address book + - [x] Get contact import status + - [x] Get contact import report - [ ] **Contact data fields** - [x] Create contact data field - [x] Delete contact data field diff --git a/phpmd.xml b/phpmd.xml index a3d0c9c..b2245bf 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -7,6 +7,8 @@ xsi:noNamespaceSchemaLocation=" http://pmd.sf.net/ruleset_xml_schema.xsd"> + + diff --git a/src/Adapter/Adapter.php b/src/Adapter/Adapter.php index c242e7e..f7f0d29 100644 --- a/src/Adapter/Adapter.php +++ b/src/Adapter/Adapter.php @@ -36,4 +36,12 @@ public function put(string $url, array $content = []): ResponseInterface; * @return ResponseInterface */ public function delete(string $url): ResponseInterface; + + /** + * @param string $url + * @param string $filename + * + * @return ResponseInterface + */ + public function postfile(string $url, string $filePath, string $fileName, string $mimeType): ResponseInterface; } diff --git a/src/Adapter/GuzzleAdapter.php b/src/Adapter/GuzzleAdapter.php index 221c259..c6f6616 100644 --- a/src/Adapter/GuzzleAdapter.php +++ b/src/Adapter/GuzzleAdapter.php @@ -82,4 +82,23 @@ public function delete(string $url): ResponseInterface { return $this->client->request('DELETE', $url); } + + /** + * {@inheritDoc} + */ + public function postfile(string $url, string $filePath, string $fileName, string $mimeType): ResponseInterface + { + return $this->client->request('POST', $url, [ + 'multipart' => [ + [ + 'name' => 'file', + 'contents' => fopen($filePath, 'r'), // Just the resource; Guzzle handles the contents internally + 'filename' => $fileName, + 'headers' => [ + 'Content-Type' => $mimeType + ] + ] + ] + ]); + } } diff --git a/src/Dotmailer.php b/src/Dotmailer.php index 036d4f2..ec0f214 100644 --- a/src/Dotmailer.php +++ b/src/Dotmailer.php @@ -11,10 +11,14 @@ use Dotmailer\Factory\CampaignFactory; use Psr\Http\Message\ResponseInterface; use function GuzzleHttp\json_decode; +use Dotmailer\Entity\Suppression; +use Dotmailer\Entity\ContactImportStatus; +use Dotmailer\Entity\ContactImportReport; class Dotmailer { const DEFAULT_URI = 'https://r1-api.dotmailer.com'; + const GUID_REGEX = '/^[A-Z0-9]{8}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{12}?$/i'; /** * @var Adapter @@ -161,7 +165,8 @@ public function getContactByEmail(string $email): Contact $contact->email, $contact->optInType, $contact->emailType, - $contact->dataFields + $contact->dataFields, + $contact->status ); } @@ -251,6 +256,22 @@ public function resubscribeContact(Contact $contact, string $preferredLocale = n $this->response = $this->adapter->post('/v2/contacts/resubscribe', array_filter($content)); } + + /** + * @param Contact $contact + * @param string|null $preferredLocale + * @param string|null $challengeUrl + */ + public function resubscribeContactWithNoChallenge(Contact $contact) + { + $content = [ + 'unsubscribedContact' => [ + 'email' => $contact->getEmail() + ], + ]; + + $this->response = $this->adapter->post('/v2/contacts/resubscribe-with-no-challenge', array_filter($content)); + } /** * @param DataField $dataField @@ -375,4 +396,105 @@ function (string $name, string $value) { ] ); } + + /** + * @param \DateTimeInterface $dateTime + * @param int|null $select + * @param int|null $skip + * + * @return Suppression[] + */ + public function getSuppressedContactsSince( + \DateTimeInterface $dateTime, + int $select = null, + int $skip = null + ): array { + $this->response = $this->adapter->get( + '/v2/contacts/suppressed-since/' . $dateTime->format('Y-m-d'), + array_filter([ + 'select' => $select, + 'skip' => $skip, + ]) + ); + + $suppressions = []; + + foreach (json_decode($this->response->getBody()->getContents()) as $suppression) { + $suppressions[] = new Suppression( + new Contact( + $suppression->suppressedContact->id, + $suppression->suppressedContact->email, + $suppression->suppressedContact->optInType, + $suppression->suppressedContact->emailType + ), + new \DateTime($suppression->dateRemoved), + $suppression->reason + ); + } + + return $suppressions; + } + + /** + * Bulk creates, or bulk updates, contacts in an address book + * + * @param AddressBook $addressBook Object containing the ID of the address book + * @param string $filePath Local filesystem path of the file to be imported + * @param string $fileName Discrete file name to pass to API + * + * @return \Dotmailer\Entity\ContactImportStatus + */ + public function bulkCreateContactsInAddressBook(AddressBook $addressBook, string $filePath, string $fileName) + { + $this->response = $this->adapter->postfile( + '/v2/address-books/' . $addressBook->getId() . '/contacts/import', + $filePath, + $fileName, + 'text/csv' + ); + + $response = json_decode($this->response->getBody()->getContents()); + + $importStatus = new ContactImportStatus($response->id, $response->status); + + return $importStatus; + } + + /** + * @param string $id GUID import ID + * + * @return ContactImportStatus + */ + public function getContactImportStatus(string $id): ContactImportStatus + { + if (!preg_match(self::GUID_REGEX, $id)) { + throw new \Exception('ID did not contain a valid GUID'); + } + + $this->response = $this->adapter->get('/v2/contacts/import/' . $id); + + $response = json_decode($this->response->getBody()->getContents()); + + $importStatus = new ContactImportStatus($response->id, $response->status); + + return $importStatus; + } + + /** + * @param string $id GUID import ID + * + * @return ContactImportReport + */ + public function getContactImportReport(string $id): ContactImportReport + { + if (!preg_match(self::GUID_REGEX, $id)) { + throw new \Exception('ID did not contain a valid GUID'); + } + + $this->response = $this->adapter->get('/v2/contacts/import/' . $id . '/report'); + + $report = ContactImportReport::fromJson($this->response->getBody()->getContents()); + + return $report; + } } diff --git a/src/Entity/Contact.php b/src/Entity/Contact.php index 71f3e03..cd7b742 100644 --- a/src/Entity/Contact.php +++ b/src/Entity/Contact.php @@ -37,6 +37,11 @@ final class Contact implements Arrayable */ private $dataFields; + /** + * @var string + */ + private $status; + /** * @param int|null $id * @param string $email @@ -49,13 +54,15 @@ public function __construct( string $email, string $optInType = self::OPT_IN_TYPE_UNKNOWN, string $emailType = self::EMAIL_TYPE_PLAIN_TEXT, - array $dataFields = [] + array $dataFields = [], + string $status = null ) { $this->id = $id; $this->email = $email; $this->optInType = $optInType; $this->emailType = $emailType; $this->dataFields = $dataFields; + $this->status = $status; } /** @@ -98,6 +105,14 @@ public function getEmailType(): string return $this->emailType; } + /** + * @return string + */ + public function getStatus(): string + { + return $this->status; + } + /** * @return array */ @@ -161,6 +176,7 @@ public function asArray(): array 'optInType' => $this->optInType, 'emailType' => $this->emailType, 'dataFields' => $this->dataFields, + 'status' => $this->status, ]; } } diff --git a/src/Entity/ContactImportReport.php b/src/Entity/ContactImportReport.php new file mode 100644 index 0000000..0c7d62b --- /dev/null +++ b/src/Entity/ContactImportReport.php @@ -0,0 +1,119 @@ +newContacts = $newContacts; + $this->updatedContacts = $updatedContacts; + $this->globallySuppressed = $globallySuppressed; + $this->invalidEntries = $invalidEntries; + $this->duplicateEmails = $duplicateEmails; + $this->blocked = $blocked; + $this->unsubscribed = $unsubscribed; + $this->hardBounced = $hardBounced; + $this->softBounced = $softBounced; + $this->ispComplaints = $ispComplaints; + $this->mailBlocked = $mailBlocked; + $this->domainSuppressed = $domainSuppressed; + $this->pendingDoubleOptin = $pendingDoubleOptin; + $this->failures = $failures; + } + + /** + * Initialise object from JSON + * + * @param string $json + * @return ContactImportReport + */ + public static function fromJson(string $json): self + { + $result = json_decode($json); + + return new self( + $result->newContacts, + $result->updatedContacts, + $result->globallySuppressed, + $result->invalidEntries, + $result->duplicateEmails, + $result->blocked, + $result->unsubscribed, + $result->hardBounced, + $result->softBounced, + $result->ispComplaints, + $result->mailBlocked, + $result->domainSuppressed, + $result->pendingDoubleOptin, + $result->failures + ); + } + + /** + * @inheritdoc + */ + public function asArray(): array + { + return [ + 'newContacts' => $this->newContacts, + 'updatedContacts' => $this->updatedContacts, + 'globallySuppressed' => $this->globallySuppressed, + 'invalidEntries' => $this->invalidEntries, + 'duplicateEmails' => $this->duplicateEmails, + 'blocked' => $this->blocked, + 'unsubscribed' => $this->unsubscribed, + 'hardBounced' => $this->hardBounced, + 'softBounced' => $this->softBounced, + 'ispComplaints' => $this->ispComplaints, + 'mailBlocked' => $this->mailBlocked, + 'domainSuppressed' => $this->domainSuppressed, + 'pendingDoubleOptin' => $this->pendingDoubleOptin, + 'failures' => $this->failures, + ]; + } +} diff --git a/src/Entity/ContactImportStatus.php b/src/Entity/ContactImportStatus.php new file mode 100644 index 0000000..1f56ed1 --- /dev/null +++ b/src/Entity/ContactImportStatus.php @@ -0,0 +1,95 @@ +id = $id; + $this->status = $status; + } + + /** + * @return string + */ + public function getId(): string + { + return $this->id; + } + + /** + * @return string + */ + public function getStatus(): string + { + return $this->status; + } + + /** + * @inheritdoc + */ + public function asArray(): array + { + return [ + 'id' => $this->id, + 'status' => $this->status, + ]; + } +} diff --git a/src/Entity/Suppression.php b/src/Entity/Suppression.php new file mode 100644 index 0000000..7634ba9 --- /dev/null +++ b/src/Entity/Suppression.php @@ -0,0 +1,145 @@ +suppressedContact = $suppressedContact; + $this->dateRemoved = $dateRemoved; + $this->reason = $reason; + } + + /** + * @return Contact + */ + public function getSuppressedContact(): Contact + { + return $this->suppressedContact; + } + + /** + * @return \DateTime + */ + public function getDateRemoved(): \DateTime + { + return $this->dateRemoved; + } + + /** + * @return string + */ + public function getReason(): string + { + return $this->reason; + } + + /** + * @inheritdoc + */ + public function asArray(): array + { + return [ + 'suppressedContact' => $this->suppressedContact, + 'dateRemoved' => $this->dateRemoved, + 'reason' => $this->reason, + ]; + } +} diff --git a/tests/DotmailerTest.php b/tests/DotmailerTest.php index d068fb8..a3e5d32 100644 --- a/tests/DotmailerTest.php +++ b/tests/DotmailerTest.php @@ -14,6 +14,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use function GuzzleHttp\json_encode; +use Dotmailer\Entity\Suppression; +use Dotmailer\Entity\ContactImportStatus; +use Dotmailer\Entity\ContactImportReport; class DotmailerTest extends TestCase { @@ -28,6 +31,10 @@ class DotmailerTest extends TestCase const WEBSITE = 'http://foo.bar/baz'; const DATA_FIELD = 'DATAFIELD'; const DATE_FROM = '2018-01-01'; + const NULL_GUID = '00000000-0000-0000-0000-000000000000'; + const FILE_PATH = '/test.csv'; + const FILE_NAME = 'test.csv'; + const FILE_MIME_TYPE = 'text/csv'; /** * @var Adapter|MockObject @@ -422,6 +429,93 @@ public function testSendTransactionalEmailUsingATriggeredCampaign() $this->assertEquals($response, $this->dotmailer->getResponse()); } + + public function testGetSuppressedContactsSince() + { + $contact = $this->getContact(); + $dateRemoved = new \DateTime('2018-01-10'); + + $this->adapter + ->expects($this->once()) + ->method('get') + ->with( + '/v2/contacts/suppressed-since/' . self::DATE_FROM, + [ + 'select' => 1, + 'skip' => 2, + ] + ) + ->willReturn( + $this->getResponse( + [ + [ + 'suppressedContact' => $contact->asArray(), + 'dateRemoved' => $dateRemoved->format('Y-m-d'), + 'reason' => 'unsubscribed', + ] + ] + ) + ); + + $this->assertEquals( + [ + new Suppression( + $contact, + $dateRemoved, + 'unsubscribed' + ) + ], + $this->dotmailer->getSuppressedContactsSince(new \DateTime(self::DATE_FROM), 1, 2) + ); + } + + public function testBulkCreateContactsInAddressBook() + { + $this->adapter + ->expects($this->once()) + ->method('postfile') + ->with( + '/v2/address-books/' . self::ID . '/contacts/import', + self::FILE_PATH, + self::FILE_NAME, + self::FILE_MIME_TYPE + ) + ->willReturn($this->getResponse($this->getContactImportStatus()->asArray())); + + $actual = $this->dotmailer->bulkCreateContactsInAddressBook( + new AddressBook(self::ID, self::NAME), + self::FILE_PATH, + self::FILE_NAME + ); + + $this->assertEquals($this->getContactImportStatus(), $actual); + } + + public function testGetContactImportStatus() + { + $this->adapter + ->expects($this->once()) + ->method('get') + ->with('/v2/contacts/import/' . self::NULL_GUID) + ->willReturn( + $this->getResponse($this->getContactImportStatus()->asArray()) + ); + + $this->assertEquals($this->getContactImportStatus(), $this->dotmailer->getContactImportStatus(self::NULL_GUID)); + } + + public function testGetContactImportReport() + { + $this->adapter + ->expects($this->once()) + ->method('get') + ->with('/v2/contacts/import/' . self::NULL_GUID . '/report') + ->willReturn( + $this->getResponse($this->getContactImportReport()->asArray()) + ); + + $this->assertEquals($this->getContactImportReport(), $this->dotmailer->getContactImportReport(self::NULL_GUID)); + } /** * @param array $contents @@ -478,4 +572,41 @@ private function getProgram(): Program { return new Program(self::ID, 'test program', Program::STATUS_ACTIVE, new \DateTime()); } + + /** + * @return ContactImportStatus + */ + private function getContactImportStatus(): ContactImportStatus + { + return new ContactImportStatus(self::NULL_GUID, ContactImportStatus::STATUS_NOT_FINISHED); + } + + /** + * @return ContactImportReport + */ + private function getContactImportReport(): ContactImportReport + { + return new ContactImportReport(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14); + } + + public function testResubscribeContactWithNoChallenge() + { + $response = $this->getResponse(); + + $this->adapter + ->expects($this->once()) + ->method('post') + ->with( + '/v2/contacts/resubscribe-with-no-challenge', + [ + 'unsubscribedContact' => [ + 'email' => self::EMAIL + ], + ] + )->willReturn($response); + + $this->dotmailer->resubscribeContactWithNoChallenge($this->getContact()); + + $this->assertEquals($response, $this->dotmailer->getResponse()); + } }