Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e1faef1
test: add a fixture that records the prepared requests
tomasmartty Oct 28, 2026
684cac8
fix: send the JSON body for PATCH requests
tomasmartty Oct 28, 2026
111f817
fix: send the JSON body for DELETE requests that have one
tomasmartty Oct 28, 2026
f231aa1
feat: expose the API error code and etag on ApiError
tomasmartty Oct 28, 2026
03f918c
feat: add ApiResponse with the status code and the replay flag
tomasmartty Oct 28, 2026
edb1ed0
feat: add emails.get
tomasmartty Oct 28, 2026
b40d208
feat: add emails.get_html
tomasmartty Oct 28, 2026
4224f6f
feat: add emails.get_block
tomasmartty Oct 28, 2026
b1cad13
feat: add emails.create_block
tomasmartty Oct 28, 2026
3170122
feat: add emails.update_block
tomasmartty Oct 28, 2026
6fe26aa
feat: add emails.delete_block
tomasmartty Oct 28, 2026
88d338f
feat: add emails.move_block
tomasmartty Oct 28, 2026
8888d48
feat: add emails.validate_block
tomasmartty Oct 28, 2026
41b6b90
feat: add emails.update_settings
tomasmartty Oct 28, 2026
2abc5e6
feat: add emails.validate_settings
tomasmartty Oct 28, 2026
b674d55
feat: add emails.create_template
tomasmartty Oct 28, 2026
faba33f
feat: add emails.list_versions
tomasmartty Oct 28, 2026
1b64206
feat: add emails.create_version
tomasmartty Oct 28, 2026
d190db7
feat: add emails.restore_version
tomasmartty Oct 28, 2026
f02167f
feat: add brand_styles.get
tomasmartty Oct 28, 2026
bb0b43a
feat: add files.list
tomasmartty Oct 28, 2026
30b1dfd
feat: create campaigns with the simple editor
tomasmartty Oct 28, 2026
037a707
feat: add idempotency keys to email writes
tomasmartty Oct 28, 2026
ec7a4bb
chore: bump the version to 0.3.0
tomasmartty Oct 28, 2026
f54b214
fix: send no body for a write without a body
tomasmartty Oct 29, 2026
e37ce80
fix: let ApiError survive a pickle round trip
tomasmartty Oct 29, 2026
59f0071
fix: check the block_id of a block placement
tomasmartty Oct 29, 2026
cbc40e5
fix: check the type of block_type
tomasmartty Oct 29, 2026
29ccdb2
fix: reject settings that contain an etag key
tomasmartty Oct 29, 2026
883dc4f
fix: read the Idempotent-Replayed header without case
tomasmartty Oct 29, 2026
53b0df0
refactor: move the request parameter helpers to a shared module
tomasmartty Oct 29, 2026
2cc2626
refactor: build the files.list query params with the shared helpers
tomasmartty Oct 29, 2026
34f20d4
docs: remove a phrasal verb from the restore_version docstring
tomasmartty Oct 29, 2026
51f6ea6
fix: check the placement block_id only for before and after
tomasmartty Oct 29, 2026
ef61eaf
docs: say which settings values an empty value unsets
tomasmartty Oct 29, 2026
b4a90a9
chore: remove redundant docstrings
tomasmartty Oct 29, 2026
87b473d
refactor: leave value checks to the API
tomasmartty Oct 30, 2026
831a4db
feat: Improvements
igorhrcek Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
483 changes: 478 additions & 5 deletions Readme.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.3
0.3.0
19 changes: 19 additions & 0 deletions mailerlite/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,28 @@
from __future__ import absolute_import

from mailerlite.api_client import ApiClient
from mailerlite.exceptions import ( # noqa: F401
ApiError,
EmptyIdError,
InvalidIdError,
)
from mailerlite.response import ApiResponse # noqa: F401
from mailerlite.sdk.automations import Automations
from mailerlite.sdk.batch import Batches
from mailerlite.sdk.brand_styles import BrandStyles

# import all api files into api package
from mailerlite.sdk.campaigns import Campaigns
from mailerlite.sdk.emails import ( # noqa: F401
EDITOR_SIMPLE,
VERSION_CONFLICT,
BlockPosition,
BlockType,
BrandMode,
Emails,
)
from mailerlite.sdk.fields import Fields
from mailerlite.sdk.files import FileSort, Files, FileType # noqa: F401
from mailerlite.sdk.forms import Forms
from mailerlite.sdk.groups import Groups
from mailerlite.sdk.segments import Segments
Expand All @@ -29,6 +45,9 @@ def __init__(self, config={}):
self.fields = Fields(self.api_client)
self.forms = Forms(self.api_client)
self.campaigns = Campaigns(self.api_client)
self.emails = Emails(self.api_client)
self.brand_styles = BrandStyles(self.api_client)
self.files = Files(self.api_client)
self.timezones = Timezones(self.api_client)
self.webhooks = Webhooks(self.api_client)
self.automations = Automations(self.api_client)
Expand Down
24 changes: 18 additions & 6 deletions mailerlite/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@
from functools import partialmethod
from typing import Optional, Tuple

from mailerlite.exceptions import ApiError


class HttpMethods(object):
"""Allowed Http methods"""

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:
Expand All @@ -26,6 +29,7 @@ def _is_method(cls, method: str, target: str) -> bool:
is_get: partialmethod = partialmethod(_is_method, target=GET)
is_post: partialmethod = partialmethod(_is_method, target=POST)
is_put: partialmethod = partialmethod(_is_method, target=PUT)
is_patch: partialmethod = partialmethod(_is_method, target=PATCH)
is_delete: partialmethod = partialmethod(_is_method, target=DELETE)


Expand Down Expand Up @@ -73,24 +77,32 @@ def request(
path: str,
query_params: Optional[dict] = None,
body: Optional[dict] = None,
headers: Optional[dict] = None,
) -> requests.models.Response:
"""Requests Wrapper"""
"""Requests Wrapper

`headers` are added to the default headers for this request only.
"""

method = method.lower()
if method not in HttpMethods.ALLOWED:
raise ValueError("http method must be `POST`, `GET`, `PUT` or `DELETE`.")
raise ValueError(
"http method must be `POST`, `GET`, `PUT`, `PATCH` or `DELETE`."
)

kwargs = {
"method": method,
"url": urljoin(self.HOST, path),
"params": query_params,
"headers": self.headers,
"headers": {**self.headers, **(headers or {})},
"timeout": self.timeout,
}
if HttpMethods.is_get(method):
kwargs.update(allow_redirects=True)
if HttpMethods.is_post(method) or HttpMethods.is_put(method):
# Without a body, send no body and not the JSON `null`
if not HttpMethods.is_get(method) and body is not None:
kwargs.update(data=json.dumps(body))
response = requests.request(**kwargs)
response.raise_for_status()
if 400 <= response.status_code < 600:
raise ApiError(response)
return response
53 changes: 53 additions & 0 deletions mailerlite/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from __future__ import absolute_import

import requests


class ApiError(requests.HTTPError):
"""
The API returned a 4xx or 5xx status code

It is a `requests.HTTPError`, so `e.response` gives the full response.
The fields are `None` when the response body does not have them.
"""

def __init__(self, response):
self.status_code = response.status_code

body = _json_or_none(response)
if not isinstance(body, dict):
body = {}

self.message = body.get("message")
self.code = body.get("code")
self.etag = body.get("etag")
self.errors = body.get("errors")

kind = "Client Error" if response.status_code < 500 else "Server Error"
text = (
f"{response.status_code} {kind}: {response.reason} for url: {response.url}"
)
if self.message:
detail = f"{self.code}: {self.message}" if self.code else self.message
text = f"{text} ({detail})"

super().__init__(text, response=response)

def __reduce__(self):
# pickle calls cls(*self.args) by default, but __init__ needs the response
return self.__class__, (self.response,)


def _json_or_none(response):
try:
return response.json()
except ValueError:
return None


class EmptyIdError(ValueError):
"""An ID for the URL path is empty. The SDK sends no request."""


class InvalidIdError(ValueError):
"""An ID for the URL path does not contain only digits. The SDK sends no request."""
48 changes: 48 additions & 0 deletions mailerlite/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from __future__ import absolute_import

import copy


class ApiResponse(dict):
"""
The decoded JSON body of a response

It is a dict, so you use it like the dicts that the other methods return.
It also has the status code and the headers of the response.
A response without a body, for example `204`, is an empty dict.
"""

def __init__(self, response):
super().__init__(_json_object(response))
self.status_code = response.status_code
self.headers = response.headers

def copy(self):
"""A shallow copy that keeps the status code and the headers"""

return copy.copy(self)

@property
def is_idempotent_replay(self):
"""
True if the API returned the stored response of an earlier request
with the same idempotency key
"""

# A proxy can change the case of the value
value = self.headers.get("Idempotent-Replayed", "")
return value.strip().lower() == "true"


def _json_object(response):
if not response.content:
return {}

body = response.json()
if not isinstance(body, dict):
raise TypeError(
f"The API returned a JSON {type(body).__name__}, not a JSON object, "
f"with status {response.status_code} for url: {response.url}"
)

return body
3 changes: 3 additions & 0 deletions mailerlite/sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from __future__ import absolute_import

# import all api files into api package
from mailerlite.sdk.brand_styles import BrandStyles
from mailerlite.sdk.campaigns import Campaigns
from mailerlite.sdk.emails import Emails
from mailerlite.sdk.fields import Fields
from mailerlite.sdk.files import Files
from mailerlite.sdk.forms import Forms
from mailerlite.sdk.groups import Groups
from mailerlite.sdk.segments import Segments
Expand Down
52 changes: 52 additions & 0 deletions mailerlite/sdk/_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from __future__ import absolute_import

import re

from mailerlite.exceptions import EmptyIdError, InvalidIdError

_ID_PATTERN = re.compile(r"[0-9]+")


def path_id(value, name):
if value is None or value == "":
raise EmptyIdError(f"`{name}` must not be empty.")

if isinstance(value, bool) or not isinstance(value, (str, int)):
raise TypeError(
f"`{name}` type is not valid. Expected `str` or `int`, got {type(value)}."
)

value = str(value)
# fullmatch, because `$` also matches before a final new line,
# and str.isdigit() also accepts digits that are not ASCII.
if not _ID_PATTERN.fullmatch(value):
raise InvalidIdError(f"`{name}` must contain only digits, got {value!r}.")

return value


def require_dict(value, name):
if not isinstance(value, dict):
raise TypeError(
f"`{name}` type is not valid. Expected `dict`, got {type(value)}."
)


def without_none(params):
return {key: value for key, value in params.items() if value is not None}


def 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()}


def idempotency_headers(key):
if key is None:
return None

return {"Idempotency-Key": key}
26 changes: 26 additions & 0 deletions mailerlite/sdk/brand_styles.py
Original file line number Diff line number Diff line change
@@ -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))
8 changes: 8 additions & 0 deletions mailerlite/sdk/campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading