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/CHANGELOG.md b/CHANGELOG.md index 61028a4..5443634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## 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. +- `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. +- Requires PHP 8.1. + ## 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..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. @@ -108,6 +110,11 @@ 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) - [dedicatedSendingIps->list()](#dedicatedsendingips-list) - [themes->list()](#themes-list) - [themes->get()](#themes-get) @@ -117,8 +124,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) --- @@ -196,7 +218,7 @@ $result = $loops->contacts->create( ```json { "success": true, - "id": "id_of_contact" + "id": "cll6b3i8901a9jx0oyktl2m4u" } ``` @@ -261,7 +283,7 @@ $result = $loops->contacts->update( ```json { "success": true, - "id": "id_of_contact" + "id": "cll6b3i8901a9jx0oyktl2m4u" } ``` @@ -762,7 +784,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. | @@ -799,7 +821,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')) ] ] @@ -843,7 +865,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 +894,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": "cly8k3m0n0044jpx2bghepq45", + "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": "cla3r8s9t0422ua56iqovab01", + "publishedEmailMessageId": "clb4s9t0u0533vb67jrpwbc12", + "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": "clc5t0u1v0644wc78ksqxcd23", + "publishedEmailMessageId": null, + "createdAt": "2024-05-14T19:02:52.000Z", + "updatedAt": "2024-05-14T19:02:52.000Z", "dataVariables": [ "firstName", "lastName", @@ -903,6 +937,161 @@ $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. | +| `$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 + +```php +$result = $loops->transactional->create(name: 'Welcome email'); +``` + +#### Response + +```json +{ + "id": "clfq6dinn000yl70fgwwyp82l", + "name": "Welcome email", + "draftEmailMessageId": "cly8k3m0n0044jpx2bghepq45", + "draftEmailMessageContentRevisionId": "clm9n4o6p0088lrz4dijslt67", + "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: 'clfq6dinn000yl70fgwwyp82l'); +``` + +--- + +### transactional->update() + +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. | +| `$transactional_group_id` | string | No | The ID of the group to move this transactional email to. | + +#### Example + +```php +$result = $loops->transactional->update( + transactional_id: 'clfq6dinn000yl70fgwwyp82l', + 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: 'clfq6dinn000yl70fgwwyp82l'); +``` + +--- + +### 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: 'clfq6dinn000yl70fgwwyp82l'); +``` + +--- + +### uploads->upload() + +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 | +| ------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------- | +| `$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->upload(path: '/path/to/image.png'); + +$imageUrl = $result['finalUrl']; +``` + +#### Response + +```json +{ + "emailAssetId": "clu1v4w6x0254tz42lrcwat45", + "finalUrl": "https://cdn.example.com/image.png" +} +``` + +--- + ### dedicatedSendingIps->list() Get a list of Loops' dedicated sending IP addresses. @@ -945,7 +1134,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'); ``` --- @@ -965,7 +1154,7 @@ Get a single email theme by ID. #### Example ```php -$result = $loops->themes->get(theme_id: 'theme_abc123'); +$result = $loops->themes->get(theme_id: 'clo5p8q0r0132ntx6flkunw89'); ``` --- @@ -1006,7 +1195,7 @@ Get a single email component by ID. #### Example ```php -$result = $loops->components->get(component_id: 'component_abc123'); +$result = $loops->components->get(component_id: 'clp6q9r1s0154ouy7gmlovx90'); ``` --- @@ -1040,14 +1229,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 @@ -1055,13 +1255,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" } ``` @@ -1075,38 +1275,247 @@ Get a single campaign by ID. #### Parameters -| Name | Type | Required | Notes | -| -------------- | ------ | -------- | --------------------- | +| Name | Type | Required | Notes | +| -------------- | ------ | -------- | ----------------------- | | `$campaign_id` | string | Yes | The ID of the campaign. | #### Example ```php -$result = $loops->campaigns->get(campaign_id: 'camp_123'); +$result = $loops->campaigns->get(campaign_id: 'cln4o7p9q0110msw5ekjtmv78'); ``` --- ### campaigns->update() -Update a draft campaign's name. +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 -| Name | Type | Required | Notes | -| -------------- | ------ | -------- | --------------------- | -| `$campaign_id` | string | Yes | The ID of the campaign. | -| `$name` | string | Yes | The updated name. | +| Name | Type | Required | Notes | +| ----------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- | +| `$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. | +| `$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. | + #### Example ```php $result = $loops->campaigns->update( - campaign_id: 'camp_123', + campaign_id: 'cln4o7p9q0110msw5ekjtmv78', name: 'Updated name' ); + +// Clear the mailing list audience target +$result = $loops->campaigns->update( + campaign_id: 'cln4o7p9q0110msw5ekjtmv78', + mailing_list_id: null +); +``` + +--- + +### 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 | +| ----- | ------ | -------- | ---------------------------- | +| `$campaign_group_id` | string | Yes | The ID of the campaign group. | + +#### Example + +```php +$result = $loops->campaignGroups->get(campaign_group_id: 'clq7r0s2t0176pvz8hnmpwy01'); +``` + +--- + +### campaignGroups->update() + +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 + +| Name | Type | Required | Notes | +| --------------- | ------ | -------- | --------------------------------------- | +| `$campaign_group_id` | string | Yes | The ID of the campaign group. | +| `$name` | string | No | Cannot be the reserved name "Unsorted". | +| `$description` | string | No | | + +#### Example + +```php +$result = $loops->campaignGroups->update( + campaign_group_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 | +| ----- | ------ | -------- | ------------------------------- | +| `$audience_segment_id` | string | Yes | The ID of the audience segment. | + +#### Example + +```php +$result = $loops->audienceSegments->get(audience_segment_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 | +| ----- | ------ | -------- | ---------------------- | +| `$workflow_id` | string | Yes | The ID of the workflow. | + +#### Example + +```php +$result = $loops->workflows->get(workflow_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' +); ``` --- @@ -1119,42 +1528,181 @@ Get an email message, including its compiled LMX content. #### Parameters -| Name | Type | Required | Notes | -| ------------------- | ------ | -------- | --------------------------- | +| Name | Type | Required | Notes | +| --------- | ------ | -------- | --------------------------- | | `$email_message_id` | string | Yes | The ID of the email message. | #### Example ```php -$result = $loops->emailMessages->get(email_message_id: 'msg_123'); +$result = $loops->emailMessages->get(email_message_id: 'cly8k3m0n0044jpx2bghepq45'); ``` --- ### 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) #### Parameters -| Name | Type | Required | Notes | -| ------------------- | ----- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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. | +| `$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: 'msg_123', - fields: [ - 'expectedRevisionId' => 'rev_123', - 'subject' => 'Updated subject', - 'lmx' => 'Hello' + email_message_id: 'cly8k3m0n0044jpx2bghepq45', + 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" ] ); + +``` + +--- + +### 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 | +| ---------------------- | -------- | -------- | --------------------------------------------------------------------------------- | +| `$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. | +| `$data_variables` | array | No | Transactional data variables to render. Accepted for transactional previews only. | + +#### Example + +```php +$result = $loops->emailMessages->preview( + email_message_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 | +| ----- | ------ | -------- | --------------------------------- | +| `$transactional_group_id` | string | Yes | The ID of the transactional group. | + +#### Example + +```php +$result = $loops->transactionalGroups->get(transactional_group_id: 'clv2w3x4y0288xbb0kqrsuv67'); +``` + +--- + +### transactionalGroups->update() + +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 + +| Name | Type | Required | Notes | +| --------------- | ------ | -------- | --------------------------------------- | +| `$transactional_group_id` | string | Yes | The ID of the transactional group. | +| `$name` | string | No | Cannot be the reserved name "Unsorted". | +| `$description` | string | No | | + + +#### Example + +```php +$result = $loops->transactionalGroups->update( + transactional_group_id: 'clv2w3x4y0288xbb0kqrsuv67', + name: 'Updated name' +); ``` --- 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/AudienceSegments.php b/src/AudienceSegments.php new file mode 100644 index 0000000..98afa9d --- /dev/null +++ b/src/AudienceSegments.php @@ -0,0 +1,30 @@ +client = $client; + } + + public function list(?int $per_page = null, ?string $cursor = null): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/audience-segments', options: [ + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) + ]); + } + + public function get(string $audience_segment_id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/audience-segments/' . $audience_segment_id); + } +} diff --git a/src/CampaignGroups.php b/src/CampaignGroups.php new file mode 100644 index 0000000..d6f4766 --- /dev/null +++ b/src/CampaignGroups.php @@ -0,0 +1,56 @@ +client = $client; + } + + public function list(?int $per_page = null, ?string $cursor = null): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/campaign-groups', options: [ + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) + ]); + } + + public function create(string $name, ?string $description = null): mixed + { + + return $this->client->query(method: 'POST', endpoint: 'v1/campaign-groups', options: [ + 'json' => Util::omitNull([ + 'name' => $name, + 'description' => $description, + ]) + ]); + } + + public function get(string $campaign_group_id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/campaign-groups/' . $campaign_group_id); + } + + public function update(string $campaign_group_id, ?string $name = null, ?string $description = null): mixed + { + $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: [ + 'json' => $payload + ]); + } +} diff --git a/src/Campaigns.php b/src/Campaigns.php index e4d863c..f59309e 100644 --- a/src/Campaigns.php +++ b/src/Campaigns.php @@ -15,23 +15,35 @@ 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, + ]) ]); } - 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 = 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' => ['name' => $name] + 'json' => $payload ]); } @@ -40,10 +52,30 @@ public function get(string $campaign_id): mixed return $this->client->query(method: 'GET', endpoint: 'v1/campaigns/' . $campaign_id); } - public function update(string $campaign_id, string $name): mixed - { + public function update( + string $campaign_id, + string $name, + ?string $campaign_group_id = null, + mixed $mailing_list_id = Core::UNSET, + mixed $audience_segment_id = Core::UNSET, + mixed $audience_filter = Core::UNSET, + ?array $scheduling = null + ): mixed { + $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' => ['name' => $name] + '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..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,12 +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 = [ - 'email' => $email, - 'userId' => $user_id, - '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 @@ -51,14 +53,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 +71,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 +88,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 +105,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/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 + ]); + } + + public function preview( + string $email_message_id, + array $emails, + ?array $contact_properties = null, + ?array $event_properties = null, + ?array $data_variables = null + ): mixed { + $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 df99b2a..2dc069b 100644 --- a/src/Events.php +++ b/src/Events.php @@ -25,19 +25,22 @@ 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 = 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, 'headers' => $headers ]); } -} \ No newline at end of file +} diff --git a/src/LoopsClient.php b/src/LoopsClient.php index 84c505b..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; @@ -17,7 +18,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) { @@ -30,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); @@ -40,7 +50,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); } /** @@ -54,6 +69,27 @@ public function setHttpClient(\GuzzleHttp\Client $client): void $this->httpClient = $client; } + 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/Omit.php b/src/Omit.php new file mode 100644 index 0000000..5d3466d --- /dev/null +++ b/src/Omit.php @@ -0,0 +1,8 @@ +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 b7220d5..800f08d 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 = [ @@ -35,17 +35,56 @@ 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 { + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-emails', options: [ + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) + ]); + } - $query = [ - 'per_page' => $per_page - ]; - if ($cursor) - $query['cursor'] = $cursor; + public function create(string $name, ?string $transactional_group_id = null): mixed + { + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-emails', options: [ + 'json' => Util::omitNull([ + 'name' => $name, + 'transactionalGroupId' => $transactional_group_id, + ]) + ]); + } - return $this->client->query(method: 'GET', endpoint: 'v1/transactional', options: [ - 'query' => $query + 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 = null, + ?string $transactional_group_id = null + ): mixed { + $payload = Util::omitNull([ + 'name' => $name, + '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' => $payload ]); } -} \ No newline at end of file + + 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'); + } +} diff --git a/src/TransactionalGroups.php b/src/TransactionalGroups.php new file mode 100644 index 0000000..ba8e32e --- /dev/null +++ b/src/TransactionalGroups.php @@ -0,0 +1,55 @@ +client = $client; + } + + public function list(?int $per_page = null, ?string $cursor = null): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-groups', options: [ + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) + ]); + } + + public function create(string $name, ?string $description = null): mixed + { + return $this->client->query(method: 'POST', endpoint: 'v1/transactional-groups', options: [ + 'json' => Util::omitNull([ + 'name' => $name, + 'description' => $description, + ]) + ]); + } + + public function get(string $transactional_group_id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/transactional-groups/' . $transactional_group_id); + } + + public function update(string $transactional_group_id, ?string $name = null, ?string $description = null): mixed + { + $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: [ + 'json' => $payload + ]); + } +} diff --git a/src/Uploads.php b/src/Uploads.php new file mode 100644 index 0000000..45c4551 --- /dev/null +++ b/src/Uploads.php @@ -0,0 +1,97 @@ + '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) + { + $this->client = $client; + } + + public function upload(string $path): mixed + { + 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->getUploadHttpClient()->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' + ); + } + + private function resolveContentType(string $path): ?string + { + $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/src/Util.php b/src/Util.php new file mode 100644 index 0000000..d730814 --- /dev/null +++ b/src/Util.php @@ -0,0 +1,30 @@ + $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/src/Workflows.php b/src/Workflows.php new file mode 100644 index 0000000..6a9abf6 --- /dev/null +++ b/src/Workflows.php @@ -0,0 +1,35 @@ +client = $client; + } + + public function list(?int $per_page = null, ?string $cursor = null): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/workflows', options: [ + 'query' => Util::omitNull([ + 'perPage' => $per_page, + 'cursor' => $cursor, + ]) + ]); + } + + public function get(string $workflow_id): mixed + { + return $this->client->query(method: 'GET', endpoint: 'v1/workflows/' . $workflow_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..c417055 --- /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(audience_segment_id: $segmentId); + + $this->assertEquals($segmentId, $result['id']); + } +} diff --git a/tests/CampaignGroupsTest.php b/tests/CampaignGroupsTest.php new file mode 100644 index 0000000..8cc6c19 --- /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( + campaign_group_id: $groupId, + name: 'Updated name' + ); + + $this->assertEquals('Updated name', $result['name']); + } +} 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 028db8c..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,10 +85,43 @@ 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']); $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( + email_message_id: $emailMessageId, + emails: ['test@example.com'], + contact_properties: ['firstName' => 'Ada'] + ); + + $this->assertTrue($result['success']); + } } diff --git a/tests/TransactionalGroupsTest.php b/tests/TransactionalGroupsTest.php new file mode 100644 index 0000000..a10d403 --- /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(transactional_group_id: $groupId); + + $this->assertEquals($groupId, $result['id']); + } +} diff --git a/tests/TransactionalTest.php b/tests/TransactionalTest.php index 6121b52..3bd488d 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,207 @@ 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 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'; + + $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 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'; + + $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..e2bd52a --- /dev/null +++ b/tests/UploadsTest.php @@ -0,0 +1,114 @@ +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( + $this->imagePath, + base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==') + ); + } + + protected function tearDown(): void + { + if (file_exists($this->imagePath)) { + unlink($this->imagePath); + } + } + + 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->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->mockUploadHttpClient + ->expects($this->once()) + ->method('put') + ->with( + $presignedUrl, + $this->callback(function ($options) use ($fileContents, $contentLength) { + return !isset($options['headers']['Authorization']) + && $options['headers']['Content-Type'] === 'image/png' + && $options['headers']['Content-Length'] === (string) $contentLength + && $options['body'] === $fileContents; + }) + ) + ->willReturn(new Response(status: 200)); + + $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 testUploadRejectsMissingFile(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->client->uploads->upload(path: '/path/does/not/exist.png'); + } + + public function testUploadRejectsUnsupportedType(): void + { + $path = sys_get_temp_dir() . '/loops_upload_test.txt'; + file_put_contents($path, 'not an image'); + + try { + $this->expectException(\InvalidArgumentException::class); + $this->client->uploads->upload(path: $path); + } finally { + unlink($path); + } + } +} diff --git a/tests/UtilTest.php b/tests/UtilTest.php new file mode 100644 index 0000000..6e36b76 --- /dev/null +++ b/tests/UtilTest.php @@ -0,0 +1,61 @@ + '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); + } + + 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); + } +} 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']); + } +}