T putRequest(String endpoint, String reque
}
+ /**
+ * Sends a request with any HTTP method, for example PATCH, to the given endpoint of the MailerLite API.
+ *
+ * The request has the headers {@code Content-Type: application/json} and {@code Accept: application/json},
+ * and then the extra headers.
+ *
+ * If the body is null, GET and DELETE requests have no body, and the other requests have an empty body.
+ * The SDK never sends the JSON {@code null}.
+ *
+ * @param the generic type
+ * @param method the HTTP method, for example "PATCH"
+ * @param endpoint the MailerLite API endpoint, with the query string
+ * @param body the JSON body, or null for no body
+ * @param headers the extra headers, or null
+ * @param responseClass the class of the response object
+ * @return T
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public T sendRequest(String method, String endpoint, String body, Map headers, Class responseClass) throws MailerLiteException
+ {
+ HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(this.endpointBase.concat(endpoint)))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .header("Authorization", "Bearer ".concat(this.apiToken));
+
+ if (headers != null) {
+
+ for (Map.Entry header : headers.entrySet()) {
+
+ builder.header(header.getKey(), header.getValue());
+ }
+ }
+
+ if (body != null) {
+
+ builder.method(method, BodyPublishers.ofString(body));
+ } else if ("GET".equals(method)) {
+
+ builder.GET();
+ } else if ("DELETE".equals(method)) {
+
+ builder.DELETE();
+ } else {
+
+ builder.method(method, BodyPublishers.noBody());
+ }
+
+ HttpResponse responseObject = null;
+
+ try {
+
+ responseObject = this.client.send(builder.build(), BodyHandlers.ofString());
+ } catch (IOException | InterruptedException e) {
+
+ throw this.networkError(e);
+ }
+
+ return this.handleApiResponse(responseObject, responseClass, null);
+ }
+
+
+ /** Uses e.toString() as the message, because e.getMessage() can be null. */
+ private MailerLiteException networkError(Exception e)
+ {
+ if (e instanceof InterruptedException) {
+
+ Thread.currentThread().interrupt();
+ }
+
+ return new MailerLiteException(e.toString(), e);
+ }
+
+
/**
* Handles the response from the MailerLite API. It deserializes the JSON response into an object with the given type
*
@@ -340,9 +406,19 @@ private T handleApiResponse(HttpResponse
JsonResponseError error = gson.fromJson(stringResponse, JsonResponseError.class);
- responseError = new MailerLiteException(error.message);
-
+ String message = error.message;
+
+ if (error.code != null && !error.code.isEmpty()) {
+
+ message = error.code.concat(": ").concat(String.valueOf(error.message));
+ }
+
+ responseError = new MailerLiteException(message);
+
+ responseError.message = error.message == null ? "" : error.message;
responseError.errors = error.errors;
+ responseError.errorCode = error.code;
+ responseError.etag = error.etag;
} catch (Exception ex) {
responseError = new MailerLiteException("Error parsing API response");
@@ -366,11 +442,9 @@ private T handleApiResponse(HttpResponse
try {
response = responseClass.getDeclaredConstructor().newInstance();
- } catch (Exception e) {
-
- MailerLiteException ex = (MailerLiteException) e;
+ } catch (ReflectiveOperationException e) {
- throw ex;
+ throw new MailerLiteException(e.getMessage(), e);
}
}
diff --git a/src/main/java/com/mailerlite/sdk/MailerLiteResponse.java b/src/main/java/com/mailerlite/sdk/MailerLiteResponse.java
index 54751f4..5931a05 100644
--- a/src/main/java/com/mailerlite/sdk/MailerLiteResponse.java
+++ b/src/main/java/com/mailerlite/sdk/MailerLiteResponse.java
@@ -44,4 +44,34 @@ public Boolean successful()
{
return responseStatusCode >= 200 && responseStatusCode < 300;
}
+
+ /**
+ * Tells if the API returned the stored response of an earlier request with the same idempotency key.
+ *
+ * The SDK finds the header Idempotent-Replayed without case, and compares its value with "true" without case.
+ *
+ * @return true if the response is a replay
+ */
+ public boolean isIdempotentReplay()
+ {
+ if (headers == null) {
+ return false;
+ }
+
+ for (Map.Entry> header : headers.entrySet()) {
+
+ if (!"Idempotent-Replayed".equalsIgnoreCase(header.getKey()) || header.getValue() == null) {
+ continue;
+ }
+
+ for (String value : header.getValue()) {
+
+ if (value != null && "true".equalsIgnoreCase(value.trim())) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
}
diff --git a/src/main/java/com/mailerlite/sdk/RequestOptions.java b/src/main/java/com/mailerlite/sdk/RequestOptions.java
new file mode 100644
index 0000000..ce7255f
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/RequestOptions.java
@@ -0,0 +1,55 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk;
+
+/**
+ * Options for one request, for example an idempotency key.
+ *
+ * The email writes, {@code Emails.createTemplate} and {@code Emails.createVersion} accept the options as the last
+ * argument.
+ */
+public final class RequestOptions {
+
+ private final String idempotencyKey;
+
+ private RequestOptions(String idempotencyKey)
+ {
+ this.idempotencyKey = idempotencyKey;
+ }
+
+ /**
+ * Makes options that send the key in the Idempotency-Key header.
+ *
+ * Use a new key for each write, and the same key when you send that write again. If the first request was a
+ * success, the API returns the stored response and does not do the write two times. Then
+ * {@link MailerLiteResponse#isIdempotentReplay()} is true. The API keeps a key for 24 hours.
+ *
+ * The SDK sends the key as you give it, and the API checks it. A null or empty key sends no header.
+ *
+ * The SDK rejects a key with a character that is not printable ASCII (0x20 to 0x7E), because the Java HTTP client
+ * would send each character that is not ASCII as "?". Then the write method throws
+ * {@link com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException} and sends no request.
+ *
+ * @param key the idempotency key, or null for no key
+ * @return the options
+ */
+ public static RequestOptions idempotencyKey(String key)
+ {
+ return new RequestOptions(key);
+ }
+
+ /**
+ * Gets the idempotency key.
+ *
+ * @return the idempotency key, or null
+ */
+ public String getIdempotencyKey()
+ {
+ return idempotencyKey;
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyle.java b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyle.java
new file mode 100644
index 0000000..f625151
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyle.java
@@ -0,0 +1,57 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.brandstyles;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A palette: one font and six colors.
+ *
+ * The identifier does not change. The name is only for display, and it can change. So match a palette on its
+ * identifier, not on its name.
+ */
+public class BrandStyle {
+
+ /**
+ * The identifier of the palette, for example "preset-retro". To apply a preset to an email, set it in EmailBrandSettings.style.
+ */
+ @SerializedName("identifier")
+ public String identifier;
+
+ /** The display name of the palette. */
+ @SerializedName("name")
+ public String name;
+
+ /** The font family. */
+ @SerializedName("font_family")
+ public String fontFamily;
+
+ /** The primary color. */
+ @SerializedName("primary_color")
+ public String primaryColor;
+
+ /** The secondary color. */
+ @SerializedName("secondary_color")
+ public String secondaryColor;
+
+ /** The heading color. */
+ @SerializedName("heading_color")
+ public String headingColor;
+
+ /** The text color. */
+ @SerializedName("text_color")
+ public String textColor;
+
+ /** The border color. */
+ @SerializedName("border_color")
+ public String borderColor;
+
+ /** The background color. */
+ @SerializedName("background_color")
+ public String backgroundColor;
+}
diff --git a/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java
new file mode 100644
index 0000000..13674b3
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStyles.java
@@ -0,0 +1,44 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.brandstyles;
+
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.MailerLiteApi;
+import com.mailerlite.sdk.exceptions.MailerLiteException;
+
+/**
+ * The brand styles endpoint: the palettes that you can apply to an email built with the simple editor.
+ */
+public class BrandStyles {
+
+ private MailerLite apiObjectReference;
+
+ /**
+ * Instantiates a new brand styles service.
+ *
+ * @param apiReference the api reference
+ */
+ public BrandStyles(MailerLite apiReference)
+ {
+ apiObjectReference = apiReference;
+ }
+
+ /**
+ * Gets the brand styles of the account, the default palette and the presets.
+ *
+ * @return the palettes
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleBrandStyles get() throws MailerLiteException
+ {
+ MailerLiteApi api = new MailerLiteApi();
+ api.setToken(apiObjectReference.getToken());
+
+ return api.sendRequest("GET", "/brand-styles", null, null, SingleBrandStyles.class);
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/brandstyles/BrandStylesData.java b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStylesData.java
new file mode 100644
index 0000000..1531668
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/brandstyles/BrandStylesData.java
@@ -0,0 +1,28 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.brandstyles;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The palettes of the account.
+ */
+public class BrandStylesData {
+
+ /** The brand styles of the account, or null if the account has none. */
+ @SerializedName("brand")
+ public BrandStyle brand;
+
+ /** The default palette of the editor. The JSON key is "default", which is a Java keyword. */
+ @SerializedName("default")
+ public BrandStyle defaultStyle;
+
+ /** The preset palettes. */
+ @SerializedName("presets")
+ public BrandStyle[] presets;
+}
diff --git a/src/main/java/com/mailerlite/sdk/brandstyles/SingleBrandStyles.java b/src/main/java/com/mailerlite/sdk/brandstyles/SingleBrandStyles.java
new file mode 100644
index 0000000..a1ddcff
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/brandstyles/SingleBrandStyles.java
@@ -0,0 +1,21 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.brandstyles;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLiteResponse;
+
+/**
+ * The response of BrandStyles.get.
+ */
+public class SingleBrandStyles extends MailerLiteResponse {
+
+ /** The palettes of the account. */
+ @SerializedName("data")
+ public BrandStylesData brandStyles;
+}
diff --git a/src/main/java/com/mailerlite/sdk/campaigns/CampaignBuilder.java b/src/main/java/com/mailerlite/sdk/campaigns/CampaignBuilder.java
index 2fee6e0..e15f8fb 100644
--- a/src/main/java/com/mailerlite/sdk/campaigns/CampaignBuilder.java
+++ b/src/main/java/com/mailerlite/sdk/campaigns/CampaignBuilder.java
@@ -161,6 +161,9 @@ public CampaignBuilder segmentIds(String[] segmentIds)
/**
* Creates the.
+ *
+ * To build the emails with the simple editor, set {@code editor} to
+ * {@link com.mailerlite.sdk.emails.EmailEditors#SIMPLE} on each email, and do not set {@code content}.
*
* @return the single campaign
* @throws MailerLiteException the mailer lite exception
@@ -188,6 +191,9 @@ public SingleCampaign create() throws MailerLiteException
/**
* Update.
+ *
+ * Only {@link #create()} accepts the email fields {@code editor} and {@code templateId}. If an email of the
+ * builder has one of them set, the API rejects the update with a 422 error.
*
* @param campaignId the campaign id
* @return the single campaign
diff --git a/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java b/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java
new file mode 100644
index 0000000..a21d07d
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EditorContentAdapter.java
@@ -0,0 +1,84 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import java.io.IOException;
+import java.util.Map;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.ToNumberPolicy;
+import com.google.gson.TypeAdapter;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+
+/**
+ * Reads and writes the {@code editor_content} document of a block or a footer text.
+ *
+ * The adapter does two things that the Gson default does not do:
+ *
+ * It reads a whole number as a {@code Long} and other numbers as a {@code Double}. So {@code "linkId": 7}
+ * is sent back as {@code 7}, not as {@code 7.0}.
+ * It writes the null values in the map, so the SDK sends the document exactly as you give it.
+ *
+ */
+public class EditorContentAdapter extends TypeAdapter> {
+
+ private static final Gson GSON = new GsonBuilder()
+ .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
+ .serializeNulls()
+ .create();
+
+ private static final TypeAdapter> DELEGATE =
+ GSON.getAdapter(new TypeToken>() { });
+
+ /**
+ * Writes the map, with its null values.
+ *
+ * @param out the writer
+ * @param value the map
+ * @throws IOException if the writer fails
+ */
+ @Override
+ public void write(JsonWriter out, Map value) throws IOException
+ {
+ if (value == null) {
+ out.nullValue();
+ return;
+ }
+
+ boolean serializeNulls = out.getSerializeNulls();
+ out.setSerializeNulls(true);
+
+ try {
+ DELEGATE.write(out, value);
+ } finally {
+ out.setSerializeNulls(serializeNulls);
+ }
+ }
+
+ /**
+ * Reads the map. Whole numbers become Long values, and other numbers become Double values.
+ *
+ * @param in the reader
+ * @return the map, or null
+ * @throws IOException if the reader fails
+ */
+ @Override
+ public Map read(JsonReader in) throws IOException
+ {
+ if (in.peek() == JsonToken.NULL) {
+ in.nextNull();
+ return null;
+ }
+
+ return DELEGATE.read(in);
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBase.java b/src/main/java/com/mailerlite/sdk/emails/EmailBase.java
index 35890a8..880d609 100644
--- a/src/main/java/com/mailerlite/sdk/emails/EmailBase.java
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailBase.java
@@ -30,4 +30,29 @@ public class EmailBase extends ApiResource {
/** The subject. */
@SerializedName("subject")
public String subject;
+
+ /** The reply-to address. If it is null, the SDK does not send it. */
+ @SerializedName("reply_to")
+ public String replyTo;
+
+ /**
+ * The editor of the email: the only accepted value is {@code simple}, which selects the simple editor instead of
+ * the drag-and-drop builder. It has to be the same across every email in the payload; a mixed array is rejected.
+ * Setting it prohibits {@code emails.*.content}.
+ *
+ * Use {@link EmailEditors#SIMPLE}. If it is null, the SDK does not send it. Only
+ * {@code CampaignBuilder.create()} accepts it. If it is set, {@code CampaignBuilder.update()} gets a 422 error.
+ */
+ @SerializedName("editor")
+ public String editor;
+
+ /**
+ * The template ID: the template the new email starts from. Allowed only when {@code editor} is {@code simple},
+ * accepts only a template from the shared gallery, and also has to be the same across every email.
+ *
+ * If it is null, the SDK does not send it. Only {@code CampaignBuilder.create()} accepts it. If it is set,
+ * {@code CampaignBuilder.update()} gets a 422 error.
+ */
+ @SerializedName("template_id")
+ public String templateId;
}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlock.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlock.java
new file mode 100644
index 0000000..5ba8df8
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlock.java
@@ -0,0 +1,45 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import java.util.Map;
+
+import com.google.gson.annotations.JsonAdapter;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A content block of an email.
+ */
+public class EmailBlock {
+
+ /** The ID of the block. */
+ @SerializedName("id")
+ public String id;
+
+ /** The block type. See EmailBlockTypes. The API can add new types, so the SDK accepts any value. */
+ @SerializedName("type")
+ public String type;
+
+ /**
+ * The editor document of the block.
+ * The keys are camelCase, for example "textAlign" and "linkId". Whole numbers are Long values.
+ */
+ @SerializedName("editor_content")
+ @JsonAdapter(EditorContentAdapter.class)
+ public Map editorContent;
+
+ /** The blocks in this block, or null if the block has none. */
+ @SerializedName("children")
+ public EmailBlock[] children;
+
+ /**
+ * The position of the block. Only the responses for one block have it. In the blocks of an email, it is null.
+ */
+ @SerializedName("placement")
+ public EmailBlockPlacement placement;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java
new file mode 100644
index 0000000..9d0e159
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockInput.java
@@ -0,0 +1,54 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import java.util.Map;
+
+import com.google.gson.annotations.JsonAdapter;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A block to insert into an email or to validate.
+ */
+public class EmailBlockInput {
+
+ /** The block type: one of the values in {@link EmailBlockTypes}, except TITLE. The API needs it. */
+ @SerializedName("type")
+ public String type;
+
+ /**
+ * The editor document of the block. The API needs it, but it can be an empty map, for example for a divider.
+ * The keys are camelCase, for example "textAlign". The SDK sends the map as it is, also its null values.
+ */
+ @SerializedName("editor_content")
+ @JsonAdapter(EditorContentAdapter.class)
+ public Map editorContent;
+
+ /** The position of the new block. If it is null, the API puts the block at the end of the email. */
+ @SerializedName("placement")
+ public EmailBlockPosition placement;
+
+ /**
+ * Instantiates an empty block input.
+ */
+ public EmailBlockInput()
+ {
+ }
+
+ /**
+ * Instantiates a block input with a type and an editor document.
+ *
+ * @param type the block type
+ * @param editorContent the editor document
+ */
+ public EmailBlockInput(String type, Map editorContent)
+ {
+ this.type = type;
+ this.editorContent = editorContent;
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPlacement.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPlacement.java
new file mode 100644
index 0000000..35ef751
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPlacement.java
@@ -0,0 +1,24 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The position of a block among the blocks at the same level.
+ */
+public class EmailBlockPlacement {
+
+ /** The ID of the block before this block, or null if this block is the first block. */
+ @SerializedName("after")
+ public String after;
+
+ /** The ID of the block after this block, or null if this block is the last block. */
+ @SerializedName("before")
+ public String before;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java
new file mode 100644
index 0000000..13aa8c7
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPosition.java
@@ -0,0 +1,58 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Where to put a block: at the start or the end of its level, or before or after a block at the same level.
+ */
+public class EmailBlockPosition {
+
+ /** The position: one of the values in {@link EmailBlockPositions}. The API needs it. */
+ @SerializedName("position")
+ public String position;
+
+ /**
+ * The ID of the block at the same level to put the block before or after.
+ *
+ * The API needs it for {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER}. The SDK
+ * sends the placement as you give it, and the API checks it.
+ */
+ @SerializedName("block_id")
+ public String blockId;
+
+ /**
+ * Instantiates an empty position.
+ */
+ public EmailBlockPosition()
+ {
+ }
+
+ /**
+ * Instantiates a position without a block, for example {@link EmailBlockPositions#START}.
+ *
+ * @param position the position
+ */
+ public EmailBlockPosition(String position)
+ {
+ this.position = position;
+ }
+
+ /**
+ * Instantiates a position before or after a block.
+ *
+ * @param position the position, for example {@link EmailBlockPositions#AFTER}
+ * @param blockId the ID of the block at the same level
+ */
+ public EmailBlockPosition(String position, String blockId)
+ {
+ this.position = position;
+ this.blockId = blockId;
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java
new file mode 100644
index 0000000..248548b
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockPositions.java
@@ -0,0 +1,30 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+/**
+ * The values of {@link EmailBlockPosition#position}.
+ */
+public final class EmailBlockPositions {
+
+ /** At the start of the level. The API does not use a block ID. */
+ public static final String START = "start";
+
+ /** At the end of the level. The API does not use a block ID. */
+ public static final String END = "end";
+
+ /** Before the block with the block ID. */
+ public static final String BEFORE = "before";
+
+ /** After the block with the block ID. */
+ public static final String AFTER = "after";
+
+ private EmailBlockPositions()
+ {
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java b/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java
new file mode 100644
index 0000000..070acac
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailBlockTypes.java
@@ -0,0 +1,57 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+/**
+ * The block types of the simple editor.
+ *
+ * The API can add new types. The SDK accepts any type string when it reads a block, and it sends any type string
+ * that you give.
+ */
+public final class EmailBlockTypes {
+
+ /** A quotation. */
+ public static final String BLOCKQUOTE = "blockquote";
+
+ /** A list with bullets. */
+ public static final String BULLETED_LIST = "bulleted_list";
+
+ /** A button. */
+ public static final String BUTTON = "button";
+
+ /** A code block. */
+ public static final String CODE = "code";
+
+ /** A divider line. */
+ public static final String DIVIDER = "divider";
+
+ /** A heading. */
+ public static final String HEADING = "heading";
+
+ /** An image. */
+ public static final String IMAGE = "image";
+
+ /** A list with numbers. */
+ public static final String NUMBERED_LIST = "numbered_list";
+
+ /** A paragraph. */
+ public static final String PARAGRAPH = "paragraph";
+
+ /** A row of social icons. */
+ public static final String SOCIAL_ICON = "social_icon";
+
+ /** The title of the email. It is read-only: you cannot insert it. */
+ public static final String TITLE = "title";
+
+ /** A video. */
+ public static final String VIDEO = "video";
+
+ private EmailBlockTypes()
+ {
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java b/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java
new file mode 100644
index 0000000..e861abb
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailBrandModes.java
@@ -0,0 +1,27 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+/**
+ * The values of {@code EmailBrandSettings.mode}: where the palette of an email comes from.
+ */
+public final class EmailBrandModes {
+
+ /** Follow the brand styles of your account. The API clears the colors of the email. */
+ public static final String ACCOUNT = "account";
+
+ /** Use the palette of the template of the email. */
+ public static final String TEMPLATE = "template";
+
+ /** Use the colors of the email. */
+ public static final String CUSTOM = "custom";
+
+ private EmailBrandModes()
+ {
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java
new file mode 100644
index 0000000..8fefa32
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailBrandSettings.java
@@ -0,0 +1,48 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The palette and the font of an email.
+ *
+ * In an update, null is not sent and does not change the value.
+ * To unset a color (backgroundColor, textColor or borderColor), send an empty string. The email then uses the color
+ * of your account. Only these colors accept an empty string. For the other fields, for example style, the API
+ * rejects an empty string with a 422 error.
+ */
+public class EmailBrandSettings {
+
+ /** Where the palette comes from: one of the values in EmailBrandModes. */
+ @SerializedName("mode")
+ public String mode;
+
+ /**
+ * Write-only. The identifier of a preset from the brand styles, for example "preset-retro".
+ * The API applies the palette of the preset, and the mode then reads back as "custom".
+ */
+ @SerializedName("style")
+ public String style;
+
+ /** The font family, for example "Inter, sans-serif". */
+ @SerializedName("font_family")
+ public String fontFamily;
+
+ /** The background color, for example "#ffffff". */
+ @SerializedName("background_color")
+ public String backgroundColor;
+
+ /** The text color. */
+ @SerializedName("text_color")
+ public String textColor;
+
+ /** The border color. */
+ @SerializedName("border_color")
+ public String borderColor;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java
new file mode 100644
index 0000000..5419db4
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailButtonSettings.java
@@ -0,0 +1,44 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The button style of an email. Send all values as strings, for example "4" and not 4.
+ *
+ * In an update, null is not sent and does not change the value.
+ * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an
+ * empty string.
+ */
+public class EmailButtonSettings {
+
+ /** The font family. */
+ @SerializedName("font_family")
+ public String fontFamily;
+
+ /** The font weight, for example "600". */
+ @SerializedName("font_weight")
+ public String fontWeight;
+
+ /** The letter spacing, for example "0". */
+ @SerializedName("letter_spacing")
+ public String letterSpacing;
+
+ /** The background color. */
+ @SerializedName("background_color")
+ public String backgroundColor;
+
+ /** The text color. */
+ @SerializedName("text_color")
+ public String textColor;
+
+ /** The border radius in pixels, without a unit, for example "4". */
+ @SerializedName("border_radius")
+ public String borderRadius;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailDocument.java b/src/main/java/com/mailerlite/sdk/emails/EmailDocument.java
new file mode 100644
index 0000000..b9b86e1
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailDocument.java
@@ -0,0 +1,42 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * An email built with the simple editor: its design settings and its content blocks.
+ */
+public class EmailDocument {
+
+ /** The ID of the email. */
+ @SerializedName("id")
+ public String id;
+
+ /** The type of the email, for example "builder_inline_email". */
+ @SerializedName("type")
+ public String type;
+
+ /** The type of the owner of the email, for example "campaigns". */
+ @SerializedName("emailable_type")
+ public String emailableType;
+
+ /** The ID of the owner of the email, for example the campaign ID. */
+ @SerializedName("emailable_id")
+ public String emailableId;
+
+ /**
+ * The design settings, as they apply to the email. They include the values that the email inherits from your account.
+ */
+ @SerializedName("settings")
+ public EmailSettings settings;
+
+ /** The content blocks, in the sequence in which they show in the email. */
+ @SerializedName("blocks")
+ public EmailBlock[] blocks;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java b/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java
new file mode 100644
index 0000000..8a2ac82
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailEditorText.java
@@ -0,0 +1,26 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import java.util.Map;
+
+import com.google.gson.annotations.JsonAdapter;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A footer text as an editor document. In an update, an empty editorContent map, or a map with an empty "content"
+ * list, unsets the text.
+ * The email then uses the text of your account.
+ */
+public class EmailEditorText {
+
+ /** The editor document. The keys are camelCase. */
+ @SerializedName("editor_content")
+ @JsonAdapter(EditorContentAdapter.class)
+ public Map editorContent;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java b/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java
new file mode 100644
index 0000000..93087dc
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailEditors.java
@@ -0,0 +1,21 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+/**
+ * The values of {@link EmailBase#editor}.
+ */
+public final class EmailEditors {
+
+ /** The simple editor. Change the email with the methods of {@link Emails}. */
+ public static final String SIMPLE = "simple";
+
+ private EmailEditors()
+ {
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java b/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java
new file mode 100644
index 0000000..a0ab678
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailErrorCodes.java
@@ -0,0 +1,27 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.mailerlite.sdk.exceptions.MailerLiteException;
+
+/**
+ * The values of {@link MailerLiteException#errorCode} that the email endpoints send.
+ */
+public final class EmailErrorCodes {
+
+ /**
+ * The email changed after you read the etag that the write sent. The exception has the current etag.
+ * Get the email again, and then send the write again with the current etag.
+ * The value is the same as {@link MailerLiteException#VERSION_CONFLICT}.
+ */
+ public static final String VERSION_CONFLICT = MailerLiteException.VERSION_CONFLICT;
+
+ private EmailErrorCodes()
+ {
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailEtagResponse.java b/src/main/java/com/mailerlite/sdk/emails/EmailEtagResponse.java
new file mode 100644
index 0000000..61e1792
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailEtagResponse.java
@@ -0,0 +1,21 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLiteResponse;
+
+/**
+ * The response of Emails.deleteBlock. It has only the new etag of the email.
+ */
+public class EmailEtagResponse extends MailerLiteResponse {
+
+ /** The new etag of the email. Send it with the next write to the email. */
+ @SerializedName("etag")
+ public String etag;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java
new file mode 100644
index 0000000..ca51a13
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailFooterSettings.java
@@ -0,0 +1,54 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import java.util.List;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The footer of an email.
+ *
+ * In an update, a null field is not sent and does not change. To unset companyName, address or infoText, send an
+ * empty editor document. The email then uses the text of your account.
+ */
+public class EmailFooterSettings {
+
+ /** True to show the preferences link. */
+ @SerializedName("show_preferences")
+ public Boolean showPreferences;
+
+ /** The company name, as an editor document. */
+ @SerializedName("company_name")
+ public EmailEditorText companyName;
+
+ /** The address, as an editor document. */
+ @SerializedName("address")
+ public EmailEditorText address;
+
+ /** The information text, as an editor document. */
+ @SerializedName("info_text")
+ public EmailEditorText infoText;
+
+ /** The text of the unsubscribe link. It is plain text. */
+ @SerializedName("unsubscribe_text")
+ public String unsubscribeText;
+
+ /** The text of the preferences link. It is plain text. */
+ @SerializedName("preferences_text")
+ public String preferencesText;
+
+ /**
+ * The social links.
+ *
+ * An update replaces the whole list. Send all the links that you want to keep, in the sequence that you want.
+ * An empty list removes all the links. Keep the linkId of each link to keep its click history.
+ */
+ @SerializedName("social_links")
+ public List socialLinks;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java
new file mode 100644
index 0000000..99c5c72
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailHeadingStyle.java
@@ -0,0 +1,48 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The heading styles of an email. Send all values as strings, for example "700" and not 700.
+ *
+ * In an update, null is not sent and does not change the value.
+ * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an
+ * empty string.
+ */
+public class EmailHeadingStyle {
+
+ /** The font family. */
+ @SerializedName("font_family")
+ public String fontFamily;
+
+ /** The font weight, for example "700". */
+ @SerializedName("font_weight")
+ public String fontWeight;
+
+ /** The line height, for example "150%". */
+ @SerializedName("line_height")
+ public String lineHeight;
+
+ /** The letter spacing, for example "0". */
+ @SerializedName("letter_spacing")
+ public String letterSpacing;
+
+ /** The style of level 1 headings. */
+ @SerializedName("h1")
+ public EmailTextStyle h1;
+
+ /** The style of level 2 headings. */
+ @SerializedName("h2")
+ public EmailTextStyle h2;
+
+ /** The style of level 3 headings. */
+ @SerializedName("h3")
+ public EmailTextStyle h3;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailHtml.java b/src/main/java/com/mailerlite/sdk/emails/EmailHtml.java
new file mode 100644
index 0000000..cf80352
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailHtml.java
@@ -0,0 +1,23 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The HTML of an email.
+ *
+ * The HTML is a preview, not an email that is ready to send. Personalization shows as tokens such as {$name},
+ * and the links have markers instead of tracking URLs. Do not send this HTML yourself.
+ */
+public class EmailHtml {
+
+ /** The HTML of the email, as one string. */
+ @SerializedName("html")
+ public String html;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java
new file mode 100644
index 0000000..ac1e985
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailLayoutSettings.java
@@ -0,0 +1,28 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The layout of an email.
+ *
+ * In an update, null is not sent and does not change the value.
+ * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an
+ * empty string.
+ */
+public class EmailLayoutSettings {
+
+ /** The width mode, for example "default". */
+ @SerializedName("width_mode")
+ public String widthMode;
+
+ /** The alignment of the email, for example "center". */
+ @SerializedName("alignment")
+ public String alignment;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java
new file mode 100644
index 0000000..f5af2da
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailLinkStyle.java
@@ -0,0 +1,24 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The link style of an email.
+ *
+ * In an update, null is not sent and does not change the value.
+ * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an
+ * empty string.
+ */
+public class EmailLinkStyle {
+
+ /** The link color, for example "#2cb191". */
+ @SerializedName("color")
+ public String color;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java
new file mode 100644
index 0000000..b433406
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailParagraphStyle.java
@@ -0,0 +1,40 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The paragraph styles of an email. Send all values as strings, for example "400" and not 400.
+ *
+ * In an update, null is not sent and does not change the value.
+ * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an
+ * empty string.
+ */
+public class EmailParagraphStyle {
+
+ /** The font family. */
+ @SerializedName("font_family")
+ public String fontFamily;
+
+ /** The font weight, for example "400". */
+ @SerializedName("font_weight")
+ public String fontWeight;
+
+ /** The line height, for example "150%". */
+ @SerializedName("line_height")
+ public String lineHeight;
+
+ /** The letter spacing, for example "0". */
+ @SerializedName("letter_spacing")
+ public String letterSpacing;
+
+ /** The style of paragraphs. */
+ @SerializedName("p1")
+ public EmailTextStyle p1;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailRejected.java b/src/main/java/com/mailerlite/sdk/emails/EmailRejected.java
new file mode 100644
index 0000000..bd7569e
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailRejected.java
@@ -0,0 +1,24 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The unsafe values that the API removed or replaced. The request is still a success.
+ */
+public class EmailRejected {
+
+ /** The message. */
+ @SerializedName("message")
+ public String message;
+
+ /** The paths of the values, for example "footer.social_links[0].url". */
+ @SerializedName("keys")
+ public String[] keys;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailSchema.java b/src/main/java/com/mailerlite/sdk/emails/EmailSchema.java
new file mode 100644
index 0000000..dcfba3c
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailSchema.java
@@ -0,0 +1,24 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The version of the rules that a validation used.
+ */
+public class EmailSchema {
+
+ /** The version of the rules, for example "2.73.2". */
+ @SerializedName("version")
+ public String version;
+
+ /** The hash of the rules. */
+ @SerializedName("hash")
+ public String hash;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java
new file mode 100644
index 0000000..5a2365e
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailSettings.java
@@ -0,0 +1,45 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The design settings of an email.
+ *
+ * In a response, the settings show the values that apply to the email, also the values that the email
+ * inherits from your account. In an update, set only the fields that you want to change. A null field is not sent.
+ * To unset a brand color, send an empty string. To unset a footer text, send an empty editor document. The email then
+ * uses the value of your account. For the other values, for example other colors, sizes, button.borderRadius and
+ * brand.style, the API rejects an empty string with a 422 error.
+ *
+ * Do not send back all the settings that you read. The API stores each value that you send, also an inherited
+ * value. The email then no longer follows your account for that value.
+ */
+public class EmailSettings {
+
+ /** The palette and the font of the email. */
+ @SerializedName("brand")
+ public EmailBrandSettings brand;
+
+ /** The layout of the email. */
+ @SerializedName("layout")
+ public EmailLayoutSettings layout;
+
+ /** The text styles of the email. */
+ @SerializedName("typography")
+ public EmailTypographySettings typography;
+
+ /** The button style of the email. */
+ @SerializedName("button")
+ public EmailButtonSettings button;
+
+ /** The footer of the email. */
+ @SerializedName("footer")
+ public EmailFooterSettings footer;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailSocialLink.java b/src/main/java/com/mailerlite/sdk/emails/EmailSocialLink.java
new file mode 100644
index 0000000..d11ac23
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailSocialLink.java
@@ -0,0 +1,36 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A social link in the footer of an email.
+ */
+public class EmailSocialLink {
+
+ /** The URL of the link. */
+ @SerializedName("url")
+ public String url;
+
+ /** The network of the link, for example "website". */
+ @SerializedName("type")
+ public String type;
+
+ /** The label of the link. */
+ @SerializedName("label")
+ public String label;
+
+ /**
+ * The ID that the click reports use.
+ *
+ * Send it back without a change. Without it, the API makes a new link, and the click history of the old link is lost.
+ */
+ @SerializedName("link_id")
+ public Long linkId;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailTemplate.java b/src/main/java/com/mailerlite/sdk/emails/EmailTemplate.java
new file mode 100644
index 0000000..1622542
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailTemplate.java
@@ -0,0 +1,32 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A template that the API made from an email.
+ */
+public class EmailTemplate {
+
+ /** The ID of the template. */
+ @SerializedName("id")
+ public String id;
+
+ /** The name of the template. */
+ @SerializedName("name")
+ public String name;
+
+ /** The type of the template, for example "email". */
+ @SerializedName("type")
+ public String type;
+
+ /** The date and time when the API made the template, for example "2026-10-28 12:00:00". */
+ @SerializedName("created_at")
+ public String createdAt;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java b/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java
new file mode 100644
index 0000000..bc96149
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailTextStyle.java
@@ -0,0 +1,28 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The color and the size of a text style. Send the size as a string, for example "16" and not 16.
+ *
+ * In an update, null is not sent and does not change the value.
+ * Do not send an empty string. The API rejects an empty string with a 422 error. Only a brand color accepts an
+ * empty string.
+ */
+public class EmailTextStyle {
+
+ /** The text color, for example "#000000". */
+ @SerializedName("color")
+ public String color;
+
+ /** The text size in pixels, without a unit, for example "16". */
+ @SerializedName("size")
+ public String size;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailTypographySettings.java b/src/main/java/com/mailerlite/sdk/emails/EmailTypographySettings.java
new file mode 100644
index 0000000..e33facb
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailTypographySettings.java
@@ -0,0 +1,28 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The text styles of an email.
+ */
+public class EmailTypographySettings {
+
+ /** The link style. */
+ @SerializedName("link")
+ public EmailLinkStyle link;
+
+ /** The heading styles. */
+ @SerializedName("heading")
+ public EmailHeadingStyle heading;
+
+ /** The paragraph styles. */
+ @SerializedName("paragraph")
+ public EmailParagraphStyle paragraph;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailValidation.java b/src/main/java/com/mailerlite/sdk/emails/EmailValidation.java
new file mode 100644
index 0000000..ba1c939
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailValidation.java
@@ -0,0 +1,40 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLiteResponse;
+
+/**
+ * The result of a validation.
+ *
+ * An invalid input is not an error: the status is 200, valid is false, and errors has the messages for each field.
+ */
+public class EmailValidation extends MailerLiteResponse {
+
+ /** True if the input is valid. */
+ @SerializedName("valid")
+ public boolean valid;
+
+ /** The error messages for each field path. It is empty if the input is valid. */
+ @SerializedName("errors")
+ public HashMap errors;
+
+ /** The version of the rules that the validation used. */
+ @SerializedName("schema")
+ public EmailSchema schema;
+
+ /**
+ * The unsafe values that an update would remove, or null if there are none. Only the validation of settings has it.
+ */
+ @SerializedName("rejected")
+ public EmailRejected rejected;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailVersion.java b/src/main/java/com/mailerlite/sdk/emails/EmailVersion.java
new file mode 100644
index 0000000..4dac399
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailVersion.java
@@ -0,0 +1,28 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A version of an email: a saved copy of its content and its design settings.
+ */
+public class EmailVersion {
+
+ /** The ID of the version. */
+ @SerializedName("id")
+ public String id;
+
+ /** The date and time when the API saved the version, for example "2026-10-28 12:00:00". */
+ @SerializedName("created_at")
+ public String createdAt;
+
+ /** True if the email has the content of this version now. */
+ @SerializedName("current")
+ public boolean current;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailVersionsList.java b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsList.java
new file mode 100644
index 0000000..c06540b
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsList.java
@@ -0,0 +1,21 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.util.PaginatedResponse;
+
+/**
+ * The response of EmailVersionsRetriever.get. The list starts with the newest version.
+ */
+public class EmailVersionsList extends PaginatedResponse {
+
+ /** The versions of the email. */
+ @SerializedName("data")
+ public EmailVersion[] versions;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java
new file mode 100644
index 0000000..78df826
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/EmailVersionsRetriever.java
@@ -0,0 +1,60 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.MailerLiteApi;
+import com.mailerlite.sdk.exceptions.MailerLiteException;
+import com.mailerlite.sdk.util.CursorPaginatedRequest;
+
+/**
+ * Lists the versions of an email. Use page() and limit() to select a page, and then call get().
+ */
+public class EmailVersionsRetriever extends CursorPaginatedRequest {
+
+ private MailerLite apiObjectReference;
+
+ private String endpoint;
+
+ EmailVersionsRetriever(MailerLite apiReference, String endpoint)
+ {
+ this.apiObjectReference = apiReference;
+ this.endpoint = endpoint;
+ }
+
+ /**
+ * Gets a page of versions, newest first.
+ *
+ * @return the versions
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public EmailVersionsList get() throws MailerLiteException
+ {
+ String query = this.getQueryParameters();
+
+ if (query.equals("?")) {
+ query = "";
+ }
+
+ MailerLiteApi api = new MailerLiteApi();
+ api.setToken(apiObjectReference.getToken());
+
+ return api.sendRequest("GET", endpoint.concat(query), null, null, EmailVersionsList.class);
+ }
+
+ /**
+ * Gets this instance.
+ *
+ * @return this retriever
+ */
+ @Override
+ protected EmailVersionsRetriever getInstance()
+ {
+ return this;
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/Emails.java b/src/main/java/com/mailerlite/sdk/emails/Emails.java
new file mode 100644
index 0000000..c0aebb6
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/Emails.java
@@ -0,0 +1,576 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import com.google.gson.annotations.JsonAdapter;
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.MailerLiteApi;
+import com.mailerlite.sdk.MailerLiteResponse;
+import com.mailerlite.sdk.RequestOptions;
+import com.mailerlite.sdk.exceptions.MailerLiteException;
+import com.mailerlite.sdk.util.RequestChecks;
+
+/**
+ * The endpoints for emails built with the simple editor.
+ *
+ * Each write must send the etag from the last response for the email, and each write 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 throws a {@link MailerLiteException} with
+ * {@link MailerLiteException#isVersionConflict()} set to true and the current etag in
+ * {@link MailerLiteException#etag}.
+ *
+ * The methods check the IDs in the URL path before they send a request. An empty or null ID throws
+ * {@link com.mailerlite.sdk.exceptions.EmptyIdException}. An ID that contains a character other than the
+ * digits 0 to 9 throws {@link com.mailerlite.sdk.exceptions.InvalidIdException}. The SDK sends all other values
+ * as you give them, and the API checks them. If a value is not valid, the API returns an error.
+ *
+ * The writes, createTemplate and createVersion have an overload with {@link RequestOptions} as the last argument.
+ * Use it to send an idempotency key, so that you can safely send a write again after a network error.
+ */
+public class Emails {
+
+ private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create();
+
+ /** Writes the trees that GSON makes, with the null values of the editor content maps. */
+ private static final Gson TREE_GSON = new GsonBuilder().disableHtmlEscaping().serializeNulls().create();
+
+ private MailerLite apiObjectReference;
+
+ /**
+ * Instantiates a new emails service.
+ *
+ * @param apiReference the api reference
+ */
+ public Emails(MailerLite apiReference)
+ {
+ apiObjectReference = apiReference;
+ }
+
+ /**
+ * Gets an email with its design settings and its blocks.
+ *
+ * @param emailId the email ID
+ * @return the email and its etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailDocument get(String emailId) throws MailerLiteException
+ {
+ return send("GET", emailPath(emailId), null, SingleEmailDocument.class);
+ }
+
+ /**
+ * Gets the HTML of an email, as a preview. The HTML is not ready to send.
+ *
+ * The API can return a temporary 503 error with a body that is not JSON. If it does, try again later.
+ *
+ * @param emailId the email ID
+ * @return the HTML and the etag of the email
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailHtml getHtml(String emailId) throws MailerLiteException
+ {
+ return send("GET", emailPath(emailId).concat("/html"), null, SingleEmailHtml.class);
+ }
+
+ /**
+ * Gets one block of an email, with its placement.
+ *
+ * @param emailId the email ID
+ * @param blockId the block ID
+ * @return the block and the etag of the email
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailBlock getBlock(String emailId, String blockId) throws MailerLiteException
+ {
+ return send("GET", blockPath(emailId, blockId), null, SingleEmailBlock.class);
+ }
+
+ /**
+ * Inserts a block into an email.
+ *
+ * Without a placement, the API puts the block at the end of the email. The response has the new block with
+ * its placement and the new etag.
+ *
+ * @param emailId the email ID
+ * @param etag the etag from the last response for the email
+ * @param block the block to insert
+ * @return the new block and the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ * @throws NullPointerException if the block is null
+ */
+ public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput block) throws MailerLiteException
+ {
+ return createBlock(emailId, etag, block, null);
+ }
+
+ /**
+ * Inserts a block into an email.
+ *
+ * Without a placement, the API puts the block at the end of the email. The response has the new block with
+ * its placement and the new etag.
+ *
+ * @param emailId the email ID
+ * @param etag the etag from the last response for the email
+ * @param block the block to insert
+ * @param options the request options, for example an idempotency key, or null for no options
+ * @return the new block and the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ * @throws NullPointerException if the block is null
+ */
+ public SingleEmailBlock createBlock(String emailId, String etag, EmailBlockInput block, RequestOptions options)
+ throws MailerLiteException
+ {
+ String endpoint = emailPath(emailId).concat("/blocks");
+ BlockBody body = blockBody(Objects.requireNonNull(block, "block"), etag);
+
+ return send("POST", endpoint, toJson(body), options, SingleEmailBlock.class);
+ }
+
+ /**
+ * Replaces the content of a block. The type and the position of the block do not change.
+ *
+ * The new editor content replaces the whole content of the block. The API removes the keys that you do not
+ * send. Read the block, change it, and send the full editor content.
+ *
+ * @param emailId the email ID
+ * @param blockId the block ID
+ * @param etag the etag from the last response for the email
+ * @param editorContent the new editor document of the block. The keys are camelCase.
+ * @return the block and the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, Map editorContent)
+ throws MailerLiteException
+ {
+ return updateBlock(emailId, blockId, etag, editorContent, null);
+ }
+
+ /**
+ * Replaces the content of a block. The type and the position of the block do not change.
+ *
+ * The new editor content replaces the whole content of the block. The API removes the keys that you do not
+ * send. Read the block, change it, and send the full editor content.
+ *
+ * @param emailId the email ID
+ * @param blockId the block ID
+ * @param etag the etag from the last response for the email
+ * @param editorContent the new editor document of the block. The keys are camelCase.
+ * @param options the request options, for example an idempotency key, or null for no options
+ * @return the block and the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailBlock updateBlock(String emailId, String blockId, String etag, Map editorContent,
+ RequestOptions options)
+ throws MailerLiteException
+ {
+ String endpoint = blockPath(emailId, blockId);
+
+ UpdateBlockBody body = new UpdateBlockBody();
+ body.etag = etag;
+ body.editorContent = editorContent;
+
+ return send("PATCH", endpoint, toJson(body), options, SingleEmailBlock.class);
+ }
+
+ /**
+ * Deletes a block of an email. The API also deletes the blocks in it.
+ *
+ * @param emailId the email ID
+ * @param blockId the block ID
+ * @param etag the etag from the last response for the email
+ * @return the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public EmailEtagResponse deleteBlock(String emailId, String blockId, String etag) throws MailerLiteException
+ {
+ return deleteBlock(emailId, blockId, etag, null);
+ }
+
+ /**
+ * Deletes a block of an email. The API also deletes the blocks in it.
+ *
+ * @param emailId the email ID
+ * @param blockId the block ID
+ * @param etag the etag from the last response for the email
+ * @param options the request options, for example an idempotency key, or null for no options
+ * @return the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public EmailEtagResponse deleteBlock(String emailId, String blockId, String etag, RequestOptions options)
+ throws MailerLiteException
+ {
+ String endpoint = blockPath(emailId, blockId);
+
+ return send("DELETE", endpoint, etagBody(etag), options, EmailEtagResponse.class);
+ }
+
+ /**
+ * Moves a block to a new position at the same level. The nesting of the block does not change.
+ *
+ * The positions {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER} need a block ID.
+ * The SDK sends the placement as you give it.
+ *
+ * @param emailId the email ID
+ * @param blockId the ID of the block to move
+ * @param etag the etag from the last response for the email
+ * @param placement the new position
+ * @return the block with its new placement, and the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, EmailBlockPosition placement)
+ throws MailerLiteException
+ {
+ return moveBlock(emailId, blockId, etag, placement, null);
+ }
+
+ /**
+ * Moves a block to a new position at the same level. The nesting of the block does not change.
+ *
+ * The positions {@link EmailBlockPositions#BEFORE} and {@link EmailBlockPositions#AFTER} need a block ID.
+ * The SDK sends the placement as you give it.
+ *
+ * @param emailId the email ID
+ * @param blockId the ID of the block to move
+ * @param etag the etag from the last response for the email
+ * @param placement the new position
+ * @param options the request options, for example an idempotency key, or null for no options
+ * @return the block with its new placement, and the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailBlock moveBlock(String emailId, String blockId, String etag, EmailBlockPosition placement,
+ RequestOptions options)
+ throws MailerLiteException
+ {
+ String endpoint = blockPath(emailId, blockId).concat("/move");
+
+ MoveBlockBody body = new MoveBlockBody();
+ body.etag = etag;
+ body.placement = placement;
+
+ return send("POST", endpoint, toJson(body), options, SingleEmailBlock.class);
+ }
+
+ /**
+ * Checks a block with the rules of an insert, and saves nothing. It needs no email and no etag.
+ *
+ * An invalid block is not an error: the response has valid set to false and the errors for each field.
+ *
+ * @param block the block to check. A null block sends an empty body.
+ * @return the result of the validation
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public EmailValidation validateBlock(EmailBlockInput block) throws MailerLiteException
+ {
+ String body = block == null ? "{}" : toJson(blockBody(block, null));
+
+ return send("POST", "/emails/blocks/validate", body, EmailValidation.class);
+ }
+
+ /**
+ * Changes the design settings of an email.
+ *
+ * Set only the fields that you want to change. A null field is not sent and does not change. To unset a brand
+ * color, send an empty string. To unset a footer text, send an empty editor document. The email then uses the
+ * value of your account. For the other values, the API rejects an empty string with a 422 error. The API stores
+ * each value that you send, also a value that the email inherits from your account. So do not send back all the
+ * settings that you read.
+ *
+ * @param emailId the email ID
+ * @param etag the etag from the last response for the email
+ * @param settings the settings to change
+ * @return the settings after the update, the new etag, and the unsafe values that the API removed
+ * @throws MailerLiteException if the API returns an error or the request fails
+ * @throws NullPointerException if the settings are null
+ */
+ public SingleEmailSettings updateSettings(String emailId, String etag, EmailSettings settings)
+ throws MailerLiteException
+ {
+ return updateSettings(emailId, etag, settings, null);
+ }
+
+ /**
+ * Changes the design settings of an email.
+ *
+ * Set only the fields that you want to change. A null field is not sent and does not change. To unset a brand
+ * color, send an empty string. To unset a footer text, send an empty editor document. The email then uses the
+ * value of your account. For the other values, the API rejects an empty string with a 422 error. The API stores
+ * each value that you send, also a value that the email inherits from your account. So do not send back all the
+ * settings that you read.
+ *
+ * @param emailId the email ID
+ * @param etag the etag from the last response for the email
+ * @param settings the settings to change
+ * @param options the request options, for example an idempotency key, or null for no options
+ * @return the settings after the update, the new etag, and the unsafe values that the API removed
+ * @throws MailerLiteException if the API returns an error or the request fails
+ * @throws NullPointerException if the settings are null
+ */
+ public SingleEmailSettings updateSettings(String emailId, String etag, EmailSettings settings, RequestOptions options)
+ throws MailerLiteException
+ {
+ String endpoint = emailPath(emailId).concat("/settings");
+
+ JsonObject body = settingsBody(Objects.requireNonNull(settings, "settings"));
+
+ if (etag != null) {
+ body.addProperty("etag", etag);
+ }
+
+ return send("PATCH", endpoint, TREE_GSON.toJson(body), options, SingleEmailSettings.class);
+ }
+
+ /**
+ * Checks a change to the design settings with the rules of an update, and saves nothing. It needs no etag.
+ *
+ * Invalid settings are not an error: the response has valid set to false and the errors for each field. If the
+ * update would remove unsafe values, the response has their paths in rejected.
+ *
+ * @param emailId the email ID
+ * @param settings the settings to check. Null settings send an empty body.
+ * @return the result of the validation
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public EmailValidation validateSettings(String emailId, EmailSettings settings) throws MailerLiteException
+ {
+ String endpoint = emailPath(emailId).concat("/settings/validate");
+
+ String body = settings == null ? "{}" : TREE_GSON.toJson(settingsBody(settings));
+
+ return send("POST", endpoint, body, EmailValidation.class);
+ }
+
+ /**
+ * Copies an email to a new template. The API cannot delete templates.
+ *
+ * @param emailId the email ID
+ * @param name the name of the new template
+ * @return the new template
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailTemplate createTemplate(String emailId, String name) throws MailerLiteException
+ {
+ return createTemplate(emailId, name, null);
+ }
+
+ /**
+ * Copies an email to a new template. The API cannot delete templates.
+ *
+ * @param emailId the email ID
+ * @param name the name of the new template
+ * @param options the request options, for example an idempotency key, or null for no options
+ * @return the new template
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailTemplate createTemplate(String emailId, String name, RequestOptions options)
+ throws MailerLiteException
+ {
+ String endpoint = emailPath(emailId).concat("/templates");
+
+ TemplateBody body = new TemplateBody();
+ body.name = name;
+
+ return send("POST", endpoint, toJson(body), options, SingleEmailTemplate.class);
+ }
+
+ /**
+ * Lists the versions of an email, newest first. Call get() on the result, after page() and limit() if you
+ * want them.
+ *
+ * @param emailId the email ID
+ * @return the retriever of the versions
+ */
+ public EmailVersionsRetriever versions(String emailId)
+ {
+ return new EmailVersionsRetriever(apiObjectReference, emailPath(emailId).concat("/versions"));
+ }
+
+ /**
+ * Saves the email as a new version.
+ *
+ * If nothing changed after the last version, the API saves no new version and returns the last version.
+ * {@link SingleEmailVersion#isCreated()} tells the two cases apart.
+ *
+ * @param emailId the email ID
+ * @return the new version, or the last version
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailVersion createVersion(String emailId) throws MailerLiteException
+ {
+ return createVersion(emailId, null);
+ }
+
+ /**
+ * Saves the email as a new version.
+ *
+ * If nothing changed after the last version, the API saves no new version and returns the last version.
+ * {@link SingleEmailVersion#isCreated()} tells the two cases apart.
+ *
+ * @param emailId the email ID
+ * @param options the request options, for example an idempotency key, or null for no options
+ * @return the new version, or the last version
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailVersion createVersion(String emailId, RequestOptions options) throws MailerLiteException
+ {
+ return send("POST", emailPath(emailId).concat("/versions"), null, options, SingleEmailVersion.class);
+ }
+
+ /**
+ * Puts an email back to a 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.
+ *
+ * @param emailId the email ID
+ * @param versionId the version ID
+ * @param etag the etag from the last response for the email
+ * @return the email and the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailDocument restoreVersion(String emailId, String versionId, String etag) throws MailerLiteException
+ {
+ return restoreVersion(emailId, versionId, etag, null);
+ }
+
+ /**
+ * Puts an email back to a 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.
+ *
+ * @param emailId the email ID
+ * @param versionId the version ID
+ * @param etag the etag from the last response for the email
+ * @param options the request options, for example an idempotency key, or null for no options
+ * @return the email and the new etag
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public SingleEmailDocument restoreVersion(String emailId, String versionId, String etag, RequestOptions options)
+ throws MailerLiteException
+ {
+ String endpoint = emailPath(emailId).concat("/versions/")
+ .concat(RequestChecks.pathId(versionId, "versionId")).concat("/restore");
+
+ return send("POST", endpoint, etagBody(etag), options, SingleEmailDocument.class);
+ }
+
+ private static String emailPath(String emailId)
+ {
+ return "/emails/".concat(RequestChecks.pathId(emailId, "emailId"));
+ }
+
+ private static String blockPath(String emailId, String blockId)
+ {
+ return emailPath(emailId).concat("/blocks/").concat(RequestChecks.pathId(blockId, "blockId"));
+ }
+
+ private static BlockBody blockBody(EmailBlockInput block, String etag)
+ {
+ BlockBody body = new BlockBody();
+ body.type = block.type;
+ body.editorContent = block.editorContent;
+ body.placement = block.placement;
+ body.etag = etag;
+
+ return body;
+ }
+
+ private static String etagBody(String etag)
+ {
+ EtagBody body = new EtagBody();
+ body.etag = etag;
+
+ return toJson(body);
+ }
+
+ private static JsonObject settingsBody(EmailSettings settings)
+ {
+ return GSON.toJsonTree(settings).getAsJsonObject();
+ }
+
+ private static String toJson(Object body)
+ {
+ return GSON.toJson(body);
+ }
+
+ private T send(String method, String endpoint, String body, Class responseClass)
+ throws MailerLiteException
+ {
+ return send(method, endpoint, body, null, responseClass);
+ }
+
+ private T send(String method, String endpoint, String body, RequestOptions options,
+ Class responseClass) throws MailerLiteException
+ {
+ Map headers = null;
+
+ String key = options == null ? null : RequestChecks.idempotencyKey(options.getIdempotencyKey());
+
+ if (key != null && !key.isEmpty()) {
+ headers = Collections.singletonMap("Idempotency-Key", key);
+ }
+
+ MailerLiteApi api = new MailerLiteApi();
+ api.setToken(apiObjectReference.getToken());
+
+ return api.sendRequest(method, endpoint, body, headers, responseClass);
+ }
+
+ private static class BlockBody {
+
+ @SerializedName("type")
+ String type;
+
+ @SerializedName("editor_content")
+ @JsonAdapter(EditorContentAdapter.class)
+ Map editorContent;
+
+ @SerializedName("placement")
+ EmailBlockPosition placement;
+
+ @SerializedName("etag")
+ String etag;
+ }
+
+ private static class UpdateBlockBody {
+
+ @SerializedName("editor_content")
+ @JsonAdapter(EditorContentAdapter.class)
+ Map editorContent;
+
+ @SerializedName("etag")
+ String etag;
+ }
+
+ private static class EtagBody {
+
+ @SerializedName("etag")
+ String etag;
+ }
+
+ private static class MoveBlockBody {
+
+ @SerializedName("placement")
+ EmailBlockPosition placement;
+
+ @SerializedName("etag")
+ String etag;
+ }
+
+ private static class TemplateBody {
+
+ @SerializedName("name")
+ String name;
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailBlock.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailBlock.java
new file mode 100644
index 0000000..d9431fc
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailBlock.java
@@ -0,0 +1,25 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLiteResponse;
+
+/**
+ * The response of the endpoints that return one block.
+ */
+public class SingleEmailBlock extends MailerLiteResponse {
+
+ /** The block, with its placement. */
+ @SerializedName("data")
+ public EmailBlock block;
+
+ /** The etag of the email. Send it with the next write to the email. */
+ @SerializedName("etag")
+ public String etag;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailDocument.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailDocument.java
new file mode 100644
index 0000000..d6cab76
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailDocument.java
@@ -0,0 +1,25 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLiteResponse;
+
+/**
+ * The response of the endpoints that return a full email.
+ */
+public class SingleEmailDocument extends MailerLiteResponse {
+
+ /** The email. */
+ @SerializedName("data")
+ public EmailDocument email;
+
+ /** The etag of the email. Send it with the next write to the email. */
+ @SerializedName("etag")
+ public String etag;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailHtml.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailHtml.java
new file mode 100644
index 0000000..a0da351
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailHtml.java
@@ -0,0 +1,25 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLiteResponse;
+
+/**
+ * The response of Emails.getHtml.
+ */
+public class SingleEmailHtml extends MailerLiteResponse {
+
+ /** The HTML preview of the email. */
+ @SerializedName("data")
+ public EmailHtml preview;
+
+ /** The etag of the email. Send it with the next write to the email. */
+ @SerializedName("etag")
+ public String etag;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailSettings.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailSettings.java
new file mode 100644
index 0000000..26db987
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailSettings.java
@@ -0,0 +1,29 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLiteResponse;
+
+/**
+ * The response of Emails.updateSettings.
+ */
+public class SingleEmailSettings extends MailerLiteResponse {
+
+ /** The design settings after the update, as they apply to the email. */
+ @SerializedName("data")
+ public EmailSettings settings;
+
+ /** The new etag of the email. Send it with the next write to the email. */
+ @SerializedName("etag")
+ public String etag;
+
+ /** The unsafe values that the API removed or replaced, or null if there are none. */
+ @SerializedName("rejected")
+ public EmailRejected rejected;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailTemplate.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailTemplate.java
new file mode 100644
index 0000000..05fb98c
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailTemplate.java
@@ -0,0 +1,21 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLiteResponse;
+
+/**
+ * The response of Emails.createTemplate.
+ */
+public class SingleEmailTemplate extends MailerLiteResponse {
+
+ /** The new template. */
+ @SerializedName("data")
+ public EmailTemplate template;
+}
diff --git a/src/main/java/com/mailerlite/sdk/emails/SingleEmailVersion.java b/src/main/java/com/mailerlite/sdk/emails/SingleEmailVersion.java
new file mode 100644
index 0000000..513e324
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/emails/SingleEmailVersion.java
@@ -0,0 +1,32 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.emails;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.MailerLiteResponse;
+
+/**
+ * The response of Emails.createVersion.
+ */
+public class SingleEmailVersion extends MailerLiteResponse {
+
+ /** The new version, or the last version if nothing changed. */
+ @SerializedName("data")
+ public EmailVersion version;
+
+ /**
+ * Tells if the API saved a new version. If nothing changed after the last version, the API saves no new
+ * version, returns the last version, and the status is 200.
+ *
+ * @return true if the status is 201
+ */
+ public boolean isCreated()
+ {
+ return responseStatusCode == 201;
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java b/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java
new file mode 100644
index 0000000..7fd78b1
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/exceptions/EmptyIdException.java
@@ -0,0 +1,27 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.exceptions;
+
+/**
+ * An email, block or version ID is empty or null.
+ * The SDK sends no request when it throws this exception.
+ */
+public class EmptyIdException extends IllegalArgumentException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Makes the exception.
+ *
+ * @param message the message
+ */
+ public EmptyIdException(String message)
+ {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java
new file mode 100644
index 0000000..9041517
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdException.java
@@ -0,0 +1,27 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.exceptions;
+
+/**
+ * An email, block or version ID contains a character that is not an ASCII digit (0 to 9).
+ * The SDK sends no request when it throws this exception.
+ */
+public class InvalidIdException extends IllegalArgumentException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Makes the exception.
+ *
+ * @param message the message
+ */
+ public InvalidIdException(String message)
+ {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java
new file mode 100644
index 0000000..0a4f80c
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/exceptions/InvalidIdempotencyKeyException.java
@@ -0,0 +1,28 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.exceptions;
+
+/**
+ * An idempotency key contains a character that is not printable ASCII (0x20 to 0x7E).
+ * The Java HTTP client sends each character that is not ASCII as "?", so the API would get a different key.
+ * The SDK sends no request when it throws this exception.
+ */
+public class InvalidIdempotencyKeyException extends IllegalArgumentException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Makes the exception.
+ *
+ * @param message the message
+ */
+ public InvalidIdempotencyKeyException(String message)
+ {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/exceptions/JsonResponseError.java b/src/main/java/com/mailerlite/sdk/exceptions/JsonResponseError.java
index 7bdb03e..efa9527 100644
--- a/src/main/java/com/mailerlite/sdk/exceptions/JsonResponseError.java
+++ b/src/main/java/com/mailerlite/sdk/exceptions/JsonResponseError.java
@@ -19,4 +19,10 @@ public class JsonResponseError {
/** The errors. */
public HashMap errors = new HashMap();
+
+ /** The error code, for example "version_conflict", or null. */
+ public String code;
+
+ /** The current etag of the email, or null. */
+ public String etag;
}
diff --git a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java
index 69310df..b69f5fb 100644
--- a/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java
+++ b/src/main/java/com/mailerlite/sdk/exceptions/MailerLiteException.java
@@ -14,10 +14,19 @@
*/
public class MailerLiteException extends Exception {
+ /**
+ * The value of {@link #errorCode} for a version conflict: the email changed after you read the etag that the
+ * write sent. {@code EmailErrorCodes.VERSION_CONFLICT} has the same value.
+ */
+ public static final String VERSION_CONFLICT = "version_conflict";
+
/** The code. */
public int code; // the response code
- /** The message. */
+ /**
+ * The {@code message} of the API error body, without the error code, or "" if the body has none.
+ * If the SDK cannot read the error body, or for a network error, the same text as {@link #getMessage()}.
+ */
public String message = "";
/** The response body. */
@@ -26,6 +35,18 @@ public class MailerLiteException extends Exception {
/** The errors. */
public HashMap errors = new HashMap();
+ /**
+ * The {@code code} string of the API error body, for example "version_conflict", or null if the body has none.
+ * The field {@code code} is the HTTP status.
+ */
+ public String errorCode = null;
+
+ /**
+ * The {@code etag} of the API error body, or null if the body has none.
+ * A version conflict error has the current etag of the email.
+ */
+ public String etag = null;
+
/**
* Constructor for MailerListException.
*
@@ -34,5 +55,37 @@ public class MailerLiteException extends Exception {
public MailerLiteException(String message) {
super(message);
+
+ if (message != null) {
+
+ this.message = message;
+ }
+ }
+
+ /**
+ * Makes an exception with a message and a cause, for example a network error.
+ *
+ * @param message the message
+ * @param cause the error that caused this exception
+ */
+ public MailerLiteException(String message, Throwable cause) {
+
+ super(message, cause);
+
+ if (message != null) {
+
+ this.message = message;
+ }
+ }
+
+ /**
+ * Tells if the error is a version conflict: the email changed after you read the etag that the write sent.
+ * Then {@link #etag} is the current etag of the email.
+ *
+ * @return true if {@link #errorCode} is {@link #VERSION_CONFLICT}
+ */
+ public boolean isVersionConflict() {
+
+ return VERSION_CONFLICT.equals(this.errorCode);
}
}
diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFile.java b/src/main/java/com/mailerlite/sdk/files/MediaFile.java
new file mode 100644
index 0000000..8ddea6e
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/files/MediaFile.java
@@ -0,0 +1,48 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.files;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A file in the media library of the account. Use its URL in image blocks.
+ */
+public class MediaFile {
+
+ /** The ID of the file. */
+ @SerializedName("id")
+ public String id;
+
+ /** The file type, for example "image". See MediaFileTypes. The SDK accepts any value. */
+ @SerializedName("type")
+ public String type;
+
+ /** The file name. */
+ @SerializedName("name")
+ public String name;
+
+ /** The public URL of the file. */
+ @SerializedName("url")
+ public String url;
+
+ /** The size of the file in bytes. */
+ @SerializedName("size")
+ public long size;
+
+ /** The width in pixels, or null if the file is not an image. */
+ @SerializedName("width")
+ public Integer width;
+
+ /** The height in pixels, or null if the file is not an image. */
+ @SerializedName("height")
+ public Integer height;
+
+ /** The date and time when the file was added, for example "2026-10-20 09:30:00". */
+ @SerializedName("created_at")
+ public String createdAt;
+}
diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java b/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java
new file mode 100644
index 0000000..359fa3d
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/files/MediaFileRetriever.java
@@ -0,0 +1,114 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.files;
+
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.MailerLiteApi;
+import com.mailerlite.sdk.exceptions.MailerLiteException;
+import com.mailerlite.sdk.util.CursorPaginatedRequest;
+
+/**
+ * Lists the files of the media library. Set the filters, the sort, the page and the limit, and then call get().
+ *
+ * The SDK encodes each value, so a space is sent as "+". A null value is not sent. An empty value is sent as
+ * "name=", and the API then ignores it.
+ */
+public class MediaFileRetriever extends CursorPaginatedRequest {
+
+ private MailerLite apiObjectReference;
+
+ /**
+ * Instantiates a new retriever. Use {@link MediaFiles#retriever()}.
+ *
+ * @param apiReference the api reference
+ */
+ public MediaFileRetriever(MailerLite apiReference)
+ {
+ apiObjectReference = apiReference;
+ }
+
+ /**
+ * Filters the files on a part of the name.
+ *
+ * @param name a part of the file name
+ * @return this retriever
+ */
+ public MediaFileRetriever nameFilter(String name)
+ {
+ return filter("name", name);
+ }
+
+ /**
+ * Filters the files on the type.
+ *
+ * @param type the file type, for example {@link MediaFileTypes#IMAGE}
+ * @return this retriever
+ */
+ public MediaFileRetriever typeFilter(String type)
+ {
+ return filter("type", type);
+ }
+
+ /**
+ * Adds a filter. The SDK sends it as "filter[name]=value".
+ *
+ * @param name the filter name, for example "name" or "type"
+ * @param value the value
+ * @return this retriever
+ */
+ public MediaFileRetriever filter(String name, String value)
+ {
+ this.addQueryParameter("filter[".concat(name).concat("]"), value);
+
+ return this;
+ }
+
+ /**
+ * Sorts the files.
+ *
+ * @param sort one of the values in {@link MediaFileSorts}
+ * @return this retriever
+ */
+ public MediaFileRetriever sort(String sort)
+ {
+ this.addQueryParameter("sort", sort);
+
+ return this;
+ }
+
+ /**
+ * Gets a page of files.
+ *
+ * @return the files
+ * @throws MailerLiteException if the API returns an error or the request fails
+ */
+ public MediaFilesList get() throws MailerLiteException
+ {
+ String query = this.getQueryParameters();
+
+ if (query.equals("?")) {
+ query = "";
+ }
+
+ MailerLiteApi api = new MailerLiteApi();
+ api.setToken(apiObjectReference.getToken());
+
+ return api.sendRequest("GET", "/files".concat(query), null, null, MediaFilesList.class);
+ }
+
+ /**
+ * Gets this instance.
+ *
+ * @return this retriever
+ */
+ @Override
+ protected MediaFileRetriever getInstance()
+ {
+ return this;
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java b/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java
new file mode 100644
index 0000000..0dc3608
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/files/MediaFileSorts.java
@@ -0,0 +1,36 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.files;
+
+/**
+ * The sort values of the file list. The default is {@link #CREATED_AT_DESC}: the newest files first.
+ */
+public final class MediaFileSorts {
+
+ /** The oldest files first. */
+ public static final String CREATED_AT = "created_at";
+
+ /** The newest files first. */
+ public static final String CREATED_AT_DESC = "-created_at";
+
+ /** By name, from A to Z. */
+ public static final String NAME = "name";
+
+ /** By name, from Z to A. */
+ public static final String NAME_DESC = "-name";
+
+ /** The smallest files first. */
+ public static final String SIZE = "size";
+
+ /** The largest files first. */
+ public static final String SIZE_DESC = "-size";
+
+ private MediaFileSorts()
+ {
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java b/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java
new file mode 100644
index 0000000..c102499
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/files/MediaFileTypes.java
@@ -0,0 +1,28 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.files;
+
+/**
+ * The file types of the media library, for the type filter. The API can add new types, so the filter accepts any
+ * string.
+ */
+public final class MediaFileTypes {
+
+ /** An image. */
+ public static final String IMAGE = "image";
+
+ /** A PDF document. */
+ public static final String PDF = "pdf";
+
+ /** An EPUB book. */
+ public static final String EPUB = "epub";
+
+ private MediaFileTypes()
+ {
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFiles.java b/src/main/java/com/mailerlite/sdk/files/MediaFiles.java
new file mode 100644
index 0000000..ad4d948
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/files/MediaFiles.java
@@ -0,0 +1,38 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.files;
+
+import com.mailerlite.sdk.MailerLite;
+
+/**
+ * The files endpoint: the files of the media library of the account.
+ */
+public class MediaFiles {
+
+ private MailerLite apiObjectReference;
+
+ /**
+ * Instantiates a new files service.
+ *
+ * @param apiReference the api reference
+ */
+ public MediaFiles(MailerLite apiReference)
+ {
+ apiObjectReference = apiReference;
+ }
+
+ /**
+ * Lists the files.
+ *
+ * @return the retriever
+ */
+ public MediaFileRetriever retriever()
+ {
+ return new MediaFileRetriever(apiObjectReference);
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/files/MediaFilesList.java b/src/main/java/com/mailerlite/sdk/files/MediaFilesList.java
new file mode 100644
index 0000000..ce8e31a
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/files/MediaFilesList.java
@@ -0,0 +1,21 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.files;
+
+import com.google.gson.annotations.SerializedName;
+import com.mailerlite.sdk.util.PaginatedResponse;
+
+/**
+ * The response of MediaFileRetriever.get.
+ */
+public class MediaFilesList extends PaginatedResponse {
+
+ /** The files. */
+ @SerializedName("data")
+ public MediaFile[] files;
+}
diff --git a/src/main/java/com/mailerlite/sdk/util/ApiRequest.java b/src/main/java/com/mailerlite/sdk/util/ApiRequest.java
index 32ab1c1..5e3f2bc 100644
--- a/src/main/java/com/mailerlite/sdk/util/ApiRequest.java
+++ b/src/main/java/com/mailerlite/sdk/util/ApiRequest.java
@@ -7,6 +7,8 @@
**************************************************/
package com.mailerlite.sdk.util;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
import java.util.HashMap;
/**
@@ -20,12 +22,20 @@ public class ApiRequest {
/**
* Adds the query parameter.
+ *
+ * A null value is not sent, and it removes a value that you set before. An empty value is sent as "name=".
*
- * @param name the name
- * @param value the value
+ * @param name the name, for example "filter[name]". The SDK sends it as it is.
+ * @param value the value. The SDK encodes it, so a space is sent as "+".
*/
public void addQueryParameter(String name, String value)
{
+ if (value == null) {
+
+ queryParameters.remove(name);
+ return;
+ }
+
queryParameters.put(name, value);
}
@@ -44,7 +54,7 @@ public String getQueryParameters()
query = query.concat("&");
}
- query = query.concat(name).concat("=").concat(queryParameters.get(name));
+ query = query.concat(name).concat("=").concat(URLEncoder.encode(queryParameters.get(name), StandardCharsets.UTF_8));
}
return "?".concat(query);
diff --git a/src/main/java/com/mailerlite/sdk/util/RequestChecks.java b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java
new file mode 100644
index 0000000..f7de987
--- /dev/null
+++ b/src/main/java/com/mailerlite/sdk/util/RequestChecks.java
@@ -0,0 +1,83 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.util;
+
+import com.mailerlite.sdk.exceptions.EmptyIdException;
+import com.mailerlite.sdk.exceptions.InvalidIdException;
+import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException;
+
+/**
+ * Checks the IDs in the URL path and the idempotency key of a request before the SDK sends it.
+ * Each check throws an exception that extends IllegalArgumentException, and the SDK then sends no request.
+ */
+public final class RequestChecks {
+
+ private RequestChecks()
+ {
+ }
+
+ /**
+ * Checks an ID that goes into the URL path of a request.
+ *
+ * The ID must contain only the ASCII digits 0 to 9. URL encoding is not sufficient, because the server
+ * decodes "%2F" to "/". Other Unicode digits, such as "²", are not accepted.
+ *
+ * @param value the ID
+ * @param name the name of the parameter, for the error message
+ * @return the ID
+ * @throws EmptyIdException if the ID is null or empty
+ * @throws InvalidIdException if the ID contains a character that is not an ASCII digit
+ */
+ public static String pathId(String value, String name)
+ {
+ if (value == null || value.isEmpty()) {
+ throw new EmptyIdException(name + " must not be empty");
+ }
+
+ for (int i = 0; i < value.length(); i++) {
+
+ char c = value.charAt(i);
+
+ if (c < '0' || c > '9') {
+ throw new InvalidIdException(name + " must contain only the digits 0 to 9");
+ }
+ }
+
+ return value;
+ }
+
+ /**
+ * Checks an idempotency key.
+ *
+ * The key must contain only printable ASCII characters (0x20 to 0x7E). The Java HTTP client sends each character
+ * that is not ASCII as "?", so the API would get a different key, and two different keys could become the same
+ * key. The API checks the length and the other rules of the key.
+ *
+ * @param key the idempotency key, or null
+ * @return the key
+ * @throws InvalidIdempotencyKeyException if the key contains a character outside 0x20 to 0x7E
+ */
+ public static String idempotencyKey(String key)
+ {
+ if (key == null) {
+ return null;
+ }
+
+ for (int i = 0; i < key.length(); i++) {
+
+ char c = key.charAt(i);
+
+ if (c < 0x20 || c > 0x7E) {
+ throw new InvalidIdempotencyKeyException("idempotency key must contain only printable ASCII characters"
+ + " (0x20 to 0x7E), because the Java HTTP client changes the other characters");
+ }
+ }
+
+ return key;
+ }
+}
diff --git a/src/main/java/com/mailerlite/sdk/util/ResponseMeta.java b/src/main/java/com/mailerlite/sdk/util/ResponseMeta.java
index 7bead1f..d3414ae 100644
--- a/src/main/java/com/mailerlite/sdk/util/ResponseMeta.java
+++ b/src/main/java/com/mailerlite/sdk/util/ResponseMeta.java
@@ -20,7 +20,7 @@ public class ResponseMeta {
@SerializedName("current_page")
public int currentPage;
- /** The from. */
+ /** The number of the first item on the page. It is 0 when the API sends null, for example for an empty page. */
@SerializedName("from")
public int from;
@@ -40,7 +40,7 @@ public class ResponseMeta {
@SerializedName("per_page")
public int perPage;
- /** The to. */
+ /** The number of the last item on the page. It is 0 when the API sends null, for example for an empty page. */
@SerializedName("to")
public int to;
diff --git a/src/test/java/com/mailerlite/sdk/support/Fixtures.java b/src/test/java/com/mailerlite/sdk/support/Fixtures.java
new file mode 100644
index 0000000..e2e816c
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/support/Fixtures.java
@@ -0,0 +1,35 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.support;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+
+public final class Fixtures {
+
+ private Fixtures()
+ {
+ }
+
+ public static String read(String name)
+ {
+ try (InputStream stream = Fixtures.class.getResourceAsStream("/responses/" + name)) {
+
+ if (stream == null) {
+ throw new IllegalArgumentException("No fixture with the name " + name);
+ }
+
+ return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java b/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java
new file mode 100644
index 0000000..58514bf
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/support/ReadmeExampleBase.java
@@ -0,0 +1,21 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.support;
+
+import com.mailerlite.sdk.MailerLite;
+
+public class ReadmeExampleBase {
+
+ public MailerLite getMailerLite()
+ {
+ MailerLite ml = new MailerLite();
+ ml.setToken("test-token");
+
+ return ml;
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java b/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java
new file mode 100644
index 0000000..aed59bd
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/support/RecordingHttpClient.java
@@ -0,0 +1,398 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.support;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.Authenticator;
+import java.net.CookieHandler;
+import java.net.ProxySelector;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandler;
+import java.net.http.HttpResponse.BodySubscriber;
+import java.net.http.HttpResponse.PushPromiseHandler;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Flow;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLSession;
+
+import com.mailerlite.sdk.util.MailerLiteHttpClientFactory;
+
+public class RecordingHttpClient extends HttpClient {
+
+ private final List requests = new ArrayList();
+
+ private final Deque responses = new ArrayDeque();
+
+ public static RecordingHttpClient install()
+ {
+ RecordingHttpClient client = new RecordingHttpClient();
+ MailerLiteHttpClientFactory.getInstance().setClient(client);
+
+ return client;
+ }
+
+ public static void uninstall()
+ {
+ MailerLiteHttpClientFactory.getInstance().setClient(null);
+ }
+
+ public RecordingHttpClient respond(int status, String body)
+ {
+ return respond(status, body, Collections.emptyMap());
+ }
+
+ public RecordingHttpClient respond(int status, String body, Map headers)
+ {
+ Map> headerMap = new LinkedHashMap>();
+
+ for (Map.Entry header : headers.entrySet()) {
+ headerMap.put(header.getKey(), Collections.singletonList(header.getValue()));
+ }
+
+ responses.add(new QueuedResponse(status, body, HttpHeaders.of(headerMap, (name, value) -> true)));
+
+ return this;
+ }
+
+ /** The error must be an IOException or an InterruptedException. */
+ public RecordingHttpClient fail(Exception error)
+ {
+ responses.add(error);
+
+ return this;
+ }
+
+ public List requests()
+ {
+ return requests;
+ }
+
+ public RecordedRequest lastRequest()
+ {
+ if (requests.isEmpty()) {
+ throw new AssertionError("The client got no request");
+ }
+
+ return requests.get(requests.size() - 1);
+ }
+
+ public int pendingResponses()
+ {
+ return responses.size();
+ }
+
+ @Override
+ public HttpResponse send(HttpRequest request, BodyHandler responseBodyHandler)
+ throws IOException, InterruptedException
+ {
+ requests.add(new RecordedRequest(request));
+
+ Object next = responses.poll();
+
+ if (next == null) {
+ throw new IOException("RecordingHttpClient has no queued response");
+ }
+
+ if (next instanceof IOException) {
+ throw (IOException) next;
+ }
+
+ if (next instanceof InterruptedException) {
+ throw (InterruptedException) next;
+ }
+
+ QueuedResponse queued = (QueuedResponse) next;
+
+ return new FakeResponse(request, queued, readBody(queued, responseBodyHandler));
+ }
+
+ private T readBody(QueuedResponse queued, BodyHandler handler)
+ {
+ BodySubscriber subscriber = handler.apply(new HttpResponse.ResponseInfo() {
+
+ @Override
+ public int statusCode()
+ {
+ return queued.status;
+ }
+
+ @Override
+ public HttpHeaders headers()
+ {
+ return queued.headers;
+ }
+
+ @Override
+ public Version version()
+ {
+ return Version.HTTP_1_1;
+ }
+ });
+
+ subscriber.onSubscribe(new Flow.Subscription() {
+
+ @Override
+ public void request(long n)
+ {
+ // the body is given at once below
+ }
+
+ @Override
+ public void cancel()
+ {
+ }
+ });
+
+ subscriber.onNext(Collections.singletonList(ByteBuffer.wrap(queued.body.getBytes(StandardCharsets.UTF_8))));
+ subscriber.onComplete();
+
+ return subscriber.getBody().toCompletableFuture().join();
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(HttpRequest request, BodyHandler responseBodyHandler)
+ {
+ throw new UnsupportedOperationException("RecordingHttpClient supports only send()");
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(HttpRequest request, BodyHandler responseBodyHandler,
+ PushPromiseHandler pushPromiseHandler)
+ {
+ throw new UnsupportedOperationException("RecordingHttpClient supports only send()");
+ }
+
+ @Override
+ public Optional cookieHandler()
+ {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional connectTimeout()
+ {
+ return Optional.empty();
+ }
+
+ @Override
+ public Redirect followRedirects()
+ {
+ return Redirect.NEVER;
+ }
+
+ @Override
+ public Optional proxy()
+ {
+ return Optional.empty();
+ }
+
+ @Override
+ public SSLContext sslContext()
+ {
+ return null;
+ }
+
+ @Override
+ public SSLParameters sslParameters()
+ {
+ return null;
+ }
+
+ @Override
+ public Optional authenticator()
+ {
+ return Optional.empty();
+ }
+
+ @Override
+ public Version version()
+ {
+ return Version.HTTP_1_1;
+ }
+
+ @Override
+ public Optional executor()
+ {
+ return Optional.empty();
+ }
+
+ public static class RecordedRequest {
+
+ public final String method;
+
+ public final URI uri;
+
+ public final HttpHeaders headers;
+
+ /** True if the request has a body publisher. GET() and DELETE() give no publisher. */
+ public final boolean hasBodyPublisher;
+
+ /** The content length of the body publisher, or -2 if there is no publisher. */
+ public final long contentLength;
+
+ public final byte[] body;
+
+ RecordedRequest(HttpRequest request)
+ {
+ this.method = request.method();
+ this.uri = request.uri();
+ this.headers = request.headers();
+ this.hasBodyPublisher = request.bodyPublisher().isPresent();
+ this.contentLength = request.bodyPublisher().map(p -> p.contentLength()).orElse(-2L);
+ this.body = request.bodyPublisher().map(RecordedRequest::readAll).orElse(new byte[0]);
+ }
+
+ public String bodyString()
+ {
+ return new String(body, StandardCharsets.UTF_8);
+ }
+
+ public String header(String name)
+ {
+ return headers.firstValue(name).orElse(null);
+ }
+
+ private static byte[] readAll(HttpRequest.BodyPublisher publisher)
+ {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ CompletableFuture done = new CompletableFuture();
+
+ publisher.subscribe(new Flow.Subscriber() {
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription)
+ {
+ subscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(ByteBuffer item)
+ {
+ byte[] chunk = new byte[item.remaining()];
+ item.get(chunk);
+ bytes.write(chunk, 0, chunk.length);
+ }
+
+ @Override
+ public void onError(Throwable throwable)
+ {
+ done.completeExceptionally(throwable);
+ }
+
+ @Override
+ public void onComplete()
+ {
+ done.complete(null);
+ }
+ });
+
+ done.join();
+
+ return bytes.toByteArray();
+ }
+ }
+
+ private static class QueuedResponse {
+
+ final int status;
+
+ final String body;
+
+ final HttpHeaders headers;
+
+ QueuedResponse(int status, String body, HttpHeaders headers)
+ {
+ this.status = status;
+ this.body = body;
+ this.headers = headers;
+ }
+ }
+
+ private static class FakeResponse implements HttpResponse {
+
+ private final HttpRequest request;
+
+ private final QueuedResponse queued;
+
+ private final T body;
+
+ FakeResponse(HttpRequest request, QueuedResponse queued, T body)
+ {
+ this.request = request;
+ this.queued = queued;
+ this.body = body;
+ }
+
+ @Override
+ public int statusCode()
+ {
+ return queued.status;
+ }
+
+ @Override
+ public HttpRequest request()
+ {
+ return request;
+ }
+
+ @Override
+ public Optional> previousResponse()
+ {
+ return Optional.empty();
+ }
+
+ @Override
+ public HttpHeaders headers()
+ {
+ return queued.headers;
+ }
+
+ @Override
+ public T body()
+ {
+ return body;
+ }
+
+ @Override
+ public Optional sslSession()
+ {
+ return Optional.empty();
+ }
+
+ @Override
+ public URI uri()
+ {
+ return request.uri();
+ }
+
+ @Override
+ public Version version()
+ {
+ return Version.HTTP_1_1;
+ }
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/support/UrlAssert.java b/src/test/java/com/mailerlite/sdk/support/UrlAssert.java
new file mode 100644
index 0000000..c0e0990
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/support/UrlAssert.java
@@ -0,0 +1,49 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.support;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public final class UrlAssert {
+
+ private UrlAssert()
+ {
+ }
+
+ public static boolean sameUrl(String expected, String actual)
+ {
+ return normalize(expected).equals(normalize(actual));
+ }
+
+ public static void assertUrlEquals(String expected, String actual)
+ {
+ if (!sameUrl(expected, actual)) {
+ assertEquals(expected, actual, "the URLs are different (the sequence of the query parameters is ignored)");
+ }
+ }
+
+ /** Sorts the query pairs, because ApiRequest keeps the parameters in a HashMap with no fixed sequence. */
+ private static String normalize(String url)
+ {
+ int mark = url.indexOf('?');
+
+ if (mark < 0) {
+ return url;
+ }
+
+ List pairs = new ArrayList(Arrays.asList(url.substring(mark + 1).split("&", -1)));
+ Collections.sort(pairs);
+
+ return url.substring(0, mark + 1) + pairs;
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java
new file mode 100644
index 0000000..f02c789
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/tests/ApiRequestTest.java
@@ -0,0 +1,95 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+import com.mailerlite.sdk.util.ApiRequest;
+
+public class ApiRequestTest {
+
+ @Test
+ public void skipsNullValues()
+ {
+ ApiRequest request = new ApiRequest();
+ request.addQueryParameter("filter[type]", null);
+ request.addQueryParameter("page", "2");
+
+ assertEquals("?page=2", request.getQueryParameters());
+ }
+
+ @Test
+ public void nullValueRemovesTheEarlierValue()
+ {
+ ApiRequest request = new ApiRequest();
+ request.addQueryParameter("filter[type]", "image");
+ request.addQueryParameter("filter[type]", null);
+
+ assertEquals("?", request.getQueryParameters());
+ }
+
+ @Test
+ public void encodesSpacesInValues()
+ {
+ ApiRequest request = new ApiRequest();
+ request.addQueryParameter("filter[name]", "summer sale banner");
+
+ assertEquals("?filter[name]=summer+sale+banner", request.getQueryParameters());
+ }
+
+ @Test
+ public void encodesReservedAndNonAsciiCharacters()
+ {
+ ApiRequest request = new ApiRequest();
+ request.addQueryParameter("filter[name]", "a&b=c#d/é");
+
+ assertEquals("?filter[name]=a%26b%3Dc%23d%2F%C3%A9", request.getQueryParameters());
+ }
+
+ @Test
+ public void sendsEmptyValue()
+ {
+ ApiRequest request = new ApiRequest();
+ request.addQueryParameter("filter[name]", "");
+
+ assertEquals("?filter[name]=", request.getQueryParameters());
+ }
+
+ /**
+ * A value with only safe characters does not change, so the recorded tapes still match.
+ */
+ @Test
+ public void keepsSafeValues()
+ {
+ ApiRequest request = new ApiRequest();
+ request.addQueryParameter("filter[status]", "active");
+
+ assertEquals("?filter[status]=active", request.getQueryParameters());
+ }
+
+ @Test
+ public void encodesAnEncodedValueAgain()
+ {
+ ApiRequest request = new ApiRequest();
+ request.addQueryParameter("filter[name]", "summer%20sale");
+
+ assertEquals("?filter[name]=summer%2520sale", request.getQueryParameters());
+ }
+
+ @Test
+ public void sendsPlusAsLiteralPlus()
+ {
+ ApiRequest request = new ApiRequest();
+ request.addQueryParameter("filter[name]", "a+b");
+
+ assertEquals("?filter[name]=a%2Bb", request.getQueryParameters());
+ }
+
+}
diff --git a/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java b/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java
new file mode 100644
index 0000000..8f8a276
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/tests/BrandStylesTest.java
@@ -0,0 +1,87 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.brandstyles.BrandStyle;
+import com.mailerlite.sdk.brandstyles.BrandStylesData;
+import com.mailerlite.sdk.brandstyles.SingleBrandStyles;
+import com.mailerlite.sdk.support.Fixtures;
+import com.mailerlite.sdk.support.RecordingHttpClient;
+
+public class BrandStylesTest {
+
+ private RecordingHttpClient client;
+
+ private MailerLite ml;
+
+ @BeforeEach
+ public void setupEach()
+ {
+ client = RecordingHttpClient.install();
+
+ ml = new MailerLite();
+ ml.setToken("test-token");
+ }
+
+ @AfterEach
+ public void afterEach()
+ {
+ RecordingHttpClient.uninstall();
+ }
+
+ @Test
+ public void getDecodesThePalettes() throws Exception
+ {
+ client.respond(200, Fixtures.read("brand_styles.json"));
+
+ SingleBrandStyles response = ml.brandStyles().get();
+
+ EmailsTest.assertRequest(client, "GET", "/brand-styles", null, null);
+
+ BrandStylesData data = response.brandStyles;
+ assertEquals("account", data.brand.identifier);
+ assertEquals("Brand styles", data.brand.name);
+ assertEquals("#2cb191", data.brand.primaryColor);
+
+ BrandStyle defaultStyle = data.defaultStyle;
+ assertEquals("default-brand", defaultStyle.identifier);
+ assertEquals("Default", defaultStyle.name);
+ assertEquals("Inter, sans-serif", defaultStyle.fontFamily);
+ assertEquals("#09c269", defaultStyle.primaryColor);
+ assertEquals("#000000", defaultStyle.secondaryColor);
+ assertEquals("#000000", defaultStyle.headingColor);
+ assertEquals("#000000", defaultStyle.textColor);
+ assertEquals("#e5e7eb", defaultStyle.borderColor);
+ assertEquals("#ffffff", defaultStyle.backgroundColor);
+
+ assertEquals(2, data.presets.length);
+ assertEquals("preset-business-casual", data.presets[0].identifier);
+ assertEquals("preset-retro", data.presets[1].identifier);
+ assertEquals("Courier New, monospace", data.presets[1].fontFamily);
+ }
+
+ @Test
+ public void getWithoutBrand() throws Exception
+ {
+ client.respond(200, "{\"data\":{\"brand\":null,\"default\":{\"identifier\":\"default-brand\",\"name\":\"Default\"},\"presets\":[]}}");
+
+ SingleBrandStyles response = ml.brandStyles().get();
+
+ assertNull(response.brandStyles.brand);
+ assertEquals("default-brand", response.brandStyles.defaultStyle.identifier);
+ assertEquals(0, response.brandStyles.presets.length);
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java b/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java
new file mode 100644
index 0000000..42b2e3d
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/tests/CampaignSimpleEditorTest.java
@@ -0,0 +1,92 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.campaigns.SingleCampaign;
+import com.mailerlite.sdk.emails.Email;
+import com.mailerlite.sdk.emails.EmailBase;
+import com.mailerlite.sdk.emails.EmailEditors;
+import com.mailerlite.sdk.support.Fixtures;
+import com.mailerlite.sdk.support.RecordingHttpClient;
+
+public class CampaignSimpleEditorTest {
+
+ private RecordingHttpClient client;
+
+ private MailerLite ml;
+
+ @BeforeEach
+ public void setupEach()
+ {
+ client = RecordingHttpClient.install();
+ client.respond(201, Fixtures.read("campaign_simple_editor.json"));
+
+ ml = new MailerLite();
+ ml.setToken("test-token");
+ }
+
+ @AfterEach
+ public void afterEach()
+ {
+ RecordingHttpClient.uninstall();
+ }
+
+ private EmailBase email()
+ {
+ EmailBase email = new EmailBase();
+ email.from = "sender@example.com";
+ email.fromName = "Sender";
+ email.subject = "Hello";
+
+ return email;
+ }
+
+ @Test
+ public void createWithoutEditorSendsTheSameBodyAsBefore() throws Exception
+ {
+ EmailBase email = email();
+ email.content = "Hello
";
+
+ ml.campaigns().builder().name("Newsletter").type("regular").email(email).create();
+
+ assertEquals("POST", client.lastRequest().method);
+ assertEquals("https://connect.mailerlite.com/api/campaigns", client.lastRequest().uri.toString());
+ assertEquals("{\"name\":\"Newsletter\",\"language_id\":4,\"type\":\"regular\",\"emails\":[{\"from\":\"sender@example.com\","
+ + "\"from_name\":\"Sender\",\"content\":\"\\u003cdiv\\u003eHello\\u003c/div\\u003e\",\"subject\":\"Hello\"}],"
+ + "\"groups\":[],\"segments\":[],\"ab_settings\":{},\"resend_settings\":{}}", client.lastRequest().bodyString());
+ }
+
+ @Test
+ public void createWithSimpleEditor() throws Exception
+ {
+ EmailBase email = email();
+ email.replyTo = "reply@example.com";
+ email.editor = EmailEditors.SIMPLE;
+ email.templateId = "77";
+
+ SingleCampaign response = ml.campaigns().builder().name("Simple editor campaign").type("regular").email(email).create();
+
+ assertEquals("{\"name\":\"Simple editor campaign\",\"language_id\":4,\"type\":\"regular\",\"emails\":[{"
+ + "\"from\":\"sender@example.com\",\"from_name\":\"Sender\",\"subject\":\"Hello\","
+ + "\"reply_to\":\"reply@example.com\",\"editor\":\"simple\",\"template_id\":\"77\"}],"
+ + "\"groups\":[],\"segments\":[],\"ab_settings\":{},\"resend_settings\":{}}", client.lastRequest().bodyString());
+
+ Email created = response.campaign.emails[0];
+ assertEquals("123", created.id);
+ assertEquals("builder_inline_email", created.type);
+ assertEquals("reply@example.com", created.replyTo);
+ assertEquals("456", response.campaign.id);
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java
new file mode 100644
index 0000000..ed78be7
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/tests/EmailsTest.java
@@ -0,0 +1,951 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.tests;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.emails.EmailBlock;
+import com.mailerlite.sdk.emails.EmailBlockInput;
+import com.mailerlite.sdk.emails.EmailBlockPosition;
+import com.mailerlite.sdk.emails.EmailBlockPositions;
+import com.mailerlite.sdk.emails.EmailBlockTypes;
+import com.mailerlite.sdk.emails.EmailDocument;
+import com.mailerlite.sdk.emails.EmailEtagResponse;
+import com.mailerlite.sdk.emails.EmailBrandModes;
+import com.mailerlite.sdk.emails.EmailBrandSettings;
+import com.mailerlite.sdk.emails.EmailEditorText;
+import com.mailerlite.sdk.emails.EmailFooterSettings;
+import com.mailerlite.sdk.emails.EmailLayoutSettings;
+import com.mailerlite.sdk.emails.EmailParagraphStyle;
+import com.mailerlite.sdk.emails.EmailSettings;
+import com.mailerlite.sdk.emails.EmailTextStyle;
+import com.mailerlite.sdk.emails.EmailTypographySettings;
+import com.mailerlite.sdk.emails.SingleEmailSettings;
+import com.mailerlite.sdk.emails.SingleEmailTemplate;
+import com.mailerlite.sdk.emails.SingleEmailVersion;
+import com.mailerlite.sdk.emails.EmailSocialLink;
+import com.mailerlite.sdk.emails.EmailValidation;
+import com.mailerlite.sdk.emails.EmailVersionsList;
+import com.mailerlite.sdk.emails.Emails;
+import com.mailerlite.sdk.emails.SingleEmailBlock;
+import com.mailerlite.sdk.emails.SingleEmailDocument;
+import com.mailerlite.sdk.emails.SingleEmailHtml;
+import com.mailerlite.sdk.exceptions.EmptyIdException;
+import com.mailerlite.sdk.exceptions.InvalidIdException;
+import com.mailerlite.sdk.exceptions.MailerLiteException;
+import com.mailerlite.sdk.support.Fixtures;
+import com.mailerlite.sdk.support.RecordingHttpClient;
+import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest;
+import com.mailerlite.sdk.support.UrlAssert;
+
+public class EmailsTest {
+
+ static final String BASE = "https://connect.mailerlite.com/api";
+
+ private RecordingHttpClient client;
+
+ private Emails emails;
+
+ @BeforeEach
+ public void setupEach()
+ {
+ client = RecordingHttpClient.install();
+
+ MailerLite ml = new MailerLite();
+ ml.setToken("test-token");
+ emails = ml.emails();
+ }
+
+ @AfterEach
+ public void afterEach()
+ {
+ RecordingHttpClient.uninstall();
+ }
+
+ static RecordedRequest assertRequest(RecordingHttpClient client, String method, String path, String body,
+ String idempotencyKey)
+ {
+ assertEquals(1, client.requests().size(), "number of requests");
+
+ RecordedRequest request = client.lastRequest();
+ assertEquals(method, request.method);
+ UrlAssert.assertUrlEquals(BASE + path, request.uri.toString());
+
+ Set expectedHeaders = new TreeSet(String.CASE_INSENSITIVE_ORDER);
+ expectedHeaders.addAll(Arrays.asList("Content-Type", "Accept", "Authorization"));
+
+ if (idempotencyKey != null) {
+ expectedHeaders.add("Idempotency-Key");
+ }
+
+ Set actualHeaders = new TreeSet(String.CASE_INSENSITIVE_ORDER);
+ actualHeaders.addAll(request.headers.map().keySet());
+ assertEquals(expectedHeaders, actualHeaders);
+
+ assertEquals("application/json", request.header("Content-Type"));
+ assertEquals("application/json", request.header("Accept"));
+ assertEquals("Bearer test-token", request.header("Authorization"));
+ assertEquals(idempotencyKey, request.header("Idempotency-Key"));
+
+ if (body == null) {
+ assertFalse(request.hasBodyPublisher, "the request must have no body");
+ } else {
+ assertEquals(body, request.bodyString());
+ }
+
+ return request;
+ }
+
+ static void assertRejectsIds(RecordingHttpClient client, Function call)
+ {
+ for (String id : Arrays.asList("", null)) {
+ assertThrows(EmptyIdException.class, call.apply(id), "ID " + id);
+ }
+
+ for (String id : RequestChecksTest.INVALID_IDS) {
+ assertThrows(InvalidIdException.class, call.apply(id), "ID " + id);
+ }
+
+ assertTrue(client.requests().isEmpty(), "the SDK must send no request");
+ }
+
+ @SuppressWarnings("unchecked")
+ static Map map(Map map, String key)
+ {
+ return (Map) map.get(key);
+ }
+
+ @Test
+ public void getDecodesTheEmail() throws Exception
+ {
+ client.respond(200, Fixtures.read("email.json"));
+
+ SingleEmailDocument response = emails.get("123");
+
+ assertRequest(client, "GET", "/emails/123", null, null);
+ assertEquals(200, response.responseStatusCode);
+ assertEquals("42", response.etag);
+
+ EmailDocument email = response.email;
+ assertEquals("123", email.id);
+ assertEquals("builder_inline_email", email.type);
+ assertEquals("campaigns", email.emailableType);
+ assertEquals("456", email.emailableId);
+
+ EmailSettings settings = email.settings;
+ assertEquals("account", settings.brand.mode);
+ assertNull(settings.brand.style);
+ assertEquals("Inter, sans-serif", settings.brand.fontFamily);
+ assertEquals("#ffffff", settings.brand.backgroundColor);
+ assertEquals("#000000", settings.brand.textColor);
+ assertEquals("#e5e7eb", settings.brand.borderColor);
+ assertEquals("default", settings.layout.widthMode);
+ assertEquals("center", settings.layout.alignment);
+ assertEquals("#2cb191", settings.typography.link.color);
+ assertEquals("Inter, sans-serif", settings.typography.heading.fontFamily);
+ assertEquals("700", settings.typography.heading.fontWeight);
+ assertEquals("150%", settings.typography.heading.lineHeight);
+ assertEquals("0", settings.typography.heading.letterSpacing);
+ assertEquals("32", settings.typography.heading.h1.size);
+ assertEquals("24", settings.typography.heading.h2.size);
+ assertEquals("20", settings.typography.heading.h3.size);
+ assertEquals("#000000", settings.typography.heading.h3.color);
+ assertEquals("400", settings.typography.paragraph.fontWeight);
+ assertEquals("16", settings.typography.paragraph.p1.size);
+ assertEquals("Inter, sans-serif", settings.button.fontFamily);
+ assertEquals("600", settings.button.fontWeight);
+ assertEquals("0", settings.button.letterSpacing);
+ assertEquals("#2cb191", settings.button.backgroundColor);
+ assertEquals("#ffffff", settings.button.textColor);
+ assertEquals("4", settings.button.borderRadius);
+ assertEquals(Boolean.FALSE, settings.footer.showPreferences);
+ assertEquals("Unsubscribe", settings.footer.unsubscribeText);
+ assertEquals("Update your preferences", settings.footer.preferencesText);
+
+ @SuppressWarnings("unchecked")
+ List> companyName = (List>) settings.footer.companyName.editorContent.get("content");
+ assertEquals("Acme", companyName.get(0).get("text"));
+
+ @SuppressWarnings("unchecked")
+ List> address = (List>) settings.footer.address.editorContent.get("content");
+ assertEquals("1 Main Street", address.get(0).get("text"));
+
+ @SuppressWarnings("unchecked")
+ List> infoText = (List>) settings.footer.infoText.editorContent.get("content");
+ assertEquals("You signed up on our website.", infoText.get(0).get("text"));
+
+ assertEquals(1, settings.footer.socialLinks.size());
+ EmailSocialLink link = settings.footer.socialLinks.get(0);
+ assertEquals("https://example.com", link.url);
+ assertEquals("website", link.type);
+ assertEquals("Website", link.label);
+ assertEquals(Long.valueOf(7), link.linkId);
+
+ assertEquals(2, email.blocks.length);
+ EmailBlock paragraph = email.blocks[0];
+ assertEquals("1", paragraph.id);
+ assertEquals("paragraph", paragraph.type);
+ assertEquals("left", map(paragraph.editorContent, "attrs").get("textAlign"));
+ assertNull(paragraph.children);
+ assertNull(paragraph.placement);
+
+ EmailBlock list = email.blocks[1];
+ assertEquals("2", list.id);
+ assertEquals("bulleted_list", list.type);
+ assertEquals(Collections.emptyMap(), list.editorContent);
+ assertEquals(1, list.children.length);
+ assertEquals("3", list.children[0].id);
+ assertEquals("paragraph", list.children[0].type);
+ }
+
+ @Test
+ public void getRejectsInvalidIds()
+ {
+ assertRejectsIds(client, id -> () -> emails.get(id));
+ }
+
+ @Test
+ public void getHtmlDecodesTheHtml() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_html.json"));
+
+ SingleEmailHtml response = emails.getHtml("123");
+
+ assertRequest(client, "GET", "/emails/123/html", null, null);
+ assertEquals("Hello
", response.preview.html);
+ assertEquals("42", response.etag);
+ }
+
+ @Test
+ public void getHtmlServiceUnavailable()
+ {
+ client.respond(503, "Service Unavailable");
+
+ MailerLiteException e = assertThrows(MailerLiteException.class, () -> emails.getHtml("123"));
+
+ assertEquals(503, e.code);
+ assertEquals("Service Unavailable", e.responseBody);
+ }
+
+ @Test
+ public void getHtmlRejectsInvalidIds()
+ {
+ assertRejectsIds(client, id -> () -> emails.getHtml(id));
+ }
+
+
+ @Test
+ public void getBlockDecodesTheBlock() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_block.json"));
+
+ SingleEmailBlock response = emails.getBlock("123", "57");
+
+ assertRequest(client, "GET", "/emails/123/blocks/57", null, null);
+ assertEquals("43", response.etag);
+ assertEquals("57", response.block.id);
+ assertEquals("paragraph", response.block.type);
+ assertEquals("left", map(response.block.editorContent, "attrs").get("textAlign"));
+ assertEquals("56", response.block.placement.after);
+ assertNull(response.block.placement.before);
+ assertNull(response.block.children);
+ }
+
+ @Test
+ public void getBlockRejectsInvalidIds()
+ {
+ assertRejectsIds(client, id -> () -> emails.getBlock(id, "57"));
+ assertRejectsIds(client, id -> () -> emails.getBlock("123", id));
+ }
+
+
+ static Map paragraph(String align, String text)
+ {
+ Map attrs = new LinkedHashMap();
+ attrs.put("textAlign", align);
+
+ Map node = new LinkedHashMap();
+ node.put("type", "text");
+ node.put("text", text);
+
+ Map content = new LinkedHashMap();
+ content.put("attrs", attrs);
+ content.put("content", Collections.singletonList(node));
+
+ return content;
+ }
+
+ /**
+ * The API checks the etag. The SDK sends an empty etag as it is, and sends no etag key for a null etag.
+ */
+ static void assertSendsEtags(RecordingHttpClient client, String response, Function call,
+ Function expectedBody) throws Throwable
+ {
+ client.requests().clear();
+ client.respond(200, response).respond(200, response);
+
+ call.apply("").execute();
+ assertEquals(expectedBody.apply(",\"etag\":\"\""), client.lastRequest().bodyString(), "empty etag");
+
+ call.apply(null).execute();
+ assertEquals(expectedBody.apply(""), client.lastRequest().bodyString(), "null etag");
+ assertEquals(2, client.requests().size(), "number of requests");
+ }
+
+ /**
+ * The API checks the placement. The SDK sends the placement exactly as the caller gives it.
+ */
+ static void assertSendsThePlacement(RecordingHttpClient client, String response,
+ Function call, Function expectedBody) throws Throwable
+ {
+ Object[][] sent = {
+ {EmailBlockPositions.END, null, "{\"position\":\"end\"}"},
+ {EmailBlockPositions.END, "", "{\"position\":\"end\",\"block_id\":\"\"}"},
+ {EmailBlockPositions.START, "5", "{\"position\":\"start\",\"block_id\":\"5\"}"},
+ {EmailBlockPositions.START, "1/2", "{\"position\":\"start\",\"block_id\":\"1/2\"}"},
+ {EmailBlockPositions.AFTER, "57", "{\"position\":\"after\",\"block_id\":\"57\"}"},
+ {EmailBlockPositions.AFTER, null, "{\"position\":\"after\"}"},
+ {EmailBlockPositions.AFTER, "", "{\"position\":\"after\",\"block_id\":\"\"}"},
+ {EmailBlockPositions.AFTER, "\u00b2", "{\"position\":\"after\",\"block_id\":\"\u00b2\"}"},
+ {EmailBlockPositions.BEFORE, "1", "{\"position\":\"before\",\"block_id\":\"1\"}"},
+ {EmailBlockPositions.BEFORE, null, "{\"position\":\"before\"}"},
+ {EmailBlockPositions.BEFORE, "1/2", "{\"position\":\"before\",\"block_id\":\"1/2\"}"},
+ {"middle", "5", "{\"position\":\"middle\",\"block_id\":\"5\"}"},
+ {"middle", null, "{\"position\":\"middle\"}"},
+ {"", "5", "{\"position\":\"\",\"block_id\":\"5\"}"},
+ {null, "5", "{\"block_id\":\"5\"}"},
+ {null, null, "{}"},
+ };
+
+ for (Object[] item : sent) {
+
+ client.requests().clear();
+ client.respond(200, response);
+
+ EmailBlockPosition placement = new EmailBlockPosition((String) item[0], (String) item[1]);
+ call.apply(placement).execute();
+
+ assertEquals(1, client.requests().size(), item[0] + " " + item[1]);
+ assertEquals(expectedBody.apply((String) item[2]), client.lastRequest().bodyString(), item[0] + " " + item[1]);
+ assertEquals(item[0], placement.position, "the SDK must not change the placement of the caller");
+ assertEquals(item[1], placement.blockId, "the SDK must not change the placement of the caller");
+ }
+ }
+
+ @Test
+ public void createBlockSendsTheBlock() throws Exception
+ {
+ client.respond(201, Fixtures.read("email_block_created.json"));
+
+ EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.PARAGRAPH, paragraph("center", "Hi"));
+ block.placement = new EmailBlockPosition(EmailBlockPositions.AFTER, "57");
+
+ SingleEmailBlock response = emails.createBlock("123", "42", block);
+
+ assertRequest(client, "POST", "/emails/123/blocks",
+ "{\"type\":\"paragraph\",\"editor_content\":{\"attrs\":{\"textAlign\":\"center\"},"
+ + "\"content\":[{\"type\":\"text\",\"text\":\"Hi\"}]},"
+ + "\"placement\":{\"position\":\"after\",\"block_id\":\"57\"},\"etag\":\"42\"}", null);
+ assertEquals(201, response.responseStatusCode);
+ assertEquals("43", response.etag);
+ assertEquals("58", response.block.id);
+ assertEquals("center", map(response.block.editorContent, "attrs").get("textAlign"));
+ assertEquals("57", response.block.placement.after);
+ assertNull(response.block.placement.before);
+ }
+
+ @Test
+ public void createBlockWithoutPlacement() throws Exception
+ {
+ client.respond(201, Fixtures.read("email_block_created.json")).respond(201, Fixtures.read("email_block_created.json"));
+
+ emails.createBlock("123", "42", new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()));
+
+ assertEquals("{\"type\":\"divider\",\"editor_content\":{},\"etag\":\"42\"}", client.lastRequest().bodyString());
+
+ Map attrs = new LinkedHashMap();
+ attrs.put("url", null);
+ Map content = new LinkedHashMap();
+ content.put("attrs", attrs);
+
+ emails.createBlock("123", "42", new EmailBlockInput(EmailBlockTypes.BUTTON, content));
+
+ assertEquals("{\"type\":\"button\",\"editor_content\":{\"attrs\":{\"url\":null}},\"etag\":\"42\"}",
+ client.lastRequest().bodyString());
+ }
+
+ @Test
+ public void createBlockSendsThePlacementAsGiven() throws Throwable
+ {
+ assertSendsThePlacement(client, Fixtures.read("email_block_created.json"), placement -> () -> {
+ EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap());
+ block.placement = placement;
+ emails.createBlock("123", "42", block);
+ }, json -> "{\"type\":\"divider\",\"editor_content\":{},\"placement\":" + json + ",\"etag\":\"42\"}");
+ }
+
+ @Test
+ public void createBlockRejectsInvalidInput()
+ {
+ EmailBlockInput divider = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap());
+
+ assertRejectsIds(client, id -> () -> emails.createBlock(id, "42", divider));
+
+ NullPointerException noBlock = assertThrows(NullPointerException.class, () -> emails.createBlock("123", "42", null));
+ assertTrue(noBlock.getMessage().contains("block"), noBlock.getMessage());
+
+ assertTrue(client.requests().isEmpty(), "the SDK must send no request");
+ }
+
+ @Test
+ public void createBlockSendsTheEtagAsGiven() throws Throwable
+ {
+ EmailBlockInput divider = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap());
+
+ assertSendsEtags(client, Fixtures.read("email_block_created.json"),
+ etag -> () -> emails.createBlock("123", etag, divider),
+ etag -> "{\"type\":\"divider\",\"editor_content\":{}" + etag + "}");
+ }
+
+ @Test
+ public void createBlockSendsTheBlockValuesAsGiven() throws Exception
+ {
+ client.respond(422, "{\"message\":\"The given data was invalid.\","
+ + "\"errors\":{\"type\":[\"The type field is required.\"]}}")
+ .respond(201, Fixtures.read("email_block_created.json"))
+ .respond(201, Fixtures.read("email_block_created.json"));
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> emails.createBlock("123", "42", new EmailBlockInput("", new LinkedHashMap())));
+
+ assertEquals("{\"type\":\"\",\"editor_content\":{},\"etag\":\"42\"}", client.lastRequest().bodyString());
+ assertEquals(422, e.code);
+ assertArrayEquals(new String[] {"The type field is required."}, e.errors.get("type"));
+
+ emails.createBlock("123", "42", new EmailBlockInput(null, new LinkedHashMap()));
+ assertEquals("{\"editor_content\":{},\"etag\":\"42\"}", client.lastRequest().bodyString());
+
+ emails.createBlock("123", "42", new EmailBlockInput(EmailBlockTypes.DIVIDER, null));
+ assertEquals("{\"type\":\"divider\",\"etag\":\"42\"}", client.lastRequest().bodyString());
+ assertEquals(3, client.requests().size());
+ }
+
+
+ @Test
+ public void updateBlockSendsTheEditorContent() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_block_updated.json"));
+
+ SingleEmailBlock response = emails.updateBlock("123", "57", "43", paragraph("center", "Hello again"));
+
+ assertRequest(client, "PATCH", "/emails/123/blocks/57",
+ "{\"editor_content\":{\"attrs\":{\"textAlign\":\"center\"},"
+ + "\"content\":[{\"type\":\"text\",\"text\":\"Hello again\"}]},\"etag\":\"43\"}", null);
+ assertEquals("44", response.etag);
+ assertEquals("57", response.block.id);
+ assertEquals("56", response.block.placement.after);
+ assertEquals("58", response.block.placement.before);
+ }
+
+ @Test
+ public void updateBlockSendsBackTheEditorContentExactly() throws Exception
+ {
+ String editorContent = "{\"attrs\":{\"url\":\"https://example.com\",\"linkId\":7,\"size\":24,\"ratio\":1.5,\"title\":null}}";
+ client.respond(200, "{\"data\":{\"id\":\"57\",\"type\":\"button\",\"editor_content\":" + editorContent + "},\"etag\":\"43\"}")
+ .respond(200, Fixtures.read("email_block_updated.json"));
+
+ SingleEmailBlock read = emails.getBlock("123", "57");
+ assertEquals(Long.valueOf(7), map(read.block.editorContent, "attrs").get("linkId"));
+
+ emails.updateBlock("123", "57", read.etag, read.block.editorContent);
+
+ assertEquals("{\"editor_content\":" + editorContent + ",\"etag\":\"43\"}", client.lastRequest().bodyString());
+ }
+
+ @Test
+ public void updateBlockRejectsInvalidInput()
+ {
+ Map content = paragraph("left", "Hi");
+
+ assertRejectsIds(client, id -> () -> emails.updateBlock(id, "57", "43", content));
+ assertRejectsIds(client, id -> () -> emails.updateBlock("123", id, "43", content));
+ }
+
+ @Test
+ public void updateBlockSendsTheValuesAsGiven() throws Throwable
+ {
+ assertSendsEtags(client, Fixtures.read("email_block_updated.json"),
+ etag -> () -> emails.updateBlock("123", "57", etag, new LinkedHashMap()),
+ etag -> "{\"editor_content\":{}" + etag + "}");
+
+ client.requests().clear();
+ client.respond(200, Fixtures.read("email_block_updated.json"));
+
+ emails.updateBlock("123", "57", "43", null);
+
+ assertRequest(client, "PATCH", "/emails/123/blocks/57", "{\"etag\":\"43\"}", null);
+ }
+
+
+ @Test
+ public void deleteBlockSendsTheEtagInTheBody() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_etag.json"));
+
+ EmailEtagResponse response = emails.deleteBlock("123", "57", "44");
+
+ assertRequest(client, "DELETE", "/emails/123/blocks/57", "{\"etag\":\"44\"}", null);
+ assertEquals(200, response.responseStatusCode);
+ assertEquals("45", response.etag);
+ }
+
+ @Test
+ public void deleteBlockRejectsInvalidInput()
+ {
+ assertRejectsIds(client, id -> () -> emails.deleteBlock(id, "57", "44"));
+ assertRejectsIds(client, id -> () -> emails.deleteBlock("123", id, "44"));
+ }
+
+ @Test
+ public void deleteBlockSendsTheEtagAsGiven() throws Throwable
+ {
+ assertSendsEtags(client, Fixtures.read("email_etag.json"),
+ etag -> () -> emails.deleteBlock("123", "57", etag),
+ etag -> "{" + (etag.isEmpty() ? "" : etag.substring(1)) + "}");
+ }
+
+
+ @Test
+ public void moveBlockSendsThePlacement() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_block_moved.json"));
+
+ SingleEmailBlock response = emails.moveBlock("123", "57", "45", new EmailBlockPosition(EmailBlockPositions.BEFORE, "1"));
+
+ assertRequest(client, "POST", "/emails/123/blocks/57/move",
+ "{\"placement\":{\"position\":\"before\",\"block_id\":\"1\"},\"etag\":\"45\"}", null);
+ assertEquals("46", response.etag);
+ assertEquals("57", response.block.id);
+ assertNull(response.block.placement.after);
+ assertEquals("1", response.block.placement.before);
+ }
+
+ @Test
+ public void moveBlockSendsThePlacementAsGiven() throws Throwable
+ {
+ assertSendsThePlacement(client, Fixtures.read("email_block_moved.json"),
+ placement -> () -> emails.moveBlock("123", "57", "45", placement),
+ json -> "{\"placement\":" + json + ",\"etag\":\"45\"}");
+ }
+
+ @Test
+ public void moveBlockRejectsInvalidInput()
+ {
+ EmailBlockPosition start = new EmailBlockPosition(EmailBlockPositions.START);
+
+ assertRejectsIds(client, id -> () -> emails.moveBlock(id, "57", "45", start));
+ assertRejectsIds(client, id -> () -> emails.moveBlock("123", id, "45", start));
+ }
+
+ @Test
+ public void moveBlockSendsTheValuesAsGiven() throws Throwable
+ {
+ EmailBlockPosition start = new EmailBlockPosition(EmailBlockPositions.START);
+
+ assertSendsEtags(client, Fixtures.read("email_block_moved.json"),
+ etag -> () -> emails.moveBlock("123", "57", etag, start),
+ etag -> "{\"placement\":{\"position\":\"start\"}" + etag + "}");
+
+ client.requests().clear();
+ client.respond(200, Fixtures.read("email_block_moved.json"));
+
+ emails.moveBlock("123", "57", "45", null);
+
+ assertRequest(client, "POST", "/emails/123/blocks/57/move", "{\"etag\":\"45\"}", null);
+ }
+
+
+ @Test
+ public void validateBlockValid() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_block_validation_valid.json"));
+
+ EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.PARAGRAPH, paragraph("left", "Hi"));
+ block.placement = new EmailBlockPosition(EmailBlockPositions.END);
+
+ EmailValidation response = emails.validateBlock(block);
+
+ assertRequest(client, "POST", "/emails/blocks/validate",
+ "{\"type\":\"paragraph\",\"editor_content\":{\"attrs\":{\"textAlign\":\"left\"},"
+ + "\"content\":[{\"type\":\"text\",\"text\":\"Hi\"}]},\"placement\":{\"position\":\"end\"}}", null);
+ assertTrue(response.valid);
+ assertTrue(response.errors.isEmpty());
+ assertEquals("2.73.2", response.schema.version);
+ assertEquals("0f3a9c", response.schema.hash);
+ assertNull(response.rejected);
+ }
+
+ @Test
+ public void validateBlockInvalid() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_block_validation_invalid.json"));
+
+ EmailValidation response = emails.validateBlock(new EmailBlockInput("table", new LinkedHashMap()));
+
+ assertEquals("{\"type\":\"table\",\"editor_content\":{}}", client.lastRequest().bodyString());
+ assertEquals(200, response.responseStatusCode);
+ assertFalse(response.valid);
+ assertArrayEquals(new String[] {"The selected type is invalid."}, response.errors.get("type"));
+ }
+
+ @Test
+ public void validateBlockSendsThePlacementAsGiven() throws Throwable
+ {
+ assertSendsThePlacement(client, Fixtures.read("email_block_validation_valid.json"), placement -> () -> {
+ EmailBlockInput block = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap());
+ block.placement = placement;
+ emails.validateBlock(block);
+ }, json -> "{\"type\":\"divider\",\"editor_content\":{},\"placement\":" + json + "}");
+ }
+
+ @Test
+ public void validateBlockSendsAnInvalidBlock() throws Exception
+ {
+ String invalid = Fixtures.read("email_block_validation_invalid.json");
+ client.respond(200, invalid).respond(200, invalid).respond(200, invalid);
+
+ EmailValidation response = emails.validateBlock(new EmailBlockInput("", new LinkedHashMap()));
+ assertEquals("{\"type\":\"\",\"editor_content\":{}}", client.lastRequest().bodyString());
+ assertFalse(response.valid);
+
+ emails.validateBlock(new EmailBlockInput(EmailBlockTypes.DIVIDER, null));
+ assertEquals("{\"type\":\"divider\"}", client.lastRequest().bodyString());
+
+ emails.validateBlock(null);
+ assertEquals("{}", client.lastRequest().bodyString());
+ assertEquals(3, client.requests().size());
+ }
+
+
+ static EmailSettings brandAndParagraphSettings()
+ {
+ EmailSettings settings = new EmailSettings();
+ settings.brand = new EmailBrandSettings();
+ settings.brand.mode = EmailBrandModes.CUSTOM;
+ settings.brand.backgroundColor = "#f4f4f4";
+ settings.typography = new EmailTypographySettings();
+ settings.typography.paragraph = new EmailParagraphStyle();
+ settings.typography.paragraph.p1 = new EmailTextStyle();
+ settings.typography.paragraph.p1.size = "18";
+
+ return settings;
+ }
+
+ @Test
+ public void updateSettingsSendsOnlyTheSetFields() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_settings.json"));
+
+ SingleEmailSettings response = emails.updateSettings("123", "46", brandAndParagraphSettings());
+
+ assertRequest(client, "PATCH", "/emails/123/settings",
+ "{\"brand\":{\"mode\":\"custom\",\"background_color\":\"#f4f4f4\"},"
+ + "\"typography\":{\"paragraph\":{\"p1\":{\"size\":\"18\"}}},\"etag\":\"46\"}", null);
+ assertEquals("47", response.etag);
+ assertEquals("custom", response.settings.brand.mode);
+ assertEquals("#f4f4f4", response.settings.brand.backgroundColor);
+ assertEquals("16", response.settings.typography.paragraph.p1.size);
+ assertEquals(Long.valueOf(7), response.settings.footer.socialLinks.get(0).linkId);
+ assertEquals("Some attributes were rejected.", response.rejected.message);
+ assertArrayEquals(new String[] {"footer.social_links[0].url"}, response.rejected.keys);
+ }
+
+ @Test
+ public void updateSettingsSendsEmptyValues() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_settings.json"));
+
+ Map companyName = new LinkedHashMap();
+ companyName.put("content", Collections.emptyList());
+ companyName.put("attrs", null);
+
+ EmailSettings settings = new EmailSettings();
+ settings.brand = new EmailBrandSettings();
+ settings.brand.backgroundColor = "";
+ settings.footer = new EmailFooterSettings();
+ settings.footer.showPreferences = false;
+ settings.footer.infoText = new EmailEditorText();
+ settings.footer.infoText.editorContent = new LinkedHashMap();
+ settings.footer.companyName = new EmailEditorText();
+ settings.footer.companyName.editorContent = companyName;
+ settings.footer.socialLinks = new ArrayList();
+
+ emails.updateSettings("123", "46", settings);
+
+ assertEquals("{\"brand\":{\"background_color\":\"\"},\"footer\":{\"show_preferences\":false,"
+ + "\"company_name\":{\"editor_content\":{\"content\":[],\"attrs\":null}},"
+ + "\"info_text\":{\"editor_content\":{}},\"social_links\":[]},\"etag\":\"46\"}",
+ client.lastRequest().bodyString());
+ }
+
+ @Test
+ public void updateSettingsKeepsTheLinkId() throws Exception
+ {
+ client.respond(200, Fixtures.read("email.json")).respond(200, Fixtures.read("email_settings.json"));
+
+ SingleEmailDocument email = emails.get("123");
+
+ EmailSettings settings = new EmailSettings();
+ settings.footer = new EmailFooterSettings();
+ settings.footer.socialLinks = email.email.settings.footer.socialLinks;
+
+ emails.updateSettings("123", email.etag, settings);
+
+ assertEquals("{\"footer\":{\"social_links\":[{\"url\":\"https://example.com\",\"type\":\"website\","
+ + "\"label\":\"Website\",\"link_id\":7}]},\"etag\":\"42\"}", client.lastRequest().bodyString());
+ }
+
+ @Test
+ public void updateSettingsRejectsInvalidInput()
+ {
+ EmailSettings settings = brandAndParagraphSettings();
+
+ assertRejectsIds(client, id -> () -> emails.updateSettings(id, "46", settings));
+
+ NullPointerException e = assertThrows(NullPointerException.class, () -> emails.updateSettings("123", "46", null));
+ assertTrue(e.getMessage().contains("settings"), e.getMessage());
+ assertTrue(client.requests().isEmpty(), "the SDK must send no request");
+ }
+
+
+ @Test
+ public void updateSettingsSendsTheEtagAsGiven() throws Throwable
+ {
+ EmailSettings settings = new EmailSettings();
+ settings.layout = new EmailLayoutSettings();
+ settings.layout.alignment = "left";
+
+ assertSendsEtags(client, Fixtures.read("email_settings.json"),
+ etag -> () -> emails.updateSettings("123", etag, settings),
+ etag -> "{\"layout\":{\"alignment\":\"left\"}" + etag + "}");
+ }
+
+
+ @Test
+ public void validateSettingsSendsTheSettingsWithoutEtag() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_settings_validation.json"));
+
+ EmailValidation response = emails.validateSettings("123", brandAndParagraphSettings());
+
+ assertRequest(client, "POST", "/emails/123/settings/validate",
+ "{\"brand\":{\"mode\":\"custom\",\"background_color\":\"#f4f4f4\"},"
+ + "\"typography\":{\"paragraph\":{\"p1\":{\"size\":\"18\"}}}}", null);
+ assertFalse(response.valid);
+ assertArrayEquals(new String[] {"The layout.width field cannot be changed."}, response.errors.get("layout.width"));
+ assertEquals("2.73.2", response.schema.version);
+ assertEquals("Some attributes were rejected.", response.rejected.message);
+ assertArrayEquals(new String[] {"footer.social_links[0].url"}, response.rejected.keys);
+ }
+
+ @Test
+ public void validateSettingsRejectsInvalidInput()
+ {
+ assertRejectsIds(client, id -> () -> emails.validateSettings(id, brandAndParagraphSettings()));
+ }
+
+ @Test
+ public void validateSettingsWithNullSettingsSendsAnEmptyBody() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_settings_validation.json"));
+
+ emails.validateSettings("123", null);
+
+ assertRequest(client, "POST", "/emails/123/settings/validate", "{}", null);
+ }
+
+
+ @Test
+ public void createTemplateSendsTheName() throws Exception
+ {
+ client.respond(201, Fixtures.read("email_template.json"));
+
+ SingleEmailTemplate response = emails.createTemplate("123", "Newsletter");
+
+ assertRequest(client, "POST", "/emails/123/templates", "{\"name\":\"Newsletter\"}", null);
+ assertEquals(201, response.responseStatusCode);
+ assertEquals("789", response.template.id);
+ assertEquals("Newsletter", response.template.name);
+ assertEquals("email", response.template.type);
+ assertEquals("2026-10-28 12:00:00", response.template.createdAt);
+ }
+
+ @Test
+ public void createTemplateRejectsInvalidInput()
+ {
+ assertRejectsIds(client, id -> () -> emails.createTemplate(id, "Newsletter"));
+ }
+
+ @Test
+ public void createTemplateSendsTheNameAsGiven() throws Exception
+ {
+ client.respond(422, "{\"message\":\"The given data was invalid.\","
+ + "\"errors\":{\"name\":[\"The name field is required.\"]}}")
+ .respond(422, "{\"message\":\"The given data was invalid.\"}");
+
+ MailerLiteException e = assertThrows(MailerLiteException.class, () -> emails.createTemplate("123", ""));
+
+ assertRequest(client, "POST", "/emails/123/templates", "{\"name\":\"\"}", null);
+ assertEquals(422, e.code);
+ assertArrayEquals(new String[] {"The name field is required."}, e.errors.get("name"));
+
+ assertThrows(MailerLiteException.class, () -> emails.createTemplate("123", null));
+ assertEquals("{}", client.lastRequest().bodyString());
+ assertEquals(2, client.requests().size());
+ }
+
+
+ @Test
+ public void versionsWithoutOptions() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_versions.json"));
+
+ EmailVersionsList response = emails.versions("123").get();
+
+ assertRequest(client, "GET", "/emails/123/versions", null, null);
+ assertEquals(2, response.versions.length);
+ assertEquals("902", response.versions[0].id);
+ assertEquals("2026-10-28 12:05:00", response.versions[0].createdAt);
+ assertTrue(response.versions[0].current);
+ assertEquals("901", response.versions[1].id);
+ assertFalse(response.versions[1].current);
+ assertNull(response.links.previous);
+ assertNull(response.links.next);
+ assertEquals(1, response.meta.from);
+ assertEquals(2, response.meta.to);
+ assertEquals(2, response.meta.total);
+ }
+
+ @Test
+ public void versionsWithPageAndLimit() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_versions.json"));
+
+ emails.versions("123").page(2).limit(10).get();
+
+ assertRequest(client, "GET", "/emails/123/versions?page=2&limit=10", null, null);
+ }
+
+ @Test
+ public void versionsEmptyPageReadsNullFromAndToAsZero() throws Exception
+ {
+ client.respond(200, "{\"data\":[],\"links\":{\"first\":null,\"last\":null,\"prev\":null,\"next\":null},"
+ + "\"meta\":{\"current_page\":1,\"from\":null,\"last_page\":1,\"per_page\":25,\"to\":null,\"total\":0}}");
+
+ EmailVersionsList response = emails.versions("123").get();
+
+ assertEquals(0, response.versions.length);
+ assertEquals(0, response.meta.from);
+ assertEquals(0, response.meta.to);
+ assertEquals(0, response.meta.total);
+ }
+
+ @Test
+ public void versionsRejectsInvalidIds()
+ {
+ assertRejectsIds(client, id -> () -> emails.versions(id).get());
+ }
+
+
+ @Test
+ public void createVersionNew() throws Exception
+ {
+ client.respond(201, Fixtures.read("email_version.json"));
+
+ SingleEmailVersion response = emails.createVersion("123");
+
+ RecordedRequest request = assertRequest(client, "POST", "/emails/123/versions", "", null);
+ assertTrue(request.hasBodyPublisher);
+ assertEquals(0, request.contentLength);
+ assertEquals(0, request.body.length);
+ assertTrue(response.isCreated());
+ assertEquals(201, response.responseStatusCode);
+ assertEquals("903", response.version.id);
+ assertEquals("2026-10-28 12:10:00", response.version.createdAt);
+ assertTrue(response.version.current);
+ }
+
+ @Test
+ public void createVersionUnchanged() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_version.json"));
+
+ SingleEmailVersion response = emails.createVersion("123");
+
+ assertFalse(response.isCreated());
+ assertEquals(200, response.responseStatusCode);
+ assertEquals("903", response.version.id);
+ }
+
+ @Test
+ public void createVersionRejectsInvalidIds()
+ {
+ assertRejectsIds(client, id -> () -> emails.createVersion(id));
+ }
+
+
+ @Test
+ public void restoreVersionSendsTheEtag() throws Exception
+ {
+ client.respond(200, Fixtures.read("email.json"));
+
+ SingleEmailDocument response = emails.restoreVersion("123", "901", "47");
+
+ assertRequest(client, "POST", "/emails/123/versions/901/restore", "{\"etag\":\"47\"}", null);
+ assertEquals("42", response.etag);
+ assertEquals("123", response.email.id);
+ assertEquals(2, response.email.blocks.length);
+ }
+
+ @Test
+ public void restoreVersionRejectsInvalidInput()
+ {
+ assertRejectsIds(client, id -> () -> emails.restoreVersion(id, "901", "47"));
+ assertRejectsIds(client, id -> () -> emails.restoreVersion("123", id, "47"));
+ }
+
+ @Test
+ public void restoreVersionSendsTheEtagAsGiven() throws Throwable
+ {
+ assertSendsEtags(client, Fixtures.read("email.json"),
+ etag -> () -> emails.restoreVersion("123", "901", etag),
+ etag -> "{" + (etag.isEmpty() ? "" : etag.substring(1)) + "}");
+ }
+
+}
diff --git a/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java
new file mode 100644
index 0000000..22e3b41
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/tests/IdempotencyTest.java
@@ -0,0 +1,222 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.MailerLiteResponse;
+import com.mailerlite.sdk.RequestOptions;
+import com.mailerlite.sdk.emails.EmailBlockInput;
+import com.mailerlite.sdk.emails.EmailBlockPosition;
+import com.mailerlite.sdk.emails.EmailBlockPositions;
+import com.mailerlite.sdk.emails.EmailBlockTypes;
+import com.mailerlite.sdk.emails.Emails;
+import com.mailerlite.sdk.emails.SingleEmailBlock;
+import com.mailerlite.sdk.exceptions.InvalidIdempotencyKeyException;
+import com.mailerlite.sdk.support.Fixtures;
+import com.mailerlite.sdk.support.RecordingHttpClient;
+
+public class IdempotencyTest {
+
+ private RecordingHttpClient client;
+
+ private Emails emails;
+
+ @BeforeEach
+ public void setupEach()
+ {
+ client = RecordingHttpClient.install();
+
+ MailerLite ml = new MailerLite();
+ ml.setToken("test-token");
+ emails = ml.emails();
+ }
+
+ @AfterEach
+ public void afterEach()
+ {
+ RecordingHttpClient.uninstall();
+ }
+
+ private Map writeMethods()
+ {
+ EmailBlockInput divider = new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap());
+
+ Map methods = new LinkedHashMap();
+ methods.put("createBlock", new Object[] {"email_block_created.json",
+ (Function) o -> () -> emails.createBlock("123", "42", divider, o)});
+ methods.put("updateBlock", new Object[] {"email_block_updated.json",
+ (Function) o -> () -> emails.updateBlock("123", "57", "43", new HashMap(), o)});
+ methods.put("deleteBlock", new Object[] {"email_etag.json",
+ (Function) o -> () -> emails.deleteBlock("123", "57", "44", o)});
+ methods.put("moveBlock", new Object[] {"email_block_moved.json",
+ (Function) o -> () -> emails.moveBlock("123", "57", "45",
+ new EmailBlockPosition(EmailBlockPositions.START), o)});
+ methods.put("updateSettings", new Object[] {"email_settings.json",
+ (Function) o -> () -> emails.updateSettings("123", "46",
+ EmailsTest.brandAndParagraphSettings(), o)});
+ methods.put("createTemplate", new Object[] {"email_template.json",
+ (Function) o -> () -> emails.createTemplate("123", "Newsletter", o)});
+ methods.put("createVersion", new Object[] {"email_version.json",
+ (Function) o -> () -> emails.createVersion("123", o)});
+ methods.put("restoreVersion", new Object[] {"email.json",
+ (Function) o -> () -> emails.restoreVersion("123", "901", "47", o)});
+
+ return methods;
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void writeMethodsSendTheKeyOnlyWhenSet() throws Throwable
+ {
+ for (Map.Entry method : writeMethods().entrySet()) {
+
+ String name = method.getKey();
+ String fixture = (String) method.getValue()[0];
+ Function call = (Function) method.getValue()[1];
+
+ client.requests().clear();
+ client.respond(200, Fixtures.read(fixture)).respond(200, Fixtures.read(fixture)).respond(200, Fixtures.read(fixture));
+
+ call.apply(RequestOptions.idempotencyKey("key-" + name)).execute();
+ call.apply(null).execute();
+ call.apply(RequestOptions.idempotencyKey(null)).execute();
+
+ List requests = client.requests();
+ assertEquals("key-" + name, requests.get(0).header("Idempotency-Key"), name);
+ assertNull(requests.get(1).header("Idempotency-Key"), name);
+ assertNull(requests.get(2).header("Idempotency-Key"), name);
+ assertEquals(requests.get(0).bodyString(), requests.get(1).bodyString(), name);
+ }
+ }
+
+ @Test
+ public void validateMethodsSendNoKey() throws Exception
+ {
+ client.respond(200, Fixtures.read("email_block_validation_valid.json"))
+ .respond(200, Fixtures.read("email_settings_validation.json"));
+
+ emails.validateBlock(new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()));
+ emails.validateSettings("123", EmailsTest.brandAndParagraphSettings());
+
+ assertNull(client.requests().get(0).header("Idempotency-Key"));
+ assertNull(client.requests().get(1).header("Idempotency-Key"));
+ }
+
+ /**
+ * The SDK gives a printable ASCII key to the HTTP client as it is, and the API checks it. The HTTP client removes
+ * the spaces at the start and the end of a header value, as HTTP does.
+ */
+ @Test
+ public void keysAreSentAsGiven() throws Exception
+ {
+ List keys = Arrays.asList(" key", "key ", "k ~k", RequestChecksTest.repeat('k', 256), "a");
+
+ for (String key : keys) {
+
+ client.requests().clear();
+ client.respond(201, Fixtures.read("email_version.json"));
+
+ RequestOptions options = RequestOptions.idempotencyKey(key);
+ assertEquals(key, options.getIdempotencyKey());
+
+ emails.createVersion("123", options);
+
+ assertEquals(1, client.requests().size(), "key of length " + key.length());
+ assertEquals(key.trim(), client.lastRequest().header("Idempotency-Key"), "key of length " + key.length());
+ }
+ }
+
+ @Test
+ public void emptyKeySendsNoHeader() throws Exception
+ {
+ client.respond(201, Fixtures.read("email_version.json"));
+
+ emails.createVersion("123", RequestOptions.idempotencyKey(""));
+
+ assertEquals(1, client.requests().size());
+ assertNull(client.lastRequest().header("Idempotency-Key"));
+ }
+
+ /**
+ * The Java HTTP client sends each character that is not ASCII as "?", so "k-\u00e9-k" would reach the API as the
+ * valid key "k-?-k". The SDK rejects each character outside 0x20 to 0x7E and sends no request.
+ */
+ @Test
+ public void keysOutsidePrintableAsciiAreRejected()
+ {
+ List keys = Arrays.asList("k-\u00e9-k", "cl\u00e9", "\u4e2d", "k\ud83d\ude00", "key\n", "key\t", "key\u007f",
+ "key\u001f");
+
+ for (String key : keys) {
+
+ RequestOptions options = RequestOptions.idempotencyKey(key);
+
+ InvalidIdempotencyKeyException e = assertThrows(InvalidIdempotencyKeyException.class,
+ () -> emails.createVersion("123", options), "key of length " + key.length());
+ assertTrue(e.getMessage().contains("0x20 to 0x7E"), e.getMessage());
+ }
+
+ assertTrue(client.requests().isEmpty());
+ }
+
+ @Test
+ public void replayFlag() throws Exception
+ {
+ String[][] cases = {
+ {"Idempotent-Replayed", "true", "true"},
+ {"Idempotent-Replayed", "True", "true"},
+ {"idempotent-replayed", " TRUE ", "true"},
+ {"Idempotent-Replayed", "false", "false"},
+ };
+
+ for (String[] item : cases) {
+
+ client.respond(201, Fixtures.read("email_block_created.json"), Collections.singletonMap(item[0], item[1]));
+
+ SingleEmailBlock response = emails.createBlock("123", "42",
+ new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap()));
+
+ assertEquals(Boolean.parseBoolean(item[2]), response.isIdempotentReplay(), item[0] + ": " + item[1]);
+ }
+
+ client.respond(201, Fixtures.read("email_block_created.json"));
+ assertFalse(emails.createBlock("123", "42",
+ new EmailBlockInput(EmailBlockTypes.DIVIDER, new LinkedHashMap())).isIdempotentReplay());
+ }
+
+ @Test
+ public void replayFlagWithPlainHeaderMap()
+ {
+ MailerLiteResponse response = new MailerLiteResponse();
+ assertFalse(response.isIdempotentReplay());
+
+ Map> headers = new HashMap>();
+ headers.put("idempotent-replayed", Collections.singletonList("true"));
+ response.headers = headers;
+ assertTrue(response.isIdempotentReplay());
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java
new file mode 100644
index 0000000..0df806c
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteApiTest.java
@@ -0,0 +1,390 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.tests;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.net.ConnectException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.MailerLiteApi;
+import com.mailerlite.sdk.MailerLiteResponse;
+import com.mailerlite.sdk.exceptions.MailerLiteException;
+import com.mailerlite.sdk.support.RecordingHttpClient;
+import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest;
+
+public class MailerLiteApiTest {
+
+ private RecordingHttpClient client;
+
+ @BeforeEach
+ public void setupEach()
+ {
+ client = RecordingHttpClient.install();
+ }
+
+ @AfterEach
+ public void afterEach()
+ {
+ RecordingHttpClient.uninstall();
+ Thread.interrupted();
+ }
+
+ private MailerLiteApi api()
+ {
+ MailerLiteApi api = new MailerLiteApi();
+ api.setToken("test-token");
+
+ return api;
+ }
+
+ private Map oldRequestMethods()
+ {
+ Map methods = new LinkedHashMap();
+ methods.put("GET", () -> api().getRequest("/groups", MailerLiteResponse.class));
+ methods.put("POST", () -> api().postRequest("/groups", "{}", MailerLiteResponse.class));
+ methods.put("PUT", () -> api().putRequest("/groups/1", "{}", MailerLiteResponse.class));
+ methods.put("DELETE", () -> api().deleteRequest("/groups/1", MailerLiteResponse.class));
+
+ return methods;
+ }
+
+ @Test
+ public void networkErrorBecomesMailerLiteException()
+ {
+ for (Map.Entry method : oldRequestMethods().entrySet()) {
+
+ IOException error = new IOException("connection reset");
+ client.fail(error);
+
+ MailerLiteException e = assertThrows(MailerLiteException.class, method.getValue(), method.getKey());
+
+ assertSame(error, e.getCause(), method.getKey());
+ assertEquals("java.io.IOException: connection reset", e.getMessage(), method.getKey());
+ }
+ }
+
+ @Test
+ public void interruptBecomesMailerLiteExceptionAndKeepsTheFlag()
+ {
+ for (Map.Entry method : oldRequestMethods().entrySet()) {
+
+ InterruptedException error = new InterruptedException("interrupted");
+ client.fail(error);
+
+ MailerLiteException e = assertThrows(MailerLiteException.class, method.getValue(), method.getKey());
+
+ assertSame(error, e.getCause(), method.getKey());
+ assertTrue(Thread.interrupted(), method.getKey());
+ }
+ }
+
+ @Test
+ public void emptyBodyForResponseWithoutDefaultConstructorBecomesMailerLiteException()
+ {
+ client.respond(200, "");
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().getRequest("/test", NoDefaultConstructorResponse.class));
+
+ assertTrue(e.getCause() instanceof ReflectiveOperationException);
+ }
+
+ private void assertDefaultHeaders(RecordedRequest request)
+ {
+ assertEquals("application/json", request.header("Content-Type"));
+ assertEquals("application/json", request.header("Accept"));
+ assertEquals("Bearer test-token", request.header("Authorization"));
+ }
+
+ @Test
+ public void sendRequestSendsPatchWithBody() throws MailerLiteException
+ {
+ client.respond(200, "{}");
+
+ MailerLiteResponse response = api().sendRequest("PATCH", "/emails/123/blocks/57", "{\"etag\":\"42\"}",
+ Collections.singletonMap("Idempotency-Key", "key-1"), MailerLiteResponse.class);
+
+ RecordedRequest request = client.lastRequest();
+ assertEquals("PATCH", request.method);
+ assertEquals("https://connect.mailerlite.com/api/emails/123/blocks/57", request.uri.toString());
+ assertEquals("{\"etag\":\"42\"}", request.bodyString());
+ assertDefaultHeaders(request);
+ assertEquals("key-1", request.header("Idempotency-Key"));
+ assertEquals(200, response.responseStatusCode);
+ }
+
+ @Test
+ public void sendRequestSendsDeleteWithBody() throws MailerLiteException
+ {
+ client.respond(200, "{\"etag\":\"45\"}");
+
+ api().sendRequest("DELETE", "/emails/123/blocks/57", "{\"etag\":\"44\"}", null, MailerLiteResponse.class);
+
+ RecordedRequest request = client.lastRequest();
+ assertEquals("DELETE", request.method);
+ assertEquals("{\"etag\":\"44\"}", request.bodyString());
+ assertDefaultHeaders(request);
+ assertNull(request.header("Idempotency-Key"));
+ }
+
+ @Test
+ public void sendRequestSendsGetAndDeleteWithoutBody() throws MailerLiteException
+ {
+ client.respond(200, "{}").respond(204, "");
+
+ api().sendRequest("GET", "/emails/123", null, null, MailerLiteResponse.class);
+ MailerLiteResponse deleted = api().sendRequest("DELETE", "/campaigns/1", null, null, MailerLiteResponse.class);
+
+ for (RecordedRequest request : client.requests()) {
+ assertFalse(request.hasBodyPublisher, request.method);
+ assertDefaultHeaders(request);
+ }
+
+ assertEquals("GET", client.requests().get(0).method);
+ assertEquals("DELETE", client.requests().get(1).method);
+ assertEquals(204, deleted.responseStatusCode);
+ }
+
+ @Test
+ public void sendRequestSendsPostWithEmptyBody() throws MailerLiteException
+ {
+ client.respond(201, "{}");
+
+ MailerLiteResponse response = api().sendRequest("POST", "/emails/123/versions", null, null, MailerLiteResponse.class);
+
+ RecordedRequest request = client.lastRequest();
+ assertEquals("POST", request.method);
+ assertTrue(request.hasBodyPublisher);
+ assertEquals(0, request.contentLength);
+ assertArrayEquals(new byte[0], request.body);
+ assertDefaultHeaders(request);
+ assertEquals(201, response.responseStatusCode);
+ }
+
+ @Test
+ public void sendRequestNetworkErrorBecomesMailerLiteException()
+ {
+ IOException error = new IOException("timeout");
+ client.fail(error);
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("POST", "/emails/123/versions", null, null, MailerLiteResponse.class));
+
+ assertSame(error, e.getCause());
+ }
+
+ @Test
+ public void versionConflictExposesCodeAndEtag()
+ {
+ String body = "{\"message\":\"The email changed since you read it. Fetch it again, then retry with its new etag.\","
+ + "\"code\":\"version_conflict\",\"etag\":\"43\"}";
+ client.respond(409, body);
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("PATCH", "/emails/123/blocks/57", "{}", null, MailerLiteResponse.class));
+
+ assertEquals(409, e.code);
+ assertEquals("version_conflict", e.errorCode);
+ assertEquals("43", e.etag);
+ assertTrue(e.isVersionConflict());
+ assertEquals("version_conflict: The email changed since you read it. Fetch it again, then retry with its new etag.",
+ e.getMessage());
+ assertEquals(body, e.responseBody);
+ assertTrue(e.errors.isEmpty());
+ }
+
+ @Test
+ public void conflictWithoutCodeIsNotVersionConflict()
+ {
+ client.respond(409, "{\"message\":\"The campaign is not a draft.\"}");
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().postRequest("/emails/123/versions", "{}", MailerLiteResponse.class));
+
+ assertEquals(409, e.code);
+ assertNull(e.errorCode);
+ assertNull(e.etag);
+ assertFalse(e.isVersionConflict());
+ assertEquals("The campaign is not a draft.", e.getMessage());
+ }
+
+ @Test
+ public void validationErrorExposesErrors()
+ {
+ client.respond(422, "{\"message\":\"The given data was invalid.\","
+ + "\"errors\":{\"editor_content\":[\"The editor content field must be present.\"]}}");
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("POST", "/emails/123/blocks", "{}", null, MailerLiteResponse.class));
+
+ assertEquals(422, e.code);
+ assertNull(e.errorCode);
+ assertFalse(e.isVersionConflict());
+ assertEquals("The given data was invalid.", e.getMessage());
+ assertArrayEquals(new String[] {"The editor content field must be present."}, e.errors.get("editor_content"));
+ }
+
+ @Test
+ public void validationErrorWithOnlyMessage()
+ {
+ client.respond(422, "{\"message\":\"This email was not built with the simple editor.\"}");
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("GET", "/emails/123", null, null, MailerLiteResponse.class));
+
+ assertEquals(422, e.code);
+ assertEquals("This email was not built with the simple editor.", e.getMessage());
+ assertTrue(e.errors.isEmpty());
+ assertNull(e.errorCode);
+ }
+
+ @Test
+ public void serviceUnavailableWithHtmlBody()
+ {
+ String body = "503 Service Unavailable ";
+ client.respond(503, body);
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("GET", "/emails/123/html", null, null, MailerLiteResponse.class));
+
+ assertEquals(503, e.code);
+ assertEquals("Error parsing API response", e.getMessage());
+ assertEquals(body, e.responseBody);
+ assertNull(e.errorCode);
+ assertNull(e.etag);
+ assertFalse(e.isVersionConflict());
+ }
+
+ public static class NoDefaultConstructorResponse extends MailerLiteResponse {
+
+ public NoDefaultConstructorResponse(int value)
+ {
+ }
+ }
+
+ @Test
+ public void networkErrorWithoutMessageKeepsTheErrorName()
+ {
+ for (Map.Entry method : oldRequestMethods().entrySet()) {
+
+ ConnectException error = new ConnectException();
+ client.fail(error);
+
+ MailerLiteException e = assertThrows(MailerLiteException.class, method.getValue(), method.getKey());
+
+ assertSame(error, e.getCause(), method.getKey());
+ assertTrue(e.getMessage() != null && e.getMessage().contains("ConnectException"), method.getKey() + ": " + e.getMessage());
+ }
+
+ ConnectException error = new ConnectException();
+ client.fail(error);
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("POST", "/emails/123/versions", null, null, MailerLiteResponse.class));
+
+ assertSame(error, e.getCause());
+ assertTrue(e.getMessage() != null && e.getMessage().contains("ConnectException"), e.getMessage());
+ }
+
+ @Test
+ public void interruptWithoutMessageKeepsTheErrorNameAndTheFlag()
+ {
+ InterruptedException error = new InterruptedException();
+ client.fail(error);
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("GET", "/emails/123", null, null, MailerLiteResponse.class));
+
+ assertSame(error, e.getCause());
+ assertTrue(e.getMessage() != null && e.getMessage().contains("InterruptedException"), e.getMessage());
+ assertTrue(Thread.interrupted());
+ }
+
+ @Test
+ public void apiErrorSetsTheMessageField()
+ {
+ Map methods = oldRequestMethods();
+ methods.put("sendRequest", () -> api().sendRequest("POST", "/emails/123/versions", null, null, MailerLiteResponse.class));
+ methods.put("automation activity", () -> {
+ MailerLite ml = new MailerLite();
+ ml.setToken("test-token");
+ ml.automations().subscriberActivityRetriever().get("1");
+ });
+
+ for (Map.Entry method : methods.entrySet()) {
+
+ client.respond(404, "{\"message\":\"Resource not found.\"}");
+
+ MailerLiteException e = assertThrows(MailerLiteException.class, method.getValue(), method.getKey());
+
+ assertEquals("Resource not found.", e.message, method.getKey());
+ assertEquals("Resource not found.", e.getMessage(), method.getKey());
+ }
+ }
+
+ @Test
+ public void apiErrorWithCodeSetsTheMessageFieldWithoutTheCode()
+ {
+ client.respond(409, "{\"message\":\"The email changed.\",\"code\":\"version_conflict\",\"etag\":\"43\"}");
+
+ MailerLiteException e = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("PATCH", "/emails/123/blocks/57", "{}", null, MailerLiteResponse.class));
+
+ assertEquals("The email changed.", e.message);
+ assertEquals("version_conflict: The email changed.", e.getMessage());
+ }
+
+ @Test
+ public void apiErrorWithoutMessageKeepsAnEmptyMessageField()
+ {
+ client.respond(409, "{\"code\":\"version_conflict\"}").respond(500, "{\"message\":null}");
+
+ MailerLiteException withCode = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("PATCH", "/emails/123/blocks/57", "{}", null, MailerLiteResponse.class));
+ MailerLiteException withNull = assertThrows(MailerLiteException.class,
+ () -> api().getRequest("/groups", MailerLiteResponse.class));
+
+ assertEquals("", withCode.message);
+ assertEquals("", withNull.message);
+ }
+
+ @Test
+ public void errorWithoutApiMessageSetsTheMessageFieldToGetMessage()
+ {
+ client.respond(503, "503");
+
+ MailerLiteException unreadable = assertThrows(MailerLiteException.class,
+ () -> api().getRequest("/groups", MailerLiteResponse.class));
+
+ assertEquals("Error parsing API response", unreadable.message);
+
+ client.fail(new IOException("connection reset"));
+
+ MailerLiteException network = assertThrows(MailerLiteException.class,
+ () -> api().sendRequest("GET", "/emails/123", null, null, MailerLiteResponse.class));
+
+ assertEquals("java.io.IOException: connection reset", network.message);
+ assertEquals(network.getMessage(), network.message);
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/tests/MailerLiteExceptionTest.java b/src/test/java/com/mailerlite/sdk/tests/MailerLiteExceptionTest.java
new file mode 100644
index 0000000..c5e4c2a
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/tests/MailerLiteExceptionTest.java
@@ -0,0 +1,69 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+
+import com.mailerlite.sdk.emails.EmailErrorCodes;
+import com.mailerlite.sdk.exceptions.MailerLiteException;
+
+public class MailerLiteExceptionTest {
+
+ /** The emails package uses the exceptions package, so the exceptions package must not use the emails package. */
+ @Test
+ public void exceptionsPackageDoesNotImportEmailsPackage() throws IOException
+ {
+ List files;
+
+ try (Stream paths = Files.list(Paths.get("src/main/java/com/mailerlite/sdk/exceptions"))) {
+ files = paths.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList());
+ }
+
+ assertFalse(files.isEmpty());
+
+ for (Path file : files) {
+
+ String source = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
+ assertFalse(source.contains("com.mailerlite.sdk.emails"), file.getFileName().toString());
+ }
+ }
+
+ @Test
+ public void versionConflictCodeIsTheSameConstant()
+ {
+ assertEquals("version_conflict", MailerLiteException.VERSION_CONFLICT);
+ assertSame(MailerLiteException.VERSION_CONFLICT, EmailErrorCodes.VERSION_CONFLICT);
+ }
+
+ @Test
+ public void isVersionConflictComparesTheErrorCode()
+ {
+ MailerLiteException e = new MailerLiteException("conflict");
+ assertFalse(e.isVersionConflict());
+
+ e.errorCode = "other";
+ assertFalse(e.isVersionConflict());
+
+ e.errorCode = new String("version_conflict");
+ assertTrue(e.isVersionConflict());
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java b/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java
new file mode 100644
index 0000000..0c5ab00
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/tests/MediaFilesTest.java
@@ -0,0 +1,104 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.files.MediaFile;
+import com.mailerlite.sdk.files.MediaFileSorts;
+import com.mailerlite.sdk.files.MediaFileTypes;
+import com.mailerlite.sdk.files.MediaFilesList;
+import com.mailerlite.sdk.support.Fixtures;
+import com.mailerlite.sdk.support.RecordingHttpClient;
+
+public class MediaFilesTest {
+
+ private RecordingHttpClient client;
+
+ private MailerLite ml;
+
+ @BeforeEach
+ public void setupEach()
+ {
+ client = RecordingHttpClient.install();
+ client.respond(200, Fixtures.read("files.json"));
+
+ ml = new MailerLite();
+ ml.setToken("test-token");
+ }
+
+ @AfterEach
+ public void afterEach()
+ {
+ RecordingHttpClient.uninstall();
+ }
+
+ @Test
+ public void listWithoutOptions() throws Exception
+ {
+ MediaFilesList response = ml.files().retriever().get();
+
+ EmailsTest.assertRequest(client, "GET", "/files", null, null);
+ assertEquals(2, response.files.length);
+
+ MediaFile image = response.files[0];
+ assertEquals("301", image.id);
+ assertEquals("image", image.type);
+ assertEquals("summer sale banner.png", image.name);
+ assertEquals("https://storage.example.com/files/summer-sale-banner.png", image.url);
+ assertEquals(48213L, image.size);
+ assertEquals(Integer.valueOf(1200), image.width);
+ assertEquals(Integer.valueOf(600), image.height);
+ assertEquals("2026-10-20 09:30:00", image.createdAt);
+
+ MediaFile pdf = response.files[1];
+ assertEquals("pdf", pdf.type);
+ assertNull(pdf.width);
+ assertNull(pdf.height);
+
+ assertNull(response.links.next);
+ assertEquals(2, response.meta.total);
+ }
+
+ @Test
+ public void nameFilterWithSpaces() throws Exception
+ {
+ ml.files().retriever().nameFilter("summer sale banner").get();
+
+ EmailsTest.assertRequest(client, "GET", "/files?filter[name]=summer+sale+banner", null, null);
+ }
+
+ @Test
+ public void emptyAndNullFilterValues() throws Exception
+ {
+ ml.files().retriever().nameFilter("").typeFilter(null).get();
+
+ EmailsTest.assertRequest(client, "GET", "/files?filter[name]=", null, null);
+ }
+
+ @Test
+ public void allOptions() throws Exception
+ {
+ ml.files().retriever()
+ .typeFilter(MediaFileTypes.IMAGE)
+ .filter("name", "banner")
+ .sort(MediaFileSorts.SIZE_DESC)
+ .page(2)
+ .limit(25)
+ .get();
+
+ EmailsTest.assertRequest(client, "GET",
+ "/files?filter[type]=image&filter[name]=banner&sort=-size&page=2&limit=25", null, null);
+ }
+}
diff --git a/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java
new file mode 100644
index 0000000..af7bc44
--- /dev/null
+++ b/src/test/java/com/mailerlite/sdk/tests/ReadmeExamplesTest.java
@@ -0,0 +1,342 @@
+/*************************************************
+ * MailerLite Java SDK
+ * https://github.com/mailerlite/mailerlite-java
+ *
+ * @author MailerLite
+ * https://mailerlite.com
+ **************************************************/
+package com.mailerlite.sdk.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.DynamicTest.dynamicTest;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import javax.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.ToolProvider;
+
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.TestFactory;
+
+import com.google.gson.Gson;
+import com.mailerlite.sdk.MailerLite;
+import com.mailerlite.sdk.support.Fixtures;
+import com.mailerlite.sdk.support.ReadmeExampleBase;
+import com.mailerlite.sdk.support.RecordingHttpClient;
+import com.mailerlite.sdk.support.RecordingHttpClient.RecordedRequest;
+import com.mailerlite.sdk.support.UrlAssert;
+
+public class ReadmeExamplesTest {
+
+ private static final String BASE = "https://connect.mailerlite.com/api";
+
+ /** The README shows these imports only as "// import ....", so the test adds them. */
+ private static final List IMPORTS = Arrays.asList(
+ "com.mailerlite.sdk.*",
+ "com.mailerlite.sdk.brandstyles.*",
+ "com.mailerlite.sdk.campaigns.*",
+ "com.mailerlite.sdk.emails.*",
+ "com.mailerlite.sdk.exceptions.*",
+ "com.mailerlite.sdk.files.*",
+ "java.io.*",
+ "java.util.*");
+
+ private static class Example {
+
+ final String heading;
+
+ final List responses = new ArrayList();
+
+ final List requests = new ArrayList();
+
+ Example(String heading)
+ {
+ this.heading = heading;
+ }
+
+ Example respond(int status, String body)
+ {
+ return respond(status, body, Collections.emptyMap());
+ }
+
+ Example respond(int status, String body, Map headers)
+ {
+ responses.add(new Object[] {status, body, headers});
+ return this;
+ }
+
+ Example fail(Exception error)
+ {
+ responses.add(new Object[] {error});
+ return this;
+ }
+
+ Example expect(String request)
+ {
+ requests.add(request);
+ return this;
+ }
+ }
+
+ private static List examples()
+ {
+ List list = new ArrayList();
+
+ list.add(new Example("Get an email")
+ .respond(200, Fixtures.read("email.json"))
+ .expect("GET /emails/123456"));
+
+ list.add(new Example("Get the HTML of an email")
+ .respond(200, Fixtures.read("email_html.json"))
+ .expect("GET /emails/123456/html"));
+
+ list.add(new Example("Get a block")
+ .respond(200, Fixtures.read("email_block.json"))
+ .expect("GET /emails/123456/blocks/57"));
+
+ list.add(new Example("Insert a block")
+ .respond(200, Fixtures.read("email.json"))
+ .respond(201, Fixtures.read("email_block_created.json"))
+ .expect("GET /emails/123456")
+ .expect("POST /emails/123456/blocks"));
+
+ list.add(new Example("Update a block")
+ .respond(200, Fixtures.read("email_block.json"))
+ .respond(200, Fixtures.read("email_block_updated.json"))
+ .expect("GET /emails/123456/blocks/57")
+ .expect("PATCH /emails/123456/blocks/57"));
+
+ list.add(new Example("Delete a block")
+ .respond(200, Fixtures.read("email_etag.json"))
+ .expect("DELETE /emails/123456/blocks/57"));
+
+ list.add(new Example("Move a block")
+ .respond(200, Fixtures.read("email_block_moved.json"))
+ .expect("POST /emails/123456/blocks/57/move"));
+
+ list.add(new Example("Validate a block")
+ .respond(200, Fixtures.read("email_block_validation_invalid.json"))
+ .expect("POST /emails/blocks/validate"));
+
+ list.add(new Example("Update the design settings")
+ .respond(200, Fixtures.read("email_settings.json"))
+ .expect("PATCH /emails/123456/settings"));
+
+ list.add(new Example("Validate the design settings")
+ .respond(200, Fixtures.read("email_settings_validation.json"))
+ .expect("POST /emails/123456/settings/validate"));
+
+ list.add(new Example("Save an email as a template")
+ .respond(201, Fixtures.read("email_template.json"))
+ .expect("POST /emails/123456/templates"));
+
+ list.add(new Example("List the versions of an email")
+ .respond(200, Fixtures.read("email_versions.json"))
+ .expect("GET /emails/123456/versions?page=1&limit=10"));
+
+ list.add(new Example("Save a version of an email")
+ .respond(201, Fixtures.read("email_version.json"))
+ .expect("POST /emails/123456/versions"));
+
+ list.add(new Example("Restore a version of an email")
+ .respond(200, Fixtures.read("email.json"))
+ .expect("POST /emails/123456/versions/901/restore"));
+
+ list.add(new Example("Get the brand styles")
+ .respond(200, Fixtures.read("brand_styles.json"))
+ .expect("GET /brand-styles"));
+
+ list.add(new Example("Get a list of files")
+ .respond(200, Fixtures.read("files.json"))
+ .expect("GET /files?filter[name]=summer+sale&filter[type]=image&sort=-size&page=1&limit=25"));
+
+ list.add(new Example("Create a campaign with the simple editor")
+ .respond(201, Fixtures.read("campaign_simple_editor.json"))
+ .expect("POST /campaigns"));
+
+ list.add(new Example("Retry a write with an idempotency key")
+ .fail(new IOException("connection reset"))
+ .respond(201, Fixtures.read("email_block_created.json"), Collections.singletonMap("Idempotent-Replayed", "true"))
+ .expect("POST /emails/123456/blocks")
+ .expect("POST /emails/123456/blocks"));
+
+ return list;
+ }
+
+ @TestFactory
+ public List