From cfe5b81d42f0c8543f2e379e9a483f49f94fbcf2 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sun, 18 Oct 2026 17:14:17 +0200 Subject: [PATCH] feat: add per-email idempotency keys to batch send EmailMessage.idempotencyKey(String) and EmailBuilder.idempotencyKey(String) set a per-email key that emails().sendBatch() sends as the item's idempotency_key. If an email with the same key and content was accepted in the last 24 hours, the API returns its original result and does not send it again. The SDK checks that keys are 1 to 255 characters and unique within a batch. emails().send() rejects a message with a per-email key; SendOptions.idempotencyKey(...) remains the Idempotency-Key header for single sends and whole batches. Co-Authored-By: Claude --- README.md | 11 ++++++ src/main/java/co/lettermint/EmailBuilder.java | 12 +++++++ src/main/java/co/lettermint/EmailMessage.java | 36 +++++++++++++++++++ .../java/co/lettermint/EmailValidation.java | 6 ++++ src/main/java/co/lettermint/Emails.java | 26 +++++++++++--- src/test/java/co/lettermint/EmailsTest.java | 36 +++++++++++++++++++ 6 files changed, 123 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0f2cc25..038acdc 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ email.html(invoiceHtml).send(); | `settings(SendMailRequestSettings)` | Per-email settings that override the route. | | `sandboxResult(SandboxResult)` | The result a Sandbox project simulates. | | `attach(EmailAttachment)` | Adds an attachment. | +| `idempotencyKey(key)` | Per-email idempotency key for [batch sending](#batch-sending). `send()` rejects it. | | `send()`, `send(SendOptions)` | Sends the email. The builder can be sent again. | | `build()` | Returns the `EmailMessage`. | @@ -163,6 +164,16 @@ List results = lettermint.emails().sendBatch(List.of( welcome.to("john@example.com").html("

Hi John

").build())); ``` +Give an email in a batch its own idempotency key to make it safe to send again, in this or a later batch. If an email with the same key and content was accepted in the last 24 hours, the API returns its original result and does not send it again. Keys are 1 to 255 characters and must be unique within the batch; reusing a key with different content rejects the whole batch with a `ConflictException` and sends nothing: + +```java +lettermint.emails().sendBatch(orders.stream() + .map(order -> receipt.to(order.email()).idempotencyKey("receipt-" + order.id()).build()) + .toList()); +``` + +The per-email key is only for batches: `send()` rejects a message that has one (use `SendOptions.idempotencyKey(...)` instead). If a batch fails part-way, emails before the failing one may already be accepted; the exception's `getDetails()` then has an `accepted` list with the `index` and `message_id` of each. + ### Idempotency Pass an idempotency key to make retries safe. The API processes a key once, so a retry with the same key does not send the email again. The key applies only to the call it is passed to. diff --git a/src/main/java/co/lettermint/EmailBuilder.java b/src/main/java/co/lettermint/EmailBuilder.java index a9bf294..3f83777 100644 --- a/src/main/java/co/lettermint/EmailBuilder.java +++ b/src/main/java/co/lettermint/EmailBuilder.java @@ -216,6 +216,18 @@ public EmailBuilder attach(EmailAttachment attachment) { return new EmailBuilder(emails, message.attach(attachment)); } + /** + * Sets a per-email idempotency key for {@code emails().sendBatch()}; see + * {@link EmailMessage#idempotencyKey(String)}. {@link #send()} rejects a builder that has one: for + * a single email, pass {@link SendOptions#idempotencyKey(String)} to {@link #send(SendOptions)}. + * + * @param idempotencyKey 1 to 255 characters; null removes it + * @return a new builder + */ + public EmailBuilder idempotencyKey(String idempotencyKey) { + return new EmailBuilder(emails, message.idempotencyKey(idempotencyKey)); + } + /** * @return the email as an {@link EmailMessage}, for example for {@code emails().sendBatch()} */ diff --git a/src/main/java/co/lettermint/EmailMessage.java b/src/main/java/co/lettermint/EmailMessage.java index 4af4ed3..fc17b4a 100644 --- a/src/main/java/co/lettermint/EmailMessage.java +++ b/src/main/java/co/lettermint/EmailMessage.java @@ -3,6 +3,7 @@ import co.lettermint.types.MessageAttachmentInput; import co.lettermint.types.MessageTagInput; import co.lettermint.types.SandboxResult; +import co.lettermint.types.SendBatchMailItem; import co.lettermint.types.SendMailRequest; import co.lettermint.types.SendMailRequestSettings; import java.time.Instant; @@ -55,6 +56,7 @@ private static final class Fields implements Cloneable { SendMailRequestSettings settings; SandboxResult sandboxResult; List attachments; + String idempotencyKey; @Override protected Fields clone() { @@ -311,6 +313,26 @@ public EmailMessage attach(EmailAttachment attachment) { }); } + /** + * Sets a per-email idempotency key for {@code emails().sendBatch()}, sent as the item's + * {@code idempotency_key}. If an email with the same key and content was accepted in the last 24 + * hours, the API returns its original result and does not send it again. Reusing a key with + * different content, or while that email is still being processed, rejects the whole batch with + * {@code 409 Conflict} and sends nothing. Keys must be unique within a batch. + * + *

