Skip to content

Add the simple-editor email, brand style and file endpoints - #23

Open
tomasmartty wants to merge 35 commits into
mainfrom
feature/cmac-1017-add-the-simple-editor-email-endpoints-to-the-java-sdk
Open

tomasmartty wants to merge 35 commits into
mainfrom
feature/cmac-1017-add-the-simple-editor-email-endpoints-to-the-java-sdk

Conversation

@tomasmartty

@tomasmartty tomasmartty commented Oct 29, 2026 •

Copy link
Copy Markdown

Why?

The MailerLite API adds endpoints to read and edit emails that are built with the simple editor. It also adds two related endpoints: brand styles and files. This PR adds all 16 endpoints to the Java SDK, so that Java users can build and edit these emails without raw HTTP requests.

Merge and release this only after the endpoints are in the public API documentation.

Changes

  1. New Emails service (com.mailerlite.sdk.emails), from ml.emails():

    Method Endpoint
    get GET /emails/{email}
    getHtml GET /emails/{email}/html
    getBlock GET /emails/{email}/blocks/{block}
    createBlock POST /emails/{email}/blocks
    updateBlock PATCH /emails/{email}/blocks/{block}
    deleteBlock DELETE /emails/{email}/blocks/{block}
    moveBlock POST /emails/{email}/blocks/{block}/move
    validateBlock POST /emails/blocks/validate
    updateSettings PATCH /emails/{email}/settings
    validateSettings POST /emails/{email}/settings/validate
    createTemplate POST /emails/{email}/templates
    versions(...).get() GET /emails/{email}/versions
    createVersion POST /emails/{email}/versions
    restoreVersion POST /emails/{email}/versions/{version}/restore
    • The package already had the campaign email models (Email, EmailBase), so the email model is EmailDocument.
    • Required IDs and the etag are method arguments. Request bodies are plain classes with public fields (EmailBlockInput, EmailBlockPosition, EmailSettings).
    • editor_content is a Map<String, Object> with its own Gson adapter. Whole numbers stay whole (linkId: 7 is not sent back as 7.0), and null values in the map are kept.
    • In the settings classes, a null field is not sent. Only a brand colour accepts an empty string, and an empty editor document unsets a footer text.
  2. New BrandStyles service (com.mailerlite.sdk.brandstyles): ml.brandStyles().get() calls GET /brand-styles. brand is null when the account has no brand styles.

  3. New MediaFiles service (com.mailerlite.sdk.files): ml.files().retriever() calls GET /files, with nameFilter, typeFilter, sort, page and limit. The names do not use File or Files, so that they do not clash with java.io.File and java.nio.file.Files.

  4. HTTP layer (MailerLiteApi)

    • The new sendRequest method supports PATCH, a DELETE body (only deleteBlock sends one) and a POST with no body (createVersion). The old request methods do not change.
    • MailerLiteException has new fields: errorCode and etag, a new method, isVersionConflict(), and a new constant, VERSION_CONFLICT. code stays the HTTP status. When the API sends a code, getMessage() starts with it. EmailErrorCodes.VERSION_CONFLICT refers to the constant, so the exceptions package does not import the emails package.
    • The public field MailerLiteException.message now has the message of the API error, without the code. Before, it was always "" (also on main). If the SDK cannot read the error body, or for a network error, it has the same text as getMessage().
    • A network error is now a MailerLiteException with the network error as the cause. Before, all request methods threw a ClassCastException.
    • ApiRequest now encodes the query values and skips null values. This also applies to the old retrievers (see "Upgrading to 0.1.0").
  5. Checks before the request: the SDK checks only the IDs in the URL path and the characters of the idempotency key. If a check fails, the SDK throws the error and sends no request.

    • Email, block and version IDs must contain only ASCII digits. Otherwise the SDK throws EmptyIdException or InvalidIdException. Both are unchecked subclasses of IllegalArgumentException. Reason: the API cannot see this problem. It decodes an escaped / in a path, so an escaped ID could reach a different endpoint.
    • The SDK sends all other values as the caller gives them, and the API checks them: the etag, the placement and its block_id, the block type, editor_content and the template name. The SDK does not repeat the API rules, so a rule change in the API needs no SDK release.
    • A null block in createBlock and null settings in updateSettings throw NullPointerException. In validateBlock and validateSettings, a null argument sends an empty body, and the API returns the validation errors.
  6. Idempotency keys: the 8 email writes have an overload with a last RequestOptions argument: RequestOptions.idempotencyKey(key). The SDK sends it as the Idempotency-Key header. If a request with the same key already succeeded, the API returns the stored response again and does not do the write two times. This makes it safe to retry a write after a network error. The SDK sends the key as given, and the API checks it (an invalid key gets 400). Only one exception: a key with a character outside printable ASCII (0x20 to 0x7E) throws InvalidIdempotencyKeyException (an IllegalArgumentException), and the SDK sends no request. Reason: the Java HTTP client sends each character that is not ASCII as ?, so k-é-k reached a development instance of the API as the valid key k-?-k, and the write ran. A null or empty key sends no header. MailerLiteResponse.isIdempotentReplay() is true when the API returned a stored response.

  7. Campaigns: EmailBase has three new fields: replyTo, editor and templateId. Set editor to EmailEditors.SIMPLE to create an email with the simple editor, and then edit it with ml.emails(). Only create accepts editor and templateId. An update with these fields gets a 422.

  8. Constants: block types, block positions, brand modes, the simple editor, the version_conflict error code, file types and file sorts. They are String constants, so a new value from the API does not break the decode.

  9. README: a new "Upgrading to 0.1.0" section, new "Emails", "Brand styles" and "Files" sections with one example for each method, a "Retry a write with an idempotency key" example, and "Create a campaign with the simple editor".

  10. Version: 0.1.0 in pom.xml and in the README install snippets.

Compatibility

All changes add to the public API. A javap comparison of main and this branch shows no public or protected member that was removed or changed. Some behavior changes, and the README section "Upgrading to 0.1.0" describes each one:

  • The SDK now encodes query values. A caller who encoded a value before now gets it encoded two times.
  • A + in a query value is now sent as a literal +.
  • A subclass of EmailBase with its own reply_to, editor or template_id field now makes Gson throw an error.
  • A network error is now a MailerLiteException, not a ClassCastException.
  • The field MailerLiteException.message now has a value. Before, it was always "". getMessage() does not change.

Tests

  • The new tests use a fake HttpClient that keeps each request and its raw body, and returns queued responses. They use no recorded tapes and no account, and they cannot reach the API.
  • Each new method has a test. The test checks the HTTP method, the full URL with the query string, the headers, the exact raw body and the decoded fields.
  • Other tests cover these cases:
    • 409 with version_conflict, 422 with errors, and 503 with an HTML body;
    • createVersion with 201 and with 200, and with an empty raw body;
    • brand: null, a file name filter with spaces, and the encoding of an encoded value;
    • each invalid path ID, and that no request is sent;
    • an empty etag, a placement with or without block_id for each position, an empty block type and an empty template name: the SDK sends each one as given, and the test checks the exact body and the API error. A null etag sends no etag key;
    • the idempotency key header, the replay flag (true, True, no header), and keys with spaces, ~ or more than 255 characters. The SDK gives each key to the Java HTTP client as given. The client removes the spaces at the start and the end;
    • keys with é, a CJK character, an emoji, a new line, a tab, DEL or another control character: the SDK throws InvalidIdempotencyKeyException and sends no request;
    • the message field of MailerLiteException for each request method, for an error with a code, for an error body without a message, for an HTML body and for a network error;
    • that the exceptions package does not import the emails package;
    • a network error without a message;
    • the campaign create body: editor and template_id are present, and content is absent.
  • mvn test passes on JDK 11 and on JDK 21 (177 tests, 0 failures, 0 errors). All old tests still match their recorded tapes. mvn javadoc:javadoc on JDK 11 gives no warnings.
  • Each README example runs in a test against the fake HttpClient.
  • All 16 endpoints were also run against a development instance of the API, through the SDK. These runs included a full flow that passes each new etag to the next write, the error cases 409 (old etag), 404 (unknown block) and 422 (invalid file type), and an idempotency check: a retry with the same key returned the stored 201 with the same block ID, the same key with a different body returned 422, and a retry without a key returned 409. These runs were before the change that leaves the value checks to the API.

