From e1faef1188d3f4d977570f17c8a0a43e8e8a72cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:43 +0100 Subject: [PATCH 01/48] test: add a fixture that records the prepared requests The mock_api fixture replaces requests.Session.send. Tests can assert the method, the full URL, the headers and the body of each request, and give the response that the API returns. Co-Authored-By: Claude Opus 5.5 --- tests/api_client_test.py | 18 ++++++++++ tests/conftest.py | 71 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index adb7433..a081fa5 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -49,3 +49,21 @@ 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): + """Tests that mock_api records the request and returns the queued response""" + + 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 diff --git a/tests/conftest.py b/tests/conftest.py index 60b49a9..9045abf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,76 @@ +import json +from http.client import responses +from pathlib import Path + import pytest +import requests + +FIXTURES_DIR = Path(__file__).parent / "fixtures" def pytest_configure(): return {"entity_id": 0, "entity_email": "random@mailerlite.com"} + + +def load_fixture(name): + """Returns the decoded JSON of a file in tests/fixtures""" + + with open(FIXTURES_DIR / name) as fd: + return json.load(fd) + + +class MockApi(object): + """Records the prepared requests and returns the queued responses""" + + def __init__(self): + self.requests = [] + self.responses = [] + + def add_response(self, status_code=200, body=None, headers=None): + """Queues a response. A dict or list body is encoded as JSON.""" + + 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) + + response = requests.Response() + response.status_code = status_code + response.reason = responses.get(status_code, "") + response.headers.update({"Content-Type": "application/json", **headers}) + response._content = body + response.url = request.url + response.request = request + + return response + + @property + def last_request(self): + return self.requests[-1] + + @property + def last_body(self): + """Returns the decoded JSON body of the last request, or None without a body""" + + 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 From 684cac80fa171fdf0965bba1429a9bf3ea4497d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:43 +0100 Subject: [PATCH 02/48] fix: send the JSON body for PATCH requests ApiClient.request() rejected PATCH. Allow it, and send the body as JSON like POST and PUT. Co-Authored-By: Claude Opus 5.5 --- mailerlite/api_client.py | 14 +++++++++++--- tests/api_client_test.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/mailerlite/api_client.py b/mailerlite/api_client.py index e487c10..c867050 100644 --- a/mailerlite/api_client.py +++ b/mailerlite/api_client.py @@ -13,10 +13,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 +27,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) @@ -78,7 +80,9 @@ def request( 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, @@ -89,7 +93,11 @@ def request( } if HttpMethods.is_get(method): kwargs.update(allow_redirects=True) - if HttpMethods.is_post(method) or HttpMethods.is_put(method): + if ( + HttpMethods.is_post(method) + or HttpMethods.is_put(method) + or HttpMethods.is_patch(method) + ): kwargs.update(data=json.dumps(body)) response = requests.request(**kwargs) response.raise_for_status() diff --git a/tests/api_client_test.py b/tests/api_client_test.py index a081fa5..c90fe6c 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -67,3 +67,14 @@ def test_mock_api_records_the_prepared_request(self, mock_api): ) 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): + """Tests that a PATCH request sends the body as JSON""" + + 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"} From 111f817f0b0ca2c4a1bb7e4fddbfbe7a88abc288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:44 +0100 Subject: [PATCH 03/48] fix: send the JSON body for DELETE requests that have one Some endpoints need a body on DELETE, for example the etag to delete an email block. Send the body when the caller gives one. A DELETE without a body continues to send no body. Co-Authored-By: Claude Opus 5.5 --- mailerlite/api_client.py | 2 ++ tests/api_client_test.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/mailerlite/api_client.py b/mailerlite/api_client.py index c867050..25a1e27 100644 --- a/mailerlite/api_client.py +++ b/mailerlite/api_client.py @@ -99,6 +99,8 @@ def request( or HttpMethods.is_patch(method) ): kwargs.update(data=json.dumps(body)) + if HttpMethods.is_delete(method) and body is not None: + kwargs.update(data=json.dumps(body)) response = requests.request(**kwargs) response.raise_for_status() return response diff --git a/tests/api_client_test.py b/tests/api_client_test.py index c90fe6c..949047b 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -78,3 +78,25 @@ def test_patch_request_sends_the_json_body(self, mock_api): 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): + """Tests that a DELETE request with a body sends the body as JSON""" + + 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): + """Tests that a DELETE request without a body sends no body""" + + 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 From f231aa1c50b6922b6b4f376cc33d0fc082f0425b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:44 +0100 Subject: [PATCH 04/48] feat: expose the API error code and etag on ApiError API errors now raise mailerlite.ApiError, a subclass of requests.HTTPError. It has the status code, the message, the code, the etag and the field errors of the response body. A version conflict sends the current etag of the email, so the caller can get the email again. The error text keeps the format of raise_for_status() and adds the code and the message of the API. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 20 +++++++--- mailerlite/__init__.py | 1 + mailerlite/api_client.py | 5 ++- mailerlite/exceptions.py | 41 ++++++++++++++++++++ tests/api_client_test.py | 84 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 mailerlite/exceptions.py diff --git a/Readme.md b/Readme.md index 3a41cc1..eeffd2d 100644 --- a/Readme.md +++ b/Readme.md @@ -111,10 +111,18 @@ client = MailerLite.Client({ ### 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({ @@ -123,9 +131,11 @@ client = MailerLite.Client({ 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 +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. diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index 08810f2..fa3111f 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -3,6 +3,7 @@ from __future__ import absolute_import from mailerlite.api_client import ApiClient +from mailerlite.exceptions import ApiError # noqa: F401 from mailerlite.sdk.automations import Automations from mailerlite.sdk.batch import Batches diff --git a/mailerlite/api_client.py b/mailerlite/api_client.py index 25a1e27..3664b9c 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""" @@ -102,5 +104,6 @@ def request( if HttpMethods.is_delete(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..b824268 --- /dev/null +++ b/mailerlite/exceptions.py @@ -0,0 +1,41 @@ +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 _json_or_none(response): + try: + return response.json() + except ValueError: + return None diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 949047b..b56919b 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1,5 +1,7 @@ import pytest +import requests from mailerlite.api_client import ApiClient +from mailerlite.exceptions import ApiError class TestApiClient: @@ -100,3 +102,85 @@ def test_delete_request_without_a_body_sends_no_body(self, mock_api): assert mock_api.last_request.method == "DELETE" assert mock_api.last_request.body is None + + def test_version_conflict_raises_api_error_with_code_and_etag(self, mock_api): + """Tests that a 409 response gives the message, the code and the etag""" + + 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): + """Tests that a 422 response gives the field errors""" + + 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): + """Tests that a response that is not JSON gives an error without details""" + + 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_is_exported_from_the_package(self): + """Tests that ApiError is available as mailerlite.ApiError""" + + import mailerlite + + assert mailerlite.ApiError is ApiError From 03f918c17e172813af18ed8f7f205bed41560c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:44 +0100 Subject: [PATCH 05/48] feat: add ApiResponse with the status code and the replay flag ApiResponse is a dict with the decoded JSON body, so callers use it like the dicts that the other methods return. It also has the status code, the headers and is_idempotent_replay, which is true when the API sends "Idempotent-Replayed: true". Co-Authored-By: Claude Opus 5.5 --- mailerlite/__init__.py | 1 + mailerlite/response.py | 24 ++++++++++++++++++++++++ tests/response_test.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 mailerlite/response.py create mode 100644 tests/response_test.py diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index fa3111f..1a723fc 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -4,6 +4,7 @@ from mailerlite.api_client import ApiClient from mailerlite.exceptions import ApiError # noqa: F401 +from mailerlite.response import ApiResponse # noqa: F401 from mailerlite.sdk.automations import Automations from mailerlite.sdk.batch import Batches diff --git a/mailerlite/response.py b/mailerlite/response.py new file mode 100644 index 0000000..a4a36ee --- /dev/null +++ b/mailerlite/response.py @@ -0,0 +1,24 @@ +from __future__ import absolute_import + + +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. + """ + + def __init__(self, response): + super().__init__(response.json()) + self.status_code = response.status_code + self.headers = response.headers + + @property + def is_idempotent_replay(self): + """ + True if the API returned the stored response of an earlier request + with the same idempotency key + """ + + return self.headers.get("Idempotent-Replayed") == "true" diff --git a/tests/response_test.py b/tests/response_test.py new file mode 100644 index 0000000..7b64ee6 --- /dev/null +++ b/tests/response_test.py @@ -0,0 +1,41 @@ +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): + """Tests that ApiResponse has the decoded JSON body and the status code""" + + 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): + """Tests that the Idempotent-Replayed header sets the replay flag""" + + 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 + + def test_response_without_the_header_is_not_an_idempotent_replay(self, mock_api): + """Tests that the replay flag is false without the Idempotent-Replayed header""" + + mock_api.add_response(201, {"data": {}}) + + response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) + + assert response.is_idempotent_replay is False + + def test_api_response_is_exported_from_the_package(self): + """Tests that ApiResponse is available as mailerlite.ApiResponse""" + + assert mailerlite.ApiResponse is ApiResponse From edb1ed0542d7f1dbc41947f9885da89743dc7aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:45 +0100 Subject: [PATCH 06/48] feat: add emails.get Add the Emails service for emails built with the simple editor, with get(). IDs in the URL path must contain only digits. An empty ID raises EmptyIdError and any other ID raises InvalidIdError, and the SDK sends no request. URL encoding is not sufficient, because the server decodes "%2F" in the path. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 30 ++++++++++++++ mailerlite/__init__.py | 8 +++- mailerlite/exceptions.py | 8 ++++ mailerlite/sdk/__init__.py | 1 + mailerlite/sdk/emails.py | 64 ++++++++++++++++++++++++++++++ tests/emails_test.py | 78 ++++++++++++++++++++++++++++++++++++ tests/fixtures/email.json | 81 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 mailerlite/sdk/emails.py create mode 100644 tests/emails_test.py create mode 100644 tests/fixtures/email.json diff --git a/Readme.md b/Readme.md index eeffd2d..c4a670c 100644 --- a/Readme.md +++ b/Readme.md @@ -59,6 +59,8 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -692,6 +694,34 @@ 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. +- The methods return a `mailerlite.ApiResponse`. It is a `dict` with the response body, and it also has `status_code` and `headers`. + +### 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'] +``` + ## Forms diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index 1a723fc..24728e0 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -3,13 +3,18 @@ from __future__ import absolute_import from mailerlite.api_client import ApiClient -from mailerlite.exceptions import ApiError # noqa: F401 +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 # import all api files into api package from mailerlite.sdk.campaigns import Campaigns +from mailerlite.sdk.emails import Emails from mailerlite.sdk.fields import Fields from mailerlite.sdk.forms import Forms from mailerlite.sdk.groups import Groups @@ -31,6 +36,7 @@ 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.timezones = Timezones(self.api_client) self.webhooks = Webhooks(self.api_client) self.automations = Automations(self.api_client) diff --git a/mailerlite/exceptions.py b/mailerlite/exceptions.py index b824268..5ab0d1d 100644 --- a/mailerlite/exceptions.py +++ b/mailerlite/exceptions.py @@ -39,3 +39,11 @@ def _json_or_none(response): 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/sdk/__init__.py b/mailerlite/sdk/__init__.py index b07aabd..e394601 100644 --- a/mailerlite/sdk/__init__.py +++ b/mailerlite/sdk/__init__.py @@ -2,6 +2,7 @@ # import all api files into api package from mailerlite.sdk.campaigns import Campaigns +from mailerlite.sdk.emails import Emails from mailerlite.sdk.fields import Fields from mailerlite.sdk.forms import Forms from mailerlite.sdk.groups import Groups diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py new file mode 100644 index 0000000..369b453 --- /dev/null +++ b/mailerlite/sdk/emails.py @@ -0,0 +1,64 @@ +from __future__ import absolute_import + +import re + +from mailerlite.exceptions import EmptyIdError, InvalidIdError +from mailerlite.response import ApiResponse + +_ID_PATTERN = re.compile(r"[0-9]+") + + +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. + """ + + # 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 = _path_id(email_id, "email_id") + + return ApiResponse( + self.api_client.request("GET", f"{self.base_api_url}/{email_id}") + ) + + +def _path_id(value, name): + """Returns the ID as a string for the URL path, or raises if it is not valid""" + + 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 diff --git a/tests/emails_test.py b/tests/emails_test.py new file mode 100644 index 0000000..7020e03 --- /dev/null +++ b/tests/emails_test.py @@ -0,0 +1,78 @@ +import pytest +from mailerlite import Client +from mailerlite.exceptions import EmptyIdError, InvalidIdError +from mailerlite.response import ApiResponse + +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) 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" +} From b40d2085cb5200fe18bdf8aeacf9e8ccfda52ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:45 +0100 Subject: [PATCH 07/48] feat: add emails.get_html Co-Authored-By: Claude Opus 5.5 --- Readme.md | 20 ++++++++++++++++++++ mailerlite/sdk/emails.py | 22 ++++++++++++++++++++++ tests/emails_test.py | 19 +++++++++++++++++++ tests/fixtures/email_html.json | 1 + 4 files changed, 62 insertions(+) create mode 100644 tests/fixtures/email_html.json diff --git a/Readme.md b/Readme.md index c4a670c..4ef6dcc 100644 --- a/Readme.md +++ b/Readme.md @@ -61,6 +61,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -722,6 +723,25 @@ 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'] +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 369b453..cb6ab23 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -43,6 +43,28 @@ def get(self, email_id): 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 = _path_id(email_id, "email_id") + + return ApiResponse( + self.api_client.request("GET", f"{self.base_api_url}/{email_id}/html") + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index 7020e03..530c44e 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -76,3 +76,22 @@ def test_get_rejects_an_id_of_the_wrong_type(self, client, mock_api, email_id): 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 == [] 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"} From 4224f6f1ad9e911edb15cf2787a48ca7130786b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:45 +0100 Subject: [PATCH 08/48] feat: add emails.get_block Co-Authored-By: Claude Opus 5.5 --- Readme.md | 21 +++++++++++++++++++++ mailerlite/sdk/emails.py | 25 +++++++++++++++++++++++++ tests/emails_test.py | 33 +++++++++++++++++++++++++++++++++ tests/fixtures/email_block.json | 12 ++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 tests/fixtures/email_block.json diff --git a/Readme.md b/Readme.md index 4ef6dcc..c5eec82 100644 --- a/Readme.md +++ b/Readme.md @@ -62,6 +62,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -742,6 +743,26 @@ 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'] +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index cb6ab23..20c9675 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -65,6 +65,31 @@ def get_html(self, email_id): 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 = _path_id(email_id, "email_id") + block_id = _path_id(block_id, "block_id") + + return ApiResponse( + self.api_client.request( + "GET", f"{self.base_api_url}/{email_id}/blocks/{block_id}" + ) + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index 530c44e..b182496 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -95,3 +95,36 @@ def test_get_html_rejects_an_invalid_id(self, client, mock_api): 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 == [] 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" +} From b1cad13041cb17148f7456a3f914b7180817226c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:46 +0100 Subject: [PATCH 09/48] feat: add emails.create_block Writes send the etag from the last response for the email. An empty etag raises EmptyEtagError, and the SDK sends no request. Add the BlockType and BlockPosition constants, and VERSION_CONFLICT for the error code of a write with an old etag. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 31 ++++++ mailerlite/__init__.py | 8 +- mailerlite/exceptions.py | 4 + mailerlite/sdk/emails.py | 98 ++++++++++++++++++- tests/emails_test.py | 121 +++++++++++++++++++++++- tests/fixtures/email_block_created.json | 12 +++ 6 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/email_block_created.json diff --git a/Readme.md b/Readme.md index c5eec82..50583d0 100644 --- a/Readme.md +++ b/Readme.md @@ -63,6 +63,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -703,6 +704,9 @@ These methods edit emails built with the simple editor. To create such an email, - 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. - 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. An empty etag raises `mailerlite.EmptyEtagError`, and the SDK sends no request. +- 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 @@ -763,6 +767,33 @@ 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`. + +```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'] +``` + ## Forms diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index 24728e0..c8f4271 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -5,6 +5,7 @@ from mailerlite.api_client import ApiClient from mailerlite.exceptions import ( # noqa: F401 ApiError, + EmptyEtagError, EmptyIdError, InvalidIdError, ) @@ -14,7 +15,12 @@ # import all api files into api package from mailerlite.sdk.campaigns import Campaigns -from mailerlite.sdk.emails import Emails +from mailerlite.sdk.emails import ( # noqa: F401 + VERSION_CONFLICT, + BlockPosition, + BlockType, + Emails, +) from mailerlite.sdk.fields import Fields from mailerlite.sdk.forms import Forms from mailerlite.sdk.groups import Groups diff --git a/mailerlite/exceptions.py b/mailerlite/exceptions.py index 5ab0d1d..1790aff 100644 --- a/mailerlite/exceptions.py +++ b/mailerlite/exceptions.py @@ -47,3 +47,7 @@ class EmptyIdError(ValueError): class InvalidIdError(ValueError): """An ID for the URL path does not contain only digits. The SDK sends no request.""" + + +class EmptyEtagError(ValueError): + """The etag of a write is empty. The SDK sends no request.""" diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 20c9675..1a7f8bd 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -2,11 +2,41 @@ import re -from mailerlite.exceptions import EmptyIdError, InvalidIdError +from mailerlite.exceptions import EmptyEtagError, EmptyIdError, InvalidIdError from mailerlite.response import ApiResponse _ID_PATTERN = re.compile(r"[0-9]+") +# The error code of a write with an etag that is not the current etag of the email +VERSION_CONFLICT = "version_conflict" + + +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`.""" + + START = "start" + END = "end" + BEFORE = "before" + AFTER = "after" + class Emails(object): """ @@ -90,6 +120,38 @@ def get_block(self, email_id, block_id): ) ) + def create_block(self, email_id, etag, block_type, editor_content, placement=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. + :raises: :class: `TypeError` : an argument type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `EmptyEtagError` : `etag` is empty + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _path_id(email_id, "email_id") + body = _block_body(block_type, editor_content, placement) + body["etag"] = _etag(etag) + + return ApiResponse( + self.api_client.request( + "POST", f"{self.base_api_url}/{email_id}/blocks", body=body + ) + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" @@ -109,3 +171,37 @@ def _path_id(value, name): raise InvalidIdError(f"`{name}` must contain only digits, got {value!r}.") return value + + +def _block_body(block_type, editor_content, placement): + """Returns the body to insert or to validate a block""" + + _require_dict(editor_content, "editor_content") + + body = {"type": block_type, "editor_content": editor_content} + if placement is not None: + _require_dict(placement, "placement") + body["placement"] = placement + + return body + + +def _etag(value): + """Returns the etag for the body of a write, or raises if it is not valid""" + + if value is None or value == "": + raise EmptyEtagError("`etag` must not be empty.") + + if not isinstance(value, str): + raise TypeError(f"`etag` type is not valid. Expected `str`, got {type(value)}.") + + return value + + +def _require_dict(value, name): + """Raises if the value is not a dict""" + + if not isinstance(value, dict): + raise TypeError( + f"`{name}` type is not valid. Expected `dict`, got {type(value)}." + ) diff --git a/tests/emails_test.py b/tests/emails_test.py index b182496..eb9a3ad 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -1,7 +1,9 @@ import pytest +import mailerlite from mailerlite import Client -from mailerlite.exceptions import EmptyIdError, InvalidIdError +from mailerlite.exceptions import ApiError, EmptyEtagError, EmptyIdError, InvalidIdError from mailerlite.response import ApiResponse +from mailerlite.sdk.emails import VERSION_CONFLICT, BlockPosition, BlockType from tests.conftest import load_fixture @@ -128,3 +130,120 @@ def test_get_block_rejects_an_invalid_id( 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", + } + + @pytest.mark.parametrize("etag", ["", None]) + def test_create_block_rejects_an_empty_etag(self, client, mock_api, etag): + with pytest.raises(EmptyEtagError): + client.emails.create_block("123", etag, BlockType.DIVIDER, {}) + + assert mock_api.requests == [] + + @pytest.mark.parametrize( + "etag, editor_content, placement", + [ + (42, {}, None), + ("42", None, None), + ("42", "text", None), + ("42", {}, "end"), + ], + ) + def test_create_block_rejects_arguments_of_the_wrong_type( + self, client, mock_api, etag, editor_content, placement + ): + with pytest.raises(TypeError): + client.emails.create_block( + "123", etag, BlockType.DIVIDER, editor_content, placement=placement + ) + + assert mock_api.requests == [] + + 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.EmptyEtagError is EmptyEtagError + assert mailerlite.BlockType is BlockType + assert mailerlite.BlockPosition is BlockPosition + assert mailerlite.VERSION_CONFLICT == VERSION_CONFLICT 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" +} From 3170122b536088a4f7215689db291a4d7251a9f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:46 +0100 Subject: [PATCH 10/48] feat: add emails.update_block Co-Authored-By: Claude Opus 5.5 --- Readme.md | 27 +++++++++++++++++++++ mailerlite/sdk/emails.py | 32 +++++++++++++++++++++++++ tests/emails_test.py | 30 +++++++++++++++++++++++ tests/fixtures/email_block_updated.json | 9 +++++++ 4 files changed, 98 insertions(+) create mode 100644 tests/fixtures/email_block_updated.json diff --git a/Readme.md b/Readme.md index 50583d0..5026db1 100644 --- a/Readme.md +++ b/Readme.md @@ -64,6 +64,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -794,6 +795,32 @@ 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'] +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 1a7f8bd..a526753 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -152,6 +152,38 @@ def create_block(self, email_id, etag, block_type, editor_content, placement=Non ) ) + def update_block(self, email_id, block_id, etag, editor_content): + """ + 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`. + :raises: :class: `TypeError` : an argument 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: `EmptyEtagError` : `etag` is empty + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _path_id(email_id, "email_id") + block_id = _path_id(block_id, "block_id") + _require_dict(editor_content, "editor_content") + body = {"editor_content": editor_content, "etag": _etag(etag)} + + return ApiResponse( + self.api_client.request( + "PATCH", f"{self.base_api_url}/{email_id}/blocks/{block_id}", body=body + ) + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index eb9a3ad..e55fe51 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -247,3 +247,33 @@ 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), + ("57", "", {}, EmptyEtagError), + ("57", "43", None, TypeError), + ], + ) + 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 == [] 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" +} From 6fe26aa7d03c11957ecfc51f4d283eccabc1efb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:46 +0100 Subject: [PATCH 11/48] feat: add emails.delete_block The etag goes in the body of the DELETE request. The method returns the response, because it has the new etag of the email. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 22 ++++++++++++++++++++++ mailerlite/sdk/emails.py | 29 +++++++++++++++++++++++++++++ tests/emails_test.py | 27 +++++++++++++++++++++++++++ tests/fixtures/email_etag.json | 1 + 4 files changed, 79 insertions(+) create mode 100644 tests/fixtures/email_etag.json diff --git a/Readme.md b/Readme.md index 5026db1..62f99af 100644 --- a/Readme.md +++ b/Readme.md @@ -65,6 +65,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [Get a block](#get-a-block) - [Insert a block](#insert-a-block) - [Update a block](#update-a-block) + - [Delete a block](#delete-a-block) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -821,6 +822,27 @@ response = client.emails.update_block( 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'] +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index a526753..6c8e19e 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -184,6 +184,35 @@ def update_block(self, email_id, block_id, etag, editor_content): ) ) + def delete_block(self, email_id, block_id, etag): + """ + 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 + :raises: :class: `TypeError` : an argument 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: `EmptyEtagError` : `etag` is empty + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object with only the new etag + :rtype: ApiResponse + """ + + email_id = _path_id(email_id, "email_id") + block_id = _path_id(block_id, "block_id") + body = {"etag": _etag(etag)} + + return ApiResponse( + self.api_client.request( + "DELETE", f"{self.base_api_url}/{email_id}/blocks/{block_id}", body=body + ) + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index e55fe51..377d745 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -277,3 +277,30 @@ def test_update_block_rejects_invalid_arguments( 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), + ("57", None, EmptyEtagError), + ], + ) + 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 == [] 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"} From 88d338ff389d6083bfc0deb3cdaaafd8e23ab3bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:46 +0100 Subject: [PATCH 12/48] feat: add emails.move_block Co-Authored-By: Claude Opus 5.5 --- Readme.md | 27 ++++++++++++++++++++++ mailerlite/sdk/emails.py | 32 +++++++++++++++++++++++++++ tests/emails_test.py | 32 +++++++++++++++++++++++++++ tests/fixtures/email_block_moved.json | 12 ++++++++++ 4 files changed, 103 insertions(+) create mode 100644 tests/fixtures/email_block_moved.json diff --git a/Readme.md b/Readme.md index 62f99af..6703688 100644 --- a/Readme.md +++ b/Readme.md @@ -66,6 +66,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [Insert a block](#insert-a-block) - [Update a block](#update-a-block) - [Delete a block](#delete-a-block) + - [Move a block](#move-a-block) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -843,6 +844,32 @@ 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`. + +```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'] +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 6c8e19e..f44a030 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -213,6 +213,38 @@ def delete_block(self, email_id, block_id, etag): ) ) + def move_block(self, email_id, block_id, etag, placement): + """ + 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` + :raises: :class: `TypeError` : an argument 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: `EmptyEtagError` : `etag` is empty + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _path_id(email_id, "email_id") + block_id = _path_id(block_id, "block_id") + _require_dict(placement, "placement") + body = {"placement": placement, "etag": _etag(etag)} + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/blocks/{block_id}/move", + body=body, + ) + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index 377d745..85d4ae2 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -304,3 +304,35 @@ def test_delete_block_rejects_invalid_arguments( 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), + ("57", "", {"position": "end"}, EmptyEtagError), + ("57", "45", None, TypeError), + ("57", "45", "end", TypeError), + ], + ) + 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 == [] 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" +} From 8888d48e5943383ae972783c74beb38e7aca23a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:47 +0100 Subject: [PATCH 13/48] feat: add emails.validate_block Validation sends no etag and needs no email. An invalid block returns 200 with "valid" set to false, so it does not raise an error. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 22 ++++++++++ mailerlite/sdk/emails.py | 24 +++++++++++ tests/emails_test.py | 40 +++++++++++++++++++ .../email_block_validation_invalid.json | 5 +++ .../email_block_validation_valid.json | 1 + 5 files changed, 92 insertions(+) create mode 100644 tests/fixtures/email_block_validation_invalid.json create mode 100644 tests/fixtures/email_block_validation_valid.json diff --git a/Readme.md b/Readme.md index 6703688..cab7bec 100644 --- a/Readme.md +++ b/Readme.md @@ -67,6 +67,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [Update a block](#update-a-block) - [Delete a block](#delete-a-block) - [Move a block](#move-a-block) + - [Validate a block](#validate-a-block) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -870,6 +871,27 @@ response = client.emails.move_block( 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']) +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index f44a030..8adc3a1 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -245,6 +245,30 @@ def move_block(self, email_id, block_id, etag, placement): ) ) + 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": ...}` + :raises: :class: `TypeError` : an argument type is not valid + :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 _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index 85d4ae2..040e745 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -336,3 +336,43 @@ def test_move_block_rejects_invalid_arguments( 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_validate_block_rejects_editor_content_of_the_wrong_type( + self, client, mock_api + ): + with pytest.raises(TypeError): + client.emails.validate_block(BlockType.HEADING, []) + + assert mock_api.requests == [] 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"}} From 41b6b90c7249dc8f8298518938959411e6284600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:47 +0100 Subject: [PATCH 14/48] feat: add emails.update_settings The SDK sends only the keys that the caller gives, so each value can be not set, set, or unset with an empty string. Add the BrandMode constants. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 32 +++++++++ mailerlite/__init__.py | 1 + mailerlite/sdk/emails.py | 42 +++++++++++ tests/emails_test.py | 65 ++++++++++++++++- tests/fixtures/email_settings.json | 111 +++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/email_settings.json diff --git a/Readme.md b/Readme.md index cab7bec..3aa5f64 100644 --- a/Readme.md +++ b/Readme.md @@ -68,6 +68,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -892,6 +893,37 @@ 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. Send an empty string to unset a value. +- `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` in `brand.style`. +- If the API removed unsafe values, the response has `rejected` with the keys of these values. + +```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']) +``` + ## Forms diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index c8f4271..e6aa674 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -19,6 +19,7 @@ VERSION_CONFLICT, BlockPosition, BlockType, + BrandMode, Emails, ) from mailerlite.sdk.fields import Fields diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 8adc3a1..98047b0 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -38,6 +38,14 @@ class BlockPosition(object): 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 @@ -269,6 +277,40 @@ def validate_block(self, block_type, editor_content, placement=None): ) ) + def update_settings(self, email_id, etag, settings): + """ + 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 value. + `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 + :raises: :class: `TypeError` : an argument type is not valid + :raises: :class: `EmptyIdError` : `email_id` is empty + :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `EmptyEtagError` : `etag` is empty + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _path_id(email_id, "email_id") + _require_dict(settings, "settings") + body = {**settings, "etag": _etag(etag)} + + return ApiResponse( + self.api_client.request( + "PATCH", f"{self.base_api_url}/{email_id}/settings", body=body + ) + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index 040e745..eff5fb6 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -3,7 +3,12 @@ from mailerlite import Client from mailerlite.exceptions import ApiError, EmptyEtagError, EmptyIdError, InvalidIdError from mailerlite.response import ApiResponse -from mailerlite.sdk.emails import VERSION_CONFLICT, BlockPosition, BlockType +from mailerlite.sdk.emails import ( + VERSION_CONFLICT, + BlockPosition, + BlockType, + BrandMode, +) from tests.conftest import load_fixture @@ -376,3 +381,61 @@ def test_validate_block_rejects_editor_content_of_the_wrong_type( client.emails.validate_block(BlockType.HEADING, []) assert mock_api.requests == [] + + 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", "", {}, EmptyEtagError), + ("123", "46", None, TypeError), + ], + ) + 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 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" + ] + } +} From 2abc5e686a3b42956f295d63a584bba4c9e151a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:47 +0100 Subject: [PATCH 15/48] feat: add emails.validate_settings Validation sends no etag. Invalid settings return 200 with "valid" set to false, so they do not raise an error. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 23 +++++++++++++ mailerlite/sdk/emails.py | 29 ++++++++++++++++ tests/emails_test.py | 34 +++++++++++++++++++ tests/fixtures/email_settings_validation.json | 9 +++++ 4 files changed, 95 insertions(+) create mode 100644 tests/fixtures/email_settings_validation.json diff --git a/Readme.md b/Readme.md index 3aa5f64..47efb52 100644 --- a/Readme.md +++ b/Readme.md @@ -69,6 +69,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -924,6 +925,28 @@ 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']) +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 98047b0..0604030 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -311,6 +311,35 @@ def update_settings(self, email_id, etag, settings): ) ) + 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` : an argument 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 with `valid`, `errors`, `schema` and maybe `rejected` + :rtype: ApiResponse + """ + + email_id = _path_id(email_id, "email_id") + _require_dict(settings, "settings") + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/settings/validate", + body=settings, + ) + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index eff5fb6..a449685 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -439,3 +439,37 @@ def test_brand_mode_constants(self): 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), + ], + ) + 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 == [] 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"] + } +} From b674d55f3ef1f5f4360d77095bf3432982e5bf17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:48 +0100 Subject: [PATCH 16/48] feat: add emails.create_template Co-Authored-By: Claude Opus 5.5 --- Readme.md | 20 ++++++++++++++++++ mailerlite/sdk/emails.py | 29 ++++++++++++++++++++++++++ tests/emails_test.py | 33 ++++++++++++++++++++++++++++++ tests/fixtures/email_template.json | 1 + 4 files changed, 83 insertions(+) create mode 100644 tests/fixtures/email_template.json diff --git a/Readme.md b/Readme.md index 47efb52..32898f7 100644 --- a/Readme.md +++ b/Readme.md @@ -70,6 +70,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -947,6 +948,25 @@ 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'] +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 0604030..65863df 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -340,6 +340,35 @@ def validate_settings(self, email_id, settings): ) ) + def create_template(self, email_id, name): + """ + 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 + :raises: :class: `TypeError` : an argument 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 = _path_id(email_id, "email_id") + if not isinstance(name, str): + raise TypeError( + f"`name` type is not valid. Expected `str`, got {type(name)}." + ) + + return ApiResponse( + self.api_client.request( + "POST", f"{self.base_api_url}/{email_id}/templates", body={"name": name} + ) + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index a449685..7dd9db7 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -473,3 +473,36 @@ def test_validate_settings_rejects_invalid_arguments( 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), + ("123", None, TypeError), + ], + ) + 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 == [] 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"}} From faba33f8fc29fbd179784403950b083f90e4203f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:48 +0100 Subject: [PATCH 17/48] feat: add emails.list_versions Co-Authored-By: Claude Opus 5.5 --- Readme.md | 18 ++++++++++++++++ mailerlite/sdk/emails.py | 33 ++++++++++++++++++++++++++++++ tests/emails_test.py | 31 ++++++++++++++++++++++++++++ tests/fixtures/email_versions.json | 26 +++++++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 tests/fixtures/email_versions.json diff --git a/Readme.md b/Readme.md index 32898f7..f270bc9 100644 --- a/Readme.md +++ b/Readme.md @@ -71,6 +71,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -967,6 +968,23 @@ 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) +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 65863df..8365e99 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -369,6 +369,33 @@ def create_template(self, email_id, name): ) ) + 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 = _path_id(email_id, "email_id") + query_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 _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" @@ -403,6 +430,12 @@ def _block_body(block_type, editor_content, placement): return body +def _without_none(params): + """Returns the params without the keys that have no value""" + + return {key: value for key, value in params.items() if value is not None} + + def _etag(value): """Returns the etag for the body of a write, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index 7dd9db7..d18bc11 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -506,3 +506,34 @@ def test_create_template_rejects_invalid_arguments( 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 == [] 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 + } +} From 1b64206f1da3c98c49fd327e31e6c55ca70aa565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:48 +0100 Subject: [PATCH 18/48] feat: add emails.create_version The API returns 201 for a new version, and 200 with the last version when nothing changed. Both are a success, and status_code on the response tells them apart. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 21 +++++++++++++++++++++ mailerlite/sdk/emails.py | 24 ++++++++++++++++++++++++ tests/emails_test.py | 23 +++++++++++++++++++++++ tests/fixtures/email_version.json | 1 + 4 files changed, 69 insertions(+) create mode 100644 tests/fixtures/email_version.json diff --git a/Readme.md b/Readme.md index f270bc9..eec631a 100644 --- a/Readme.md +++ b/Readme.md @@ -72,6 +72,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -985,6 +986,26 @@ 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 +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 8365e99..45235d3 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -396,6 +396,30 @@ def list_versions(self, email_id, page=None, limit=None): ) ) + def create_version(self, email_id): + """ + 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 + :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 = _path_id(email_id, "email_id") + + return ApiResponse( + self.api_client.request("POST", f"{self.base_api_url}/{email_id}/versions") + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index d18bc11..2bf84e2 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -537,3 +537,26 @@ def test_list_versions_rejects_an_invalid_id(self, client, mock_api): 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_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 == [] 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}} From d190db728dd84ec4751e02e92c157abb74d77678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:49 +0100 Subject: [PATCH 19/48] feat: add emails.restore_version Co-Authored-By: Claude Opus 5.5 --- Readme.md | 22 ++++++++++++++++++++++ mailerlite/sdk/emails.py | 32 ++++++++++++++++++++++++++++++++ tests/emails_test.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/Readme.md b/Readme.md index eec631a..98024d3 100644 --- a/Readme.md +++ b/Readme.md @@ -73,6 +73,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -1006,6 +1007,27 @@ 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'] +``` + ## Forms diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 45235d3..8f6f79b 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -420,6 +420,38 @@ def create_version(self, email_id): self.api_client.request("POST", f"{self.base_api_url}/{email_id}/versions") ) + def restore_version(self, email_id, version_id, etag): + """ + Restore a version of an email + + Puts the email back 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 + :raises: :class: `TypeError` : an argument 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: `EmptyEtagError` : `etag` is empty + :raises: :class: `ApiError` : API request failed, e.g. the etag is old + :return: JSON object + :rtype: ApiResponse + """ + + email_id = _path_id(email_id, "email_id") + version_id = _path_id(version_id, "version_id") + body = {"etag": _etag(etag)} + + return ApiResponse( + self.api_client.request( + "POST", + f"{self.base_api_url}/{email_id}/versions/{version_id}/restore", + body=body, + ) + ) + def _path_id(value, name): """Returns the ID as a string for the URL path, or raises if it is not valid""" diff --git a/tests/emails_test.py b/tests/emails_test.py index 2bf84e2..1b216cc 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -560,3 +560,32 @@ def test_create_version_rejects_an_empty_id(self, client, mock_api): 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), + ("901", "", EmptyEtagError), + ("901", 47, TypeError), + ], + ) + 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 == [] From f02167f2214571d8891e8f1ba5c79de248a0870e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:49 +0100 Subject: [PATCH 20/48] feat: add brand_styles.get Returns the palettes of the account. "brand" is None when the account has no brand styles. A preset identifier applies the preset to an email through emails.update_settings(). Co-Authored-By: Claude Opus 5.5 --- Readme.md | 24 ++++++++++++++- mailerlite/__init__.py | 2 ++ mailerlite/sdk/__init__.py | 1 + mailerlite/sdk/brand_styles.py | 26 +++++++++++++++++ tests/brand_styles_test.py | 46 +++++++++++++++++++++++++++++ tests/fixtures/brand_styles.json | 50 ++++++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 mailerlite/sdk/brand_styles.py create mode 100644 tests/brand_styles_test.py create mode 100644 tests/fixtures/brand_styles.json diff --git a/Readme.md b/Readme.md index 98024d3..1008b5a 100644 --- a/Readme.md +++ b/Readme.md @@ -74,6 +74,8 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) + - [Brand styles](#brand-styles) + - [Get the brand styles](#get-the-brand-styles) - [Forms](#forms) - [List all forms](#list-all-forms) - [Get a form](#get-a-form) @@ -906,7 +908,7 @@ Send only the keys to change, in the shape of `settings` in the email. The group - 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. Send an empty string to unset a value. - `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` in `brand.style`. +- `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. ```python @@ -1028,6 +1030,26 @@ response = client.emails.restore_version(email_id, version_id, etag) etag = response['etag'] ``` +## 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']] +``` + ## Forms diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index e6aa674..17486fa 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -12,6 +12,7 @@ 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 @@ -44,6 +45,7 @@ def __init__(self, config={}): 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.timezones = Timezones(self.api_client) self.webhooks = Webhooks(self.api_client) self.automations = Automations(self.api_client) diff --git a/mailerlite/sdk/__init__.py b/mailerlite/sdk/__init__.py index e394601..baaf0b3 100644 --- a/mailerlite/sdk/__init__.py +++ b/mailerlite/sdk/__init__.py @@ -1,6 +1,7 @@ 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 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/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/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" + } + ] + } +} From bb0b43a15ba78608719f6182f1a40835266b18bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:49 +0100 Subject: [PATCH 21/48] feat: add files.list Lists the files of the media library, with the name and type filters, the sort and the pagination. Add the FileType and FileSort constants. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 28 +++++++++++++ mailerlite/__init__.py | 2 + mailerlite/sdk/__init__.py | 1 + mailerlite/sdk/files.py | 65 +++++++++++++++++++++++++++++++ tests/files_test.py | 80 ++++++++++++++++++++++++++++++++++++++ tests/fixtures/files.json | 44 +++++++++++++++++++++ 6 files changed, 220 insertions(+) create mode 100644 mailerlite/sdk/files.py create mode 100644 tests/files_test.py create mode 100644 tests/fixtures/files.json diff --git a/Readme.md b/Readme.md index 1008b5a..daa0cb8 100644 --- a/Readme.md +++ b/Readme.md @@ -76,6 +76,8 @@ For more information how to obtain an API key visit the [following link](https:/ - [Restore a version of an email](#restore-a-version-of-an-email) - [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) @@ -1050,6 +1052,32 @@ 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 diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index 17486fa..ec0d211 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -24,6 +24,7 @@ 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 @@ -46,6 +47,7 @@ def __init__(self, config={}): 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/sdk/__init__.py b/mailerlite/sdk/__init__.py index baaf0b3..d8af9ca 100644 --- a/mailerlite/sdk/__init__.py +++ b/mailerlite/sdk/__init__.py @@ -5,6 +5,7 @@ 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/files.py b/mailerlite/sdk/files.py new file mode 100644 index 0000000..06d1712 --- /dev/null +++ b/mailerlite/sdk/files.py @@ -0,0 +1,65 @@ +from __future__ import absolute_import + +from mailerlite.response import ApiResponse + + +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 = {} + if filter is not None: + if not isinstance(filter, dict): + raise TypeError( + f"`filter` type is not valid. Expected `dict`, got {type(filter)}." + ) + for filter_key, filter_value in filter.items(): + query_params[f"filter[{filter_key}]"] = filter_value + + for key, val in {"sort": sort, "page": page, "limit": limit}.items(): + if val is not None: + query_params[key] = val + + return ApiResponse( + self.api_client.request("GET", self.base_api_url, query_params) + ) 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/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 + } +} From 30b1dfd5d6bacec6830e854ace96793657d6a0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:50 +0100 Subject: [PATCH 22/48] feat: create campaigns with the simple editor campaigns.create() sends the campaign dict as it is, so "editor" and "template_id" already reach the API. Add the EDITOR_SIMPLE constant, a test that the body has "editor" and no "content", and the documentation. The API refuses "content" together with the simple editor. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 33 ++++++++++++++ mailerlite/__init__.py | 1 + mailerlite/sdk/campaigns.py | 8 ++++ mailerlite/sdk/emails.py | 3 ++ tests/campaigns_test.py | 51 ++++++++++++++++++++++ tests/fixtures/campaign_simple_editor.json | 24 ++++++++++ 6 files changed, 120 insertions(+) create mode 100644 tests/fixtures/campaign_simple_editor.json diff --git a/Readme.md b/Readme.md index daa0cb8..7b1332e 100644 --- a/Readme.md +++ b/Readme.md @@ -54,6 +54,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) @@ -618,6 +619,38 @@ params = { 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 diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index ec0d211..06e2879 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -17,6 +17,7 @@ # 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, 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 index 8f6f79b..a09c1d0 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -10,6 +10,9 @@ # 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.""" 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/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 + } + ] + } +} From 037a7072f5205b0f429da369d1987be987947324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:50 +0100 Subject: [PATCH 23/48] feat: add idempotency keys to email writes The eight email writes accept a keyword-only idempotency_key. The SDK sends it in the Idempotency-Key header, so a retry after a network error does not do the write two times. ApiResponse.is_idempotent_replay tells if the API returned the stored response. The SDK checks the key before the request: 1 to 255 printable ASCII characters, as the API requires, and no space at the start or the end, because HTTP removes these spaces. An invalid key raises InvalidIdempotencyKeyError, and the SDK sends no request. ApiClient.request() accepts extra headers for one request. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 38 ++++++++++++ mailerlite/__init__.py | 1 + mailerlite/api_client.py | 8 ++- mailerlite/exceptions.py | 8 +++ mailerlite/sdk/emails.py | 128 ++++++++++++++++++++++++++++++++++----- tests/api_client_test.py | 17 ++++++ tests/emails_test.py | 108 ++++++++++++++++++++++++++++++++- 7 files changed, 290 insertions(+), 18 deletions(-) diff --git a/Readme.md b/Readme.md index 7b1332e..476dc33 100644 --- a/Readme.md +++ b/Readme.md @@ -75,6 +75,7 @@ For more information how to obtain an API key visit the [following link](https:/ - [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) @@ -1065,6 +1066,43 @@ 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, without a space at the start or the end. Otherwise the SDK raises `mailerlite.InvalidIdempotencyKeyError` and sends no request. +- 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 diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index 06e2879..a2c9fb0 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -7,6 +7,7 @@ ApiError, EmptyEtagError, EmptyIdError, + InvalidIdempotencyKeyError, InvalidIdError, ) from mailerlite.response import ApiResponse # noqa: F401 diff --git a/mailerlite/api_client.py b/mailerlite/api_client.py index 3664b9c..4b0c99d 100644 --- a/mailerlite/api_client.py +++ b/mailerlite/api_client.py @@ -77,8 +77,12 @@ 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: @@ -90,7 +94,7 @@ def request( "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): diff --git a/mailerlite/exceptions.py b/mailerlite/exceptions.py index 1790aff..48219fc 100644 --- a/mailerlite/exceptions.py +++ b/mailerlite/exceptions.py @@ -51,3 +51,11 @@ class InvalidIdError(ValueError): class EmptyEtagError(ValueError): """The etag of a write is empty. The SDK sends no request.""" + + +class InvalidIdempotencyKeyError(ValueError): + """ + The idempotency key is empty, longer than 255 characters, contains a character + that is not printable ASCII, or starts or ends with a space. + The SDK sends no request. + """ diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index a09c1d0..7744ab5 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -2,10 +2,17 @@ import re -from mailerlite.exceptions import EmptyEtagError, EmptyIdError, InvalidIdError +from mailerlite.exceptions import ( + EmptyEtagError, + EmptyIdError, + InvalidIdempotencyKeyError, + InvalidIdError, +) from mailerlite.response import ApiResponse _ID_PATTERN = re.compile(r"[0-9]+") +# The API accepts 1 to 255 printable ASCII characters +_IDEMPOTENCY_KEY_PATTERN = re.compile(r"[\x20-\x7E]{1,255}") # The error code of a write with an etag that is not the current etag of the email VERSION_CONFLICT = "version_conflict" @@ -55,6 +62,13 @@ class Emails(object): 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. """ # Emails base API uri @@ -131,7 +145,16 @@ def get_block(self, email_id, block_id): ) ) - def create_block(self, email_id, etag, block_type, editor_content, placement=None): + def create_block( + self, + email_id, + etag, + block_type, + editor_content, + placement=None, + *, + idempotency_key=None, + ): """ Insert a block in an email @@ -144,10 +167,13 @@ def create_block(self, email_id, etag, block_type, editor_content, placement=Non 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` : an argument type is not valid :raises: :class: `EmptyIdError` : `email_id` is empty :raises: :class: `InvalidIdError` : `email_id` does not contain only digits :raises: :class: `EmptyEtagError` : `etag` is empty + :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object :rtype: ApiResponse @@ -156,14 +182,20 @@ def create_block(self, email_id, etag, block_type, editor_content, placement=Non email_id = _path_id(email_id, "email_id") body = _block_body(block_type, editor_content, placement) body["etag"] = _etag(etag) + headers = _idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( - "POST", f"{self.base_api_url}/{email_id}/blocks", body=body + "POST", + f"{self.base_api_url}/{email_id}/blocks", + body=body, + headers=headers, ) ) - def update_block(self, email_id, block_id, etag, editor_content): + def update_block( + self, email_id, block_id, etag, editor_content, *, idempotency_key=None + ): """ Update a block of an email @@ -175,10 +207,13 @@ def update_block(self, email_id, block_id, etag, editor_content): :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` : an argument 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: `EmptyEtagError` : `etag` is empty + :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object :rtype: ApiResponse @@ -188,14 +223,18 @@ def update_block(self, email_id, block_id, etag, editor_content): block_id = _path_id(block_id, "block_id") _require_dict(editor_content, "editor_content") body = {"editor_content": editor_content, "etag": _etag(etag)} + headers = _idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( - "PATCH", f"{self.base_api_url}/{email_id}/blocks/{block_id}", body=body + "PATCH", + f"{self.base_api_url}/{email_id}/blocks/{block_id}", + body=body, + headers=headers, ) ) - def delete_block(self, email_id, block_id, etag): + def delete_block(self, email_id, block_id, etag, *, idempotency_key=None): """ Delete a block of an email @@ -205,10 +244,13 @@ def delete_block(self, email_id, block_id, etag): :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` : an argument 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: `EmptyEtagError` : `etag` is empty + :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object with only the new etag :rtype: ApiResponse @@ -217,14 +259,18 @@ def delete_block(self, email_id, block_id, etag): email_id = _path_id(email_id, "email_id") block_id = _path_id(block_id, "block_id") body = {"etag": _etag(etag)} + headers = _idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( - "DELETE", f"{self.base_api_url}/{email_id}/blocks/{block_id}", body=body + "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): + def move_block(self, email_id, block_id, etag, placement, *, idempotency_key=None): """ Move a block of an email @@ -234,10 +280,13 @@ def move_block(self, email_id, block_id, etag, placement): :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` : an argument 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: `EmptyEtagError` : `etag` is empty + :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object :rtype: ApiResponse @@ -247,12 +296,14 @@ def move_block(self, email_id, block_id, etag, placement): block_id = _path_id(block_id, "block_id") _require_dict(placement, "placement") body = {"placement": placement, "etag": _etag(etag)} + headers = _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, ) ) @@ -280,7 +331,7 @@ def validate_block(self, block_type, editor_content, placement=None): ) ) - def update_settings(self, email_id, etag, settings): + def update_settings(self, email_id, etag, settings, *, idempotency_key=None): """ Update the design settings of an email @@ -295,10 +346,13 @@ def update_settings(self, email_id, etag, settings): :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 + :param idempotency_key: str Optional. A key to retry the write safely, + see "Idempotency keys" in the class docstring :raises: :class: `TypeError` : an argument type is not valid :raises: :class: `EmptyIdError` : `email_id` is empty :raises: :class: `InvalidIdError` : `email_id` does not contain only digits :raises: :class: `EmptyEtagError` : `etag` is empty + :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object :rtype: ApiResponse @@ -307,10 +361,14 @@ def update_settings(self, email_id, etag, settings): email_id = _path_id(email_id, "email_id") _require_dict(settings, "settings") body = {**settings, "etag": _etag(etag)} + headers = _idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( - "PATCH", f"{self.base_api_url}/{email_id}/settings", body=body + "PATCH", + f"{self.base_api_url}/{email_id}/settings", + body=body, + headers=headers, ) ) @@ -343,7 +401,7 @@ def validate_settings(self, email_id, settings): ) ) - def create_template(self, email_id, name): + def create_template(self, email_id, name, *, idempotency_key=None): """ Save an email as a template @@ -352,9 +410,12 @@ def create_template(self, email_id, name): :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` : an argument type is not valid :raises: :class: `EmptyIdError` : `email_id` is empty :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed :return: JSON object :rtype: ApiResponse @@ -365,10 +426,14 @@ def create_template(self, email_id, name): raise TypeError( f"`name` type is not valid. Expected `str`, got {type(name)}." ) + headers = _idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( - "POST", f"{self.base_api_url}/{email_id}/templates", body={"name": name} + "POST", + f"{self.base_api_url}/{email_id}/templates", + body={"name": name}, + headers=headers, ) ) @@ -399,7 +464,7 @@ def list_versions(self, email_id, page=None, limit=None): ) ) - def create_version(self, email_id): + def create_version(self, email_id, *, idempotency_key=None): """ Save a version of an email @@ -409,21 +474,27 @@ def create_version(self, email_id): 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: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed :return: JSON object :rtype: ApiResponse """ email_id = _path_id(email_id, "email_id") + headers = _idempotency_headers(idempotency_key) return ApiResponse( - self.api_client.request("POST", f"{self.base_api_url}/{email_id}/versions") + self.api_client.request( + "POST", f"{self.base_api_url}/{email_id}/versions", headers=headers + ) ) - def restore_version(self, email_id, version_id, etag): + def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): """ Restore a version of an email @@ -434,10 +505,13 @@ def restore_version(self, email_id, version_id, etag): :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` : an argument 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: `EmptyEtagError` : `etag` is empty + :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object :rtype: ApiResponse @@ -446,12 +520,14 @@ def restore_version(self, email_id, version_id, etag): email_id = _path_id(email_id, "email_id") version_id = _path_id(version_id, "version_id") body = {"etag": _etag(etag)} + headers = _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, ) ) @@ -514,3 +590,25 @@ def _require_dict(value, name): raise TypeError( f"`{name}` type is not valid. Expected `dict`, got {type(value)}." ) + + +def _idempotency_headers(key): + """Returns the headers for the idempotency key, or raises if it is not valid""" + + if key is None: + return None + + if not isinstance(key, str): + raise TypeError( + f"`idempotency_key` type is not valid. Expected `str`, got {type(key)}." + ) + + # HTTP removes spaces at the start and the end of a header value, + # so such a key does not reach the API as it is. + if not _IDEMPOTENCY_KEY_PATTERN.fullmatch(key) or key != key.strip(" "): + raise InvalidIdempotencyKeyError( + "`idempotency_key` must be 1 to 255 printable ASCII characters, " + "without a space at the start or the end." + ) + + return {"Idempotency-Key": key} diff --git a/tests/api_client_test.py b/tests/api_client_test.py index b56919b..ae6eab1 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -184,3 +184,20 @@ 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): + """Tests that request() sends extra headers only for that request""" + + 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/emails_test.py b/tests/emails_test.py index 1b216cc..3e4a286 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -1,7 +1,13 @@ import pytest import mailerlite from mailerlite import Client -from mailerlite.exceptions import ApiError, EmptyEtagError, EmptyIdError, InvalidIdError +from mailerlite.exceptions import ( + ApiError, + EmptyEtagError, + EmptyIdError, + InvalidIdempotencyKeyError, + InvalidIdError, +) from mailerlite.response import ApiResponse from mailerlite.sdk.emails import ( VERSION_CONFLICT, @@ -589,3 +595,103 @@ def test_restore_version_rejects_invalid_arguments( 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 + + @pytest.mark.parametrize( + "key", + [ + "", + "k" * 256, + "line\nbreak", + "tab\there", + "caf\u00e9", + "delete\x7f", + " leading-space", + "trailing-space ", + ], + ) + @pytest.mark.parametrize("method, args, fixture", WRITES) + def test_write_rejects_an_invalid_key( + self, client, mock_api, method, args, fixture, key + ): + with pytest.raises(InvalidIdempotencyKeyError): + getattr(client.emails, method)(*args, idempotency_key=key) + + assert mock_api.requests == [] + + def test_write_rejects_a_key_of_the_wrong_type(self, client, mock_api): + with pytest.raises(TypeError): + client.emails.create_version("123", idempotency_key=1) + + assert mock_api.requests == [] + + 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 + + def test_idempotency_key_error_is_exported_from_the_package(self): + assert issubclass(InvalidIdempotencyKeyError, ValueError) + assert mailerlite.InvalidIdempotencyKeyError is InvalidIdempotencyKeyError From ec7a4bb20b1b0b5bdde309afd6f48242dbe420d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Wed, 28 Oct 2026 17:49:50 +0100 Subject: [PATCH 24/48] chore: bump the version to 0.3.0 Set the same version in pyproject.toml, VERSION and setup.py. The three files had different versions before. Co-Authored-By: Claude Opus 5.5 --- VERSION | 2 +- pyproject.toml | 2 +- setup.py | 2 +- uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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/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" }, From f54b214d24a9cc533f81994beb06cdbb5503711e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 11:36:13 +0100 Subject: [PATCH 25/48] fix: send no body for a write without a body ApiClient sent the JSON body `null` for a POST, PUT or PATCH request without a body. This affected emails.create_version and campaigns.cancel. The client now sends no body, as it already did for DELETE. Co-Authored-By: Claude Opus 5.5 --- mailerlite/api_client.py | 9 ++------- tests/api_client_test.py | 12 ++++++++++++ tests/emails_test.py | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/mailerlite/api_client.py b/mailerlite/api_client.py index 4b0c99d..38a3b0e 100644 --- a/mailerlite/api_client.py +++ b/mailerlite/api_client.py @@ -99,13 +99,8 @@ def request( } if HttpMethods.is_get(method): kwargs.update(allow_redirects=True) - if ( - HttpMethods.is_post(method) - or HttpMethods.is_put(method) - or HttpMethods.is_patch(method) - ): - kwargs.update(data=json.dumps(body)) - if HttpMethods.is_delete(method) and body is not None: + # 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) if 400 <= response.status_code < 600: diff --git a/tests/api_client_test.py b/tests/api_client_test.py index ae6eab1..49de6bc 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -103,6 +103,18 @@ def test_delete_request_without_a_body_sends_no_body(self, mock_api): 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): + """Tests that a write request without a body does not send the JSON `null`""" + + 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): """Tests that a 409 response gives the message, the code and the etag""" diff --git a/tests/emails_test.py b/tests/emails_test.py index 3e4a286..bb0e006 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -552,7 +552,7 @@ def test_create_version_returns_the_version(self, client, mock_api, status_code) assert mock_api.last_request.method == "POST" assert mock_api.last_request.url == f"{BASE_URL}/123/versions" - assert mock_api.last_body is None + assert mock_api.last_request.body is None assert response.status_code == status_code assert response["data"] == { "id": "903", From e37ce80983cc9e864727e03d9bac46eaf6aaddd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 11:37:06 +0100 Subject: [PATCH 26/48] fix: let ApiError survive a pickle round trip pickle rebuilds an exception with cls(*self.args), so it gave the message string to ApiError.__init__, which needs the response. An ApiError from a multiprocessing or Celery worker then raised AttributeError. ApiError now rebuilds itself from the response. Co-Authored-By: Claude Opus 5.5 --- mailerlite/exceptions.py | 4 ++++ tests/api_client_test.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/mailerlite/exceptions.py b/mailerlite/exceptions.py index 48219fc..b10bc8d 100644 --- a/mailerlite/exceptions.py +++ b/mailerlite/exceptions.py @@ -33,6 +33,10 @@ def __init__(self, response): 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: diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 49de6bc..daea1ba 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1,3 +1,5 @@ +import pickle + import pytest import requests from mailerlite.api_client import ApiClient @@ -190,6 +192,33 @@ def test_error_without_a_json_body_raises_api_error(self, mock_api): "https://connect.mailerlite.com/api/emails/1/html" ) + def test_api_error_survives_a_pickle_round_trip(self, mock_api): + """Tests that an ApiError can go to another process, as in multiprocessing""" + + 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): """Tests that ApiError is available as mailerlite.ApiError""" From 59f0071c58ebe726c136e0781fff3218008dcd29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 11:37:55 +0100 Subject: [PATCH 27/48] fix: check the block_id of a block placement create_block, move_block and validate_block sent placement["block_id"] unchanged. An int ID went to the API as a JSON number, and an empty or invalid ID reached the API. The block_id now gets the same check as the IDs in the URL path, and goes to the API as a string. Co-Authored-By: Claude Opus 5.5 --- mailerlite/sdk/emails.py | 22 +++++++++++++----- tests/emails_test.py | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 7744ab5..f94bc80 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -170,8 +170,8 @@ def create_block( :param idempotency_key: str Optional. A key to retry the write safely, see "Idempotency keys" in the class docstring :raises: :class: `TypeError` : an argument type is not valid - :raises: :class: `EmptyIdError` : `email_id` is empty - :raises: :class: `InvalidIdError` : `email_id` does not contain only digits + :raises: :class: `EmptyIdError` : an ID is empty + :raises: :class: `InvalidIdError` : an ID does not contain only digits :raises: :class: `EmptyEtagError` : `etag` is empty :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old @@ -294,8 +294,7 @@ def move_block(self, email_id, block_id, etag, placement, *, idempotency_key=Non email_id = _path_id(email_id, "email_id") block_id = _path_id(block_id, "block_id") - _require_dict(placement, "placement") - body = {"placement": placement, "etag": _etag(etag)} + body = {"placement": _placement(placement), "etag": _etag(etag)} headers = _idempotency_headers(idempotency_key) return ApiResponse( @@ -318,6 +317,8 @@ def validate_block(self, block_type, editor_content, placement=None): :param editor_content: dict The content of the block :param placement: dict Optional. `{"position": ..., "block_id": ...}` :raises: :class: `TypeError` : an argument type is not valid + :raises: :class: `EmptyIdError` : the `block_id` of `placement` is empty + :raises: :class: `InvalidIdError` : the `block_id` of `placement` is not valid :raises: :class: `ApiError` : API request failed :return: JSON object with `valid`, `errors` and `schema` :rtype: ApiResponse @@ -559,12 +560,21 @@ def _block_body(block_type, editor_content, placement): body = {"type": block_type, "editor_content": editor_content} if placement is not None: - _require_dict(placement, "placement") - body["placement"] = placement + body["placement"] = _placement(placement) return body +def _placement(placement): + """Returns a copy of the placement with a valid `block_id`, or raises""" + + _require_dict(placement, "placement") + if "block_id" not in placement: + return placement + + return {**placement, "block_id": _path_id(placement["block_id"], "block_id")} + + def _without_none(params): """Returns the params without the keys that have no value""" diff --git a/tests/emails_test.py b/tests/emails_test.py index bb0e006..79e750f 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -184,6 +184,40 @@ def test_create_block_sends_the_placement(self, client, mock_api): "etag": "42", } + def test_create_block_sends_an_int_placement_block_id_as_a_string( + self, client, mock_api + ): + mock_api.add_response(201, load_fixture("email_block_created.json")) + placement = {"position": BlockPosition.AFTER, "block_id": 57} + + client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) + + assert mock_api.last_body["placement"] == { + "position": "after", + "block_id": "57", + } + assert placement == {"position": "after", "block_id": 57} + + @pytest.mark.parametrize( + "block_id, error", + [ + ("", EmptyIdError), + (None, EmptyIdError), + ("57/x", InvalidIdError), + ("abc", InvalidIdError), + (5.7, TypeError), + ], + ) + def test_create_block_rejects_an_invalid_placement_block_id( + self, client, mock_api, block_id, error + ): + placement = {"position": BlockPosition.AFTER, "block_id": block_id} + + with pytest.raises(error): + client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) + + assert mock_api.requests == [] + @pytest.mark.parametrize("etag", ["", None]) def test_create_block_rejects_an_empty_etag(self, client, mock_api, etag): with pytest.raises(EmptyEtagError): @@ -330,6 +364,20 @@ def test_move_block_sends_the_placement(self, client, mock_api): assert response["data"]["id"] == "57" assert response["data"]["placement"] == {"after": None, "before": "1"} + def test_move_block_sends_an_int_placement_block_id_as_a_string( + self, client, mock_api + ): + mock_api.add_response(200, load_fixture("email_block_moved.json")) + + client.emails.move_block( + "123", "57", "45", {"position": "before", "block_id": 1} + ) + + assert mock_api.last_body["placement"] == { + "position": "before", + "block_id": "1", + } + @pytest.mark.parametrize( "block_id, etag, placement, error", [ @@ -338,6 +386,8 @@ def test_move_block_sends_the_placement(self, client, mock_api): ("57", "", {"position": "end"}, EmptyEtagError), ("57", "45", None, TypeError), ("57", "45", "end", TypeError), + ("57", "45", {"position": "after", "block_id": ""}, EmptyIdError), + ("57", "45", {"position": "after", "block_id": "1/x"}, InvalidIdError), ], ) def test_move_block_rejects_invalid_arguments( From cbc40e5051a07bb5ad4ce4edb9d5350c20d226c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 11:38:11 +0100 Subject: [PATCH 28/48] fix: check the type of block_type create_block and validate_block sent any block_type to the API, for example None or an int. They now raise TypeError when block_type is not a str, as their docstrings say. Co-Authored-By: Claude Opus 5.5 --- mailerlite/sdk/emails.py | 4 ++++ tests/emails_test.py | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index f94bc80..9c15cb5 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -556,6 +556,10 @@ def _path_id(value, name): def _block_body(block_type, editor_content, placement): """Returns the body to insert or to validate a block""" + if not isinstance(block_type, str): + raise TypeError( + f"`block_type` type is not valid. Expected `str`, got {type(block_type)}." + ) _require_dict(editor_content, "editor_content") body = {"type": block_type, "editor_content": editor_content} diff --git a/tests/emails_test.py b/tests/emails_test.py index 79e750f..929b152 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -430,6 +430,17 @@ def test_validate_block_returns_the_errors_of_an_invalid_block( assert response["valid"] is False assert response["errors"] == {"type": ["The selected type is invalid."]} + @pytest.mark.parametrize("block_type", [None, 5, ["divider"]]) + def test_block_writes_reject_a_block_type_of_the_wrong_type( + self, client, mock_api, block_type + ): + with pytest.raises(TypeError): + client.emails.create_block("123", "42", block_type, {}) + with pytest.raises(TypeError): + client.emails.validate_block(block_type, {}) + + assert mock_api.requests == [] + def test_validate_block_rejects_editor_content_of_the_wrong_type( self, client, mock_api ): From 29ccdb2afe10171ecbf2b8fb2e85db31efac5d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 11:38:48 +0100 Subject: [PATCH 29/48] fix: reject settings that contain an etag key update_settings merged the settings and the etag at the same level of the body, so it silently replaced an `etag` key in settings. update_settings and validate_settings now raise ValueError when settings contains `etag`. Co-Authored-By: Claude Opus 5.5 --- mailerlite/sdk/emails.py | 20 ++++++++++++++++---- tests/emails_test.py | 2 ++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 9c15cb5..c0bbf65 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -346,10 +346,11 @@ def update_settings(self, email_id, etag, settings, *, idempotency_key=None): :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 + :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` : an argument 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: `EmptyEtagError` : `etag` is empty @@ -360,8 +361,7 @@ def update_settings(self, email_id, etag, settings, *, idempotency_key=None): """ email_id = _path_id(email_id, "email_id") - _require_dict(settings, "settings") - body = {**settings, "etag": _etag(etag)} + body = {**_settings(settings), "etag": _etag(etag)} headers = _idempotency_headers(idempotency_key) return ApiResponse( @@ -384,6 +384,7 @@ def validate_settings(self, email_id, settings): :param email_id: str|int Email ID :param settings: dict The settings that you want to send to `update_settings()` :raises: :class: `TypeError` : an argument 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 @@ -392,7 +393,7 @@ def validate_settings(self, email_id, settings): """ email_id = _path_id(email_id, "email_id") - _require_dict(settings, "settings") + settings = _settings(settings) return ApiResponse( self.api_client.request( @@ -579,6 +580,17 @@ def _placement(placement): return {**placement, "block_id": _path_id(placement["block_id"], "block_id")} +def _settings(settings): + """Returns the settings, or raises if they are not valid""" + + _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 + + def _without_none(params): """Returns the params without the keys that have no value""" diff --git a/tests/emails_test.py b/tests/emails_test.py index 929b152..8b2e014 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -491,6 +491,7 @@ def test_update_settings_does_not_change_the_given_dict(self, client, mock_api): ("123/html", "46", {}, InvalidIdError), ("123", "", {}, EmptyEtagError), ("123", "46", None, TypeError), + ("123", "46", {"etag": "45"}, ValueError), ], ) def test_update_settings_rejects_invalid_arguments( @@ -531,6 +532,7 @@ def test_validate_settings_sends_the_settings_without_an_etag( (None, {}, EmptyIdError), ("1.5", {}, InvalidIdError), ("123", [], TypeError), + ("123", {"etag": "45"}, ValueError), ], ) def test_validate_settings_rejects_invalid_arguments( From 883dc4fb07067411aa99949e11637b2970bd4040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 11:39:06 +0100 Subject: [PATCH 30/48] fix: read the Idempotent-Replayed header without case is_idempotent_replay compared the header value to "true" with a case check. A proxy that sent "True" or "TRUE" made a replayed response look like a new write. The check now ignores case and spaces at the ends. Co-Authored-By: Claude Opus 5.5 --- mailerlite/response.py | 4 +++- tests/response_test.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/mailerlite/response.py b/mailerlite/response.py index a4a36ee..e83d720 100644 --- a/mailerlite/response.py +++ b/mailerlite/response.py @@ -21,4 +21,6 @@ def is_idempotent_replay(self): with the same idempotency key """ - return self.headers.get("Idempotent-Replayed") == "true" + # A proxy can change the case of the value + value = self.headers.get("Idempotent-Replayed", "") + return value.strip().lower() == "true" diff --git a/tests/response_test.py b/tests/response_test.py index 7b64ee6..2574434 100644 --- a/tests/response_test.py +++ b/tests/response_test.py @@ -1,3 +1,5 @@ +import pytest + import mailerlite from mailerlite.api_client import ApiClient from mailerlite.response import ApiResponse @@ -26,6 +28,28 @@ def test_replayed_response_is_an_idempotent_replay(self, mock_api): 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): + """Tests that a proxy can change the case of the Idempotent-Replayed 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 + ): + """Tests that only the value true sets the replay flag""" + + 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): """Tests that the replay flag is false without the Idempotent-Replayed header""" From 53b0df08a5761de8fc13608ed68ad1f1fad7d8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 11:39:56 +0100 Subject: [PATCH 31/48] refactor: move the request parameter helpers to a shared module The checks for IDs, etags, dicts and idempotency keys were private functions in emails.py, so other resources could not use them. They are now in mailerlite/sdk/_params.py. The behavior does not change. Co-Authored-By: Claude Opus 5.5 --- mailerlite/sdk/_params.py | 85 ++++++++++++++++++++ mailerlite/sdk/emails.py | 158 ++++++++++---------------------------- tests/params_test.py | 52 +++++++++++++ 3 files changed, 176 insertions(+), 119 deletions(-) create mode 100644 mailerlite/sdk/_params.py create mode 100644 tests/params_test.py diff --git a/mailerlite/sdk/_params.py b/mailerlite/sdk/_params.py new file mode 100644 index 0000000..4f0ee6e --- /dev/null +++ b/mailerlite/sdk/_params.py @@ -0,0 +1,85 @@ +"""Checks and helpers for the request parameters of all resources""" + +from __future__ import absolute_import + +import re + +from mailerlite.exceptions import ( + EmptyEtagError, + EmptyIdError, + InvalidIdempotencyKeyError, + InvalidIdError, +) + +_ID_PATTERN = re.compile(r"[0-9]+") +# The API accepts 1 to 255 printable ASCII characters +_IDEMPOTENCY_KEY_PATTERN = re.compile(r"[\x20-\x7E]{1,255}") + + +def path_id(value, name): + """Returns the ID as a string for the URL path, or raises if it is not valid""" + + 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 etag(value): + """Returns the etag for the body of a write, or raises if it is not valid""" + + if value is None or value == "": + raise EmptyEtagError("`etag` must not be empty.") + + if not isinstance(value, str): + raise TypeError(f"`etag` type is not valid. Expected `str`, got {type(value)}.") + + return value + + +def require_dict(value, name): + """Raises if the value is not a dict""" + + if not isinstance(value, dict): + raise TypeError( + f"`{name}` type is not valid. Expected `dict`, got {type(value)}." + ) + + +def without_none(params): + """Returns the params without the keys that have no value""" + + return {key: value for key, value in params.items() if value is not None} + + +def idempotency_headers(key): + """Returns the headers for the idempotency key, or raises if it is not valid""" + + if key is None: + return None + + if not isinstance(key, str): + raise TypeError( + f"`idempotency_key` type is not valid. Expected `str`, got {type(key)}." + ) + + # HTTP removes spaces at the start and the end of a header value, + # so such a key does not reach the API as it is. + if not _IDEMPOTENCY_KEY_PATTERN.fullmatch(key) or key != key.strip(" "): + raise InvalidIdempotencyKeyError( + "`idempotency_key` must be 1 to 255 printable ASCII characters, " + "without a space at the start or the end." + ) + + return {"Idempotency-Key": key} diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index c0bbf65..c2da095 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -1,18 +1,7 @@ from __future__ import absolute_import -import re - -from mailerlite.exceptions import ( - EmptyEtagError, - EmptyIdError, - InvalidIdempotencyKeyError, - InvalidIdError, -) from mailerlite.response import ApiResponse - -_ID_PATTERN = re.compile(r"[0-9]+") -# The API accepts 1 to 255 printable ASCII characters -_IDEMPOTENCY_KEY_PATTERN = re.compile(r"[\x20-\x7E]{1,255}") +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" @@ -92,7 +81,7 @@ def get(self, email_id): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") + email_id = _params.path_id(email_id, "email_id") return ApiResponse( self.api_client.request("GET", f"{self.base_api_url}/{email_id}") @@ -114,7 +103,7 @@ def get_html(self, email_id): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") + email_id = _params.path_id(email_id, "email_id") return ApiResponse( self.api_client.request("GET", f"{self.base_api_url}/{email_id}/html") @@ -136,8 +125,8 @@ def get_block(self, email_id, block_id): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") - block_id = _path_id(block_id, "block_id") + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") return ApiResponse( self.api_client.request( @@ -179,10 +168,10 @@ def create_block( :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") + email_id = _params.path_id(email_id, "email_id") body = _block_body(block_type, editor_content, placement) - body["etag"] = _etag(etag) - headers = _idempotency_headers(idempotency_key) + body["etag"] = _params.etag(etag) + headers = _params.idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( @@ -219,11 +208,11 @@ def update_block( :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") - block_id = _path_id(block_id, "block_id") - _require_dict(editor_content, "editor_content") - body = {"editor_content": editor_content, "etag": _etag(etag)} - headers = _idempotency_headers(idempotency_key) + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + _params.require_dict(editor_content, "editor_content") + body = {"editor_content": editor_content, "etag": _params.etag(etag)} + headers = _params.idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( @@ -256,10 +245,10 @@ def delete_block(self, email_id, block_id, etag, *, idempotency_key=None): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") - block_id = _path_id(block_id, "block_id") - body = {"etag": _etag(etag)} - headers = _idempotency_headers(idempotency_key) + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + body = {"etag": _params.etag(etag)} + headers = _params.idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( @@ -292,10 +281,10 @@ def move_block(self, email_id, block_id, etag, placement, *, idempotency_key=Non :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") - block_id = _path_id(block_id, "block_id") - body = {"placement": _placement(placement), "etag": _etag(etag)} - headers = _idempotency_headers(idempotency_key) + email_id = _params.path_id(email_id, "email_id") + block_id = _params.path_id(block_id, "block_id") + body = {"placement": _placement(placement), "etag": _params.etag(etag)} + headers = _params.idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( @@ -360,9 +349,9 @@ def update_settings(self, email_id, etag, settings, *, idempotency_key=None): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") - body = {**_settings(settings), "etag": _etag(etag)} - headers = _idempotency_headers(idempotency_key) + email_id = _params.path_id(email_id, "email_id") + body = {**_settings(settings), "etag": _params.etag(etag)} + headers = _params.idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( @@ -392,7 +381,7 @@ def validate_settings(self, email_id, settings): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") + email_id = _params.path_id(email_id, "email_id") settings = _settings(settings) return ApiResponse( @@ -423,12 +412,12 @@ def create_template(self, email_id, name, *, idempotency_key=None): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") + email_id = _params.path_id(email_id, "email_id") if not isinstance(name, str): raise TypeError( f"`name` type is not valid. Expected `str`, got {type(name)}." ) - headers = _idempotency_headers(idempotency_key) + headers = _params.idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( @@ -457,8 +446,8 @@ def list_versions(self, email_id, page=None, limit=None): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") - query_params = _without_none({"page": page, "limit": limit}) + email_id = _params.path_id(email_id, "email_id") + query_params = _params.without_none({"page": page, "limit": limit}) return ApiResponse( self.api_client.request( @@ -487,8 +476,8 @@ def create_version(self, email_id, *, idempotency_key=None): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") - headers = _idempotency_headers(idempotency_key) + email_id = _params.path_id(email_id, "email_id") + headers = _params.idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( @@ -519,10 +508,10 @@ def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): :rtype: ApiResponse """ - email_id = _path_id(email_id, "email_id") - version_id = _path_id(version_id, "version_id") - body = {"etag": _etag(etag)} - headers = _idempotency_headers(idempotency_key) + email_id = _params.path_id(email_id, "email_id") + version_id = _params.path_id(version_id, "version_id") + body = {"etag": _params.etag(etag)} + headers = _params.idempotency_headers(idempotency_key) return ApiResponse( self.api_client.request( @@ -534,26 +523,6 @@ def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): ) -def _path_id(value, name): - """Returns the ID as a string for the URL path, or raises if it is not valid""" - - 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 _block_body(block_type, editor_content, placement): """Returns the body to insert or to validate a block""" @@ -561,7 +530,7 @@ def _block_body(block_type, editor_content, placement): raise TypeError( f"`block_type` type is not valid. Expected `str`, got {type(block_type)}." ) - _require_dict(editor_content, "editor_content") + _params.require_dict(editor_content, "editor_content") body = {"type": block_type, "editor_content": editor_content} if placement is not None: @@ -573,68 +542,19 @@ def _block_body(block_type, editor_content, placement): def _placement(placement): """Returns a copy of the placement with a valid `block_id`, or raises""" - _require_dict(placement, "placement") + _params.require_dict(placement, "placement") if "block_id" not in placement: return placement - return {**placement, "block_id": _path_id(placement["block_id"], "block_id")} + return {**placement, "block_id": _params.path_id(placement["block_id"], "block_id")} def _settings(settings): """Returns the settings, or raises if they are not valid""" - _require_dict(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 - - -def _without_none(params): - """Returns the params without the keys that have no value""" - - return {key: value for key, value in params.items() if value is not None} - - -def _etag(value): - """Returns the etag for the body of a write, or raises if it is not valid""" - - if value is None or value == "": - raise EmptyEtagError("`etag` must not be empty.") - - if not isinstance(value, str): - raise TypeError(f"`etag` type is not valid. Expected `str`, got {type(value)}.") - - return value - - -def _require_dict(value, name): - """Raises if the value is not a dict""" - - if not isinstance(value, dict): - raise TypeError( - f"`{name}` type is not valid. Expected `dict`, got {type(value)}." - ) - - -def _idempotency_headers(key): - """Returns the headers for the idempotency key, or raises if it is not valid""" - - if key is None: - return None - - if not isinstance(key, str): - raise TypeError( - f"`idempotency_key` type is not valid. Expected `str`, got {type(key)}." - ) - - # HTTP removes spaces at the start and the end of a header value, - # so such a key does not reach the API as it is. - if not _IDEMPOTENCY_KEY_PATTERN.fullmatch(key) or key != key.strip(" "): - raise InvalidIdempotencyKeyError( - "`idempotency_key` must be 1 to 255 printable ASCII characters, " - "without a space at the start or the end." - ) - - return {"Idempotency-Key": key} diff --git a/tests/params_test.py b/tests/params_test.py new file mode 100644 index 0000000..fbe1b7f --- /dev/null +++ b/tests/params_test.py @@ -0,0 +1,52 @@ +import pytest +from mailerlite.exceptions import ( + EmptyEtagError, + EmptyIdError, + InvalidIdempotencyKeyError, + 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_etag_returns_the_etag(self): + assert _params.etag("42") == "42" + + @pytest.mark.parametrize("value, error", [("", EmptyEtagError), (42, TypeError)]) + def test_etag_rejects_an_invalid_etag(self, value, error): + with pytest.raises(error): + _params.etag(value) + + 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_idempotency_headers(self): + assert _params.idempotency_headers(None) is None + assert _params.idempotency_headers("k1") == {"Idempotency-Key": "k1"} + + with pytest.raises(InvalidIdempotencyKeyError): + _params.idempotency_headers(" k1") From 2cc26265a716531e69dc18a2f938bac3ac7218d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 11:40:19 +0100 Subject: [PATCH 32/48] refactor: build the files.list query params with the shared helpers files.list had its own copies of the dict check, the filter expansion and the None filter. It now uses the helpers in mailerlite/sdk/_params.py, with a new filter_params helper. The behavior does not change. Co-Authored-By: Claude Opus 5.5 --- mailerlite/sdk/_params.py | 11 +++++++++++ mailerlite/sdk/files.py | 17 +++++------------ tests/params_test.py | 11 +++++++++++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/mailerlite/sdk/_params.py b/mailerlite/sdk/_params.py index 4f0ee6e..430a666 100644 --- a/mailerlite/sdk/_params.py +++ b/mailerlite/sdk/_params.py @@ -63,6 +63,17 @@ def without_none(params): return {key: value for key, value in params.items() if value is not None} +def filter_params(filter): + """Returns a `filter[key]` query param for each key of the filter dict""" + + if filter is None: + return {} + + require_dict(filter, "filter") + + return {f"filter[{key}]": value for key, value in filter.items()} + + def idempotency_headers(key): """Returns the headers for the idempotency key, or raises if it is not valid""" diff --git a/mailerlite/sdk/files.py b/mailerlite/sdk/files.py index 06d1712..cffe2ed 100644 --- a/mailerlite/sdk/files.py +++ b/mailerlite/sdk/files.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from mailerlite.response import ApiResponse +from mailerlite.sdk import _params class FileType(object): @@ -47,18 +48,10 @@ def list(self, filter=None, sort=None, page=None, limit=None): :rtype: ApiResponse """ - query_params = {} - if filter is not None: - if not isinstance(filter, dict): - raise TypeError( - f"`filter` type is not valid. Expected `dict`, got {type(filter)}." - ) - for filter_key, filter_value in filter.items(): - query_params[f"filter[{filter_key}]"] = filter_value - - for key, val in {"sort": sort, "page": page, "limit": limit}.items(): - if val is not None: - query_params[key] = val + 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/tests/params_test.py b/tests/params_test.py index fbe1b7f..54839d6 100644 --- a/tests/params_test.py +++ b/tests/params_test.py @@ -44,6 +44,17 @@ def test_require_dict_rejects_a_value_that_is_not_a_dict(self): 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_rejects_a_filter_that_is_not_a_dict(self): + with pytest.raises(TypeError, match="filter"): + _params.filter_params("image") + def test_idempotency_headers(self): assert _params.idempotency_headers(None) is None assert _params.idempotency_headers("k1") == {"Idempotency-Key": "k1"} From 34f20d41d8bcb162b88240b28fb1751910bb6fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 11:40:33 +0100 Subject: [PATCH 33/48] docs: remove a phrasal verb from the restore_version docstring Co-Authored-By: Claude Opus 5.5 --- mailerlite/sdk/emails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index c2da095..1e09d49 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -489,7 +489,7 @@ def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): """ Restore a version of an email - Puts the email back to the version, and returns the email in the shape + 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. From 51f6ea6fbccdb60de07cee2fe074d556516e8a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 16:21:28 +0100 Subject: [PATCH 34/48] fix: check the placement block_id only for before and after The API uses placement.block_id only for the before and after positions. For start and end, it ignores the value and does not check it. The SDK checked the block_id for all positions, so it refused valid requests, and it sent the block_id for start and end. Now the SDK does not check or send the block_id for start and end. For before and after, a missing block_id is an EmptyIdError. For an unknown position, the SDK checks and sends a given block_id. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 4 +-- mailerlite/sdk/emails.py | 38 ++++++++++++++++----- tests/emails_test.py | 73 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/Readme.md b/Readme.md index 476dc33..076236d 100644 --- a/Readme.md +++ b/Readme.md @@ -818,7 +818,7 @@ 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`. +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`, so the SDK does not check it and does not send it. ```python import mailerlite as MailerLite @@ -892,7 +892,7 @@ etag = response['etag'] ### Move a block -The `before` and `after` positions also need a `block_id`. +The `before` and `after` positions also need a `block_id`. The API ignores the `block_id` of `start` and `end`, so the SDK does not check it and does not send it. ```python import mailerlite as MailerLite diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 1e09d49..c488ed4 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -29,7 +29,12 @@ class BlockType(object): class BlockPosition(object): - """Positions of a block. `BEFORE` and `AFTER` also need a `block_id`.""" + """ + Positions of a block. `BEFORE` and `AFTER` also need a `block_id`. + + The API ignores the `block_id` of `START` and `END`. For these positions, + the SDK does not check the `block_id` and does not send it. + """ START = "start" END = "end" @@ -156,10 +161,11 @@ def create_block( 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. + The SDK does not check or send `block_id` for `start` and `end`. :param idempotency_key: str Optional. A key to retry the write safely, see "Idempotency keys" in the class docstring :raises: :class: `TypeError` : an argument type is not valid - :raises: :class: `EmptyIdError` : an ID is empty + :raises: :class: `EmptyIdError` : an ID is empty or missing :raises: :class: `InvalidIdError` : an ID does not contain only digits :raises: :class: `EmptyEtagError` : `etag` is empty :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid @@ -268,11 +274,13 @@ def move_block(self, email_id, block_id, etag, placement, *, idempotency_key=Non :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 placement: dict `{"position": ..., "block_id": ...}`, + see `BlockPosition`. The SDK does not check or send `block_id` + for `start` and `end`. :param idempotency_key: str Optional. A key to retry the write safely, see "Idempotency keys" in the class docstring :raises: :class: `TypeError` : an argument type is not valid - :raises: :class: `EmptyIdError` : `email_id` or `block_id` is empty + :raises: :class: `EmptyIdError` : an ID is empty or missing :raises: :class: `InvalidIdError` : an ID does not contain only digits :raises: :class: `EmptyEtagError` : `etag` is empty :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid @@ -304,9 +312,12 @@ def validate_block(self, block_type, editor_content, placement=None): :param block_type: str Block type, see `BlockType` :param editor_content: dict The content of the block - :param placement: dict Optional. `{"position": ..., "block_id": ...}` + :param placement: dict Optional. `{"position": ..., "block_id": ...}`, + see `BlockPosition`. The SDK does not check or send `block_id` + for `start` and `end`. :raises: :class: `TypeError` : an argument type is not valid :raises: :class: `EmptyIdError` : the `block_id` of `placement` is empty + or missing :raises: :class: `InvalidIdError` : the `block_id` of `placement` is not valid :raises: :class: `ApiError` : API request failed :return: JSON object with `valid`, `errors` and `schema` @@ -540,13 +551,22 @@ def _block_body(block_type, editor_content, placement): def _placement(placement): - """Returns a copy of the placement with a valid `block_id`, or raises""" + """Returns a copy of the placement to send, or raises if it is not valid""" _params.require_dict(placement, "placement") - if "block_id" not in placement: - return placement + position = placement.get("position") + + # The API ignores the `block_id` of these positions and does not check it + if position in (BlockPosition.START, BlockPosition.END): + return {key: value for key, value in placement.items() if key != "block_id"} + + if position in (BlockPosition.BEFORE, BlockPosition.AFTER) or ( + "block_id" in placement + ): + block_id = _params.path_id(placement.get("block_id"), "block_id") + return {**placement, "block_id": block_id} - return {**placement, "block_id": _params.path_id(placement["block_id"], "block_id")} + return dict(placement) def _settings(settings): diff --git a/tests/emails_test.py b/tests/emails_test.py index 8b2e014..81d84fb 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -449,6 +449,79 @@ def test_validate_block_rejects_editor_content_of_the_wrong_type( assert mock_api.requests == [] + # The API ignores `block_id` for `start` and `end` and does not check it + @pytest.mark.parametrize("position", [BlockPosition.START, BlockPosition.END]) + @pytest.mark.parametrize("block_id", [None, "", "1/2", "abc", 5.7, 57]) + def test_block_writes_do_not_check_or_send_the_block_id_for_start_and_end( + self, client, mock_api, position, block_id + ): + 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")) + placement = {"position": position, "block_id": block_id} + + client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) + client.emails.move_block("123", "57", "45", placement) + client.emails.validate_block(BlockType.DIVIDER, {}, placement) + + sent = f'"placement": {{"position": "{position}"}}' + assert [request.body for request in mock_api.requests] == [ + f'{{"type": "divider", "editor_content": {{}}, {sent}, "etag": "42"}}', + f'{{{sent}, "etag": "45"}}', + f'{{"type": "divider", "editor_content": {{}}, {sent}}}', + ] + assert placement == {"position": position, "block_id": block_id} + + @pytest.mark.parametrize("position", [BlockPosition.BEFORE, BlockPosition.AFTER]) + def test_block_writes_reject_a_missing_block_id_for_before_and_after( + self, client, mock_api, position + ): + placement = {"position": position} + + with pytest.raises(EmptyIdError): + client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) + with pytest.raises(EmptyIdError): + client.emails.move_block("123", "57", "45", placement) + with pytest.raises(EmptyIdError): + client.emails.validate_block(BlockType.DIVIDER, {}, placement) + + assert mock_api.requests == [] + + @pytest.mark.parametrize( + "block_id, error", [(None, EmptyIdError), ("1/2", InvalidIdError)] + ) + def test_block_writes_check_the_block_id_of_an_unknown_position( + self, client, mock_api, block_id, error + ): + placement = {"position": "inside", "block_id": block_id} + + with pytest.raises(error): + client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) + with pytest.raises(error): + client.emails.move_block("123", "57", "45", placement) + with pytest.raises(error): + client.emails.validate_block(BlockType.DIVIDER, {}, placement) + + assert mock_api.requests == [] + + @pytest.mark.parametrize( + "placement, sent", + [ + ({"position": "inside", "block_id": 7}, '"inside", "block_id": "7"'), + ({"position": "inside"}, '"inside"'), + ], + ) + def test_move_block_sends_the_block_id_of_an_unknown_position( + self, client, mock_api, placement, sent + ): + mock_api.add_response(200, load_fixture("email_block_moved.json")) + + client.emails.move_block("123", "57", "45", placement) + + assert mock_api.last_request.body == ( + f'{{"placement": {{"position": {sent}}}, "etag": "45"}}' + ) + def test_update_settings_sends_only_the_given_keys(self, client, mock_api): mock_api.add_response(200, load_fixture("email_settings.json")) settings = { From ef61eaf9035528fc5216f490e28f2cf9582f9294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 17:06:05 +0100 Subject: [PATCH 35/48] docs: say which settings values an empty value unsets An empty string unsets only a brand colour. An empty editor document unsets a footer text. For other colours, sizes, button.border_radius and brand.style, an empty string gives a 422 error. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 5 ++++- mailerlite/sdk/emails.py | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Readme.md b/Readme.md index 076236d..02c359d 100644 --- a/Readme.md +++ b/Readme.md @@ -942,7 +942,10 @@ if not response['valid']: 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. Send an empty string to unset a value. +- 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. diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index c488ed4..5baa60f 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -338,7 +338,12 @@ def update_settings(self, email_id, etag, settings, *, idempotency_key=None): 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 value. + 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. From b4a90a990205cee75d1d6dbb3b43e1f6c215778c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Thu, 29 Oct 2026 18:43:41 +0100 Subject: [PATCH 36/48] chore: remove redundant docstrings Remove the docstrings of private helpers and tests that only repeat the function name. The public docstrings do not change. Co-Authored-By: Claude Opus 5.5 --- mailerlite/sdk/_params.py | 14 -------------- mailerlite/sdk/emails.py | 6 ------ tests/api_client_test.py | 22 ---------------------- tests/conftest.py | 8 -------- tests/response_test.py | 12 ------------ 5 files changed, 62 deletions(-) diff --git a/mailerlite/sdk/_params.py b/mailerlite/sdk/_params.py index 430a666..62e49cb 100644 --- a/mailerlite/sdk/_params.py +++ b/mailerlite/sdk/_params.py @@ -1,5 +1,3 @@ -"""Checks and helpers for the request parameters of all resources""" - from __future__ import absolute_import import re @@ -17,8 +15,6 @@ def path_id(value, name): - """Returns the ID as a string for the URL path, or raises if it is not valid""" - if value is None or value == "": raise EmptyIdError(f"`{name}` must not be empty.") @@ -37,8 +33,6 @@ def path_id(value, name): def etag(value): - """Returns the etag for the body of a write, or raises if it is not valid""" - if value is None or value == "": raise EmptyEtagError("`etag` must not be empty.") @@ -49,8 +43,6 @@ def etag(value): def require_dict(value, name): - """Raises if the value is not a dict""" - if not isinstance(value, dict): raise TypeError( f"`{name}` type is not valid. Expected `dict`, got {type(value)}." @@ -58,14 +50,10 @@ def require_dict(value, name): def without_none(params): - """Returns the params without the keys that have no value""" - return {key: value for key, value in params.items() if value is not None} def filter_params(filter): - """Returns a `filter[key]` query param for each key of the filter dict""" - if filter is None: return {} @@ -75,8 +63,6 @@ def filter_params(filter): def idempotency_headers(key): - """Returns the headers for the idempotency key, or raises if it is not valid""" - if key is None: return None diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 5baa60f..fc3f826 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -540,8 +540,6 @@ def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): def _block_body(block_type, editor_content, placement): - """Returns the body to insert or to validate a block""" - if not isinstance(block_type, str): raise TypeError( f"`block_type` type is not valid. Expected `str`, got {type(block_type)}." @@ -556,8 +554,6 @@ def _block_body(block_type, editor_content, placement): def _placement(placement): - """Returns a copy of the placement to send, or raises if it is not valid""" - _params.require_dict(placement, "placement") position = placement.get("position") @@ -575,8 +571,6 @@ def _placement(placement): def _settings(settings): - """Returns the settings, or raises if they are not valid""" - _params.require_dict(settings, "settings") # The body of update_settings() has the settings and the etag at the same level if "etag" in settings: diff --git a/tests/api_client_test.py b/tests/api_client_test.py index daea1ba..c4e0cd9 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -55,8 +55,6 @@ def test_it_will_return_error_if_request_method_is_not_supported(self): client.request("PPOST", "some-url") def test_mock_api_records_the_prepared_request(self, mock_api): - """Tests that mock_api records the request and returns the queued response""" - mock_api.add_response(200, {"data": []}) client = ApiClient({"api_key": "1234567890"}) @@ -73,8 +71,6 @@ def test_mock_api_records_the_prepared_request(self, mock_api): assert mock_api.last_body is None def test_patch_request_sends_the_json_body(self, mock_api): - """Tests that a PATCH request sends the body as JSON""" - mock_api.add_response(200, {"data": {}}) client = ApiClient({}) @@ -84,8 +80,6 @@ def test_patch_request_sends_the_json_body(self, mock_api): assert mock_api.last_body == {"etag": "42"} def test_delete_request_sends_the_json_body(self, mock_api): - """Tests that a DELETE request with a body sends the body as JSON""" - mock_api.add_response(200, {"etag": "43"}) client = ApiClient({}) @@ -95,8 +89,6 @@ def test_delete_request_sends_the_json_body(self, mock_api): assert mock_api.last_body == {"etag": "42"} def test_delete_request_without_a_body_sends_no_body(self, mock_api): - """Tests that a DELETE request without a body sends no body""" - mock_api.add_response(204) client = ApiClient({}) @@ -107,8 +99,6 @@ def test_delete_request_without_a_body_sends_no_body(self, mock_api): @pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) def test_write_request_without_a_body_sends_no_body(self, mock_api, method): - """Tests that a write request without a body does not send the JSON `null`""" - mock_api.add_response(200, {"data": {}}) client = ApiClient({}) @@ -118,8 +108,6 @@ def test_write_request_without_a_body_sends_no_body(self, mock_api, method): assert mock_api.last_request.body is None def test_version_conflict_raises_api_error_with_code_and_etag(self, mock_api): - """Tests that a 409 response gives the message, the code and the etag""" - mock_api.add_response( 409, { @@ -147,8 +135,6 @@ def test_version_conflict_raises_api_error_with_code_and_etag(self, mock_api): ) def test_validation_error_raises_api_error_with_errors(self, mock_api): - """Tests that a 422 response gives the field errors""" - mock_api.add_response( 422, { @@ -174,8 +160,6 @@ def test_validation_error_raises_api_error_with_errors(self, mock_api): ) def test_error_without_a_json_body_raises_api_error(self, mock_api): - """Tests that a response that is not JSON gives an error without details""" - mock_api.add_response(503, "Service Unavailable", {"Content-Type": "text/html"}) client = ApiClient({}) @@ -193,8 +177,6 @@ def test_error_without_a_json_body_raises_api_error(self, mock_api): ) def test_api_error_survives_a_pickle_round_trip(self, mock_api): - """Tests that an ApiError can go to another process, as in multiprocessing""" - mock_api.add_response( 409, { @@ -220,15 +202,11 @@ def test_api_error_survives_a_pickle_round_trip(self, mock_api): assert copy.response.status_code == 409 def test_api_error_is_exported_from_the_package(self): - """Tests that ApiError is available as mailerlite.ApiError""" - import mailerlite assert mailerlite.ApiError is ApiError def test_request_adds_the_given_headers(self, mock_api): - """Tests that request() sends extra headers only for that request""" - mock_api.add_response(200, {"data": {}}) mock_api.add_response(200, {"data": {}}) client = ApiClient({"api_key": "1234567890"}) diff --git a/tests/conftest.py b/tests/conftest.py index 9045abf..9d0f1a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,22 +13,16 @@ def pytest_configure(): def load_fixture(name): - """Returns the decoded JSON of a file in tests/fixtures""" - with open(FIXTURES_DIR / name) as fd: return json.load(fd) class MockApi(object): - """Records the prepared requests and returns the queued responses""" - def __init__(self): self.requests = [] self.responses = [] def add_response(self, status_code=200, body=None, headers=None): - """Queues a response. A dict or list body is encoded as JSON.""" - if isinstance(body, (dict, list)): body = json.dumps(body) if isinstance(body, str): @@ -56,8 +50,6 @@ def last_request(self): @property def last_body(self): - """Returns the decoded JSON body of the last request, or None without a body""" - body = self.last_request.body return None if body is None else json.loads(body) diff --git a/tests/response_test.py b/tests/response_test.py index 2574434..7452728 100644 --- a/tests/response_test.py +++ b/tests/response_test.py @@ -7,8 +7,6 @@ class TestApiResponse: def test_it_is_a_dict_with_the_decoded_body(self, mock_api): - """Tests that ApiResponse has the decoded JSON body and the status code""" - mock_api.add_response(201, {"data": {"id": "1"}}, {"X-Request-Id": "abc"}) response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/versions")) @@ -20,8 +18,6 @@ def test_it_is_a_dict_with_the_decoded_body(self, mock_api): assert response.headers["x-request-id"] == "abc" def test_replayed_response_is_an_idempotent_replay(self, mock_api): - """Tests that the Idempotent-Replayed header sets the replay flag""" - mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": "true"}) response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) @@ -30,8 +26,6 @@ def test_replayed_response_is_an_idempotent_replay(self, mock_api): @pytest.mark.parametrize("value", ["True", "TRUE", " true "]) def test_replay_header_value_is_not_case_sensitive(self, mock_api, value): - """Tests that a proxy can change the case of the Idempotent-Replayed value""" - mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": value}) response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) @@ -42,8 +36,6 @@ def test_replay_header_value_is_not_case_sensitive(self, mock_api, value): def test_other_replay_header_values_are_not_an_idempotent_replay( self, mock_api, value ): - """Tests that only the value true sets the replay flag""" - mock_api.add_response(201, {"data": {}}, {"Idempotent-Replayed": value}) response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) @@ -51,8 +43,6 @@ def test_other_replay_header_values_are_not_an_idempotent_replay( assert response.is_idempotent_replay is False def test_response_without_the_header_is_not_an_idempotent_replay(self, mock_api): - """Tests that the replay flag is false without the Idempotent-Replayed header""" - mock_api.add_response(201, {"data": {}}) response = ApiResponse(ApiClient({}).request("POST", "api/emails/1/blocks")) @@ -60,6 +50,4 @@ def test_response_without_the_header_is_not_an_idempotent_replay(self, mock_api) assert response.is_idempotent_replay is False def test_api_response_is_exported_from_the_package(self): - """Tests that ApiResponse is available as mailerlite.ApiResponse""" - assert mailerlite.ApiResponse is ApiResponse From 1910ebcf94cda27346aed1957909d1bbdda6a5fa Mon Sep 17 00:00:00 2001 From: Kavan Pancholi Date: Fri, 30 Oct 2026 15:04:28 +0530 Subject: [PATCH 37/48] Add shared request param helpers --- mailerlite/sdk/_request_params.py | 38 +++++++++++++++++++++++++++ tests/conftest.py | 25 ++++++++++++++++++ tests/request_params_test.py | 43 +++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 mailerlite/sdk/_request_params.py create mode 100644 tests/request_params_test.py diff --git a/mailerlite/sdk/_request_params.py b/mailerlite/sdk/_request_params.py new file mode 100644 index 0000000..70e0d0f --- /dev/null +++ b/mailerlite/sdk/_request_params.py @@ -0,0 +1,38 @@ +from __future__ import absolute_import + + +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): + 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 query_params(kwargs, available_params): + reject_unknown(kwargs, available_params) + + params = {} + for key, value in kwargs.items(): + if key == "filter": + for filter_key, filter_value in value.items(): + if filter_value is not None: + params[f"filter[{filter_key}]"] = query_value(filter_value) + elif value is not None: + params[key] = query_value(value) + + return params + + +def body_params(kwargs, available_params): + reject_unknown(kwargs, available_params) + + return kwargs diff --git a/tests/conftest.py b/tests/conftest.py index 60b49a9..2dedaac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,30 @@ +import json +from types import SimpleNamespace + import pytest +from mailerlite.api_client import ApiClient def pytest_configure(): return {"entity_id": 0, "entity_email": "random@mailerlite.com"} + + +@pytest.fixture +def sent_request(mocker): + response = mocker.Mock() + response.json.return_value = {"data": {}} + request = mocker.patch( + "mailerlite.api_client.requests.request", return_value=response + ) + + 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/request_params_test.py b/tests/request_params_test.py new file mode 100644 index 0000000..2edbbdc --- /dev/null +++ b/tests/request_params_test.py @@ -0,0 +1,43 @@ +import pytest + +from mailerlite.sdk._request_params import body_params, query_params + + +class TestRequestParams: + def test_query_params_flatten_filter_and_encode_values(self): + 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 query_params({"filter": {"search": None}}, ["filter"]) == {} + + def test_query_params_reject_unknown_argument(self): + with pytest.raises(TypeError): + query_params({"unknown": 1}, ["filter"]) + + def test_body_params_keep_json_types(self): + body = body_params( + {"resubscribe": True, "groups": [1, 2], "parent_id": None}, + ["resubscribe", "groups", "parent_id"], + ) + + assert body == {"resubscribe": True, "groups": [1, 2], "parent_id": None} + + def test_body_params_reject_unknown_argument(self): + with pytest.raises(TypeError): + body_params({"unknown": 1}, ["resubscribe"]) From 3268d1526c13bb1ed242a106c95dd266c0beadb4 Mon Sep 17 00:00:00 2001 From: Kavan Pancholi Date: Fri, 30 Oct 2026 15:04:29 +0530 Subject: [PATCH 38/48] Send subscriber update as PUT and add activity log --- mailerlite/sdk/subscribers.py | 140 +++++++++++++-------- tests/subscribers_test.py | 109 ++++++++++++++++ tests/vcr_cassettes/subscribers-update.yml | 9 +- 3 files changed, 198 insertions(+), 60 deletions(-) diff --git a/mailerlite/sdk/subscribers.py b/mailerlite/sdk/subscribers.py index 9281eaf..09d8bf7 100644 --- a/mailerlite/sdk/subscribers.py +++ b/mailerlite/sdk/subscribers.py @@ -1,6 +1,11 @@ from __future__ import absolute_import import re +from urllib.parse import quote + +from mailerlite.sdk._request_params import query_params, reject_unknown + +_EMAIL_RE = re.compile(r"[\w.]+\@[\w.]+") class Subscribers(object): @@ -17,26 +22,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 +53,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 +71,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 +119,21 @@ def update(self, email, **kwargs): "unsubscribed_at", ] - valid = re.search(r"[\w.]+\@[\w.]+", email) - - if not valid: + if not isinstance(email, int) and 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.") + reject_unknown(kwargs, available_params) + self._validate_body(kwargs) - if key == "groups" and type(val) is not list: - raise TypeError("`groups` argument should be a list.") - - 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): @@ -156,7 +150,7 @@ def get(self, subscriber_id): """ if not isinstance(subscriber_id, int): - valid = re.search(r"[\w.]+\@[\w.]+", subscriber_id) + valid = _EMAIL_RE.search(subscriber_id) if not valid: raise TypeError( @@ -308,3 +302,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/tests/subscribers_test.py b/tests/subscribers_test.py index 83719d0..7c57485 100644 --- a/tests/subscribers_test.py +++ b/tests/subscribers_test.py @@ -299,3 +299,112 @@ 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_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 From 770f4bffebaf31a7d3e357d774eb6609562a1ca3 Mon Sep 17 00:00:00 2001 From: Kavan Pancholi Date: Fri, 30 Oct 2026 15:04:29 +0530 Subject: [PATCH 39/48] Use the documented group import route --- mailerlite/sdk/groups.py | 34 +++++++++++++--------------- tests/groups_test.py | 48 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/mailerlite/sdk/groups.py b/mailerlite/sdk/groups.py index 13fa0fe..d5fff24 100644 --- a/mailerlite/sdk/groups.py +++ b/mailerlite/sdk/groups.py @@ -1,5 +1,7 @@ from __future__ import absolute_import +from mailerlite.sdk._request_params import body_params, query_params + class Groups(object): # Groups base API uri @@ -112,7 +114,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 +123,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 +141,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 +160,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 +172,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/tests/groups_test.py b/tests/groups_test.py index 93d03fb..0c00232 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,43 @@ 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_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", + } From bfea7c47bbb1954671c095870289273e89aab0e2 Mon Sep 17 00:00:00 2001 From: Kavan Pancholi Date: Fri, 30 Oct 2026 15:04:29 +0530 Subject: [PATCH 40/48] Send cursor and filter status on segment subscribers --- mailerlite/sdk/segments.py | 42 +++++++++++------------------- tests/segments_test.py | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/mailerlite/sdk/segments.py b/mailerlite/sdk/segments.py index ae3f70c..74a863d 100644 --- a/mailerlite/sdk/segments.py +++ b/mailerlite/sdk/segments.py @@ -1,5 +1,7 @@ from __future__ import absolute_import +from mailerlite.sdk._request_params import body_params, query_params + class Segments(object): # Segments base API uri @@ -15,22 +17,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): """ @@ -80,7 +75,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, use `cursor` :raises: :class: `TypeError` : Got an unknown argument :raises: :class: `TypeError` : `segment_id` type is not valid :return: JSON array @@ -95,20 +90,13 @@ 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 - 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", "after"]), ).json() - def update(self, segment_id, name): + def update(self, segment_id, name, **kwargs): """ Update a segment @@ -117,8 +105,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 +122,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/tests/segments_test.py b/tests/segments_test.py index 5f69fef..b5f245a 100644 --- a/tests/segments_test.py +++ b/tests/segments_test.py @@ -151,3 +151,55 @@ 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_still_sends_after(self, sent_request): + self.client.segments.get_subscribers(123, after="abc") + + assert sent_request().params == {"after": "abc"} + + def test_get_subscribers_sends_after_and_cursor_together(self, sent_request): + self.client.segments.get_subscribers(123, after="old", cursor="new") + + assert sent_request().params == {"after": "old", "cursor": "new"} + + 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) From cf9685572dfc73e70b7154c9e806e9c93648a1f3 Mon Sep 17 00:00:00 2001 From: Kavan Pancholi Date: Fri, 30 Oct 2026 15:04:29 +0530 Subject: [PATCH 41/48] Add automation, trigger, step, email, template and report endpoints --- mailerlite/sdk/automations.py | 726 ++++++++++++++++++++++++++++++++-- tests/automations_test.py | 508 ++++++++++++++++++++++++ 2 files changed, 1205 insertions(+), 29 deletions(-) diff --git a/mailerlite/sdk/automations.py b/mailerlite/sdk/automations.py index 94b33a0..77fd705 100644 --- a/mailerlite/sdk/automations.py +++ b/mailerlite/sdk/automations.py @@ -1,9 +1,12 @@ from __future__ import absolute_import +from mailerlite.sdk._request_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, shop_id or excel + :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", "excel"]), + ).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, shop_id or excel + :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", "excel"]), + ).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/tests/automations_test.py b/tests/automations_test.py index ec29fd8..0ff8be3 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,510 @@ 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", + "excel": False, + }, + "GET", + "/automations/1/emails/6/report-stats", + { + "start_date": "2026-01-01", + "end_date": "2026-02-01", + "shop_id": "7", + "excel": 0, + }, + 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", + "excel": True, + }, + "GET", + "/automations/1/monthly-totals/email", + { + "start_date": "2026-01-01", + "end_date": "2026-02-01", + "shop_id": "7", + "excel": 1, + }, + 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() From 6deaf3e685950fbc1a5dd982bdb14c268a526e05 Mon Sep 17 00:00:00 2001 From: Kavan Pancholi Date: Fri, 30 Oct 2026 15:04:29 +0530 Subject: [PATCH 42/48] Fix README examples and document the new methods --- Readme.md | 637 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 635 insertions(+), 2 deletions(-) diff --git a/Readme.md b/Readme.md index 3a41cc1..1d8af5e 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,6 +51,43 @@ 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) @@ -176,6 +214,8 @@ client = MailerLite.Client({ 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 @@ -219,6 +259,21 @@ 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 @@ -293,7 +348,10 @@ client = MailerLite.Client({ 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 @@ -422,6 +480,9 @@ client = MailerLite.Client({ segment_id = 123456 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 @@ -451,7 +512,7 @@ client = MailerLite.Client({ }) name = 'My Field' -type = 'text +type = 'text' response = client.fields.create(name, type) ``` @@ -533,6 +594,578 @@ 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'}) ``` +### Create an automation + + +```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' +}) + +automation_id = 123456 + +response = client.automations.update(automation_id, 'My new name') +``` + +### Delete an automation + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.delete(automation_id) +``` + +### Copy an automation + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.copy(automation_id) +``` + +### Enable an automation + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.enable(automation_id) +``` + +### Disable an automation + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.disable(automation_id) +``` + +### Send a test of an automation + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.test(automation_id, 'some@email.com') +``` + +### Add subscribers to an automation + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.add_subscribers(automation_id, emails=['some@email.com'], groups=[1234567]) +``` + +### Re-add subscribers to an automation + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.readd(automation_id, 'last_completed_step', {'status': 'completed'}) +``` + +### Copy automation subscribers to groups + + +```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' +}) + +automation_id = 123456 + +response = client.automations.activity_cancel_automation(automation_id, {'status': 'active'}) +``` + +### Skip an automation delay + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.activity_skip_delay(automation_id, {'status': 'active'}) +``` + +### List automation triggers + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.list_triggers(automation_id) +``` + +### Create an automation trigger + + +```python +import mailerlite as MailerLite + +client = MailerLite.Client({ + 'api_key': 'your-api-key' +}) + +automation_id = 123456 + +response = client.automations.create_trigger(automation_id, 'subscriber_joins_group', data={'group_ids': [1234567]}) +``` + +### Get 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.get_trigger(automation_id, trigger_id) +``` + +### Update 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.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' +}) + +automation_id = 123456 +trigger_id = 123456 + +response = client.automations.delete_trigger(automation_id, trigger_id) +``` + +### List automation steps + + +```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' +}) + +automation_id = 123456 + +response = client.automations.create_step(automation_id, 'delay', parent_id=123456, data={'unit': 'days', 'value': 1}) +``` + +### Get 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.get_step(automation_id, step_id) +``` + +### Update 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.update_step(automation_id, step_id, data={'unit': 'days', 'value': 2}) +``` + +### Delete 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.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', excel=False) +``` + +### 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 From 87b473dd618df03bc6863f2db6fb7e6048bc0900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Martty?= Date: Fri, 30 Oct 2026 11:55:25 +0100 Subject: [PATCH 43/48] refactor: leave value checks to the API The SDK now checks only the IDs in the URL path and the settings of update_settings before the request. It sends the placement, the etag, the idempotency key and the other body values as the caller gives them, and the API checks them. EmptyEtagError and InvalidIdempotencyKeyError are removed. Co-Authored-By: Claude Opus 5.5 --- Readme.md | 11 +- mailerlite/__init__.py | 2 - mailerlite/exceptions.py | 12 -- mailerlite/sdk/_params.py | 32 +--- mailerlite/sdk/emails.py | 102 +++-------- tests/emails_test.py | 359 ++++++++++++++++---------------------- tests/params_test.py | 22 +-- 7 files changed, 193 insertions(+), 347 deletions(-) diff --git a/Readme.md b/Readme.md index 02c359d..dae8d0b 100644 --- a/Readme.md +++ b/Readme.md @@ -751,8 +751,9 @@ response = client.campaigns.activity(campaign_id) 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. An empty etag raises `mailerlite.EmptyEtagError`, and the SDK sends no request. +- 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. @@ -818,7 +819,7 @@ 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`, so the SDK does not check it and does not send it. +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 @@ -892,7 +893,7 @@ 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`, so the SDK does not check it and does not send it. +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 @@ -949,6 +950,7 @@ Send only the keys to change, in the shape of `settings` in the email. The group - `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 @@ -1075,7 +1077,8 @@ etag = response['etag'] 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, without a space at the start or the end. Otherwise the SDK raises `mailerlite.InvalidIdempotencyKeyError` and sends no request. +- 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`. diff --git a/mailerlite/__init__.py b/mailerlite/__init__.py index a2c9fb0..45a473f 100644 --- a/mailerlite/__init__.py +++ b/mailerlite/__init__.py @@ -5,9 +5,7 @@ from mailerlite.api_client import ApiClient from mailerlite.exceptions import ( # noqa: F401 ApiError, - EmptyEtagError, EmptyIdError, - InvalidIdempotencyKeyError, InvalidIdError, ) from mailerlite.response import ApiResponse # noqa: F401 diff --git a/mailerlite/exceptions.py b/mailerlite/exceptions.py index b10bc8d..72e898e 100644 --- a/mailerlite/exceptions.py +++ b/mailerlite/exceptions.py @@ -51,15 +51,3 @@ class EmptyIdError(ValueError): class InvalidIdError(ValueError): """An ID for the URL path does not contain only digits. The SDK sends no request.""" - - -class EmptyEtagError(ValueError): - """The etag of a write is empty. The SDK sends no request.""" - - -class InvalidIdempotencyKeyError(ValueError): - """ - The idempotency key is empty, longer than 255 characters, contains a character - that is not printable ASCII, or starts or ends with a space. - The SDK sends no request. - """ diff --git a/mailerlite/sdk/_params.py b/mailerlite/sdk/_params.py index 62e49cb..b08aecd 100644 --- a/mailerlite/sdk/_params.py +++ b/mailerlite/sdk/_params.py @@ -2,16 +2,9 @@ import re -from mailerlite.exceptions import ( - EmptyEtagError, - EmptyIdError, - InvalidIdempotencyKeyError, - InvalidIdError, -) +from mailerlite.exceptions import EmptyIdError, InvalidIdError _ID_PATTERN = re.compile(r"[0-9]+") -# The API accepts 1 to 255 printable ASCII characters -_IDEMPOTENCY_KEY_PATTERN = re.compile(r"[\x20-\x7E]{1,255}") def path_id(value, name): @@ -32,16 +25,6 @@ def path_id(value, name): return value -def etag(value): - if value is None or value == "": - raise EmptyEtagError("`etag` must not be empty.") - - if not isinstance(value, str): - raise TypeError(f"`etag` type is not valid. Expected `str`, got {type(value)}.") - - return value - - def require_dict(value, name): if not isinstance(value, dict): raise TypeError( @@ -66,17 +49,4 @@ def idempotency_headers(key): if key is None: return None - if not isinstance(key, str): - raise TypeError( - f"`idempotency_key` type is not valid. Expected `str`, got {type(key)}." - ) - - # HTTP removes spaces at the start and the end of a header value, - # so such a key does not reach the API as it is. - if not _IDEMPOTENCY_KEY_PATTERN.fullmatch(key) or key != key.strip(" "): - raise InvalidIdempotencyKeyError( - "`idempotency_key` must be 1 to 255 printable ASCII characters, " - "without a space at the start or the end." - ) - return {"Idempotency-Key": key} diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index fc3f826..9043f91 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -31,9 +31,7 @@ class BlockType(object): 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`. For these positions, - the SDK does not check the `block_id` and does not send it. + The API ignores the `block_id` of `START` and `END`. """ START = "start" @@ -62,7 +60,12 @@ class Emails(object): 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 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 @@ -161,14 +164,11 @@ def create_block( 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. - The SDK does not check or send `block_id` for `start` and `end`. :param idempotency_key: str Optional. A key to retry the write safely, see "Idempotency keys" in the class docstring - :raises: :class: `TypeError` : an argument type is not valid - :raises: :class: `EmptyIdError` : an ID is empty or missing - :raises: :class: `InvalidIdError` : an ID does not contain only digits - :raises: :class: `EmptyEtagError` : `etag` is empty - :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid + :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 @@ -176,7 +176,7 @@ def create_block( email_id = _params.path_id(email_id, "email_id") body = _block_body(block_type, editor_content, placement) - body["etag"] = _params.etag(etag) + body["etag"] = etag headers = _params.idempotency_headers(idempotency_key) return ApiResponse( @@ -204,11 +204,9 @@ def update_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` : an argument type is not valid + :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: `EmptyEtagError` : `etag` is empty - :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object :rtype: ApiResponse @@ -216,8 +214,7 @@ def update_block( email_id = _params.path_id(email_id, "email_id") block_id = _params.path_id(block_id, "block_id") - _params.require_dict(editor_content, "editor_content") - body = {"editor_content": editor_content, "etag": _params.etag(etag)} + body = {"editor_content": editor_content, "etag": etag} headers = _params.idempotency_headers(idempotency_key) return ApiResponse( @@ -241,11 +238,9 @@ def delete_block(self, email_id, block_id, etag, *, idempotency_key=None): :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` : an argument type is not valid + :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: `EmptyEtagError` : `etag` is empty - :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object with only the new etag :rtype: ApiResponse @@ -253,7 +248,7 @@ def delete_block(self, email_id, block_id, etag, *, idempotency_key=None): email_id = _params.path_id(email_id, "email_id") block_id = _params.path_id(block_id, "block_id") - body = {"etag": _params.etag(etag)} + body = {"etag": etag} headers = _params.idempotency_headers(idempotency_key) return ApiResponse( @@ -275,15 +270,12 @@ def move_block(self, email_id, block_id, etag, placement, *, idempotency_key=Non :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`. The SDK does not check or send `block_id` - for `start` and `end`. + see `BlockPosition` :param idempotency_key: str Optional. A key to retry the write safely, see "Idempotency keys" in the class docstring - :raises: :class: `TypeError` : an argument type is not valid - :raises: :class: `EmptyIdError` : an ID is empty or missing + :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: `EmptyEtagError` : `etag` is empty - :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object :rtype: ApiResponse @@ -291,7 +283,7 @@ def move_block(self, email_id, block_id, etag, placement, *, idempotency_key=Non email_id = _params.path_id(email_id, "email_id") block_id = _params.path_id(block_id, "block_id") - body = {"placement": _placement(placement), "etag": _params.etag(etag)} + body = {"placement": placement, "etag": etag} headers = _params.idempotency_headers(idempotency_key) return ApiResponse( @@ -313,12 +305,7 @@ def validate_block(self, block_type, editor_content, placement=None): :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`. The SDK does not check or send `block_id` - for `start` and `end`. - :raises: :class: `TypeError` : an argument type is not valid - :raises: :class: `EmptyIdError` : the `block_id` of `placement` is empty - or missing - :raises: :class: `InvalidIdError` : the `block_id` of `placement` is not valid + see `BlockPosition` :raises: :class: `ApiError` : API request failed :return: JSON object with `valid`, `errors` and `schema` :rtype: ApiResponse @@ -354,19 +341,17 @@ def update_settings(self, email_id, etag, settings, *, idempotency_key=None): :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` : an argument type is not valid + :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: `EmptyEtagError` : `etag` is empty - :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :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": _params.etag(etag)} + body = {**_settings(settings), "etag": etag} headers = _params.idempotency_headers(idempotency_key) return ApiResponse( @@ -388,8 +373,7 @@ def validate_settings(self, email_id, settings): :param email_id: str|int Email ID :param settings: dict The settings that you want to send to `update_settings()` - :raises: :class: `TypeError` : an argument type is not valid - :raises: :class: `ValueError` : `settings` contains `etag` + :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 @@ -398,7 +382,6 @@ def validate_settings(self, email_id, settings): """ email_id = _params.path_id(email_id, "email_id") - settings = _settings(settings) return ApiResponse( self.api_client.request( @@ -419,20 +402,15 @@ def create_template(self, email_id, name, *, idempotency_key=None): :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` : an argument type is not valid + :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: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed :return: JSON object :rtype: ApiResponse """ email_id = _params.path_id(email_id, "email_id") - if not isinstance(name, str): - raise TypeError( - f"`name` type is not valid. Expected `str`, got {type(name)}." - ) headers = _params.idempotency_headers(idempotency_key) return ApiResponse( @@ -486,7 +464,6 @@ def create_version(self, email_id, *, idempotency_key=None): :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: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed :return: JSON object :rtype: ApiResponse @@ -514,11 +491,9 @@ def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): :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` : an argument type is not valid + :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: `EmptyEtagError` : `etag` is empty - :raises: :class: `InvalidIdempotencyKeyError` : `idempotency_key` is not valid :raises: :class: `ApiError` : API request failed, e.g. the etag is old :return: JSON object :rtype: ApiResponse @@ -526,7 +501,7 @@ def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): email_id = _params.path_id(email_id, "email_id") version_id = _params.path_id(version_id, "version_id") - body = {"etag": _params.etag(etag)} + body = {"etag": etag} headers = _params.idempotency_headers(idempotency_key) return ApiResponse( @@ -540,36 +515,13 @@ def restore_version(self, email_id, version_id, etag, *, idempotency_key=None): def _block_body(block_type, editor_content, placement): - if not isinstance(block_type, str): - raise TypeError( - f"`block_type` type is not valid. Expected `str`, got {type(block_type)}." - ) - _params.require_dict(editor_content, "editor_content") - body = {"type": block_type, "editor_content": editor_content} if placement is not None: - body["placement"] = _placement(placement) + body["placement"] = placement return body -def _placement(placement): - _params.require_dict(placement, "placement") - position = placement.get("position") - - # The API ignores the `block_id` of these positions and does not check it - if position in (BlockPosition.START, BlockPosition.END): - return {key: value for key, value in placement.items() if key != "block_id"} - - if position in (BlockPosition.BEFORE, BlockPosition.AFTER) or ( - "block_id" in placement - ): - block_id = _params.path_id(placement.get("block_id"), "block_id") - return {**placement, "block_id": block_id} - - return dict(placement) - - def _settings(settings): _params.require_dict(settings, "settings") # The body of update_settings() has the settings and the etag at the same level diff --git a/tests/emails_test.py b/tests/emails_test.py index 81d84fb..0d4f373 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -1,11 +1,11 @@ +import json import pytest +import requests import mailerlite from mailerlite import Client from mailerlite.exceptions import ( ApiError, - EmptyEtagError, EmptyIdError, - InvalidIdempotencyKeyError, InvalidIdError, ) from mailerlite.response import ApiResponse @@ -184,66 +184,6 @@ def test_create_block_sends_the_placement(self, client, mock_api): "etag": "42", } - def test_create_block_sends_an_int_placement_block_id_as_a_string( - self, client, mock_api - ): - mock_api.add_response(201, load_fixture("email_block_created.json")) - placement = {"position": BlockPosition.AFTER, "block_id": 57} - - client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) - - assert mock_api.last_body["placement"] == { - "position": "after", - "block_id": "57", - } - assert placement == {"position": "after", "block_id": 57} - - @pytest.mark.parametrize( - "block_id, error", - [ - ("", EmptyIdError), - (None, EmptyIdError), - ("57/x", InvalidIdError), - ("abc", InvalidIdError), - (5.7, TypeError), - ], - ) - def test_create_block_rejects_an_invalid_placement_block_id( - self, client, mock_api, block_id, error - ): - placement = {"position": BlockPosition.AFTER, "block_id": block_id} - - with pytest.raises(error): - client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) - - assert mock_api.requests == [] - - @pytest.mark.parametrize("etag", ["", None]) - def test_create_block_rejects_an_empty_etag(self, client, mock_api, etag): - with pytest.raises(EmptyEtagError): - client.emails.create_block("123", etag, BlockType.DIVIDER, {}) - - assert mock_api.requests == [] - - @pytest.mark.parametrize( - "etag, editor_content, placement", - [ - (42, {}, None), - ("42", None, None), - ("42", "text", None), - ("42", {}, "end"), - ], - ) - def test_create_block_rejects_arguments_of_the_wrong_type( - self, client, mock_api, etag, editor_content, placement - ): - with pytest.raises(TypeError): - client.emails.create_block( - "123", etag, BlockType.DIVIDER, editor_content, placement=placement - ) - - assert mock_api.requests == [] - 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, {}) @@ -288,7 +228,6 @@ def test_block_constants(self): assert VERSION_CONFLICT == "version_conflict" def test_write_names_are_exported_from_the_package(self): - assert mailerlite.EmptyEtagError is EmptyEtagError assert mailerlite.BlockType is BlockType assert mailerlite.BlockPosition is BlockPosition assert mailerlite.VERSION_CONFLICT == VERSION_CONFLICT @@ -311,8 +250,6 @@ def test_update_block_replaces_the_editor_content(self, client, mock_api): [ ("", "43", {}, EmptyIdError), ("57/move", "43", {}, InvalidIdError), - ("57", "", {}, EmptyEtagError), - ("57", "43", None, TypeError), ], ) def test_update_block_rejects_invalid_arguments( @@ -339,7 +276,6 @@ def test_delete_block_sends_the_etag_in_the_body(self, client, mock_api): [ (None, "44", EmptyIdError), ("57%2F", "44", InvalidIdError), - ("57", None, EmptyEtagError), ], ) def test_delete_block_rejects_invalid_arguments( @@ -364,30 +300,11 @@ def test_move_block_sends_the_placement(self, client, mock_api): assert response["data"]["id"] == "57" assert response["data"]["placement"] == {"after": None, "before": "1"} - def test_move_block_sends_an_int_placement_block_id_as_a_string( - self, client, mock_api - ): - mock_api.add_response(200, load_fixture("email_block_moved.json")) - - client.emails.move_block( - "123", "57", "45", {"position": "before", "block_id": 1} - ) - - assert mock_api.last_body["placement"] == { - "position": "before", - "block_id": "1", - } - @pytest.mark.parametrize( "block_id, etag, placement, error", [ ("", "45", {"position": "end"}, EmptyIdError), ("57/x", "45", {"position": "end"}, InvalidIdError), - ("57", "", {"position": "end"}, EmptyEtagError), - ("57", "45", None, TypeError), - ("57", "45", "end", TypeError), - ("57", "45", {"position": "after", "block_id": ""}, EmptyIdError), - ("57", "45", {"position": "after", "block_id": "1/x"}, InvalidIdError), ], ) def test_move_block_rejects_invalid_arguments( @@ -430,98 +347,6 @@ def test_validate_block_returns_the_errors_of_an_invalid_block( assert response["valid"] is False assert response["errors"] == {"type": ["The selected type is invalid."]} - @pytest.mark.parametrize("block_type", [None, 5, ["divider"]]) - def test_block_writes_reject_a_block_type_of_the_wrong_type( - self, client, mock_api, block_type - ): - with pytest.raises(TypeError): - client.emails.create_block("123", "42", block_type, {}) - with pytest.raises(TypeError): - client.emails.validate_block(block_type, {}) - - assert mock_api.requests == [] - - def test_validate_block_rejects_editor_content_of_the_wrong_type( - self, client, mock_api - ): - with pytest.raises(TypeError): - client.emails.validate_block(BlockType.HEADING, []) - - assert mock_api.requests == [] - - # The API ignores `block_id` for `start` and `end` and does not check it - @pytest.mark.parametrize("position", [BlockPosition.START, BlockPosition.END]) - @pytest.mark.parametrize("block_id", [None, "", "1/2", "abc", 5.7, 57]) - def test_block_writes_do_not_check_or_send_the_block_id_for_start_and_end( - self, client, mock_api, position, block_id - ): - 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")) - placement = {"position": position, "block_id": block_id} - - client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) - client.emails.move_block("123", "57", "45", placement) - client.emails.validate_block(BlockType.DIVIDER, {}, placement) - - sent = f'"placement": {{"position": "{position}"}}' - assert [request.body for request in mock_api.requests] == [ - f'{{"type": "divider", "editor_content": {{}}, {sent}, "etag": "42"}}', - f'{{{sent}, "etag": "45"}}', - f'{{"type": "divider", "editor_content": {{}}, {sent}}}', - ] - assert placement == {"position": position, "block_id": block_id} - - @pytest.mark.parametrize("position", [BlockPosition.BEFORE, BlockPosition.AFTER]) - def test_block_writes_reject_a_missing_block_id_for_before_and_after( - self, client, mock_api, position - ): - placement = {"position": position} - - with pytest.raises(EmptyIdError): - client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) - with pytest.raises(EmptyIdError): - client.emails.move_block("123", "57", "45", placement) - with pytest.raises(EmptyIdError): - client.emails.validate_block(BlockType.DIVIDER, {}, placement) - - assert mock_api.requests == [] - - @pytest.mark.parametrize( - "block_id, error", [(None, EmptyIdError), ("1/2", InvalidIdError)] - ) - def test_block_writes_check_the_block_id_of_an_unknown_position( - self, client, mock_api, block_id, error - ): - placement = {"position": "inside", "block_id": block_id} - - with pytest.raises(error): - client.emails.create_block("123", "42", BlockType.DIVIDER, {}, placement) - with pytest.raises(error): - client.emails.move_block("123", "57", "45", placement) - with pytest.raises(error): - client.emails.validate_block(BlockType.DIVIDER, {}, placement) - - assert mock_api.requests == [] - - @pytest.mark.parametrize( - "placement, sent", - [ - ({"position": "inside", "block_id": 7}, '"inside", "block_id": "7"'), - ({"position": "inside"}, '"inside"'), - ], - ) - def test_move_block_sends_the_block_id_of_an_unknown_position( - self, client, mock_api, placement, sent - ): - mock_api.add_response(200, load_fixture("email_block_moved.json")) - - client.emails.move_block("123", "57", "45", placement) - - assert mock_api.last_request.body == ( - f'{{"placement": {{"position": {sent}}}, "etag": "45"}}' - ) - def test_update_settings_sends_only_the_given_keys(self, client, mock_api): mock_api.add_response(200, load_fixture("email_settings.json")) settings = { @@ -562,7 +387,6 @@ def test_update_settings_does_not_change_the_given_dict(self, client, mock_api): [ ("", "46", {}, EmptyIdError), ("123/html", "46", {}, InvalidIdError), - ("123", "", {}, EmptyEtagError), ("123", "46", None, TypeError), ("123", "46", {"etag": "45"}, ValueError), ], @@ -604,8 +428,6 @@ def test_validate_settings_sends_the_settings_without_an_etag( [ (None, {}, EmptyIdError), ("1.5", {}, InvalidIdError), - ("123", [], TypeError), - ("123", {"etag": "45"}, ValueError), ], ) def test_validate_settings_rejects_invalid_arguments( @@ -638,7 +460,6 @@ def test_create_template_saves_the_email_as_a_template(self, client, mock_api): [ ("", "Newsletter", EmptyIdError), ("12a", "Newsletter", InvalidIdError), - ("123", None, TypeError), ], ) def test_create_template_rejects_invalid_arguments( @@ -720,8 +541,6 @@ def test_restore_version_returns_the_email(self, client, mock_api): [ ("", "47", EmptyIdError), ("901/restore", "47", InvalidIdError), - ("901", "", EmptyEtagError), - ("901", 47, TypeError), ], ) def test_restore_version_rejects_invalid_arguments( @@ -791,43 +610,169 @@ def test_first_write_is_not_an_idempotent_replay(self, client, mock_api): 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("settings", [[], {"etag": "45"}]) + def test_validate_settings_sends_the_settings_as_given( + self, client, mock_api, settings + ): + mock_api.add_response(200, load_fixture("email_settings_validation.json")) + + response = client.emails.validate_settings("123", settings) + + assert mock_api.last_body == settings + assert response["valid"] is False + @pytest.mark.parametrize( "key", [ "", - "k" * 256, - "line\nbreak", + pytest.param("k" * 256, id="256-characters"), "tab\there", - "caf\u00e9", "delete\x7f", - " leading-space", "trailing-space ", ], ) @pytest.mark.parametrize("method, args, fixture", WRITES) - def test_write_rejects_an_invalid_key( + def test_write_sends_the_idempotency_key_as_given( self, client, mock_api, method, args, fixture, key ): - with pytest.raises(InvalidIdempotencyKeyError): - getattr(client.emails, method)(*args, idempotency_key=key) - - assert mock_api.requests == [] - - def test_write_rejects_a_key_of_the_wrong_type(self, client, mock_api): - with pytest.raises(TypeError): - client.emails.create_version("123", idempotency_key=1) - - assert mock_api.requests == [] - - 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")) + mock_api.add_response(200, load_fixture(fixture)) - client.emails.create_version("123", idempotency_key=key) + getattr(client.emails, method)(*args, idempotency_key=key) assert mock_api.last_request.headers["Idempotency-Key"] == key - def test_idempotency_key_error_is_exported_from_the_package(self): - assert issubclass(InvalidIdempotencyKeyError, ValueError) - assert mailerlite.InvalidIdempotencyKeyError is InvalidIdempotencyKeyError + # 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/params_test.py b/tests/params_test.py index 54839d6..5be0d0c 100644 --- a/tests/params_test.py +++ b/tests/params_test.py @@ -1,10 +1,5 @@ import pytest -from mailerlite.exceptions import ( - EmptyEtagError, - EmptyIdError, - InvalidIdempotencyKeyError, - InvalidIdError, -) +from mailerlite.exceptions import EmptyIdError, InvalidIdError from mailerlite.sdk import _params @@ -27,14 +22,6 @@ 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_etag_returns_the_etag(self): - assert _params.etag("42") == "42" - - @pytest.mark.parametrize("value, error", [("", EmptyEtagError), (42, TypeError)]) - def test_etag_rejects_an_invalid_etag(self, value, error): - with pytest.raises(error): - _params.etag(value) - def test_require_dict_rejects_a_value_that_is_not_a_dict(self): _params.require_dict({}, "settings") @@ -59,5 +46,8 @@ def test_idempotency_headers(self): assert _params.idempotency_headers(None) is None assert _params.idempotency_headers("k1") == {"Idempotency-Key": "k1"} - with pytest.raises(InvalidIdempotencyKeyError): - _params.idempotency_headers(" 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} From 831a4db32e31221afdb3073fd3d8ffb14f154502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20Hr=C4=8Dek?= Date: Tue, 3 Nov 2026 14:00:10 +0100 Subject: [PATCH 44/48] feat: Improvements --- mailerlite/response.py | 24 +++++++++++++++++++++++- mailerlite/sdk/_params.py | 2 +- mailerlite/sdk/emails.py | 6 ++++-- tests/emails_test.py | 14 +++----------- tests/params_test.py | 5 +++++ tests/response_test.py | 29 +++++++++++++++++++++++++++++ 6 files changed, 65 insertions(+), 15 deletions(-) diff --git a/mailerlite/response.py b/mailerlite/response.py index e83d720..3b88d6a 100644 --- a/mailerlite/response.py +++ b/mailerlite/response.py @@ -1,5 +1,7 @@ from __future__ import absolute_import +import copy + class ApiResponse(dict): """ @@ -7,13 +9,19 @@ class ApiResponse(dict): 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__(response.json()) + 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): """ @@ -24,3 +32,17 @@ def is_idempotent_replay(self): # 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/_params.py b/mailerlite/sdk/_params.py index b08aecd..f781cfb 100644 --- a/mailerlite/sdk/_params.py +++ b/mailerlite/sdk/_params.py @@ -42,7 +42,7 @@ def filter_params(filter): require_dict(filter, "filter") - return {f"filter[{key}]": value for key, value in filter.items()} + return {f"filter[{key}]": value for key, value in without_none(filter).items()} def idempotency_headers(key): diff --git a/mailerlite/sdk/emails.py b/mailerlite/sdk/emails.py index 9043f91..7ac3e67 100644 --- a/mailerlite/sdk/emails.py +++ b/mailerlite/sdk/emails.py @@ -373,7 +373,8 @@ def validate_settings(self, email_id, settings): :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` type is not valid + :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 @@ -382,12 +383,13 @@ def validate_settings(self, email_id, settings): """ 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=settings, + body=body, ) ) diff --git a/tests/emails_test.py b/tests/emails_test.py index 0d4f373..27396d3 100644 --- a/tests/emails_test.py +++ b/tests/emails_test.py @@ -428,6 +428,9 @@ def test_validate_settings_sends_the_settings_without_an_etag( [ (None, {}, EmptyIdError), ("1.5", {}, InvalidIdError), + ("123", [], TypeError), + ("123", None, TypeError), + ("123", {"etag": "45"}, ValueError), ], ) def test_validate_settings_rejects_invalid_arguments( @@ -736,17 +739,6 @@ def test_validate_block_sends_an_invalid_block( } assert response["valid"] is False - @pytest.mark.parametrize("settings", [[], {"etag": "45"}]) - def test_validate_settings_sends_the_settings_as_given( - self, client, mock_api, settings - ): - mock_api.add_response(200, load_fixture("email_settings_validation.json")) - - response = client.emails.validate_settings("123", settings) - - assert mock_api.last_body == settings - assert response["valid"] is False - @pytest.mark.parametrize( "key", [ diff --git a/tests/params_test.py b/tests/params_test.py index 5be0d0c..b45f71b 100644 --- a/tests/params_test.py +++ b/tests/params_test.py @@ -38,6 +38,11 @@ def test_filter_params_returns_a_query_param_for_each_filter(self): "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") diff --git a/tests/response_test.py b/tests/response_test.py index 7452728..e0a3445 100644 --- a/tests/response_test.py +++ b/tests/response_test.py @@ -1,3 +1,5 @@ +import json + import pytest import mailerlite @@ -49,5 +51,32 @@ def test_response_without_the_header_is_not_an_idempotent_replay(self, mock_api) 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 From 22d343bf72663dda33274db45dab9a2b3bf44c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20Hr=C4=8Dek?= Date: Tue, 3 Nov 2026 14:01:44 +0100 Subject: [PATCH 45/48] feat: Improvements --- Readme.md | 551 ++++++++++++++++++------------------------- tests/groups_test.py | 8 +- 2 files changed, 232 insertions(+), 327 deletions(-) diff --git a/Readme.md b/Readme.md index dae8d0b..6bbc6cd 100644 --- a/Readme.md +++ b/Readme.md @@ -115,19 +115,14 @@ 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 @@ -146,16 +141,14 @@ The attributes are `None` when the response does not have them. ```python 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') + 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.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 ``` @@ -174,11 +167,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 @@ -187,11 +178,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 @@ -200,11 +194,14 @@ 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", +) ``` ### Get a subscriber @@ -213,11 +210,9 @@ response = client.subscribers.update('some@email.com', fields={'name': 'New', 'l ```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 @@ -226,9 +221,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 @@ -241,9 +234,7 @@ 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 @@ -259,11 +250,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 @@ -272,11 +261,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) ``` @@ -287,12 +274,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) ``` @@ -303,9 +288,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 @@ -318,13 +301,13 @@ 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, page=1, limit=10, filter={"status": "active"} +) ``` ### Import bulk subscribers to a group @@ -333,14 +316,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) @@ -352,9 +333,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 @@ -368,9 +347,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 @@ -387,9 +364,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) ``` @@ -400,9 +375,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" @@ -416,9 +389,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 @@ -431,13 +402,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 @@ -446,13 +415,13 @@ 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"} +) ``` ## Fields @@ -464,11 +433,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 @@ -493,12 +462,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) ``` @@ -509,9 +476,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 @@ -527,11 +492,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 @@ -540,9 +505,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 @@ -555,13 +518,16 @@ 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 @@ -573,11 +539,11 @@ response = client.automations.activity(automation_id, page=1, limit=10, filter={ ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.campaigns.list(limit=10, page=1, filter={'status': 'ready', 'type': 'regular'}) +response = client.campaigns.list( + limit=10, page=1, filter={"status": "ready", "type": "regular"} +) ``` ### Get a campaign @@ -586,9 +552,7 @@ response = client.campaigns.list(limit=10, page=1, filter={'status': 'ready', 't ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) campaign_id = 123456 @@ -601,20 +565,20 @@ response = client.campaigns.get(campaign_id) ```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!" - }] + "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) @@ -632,24 +596,24 @@ To build the email with the simple editor, set `editor` to `MailerLite.EDITOR_SI ```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", "type": "regular", - "emails": [{ - "subject": "This is a test campaign", - "from_name": "Test Man", - "from": "testuser@mailerlite.com", - "editor": MailerLite.EDITOR_SIMPLE, - }] + "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'] +email_id = response["data"]["emails"][0]["id"] ``` ### Update a campaign @@ -658,20 +622,20 @@ email_id = response['data']['emails'][0]['id'] ```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!" - }] + "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) @@ -683,18 +647,12 @@ response = client.campaigns.update(campaign_id, params) ```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" - } + "schedule": {"date": "2022-12-31", "hours": "22", "minutes": "00"}, } response = client.campaigns.schedule(campaign_id, params) @@ -706,9 +664,7 @@ response = client.campaigns.schedule(campaign_id, params) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) campaign_id = 123456 @@ -721,9 +677,7 @@ response = client.campaigns.cancel(campaign_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) campaign_id = 123456 @@ -736,9 +690,7 @@ response = client.campaigns.delete(campaign_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) campaign_id = 123456 @@ -765,16 +717,14 @@ 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' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' +email_id = "123456" response = client.emails.get(email_id) -email = response['data'] -etag = response['etag'] +email = response["data"] +etag = response["etag"] ``` ### Get the HTML of an email @@ -785,15 +735,13 @@ Returns the HTML that the email sends. The API can return a temporary `503` erro ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' +email_id = "123456" response = client.emails.get_html(email_id) -html = response['data']['html'] +html = response["data"]["html"] ``` ### Get a block @@ -804,16 +752,14 @@ Returns the block and its place in the email: `placement` has the IDs of the blo ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' -block_id = '57' +email_id = "123456" +block_id = "57" response = client.emails.get_block(email_id, block_id) -block = response['data'] +block = response["data"] ``` ### Insert a block @@ -824,23 +770,21 @@ Use the block types in `MailerLite.BlockType`. `editor_content` can be empty. Wi ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' -etag = client.emails.get(email_id)['etag'] +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}, + {"attrs": {"textAlign": "left"}, "content": [{"type": "text", "text": "Hello"}]}, + placement={"position": MailerLite.BlockPosition.START}, ) -block_id = response['data']['id'] -etag = response['etag'] +block_id = response["data"]["id"] +etag = response["etag"] ``` ### Update a block @@ -851,22 +795,23 @@ The new `editor_content` replaces the whole content of the block. The API remove ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' -block_id = '57' -etag = 'etag-from-the-last-response' +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'}]}, + { + "attrs": {"textAlign": "center"}, + "content": [{"type": "text", "text": "Hello again"}], + }, ) -etag = response['etag'] +etag = response["etag"] ``` ### Delete a block @@ -877,17 +822,15 @@ 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' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' -block_id = '57' -etag = 'etag-from-the-last-response' +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'] +etag = response["etag"] ``` ### Move a block @@ -898,22 +841,20 @@ The `before` and `after` positions also need a `block_id`. The API ignores the ` ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' -block_id = '57' -etag = 'etag-from-the-last-response' +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'}, + {"position": MailerLite.BlockPosition.AFTER, "block_id": "12"}, ) -etag = response['etag'] +etag = response["etag"] ``` ### Validate a block @@ -924,17 +865,18 @@ Checks a block before you insert it. It needs no email and no etag. An invalid b ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +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'}]}, + { + "attrs": {"href": "https://example.com"}, + "content": [{"type": "text", "text": "Buy now"}], + }, ) -if not response['valid']: - print(response['errors']) +if not response["valid"]: + print(response["errors"]) ``` ### Update the design settings @@ -955,21 +897,23 @@ Send only the keys to change, in the shape of `settings` in the email. The group ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' -etag = 'etag-from-the-last-response' +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'}}}, -}) +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']) +etag = response["etag"] +if response.get("rejected"): + print(response["rejected"]["keys"]) ``` ### Validate the design settings @@ -980,18 +924,19 @@ Checks a change with the rules of the update, and saves nothing. It needs no eta ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' +email_id = "123456" -response = client.emails.validate_settings(email_id, { - 'button': {'border_radius': '8'}, -}) +response = client.emails.validate_settings( + email_id, + { + "button": {"border_radius": "8"}, + }, +) -if not response['valid']: - print(response['errors']) +if not response["valid"]: + print(response["errors"]) ``` ### Save an email as a template @@ -1002,15 +947,13 @@ 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' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' +email_id = "123456" -response = client.emails.create_template(email_id, 'Monthly newsletter') +response = client.emails.create_template(email_id, "Monthly newsletter") -template_id = response['data']['id'] +template_id = response["data"]["id"] ``` ### List the versions of an email @@ -1021,11 +964,9 @@ A version is a saved copy of the content and the settings of an email. The list ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' +email_id = "123456" response = client.emails.list_versions(email_id, page=1, limit=10) ``` @@ -1038,15 +979,13 @@ Writes through the API do not save versions. If nothing changed after the last v ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' +email_id = "123456" response = client.emails.create_version(email_id) -version_id = response['data']['id'] +version_id = response["data"]["id"] is_new = response.status_code == 201 ``` @@ -1058,17 +997,15 @@ Puts the email back to the version, and returns the full email with the new etag ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' -version_id = '901' -etag = 'etag-from-the-last-response' +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'] +etag = response["etag"] ``` ### Retry a write with an idempotency key @@ -1088,12 +1025,10 @@ import uuid import requests import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -email_id = '123456' -etag = 'etag-from-the-last-response' +email_id = "123456" +etag = "etag-from-the-last-response" key = str(uuid.uuid4()) try: @@ -1106,7 +1041,7 @@ except (requests.ConnectionError, requests.Timeout): email_id, etag, MailerLite.BlockType.DIVIDER, {}, idempotency_key=key ) -print(response['data']['id'], response.is_idempotent_replay) +print(response["data"]["id"], response.is_idempotent_replay) ``` ## Brand styles @@ -1120,13 +1055,11 @@ Returns the palettes of your account: `brand` (your brand styles, or `None` if y ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) response = client.brand_styles.get() -identifiers = [preset['identifier'] for preset in response['data']['presets']] +identifiers = [preset["identifier"] for preset in response["data"]["presets"]] ``` ## Files @@ -1143,12 +1076,10 @@ Returns the files of your media library. Use their `url` in image blocks. `width ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) response = client.files.list( - filter={'name': 'summer sale', 'type': MailerLite.FileType.IMAGE}, + filter={"name": "summer sale", "type": MailerLite.FileType.IMAGE}, sort=MailerLite.FileSort.SIZE_DESCENDING, page=1, limit=25, @@ -1164,11 +1095,11 @@ response = client.files.list( ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.forms.list(limit=10, page=1, sort='name', filter={'name': 'form name'}) +response = client.forms.list( + limit=10, page=1, sort="name", filter={"name": "form name"} +) ``` ### Get a form @@ -1177,9 +1108,7 @@ response = client.forms.list(limit=10, page=1, sort='name', filter={'name': 'for ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) form_id = 123456 @@ -1192,12 +1121,10 @@ response = client.forms.get(form_id) ```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' +name = "New Form Name" response = client.forms.update(form_id, name) ``` @@ -1208,9 +1135,7 @@ response = client.forms.update(form_id, name) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) form_id = 123456 @@ -1223,13 +1148,13 @@ response = client.forms.delete(form_id) ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +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'}) +response = client.forms.get_subscribers( + form_id, page=1, limit=10, filter={"status": "active"} +) ``` ## Batching @@ -1241,9 +1166,7 @@ response = client.forms.get_subscribers(form_id, page=1, limit=10, filter={'stat ```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"}, @@ -1262,9 +1185,7 @@ response = self.client.batches.request(params) ```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() ``` @@ -1275,9 +1196,7 @@ response = client.webhooks.list() ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) webhook_id = 123456 @@ -1290,17 +1209,11 @@ response = client.webhooks.get(webhook_id) ```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' +events = ["subscriber.created", "subscriber.updated", "subscriber.unsubscribed"] +url = "https://my-url.com" +name = "Webhook name" response = client.webhooks.create(events, url, name) ``` @@ -1311,18 +1224,12 @@ response = client.webhooks.create(events, url, name) ```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' +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) @@ -1334,9 +1241,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 @@ -1352,9 +1257,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() ``` @@ -1368,9 +1271,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/tests/groups_test.py b/tests/groups_test.py index 93d03fb..d695164 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) From b58ac1f6d84af5539c9de5bd3f9f22501e2f36be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20Hr=C4=8Dek?= Date: Tue, 3 Nov 2026 14:48:12 +0100 Subject: [PATCH 46/48] fix(sdk): report, subscriber path and paging bugs - email_report_stats and email_monthly_totals drop the `excel` argument: the SDK always decodes JSON, so the XLSX download could never work - subscribers.get quotes the email in the path like update does - segments.get_subscribers sends `after` as `cursor` and warns: the API ignores `after`, so old paging loops never advanced - a bool is not a subscriber ID --- Readme.md | 2 +- mailerlite/sdk/automations.py | 8 ++++---- mailerlite/sdk/segments.py | 14 ++++++++++++-- mailerlite/sdk/subscribers.py | 22 +++++++++++++--------- tests/automations_test.py | 20 ++++++++++++++++---- tests/segments_test.py | 14 ++++++++------ tests/subscribers_test.py | 17 +++++++++++++++++ 7 files changed, 71 insertions(+), 26 deletions(-) diff --git a/Readme.md b/Readme.md index a14c5ee..97f8bcd 100644 --- a/Readme.md +++ b/Readme.md @@ -1014,7 +1014,7 @@ client = MailerLite.Client({ 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', excel=False) +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 diff --git a/mailerlite/sdk/automations.py b/mailerlite/sdk/automations.py index 77fd705..ea5bc00 100644 --- a/mailerlite/sdk/automations.py +++ b/mailerlite/sdk/automations.py @@ -551,7 +551,7 @@ def email_report_stats(self, automation_id, email_id, **kwargs): :param automation_id: Automation ID :param email_id: Email ID - :param **kwargs: You can pass additional arguments - start_date, end_date, shop_id or excel + :param **kwargs: You can pass additional arguments - start_date, end_date or shop_id :raises: :class: `TypeError` : Got an unknown argument :return: JSON dict """ @@ -559,7 +559,7 @@ def email_report_stats(self, automation_id, email_id, **kwargs): return self.api_client.request( "GET", f"{self.base_api_url}/{automation_id}/emails/{email_id}/report-stats", - query_params(kwargs, ["start_date", "end_date", "shop_id", "excel"]), + query_params(kwargs, ["start_date", "end_date", "shop_id"]), ).json() def email_unsubscribe_reasons(self, automation_id, email_id): @@ -728,7 +728,7 @@ def email_monthly_totals(self, automation_id, **kwargs): Ref: https://developers.mailerlite.com/docs/automations.html :param automation_id: Automation ID - :param **kwargs: You can pass additional arguments - start_date, end_date, shop_id or excel + :param **kwargs: You can pass additional arguments - start_date, end_date or shop_id :raises: :class: `TypeError` : Got an unknown argument :return: JSON dict """ @@ -736,7 +736,7 @@ def email_monthly_totals(self, automation_id, **kwargs): 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", "excel"]), + query_params(kwargs, ["start_date", "end_date", "shop_id"]), ).json() def _activity_subscribers(self, automation_id, kind, item_id, status, kwargs): diff --git a/mailerlite/sdk/segments.py b/mailerlite/sdk/segments.py index 74a863d..7c24b8d 100644 --- a/mailerlite/sdk/segments.py +++ b/mailerlite/sdk/segments.py @@ -1,5 +1,7 @@ from __future__ import absolute_import +import warnings + from mailerlite.sdk._request_params import body_params, query_params @@ -75,7 +77,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 - cursor, limit or filter by status. `after` is deprecated, use `cursor` + :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 @@ -90,10 +92,18 @@ def get_subscribers(self, segment_id, **kwargs): if not isinstance(segment_id, int): raise TypeError("Segment ID are not valid.") + 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(kwargs, ["filter", "limit", "cursor", "after"]), + query_params(kwargs, ["filter", "limit", "cursor"]), ).json() def update(self, segment_id, name, **kwargs): diff --git a/mailerlite/sdk/subscribers.py b/mailerlite/sdk/subscribers.py index 09d8bf7..f0c81db 100644 --- a/mailerlite/sdk/subscribers.py +++ b/mailerlite/sdk/subscribers.py @@ -8,6 +8,15 @@ _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): # Subscribers base API uri base_api_url = "api/subscribers" @@ -119,7 +128,7 @@ def update(self, email, **kwargs): "unsubscribed_at", ] - if not isinstance(email, int) and not _EMAIL_RE.search(email): + if not _is_id(email) and not _is_email(email): raise TypeError("`email` is not a valid email address.") reject_unknown(kwargs, available_params) @@ -149,16 +158,11 @@ def get(self, subscriber_id): :rtype: dict """ - if not isinstance(subscriber_id, int): - valid = _EMAIL_RE.search(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): diff --git a/tests/automations_test.py b/tests/automations_test.py index 0ff8be3..a243221 100644 --- a/tests/automations_test.py +++ b/tests/automations_test.py @@ -319,7 +319,6 @@ def _method_ids(cases): "start_date": "2026-01-01", "end_date": "2026-02-01", "shop_id": "7", - "excel": False, }, "GET", "/automations/1/emails/6/report-stats", @@ -327,7 +326,6 @@ def _method_ids(cases): "start_date": "2026-01-01", "end_date": "2026-02-01", "shop_id": "7", - "excel": 0, }, None, ), @@ -453,7 +451,6 @@ def _method_ids(cases): "start_date": "2026-01-01", "end_date": "2026-02-01", "shop_id": "7", - "excel": True, }, "GET", "/automations/1/monthly-totals/email", @@ -461,7 +458,6 @@ def _method_ids(cases): "start_date": "2026-01-01", "end_date": "2026-02-01", "shop_id": "7", - "excel": 1, }, None, ), @@ -578,3 +574,19 @@ def test_no_content_method_returns_true_without_reading_body( 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/segments_test.py b/tests/segments_test.py index b5f245a..6f07596 100644 --- a/tests/segments_test.py +++ b/tests/segments_test.py @@ -180,15 +180,17 @@ def test_get_subscribers_sends_cursor_and_filter_status(self, sent_request): "cursor": "abc", } - def test_get_subscribers_still_sends_after(self, sent_request): - self.client.segments.get_subscribers(123, after="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 == {"after": "abc"} + assert sent_request().params == {"cursor": "abc"} - def test_get_subscribers_sends_after_and_cursor_together(self, sent_request): - self.client.segments.get_subscribers(123, after="old", cursor="new") + 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 == {"after": "old", "cursor": "new"} + assert sent_request().params == {"cursor": "new"} def test_update_sends_documented_body_params(self, sent_request): rules = [[{"operator": "in_any", "args": ["groups", 1]}]] diff --git a/tests/subscribers_test.py b/tests/subscribers_test.py index 7c57485..3ec55ce 100644 --- a/tests/subscribers_test.py +++ b/tests/subscribers_test.py @@ -383,6 +383,23 @@ def test_update_quotes_the_email_in_the_path(self, sent_request): 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") From e7cb21ea051cf2239ee0f20210a5df475cde4853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20Hr=C4=8Dek?= Date: Tue, 3 Nov 2026 14:48:13 +0100 Subject: [PATCH 47/48] refactor(sdk): merge the request param helpers _params.py and _request_params.py built params two ways. One module, one rule: unknown arguments raise TypeError, `filter` must be a dict, `None` values are not sent, booleans and lists are encoded for the query string. groups.list and segments.get use it too, which removes the stray `list` argument of groups.list and `after` of segments.get. --- mailerlite/sdk/_params.py | 46 +++++++++++++++++++++++++++- mailerlite/sdk/_request_params.py | 38 ----------------------- mailerlite/sdk/automations.py | 2 +- mailerlite/sdk/groups.py | 21 ++++--------- mailerlite/sdk/segments.py | 24 ++++----------- mailerlite/sdk/subscribers.py | 2 +- tests/groups_test.py | 12 ++++++++ tests/params_test.py | 50 +++++++++++++++++++++++++++++++ tests/request_params_test.py | 43 -------------------------- tests/segments_test.py | 12 ++++++++ 10 files changed, 132 insertions(+), 118 deletions(-) delete mode 100644 mailerlite/sdk/_request_params.py delete mode 100644 tests/request_params_test.py diff --git a/mailerlite/sdk/_params.py b/mailerlite/sdk/_params.py index f781cfb..b868496 100644 --- a/mailerlite/sdk/_params.py +++ b/mailerlite/sdk/_params.py @@ -36,13 +36,57 @@ 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}]": value for key, value in without_none(filter).items()} + 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): diff --git a/mailerlite/sdk/_request_params.py b/mailerlite/sdk/_request_params.py deleted file mode 100644 index 70e0d0f..0000000 --- a/mailerlite/sdk/_request_params.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import absolute_import - - -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): - 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 query_params(kwargs, available_params): - reject_unknown(kwargs, available_params) - - params = {} - for key, value in kwargs.items(): - if key == "filter": - for filter_key, filter_value in value.items(): - if filter_value is not None: - params[f"filter[{filter_key}]"] = query_value(filter_value) - elif value is not None: - params[key] = query_value(value) - - return params - - -def body_params(kwargs, available_params): - reject_unknown(kwargs, available_params) - - return kwargs diff --git a/mailerlite/sdk/automations.py b/mailerlite/sdk/automations.py index ea5bc00..fd75038 100644 --- a/mailerlite/sdk/automations.py +++ b/mailerlite/sdk/automations.py @@ -1,6 +1,6 @@ from __future__ import absolute_import -from mailerlite.sdk._request_params import body_params, query_params, query_value +from mailerlite.sdk._params import body_params, query_params, query_value class Automations(object): diff --git a/mailerlite/sdk/groups.py b/mailerlite/sdk/groups.py index d5fff24..d40833f 100644 --- a/mailerlite/sdk/groups.py +++ b/mailerlite/sdk/groups.py @@ -1,6 +1,6 @@ from __future__ import absolute_import -from mailerlite.sdk._request_params import body_params, query_params +from mailerlite.sdk._params import body_params, query_params class Groups(object): @@ -23,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): """ diff --git a/mailerlite/sdk/segments.py b/mailerlite/sdk/segments.py index 7c24b8d..a72ce31 100644 --- a/mailerlite/sdk/segments.py +++ b/mailerlite/sdk/segments.py @@ -2,7 +2,7 @@ import warnings -from mailerlite.sdk._request_params import body_params, query_params +from mailerlite.sdk._params import body_params, query_params class Segments(object): @@ -37,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 @@ -49,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): diff --git a/mailerlite/sdk/subscribers.py b/mailerlite/sdk/subscribers.py index f0c81db..873e1c1 100644 --- a/mailerlite/sdk/subscribers.py +++ b/mailerlite/sdk/subscribers.py @@ -3,7 +3,7 @@ import re from urllib.parse import quote -from mailerlite.sdk._request_params import query_params, reject_unknown +from mailerlite.sdk._params import query_params, reject_unknown _EMAIL_RE = re.compile(r"[\w.]+\@[\w.]+") diff --git a/tests/groups_test.py b/tests/groups_test.py index 0c00232..8e26156 100644 --- a/tests/groups_test.py +++ b/tests/groups_test.py @@ -191,6 +191,18 @@ class TestGroupsRequests: 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( diff --git a/tests/params_test.py b/tests/params_test.py index b45f71b..d706d2c 100644 --- a/tests/params_test.py +++ b/tests/params_test.py @@ -47,6 +47,56 @@ 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"} diff --git a/tests/request_params_test.py b/tests/request_params_test.py deleted file mode 100644 index 2edbbdc..0000000 --- a/tests/request_params_test.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - -from mailerlite.sdk._request_params import body_params, query_params - - -class TestRequestParams: - def test_query_params_flatten_filter_and_encode_values(self): - 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 query_params({"filter": {"search": None}}, ["filter"]) == {} - - def test_query_params_reject_unknown_argument(self): - with pytest.raises(TypeError): - query_params({"unknown": 1}, ["filter"]) - - def test_body_params_keep_json_types(self): - body = body_params( - {"resubscribe": True, "groups": [1, 2], "parent_id": None}, - ["resubscribe", "groups", "parent_id"], - ) - - assert body == {"resubscribe": True, "groups": [1, 2], "parent_id": None} - - def test_body_params_reject_unknown_argument(self): - with pytest.raises(TypeError): - body_params({"unknown": 1}, ["resubscribe"]) diff --git a/tests/segments_test.py b/tests/segments_test.py index 6f07596..7468296 100644 --- a/tests/segments_test.py +++ b/tests/segments_test.py @@ -192,6 +192,18 @@ def test_get_subscribers_prefers_cursor_over_after(self, sent_request): 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) From c3d2a2d312fce3c996a8aa96efa64c2f120d00d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20Hr=C4=8Dek?= Date: Tue, 3 Nov 2026 14:50:37 +0100 Subject: [PATCH 48/48] docs: Readme update --- Readme.md | 256 +++++++++++++++++++++++------------------------------- 1 file changed, 109 insertions(+), 147 deletions(-) diff --git a/Readme.md b/Readme.md index 97f8bcd..67c03dd 100644 --- a/Readme.md +++ b/Readme.md @@ -287,13 +287,13 @@ 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"}) subscriber_id = 1234567890 -response = client.subscribers.activity_log(subscriber_id, limit=10, page=1, filter={'log_name': 'email_open'}) +response = client.subscribers.activity_log( + subscriber_id, limit=10, page=1, filter={"log_name": "email_open"} +) ``` ## Groups @@ -360,10 +360,14 @@ client = MailerLite.Client({"api_key": "your-api-key"}) group_id = 1234567 -response = client.groups.get_group_subscribers(group_id, 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'}) +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 @@ -475,10 +479,14 @@ 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'}) +next_cursor = response["meta"]["next_cursor"] +response = client.segments.get_subscribers( + segment_id, cursor=next_cursor, limit=10, filter={"status": "active"} +) ``` ## Fields @@ -503,12 +511,10 @@ response = client.fields.list( ```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) ``` @@ -593,11 +599,9 @@ response = client.automations.activity( ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) -response = client.automations.create('My automation') +response = client.automations.create("My automation") ``` ### Update an automation @@ -606,13 +610,11 @@ response = client.automations.create('My 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.automations.update(automation_id, 'My new name') +response = client.automations.update(automation_id, "My new name") ``` ### Delete an automation @@ -621,9 +623,7 @@ response = client.automations.update(automation_id, 'My new name') ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 @@ -636,9 +636,7 @@ response = client.automations.delete(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 @@ -651,9 +649,7 @@ response = client.automations.copy(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 @@ -666,9 +662,7 @@ response = client.automations.enable(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 @@ -681,13 +675,11 @@ response = client.automations.disable(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.test(automation_id, 'some@email.com') +response = client.automations.test(automation_id, "some@email.com") ``` ### Add subscribers to an automation @@ -696,13 +688,13 @@ response = client.automations.test(automation_id, 'some@email.com') ```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.add_subscribers(automation_id, emails=['some@email.com'], groups=[1234567]) +response = client.automations.add_subscribers( + automation_id, emails=["some@email.com"], groups=[1234567] +) ``` ### Re-add subscribers to an automation @@ -711,13 +703,13 @@ response = client.automations.add_subscribers(automation_id, emails=['some@email ```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.readd(automation_id, 'last_completed_step', {'status': 'completed'}) +response = client.automations.readd( + automation_id, "last_completed_step", {"status": "completed"} +) ``` ### Copy automation subscribers to groups @@ -726,13 +718,13 @@ response = client.automations.readd(automation_id, 'last_completed_step', {'stat ```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_add_to_group(automation_id, ['1234567'], {'status': 'completed'}) +response = client.automations.activity_add_to_group( + automation_id, ["1234567"], {"status": "completed"} +) ``` ### Cancel active automation runs @@ -741,13 +733,13 @@ response = client.automations.activity_add_to_group(automation_id, ['1234567'], ```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_cancel_automation(automation_id, {'status': 'active'}) +response = client.automations.activity_cancel_automation( + automation_id, {"status": "active"} +) ``` ### Skip an automation delay @@ -756,13 +748,11 @@ response = client.automations.activity_cancel_automation(automation_id, {'status ```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_skip_delay(automation_id, {'status': 'active'}) +response = client.automations.activity_skip_delay(automation_id, {"status": "active"}) ``` ### List automation triggers @@ -771,9 +761,7 @@ response = client.automations.activity_skip_delay(automation_id, {'status': 'act ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 @@ -786,13 +774,13 @@ response = client.automations.list_triggers(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.create_trigger(automation_id, 'subscriber_joins_group', data={'group_ids': [1234567]}) +response = client.automations.create_trigger( + automation_id, "subscriber_joins_group", data={"group_ids": [1234567]} +) ``` ### Get an automation trigger @@ -801,9 +789,7 @@ response = client.automations.create_trigger(automation_id, 'subscriber_joins_gr ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 trigger_id = 123456 @@ -817,14 +803,14 @@ response = client.automations.get_trigger(automation_id, trigger_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 trigger_id = 123456 -response = client.automations.update_trigger(automation_id, trigger_id, data={'group_ids': [1234567]}) +response = client.automations.update_trigger( + automation_id, trigger_id, data={"group_ids": [1234567]} +) ``` ### Delete an automation trigger @@ -833,9 +819,7 @@ response = client.automations.update_trigger(automation_id, trigger_id, data={'g ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 trigger_id = 123456 @@ -849,9 +833,7 @@ response = client.automations.delete_trigger(automation_id, trigger_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 @@ -864,13 +846,13 @@ response = client.automations.list_steps(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.create_step(automation_id, 'delay', parent_id=123456, data={'unit': 'days', 'value': 1}) +response = client.automations.create_step( + automation_id, "delay", parent_id=123456, data={"unit": "days", "value": 1} +) ``` ### Get an automation step @@ -879,9 +861,7 @@ response = client.automations.create_step(automation_id, 'delay', parent_id=1234 ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 step_id = 123456 @@ -895,14 +875,14 @@ response = client.automations.get_step(automation_id, step_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 step_id = 123456 -response = client.automations.update_step(automation_id, step_id, data={'unit': 'days', 'value': 2}) +response = client.automations.update_step( + automation_id, step_id, data={"unit": "days", "value": 2} +) ``` ### Delete an automation step @@ -911,14 +891,14 @@ response = client.automations.update_step(automation_id, step_id, data={'unit': ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 step_id = 123456 -response = client.automations.delete_step(automation_id, step_id, branch_to_delete='all') +response = client.automations.delete_step( + automation_id, step_id, branch_to_delete="all" +) ``` ### Copy an automation step @@ -927,9 +907,7 @@ response = client.automations.delete_step(automation_id, step_id, branch_to_dele ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 step_id = 123456 @@ -943,14 +921,14 @@ response = client.automations.copy_step(automation_id, step_id, parent_id=123456 ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +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) +response = client.automations.reorder_steps( + automation_id, step_id, new_parent_step_id=123456 +) ``` ### Select the winner of an A/B test step @@ -959,14 +937,12 @@ response = client.automations.reorder_steps(automation_id, step_id, new_parent_s ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +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') +response = client.automations.select_ab_test_winner(automation_id, step_id, "a_path") ``` ### Get the content of an automation email @@ -975,9 +951,7 @@ response = client.automations.select_ab_test_winner(automation_id, step_id, 'a_p ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 email_id = 123456 @@ -991,14 +965,14 @@ response = client.automations.get_email_content(automation_id, email_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 email_id = 123456 -response = client.automations.update_email_content(automation_id, email_id, '

Hello

') +response = client.automations.update_email_content( + automation_id, email_id, "

Hello

" +) ``` ### Get the report stats of an automation email @@ -1007,14 +981,14 @@ response = client.automations.update_email_content(automation_id, email_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 email_id = 123456 -response = client.automations.email_report_stats(automation_id, email_id, start_date='2026-01-01', end_date='2026-01-31') +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 @@ -1023,9 +997,7 @@ response = client.automations.email_report_stats(automation_id, email_id, start_ ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) automation_id = 123456 email_id = 123456 @@ -1039,11 +1011,9 @@ response = client.automations.email_unsubscribe_reasons(automation_id, email_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_templates(limit=10, page=1, filter={'category': 1}) +response = client.automations.list_templates(limit=10, page=1, filter={"category": 1}) ``` ### Create an automation from a template @@ -1052,9 +1022,7 @@ response = client.automations.list_templates(limit=10, page=1, filter={'category ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +client = MailerLite.Client({"api_key": "your-api-key"}) template_id = 123456 @@ -1067,9 +1035,7 @@ response = client.automations.select_template(template_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 step_id = 123456 @@ -1084,9 +1050,7 @@ response = client.automations.step_run_counts(automation_id, step_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 trigger_id = 123456 @@ -1101,9 +1065,7 @@ response = client.automations.trigger_run_counts(automation_id, trigger_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 step_id = 123456 @@ -1118,14 +1080,14 @@ response = client.automations.ab_step_stats(automation_id, step_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 step_id = 123456 -response = client.automations.step_subscribers(automation_id, step_id, 'queued', limit=10, page=1) +response = client.automations.step_subscribers( + automation_id, step_id, "queued", limit=10, page=1 +) ``` ### List the subscribers on an automation trigger @@ -1134,14 +1096,14 @@ response = client.automations.step_subscribers(automation_id, step_id, 'queued', ```python import mailerlite as MailerLite -client = MailerLite.Client({ - 'api_key': 'your-api-key' -}) +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) +response = client.automations.trigger_subscribers( + automation_id, trigger_id, "completed", limit=10, page=1 +) ``` ### Get the monthly email totals of an automation @@ -1150,13 +1112,13 @@ response = client.automations.trigger_subscribers(automation_id, trigger_id, 'co ```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.email_monthly_totals(automation_id, start_date='2026-01-01', end_date='2026-06-30') +response = client.automations.email_monthly_totals( + automation_id, start_date="2026-01-01", end_date="2026-06-30" +) ``` ## Campaigns