Only batch sending uses it: {@code emails().send()} rejects a message that has one, and + * {@link #toRequest()} leaves it out. For a single email, or for the whole batch request, use + * {@link SendOptions#idempotencyKey(String)}, which is sent as the {@code Idempotency-Key} header. + * + * @param idempotencyKey 1 to 255 characters; null removes it + * @return a copy with this key + * @throws co.lettermint.exceptions.LettermintValidationException when the key is empty or longer + * than 255 characters + */ + public EmailMessage idempotencyKey(String idempotencyKey) { + return with(f -> f.idempotencyKey = idempotencyKey); + } + /** @return the sender, or null */ public String from() { return fields.from; @@ -396,6 +418,11 @@ public List attachments() { return fields.attachments == null ? List.of() : fields.attachments; } + /** @return the per-email idempotency key for batch sending, or null */ + public String idempotencyKey() { + return fields.idempotencyKey; + } + /** * @return the message in the API's wire format, with attachments base64-encoded */ @@ -443,6 +470,14 @@ public SendMailRequest toRequest() { return request.build(); } + /** The message as a {@code POST /send/batch} item, with its per-email idempotency key. */ + SendBatchMailItem toBatchItem() { + SendMailRequest r = toRequest(); + return new SendBatchMailItem(r.route(), r.from(), r.to(), r.cc(), r.bcc(), r.replyTo(), r.subject(), r.scheduledAt(), + r.sandboxResult(), r.headers(), r.metadata(), r.tag(), r.tags(), r.settings(), r.html(), r.text(), r.attachments(), + fields.idempotencyKey); + } + /** The message without attachment content. Contains no credentials. */ @Override public String toString() { @@ -464,6 +499,7 @@ public String toString() { add(joiner, "settings", fields.settings); add(joiner, "sandboxResult", fields.sandboxResult); add(joiner, "attachments", fields.attachments); + add(joiner, "idempotencyKey", fields.idempotencyKey); return joiner.toString(); } diff --git a/src/main/java/co/lettermint/EmailValidation.java b/src/main/java/co/lettermint/EmailValidation.java index 76f3fa7..00f1d03 100644 --- a/src/main/java/co/lettermint/EmailValidation.java +++ b/src/main/java/co/lettermint/EmailValidation.java @@ -16,6 +16,7 @@ final class EmailValidation { private static final Pattern TAG_NAME = Pattern.compile("^[A-Za-z0-9_-]{1,32}$"); private static final Pattern TAG_VALUE = Pattern.compile("^[A-Za-z0-9_-]{1,64}$"); private static final int MAX_TAGS = 20; + private static final int MAX_IDEMPOTENCY_KEY = 255; private EmailValidation() { } @@ -24,6 +25,11 @@ static void validate(EmailMessage message, String prefix) { String field = prefix.isEmpty() ? "tags" : prefix + ".tags"; String legacyTag = message.tag(); validateTags(message.tags(), legacyTag != null && !legacyTag.isEmpty(), field); + String key = message.idempotencyKey(); + if (key != null && (key.isEmpty() || key.codePointCount(0, key.length()) > MAX_IDEMPOTENCY_KEY)) { + throw new LettermintValidationException("idempotencyKey must be 1 to " + MAX_IDEMPOTENCY_KEY + " characters.", + prefix.isEmpty() ? "idempotencyKey" : prefix + ".idempotencyKey"); + } } static void validateTags(List tags, boolean hasLegacyTag, String field) { diff --git a/src/main/java/co/lettermint/Emails.java b/src/main/java/co/lettermint/Emails.java index b32c072..466ae2b 100644 --- a/src/main/java/co/lettermint/Emails.java +++ b/src/main/java/co/lettermint/Emails.java @@ -3,10 +3,12 @@ import co.lettermint.exceptions.LettermintValidationException; import co.lettermint.types.Operations; import co.lettermint.types.Operations.AuthSurface; -import co.lettermint.types.SendMailRequest; +import co.lettermint.types.SendBatchMailItem; import co.lettermint.types.SendMailResponse; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; /** * Sends email with the project sending token ({@code x-lettermint-token}). Holds no message state: @@ -42,11 +44,17 @@ public SendMailResponse send(EmailMessage message, SendOptions options) { throw new LettermintValidationException("send() takes an EmailMessage.", "message"); } EmailValidation.validate(message, ""); + if (message.idempotencyKey() != null) { + throw new LettermintValidationException("A per-email idempotencyKey applies only to sendBatch(); pass SendOptions.idempotencyKey(...) to send().", + "idempotencyKey"); + } return transport.call(Operations.SEND_MAIL, Transport.call("emails.send").body(message.toRequest()).options(options)); } /** - * Sends up to 500 emails in one request. Use {@link EmailBuilder#build()} for builders. + * Sends up to 500 emails in one request. Use {@link EmailBuilder#build()} for builders. Give each + * email an {@link EmailMessage#idempotencyKey(String)} to make it safe to send again, in this or a + * later batch. * * @param messages the emails * @return one result per email, in order @@ -58,6 +66,12 @@ public List sendBatch(List messages) { /** * Sends up to 500 emails in one request. * + *

{@code options} can carry an idempotency key for the whole request ({@code Idempotency-Key} + * header); per-email keys are set with {@link EmailMessage#idempotencyKey(String)} and must be unique + * within the batch. If the batch fails part-way, emails before the failing one may already be + * accepted: {@link co.lettermint.exceptions.ApiException#getDetails()} then has an {@code accepted} + * list with the {@code index} and {@code message_id} of each. + * * @param messages the emails * @param options the idempotency key and timeout of this call, or null * @return one result per email, in order @@ -67,14 +81,18 @@ public List sendBatch(List messages, SendOptions if (messages == null) { throw new LettermintValidationException("sendBatch() takes a list of messages.", "messages"); } - List body = new ArrayList<>(messages.size()); + List body = new ArrayList<>(messages.size()); + Set keys = new HashSet<>(); for (int index = 0; index < messages.size(); index++) { EmailMessage message = messages.get(index); if (message == null) { throw new LettermintValidationException("An email message must not be null.", "messages[" + index + "]"); } EmailValidation.validate(message, "messages[" + index + "]"); - body.add(message.toRequest()); + if (message.idempotencyKey() != null && !keys.add(message.idempotencyKey())) { + throw new LettermintValidationException("Idempotency keys must be unique within a batch.", "messages[" + index + "].idempotencyKey"); + } + body.add(message.toBatchItem()); } return transport.call(Operations.SEND_BATCH_MAIL, Transport.call("emails.sendBatch").body(body).options(options)); } diff --git a/src/test/java/co/lettermint/EmailsTest.java b/src/test/java/co/lettermint/EmailsTest.java index 1801c92..721fc79 100644 --- a/src/test/java/co/lettermint/EmailsTest.java +++ b/src/test/java/co/lettermint/EmailsTest.java @@ -235,6 +235,42 @@ void sendsBatches() throws Exception { assertThrows(LettermintValidationException.class, () -> client.emails().sendBatch(null)); } + @Test + void sendsPerEmailIdempotencyKeysInBatches() throws Exception { + EmailBuilder welcome = client.emails().compose().from("hello@acme.com").subject("Welcome").idempotencyKey("welcome-john"); + EmailMessage one = EmailMessage.create().from("a@b.c").to("d@e.f").subject("One").text("1").idempotencyKey("order-1"); + client.emails().sendBatch(List.of(one, welcome.to("john@example.com").build()), SendOptions.idempotencyKey("batch-1")); + assertEquals("batch-1", server.last().header("idempotency-key")); + assertEquals("{\"from\":\"a@b.c\",\"to\":[\"d@e.f\"],\"subject\":\"One\",\"text\":\"1\",\"idempotency_key\":\"order-1\"}", + lastBody().get(0).toString()); + assertEquals("welcome-john", lastBody().get(1).get("idempotency_key").asText()); + assertEquals("order-1", one.idempotencyKey()); + + client.emails().sendBatch(List.of(one.idempotencyKey(null), one.idempotencyKey("order-2"))); + assertNull(server.last().header("idempotency-key")); + assertNull(lastBody().get(0).get("idempotency_key")); + assertEquals("order-2", lastBody().get(1).get("idempotency_key").asText()); + int before = server.requests().size(); + + LettermintValidationException duplicate = assertThrows(LettermintValidationException.class, + () -> client.emails().sendBatch(List.of(one, one.subject("Two")))); + assertEquals("messages[1].idempotencyKey", duplicate.getField()); + + assertEquals("idempotencyKey", assertThrows(LettermintValidationException.class, () -> one.idempotencyKey("")).getField()); + assertThrows(LettermintValidationException.class, () -> one.idempotencyKey("k".repeat(256))); + assertEquals("k".repeat(255), one.idempotencyKey("k".repeat(255)).idempotencyKey()); + assertThrows(LettermintValidationException.class, () -> welcome.idempotencyKey("")); + + LettermintValidationException single = assertThrows(LettermintValidationException.class, () -> client.emails().send(one)); + assertEquals("idempotencyKey", single.getField()); + assertThrows(LettermintValidationException.class, () -> welcome.to("john@example.com").send()); + assertEquals(before, server.requests().size()); + + client.emails().send(one.idempotencyKey(null), SendOptions.idempotencyKey("single-1")); + assertEquals("single-1", server.last().header("idempotency-key")); + assertNull(lastBody().get("idempotency_key")); + } + @Test void reschedulesFromAString() throws Exception { client.emails().send(EmailMessage.create().from("a@b.c").scheduledAt("tomorrow 9am"));