How to test

  1. Run mvn test. pom.xml sets testFailureIgnore, so read the Tests run summary, not the exit code.
  2. Get an API token for a test account. Run the README example "Create a campaign with the simple editor", and get the email ID from the response.
  3. Run the README examples in this order: "Get an email", "Insert a block", "Move a block", "Delete a block". Pass the etag of each response to the next write.
  4. Make sure that a write with an old etag throws a MailerLiteException where isVersionConflict() is true.
  5. Run the README example "Retry a write with an idempotency key" two times with the same key and the same etag. Make sure that the second response has the same block ID and that isIdempotentReplay() is true.

How to release

  1. Wait until the endpoints are in the public API documentation.
  2. Merge, then create the GitHub release v0.1.0. The publish workflow checks that the tag is the same as the version in pom.xml, and then publishes to Maven Central. This is a minor version because of the behavior changes in "Upgrading to 0.1.0".

Rollback

No data or configuration changes. To roll back, revert the merge commit and release a new patch version.

🤖 Generated with Claude Code

tomasmartty and others added 29 commits October 29, 2026 16:08
Add a fake HttpClient that keeps each request with its raw body and
returns queued responses. New tests use it, so they send no request to
the network and need no recorded tapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The request methods cast an IOException or an InterruptedException to
MailerLiteException. The cast failed, so callers got a
ClassCastException. Now the methods throw a MailerLiteException with
the network error as the cause. For an InterruptedException, the
methods also set the interrupt flag of the thread again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add MailerLiteApi.sendRequest. It sends any HTTP method, for example
PATCH, with an optional body and extra headers. It sends a DELETE body
only when the caller gives one. When the body is null, a POST or PATCH
has an empty body, not the JSON null. Each request has the headers
Content-Type and Accept with the value application/json.

The old request methods do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the fields errorCode and etag, and the method isVersionConflict(),
to MailerLiteException. The field code stays the HTTP status. When the
API sends a code, the message starts with the code, for example
"version_conflict: ...". A body that is not JSON keeps the old message.

Add EmailErrorCodes.VERSION_CONFLICT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApiRequest sent query values as they were. A space made the URI
invalid, and a null value caused a NullPointerException. Now the SDK
encodes each value as UTF-8, so a space is sent as "+". A null value is
not sent, and it removes a value that was set before. An empty value is
sent as "name=". The keys do not change, so "filter[name]" keeps its
brackets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add RequestChecks with checks for IDs, etags and idempotency keys. An
ID must contain only the ASCII digits 0 to 9. The checks throw
EmptyIdException, InvalidIdException, EmptyEtagException or
InvalidIdempotencyKeyException. All four extend
IllegalArgumentException.

Add EditorContentAdapter for editor_content maps. It reads whole
numbers as Long, so 7 is not sent back as 7.0. It also keeps the null
values in the map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the Emails service for emails built with the simple editor, and the
accessor MailerLite.emails(). Emails.get returns the email with its
design settings, its blocks and its etag.

Add the models of the email, the blocks and the design settings. The
editor_content maps use EditorContentAdapter.

Add a test that compiles the new README examples and runs them against
the recording HTTP client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.getHtml returns the HTML preview of an email and its etag. The
HTML is not ready to send. The API can return a temporary 503 error
with a body that is not JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.getBlock returns one block of an email with its placement, and
the etag of the email. The method checks the email ID and the block ID
before it sends the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.createBlock inserts a block into an email and returns the new
block with its placement and the new etag. Add EmailBlockInput,
EmailBlockPosition, EmailBlockTypes and EmailBlockPositions.

