From ba116655acf3f5d3a0fe877493bc5763570612d2 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 10 Jun 2026 12:12:36 +0300 Subject: [PATCH 01/13] Uploads and transactional support --- CHANGELOG.md | 7 ++ README.md | 210 +++++++++++++++++++++++++++++++++++- src/LoopsClient.php | 2 + src/Transactional.php | 44 ++++++-- src/Uploads.php | 30 ++++++ tests/TransactionalTest.php | 162 ++++++++++++++++++++++++++-- tests/UploadsTest.php | 70 ++++++++++++ 7 files changed, 507 insertions(+), 18 deletions(-) create mode 100644 src/Uploads.php create mode 100644 tests/UploadsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 61028a4..02975af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## v3.1.0 - Jun 10, 2026 + +Added support for image uploads and transactional email content management. + +- `uploads->create()` and `uploads->complete()` for the uploads API. +- `transactional->create()`, `transactional->get()`, `transactional->update()`, `transactional->ensureDraft()`, and `transactional->publish()` for managing transactional emails. + ## v3.0.0 - May 19, 2026 Added support for dedicated sending IPs, themes, components, campaigns, and email messages. diff --git a/README.md b/README.md index 42399b7..20b4174 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,13 @@ You can use custom contact properties in API calls. Please make sure to [add cus - [events->send()](#events-send) - [transactional->send()](#transactional-send) - [transactional->list()](#transactional-list) +- [transactional->create()](#transactional-create) +- [transactional->get()](#transactional-get) +- [transactional->update()](#transactional-update) +- [transactional->ensureDraft()](#transactional-ensuredraft) +- [transactional->publish()](#transactional-publish) +- [uploads->create()](#uploads-create) +- [uploads->complete()](#uploads-complete) - [dedicatedSendingIps->list()](#dedicatedsendingips-list) - [themes->list()](#themes-list) - [themes->get()](#themes-get) @@ -843,7 +850,7 @@ If there is a problem with the request, a descriptive error message will be retu ### transactional->list() -Get a list of published transactional emails. +Get a paginated list of transactional emails, most recently created first. [API Reference](https://loops.so/docs/api-reference/list-transactional-emails) @@ -872,24 +879,36 @@ $result = $loops->transactional->list(per_page: 15); "perPage": 20, "totalPages": 2, "nextCursor": "clyo0q4wo01p59fsecyxqsh38", - "nextPage": "https://app.loops.so/api/v1/transactional?cursor=clyo0q4wo01p59fsecyxqsh38&perPage=20" + "nextPage": "https://app.loops.so/api/v1/transactional-emails?cursor=clyo0q4wo01p59fsecyxqsh38&perPage=20" }, "data": [ { "id": "clfn0k1yg001imo0fdeqg30i8", - "lastUpdated": "2023-11-06T17:48:07.249Z", + "name": "Welcome email", + "draftEmailMessageId": null, + "publishedEmailMessageId": "msg_abc123", + "createdAt": "2023-11-06T17:48:07.249Z", + "updatedAt": "2023-11-06T17:48:07.249Z", "dataVariables": [] }, { "id": "cll42l54f20i1la0lfooe3z12", - "lastUpdated": "2025-02-02T02:56:28.845Z", + "name": "Password reset", + "draftEmailMessageId": "msg_def456", + "publishedEmailMessageId": "msg_ghi789", + "createdAt": "2025-01-15T10:00:00.000Z", + "updatedAt": "2025-02-02T02:56:28.845Z", "dataVariables": [ "confirmationUrl" ] }, { "id": "clw6rbuwp01rmeiyndm80155l", - "lastUpdated": "2024-05-14T19:02:52.000Z", + "name": "Team invite", + "draftEmailMessageId": "msg_jkl012", + "publishedEmailMessageId": null, + "createdAt": "2024-05-14T19:02:52.000Z", + "updatedAt": "2024-05-14T19:02:52.000Z", "dataVariables": [ "firstName", "lastName", @@ -903,6 +922,187 @@ $result = $loops->transactional->list(per_page: 15); --- +### transactional->create() + +Create a new transactional email. An empty draft email message is created automatically. + +[API Reference](https://loops.so/docs/api-reference/create-transactional-email) + +#### Parameters + +| Name | Type | Required | Notes | +| ------- | ------ | -------- | ---------------------------------- | +| `$name` | string | Yes | The name of the transactional email. | + +#### Example + +```php +$result = $loops->transactional->create(name: 'Welcome email'); +``` + +#### Response + +```json +{ + "id": "txn_123", + "name": "Welcome email", + "draftEmailMessageId": "msg_123", + "draftEmailMessageContentRevisionId": "rev_123", + "publishedEmailMessageId": null, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z", + "dataVariables": [] +} +``` + +--- + +### transactional->get() + +Get a single transactional email by ID. + +[API Reference](https://loops.so/docs/api-reference/get-transactional-email) + +#### Parameters + +| Name | Type | Required | Notes | +| ------------------- | ------ | -------- | -------------------------------- | +| `$transactional_id` | string | Yes | The ID of the transactional email. | + +#### Example + +```php +$result = $loops->transactional->get(transactional_id: 'txn_123'); +``` + +--- + +### transactional->update() + +Update a transactional email's name. + +[API Reference](https://loops.so/docs/api-reference/update-transactional-email) + +#### Parameters + +| Name | Type | Required | Notes | +| ------------------- | ------ | -------- | -------------------------------- | +| `$transactional_id` | string | Yes | The ID of the transactional email. | +| `$name` | string | Yes | The updated name. | + +#### Example + +```php +$result = $loops->transactional->update( + transactional_id: 'txn_123', + name: 'Updated welcome email' +); +``` + +--- + +### transactional->ensureDraft() + +Ensure a transactional email has a draft email message. If a draft already exists it is returned unchanged; otherwise a new empty draft is created. + +[API Reference](https://loops.so/docs/api-reference/ensure-transactional-email-draft) + +#### Parameters + +| Name | Type | Required | Notes | +| ------------------- | ------ | -------- | -------------------------------- | +| `$transactional_id` | string | Yes | The ID of the transactional email. | + +#### Example + +```php +$result = $loops->transactional->ensureDraft(transactional_id: 'txn_123'); +``` + +--- + +### transactional->publish() + +Publish the transactional email's current draft email message. + +[API Reference](https://loops.so/docs/api-reference/publish-transactional-email) + +#### Parameters + +| Name | Type | Required | Notes | +| ------------------- | ------ | -------- | -------------------------------- | +| `$transactional_id` | string | Yes | The ID of the transactional email. | + +#### Example + +```php +$result = $loops->transactional->publish(transactional_id: 'txn_123'); +``` + +--- + +### uploads->create() + +Request a pre-signed URL to upload an image asset. Upload the file with HTTP `PUT` to the returned `presignedUrl` using the same `Content-Type` and `Content-Length`, then call `uploads->complete()`. + +[API Reference](https://loops.so/docs/api-reference/create-upload) + +#### Parameters + +| Name | Type | Required | Notes | +| ----------------- | ------- | -------- | ------------------------------------------------------------------------------------------ | +| `$content_type` | string | Yes | MIME type of the file (`image/jpeg`, `image/png`, `image/gif`, or `image/webp`). | +| `$content_length` | integer | Yes | File size in bytes. Must be a positive integer no greater than 4,000,000. | + +#### Example + +```php +$result = $loops->uploads->create( + content_type: 'image/png', + content_length: 102400 +); +``` + +#### Response + +```json +{ + "emailAssetId": "asset_123", + "presignedUrl": "https://example.com/upload" +} +``` + +--- + +### uploads->complete() + +Finalize an asset after the file has been uploaded to the pre-signed URL. + +[API Reference](https://loops.so/docs/api-reference/complete-upload) + +#### Parameters + +| Name | Type | Required | Notes | +| ----- | ------ | -------- | --------------------------------------------------------------------- | +| `$id` | string | Yes | The `emailAssetId` returned from `uploads->create()`. | + +#### Example + +```php +$result = $loops->uploads->complete(id: 'asset_123'); +``` + +#### Response + +```json +{ + "emailAssetId": "asset_123", + "finalUrl": "https://cdn.example.com/image.png" +} +``` + +--- + ### dedicatedSendingIps->list() Get a list of Loops' dedicated sending IP addresses. diff --git a/src/LoopsClient.php b/src/LoopsClient.php index 84c505b..78e0326 100644 --- a/src/LoopsClient.php +++ b/src/LoopsClient.php @@ -18,6 +18,7 @@ class LoopsClient public Components $components; public Campaigns $campaigns; public EmailMessages $emailMessages; + public Uploads $uploads; public function __construct(string $api_key) { @@ -41,6 +42,7 @@ public function __construct(string $api_key) $this->components = new Components(client: $this); $this->campaigns = new Campaigns(client: $this); $this->emailMessages = new EmailMessages(client: $this); + $this->uploads = new Uploads(client: $this); } /** diff --git a/src/Transactional.php b/src/Transactional.php index b7220d5..6e301e0 100644 --- a/src/Transactional.php +++ b/src/Transactional.php @@ -35,17 +35,47 @@ public function send( ]); } - public function list(?int $per_page = 20, ?string $cursor = null): mixed + public function list(?int $per_page = null, ?string $cursor = null): mixed { - - $query = [ - 'per_page' => $per_page - ]; - if ($cursor) + $query = []; + if ($per_page !== null) { + $query['perPage'] = $per_page; + } + if ($cursor) { $query['cursor'] = $cursor; + } - return $this->client->query(method: 'GET', endpoint: 'v1/transactional', options: [ + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails', options: [ 'query' => $query ]); } + + public function create(string $name): mixed + { + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails', options: [ + 'json' => ['name' => $name] + ]); + } + + public function get(string $transactional_id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails/' . $transactional_id); + } + + public function update(string $transactional_id, string $name): mixed + { + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id, options: [ + 'json' => ['name' => $name] + ]); + } + + public function ensureDraft(string $transactional_id): mixed + { + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id . '/draft'); + } + + public function publish(string $transactional_id): mixed + { + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id . '/publish'); + } } \ No newline at end of file diff --git a/src/Uploads.php b/src/Uploads.php new file mode 100644 index 0000000..0adcb85 --- /dev/null +++ b/src/Uploads.php @@ -0,0 +1,30 @@ +client = $client; + } + + public function create(string $content_type, int $content_length): mixed + { + return $this->client->query(method: 'POST', endpoint: 'v1/uploads', options: [ + 'json' => [ + 'contentType' => $content_type, + 'contentLength' => $content_length, + ] + ]); + } + + public function complete(string $id): mixed + { + return $this->client->query(method: 'POST', endpoint: 'v1/uploads/' . $id . '/complete'); + } +} diff --git a/tests/TransactionalTest.php b/tests/TransactionalTest.php index 6121b52..1c0ded2 100644 --- a/tests/TransactionalTest.php +++ b/tests/TransactionalTest.php @@ -105,7 +105,7 @@ public function testSendTransactional(): void $this->assertTrue($result['success']); } - public function testGetTransactionals(): void + public function testListTransactionals(): void { $per_page = 20; $cursor = 'clyo0q4wo01p59fsecyxqsh38'; @@ -115,11 +115,11 @@ public function testGetTransactionals(): void ->expects($this->once()) ->method('get') ->with( - 'v1/transactional', + 'v1/transactional-emails', $this->callback(function ($options) use ($per_page, $cursor) { // Verify the query parameters are passed correctly return isset($options['query']) - && $options['query']['per_page'] === $per_page + && $options['query']['perPage'] === $per_page && $options['query']['cursor'] === $cursor; }) ) @@ -132,13 +132,16 @@ public function testGetTransactionals(): void 'perPage' => 20, 'totalPages' => 2, 'nextCursor' => 'clyo0q4wo01p59fsecyxqsh38', - 'nextPage' => 'https://app.loops.so/api/v1/transactional?cursor=clyo0q4wo01p59fsecyxqsh38&perPage=20' + 'nextPage' => 'https://app.loops.so/api/v1/transactional-emails?cursor=clyo0q4wo01p59fsecyxqsh38&perPage=20' ], 'data' => [ [ 'id' => 'clfn0k1yg001imo0fdeqg30i8', 'name' => 'Welcome email', - 'lastUpdated' => '2023-11-06T17:48:07.249Z', + 'draftEmailMessageId' => null, + 'publishedEmailMessageId' => 'msg_abc123', + 'createdAt' => '2023-11-06T17:48:07.249Z', + 'updatedAt' => '2023-11-06T17:48:07.249Z', 'dataVariables' => [] ] ] @@ -169,7 +172,154 @@ public function testGetTransactionals(): void $this->assertNotEmpty($result['data']); $this->assertArrayHasKey('id', $result['data'][0]); $this->assertArrayHasKey('name', $result['data'][0]); - $this->assertArrayHasKey('lastUpdated', $result['data'][0]); + $this->assertArrayHasKey('draftEmailMessageId', $result['data'][0]); + $this->assertArrayHasKey('publishedEmailMessageId', $result['data'][0]); + $this->assertArrayHasKey('createdAt', $result['data'][0]); + $this->assertArrayHasKey('updatedAt', $result['data'][0]); $this->assertArrayHasKey('dataVariables', $result['data'][0]); } + + public function testCreateTransactional(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/transactional-emails', + $this->callback(function ($options) { + return $options['json']['name'] === 'Welcome email'; + }) + ) + ->willReturn(new Response( + status: 201, + body: json_encode([ + 'id' => 'txn_123', + 'name' => 'Welcome email', + 'draftEmailMessageId' => 'msg_123', + 'draftEmailMessageContentRevisionId' => 'rev_123', + 'publishedEmailMessageId' => null, + 'createdAt' => '2025-01-01T00:00:00.000Z', + 'updatedAt' => '2025-01-01T00:00:00.000Z', + 'dataVariables' => [] + ]) + )); + + $result = $this->client->transactional->create(name: 'Welcome email'); + + $this->assertEquals('txn_123', $result['id']); + $this->assertEquals('msg_123', $result['draftEmailMessageId']); + } + + public function testGetTransactional(): void + { + $transactionalId = 'txn_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('get') + ->with('v1/transactional-emails/' . $transactionalId) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'id' => $transactionalId, + 'name' => 'Welcome email', + 'draftEmailMessageId' => 'msg_123', + 'publishedEmailMessageId' => 'msg_456', + 'createdAt' => '2025-01-01T00:00:00.000Z', + 'updatedAt' => '2025-01-01T00:00:00.000Z', + 'dataVariables' => ['firstName'] + ]) + )); + + $result = $this->client->transactional->get(transactional_id: $transactionalId); + + $this->assertEquals($transactionalId, $result['id']); + } + + public function testUpdateTransactional(): void + { + $transactionalId = 'txn_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/transactional-emails/' . $transactionalId, + $this->callback(function ($options) { + return $options['json']['name'] === 'Updated welcome email'; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'id' => $transactionalId, + 'name' => 'Updated welcome email', + 'draftEmailMessageId' => 'msg_123', + 'publishedEmailMessageId' => null, + 'createdAt' => '2025-01-01T00:00:00.000Z', + 'updatedAt' => '2025-01-02T00:00:00.000Z', + 'dataVariables' => [] + ]) + )); + + $result = $this->client->transactional->update( + transactional_id: $transactionalId, + name: 'Updated welcome email' + ); + + $this->assertEquals('Updated welcome email', $result['name']); + } + + public function testEnsureDraftTransactional(): void + { + $transactionalId = 'txn_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with('v1/transactional-emails/' . $transactionalId . '/draft') + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'id' => $transactionalId, + 'name' => 'Welcome email', + 'draftEmailMessageId' => 'msg_123', + 'draftEmailMessageContentRevisionId' => 'rev_123', + 'publishedEmailMessageId' => 'msg_456', + 'createdAt' => '2025-01-01T00:00:00.000Z', + 'updatedAt' => '2025-01-02T00:00:00.000Z', + 'dataVariables' => [] + ]) + )); + + $result = $this->client->transactional->ensureDraft(transactional_id: $transactionalId); + + $this->assertEquals('msg_123', $result['draftEmailMessageId']); + } + + public function testPublishTransactional(): void + { + $transactionalId = 'txn_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with('v1/transactional-emails/' . $transactionalId . '/publish') + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'id' => $transactionalId, + 'name' => 'Welcome email', + 'draftEmailMessageId' => null, + 'publishedEmailMessageId' => 'msg_123', + 'createdAt' => '2025-01-01T00:00:00.000Z', + 'updatedAt' => '2025-01-02T00:00:00.000Z', + 'dataVariables' => ['firstName'] + ]) + )); + + $result = $this->client->transactional->publish(transactional_id: $transactionalId); + + $this->assertEquals('msg_123', $result['publishedEmailMessageId']); + } } \ No newline at end of file diff --git a/tests/UploadsTest.php b/tests/UploadsTest.php new file mode 100644 index 0000000..9ff50c0 --- /dev/null +++ b/tests/UploadsTest.php @@ -0,0 +1,70 @@ +mockHttpClient = $this->createMock(\GuzzleHttp\Client::class); + $this->client = new LoopsClient('test_api_key'); + $this->client->setHttpClient($this->mockHttpClient); + } + + public function testCreateUpload(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/uploads', + $this->callback(function ($options) { + return $options['json']['contentType'] === 'image/png' + && $options['json']['contentLength'] === 102400; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'emailAssetId' => 'asset_123', + 'presignedUrl' => 'https://example.com/upload' + ]) + )); + + $result = $this->client->uploads->create( + content_type: 'image/png', + content_length: 102400 + ); + + $this->assertEquals('asset_123', $result['emailAssetId']); + $this->assertEquals('https://example.com/upload', $result['presignedUrl']); + } + + public function testCompleteUpload(): void + { + $assetId = 'asset_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with('v1/uploads/' . $assetId . '/complete') + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'emailAssetId' => $assetId, + 'finalUrl' => 'https://cdn.example.com/image.png' + ]) + )); + + $result = $this->client->uploads->complete(id: $assetId); + + $this->assertEquals('https://cdn.example.com/image.png', $result['finalUrl']); + } +} From 42329d615cc202df60a7ac6626e2ce1538d38e6e Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 10 Jun 2026 15:17:21 +0300 Subject: [PATCH 02/13] Single upload command --- CHANGELOG.md | 2 +- README.md | 48 +++--------------- src/LoopsClient.php | 5 ++ src/Uploads.php | 75 ++++++++++++++++++++++++++-- tests/UploadsTest.php | 110 ++++++++++++++++++++++++++++-------------- 5 files changed, 160 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02975af..8ed45f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ Added support for image uploads and transactional email content management. -- `uploads->create()` and `uploads->complete()` for the uploads API. +- `uploads->upload()` for uploading images. - `transactional->create()`, `transactional->get()`, `transactional->update()`, `transactional->ensureDraft()`, and `transactional->publish()` for managing transactional emails. ## v3.0.0 - May 19, 2026 diff --git a/README.md b/README.md index 20b4174..2a3c7e7 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,7 @@ You can use custom contact properties in API calls. Please make sure to [add cus - [transactional->update()](#transactional-update) - [transactional->ensureDraft()](#transactional-ensuredraft) - [transactional->publish()](#transactional-publish) -- [uploads->create()](#uploads-create) -- [uploads->complete()](#uploads-complete) +- [uploads->upload()](#uploads-upload) - [dedicatedSendingIps->list()](#dedicatedsendingips-list) - [themes->list()](#themes-list) - [themes->get()](#themes-get) @@ -1041,55 +1040,24 @@ $result = $loops->transactional->publish(transactional_id: 'txn_123'); --- -### uploads->create() +### uploads->upload() -Request a pre-signed URL to upload an image asset. Upload the file with HTTP `PUT` to the returned `presignedUrl` using the same `Content-Type` and `Content-Length`, then call `uploads->complete()`. +Upload an image asset for use in LMX email content. The returned `finalUrl` can be used in an `` tag in your [LMX content](https://loops.so/docs/creating-emails/lmx). [API Reference](https://loops.so/docs/api-reference/create-upload) #### Parameters -| Name | Type | Required | Notes | -| ----------------- | ------- | -------- | ------------------------------------------------------------------------------------------ | -| `$content_type` | string | Yes | MIME type of the file (`image/jpeg`, `image/png`, `image/gif`, or `image/webp`). | -| `$content_length` | integer | Yes | File size in bytes. Must be a positive integer no greater than 4,000,000. | +| Name | Type | Required | Notes | +| ------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------- | +| `$path` | string | Yes | Path to an image file. Supported types: JPEG, PNG, GIF, and WebP. Maximum file size is 4,000,000 bytes (4 MB). | #### Example ```php -$result = $loops->uploads->create( - content_type: 'image/png', - content_length: 102400 -); -``` - -#### Response +$result = $loops->uploads->upload(path: '/path/to/image.png'); -```json -{ - "emailAssetId": "asset_123", - "presignedUrl": "https://example.com/upload" -} -``` - ---- - -### uploads->complete() - -Finalize an asset after the file has been uploaded to the pre-signed URL. - -[API Reference](https://loops.so/docs/api-reference/complete-upload) - -#### Parameters - -| Name | Type | Required | Notes | -| ----- | ------ | -------- | --------------------------------------------------------------------- | -| `$id` | string | Yes | The `emailAssetId` returned from `uploads->create()`. | - -#### Example - -```php -$result = $loops->uploads->complete(id: 'asset_123'); +$imageUrl = $result['finalUrl']; ``` #### Response diff --git a/src/LoopsClient.php b/src/LoopsClient.php index 78e0326..a943690 100644 --- a/src/LoopsClient.php +++ b/src/LoopsClient.php @@ -56,6 +56,11 @@ public function setHttpClient(\GuzzleHttp\Client $client): void $this->httpClient = $client; } + public function getHttpClient(): \GuzzleHttp\Client + { + return $this->httpClient; + } + /** * Performs an HTTP request to the Loops API * diff --git a/src/Uploads.php b/src/Uploads.php index 0adcb85..ad2723f 100644 --- a/src/Uploads.php +++ b/src/Uploads.php @@ -6,6 +6,23 @@ class Uploads { + private const ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ]; + + private const EXTENSION_MIME_TYPES = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + ]; + + private const MAX_BYTES = 4000000; + private $client; public function __construct(LoopsClient $client) @@ -13,18 +30,68 @@ public function __construct(LoopsClient $client) $this->client = $client; } - public function create(string $content_type, int $content_length): mixed + public function upload(string $path): mixed { - return $this->client->query(method: 'POST', endpoint: 'v1/uploads', options: [ + if (!is_readable($path)) { + throw new \InvalidArgumentException(message: 'File not found or not readable: ' . $path); + } + + $contents = file_get_contents(filename: $path); + $content_length = strlen(string: $contents); + + if ($content_length === 0) { + throw new \InvalidArgumentException(message: 'File is empty: ' . $path); + } + + if ($content_length > self::MAX_BYTES) { + throw new \InvalidArgumentException(message: 'File exceeds the maximum allowed size of 4,000,000 bytes.'); + } + + $content_type = $this->resolveContentType(path: $path); + if ($content_type === null) { + throw new \InvalidArgumentException(message: 'Unsupported image type. Supported types: JPEG, PNG, GIF, and WebP.'); + } + + $created = $this->client->query(method: 'POST', endpoint: 'v1/uploads', options: [ 'json' => [ 'contentType' => $content_type, 'contentLength' => $content_length, ] ]); + + $response = $this->client->getHttpClient()->put($created['presignedUrl'], [ + 'headers' => [ + 'Content-Type' => $content_type, + 'Content-Length' => (string) $content_length, + ], + 'body' => $contents, + 'http_errors' => false, + ]); + + if ($response->getStatusCode() >= 400) { + throw new \RuntimeException( + message: 'Failed to upload file to pre-signed URL. HTTP status: ' . $response->getStatusCode(), + code: $response->getStatusCode() + ); + } + + return $this->client->query( + method: 'POST', + endpoint: 'v1/uploads/' . $created['emailAssetId'] . '/complete' + ); } - public function complete(string $id): mixed + private function resolveContentType(string $path): ?string { - return $this->client->query(method: 'POST', endpoint: 'v1/uploads/' . $id . '/complete'); + $finfo = finfo_open(flags: FILEINFO_MIME_TYPE); + $mime = finfo_file(finfo: $finfo, filename: $path); + + if (in_array(needle: $mime, haystack: self::ALLOWED_MIME_TYPES, strict: true)) { + return $mime; + } + + $extension = strtolower(string: pathinfo(path: $path, flags: PATHINFO_EXTENSION)); + + return self::EXTENSION_MIME_TYPES[$extension] ?? null; } } diff --git a/tests/UploadsTest.php b/tests/UploadsTest.php index 9ff50c0..fdbeb2e 100644 --- a/tests/UploadsTest.php +++ b/tests/UploadsTest.php @@ -10,61 +10,101 @@ class UploadsTest extends TestCase { private LoopsClient $client; private \GuzzleHttp\Client $mockHttpClient; + private string $imagePath; protected function setUp(): void { $this->mockHttpClient = $this->createMock(\GuzzleHttp\Client::class); $this->client = new LoopsClient('test_api_key'); $this->client->setHttpClient($this->mockHttpClient); + + $this->imagePath = sys_get_temp_dir() . '/loops_upload_test.png'; + file_put_contents( + $this->imagePath, + base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==') + ); + } + + protected function tearDown(): void + { + if (file_exists($this->imagePath)) { + unlink($this->imagePath); + } } - public function testCreateUpload(): void + public function testUpload(): void { + $presignedUrl = 'https://example.com/upload'; + $assetId = 'asset_123'; + $fileContents = file_get_contents($this->imagePath); + $contentLength = strlen($fileContents); + $this->mockHttpClient - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('post') + ->willReturnCallback(function ($endpoint, $options = []) use ($presignedUrl, $assetId, $contentLength) { + if ($endpoint === 'v1/uploads') { + $this->assertEquals('image/png', $options['json']['contentType']); + $this->assertEquals($contentLength, $options['json']['contentLength']); + + return new Response( + status: 200, + body: json_encode([ + 'emailAssetId' => $assetId, + 'presignedUrl' => $presignedUrl, + ]) + ); + } + + if ($endpoint === 'v1/uploads/' . $assetId . '/complete') { + return new Response( + status: 200, + body: json_encode([ + 'emailAssetId' => $assetId, + 'finalUrl' => 'https://cdn.example.com/image.png', + ]) + ); + } + + $this->fail('Unexpected POST endpoint: ' . $endpoint); + }); + + $this->mockHttpClient + ->expects($this->once()) + ->method('put') ->with( - 'v1/uploads', - $this->callback(function ($options) { - return $options['json']['contentType'] === 'image/png' - && $options['json']['contentLength'] === 102400; + $presignedUrl, + $this->callback(function ($options) use ($fileContents, $contentLength) { + return $options['headers']['Content-Type'] === 'image/png' + && $options['headers']['Content-Length'] === (string) $contentLength + && $options['body'] === $fileContents; }) ) - ->willReturn(new Response( - status: 200, - body: json_encode([ - 'emailAssetId' => 'asset_123', - 'presignedUrl' => 'https://example.com/upload' - ]) - )); - - $result = $this->client->uploads->create( - content_type: 'image/png', - content_length: 102400 - ); + ->willReturn(new Response(status: 200)); - $this->assertEquals('asset_123', $result['emailAssetId']); - $this->assertEquals('https://example.com/upload', $result['presignedUrl']); + $result = $this->client->uploads->upload(path: $this->imagePath); + + $this->assertEquals($assetId, $result['emailAssetId']); + $this->assertEquals('https://cdn.example.com/image.png', $result['finalUrl']); } - public function testCompleteUpload(): void + public function testUploadRejectsMissingFile(): void { - $assetId = 'asset_123'; + $this->expectException(\InvalidArgumentException::class); - $this->mockHttpClient - ->expects($this->once()) - ->method('post') - ->with('v1/uploads/' . $assetId . '/complete') - ->willReturn(new Response( - status: 200, - body: json_encode([ - 'emailAssetId' => $assetId, - 'finalUrl' => 'https://cdn.example.com/image.png' - ]) - )); + $this->client->uploads->upload(path: '/path/does/not/exist.png'); + } - $result = $this->client->uploads->complete(id: $assetId); + public function testUploadRejectsUnsupportedType(): void + { + $path = sys_get_temp_dir() . '/loops_upload_test.txt'; + file_put_contents($path, 'not an image'); - $this->assertEquals('https://cdn.example.com/image.png', $result['finalUrl']); + try { + $this->expectException(\InvalidArgumentException::class); + $this->client->uploads->upload(path: $path); + } finally { + unlink($path); + } } } From 2544013112a45767b52c379729ce654971a86d2c Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 24 Jun 2026 12:42:17 +0300 Subject: [PATCH 03/13] OpenAPI spec 1.14.0 --- CHANGELOG.md | 11 +- README.md | 452 ++++++++++++++++++++++++++---- src/AudienceSegments.php | 35 +++ src/CampaignGroups.php | 62 ++++ src/Campaigns.php | 68 ++++- src/Components.php | 4 +- src/EmailMessages.php | 31 +- src/LoopsClient.php | 8 + src/Themes.php | 4 +- src/Transactional.php | 20 +- src/TransactionalGroups.php | 62 ++++ src/Workflows.php | 40 +++ tests/AudienceSegmentsTest.php | 66 +++++ tests/CampaignGroupsTest.php | 68 +++++ tests/CampaignsTest.php | 4 +- tests/ComponentsTest.php | 2 +- tests/EmailMessagesTest.php | 35 ++- tests/ThemesTest.php | 2 +- tests/TransactionalGroupsTest.php | 57 ++++ tests/TransactionalTest.php | 16 +- tests/WorkflowsTest.php | 61 ++++ 21 files changed, 1011 insertions(+), 97 deletions(-) create mode 100644 src/AudienceSegments.php create mode 100644 src/CampaignGroups.php create mode 100644 src/TransactionalGroups.php create mode 100644 src/Workflows.php create mode 100644 tests/AudienceSegmentsTest.php create mode 100644 tests/CampaignGroupsTest.php create mode 100644 tests/TransactionalGroupsTest.php create mode 100644 tests/WorkflowsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed45f8..c31599c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,16 @@ -## v3.1.0 - Jun 10, 2026 +## v4.0.0 - Jun 24, 2026 -Added support for image uploads and transactional email content management. +Added support for new content endpoints: - `uploads->upload()` for uploading images. - `transactional->create()`, `transactional->get()`, `transactional->update()`, `transactional->ensureDraft()`, and `transactional->publish()` for managing transactional emails. +- `audienceSegments->list()` and `audienceSegments->get()` for audience segments. +- `workflows->list()`, `workflows->get()`, and `workflows->getNode()` for reading workflows. +- `campaignGroups->list()`, `campaignGroups->create()`, `campaignGroups->get()`, and `campaignGroups->update()` for campaign groups. +- `transactionalGroups->list()`, `transactionalGroups->create()`, `transactionalGroups->get()`, and `transactionalGroups->update()` for transactional groups. +- `emailMessages->preview()` for sending test email previews. +- Extended `campaigns->create()` and `campaigns->update()` with audience, group, and scheduling fields. +- Renamed primary resource ID parameters to `id` (for example, `transactional->get(id: '...')` instead of `transactional_id:`). ## v3.0.0 - May 19, 2026 diff --git a/README.md b/README.md index 2a3c7e7..553d0b5 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ You can use custom contact properties in API calls. Please make sure to [add cus - [transactional->update()](#transactional-update) - [transactional->ensureDraft()](#transactional-ensuredraft) - [transactional->publish()](#transactional-publish) -- [uploads->upload()](#uploads-upload) - [dedicatedSendingIps->list()](#dedicatedsendingips-list) - [themes->list()](#themes-list) - [themes->get()](#themes-get) @@ -123,8 +122,23 @@ You can use custom contact properties in API calls. Please make sure to [add cus - [campaigns->create()](#campaigns-create) - [campaigns->get()](#campaigns-get) - [campaigns->update()](#campaigns-update) +- [campaignGroups->list()](#campaigngroups-list) +- [campaignGroups->create()](#campaigngroups-create) +- [campaignGroups->get()](#campaigngroups-get) +- [campaignGroups->update()](#campaigngroups-update) +- [audienceSegments->list()](#audiencesegments-list) +- [audienceSegments->get()](#audiencesegments-get) +- [workflows->list()](#workflows-list) +- [workflows->get()](#workflows-get) +- [workflows->getNode()](#workflows-getnode) - [emailMessages->get()](#emailmessages-get) - [emailMessages->update()](#emailmessages-update) +- [emailMessages->preview()](#emailmessages-preview) +- [transactionalGroups->list()](#transactionalgroups-list) +- [transactionalGroups->create()](#transactionalgroups-create) +- [transactionalGroups->get()](#transactionalgroups-get) +- [transactionalGroups->update()](#transactionalgroups-update) +- [uploads->upload()](#uploads-upload) --- @@ -202,7 +216,7 @@ $result = $loops->contacts->create( ```json { "success": true, - "id": "id_of_contact" + "id": "cll6b3i8901a9jx0oyktl2m4u" } ``` @@ -267,7 +281,7 @@ $result = $loops->contacts->update( ```json { "success": true, - "id": "id_of_contact" + "id": "cll6b3i8901a9jx0oyktl2m4u" } ``` @@ -762,7 +776,7 @@ Send a transactional email to a contact. [Learn about sending transactional emai | Name | Type | Required | Notes | | -------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `$transactional_id` | string | Yes | The ID of the transactional email to send. | +| `$id` | string | Yes | The ID of the transactional email to send. | | `$email` | string | Yes | The email address of the recipient. | | `$add_to_audience` | boolean | No | If `true`, a contact will be created in your audience using the `$email` value (if a matching contact doesn’t already exist). | | `$data_variables` | array | No | An array containing data as defined by the data variables added to the transactional email template.
Values can be of type `string` or `number`. | @@ -776,7 +790,7 @@ Send a transactional email to a contact. [Learn about sending transactional emai ```php $result = $loops->transactional->send( - transactional_id: 'clfq6dinn000yl70fgwwyp82l', + id: 'clfq6dinn000yl70fgwwyp82l', email: 'hello@gmail.com', data_variables: [ 'loginUrl' => 'https://myapp.com/login/', @@ -785,7 +799,7 @@ $result = $loops->transactional->send( # Example with Idempotency-Key header $result = $loops->transactional->send( - transactional_id: 'clfq6dinn000yl70fgwwyp82l', + id: 'clfq6dinn000yl70fgwwyp82l', email: 'hello@gmail.com', data_variables: [ 'loginUrl' => 'https://myapp.com/login/', @@ -797,7 +811,7 @@ $result = $loops->transactional->send( # Please contact us to enable attachments on your account. $result = $loops->transactional->send( - transactional_id: 'clfq6dinn000yl70fgwwyp82l', + id: 'clfq6dinn000yl70fgwwyp82l', email: 'hello@gmail.com', data_variables: [ 'loginUrl' => 'https://myapp.com/login/', @@ -885,7 +899,7 @@ $result = $loops->transactional->list(per_page: 15); "id": "clfn0k1yg001imo0fdeqg30i8", "name": "Welcome email", "draftEmailMessageId": null, - "publishedEmailMessageId": "msg_abc123", + "publishedEmailMessageId": "cly8k3m0n0044jpx2bghepq45", "createdAt": "2023-11-06T17:48:07.249Z", "updatedAt": "2023-11-06T17:48:07.249Z", "dataVariables": [] @@ -893,8 +907,8 @@ $result = $loops->transactional->list(per_page: 15); { "id": "cll42l54f20i1la0lfooe3z12", "name": "Password reset", - "draftEmailMessageId": "msg_def456", - "publishedEmailMessageId": "msg_ghi789", + "draftEmailMessageId": "cla3r8s9t0422ua56iqovab01", + "publishedEmailMessageId": "clb4s9t0u0533vb67jrpwbc12", "createdAt": "2025-01-15T10:00:00.000Z", "updatedAt": "2025-02-02T02:56:28.845Z", "dataVariables": [ @@ -904,7 +918,7 @@ $result = $loops->transactional->list(per_page: 15); { "id": "clw6rbuwp01rmeiyndm80155l", "name": "Team invite", - "draftEmailMessageId": "msg_jkl012", + "draftEmailMessageId": "clc5t0u1v0644wc78ksqxcd23", "publishedEmailMessageId": null, "createdAt": "2024-05-14T19:02:52.000Z", "updatedAt": "2024-05-14T19:02:52.000Z", @@ -943,10 +957,10 @@ $result = $loops->transactional->create(name: 'Welcome email'); ```json { - "id": "txn_123", + "id": "clfq6dinn000yl70fgwwyp82l", "name": "Welcome email", - "draftEmailMessageId": "msg_123", - "draftEmailMessageContentRevisionId": "rev_123", + "draftEmailMessageId": "cly8k3m0n0044jpx2bghepq45", + "draftEmailMessageContentRevisionId": "clm9n4o6p0088lrz4dijslt67", "publishedEmailMessageId": null, "createdAt": "2025-01-01T00:00:00.000Z", "updatedAt": "2025-01-01T00:00:00.000Z", @@ -966,12 +980,12 @@ Get a single transactional email by ID. | Name | Type | Required | Notes | | ------------------- | ------ | -------- | -------------------------------- | -| `$transactional_id` | string | Yes | The ID of the transactional email. | +| `$id` | string | Yes | The ID of the transactional email. | #### Example ```php -$result = $loops->transactional->get(transactional_id: 'txn_123'); +$result = $loops->transactional->get(id: 'clfq6dinn000yl70fgwwyp82l'); ``` --- @@ -986,14 +1000,14 @@ Update a transactional email's name. | Name | Type | Required | Notes | | ------------------- | ------ | -------- | -------------------------------- | -| `$transactional_id` | string | Yes | The ID of the transactional email. | +| `$id` | string | Yes | The ID of the transactional email. | | `$name` | string | Yes | The updated name. | #### Example ```php $result = $loops->transactional->update( - transactional_id: 'txn_123', + id: 'clfq6dinn000yl70fgwwyp82l', name: 'Updated welcome email' ); ``` @@ -1010,12 +1024,12 @@ Ensure a transactional email has a draft email message. If a draft already exist | Name | Type | Required | Notes | | ------------------- | ------ | -------- | -------------------------------- | -| `$transactional_id` | string | Yes | The ID of the transactional email. | +| `$id` | string | Yes | The ID of the transactional email. | #### Example ```php -$result = $loops->transactional->ensureDraft(transactional_id: 'txn_123'); +$result = $loops->transactional->ensureDraft(id: 'clfq6dinn000yl70fgwwyp82l'); ``` --- @@ -1030,12 +1044,12 @@ Publish the transactional email's current draft email message. | Name | Type | Required | Notes | | ------------------- | ------ | -------- | -------------------------------- | -| `$transactional_id` | string | Yes | The ID of the transactional email. | +| `$id` | string | Yes | The ID of the transactional email. | #### Example ```php -$result = $loops->transactional->publish(transactional_id: 'txn_123'); +$result = $loops->transactional->publish(id: 'clfq6dinn000yl70fgwwyp82l'); ``` --- @@ -1064,7 +1078,7 @@ $imageUrl = $result['finalUrl']; ```json { - "emailAssetId": "asset_123", + "emailAssetId": "clu1v4w6x0254tz42lrcwat45", "finalUrl": "https://cdn.example.com/image.png" } ``` @@ -1113,7 +1127,7 @@ List email themes. ```php $result = $loops->themes->list(); -$result = $loops->themes->list(per_page: 15, cursor: 'cursor123'); +$result = $loops->themes->list(per_page: 15, cursor: 'clyo0q4wo01p59fsecyxqsh38'); ``` --- @@ -1128,12 +1142,12 @@ Get a single email theme by ID. | Name | Type | Required | Notes | | ----------- | ------ | -------- | ------------------ | -| `$theme_id` | string | Yes | The ID of the theme. | +| `$id` | string | Yes | The ID of the theme. | #### Example ```php -$result = $loops->themes->get(theme_id: 'theme_abc123'); +$result = $loops->themes->get(id: 'clo5p8q0r0132ntx6flkunw89'); ``` --- @@ -1169,12 +1183,12 @@ Get a single email component by ID. | Name | Type | Required | Notes | | --------------- | ------ | -------- | ----------------------- | -| `$component_id` | string | Yes | The ID of the component. | +| `$id` | string | Yes | The ID of the component. | #### Example ```php -$result = $loops->components->get(component_id: 'component_abc123'); +$result = $loops->components->get(id: 'clp6q9r1s0154ouy7gmlovx90'); ``` --- @@ -1208,14 +1222,25 @@ Create a new draft campaign. #### Parameters -| Name | Type | Required | Notes | -| ------- | ------ | -------- | ------------------ | -| `$name` | string | Yes | The campaign name. | +| Name | Type | Required | Notes | +| ----------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- | +| `$name` | string | Yes | The campaign name. | +| `$campaign_group_id` | string | No | The ID of the group to add this campaign to. | +| `$mailing_list_id` | string | No | The ID of the mailing list to send to. | +| `$audience_segment_id` | string | No | The ID of an audience segment. Setting this clears any `audience_filter`. | +| `$audience_filter` | array | No | A tree of audience conditions. See the API reference for the filter schema. | +| `$scheduling` | array | No | When the campaign should send. Use `['method' => 'now']` or `['method' => 'schedule', 'timestamp' => '...']`. | #### Example ```php $result = $loops->campaigns->create(name: 'Spring announcement'); + +$result = $loops->campaigns->create( + name: 'Spring announcement', + mailing_list_id: 'cm06f5v0e45nf0ml5754o9cix', + scheduling: ['method' => 'schedule', 'timestamp' => '2026-06-01T10:00:00Z'] +); ``` #### Response @@ -1223,13 +1248,13 @@ $result = $loops->campaigns->create(name: 'Spring announcement'); ```json { "success": true, - "campaignId": "camp_123", + "campaignId": "cln4o7p9q0110msw5ekjtmv78", "name": "Spring announcement", "status": "Draft", "createdAt": "2025-01-01T00:00:00.000Z", "updatedAt": "2025-01-01T00:00:00.000Z", - "emailMessageId": "msg_123", - "emailMessageContentRevisionId": "rev_123" + "emailMessageId": "cly8k3m0n0044jpx2bghepq45", + "emailMessageContentRevisionId": "clm9n4o6p0088lrz4dijslt67" } ``` @@ -1243,42 +1268,244 @@ Get a single campaign by ID. #### Parameters -| Name | Type | Required | Notes | -| -------------- | ------ | -------- | --------------------- | -| `$campaign_id` | string | Yes | The ID of the campaign. | +| Name | Type | Required | Notes | +| ----------------------- | ------ | -------- | --------------------- | +| `$id` | string | Yes | The ID of the campaign. | #### Example ```php -$result = $loops->campaigns->get(campaign_id: 'camp_123'); +$result = $loops->campaigns->get(id: 'cln4o7p9q0110msw5ekjtmv78'); ``` --- ### campaigns->update() -Update a draft campaign's name. +Update a draft campaign's name, group, audience, or scheduling. [API Reference](https://loops.so/docs/api-reference/update-campaign) #### Parameters -| Name | Type | Required | Notes | -| -------------- | ------ | -------- | --------------------- | -| `$campaign_id` | string | Yes | The ID of the campaign. | -| `$name` | string | Yes | The updated name. | +| Name | Type | Required | Notes | +| ----------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- | +| `$id` | string | Yes | The ID of the campaign. | +| `$name` | string | No | The updated name. | +| `$campaign_group_id` | string | No | The ID of the group to move this campaign to. | +| `$mailing_list_id` | string | No | The ID of the mailing list to send to. | +| `$audience_segment_id` | string | No | The ID of an audience segment. Setting this clears any `audience_filter`. | +| `$audience_filter` | array | No | A tree of audience conditions. See the API reference for the filter schema. | +| `$scheduling` | array | No | When the campaign should send. Use `['method' => 'now']` or `['method' => 'schedule', 'timestamp' => '...']`. | + +At least one field must be provided. #### Example ```php $result = $loops->campaigns->update( - campaign_id: 'camp_123', + id: 'cln4o7p9q0110msw5ekjtmv78', + name: 'Updated name' +); +``` + +--- + +### campaignGroups->list() + +List campaign groups. + +[API Reference](https://loops.so/docs/api-reference/list-campaign-groups) + +#### Parameters + +| Name | Type | Required | Notes | +| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `$per_page` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. | +| `$cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. | + +#### Example + +```php +$result = $loops->campaignGroups->list(); +``` + +--- + +### campaignGroups->create() + +Create a campaign group. + +[API Reference](https://loops.so/docs/api-reference/create-campaign-group) + +#### Parameters + +| Name | Type | Required | Notes | +| --------------- | ------ | -------- | --------------------------------------- | +| `$name` | string | Yes | Cannot be the reserved name "Unsorted". | +| `$description` | string | No | An optional description for the group. | + +#### Example + +```php +$result = $loops->campaignGroups->create(name: 'Newsletters', description: 'Monthly updates'); +``` + +--- + +### campaignGroups->get() + +Get a campaign group by ID. + +[API Reference](https://loops.so/docs/api-reference/get-campaign-group) + +#### Parameters + +| Name | Type | Required | Notes | +| ----- | ------ | -------- | ---------------------------- | +| `$id` | string | Yes | The ID of the campaign group. | + +#### Example + +```php +$result = $loops->campaignGroups->get(id: 'clq7r0s2t0176pvz8hnmpwy01'); +``` + +--- + +### campaignGroups->update() + +Update a campaign group's name or description. + +[API Reference](https://loops.so/docs/api-reference/update-campaign-group) + +#### Parameters + +| Name | Type | Required | Notes | +| --------------- | ------ | -------- | --------------------------------------- | +| `$id` | string | Yes | The ID of the campaign group. | +| `$name` | string | No | Cannot be the reserved name "Unsorted". | +| `$description` | string | No | | + +At least one field must be provided. + +#### Example + +```php +$result = $loops->campaignGroups->update( + id: 'clq7r0s2t0176pvz8hnmpwy01', name: 'Updated name' ); ``` --- +### audienceSegments->list() + +List audience segments. + +[API Reference](https://loops.so/docs/api-reference/list-audience-segments) + +#### Parameters + +| Name | Type | Required | Notes | +| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `$per_page` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. | +| `$cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. | + +#### Example + +```php +$result = $loops->audienceSegments->list(); +``` + +--- + +### audienceSegments->get() + +Get an audience segment by ID. + +[API Reference](https://loops.so/docs/api-reference/get-audience-segment) + +#### Parameters + +| Name | Type | Required | Notes | +| ----- | ------ | -------- | ------------------------------- | +| `$id` | string | Yes | The ID of the audience segment. | + +#### Example + +```php +$result = $loops->audienceSegments->get(id: 'clr8s1t3u0198qw09iotqzx12'); +``` + +--- + +### workflows->list() + +List workflows. + +[API Reference](https://loops.so/docs/api-reference/list-workflows) + +#### Parameters + +| Name | Type | Required | Notes | +| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `$per_page` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. | +| `$cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. | + +#### Example + +```php +$result = $loops->workflows->list(); +``` + +--- + +### workflows->get() + +Get a simplified workflow graph. + +[API Reference](https://loops.so/docs/api-reference/get-workflow) + +#### Parameters + +| Name | Type | Required | Notes | +| ----- | ------ | -------- | ---------------------- | +| `$id` | string | Yes | The ID of the workflow. | + +#### Example + +```php +$result = $loops->workflows->get(id: 'cls9t2u4v0210rx20jpuary23'); +``` + +--- + +### workflows->getNode() + +Get detailed data for a single workflow node. + +[API Reference](https://loops.so/docs/api-reference/get-workflow-node) + +#### Parameters + +| Name | Type | Required | Notes | +| --------------- | ------ | -------- | ---------------------------- | +| `$workflow_id` | string | Yes | The ID of the workflow. | +| `$node_id` | string | Yes | The ID of the workflow node. | + +#### Example + +```php +$result = $loops->workflows->getNode( + workflow_id: 'cls9t2u4v0210rx20jpuary23', + node_id: 'clt0u3v5w0232sy31kqvbzs34' +); +``` + +--- + ### emailMessages->get() Get an email message, including its compiled LMX content. @@ -1287,14 +1514,14 @@ Get an email message, including its compiled LMX content. #### Parameters -| Name | Type | Required | Notes | -| ------------------- | ------ | -------- | --------------------------- | -| `$email_message_id` | string | Yes | The ID of the email message. | +| Name | Type | Required | Notes | +| --------- | ------ | -------- | --------------------------- | +| `$id` | string | Yes | The ID of the email message. | #### Example ```php -$result = $loops->emailMessages->get(email_message_id: 'msg_123'); +$result = $loops->emailMessages->get(id: 'cly8k3m0n0044jpx2bghepq45'); ``` --- @@ -1307,18 +1534,18 @@ Update an email message's subject, preview text, sender, or LMX content. #### Parameters -| Name | Type | Required | Notes | -| ------------------- | ----- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `$email_message_id` | string | Yes | The ID of the email message. | -| `$fields` | array | No | Fields to update. Use API field names: `expectedRevisionId`, `subject`, `previewText`, `fromName`, `fromEmail`, `replyToEmail`, `lmx`. Supply `expectedRevisionId` matching the current `contentRevisionId` to avoid 409 conflicts. | +| Name | Type | Required | Notes | +| --------- | ----- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `$id` | string | Yes | The ID of the email message. | +| `$fields` | array | No | Fields to update. Use API field names: `expectedRevisionId`, `subject`, `previewText`, `fromName`, `fromEmail`, `replyToEmail`, `lmx`. Supply `expectedRevisionId` matching the current `contentRevisionId` to avoid 409 conflicts. | #### Example ```php $result = $loops->emailMessages->update( - email_message_id: 'msg_123', + id: 'cly8k3m0n0044jpx2bghepq45', fields: [ - 'expectedRevisionId' => 'rev_123', + 'expectedRevisionId' => 'clm9n4o6p0088lrz4dijslt67', 'subject' => 'Updated subject', 'lmx' => 'Hello' ] @@ -1327,6 +1554,123 @@ $result = $loops->emailMessages->update( --- +### emailMessages->preview() + +Send a test preview of an email message to one or more addresses. + +[API Reference](https://loops.so/docs/api-reference/send-email-message-preview) + +#### Parameters + +| Name | Type | Required | Notes | +| ---------------------- | -------- | -------- | --------------------------------------------------------------------------------- | +| `$id` | string | Yes | The ID of the email message. | +| `$emails` | array | Yes | One or more addresses to send the preview to. | +| `$contact_properties` | array | No | Contact property values to render. Accepted for campaign and workflow previews. | +| `$event_properties` | array | No | Event property values to render. Accepted for workflow previews only. | +| `$data_variables` | array | No | Transactional data variables to render. Accepted for transactional previews only. | + +#### Example + +```php +$result = $loops->emailMessages->preview( + id: 'cly8k3m0n0044jpx2bghepq45', + emails: ['test@example.com'], + contact_properties: ['firstName' => 'Alex'] +); +``` + +--- + +### transactionalGroups->list() + +List transactional groups. + +[API Reference](https://loops.so/docs/api-reference/list-transactional-groups) + +#### Parameters + +| Name | Type | Required | Notes | +| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `$per_page` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. | +| `$cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. | + +#### Example + +```php +$result = $loops->transactionalGroups->list(); +``` + +--- + +### transactionalGroups->create() + +Create a transactional group. + +[API Reference](https://loops.so/docs/api-reference/create-transactional-group) + +#### Parameters + +| Name | Type | Required | Notes | +| --------------- | ------ | -------- | --------------------------------------- | +| `$name` | string | Yes | Cannot be the reserved name "Unsorted". | +| `$description` | string | No | An optional description for the group. | + +#### Example + +```php +$result = $loops->transactionalGroups->create(name: 'Account emails'); +``` + +--- + +### transactionalGroups->get() + +Get a transactional group by ID. + +[API Reference](https://loops.so/docs/api-reference/get-transactional-group) + +#### Parameters + +| Name | Type | Required | Notes | +| ----- | ------ | -------- | --------------------------------- | +| `$id` | string | Yes | The ID of the transactional group. | + +#### Example + +```php +$result = $loops->transactionalGroups->get(id: 'clv2w3x4y0288xbb0kqrsuv67'); +``` + +--- + +### transactionalGroups->update() + +Update a transactional group's name or description. + +[API Reference](https://loops.so/docs/api-reference/update-transactional-group) + +#### Parameters + +| Name | Type | Required | Notes | +| --------------- | ------ | -------- | --------------------------------------- | +| `$id` | string | Yes | The ID of the transactional group. | +| `$name` | string | No | Cannot be the reserved name "Unsorted". | +| `$description` | string | No | | + +At least one field must be provided. + +#### Example + +```php +$result = $loops->transactionalGroups->update( + id: 'clv2w3x4y0288xbb0kqrsuv67', + name: 'Updated name' +); +``` + +--- + ## Testing ```bash diff --git a/src/AudienceSegments.php b/src/AudienceSegments.php new file mode 100644 index 0000000..443e108 --- /dev/null +++ b/src/AudienceSegments.php @@ -0,0 +1,35 @@ +client = $client; + } + + public function list(?int $per_page = null, ?string $cursor = null): mixed + { + $query = []; + if ($per_page !== null) { + $query['perPage'] = $per_page; + } + if ($cursor) { + $query['cursor'] = $cursor; + } + + return $this->client->query(method: 'GET', endpoint: 'v1/audience-segments', options: [ + 'query' => $query + ]); + } + + public function get(string $id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/audience-segments/' . $id); + } +} diff --git a/src/CampaignGroups.php b/src/CampaignGroups.php new file mode 100644 index 0000000..ecb5bce --- /dev/null +++ b/src/CampaignGroups.php @@ -0,0 +1,62 @@ +client = $client; + } + + public function list(?int $per_page = null, ?string $cursor = null): mixed + { + $query = []; + if ($per_page !== null) { + $query['perPage'] = $per_page; + } + if ($cursor) { + $query['cursor'] = $cursor; + } + + return $this->client->query(method: 'GET', endpoint: 'v1/campaign-groups', options: [ + 'query' => $query + ]); + } + + public function create(string $name, ?string $description = null): mixed + { + $payload = ['name' => $name]; + if ($description !== null) { + $payload['description'] = $description; + } + + return $this->client->query(method: 'POST', endpoint: 'v1/campaign-groups', options: [ + 'json' => $payload + ]); + } + + public function get(string $id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/campaign-groups/' . $id); + } + + public function update(string $id, ?string $name = null, ?string $description = null): mixed + { + $payload = []; + if ($name !== null) { + $payload['name'] = $name; + } + if ($description !== null) { + $payload['description'] = $description; + } + + return $this->client->query(method: 'POST', endpoint: 'v1/campaign-groups/' . $id, options: [ + 'json' => $payload + ]); + } +} diff --git a/src/Campaigns.php b/src/Campaigns.php index e4d863c..caa1735 100644 --- a/src/Campaigns.php +++ b/src/Campaigns.php @@ -28,22 +28,72 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed ]); } - public function create(string $name): mixed - { + public function create( + string $name, + ?string $campaign_group_id = null, + ?string $mailing_list_id = null, + ?string $audience_segment_id = null, + ?array $audience_filter = null, + ?array $scheduling = null + ): mixed { + $payload = ['name' => $name]; + if ($campaign_group_id !== null) { + $payload['campaignGroupId'] = $campaign_group_id; + } + if ($mailing_list_id !== null) { + $payload['mailingListId'] = $mailing_list_id; + } + if ($audience_segment_id !== null) { + $payload['audienceSegmentId'] = $audience_segment_id; + } + if ($audience_filter !== null) { + $payload['audienceFilter'] = $audience_filter; + } + if ($scheduling !== null) { + $payload['scheduling'] = $scheduling; + } + return $this->client->query(method: 'POST', endpoint: 'v1/campaigns', options: [ - 'json' => ['name' => $name] + 'json' => $payload ]); } - public function get(string $campaign_id): mixed + public function get(string $id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/campaigns/' . $campaign_id); + return $this->client->query(method: 'GET', endpoint: 'v1/campaigns/' . $id); } - public function update(string $campaign_id, string $name): mixed - { - return $this->client->query(method: 'POST', endpoint: 'v1/campaigns/' . $campaign_id, options: [ - 'json' => ['name' => $name] + public function update( + string $id, + ?string $name = null, + ?string $campaign_group_id = null, + ?string $mailing_list_id = null, + ?string $audience_segment_id = null, + ?array $audience_filter = null, + ?array $scheduling = null + ): mixed { + $payload = []; + if ($name !== null) { + $payload['name'] = $name; + } + if ($campaign_group_id !== null) { + $payload['campaignGroupId'] = $campaign_group_id; + } + if ($mailing_list_id !== null) { + $payload['mailingListId'] = $mailing_list_id; + } + if ($audience_segment_id !== null) { + $payload['audienceSegmentId'] = $audience_segment_id; + } + if ($audience_filter !== null) { + $payload['audienceFilter'] = $audience_filter; + } + if ($scheduling !== null) { + $payload['scheduling'] = $scheduling; + } + + return $this->client->query(method: 'POST', endpoint: 'v1/campaigns/' . $id, options: [ + 'json' => $payload ]); } } diff --git a/src/Components.php b/src/Components.php index 9f38afa..18c368d 100644 --- a/src/Components.php +++ b/src/Components.php @@ -28,8 +28,8 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed ]); } - public function get(string $component_id): mixed + public function get(string $id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/components/' . $component_id); + return $this->client->query(method: 'GET', endpoint: 'v1/components/' . $id); } } diff --git a/src/EmailMessages.php b/src/EmailMessages.php index 6aa99b3..6ebdc01 100644 --- a/src/EmailMessages.php +++ b/src/EmailMessages.php @@ -13,15 +13,38 @@ public function __construct(LoopsClient $client) $this->client = $client; } - public function get(string $email_message_id): mixed + public function get(string $id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/email-messages/' . $email_message_id); + return $this->client->query(method: 'GET', endpoint: 'v1/email-messages/' . $id); } - public function update(string $email_message_id, array $fields = []): mixed + public function update(string $id, array $fields = []): mixed { - return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $email_message_id, options: [ + return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $id, options: [ 'json' => $fields ]); } + + public function preview( + string $id, + array $emails, + ?array $contact_properties = null, + ?array $event_properties = null, + ?array $data_variables = null + ): mixed { + $payload = ['emails' => $emails]; + if ($contact_properties !== null) { + $payload['contactProperties'] = $contact_properties; + } + if ($event_properties !== null) { + $payload['eventProperties'] = $event_properties; + } + if ($data_variables !== null) { + $payload['dataVariables'] = $data_variables; + } + + return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $id . '/preview', options: [ + 'json' => $payload + ]); + } } diff --git a/src/LoopsClient.php b/src/LoopsClient.php index a943690..eac1d64 100644 --- a/src/LoopsClient.php +++ b/src/LoopsClient.php @@ -17,8 +17,12 @@ class LoopsClient public Themes $themes; public Components $components; public Campaigns $campaigns; + public CampaignGroups $campaignGroups; public EmailMessages $emailMessages; public Uploads $uploads; + public AudienceSegments $audienceSegments; + public Workflows $workflows; + public TransactionalGroups $transactionalGroups; public function __construct(string $api_key) { @@ -41,8 +45,12 @@ public function __construct(string $api_key) $this->themes = new Themes(client: $this); $this->components = new Components(client: $this); $this->campaigns = new Campaigns(client: $this); + $this->campaignGroups = new CampaignGroups(client: $this); $this->emailMessages = new EmailMessages(client: $this); $this->uploads = new Uploads(client: $this); + $this->audienceSegments = new AudienceSegments(client: $this); + $this->workflows = new Workflows(client: $this); + $this->transactionalGroups = new TransactionalGroups(client: $this); } /** diff --git a/src/Themes.php b/src/Themes.php index 8c7d6e7..80c531d 100644 --- a/src/Themes.php +++ b/src/Themes.php @@ -28,8 +28,8 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed ]); } - public function get(string $theme_id): mixed + public function get(string $id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/themes/' . $theme_id); + return $this->client->query(method: 'GET', endpoint: 'v1/themes/' . $id); } } diff --git a/src/Transactional.php b/src/Transactional.php index 6e301e0..7219c00 100644 --- a/src/Transactional.php +++ b/src/Transactional.php @@ -14,7 +14,7 @@ public function __construct(LoopsClient $client) } public function send( - string $transactional_id, + string $id, string $email, ?bool $add_to_audience = false, ?array $data_variables = [], @@ -22,7 +22,7 @@ public function send( ?array $headers = [] ): mixed { $payload = [ - 'transactionalId' => $transactional_id, + 'transactionalId' => $id, 'email' => $email, 'addToAudience' => $add_to_audience, 'dataVariables' => $data_variables, @@ -57,25 +57,25 @@ public function create(string $name): mixed ]); } - public function get(string $transactional_id): mixed + public function get(string $id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails/' . $transactional_id); + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails/' . $id); } - public function update(string $transactional_id, string $name): mixed + public function update(string $id, string $name): mixed { - return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id, options: [ + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $id, options: [ 'json' => ['name' => $name] ]); } - public function ensureDraft(string $transactional_id): mixed + public function ensureDraft(string $id): mixed { - return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id . '/draft'); + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $id . '/draft'); } - public function publish(string $transactional_id): mixed + public function publish(string $id): mixed { - return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id . '/publish'); + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $id . '/publish'); } } \ No newline at end of file diff --git a/src/TransactionalGroups.php b/src/TransactionalGroups.php new file mode 100644 index 0000000..d5e856f --- /dev/null +++ b/src/TransactionalGroups.php @@ -0,0 +1,62 @@ +client = $client; + } + + public function list(?int $per_page = null, ?string $cursor = null): mixed + { + $query = []; + if ($per_page !== null) { + $query['perPage'] = $per_page; + } + if ($cursor) { + $query['cursor'] = $cursor; + } + + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-groups', options: [ + 'query' => $query + ]); + } + + public function create(string $name, ?string $description = null): mixed + { + $payload = ['name' => $name]; + if ($description !== null) { + $payload['description'] = $description; + } + + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-groups', options: [ + 'json' => $payload + ]); + } + + public function get(string $id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-groups/' . $id); + } + + public function update(string $id, ?string $name = null, ?string $description = null): mixed + { + $payload = []; + if ($name !== null) { + $payload['name'] = $name; + } + if ($description !== null) { + $payload['description'] = $description; + } + + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-groups/' . $id, options: [ + 'json' => $payload + ]); + } +} diff --git a/src/Workflows.php b/src/Workflows.php new file mode 100644 index 0000000..e74d570 --- /dev/null +++ b/src/Workflows.php @@ -0,0 +1,40 @@ +client = $client; + } + + public function list(?int $per_page = null, ?string $cursor = null): mixed + { + $query = []; + if ($per_page !== null) { + $query['perPage'] = $per_page; + } + if ($cursor) { + $query['cursor'] = $cursor; + } + + return $this->client->query(method: 'GET', endpoint: 'v1/workflows', options: [ + 'query' => $query + ]); + } + + public function get(string $id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/workflows/' . $id); + } + + public function getNode(string $workflow_id, string $node_id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/workflows/' . $workflow_id . '/nodes/' . $node_id); + } +} diff --git a/tests/AudienceSegmentsTest.php b/tests/AudienceSegmentsTest.php new file mode 100644 index 0000000..71c6e01 --- /dev/null +++ b/tests/AudienceSegmentsTest.php @@ -0,0 +1,66 @@ +mockHttpClient = $this->createMock(\GuzzleHttp\Client::class); + $this->client = new LoopsClient('test_api_key'); + $this->client->setHttpClient($this->mockHttpClient); + } + + public function testListAudienceSegments(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('get') + ->with( + 'v1/audience-segments', + $this->callback(function ($options) { + return $options['query']['perPage'] === 20 + && $options['query']['cursor'] === 'cursor123'; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'pagination' => ['nextCursor' => null], + 'data' => [] + ]) + )); + + $result = $this->client->audienceSegments->list(per_page: 20, cursor: 'cursor123'); + + $this->assertEquals([], $result['data']); + } + + public function testGetAudienceSegment(): void + { + $segmentId = 'seg_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('get') + ->with('v1/audience-segments/' . $segmentId) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'id' => $segmentId, + 'name' => 'Active subscribers' + ]) + )); + + $result = $this->client->audienceSegments->get(id: $segmentId); + + $this->assertEquals($segmentId, $result['id']); + } +} diff --git a/tests/CampaignGroupsTest.php b/tests/CampaignGroupsTest.php new file mode 100644 index 0000000..5048def --- /dev/null +++ b/tests/CampaignGroupsTest.php @@ -0,0 +1,68 @@ +mockHttpClient = $this->createMock(\GuzzleHttp\Client::class); + $this->client = new LoopsClient('test_api_key'); + $this->client->setHttpClient($this->mockHttpClient); + } + + public function testCreateCampaignGroup(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/campaign-groups', + $this->callback(function ($options) { + return $options['json']['name'] === 'Newsletters' + && $options['json']['description'] === 'Monthly'; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode(['id' => 'grp_123', 'name' => 'Newsletters']) + )); + + $result = $this->client->campaignGroups->create(name: 'Newsletters', description: 'Monthly'); + + $this->assertEquals('grp_123', $result['id']); + } + + public function testUpdateCampaignGroup(): void + { + $groupId = 'grp_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/campaign-groups/' . $groupId, + $this->callback(function ($options) { + return $options['json']['name'] === 'Updated name'; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode(['id' => $groupId, 'name' => 'Updated name']) + )); + + $result = $this->client->campaignGroups->update( + id: $groupId, + name: 'Updated name' + ); + + $this->assertEquals('Updated name', $result['name']); + } +} diff --git a/tests/CampaignsTest.php b/tests/CampaignsTest.php index 39ee98d..5bf36ea 100644 --- a/tests/CampaignsTest.php +++ b/tests/CampaignsTest.php @@ -89,7 +89,7 @@ public function testFindCampaign(): void ]) )); - $result = $this->client->campaigns->get(campaign_id: $campaignId); + $result = $this->client->campaigns->get(id: $campaignId); $this->assertEquals($campaignId, $result['campaignId']); } @@ -121,7 +121,7 @@ public function testUpdateCampaign(): void )); $result = $this->client->campaigns->update( - campaign_id: $campaignId, + id: $campaignId, name: 'Updated name' ); diff --git a/tests/ComponentsTest.php b/tests/ComponentsTest.php index b5f5ce0..8077f8a 100644 --- a/tests/ComponentsTest.php +++ b/tests/ComponentsTest.php @@ -58,7 +58,7 @@ public function testFindComponent(): void ]) )); - $result = $this->client->components->get(component_id: $componentId); + $result = $this->client->components->get(id: $componentId); $this->assertEquals($componentId, $result['componentId']); } diff --git a/tests/EmailMessagesTest.php b/tests/EmailMessagesTest.php index 028db8c..e850b2b 100644 --- a/tests/EmailMessagesTest.php +++ b/tests/EmailMessagesTest.php @@ -43,7 +43,7 @@ public function testFindEmailMessage(): void ]) )); - $result = $this->client->emailMessages->get(email_message_id: $emailMessageId); + $result = $this->client->emailMessages->get(id: $emailMessageId); $this->assertEquals($emailMessageId, $result['emailMessageId']); } @@ -84,11 +84,42 @@ public function testUpdateEmailMessage(): void )); $result = $this->client->emailMessages->update( - email_message_id: $emailMessageId, + id: $emailMessageId, fields: $fields ); $this->assertEquals('Updated subject', $result['subject']); $this->assertEquals('rev_456', $result['contentRevisionId']); } + + public function testPreviewEmailMessage(): void + { + $emailMessageId = 'msg_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/email-messages/' . $emailMessageId . '/preview', + $this->callback(function ($options) { + return $options['json']['emails'] === ['test@example.com'] + && $options['json']['contactProperties'] === ['firstName' => 'Ada']; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'success' => true, + 'emailMessageId' => $emailMessageId + ]) + )); + + $result = $this->client->emailMessages->preview( + id: $emailMessageId, + emails: ['test@example.com'], + contact_properties: ['firstName' => 'Ada'] + ); + + $this->assertTrue($result['success']); + } } diff --git a/tests/ThemesTest.php b/tests/ThemesTest.php index b4995b6..f919dc0 100644 --- a/tests/ThemesTest.php +++ b/tests/ThemesTest.php @@ -65,7 +65,7 @@ public function testFindTheme(): void ]) )); - $result = $this->client->themes->get(theme_id: $themeId); + $result = $this->client->themes->get(id: $themeId); $this->assertEquals($themeId, $result['themeId']); } diff --git a/tests/TransactionalGroupsTest.php b/tests/TransactionalGroupsTest.php new file mode 100644 index 0000000..f2d8b71 --- /dev/null +++ b/tests/TransactionalGroupsTest.php @@ -0,0 +1,57 @@ +mockHttpClient = $this->createMock(\GuzzleHttp\Client::class); + $this->client = new LoopsClient('test_api_key'); + $this->client->setHttpClient($this->mockHttpClient); + } + + public function testListTransactionalGroups(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('get') + ->with('v1/transactional-groups') + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'pagination' => ['nextCursor' => null], + 'data' => [] + ]) + )); + + $result = $this->client->transactionalGroups->list(); + + $this->assertEquals([], $result['data']); + } + + public function testGetTransactionalGroup(): void + { + $groupId = 'tgrp_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('get') + ->with('v1/transactional-groups/' . $groupId) + ->willReturn(new Response( + status: 200, + body: json_encode(['id' => $groupId, 'name' => 'Onboarding']) + )); + + $result = $this->client->transactionalGroups->get(id: $groupId); + + $this->assertEquals($groupId, $result['id']); + } +} diff --git a/tests/TransactionalTest.php b/tests/TransactionalTest.php index 1c0ded2..bfd3b41 100644 --- a/tests/TransactionalTest.php +++ b/tests/TransactionalTest.php @@ -29,7 +29,7 @@ protected function setUp(): void public function testSendTransactional(): void { - $transactional_id = 'test_template_123'; + $id = 'test_template_123'; $email = 'test@example.com'; $add_to_audience = true; $data_variables = ['name' => 'Test User']; @@ -48,7 +48,7 @@ public function testSendTransactional(): void ->method('post') ->with( 'v1/transactional', - $this->callback(function ($options) use ($transactional_id, $email, $add_to_audience, $data_variables, $attachments, $custom_headers) { + $this->callback(function ($options) use ($id, $email, $add_to_audience, $data_variables, $attachments, $custom_headers) { // Verify the request structure if (!isset($options['json']) || !isset($options['headers'])) { return false; @@ -68,7 +68,7 @@ public function testSendTransactional(): void && isset($payload['attachments']); // Verify payload values - $has_correct_values = $payload['transactionalId'] === $transactional_id + $has_correct_values = $payload['transactionalId'] === $id && $payload['email'] === $email && $payload['addToAudience'] === $add_to_audience && $payload['dataVariables'] === $data_variables @@ -89,7 +89,7 @@ public function testSendTransactional(): void // Make the API call $result = $this->client->transactional->send( - transactional_id: $transactional_id, + id: $id, email: $email, add_to_audience: $add_to_audience, data_variables: $data_variables, @@ -231,7 +231,7 @@ public function testGetTransactional(): void ]) )); - $result = $this->client->transactional->get(transactional_id: $transactionalId); + $result = $this->client->transactional->get(id: $transactionalId); $this->assertEquals($transactionalId, $result['id']); } @@ -263,7 +263,7 @@ public function testUpdateTransactional(): void )); $result = $this->client->transactional->update( - transactional_id: $transactionalId, + id: $transactionalId, name: 'Updated welcome email' ); @@ -292,7 +292,7 @@ public function testEnsureDraftTransactional(): void ]) )); - $result = $this->client->transactional->ensureDraft(transactional_id: $transactionalId); + $result = $this->client->transactional->ensureDraft(id: $transactionalId); $this->assertEquals('msg_123', $result['draftEmailMessageId']); } @@ -318,7 +318,7 @@ public function testPublishTransactional(): void ]) )); - $result = $this->client->transactional->publish(transactional_id: $transactionalId); + $result = $this->client->transactional->publish(id: $transactionalId); $this->assertEquals('msg_123', $result['publishedEmailMessageId']); } diff --git a/tests/WorkflowsTest.php b/tests/WorkflowsTest.php new file mode 100644 index 0000000..2269cd9 --- /dev/null +++ b/tests/WorkflowsTest.php @@ -0,0 +1,61 @@ +mockHttpClient = $this->createMock(\GuzzleHttp\Client::class); + $this->client = new LoopsClient('test_api_key'); + $this->client->setHttpClient($this->mockHttpClient); + } + + public function testListWorkflows(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('get') + ->with('v1/workflows') + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'pagination' => ['nextCursor' => null], + 'data' => [] + ]) + )); + + $result = $this->client->workflows->list(); + + $this->assertEquals([], $result['data']); + } + + public function testGetWorkflowNode(): void + { + $workflowId = 'wf_123'; + $nodeId = 'node_456'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('get') + ->with('v1/workflows/' . $workflowId . '/nodes/' . $nodeId) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'id' => $nodeId, + 'type' => 'email' + ]) + )); + + $result = $this->client->workflows->getNode(workflow_id: $workflowId, node_id: $nodeId); + + $this->assertEquals($nodeId, $result['id']); + } +} From 3ca9728fc0f5d0188ce583bc816decd1f323e889 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 24 Jun 2026 14:36:56 +0300 Subject: [PATCH 04/13] Revert ID name changes --- CHANGELOG.md | 1 - README.md | 76 +++++++++++++++---------------- src/AudienceSegments.php | 4 +- src/CampaignGroups.php | 8 ++-- src/Campaigns.php | 8 ++-- src/Components.php | 4 +- src/EmailMessages.php | 12 ++--- src/Themes.php | 4 +- src/Transactional.php | 20 ++++---- src/TransactionalGroups.php | 8 ++-- src/Workflows.php | 4 +- tests/AudienceSegmentsTest.php | 2 +- tests/CampaignGroupsTest.php | 2 +- tests/CampaignsTest.php | 4 +- tests/ComponentsTest.php | 2 +- tests/EmailMessagesTest.php | 6 +-- tests/ThemesTest.php | 2 +- tests/TransactionalGroupsTest.php | 2 +- tests/TransactionalTest.php | 16 +++---- 19 files changed, 92 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c31599c..8b18bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ Added support for new content endpoints: - `transactionalGroups->list()`, `transactionalGroups->create()`, `transactionalGroups->get()`, and `transactionalGroups->update()` for transactional groups. - `emailMessages->preview()` for sending test email previews. - Extended `campaigns->create()` and `campaigns->update()` with audience, group, and scheduling fields. -- Renamed primary resource ID parameters to `id` (for example, `transactional->get(id: '...')` instead of `transactional_id:`). ## v3.0.0 - May 19, 2026 diff --git a/README.md b/README.md index 553d0b5..893871f 100644 --- a/README.md +++ b/README.md @@ -776,7 +776,7 @@ Send a transactional email to a contact. [Learn about sending transactional emai | Name | Type | Required | Notes | | -------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `$id` | string | Yes | The ID of the transactional email to send. | +| `$transactional_id` | string | Yes | The ID of the transactional email to send. | | `$email` | string | Yes | The email address of the recipient. | | `$add_to_audience` | boolean | No | If `true`, a contact will be created in your audience using the `$email` value (if a matching contact doesn’t already exist). | | `$data_variables` | array | No | An array containing data as defined by the data variables added to the transactional email template.
Values can be of type `string` or `number`. | @@ -790,7 +790,7 @@ Send a transactional email to a contact. [Learn about sending transactional emai ```php $result = $loops->transactional->send( - id: 'clfq6dinn000yl70fgwwyp82l', + transactional_id: 'clfq6dinn000yl70fgwwyp82l', email: 'hello@gmail.com', data_variables: [ 'loginUrl' => 'https://myapp.com/login/', @@ -799,7 +799,7 @@ $result = $loops->transactional->send( # Example with Idempotency-Key header $result = $loops->transactional->send( - id: 'clfq6dinn000yl70fgwwyp82l', + transactional_id: 'clfq6dinn000yl70fgwwyp82l', email: 'hello@gmail.com', data_variables: [ 'loginUrl' => 'https://myapp.com/login/', @@ -811,7 +811,7 @@ $result = $loops->transactional->send( # Please contact us to enable attachments on your account. $result = $loops->transactional->send( - id: 'clfq6dinn000yl70fgwwyp82l', + transactional_id: 'clfq6dinn000yl70fgwwyp82l', email: 'hello@gmail.com', data_variables: [ 'loginUrl' => 'https://myapp.com/login/', @@ -980,12 +980,12 @@ Get a single transactional email by ID. | Name | Type | Required | Notes | | ------------------- | ------ | -------- | -------------------------------- | -| `$id` | string | Yes | The ID of the transactional email. | +| `$transactional_id` | string | Yes | The ID of the transactional email. | #### Example ```php -$result = $loops->transactional->get(id: 'clfq6dinn000yl70fgwwyp82l'); +$result = $loops->transactional->get(transactional_id: 'clfq6dinn000yl70fgwwyp82l'); ``` --- @@ -1000,14 +1000,14 @@ Update a transactional email's name. | Name | Type | Required | Notes | | ------------------- | ------ | -------- | -------------------------------- | -| `$id` | string | Yes | The ID of the transactional email. | +| `$transactional_id` | string | Yes | The ID of the transactional email. | | `$name` | string | Yes | The updated name. | #### Example ```php $result = $loops->transactional->update( - id: 'clfq6dinn000yl70fgwwyp82l', + transactional_id: 'clfq6dinn000yl70fgwwyp82l', name: 'Updated welcome email' ); ``` @@ -1024,12 +1024,12 @@ Ensure a transactional email has a draft email message. If a draft already exist | Name | Type | Required | Notes | | ------------------- | ------ | -------- | -------------------------------- | -| `$id` | string | Yes | The ID of the transactional email. | +| `$transactional_id` | string | Yes | The ID of the transactional email. | #### Example ```php -$result = $loops->transactional->ensureDraft(id: 'clfq6dinn000yl70fgwwyp82l'); +$result = $loops->transactional->ensureDraft(transactional_id: 'clfq6dinn000yl70fgwwyp82l'); ``` --- @@ -1044,12 +1044,12 @@ Publish the transactional email's current draft email message. | Name | Type | Required | Notes | | ------------------- | ------ | -------- | -------------------------------- | -| `$id` | string | Yes | The ID of the transactional email. | +| `$transactional_id` | string | Yes | The ID of the transactional email. | #### Example ```php -$result = $loops->transactional->publish(id: 'clfq6dinn000yl70fgwwyp82l'); +$result = $loops->transactional->publish(transactional_id: 'clfq6dinn000yl70fgwwyp82l'); ``` --- @@ -1142,12 +1142,12 @@ Get a single email theme by ID. | Name | Type | Required | Notes | | ----------- | ------ | -------- | ------------------ | -| `$id` | string | Yes | The ID of the theme. | +| `$theme_id` | string | Yes | The ID of the theme. | #### Example ```php -$result = $loops->themes->get(id: 'clo5p8q0r0132ntx6flkunw89'); +$result = $loops->themes->get(theme_id: 'clo5p8q0r0132ntx6flkunw89'); ``` --- @@ -1183,12 +1183,12 @@ Get a single email component by ID. | Name | Type | Required | Notes | | --------------- | ------ | -------- | ----------------------- | -| `$id` | string | Yes | The ID of the component. | +| `$component_id` | string | Yes | The ID of the component. | #### Example ```php -$result = $loops->components->get(id: 'clp6q9r1s0154ouy7gmlovx90'); +$result = $loops->components->get(component_id: 'clp6q9r1s0154ouy7gmlovx90'); ``` --- @@ -1270,12 +1270,12 @@ Get a single campaign by ID. | Name | Type | Required | Notes | | ----------------------- | ------ | -------- | --------------------- | -| `$id` | string | Yes | The ID of the campaign. | +| `$campaign_id` | string | Yes | The ID of the campaign. | #### Example ```php -$result = $loops->campaigns->get(id: 'cln4o7p9q0110msw5ekjtmv78'); +$result = $loops->campaigns->get(campaign_id: 'cln4o7p9q0110msw5ekjtmv78'); ``` --- @@ -1290,7 +1290,7 @@ Update a draft campaign's name, group, audience, or scheduling. | Name | Type | Required | Notes | | ----------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- | -| `$id` | string | Yes | The ID of the campaign. | +| `$campaign_id` | string | Yes | The ID of the campaign. | | `$name` | string | No | The updated name. | | `$campaign_group_id` | string | No | The ID of the group to move this campaign to. | | `$mailing_list_id` | string | No | The ID of the mailing list to send to. | @@ -1304,7 +1304,7 @@ At least one field must be provided. ```php $result = $loops->campaigns->update( - id: 'cln4o7p9q0110msw5ekjtmv78', + campaign_id: 'cln4o7p9q0110msw5ekjtmv78', name: 'Updated name' ); ``` @@ -1363,12 +1363,12 @@ Get a campaign group by ID. | Name | Type | Required | Notes | | ----- | ------ | -------- | ---------------------------- | -| `$id` | string | Yes | The ID of the campaign group. | +| `$campaign_group_id` | string | Yes | The ID of the campaign group. | #### Example ```php -$result = $loops->campaignGroups->get(id: 'clq7r0s2t0176pvz8hnmpwy01'); +$result = $loops->campaignGroups->get(campaign_group_id: 'clq7r0s2t0176pvz8hnmpwy01'); ``` --- @@ -1383,7 +1383,7 @@ Update a campaign group's name or description. | Name | Type | Required | Notes | | --------------- | ------ | -------- | --------------------------------------- | -| `$id` | string | Yes | The ID of the campaign group. | +| `$campaign_group_id` | string | Yes | The ID of the campaign group. | | `$name` | string | No | Cannot be the reserved name "Unsorted". | | `$description` | string | No | | @@ -1393,7 +1393,7 @@ At least one field must be provided. ```php $result = $loops->campaignGroups->update( - id: 'clq7r0s2t0176pvz8hnmpwy01', + campaign_group_id: 'clq7r0s2t0176pvz8hnmpwy01', name: 'Updated name' ); ``` @@ -1431,12 +1431,12 @@ Get an audience segment by ID. | Name | Type | Required | Notes | | ----- | ------ | -------- | ------------------------------- | -| `$id` | string | Yes | The ID of the audience segment. | +| `$audience_segment_id` | string | Yes | The ID of the audience segment. | #### Example ```php -$result = $loops->audienceSegments->get(id: 'clr8s1t3u0198qw09iotqzx12'); +$result = $loops->audienceSegments->get(audience_segment_id: 'clr8s1t3u0198qw09iotqzx12'); ``` --- @@ -1472,12 +1472,12 @@ Get a simplified workflow graph. | Name | Type | Required | Notes | | ----- | ------ | -------- | ---------------------- | -| `$id` | string | Yes | The ID of the workflow. | +| `$workflow_id` | string | Yes | The ID of the workflow. | #### Example ```php -$result = $loops->workflows->get(id: 'cls9t2u4v0210rx20jpuary23'); +$result = $loops->workflows->get(workflow_id: 'cls9t2u4v0210rx20jpuary23'); ``` --- @@ -1516,12 +1516,12 @@ Get an email message, including its compiled LMX content. | Name | Type | Required | Notes | | --------- | ------ | -------- | --------------------------- | -| `$id` | string | Yes | The ID of the email message. | +| `$email_message_id` | string | Yes | The ID of the email message. | #### Example ```php -$result = $loops->emailMessages->get(id: 'cly8k3m0n0044jpx2bghepq45'); +$result = $loops->emailMessages->get(email_message_id: 'cly8k3m0n0044jpx2bghepq45'); ``` --- @@ -1536,14 +1536,14 @@ Update an email message's subject, preview text, sender, or LMX content. | Name | Type | Required | Notes | | --------- | ----- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `$id` | string | Yes | The ID of the email message. | +| `$email_message_id` | string | Yes | The ID of the email message. | | `$fields` | array | No | Fields to update. Use API field names: `expectedRevisionId`, `subject`, `previewText`, `fromName`, `fromEmail`, `replyToEmail`, `lmx`. Supply `expectedRevisionId` matching the current `contentRevisionId` to avoid 409 conflicts. | #### Example ```php $result = $loops->emailMessages->update( - id: 'cly8k3m0n0044jpx2bghepq45', + email_message_id: 'cly8k3m0n0044jpx2bghepq45', fields: [ 'expectedRevisionId' => 'clm9n4o6p0088lrz4dijslt67', 'subject' => 'Updated subject', @@ -1564,7 +1564,7 @@ Send a test preview of an email message to one or more addresses. | Name | Type | Required | Notes | | ---------------------- | -------- | -------- | --------------------------------------------------------------------------------- | -| `$id` | string | Yes | The ID of the email message. | +| `$email_message_id` | string | Yes | The ID of the email message. | | `$emails` | array | Yes | One or more addresses to send the preview to. | | `$contact_properties` | array | No | Contact property values to render. Accepted for campaign and workflow previews. | | `$event_properties` | array | No | Event property values to render. Accepted for workflow previews only. | @@ -1574,7 +1574,7 @@ Send a test preview of an email message to one or more addresses. ```php $result = $loops->emailMessages->preview( - id: 'cly8k3m0n0044jpx2bghepq45', + email_message_id: 'cly8k3m0n0044jpx2bghepq45', emails: ['test@example.com'], contact_properties: ['firstName' => 'Alex'] ); @@ -1634,12 +1634,12 @@ Get a transactional group by ID. | Name | Type | Required | Notes | | ----- | ------ | -------- | --------------------------------- | -| `$id` | string | Yes | The ID of the transactional group. | +| `$transactional_group_id` | string | Yes | The ID of the transactional group. | #### Example ```php -$result = $loops->transactionalGroups->get(id: 'clv2w3x4y0288xbb0kqrsuv67'); +$result = $loops->transactionalGroups->get(transactional_group_id: 'clv2w3x4y0288xbb0kqrsuv67'); ``` --- @@ -1654,7 +1654,7 @@ Update a transactional group's name or description. | Name | Type | Required | Notes | | --------------- | ------ | -------- | --------------------------------------- | -| `$id` | string | Yes | The ID of the transactional group. | +| `$transactional_group_id` | string | Yes | The ID of the transactional group. | | `$name` | string | No | Cannot be the reserved name "Unsorted". | | `$description` | string | No | | @@ -1664,7 +1664,7 @@ At least one field must be provided. ```php $result = $loops->transactionalGroups->update( - id: 'clv2w3x4y0288xbb0kqrsuv67', + transactional_group_id: 'clv2w3x4y0288xbb0kqrsuv67', name: 'Updated name' ); ``` diff --git a/src/AudienceSegments.php b/src/AudienceSegments.php index 443e108..9d60740 100644 --- a/src/AudienceSegments.php +++ b/src/AudienceSegments.php @@ -28,8 +28,8 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed ]); } - public function get(string $id): mixed + public function get(string $audience_segment_id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/audience-segments/' . $id); + return $this->client->query(method: 'GET', endpoint: 'v1/audience-segments/' . $audience_segment_id); } } diff --git a/src/CampaignGroups.php b/src/CampaignGroups.php index ecb5bce..3fc1d2f 100644 --- a/src/CampaignGroups.php +++ b/src/CampaignGroups.php @@ -40,12 +40,12 @@ public function create(string $name, ?string $description = null): mixed ]); } - public function get(string $id): mixed + public function get(string $campaign_group_id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/campaign-groups/' . $id); + return $this->client->query(method: 'GET', endpoint: 'v1/campaign-groups/' . $campaign_group_id); } - public function update(string $id, ?string $name = null, ?string $description = null): mixed + public function update(string $campaign_group_id, ?string $name = null, ?string $description = null): mixed { $payload = []; if ($name !== null) { @@ -55,7 +55,7 @@ public function update(string $id, ?string $name = null, ?string $description = $payload['description'] = $description; } - return $this->client->query(method: 'POST', endpoint: 'v1/campaign-groups/' . $id, options: [ + return $this->client->query(method: 'POST', endpoint: 'v1/campaign-groups/' . $campaign_group_id, options: [ 'json' => $payload ]); } diff --git a/src/Campaigns.php b/src/Campaigns.php index caa1735..720f487 100644 --- a/src/Campaigns.php +++ b/src/Campaigns.php @@ -58,13 +58,13 @@ public function create( ]); } - public function get(string $id): mixed + public function get(string $campaign_id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/campaigns/' . $id); + return $this->client->query(method: 'GET', endpoint: 'v1/campaigns/' . $campaign_id); } public function update( - string $id, + string $campaign_id, ?string $name = null, ?string $campaign_group_id = null, ?string $mailing_list_id = null, @@ -92,7 +92,7 @@ public function update( $payload['scheduling'] = $scheduling; } - return $this->client->query(method: 'POST', endpoint: 'v1/campaigns/' . $id, options: [ + return $this->client->query(method: 'POST', endpoint: 'v1/campaigns/' . $campaign_id, options: [ 'json' => $payload ]); } diff --git a/src/Components.php b/src/Components.php index 18c368d..9f38afa 100644 --- a/src/Components.php +++ b/src/Components.php @@ -28,8 +28,8 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed ]); } - public function get(string $id): mixed + public function get(string $component_id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/components/' . $id); + return $this->client->query(method: 'GET', endpoint: 'v1/components/' . $component_id); } } diff --git a/src/EmailMessages.php b/src/EmailMessages.php index 6ebdc01..cbbf82b 100644 --- a/src/EmailMessages.php +++ b/src/EmailMessages.php @@ -13,20 +13,20 @@ public function __construct(LoopsClient $client) $this->client = $client; } - public function get(string $id): mixed + public function get(string $email_message_id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/email-messages/' . $id); + return $this->client->query(method: 'GET', endpoint: 'v1/email-messages/' . $email_message_id); } - public function update(string $id, array $fields = []): mixed + public function update(string $email_message_id, array $fields = []): mixed { - return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $id, options: [ + return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $email_message_id, options: [ 'json' => $fields ]); } public function preview( - string $id, + string $email_message_id, array $emails, ?array $contact_properties = null, ?array $event_properties = null, @@ -43,7 +43,7 @@ public function preview( $payload['dataVariables'] = $data_variables; } - return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $id . '/preview', options: [ + return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $email_message_id . '/preview', options: [ 'json' => $payload ]); } diff --git a/src/Themes.php b/src/Themes.php index 80c531d..8c7d6e7 100644 --- a/src/Themes.php +++ b/src/Themes.php @@ -28,8 +28,8 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed ]); } - public function get(string $id): mixed + public function get(string $theme_id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/themes/' . $id); + return $this->client->query(method: 'GET', endpoint: 'v1/themes/' . $theme_id); } } diff --git a/src/Transactional.php b/src/Transactional.php index 7219c00..6e301e0 100644 --- a/src/Transactional.php +++ b/src/Transactional.php @@ -14,7 +14,7 @@ public function __construct(LoopsClient $client) } public function send( - string $id, + string $transactional_id, string $email, ?bool $add_to_audience = false, ?array $data_variables = [], @@ -22,7 +22,7 @@ public function send( ?array $headers = [] ): mixed { $payload = [ - 'transactionalId' => $id, + 'transactionalId' => $transactional_id, 'email' => $email, 'addToAudience' => $add_to_audience, 'dataVariables' => $data_variables, @@ -57,25 +57,25 @@ public function create(string $name): mixed ]); } - public function get(string $id): mixed + public function get(string $transactional_id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails/' . $id); + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails/' . $transactional_id); } - public function update(string $id, string $name): mixed + public function update(string $transactional_id, string $name): mixed { - return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $id, options: [ + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id, options: [ 'json' => ['name' => $name] ]); } - public function ensureDraft(string $id): mixed + public function ensureDraft(string $transactional_id): mixed { - return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $id . '/draft'); + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id . '/draft'); } - public function publish(string $id): mixed + public function publish(string $transactional_id): mixed { - return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $id . '/publish'); + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id . '/publish'); } } \ No newline at end of file diff --git a/src/TransactionalGroups.php b/src/TransactionalGroups.php index d5e856f..9a1aec7 100644 --- a/src/TransactionalGroups.php +++ b/src/TransactionalGroups.php @@ -40,12 +40,12 @@ public function create(string $name, ?string $description = null): mixed ]); } - public function get(string $id): mixed + public function get(string $transactional_group_id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/transactional-groups/' . $id); + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-groups/' . $transactional_group_id); } - public function update(string $id, ?string $name = null, ?string $description = null): mixed + public function update(string $transactional_group_id, ?string $name = null, ?string $description = null): mixed { $payload = []; if ($name !== null) { @@ -55,7 +55,7 @@ public function update(string $id, ?string $name = null, ?string $description = $payload['description'] = $description; } - return $this->client->query(method: 'POST', endpoint: 'v1/transactional-groups/' . $id, options: [ + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-groups/' . $transactional_group_id, options: [ 'json' => $payload ]); } diff --git a/src/Workflows.php b/src/Workflows.php index e74d570..5337a09 100644 --- a/src/Workflows.php +++ b/src/Workflows.php @@ -28,9 +28,9 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed ]); } - public function get(string $id): mixed + public function get(string $workflow_id): mixed { - return $this->client->query(method: 'GET', endpoint: 'v1/workflows/' . $id); + return $this->client->query(method: 'GET', endpoint: 'v1/workflows/' . $workflow_id); } public function getNode(string $workflow_id, string $node_id): mixed diff --git a/tests/AudienceSegmentsTest.php b/tests/AudienceSegmentsTest.php index 71c6e01..c417055 100644 --- a/tests/AudienceSegmentsTest.php +++ b/tests/AudienceSegmentsTest.php @@ -59,7 +59,7 @@ public function testGetAudienceSegment(): void ]) )); - $result = $this->client->audienceSegments->get(id: $segmentId); + $result = $this->client->audienceSegments->get(audience_segment_id: $segmentId); $this->assertEquals($segmentId, $result['id']); } diff --git a/tests/CampaignGroupsTest.php b/tests/CampaignGroupsTest.php index 5048def..8cc6c19 100644 --- a/tests/CampaignGroupsTest.php +++ b/tests/CampaignGroupsTest.php @@ -59,7 +59,7 @@ public function testUpdateCampaignGroup(): void )); $result = $this->client->campaignGroups->update( - id: $groupId, + campaign_group_id: $groupId, name: 'Updated name' ); diff --git a/tests/CampaignsTest.php b/tests/CampaignsTest.php index 5bf36ea..39ee98d 100644 --- a/tests/CampaignsTest.php +++ b/tests/CampaignsTest.php @@ -89,7 +89,7 @@ public function testFindCampaign(): void ]) )); - $result = $this->client->campaigns->get(id: $campaignId); + $result = $this->client->campaigns->get(campaign_id: $campaignId); $this->assertEquals($campaignId, $result['campaignId']); } @@ -121,7 +121,7 @@ public function testUpdateCampaign(): void )); $result = $this->client->campaigns->update( - id: $campaignId, + campaign_id: $campaignId, name: 'Updated name' ); diff --git a/tests/ComponentsTest.php b/tests/ComponentsTest.php index 8077f8a..b5f5ce0 100644 --- a/tests/ComponentsTest.php +++ b/tests/ComponentsTest.php @@ -58,7 +58,7 @@ public function testFindComponent(): void ]) )); - $result = $this->client->components->get(id: $componentId); + $result = $this->client->components->get(component_id: $componentId); $this->assertEquals($componentId, $result['componentId']); } diff --git a/tests/EmailMessagesTest.php b/tests/EmailMessagesTest.php index e850b2b..4b8e5ed 100644 --- a/tests/EmailMessagesTest.php +++ b/tests/EmailMessagesTest.php @@ -43,7 +43,7 @@ public function testFindEmailMessage(): void ]) )); - $result = $this->client->emailMessages->get(id: $emailMessageId); + $result = $this->client->emailMessages->get(email_message_id: $emailMessageId); $this->assertEquals($emailMessageId, $result['emailMessageId']); } @@ -84,7 +84,7 @@ public function testUpdateEmailMessage(): void )); $result = $this->client->emailMessages->update( - id: $emailMessageId, + email_message_id: $emailMessageId, fields: $fields ); @@ -115,7 +115,7 @@ public function testPreviewEmailMessage(): void )); $result = $this->client->emailMessages->preview( - id: $emailMessageId, + email_message_id: $emailMessageId, emails: ['test@example.com'], contact_properties: ['firstName' => 'Ada'] ); diff --git a/tests/ThemesTest.php b/tests/ThemesTest.php index f919dc0..b4995b6 100644 --- a/tests/ThemesTest.php +++ b/tests/ThemesTest.php @@ -65,7 +65,7 @@ public function testFindTheme(): void ]) )); - $result = $this->client->themes->get(id: $themeId); + $result = $this->client->themes->get(theme_id: $themeId); $this->assertEquals($themeId, $result['themeId']); } diff --git a/tests/TransactionalGroupsTest.php b/tests/TransactionalGroupsTest.php index f2d8b71..a10d403 100644 --- a/tests/TransactionalGroupsTest.php +++ b/tests/TransactionalGroupsTest.php @@ -50,7 +50,7 @@ public function testGetTransactionalGroup(): void body: json_encode(['id' => $groupId, 'name' => 'Onboarding']) )); - $result = $this->client->transactionalGroups->get(id: $groupId); + $result = $this->client->transactionalGroups->get(transactional_group_id: $groupId); $this->assertEquals($groupId, $result['id']); } diff --git a/tests/TransactionalTest.php b/tests/TransactionalTest.php index bfd3b41..1c0ded2 100644 --- a/tests/TransactionalTest.php +++ b/tests/TransactionalTest.php @@ -29,7 +29,7 @@ protected function setUp(): void public function testSendTransactional(): void { - $id = 'test_template_123'; + $transactional_id = 'test_template_123'; $email = 'test@example.com'; $add_to_audience = true; $data_variables = ['name' => 'Test User']; @@ -48,7 +48,7 @@ public function testSendTransactional(): void ->method('post') ->with( 'v1/transactional', - $this->callback(function ($options) use ($id, $email, $add_to_audience, $data_variables, $attachments, $custom_headers) { + $this->callback(function ($options) use ($transactional_id, $email, $add_to_audience, $data_variables, $attachments, $custom_headers) { // Verify the request structure if (!isset($options['json']) || !isset($options['headers'])) { return false; @@ -68,7 +68,7 @@ public function testSendTransactional(): void && isset($payload['attachments']); // Verify payload values - $has_correct_values = $payload['transactionalId'] === $id + $has_correct_values = $payload['transactionalId'] === $transactional_id && $payload['email'] === $email && $payload['addToAudience'] === $add_to_audience && $payload['dataVariables'] === $data_variables @@ -89,7 +89,7 @@ public function testSendTransactional(): void // Make the API call $result = $this->client->transactional->send( - id: $id, + transactional_id: $transactional_id, email: $email, add_to_audience: $add_to_audience, data_variables: $data_variables, @@ -231,7 +231,7 @@ public function testGetTransactional(): void ]) )); - $result = $this->client->transactional->get(id: $transactionalId); + $result = $this->client->transactional->get(transactional_id: $transactionalId); $this->assertEquals($transactionalId, $result['id']); } @@ -263,7 +263,7 @@ public function testUpdateTransactional(): void )); $result = $this->client->transactional->update( - id: $transactionalId, + transactional_id: $transactionalId, name: 'Updated welcome email' ); @@ -292,7 +292,7 @@ public function testEnsureDraftTransactional(): void ]) )); - $result = $this->client->transactional->ensureDraft(id: $transactionalId); + $result = $this->client->transactional->ensureDraft(transactional_id: $transactionalId); $this->assertEquals('msg_123', $result['draftEmailMessageId']); } @@ -318,7 +318,7 @@ public function testPublishTransactional(): void ]) )); - $result = $this->client->transactional->publish(id: $transactionalId); + $result = $this->client->transactional->publish(transactional_id: $transactionalId); $this->assertEquals('msg_123', $result['publishedEmailMessageId']); } From 3ca133c44168ad51a1879b3378a6862dfd63368e Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 24 Jun 2026 14:55:20 +0300 Subject: [PATCH 05/13] Reduce to minor update (no breaking changes) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b18bce..e801204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## v4.0.0 - Jun 24, 2026 +## v3.1.0 - Jun 24, 2026 Added support for new content endpoints: From 75443343af74540eda85a8525d6238418b0e97c2 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 24 Jun 2026 16:23:05 +0300 Subject: [PATCH 06/13] Add group parameters --- README.md | 18 +++++++------ src/Transactional.php | 29 ++++++++++++++++---- tests/TransactionalTest.php | 53 +++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 893871f..9f4b1ae 100644 --- a/README.md +++ b/README.md @@ -943,9 +943,10 @@ Create a new transactional email. An empty draft email message is created automa #### Parameters -| Name | Type | Required | Notes | -| ------- | ------ | -------- | ---------------------------------- | -| `$name` | string | Yes | The name of the transactional email. | +| Name | Type | Required | Notes | +| -------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `$name` | string | Yes | The name of the transactional email. | +| `$transactional_group_id` | string | No | The ID of the group to add this transactional email to. Defaults to the team's default group when omitted. | #### Example @@ -992,16 +993,17 @@ $result = $loops->transactional->get(transactional_id: 'clfq6dinn000yl70fgwwyp82 ### transactional->update() -Update a transactional email's name. +Update a transactional email. [API Reference](https://loops.so/docs/api-reference/update-transactional-email) #### Parameters -| Name | Type | Required | Notes | -| ------------------- | ------ | -------- | -------------------------------- | -| `$transactional_id` | string | Yes | The ID of the transactional email. | -| `$name` | string | Yes | The updated name. | +| Name | Type | Required | Notes | +| -------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------ | +| `$transactional_id` | string | Yes | The ID of the transactional email. | +| `$name` | string | No | The updated name. At least one of `$name` or `$transactional_group_id` must be provided. | +| `$transactional_group_id` | string | No | The ID of the group to move this transactional email to. | #### Example diff --git a/src/Transactional.php b/src/Transactional.php index 6e301e0..61d3b5c 100644 --- a/src/Transactional.php +++ b/src/Transactional.php @@ -50,10 +50,15 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed ]); } - public function create(string $name): mixed + public function create(string $name, ?string $transactional_group_id = null): mixed { + $payload = ['name' => $name]; + if ($transactional_group_id !== null) { + $payload['transactionalGroupId'] = $transactional_group_id; + } + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails', options: [ - 'json' => ['name' => $name] + 'json' => $payload ]); } @@ -62,10 +67,24 @@ public function get(string $transactional_id): mixed return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails/' . $transactional_id); } - public function update(string $transactional_id, string $name): mixed - { + public function update( + string $transactional_id, + ?string $name = null, + ?string $transactional_group_id = null + ): mixed { + $payload = []; + if ($name !== null) { + $payload['name'] = $name; + } + if ($transactional_group_id !== null) { + $payload['transactionalGroupId'] = $transactional_group_id; + } + if ($payload === []) { + throw new \InvalidArgumentException(message: 'At least one field must be provided.'); + } + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id, options: [ - 'json' => ['name' => $name] + 'json' => $payload ]); } diff --git a/tests/TransactionalTest.php b/tests/TransactionalTest.php index 1c0ded2..3bd488d 100644 --- a/tests/TransactionalTest.php +++ b/tests/TransactionalTest.php @@ -210,6 +210,28 @@ public function testCreateTransactional(): void $this->assertEquals('msg_123', $result['draftEmailMessageId']); } + public function testCreateTransactionalWithGroup(): void + { + $groupId = 'grp_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/transactional-emails', + $this->callback(function ($options) use ($groupId) { + return $options['json']['name'] === 'Welcome email' + && $options['json']['transactionalGroupId'] === $groupId; + }) + ) + ->willReturn(new Response(status: 201, body: json_encode(['id' => 'txn_123']))); + + $this->client->transactional->create( + name: 'Welcome email', + transactional_group_id: $groupId + ); + } + public function testGetTransactional(): void { $transactionalId = 'txn_123'; @@ -270,6 +292,37 @@ public function testUpdateTransactional(): void $this->assertEquals('Updated welcome email', $result['name']); } + public function testUpdateTransactionalGroup(): void + { + $transactionalId = 'txn_123'; + $groupId = 'grp_456'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/transactional-emails/' . $transactionalId, + $this->callback(function ($options) use ($groupId) { + return $options['json']['transactionalGroupId'] === $groupId + && !isset($options['json']['name']); + }) + ) + ->willReturn(new Response(status: 200, body: json_encode(['id' => $transactionalId]))); + + $this->client->transactional->update( + transactional_id: $transactionalId, + transactional_group_id: $groupId + ); + } + + public function testUpdateTransactionalWithoutFieldsThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one field must be provided.'); + + $this->client->transactional->update(transactional_id: 'txn_123'); + } + public function testEnsureDraftTransactional(): void { $transactionalId = 'txn_123'; From 5e4259aa660c0811b1d5282cfc5e9efd6ca2a846 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 24 Jun 2026 16:23:27 +0300 Subject: [PATCH 07/13] Fix content_type docs --- README.md | 4 ++-- src/Transactional.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9f4b1ae..ba6399d 100644 --- a/README.md +++ b/README.md @@ -782,7 +782,7 @@ Send a transactional email to a contact. [Learn about sending transactional emai | `$data_variables` | array | No | An array containing data as defined by the data variables added to the transactional email template.
Values can be of type `string` or `number`. | | `$attachments` | array[] | No | A list of attachments objects.
**Please note**: Attachments need to be enabled on your account before using them with the API. [Read more](https://loops.so/docs/transactional/attachments) | | `$attachments[]["filename"]` | string | No | The name of the file, shown in email clients. | -| `$attachments[]["content_type"]` | string | No | The MIME type of the file. | +| `$attachments[]["contentType"]` | string | No | The MIME type of the file. | | `$attachments[]["data"]` | string | No | The base64-encoded content of the file. | | `$headers` | array | No | Additional headers to send with the request. | @@ -819,7 +819,7 @@ $result = $loops->transactional->send( attachments: [ [ 'filename' => 'presentation.pdf', - 'content_type' => 'application/pdf', + 'contentType' => 'application/pdf', 'data' => base64_encode(file_get_contents('path/to/presentation.pdf')) ] ] diff --git a/src/Transactional.php b/src/Transactional.php index 61d3b5c..37a75ab 100644 --- a/src/Transactional.php +++ b/src/Transactional.php @@ -18,7 +18,7 @@ public function send( string $email, ?bool $add_to_audience = false, ?array $data_variables = [], - ?array $attachments = [], /** @var array */ + ?array $attachments = [], /** @var array */ ?array $headers = [] ): mixed { $payload = [ From e3db9b745819a8a35e1ed49a0b1f1fdf782210b2 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 24 Jun 2026 16:43:25 +0300 Subject: [PATCH 08/13] Reusable function for payload creation --- README.md | 10 ++++----- src/AudienceSegments.php | 13 ++++------- src/CampaignGroups.php | 13 ++++------- src/Campaigns.php | 16 ++++++------- src/Components.php | 13 ++++------- src/ContactProperties.php | 8 +++---- src/Contacts.php | 45 ++++++++++++++----------------------- src/Themes.php | 13 ++++------- src/Transactional.php | 15 +++++-------- src/TransactionalGroups.php | 13 ++++------- src/Util.php | 18 +++++++++++++++ src/Workflows.php | 13 ++++------- tests/UtilTest.php | 39 ++++++++++++++++++++++++++++++++ 13 files changed, 118 insertions(+), 111 deletions(-) create mode 100644 src/Util.php create mode 100644 tests/UtilTest.php diff --git a/README.md b/README.md index ba6399d..f966f64 100644 --- a/README.md +++ b/README.md @@ -1270,9 +1270,9 @@ Get a single campaign by ID. #### Parameters -| Name | Type | Required | Notes | -| ----------------------- | ------ | -------- | --------------------- | -| `$campaign_id` | string | Yes | The ID of the campaign. | +| Name | Type | Required | Notes | +| -------------- | ------ | -------- | ----------------------- | +| `$campaign_id` | string | Yes | The ID of the campaign. | #### Example @@ -1292,7 +1292,7 @@ Update a draft campaign's name, group, audience, or scheduling. | Name | Type | Required | Notes | | ----------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- | -| `$campaign_id` | string | Yes | The ID of the campaign. | +| `$campaign_id` | string | Yes | The ID of the campaign. | | `$name` | string | No | The updated name. | | `$campaign_group_id` | string | No | The ID of the group to move this campaign to. | | `$mailing_list_id` | string | No | The ID of the mailing list to send to. | @@ -1539,7 +1539,7 @@ Update an email message's subject, preview text, sender, or LMX content. | Name | Type | Required | Notes | | --------- | ----- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `$email_message_id` | string | Yes | The ID of the email message. | -| `$fields` | array | No | Fields to update. Use API field names: `expectedRevisionId`, `subject`, `previewText`, `fromName`, `fromEmail`, `replyToEmail`, `lmx`. Supply `expectedRevisionId` matching the current `contentRevisionId` to avoid 409 conflicts. | +| `$fields` | array | No | Fields to update. Use API field names: `expectedRevisionId`, `subject`, `previewText`, `fromName`, `fromEmail`, `replyToEmail`, `lmx`, `contactPropertiesFallbacks`, `eventPropertiesFallbacks`, `dataVariablesFallbacks`. Supply `expectedRevisionId` matching the current `contentRevisionId` to avoid 409 conflicts. For the `*Fallbacks` maps, a `null` value deletes an individual fallback entry. | #### Example diff --git a/src/AudienceSegments.php b/src/AudienceSegments.php index 9d60740..98afa9d 100644 --- a/src/AudienceSegments.php +++ b/src/AudienceSegments.php @@ -15,16 +15,11 @@ public function __construct(LoopsClient $client) public function list(?int $per_page = null, ?string $cursor = null): mixed { - $query = []; - if ($per_page !== null) { - $query['perPage'] = $per_page; - } - if ($cursor) { - $query['cursor'] = $cursor; - } - return $this->client->query(method: 'GET', endpoint: 'v1/audience-segments', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) ]); } diff --git a/src/CampaignGroups.php b/src/CampaignGroups.php index 3fc1d2f..02fd83a 100644 --- a/src/CampaignGroups.php +++ b/src/CampaignGroups.php @@ -15,16 +15,11 @@ public function __construct(LoopsClient $client) public function list(?int $per_page = null, ?string $cursor = null): mixed { - $query = []; - if ($per_page !== null) { - $query['perPage'] = $per_page; - } - if ($cursor) { - $query['cursor'] = $cursor; - } - return $this->client->query(method: 'GET', endpoint: 'v1/campaign-groups', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) ]); } diff --git a/src/Campaigns.php b/src/Campaigns.php index 720f487..eeb6755 100644 --- a/src/Campaigns.php +++ b/src/Campaigns.php @@ -15,16 +15,11 @@ public function __construct(LoopsClient $client) public function list(?int $per_page = null, ?string $cursor = null): mixed { - $query = []; - if ($per_page !== null) { - $query['perPage'] = $per_page; - } - if ($cursor) { - $query['cursor'] = $cursor; - } - return $this->client->query(method: 'GET', endpoint: 'v1/campaigns', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) ]); } @@ -91,6 +86,9 @@ public function update( if ($scheduling !== null) { $payload['scheduling'] = $scheduling; } + if ($payload === []) { + throw new \InvalidArgumentException(message: 'At least one field must be provided.'); + } return $this->client->query(method: 'POST', endpoint: 'v1/campaigns/' . $campaign_id, options: [ 'json' => $payload diff --git a/src/Components.php b/src/Components.php index 9f38afa..c1af4b6 100644 --- a/src/Components.php +++ b/src/Components.php @@ -15,16 +15,11 @@ public function __construct(LoopsClient $client) public function list(?int $per_page = null, ?string $cursor = null): mixed { - $query = []; - if ($per_page !== null) { - $query['perPage'] = $per_page; - } - if ($cursor) { - $query['cursor'] = $cursor; - } - return $this->client->query(method: 'GET', endpoint: 'v1/components', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) ]); } diff --git a/src/ContactProperties.php b/src/ContactProperties.php index eb45062..9e2ca35 100644 --- a/src/ContactProperties.php +++ b/src/ContactProperties.php @@ -26,12 +26,10 @@ public function create(string $name, string $type = 'string' | 'number' | 'boole } public function list(?string $list = null): mixed { - $query = []; - if ($list) { - $query['list'] = $list; - } return $this->client->query(method: 'GET', endpoint: 'v1/contacts/properties', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'list' => $list, + ]) ]); } } \ No newline at end of file diff --git a/src/Contacts.php b/src/Contacts.php index ee04dff..9df3971 100644 --- a/src/Contacts.php +++ b/src/Contacts.php @@ -51,14 +51,12 @@ public function find(?string $email = null, ?string $user_id = null): mixed if (!$email && !$user_id) { throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.'); } - $query = []; - if ($email) - $query['email'] = $email; - if ($user_id) - $query['userId'] = $user_id; return $this->client->query(method: 'GET', endpoint: 'v1/contacts/find', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'email' => $email, + 'userId' => $user_id, + ]) ]); } @@ -71,14 +69,11 @@ public function delete(?string $email = null, ?string $user_id = null): mixed throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.'); } - $payload = []; - if ($email) - $payload['email'] = $email; - if ($user_id) - $payload['userId'] = $user_id; - return $this->client->query(method: 'POST', endpoint: 'v1/contacts/delete', options: [ - 'json' => $payload + 'json' => Util::omitNull([ + 'email' => $email, + 'userId' => $user_id, + ]) ]); } @@ -91,14 +86,11 @@ public function checkSuppression(?string $email = null, ?string $user_id = null) throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.'); } - $query = []; - if ($email) - $query['email'] = $email; - if ($user_id) - $query['userId'] = $user_id; - return $this->client->query(method: 'GET', endpoint: 'v1/contacts/suppression', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'email' => $email, + 'userId' => $user_id, + ]) ]); } @@ -111,14 +103,11 @@ public function removeSuppression(?string $email = null, ?string $user_id = null throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.'); } - $query = []; - if ($email) - $query['email'] = $email; - if ($user_id) - $query['userId'] = $user_id; - return $this->client->query(method: 'DELETE', endpoint: 'v1/contacts/suppression', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'email' => $email, + 'userId' => $user_id, + ]) ]); } -} \ No newline at end of file +} diff --git a/src/Themes.php b/src/Themes.php index 8c7d6e7..ad0ba2b 100644 --- a/src/Themes.php +++ b/src/Themes.php @@ -15,16 +15,11 @@ public function __construct(LoopsClient $client) public function list(?int $per_page = null, ?string $cursor = null): mixed { - $query = []; - if ($per_page !== null) { - $query['perPage'] = $per_page; - } - if ($cursor) { - $query['cursor'] = $cursor; - } - return $this->client->query(method: 'GET', endpoint: 'v1/themes', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) ]); } diff --git a/src/Transactional.php b/src/Transactional.php index 37a75ab..3df84a1 100644 --- a/src/Transactional.php +++ b/src/Transactional.php @@ -37,16 +37,11 @@ public function send( public function list(?int $per_page = null, ?string $cursor = null): mixed { - $query = []; - if ($per_page !== null) { - $query['perPage'] = $per_page; - } - if ($cursor) { - $query['cursor'] = $cursor; - } - return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) ]); } @@ -97,4 +92,4 @@ public function publish(string $transactional_id): mixed { return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails/' . $transactional_id . '/publish'); } -} \ No newline at end of file +} diff --git a/src/TransactionalGroups.php b/src/TransactionalGroups.php index 9a1aec7..ca7eb39 100644 --- a/src/TransactionalGroups.php +++ b/src/TransactionalGroups.php @@ -15,16 +15,11 @@ public function __construct(LoopsClient $client) public function list(?int $per_page = null, ?string $cursor = null): mixed { - $query = []; - if ($per_page !== null) { - $query['perPage'] = $per_page; - } - if ($cursor) { - $query['cursor'] = $cursor; - } - return $this->client->query(method: 'GET', endpoint: 'v1/transactional-groups', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) ]); } diff --git a/src/Util.php b/src/Util.php new file mode 100644 index 0000000..b22106d --- /dev/null +++ b/src/Util.php @@ -0,0 +1,18 @@ + $params + * @return array + */ + public static function omitNull(array $params): array + { + return array_filter($params, fn ($value) => $value !== null); + } +} diff --git a/src/Workflows.php b/src/Workflows.php index 5337a09..6a9abf6 100644 --- a/src/Workflows.php +++ b/src/Workflows.php @@ -15,16 +15,11 @@ public function __construct(LoopsClient $client) public function list(?int $per_page = null, ?string $cursor = null): mixed { - $query = []; - if ($per_page !== null) { - $query['perPage'] = $per_page; - } - if ($cursor) { - $query['cursor'] = $cursor; - } - return $this->client->query(method: 'GET', endpoint: 'v1/workflows', options: [ - 'query' => $query + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) ]); } diff --git a/tests/UtilTest.php b/tests/UtilTest.php new file mode 100644 index 0000000..618964c --- /dev/null +++ b/tests/UtilTest.php @@ -0,0 +1,39 @@ + 'Test', + 'campaignGroupId' => null, + 'mailingListId' => 'list_123', + 'audienceSegmentId' => null, + ]); + + $this->assertEquals([ + 'name' => 'Test', + 'mailingListId' => 'list_123', + ], $result); + } + + public function testOmitNullKeepsFalsyNonNullValues(): void + { + $result = Util::omitNull([ + 'addToAudience' => false, + 'dataVariables' => [], + 'count' => 0, + ]); + + $this->assertEquals([ + 'addToAudience' => false, + 'dataVariables' => [], + 'count' => 0, + ], $result); + } +} From ff03b98742fccf6cbeb4d9bacac540b893d10a1a Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 24 Jun 2026 16:43:36 +0300 Subject: [PATCH 09/13] Better handling of null email/userId --- src/Contacts.php | 15 ++++++++++----- src/Events.php | 23 ++++++++++++++--------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/Contacts.php b/src/Contacts.php index 9df3971..0113974 100644 --- a/src/Contacts.php +++ b/src/Contacts.php @@ -31,11 +31,16 @@ public function update(?string $email = null, ?string $user_id = null, ?array $p if (!$email && !$user_id) { throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.'); } - $payload = [ - 'email' => $email, - 'userId' => $user_id, - 'mailingLists' => $mailing_lists - ]; + $payload = []; + if ($email !== null) { + $payload['email'] = $email; + } + if ($user_id !== null) { + $payload['userId'] = $user_id; + } + if ($mailing_lists !== []) { + $payload['mailingLists'] = $mailing_lists; + } $payload = array_merge($payload, $properties); return $this->client->query(method: 'PUT', endpoint: 'v1/contacts/update', options: [ diff --git a/src/Events.php b/src/Events.php index df99b2a..2a18000 100644 --- a/src/Events.php +++ b/src/Events.php @@ -25,14 +25,19 @@ public function send( throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.'); } - $payload = [ - 'eventName' => $event_name, - 'email' => $email, - 'userId' => $user_id, - 'eventProperties' => $event_properties, - 'mailingLists' => $mailing_lists, - ]; - + $payload = ['eventName' => $event_name]; + if ($email !== null) { + $payload['email'] = $email; + } + if ($user_id !== null) { + $payload['userId'] = $user_id; + } + if ($event_properties !== []) { + $payload['eventProperties'] = $event_properties; + } + if ($mailing_lists !== []) { + $payload['mailingLists'] = $mailing_lists; + } $payload = array_merge($payload, $contact_properties); return $this->client->query(method: 'POST', endpoint: 'v1/events/send', options: [ @@ -40,4 +45,4 @@ public function send( 'headers' => $headers ]); } -} \ No newline at end of file +} From 45b072ea313d8a4d2dc4a711150bce1c6468fa87 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 24 Jun 2026 17:27:38 +0300 Subject: [PATCH 10/13] Use different Guzzle client for S3 uploads --- src/LoopsClient.php | 21 +++++++++++++++++++++ src/Uploads.php | 2 +- tests/UploadsTest.php | 8 ++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/LoopsClient.php b/src/LoopsClient.php index eac1d64..b86fd18 100644 --- a/src/LoopsClient.php +++ b/src/LoopsClient.php @@ -7,6 +7,7 @@ class LoopsClient private const BASE_URI = 'https://app.loops.so/api/'; private \GuzzleHttp\Client $httpClient; + private \GuzzleHttp\Client $uploadHttpClient; public ApiKey $apiKey; public Contacts $contacts; public Events $events; @@ -35,6 +36,10 @@ public function __construct(string $api_key) 'http_errors' => false ]); + $this->uploadHttpClient = new \GuzzleHttp\Client(config: [ + 'http_errors' => false, + ]); + $this->apiKey = new ApiKey(client: $this); $this->contacts = new Contacts(client: $this); $this->events = new Events(client: $this); @@ -69,6 +74,22 @@ public function getHttpClient(): \GuzzleHttp\Client return $this->httpClient; } + /** + * Sets the HTTP client used for pre-signed upload URLs. Primarily for testing. + * + * @param \GuzzleHttp\Client $client + * @return void + */ + public function setUploadHttpClient(\GuzzleHttp\Client $client): void + { + $this->uploadHttpClient = $client; + } + + public function getUploadHttpClient(): \GuzzleHttp\Client + { + return $this->uploadHttpClient; + } + /** * Performs an HTTP request to the Loops API * diff --git a/src/Uploads.php b/src/Uploads.php index ad2723f..45c4551 100644 --- a/src/Uploads.php +++ b/src/Uploads.php @@ -59,7 +59,7 @@ public function upload(string $path): mixed ] ]); - $response = $this->client->getHttpClient()->put($created['presignedUrl'], [ + $response = $this->client->getUploadHttpClient()->put($created['presignedUrl'], [ 'headers' => [ 'Content-Type' => $content_type, 'Content-Length' => (string) $content_length, diff --git a/tests/UploadsTest.php b/tests/UploadsTest.php index fdbeb2e..e2bd52a 100644 --- a/tests/UploadsTest.php +++ b/tests/UploadsTest.php @@ -10,13 +10,16 @@ class UploadsTest extends TestCase { private LoopsClient $client; private \GuzzleHttp\Client $mockHttpClient; + private \GuzzleHttp\Client $mockUploadHttpClient; private string $imagePath; protected function setUp(): void { $this->mockHttpClient = $this->createMock(\GuzzleHttp\Client::class); + $this->mockUploadHttpClient = $this->createMock(\GuzzleHttp\Client::class); $this->client = new LoopsClient('test_api_key'); $this->client->setHttpClient($this->mockHttpClient); + $this->client->setUploadHttpClient($this->mockUploadHttpClient); $this->imagePath = sys_get_temp_dir() . '/loops_upload_test.png'; file_put_contents( @@ -69,13 +72,14 @@ public function testUpload(): void $this->fail('Unexpected POST endpoint: ' . $endpoint); }); - $this->mockHttpClient + $this->mockUploadHttpClient ->expects($this->once()) ->method('put') ->with( $presignedUrl, $this->callback(function ($options) use ($fileContents, $contentLength) { - return $options['headers']['Content-Type'] === 'image/png' + return !isset($options['headers']['Authorization']) + && $options['headers']['Content-Type'] === 'image/png' && $options['headers']['Content-Length'] === (string) $contentLength && $options['body'] === $fileContents; }) From 96df06a163cfc2ec4bbace58e0c27d352e3c856e Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 24 Jun 2026 17:37:28 +0300 Subject: [PATCH 11/13] Use omitNull in more places --- src/CampaignGroups.php | 21 ++++++++++----------- src/Contacts.php | 29 +++++++++++++---------------- src/Events.php | 23 +++++++++-------------- src/Transactional.php | 21 ++++++++------------- src/TransactionalGroups.php | 22 ++++++++++------------ 5 files changed, 50 insertions(+), 66 deletions(-) diff --git a/src/CampaignGroups.php b/src/CampaignGroups.php index 02fd83a..d6f4766 100644 --- a/src/CampaignGroups.php +++ b/src/CampaignGroups.php @@ -25,13 +25,12 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed public function create(string $name, ?string $description = null): mixed { - $payload = ['name' => $name]; - if ($description !== null) { - $payload['description'] = $description; - } return $this->client->query(method: 'POST', endpoint: 'v1/campaign-groups', options: [ - 'json' => $payload + 'json' => Util::omitNull([ + 'name' => $name, + 'description' => $description, + ]) ]); } @@ -42,12 +41,12 @@ public function get(string $campaign_group_id): mixed public function update(string $campaign_group_id, ?string $name = null, ?string $description = null): mixed { - $payload = []; - if ($name !== null) { - $payload['name'] = $name; - } - if ($description !== null) { - $payload['description'] = $description; + $payload = Util::omitNull([ + 'name' => $name, + 'description' => $description, + ]); + if ($payload === []) { + throw new \InvalidArgumentException(message: 'At least one field must be provided.'); } return $this->client->query(method: 'POST', endpoint: 'v1/campaign-groups/' . $campaign_group_id, options: [ diff --git a/src/Contacts.php b/src/Contacts.php index 0113974..b7e37b1 100644 --- a/src/Contacts.php +++ b/src/Contacts.php @@ -15,11 +15,13 @@ public function __construct(LoopsClient $client) public function create(string $email, ?array $properties = [], ?array $mailing_lists = []): mixed { - $payload = [ - 'email' => $email, - 'mailingLists' => $mailing_lists - ]; - $payload = array_merge($payload, $properties); + $payload = array_merge( + [ + 'email' => $email, + 'mailingLists' => $mailing_lists + ], + $properties + ); return $this->client->query(method: 'POST', endpoint: 'v1/contacts/create', options: [ 'json' => $payload @@ -31,17 +33,12 @@ public function update(?string $email = null, ?string $user_id = null, ?array $p if (!$email && !$user_id) { throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.'); } - $payload = []; - if ($email !== null) { - $payload['email'] = $email; - } - if ($user_id !== null) { - $payload['userId'] = $user_id; - } - if ($mailing_lists !== []) { - $payload['mailingLists'] = $mailing_lists; - } - $payload = array_merge($payload, $properties); + + $payload = array_merge( + Util::omitNull(['email' => $email, 'userId' => $user_id]), + ['mailingLists' => $mailing_lists], + $properties + ); return $this->client->query(method: 'PUT', endpoint: 'v1/contacts/update', options: [ 'json' => $payload diff --git a/src/Events.php b/src/Events.php index 2a18000..be757e8 100644 --- a/src/Events.php +++ b/src/Events.php @@ -25,20 +25,15 @@ public function send( throw new \InvalidArgumentException(message: 'You must provide an email or user_id value.'); } - $payload = ['eventName' => $event_name]; - if ($email !== null) { - $payload['email'] = $email; - } - if ($user_id !== null) { - $payload['userId'] = $user_id; - } - if ($event_properties !== []) { - $payload['eventProperties'] = $event_properties; - } - if ($mailing_lists !== []) { - $payload['mailingLists'] = $mailing_lists; - } - $payload = array_merge($payload, $contact_properties); + $payload = array_merge( + [ + 'eventName' => $event_name, + 'eventProperties' => $event_properties, + 'mailingLists' => $mailing_lists, + ], + Util::omitNull(['email' => $email, 'userId' => $user_id]), + $contact_properties + ); return $this->client->query(method: 'POST', endpoint: 'v1/events/send', options: [ 'json' => $payload, diff --git a/src/Transactional.php b/src/Transactional.php index 3df84a1..800f08d 100644 --- a/src/Transactional.php +++ b/src/Transactional.php @@ -47,13 +47,11 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed public function create(string $name, ?string $transactional_group_id = null): mixed { - $payload = ['name' => $name]; - if ($transactional_group_id !== null) { - $payload['transactionalGroupId'] = $transactional_group_id; - } - return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails', options: [ - 'json' => $payload + 'json' => Util::omitNull([ + 'name' => $name, + 'transactionalGroupId' => $transactional_group_id, + ]) ]); } @@ -67,13 +65,10 @@ public function update( ?string $name = null, ?string $transactional_group_id = null ): mixed { - $payload = []; - if ($name !== null) { - $payload['name'] = $name; - } - if ($transactional_group_id !== null) { - $payload['transactionalGroupId'] = $transactional_group_id; - } + $payload = Util::omitNull([ + 'name' => $name, + 'transactionalGroupId' => $transactional_group_id, + ]); if ($payload === []) { throw new \InvalidArgumentException(message: 'At least one field must be provided.'); } diff --git a/src/TransactionalGroups.php b/src/TransactionalGroups.php index ca7eb39..ba8e32e 100644 --- a/src/TransactionalGroups.php +++ b/src/TransactionalGroups.php @@ -25,13 +25,11 @@ public function list(?int $per_page = null, ?string $cursor = null): mixed public function create(string $name, ?string $description = null): mixed { - $payload = ['name' => $name]; - if ($description !== null) { - $payload['description'] = $description; - } - return $this->client->query(method: 'POST', endpoint: 'v1/transactional-groups', options: [ - 'json' => $payload + 'json' => Util::omitNull([ + 'name' => $name, + 'description' => $description, + ]) ]); } @@ -42,12 +40,12 @@ public function get(string $transactional_group_id): mixed public function update(string $transactional_group_id, ?string $name = null, ?string $description = null): mixed { - $payload = []; - if ($name !== null) { - $payload['name'] = $name; - } - if ($description !== null) { - $payload['description'] = $description; + $payload = Util::omitNull([ + 'name' => $name, + 'description' => $description, + ]); + if ($payload === []) { + throw new \InvalidArgumentException(message: 'At least one field must be provided.'); } return $this->client->query(method: 'POST', endpoint: 'v1/transactional-groups/' . $transactional_group_id, options: [ From 2810ce41b1e63d40037ad490fe796c7a5bcfaf7e Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 26 Jun 2026 16:04:49 +0300 Subject: [PATCH 12/13] Params for updating EmailMessage; fix null values --- .github/workflows/php-tests.yml | 2 +- README.md | 66 +++++++++++++++++++++------- composer.json | 2 +- src/Campaigns.php | 68 +++++++++++------------------ src/Core.php | 8 ++++ src/EmailMessages.php | 51 ++++++++++++++++------ src/Events.php | 5 ++- src/Omit.php | 8 ++++ src/Util.php | 34 ++++++++++----- tests/CampaignsTest.php | 76 +++++++++++++++++++++++++++++++++ tests/EmailMessagesTest.php | 20 +++++---- tests/UtilTest.php | 22 ++++++++++ 12 files changed, 268 insertions(+), 94 deletions(-) create mode 100644 src/Core.php create mode 100644 src/Omit.php diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml index 5325b3b..db07f91 100644 --- a/.github/workflows/php-tests.yml +++ b/.github/workflows/php-tests.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: ["8.2", "8.3", "8.4"] + php-version: ["8.1", "8.2", "8.3", "8.4"] fail-fast: false steps: diff --git a/README.md b/README.md index f966f64..5f80839 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Install the Loops package [using Composer](https://packagist.org/packages/loops- composer require loops-so/loops ``` +Requires PHP 8.1. + ## Usage You will need a Loops API key to use the package. @@ -995,15 +997,18 @@ $result = $loops->transactional->get(transactional_id: 'clfq6dinn000yl70fgwwyp82 Update a transactional email. +At least one field alongside `transactional_id` must be provided. + [API Reference](https://loops.so/docs/api-reference/update-transactional-email) #### Parameters + | Name | Type | Required | Notes | | -------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------ | -| `$transactional_id` | string | Yes | The ID of the transactional email. | -| `$name` | string | No | The updated name. At least one of `$name` or `$transactional_group_id` must be provided. | -| `$transactional_group_id` | string | No | The ID of the group to move this transactional email to. | +| `$transactional_id` | string | Yes | The ID of the transactional email. | +| `$name` | string | No | The updated name. | +| `$transactional_group_id` | string | No | The ID of the group to move this transactional email to. | #### Example @@ -1286,6 +1291,8 @@ $result = $loops->campaigns->get(campaign_id: 'cln4o7p9q0110msw5ekjtmv78'); Update a draft campaign's name, group, audience, or scheduling. +At least one field alongside `campaign_id` must be provided. + [API Reference](https://loops.so/docs/api-reference/update-campaign) #### Parameters @@ -1295,12 +1302,11 @@ Update a draft campaign's name, group, audience, or scheduling. | `$campaign_id` | string | Yes | The ID of the campaign. | | `$name` | string | No | The updated name. | | `$campaign_group_id` | string | No | The ID of the group to move this campaign to. | -| `$mailing_list_id` | string | No | The ID of the mailing list to send to. | -| `$audience_segment_id` | string | No | The ID of an audience segment. Setting this clears any `audience_filter`. | -| `$audience_filter` | array | No | A tree of audience conditions. See the API reference for the filter schema. | | `$scheduling` | array | No | When the campaign should send. Use `['method' => 'now']` or `['method' => 'schedule', 'timestamp' => '...']`. | +| `$mailing_list_id` | string | No | The ID of the mailing list to send to. Pass `null` to clear. | +| `$audience_segment_id` | string | No | The ID of an audience segment. Setting this clears any `audience_filter`. Pass `null` to clear. | +| `$audience_filter` | array | No | A tree of audience conditions. See the API reference for the filter schema. Pass `null` to clear. | -At least one field must be provided. #### Example @@ -1309,6 +1315,12 @@ $result = $loops->campaigns->update( campaign_id: 'cln4o7p9q0110msw5ekjtmv78', name: 'Updated name' ); + +// Clear the mailing list audience target +$result = $loops->campaigns->update( + campaign_id: 'cln4o7p9q0110msw5ekjtmv78', + mailing_list_id: null +); ``` --- @@ -1379,6 +1391,8 @@ $result = $loops->campaignGroups->get(campaign_group_id: 'clq7r0s2t0176pvz8hnmpw Update a campaign group's name or description. +At least one field alongside `campaign_group_id` must be provided. + [API Reference](https://loops.so/docs/api-reference/update-campaign-group) #### Parameters @@ -1389,8 +1403,6 @@ Update a campaign group's name or description. | `$name` | string | No | Cannot be the reserved name "Unsorted". | | `$description` | string | No | | -At least one field must be provided. - #### Example ```php @@ -1530,7 +1542,9 @@ $result = $loops->emailMessages->get(email_message_id: 'cly8k3m0n0044jpx2bghepq4 ### emailMessages->update() -Update an email message's subject, preview text, sender, or LMX content. +Update an email message. + +At least one field alongside `email_message_id` must be provided. [API Reference](https://loops.so/docs/api-reference/update-email-message) @@ -1539,19 +1553,38 @@ Update an email message's subject, preview text, sender, or LMX content. | Name | Type | Required | Notes | | --------- | ----- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `$email_message_id` | string | Yes | The ID of the email message. | -| `$fields` | array | No | Fields to update. Use API field names: `expectedRevisionId`, `subject`, `previewText`, `fromName`, `fromEmail`, `replyToEmail`, `lmx`, `contactPropertiesFallbacks`, `eventPropertiesFallbacks`, `dataVariablesFallbacks`. Supply `expectedRevisionId` matching the current `contentRevisionId` to avoid 409 conflicts. For the `*Fallbacks` maps, a `null` value deletes an individual fallback entry. | +| `$expected_revision_id` | string | No | Supply a value matching the current `contentRevisionId` to avoid 409 conflicts. | +| `$subject` | string | No | The email subject line. | +| `$preview_text` | string | No | The email preview text. | +| `$from_name` | string | No | The sender name. | +| `$from_email` | string | No | The sender email address (the name before the `@`; your sending domain will be automatically appended). | +| `$reply_to_email` | string | No | The reply-to email address. | +| `$lmx` | string | No | The LMX content for the email message. | +| `$contact_properties_fallbacks` | array | No | Contact property fallback values. Pass `null` as a value to remove an individual fallback entry. | +| `$event_properties_fallbacks` | array | No | Event property fallback values. Pass `null` as a value to remove an individual fallback entry. | +| `$data_variables_fallbacks` | array | No | Data variable fallback values. Pass `null` as a value to remove an individual fallback entry. | + #### Example ```php $result = $loops->emailMessages->update( email_message_id: 'cly8k3m0n0044jpx2bghepq45', - fields: [ - 'expectedRevisionId' => 'clm9n4o6p0088lrz4dijslt67', - 'subject' => 'Updated subject', - 'lmx' => 'Hello' + expected_revision_id: 'clm9n4o6p0088lrz4dijslt67', + subject: 'Updated subject', + lmx: 'Hello' +); + +// Example with contact property fallbacks +$result = $loops->emailMessages->update( + email_message_id: 'cly8k3m0n0044jpx2bghepq45', + contact_properties_fallbacks: [ + 'firstName' => 'there', // If firstName is missing, use "there" + 'company' => 'your company', // If company is missing, use "your company" + 'planName' => null // null removes the fallback for "planName" ] ); + ``` --- @@ -1650,6 +1683,8 @@ $result = $loops->transactionalGroups->get(transactional_group_id: 'clv2w3x4y028 Update a transactional group's name or description. +At least one field alongside `transactional_group_id` must be provided. + [API Reference](https://loops.so/docs/api-reference/update-transactional-group) #### Parameters @@ -1660,7 +1695,6 @@ Update a transactional group's name or description. | `$name` | string | No | Cannot be the reserved name "Unsorted". | | `$description` | string | No | | -At least one field must be provided. #### Example diff --git a/composer.json b/composer.json index 2c3e1d9..5c143e7 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "type": "library", "homepage": "https://github.com/Loops-so/loops-php", "require": { - "php": "^8.0", + "php": "^8.1", "guzzlehttp/guzzle": "^7.9" }, "autoload": { diff --git a/src/Campaigns.php b/src/Campaigns.php index eeb6755..f59309e 100644 --- a/src/Campaigns.php +++ b/src/Campaigns.php @@ -31,22 +31,16 @@ public function create( ?array $audience_filter = null, ?array $scheduling = null ): mixed { - $payload = ['name' => $name]; - if ($campaign_group_id !== null) { - $payload['campaignGroupId'] = $campaign_group_id; - } - if ($mailing_list_id !== null) { - $payload['mailingListId'] = $mailing_list_id; - } - if ($audience_segment_id !== null) { - $payload['audienceSegmentId'] = $audience_segment_id; - } - if ($audience_filter !== null) { - $payload['audienceFilter'] = $audience_filter; - } - if ($scheduling !== null) { - $payload['scheduling'] = $scheduling; - } + $payload = array_merge( + ['name' => $name], + Util::omitNull([ + 'campaignGroupId' => $campaign_group_id, + 'mailingListId' => $mailing_list_id, + 'audienceSegmentId' => $audience_segment_id, + 'audienceFilter' => $audience_filter, + 'scheduling' => $scheduling, + ]) + ); return $this->client->query(method: 'POST', endpoint: 'v1/campaigns', options: [ 'json' => $payload @@ -60,35 +54,25 @@ public function get(string $campaign_id): mixed public function update( string $campaign_id, - ?string $name = null, + string $name, ?string $campaign_group_id = null, - ?string $mailing_list_id = null, - ?string $audience_segment_id = null, - ?array $audience_filter = null, + mixed $mailing_list_id = Core::UNSET, + mixed $audience_segment_id = Core::UNSET, + mixed $audience_filter = Core::UNSET, ?array $scheduling = null ): mixed { - $payload = []; - if ($name !== null) { - $payload['name'] = $name; - } - if ($campaign_group_id !== null) { - $payload['campaignGroupId'] = $campaign_group_id; - } - if ($mailing_list_id !== null) { - $payload['mailingListId'] = $mailing_list_id; - } - if ($audience_segment_id !== null) { - $payload['audienceSegmentId'] = $audience_segment_id; - } - if ($audience_filter !== null) { - $payload['audienceFilter'] = $audience_filter; - } - if ($scheduling !== null) { - $payload['scheduling'] = $scheduling; - } - if ($payload === []) { - throw new \InvalidArgumentException(message: 'At least one field must be provided.'); - } + $payload = array_merge( + ['name' => $name], + Util::omitNull([ + 'campaignGroupId' => $campaign_group_id, + 'scheduling' => $scheduling, + ]), + Util::omitUnset([ + 'mailingListId' => $mailing_list_id, + 'audienceSegmentId' => $audience_segment_id, + 'audienceFilter' => $audience_filter, + ]), + ); return $this->client->query(method: 'POST', endpoint: 'v1/campaigns/' . $campaign_id, options: [ 'json' => $payload diff --git a/src/Core.php b/src/Core.php new file mode 100644 index 0000000..5086fed --- /dev/null +++ b/src/Core.php @@ -0,0 +1,8 @@ +client->query(method: 'GET', endpoint: 'v1/email-messages/' . $email_message_id); } - public function update(string $email_message_id, array $fields = []): mixed - { + public function update( + string $email_message_id, + ?string $expected_revision_id = null, + ?string $subject = null, + ?string $preview_text = null, + ?string $from_name = null, + ?string $from_email = null, + ?string $reply_to_email = null, + ?string $lmx = null, + ?array $contact_properties_fallbacks = null, + ?array $event_properties_fallbacks = null, + ?array $data_variables_fallbacks = null + ): mixed { + $payload = Util::omitNull([ + 'expectedRevisionId' => $expected_revision_id, + 'subject' => $subject, + 'previewText' => $preview_text, + 'fromName' => $from_name, + 'fromEmail' => $from_email, + 'replyToEmail' => $reply_to_email, + 'lmx' => $lmx, + 'contactPropertiesFallbacks' => $contact_properties_fallbacks, + 'eventPropertiesFallbacks' => $event_properties_fallbacks, + 'dataVariablesFallbacks' => $data_variables_fallbacks, + ]); + if ($payload === []) { + throw new \InvalidArgumentException(message: 'At least one field must be provided.'); + } + return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $email_message_id, options: [ - 'json' => $fields + 'json' => $payload ]); } @@ -32,16 +59,14 @@ public function preview( ?array $event_properties = null, ?array $data_variables = null ): mixed { - $payload = ['emails' => $emails]; - if ($contact_properties !== null) { - $payload['contactProperties'] = $contact_properties; - } - if ($event_properties !== null) { - $payload['eventProperties'] = $event_properties; - } - if ($data_variables !== null) { - $payload['dataVariables'] = $data_variables; - } + $payload = array_merge( + ['emails' => $emails], + Util::omitNull([ + 'contactProperties' => $contact_properties, + 'eventProperties' => $event_properties, + 'dataVariables' => $data_variables, + ]) + ); return $this->client->query(method: 'POST', endpoint: 'v1/email-messages/' . $email_message_id . '/preview', options: [ 'json' => $payload diff --git a/src/Events.php b/src/Events.php index be757e8..2dc069b 100644 --- a/src/Events.php +++ b/src/Events.php @@ -31,7 +31,10 @@ public function send( 'eventProperties' => $event_properties, 'mailingLists' => $mailing_lists, ], - Util::omitNull(['email' => $email, 'userId' => $user_id]), + Util::omitNull([ + 'email' => $email, + 'userId' => $user_id, + ]), $contact_properties ); diff --git a/src/Omit.php b/src/Omit.php new file mode 100644 index 0000000..5d3466d --- /dev/null +++ b/src/Omit.php @@ -0,0 +1,8 @@ + $params - * @return array - */ - public static function omitNull(array $params): array - { - return array_filter($params, fn ($value) => $value !== null); - } + /** + * Returns a copy of the array with null values removed. + * Use for query parameters where null means "not provided". + * + * @param array $params + * @return array + */ + public static function omitNull(array $params): array + { + return array_filter($params, fn ($value) => $value !== null); + } + + /** + * Returns a copy of the array with unset sentinel values removed. + * Use for patch parameters where null means "clear" and UNSET means "not provided". + * + * @param array $params + * @return array + */ + public static function omitUnset(array $params): array + { + return array_filter($params, fn ($value) => $value !== Core::UNSET); + } } diff --git a/tests/CampaignsTest.php b/tests/CampaignsTest.php index 39ee98d..b96fdb0 100644 --- a/tests/CampaignsTest.php +++ b/tests/CampaignsTest.php @@ -127,4 +127,80 @@ public function testUpdateCampaign(): void $this->assertEquals('Updated name', $result['name']); } + + public function testUpdateCampaignClearsMailingList(): void + { + $campaignId = 'camp_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/campaigns/' . $campaignId, + $this->callback(function ($options) { + return $options['json'] === [ + 'name' => 'Spring announcement', + 'mailingListId' => null, + ]; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'success' => true, + 'campaignId' => $campaignId, + 'name' => 'Spring announcement', + 'status' => 'Draft', + 'createdAt' => '2025-01-01T00:00:00.000Z', + 'updatedAt' => '2025-01-02T00:00:00.000Z', + 'emailMessageId' => 'msg_123' + ]) + )); + + $result = $this->client->campaigns->update( + campaign_id: $campaignId, + name: 'Spring announcement', + mailing_list_id: null + ); + + $this->assertTrue($result['success']); + } + + public function testUpdateCampaignClearsAudienceFilter(): void + { + $campaignId = 'camp_123'; + + $this->mockHttpClient + ->expects($this->once()) + ->method('post') + ->with( + 'v1/campaigns/' . $campaignId, + $this->callback(function ($options) { + return $options['json'] === [ + 'name' => 'Spring announcement', + 'audienceFilter' => null, + ]; + }) + ) + ->willReturn(new Response( + status: 200, + body: json_encode([ + 'success' => true, + 'campaignId' => $campaignId, + 'name' => 'Spring announcement', + 'status' => 'Draft', + 'createdAt' => '2025-01-01T00:00:00.000Z', + 'updatedAt' => '2025-01-02T00:00:00.000Z', + 'emailMessageId' => 'msg_123' + ]) + )); + + $result = $this->client->campaigns->update( + campaign_id: $campaignId, + name: 'Spring announcement', + audience_filter: null + ); + + $this->assertTrue($result['success']); + } } diff --git a/tests/EmailMessagesTest.php b/tests/EmailMessagesTest.php index 4b8e5ed..2180711 100644 --- a/tests/EmailMessagesTest.php +++ b/tests/EmailMessagesTest.php @@ -51,19 +51,19 @@ public function testFindEmailMessage(): void public function testUpdateEmailMessage(): void { $emailMessageId = 'msg_123'; - $fields = [ - 'expectedRevisionId' => 'rev_123', - 'subject' => 'Updated subject', - 'lmx' => 'Hello' - ]; + $lmx = 'Hello'; $this->mockHttpClient ->expects($this->once()) ->method('post') ->with( 'v1/email-messages/' . $emailMessageId, - $this->callback(function ($options) use ($fields) { - return $options['json'] === $fields; + $this->callback(function ($options) use ($lmx) { + return $options['json'] === [ + 'expectedRevisionId' => 'rev_123', + 'subject' => 'Updated subject', + 'lmx' => $lmx, + ]; }) ) ->willReturn(new Response( @@ -77,7 +77,7 @@ public function testUpdateEmailMessage(): void 'fromName' => 'Loops', 'fromEmail' => 'hello', 'replyToEmail' => '', - 'lmx' => $fields['lmx'], + 'lmx' => $lmx, 'contentRevisionId' => 'rev_456', 'updatedAt' => '2025-01-02T00:00:00.000Z' ]) @@ -85,7 +85,9 @@ public function testUpdateEmailMessage(): void $result = $this->client->emailMessages->update( email_message_id: $emailMessageId, - fields: $fields + expected_revision_id: 'rev_123', + subject: 'Updated subject', + lmx: $lmx ); $this->assertEquals('Updated subject', $result['subject']); diff --git a/tests/UtilTest.php b/tests/UtilTest.php index 618964c..6e36b76 100644 --- a/tests/UtilTest.php +++ b/tests/UtilTest.php @@ -2,6 +2,7 @@ namespace Tests; +use Loops\Core; use Loops\Util; use PHPUnit\Framework\TestCase; @@ -36,4 +37,25 @@ public function testOmitNullKeepsFalsyNonNullValues(): void 'count' => 0, ], $result); } + + public function testOmitUnsetRemovesUnsetValues(): void + { + $result = Util::omitUnset([ + 'name' => 'Updated', + 'mailingListId' => Core::UNSET, + 'audienceFilter' => Core::UNSET, + ]); + + $this->assertEquals(['name' => 'Updated'], $result); + } + + public function testOmitUnsetKeepsExplicitNull(): void + { + $result = Util::omitUnset([ + 'mailingListId' => null, + 'audienceFilter' => Core::UNSET, + ]); + + $this->assertEquals(['mailingListId' => null], $result); + } } From e967176ef02c0925da2c67e0640680b7040d4765 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 26 Jun 2026 16:08:24 +0300 Subject: [PATCH 13/13] Update CHANGELOG.md --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e801204..5443634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ -## v3.1.0 - Jun 24, 2026 +## v3.1.0 - Jun 26, 2026 Added support for new content endpoints: +- Breaking change: `emailMessages->update()` now has named parameters. - `uploads->upload()` for uploading images. - `transactional->create()`, `transactional->get()`, `transactional->update()`, `transactional->ensureDraft()`, and `transactional->publish()` for managing transactional emails. - `audienceSegments->list()` and `audienceSegments->get()` for audience segments. @@ -10,6 +11,7 @@ Added support for new content endpoints: - `transactionalGroups->list()`, `transactionalGroups->create()`, `transactionalGroups->get()`, and `transactionalGroups->update()` for transactional groups. - `emailMessages->preview()` for sending test email previews. - Extended `campaigns->create()` and `campaigns->update()` with audience, group, and scheduling fields. +- Requires PHP 8.1. ## v3.0.0 - May 19, 2026