diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d562474..2865e5d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,8 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, windows-latest]
- java: [8, 11, 17, 21]
+ # 17 is the minimum; 21 and 25 are the current LTS releases.
+ java: [17, 21, 25]
steps:
- name: Checkout code
@@ -34,12 +35,11 @@ jobs:
run: chmod +x gradlew
if: runner.os != 'Windows'
- - name: Build with Gradle
+ # build runs the tests, javadoc and checkGeneratedTypes. The SDK generator is
+ # private, so CI only verifies the headers of src/main/java/co/lettermint/types.
+ - name: Build and test
run: ./gradlew build --no-daemon
- - name: Run tests
- run: ./gradlew test --no-daemon
-
- name: Upload test results
uses: actions/upload-artifact@v7
if: failure()
@@ -71,7 +71,6 @@ jobs:
- name: Check build output
run: |
- if [ ! -d "build/libs" ]; then
- echo "Build output directory 'build/libs' not found"
- exit 1
- fi
+ jar=$(ls build/libs/lettermint-*.jar | grep -v -e sources -e javadoc)
+ unzip -p "$jar" META-INF/MANIFEST.MF | grep -q 'Automatic-Module-Name: co.lettermint'
+ if unzip -l "$jar" | grep -q okhttp; then echo "okhttp must not be bundled"; exit 1; fi
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d822fb7..a44fe95 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- java: [8, 11, 17, 21]
+ java: [17, 21, 25]
steps:
- name: Checkout code
@@ -36,7 +36,7 @@ jobs:
run: chmod +x gradlew
- name: Run tests
- run: ./gradlew test --no-daemon
+ run: ./gradlew build --no-daemon
release:
name: Release
@@ -71,8 +71,14 @@ jobs:
fi
echo "RELEASE_VERSION=$version" >> "$GITHUB_ENV"
- perl -0pi -e "s/^version = '.*'$/version = '$version'/m" build.gradle
- RELEASE_VERSION="$version" perl -0pi -e 's|(lettermint\s*)[^<]+()|$1$ENV{RELEASE_VERSION}$2|' pom.xml
+
+ # build.gradle reads the version from -PreleaseVersion; check the jar before publishing.
+ - name: Build and check the release version
+ run: |
+ ./gradlew build -PreleaseVersion="$RELEASE_VERSION" --no-daemon
+ jar="build/libs/lettermint-$RELEASE_VERSION.jar"
+ unzip -p "$jar" META-INF/MANIFEST.MF | grep -q "Implementation-Version: $RELEASE_VERSION"
+ unzip -p "$jar" co/lettermint/BuildInfo.class | grep -q "$RELEASE_VERSION"
- name: Publish to Maven Central
env:
@@ -83,7 +89,7 @@ jobs:
GPG_PRIVATE_KEY_BASE64: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
export ORG_GRADLE_PROJECT_signingKey=$(echo "$GPG_PRIVATE_KEY_BASE64" | base64 -d)
- ./gradlew publish --no-daemon
+ ./gradlew publish -PreleaseVersion="$RELEASE_VERSION" --no-daemon
- name: Release staging repository
env:
diff --git a/README.md b/README.md
index 94d53f1..8c353bd 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,9 @@

[](https://lettermint.co/r/discord)
-The official Java SDK for [Lettermint](https://lettermint.co).
+The official Java SDK for [Lettermint](https://lettermint.co). It runs on Java 17 or later, uses the JDK's `java.net.http` client and depends only on Jackson.
-## Requirements
-
-- Java 8 or higher
+Upgrading from 2.x? Read [UPGRADE.md](UPGRADE.md).
## Installation
@@ -18,269 +16,410 @@ The official Java SDK for [Lettermint](https://lettermint.co).
co.lettermintlettermint
- 2.0.0
+ 3.0.0
```
### Gradle
```groovy
-implementation 'co.lettermint:lettermint:2.0.0'
+implementation 'co.lettermint:lettermint:3.0.0'
```
-## Quick Start
+## Quick start
+
+Create a client with a project sending token and send an email:
```java
+import co.lettermint.EmailMessage;
import co.lettermint.Lettermint;
-import co.lettermint.endpoints.EmailEndpoint;
-import co.lettermint.models.SendEmailResponse;
+import co.lettermint.types.SendMailResponse;
-EmailEndpoint email = Lettermint.email("your-sending-token");
+Lettermint lettermint = Lettermint.builder()
+ .sendingToken(System.getenv("LETTERMINT_PROJECT_TOKEN"))
+ .build();
-SendEmailResponse response = email
- .from("sender@example.com")
- .to("recipient@example.com")
- .subject("Hello from Lettermint")
- .html("
Hello World!
")
- .send();
+SendMailResponse result = lettermint.emails().send(EmailMessage.create()
+ .from("Acme ")
+ .to("jane@example.com")
+ .subject("Welcome to Acme")
+ .html("
Thanks for signing up.
")
+ .text("Thanks for signing up."));
-System.out.println("Message ID: " + response.getMessageId());
+System.out.println(result.messageId() + " " + result.status()); // "…", "pending"
```
-## Usage
+The client is immutable and thread-safe and holds no message state. Create it once (for example as a Spring bean) and share it.
+
+## Tokens
-### Sending Emails
+Lettermint has two kinds of API tokens:
-Use a project sending token with `Lettermint.email(...)`. Sending tokens authenticate with the `x-lettermint-token` header.
+| Builder method | Token | Used by | Sent as |
+| --- | --- | --- | --- |
+| `sendingToken(...)` | Project sending token (`lm_…`) | `lettermint.emails()` | `x-lettermint-token` header |
+| `teamToken(...)` | Team API token (`lm_team_…`) | Every other part (domains, messages, projects, …) | `Authorization: Bearer` header |
-The SDK provides a fluent builder interface for composing emails:
+Pass one or both:
```java
-import co.lettermint.Lettermint;
-import co.lettermint.endpoints.EmailEndpoint;
-import co.lettermint.models.SendEmailResponse;
+Lettermint lettermint = Lettermint.builder()
+ .sendingToken(System.getenv("LETTERMINT_PROJECT_TOKEN"))
+ .teamToken(System.getenv("LETTERMINT_TEAM_TOKEN"))
+ .build();
+```
-import java.util.HashMap;
-import java.util.Map;
+Each part uses its own token and never falls back to the other one. If the token a method needs is missing, it throws a `LettermintConfigException` that names it (`domains.list needs teamToken; …`), before any request. `lettermint.ping()` uses the team token when it is set, otherwise the sending token. `messages().reschedule()` and `messages().cancel()` accept either token in the same way.
-EmailEndpoint email = Lettermint.email("your-sending-token");
+You can also pass a single token and let the SDK choose its type by the format: `lm_team_` followed by letters and digits is a team token, and `lm_` followed by letters and digits is a sending token.
-Map headers = new HashMap<>();
-headers.put("X-Custom-Header", "value");
+```java
+Lettermint lettermint = Lettermint.of(System.getenv("LETTERMINT_TOKEN"));
+// With other options:
+Lettermint lettermint = Lettermint.builder().token(System.getenv("LETTERMINT_TOKEN")).timeout(Duration.ofSeconds(10)).build();
+```
-Map metadata = new HashMap<>();
-metadata.put("userId", "123");
-metadata.put("campaign", "welcome");
+Any other format, such as an SSO verification token (`lm_sso_…`), throws `LettermintConfigException`; use `sendingToken(...)` or `teamToken(...)` for those. Error messages never contain the token.
-SendEmailResponse response = email
- // Sender
- .from("John Doe ")
+### Options
- // Recipients (varargs)
- .to("recipient1@example.com", "recipient2@example.com")
- .cc("cc@example.com")
- .bcc("bcc@example.com")
- .replyTo("reply@example.com")
+| Builder method | Default | Description |
+| --- | --- | --- |
+| `sendingToken(String)` | | Project sending token. |
+| `teamToken(String)` | | Team API token. |
+| `token(String)` | | Either token, detected by its format. |
+| `baseUrl(String)` | `https://api.lettermint.co/v1` | API base URL. |
+| `timeout(Duration)` | 30 seconds | Request timeout. It covers the whole request: connecting, sending, the response headers and the body. |
+| `httpClient(HttpClient)` | a new `java.net.http.HttpClient` | Your own client, for example with a proxy, TLS settings or an executor. It must not follow redirects (`HttpClient.Redirect.NEVER`, the JDK default). |
- // Content
- .subject("Welcome!")
- .html("
Hello World
")
- .text("Hello World")
+## Sending email
- // Custom headers
- .headers(headers)
- // Or add headers individually
- .header("X-Another-Header", "value")
+### Messages
- // Attachments
- .attach("document.pdf", base64EncodedContent)
- .attach("logo.png", base64EncodedContent, "logo") // Inline with content ID
+`EmailMessage` is immutable: every setter returns a new message and leaves the original unchanged. Each setter has an accessor of the same name (`message.subject()`).
- // Routing
- .route("route-id")
+```java
+EmailMessage message = EmailMessage.create()
+ .from("Acme ")
+ .to("jane@example.com")
+ .replyTo("support@acme.com")
+ .subject("Your order has shipped")
+ .html(html)
+ .metadata(Map.of("order_id", "1234"));
+
+lettermint.emails().send(message);
+```
- // Metadata and tags
- .metadata(metadata)
- .tag("legacy-tag")
- .tags(
- new MessageTag("campaign", "welcome"),
- new MessageTag("customer", "new")
- )
+### The email builder
- // Idempotency
- .idempotencyKey("unique-request-key")
+`emails().compose()` returns an immutable builder bound to the client, with the same setters plus `send()`. Every setter returns a new builder, so you can keep a base builder and reuse it, also across threads:
- .send();
-```
+```java
+EmailBuilder welcome = lettermint.emails().compose()
+ .from("Acme ")
+ .subject("Welcome to Acme")
+ .tags(new MessageTagInput("campaign", "welcome"));
-`tag()` remains available for the legacy single tag. The previous map form of
-`tags()` also remains available.
+welcome.to("jane@example.com").html("
Hi Jane
").send();
+welcome.to("john@example.com").html("
Hi John
").send();
+```
-Existing constructor-based sending usage still works:
+When you build an email over several statements, keep the returned builder:
```java
-Lettermint lettermint = new Lettermint("your-sending-token");
-lettermint.email().from("sender@example.com").to("recipient@example.com").subject("Hello").send();
+EmailBuilder email = lettermint.emails().compose().from("hello@acme.com").to(user.email()).subject("Your invoice");
+if (user.accountant() != null) {
+ email = email.cc(user.accountant());
+}
+email.html(invoiceHtml).send();
```
-### Batch Sending
+| Method | Description |
+| --- | --- |
+| `from(address)` | Sender, for example `Acme `. |
+| `to(...)`, `cc(...)`, `bcc(...)`, `replyTo(...)` | Replace the recipient list (varargs or a collection). |
+| `subject(text)` | Subject line. |
+| `html(html)`, `text(text)` | Bodies. `null` removes one. |
+| `headers(map)` | Custom email headers. |
+| `metadata(map)` | Data stored with the message, not added as headers. |
+| `tags(MessageTagInput...)`, `tag(name)` | Name/value tags, and the legacy single tag. |
+| `route(slug)` | The route to send through. |
+| `scheduledAt(String or Instant)` | Delivery time: an `Instant`, ISO 8601, or English such as `tomorrow 9am`. |
+| `settings(SendMailRequestSettings)` | Per-email settings that override the route. |
+| `sandboxResult(SandboxResult)` | The result a Sandbox project simulates. |
+| `attach(EmailAttachment)` | Adds an attachment. |
+| `send()`, `send(SendOptions)` | Sends the email. The builder can be sent again. |
+| `build()` | Returns the `EmailMessage`. |
+
+`emails().compose(message)` starts a builder from a message. `message.toRequest()` returns the API's wire format (`SendMailRequest`).
+
+### Batch sending
+
+Send up to 500 emails in one request:
```java
-import co.lettermint.models.api.SendMailRequest;
-import co.lettermint.models.api.SendMailResponse;
+List results = lettermint.emails().sendBatch(List.of(
+ EmailMessage.create().from("hello@acme.com").to("jane@example.com").subject("Hi Jane").text("Hello"),
+ welcome.to("john@example.com").html("
Hi John
").build()));
+```
-import java.util.Collections;
-import java.util.List;
+### Idempotency
-SendMailRequest message = new SendMailRequest();
-message.fromValue = "sender@example.com";
-message.to = Collections.singletonList("recipient@example.com");
-message.subject = "Hello from Lettermint";
-message.text = "This is a batch email.";
+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.
-List response = Lettermint.email("your-sending-token")
- .sendBatch(Collections.singletonList(message));
+```java
+lettermint.emails().send(message, SendOptions.idempotencyKey("order-" + order.id() + "-confirmation"));
+builder.send(SendOptions.idempotencyKey("welcome-jane"));
+lettermint.emails().sendBatch(messages, SendOptions.idempotencyKey("newsletter-2026-10"));
```
-Both sending and API clients support `ping()`:
+The SDK never retries on its own.
+
+### Scheduling
```java
-String pong = Lettermint.email("your-sending-token").ping();
+SendMailResponse result = lettermint.emails().compose()
+ .from("hello@acme.com")
+ .to("jane@example.com")
+ .subject("Your trial ends tomorrow")
+ .text("…")
+ .scheduledAt(Instant.now().plus(Duration.ofDays(1)))
+ .send();
+
+if (MessageStatus.SCHEDULED.equals(result.status())) {
+ System.out.println(result.scheduledAt());
+}
+
+lettermint.messages().reschedule(result.messageId(), new RescheduleMessageRequest("2026-10-20T09:00:00Z"));
+lettermint.messages().cancel(result.messageId());
```
-### Team API
+### Sandbox
-Use a team API token with `Lettermint.api(...)`. API tokens authenticate with `Authorization: Bearer ...` and are separate from project sending tokens.
+In a Sandbox project, nothing is delivered. Choose the simulated result per email:
```java
-import co.lettermint.Lettermint;
-import co.lettermint.api.ApiClient;
-import co.lettermint.models.api.DomainIndexResponse;
-import co.lettermint.models.api.TeamData;
+SendMailResponse result = lettermint.emails().compose()
+ .from("hello@acme.com")
+ .to("jane@example.com")
+ .subject("Test")
+ .text("Test")
+ .sandboxResult(SandboxResult.HARD_BOUNCED)
+ .send();
+
+System.out.println(result.sandbox() + " " + result.sandboxResult()); // true hard_bounced
+```
-import java.util.Collections;
-import java.util.Map;
+### Tags
-ApiClient api = Lettermint.api("your-api-token");
+`tags(...)` accepts up to 20 case-sensitive name/value tags (19 when the legacy `tag(...)` is also set). Names match `^[A-Za-z0-9_-]{1,32}$`, may not start with `__lettermint` and must be unique. Values match `^[A-Za-z0-9_-]{1,64}$`. The SDK checks this before the request and throws `LettermintValidationException`. Because messages and builders are immutable, a rejected tag leaves them unchanged.
-Map query = Collections.singletonMap("page[size]", "10");
+### Attachments
-DomainIndexResponse domains = api.domains().list(query);
-TeamData team = api.team().retrieve();
-String messageHtml = api.messages().html("message-id");
-String pong = api.ping();
+```java
+lettermint.emails().compose()
+ .from("billing@acme.com")
+ .to("jane@example.com")
+ .subject("Your invoice")
+ .html(" Your invoice is attached.")
+ .attach(EmailAttachment.of("invoice.pdf", Files.readAllBytes(invoicePath)).contentType("application/pdf"))
+ .attach(EmailAttachment.ofBase64("logo.png", logoBase64).contentId("logo"))
+ .send();
```
-Endpoint groups are available as `domains()`, `messages()`, `projects()`, `routes()`, `stats()`, `suppressions()`, `team()`, and `webhooks()`.
+`EmailAttachment.of` takes raw bytes, which the SDK base64-encodes; `ofBase64` takes content that is already encoded. `lettermint.blockedFileTypes()` lists the extensions and MIME types the API rejects.
-### Webhook Verification
+## Team API
-Verify webhook signatures to ensure requests are from Lettermint:
+With a team token, the client manages domains, messages, projects, routes, statistics, suppressions, the team and webhooks:
```java
-import co.lettermint.webhooks.Webhook;
-import co.lettermint.exceptions.webhook.WebhookVerificationException;
+Lettermint lettermint = Lettermint.builder().teamToken(System.getenv("LETTERMINT_TEAM_TOKEN")).build();
-import java.util.Map;
+DomainData domain = lettermint.domains().create(new StoreDomainData("acme.com"));
+lettermint.domains().verifyDnsRecords(domain.id());
-String rawPayload = "..."; // Raw JSON body from request
-String signature = "..."; // Value of X-Lettermint-Signature header
-String secret = "whsec_..."; // Your webhook signing secret
+ProjectCreatedData project = lettermint.projects().create(StoreProjectData.builder().name("Production").build());
+System.out.println(project.apiToken()); // the new project's sending token, shown once
-try {
- Map payload = Webhook.verify(rawPayload, signature, secret);
+StatsData stats = lettermint.stats().retrieve(GetStatsQuery.builder().from("2026-10-01").to("2026-10-31").build());
+String html = lettermint.messages().html("message-id");
+```
- String event = (String) payload.get("event");
- Map data = (Map) payload.get("data");
+| Accessor | Methods |
+| --- | --- |
+| `domains()` | `list`, `iterate`, `create`, `retrieve`, `delete`, `verifyDnsRecords`, `verifyDnsRecord`, `updateProjects` |
+| `messages()` | `list`, `iterate`, `retrieve`, `events`, `iterateEvents`, `source`, `html`, `text`, `reschedule`, `cancel`, `process` |
+| `projects()` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `rotateToken` |
+| `projects().reportForwarding()` | `retrieve`, `update`, `delete`, `verify`, `resendCode` |
+| `routes()` | `list(projectId)`, `iterate(projectId)`, `create(projectId, …)`, `retrieve`, `update`, `delete`, `verifyInboundDomain` |
+| `stats()` | `retrieve` |
+| `suppressions()` | `list`, `iterate`, `create`, `delete` |
+| `team()` | `retrieve`, `update`, `usage`, `roles` |
+| `team().members()` | `list`, `iterate`, `retrieve`, `updateAssignment` |
+| `webhooks()` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerateSecret` |
+| `webhooks().deliveries()` | `list(webhookId)`, `iterate(webhookId)`, `retrieve(webhookId, deliveryId)` |
+| (root) | `ping`, `analytics`, `blockedFileTypes` |
+
+Request and response types are immutable records in `co.lettermint.types`. Build requests with their builder (`UpdateRouteData.builder().name("Main").build()`) or, for small ones, the constructor (`new StoreDomainData("acme.com")`). In update requests, a field that is optional and nullable is an `OptionalNullable`: leave it unset to keep the current value, or pass `null` to the builder to clear it:
- // Handle the webhook event
-} catch (WebhookVerificationException e) {
- // Invalid signature
-}
+```java
+lettermint.routes().update(routeId, UpdateRouteData.builder().inboundDomain(null).build()); // {"inbound_domain": null}
```
-With custom timestamp tolerance (in seconds):
+### Query parameters and pagination
+
+Query parameters are typed records with builders. The SDK sends them in the API's bracket syntax (`page[size]=30&filter[status]=verified&sort=-created_at`):
```java
-// Allow signatures up to 10 minutes old
-Map payload = Webhook.verify(rawPayload, signature, secret, 600);
+CursorPage page = lettermint.domains().list(ListDomainsQuery.builder()
+ .pageSize(30)
+ .filterStatus(DomainStatus.VERIFIED)
+ .sort(List.of(ListDomainsQuerySortItem.CREATED_AT_DESC))
+ .build());
-// Disable timestamp checking
-Map payload = Webhook.verify(rawPayload, signature, secret, 0);
+System.out.println(page.data().size() + " " + page.nextCursor());
```
-## Exception Handling
+Every list has an `iterate()` method that follows `nextCursor` until the last page. It returns a `CursorIterable`, which you can loop over or stream; pages are requested only when you get to them:
-The SDK uses unchecked exceptions that extend `RuntimeException`:
+```java
+for (MessageListData message : lettermint.messages().iterate(ListMessagesQuery.builder().filterStatus(MessageStatus.HARD_BOUNCED).build())) {
+ System.out.println(message.id() + " " + message.subject());
+}
+
+lettermint.webhooks().deliveries().iterate(webhookId).stream().limit(100).forEach(System.out::println);
+```
+
+### Timeouts and cancellation
+
+Every method takes an optional last argument with per-call options: `RequestOptions.timeout(Duration)`, or `SendOptions` for calls that also take an idempotency key.
```java
-import co.lettermint.exceptions.*;
+lettermint.messages().list(null, RequestOptions.timeout(Duration.ofSeconds(5)));
+```
+
+To cancel a call, interrupt the calling thread. The SDK aborts the request and throws `java.util.concurrent.CancellationException` with the thread's interrupt status set.
+
+The SDK is synchronous. On Java 21, virtual threads make blocking calls cheap; to run a call asynchronously, use `CompletableFuture.supplyAsync(() -> lettermint.emails().send(message), executor)`.
+
+## Errors
+
+Every exception the SDK throws is unchecked and extends `LettermintException`:
+
+| Class | When | Accessors |
+| --- | --- | --- |
+| `ApiException` | Any 4xx or 5xx JSON (or empty) response | `getStatus`, `getCode`, `getMessage`, `getDetails`, `getBody` |
+| `AuthenticationException` | 401 | |
+| `PermissionException` | 403 | |
+| `NotFoundException` | 404 | |
+| `ConflictException` | 409 | |
+| `ValidationException` | 422 | `getErrors` (field errors) |
+| `RateLimitException` | 429 | `getRetryAfter` (`Duration`) |
+| `ServerException` | 5xx | |
+| `TimeoutException` | No complete response within the timeout | `getTimeout` |
+| `ConnectionException` | The request failed (DNS, TLS, refused, reset) | `getCause` |
+| `UnexpectedResponseException` | An empty or non-JSON body where JSON was expected, or an error page such as a proxy's HTML 502 | `getStatus`, `getBodyExcerpt` |
+| `RedirectException` | A 3xx response. Redirects are never followed, so tokens never go elsewhere. | `getStatus` |
+| `LettermintConfigException` | A missing or unrecognised token, an invalid option or ID | |
+| `LettermintValidationException` | The SDK rejected the request before sending it, such as invalid tags | `getField` |
+| `WebhookVerificationException` | A webhook delivery is not genuine | `getReason` |
+The subclasses of `ApiException` extend it. `getCode` and `getMessage` come from the API's error body (`{"error": {"code", "message", "details"}}` or `{"message", "errors"}`). All exceptions are in `co.lettermint.exceptions`; note that `co.lettermint.exceptions.TimeoutException` is not `java.util.concurrent.TimeoutException`.
+
+```java
try {
- lettermint.email()
- .from("sender@example.com")
- .to("recipient@example.com")
- .subject("Test")
- .send();
-} catch (ValidationException e) {
- // HTTP 422 - validation errors
- System.err.println("Validation failed: " + e.getMessage());
- System.err.println("Response: " + e.getResponseBody());
-} catch (HttpRequestException e) {
- // Other HTTP errors
- System.err.println("HTTP " + e.getStatusCode() + ": " + e.getMessage());
-} catch (LettermintException e) {
- // Other SDK errors (including timeouts)
- System.err.println("Error: " + e.getMessage());
+ lettermint.emails().send(message, SendOptions.idempotencyKey(key));
+} catch (ValidationException error) {
+ System.err.println(error.getMessage() + " " + error.getErrors());
+} catch (RateLimitException error) {
+ // Wait error.getRetryAfter(), then retry with the same idempotency key.
+} catch (TimeoutException error) {
+ // The outcome is unknown. Retry with the same idempotency key.
+} catch (ApiException error) {
+ System.err.println(error.getStatus() + " " + error.getCode() + " " + error.getMessage());
}
```
-### Webhook Exceptions
+Exceptions never contain request headers or tokens, and `toString()` of the client, its builder and its sub-clients shows tokens as `[redacted]`. Records that carry credentials, such as `ProjectCreatedData.apiToken()` or `WebhookSecretData.secret()`, also print them as `[redacted]`.
+
+## Webhooks
+
+Verify each webhook delivery before you trust it. Use the webhook's signing secret (`whsec_…`), not an API token, and pass the **raw** request body: the signature covers the exact bytes, so parsing and re-serializing the JSON breaks it.
```java
-import co.lettermint.exceptions.webhook.*;
+import co.lettermint.Webhook;
+import co.lettermint.WebhookPayload;
+import co.lettermint.exceptions.WebhookVerificationException;
-try {
- Webhook.verify(payload, signature, secret);
-} catch (InvalidSignatureException e) {
- // Signature doesn't match
-} catch (TimestampToleranceException e) {
- // Timestamp too old
- System.err.println("Timestamp: " + e.getTimestamp());
- System.err.println("Tolerance: " + e.getTolerance());
-} catch (WebhookVerificationException e) {
- // Other verification errors
-}
+Webhook webhook = new Webhook(System.getenv("LETTERMINT_WEBHOOK_SECRET"));
+
+WebhookPayload event = webhook.verify(rawBody, headers);
+System.out.println(event.event() + " " + event.data());
```
-## Building
+`verify(rawBody, headers)` takes the body as `byte[]` or `String`, and the headers as a `Map`, a `Map>` (Spring's `HttpHeaders`, JAX-RS `MultivaluedMap`) or a lookup function such as a servlet's `request::getHeader`. It requires `X-Lettermint-Signature` and `X-Lettermint-Delivery` (header names are case-insensitive), checks the HMAC-SHA256 signature in constant time, checks that the delivery timestamp equals the signed one and is within the tolerance, and returns the payload. Otherwise it throws `WebhookVerificationException` with a `getReason()`.
-### Maven
+### Spring Boot
-```bash
-mvn clean install
+```java
+@RestController
+class LettermintWebhookController {
+ private final Webhook webhook = new Webhook(System.getenv("LETTERMINT_WEBHOOK_SECRET"));
+
+ @PostMapping("/webhooks/lettermint")
+ ResponseEntity receive(@RequestBody byte[] body, @RequestHeader HttpHeaders headers) {
+ try {
+ WebhookPayload event = webhook.verify(body, headers);
+ // Handle event.event() and event.data() here.
+ return ResponseEntity.noContent().build();
+ } catch (WebhookVerificationException error) {
+ return ResponseEntity.badRequest().build();
+ }
+ }
+}
```
-### Gradle
+### Servlets
-```bash
-./gradlew build
+```java
+byte[] body = request.getInputStream().readAllBytes();
+WebhookPayload event = webhook.verify(body, request::getHeader);
```
-## Testing
+### Options and lower-level verification
-### Maven
+The default tolerance is 300 seconds in either direction. Change it with `new Webhook(secret, Duration.ofSeconds(60))`. `Duration.ZERO` accepts only the current second; it does not disable the check. A third argument takes a `java.time.Clock` for tests. A valid signature does not prevent a repeated delivery within the tolerance, so track `event.id()` if you must not process an event twice.
-```bash
-mvn test
-```
+If the headers are not at hand, call `webhook.verifySignature(rawBody, signatureHeader, deliveryHeader)`.
-### Gradle
+`WebhookPayload` has `id()`, `event()` (a `WebhookEvent` such as `WebhookEvent.MESSAGE_DELIVERED`; unknown events keep their raw name), `timestamp()`, `data()` and `fields()` with every top-level field. `data(MyRecord.class)` converts the data with Jackson.
+
+## Types
+
+Request and response types are generated from the Lettermint API specification into `co.lettermint.types`, for example `SendMailRequest`, `SendMailResponse`, `DomainData` and `CursorPage`. They are records: read fields with accessors such as `domain.id()`. Unknown JSON fields are ignored.
+
+Enums are open: `MessageStatus`, `DomainStatus`, `WebhookEvent` and the others are classes with a constant per known value. A value that the API adds later decodes without an error and keeps its raw string: `status.value()` returns it and `status.isKnown()` tells whether this version knows it. Compare with `equals` (`MessageStatus.DELIVERED.equals(status)`) or switch on `status.value()`.
+
+`co.lettermint.types.Operations` describes every API operation (path, token, request and response types).
+
+## Requirements
+
+- Java 17 or later (tested on 17, 21 and 25).
+- Jackson Databind 2.x, the only dependency.
+- The jar has the automatic module name `co.lettermint`.
+- The `User-Agent` header is `lettermint-java/`.
+- Use API tokens on servers only, never in apps that you ship to users.
+
+## Development
```bash
+./gradlew build # compile, test, javadoc and the generated-code check
./gradlew test
```
+`src/main/java/co/lettermint/types/` is generated by the private [SDK generator](https://github.com/lettermint/sdk-generator). Do not edit it by hand. With a checkout of the generator, `./gradlew generateTypes` regenerates it and `./gradlew checkGeneratedTypes` verifies it; set `LETTERMINT_SDK_GENERATOR` to the checkout (default `../sdk-generator`). Without the generator, as in CI, `checkGeneratedTypes` (part of `build`) only verifies the generated headers.
+
## License
MIT License - see [LICENSE](LICENSE) for details.
diff --git a/UPGRADE.md b/UPGRADE.md
index 9f2ef7e..8c4a5db 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -1,5 +1,475 @@
-# Upgrade To v2
+# Upgrade guide
+- [Upgrade from 2.x to 3.0](#upgrade-from-2x-to-30)
+- [Upgrade from 1.x to 2.0](#upgrade-from-1x-to-20)
+
+# Upgrade from 2.x to 3.0
+
+2.x no longer receives updates, including fixes. Upgrade to 3.0 to keep getting them.
+
+3.0 is a new major version. The main reason is safety: in 2.x, `Lettermint.email(token)` returned one mutable, non-thread-safe `EmailEndpoint`, and the README suggested keeping it as the client. Two emails composed at the same time on it, for example in two web requests, could mix recipients, content and `Idempotency-Key`, and an email abandoned halfway (for example because `tags()` threw) leaked into the next send. 2.x also followed redirects with the sending token, could silently re-send `POST /send` after a connection failure, and treated a webhook tolerance of `0` as "no check". 3.0 stores nothing about a message on the client and fixes the rest.
+
+## Highlights
+
+- One thread-safe client: `Lettermint.builder().sendingToken(...).teamToken(...).build()`, or `Lettermint.of(token)`. It replaces `Lettermint.email()`, `Lettermint.api()`, `new Lettermint(token)`, `ApiClient` and `LettermintClient`.
+- Sending is stateless: `emails().send(message, options)`, `emails().sendBatch(messages, options)` and an immutable `emails().compose()` builder. The `Idempotency-Key` is a per-call option.
+- Each part uses its own token: `emails()` uses the sending token, the Team API uses the team token. The SDK never falls back to the other token.
+- Typed exceptions for every outcome, including empty or HTML responses, redirects, timeouts and network failures.
+- Redirects are never followed, and nothing is retried.
+- A configurable timeout that covers the whole request, an injectable `java.net.http.HttpClient`, and cancellation by interrupting the thread.
+- Tokens never appear in `toString()` or exception messages.
+- Webhook verification is an object, `new Webhook(secret)`, and requires both signature headers.
+- Typed query records and `iterate()` methods that follow `next_cursor`.
+- Types are immutable records generated from the current API specification and use its names (see [Type names](#type-names)). Enums are open.
+- Java 17 or later. OkHttp is no longer a dependency.
+
+## Requirements
+
+- **Java 17 or later** (2.x: Java 8). Java 8 and 11 are past their mainstream support, and 3.0 relies on Java 17: records for the generated types, and the JDK's `java.net.http` client, which since Java 16 aborts a request when its future is cancelled, so timeouts and thread interrupts really stop the request.
+- The only dependency is Jackson Databind 2.x. OkHttp and its Kotlin standard library are gone; if your code used OkHttp only through this SDK, you can drop it.
+
+## Upgrade with a coding agent
+
+You can let a coding agent (Claude Code, Codex, Cursor, Copilot, …) do the upgrade. Copy this instruction into the agent from your project's root, then review its changes:
+
+````text
+Upgrade this project from the Lettermint Java SDK 2.x (co.lettermint:lettermint) to 3.0.
+
+1. Change the dependency to co.lettermint:lettermint:3.0.0 in pom.xml, build.gradle(.kts) or the version catalog. 3.0 needs Java 17 or newer: check the compiler release/target, toolchains, CI workflows and Dockerfiles, and report anything older.
+2. Read the upgrade guide before changing code: https://github.com/lettermint/lettermint-java/blob/main/UPGRADE.md (the sources jar, co.lettermint:lettermint:3.0.0:sources, has the Javadoc of every class). Treat the guide as the source of truth and don't guess APIs.
+3. Find every use of the SDK: imports from co.lettermint, Lettermint.email(, Lettermint.api(, new Lettermint(, getClient(, EmailEndpoint, ApiClient, LettermintClient, .idempotencyKey(, .attach(, .sendBatch(, Webhook.verify(, HttpRequestException, ValidationException, InvalidSignatureException, TimestampToleranceException, co.lettermint.models, and the 2.x type names from the guide's type-name table.
+4. Rewrite each use following the guide's before/after examples:
+ - Create one Lettermint client with Lettermint.builder().sendingToken(...), adding .teamToken(...) only where the Team API is used, and share it (for example as one Spring bean). Keep the project's existing environment variable or property names.
+ - Replace fluent EmailEndpoint chains with lettermint.emails().send(EmailMessage.create()...) or lettermint.emails().compose()...send(). Builders are immutable: assign the result of every setter. Never keep a half-built email in a field.
+ - Move idempotency keys into SendOptions.idempotencyKey(...) on send()/sendBatch(). Attachments become EmailAttachment.ofBase64(filename, base64) or EmailAttachment.of(filename, bytes), with .contentType(...) and .contentId(...).
+ - Team API: use the same client (lettermint.domains(), ...), typed query records instead of Map with bracket keys, record accessors (domain.id()) instead of public fields (domain.id), builders for request bodies, and the renamed methods from the guide.
+ - Enum constants are now objects, not strings: compare with equals() or use .value(); constant names gained underscores (SOFTBOUNCED -> SOFT_BOUNCED).
+ - Exceptions: switch to the 3.0 classes in co.lettermint.exceptions (ApiException and its subclasses, TimeoutException, ...). Accessors are getStatus(), getBody(), getCode().
+ - Webhooks: new Webhook(secret).verify(rawBody, headers). Keep passing the raw request body, keep the secret's whsec_ prefix, and make sure the X-Lettermint-Signature and X-Lettermint-Delivery headers reach the handler.
+ - Rename types using the guide's type-name table; co.lettermint.models.api becomes co.lettermint.types.
+5. Compile and run the tests, and fix every error. Don't send real email or call the live API while testing.
+6. Finish with a summary: the files you changed, anything you could not migrate with certainty, and behaviour changes I should review.
+
+Never print, log or commit API tokens or webhook secrets.
+````
+
+## Create the client
+
+`Lettermint.email(...)`, `Lettermint.api(...)`, `new Lettermint(token[, baseUrl])`, `lettermint.email()`, `getClient()`, `ApiClient`, `LettermintClient` and `Endpoint` are removed.
+
+```java
+// 2.x
+EmailEndpoint email = Lettermint.email(System.getenv("LETTERMINT_PROJECT_TOKEN"));
+ApiClient api = Lettermint.api(System.getenv("LETTERMINT_API_TOKEN"), "https://api.lettermint.co/v1");
+Lettermint legacy = new Lettermint(System.getenv("LETTERMINT_PROJECT_TOKEN"));
+
+// 3.0
+Lettermint lettermint = Lettermint.builder()
+ .sendingToken(System.getenv("LETTERMINT_PROJECT_TOKEN")) // for lettermint.emails()
+ .teamToken(System.getenv("LETTERMINT_TEAM_TOKEN")) // for the Team API
+ .baseUrl("https://api.lettermint.co/v1") // optional
+ .timeout(Duration.ofSeconds(10)) // new; default 30 seconds
+ .build();
+```
+
+Pass one token or both. With only one, calling a part that needs the other throws `LettermintConfigException` (for example `domains.list needs teamToken; …`) before any request.
+
+You can also pass a token string; the SDK chooses its type by the prefix:
+
+```java
+Lettermint lettermint = Lettermint.of("lm_team_..."); // team token
+Lettermint lettermint = Lettermint.of("lm_..."); // project sending token
+Lettermint lettermint = Lettermint.builder().token(token).timeout(Duration.ofSeconds(10)).build(); // with options
+```
+
+Any other format (SSO tokens, OAuth tokens, an empty string) throws `LettermintConfigException`; use `sendingToken(...)` or `teamToken(...)` for those.
+
+New options: `timeout(Duration)` (2.x had a fixed 30 seconds per connect, read and write phase; 3.0 limits the whole request) and `httpClient(HttpClient)` for a proxy, TLS settings or an executor. The client must not follow redirects. 2.x created a new OkHttp client for every `Lettermint.email(...)`/`Lettermint.api(...)` call; create the 3.0 client once and share it.
+
+## Send an email
+
+The 2.x `EmailEndpoint` was the client and a mutable builder at once, and was reset after each send. In 3.0, emails are immutable values: `EmailMessage`, or the `EmailBuilder` that `emails().compose()` returns. Each setter returns a new object and leaves the old one unchanged. Chaining works as before; if you built an email over several statements, assign the result of each setter.
+
+```java
+// 2.x
+EmailEndpoint email = Lettermint.email(token);
+SendEmailResponse response = email
+ .from("Acme ")
+ .to("jane@example.com")
+ .subject("Welcome")
+ .html("
+ */
+public final class EmailAttachment {
+ private final String filename;
+ private final byte[] bytes;
+ private final String base64;
+ private final String contentType;
+ private final String contentId;
+
+ private EmailAttachment(String filename, byte[] bytes, String base64, String contentType, String contentId) {
+ this.filename = filename;
+ this.bytes = bytes;
+ this.base64 = base64;
+ this.contentType = contentType;
+ this.contentId = contentId;
+ }
+
+ private static String checkFilename(String filename) {
+ if (filename == null || filename.isEmpty()) {
+ throw new LettermintValidationException("An attachment needs a filename.", "attachments");
+ }
+ return filename;
+ }
+
+ /**
+ * @param filename the file name, for example {@code invoice.pdf}
+ * @param content the raw bytes; the SDK copies them and base64-encodes them when sending
+ * @return the attachment
+ */
+ public static EmailAttachment of(String filename, byte[] content) {
+ checkFilename(filename);
+ if (content == null) {
+ throw new LettermintValidationException("Attachment content must not be null.", "attachments");
+ }
+ return new EmailAttachment(filename, content.clone(), null, null, null);
+ }
+
+ /**
+ * @param filename the file name, for example {@code logo.png}
+ * @param base64 the content, already base64-encoded
+ * @return the attachment
+ */
+ public static EmailAttachment ofBase64(String filename, String base64) {
+ checkFilename(filename);
+ if (base64 == null) {
+ throw new LettermintValidationException("Attachment content must not be null.", "attachments");
+ }
+ return new EmailAttachment(filename, null, base64, null, null);
+ }
+
+ /**
+ * @param contentType the MIME type, for example {@code application/pdf}; detected by the API when null
+ * @return a copy with this content type
+ */
+ public EmailAttachment contentType(String contentType) {
+ return new EmailAttachment(filename, bytes, base64, contentType, contentId);
+ }
+
+ /**
+ * @param contentId the Content-ID of an inline image referenced as {@code cid:}, or null
+ * @return a copy with this Content-ID
+ */
+ public EmailAttachment contentId(String contentId) {
+ return new EmailAttachment(filename, bytes, base64, contentType, contentId);
+ }
+
+ /**
+ * @return the file name
+ */
+ public String filename() {
+ return filename;
+ }
+
+ /**
+ * @return the MIME type, or null
+ */
+ public String contentType() {
+ return contentType;
+ }
+
+ /**
+ * @return the Content-ID, or null
+ */
+ public String contentId() {
+ return contentId;
+ }
+
+ /**
+ * @return the content, base64-encoded
+ */
+ public String base64Content() {
+ return base64 != null ? base64 : Base64.getEncoder().encodeToString(bytes);
+ }
+
+ @Override
+ public String toString() {
+ return "EmailAttachment{filename=" + filename
+ + (contentType != null ? ", contentType=" + contentType : "")
+ + (contentId != null ? ", contentId=" + contentId : "") + "}";
+ }
+}
diff --git a/src/main/java/co/lettermint/EmailBuilder.java b/src/main/java/co/lettermint/EmailBuilder.java
new file mode 100644
index 0000000..a9bf294
--- /dev/null
+++ b/src/main/java/co/lettermint/EmailBuilder.java
@@ -0,0 +1,250 @@
+package co.lettermint;
+
+import co.lettermint.types.MessageTagInput;
+import co.lettermint.types.SandboxResult;
+import co.lettermint.types.SendMailRequestSettings;
+import co.lettermint.types.SendMailResponse;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * An immutable email builder bound to a client, created by {@code lettermint.emails().compose()}.
+ *
+ *
Every setter returns a new builder and leaves this one unchanged, so a base builder can be kept
+ * and reused as a template, also across threads. A setter that throws leaves the builder unchanged.
+ * Keep the returned builder when you build an email over several statements:
+ *
+ *
+ */
+public final class EmailBuilder {
+ private final Emails emails;
+ private final EmailMessage message;
+
+ EmailBuilder(Emails emails, EmailMessage message) {
+ this.emails = emails;
+ this.message = message;
+ }
+
+ /**
+ * @param from the sender, for example {@code Acme }
+ * @return a new builder
+ */
+ public EmailBuilder from(String from) {
+ return new EmailBuilder(emails, message.from(from));
+ }
+
+ /**
+ * @param to the recipients; replaces the list
+ * @return a new builder
+ */
+ public EmailBuilder to(String... to) {
+ return new EmailBuilder(emails, message.to(to));
+ }
+
+ /**
+ * @param to the recipients; replaces the list
+ * @return a new builder
+ */
+ public EmailBuilder to(Collection to) {
+ return new EmailBuilder(emails, message.to(to));
+ }
+
+ /**
+ * @param cc the CC recipients; replaces the list
+ * @return a new builder
+ */
+ public EmailBuilder cc(String... cc) {
+ return new EmailBuilder(emails, message.cc(cc));
+ }
+
+ /**
+ * @param cc the CC recipients; replaces the list, null removes it
+ * @return a new builder
+ */
+ public EmailBuilder cc(Collection cc) {
+ return new EmailBuilder(emails, message.cc(cc));
+ }
+
+ /**
+ * @param bcc the BCC recipients; replaces the list
+ * @return a new builder
+ */
+ public EmailBuilder bcc(String... bcc) {
+ return new EmailBuilder(emails, message.bcc(bcc));
+ }
+
+ /**
+ * @param bcc the BCC recipients; replaces the list, null removes it
+ * @return a new builder
+ */
+ public EmailBuilder bcc(Collection bcc) {
+ return new EmailBuilder(emails, message.bcc(bcc));
+ }
+
+ /**
+ * @param replyTo the Reply-To addresses; replaces the list
+ * @return a new builder
+ */
+ public EmailBuilder replyTo(String... replyTo) {
+ return new EmailBuilder(emails, message.replyTo(replyTo));
+ }
+
+ /**
+ * @param replyTo the Reply-To addresses; replaces the list, null removes it
+ * @return a new builder
+ */
+ public EmailBuilder replyTo(Collection replyTo) {
+ return new EmailBuilder(emails, message.replyTo(replyTo));
+ }
+
+ /**
+ * @param subject the subject line
+ * @return a new builder
+ */
+ public EmailBuilder subject(String subject) {
+ return new EmailBuilder(emails, message.subject(subject));
+ }
+
+ /**
+ * @param html the HTML body; null removes it
+ * @return a new builder
+ */
+ public EmailBuilder html(String html) {
+ return new EmailBuilder(emails, message.html(html));
+ }
+
+ /**
+ * @param text the plain-text body; null removes it
+ * @return a new builder
+ */
+ public EmailBuilder text(String text) {
+ return new EmailBuilder(emails, message.text(text));
+ }
+
+ /**
+ * @param headers custom email headers; replaces them, null removes them
+ * @return a new builder
+ */
+ public EmailBuilder headers(Map headers) {
+ return new EmailBuilder(emails, message.headers(headers));
+ }
+
+ /**
+ * @param metadata data stored with the message, not added as headers
+ * @return a new builder
+ */
+ public EmailBuilder metadata(Map metadata) {
+ return new EmailBuilder(emails, message.metadata(metadata));
+ }
+
+ /**
+ * @param tag the legacy single tag; null removes it
+ * @return a new builder
+ */
+ public EmailBuilder tag(String tag) {
+ return new EmailBuilder(emails, message.tag(tag));
+ }
+
+ /**
+ * @param tags name/value tags (see {@link EmailMessage#tags(MessageTagInput...)}); replaces them
+ * @return a new builder
+ */
+ public EmailBuilder tags(MessageTagInput... tags) {
+ return new EmailBuilder(emails, message.tags(tags));
+ }
+
+ /**
+ * @param tags name/value tags; replaces them, null removes them
+ * @return a new builder
+ */
+ public EmailBuilder tags(Collection tags) {
+ return new EmailBuilder(emails, message.tags(tags));
+ }
+
+ /**
+ * @param route the slug of the route to send through
+ * @return a new builder
+ */
+ public EmailBuilder route(String route) {
+ return new EmailBuilder(emails, message.route(route));
+ }
+
+ /**
+ * @param scheduledAt the delivery time: ISO 8601 or English such as {@code tomorrow 9am}; null removes it
+ * @return a new builder
+ */
+ public EmailBuilder scheduledAt(String scheduledAt) {
+ return new EmailBuilder(emails, message.scheduledAt(scheduledAt));
+ }
+
+ /**
+ * @param scheduledAt the delivery time, sent as ISO 8601 in UTC
+ * @return a new builder
+ */
+ public EmailBuilder scheduledAt(Instant scheduledAt) {
+ return new EmailBuilder(emails, message.scheduledAt(scheduledAt));
+ }
+
+ /**
+ * @param settings per-email settings that override the route settings
+ * @return a new builder
+ */
+ public EmailBuilder settings(SendMailRequestSettings settings) {
+ return new EmailBuilder(emails, message.settings(settings));
+ }
+
+ /**
+ * @param sandboxResult the result a Sandbox project simulates for every recipient
+ * @return a new builder
+ */
+ public EmailBuilder sandboxResult(SandboxResult sandboxResult) {
+ return new EmailBuilder(emails, message.sandboxResult(sandboxResult));
+ }
+
+ /**
+ * @param attachment the attachment to add
+ * @return a new builder
+ */
+ public EmailBuilder attach(EmailAttachment attachment) {
+ return new EmailBuilder(emails, message.attach(attachment));
+ }
+
+ /**
+ * @return the email as an {@link EmailMessage}, for example for {@code emails().sendBatch()}
+ */
+ public EmailMessage build() {
+ return message;
+ }
+
+ /**
+ * Sends this email. The builder stays unchanged and can be sent again.
+ *
+ * @return the message id and status
+ */
+ public SendMailResponse send() {
+ return emails.send(message, null);
+ }
+
+ /**
+ * Sends this email with options, such as an idempotency key.
+ *
+ * @param options the idempotency key and timeout of this call, or null
+ * @return the message id and status
+ */
+ public SendMailResponse send(SendOptions options) {
+ return emails.send(message, options);
+ }
+
+ /** The email, without attachment content. Contains no credentials. */
+ @Override
+ public String toString() {
+ return "EmailBuilder{" + message + "}";
+ }
+}
diff --git a/src/main/java/co/lettermint/EmailMessage.java b/src/main/java/co/lettermint/EmailMessage.java
new file mode 100644
index 0000000..4af4ed3
--- /dev/null
+++ b/src/main/java/co/lettermint/EmailMessage.java
@@ -0,0 +1,475 @@
+package co.lettermint;
+
+import co.lettermint.types.MessageAttachmentInput;
+import co.lettermint.types.MessageTagInput;
+import co.lettermint.types.SandboxResult;
+import co.lettermint.types.SendMailRequest;
+import co.lettermint.types.SendMailRequestSettings;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.StringJoiner;
+
+/**
+ * An email. Immutable and thread-safe: every setter returns a new message and leaves this one
+ * unchanged, so a message can be shared and reused as a template. A setter that throws leaves the
+ * message unchanged.
+ *
+ *