The API uses placement.block_id only for the positions "before" and
"after". So the SDK checks the block ID only for these positions. For
"start" and "end", the SDK does not check the block ID and does not
send it. The SDK does not change the placement of the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.updateBlock sends PATCH with the new editor content and the
etag, and returns the block and the new etag. The new editor content
replaces the whole content of the block. A block that the SDK read is
sent back with the same whole numbers and the same null values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.deleteBlock sends DELETE with the etag in the body, and returns
the new etag of the email. The API also deletes the blocks in the
block. The old delete methods of the SDK still send no body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.moveBlock sends the new placement and the etag, and returns the
block with its new placement and the new etag. The placement checks are
the same as for an insert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.validateBlock checks a block with the rules of an insert and
saves nothing. It sends no etag. An invalid block is not an error: the
response has valid set to false and the errors for each field. Add
EmailValidation, EmailSchema and EmailRejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.updateSettings sends PATCH with only the settings fields that
the caller set, and the etag at the top level of the body. A null field
is not sent. An empty string unsets a value, and an empty list of
social links removes all the links. The response has the settings, the
new etag and the unsafe values that the API removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.validateSettings checks a change to the design settings with the
rules of an update and saves nothing. It sends no etag. The response
has valid, the errors for each field and the unsafe values that an
update would remove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.createTemplate copies an email to a new template with the given
name, and returns the template. An empty or null name throws
IllegalArgumentException, and the SDK sends no request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.versions returns a retriever with page() and limit(). Its get()
returns the versions of an email, newest first. Without options, the
request has no query string.

The pagination meta can have null for from and to, for example for an
empty page. The SDK reads null as 0. The Javadoc of ResponseMeta now
says this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.createVersion saves the email as a new version. The request has
an empty body, not the JSON null. SingleEmailVersion.isCreated() is
true for status 201 (a new version) and false for status 200 (nothing
changed, and the response has the last version).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Emails.restoreVersion puts an email back to a version. It sends the
etag in the body, and returns the full email with the new etag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the BrandStyles service and the accessor MailerLite.brandStyles().
BrandStyles.get returns the brand styles of the account, the default
palette and the presets. The brand is null when the account has none.
The JSON key "default" is a Java keyword, so the field is defaultStyle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the MediaFiles service and the accessor MailerLite.files(). Its
retriever lists the files of the media library, with a name filter, a
type filter, any other filter, a sort, a page and a limit. Add
MediaFileTypes and MediaFileSorts. The names File and Files are not
used, because they clash with java.io.File and java.nio.file.Files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the fields editor, templateId and replyTo to EmailBase, and the
constant EmailEditors.SIMPLE. The SDK does not send a field that is
null, so a create without these fields sends the same body as before.
The ID of the template is a String, like the other IDs of the SDK.

Only the campaign create accepts editor and templateId. The Javadoc of
the fields and of CampaignBuilder.update() says that an update with one
of them gets a 422 error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add RequestOptions.idempotencyKey(key). It checks the key at once. Add
overloads with RequestOptions as the last argument to createBlock,
updateBlock, deleteBlock, moveBlock, updateSettings, createTemplate,
createVersion and restoreVersion. With a key, the request has the
Idempotency-Key header. Without options or with a null key, the
request has no header. The validate methods never send it.

Add MailerLiteResponse.isIdempotentReplay(). It finds the header
Idempotent-Replayed without case and compares its value with "true"
without case.

Add a README section that shows a retry after a network error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A network error such as a ConnectException or an InterruptedException
can have a null message. Then the MailerLiteException also had a null
message. Now the message is the class name and the message of the
network error, for example "java.net.ConnectException". The cause and
the interrupt flag do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK encodes each query value. A value that the caller encoded
before is encoded again, so "summer%20sale" is sent as
"summer%2520sale". A plus sign is sent as "%2B", so the API reads a
literal plus sign. The tests keep this behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the README section "Upgrading to 0.1.0". It says that the SDK now
encodes query values, so callers must not encode them, and that a plus
sign is sent as a literal plus sign. It says that EmailBase now has
reply_to, editor and template_id, so subclasses must remove their own
fields with these JSON names. It also says that a network error is now
a MailerLiteException with the network error as the cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs said that an empty string unsets any settings value. The API
accepts an empty string only for a brand color. To unset a footer
text, send an empty editor document. For the other values, for example
other colors, sizes, button.borderRadius and brand.style, the API
rejects an empty string with a 422 error. The Javadoc of the settings
classes and of Emails.updateSettings, and the README, now say this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tomasmartty tomasmartty self-assigned this Oct 29, 2026
@tomasmartty
tomasmartty marked this pull request as ready for review October 29, 2026 16:35
@mailerlite-kritika

