Skip to content

Add the simple editor email endpoints to the SDK - #107

Merged
igorhrcek merged 38 commits into
mainfrom
feature/cmac-1015-simple-editor-email-endpoints
Nov 3, 2026
Merged

igorhrcek merged 38 commits into
mainfrom
feature/cmac-1015-simple-editor-email-endpoints

Conversation

@tomasmartty

@tomasmartty tomasmartty commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Why?

The API has new endpoints to build emails with the simple editor, and the Python SDK did not support them. This PR adds these endpoints, the brand styles and the file list, so that SDK users can create and edit simple editor emails without raw HTTP calls.

Changes

  1. Emails (mailerlite/sdk/emails.py): a new client.emails resource with these methods:
    • Reads: get, get_html, get_block, list_versions.
    • Block writes: create_block, update_block, delete_block, move_block, validate_block.
    • Settings: update_settings, validate_settings.
    • Other writes: create_template, create_version, restore_version.
    • The constants BlockType, BlockPosition, BrandMode, VERSION_CONFLICT and EDITOR_SIMPLE.
  2. Etags and idempotency keys: each write needs the etag from the last response. A write with an old etag raises ApiError with the code version_conflict and the current etag. Each write accepts an optional idempotency_key, so you can retry it safely after a network error. The SDK sends the etag and the key as the caller gives them, and the API checks them.
  3. Campaigns (mailerlite/sdk/campaigns.py): create() documents editor: "simple" and template_id for the emails of a campaign.
  4. Brand styles and files: the new methods client.brand_styles.get() and client.files.list().
  5. Errors (mailerlite/exceptions.py): ApiError is a subclass of requests.HTTPError. It has status_code, message, code, etag and errors. Before the request, the SDK checks only the IDs in the URL path and the settings of update_settings. It sends all other values as the caller gives them, and the API checks them. The new errors are EmptyIdError and InvalidIdError.
  6. Responses (mailerlite/response.py): ApiResponse is a dict with the decoded body, status_code, headers and is_idempotent_replay.
  7. HTTP client (mailerlite/api_client.py):
    • It supports PATCH.
    • It sends a JSON body for DELETE when there is a body.
    • It accepts extra headers for one request.
    • A POST, PUT or PATCH request without a body now sends no body, not the JSON null.
  8. Shared helpers (mailerlite/sdk/_params.py): a new private module with the ID check, the dict check, the filter parameters and the idempotency key header, so that all resources can use them.
  9. Tests: a mock_api fixture records the prepared requests, so the new tests send no request to the network. There are new tests for emails, brand styles, files, the API client, ApiResponse and _params, with JSON fixtures in tests/fixtures.
  10. Docs and version: the Readme has examples for each new method. The version is now 0.3.0.

Code review fixes

A code review found 9 issues. Each fix is a separate commit with a test that failed before the fix:

  1. f54b214: a write without a body sent the JSON null. This affected emails.create_version, campaigns.cancel and campaigns.activity.
  2. e37ce80: an ApiError failed to unpickle, for example in a multiprocessing or Celery worker.
  3. 59f0071: placement["block_id"] now gets the same check as the IDs in the URL path, and the SDK sends it as a string.
  4. cbc40e5: create_block and validate_block now raise TypeError when block_type is not a str.
  5. 29ccdb2: update_settings and validate_settings now raise ValueError when settings contains etag. Before this fix, the etag argument silently replaced that key.
  6. 883dc4f: the Idempotent-Replayed header check now ignores case.
  7. 53b0df0: the helpers moved from emails.py to mailerlite/sdk/_params.py.
  8. 2cc2626: files.list now uses the shared helpers and the new filter_params helper.
  9. 34f20d4: a docstring correction.

A later commit, 87b473d, removes the checks of fixes 3 and 4 and the validate_settings part of fix 5. The SDK now sends these values as the caller gives them, and the API checks them. update_settings still raises ValueError when settings contains etag, because the SDK adds the etag to that dict.

Risks

  • Older methods without a body: campaigns.cancel and campaigns.activity now send no body. Before, they sent the JSON null. A recorded response shows that the API accepted that body, so the risk is low. The end-to-end test did not call these two methods.
  • Errors: the SDK now raises ApiError and not a plain requests.HTTPError. ApiError is a subclass of requests.HTTPError, so code that catches HTTPError continues to work.
  • New checks: the checks of the IDs in the URL path and of the settings of update_settings raise errors before the request. Only the new email methods and files.list have them, so existing calls do not change.
  • Older list() methods: they still have their own copy of the filter code. A follow-up PR can change them to use _params.filter_params.

Performance impact

No performance impact. Each method sends one request. The new checks are regular expressions on short strings.

Security impact

The SDK sends no new data and has no new authentication. The ID checks make security better: an ID such as 123/html or 123%2Fhtml raises InvalidIdError, so it cannot change the URL path. The SDK sends the idempotency key as the caller gives it. If a key can break a header, for example a key with a new line, requests raises requests.exceptions.InvalidHeader and sends no request.

How to QA

  1. Run the unit tests: .venv/bin/python -m pytest -q. The expected result is 300 passed, 1 skipped.
  2. Optionally, run the SDK against a development instance of the API. Set client.api_client.HOST to the URL of that instance, then create a campaign with "editor": "simple" and call the email methods on its email.

I did step 2 against a development instance of the API, and all 20 checks passed:

  • Reads: brand_styles.get, files.list with a filter, a sort and a limit, emails.get with a str and an int ID, and emails.get_html.
  • Campaign: campaigns.create with the simple editor.
  • Blocks: validate_block (valid and invalid), create_block, get_block, update_block, move_block and delete_block.
  • create_block with an int placement["block_id"]. The API put the block after the correct block.
  • Idempotency: a retry with the same idempotency key returned the same block, and is_idempotent_replay was True.
  • Version conflict: an old etag raised ApiError with 409 version_conflict and the current etag. The error survived a pickle round trip.
  • Settings: validate_settings and update_settings. An etag key in settings raised ValueError before the request.
  • Versions: create_version returned 201, and then 200 when nothing had changed, with no body sent. list_versions and restore_version also passed.
  • Checks before the request: block_type values None and 5 raised TypeError. A later commit removed this check.
  • Cleanup: campaigns.delete deleted the test campaign. A later get returned 410.

This run was before the commit that leaves value checks to the API (87b473d). That commit has unit tests only.

The end-to-end test did not call create_template, because the API cannot delete templates. create_template has unit tests only.

The VCR cassette tests of the older resources do not need QA. This PR does not change them.

How to release

Do not release yet. Release after the public API docs for these endpoints are live.

  1. Merge the PR.
  2. When the docs are live, push the tag v0.3.0 on main. The Publish to PyPI workflow runs on tags that match v[0-9]+.[0-9]+.[0-9]+*.

There are no migrations, configuration changes or feature flags.

Rollback strategy

  • Migrations: none.
  • Data changes: none. This PR changes only the SDK.
  • External dependencies: no new dependencies.
  • Configuration: none.
  • Rollback: revert the PR. If 0.3.0 is on PyPI, yank 0.3.0, and tell users to pin 0.2.0.

Screenshots, recordings

N/A

I used AI to generate parts of this PR

Yes

🤖 Generated with Claude Code

tomasmartty and others added 30 commits October 28, 2026 17:49
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 <noreply@anthropic.com>
ApiClient.request() rejected PATCH. Allow it, and send the body as JSON
like POST and PUT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
tomasmartty and others added 3 commits October 29, 2026 11:39
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tomasmartty tomasmartty self-assigned this Oct 29, 2026
@tomasmartty tomasmartty changed the title CMAC-1015: Add the simple editor email endpoints to the SDK Add the simple editor email endpoints to the SDK Oct 29, 2026
@tomasmartty
tomasmartty marked this pull request as ready for review October 29, 2026 10:58
@tomasmartty
tomasmartty requested a review from igorhrcek October 29, 2026 10:59
@mailerlite-kritika

mailerlite-kritika Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Re-runViewConfidence Score: 4/5

[Medium risk] campaigns.cancel and campaigns.activity now send no body, and nothing the review read shows that the API accepts a body-less POST for them; the description's evidence is a recording of the old JSON null body, and the end-to-end run did not call either method. The risk stays medium because the change is a routine request-building change that a revert undoes, and the new ApiError is still an HTTPError subclass.

Summary

Adds simple editor email, brand style and file endpoints to the SDK

The change adds client.emails, client.brand_styles and client.files, which return ApiResponse (a dict with status code and headers). It also adds ApiError (a requests.HTTPError subclass) and PATCH and DELETE-with-body support in ApiClient. A body-less POST, PUT or PATCH now sends no body instead of JSON null, which also changes campaigns.cancel and campaigns.activity. The newest commits make ApiResponse return {} for an empty body and raise TypeError for a non-object body. They also add a copy() that keeps the status and headers, drop None filters in filter_params, and check settings in validate_settings the same way as in update_settings.

Flow
%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Caller uses client.emails, brand_styles or files"] --> B["Check path IDs, settings dict and filter in _params"]
    B -->|invalid| E["Raise error before any request"]
    B -->|valid| C["ApiClient sends request with etag, placement and idempotency key as given"]
    C --> D{"Response status"}
    D -->|success| F["Return ApiResponse: empty body gives empty dict, non-object body raises TypeError"]
    D -->|error| G["Raise ApiError with code, etag and errors"]
Loading

What's good

  • Path IDs are checked with fullmatch on ASCII digits, so IDs like 123/html, 123%2Fhtml or a trailing newline cannot change the URL.
  • ApiError defines __reduce__, so it survives pickling, and a test covers the round trip.
  • The mock_api fixture records prepared requests, so the new tests cannot reach the network.
Review context · 0/5 commands
  • Commands run: none

Reviews (6) · Last reviewed commit: "feat: Improvements" · claude-sonnet-5-5

tomasmartty and others added 3 commits October 29, 2026 16:21
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
tomasmartty and others added 2 commits October 30, 2026 11:55
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 <noreply@anthropic.com>
@igorhrcek
igorhrcek merged commit 7c9ddd5 into main Nov 3, 2026
4 of 5 checks passed
@igorhrcek
igorhrcek deleted the feature/cmac-1015-simple-editor-email-endpoints branch November 3, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants