Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

Expand All @@ -163,6 +164,16 @@ List<SendMailResponse> results = lettermint.emails().sendBatch(List.of(
welcome.to("john@example.com").html("<p>Hi John</p>").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.
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/co/lettermint/EmailBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
*/
Expand Down
36 changes: 36 additions & 0 deletions src/main/java/co/lettermint/EmailMessage.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,6 +56,7 @@ private static final class Fields implements Cloneable {
SendMailRequestSettings settings;
SandboxResult sandboxResult;
List<EmailAttachment> attachments;
String idempotencyKey;

@Override
protected Fields clone() {
Expand Down Expand Up @@ -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.
*
* <p>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;
Expand Down Expand Up @@ -396,6 +418,11 @@ public List<EmailAttachment> 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
*/
Expand Down Expand Up @@ -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() {
Expand All @@ -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();
}

Expand Down
6 changes: 6 additions & 0 deletions src/main/java/co/lettermint/EmailValidation.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
Expand All @@ -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<MessageTagInput> tags, boolean hasLegacyTag, String field) {
Expand Down
26 changes: 22 additions & 4 deletions src/main/java/co/lettermint/Emails.java
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -58,6 +66,12 @@ public List<SendMailResponse> sendBatch(List<EmailMessage> messages) {
/**
* Sends up to 500 emails in one request.
*
* <p>{@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
Expand All @@ -67,14 +81,18 @@ public List<SendMailResponse> sendBatch(List<EmailMessage> messages, SendOptions
if (messages == null) {
throw new LettermintValidationException("sendBatch() takes a list of messages.", "messages");
}
List<SendMailRequest> body = new ArrayList<>(messages.size());
List<SendBatchMailItem> body = new ArrayList<>(messages.size());
Set<String> 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));
}
Expand Down
36 changes: 36 additions & 0 deletions src/test/java/co/lettermint/EmailsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Loading