mailerlite-kritika Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Re-runViewConfidence Score: 4/5

[High risk] The description says to merge and release only after the 16 endpoints appear in the public API documentation, and neither the description nor the review's account shows that this was checked; the review read no sources outside the repository. The risk stays high, as in the last review: the change is additive, but it changes existing behavior (query values are now encoded, + is sent literally, network errors are now MailerLiteException, and MailerLiteException.message is now filled), and a …

Findings

Earlier findings (1 resolved, 1 dismissed)

Summary

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

The change adds ml.emails(), ml.brandStyles() and ml.files() on a new MailerLiteApi.sendRequest method that supports PATCH, a DELETE body and a bodiless POST. It also adds etag and error-code fields on MailerLiteException, idempotency-key support, and encoding of query values. The commits since the last review move the version_conflict constant into MailerLiteException, so the exceptions package no longer imports emails. They also reject idempotency keys that are not printable ASCII before any request is sent. MailerLiteException.message is now filled from the API error message, and the README upgrade notes describe it.

What's good

  • Tests use a recording fake HttpClient that checks the method, URL, headers and raw body for each endpoint.
  • The README examples are compiled and run in a test, so the documentation cannot drift from the API.
  • The upgrade section lists each behavior change, such as query encoding and network errors, and a test asserts that the exceptions package stays free of emails imports.
Review context · 0/5 commands
  • Commands run: none

Reviews (5) · Last reviewed commit: "fix: set the message field of MailerLite..." · claude-sonnet-5-5

tomasmartty and others added 2 commits October 29, 2026 17:39
Some tests compared the full URL of a request with more than one query
parameter. The sequence of the parameters comes from the HashMap in
ApiRequest, and the API gives no meaning to it. Add UrlAssert. It
compares the path exactly, and the raw query pairs as a list in any
sequence, with duplicates. The encoding is still checked exactly.
ApiRequest does not change, so the recorded tapes still match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove the doc comments that only repeat the code in private members, test helpers and tests.
Keep the public Javadoc, and shorten the comments that explain a reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

import java.util.HashMap;

import com.mailerlite.sdk.emails.EmailErrorCodes;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Core exception package now depends on the emails package · maintainability

MailerLiteException in exceptions imports com.mailerlite.sdk.emails.EmailErrorCodes only to implement isVersionConflict(). emails already depends on exceptions (Emails imports MailerLiteException), so this creates a package cycle. The cycle ties a generic exception to one service's constants.

Suggested fix

Keep the cycle out by comparing against a constant that lives in exceptions (for example a public static final String VERSION_CONFLICT on MailerLiteException). Make EmailErrorCodes.VERSION_CONFLICT alias it, or the reverse, and drop the emails import here.

Prompt for a coding agent
In MailerLiteException.java remove the import of com.mailerlite.sdk.emails.EmailErrorCodes. Add a constant `public static final String VERSION_CONFLICT_CODE = "version_conflict";` to MailerLiteException and make isVersionConflict() compare against it. Change EmailErrorCodes.VERSION_CONFLICT to reference MailerLiteException.VERSION_CONFLICT_CODE so the value stays defined in one place. Update the Javadoc {@link} on isVersionConflict accordingly.

tomasmartty and others added 4 commits October 30, 2026 11:55
The SDK now checks only the IDs in the URL path before a request. It sends the
etag, the placement, the idempotency key and the block and template values as
the caller gives them, and the API returns the errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Java HTTP client sends each character that is not ASCII as "?", so the
API got a different key and ran the write. The SDK now rejects a key with a
character outside 0x20 to 0x7E and sends no request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MailerLiteException now has the VERSION_CONFLICT constant, and
EmailErrorCodes.VERSION_CONFLICT refers to it. The exceptions package no
longer imports the emails package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The public message field was always empty. It now has the message of the API
error without the error code, and for other errors the text of getMessage().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant