diff --git a/Readme.md b/Readme.md index 3a41cc1..67c03dd 100644 --- a/Readme.md +++ b/Readme.md @@ -26,6 +26,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [Get a subscriber](#get-a-subscriber) - [Delete a subscriber](#delete-a-subscriber) - [Forget a subscriber](#forget-a-subscriber) + - [Get subscriber activity](#get-subscriber-activity) - [Groups](#groups) - [List all groups](#list-all-groups) - [Create a group](#create-a-group) @@ -50,15 +51,73 @@ For more information how to obtain an API key visit the [following link](https:/ - [List all automations](#list-all-automations) - [Get an automation](#get-an-automation) - [Get subscribers activity for an automation](#get-subscribers-activity-for-an-automation) + - [Create an automation](#create-an-automation) + - [Update an automation](#update-an-automation) + - [Delete an automation](#delete-an-automation) + - [Copy an automation](#copy-an-automation) + - [Enable an automation](#enable-an-automation) + - [Disable an automation](#disable-an-automation) + - [Send a test of an automation](#send-a-test-of-an-automation) + - [Add subscribers to an automation](#add-subscribers-to-an-automation) + - [Re-add subscribers to an automation](#re-add-subscribers-to-an-automation) + - [Copy automation subscribers to groups](#copy-automation-subscribers-to-groups) + - [Cancel active automation runs](#cancel-active-automation-runs) + - [Skip an automation delay](#skip-an-automation-delay) + - [List automation triggers](#list-automation-triggers) + - [Create an automation trigger](#create-an-automation-trigger) + - [Get an automation trigger](#get-an-automation-trigger) + - [Update an automation trigger](#update-an-automation-trigger) + - [Delete an automation trigger](#delete-an-automation-trigger) + - [List automation steps](#list-automation-steps) + - [Create an automation step](#create-an-automation-step) + - [Get an automation step](#get-an-automation-step) + - [Update an automation step](#update-an-automation-step) + - [Delete an automation step](#delete-an-automation-step) + - [Copy an automation step](#copy-an-automation-step) + - [Move an automation step](#move-an-automation-step) + - [Select the winner of an A/B test step](#select-the-winner-of-an-ab-test-step) + - [Get the content of an automation email](#get-the-content-of-an-automation-email) + - [Update the content of an automation email](#update-the-content-of-an-automation-email) + - [Get the report stats of an automation email](#get-the-report-stats-of-an-automation-email) + - [Get the unsubscribe reasons of an automation email](#get-the-unsubscribe-reasons-of-an-automation-email) + - [List automation templates](#list-automation-templates) + - [Create an automation from a template](#create-an-automation-from-a-template) + - [Get the run counts of automation steps](#get-the-run-counts-of-automation-steps) + - [Get the run counts of automation triggers](#get-the-run-counts-of-automation-triggers) + - [Get the stats of A/B test steps](#get-the-stats-of-ab-test-steps) + - [List the subscribers on an automation step](#list-the-subscribers-on-an-automation-step) + - [List the subscribers on an automation trigger](#list-the-subscribers-on-an-automation-trigger) + - [Get the monthly email totals of an automation](#get-the-monthly-email-totals-of-an-automation) - [Campaigns](#campaigns) - [List all campaigns](#list-all-campaigns) - [Get a campaign](#get-a-campaign) - [Create a campaign](#create-a-campaign) + - [Create a simple editor campaign](#create-a-simple-editor-campaign) - [Update a campaign](#update-a-campaign) - [Schedule a campaign](#schedule-a-campaign) - [Cancel a campaign](#cancel-a-campaign) - [Delete a campaign](#delete-a-campaign) - [Get subscribers activity for a campaign](#get-subscribers-activity-for-a-campaign) + - [Emails](#emails) + - [Get an email](#get-an-email) + - [Get the HTML of an email](#get-the-html-of-an-email) + - [Get a block](#get-a-block) + - [Insert a block](#insert-a-block) + - [Update a block](#update-a-block) + - [Delete a block](#delete-a-block) + - [Move a block](#move-a-block) + - [Validate a block](#validate-a-block) + - [Update the design settings](#update-the-design-settings) + - [Validate the design settings](#validate-the-design-settings) + - [Save an email as a template](#save-an-email-as-a-template) + - [List the versions of an email](#list-the-versions-of-an-email) + - [Save a version of an email](#save-a-version-of-an-email) + - [Restore a version of an email](#restore-a-version-of-an-email) + - [Retry a write with an idempotency key](#retry-a-write-with-an-idempotency-key) + - [Brand styles](#brand-styles) + - [Get the brand styles](#get-the-brand-styles) + - [Files](#files) + - [List all files](#list-all-files) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -94,38 +153,41 @@ The first thing that you need to do is to import `mailerlite` into your project ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) ``` MailerLite API supports [versioning](https://developers.mailerlite.com/docs/#versioning), so you can pass additional argument to specify the API version: ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key', - 'api_version': '2038-01-19' -}) +client = MailerLite.Client({"api_key": "your-api-key", "api_version": "2038-01-19"}) ``` ### Error handling -API responses with a 4xx or 5xx status code raise a `requests.HTTPError` exception. The exception includes the full response, so you can inspect the status code and error body: +API responses with a 4xx or 5xx status code raise a `mailerlite.ApiError` exception. `ApiError` is a subclass of `requests.HTTPError`, so `except requests.HTTPError` continues to work. The exception has these attributes: + +- `status_code`: the HTTP status code. +- `message`: the error message of the API. +- `code`: a machine-readable error code, if the API sends one. For example, `version_conflict`. +- `etag`: the current etag of the email, if the API sends one. The API sends it with `version_conflict`. +- `errors`: the validation errors for each field, if the API sends them. +- `response`: the full `requests.Response`. + +The attributes are `None` when the response does not have them. ```python -import requests import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) try: - response = client.subscribers.get('some@email.com') -except requests.HTTPError as e: - print(e.response.status_code) # e.g. 404 - print(e.response.json()) # error body from the API + response = client.subscribers.get("some@email.com") +except MailerLite.ApiError as e: + print(e.status_code) # e.g. 404 + print(e.message) # error message from the API + print(e.errors) # validation errors, if any + print(e.response.json()) # full error body from the API ``` Network-level failures (DNS errors, connection resets, timeouts) raise the standard `requests` exceptions such as `requests.ConnectionError` and `requests.Timeout`. Catch `requests.RequestException` to handle both HTTP and network errors with a single handler. @@ -143,11 +205,9 @@ Network-level failures (DNS errors, connection resets, timeouts) raise the stand ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.subscribers.list(limit=10, page=1, filter={'status': 'active'}) +response = client.subscribers.list(limit=10, page=1, filter={"status": "active"}) ``` ### Create a subscriber @@ -156,11 +216,14 @@ response = client.subscribers.list(limit=10, page=1, filter={'status': 'active'} ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.subscribers.create('some@email.com', fields={'name': 'John', 'last_name': 'Doe'}, ip_address='1.2.3.4', optin_ip='1.2.3.4') +response = client.subscribers.create( + "some@email.com", + fields={"name": "John", "last_name": "Doe"}, + ip_address="1.2.3.4", + optin_ip="1.2.3.4", +) ``` ### Update a subscriber @@ -169,24 +232,27 @@ response = client.subscribers.create('some@email.com', fields={'name': 'John', ' ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.subscribers.update('some@email.com', fields={'name': 'New', 'last_name': 'Name'}, ip_address='1.2.3.5', optin_ip='1.2.3.5') +response = client.subscribers.update( + "some@email.com", + fields={"name": "New", "last_name": "Name"}, + ip_address="1.2.3.5", + optin_ip="1.2.3.5", +) ``` +`update` sends `PUT /subscribers/{subscriber}`. The first argument is the email address or the ID of the subscriber. The subscriber must exist, or the API returns 404. `groups` replaces the subscriber's groups: groups you leave out are removed. To create a subscriber, or to add groups without removing others, use `create`. Pass `new_email` to change the email address. + ### Get a subscriber ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.subscribers.get('some@email.com') +response = client.subscribers.get("some@email.com") ``` ### Delete a subscriber @@ -195,9 +261,7 @@ response = client.subscribers.get('some@email.com') ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) subscriber_id = 1234567890 @@ -210,15 +274,28 @@ response = client.subscribers.delete(subscriber_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) subscriber_id = 1234567890 response = client.subscribers.forget(subscriber_id) ``` +### Get subscriber activity + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +subscriber_id = 1234567890 + +response = client.subscribers.activity_log( + subscriber_id, limit=10, page=1, filter={"log_name": "email_open"} +) +``` + ## Groups @@ -228,11 +305,9 @@ response = client.subscribers.forget(subscriber_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.groups.list(limit=10, page=1, filter={'name': 'My'}, sort='name') +response = client.groups.list(limit=10, page=1, filter={"name": "My"}, sort="name") ``` ### Create a group @@ -241,11 +316,9 @@ response = client.groups.list(limit=10, page=1, filter={'name': 'My'}, sort='nam ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -group_name = 'My Group' +group_name = "My Group" response = client.groups.create(group_name) ``` @@ -256,12 +329,10 @@ response = client.groups.create(group_name) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) group_id = 1234567 -group_name = 'My New Group' +group_name = "My New Group" response = client.groups.update(group_id, group_name) ``` @@ -272,9 +343,7 @@ response = client.groups.update(group_id, group_name) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) group_id = 1234567 @@ -287,13 +356,18 @@ response = client.groups.delete(group_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) group_id = 1234567 -response = client.groups.get_group_subscribers(group_id, page=1, limit=10, filter={'status': 'active'}) +response = client.groups.get_group_subscribers( + group_id, limit=10, filter={"status": "active"} +) + +next_cursor = response["meta"]["next_cursor"] +response = client.groups.get_group_subscribers( + group_id, cursor=next_cursor, limit=10, filter={"status": "active"} +) ``` ### Import bulk subscribers to a group @@ -302,14 +376,12 @@ response = client.groups.get_group_subscribers(group_id, page=1, limit=10, filte ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) group_id = 1234567 subscribers = [ - {'email': 'subscriber1@example.com', 'name': 'Subscriber One'}, - {'email': 'subscriber2@example.com', 'name': 'Subscriber Two'}, + {"email": "subscriber1@example.com", "name": "Subscriber One"}, + {"email": "subscriber2@example.com", "name": "Subscriber Two"}, ] response = client.groups.import_subscribers_to_group(group_id, subscribers) @@ -321,9 +393,7 @@ response = client.groups.import_subscribers_to_group(group_id, subscribers) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) subscriber_id = 111222 group_id = 1234567 @@ -337,9 +407,7 @@ response = client.subscribers.assign_subscriber_to_group(subscriber_id, group_id ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) subscriber_id = 111222 group_id = 1234567 @@ -356,9 +424,7 @@ response = client.subscribers.unassign_subscriber_from_group(subscriber_id, grou ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) response = client.segments.list(limit=10, page=1) ``` @@ -369,9 +435,7 @@ response = client.segments.list(limit=10, page=1) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) segment_id = 123456 name = "My New Segment Name" @@ -385,9 +449,7 @@ response = client.segments.update(segment_id, name) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) segment_id = 123456 @@ -400,13 +462,11 @@ response = client.segments.delete(segment_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) segment_id = 123456 -response = client.segments.get(segment_id, limit=10, filter={'status': 'active'}) +response = client.segments.get(segment_id, limit=10, filter={"status": "active"}) ``` ### Get subscribers belonging to a segment @@ -415,13 +475,18 @@ response = client.segments.get(segment_id, limit=10, filter={'status': 'active'} ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) segment_id = 123456 -response = client.segments.get_subscribers(segment_id, limit=10, filter={'status': 'active'}) +response = client.segments.get_subscribers( + segment_id, limit=10, filter={"status": "active"} +) + +next_cursor = response["meta"]["next_cursor"] +response = client.segments.get_subscribers( + segment_id, cursor=next_cursor, limit=10, filter={"status": "active"} +) ``` ## Fields @@ -433,11 +498,11 @@ response = client.segments.get_subscribers(segment_id, limit=10, filter={'status ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.fields.list(limit=10, page=1, sort='name', filter={'keyword': 'abc', 'type': 'text'}) +response = client.fields.list( + limit=10, page=1, sort="name", filter={"keyword": "abc", "type": "text"} +) ``` ### Create a field @@ -446,12 +511,10 @@ response = client.fields.list(limit=10, page=1, sort='name', filter={'keyword': ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -name = 'My Field' -type = 'text +name = "My Field" +type = "text" response = client.fields.create(name, type) ``` @@ -462,12 +525,10 @@ response = client.fields.create(name, type) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) field_id = 123456 -name = 'My New Field' +name = "My New Field" response = client.fields.update(field_id, name) ``` @@ -478,9 +539,7 @@ response = client.fields.update(field_id, name) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) field_id = 123456 @@ -496,11 +555,11 @@ response = client.fields.delete(field_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.automations.list(limit=10, page=1, filter={'enabled': True, 'name': 'some name', 'group': 123456}) +response = client.automations.list( + limit=10, page=1, filter={"enabled": True, "name": "some name", "group": 123456} +) ``` ### Get an automation @@ -509,9 +568,7 @@ response = client.automations.list(limit=10, page=1, filter={'enabled': True, 'n ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 @@ -524,332 +581,1246 @@ response = client.automations.get(automation_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 -response = client.automations.activity(automation_id, page=1, limit=10, filter={'status': 'active', 'date_from': '2022-12-20', 'date_to': '2022-12-31'}) +response = client.automations.activity( + automation_id, + page=1, + limit=10, + filter={"status": "active", "date_from": "2022-12-20", "date_to": "2022-12-31"}, +) ``` -## Campaigns - +### Create an automation + -### List all campaigns - +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +response = client.automations.create("My automation") +``` + +### Update an automation + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 -response = client.campaigns.list(limit=10, page=1, filter={'status': 'ready', 'type': 'regular'}) +response = client.automations.update(automation_id, "My new name") ``` -### Get a campaign - +### Delete an automation + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -campaign_id = 123456 +automation_id = 123456 -response = client.campaigns.get(campaign_id) +response = client.automations.delete(automation_id) ``` -### Create a campaign - +### Copy an automation + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -params = { - "name": "Test Campaign", - "language_id": 1, - "type": "regular", - "emails": [{ - "subject": "This is a test campaign", - "from_name": "Test Man", - "from": "testuser@mailerlite.com", - "content": "Hi there, this is a test campaign!" - }] -} +automation_id = 123456 -response = client.campaigns.create(params) +response = client.automations.copy(automation_id) ``` -### Update a campaign - +### Enable an automation + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -campaign_id = 123456 -params = { - "name": "New Campaign Name", - "language_id": 2, - "emails": [{ - "subject": "This is a test campaign", - "from_name": "Test Man", - "from": "testuser@mailerlite.com", - "content": "Hi there, this is a test campaign!" - }] -} +automation_id = 123456 -response = client.campaigns.update(campaign_id, params) +response = client.automations.enable(automation_id) ``` -### Schedule a campaign - +### Disable an automation + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -campaign_id = 123456 -params = { - "delivery": "scheduled", - "schedule": { - "date": "2022-12-31", - "hours": "22", - "minutes": "00" - } -} +automation_id = 123456 -response = client.campaigns.schedule(campaign_id, params) +response = client.automations.disable(automation_id) ``` -### Cancel a campaign - +### Send a test of an automation + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -campaign_id = 123456 +automation_id = 123456 -response = client.campaigns.cancel(campaign_id) +response = client.automations.test(automation_id, "some@email.com") ``` -### Delete a campaign - +### Add subscribers to an automation + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -campaign_id = 123456 +automation_id = 123456 -response = client.campaigns.delete(campaign_id) +response = client.automations.add_subscribers( + automation_id, emails=["some@email.com"], groups=[1234567] +) ``` -### Get subscribers activity for a campaign - +### Re-add subscribers to an automation + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -campaign_id = 123456 +automation_id = 123456 -response = client.campaigns.activity(campaign_id) +response = client.automations.readd( + automation_id, "last_completed_step", {"status": "completed"} +) ``` -## Forms - +### Copy automation subscribers to groups + -### List all forms - +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 + +response = client.automations.activity_add_to_group( + automation_id, ["1234567"], {"status": "completed"} +) +``` + +### Cancel active automation runs + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 -response = client.forms.list(limit=10, page=1, sort='name', filter={'name': 'form name'}) +response = client.automations.activity_cancel_automation( + automation_id, {"status": "active"} +) ``` -### Get a form - +### Skip an automation delay + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -form_id = 123456 +automation_id = 123456 -response = client.forms.get(form_id) +response = client.automations.activity_skip_delay(automation_id, {"status": "active"}) ``` -### Update a form - +### List automation triggers + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -form_id = 123456 -name = 'New Form Name' +automation_id = 123456 -response = client.forms.update(form_id, name) +response = client.automations.list_triggers(automation_id) ``` -### Delete a form - +### Create an automation trigger + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -form_id = 123456 +automation_id = 123456 -response = client.forms.delete(form_id) +response = client.automations.create_trigger( + automation_id, "subscriber_joins_group", data={"group_ids": [1234567]} +) ``` -### Get subscribers who signed up to a specific form - +### Get an automation trigger + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -form_id = 123456 +automation_id = 123456 +trigger_id = 123456 -response = client.forms.get_subscribers(form_id, page=1, limit=10, filter={'status': 'active'}) +response = client.automations.get_trigger(automation_id, trigger_id) ``` -## Batching - +### Update an automation trigger + -### Create a new batch - +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +trigger_id = 123456 + +response = client.automations.update_trigger( + automation_id, trigger_id, data={"group_ids": [1234567]} +) +``` + +### Delete an automation trigger + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -params = [ - {"method": "GET", "path": "api/subscribers/list"}, - {"method": "GET", "path": "api/campaigns/list"}, -] +automation_id = 123456 +trigger_id = 123456 -response = self.client.batches.request(params) +response = client.automations.delete_trigger(automation_id, trigger_id) ``` -## Webhooks - +### List automation steps + -### List all webhooks - +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 + +response = client.automations.list_steps(automation_id) +``` + +### Create an automation step + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.webhooks.list() +automation_id = 123456 + +response = client.automations.create_step( + automation_id, "delay", parent_id=123456, data={"unit": "days", "value": 1} +) ``` -### Get a webhook - +### Get an automation step + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -webhook_id = 123456 +automation_id = 123456 +step_id = 123456 -response = client.webhooks.get(webhook_id) +response = client.automations.get_step(automation_id, step_id) ``` -### Create a webhook - +### Update an automation step + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -events = [ - 'subscriber.created', - 'subscriber.updated', - 'subscriber.unsubscribed' -] -url = 'https://my-url.com' -name = 'Webhook name' +automation_id = 123456 +step_id = 123456 -response = client.webhooks.create(events, url, name) +response = client.automations.update_step( + automation_id, step_id, data={"unit": "days", "value": 2} +) ``` -### Update a webhook - +### Delete an automation step + ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -webhook_id = 123456 -events = [ - 'subscriber.created', - 'subscriber.updated', - 'subscriber.unsubscribed' -] -url = 'https://my-url.com' -name = 'Webhook name' +automation_id = 123456 +step_id = 123456 + +response = client.automations.delete_step( + automation_id, step_id, branch_to_delete="all" +) +``` + +### Copy an automation step + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +step_id = 123456 + +response = client.automations.copy_step(automation_id, step_id, parent_id=123456) +``` + +### Move an automation step + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +step_id = 123456 + +response = client.automations.reorder_steps( + automation_id, step_id, new_parent_step_id=123456 +) +``` + +### Select the winner of an A/B test step + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +step_id = 123456 + +response = client.automations.select_ab_test_winner(automation_id, step_id, "a_path") +``` + +### Get the content of an automation email + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +email_id = 123456 + +response = client.automations.get_email_content(automation_id, email_id) +``` + +### Update the content of an automation email + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +email_id = 123456 + +response = client.automations.update_email_content( + automation_id, email_id, "
Hello
" +) +``` + +### Get the report stats of an automation email + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +email_id = 123456 + +response = client.automations.email_report_stats( + automation_id, email_id, start_date="2026-01-01", end_date="2026-01-31" +) +``` + +### Get the unsubscribe reasons of an automation email + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +email_id = 123456 + +response = client.automations.email_unsubscribe_reasons(automation_id, email_id) +``` + +### List automation templates + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +response = client.automations.list_templates(limit=10, page=1, filter={"category": 1}) +``` + +### Create an automation from a template + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +template_id = 123456 + +response = client.automations.select_template(template_id) +``` + +### Get the run counts of automation steps + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +step_id = 123456 + +response = client.automations.steps_run_counts(automation_id) +response = client.automations.step_run_counts(automation_id, step_id) +``` + +### Get the run counts of automation triggers + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +trigger_id = 123456 + +response = client.automations.triggers_run_counts(automation_id) +response = client.automations.trigger_run_counts(automation_id, trigger_id) +``` + +### Get the stats of A/B test steps + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +step_id = 123456 + +response = client.automations.ab_steps_stats(automation_id) +response = client.automations.ab_step_stats(automation_id, step_id) +``` + +### List the subscribers on an automation step + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +step_id = 123456 + +response = client.automations.step_subscribers( + automation_id, step_id, "queued", limit=10, page=1 +) +``` + +### List the subscribers on an automation trigger + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 +trigger_id = 123456 + +response = client.automations.trigger_subscribers( + automation_id, trigger_id, "completed", limit=10, page=1 +) +``` + +### Get the monthly email totals of an automation + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +automation_id = 123456 + +response = client.automations.email_monthly_totals( + automation_id, start_date="2026-01-01", end_date="2026-06-30" +) +``` + +## Campaigns + + +### List all campaigns + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +response = client.campaigns.list( + limit=10, page=1, filter={"status": "ready", "type": "regular"} +) +``` + +### Get a campaign + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +campaign_id = 123456 + +response = client.campaigns.get(campaign_id) +``` + +### Create a campaign + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +params = { + "name": "Test Campaign", + "language_id": 1, + "type": "regular", + "emails": [ + { + "subject": "This is a test campaign", + "from_name": "Test Man", + "from": "testuser@mailerlite.com", + "content": "Hi there, this is a test campaign!", + } + ], +} + +response = client.campaigns.create(params) +``` + +### Create a simple editor campaign + + +To build the email with the simple editor, set `editor` to `MailerLite.EDITOR_SIMPLE`, and do not send `content`. Then edit the email with [the email methods](#emails). + +- `template_id` is optional. It is a shared template of the simple editor that the email starts from. It needs `editor`. +- All emails of a campaign must have the same `editor` and `template_id`. +- Only `create` accepts `editor` and `template_id`. `update` does not. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +params = { + "name": "Test Campaign", + "type": "regular", + "emails": [ + { + "subject": "This is a test campaign", + "from_name": "Test Man", + "from": "testuser@mailerlite.com", + "editor": MailerLite.EDITOR_SIMPLE, + } + ], +} + +response = client.campaigns.create(params) + +email_id = response["data"]["emails"][0]["id"] +``` + +### Update a campaign + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +campaign_id = 123456 +params = { + "name": "New Campaign Name", + "language_id": 2, + "emails": [ + { + "subject": "This is a test campaign", + "from_name": "Test Man", + "from": "testuser@mailerlite.com", + "content": "Hi there, this is a test campaign!", + } + ], +} + +response = client.campaigns.update(campaign_id, params) +``` + +### Schedule a campaign + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +campaign_id = 123456 +params = { + "delivery": "scheduled", + "schedule": {"date": "2022-12-31", "hours": "22", "minutes": "00"}, +} + +response = client.campaigns.schedule(campaign_id, params) +``` + +### Cancel a campaign + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +campaign_id = 123456 + +response = client.campaigns.cancel(campaign_id) +``` + +### Delete a campaign + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +campaign_id = 123456 + +response = client.campaigns.delete(campaign_id) +``` + +### Get subscribers activity for a campaign + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +campaign_id = 123456 + +response = client.campaigns.activity(campaign_id) +``` + +## Emails + + +These methods edit emails built with the simple editor. To create such an email, [create a simple editor campaign](#create-a-simple-editor-campaign). The ID of the email is in `response['data']['emails'][0]['id']`. + +- IDs are strings of digits. You can also give them as `int`. An empty ID raises `mailerlite.EmptyIdError`, and an ID that does not contain only digits raises `mailerlite.InvalidIdError`. The SDK sends no request in these cases. +- Before the request, the SDK checks only the IDs and the `settings` of `update_settings()`. It sends all other values as you give them, and the API checks them. An invalid value raises `mailerlite.ApiError`, for example with status `422`. +- The methods return a `mailerlite.ApiResponse`. It is a `dict` with the response body, and it also has `status_code` and `headers`. +- Each write needs the etag from the last response for the email, and returns a new etag. The etag covers the whole email, so send the writes to one email one after another. +- If the email changed after you read it, the write raises `mailerlite.ApiError` with `code` equal to `mailerlite.VERSION_CONFLICT`. Its `etag` is the current etag. Get the email again, and then repeat the write with the current etag. +- The keys of `editor_content` are camelCase, for example `textAlign`. The SDK sends them as you give them. + +### Get an email + + +Returns the email with its settings and blocks, and the etag of the email. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" + +response = client.emails.get(email_id) + +email = response["data"] +etag = response["etag"] +``` + +### Get the HTML of an email + + +Returns the HTML that the email sends. The API can return a temporary `503` error. If it does, try again later. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" + +response = client.emails.get_html(email_id) + +html = response["data"]["html"] +``` + +### Get a block + + +Returns the block and its place in the email: `placement` has the IDs of the blocks `after` and `before` it, or `None`. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" +block_id = "57" + +response = client.emails.get_block(email_id, block_id) + +block = response["data"] +``` + +### Insert a block + + +Use the block types in `MailerLite.BlockType`. `editor_content` can be empty. Without `placement`, the API puts the block at the end of the email. The `before` and `after` positions also need a `block_id`. The API ignores the `block_id` of `start` and `end`. The SDK sends the placement as you give it. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" +etag = client.emails.get(email_id)["etag"] + +response = client.emails.create_block( + email_id, + etag, + MailerLite.BlockType.PARAGRAPH, + {"attrs": {"textAlign": "left"}, "content": [{"type": "text", "text": "Hello"}]}, + placement={"position": MailerLite.BlockPosition.START}, +) + +block_id = response["data"]["id"] +etag = response["etag"] +``` + +### Update a block + + +The new `editor_content` replaces the whole content of the block. The API removes the keys that you do not send. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" +block_id = "57" +etag = "etag-from-the-last-response" + +response = client.emails.update_block( + email_id, + block_id, + etag, + { + "attrs": {"textAlign": "center"}, + "content": [{"type": "text", "text": "Hello again"}], + }, +) + +etag = response["etag"] +``` + +### Delete a block + + +Also deletes the blocks in it. The response has only the new etag. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" +block_id = "57" +etag = "etag-from-the-last-response" + +response = client.emails.delete_block(email_id, block_id, etag) + +etag = response["etag"] +``` + +### Move a block + + +The `before` and `after` positions also need a `block_id`. The API ignores the `block_id` of `start` and `end`. The SDK sends the placement as you give it. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" +block_id = "57" +etag = "etag-from-the-last-response" + +response = client.emails.move_block( + email_id, + block_id, + etag, + {"position": MailerLite.BlockPosition.AFTER, "block_id": "12"}, +) + +etag = response["etag"] +``` + +### Validate a block + + +Checks a block before you insert it. It needs no email and no etag. An invalid block does not raise an error: the response has `valid` set to `False` and the `errors` for each field. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +response = client.emails.validate_block( + MailerLite.BlockType.BUTTON, + { + "attrs": {"href": "https://example.com"}, + "content": [{"type": "text", "text": "Buy now"}], + }, +) + +if not response["valid"]: + print(response["errors"]) +``` + +### Update the design settings + + +Send only the keys to change, in the shape of `settings` in the email. The groups are `brand`, `layout`, `typography`, `button` and `footer`. + +- Send all style values as strings, for example `'16'` and not `16`. +- The API stores each value that you send, also a value that the email inherits from your account. Then the email no longer follows your account for that value. +- To unset a brand colour, send an empty string. The API stores the value empty, and the email then uses the account value. +- To unset a footer text, send an empty editor document: `{'editor_content': {}}` or `{'editor_content': {'content': []}}`. +- Other colours, sizes, `button.border_radius` and `brand.style` cannot be empty. An empty string gives a `422` error. +- `footer.social_links` replaces the whole list. Keep the `link_id` of each link to keep its click history. +- `brand.mode` is one of `MailerLite.BrandMode`. To apply a preset palette, send its `identifier` from [the brand styles](#get-the-brand-styles) in `brand.style`. +- If the API removed unsafe values, the response has `rejected` with the keys of these values. +- `settings` must be a `dict` without an `etag` key, because the SDK adds the `etag` argument to it. Otherwise the SDK raises `TypeError` or `ValueError` and sends no request. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" +etag = "etag-from-the-last-response" + +response = client.emails.update_settings( + email_id, + etag, + { + "brand": {"mode": MailerLite.BrandMode.CUSTOM, "background_color": "#f4f4f4"}, + "typography": {"paragraph": {"p1": {"size": "18"}}}, + }, +) + +etag = response["etag"] +if response.get("rejected"): + print(response["rejected"]["keys"]) +``` + +### Validate the design settings + + +Checks a change with the rules of the update, and saves nothing. It needs no etag. Invalid settings do not raise an error: the response has `valid` set to `False` and the `errors` for each field. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" + +response = client.emails.validate_settings( + email_id, + { + "button": {"border_radius": "8"}, + }, +) + +if not response["valid"]: + print(response["errors"]) +``` + +### Save an email as a template + + +Copies the email to a new template. The API cannot delete templates. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" + +response = client.emails.create_template(email_id, "Monthly newsletter") + +template_id = response["data"]["id"] +``` + +### List the versions of an email + + +A version is a saved copy of the content and the settings of an email. The list starts with the newest version. `current` marks the version that the email has now. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" + +response = client.emails.list_versions(email_id, page=1, limit=10) +``` + +### Save a version of an email + + +Writes through the API do not save versions. If nothing changed after the last version, the API saves no new version and returns the last version. `response.status_code` is `201` for a new version and `200` for the last version. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" + +response = client.emails.create_version(email_id) + +version_id = response["data"]["id"] +is_new = response.status_code == 201 +``` + +### Restore a version of an email + + +Puts the email back to the version, and returns the full email with the new etag. Before the restore, the API saves the current state as a version, so you can undo the restore. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" +version_id = "901" +etag = "etag-from-the-last-response" + +response = client.emails.restore_version(email_id, version_id, etag) + +etag = response["etag"] +``` + +### Retry a write with an idempotency key + + +If a write fails with a network error, you do not know if the API did the write. Give the write an `idempotency_key`, and retry it with the same key and the same arguments. If the first request succeeded, the API returns the stored response and does not do the write two times. Then `response.is_idempotent_replay` is `True`. + +- Use a new key for each write. The API keeps a key for 24 hours. +- The key must be 1 to 255 printable ASCII characters. The SDK sends the key as you give it. The API answers an invalid key with status `400`. +- If `requests` cannot send the key in a header, it raises `requests.exceptions.InvalidHeader`. For example, a key with a space at the start or a new line causes this error. +- The same key with different arguments raises `ApiError` with status `422`. If the first request is still running, the API returns `409` without a `code`. +- These methods accept a key: `create_block`, `update_block`, `delete_block`, `move_block`, `update_settings`, `create_template`, `create_version` and `restore_version`. + +```python +import uuid + +import requests +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +email_id = "123456" +etag = "etag-from-the-last-response" +key = str(uuid.uuid4()) + +try: + response = client.emails.create_block( + email_id, etag, MailerLite.BlockType.DIVIDER, {}, idempotency_key=key + ) +except (requests.ConnectionError, requests.Timeout): + # Network error: retry with the same key and the same arguments. + response = client.emails.create_block( + email_id, etag, MailerLite.BlockType.DIVIDER, {}, idempotency_key=key + ) + +print(response["data"]["id"], response.is_idempotent_replay) +``` + +## Brand styles + + +### Get the brand styles + + +Returns the palettes of your account: `brand` (your brand styles, or `None` if your account has none), `default` and the `presets`. Match a palette on its `identifier`, not on its `name`. To apply a preset to an email, [update the design settings](#update-the-design-settings) with `{'brand': {'style': identifier}}`. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +response = client.brand_styles.get() + +identifiers = [preset["identifier"] for preset in response["data"]["presets"]] +``` + +## Files + + +### List all files + + +Returns the files of your media library. Use their `url` in image blocks. `width` and `height` are `None` for files that are not images. + +- `filter`: `name` (a part of the file name) and `type` (one of `MailerLite.FileType`). +- `sort`: one of `MailerLite.FileSort`. The default is newest first. + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +response = client.files.list( + filter={"name": "summer sale", "type": MailerLite.FileType.IMAGE}, + sort=MailerLite.FileSort.SIZE_DESCENDING, + page=1, + limit=25, +) +``` + +## Forms + + +### List all forms + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +response = client.forms.list( + limit=10, page=1, sort="name", filter={"name": "form name"} +) +``` + +### Get a form + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +form_id = 123456 + +response = client.forms.get(form_id) +``` + +### Update a form + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +form_id = 123456 +name = "New Form Name" + +response = client.forms.update(form_id, name) +``` + +### Delete a form + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +form_id = 123456 + +response = client.forms.delete(form_id) +``` + +### Get subscribers who signed up to a specific form + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +form_id = 123456 + +response = client.forms.get_subscribers( + form_id, page=1, limit=10, filter={"status": "active"} +) +``` + +## Batching + + +### Create a new batch + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +params = [ + {"method": "GET", "path": "api/subscribers/list"}, + {"method": "GET", "path": "api/campaigns/list"}, +] + +response = self.client.batches.request(params) +``` + +## Webhooks + + +### List all webhooks + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +response = client.webhooks.list() +``` + +### Get a webhook + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +webhook_id = 123456 + +response = client.webhooks.get(webhook_id) +``` + +### Create a webhook + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +events = ["subscriber.created", "subscriber.updated", "subscriber.unsubscribed"] +url = "https://my-url.com" +name = "Webhook name" + +response = client.webhooks.create(events, url, name) +``` + +### Update a webhook + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({"api_key": "your-api-key"}) + +webhook_id = 123456 +events = ["subscriber.created", "subscriber.updated", "subscriber.unsubscribed"] +url = "https://my-url.com" +name = "Webhook name" enabled = False response = client.webhooks.update(webhook_id, events, url, name, enabled) @@ -861,9 +1832,7 @@ response = client.webhooks.update(webhook_id, events, url, name, enabled) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) webhook_id = 123456 @@ -879,9 +1848,7 @@ response = client.webhooks.delete(webhook_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) response = client.timezone.list() ``` @@ -895,9 +1862,7 @@ response = client.timezone.list() ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) response = client.campaigns.languages() ``` diff --git a/VERSION b/VERSION index b1e80bb..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.3.0 diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index 08810f2..45a473f 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -3,12 +3,28 @@ from __future__ import absolute_import from mailerlite.api_client import ApiClient +from mailerlite.exceptions import ( # noqa: F401 + ApiError, + EmptyIdError, + InvalidIdError, +) +from mailerlite.response import ApiResponse # noqa: F401 from mailerlite.sdk.automations import Automations from mailerlite.sdk.batch import Batches +from mailerlite.sdk.brand_styles import BrandStyles # import all api files into api package from mailerlite.sdk.campaigns import Campaigns +from mailerlite.sdk.emails import ( # noqa: F401 + EDITOR_SIMPLE, + VERSION_CONFLICT, + BlockPosition, + BlockType, + BrandMode, + Emails, +) from mailerlite.sdk.fields import Fields +from mailerlite.sdk.files import FileSort, Files, FileType # noqa: F401 from mailerlite.sdk.forms import Forms from mailerlite.sdk.groups import Groups from mailerlite.sdk.segments import Segments @@ -29,6 +45,9 @@ def __init__(self, config={}): self.fields = Fields(self.api_client) self.forms = Forms(self.api_client) self.campaigns = Campaigns(self.api_client) + self.emails = Emails(self.api_client) + self.brand_styles = BrandStyles(self.api_client) + self.files = Files(self.api_client) self.timezones = Timezones(self.api_client) self.webhooks = Webhooks(self.api_client) self.automations = Automations(self.api_client) diff --git a/mailerlite/api_client.py b/mailerlite/api_client.py index e487c10..38a3b0e 100644 --- a/mailerlite/api_client.py +++ b/mailerlite/api_client.py @@ -6,6 +6,8 @@ from functools import partialmethod from typing import Optional, Tuple +from mailerlite.exceptions import ApiError + class HttpMethods(object): """Allowed Http methods""" @@ -13,10 +15,11 @@ class HttpMethods(object): GET: str = "get" POST: str = "post" PUT: str = "put" + PATCH: str = "patch" DELETE: str = "delete" # Allowed methods - ALLOWED: Tuple[str, ...] = (GET, POST, PUT, DELETE) + ALLOWED: Tuple[str, ...] = (GET, POST, PUT, PATCH, DELETE) @classmethod def _is_method(cls, method: str, target: str) -> bool: @@ -26,6 +29,7 @@ def _is_method(cls, method: str, target: str) -> bool: is_get: partialmethod = partialmethod(_is_method, target=GET) is_post: partialmethod = partialmethod(_is_method, target=POST) is_put: partialmethod = partialmethod(_is_method, target=PUT) + is_patch: partialmethod = partialmethod(_is_method, target=PATCH) is_delete: partialmethod = partialmethod(_is_method, target=DELETE) @@ -73,24 +77,32 @@ def request( path: str, query_params: Optional[dict] = None, body: Optional[dict] = None, + headers: Optional[dict] = None, ) -> requests.models.Response: - """Requests Wrapper""" + """Requests Wrapper + + `headers` are added to the default headers for this request only. + """ method = method.lower() if method not in HttpMethods.ALLOWED: - raise ValueError("http method must be `POST`, `GET`, `PUT` or `DELETE`.") + raise ValueError( + "http method must be `POST`, `GET`, `PUT`, `PATCH` or `DELETE`." + ) kwargs = { "method": method, "url": urljoin(self.HOST, path), "params": query_params, - "headers": self.headers, + "headers": {**self.headers, **(headers or {})}, "timeout": self.timeout, } if HttpMethods.is_get(method): kwargs.update(allow_redirects=True) - if HttpMethods.is_post(method) or HttpMethods.is_put(method): + # Without a body, send no body and not the JSON `null` + if not HttpMethods.is_get(method) and body is not None: kwargs.update(data=json.dumps(body)) response = requests.request(**kwargs) - response.raise_for_status() + if 400 <= response.status_code < 600: + raise ApiError(response) return response diff --git a/mailerlite/exceptions.py b/mailerlite/exceptions.py new file mode 100644 index 0000000..72e898e --- /dev/null +++ b/mailerlite/exceptions.py @@ -0,0 +1,53 @@ +from __future__ import absolute_import + +import requests + + +class ApiError(requests.HTTPError): + """ + The API returned a 4xx or 5xx status code + + It is a `requests.HTTPError`, so `e.response` gives the full response. + The fields are `None` when the response body does not have them. + """ + + def __init__(self, response): + self.status_code = response.status_code + + body = _json_or_none(response) + if not isinstance(body, dict): + body = {} + + self.message = body.get("message") + self.code = body.get("code") + self.etag = body.get("etag") + self.errors = body.get("errors") + + kind = "Client Error" if response.status_code < 500 else "Server Error" + text = ( + f"{response.status_code} {kind}: {response.reason} for url: {response.url}" + ) + if self.message: + detail = f"{self.code}: {self.message}" if self.code else self.message + text = f"{text} ({detail})" + + super().__init__(text, response=response) + + def __reduce__(self): + # pickle calls cls(*self.args) by default, but __init__ needs the response + return self.__class__, (self.response,) + + +def _json_or_none(response): + try: + return response.json() + except ValueError: + return None + + +class EmptyIdError(ValueError): + """An ID for the URL path is empty. The SDK sends no request.""" + + +class InvalidIdError(ValueError): + """An ID for the URL path does not contain only digits. The SDK sends no request.""" diff --git a/mailerlite/response.py b/mailerlite/response.py new file mode 100644 index 0000000..3b88d6a --- /dev/null +++ b/mailerlite/response.py @@ -0,0 +1,48 @@ +from __future__ import absolute_import + +import copy + + +class ApiResponse(dict): + """ + The decoded JSON body of a response + + It is a dict, so you use it like the dicts that the other methods return. + It also has the status code and the headers of the response. + A response without a body, for example `204`, is an empty dict. + """ + + def __init__(self, response): + super().__init__(_json_object(response)) + self.status_code = response.status_code + self.headers = response.headers + + def copy(self): + """A shallow copy that keeps the status code and the headers""" + + return copy.copy(self) + + @property + def is_idempotent_replay(self): + """ + True if the API returned the stored response of an earlier request + with the same idempotency key + """ + + # A proxy can change the case of the value + value = self.headers.get("Idempotent-Replayed", "") + return value.strip().lower() == "true" + + +def _json_object(response): + if not response.content: + return {} + + body = response.json() + if not isinstance(body, dict): + raise TypeError( + f"The API returned a JSON {type(body).__name__}, not a JSON object, " + f"with status {response.status_code} for url: {response.url}" + ) + + return body diff --git a/mailerlite/sdk/__init__.py b/mailerlite/sdk/__init__.py index b07aabd..d8af9ca 100644 --- a/mailerlite/sdk/__init__.py +++ b/mailerlite/sdk/__init__.py @@ -1,8 +1,11 @@ from __future__ import absolute_import # import all api files into api package +from mailerlite.sdk.brand_styles import BrandStyles from mailerlite.sdk.campaigns import Campaigns +from mailerlite.sdk.emails import Emails from mailerlite.sdk.fields import Fields +from mailerlite.sdk.files import Files from mailerlite.sdk.forms import Forms from mailerlite.sdk.groups import Groups from mailerlite.sdk.segments import Segments diff --git a/mailerlite/sdk/_params.py b/mailerlite/sdk/_params.py new file mode 100644 index 0000000..b868496 --- /dev/null +++ b/mailerlite/sdk/_params.py @@ -0,0 +1,96 @@ +from __future__ import absolute_import + +import re + +from mailerlite.exceptions import EmptyIdError, InvalidIdError + +_ID_PATTERN = re.compile(r"[0-9]+") + + +def path_id(value, name): + if value is None or value == "": + raise EmptyIdError(f"`{name}` must not be empty.") + + if isinstance(value, bool) or not isinstance(value, (str, int)): + raise TypeError( + f"`{name}` type is not valid. Expected `str` or `int`, got {type(value)}." + ) + + value = str(value) + # fullmatch, because `$` also matches before a final new line, + # and str.isdigit() also accepts digits that are not ASCII. + if not _ID_PATTERN.fullmatch(value): + raise InvalidIdError(f"`{name}` must contain only digits, got {value!r}.") + + return value + + +def require_dict(value, name): + if not isinstance(value, dict): + raise TypeError( + f"`{name}` type is not valid. Expected `dict`, got {type(value)}." + ) + + +def without_none(params): + return {key: value for key, value in params.items() if value is not None} + + +def reject_unknown(kwargs, available_params): + for key in kwargs: + if key not in available_params: + raise TypeError("Got an unknown argument '%s'" % key) + + +def query_value(value): + """A query string value: booleans as 1/0, lists comma-joined""" + + if isinstance(value, bool): + return int(value) + + if isinstance(value, (list, tuple)): + return ",".join(str(query_value(item)) for item in value) + + return value + + +def filter_params(filter): + if filter is None: + return {} + + require_dict(filter, "filter") + + return { + f"filter[{key}]": query_value(value) + for key, value in without_none(filter).items() + } + + +def query_params(kwargs, available_params): + """The query string of a method with **kwargs. `None` values are not sent.""" + + reject_unknown(kwargs, available_params) + + params = {} + for key, value in kwargs.items(): + if key == "filter": + params.update(filter_params(value)) + elif value is not None: + params[key] = query_value(value) + + return params + + +def body_params(kwargs, available_params): + """The JSON body of a method with **kwargs. `None` values are not sent.""" + + reject_unknown(kwargs, available_params) + + return without_none(kwargs) + + +def idempotency_headers(key): + if key is None: + return None + + return {"Idempotency-Key": key} diff --git a/mailerlite/sdk/automations.py b/mailerlite/sdk/automations.py index 94b33a0..fd75038 100644 --- a/mailerlite/sdk/automations.py +++ b/mailerlite/sdk/automations.py @@ -1,9 +1,12 @@ from __future__ import absolute_import +from mailerlite.sdk._params import body_params, query_params, query_value + class Automations(object): # Automations base API uri base_api_url = "api/automations" + templates_api_url = "api/automation-templates" def __init__(self, api_client): """ @@ -18,26 +21,15 @@ def list(self, **kwargs): Lists all automations on account Ref: https://developers.mailerlite.com/docs/automations.html#list-all-automations - :param **kwargs: You can pass additional arguments - page, limit or to filter by status, name and group + :param **kwargs: You can pass additional arguments - page, limit, sort, with_steps or to filter by folder, group, enabled, status, name, automation_state and drafts_state + :raises: :class: `TypeError` : Got an unknown argument :return: JSON array :rtype: dict """ - available_params = ["filter", "page", "limit"] - - params = locals() - query_params = {} - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) + params = query_params(kwargs, ["filter", "page", "limit", "sort", "with_steps"]) - if key == "filter": - for filter_key, filter_value in val.items(): - query_params[f"filter[{filter_key}]"] = filter_value - else: - query_params[key] = val - - return self.api_client.request("GET", self.base_api_url, query_params).json() + return self.api_client.request("GET", self.base_api_url, params).json() def get(self, automation_id): """ @@ -58,27 +50,703 @@ def activity(self, automation_id, **kwargs): Ref: https://developers.mailerlite.com/docs/automations.html#get-the-subscriber-activity-for-an-automation :param: automation_id: Automation ID - :param: kwargs: You can pass additional arguments - page, limit or to filter by status, date_from, date_to + :param: kwargs: You can pass additional arguments - page, limit, sort, with_subscribers or to filter by status, date_from, date_to, scheduled_from, scheduled_to and search. `with_subscribers=True` adds each subscriber's id, email and name + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + with_subscribers = kwargs.pop("with_subscribers", None) + params = query_params(kwargs, ["filter", "page", "limit", "sort"]) + if with_subscribers: + params["with_subscribers"] = 1 + + return self.api_client.request( + "GET", + f"{self.base_api_url}/{automation_id}/activity", + params, + ).json() + + def create(self, name): + """ + Create an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param name: str Automation name + :return: JSON dict + """ + + return self.api_client.request( + "POST", self.base_api_url, body={"name": name} + ).json() + + def update(self, automation_id, name, **kwargs): + """ + Update an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param name: str Automation name + :param **kwargs: You can pass additional arguments - trigger_data or triggers + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + body = {"name": name, **body_params(kwargs, ["trigger_data", "triggers"])} + + return self.api_client.request( + "PUT", f"{self.base_api_url}/{automation_id}", body=body + ).json() + + def delete(self, automation_id): + """ + Delete an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :raises: :class: `requests.HTTPError` : API request failed, e.g. automation was not found + :return: `true` if action was successful + :rtype: bool + """ + + self.api_client.request("DELETE", f"{self.base_api_url}/{automation_id}") + + return True + + def copy(self, automation_id): + """ + Copy an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :return: JSON dict + """ + + return self.api_client.request( + "POST", f"{self.base_api_url}/{automation_id}/copy" + ).json() + + def enable(self, automation_id): + """ + Enable an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :return: JSON dict + """ + + return self.api_client.request( + "POST", f"{self.base_api_url}/{automation_id}/enable" + ).json() + + def disable(self, automation_id): + """ + Disable an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :return: JSON dict + """ + + return self.api_client.request( + "POST", f"{self.base_api_url}/{automation_id}/disable" + ).json() + + def test(self, automation_id, email): + """ + Send a test of an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param email: str Address to send the test to + :raises: :class: `requests.HTTPError` : API request failed + :return: `true` if action was successful + :rtype: bool + """ + + self.api_client.request( + "POST", f"{self.base_api_url}/{automation_id}/test", body={"email": email} + ) + + return True + + def add_subscribers(self, automation_id, **kwargs): + """ + Add subscribers to an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param **kwargs: You can pass additional arguments - step_id, emails or groups + :raises: :class: `TypeError` : Got an unknown argument + :raises: :class: `requests.HTTPError` : API request failed + :return: `true` if action was successful + :rtype: bool + """ + + body = body_params(kwargs, ["step_id", "emails", "groups"]) + + self.api_client.request( + "POST", + f"{self.base_api_url}/{automation_id}/add-subscribers", + body=body, + ) + + return True + + def readd(self, automation_id, readd_subscribers_to, filter, **kwargs): + """ + Re-add subscribers to an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param readd_subscribers_to: str last_completed_step, last_failed_step or selected_step + :param filter: dict Selects which runs to re-add + :param **kwargs: You can pass additional arguments - selected_step_id + :raises: :class: `TypeError` : Got an unknown argument + :raises: :class: `requests.HTTPError` : API request failed + :return: `true` if action was successful + :rtype: bool + """ + + body = { + "readd_subscribers_to": readd_subscribers_to, + "filter": filter, + **body_params(kwargs, ["selected_step_id"]), + } + + self.api_client.request( + "POST", f"{self.base_api_url}/{automation_id}/readd", body=body + ) + + return True + + def activity_add_to_group(self, automation_id, destination_groups, filter): + """ + Copy automation subscribers to groups + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param destination_groups: list Group IDs to copy the subscribers into + :param filter: dict Selects which runs to copy subscribers from + :raises: :class: `requests.HTTPError` : API request failed + :return: `true` if action was successful + :rtype: bool + """ + + self.api_client.request( + "POST", + f"{self.base_api_url}/{automation_id}/activity/add-to-group", + body={"destination_groups": destination_groups, "filter": filter}, + ) + + return True + + def activity_cancel_automation(self, automation_id, filter): + """ + Cancel active automation runs + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param filter: dict Selects which active runs to cancel + :raises: :class: `requests.HTTPError` : API request failed + :return: `true` if action was successful + :rtype: bool + """ + + self.api_client.request( + "POST", + f"{self.base_api_url}/{automation_id}/activity/cancel-automation", + body={"filter": filter}, + ) + + return True + + def activity_skip_delay(self, automation_id, filter): + """ + Skip the delay of active automation runs + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param filter: dict Selects which active runs skip their delay + :raises: :class: `requests.HTTPError` : API request failed + :return: `true` if action was successful + :rtype: bool + """ + + self.api_client.request( + "POST", + f"{self.base_api_url}/{automation_id}/activity/skip-delay", + body={"filter": filter}, + ) + + return True + + def list_triggers(self, automation_id): + """ + List the triggers of an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/triggers" + ).json() + + def create_trigger(self, automation_id, type, **kwargs): + """ + Create an automation trigger + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param type: str Trigger type + :param **kwargs: You can pass additional arguments - data + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + body = {"type": type, **body_params(kwargs, ["data"])} + + return self.api_client.request( + "POST", f"{self.base_api_url}/{automation_id}/triggers", body=body + ).json() + + def get_trigger(self, automation_id, trigger_id): + """ + Get an automation trigger + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param trigger_id: Trigger ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/triggers/{trigger_id}" + ).json() + + def update_trigger(self, automation_id, trigger_id, **kwargs): + """ + Update an automation trigger + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param trigger_id: Trigger ID + :param **kwargs: You can pass additional arguments - data + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + return self.api_client.request( + "PUT", + f"{self.base_api_url}/{automation_id}/triggers/{trigger_id}", + body=body_params(kwargs, ["data"]), + ).json() + + def delete_trigger(self, automation_id, trigger_id): + """ + Delete an automation trigger + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param trigger_id: Trigger ID + :raises: :class: `requests.HTTPError` : API request failed, e.g. trigger was not found + :return: `true` if action was successful + :rtype: bool + """ + + self.api_client.request( + "DELETE", f"{self.base_api_url}/{automation_id}/triggers/{trigger_id}" + ) + + return True + + def list_steps(self, automation_id): + """ + List the steps of an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/steps/index" + ).json() + + def create_step(self, automation_id, type, **kwargs): + """ + Create an automation step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param type: str Step type + :param **kwargs: You can pass additional arguments - parent_id, branch, move_to_branch or data :raises: :class: `TypeError` : Got an unknown argument :return: JSON dict """ - available_params = ["filter", "page", "limit"] + body = { + "type": type, + **body_params(kwargs, ["parent_id", "branch", "move_to_branch", "data"]), + } - params = locals() - query_params = {} - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) + return self.api_client.request( + "POST", f"{self.base_api_url}/{automation_id}/steps", body=body + ).json() - if key == "filter": - for filter_key, filter_value in val.items(): - query_params[f"filter[{filter_key}]"] = filter_value - else: - query_params[key] = val + def get_step(self, automation_id, step_id): + """ + Get an automation step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param step_id: Step ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/steps/{step_id}" + ).json() + + def update_step(self, automation_id, step_id, **kwargs): + """ + Update an automation step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param step_id: Step ID + :param **kwargs: You can pass additional arguments - parent_id, data or reschedule_waiting_subscribers + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + body = body_params( + kwargs, ["parent_id", "data", "reschedule_waiting_subscribers"] + ) + + return self.api_client.request( + "PUT", + f"{self.base_api_url}/{automation_id}/steps/{step_id}", + body=body, + ).json() + + def delete_step(self, automation_id, step_id, **kwargs): + """ + Delete an automation step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param step_id: Step ID + :param **kwargs: You can pass additional arguments - branch_to_delete + :raises: :class: `TypeError` : Got an unknown argument + :raises: :class: `requests.HTTPError` : API request failed, e.g. step was not found + :return: `true` if action was successful + :rtype: bool + """ + + self.api_client.request( + "DELETE", + f"{self.base_api_url}/{automation_id}/steps/{step_id}", + query_params(kwargs, ["branch_to_delete"]), + ) + + return True + + def copy_step(self, automation_id, step_id, **kwargs): + """ + Copy an automation step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param step_id: Step ID + :param **kwargs: You can pass additional arguments - parent_id, branch or move_to_branch + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + body = body_params(kwargs, ["parent_id", "branch", "move_to_branch"]) + + return self.api_client.request( + "POST", + f"{self.base_api_url}/{automation_id}/steps/{step_id}/copy", + body=body, + ).json() + + def reorder_steps(self, automation_id, dragged_step_id, **kwargs): + """ + Move an automation step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param dragged_step_id: Step ID of the step to move + :param **kwargs: You can pass additional arguments - new_parent_step_id, branch or branch_for_child + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + body = { + "dragged_step_id": dragged_step_id, + **body_params(kwargs, ["new_parent_step_id", "branch", "branch_for_child"]), + } + + return self.api_client.request( + "POST", + f"{self.base_api_url}/{automation_id}/steps/reorder", + body=body, + ).json() + + def select_ab_test_winner(self, automation_id, step_id, ab_test_winner): + """ + Select the winner of an A/B test step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param step_id: Step ID + :param ab_test_winner: str a_path, b_path or c_path + :return: JSON dict + """ + + return self.api_client.request( + "PUT", + f"{self.base_api_url}/{automation_id}/steps/{step_id}/select-ab-test-winner", + body={"ab_test_winner": ab_test_winner}, + ).json() + + def get_email_content(self, automation_id, email_id): + """ + Get the HTML content of an automation email + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param email_id: Email ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/emails/{email_id}/content" + ).json() + + def update_email_content(self, automation_id, email_id, html): + """ + Update the HTML content of an automation email + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param email_id: Email ID + :param html: str HTML content of the email + :return: JSON dict + """ + + return self.api_client.request( + "PUT", + f"{self.base_api_url}/{automation_id}/emails/{email_id}/content", + body={"html": html}, + ).json() + + def email_report_stats(self, automation_id, email_id, **kwargs): + """ + Get the report stats of an automation email + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param email_id: Email ID + :param **kwargs: You can pass additional arguments - start_date, end_date or shop_id + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ return self.api_client.request( "GET", - f"{self.base_api_url}/{automation_id}/activity", - query_params, + f"{self.base_api_url}/{automation_id}/emails/{email_id}/report-stats", + query_params(kwargs, ["start_date", "end_date", "shop_id"]), + ).json() + + def email_unsubscribe_reasons(self, automation_id, email_id): + """ + Get the unsubscribe reasons of an automation email + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param email_id: Email ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", + f"{self.base_api_url}/{automation_id}/emails/{email_id}/unsubscribe-reasons", + ).json() + + def list_templates(self, **kwargs): + """ + List automation templates + Ref: https://developers.mailerlite.com/docs/automations.html + + :param **kwargs: You can pass additional arguments - page, limit or to filter by type and category + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + return self.api_client.request( + "GET", + self.templates_api_url, + query_params(kwargs, ["filter", "page", "limit"]), + ).json() + + def select_template(self, template_id): + """ + Create an automation from a template + Ref: https://developers.mailerlite.com/docs/automations.html + + :param template_id: Automation template ID + :return: JSON dict + """ + + return self.api_client.request( + "POST", f"{self.base_api_url}/select-template/{template_id}" + ).json() + + def steps_run_counts(self, automation_id): + """ + Get the run counts of all steps of an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/steps/run-counts" + ).json() + + def step_run_counts(self, automation_id, step_id): + """ + Get the run counts of one automation step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param step_id: Step ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/steps/run-counts/{step_id}" + ).json() + + def triggers_run_counts(self, automation_id): + """ + Get the run counts of all triggers of an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/triggers/run-counts" + ).json() + + def trigger_run_counts(self, automation_id, trigger_id): + """ + Get the run counts of one automation trigger + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param trigger_id: Trigger ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", + f"{self.base_api_url}/{automation_id}/triggers/run-counts/{trigger_id}", + ).json() + + def ab_steps_stats(self, automation_id): + """ + Get the stats of all A/B test steps of an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/ab-steps-stats" + ).json() + + def ab_step_stats(self, automation_id, step_id): + """ + Get the stats of one A/B test step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param step_id: Step ID + :return: JSON dict + """ + + return self.api_client.request( + "GET", f"{self.base_api_url}/{automation_id}/ab-steps-stats/{step_id}" + ).json() + + def step_subscribers(self, automation_id, step_id, status, **kwargs): + """ + List the subscribers on an automation step + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param step_id: Step ID + :param status: str queued or completed + :param **kwargs: You can pass additional arguments - page, limit, sort or to filter by date_from, date_to, search and id + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + return self._activity_subscribers( + automation_id, "steps", step_id, status, kwargs + ) + + def trigger_subscribers(self, automation_id, trigger_id, status, **kwargs): + """ + List the subscribers on an automation trigger + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param trigger_id: Trigger ID + :param status: str queued or completed + :param **kwargs: You can pass additional arguments - page, limit, sort or to filter by date_from, date_to, search and id + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + return self._activity_subscribers( + automation_id, "triggers", trigger_id, status, kwargs + ) + + def email_monthly_totals(self, automation_id, **kwargs): + """ + Get the monthly email totals of an automation + Ref: https://developers.mailerlite.com/docs/automations.html + + :param automation_id: Automation ID + :param **kwargs: You can pass additional arguments - start_date, end_date or shop_id + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON dict + """ + + return self.api_client.request( + "GET", + f"{self.base_api_url}/{automation_id}/monthly-totals/email", + query_params(kwargs, ["start_date", "end_date", "shop_id"]), + ).json() + + def _activity_subscribers(self, automation_id, kind, item_id, status, kwargs): + params = { + "status": query_value(status), + **query_params(kwargs, ["filter", "page", "limit", "sort"]), + } + + return self.api_client.request( + "GET", + f"{self.base_api_url}/{automation_id}/activity/{kind}/{item_id}/subscribers", + params, ).json() diff --git a/mailerlite/sdk/brand_styles.py b/mailerlite/sdk/brand_styles.py new file mode 100644 index 0000000..b0633c4 --- /dev/null +++ b/mailerlite/sdk/brand_styles.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import + +from mailerlite.response import ApiResponse + + +class BrandStyles(object): + # Brand styles base API uri + base_api_url = "api/brand-styles" + + def __init__(self, api_client): + self.api_client = api_client + + def get(self): + """ + Get the brand styles + + Returns the palettes of the account: `brand` (the brand styles of the + account, or `None` if it has none), `default` and the `presets`. + Match a palette on its `identifier`, not on its `name`. + + :raises: :class: `ApiError` : API request failed + :return: JSON object + :rtype: ApiResponse + """ + + return ApiResponse(self.api_client.request("GET", self.base_api_url)) diff --git a/mailerlite/sdk/campaigns.py b/mailerlite/sdk/campaigns.py index e1e3a39..7c5b0cb 100644 --- a/mailerlite/sdk/campaigns.py +++ b/mailerlite/sdk/campaigns.py @@ -13,6 +13,12 @@ def create(self, campaign): Create a campaign Ref: https://developers.mailerlite.com/docs/campaigns.html#create-a-campaign + To build the emails with the simple editor, set `editor` to `EDITOR_SIMPLE` + in each email, and do not send `content`. Then edit the emails with + `client.emails`. `template_id` is optional: a shared template of the + simple editor to start from. It needs `editor`. All emails of the campaign + must have the same `editor` and `template_id`. + :param campaign: dict A dict that contains all campaign related arguments :return: JSON array :rtype: dict @@ -27,6 +33,8 @@ def update(self, campaign_id, campaign): Provides ability to update single campaign. Ref: https://developers.mailerlite.com/docs/campaigns.html#update-campaign + Only `create()` accepts `editor` and `template_id` in the emails. + :param campaign_id: int Id of the campaign :param campaign: dict A dict that contains all campaign related arguments :return: JSON array diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py new file mode 100644 index 0000000..7ac3e67 --- /dev/null +++ b/mailerlite/sdk/emails.py @@ -0,0 +1,533 @@ +from __future__ import absolute_import + +from mailerlite.response import ApiResponse +from mailerlite.sdk import _params + +# The error code of a write with an etag that is not the current etag of the email +VERSION_CONFLICT = "version_conflict" + +# The value of `editor` in the emails of `campaigns.create()` for the simple editor +EDITOR_SIMPLE = "simple" + + +class BlockType(object): + """Block types. The API can also return types that are not in this list.""" + + BLOCKQUOTE = "blockquote" + BULLETED_LIST = "bulleted_list" + BUTTON = "button" + CODE = "code" + DIVIDER = "divider" + HEADING = "heading" + IMAGE = "image" + NUMBERED_LIST = "numbered_list" + PARAGRAPH = "paragraph" + SOCIAL_ICON = "social_icon" + # Only reads return this type. You cannot insert a block of this type. + TITLE = "title" + VIDEO = "video" + + +class BlockPosition(object): + """ + Positions of a block. `BEFORE` and `AFTER` also need a `block_id`. + The API ignores the `block_id` of `START` and `END`. + """ + + START = "start" + END = "end" + BEFORE = "before" + AFTER = "after" + + +class BrandMode(object): + """Where the palette of an email comes from, in `settings["brand"]["mode"]`""" + + ACCOUNT = "account" + TEMPLATE = "template" + CUSTOM = "custom" + + +class Emails(object): + """ + Emails built with the simple editor + + To create such an email, create a campaign with `"editor": "simple"`. + IDs are strings of digits. You can also give them as int. + + Idempotency keys: if a write fails with a network error, you do not know if + the API did the write. Give an `idempotency_key` to the write, and retry with + the same key and the same arguments. If the first request succeeded, the API + returns the stored response and does not do the write two times. Then + `is_idempotent_replay` of the response is true. Use a new key for each write. + The API keeps a key for 24 hours. The SDK sends the key as you give it, + and the API checks it. If `requests` cannot send the key in a header, + for example a key with a new line, it raises `requests.exceptions.InvalidHeader`. + + Before the request, the SDK checks only the IDs in the URL path and the + `settings` of `update_settings()`. The API checks all other values. + """ + + # Emails base API uri + base_api_url = "api/emails" + + def __init__(self, api_client): + self.api_client = api_client + + def get(self, email_id): + """ + Get an email + + Returns the email with its settings and blocks, and the etag of the email. + + :param email_id: str|int Email ID + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. email was not found + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + + return ApiResponse( + self.api_client.request("GET", f"{self.base_api_url}/{email_id}") + ) + + def get_html(self, email_id): + """ + Get the HTML of an email + + Returns the HTML that the email sends, and the etag of the email. + The API can return a temporary `503` error. + + :param email_id: str|int Email ID + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. email was not found + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + + return ApiResponse( + self.api_client.request("GET", f"{self.base_api_url}/{email_id}/html") + ) + + def get_block(self, email_id, block_id): + """ + Get a block of an email + + Returns the block, its place in the email, and the etag of the email. + + :param email_id: str|int Email ID + :param block_id: str|int Block ID + :raises: :class: `TypeError` : `email_id` or `block_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `block_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. block was not found + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + + return ApiResponse( + self.api_client.request( + "GET", f"{self.base_api_url}/{email_id}/blocks/{block_id}" + ) + ) + + def create_block( + self, + email_id, + etag, + block_type, + editor_content, + placement=None, + *, + idempotency_key=None, + ): + """ + Insert a block in an email + + Returns the new block and the new etag of the email. + + :param email_id: str|int Email ID + :param etag: str The etag from the last response for this email + :param block_type: str Block type, see `BlockType`. You cannot insert `title`. + :param editor_content: dict The content of the block. It can be empty. + Its keys are camelCase, for example `textAlign`. + :param placement: dict Optional. `{"position": ..., "block_id": ...}`, + see `BlockPosition`. Without it, the API puts the block at the end. + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + body = _block_body(block_type, editor_content, placement) + body["etag"] = etag + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/blocks", + body=body, + headers=headers, + ) + ) + + def update_block( + self, email_id, block_id, etag, editor_content, *, idempotency_key=None + ): + """ + Update a block of an email + + Replaces the whole content of the block. The API removes the keys that you + do not send. Returns the block and the new etag of the email. + + :param email_id: str|int Email ID + :param block_id: str|int Block ID + :param etag: str The etag from the last response for this email + :param editor_content: dict The new content of the block. + Its keys are camelCase, for example `textAlign`. + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `block_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `block_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + body = {"editor_content": editor_content, "etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "PATCH", + f"{self.base_api_url}/{email_id}/blocks/{block_id}", + body=body, + headers=headers, + ) + ) + + def delete_block(self, email_id, block_id, etag, *, idempotency_key=None): + """ + Delete a block of an email + + Also deletes the blocks in it. Returns the new etag of the email, + so it returns the response and not `True` like the other deletes. + + :param email_id: str|int Email ID + :param block_id: str|int Block ID + :param etag: str The etag from the last response for this email + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `block_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `block_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object with only the new etag + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + body = {"etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "DELETE", + f"{self.base_api_url}/{email_id}/blocks/{block_id}", + body=body, + headers=headers, + ) + ) + + def move_block(self, email_id, block_id, etag, placement, *, idempotency_key=None): + """ + Move a block of an email + + Returns the block, its new place in the email, and the new etag of the email. + + :param email_id: str|int Email ID + :param block_id: str|int Block ID + :param etag: str The etag from the last response for this email + :param placement: dict `{"position": ..., "block_id": ...}`, + see `BlockPosition` + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `block_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `block_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + body = {"placement": placement, "etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/blocks/{block_id}/move", + body=body, + headers=headers, + ) + ) + + def validate_block(self, block_type, editor_content, placement=None): + """ + Validate a block + + Checks a block without an email and without an etag. An invalid block + is not an error: the response has `valid` set to false and the `errors`. + + :param block_type: str Block type, see `BlockType` + :param editor_content: dict The content of the block + :param placement: dict Optional. `{"position": ..., "block_id": ...}`, + see `BlockPosition` + :raises: :class: `ApiError` : API request failed + :return: JSON object with `valid`, `errors` and `schema` + :rtype: ApiResponse + """ + + body = _block_body(block_type, editor_content, placement) + + return ApiResponse( + self.api_client.request( + "POST", f"{self.base_api_url}/blocks/validate", body=body + ) + ) + + def update_settings(self, email_id, etag, settings, *, idempotency_key=None): + """ + Update the design settings of an email + + Send only the keys to change, in the shape of `settings` in `get()`. + The API stores each value that you send, also a value that the email + inherits from the account. + An empty string unsets a brand colour, and the email then uses the + account value. An empty editor document, `{'editor_content': {}}` or + `{'editor_content': {'content': []}}`, unsets a footer text. Other + colours, sizes, `button.border_radius` and `brand.style` cannot be + empty: an empty string gives a `422` error. + `footer.social_links` replaces the whole list: keep the `link_id` of + each link to keep its click history. + + The response can have `rejected` when the API removed unsafe values. + + :param email_id: str|int Email ID + :param etag: str The etag from the last response for this email + :param settings: dict The settings to change, without `etag` + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `settings` type is not valid + :raises: :class: `ValueError` : `settings` contains `etag` + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + body = {**_settings(settings), "etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "PATCH", + f"{self.base_api_url}/{email_id}/settings", + body=body, + headers=headers, + ) + ) + + def validate_settings(self, email_id, settings): + """ + Validate a change to the design settings of an email + + Checks the settings with the rules of `update_settings()` and saves nothing. + It needs no etag. Invalid settings are not an error: the response has + `valid` set to false and the `errors`. + + :param email_id: str|int Email ID + :param settings: dict The settings that you want to send to `update_settings()` + :raises: :class: `TypeError` : `email_id` or `settings` type is not valid + :raises: :class: `ValueError` : `settings` contains `etag` + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed + :return: JSON object with `valid`, `errors`, `schema` and maybe `rejected` + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + body = _settings(settings) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/settings/validate", + body=body, + ) + ) + + def create_template(self, email_id, name, *, idempotency_key=None): + """ + Save an email as a template + + Copies the email to a new template with the given name. + The API cannot delete templates. + + :param email_id: str|int Email ID + :param name: str Name of the template + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/templates", + body={"name": name}, + headers=headers, + ) + ) + + def list_versions(self, email_id, page=None, limit=None): + """ + List the versions of an email + + Returns the saved versions, newest first. `current` marks the version + that the email has now. + + :param email_id: str|int Email ID + :param page: int Optional. Page number + :param limit: int Optional. Number of versions on a page + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + query_params = _params.without_none({"page": page, "limit": limit}) + + return ApiResponse( + self.api_client.request( + "GET", f"{self.base_api_url}/{email_id}/versions", query_params + ) + ) + + def create_version(self, email_id, *, idempotency_key=None): + """ + Save a version of an email + + Saves the content and the settings of the email as they are now. + If nothing changed after the last version, the API saves no new version + and returns the last version. The status code is `201` for a new version + and `200` for the last version. Both are a success. + + :param email_id: str|int Email ID + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `ApiError` : API request failed + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", f"{self.base_api_url}/{email_id}/versions", headers=headers + ) + ) + + def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): + """ + Restore a version of an email + + Restores the email to the version, and returns the email in the shape + of `get()` with the new etag. Before the restore, the API saves the + current state as a version, so you can undo the restore. + + :param email_id: str|int Email ID + :param version_id: str|int Version ID + :param etag: str The etag from the last response for this email + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring + :raises: :class: `TypeError` : `email_id` or `version_id` type is not valid + :raises: :class: `EmptyIdError` : `email_id` or `version_id` is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _params.path_id(email_id, "email_id") + version_id = _params.path_id(version_id, "version_id") + body = {"etag": etag} + headers = _params.idempotency_headers(idempotency_key) + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/versions/{version_id}/restore", + body=body, + headers=headers, + ) + ) + + +def _block_body(block_type, editor_content, placement): + body = {"type": block_type, "editor_content": editor_content} + if placement is not None: + body["placement"] = placement + + return body + + +def _settings(settings): + _params.require_dict(settings, "settings") + # The body of update_settings() has the settings and the etag at the same level + if "etag" in settings: + raise ValueError("`settings` must not contain `etag`. Use the `etag` argument.") + + return settings diff --git a/mailerlite/sdk/files.py b/mailerlite/sdk/files.py new file mode 100644 index 0000000..cffe2ed --- /dev/null +++ b/mailerlite/sdk/files.py @@ -0,0 +1,58 @@ +from __future__ import absolute_import + +from mailerlite.response import ApiResponse +from mailerlite.sdk import _params + + +class FileType(object): + """File types for `filter={"type": ...}`""" + + IMAGE = "image" + PDF = "pdf" + EPUB = "epub" + + +class FileSort(object): + """Sort values. The values that start with `-` sort in descending order.""" + + CREATED_AT = "created_at" + CREATED_AT_DESCENDING = "-created_at" + NAME = "name" + NAME_DESCENDING = "-name" + SIZE = "size" + SIZE_DESCENDING = "-size" + + +class Files(object): + # Files base API uri + base_api_url = "api/files" + + def __init__(self, api_client): + self.api_client = api_client + + def list(self, filter=None, sort=None, page=None, limit=None): + """ + List the files + + Returns the files of the media library. Use their `url` in image blocks. + `width` and `height` are `None` for files that are not images. + + :param filter: dict Optional. `name` (part of the file name) and `type` + (see `FileType`) + :param sort: str Optional. See `FileSort`. The default is `-created_at`. + :param page: int Optional. Page number + :param limit: int Optional. Number of files on a page, from 1 to 100 + :raises: :class: `TypeError` : `filter` type is not valid + :raises: :class: `ApiError` : API request failed, e.g. the type is not valid + :return: JSON object + :rtype: ApiResponse + """ + + query_params = { + **_params.filter_params(filter), + **_params.without_none({"sort": sort, "page": page, "limit": limit}), + } + + return ApiResponse( + self.api_client.request("GET", self.base_api_url, query_params) + ) diff --git a/mailerlite/sdk/groups.py b/mailerlite/sdk/groups.py index 13fa0fe..d40833f 100644 --- a/mailerlite/sdk/groups.py +++ b/mailerlite/sdk/groups.py @@ -1,5 +1,7 @@ from __future__ import absolute_import +from mailerlite.sdk._params import body_params, query_params + class Groups(object): # Groups base API uri @@ -21,20 +23,11 @@ def list(self, **kwargs): :rtype: dict """ - available_params = ["list", "limit", "page", "sort", "filter"] - - params = locals() - query_params = {} - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) - if key == "filter": - for filter_key, filter_value in val.items(): - query_params[f"filter[{filter_key}]"] = filter_value - else: - query_params[key] = val - - return self.api_client.request("GET", self.base_api_url, query_params).json() + return self.api_client.request( + "GET", + self.base_api_url, + query_params(kwargs, ["limit", "page", "sort", "filter"]), + ).json() def create(self, name): """ @@ -112,7 +105,7 @@ def delete(self, group_id): return True - def import_subscribers_to_group(self, group_id, subscribers): + def import_subscribers_to_group(self, group_id, subscribers, **kwargs): """ Import bulk subscribers to a group @@ -121,9 +114,11 @@ def import_subscribers_to_group(self, group_id, subscribers): :param group_id: int Group ID :param subscribers: list List of subscriber objects to import + :param **kwargs: You can pass additional arguments - resubscribe or trigger_automations :raises: :class: `TypeError` : `group_id` type is not valid :raises: :class: `TypeError` : `subscribers` type is not valid - :return: JSON array + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON array with `import_progress_url`. Poll `subscribers.get_import` for progress :rtype: dict """ @@ -137,10 +132,15 @@ def import_subscribers_to_group(self, group_id, subscribers): f"`subscribers` type is not valid. Expected `list`, got {type(subscribers)}." ) + body = { + "subscribers": subscribers, + **body_params(kwargs, ["resubscribe", "trigger_automations"]), + } + return self.api_client.request( "POST", - f"{self.base_api_url}/{group_id}/subscribers/import", - body={"subscribers": subscribers}, + f"{self.base_api_url}/{group_id}/import-subscribers", + body=body, ).json() def get_group_subscribers(self, group_id, **kwargs): @@ -151,7 +151,7 @@ def get_group_subscribers(self, group_id, **kwargs): Ref: https://developers.mailerlite.com/docs/groups.html#get-subscribers-belonging-to-a-group :param group_id: int Group ID - :param **kwargs: You can pass additional arguments - cursor, limit, sort or to filter by status + :param **kwargs: You can pass additional arguments - cursor, limit, include or to filter by status :raises: :class: `TypeError` : Got an unknown argument :raises: :class: `TypeError` : `group_id` type is not valid :return: JSON array @@ -163,21 +163,8 @@ def get_group_subscribers(self, group_id, **kwargs): f"`group_id` type is not valid. Expected `int`, got {type(group_id)}." ) - available_params = ["filter", "limit", "cursor"] - - params = locals() - query_params = {} - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) - if key == "filter": - for filter_key, filter_value in val.items(): - query_params[f"filter[{filter_key}]"] = filter_value - else: - query_params[key] = val - return self.api_client.request( "GET", f"{self.base_api_url}/{group_id}/subscribers", - query_params, + query_params(kwargs, ["filter", "limit", "cursor", "include"]), ).json() diff --git a/mailerlite/sdk/segments.py b/mailerlite/sdk/segments.py index ae3f70c..a72ce31 100644 --- a/mailerlite/sdk/segments.py +++ b/mailerlite/sdk/segments.py @@ -1,5 +1,9 @@ from __future__ import absolute_import +import warnings + +from mailerlite.sdk._params import body_params, query_params + class Segments(object): # Segments base API uri @@ -15,22 +19,15 @@ def list(self, **kwargs): Returns a list of all segments. Ref: https://developers.mailerlite.com/docs/segments.html#list-all-segments - :param **kwargs: dict You can pass additional arguments - page and limit + :param **kwargs: dict You can pass additional arguments - page, limit and sort :raises: :class: `TypeError` : Got an unknown argument :return: JSON array :rtype: dict """ - available_params = ["limit", "page"] - - params = locals() - query_params = {} - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) - query_params[key] = val - - return self.api_client.request("GET", self.base_api_url, query_params).json() + return self.api_client.request( + "GET", self.base_api_url, query_params(kwargs, ["limit", "page", "sort"]) + ).json() def get(self, segment_id, **kwargs): """ @@ -40,7 +37,7 @@ def get(self, segment_id, **kwargs): Ref: https://developers.mailerlite.com/docs/segments.html#get-subscribers-belonging-to-a-segment :param segment_id: int Segment ID - :param **kwargs: dict You can pass additional arguments - after, limit or filter by status + :param **kwargs: dict You can pass additional arguments - limit or filter by status :raises: :class: `TypeError` : Got an unknown argument :raises: :class: `TypeError` : `segment_id` type is not valid :return: JSON array @@ -52,24 +49,10 @@ def get(self, segment_id, **kwargs): f"`segment_id` type is not valid. Expected `int`, got {type(segment_id)}." ) - if not isinstance(segment_id, int): - raise TypeError("Segment ID are not valid.") - - available_params = ["filter", "limit", "after"] - - params = locals() - query_params = {} - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) - if key == "filter": - for filter_key, filter_value in val.items(): - query_params[f"filter[{filter_key}]"] = filter_value - else: - query_params[key] = val - return self.api_client.request( - "GET", f"{self.base_api_url}/{segment_id}", query_params + "GET", + f"{self.base_api_url}/{segment_id}", + query_params(kwargs, ["filter", "limit"]), ).json() def get_subscribers(self, segment_id, **kwargs): @@ -80,7 +63,7 @@ def get_subscribers(self, segment_id, **kwargs): Ref: https://developers.mailerlite.com/docs/segments.html#get-subscribers-belonging-to-a-segment :param segment_id: int Segment ID - :param **kwargs: dict You can pass additional arguments - after, limit or filter by status + :param **kwargs: dict You can pass additional arguments - cursor, limit or filter by status. `after` is deprecated: the SDK sends it as `cursor` and warns :raises: :class: `TypeError` : Got an unknown argument :raises: :class: `TypeError` : `segment_id` type is not valid :return: JSON array @@ -95,20 +78,21 @@ def get_subscribers(self, segment_id, **kwargs): if not isinstance(segment_id, int): raise TypeError("Segment ID are not valid.") - available_params = ["filter", "limit", "after"] - - params = locals() - query_params = {} - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) - query_params[key] = val + if "after" in kwargs: + warnings.warn( + "`after` is deprecated and the API ignores it. Use `cursor`.", + DeprecationWarning, + stacklevel=2, + ) + kwargs.setdefault("cursor", kwargs.pop("after")) return self.api_client.request( - "GET", f"{self.base_api_url}/{segment_id}/subscribers", query_params + "GET", + f"{self.base_api_url}/{segment_id}/subscribers", + query_params(kwargs, ["filter", "limit", "cursor"]), ).json() - def update(self, segment_id, name): + def update(self, segment_id, name, **kwargs): """ Update a segment @@ -117,8 +101,10 @@ def update(self, segment_id, name): :param segment_id: int Segment ID :param name: str Maximum length of 255 characters + :param **kwargs: dict You can pass additional arguments - filter :raises: :class: `ValueError` : `name` cannot exceed 255 characters :raises: :class: `TypeError` : `segment_id` type is not valid + :raises: :class: `TypeError` : Got an unknown argument :raises: :class: `requests.HTTPError` : API request failed, e.g. segment was not found :return: `true` if action was successful :rtype: bool @@ -132,11 +118,9 @@ def update(self, segment_id, name): if len(name) > 255: raise ValueError("`name` cannot exceed 255 characters.") - body_params = {"name": name} + body = {"name": name, **body_params(kwargs, ["filter"])} - self.api_client.request( - "PUT", f"{self.base_api_url}/{segment_id}", body=body_params - ) + self.api_client.request("PUT", f"{self.base_api_url}/{segment_id}", body=body) return True diff --git a/mailerlite/sdk/subscribers.py b/mailerlite/sdk/subscribers.py index 9281eaf..873e1c1 100644 --- a/mailerlite/sdk/subscribers.py +++ b/mailerlite/sdk/subscribers.py @@ -1,6 +1,20 @@ from __future__ import absolute_import import re +from urllib.parse import quote + +from mailerlite.sdk._params import query_params, reject_unknown + +_EMAIL_RE = re.compile(r"[\w.]+\@[\w.]+") + + +def _is_id(value): + # bool is an int in Python, but not a subscriber ID + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_email(value): + return isinstance(value, str) and _EMAIL_RE.search(value) is not None class Subscribers(object): @@ -17,26 +31,28 @@ def list(self, **kwargs): Returns a list of all subscribers. Ref: https://developers.mailerlite.com/docs/subscribers.html#list-all-subscribers - :param **kwargs: dict You can pass additional arguments - cursor, limit or filter by status + :param **kwargs: dict You can pass additional arguments - cursor, page, limit, sort, include, start_date, end_date, with_fields, with_groups or filter by status, group, automation, form, site, page, import or post :raises: :class: `TypeError` : Got an unknown argument :return: JSON array :rtype: dict """ - available_params = ["filter", "limit", "cursor"] - - params = locals() - query_params = {} - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) - if key == "filter": - for filter_key, filter_value in val.items(): - query_params[f"filter[{filter_key}]"] = filter_value - else: - query_params[key] = val + available_params = [ + "filter", + "limit", + "cursor", + "page", + "sort", + "include", + "start_date", + "end_date", + "with_fields", + "with_groups", + ] - return self.api_client.request("GET", self.base_api_url, query_params).json() + return self.api_client.request( + "GET", self.base_api_url, query_params(kwargs, available_params) + ).json() def create(self, email, **kwargs): """ @@ -46,7 +62,7 @@ def create(self, email, **kwargs): Ref: https://developers.mailerlite.com/docs/subscribers.html#create-update-subscriber :param email: string Valid email address as per RFC 2821 - :param **kwargs: dict You can pass additional arguments - fields, groups, status, subscribed_at, ip_address, opted_in_at, optin_ip and unsubscribed_at + :param **kwargs: dict You can pass additional arguments - fields, groups, status, subscribed_at, ip_address, opted_in_at, optin_ip, unsubscribed_at, resubscribe, double_optin and notes. `double_optin` is ignored for normal API tokens; the account setting applies :raises: :class: `TypeError` : `email` is not a valid email address :raises: :class: `TypeError` : `fields` argument should be a dict :raises: :class: `TypeError` : `groups` argument should be a list @@ -64,50 +80,44 @@ def create(self, email, **kwargs): "opted_in_at", "optin_ip", "unsubscribed_at", + "resubscribe", + "double_optin", + "notes", ] - valid = re.search(r"[\w.]+\@[\w.]+", email) - - if not valid: + if not _EMAIL_RE.search(email): raise TypeError("`email` is not a valid email address.") - params = locals() - body_params = {"email": email} - - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) - - if key == "fields" and type(val) is not dict: - raise TypeError("`fields` argument should be a dict.") - - if key == "groups" and type(val) is not list: - raise TypeError("`groups` argument should be a list.") - - body_params[key] = val + reject_unknown(kwargs, available_params) + self._validate_body(kwargs) return self.api_client.request( - "POST", self.base_api_url, body=body_params + "POST", self.base_api_url, body={"email": email, **kwargs} ).json() def update(self, email, **kwargs): """ Update a subscriber - Provides ability to update an existing subscriber. - Ref: https://developers.mailerlite.com/docs/subscribers.html#create-update-subscriber + Provides ability to update an existing subscriber with `PUT /subscribers/{subscriber}`. + `groups` replaces the subscriber's groups, so groups that are not listed are removed. + A missing subscriber returns 404. Use `create` for the additive upsert. + Ref: https://developers.mailerlite.com/docs/subscribers.html - :param email: string Valid email address as per RFC 2821 - :param **kwargs: dict You can pass additional arguments - fields, groups, status, subscribed_at, ip_address, opted_in_at, optin_ip and unsubscribed_at + :param email: string/int Email address or ID of the subscriber + :param **kwargs: dict You can pass additional arguments - new_email, fields, groups, status, subscribed_at, ip_address, opted_in_at, optin_ip and unsubscribed_at. `new_email` changes the email address of the subscriber :raises: :class: `TypeError` : `email` is not a valid email address + :raises: :class: `TypeError` : `new_email` is not a valid email address :raises: :class: `TypeError` : `fields` argument should be a dict :raises: :class: `TypeError` : `groups` argument should be a list :raises: :class: `TypeError` : Got an unknown argument + :raises: :class: `requests.HTTPError` : API request failed, e.g. subscriber was not found :return: JSON array :rtype: dict """ available_params = [ + "new_email", "fields", "groups", "status", @@ -118,28 +128,21 @@ def update(self, email, **kwargs): "unsubscribed_at", ] - valid = re.search(r"[\w.]+\@[\w.]+", email) - - if not valid: + if not _is_id(email) and not _is_email(email): raise TypeError("`email` is not a valid email address.") - params = locals() - body_params = {"email": email} - - for key, val in params["kwargs"].items(): - if key not in available_params: - raise TypeError("Got an unknown argument '%s'" % key) - - if key == "fields" and type(val) is not dict: - raise TypeError("`fields` argument should be a dict.") - - if key == "groups" and type(val) is not list: - raise TypeError("`groups` argument should be a list.") + reject_unknown(kwargs, available_params) + self._validate_body(kwargs) - body_params[key] = val + body_params = dict(kwargs) + if "new_email" in body_params: + new_email = body_params.pop("new_email") + if not _EMAIL_RE.search(new_email): + raise TypeError("`new_email` is not a valid email address.") + body_params["email"] = new_email return self.api_client.request( - "POST", self.base_api_url, body=body_params + "PUT", self._subscriber_path(email), body=body_params ).json() def get(self, subscriber_id): @@ -155,16 +158,11 @@ def get(self, subscriber_id): :rtype: dict """ - if not isinstance(subscriber_id, int): - valid = re.search(r"[\w.]+\@[\w.]+", subscriber_id) - - if not valid: - raise TypeError( - "Provided email address or subscriber id are not valid." - ) + if not _is_id(subscriber_id) and not _is_email(subscriber_id): + raise TypeError("Provided email address or subscriber id are not valid.") return self.api_client.request( - "GET", f"{self.base_api_url}/{subscriber_id}" + "GET", self._subscriber_path(subscriber_id) ).json() def delete(self, subscriber_id): @@ -308,3 +306,39 @@ def forget(self, subscriber_id): self.api_client.request("POST", f"{self.base_api_url}/{subscriber_id}/forget") return True + + def activity_log(self, subscriber_id, **kwargs): + """ + List subscriber activity + + Returns the activity log of a subscriber. + Ref: https://developers.mailerlite.com/docs/subscribers.html + + :param subscriber_id: int Subscriber ID + :param **kwargs: dict You can pass additional arguments - limit, page or filter by log_name + :raises: :class: `TypeError` : `subscriber_id` type is not valid + :raises: :class: `TypeError` : Got an unknown argument + :return: JSON array + :rtype: dict + """ + + if not isinstance(subscriber_id, int): + raise TypeError( + f"`subscriber_id` type is not valid. Expected `int`, got {type(subscriber_id)}." + ) + + return self.api_client.request( + "GET", + f"{self.base_api_url}/{subscriber_id}/activity-log", + query_params(kwargs, ["filter", "limit", "page"]), + ).json() + + def _validate_body(self, kwargs): + if "fields" in kwargs and type(kwargs["fields"]) is not dict: + raise TypeError("`fields` argument should be a dict.") + + if "groups" in kwargs and type(kwargs["groups"]) is not list: + raise TypeError("`groups` argument should be a list.") + + def _subscriber_path(self, email_or_id): + return f"{self.base_api_url}/{quote(str(email_or_id), safe='@')}" diff --git a/pyproject.toml b/pyproject.toml index 3e81aea..c0ee4c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mailerlite" -version = "0.2.0" +version = "0.3.0" description = "The official MailerLite Python SDK" authors = [{name = "MailerLite", email = "tech@mailerlite.com"}] requires-python = ">=3.10" diff --git a/setup.py b/setup.py index df55550..a0b71e2 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -__version__ = "0.1.10" +__version__ = "0.3.0" def _read_long_description(): diff --git a/tests/api_client_test.py b/tests/api_client_test.py index adb7433..c4e0cd9 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1,5 +1,9 @@ +import pickle + import pytest +import requests from mailerlite.api_client import ApiClient +from mailerlite.exceptions import ApiError class TestApiClient: @@ -49,3 +53,170 @@ def test_it_will_return_error_if_request_method_is_not_supported(self): client = ApiClient({}) with pytest.raises(ValueError): client.request("PPOST", "some-url") + + def test_mock_api_records_the_prepared_request(self, mock_api): + mock_api.add_response(200, {"data": []}) + client = ApiClient({"api_key": "1234567890"}) + + response = client.request("GET", "api/timezones", {"filter[name]": "a b"}) + + assert response.json() == {"data": []} + assert len(mock_api.requests) == 1 + assert mock_api.last_request.method == "GET" + assert ( + mock_api.last_request.url + == "https://connect.mailerlite.com/api/timezones?filter%5Bname%5D=a+b" + ) + assert mock_api.last_request.headers["Authorization"] == "Bearer 1234567890" + assert mock_api.last_body is None + + def test_patch_request_sends_the_json_body(self, mock_api): + mock_api.add_response(200, {"data": {}}) + client = ApiClient({}) + + client.request("PATCH", "api/emails/1/settings", body={"etag": "42"}) + + assert mock_api.last_request.method == "PATCH" + assert mock_api.last_body == {"etag": "42"} + + def test_delete_request_sends_the_json_body(self, mock_api): + mock_api.add_response(200, {"etag": "43"}) + client = ApiClient({}) + + client.request("DELETE", "api/emails/1/blocks/2", body={"etag": "42"}) + + assert mock_api.last_request.method == "DELETE" + assert mock_api.last_body == {"etag": "42"} + + def test_delete_request_without_a_body_sends_no_body(self, mock_api): + mock_api.add_response(204) + client = ApiClient({}) + + client.request("DELETE", "api/groups/1") + + assert mock_api.last_request.method == "DELETE" + assert mock_api.last_request.body is None + + @pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) + def test_write_request_without_a_body_sends_no_body(self, mock_api, method): + mock_api.add_response(200, {"data": {}}) + client = ApiClient({}) + + client.request(method, "api/emails/1/versions") + + assert mock_api.last_request.method == method + assert mock_api.last_request.body is None + + def test_version_conflict_raises_api_error_with_code_and_etag(self, mock_api): + mock_api.add_response( + 409, + { + "message": "The email changed. Get it again.", + "code": "version_conflict", + "etag": "44", + }, + ) + client = ApiClient({}) + + with pytest.raises(ApiError) as error: + client.request("PATCH", "api/emails/1/blocks/2", body={"etag": "42"}) + + assert isinstance(error.value, requests.HTTPError) + assert error.value.response.status_code == 409 + assert error.value.status_code == 409 + assert error.value.message == "The email changed. Get it again." + assert error.value.code == "version_conflict" + assert error.value.etag == "44" + assert error.value.errors is None + assert str(error.value) == ( + "409 Client Error: Conflict for url: " + "https://connect.mailerlite.com/api/emails/1/blocks/2 " + "(version_conflict: The email changed. Get it again.)" + ) + + def test_validation_error_raises_api_error_with_errors(self, mock_api): + mock_api.add_response( + 422, + { + "message": "The type field is required.", + "errors": {"type": ["The type field is required."]}, + }, + ) + client = ApiClient({}) + + with pytest.raises(ApiError) as error: + client.request("POST", "api/emails/1/blocks", body={}) + + assert error.value.message == "The type field is required." + assert error.value.code is None + assert error.value.etag is None + assert error.value.errors == {"type": ["The type field is required."]} + # The reason phrase of 422 depends on the Python version + reason = error.value.response.reason + assert str(error.value) == ( + f"422 Client Error: {reason} for url: " + "https://connect.mailerlite.com/api/emails/1/blocks " + "(The type field is required.)" + ) + + def test_error_without_a_json_body_raises_api_error(self, mock_api): + mock_api.add_response(503, "Service Unavailable", {"Content-Type": "text/html"}) + client = ApiClient({}) + + with pytest.raises(ApiError) as error: + client.request("GET", "api/emails/1/html") + + assert error.value.status_code == 503 + assert error.value.message is None + assert error.value.code is None + assert error.value.etag is None + assert error.value.errors is None + assert str(error.value) == ( + "503 Server Error: Service Unavailable for url: " + "https://connect.mailerlite.com/api/emails/1/html" + ) + + def test_api_error_survives_a_pickle_round_trip(self, mock_api): + mock_api.add_response( + 409, + { + "message": "The email changed. Get it again.", + "code": "version_conflict", + "etag": "44", + }, + ) + client = ApiClient({}) + + with pytest.raises(ApiError) as error: + client.request("PATCH", "api/emails/1/blocks/2", body={"etag": "42"}) + + copy = pickle.loads(pickle.dumps(error.value)) + + assert isinstance(copy, ApiError) + assert str(copy) == str(error.value) + assert copy.status_code == 409 + assert copy.message == "The email changed. Get it again." + assert copy.code == "version_conflict" + assert copy.etag == "44" + assert copy.errors is None + assert copy.response.status_code == 409 + + def test_api_error_is_exported_from_the_package(self): + import mailerlite + + assert mailerlite.ApiError is ApiError + + def test_request_adds_the_given_headers(self, mock_api): + mock_api.add_response(200, {"data": {}}) + mock_api.add_response(200, {"data": {}}) + client = ApiClient({"api_key": "1234567890"}) + + client.request( + "POST", "api/emails/1/versions", headers={"Idempotency-Key": "k1"} + ) + assert mock_api.last_request.headers["Idempotency-Key"] == "k1" + assert mock_api.last_request.headers["Authorization"] == "Bearer 1234567890" + + client.request("POST", "api/emails/1/versions") + assert "Idempotency-Key" not in mock_api.last_request.headers + assert "Idempotency-Key" not in client.headers diff --git a/tests/automations_test.py b/tests/automations_test.py index ec29fd8..a243221 100644 --- a/tests/automations_test.py +++ b/tests/automations_test.py @@ -1,6 +1,7 @@ import os import mailerlite as MailerLite +import pytest import vcr from dotenv import load_dotenv from pytest import fixture @@ -70,3 +71,522 @@ def test_automation_activity_information_should_be_returned_when_valid_automatio assert isinstance(response, dict) assert isinstance(response["data"], list) + + +def _method_ids(cases): + return [case[0] for case in cases] + + +REQUEST_CASES = [ + ( + "list", + [], + { + "filter": {"enabled": True, "automation_state": "workflows"}, + "page": 2, + "limit": 10, + "sort": "-created_at", + "with_steps": True, + }, + "GET", + "/automations", + { + "filter[enabled]": 1, + "filter[automation_state]": "workflows", + "page": 2, + "limit": 10, + "sort": "-created_at", + "with_steps": 1, + }, + None, + ), + ( + "activity", + [1], + { + "filter": {"status": "active", "search": "a@example.com"}, + "page": 1, + "limit": 10, + "sort": "-updated_at", + }, + "GET", + "/automations/1/activity", + { + "filter[status]": "active", + "filter[search]": "a@example.com", + "page": 1, + "limit": 10, + "sort": "-updated_at", + }, + None, + ), + ( + "create", + ["Welcome"], + {}, + "POST", + "/automations", + None, + {"name": "Welcome"}, + ), + ( + "update", + [1, "Welcome"], + {"trigger_data": {"repeatable": True}, "triggers": []}, + "PUT", + "/automations/1", + None, + { + "name": "Welcome", + "trigger_data": {"repeatable": True}, + "triggers": [], + }, + ), + ("copy", [1], {}, "POST", "/automations/1/copy", None, None), + ("enable", [1], {}, "POST", "/automations/1/enable", None, None), + ("disable", [1], {}, "POST", "/automations/1/disable", None, None), + ( + "test", + [1, "a@example.com"], + {}, + "POST", + "/automations/1/test", + None, + {"email": "a@example.com"}, + ), + ( + "add_subscribers", + [1], + {"step_id": 2, "emails": ["a@example.com"], "groups": [3]}, + "POST", + "/automations/1/add-subscribers", + None, + {"step_id": 2, "emails": ["a@example.com"], "groups": [3]}, + ), + ( + "readd", + [1, "selected_step", {"status": "completed"}], + {"selected_step_id": "2"}, + "POST", + "/automations/1/readd", + None, + { + "readd_subscribers_to": "selected_step", + "filter": {"status": "completed"}, + "selected_step_id": "2", + }, + ), + ( + "activity_add_to_group", + [1, ["3"], {"status": "active"}], + {}, + "POST", + "/automations/1/activity/add-to-group", + None, + {"destination_groups": ["3"], "filter": {"status": "active"}}, + ), + ( + "activity_cancel_automation", + [1, {"status": "active", "id": ["5"]}], + {}, + "POST", + "/automations/1/activity/cancel-automation", + None, + {"filter": {"status": "active", "id": ["5"]}}, + ), + ( + "activity_skip_delay", + [1, {"status": "active"}], + {}, + "POST", + "/automations/1/activity/skip-delay", + None, + {"filter": {"status": "active"}}, + ), + ("list_triggers", [1], {}, "GET", "/automations/1/triggers", None, None), + ( + "create_trigger", + [1, "subscriber_joins_group"], + {"data": {"group_ids": [3]}}, + "POST", + "/automations/1/triggers", + None, + {"type": "subscriber_joins_group", "data": {"group_ids": [3]}}, + ), + ("get_trigger", [1, 4], {}, "GET", "/automations/1/triggers/4", None, None), + ( + "update_trigger", + [1, 4], + {"data": {"group_ids": [3]}}, + "PUT", + "/automations/1/triggers/4", + None, + {"data": {"group_ids": [3]}}, + ), + ("list_steps", [1], {}, "GET", "/automations/1/steps/index", None, None), + ( + "create_step", + [1, "delay"], + { + "parent_id": 2, + "branch": "yes", + "move_to_branch": "no", + "data": {"unit": "days", "value": 1}, + }, + "POST", + "/automations/1/steps", + None, + { + "type": "delay", + "parent_id": 2, + "branch": "yes", + "move_to_branch": "no", + "data": {"unit": "days", "value": 1}, + }, + ), + ("get_step", [1, 2], {}, "GET", "/automations/1/steps/2", None, None), + ( + "update_step", + [1, 2], + { + "parent_id": 3, + "data": {"unit": "days"}, + "reschedule_waiting_subscribers": True, + }, + "PUT", + "/automations/1/steps/2", + None, + { + "parent_id": 3, + "data": {"unit": "days"}, + "reschedule_waiting_subscribers": True, + }, + ), + ( + "copy_step", + [1, 2], + {"parent_id": 3, "branch": "yes", "move_to_branch": "no"}, + "POST", + "/automations/1/steps/2/copy", + None, + {"parent_id": 3, "branch": "yes", "move_to_branch": "no"}, + ), + ( + "reorder_steps", + [1, 2], + {"new_parent_step_id": 3, "branch": "yes", "branch_for_child": "no"}, + "POST", + "/automations/1/steps/reorder", + None, + { + "dragged_step_id": 2, + "new_parent_step_id": 3, + "branch": "yes", + "branch_for_child": "no", + }, + ), + ( + "select_ab_test_winner", + [1, 2, "a_path"], + {}, + "PUT", + "/automations/1/steps/2/select-ab-test-winner", + None, + {"ab_test_winner": "a_path"}, + ), + ( + "get_email_content", + [1, 6], + {}, + "GET", + "/automations/1/emails/6/content", + None, + None, + ), + ( + "update_email_content", + [1, 6], + {"html": "Hi
"}, + "PUT", + "/automations/1/emails/6/content", + None, + {"html": "Hi
"}, + ), + ( + "email_report_stats", + [1, 6], + { + "start_date": "2026-01-01", + "end_date": "2026-02-01", + "shop_id": "7", + }, + "GET", + "/automations/1/emails/6/report-stats", + { + "start_date": "2026-01-01", + "end_date": "2026-02-01", + "shop_id": "7", + }, + None, + ), + ( + "email_unsubscribe_reasons", + [1, 6], + {}, + "GET", + "/automations/1/emails/6/unsubscribe-reasons", + None, + None, + ), + ( + "list_templates", + [], + {"filter": {"type": "free", "category": 2}, "limit": 10, "page": 1}, + "GET", + "/automation-templates", + {"filter[type]": "free", "filter[category]": 2, "limit": 10, "page": 1}, + None, + ), + ( + "select_template", + [8], + {}, + "POST", + "/automations/select-template/8", + None, + None, + ), + ( + "steps_run_counts", + [1], + {}, + "GET", + "/automations/1/steps/run-counts", + None, + None, + ), + ( + "step_run_counts", + [1, 2], + {}, + "GET", + "/automations/1/steps/run-counts/2", + None, + None, + ), + ( + "triggers_run_counts", + [1], + {}, + "GET", + "/automations/1/triggers/run-counts", + None, + None, + ), + ( + "trigger_run_counts", + [1, 4], + {}, + "GET", + "/automations/1/triggers/run-counts/4", + None, + None, + ), + ( + "ab_steps_stats", + [1], + {}, + "GET", + "/automations/1/ab-steps-stats", + None, + None, + ), + ( + "ab_step_stats", + [1, 2], + {}, + "GET", + "/automations/1/ab-steps-stats/2", + None, + None, + ), + ( + "step_subscribers", + [1, 2, "queued"], + { + "filter": {"search": "a@example.com", "id": ["9", "10"]}, + "page": 1, + "limit": 10, + "sort": "-date", + }, + "GET", + "/automations/1/activity/steps/2/subscribers", + { + "status": "queued", + "filter[search]": "a@example.com", + "filter[id]": "9,10", + "page": 1, + "limit": 10, + "sort": "-date", + }, + None, + ), + ( + "trigger_subscribers", + [1, 4, "completed"], + {"filter": {"date_from": "2026-01-01", "date_to": "2026-02-01"}}, + "GET", + "/automations/1/activity/triggers/4/subscribers", + { + "status": "completed", + "filter[date_from]": "2026-01-01", + "filter[date_to]": "2026-02-01", + }, + None, + ), + ( + "email_monthly_totals", + [1], + { + "start_date": "2026-01-01", + "end_date": "2026-02-01", + "shop_id": "7", + }, + "GET", + "/automations/1/monthly-totals/email", + { + "start_date": "2026-01-01", + "end_date": "2026-02-01", + "shop_id": "7", + }, + None, + ), +] + + +DELETE_CASES = [ + ("delete", [1], "/automations/1"), + ("delete_trigger", [1, 4], "/automations/1/triggers/4"), + ("delete_step", [1, 2], "/automations/1/steps/2"), +] + + +UNKNOWN_ARGUMENT_CASES = [ + ("list", []), + ("activity", [1]), + ("update", [1, "Welcome"]), + ("add_subscribers", [1]), + ("create_step", [1, "delay"]), + ("delete_step", [1, 2]), + ("list_templates", []), + ("step_subscribers", [1, 2, "queued"]), +] + + +NO_CONTENT_CASES = [ + ("test", [1, "a@example.com"], {}), + ("add_subscribers", [1], {"emails": ["a@example.com"]}), + ("readd", [1, "last_completed_step", {"status": "completed"}], {}), + ("activity_add_to_group", [1, ["3"], {"status": "active"}], {}), + ("activity_cancel_automation", [1, {"status": "active"}], {}), + ("activity_skip_delay", [1, {"status": "active"}], {}), +] + + +class TestAutomationsRequests: + @classmethod + def setup_class(self): + self.client = MailerLite.Client({"api_key": "test"}) + + @pytest.mark.parametrize( + "method_name, args, kwargs, verb, path, params, body", + REQUEST_CASES, + ids=_method_ids(REQUEST_CASES), + ) + def test_method_sends_documented_request( + self, sent_request, method_name, args, kwargs, verb, path, params, body + ): + getattr(self.client.automations, method_name)(*args, **kwargs) + + request = sent_request() + assert request.method == verb + assert request.path == path + assert (request.params or None) == params + assert request.body == body + + @pytest.mark.parametrize( + "method_name, args, path", + DELETE_CASES, + ids=_method_ids(DELETE_CASES), + ) + def test_delete_method_sends_documented_request_and_returns_true( + self, sent_request, method_name, args, path + ): + response = getattr(self.client.automations, method_name)(*args) + + request = sent_request() + assert response is True + assert request.method == "DELETE" + assert request.path == path + + def test_activity_sends_with_subscribers_only_when_truthy(self, sent_request): + self.client.automations.activity( + 1, filter={"status": "active"}, with_subscribers=True + ) + assert sent_request().params == { + "filter[status]": "active", + "with_subscribers": 1, + } + + for value in (False, None): + self.client.automations.activity( + 1, filter={"status": "active"}, with_subscribers=value + ) + assert sent_request().params == {"filter[status]": "active"} + + def test_delete_step_sends_branch_to_delete(self, sent_request): + self.client.automations.delete_step(1, 2, branch_to_delete="yes") + + assert sent_request().params == {"branch_to_delete": "yes"} + + @pytest.mark.parametrize( + "method_name, args", + UNKNOWN_ARGUMENT_CASES, + ids=_method_ids(UNKNOWN_ARGUMENT_CASES), + ) + def test_method_rejects_unknown_argument(self, sent_request, method_name, args): + with pytest.raises(TypeError): + getattr(self.client.automations, method_name)(*args, unknown=True) + + @pytest.mark.parametrize( + "method_name, args, kwargs", + NO_CONTENT_CASES, + ids=_method_ids(NO_CONTENT_CASES), + ) + def test_no_content_method_returns_true_without_reading_body( + self, mocker, method_name, args, kwargs + ): + response = mocker.Mock(status_code=204) + response.json.side_effect = ValueError("no body") + mocker.patch("mailerlite.api_client.requests.request", return_value=response) + + result = getattr(self.client.automations, method_name)(*args, **kwargs) + + assert result is True + response.json.assert_not_called() + + +class TestAutomationReportsRejectExcel: + """The SDK always decodes JSON, so it cannot offer the XLSX download""" + + @classmethod + def setup_class(self): + self.client = MailerLite.Client({"api_key": "test"}) + + def test_email_report_stats_rejects_excel(self, sent_request): + with pytest.raises(TypeError, match="excel"): + self.client.automations.email_report_stats(1, 6, excel=True) + + def test_email_monthly_totals_rejects_excel(self, sent_request): + with pytest.raises(TypeError, match="excel"): + self.client.automations.email_monthly_totals(1, excel=True) diff --git a/tests/brand_styles_test.py b/tests/brand_styles_test.py new file mode 100644 index 0000000..9a7356b --- /dev/null +++ b/tests/brand_styles_test.py @@ -0,0 +1,46 @@ +import pytest +from mailerlite import Client +from mailerlite.response import ApiResponse + +from tests.conftest import load_fixture + +URL = "https://connect.mailerlite.com/api/brand-styles" + + +@pytest.fixture +def client(): + return Client({"api_key": "1234567890"}) + + +class TestBrandStyles: + def test_api_url_is_properly_set(self, client): + assert client.brand_styles.base_api_url == "api/brand-styles" + + def test_get_returns_the_palettes(self, client, mock_api): + mock_api.add_response(200, load_fixture("brand_styles.json")) + + response = client.brand_styles.get() + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == URL + assert mock_api.last_body is None + assert isinstance(response, ApiResponse) + data = response["data"] + assert data["brand"]["identifier"] == "account" + assert data["brand"]["primary_color"] == "#2cb191" + assert data["default"]["identifier"] == "default-brand" + assert [preset["identifier"] for preset in data["presets"]] == [ + "preset-business-casual", + "preset-retro", + ] + assert data["presets"][1]["font_family"] == "Courier New, monospace" + + def test_get_returns_no_brand_when_the_account_has_none(self, client, mock_api): + body = load_fixture("brand_styles.json") + body["data"]["brand"] = None + mock_api.add_response(200, body) + + response = client.brand_styles.get() + + assert response["data"]["brand"] is None + assert response["data"]["default"]["identifier"] == "default-brand" diff --git a/tests/campaigns_test.py b/tests/campaigns_test.py index 4a58e85..b3d7b04 100644 --- a/tests/campaigns_test.py +++ b/tests/campaigns_test.py @@ -6,8 +6,11 @@ import requests import vcr from dotenv import load_dotenv +from mailerlite.sdk.emails import EDITOR_SIMPLE from pytest import fixture +from tests.conftest import load_fixture + @fixture def campaign_keys(): @@ -250,3 +253,51 @@ def test_list_of_all_supported_languages_in_campaign(self, campaign_language_key assert isinstance(response, dict) assert isinstance(response["data"], list) assert set(campaign_language_keys).issubset(response["data"][0].keys()) + + +class TestCreateSimpleEditorCampaign: + def test_create_sends_the_editor_and_no_content(self, mock_api): + mock_api.add_response(201, load_fixture("campaign_simple_editor.json")) + client = MailerLite.Client({"api_key": "1234567890"}) + params = { + "name": "Simple editor campaign", + "type": "regular", + "emails": [ + { + "subject": "Hello", + "from_name": "Sender", + "from": "sender@example.com", + "reply_to": "reply@example.com", + "editor": EDITOR_SIMPLE, + "template_id": 789, + } + ], + } + + response = client.campaigns.create(params) + + assert mock_api.last_request.method == "POST" + assert ( + mock_api.last_request.url == "https://connect.mailerlite.com/api/campaigns" + ) + assert mock_api.last_body == { + "name": "Simple editor campaign", + "type": "regular", + "emails": [ + { + "subject": "Hello", + "from_name": "Sender", + "from": "sender@example.com", + "reply_to": "reply@example.com", + "editor": "simple", + "template_id": 789, + } + ], + } + assert "content" not in mock_api.last_body["emails"][0] + assert response["data"]["emails"][0]["id"] == "123" + assert response["data"]["emails"][0]["type"] == "builder_inline_email" + + def test_editor_simple_is_exported_from_the_package(self): + assert EDITOR_SIMPLE == "simple" + assert MailerLite.EDITOR_SIMPLE == EDITOR_SIMPLE diff --git a/tests/conftest.py b/tests/conftest.py index 60b49a9..3f71162 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,100 @@ +import json +from http.client import responses +from pathlib import Path +from types import SimpleNamespace + import pytest +import requests +from mailerlite.api_client import ApiClient + +FIXTURES_DIR = Path(__file__).parent / "fixtures" def pytest_configure(): return {"entity_id": 0, "entity_email": "random@mailerlite.com"} + + +def load_fixture(name): + with open(FIXTURES_DIR / name) as fd: + return json.load(fd) + + +def make_response(status_code=200, body=b"", headers=None, url=None, request=None): + """A real requests.Response, so the client can check its status code.""" + + response = requests.Response() + response.status_code = status_code + response.reason = responses.get(status_code, "") + response.headers.update({"Content-Type": "application/json", **(headers or {})}) + response._content = body + response.url = url + response.request = request + + return response + + +class MockApi(object): + def __init__(self): + self.requests = [] + self.responses = [] + + def add_response(self, status_code=200, body=None, headers=None): + if isinstance(body, (dict, list)): + body = json.dumps(body) + if isinstance(body, str): + body = body.encode() + + self.responses.append((status_code, body or b"", headers or {})) + + def send(self, request): + self.requests.append(request) + status_code, body, headers = self.responses.pop(0) + + return make_response(status_code, body, headers, request.url, request) + + @property + def last_request(self): + return self.requests[-1] + + @property + def last_body(self): + body = self.last_request.body + return None if body is None else json.loads(body) + + +@pytest.fixture +def mock_api(mocker): + """Replaces the HTTP transport. No request leaves the machine.""" + + api = MockApi() + mocker.patch( + "requests.Session.send", + autospec=True, + side_effect=lambda session, request, **kwargs: api.send(request), + ) + + return api + + +@pytest.fixture +def sent_request(mocker): + """Replaces requests.request and gives back the method, path, params and body""" + + def respond(**kwargs): + return make_response(200, b'{"data": {}}', url=kwargs["url"]) + + request = mocker.patch( + "mailerlite.api_client.requests.request", side_effect=respond + ) + + def last_request(): + kwargs = request.call_args.kwargs + body = kwargs.get("data") + return SimpleNamespace( + method=kwargs["method"].upper(), + path=kwargs["url"].removeprefix(ApiClient.HOST + "api"), + params=kwargs["params"], + body=json.loads(body) if body else None, + ) + + return last_request diff --git a/tests/emails_test.py b/tests/emails_test.py new file mode 100644 index 0000000..27396d3 --- /dev/null +++ b/tests/emails_test.py @@ -0,0 +1,770 @@ +import json +import pytest +import requests +import mailerlite +from mailerlite import Client +from mailerlite.exceptions import ( + ApiError, + EmptyIdError, + InvalidIdError, +) +from mailerlite.response import ApiResponse +from mailerlite.sdk.emails import ( + VERSION_CONFLICT, + BlockPosition, + BlockType, + BrandMode, +) + +from tests.conftest import load_fixture + +BASE_URL = "https://connect.mailerlite.com/api/emails" + + +@pytest.fixture +def client(): + return Client({"api_key": "1234567890"}) + + +class TestEmails: + def test_api_url_is_properly_set(self, client): + assert client.emails.base_api_url == "api/emails" + + def test_get_returns_the_email(self, client, mock_api): + mock_api.add_response(200, load_fixture("email.json")) + + response = client.emails.get("123") + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == f"{BASE_URL}/123" + assert mock_api.last_body is None + assert isinstance(response, ApiResponse) + assert response.status_code == 200 + assert response["etag"] == "42" + email = response["data"] + assert email["id"] == "123" + assert email["type"] == "builder_inline_email" + assert email["emailable_type"] == "campaigns" + assert email["emailable_id"] == "456" + assert email["settings"]["brand"]["mode"] == "account" + assert email["settings"]["typography"]["heading"]["h1"]["size"] == "32" + assert email["settings"]["button"]["border_radius"] == "4" + assert email["settings"]["footer"]["show_preferences"] is False + assert email["settings"]["footer"]["social_links"][0]["link_id"] == 7 + assert email["blocks"][0]["editor_content"]["attrs"] == {"textAlign": "left"} + assert email["blocks"][1]["children"][0]["id"] == "3" + + def test_get_accepts_an_int_id(self, client, mock_api): + mock_api.add_response(200, load_fixture("email.json")) + + client.emails.get(123) + + assert mock_api.last_request.url == f"{BASE_URL}/123" + + @pytest.mark.parametrize("email_id", ["", None]) + def test_get_rejects_an_empty_id(self, client, mock_api, email_id): + with pytest.raises(EmptyIdError): + client.emails.get(email_id) + + assert mock_api.requests == [] + + @pytest.mark.parametrize( + "email_id", ["123/html", "123%2Fhtml", "12 3", "-1", "²", "123\n", "abc"] + ) + def test_get_rejects_an_id_that_is_not_only_digits( + self, client, mock_api, email_id + ): + with pytest.raises(InvalidIdError): + client.emails.get(email_id) + + assert mock_api.requests == [] + + @pytest.mark.parametrize("email_id", [True, 1.5, ["123"]]) + def test_get_rejects_an_id_of_the_wrong_type(self, client, mock_api, email_id): + with pytest.raises(TypeError): + client.emails.get(email_id) + + assert mock_api.requests == [] + + def test_id_errors_are_value_errors(self): + assert issubclass(EmptyIdError, ValueError) + assert issubclass(InvalidIdError, ValueError) + + def test_get_html_returns_the_html(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_html.json")) + + response = client.emails.get_html("123") + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == f"{BASE_URL}/123/html" + assert mock_api.last_body is None + assert response["data"]["html"] == ( + "Hello
" + ) + assert response["etag"] == "42" + + def test_get_html_rejects_an_invalid_id(self, client, mock_api): + with pytest.raises(InvalidIdError): + client.emails.get_html("123/blocks") + + assert mock_api.requests == [] + + def test_get_block_returns_the_block(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_block.json")) + + response = client.emails.get_block("123", "57") + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks/57" + assert mock_api.last_body is None + assert response["etag"] == "43" + block = response["data"] + assert block["id"] == "57" + assert block["type"] == "paragraph" + assert block["editor_content"]["attrs"] == {"textAlign": "left"} + assert block["placement"] == {"after": "56", "before": None} + + @pytest.mark.parametrize( + "email_id, block_id, error", + [ + ("", "57", EmptyIdError), + ("123", "", EmptyIdError), + ("123", None, EmptyIdError), + ("123/html", "57", InvalidIdError), + ("123", "57/move", InvalidIdError), + ], + ) + def test_get_block_rejects_an_invalid_id( + self, client, mock_api, email_id, block_id, error + ): + with pytest.raises(error): + client.emails.get_block(email_id, block_id) + + assert mock_api.requests == [] + + def test_create_block_inserts_the_block(self, client, mock_api): + mock_api.add_response(201, load_fixture("email_block_created.json")) + editor_content = { + "attrs": {"textAlign": "center"}, + "content": [{"text": "Hi", "type": "text"}], + } + + response = client.emails.create_block( + "123", "42", BlockType.PARAGRAPH, editor_content + ) + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks" + assert mock_api.last_body == { + "type": "paragraph", + "editor_content": editor_content, + "etag": "42", + } + assert response.status_code == 201 + assert response["etag"] == "43" + assert response["data"]["id"] == "58" + assert response["data"]["editor_content"]["attrs"] == {"textAlign": "center"} + assert response["data"]["placement"] == {"after": "57", "before": None} + + def test_create_block_sends_the_placement(self, client, mock_api): + mock_api.add_response(201, load_fixture("email_block_created.json")) + + client.emails.create_block( + "123", + "42", + BlockType.DIVIDER, + {}, + placement={"position": BlockPosition.AFTER, "block_id": "57"}, + ) + + assert mock_api.last_body == { + "type": "divider", + "editor_content": {}, + "placement": {"position": "after", "block_id": "57"}, + "etag": "42", + } + + def test_create_block_rejects_an_invalid_email_id(self, client, mock_api): + with pytest.raises(InvalidIdError): + client.emails.create_block("123/html", "42", BlockType.DIVIDER, {}) + + assert mock_api.requests == [] + + def test_write_with_an_old_etag_raises_a_version_conflict(self, client, mock_api): + mock_api.add_response( + 409, + { + "message": "The email has changed since you read it.", + "code": "version_conflict", + "etag": "44", + }, + ) + + with pytest.raises(ApiError) as error: + client.emails.create_block("123", "42", BlockType.DIVIDER, {}) + + assert error.value.status_code == 409 + assert error.value.code == VERSION_CONFLICT + assert error.value.etag == "44" + assert "version_conflict" in str(error.value) + + def test_block_constants(self): + assert BlockType.BLOCKQUOTE == "blockquote" + assert BlockType.BULLETED_LIST == "bulleted_list" + assert BlockType.BUTTON == "button" + assert BlockType.CODE == "code" + assert BlockType.DIVIDER == "divider" + assert BlockType.HEADING == "heading" + assert BlockType.IMAGE == "image" + assert BlockType.NUMBERED_LIST == "numbered_list" + assert BlockType.PARAGRAPH == "paragraph" + assert BlockType.SOCIAL_ICON == "social_icon" + assert BlockType.TITLE == "title" + assert BlockType.VIDEO == "video" + assert BlockPosition.START == "start" + assert BlockPosition.END == "end" + assert BlockPosition.BEFORE == "before" + assert BlockPosition.AFTER == "after" + assert VERSION_CONFLICT == "version_conflict" + + def test_write_names_are_exported_from_the_package(self): + assert mailerlite.BlockType is BlockType + assert mailerlite.BlockPosition is BlockPosition + assert mailerlite.VERSION_CONFLICT == VERSION_CONFLICT + + def test_update_block_replaces_the_editor_content(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_block_updated.json")) + editor_content = {"content": [{"text": "Hello again", "type": "text"}]} + + response = client.emails.update_block("123", "57", "43", editor_content) + + assert mock_api.last_request.method == "PATCH" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks/57" + assert mock_api.last_body == {"editor_content": editor_content, "etag": "43"} + assert response["etag"] == "44" + assert response["data"]["editor_content"] == editor_content + assert response["data"]["placement"] == {"after": "56", "before": "58"} + + @pytest.mark.parametrize( + "block_id, etag, editor_content, error", + [ + ("", "43", {}, EmptyIdError), + ("57/move", "43", {}, InvalidIdError), + ], + ) + def test_update_block_rejects_invalid_arguments( + self, client, mock_api, block_id, etag, editor_content, error + ): + with pytest.raises(error): + client.emails.update_block("123", block_id, etag, editor_content) + + assert mock_api.requests == [] + + def test_delete_block_sends_the_etag_in_the_body(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_etag.json")) + + response = client.emails.delete_block("123", "57", "44") + + assert mock_api.last_request.method == "DELETE" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks/57" + assert mock_api.last_body == {"etag": "44"} + assert response == {"etag": "45"} + assert response.status_code == 200 + + @pytest.mark.parametrize( + "block_id, etag, error", + [ + (None, "44", EmptyIdError), + ("57%2F", "44", InvalidIdError), + ], + ) + def test_delete_block_rejects_invalid_arguments( + self, client, mock_api, block_id, etag, error + ): + with pytest.raises(error): + client.emails.delete_block("123", block_id, etag) + + assert mock_api.requests == [] + + def test_move_block_sends_the_placement(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_block_moved.json")) + + response = client.emails.move_block( + "123", "57", "45", {"position": BlockPosition.START} + ) + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/blocks/57/move" + assert mock_api.last_body == {"placement": {"position": "start"}, "etag": "45"} + assert response["etag"] == "46" + assert response["data"]["id"] == "57" + assert response["data"]["placement"] == {"after": None, "before": "1"} + + @pytest.mark.parametrize( + "block_id, etag, placement, error", + [ + ("", "45", {"position": "end"}, EmptyIdError), + ("57/x", "45", {"position": "end"}, InvalidIdError), + ], + ) + def test_move_block_rejects_invalid_arguments( + self, client, mock_api, block_id, etag, placement, error + ): + with pytest.raises(error): + client.emails.move_block("123", block_id, etag, placement) + + assert mock_api.requests == [] + + def test_validate_block_sends_the_block_without_an_etag(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_block_validation_valid.json")) + + response = client.emails.validate_block( + BlockType.HEADING, + {"attrs": {"level": 2}}, + placement={"position": BlockPosition.END}, + ) + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/blocks/validate" + assert mock_api.last_body == { + "type": "heading", + "editor_content": {"attrs": {"level": 2}}, + "placement": {"position": "end"}, + } + assert response["valid"] is True + assert response["errors"] == {} + assert response["schema"] == {"version": "2.73.2", "hash": "0f3a9c"} + + def test_validate_block_returns_the_errors_of_an_invalid_block( + self, client, mock_api + ): + mock_api.add_response(200, load_fixture("email_block_validation_invalid.json")) + + response = client.emails.validate_block("table", {}) + + assert mock_api.last_body == {"type": "table", "editor_content": {}} + assert response.status_code == 200 + assert response["valid"] is False + assert response["errors"] == {"type": ["The selected type is invalid."]} + + def test_update_settings_sends_only_the_given_keys(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_settings.json")) + settings = { + "brand": {"mode": BrandMode.CUSTOM, "background_color": "#f4f4f4"}, + "footer": {"info_text": {"editor_content": {"content": []}}}, + "typography": {"link": {"color": ""}}, + } + + response = client.emails.update_settings("123", "46", settings) + + assert mock_api.last_request.method == "PATCH" + assert mock_api.last_request.url == f"{BASE_URL}/123/settings" + assert mock_api.last_body == { + "brand": {"mode": "custom", "background_color": "#f4f4f4"}, + "footer": {"info_text": {"editor_content": {"content": []}}}, + "typography": {"link": {"color": ""}}, + "etag": "46", + } + assert response["etag"] == "47" + assert response["data"]["brand"]["mode"] == "custom" + assert response["data"]["brand"]["background_color"] == "#f4f4f4" + assert response["data"]["footer"]["social_links"][0]["link_id"] == 7 + assert response["rejected"] == { + "message": "Some attributes were rejected.", + "keys": ["footer.social_links[0].url"], + } + + def test_update_settings_does_not_change_the_given_dict(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_settings.json")) + settings = {"layout": {"alignment": "left"}} + + client.emails.update_settings("123", "46", settings) + + assert settings == {"layout": {"alignment": "left"}} + + @pytest.mark.parametrize( + "email_id, etag, settings, error", + [ + ("", "46", {}, EmptyIdError), + ("123/html", "46", {}, InvalidIdError), + ("123", "46", None, TypeError), + ("123", "46", {"etag": "45"}, ValueError), + ], + ) + def test_update_settings_rejects_invalid_arguments( + self, client, mock_api, email_id, etag, settings, error + ): + with pytest.raises(error): + client.emails.update_settings(email_id, etag, settings) + + assert mock_api.requests == [] + + def test_brand_mode_constants(self): + assert BrandMode.ACCOUNT == "account" + assert BrandMode.TEMPLATE == "template" + assert BrandMode.CUSTOM == "custom" + assert mailerlite.BrandMode is BrandMode + + def test_validate_settings_sends_the_settings_without_an_etag( + self, client, mock_api + ): + mock_api.add_response(200, load_fixture("email_settings_validation.json")) + + response = client.emails.validate_settings("123", {"layout": {"width": "700"}}) + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/settings/validate" + assert mock_api.last_body == {"layout": {"width": "700"}} + assert response.status_code == 200 + assert response["valid"] is False + assert response["errors"] == { + "layout.width": ["The layout.width field cannot be changed."] + } + assert response["schema"]["version"] == "2.73.2" + assert response["rejected"]["keys"] == ["footer.social_links[0].url"] + + @pytest.mark.parametrize( + "email_id, settings, error", + [ + (None, {}, EmptyIdError), + ("1.5", {}, InvalidIdError), + ("123", [], TypeError), + ("123", None, TypeError), + ("123", {"etag": "45"}, ValueError), + ], + ) + def test_validate_settings_rejects_invalid_arguments( + self, client, mock_api, email_id, settings, error + ): + with pytest.raises(error): + client.emails.validate_settings(email_id, settings) + + assert mock_api.requests == [] + + def test_create_template_saves_the_email_as_a_template(self, client, mock_api): + mock_api.add_response(201, load_fixture("email_template.json")) + + response = client.emails.create_template("123", "Newsletter") + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/templates" + assert mock_api.last_body == {"name": "Newsletter"} + assert response.status_code == 201 + assert response["data"] == { + "id": "789", + "name": "Newsletter", + "type": "email", + "created_at": "2026-10-28 12:00:00", + } + assert "etag" not in response + + @pytest.mark.parametrize( + "email_id, name, error", + [ + ("", "Newsletter", EmptyIdError), + ("12a", "Newsletter", InvalidIdError), + ], + ) + def test_create_template_rejects_invalid_arguments( + self, client, mock_api, email_id, name, error + ): + with pytest.raises(error): + client.emails.create_template(email_id, name) + + assert mock_api.requests == [] + + def test_list_versions_without_options(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_versions.json")) + + response = client.emails.list_versions("123") + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == f"{BASE_URL}/123/versions" + assert mock_api.last_body is None + assert response["data"][0] == { + "id": "902", + "created_at": "2026-10-28 12:05:00", + "current": True, + } + assert response["data"][1]["current"] is False + assert response["links"]["next"] is None + assert response["meta"]["total"] == 2 + assert "etag" not in response + + def test_list_versions_sends_the_page_and_the_limit(self, client, mock_api): + mock_api.add_response(200, load_fixture("email_versions.json")) + + client.emails.list_versions("123", page=2, limit=10) + + assert mock_api.last_request.url == f"{BASE_URL}/123/versions?page=2&limit=10" + + def test_list_versions_rejects_an_invalid_id(self, client, mock_api): + with pytest.raises(InvalidIdError): + client.emails.list_versions("123/blocks") + + assert mock_api.requests == [] + + @pytest.mark.parametrize("status_code", [201, 200]) + def test_create_version_returns_the_version(self, client, mock_api, status_code): + mock_api.add_response(status_code, load_fixture("email_version.json")) + + response = client.emails.create_version("123") + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/versions" + assert mock_api.last_request.body is None + assert response.status_code == status_code + assert response["data"] == { + "id": "903", + "created_at": "2026-10-28 12:10:00", + "current": True, + } + assert "etag" not in response + + def test_create_version_rejects_an_empty_id(self, client, mock_api): + with pytest.raises(EmptyIdError): + client.emails.create_version("") + + assert mock_api.requests == [] + + def test_restore_version_returns_the_email(self, client, mock_api): + mock_api.add_response(200, load_fixture("email.json")) + + response = client.emails.restore_version("123", "901", "47") + + assert mock_api.last_request.method == "POST" + assert mock_api.last_request.url == f"{BASE_URL}/123/versions/901/restore" + assert mock_api.last_body == {"etag": "47"} + assert response["etag"] == "42" + assert response["data"]["id"] == "123" + assert response["data"]["blocks"][0]["id"] == "1" + + @pytest.mark.parametrize( + "version_id, etag, error", + [ + ("", "47", EmptyIdError), + ("901/restore", "47", InvalidIdError), + ], + ) + def test_restore_version_rejects_invalid_arguments( + self, client, mock_api, version_id, etag, error + ): + with pytest.raises(error): + client.emails.restore_version("123", version_id, etag) + + assert mock_api.requests == [] + + +# Each email write that accepts an idempotency key: (method, arguments, fixture) +WRITES = [ + ("create_block", ("123", "42", "divider", {}), "email_block_created.json"), + ("update_block", ("123", "57", "42", {}), "email_block_updated.json"), + ("delete_block", ("123", "57", "42"), "email_etag.json"), + ("move_block", ("123", "57", "42", {"position": "end"}), "email_block_moved.json"), + ("update_settings", ("123", "42", {}), "email_settings.json"), + ("create_template", ("123", "Newsletter"), "email_template.json"), + ("create_version", ("123",), "email_version.json"), + ("restore_version", ("123", "901", "42"), "email.json"), +] + + +class TestEmailIdempotencyKeys: + @pytest.mark.parametrize("method, args, fixture", WRITES) + def test_write_sends_the_idempotency_key( + self, client, mock_api, method, args, fixture + ): + mock_api.add_response(200, load_fixture(fixture)) + + getattr(client.emails, method)(*args, idempotency_key="insert-divider-0001") + + assert mock_api.last_request.headers["Idempotency-Key"] == "insert-divider-0001" + + @pytest.mark.parametrize("method, args, fixture", WRITES) + def test_write_without_a_key_sends_no_idempotency_key( + self, client, mock_api, method, args, fixture + ): + mock_api.add_response(200, load_fixture(fixture)) + + getattr(client.emails, method)(*args) + + assert "Idempotency-Key" not in mock_api.last_request.headers + + def test_replayed_write_is_an_idempotent_replay(self, client, mock_api): + mock_api.add_response( + 201, + load_fixture("email_block_created.json"), + {"Idempotent-Replayed": "true"}, + ) + + response = client.emails.create_block( + "123", "42", "divider", {}, idempotency_key="insert-divider-0001" + ) + + assert response.status_code == 201 + assert response.is_idempotent_replay is True + assert response["data"]["id"] == "58" + + def test_first_write_is_not_an_idempotent_replay(self, client, mock_api): + mock_api.add_response(201, load_fixture("email_block_created.json")) + + response = client.emails.create_block( + "123", "42", "divider", {}, idempotency_key="insert-divider-0001" + ) + + assert response.is_idempotent_replay is False + + def test_write_accepts_a_key_of_255_printable_characters(self, client, mock_api): + key = "".join(chr(code) for code in range(0x21, 0x7F)) + " " + "k" * 160 + assert len(key) == 255 + mock_api.add_response(201, load_fixture("email_version.json")) + + client.emails.create_version("123", idempotency_key=key) + + assert mock_api.last_request.headers["Idempotency-Key"] == key + + +# Each email write that sends an etag: (method, arguments before the etag, after it) +ETAG_WRITES = [ + ("create_block", ("123",), ("divider", {}), "email_block_created.json"), + ("update_block", ("123", "57"), ({},), "email_block_updated.json"), + ("delete_block", ("123", "57"), (), "email_etag.json"), + ("move_block", ("123", "57"), ({"position": "end"},), "email_block_moved.json"), + ("update_settings", ("123",), ({},), "email_settings.json"), + ("restore_version", ("123", "901"), (), "email.json"), +] + +INVALID_BODY = { + "message": "The given data was invalid.", + "errors": {"etag": ["The etag field is required."]}, +} + + +class TestEmailsSendTheGivenValues: + """The SDK sends the values of the caller, and the API checks them.""" + + @pytest.mark.parametrize( + "placement", + [ + {"position": "after"}, + {"position": "before", "block_id": 57}, + {"position": "after", "block_id": "1/2"}, + {"position": "start", "block_id": "57"}, + {"position": "end", "block_id": None}, + {"position": "inside", "block_id": 7}, + "end", + ], + ) + def test_block_writes_send_the_placement_as_given( + self, client, mock_api, placement + ): + mock_api.add_response(201, load_fixture("email_block_created.json")) + mock_api.add_response(200, load_fixture("email_block_moved.json")) + mock_api.add_response(200, load_fixture("email_block_validation_valid.json")) + + client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) + client.emails.move_block("123", "57", "45", placement) + client.emails.validate_block(BlockType.DIVIDER, {}, placement) + + assert [json.loads(request.body) for request in mock_api.requests] == [ + { + "type": "divider", + "editor_content": {}, + "placement": placement, + "etag": "42", + }, + {"placement": placement, "etag": "45"}, + {"type": "divider", "editor_content": {}, "placement": placement}, + ] + + @pytest.mark.parametrize("etag", ["", None, 42]) + @pytest.mark.parametrize("method, before, after, fixture", ETAG_WRITES) + def test_write_sends_the_etag_as_given( + self, client, mock_api, method, before, after, fixture, etag + ): + mock_api.add_response(200, load_fixture(fixture)) + + getattr(client.emails, method)(*before, etag, *after) + + assert mock_api.last_body["etag"] == etag + + def test_write_with_an_empty_etag_raises_the_api_error(self, client, mock_api): + mock_api.add_response(422, INVALID_BODY) + + with pytest.raises(ApiError) as error: + client.emails.delete_block("123", "57", "") + + assert mock_api.last_body == {"etag": ""} + assert error.value.status_code == 422 + assert error.value.errors == INVALID_BODY["errors"] + + @pytest.mark.parametrize( + "method, args, body", + [ + ( + "create_block", + ("123", "42", None, "text"), + {"type": None, "editor_content": "text", "etag": "42"}, + ), + ( + "update_block", + ("123", "57", "43", None), + {"editor_content": None, "etag": "43"}, + ), + ("create_template", ("123", None), {"name": None}), + ], + ) + def test_write_sends_a_value_of_the_wrong_type_and_raises_the_api_error( + self, client, mock_api, method, args, body + ): + mock_api.add_response(422, INVALID_BODY) + + with pytest.raises(ApiError): + getattr(client.emails, method)(*args) + + assert mock_api.last_body == body + + @pytest.mark.parametrize( + "block_type, editor_content", [(5, {}), (None, {}), ("heading", [])] + ) + def test_validate_block_sends_an_invalid_block( + self, client, mock_api, block_type, editor_content + ): + mock_api.add_response(200, load_fixture("email_block_validation_invalid.json")) + + response = client.emails.validate_block(block_type, editor_content) + + assert mock_api.last_body == { + "type": block_type, + "editor_content": editor_content, + } + assert response["valid"] is False + + @pytest.mark.parametrize( + "key", + [ + "", + pytest.param("k" * 256, id="256-characters"), + "tab\there", + "delete\x7f", + "trailing-space ", + ], + ) + @pytest.mark.parametrize("method, args, fixture", WRITES) + def test_write_sends_the_idempotency_key_as_given( + self, client, mock_api, method, args, fixture, key + ): + mock_api.add_response(200, load_fixture(fixture)) + + getattr(client.emails, method)(*args, idempotency_key=key) + + assert mock_api.last_request.headers["Idempotency-Key"] == key + + # requests cannot send these values in a header + @pytest.mark.parametrize("key", [" leading-space", "line\nbreak", 1]) + def test_write_raises_the_requests_error_for_a_key_that_http_cannot_send( + self, client, mock_api, key + ): + with pytest.raises(requests.exceptions.InvalidHeader): + client.emails.create_version("123", idempotency_key=key) + + assert mock_api.requests == [] diff --git a/tests/files_test.py b/tests/files_test.py new file mode 100644 index 0000000..828bdfb --- /dev/null +++ b/tests/files_test.py @@ -0,0 +1,80 @@ +import pytest +import mailerlite +from mailerlite import Client +from mailerlite.response import ApiResponse +from mailerlite.sdk.files import FileSort, FileType + +from tests.conftest import load_fixture + +URL = "https://connect.mailerlite.com/api/files" + + +@pytest.fixture +def client(): + return Client({"api_key": "1234567890"}) + + +class TestFiles: + def test_api_url_is_properly_set(self, client): + assert client.files.base_api_url == "api/files" + + def test_list_without_options(self, client, mock_api): + mock_api.add_response(200, load_fixture("files.json")) + + response = client.files.list() + + assert mock_api.last_request.method == "GET" + assert mock_api.last_request.url == URL + assert mock_api.last_body is None + assert isinstance(response, ApiResponse) + image, pdf = response["data"] + assert image["id"] == "301" + assert image["type"] == "image" + assert image["name"] == "summer sale banner.png" + assert image["size"] == 48213 + assert (image["width"], image["height"]) == (1200, 600) + assert pdf["type"] == "pdf" + assert pdf["width"] is None + assert pdf["height"] is None + assert response["meta"]["total"] == 2 + + def test_list_sends_a_name_filter_with_spaces(self, client, mock_api): + mock_api.add_response(200, load_fixture("files.json")) + + client.files.list(filter={"name": "summer sale"}) + + assert mock_api.last_request.url == f"{URL}?filter%5Bname%5D=summer+sale" + + def test_list_sends_all_options(self, client, mock_api): + mock_api.add_response(200, load_fixture("files.json")) + + client.files.list( + filter={"name": "banner", "type": FileType.IMAGE}, + sort=FileSort.SIZE_DESCENDING, + page=2, + limit=50, + ) + + assert mock_api.last_request.url == ( + f"{URL}?filter%5Bname%5D=banner&filter%5Btype%5D=image" + "&sort=-size&page=2&limit=50" + ) + + def test_list_rejects_a_filter_of_the_wrong_type(self, client, mock_api): + with pytest.raises(TypeError): + client.files.list(filter="image") + + assert mock_api.requests == [] + + def test_file_constants(self): + assert FileType.IMAGE == "image" + assert FileType.PDF == "pdf" + assert FileType.EPUB == "epub" + assert FileSort.CREATED_AT == "created_at" + assert FileSort.CREATED_AT_DESCENDING == "-created_at" + assert FileSort.NAME == "name" + assert FileSort.NAME_DESCENDING == "-name" + assert FileSort.SIZE == "size" + assert FileSort.SIZE_DESCENDING == "-size" + assert mailerlite.FileType is FileType + assert mailerlite.FileSort is FileSort diff --git a/tests/fixtures/brand_styles.json b/tests/fixtures/brand_styles.json new file mode 100644 index 0000000..dab5ce3 --- /dev/null +++ b/tests/fixtures/brand_styles.json @@ -0,0 +1,50 @@ +{ + "data": { + "brand": { + "identifier": "account", + "name": "Brand styles", + "font_family": "Inter, sans-serif", + "primary_color": "#2cb191", + "secondary_color": "#1f2937", + "heading_color": "#111827", + "text_color": "#374151", + "border_color": "#e5e7eb", + "background_color": "#ffffff" + }, + "default": { + "identifier": "default-brand", + "name": "Default", + "font_family": "Inter, sans-serif", + "primary_color": "#09c269", + "secondary_color": "#000000", + "heading_color": "#000000", + "text_color": "#000000", + "border_color": "#e5e7eb", + "background_color": "#ffffff" + }, + "presets": [ + { + "identifier": "preset-business-casual", + "name": "Business casual", + "font_family": "Georgia, serif", + "primary_color": "#1d4ed8", + "secondary_color": "#1e3a8a", + "heading_color": "#0f172a", + "text_color": "#334155", + "border_color": "#cbd5e1", + "background_color": "#f8fafc" + }, + { + "identifier": "preset-retro", + "name": "Retro", + "font_family": "Courier New, monospace", + "primary_color": "#d97706", + "secondary_color": "#92400e", + "heading_color": "#451a03", + "text_color": "#78350f", + "border_color": "#fde68a", + "background_color": "#fffbeb" + } + ] + } +} diff --git a/tests/fixtures/campaign_simple_editor.json b/tests/fixtures/campaign_simple_editor.json new file mode 100644 index 0000000..d54f882 --- /dev/null +++ b/tests/fixtures/campaign_simple_editor.json @@ -0,0 +1,24 @@ +{ + "data": { + "id": "456", + "account_id": "1", + "name": "Simple editor campaign", + "type": "regular", + "status": "draft", + "default_email_id": "123", + "emails": [ + { + "id": "123", + "account_id": "1", + "emailable_id": "456", + "emailable_type": "campaigns", + "type": "builder_inline_email", + "from": "sender@example.com", + "from_name": "Sender", + "reply_to": "reply@example.com", + "subject": "Hello", + "is_designed": true + } + ] + } +} diff --git a/tests/fixtures/email.json b/tests/fixtures/email.json new file mode 100644 index 0000000..2762d5b --- /dev/null +++ b/tests/fixtures/email.json @@ -0,0 +1,81 @@ +{ + "data": { + "id": "123", + "type": "builder_inline_email", + "emailable_type": "campaigns", + "emailable_id": "456", + "settings": { + "brand": { + "mode": "account", + "font_family": "Inter, sans-serif", + "background_color": "#ffffff", + "text_color": "#000000", + "border_color": "#e5e7eb" + }, + "layout": {"width_mode": "default", "alignment": "center"}, + "typography": { + "link": {"color": "#2cb191"}, + "heading": { + "font_family": "Inter, sans-serif", + "font_weight": "700", + "line_height": "150%", + "letter_spacing": "0", + "h1": {"color": "#000000", "size": "32"}, + "h2": {"color": "#000000", "size": "24"}, + "h3": {"color": "#000000", "size": "20"} + }, + "paragraph": { + "font_family": "Inter, sans-serif", + "font_weight": "400", + "line_height": "150%", + "letter_spacing": "0", + "p1": {"color": "#000000", "size": "16"} + } + }, + "button": { + "font_family": "Inter, sans-serif", + "font_weight": "600", + "letter_spacing": "0", + "background_color": "#2cb191", + "text_color": "#ffffff", + "border_radius": "4" + }, + "footer": { + "show_preferences": false, + "company_name": { + "editor_content": { + "content": [{"type": "text", "text": "Acme", "marks": [{"type": "bold"}]}] + } + }, + "address": { + "editor_content": {"content": [{"text": "1 Main Street", "type": "text"}]} + }, + "info_text": { + "editor_content": {"content": [{"text": "You signed up on our website.", "type": "text"}]} + }, + "unsubscribe_text": "Unsubscribe", + "preferences_text": "Update your preferences", + "social_links": [ + {"url": "https://example.com", "type": "website", "label": "Website", "link_id": 7} + ] + } + }, + "blocks": [ + { + "id": "1", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "left"}, + "content": [{"text": "Hello", "type": "text"}] + } + }, + { + "id": "2", + "type": "bulleted_list", + "editor_content": {}, + "children": [{"id": "3", "type": "paragraph", "editor_content": {}}] + } + ] + }, + "etag": "42" +} diff --git a/tests/fixtures/email_block.json b/tests/fixtures/email_block.json new file mode 100644 index 0000000..df7d248 --- /dev/null +++ b/tests/fixtures/email_block.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "57", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "left"}, + "content": [{"text": "Hello", "type": "text"}] + }, + "placement": {"after": "56", "before": null} + }, + "etag": "43" +} diff --git a/tests/fixtures/email_block_created.json b/tests/fixtures/email_block_created.json new file mode 100644 index 0000000..7b52e03 --- /dev/null +++ b/tests/fixtures/email_block_created.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "58", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "center"}, + "content": [{"text": "Hi", "type": "text"}] + }, + "placement": {"after": "57", "before": null} + }, + "etag": "43" +} diff --git a/tests/fixtures/email_block_moved.json b/tests/fixtures/email_block_moved.json new file mode 100644 index 0000000..3041e81 --- /dev/null +++ b/tests/fixtures/email_block_moved.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": "57", + "type": "paragraph", + "editor_content": { + "attrs": {"textAlign": "left"}, + "content": [{"text": "Hello", "type": "text"}] + }, + "placement": {"after": null, "before": "1"} + }, + "etag": "46" +} diff --git a/tests/fixtures/email_block_updated.json b/tests/fixtures/email_block_updated.json new file mode 100644 index 0000000..cb87a43 --- /dev/null +++ b/tests/fixtures/email_block_updated.json @@ -0,0 +1,9 @@ +{ + "data": { + "id": "57", + "type": "paragraph", + "editor_content": {"content": [{"text": "Hello again", "type": "text"}]}, + "placement": {"after": "56", "before": "58"} + }, + "etag": "44" +} diff --git a/tests/fixtures/email_block_validation_invalid.json b/tests/fixtures/email_block_validation_invalid.json new file mode 100644 index 0000000..c5c04fb --- /dev/null +++ b/tests/fixtures/email_block_validation_invalid.json @@ -0,0 +1,5 @@ +{ + "valid": false, + "errors": {"type": ["The selected type is invalid."]}, + "schema": {"version": "2.73.2", "hash": "0f3a9c"} +} diff --git a/tests/fixtures/email_block_validation_valid.json b/tests/fixtures/email_block_validation_valid.json new file mode 100644 index 0000000..b73c417 --- /dev/null +++ b/tests/fixtures/email_block_validation_valid.json @@ -0,0 +1 @@ +{"valid": true, "errors": {}, "schema": {"version": "2.73.2", "hash": "0f3a9c"}} diff --git a/tests/fixtures/email_etag.json b/tests/fixtures/email_etag.json new file mode 100644 index 0000000..730eaff --- /dev/null +++ b/tests/fixtures/email_etag.json @@ -0,0 +1 @@ +{"etag": "45"} diff --git a/tests/fixtures/email_html.json b/tests/fixtures/email_html.json new file mode 100644 index 0000000..d680c41 --- /dev/null +++ b/tests/fixtures/email_html.json @@ -0,0 +1 @@ +{"data": {"html": "Hello
"}, "etag": "42"} diff --git a/tests/fixtures/email_settings.json b/tests/fixtures/email_settings.json new file mode 100644 index 0000000..b6193ec --- /dev/null +++ b/tests/fixtures/email_settings.json @@ -0,0 +1,111 @@ +{ + "data": { + "brand": { + "mode": "custom", + "font_family": "Inter, sans-serif", + "background_color": "#f4f4f4", + "text_color": "#000000", + "border_color": "#e5e7eb" + }, + "layout": { + "width_mode": "default", + "alignment": "center" + }, + "typography": { + "link": { + "color": "#2cb191" + }, + "heading": { + "font_family": "Inter, sans-serif", + "font_weight": "700", + "line_height": "150%", + "letter_spacing": "0", + "h1": { + "color": "#000000", + "size": "32" + }, + "h2": { + "color": "#000000", + "size": "24" + }, + "h3": { + "color": "#000000", + "size": "20" + } + }, + "paragraph": { + "font_family": "Inter, sans-serif", + "font_weight": "400", + "line_height": "150%", + "letter_spacing": "0", + "p1": { + "color": "#000000", + "size": "16" + } + } + }, + "button": { + "font_family": "Inter, sans-serif", + "font_weight": "600", + "letter_spacing": "0", + "background_color": "#2cb191", + "text_color": "#ffffff", + "border_radius": "4" + }, + "footer": { + "show_preferences": false, + "company_name": { + "editor_content": { + "content": [ + { + "type": "text", + "text": "Acme", + "marks": [ + { + "type": "bold" + } + ] + } + ] + } + }, + "address": { + "editor_content": { + "content": [ + { + "text": "1 Main Street", + "type": "text" + } + ] + } + }, + "info_text": { + "editor_content": { + "content": [ + { + "text": "You signed up on our website.", + "type": "text" + } + ] + } + }, + "unsubscribe_text": "Unsubscribe", + "preferences_text": "Update your preferences", + "social_links": [ + { + "url": "https://example.com", + "type": "website", + "label": "Website", + "link_id": 7 + } + ] + } + }, + "etag": "47", + "rejected": { + "message": "Some attributes were rejected.", + "keys": [ + "footer.social_links[0].url" + ] + } +} diff --git a/tests/fixtures/email_settings_validation.json b/tests/fixtures/email_settings_validation.json new file mode 100644 index 0000000..d1a571a --- /dev/null +++ b/tests/fixtures/email_settings_validation.json @@ -0,0 +1,9 @@ +{ + "valid": false, + "errors": {"layout.width": ["The layout.width field cannot be changed."]}, + "schema": {"version": "2.73.2", "hash": "0f3a9c"}, + "rejected": { + "message": "Some attributes were rejected.", + "keys": ["footer.social_links[0].url"] + } +} diff --git a/tests/fixtures/email_template.json b/tests/fixtures/email_template.json new file mode 100644 index 0000000..8f44cd4 --- /dev/null +++ b/tests/fixtures/email_template.json @@ -0,0 +1 @@ +{"data": {"id": "789", "name": "Newsletter", "type": "email", "created_at": "2026-10-28 12:00:00"}} diff --git a/tests/fixtures/email_version.json b/tests/fixtures/email_version.json new file mode 100644 index 0000000..8e0ef77 --- /dev/null +++ b/tests/fixtures/email_version.json @@ -0,0 +1 @@ +{"data": {"id": "903", "created_at": "2026-10-28 12:10:00", "current": true}} diff --git a/tests/fixtures/email_versions.json b/tests/fixtures/email_versions.json new file mode 100644 index 0000000..d1881d8 --- /dev/null +++ b/tests/fixtures/email_versions.json @@ -0,0 +1,26 @@ +{ + "data": [ + {"id": "902", "created_at": "2026-10-28 12:05:00", "current": true}, + {"id": "901", "created_at": "2026-10-28 12:00:00", "current": false} + ], + "links": { + "first": "https://connect.mailerlite.com/api/emails/123/versions?page=1", + "last": "https://connect.mailerlite.com/api/emails/123/versions?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + {"url": null, "label": "« Previous", "page": null, "active": false}, + {"url": "https://connect.mailerlite.com/api/emails/123/versions?page=1", "label": "1", "page": 1, "active": true}, + {"url": null, "label": "Next »", "page": null, "active": false} + ], + "path": "https://connect.mailerlite.com/api/emails/123/versions", + "per_page": 25, + "to": 2, + "total": 2 + } +} diff --git a/tests/fixtures/files.json b/tests/fixtures/files.json new file mode 100644 index 0000000..4bf0846 --- /dev/null +++ b/tests/fixtures/files.json @@ -0,0 +1,44 @@ +{ + "data": [ + { + "id": "301", + "type": "image", + "name": "summer sale banner.png", + "url": "https://storage.example.com/files/summer-sale-banner.png", + "size": 48213, + "width": 1200, + "height": 600, + "created_at": "2026-10-20 09:30:00" + }, + { + "id": "302", + "type": "pdf", + "name": "summer sale terms.pdf", + "url": "https://storage.example.com/files/summer-sale-terms.pdf", + "size": 10240, + "width": null, + "height": null, + "created_at": "2026-10-21 10:00:00" + } + ], + "links": { + "first": "https://connect.mailerlite.com/api/files?page=1", + "last": "https://connect.mailerlite.com/api/files?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + {"url": null, "label": "« Previous", "page": null, "active": false}, + {"url": "https://connect.mailerlite.com/api/files?page=1", "label": "1", "page": 1, "active": true}, + {"url": null, "label": "Next »", "page": null, "active": false} + ], + "path": "https://connect.mailerlite.com/api/files", + "per_page": 25, + "to": 2, + "total": 2 + } +} diff --git a/tests/groups_test.py b/tests/groups_test.py index 93d03fb..8e26156 100644 --- a/tests/groups_test.py +++ b/tests/groups_test.py @@ -122,7 +122,9 @@ def test_given_incorrect_subscribers_when_calling_import_subscribers_to_group_th with pytest.raises(TypeError): self.client.groups.import_subscribers_to_group(1234, "not-a-list") - @pytest.mark.skip(reason="VCR cassette was recorded with a 401 response and needs to be re-recorded") + @pytest.mark.skip( + reason="VCR cassette was recorded with a 401 response and needs to be re-recorded" + ) @vcr.use_cassette( "tests/vcr_cassettes/groups-import-subscribers.yml", filter_headers=["Authorization"], @@ -131,7 +133,9 @@ def test_given_correct_parameters_when_calling_import_subscribers_to_group_then_ self, ): subscribers = [{"email": "test@example.com", "name": "Test User"}] - response = self.client.groups.import_subscribers_to_group(pytest.entity_id, subscribers) + response = self.client.groups.import_subscribers_to_group( + pytest.entity_id, subscribers + ) assert isinstance(response, dict) @@ -180,3 +184,55 @@ def test_given_correct_group_id_when_calling_delete_then_group_is_removed( with pytest.raises(requests.HTTPError): self.client.groups.delete(121212) + + +class TestGroupsRequests: + @classmethod + def setup_class(self): + self.client = MailerLite.Client({"api_key": "test"}) + + def test_list_sends_documented_query_params(self, sent_request): + self.client.groups.list(filter={"name": "x"}, page=None, limit=10, sort="name") + + request = sent_request() + assert request.method == "GET" + assert request.path == "/groups" + assert request.params == {"filter[name]": "x", "limit": 10, "sort": "name"} + + def test_list_rejects_unknown_argument(self, sent_request): + with pytest.raises(TypeError, match="list"): + self.client.groups.list(list=1) + + def test_import_subscribers_to_group_posts_to_documented_path(self, sent_request): + subscribers = [{"email": "test@example.com"}] + self.client.groups.import_subscribers_to_group( + 123, subscribers, resubscribe=True, trigger_automations=False + ) + + request = sent_request() + assert request.method == "POST" + assert request.path == "/groups/123/import-subscribers" + assert request.body == { + "subscribers": subscribers, + "resubscribe": True, + "trigger_automations": False, + } + + def test_import_subscribers_to_group_rejects_unknown_argument(self, sent_request): + with pytest.raises(TypeError): + self.client.groups.import_subscribers_to_group(123, [], unknown=True) + + def test_get_group_subscribers_sends_documented_query_params(self, sent_request): + self.client.groups.get_group_subscribers( + 123, filter={"status": "active"}, limit=10, cursor="abc", include="groups" + ) + + request = sent_request() + assert request.method == "GET" + assert request.path == "/groups/123/subscribers" + assert request.params == { + "filter[status]": "active", + "limit": 10, + "cursor": "abc", + "include": "groups", + } diff --git a/tests/params_test.py b/tests/params_test.py new file mode 100644 index 0000000..d706d2c --- /dev/null +++ b/tests/params_test.py @@ -0,0 +1,108 @@ +import pytest +from mailerlite.exceptions import EmptyIdError, InvalidIdError +from mailerlite.sdk import _params + + +class TestParams: + def test_path_id_returns_the_id_as_a_string(self): + assert _params.path_id(123, "email_id") == "123" + assert _params.path_id("123", "email_id") == "123" + + @pytest.mark.parametrize( + "value, error", + [ + ("", EmptyIdError), + (None, EmptyIdError), + ("12/3", InvalidIdError), + (True, TypeError), + (1.5, TypeError), + ], + ) + def test_path_id_rejects_an_invalid_id(self, value, error): + with pytest.raises(error, match="email_id"): + _params.path_id(value, "email_id") + + def test_require_dict_rejects_a_value_that_is_not_a_dict(self): + _params.require_dict({}, "settings") + + with pytest.raises(TypeError, match="settings"): + _params.require_dict([], "settings") + + def test_without_none_removes_the_keys_without_a_value(self): + assert _params.without_none({"page": 2, "limit": None}) == {"page": 2} + + def test_filter_params_returns_a_query_param_for_each_filter(self): + assert _params.filter_params(None) == {} + assert _params.filter_params({"name": "logo", "type": "image"}) == { + "filter[name]": "logo", + "filter[type]": "image", + } + + def test_filter_params_removes_the_filters_without_a_value(self): + assert _params.filter_params({"name": "logo", "type": None}) == { + "filter[name]": "logo" + } + + def test_filter_params_rejects_a_filter_that_is_not_a_dict(self): + with pytest.raises(TypeError, match="filter"): + _params.filter_params("image") + + def test_query_params_flatten_filter_and_encode_values(self): + params = _params.query_params( + { + "filter": {"status": "active", "id": [1, 2, 3], "enabled": True}, + "with_steps": False, + "limit": 10, + "cursor": None, + }, + ["filter", "with_steps", "limit", "cursor"], + ) + + assert params == { + "filter[status]": "active", + "filter[id]": "1,2,3", + "filter[enabled]": 1, + "with_steps": 0, + "limit": 10, + } + + def test_query_params_drop_none_filter_values(self): + assert _params.query_params({"filter": {"search": None}}, ["filter"]) == {} + + def test_query_params_reject_unknown_argument(self): + with pytest.raises(TypeError, match="unknown"): + _params.query_params({"unknown": 1}, ["filter"]) + + def test_query_params_reject_a_filter_that_is_not_a_dict(self): + with pytest.raises(TypeError, match="filter"): + _params.query_params({"filter": "active"}, ["filter"]) + + def test_body_params_keep_json_types(self): + body = _params.body_params( + {"resubscribe": True, "groups": [1, 2]}, ["resubscribe", "groups"] + ) + + assert body == {"resubscribe": True, "groups": [1, 2]} + + def test_body_params_drop_none_and_return_a_new_dict(self): + kwargs = {"resubscribe": True, "parent_id": None} + + body = _params.body_params(kwargs, ["resubscribe", "parent_id"]) + + assert body == {"resubscribe": True} + assert body is not kwargs + assert kwargs == {"resubscribe": True, "parent_id": None} + + def test_body_params_reject_unknown_argument(self): + with pytest.raises(TypeError, match="unknown"): + _params.body_params({"unknown": 1}, ["resubscribe"]) + + def test_idempotency_headers(self): + assert _params.idempotency_headers(None) is None + assert _params.idempotency_headers("k1") == {"Idempotency-Key": "k1"} + + @pytest.mark.parametrize( + "key", ["", " k1", pytest.param("k" * 256, id="256-characters")] + ) + def test_idempotency_headers_send_the_key_as_given(self, key): + assert _params.idempotency_headers(key) == {"Idempotency-Key": key} diff --git a/tests/response_test.py b/tests/response_test.py new file mode 100644 index 0000000..e0a3445 --- /dev/null +++ b/tests/response_test.py @@ -0,0 +1,82 @@ +import json + +import pytest + +import mailerlite +from mailerlite.api_client import ApiClient +from mailerlite.response import ApiResponse + + +class TestApiResponse: + def test_it_is_a_dict_with_the_decoded_body(self, mock_api): + mock_api.add_response(201, {"data": {"id": "1"}}, {"X-Request-Id": "abc"}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/versions")) + + assert isinstance(response, dict) + assert response == {"data": {"id": "1"}} + assert response["data"]["id"] == "1" + assert response.status_code == 201 + assert response.headers["x-request-id"] == "abc" + + def test_replayed_response_is_an_idempotent_replay(self, mock_api): + mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": "true"}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + assert response.is_idempotent_replay is True + + @pytest.mark.parametrize("value", ["True", "TRUE", " true "]) + def test_replay_header_value_is_not_case_sensitive(self, mock_api, value): + mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": value}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + assert response.is_idempotent_replay is True + + @pytest.mark.parametrize("value", ["false", "1", ""]) + def test_other_replay_header_values_are_not_an_idempotent_replay( + self, mock_api, value + ): + mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": value}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + assert response.is_idempotent_replay is False + + def test_response_without_the_header_is_not_an_idempotent_replay(self, mock_api): + mock_api.add_response(201, {"data": {}}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + assert response.is_idempotent_replay is False + + def test_empty_body_is_an_empty_dict(self, mock_api): + mock_api.add_response(204) + + response = ApiResponse(ApiClient({}).request("DELETE", "api/emails/1/blocks/2")) + + assert response == {} + assert response.status_code == 204 + + @pytest.mark.parametrize("body", [[1, 2], "text", None, 1]) + def test_body_that_is_not_a_json_object_raises_type_error(self, mock_api, body): + mock_api.add_response(200, json.dumps(body)) + + with pytest.raises(TypeError, match="JSON object"): + ApiResponse(ApiClient({}).request("GET", "api/emails/1")) + + def test_copy_keeps_the_status_code_and_the_headers(self, mock_api): + mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": "true"}) + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + copied = response.copy() + + assert isinstance(copied, ApiResponse) + assert copied == response + assert copied is not response + assert copied.status_code == 201 + assert copied.is_idempotent_replay is True + + def test_api_response_is_exported_from_the_package(self): + assert mailerlite.ApiResponse is ApiResponse diff --git a/tests/segments_test.py b/tests/segments_test.py index 5f69fef..7468296 100644 --- a/tests/segments_test.py +++ b/tests/segments_test.py @@ -151,3 +151,69 @@ def test_given_incorrect_segment_id_when_calling_delete_then_type_error_is_retur # response = self.client.segments.delete(segment_id) # assert response is False + + +class TestSegmentsRequests: + @classmethod + def setup_class(self): + self.client = MailerLite.Client({"api_key": "test"}) + + def test_list_sends_documented_query_params(self, sent_request): + self.client.segments.list(limit=10, page=2, sort="name") + + request = sent_request() + assert request.method == "GET" + assert request.path == "/segments" + assert request.params == {"limit": 10, "page": 2, "sort": "name"} + + def test_get_subscribers_sends_cursor_and_filter_status(self, sent_request): + self.client.segments.get_subscribers( + 123, filter={"status": "active"}, limit=10, cursor="abc" + ) + + request = sent_request() + assert request.method == "GET" + assert request.path == "/segments/123/subscribers" + assert request.params == { + "filter[status]": "active", + "limit": 10, + "cursor": "abc", + } + + def test_get_subscribers_sends_after_as_cursor_with_a_warning(self, sent_request): + with pytest.warns(DeprecationWarning, match="after"): + self.client.segments.get_subscribers(123, after="abc") + + assert sent_request().params == {"cursor": "abc"} + + def test_get_subscribers_prefers_cursor_over_after(self, sent_request): + with pytest.warns(DeprecationWarning, match="after"): + self.client.segments.get_subscribers(123, after="old", cursor="new") + + assert sent_request().params == {"cursor": "new"} + + def test_get_sends_query_params_through_the_shared_helper(self, sent_request): + self.client.segments.get(123, filter={"status": "active"}, limit=None) + + request = sent_request() + assert request.method == "GET" + assert request.path == "/segments/123" + assert request.params == {"filter[status]": "active"} + + def test_get_rejects_after(self, sent_request): + with pytest.raises(TypeError, match="after"): + self.client.segments.get(123, after="abc") + + def test_update_sends_documented_body_params(self, sent_request): + rules = [[{"operator": "in_any", "args": ["groups", 1]}]] + response = self.client.segments.update(123, "New name", filter=rules) + + request = sent_request() + assert response is True + assert request.method == "PUT" + assert request.path == "/segments/123" + assert request.body == {"name": "New name", "filter": rules} + + def test_update_rejects_unknown_argument(self, sent_request): + with pytest.raises(TypeError): + self.client.segments.update(123, "New name", unknown=True) diff --git a/tests/subscribers_test.py b/tests/subscribers_test.py index 83719d0..3ec55ce 100644 --- a/tests/subscribers_test.py +++ b/tests/subscribers_test.py @@ -299,3 +299,129 @@ def test_given_correct_params_when_calling_forget_then_subscirber_is_marked_as_f response = self.client.subscribers.forget(int(user["data"]["id"])) assert response is True + + +class TestSubscribersRequests: + @classmethod + def setup_class(self): + self.client = MailerLite.Client({"api_key": "test"}) + + def test_list_sends_documented_query_params(self, sent_request): + self.client.subscribers.list( + filter={"status": "active", "group": 1}, + limit=10, + page=2, + cursor="abc", + sort="-created_at", + include="groups", + start_date="2026-01-01", + end_date="2026-02-01", + with_fields=True, + with_groups=False, + ) + + request = sent_request() + assert request.method == "GET" + assert request.path == "/subscribers" + assert request.params == { + "filter[status]": "active", + "filter[group]": 1, + "limit": 10, + "page": 2, + "cursor": "abc", + "sort": "-created_at", + "include": "groups", + "start_date": "2026-01-01", + "end_date": "2026-02-01", + "with_fields": 1, + "with_groups": 0, + } + + def test_create_sends_documented_body_params(self, sent_request): + self.client.subscribers.create( + "test@example.com", + resubscribe=True, + double_optin=True, + notes="note", + status="active", + ) + + request = sent_request() + assert request.method == "POST" + assert request.path == "/subscribers" + assert request.body == { + "email": "test@example.com", + "resubscribe": True, + "double_optin": True, + "notes": "note", + "status": "active", + } + + def test_update_sends_put_to_subscriber_by_email(self, sent_request): + self.client.subscribers.update( + "test@example.com", fields={"name": "Someone"}, status="active" + ) + + request = sent_request() + assert request.method == "PUT" + assert request.path == "/subscribers/test@example.com" + assert request.body == {"fields": {"name": "Someone"}, "status": "active"} + + def test_update_sends_put_to_subscriber_by_id(self, sent_request): + self.client.subscribers.update(123, ip_address="1.1.1.1") + + request = sent_request() + assert request.method == "PUT" + assert request.path == "/subscribers/123" + assert request.body == {"ip_address": "1.1.1.1"} + + def test_update_quotes_the_email_in_the_path(self, sent_request): + self.client.subscribers.update("a+b#c@example.com", status="active") + + request = sent_request() + assert request.method == "PUT" + assert request.path == "/subscribers/a%2Bb%23c@example.com" + assert request.body == {"status": "active"} + + def test_get_quotes_the_email_in_the_path(self, sent_request): + self.client.subscribers.get("a+b#c@example.com") + + request = sent_request() + assert request.method == "GET" + assert request.path == "/subscribers/a%2Bb%23c@example.com" + + @pytest.mark.parametrize("value", [True, False]) + def test_update_rejects_a_bool_as_subscriber(self, sent_request, value): + with pytest.raises(TypeError): + self.client.subscribers.update(value, status="active") + + @pytest.mark.parametrize("value", [True, False]) + def test_get_rejects_a_bool_as_subscriber(self, sent_request, value): + with pytest.raises(TypeError): + self.client.subscribers.get(value) + + def test_update_sends_new_email_as_email(self, sent_request): + self.client.subscribers.update(123, new_email="new@example.com") + + request = sent_request() + assert request.method == "PUT" + assert request.path == "/subscribers/123" + assert request.body == {"email": "new@example.com"} + + def test_activity_log_sends_documented_query_params(self, sent_request): + self.client.subscribers.activity_log( + 123, filter={"log_name": "email_open"}, limit=5, page=1 + ) + + request = sent_request() + assert request.method == "GET" + assert request.path == "/subscribers/123/activity-log" + assert request.params == { + "filter[log_name]": "email_open", + "limit": 5, + "page": 1, + } + + def test_activity_log_rejects_unknown_argument(self, sent_request): + with pytest.raises(TypeError): + self.client.subscribers.activity_log(123, cursor="abc") diff --git a/tests/vcr_cassettes/subscribers-update.yml b/tests/vcr_cassettes/subscribers-update.yml index c6e50d3..1b829f4 100644 --- a/tests/vcr_cassettes/subscribers-update.yml +++ b/tests/vcr_cassettes/subscribers-update.yml @@ -1,7 +1,6 @@ interactions: - request: - body: '{"email": "test5@email.com", "fields": {"name": "Someone", "last_name": - "Doe"}, "ip_address": "1.1.1.1"}' + body: '{"fields": {"name": "Someone", "last_name": "Doe"}, "ip_address": "1.1.1.1"}' headers: Accept: - application/json @@ -10,13 +9,13 @@ interactions: Connection: - keep-alive Content-Length: - - '104' + - '76' Content-Type: - application/json User-Agent: - MailerLite-Python-SDK-Client - method: POST - uri: https://connect.mailerlite.com/api/subscribers + method: PUT + uri: https://connect.mailerlite.com/api/subscribers/test5@email.com response: body: string: '{"data":{"id":"165968862226941797","email":"test5@email.com","status":"active","source":"api","sent":0,"opens_count":0,"clicks_count":0,"open_rate":0,"click_rate":0,"ip_address":"1.1.1.1","subscribed_at":"2025-09-19 diff --git a/uv.lock b/uv.lock index c095698..d4ee8fa 100644 --- a/uv.lock +++ b/uv.lock @@ -369,7 +369,7 @@ wheels = [ [[package]] name = "mailerlite" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "requests" },