Repository navigation
Add the simple editor email endpoints to the SDK - #107
Conversation
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>
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>
[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. SummaryAdds simple editor email, brand style and file endpoints to the SDK The change adds 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"]
What's good
Review context · 0/5 commands
Reviews (6) · Last reviewed commit: "feat: Improvements" · claude-sonnet-5-5 |
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>
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>
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
mailerlite/sdk/emails.py): a newclient.emailsresource with these methods:get,get_html,get_block,list_versions.create_block,update_block,delete_block,move_block,validate_block.update_settings,validate_settings.create_template,create_version,restore_version.BlockType,BlockPosition,BrandMode,VERSION_CONFLICTandEDITOR_SIMPLE.ApiErrorwith the codeversion_conflictand the current etag. Each write accepts an optionalidempotency_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.mailerlite/sdk/campaigns.py):create()documentseditor: "simple"andtemplate_idfor the emails of a campaign.client.brand_styles.get()andclient.files.list().mailerlite/exceptions.py):ApiErroris a subclass ofrequests.HTTPError. It hasstatus_code,message,code,etaganderrors. Before the request, the SDK checks only the IDs in the URL path and thesettingsofupdate_settings. It sends all other values as the caller gives them, and the API checks them. The new errors areEmptyIdErrorandInvalidIdError.mailerlite/response.py):ApiResponseis a dict with the decoded body,status_code,headersandis_idempotent_replay.mailerlite/api_client.py):null.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.mock_apifixture 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,ApiResponseand_params, with JSON fixtures intests/fixtures.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:
f54b214: a write without a body sent the JSONnull. This affectedemails.create_version,campaigns.cancelandcampaigns.activity.e37ce80: anApiErrorfailed to unpickle, for example in a multiprocessing or Celery worker.59f0071:placement["block_id"]now gets the same check as the IDs in the URL path, and the SDK sends it as a string.cbc40e5:create_blockandvalidate_blocknow raiseTypeErrorwhenblock_typeis not astr.29ccdb2:update_settingsandvalidate_settingsnow raiseValueErrorwhensettingscontainsetag. Before this fix, the etag argument silently replaced that key.883dc4f: theIdempotent-Replayedheader check now ignores case.53b0df0: the helpers moved fromemails.pytomailerlite/sdk/_params.py.2cc2626:files.listnow uses the shared helpers and the newfilter_paramshelper.34f20d4: a docstring correction.A later commit,
87b473d, removes the checks of fixes 3 and 4 and thevalidate_settingspart of fix 5. The SDK now sends these values as the caller gives them, and the API checks them.update_settingsstill raisesValueErrorwhensettingscontainsetag, because the SDK adds the etag to that dict.Risks
campaigns.cancelandcampaigns.activitynow send no body. Before, they sent the JSONnull. 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.ApiErrorand not a plainrequests.HTTPError.ApiErroris a subclass ofrequests.HTTPError, so code that catchesHTTPErrorcontinues to work.settingsofupdate_settingsraise errors before the request. Only the new email methods andfiles.listhave them, so existing calls do not change.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/htmlor123%2FhtmlraisesInvalidIdError, 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,requestsraisesrequests.exceptions.InvalidHeaderand sends no request.How to QA
.venv/bin/python -m pytest -q. The expected result is300 passed, 1 skipped.client.api_client.HOSTto 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:
brand_styles.get,files.listwith a filter, a sort and a limit,emails.getwith a str and an int ID, andemails.get_html.campaigns.createwith the simple editor.validate_block(valid and invalid),create_block,get_block,update_block,move_blockanddelete_block.create_blockwith an intplacement["block_id"]. The API put the block after the correct block.is_idempotent_replaywasTrue.ApiErrorwith409 version_conflictand the current etag. The error survived a pickle round trip.validate_settingsandupdate_settings. Anetagkey insettingsraisedValueErrorbefore the request.create_versionreturned201, and then200when nothing had changed, with no body sent.list_versionsandrestore_versionalso passed.block_typevaluesNoneand5raisedTypeError. A later commit removed this check.campaigns.deletedeleted the test campaign. A latergetreturned410.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_templatehas 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.
v0.3.0onmain. ThePublish to PyPIworkflow runs on tags that matchv[0-9]+.[0-9]+.[0-9]+*.There are no migrations, configuration changes or feature flags.
Rollback strategy
0.3.0is on PyPI, yank0.3.0, and tell users to pin0.2.0.Screenshots, recordings
N/A
I used AI to generate parts of this PR
Yes
🤖 Generated with Claude Code