From bc4df171c69b70f2754dc84b3a202ab6bd5a4243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Mon, 12 Oct 2026 03:21:28 -0300 Subject: [PATCH 1/5] Receiving and inboxes: received mail, inboxes, threads, labels, drafts, safety, quarantine and codes (0.9.0) ms.receiving() lists, reads, replies to, forwards and erases received mail, with the long-poll wait (the request timeout stretches by the wait) and the held ids. ms.inboxes() covers inbox CRUD plus safety(), codes(), quarantine(), labels(), threads() with emails(), and drafts(). Reply, forward and draft send return a SendResult: a 202 from the API is the draft the inbox's send rules held for approval. Domain gains receivingCatchAll, API keys an inboxId, webhooks inboxIds (null removes the filter), and WebhookEvent knows email.received and the 14 inbox events. Co-Authored-By: Claude Opus 5.5 --- README.md | 129 ++- pom.xml | 2 +- .../java/com/millionsend/MillionSend.java | 16 + .../java/com/millionsend/core/HttpClient.java | 54 +- .../java/com/millionsend/model/ApiKey.java | 6 + .../model/CreateApiKeyOptions.java | 10 + .../millionsend/model/CreateDraftOptions.java | 92 +++ .../millionsend/model/CreateInboxOptions.java | 74 ++ .../model/CreateWebhookOptions.java | 7 + .../java/com/millionsend/model/Domain.java | 9 + .../com/millionsend/model/ForwardOptions.java | 113 +++ .../java/com/millionsend/model/HeldEmail.java | 26 + .../java/com/millionsend/model/Inbox.java | 100 +++ .../com/millionsend/model/InboxDraft.java | 124 +++ .../com/millionsend/model/InboxLabel.java | 32 + .../millionsend/model/InboxLabelOptions.java | 37 + .../com/millionsend/model/InboxSafety.java | 75 ++ .../millionsend/model/InboxSafetyOptions.java | 97 +++ .../com/millionsend/model/InboxThread.java | 105 +++ .../millionsend/model/LatestCodeOptions.java | 57 ++ .../millionsend/model/ListInboxesOptions.java | 58 ++ .../model/ListReceivedEmailsOptions.java | 123 +++ .../com/millionsend/model/ListResponse.java | 2 +- .../millionsend/model/ListThreadsOptions.java | 90 +++ .../millionsend/model/QuarantineRelease.java | 32 + .../millionsend/model/QuarantinedEmail.java | 57 ++ .../java/com/millionsend/model/Query.java | 22 + .../millionsend/model/ReceivedAttachment.java | 58 ++ .../com/millionsend/model/ReceivedEmail.java | 186 +++++ .../model/ReceivedEmailExtracted.java | 39 + .../millionsend/model/ReceivedEmailInbox.java | 22 + .../model/ReceivedEmailListResponse.java | 16 + .../com/millionsend/model/ReplyOptions.java | 99 +++ .../java/com/millionsend/model/Screening.java | 56 ++ .../model/SearchThreadsOptions.java | 131 +++ .../com/millionsend/model/SendResult.java | 47 ++ .../com/millionsend/model/ThreadEmail.java | 138 ++++ .../model/UpdateDomainOptions.java | 6 + .../millionsend/model/UpdateDraftOptions.java | 66 ++ .../millionsend/model/UpdateInboxOptions.java | 51 ++ .../model/UpdateThreadOptions.java | 44 + .../model/UpdateWebhookOptions.java | 35 +- .../millionsend/model/VerificationCode.java | 61 ++ .../java/com/millionsend/model/Webhook.java | 6 + .../com/millionsend/model/WebhookEvent.java | 21 +- .../com/millionsend/services/Inboxes.java | 480 +++++++++++ .../com/millionsend/services/Receiving.java | 148 ++++ src/test/java/com/millionsend/ParityTest.java | 754 ++++++++++++++++++ 48 files changed, 3989 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/millionsend/model/CreateDraftOptions.java create mode 100644 src/main/java/com/millionsend/model/CreateInboxOptions.java create mode 100644 src/main/java/com/millionsend/model/ForwardOptions.java create mode 100644 src/main/java/com/millionsend/model/HeldEmail.java create mode 100644 src/main/java/com/millionsend/model/Inbox.java create mode 100644 src/main/java/com/millionsend/model/InboxDraft.java create mode 100644 src/main/java/com/millionsend/model/InboxLabel.java create mode 100644 src/main/java/com/millionsend/model/InboxLabelOptions.java create mode 100644 src/main/java/com/millionsend/model/InboxSafety.java create mode 100644 src/main/java/com/millionsend/model/InboxSafetyOptions.java create mode 100644 src/main/java/com/millionsend/model/InboxThread.java create mode 100644 src/main/java/com/millionsend/model/LatestCodeOptions.java create mode 100644 src/main/java/com/millionsend/model/ListInboxesOptions.java create mode 100644 src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java create mode 100644 src/main/java/com/millionsend/model/ListThreadsOptions.java create mode 100644 src/main/java/com/millionsend/model/QuarantineRelease.java create mode 100644 src/main/java/com/millionsend/model/QuarantinedEmail.java create mode 100644 src/main/java/com/millionsend/model/Query.java create mode 100644 src/main/java/com/millionsend/model/ReceivedAttachment.java create mode 100644 src/main/java/com/millionsend/model/ReceivedEmail.java create mode 100644 src/main/java/com/millionsend/model/ReceivedEmailExtracted.java create mode 100644 src/main/java/com/millionsend/model/ReceivedEmailInbox.java create mode 100644 src/main/java/com/millionsend/model/ReceivedEmailListResponse.java create mode 100644 src/main/java/com/millionsend/model/ReplyOptions.java create mode 100644 src/main/java/com/millionsend/model/Screening.java create mode 100644 src/main/java/com/millionsend/model/SearchThreadsOptions.java create mode 100644 src/main/java/com/millionsend/model/SendResult.java create mode 100644 src/main/java/com/millionsend/model/ThreadEmail.java create mode 100644 src/main/java/com/millionsend/model/UpdateDraftOptions.java create mode 100644 src/main/java/com/millionsend/model/UpdateInboxOptions.java create mode 100644 src/main/java/com/millionsend/model/UpdateThreadOptions.java create mode 100644 src/main/java/com/millionsend/model/VerificationCode.java create mode 100644 src/main/java/com/millionsend/services/Inboxes.java create mode 100644 src/main/java/com/millionsend/services/Receiving.java diff --git a/README.md b/README.md index 10a1956..ae51705 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,14 @@ Maven: com.millionsend millionsend-java - 0.8.0 + 0.9.0 ``` Gradle: ```groovy -implementation 'com.millionsend:millionsend-java:0.8.0' +implementation 'com.millionsend:millionsend-java:0.9.0' ``` Requires Java 11+. @@ -96,8 +96,9 @@ on templates) are forwarded as-is and answered with a 422. ### Request options -`emails().send`, `batch().send` and `contacts().batch().create` accept a -`RequestOptions` (the same builder shape as `resend-java`): +`emails().send`, `batch().send`, `contacts().batch().create` and the inbox +sends (`receiving().reply`/`forward`, `threads().emails().reply`/`forward`, +`drafts().send`) accept a `RequestOptions` (the same builder shape as `resend-java`): ```java import com.millionsend.core.RequestOptions; @@ -285,6 +286,111 @@ ms.suppressions().batch().add(AddSuppressionsOptions.builder().emails(List.of(a, ms.suppressions().batch().remove(RemoveSuppressionsOptions.builder().emails(List.of(a)).build()); // or .ids(...) ``` +### Receiving + +Mail received at your receiving domains and inboxes. Everything in a received +email was written by its sender, so show its HTML only in a sandbox. + +```java +ReceivedEmailListResponse page = ms.receiving().list(ListReceivedEmailsOptions.builder() + .inboxId(inboxId).from("acme.dev").subjectContains("code").limit(20).build()); // newest first +ReceivedEmail email = ms.receiving().get(id); // GET /emails/receiving/{id} +ms.receiving().get(id, "cid"); // leave inline images as cid: references +email.getExtractedText(); // the reply without quotes, signature or hidden text +email.getScreening().getSenderTrust(); // owner, allowlisted, known_thread, external, … +email.getExtracted().getCode(); // a verification code, when one was found + +// Long-poll: returns as soon as matching mail arrives (or an empty page after 30 s) +ReceivedEmailListResponse next = ms.receiving().list(ListReceivedEmailsOptions.builder() + .inboxId(inboxId).waitSeconds(30).build()); +next.getHeld(); // matching mail held in quarantine, ids only + +SendResult r = ms.receiving().reply(id, + ReplyOptions.builder().text("Thanks, confirmed.").build(), + RequestOptions.idempotencyKey("reply-" + id)); +if (r.isDraft()) { + r.getDraft().getApprovalReasons(); // held for a person to approve (HTTP 202) +} else { + r.getSent().getThreadId(); +} +ms.receiving().forward(id, ForwardOptions.builder().to("ops@acme.dev").mode("attachment").build()); +ms.receiving().remove(id); // erase now + +ms.receiving().attachments().list(id); // signed download links, valid one hour +ms.receiving().attachments().get(id, attachmentId); +``` + +Held mail is invisible to inbox-scoped keys: a detail read answers 403 +`email_quarantined`. `includeQuarantined(true)` lists it for full-access keys. + +### Inboxes + +An inbox is one address that receives mail, read by an agent (`kind` `agent`, +the default) or by people (`human`), with threads in both directions. The +paths mirror Resend's Inboxes API; `kind`, `shared`, `expiresAt`, safety, +quarantine and codes are MillionSend extensions. + +```java +Inbox inbox = ms.inboxes().create(CreateInboxOptions.builder() + .name("Signup bot").expiresIn("24h") // no emailAddress: a random address on the shared domain + .safety(InboxSafetyOptions.builder().acceptFrom("anyone").build()) + .build()); +ms.inboxes().list(ListInboxesOptions.builder().kind("agent").build()); +ms.inboxes().get("bot@acme.dev"); // by id or address +ms.inboxes().update(inbox.getId(), UpdateInboxOptions.builder().expiresIn(null).build()); // null: never expires +ms.inboxes().remove(inbox.getId()); + +// Verification codes and sign-in links (links are never fetched) +VerificationCode code = ms.inboxes().codes().latest(inboxId, + LatestCodeOptions.builder().from("github.com").waitSeconds(30).build()); +if ("found".equals(code.getStatus())) { + code.getCode(); // or code.getUrl() for a sign-in link +} + +// Safety rules (inbox-scoped keys can read them, never change them) +ms.inboxes().safety().get(inboxId); +ms.inboxes().safety().update(inboxId, InboxSafetyOptions.builder() + .acceptFrom("allowlist").allowlist("*@partner.dev").sendPolicy("reply_only").sendCap(100).build()); + +// Quarantine: held mail is kept and listed until a person releases it +ms.inboxes().quarantine().list(inboxId); +ms.inboxes().quarantine().release(inboxId, emailId, true); // true also allowlists the sender + +// Threads and their emails +ms.inboxes().threads().list(inboxId, ListThreadsOptions.builder().folders("inbox", "archive").read(false).build()); +ms.inboxes().threads().search(inboxId, SearchThreadsOptions.builder().query("invoice -draft").from("acme").build()); +ms.inboxes().threads().get(inboxId, threadId); +ms.inboxes().threads().update(inboxId, threadId, UpdateThreadOptions.builder().read(true).folder("archive").build()); +ms.inboxes().threads().remove(inboxId, threadId); // to trash, purged after 30 days +ms.inboxes().threads().emails().list(inboxId, threadId); // oldest first +ms.inboxes().threads().emails().get(inboxId, threadId, emailId); +ms.inboxes().threads().emails().reply(inboxId, threadId, emailId, ReplyOptions.builder().text("On it").build()); +ms.inboxes().threads().emails().forward(inboxId, threadId, emailId, ForwardOptions.builder().to("ops@acme.dev").build()); + +// Labels +InboxLabel label = ms.inboxes().labels().create(inboxId, InboxLabelOptions.builder().name("Billing").build()); +ms.inboxes().labels().list(inboxId); +ms.inboxes().labels().update(inboxId, label.getId(), InboxLabelOptions.builder().color("#FF8800").build()); +ms.inboxes().labels().remove(inboxId, label.getId()); + +// Drafts, including the ones the send rules hold for approval +InboxDraft d = ms.inboxes().drafts().create(inboxId, CreateDraftOptions.builder() + .to("ada@example.com").subject("Hello").text("Hi Ada").build()); +ms.inboxes().drafts().list(inboxId); +ms.inboxes().drafts().get(inboxId, d.getId()); +ms.inboxes().drafts().update(inboxId, d.getId(), UpdateDraftOptions.builder().subject(null).build()); // null clears +SendResult s = ms.inboxes().drafts().send(inboxId, d.getId()); +ms.inboxes().drafts().reject(inboxId, d.getId()); // approvers only +ms.inboxes().drafts().remove(inboxId, d.getId()); +``` + +Reply, forward and draft send return a `SendResult`: `isDraft()` is true when +the inbox's send rules held the message for a person (the API answers 202); an +inbox-scoped key sending a pending draft gets 403 `approval_required`. Folder, +verdict, kind and status values are plain strings, so new server values never +break decoding. Hosted agent settings (`/inboxes/{id}/agent`) are not exposed: +MillionSend runs no agent of its own. + ### Domains ```java @@ -296,9 +402,13 @@ ms.domains().get(id); ms.domains().list(); ms.domains().verify(id); // re-check DNS ms.domains().update(UpdateDomainOptions.builder().id(id).openTracking(true).trackingSubdomain(null).build()); // null clears +ms.domains().update(UpdateDomainOptions.builder().id(id).receivingCatchAll(true).build()); // paid plans on Cloud ms.domains().remove(id); ``` +`Domain.getReceivingCatchAll()`: on, mail to addresses without an inbox lands in +`receiving().list()`; off, those addresses are refused at SMTP time. + ### Webhooks ```java @@ -327,6 +437,12 @@ holding either verifies. `deliverability.*`, `quota.*`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`, `contact.topic_opt_out` and `suppression.*` events are MillionSend extensions. +`email.received` and the `inbox.*` events (`INBOX_CREATED` … `INBOX_DRAFT_DELETED`) +are delivered only to endpoints that name them; an endpoint subscribed to all +events skips them. `inboxIds(...)` narrows them to some inboxes +(`UpdateWebhookOptions.builder().inboxIds(null)` removes the filter). +`inbox.expired` and `inbox.email.quarantined` are MillionSend extensions. + ### API keys ```java @@ -335,6 +451,9 @@ CreateApiKeyResponse k = ms.apiKeys().create(CreateApiKeyOptions.builder() k.getToken(); // shown only once ms.apiKeys().list(); ms.apiKeys().remove(id); + +// A key restricted to one inbox: it reads that inbox and sends from its address only +ms.apiKeys().create(CreateApiKeyOptions.builder().name("support-agent").inboxId(inboxId).build()); ``` ### Templates @@ -396,7 +515,7 @@ Notes: - **No audiences**: contacts are team-global, so there is no `.audiences()` resource and no `audienceId` anywhere. The server keeps `/audiences/*` only as a compatibility shim; it is not part of this SDK. `.segments()` is MillionSend's dynamic-filter feature, not Resend's audience alias. - **Request options** use the same `RequestOptions` builder shape (`setIdempotencyKey`, `add(header, value)`), plus `batchValidation(...)`. - Errors throw `MillionSendException` (in place of Resend's `ResendException`) with the same `{ statusCode, name, message }` fields. -- **MillionSend extensions** (no Resend counterpart): `segments()`, `deliverability()`, `usage()`, `contacts().batch()`, `contacts().preferencesLink()`, `webhooks().rotate()`, `emails().remove()`, `emails().getInsights()`, `Email.getScore()`. +- **MillionSend extensions** (no Resend counterpart): `segments()`, `deliverability()`, `usage()`, `contacts().batch()`, `contacts().preferencesLink()`, `webhooks().rotate()`, `emails().remove()`, `emails().getInsights()`, `Email.getScore()`, and in `receiving()` and `inboxes()` the long-poll `waitSeconds`, the reply/forward/remove of received mail, inbox `safety()`, `codes()`, `quarantine()`, draft `reject()` and the extension fields. ## Build & test diff --git a/pom.xml b/pom.xml index 67fdf30..d1166de 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.millionsend millionsend-java - 0.8.0 + 0.9.0 jar millionsend-java diff --git a/src/main/java/com/millionsend/MillionSend.java b/src/main/java/com/millionsend/MillionSend.java index 9e7dd47..7dca2a4 100644 --- a/src/main/java/com/millionsend/MillionSend.java +++ b/src/main/java/com/millionsend/MillionSend.java @@ -9,6 +9,8 @@ import com.millionsend.services.Deliverability; import com.millionsend.services.Domains; import com.millionsend.services.Emails; +import com.millionsend.services.Inboxes; +import com.millionsend.services.Receiving; import com.millionsend.services.Segments; import com.millionsend.services.Suppressions; import com.millionsend.services.Templates; @@ -55,6 +57,8 @@ public final class MillionSend { private final Templates templates; private final ContactProperties contactProperties; private final Usage usage; + private final Receiving receiving; + private final Inboxes inboxes; /** Reads the API key from {@code MILLIONSEND_API_KEY} and the base URL from the environment. */ public MillionSend() { @@ -101,6 +105,8 @@ public MillionSend(String apiKey, String baseUrl, boolean allowInsecureHttp) { this.templates = new Templates(http); this.contactProperties = new ContactProperties(http); this.usage = new Usage(http); + this.receiving = new Receiving(http); + this.inboxes = new Inboxes(http); } /** Explicit option, then the environment, then MillionSend Cloud. */ @@ -170,4 +176,14 @@ public ContactProperties contactProperties() { public Usage usage() { return usage; } + + /** Received mail ({@code receiving().list()}, {@code get(id)}, {@code reply(...)}, …). */ + public Receiving receiving() { + return receiving; + } + + /** Inboxes for agents and people, with threads, labels, drafts and safety rules. */ + public Inboxes inboxes() { + return inboxes; + } } diff --git a/src/main/java/com/millionsend/core/HttpClient.java b/src/main/java/com/millionsend/core/HttpClient.java index 66d9aa5..4901a3d 100644 --- a/src/main/java/com/millionsend/core/HttpClient.java +++ b/src/main/java/com/millionsend/core/HttpClient.java @@ -9,6 +9,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.millionsend.MillionSendException; +import com.millionsend.model.InboxDraft; +import com.millionsend.model.SendResult; import java.io.IOException; import java.net.URI; import java.net.URLEncoder; @@ -28,7 +30,7 @@ public final class HttpClient { /** Kept in sync with the Maven {@code version}; surfaced in the User-Agent. */ - public static final String VERSION = "0.8.0"; + public static final String VERSION = "0.9.0"; private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); @@ -93,10 +95,49 @@ public T request( RequestOptions options, TypeReference returnType) throws MillionSendException { + return read(execute(method, path, body, query, options, REQUEST_TIMEOUT).body(), returnType); + } + + /** + * A GET the API may hold open for up to {@code waitSeconds} (a long-poll), so the + * request timeout stretches by that long. + */ + public T longPoll( + String path, Map query, Integer waitSeconds, TypeReference returnType) + throws MillionSendException { + Duration timeout = + waitSeconds == null ? REQUEST_TIMEOUT : REQUEST_TIMEOUT.plusSeconds(Math.max(0, waitSeconds)); + return read(execute("GET", path, null, query, null, timeout).body(), returnType); + } + + /** + * A POST to an inbox send endpoint (reply, forward, draft send). A 202 means the + * inbox's send rules held the message, so its body is the held {@link InboxDraft}; + * any other 2xx body reads as {@code sentType}. + */ + public SendResult sendOrHold( + String path, Object body, RequestOptions options, TypeReference sentType) + throws MillionSendException { + HttpResponse response = execute("POST", path, body, null, options, REQUEST_TIMEOUT); + if (response.statusCode() == 202) { + return SendResult.held(read(response.body(), new TypeReference() {})); + } + return SendResult.sent(read(response.body(), sentType)); + } + + /** Sends the request and returns the 2xx response; any other status throws. */ + private HttpResponse execute( + String method, + String path, + Object body, + Map query, + RequestOptions options, + Duration timeout) + throws MillionSendException { HttpRequest.Builder rb = HttpRequest.newBuilder(URI.create(baseUrl + path + queryString(query))) - .timeout(REQUEST_TIMEOUT) + .timeout(timeout) .header("Authorization", "Bearer " + apiKey) .header("Accept", "application/json") .header("User-Agent", userAgent); @@ -132,12 +173,15 @@ public T request( } int status = response.statusCode(); - String text = response.body(); if (status < 200 || status >= 300) { - throw toException(text, status); + throw toException(response.body(), status); } + return response; + } + + private T read(String text, TypeReference type) throws MillionSendException { try { - return mapper.readValue(text, returnType); + return mapper.readValue(text, type); } catch (IOException e) { throw new MillionSendException("application_error", e.getMessage(), null); } diff --git a/src/main/java/com/millionsend/model/ApiKey.java b/src/main/java/com/millionsend/model/ApiKey.java index 4926537..e10a788 100644 --- a/src/main/java/com/millionsend/model/ApiKey.java +++ b/src/main/java/com/millionsend/model/ApiKey.java @@ -7,6 +7,7 @@ public final class ApiKey { private String name; private String createdAt; private String lastUsedAt; + private String inboxId; public String getId() { return id; @@ -24,4 +25,9 @@ public String getCreatedAt() { public String getLastUsedAt() { return lastUsedAt; } + + /** Set for a key restricted to one inbox. */ + public String getInboxId() { + return inboxId; + } } diff --git a/src/main/java/com/millionsend/model/CreateApiKeyOptions.java b/src/main/java/com/millionsend/model/CreateApiKeyOptions.java index 4e1cfc3..4b08ad6 100644 --- a/src/main/java/com/millionsend/model/CreateApiKeyOptions.java +++ b/src/main/java/com/millionsend/model/CreateApiKeyOptions.java @@ -9,6 +9,7 @@ public final class CreateApiKeyOptions { private String name; private String permission; private String domainId; + private String inboxId; private CreateApiKeyOptions() {} @@ -36,6 +37,15 @@ public Builder domainId(String domainId) { return this; } + /** + * Restrict a {@code full_access} key to one inbox: it reads that inbox and sends + * from its address only. Not combinable with {@code domainId}. + */ + public Builder inboxId(String inboxId) { + o.inboxId = inboxId; + return this; + } + public CreateApiKeyOptions build() { return o; } diff --git a/src/main/java/com/millionsend/model/CreateDraftOptions.java b/src/main/java/com/millionsend/model/CreateDraftOptions.java new file mode 100644 index 0000000..a2867f5 --- /dev/null +++ b/src/main/java/com/millionsend/model/CreateDraftOptions.java @@ -0,0 +1,92 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Options for {@code inboxes().drafts().create(...)}. A reply draft sets both + * {@code threadId} and {@code replyToEmailId}; it does not copy recipients. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class CreateDraftOptions { + + private List to; + private List cc; + private List bcc; + private String subject; + private String html; + private String text; + private String threadId; + private String replyToEmailId; + + private CreateDraftOptions() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final CreateDraftOptions o = new CreateDraftOptions(); + + public Builder to(String... to) { + o.to = new ArrayList<>(Arrays.asList(to)); + return this; + } + + public Builder to(List to) { + o.to = to; + return this; + } + + public Builder cc(String... cc) { + o.cc = new ArrayList<>(Arrays.asList(cc)); + return this; + } + + public Builder cc(List cc) { + o.cc = cc; + return this; + } + + public Builder bcc(String... bcc) { + o.bcc = new ArrayList<>(Arrays.asList(bcc)); + return this; + } + + public Builder bcc(List bcc) { + o.bcc = bcc; + return this; + } + + public Builder subject(String subject) { + o.subject = subject; + return this; + } + + public Builder html(String html) { + o.html = html; + return this; + } + + public Builder text(String text) { + o.text = text; + return this; + } + + public Builder threadId(String threadId) { + o.threadId = threadId; + return this; + } + + public Builder replyToEmailId(String replyToEmailId) { + o.replyToEmailId = replyToEmailId; + return this; + } + + public CreateDraftOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/CreateInboxOptions.java b/src/main/java/com/millionsend/model/CreateInboxOptions.java new file mode 100644 index 0000000..633ccaf --- /dev/null +++ b/src/main/java/com/millionsend/model/CreateInboxOptions.java @@ -0,0 +1,74 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Options for {@code inboxes().create(...)}. Without {@code emailAddress} the + * inbox gets a random address on the shared domain (when the instance has one). + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class CreateInboxOptions { + + private String emailAddress; + private String name; + private String fromName; + private Boolean forwarding; + private String kind; + private String expiresIn; + private InboxSafetyOptions safety; + + private CreateInboxOptions() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final CreateInboxOptions o = new CreateInboxOptions(); + + /** An address on a verified team domain with receiving on. */ + public Builder emailAddress(String emailAddress) { + o.emailAddress = emailAddress; + return this; + } + + public Builder name(String name) { + o.name = name; + return this; + } + + /** The display name used when the inbox sends. */ + public Builder fromName(String fromName) { + o.fromName = fromName; + return this; + } + + /** Provision a {@code receivingAddress} to forward mail to, for a domain that keeps its own MX. */ + public Builder forwarding(boolean forwarding) { + o.forwarding = forwarding; + return this; + } + + /** {@code agent} (the default) or {@code human}. */ + public Builder kind(String kind) { + o.kind = kind; + return this; + } + + /** Like {@code 1h} or {@code 30d}. Shared-domain inboxes default to 24 hours, at most 30 days. */ + public Builder expiresIn(String expiresIn) { + o.expiresIn = expiresIn; + return this; + } + + /** Safety rules to start with; the rest keep the defaults for the kind. */ + public Builder safety(InboxSafetyOptions safety) { + o.safety = safety; + return this; + } + + public CreateInboxOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/CreateWebhookOptions.java b/src/main/java/com/millionsend/model/CreateWebhookOptions.java index 41eb486..920174b 100644 --- a/src/main/java/com/millionsend/model/CreateWebhookOptions.java +++ b/src/main/java/com/millionsend/model/CreateWebhookOptions.java @@ -12,6 +12,7 @@ public final class CreateWebhookOptions { private String endpoint; private List events; private String signingSecret; + private List inboxIds; private CreateWebhookOptions() {} @@ -43,6 +44,12 @@ public Builder signingSecret(String signingSecret) { return this; } + /** Deliver inbox events and {@code email.received} for these inboxes only. */ + public Builder inboxIds(List inboxIds) { + o.inboxIds = inboxIds; + return this; + } + public CreateWebhookOptions build() { return o; } diff --git a/src/main/java/com/millionsend/model/Domain.java b/src/main/java/com/millionsend/model/Domain.java index c779923..12b58a6 100644 --- a/src/main/java/com/millionsend/model/Domain.java +++ b/src/main/java/com/millionsend/model/Domain.java @@ -19,6 +19,7 @@ public final class Domain { private boolean clickTracking; private String trackingSubdomain; private Map capabilities; + private Boolean receivingCatchAll; private List records; public String getObject() { @@ -63,6 +64,14 @@ public Map getCapabilities() { return capabilities; } + /** + * On, mail to addresses without an inbox lands in the received list; off, those + * addresses are refused at SMTP time. + */ + public Boolean getReceivingCatchAll() { + return receivingCatchAll; + } + public List getRecords() { return records; } diff --git a/src/main/java/com/millionsend/model/ForwardOptions.java b/src/main/java/com/millionsend/model/ForwardOptions.java new file mode 100644 index 0000000..692d2c7 --- /dev/null +++ b/src/main/java/com/millionsend/model/ForwardOptions.java @@ -0,0 +1,113 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Body of a forward, of a received email or of an email in an inbox thread. + * {@code html}/{@code text} is an optional note above the quoted original. + * {@code from}, {@code inboxId} and {@code mode} apply to {@code receiving().forward(...)} only. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class ForwardOptions { + + private List to; + private List cc; + private List bcc; + private String html; + private String text; + private String subject; + private Boolean requiresApproval; + private String from; + private String inboxId; + private String mode; + + private ForwardOptions() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final ForwardOptions o = new ForwardOptions(); + + public Builder to(String... to) { + o.to = new ArrayList<>(Arrays.asList(to)); + return this; + } + + public Builder to(List to) { + o.to = to; + return this; + } + + public Builder cc(String... cc) { + o.cc = new ArrayList<>(Arrays.asList(cc)); + return this; + } + + public Builder cc(List cc) { + o.cc = cc; + return this; + } + + public Builder bcc(String... bcc) { + o.bcc = new ArrayList<>(Arrays.asList(bcc)); + return this; + } + + public Builder bcc(List bcc) { + o.bcc = bcc; + return this; + } + + public Builder html(String html) { + o.html = html; + return this; + } + + public Builder text(String text) { + o.text = text; + return this; + } + + /** Defaults to {@code Fwd: } plus the original subject. */ + public Builder subject(String subject) { + o.subject = subject; + return this; + } + + /** Hold the forward as a draft for a person to approve. */ + public Builder requiresApproval(boolean requiresApproval) { + o.requiresApproval = requiresApproval; + return this; + } + + /** An address on a verified team domain; defaults to the inbox address. */ + public Builder from(String from) { + o.from = from; + return this; + } + + /** Which of the email's inboxes forwards, when it reached several. */ + public Builder inboxId(String inboxId) { + o.inboxId = inboxId; + return this; + } + + /** + * {@code inline} (the default) quotes the original; {@code attachment} attaches it + * as {@code forwarded_message.eml}. + */ + public Builder mode(String mode) { + o.mode = mode; + return this; + } + + public ForwardOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/HeldEmail.java b/src/main/java/com/millionsend/model/HeldEmail.java new file mode 100644 index 0000000..9eefbe7 --- /dev/null +++ b/src/main/java/com/millionsend/model/HeldEmail.java @@ -0,0 +1,26 @@ +package com.millionsend.model; + +/** A matching email a long-poll found held (quarantined or being screened): its id only. */ +public final class HeldEmail { + + private String id; + private String inboxId; + private String reason; + private String createdAt; + + public String getId() { + return id; + } + + public String getInboxId() { + return inboxId; + } + + public String getReason() { + return reason; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/millionsend/model/Inbox.java b/src/main/java/com/millionsend/model/Inbox.java new file mode 100644 index 0000000..f00f0bd --- /dev/null +++ b/src/main/java/com/millionsend/model/Inbox.java @@ -0,0 +1,100 @@ +package com.millionsend.model; + +/** + * An inbox: one address that receives mail, read by an agent or by people. List + * items carry {@code id}, {@code name}, {@code emailAddress}, {@code fromName}, + * {@code unread}, {@code lastReceived}, {@code kind}, {@code shared} and + * {@code expiresAt}; the other fields are null there. + */ +public final class Inbox { + + private String object; + private String id; + private String name; + private String emailAddress; + private String domainId; + private String receivingAddress; + private String fromName; + private Integer unread; + private Integer drafts; + private String lastReceived; + private String createdAt; + private String kind; + private Boolean shared; + private String expiresAt; + private Integer quarantined; + private Integer pendingApproval; + + public String getObject() { + return object; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getEmailAddress() { + return emailAddress; + } + + /** The custom domain's id, or for a shared-domain inbox the team's shared subdomain id. */ + public String getDomainId() { + return domainId; + } + + /** Set for {@code forwarding} inboxes: the address to forward mail to. */ + public String getReceivingAddress() { + return receivingAddress; + } + + public String getFromName() { + return fromName; + } + + /** Threads with unread mail. */ + public Integer getUnread() { + return unread; + } + + /** Unsent drafts. */ + public Integer getDrafts() { + return drafts; + } + + public String getLastReceived() { + return lastReceived; + } + + public String getCreatedAt() { + return createdAt; + } + + /** {@code agent} or {@code human}. */ + public String getKind() { + return kind; + } + + /** True for an address on the shared domain (no DNS of your own). */ + public Boolean getShared() { + return shared; + } + + /** When the inbox stops receiving and is deleted; null for never. */ + public String getExpiresAt() { + return expiresAt; + } + + /** Emails held in quarantine, not yet released. */ + public Integer getQuarantined() { + return quarantined; + } + + /** Drafts waiting for a person to approve them. */ + public Integer getPendingApproval() { + return pendingApproval; + } +} diff --git a/src/main/java/com/millionsend/model/InboxDraft.java b/src/main/java/com/millionsend/model/InboxDraft.java new file mode 100644 index 0000000..5760e79 --- /dev/null +++ b/src/main/java/com/millionsend/model/InboxDraft.java @@ -0,0 +1,124 @@ +package com.millionsend.model; + +import java.util.List; + +/** + * A draft in an inbox: written by an agent or a person, or held by the inbox's send + * rules ({@code status: pending_approval}) until a person approves it. List items + * carry a {@code snippet} instead of {@code html}/{@code text}. A draft send's 200 + * sets only {@code object}, {@code id}, {@code threadId} and {@code emailId}. + */ +public final class InboxDraft { + + private String object; + private String id; + private String type; + private List to; + private List cc; + private List bcc; + private String subject; + private String html; + private String text; + private String snippet; + private String threadId; + private String replyToEmailId; + private String emailId; + private String createdAt; + private String updatedAt; + private String status; + private List approvalReasons; + private Boolean forward; + private String decidedBy; + private String decidedAt; + private String expiresAt; + + public String getObject() { + return object; + } + + public String getId() { + return id; + } + + /** {@code standalone} or {@code reply}. */ + public String getType() { + return type; + } + + public List getTo() { + return to; + } + + public List getCc() { + return cc; + } + + public List getBcc() { + return bcc; + } + + public String getSubject() { + return subject; + } + + public String getHtml() { + return html; + } + + public String getText() { + return text; + } + + public String getSnippet() { + return snippet; + } + + public String getThreadId() { + return threadId; + } + + public String getReplyToEmailId() { + return replyToEmailId; + } + + /** Set once the draft is sent. */ + public String getEmailId() { + return emailId; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } + + /** {@code draft}, {@code pending_approval}, {@code sent}, {@code rejected} or {@code expired}. */ + public String getStatus() { + return status; + } + + /** Why the send rules held it, e.g. {@code send_policy}, {@code ramp}, {@code private_code}. */ + public List getApprovalReasons() { + return approvalReasons; + } + + /** True for a held forward. */ + public Boolean getForward() { + return forward; + } + + public String getDecidedBy() { + return decidedBy; + } + + public String getDecidedAt() { + return decidedAt; + } + + /** A pending draft expires 7 days after it was held. */ + public String getExpiresAt() { + return expiresAt; + } +} diff --git a/src/main/java/com/millionsend/model/InboxLabel.java b/src/main/java/com/millionsend/model/InboxLabel.java new file mode 100644 index 0000000..1ac9a9b --- /dev/null +++ b/src/main/java/com/millionsend/model/InboxLabel.java @@ -0,0 +1,32 @@ +package com.millionsend.model; + +/** A label of one inbox; a thread's {@code labels} carry {@code id}, {@code name} and {@code color}. */ +public final class InboxLabel { + + private String object; + private String id; + private String name; + private String color; + private String createdAt; + + public String getObject() { + return object; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + /** {@code #RRGGBB}, uppercase. */ + public String getColor() { + return color; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/millionsend/model/InboxLabelOptions.java b/src/main/java/com/millionsend/model/InboxLabelOptions.java new file mode 100644 index 0000000..da43122 --- /dev/null +++ b/src/main/java/com/millionsend/model/InboxLabelOptions.java @@ -0,0 +1,37 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** Options for {@code inboxes().labels().create(...)} and {@code update(...)}. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class InboxLabelOptions { + + private String name; + private String color; + + private InboxLabelOptions() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final InboxLabelOptions o = new InboxLabelOptions(); + + /** 1-64 characters, unique per inbox ignoring case. */ + public Builder name(String name) { + o.name = name; + return this; + } + + /** {@code #RRGGBB}; a random color when omitted on create. */ + public Builder color(String color) { + o.color = color; + return this; + } + + public InboxLabelOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/InboxSafety.java b/src/main/java/com/millionsend/model/InboxSafety.java new file mode 100644 index 0000000..76beed1 --- /dev/null +++ b/src/main/java/com/millionsend/model/InboxSafety.java @@ -0,0 +1,75 @@ +package com.millionsend.model; + +import java.util.List; + +/** An inbox's safety rules ({@code inboxes().safety().get(...)}). */ +public final class InboxSafety { + + private String object; + private String inboxId; + private String acceptFrom; + private List allowlist; + private List ownerAddresses; + private Boolean screening; + private Boolean extractCodes; + private String sendPolicy; + private Boolean requireApproval; + private Boolean privateCodes; + private Integer sendCap; + private List forwardTo; + + public String getObject() { + return object; + } + + public String getInboxId() { + return inboxId; + } + + /** {@code anyone}, {@code verified} or {@code allowlist}: mail failing it is quarantined. */ + public String getAcceptFrom() { + return acceptFrom; + } + + /** {@code local@domain} addresses, or {@code *@domain} for a domain and its subdomains. */ + public List getAllowlist() { + return allowlist; + } + + /** Owners besides the team members' login addresses. */ + public List getOwnerAddresses() { + return ownerAddresses; + } + + /** The prompt-injection screen (agent inboxes). */ + public Boolean getScreening() { + return screening; + } + + public Boolean getExtractCodes() { + return extractCodes; + } + + /** {@code reply_only}, {@code allowlist} or {@code open}. */ + public String getSendPolicy() { + return sendPolicy; + } + + public Boolean getRequireApproval() { + return requireApproval; + } + + /** Received codes and sign-in links can't be sent to anyone but owners and the allowlist. */ + public Boolean getPrivateCodes() { + return privateCodes; + } + + /** Unattended recipients per UTC day; null for no inbox cap. */ + public Integer getSendCap() { + return sendCap; + } + + public List getForwardTo() { + return forwardTo; + } +} diff --git a/src/main/java/com/millionsend/model/InboxSafetyOptions.java b/src/main/java/com/millionsend/model/InboxSafetyOptions.java new file mode 100644 index 0000000..092304e --- /dev/null +++ b/src/main/java/com/millionsend/model/InboxSafetyOptions.java @@ -0,0 +1,97 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A partial set of safety rules, for {@code inboxes().safety().update(...)} and + * {@link CreateInboxOptions.Builder#safety}. Only the fields you set are sent; + * {@code sendCap(null)} removes the inbox's daily cap. + */ +public final class InboxSafetyOptions { + + private final Map changes = new LinkedHashMap<>(); + + private InboxSafetyOptions() {} + + public static Builder builder() { + return new Builder(); + } + + /** The snake_case body — only the fields the caller set. */ + @JsonValue + public Map getChanges() { + return changes; + } + + public static final class Builder { + private final InboxSafetyOptions o = new InboxSafetyOptions(); + + /** {@code anyone}, {@code verified} or {@code allowlist}. */ + public Builder acceptFrom(String acceptFrom) { + o.changes.put("accept_from", acceptFrom); + return this; + } + + public Builder allowlist(String... allowlist) { + return allowlist(new ArrayList<>(Arrays.asList(allowlist))); + } + + /** Replaces the whole list. */ + public Builder allowlist(List allowlist) { + o.changes.put("allowlist", allowlist); + return this; + } + + public Builder ownerAddresses(List ownerAddresses) { + o.changes.put("owner_addresses", ownerAddresses); + return this; + } + + public Builder screening(boolean screening) { + o.changes.put("screening", screening); + return this; + } + + public Builder extractCodes(boolean extractCodes) { + o.changes.put("extract_codes", extractCodes); + return this; + } + + /** {@code reply_only}, {@code allowlist} or {@code open}. */ + public Builder sendPolicy(String sendPolicy) { + o.changes.put("send_policy", sendPolicy); + return this; + } + + public Builder requireApproval(boolean requireApproval) { + o.changes.put("require_approval", requireApproval); + return this; + } + + public Builder privateCodes(boolean privateCodes) { + o.changes.put("private_codes", privateCodes); + return this; + } + + /** {@code null} removes the cap. */ + public Builder sendCap(Integer sendCap) { + o.changes.put("send_cap", sendCap); + return this; + } + + /** Team members' login addresses or owner addresses that get a copy of every message. */ + public Builder forwardTo(List forwardTo) { + o.changes.put("forward_to", forwardTo); + return this; + } + + public InboxSafetyOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/InboxThread.java b/src/main/java/com/millionsend/model/InboxThread.java new file mode 100644 index 0000000..a4a972c --- /dev/null +++ b/src/main/java/com/millionsend/model/InboxThread.java @@ -0,0 +1,105 @@ +package com.millionsend.model; + +import java.util.List; +import java.util.Map; + +/** + * A conversation in one inbox, both directions. {@code get}/{@code update} return the + * summary ({@code object}, {@code id}, {@code subject}, {@code folder}, {@code labels}, + * {@code read}, {@code quarantined}); list items add the latest message's + * {@code from}/{@code to}/{@code cc}/{@code bcc}, counts and flags; search results add + * {@code matchedEmailId} and {@code highlights}. + */ +public final class InboxThread { + + private String object; + private String id; + private String subject; + private String folder; + private List labels; + private Boolean read; + private Integer quarantined; + private String from; + private List to; + private List cc; + private List bcc; + private Integer messageCount; + private Boolean hasAttachment; + private Boolean hasDraft; + private String receivedAt; + private String matchedEmailId; + private Map> highlights; + + public String getObject() { + return object; + } + + public String getId() { + return id; + } + + public String getSubject() { + return subject; + } + + /** {@code inbox}, {@code archive}, {@code spam}, {@code sent} or {@code trash}. */ + public String getFolder() { + return folder; + } + + public List getLabels() { + return labels; + } + + /** True when every message in the thread is read. */ + public Boolean getRead() { + return read; + } + + /** Held messages in the thread; owner views only, else 0. */ + public Integer getQuarantined() { + return quarantined; + } + + public String getFrom() { + return from; + } + + public List getTo() { + return to; + } + + public List getCc() { + return cc; + } + + public List getBcc() { + return bcc; + } + + public Integer getMessageCount() { + return messageCount; + } + + public Boolean getHasAttachment() { + return hasAttachment; + } + + public Boolean getHasDraft() { + return hasDraft; + } + + /** When the thread was last active. */ + public String getReceivedAt() { + return receivedAt; + } + + public String getMatchedEmailId() { + return matchedEmailId; + } + + /** Matched fragments per field, with {@code **match**} markers. */ + public Map> getHighlights() { + return highlights; + } +} diff --git a/src/main/java/com/millionsend/model/LatestCodeOptions.java b/src/main/java/com/millionsend/model/LatestCodeOptions.java new file mode 100644 index 0000000..3eda853 --- /dev/null +++ b/src/main/java/com/millionsend/model/LatestCodeOptions.java @@ -0,0 +1,57 @@ +package com.millionsend.model; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Filters for {@code inboxes().codes().latest(...)}. */ +public final class LatestCodeOptions { + + private String from; + private Integer wait; + private Integer maxAge; + + private LatestCodeOptions() {} + + public static Builder builder() { + return new Builder(); + } + + public Integer getWaitSeconds() { + return wait; + } + + /** Flatten into the wire query map, dropping unset params. */ + public Map toQuery() { + Map q = new LinkedHashMap<>(); + Query.put(q, "from", from); + Query.put(q, "wait", wait); + Query.put(q, "max_age", maxAge); + return q; + } + + public static final class Builder { + private final LatestCodeOptions o = new LatestCodeOptions(); + + /** A sender address, or a domain that also covers its subdomains. */ + public Builder from(String from) { + o.from = from; + return this; + } + + /** Seconds (0-50) to wait for a code to arrive. */ + public Builder waitSeconds(int seconds) { + o.wait = seconds; + return this; + } + + /** Ignore codes older than this many seconds (60-86400; the API defaults to 600). */ + public Builder maxAge(int seconds) { + o.maxAge = seconds; + return this; + } + + public LatestCodeOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/ListInboxesOptions.java b/src/main/java/com/millionsend/model/ListInboxesOptions.java new file mode 100644 index 0000000..9089235 --- /dev/null +++ b/src/main/java/com/millionsend/model/ListInboxesOptions.java @@ -0,0 +1,58 @@ +package com.millionsend.model; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Pagination plus an optional {@code kind} filter for {@code inboxes().list(...)}. */ +public final class ListInboxesOptions { + + private Integer limit; + private String after; + private String before; + private String kind; + + private ListInboxesOptions() {} + + public static Builder builder() { + return new Builder(); + } + + /** Flatten into the wire query map, dropping unset params. */ + public Map toQuery() { + Map q = new LinkedHashMap<>(); + Query.put(q, "limit", limit); + Query.put(q, "after", after); + Query.put(q, "before", before); + Query.put(q, "kind", kind); + return q; + } + + public static final class Builder { + private final ListInboxesOptions o = new ListInboxesOptions(); + + public Builder limit(int limit) { + o.limit = limit; + return this; + } + + public Builder after(String after) { + o.after = after; + return this; + } + + public Builder before(String before) { + o.before = before; + return this; + } + + /** {@code agent} or {@code human}. */ + public Builder kind(String kind) { + o.kind = kind; + return this; + } + + public ListInboxesOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java b/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java new file mode 100644 index 0000000..ea6caae --- /dev/null +++ b/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java @@ -0,0 +1,123 @@ +package com.millionsend.model; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Pagination and filters for {@code receiving().list(...)}. {@code waitSeconds} (0-50, + * the wire's {@code wait}) long-polls: the API answers as soon as a matching email + * arrives, or with an empty page when the wait ends. + */ +public final class ListReceivedEmailsOptions { + + private Integer limit; + private String after; + private String before; + private Integer wait; + private String since; + private String inboxId; + private String to; + private String from; + private String subjectContains; + private Boolean unread; + private boolean includeQuarantined; + + private ListReceivedEmailsOptions() {} + + public static Builder builder() { + return new Builder(); + } + + public Integer getWaitSeconds() { + return wait; + } + + /** Flatten into the wire query map, dropping unset params. */ + public Map toQuery() { + Map q = new LinkedHashMap<>(); + Query.put(q, "limit", limit); + Query.put(q, "after", after); + Query.put(q, "before", before); + Query.put(q, "wait", wait); + Query.put(q, "since", since); + Query.put(q, "inbox_id", inboxId); + Query.put(q, "to", to); + Query.put(q, "from", from); + Query.put(q, "subject_contains", subjectContains); + Query.put(q, "unread", unread); + if (includeQuarantined) { + q.put("include_quarantined", "true"); + } + return q; + } + + public static final class Builder { + private final ListReceivedEmailsOptions o = new ListReceivedEmailsOptions(); + + public Builder limit(int limit) { + o.limit = limit; + return this; + } + + public Builder after(String after) { + o.after = after; + return this; + } + + /** Toward newer mail; with {@code waitSeconds}, waits for mail newer than this id. */ + public Builder before(String before) { + o.before = before; + return this; + } + + /** Seconds to hold the request open, 0-50. */ + public Builder waitSeconds(int seconds) { + o.wait = seconds; + return this; + } + + /** ISO 8601: only mail received after this instant. */ + public Builder since(String since) { + o.since = since; + return this; + } + + public Builder inboxId(String inboxId) { + o.inboxId = inboxId; + return this; + } + + /** An address the message was delivered to ({@code receivedFor}). */ + public Builder to(String to) { + o.to = to; + return this; + } + + /** A sender address, or a domain that also covers its subdomains. */ + public Builder from(String from) { + o.from = from; + return this; + } + + public Builder subjectContains(String subjectContains) { + o.subjectContains = subjectContains; + return this; + } + + /** Needs {@link #inboxId(String)}. */ + public Builder unread(boolean unread) { + o.unread = unread; + return this; + } + + /** Also list held mail; needs a full access key that is not scoped to an inbox. */ + public Builder includeQuarantined(boolean includeQuarantined) { + o.includeQuarantined = includeQuarantined; + return this; + } + + public ListReceivedEmailsOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/ListResponse.java b/src/main/java/com/millionsend/model/ListResponse.java index 89241ff..6a3b82b 100644 --- a/src/main/java/com/millionsend/model/ListResponse.java +++ b/src/main/java/com/millionsend/model/ListResponse.java @@ -3,7 +3,7 @@ import java.util.List; /** The paginated list envelope {@code { object: "list", data, has_more }}. */ -public final class ListResponse { +public class ListResponse { private String object; private List data; diff --git a/src/main/java/com/millionsend/model/ListThreadsOptions.java b/src/main/java/com/millionsend/model/ListThreadsOptions.java new file mode 100644 index 0000000..f60a8b6 --- /dev/null +++ b/src/main/java/com/millionsend/model/ListThreadsOptions.java @@ -0,0 +1,90 @@ +package com.millionsend.model; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Filters for {@code inboxes().threads().list(...)}. {@code folders} defaults to + * {@code inbox} ({@code inbox,archive,sent} with {@code labels}). + */ +public final class ListThreadsOptions { + + private List folders; + private List labels; + private Boolean read; + private Integer limit; + private String after; + private String before; + private boolean includeQuarantined; + + private ListThreadsOptions() {} + + public static Builder builder() { + return new Builder(); + } + + /** Flatten into the wire query map, dropping unset params. */ + public Map toQuery() { + Map q = new LinkedHashMap<>(); + Query.csv(q, "folders", folders); + Query.csv(q, "labels", labels); + Query.put(q, "read", read); + Query.put(q, "limit", limit); + Query.put(q, "after", after); + Query.put(q, "before", before); + if (includeQuarantined) { + q.put("include_quarantined", "true"); + } + return q; + } + + public static final class Builder { + private final ListThreadsOptions o = new ListThreadsOptions(); + + /** Any of {@code inbox}, {@code archive}, {@code spam}, {@code sent}, {@code trash}. */ + public Builder folders(String... folders) { + o.folders = new ArrayList<>(Arrays.asList(folders)); + return this; + } + + /** Label ids; a thread matches when it has any of them. */ + public Builder labels(String... labels) { + o.labels = new ArrayList<>(Arrays.asList(labels)); + return this; + } + + /** {@code true}: fully read threads; {@code false}: threads with unread mail. */ + public Builder read(boolean read) { + o.read = read; + return this; + } + + public Builder limit(int limit) { + o.limit = limit; + return this; + } + + public Builder after(String after) { + o.after = after; + return this; + } + + public Builder before(String before) { + o.before = before; + return this; + } + + /** Also count held mail (owner views: a full access key not scoped to an inbox). */ + public Builder includeQuarantined(boolean includeQuarantined) { + o.includeQuarantined = includeQuarantined; + return this; + } + + public ListThreadsOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/QuarantineRelease.java b/src/main/java/com/millionsend/model/QuarantineRelease.java new file mode 100644 index 0000000..914e872 --- /dev/null +++ b/src/main/java/com/millionsend/model/QuarantineRelease.java @@ -0,0 +1,32 @@ +package com.millionsend.model; + +/** The result of {@code inboxes().quarantine().release(...)}. */ +public final class QuarantineRelease { + + private String object; + private String inboxId; + private String emailId; + private String releasedAt; + private String trustedSender; + + public String getObject() { + return object; + } + + public String getInboxId() { + return inboxId; + } + + public String getEmailId() { + return emailId; + } + + public String getReleasedAt() { + return releasedAt; + } + + /** The sender address added to the allowlist; null unless {@code trustSender} was set. */ + public String getTrustedSender() { + return trustedSender; + } +} diff --git a/src/main/java/com/millionsend/model/QuarantinedEmail.java b/src/main/java/com/millionsend/model/QuarantinedEmail.java new file mode 100644 index 0000000..3c9cbb0 --- /dev/null +++ b/src/main/java/com/millionsend/model/QuarantinedEmail.java @@ -0,0 +1,57 @@ +package com.millionsend.model; + +/** An email held in an inbox's quarantine ({@code inboxes().quarantine().list(...)}). */ +public final class QuarantinedEmail { + + private String id; + private String threadId; + private String from; + private String subject; + private String receivedAt; + private String quarantineReason; + private String senderTrust; + private Integer injectionScore; + private Integer phishingScore; + private String virusSignature; + + public String getId() { + return id; + } + + public String getThreadId() { + return threadId; + } + + public String getFrom() { + return from; + } + + public String getSubject() { + return subject; + } + + public String getReceivedAt() { + return receivedAt; + } + + /** e.g. {@code failed_auth}, {@code not_allowlisted}, {@code injection}, {@code virus}. */ + public String getQuarantineReason() { + return quarantineReason; + } + + public String getSenderTrust() { + return senderTrust; + } + + public Integer getInjectionScore() { + return injectionScore; + } + + public Integer getPhishingScore() { + return phishingScore; + } + + public String getVirusSignature() { + return virusSignature; + } +} diff --git a/src/main/java/com/millionsend/model/Query.java b/src/main/java/com/millionsend/model/Query.java new file mode 100644 index 0000000..67ca3ef --- /dev/null +++ b/src/main/java/com/millionsend/model/Query.java @@ -0,0 +1,22 @@ +package com.millionsend.model; + +import java.util.List; +import java.util.Map; + +/** Wire query helpers for the option classes: unset params are dropped, lists join with commas. */ +final class Query { + + private Query() {} + + static void put(Map q, String key, Object value) { + if (value != null) { + q.put(key, String.valueOf(value)); + } + } + + static void csv(Map q, String key, List values) { + if (values != null && !values.isEmpty()) { + q.put(key, String.join(",", values)); + } + } +} diff --git a/src/main/java/com/millionsend/model/ReceivedAttachment.java b/src/main/java/com/millionsend/model/ReceivedAttachment.java new file mode 100644 index 0000000..d18ba77 --- /dev/null +++ b/src/main/java/com/millionsend/model/ReceivedAttachment.java @@ -0,0 +1,58 @@ +package com.millionsend.model; + +/** + * An attachment of a received email, or of a thread email ({@code id}, {@code filename} + * and {@code size} only; the id of an attachment the inbox sent is its position, + * {@code "0"}, {@code "1"}, …). {@code downloadUrl} is a signed link valid for one + * hour, set by the attachment endpoints. + */ +public final class ReceivedAttachment { + + private String object; + private String id; + private String filename; + private Long size; + private String contentType; + private String contentDisposition; + private String contentId; + private String downloadUrl; + private String expiresAt; + + public String getObject() { + return object; + } + + public String getId() { + return id; + } + + public String getFilename() { + return filename; + } + + public Long getSize() { + return size; + } + + public String getContentType() { + return contentType; + } + + /** {@code inline} or {@code attachment}. */ + public String getContentDisposition() { + return contentDisposition; + } + + public String getContentId() { + return contentId; + } + + /** Answers 410 {@code email_blocked} when the email carries malware. */ + public String getDownloadUrl() { + return downloadUrl; + } + + public String getExpiresAt() { + return expiresAt; + } +} diff --git a/src/main/java/com/millionsend/model/ReceivedEmail.java b/src/main/java/com/millionsend/model/ReceivedEmail.java new file mode 100644 index 0000000..7cb5454 --- /dev/null +++ b/src/main/java/com/millionsend/model/ReceivedEmail.java @@ -0,0 +1,186 @@ +package com.millionsend.model; + +import java.util.List; +import java.util.Map; + +/** + * A received email. List items leave the body fields ({@code html}, {@code text}, + * {@code headers}, {@code authentication}, {@code raw}) and the detail-only extensions + * null. Everything in it was written by whoever sent the message: show its HTML only + * in a sandbox. + */ +public final class ReceivedEmail { + + private String object; + private String id; + private String from; + private List to; + private List cc; + private List bcc; + private List replyTo; + private String subject; + private String createdAt; + private List receivedFor; + private String messageId; + private List attachments; + private String html; + private String htmlFormat; + private String text; + private Map headers; + private Map authentication; + private Raw raw; + private String inboxId; + private String threadId; + private List inboxes; + private Screening screening; + private String spamVerdict; + private Integer spamScore; + private String virusVerdict; + private String virusSignature; + private String extractedText; + private ReceivedEmailExtracted extracted; + private Boolean read; + + public String getObject() { + return object; + } + + public String getId() { + return id; + } + + /** Bare address from the From header; the display name is in {@code headers}. */ + public String getFrom() { + return from; + } + + public List getTo() { + return to; + } + + public List getCc() { + return cc; + } + + public List getBcc() { + return bcc; + } + + public List getReplyTo() { + return replyTo; + } + + public String getSubject() { + return subject; + } + + public String getCreatedAt() { + return createdAt; + } + + /** The team's envelope recipients: the addresses the message was delivered to. */ + public List getReceivedFor() { + return receivedFor; + } + + public String getMessageId() { + return messageId; + } + + public List getAttachments() { + return attachments; + } + + public String getHtml() { + return html; + } + + /** {@code data_uri} or {@code cid}: how {@code html} references inline images. */ + public String getHtmlFormat() { + return htmlFormat; + } + + /** The text part with invisible characters removed. */ + public String getText() { + return text; + } + + /** Top-level headers, lowercase names; a repeated header's values joined by newlines. */ + public Map getHeaders() { + return headers; + } + + /** {@code spf}, {@code dkim}, {@code dmarc} → {@code pass}, {@code fail}, {@code gray}, … */ + public Map getAuthentication() { + return authentication; + } + + /** The original message; {@code null} once retention removed it, or when it carries malware. */ + public Raw getRaw() { + return raw; + } + + /** The inbox this view reads it through (an inbox key's own, else its first inbox); null for catch-all mail. */ + public String getInboxId() { + return inboxId; + } + + public String getThreadId() { + return threadId; + } + + /** Every inbox it was delivered to; owner views only. */ + public List getInboxes() { + return inboxes; + } + + public Screening getScreening() { + return screening; + } + + /** {@code pass}, {@code gray} or {@code fail}; null when not computed. */ + public String getSpamVerdict() { + return spamVerdict; + } + + public Integer getSpamScore() { + return spamScore; + } + + /** {@code pass}, {@code fail} or {@code processing_failed}; null when not scanned. */ + public String getVirusVerdict() { + return virusVerdict; + } + + public String getVirusSignature() { + return virusSignature; + } + + /** The reply without quotes, signature or hidden text: the text to hand an agent. */ + public String getExtractedText() { + return extractedText; + } + + public ReceivedEmailExtracted getExtracted() { + return extracted; + } + + /** Read state in the inbox this view reads it through; null without an inbox context. */ + public Boolean getRead() { + return read; + } + + /** A signed link to the original RFC 822 message, valid for one hour; it needs no API key. */ + public static final class Raw { + private String downloadUrl; + private String expiresAt; + + public String getDownloadUrl() { + return downloadUrl; + } + + public String getExpiresAt() { + return expiresAt; + } + } +} diff --git a/src/main/java/com/millionsend/model/ReceivedEmailExtracted.java b/src/main/java/com/millionsend/model/ReceivedEmailExtracted.java new file mode 100644 index 0000000..95a91cb --- /dev/null +++ b/src/main/java/com/millionsend/model/ReceivedEmailExtracted.java @@ -0,0 +1,39 @@ +package com.millionsend.model; + +import java.util.List; + +/** + * Codes and sign-in links found in a received email. Lists carry {@code kinds} only; + * the code and link values come only from {@code receiving().get(id)}, and never for + * held mail, failed authentication or an inbox with code extraction off. + */ +public final class ReceivedEmailExtracted { + + private List kinds; + private String code; + private String link; + private Boolean linkDomainMatchesSender; + private Boolean ambiguous; + + /** A subset of {@code code} and {@code link}. */ + public List getKinds() { + return kinds; + } + + public String getCode() { + return code; + } + + public String getLink() { + return link; + } + + public Boolean getLinkDomainMatchesSender() { + return linkDomainMatchesSender; + } + + /** True when several candidates matched and none was picked. */ + public Boolean getAmbiguous() { + return ambiguous; + } +} diff --git a/src/main/java/com/millionsend/model/ReceivedEmailInbox.java b/src/main/java/com/millionsend/model/ReceivedEmailInbox.java new file mode 100644 index 0000000..054e856 --- /dev/null +++ b/src/main/java/com/millionsend/model/ReceivedEmailInbox.java @@ -0,0 +1,22 @@ +package com.millionsend.model; + +/** One inbox a received email was delivered to (owner views of {@link ReceivedEmail#getInboxes()}). */ +public final class ReceivedEmailInbox { + + private String inboxId; + private String threadId; + private String verdict; + + public String getInboxId() { + return inboxId; + } + + public String getThreadId() { + return threadId; + } + + /** The screening verdict in that inbox. */ + public String getVerdict() { + return verdict; + } +} diff --git a/src/main/java/com/millionsend/model/ReceivedEmailListResponse.java b/src/main/java/com/millionsend/model/ReceivedEmailListResponse.java new file mode 100644 index 0000000..ad625cd --- /dev/null +++ b/src/main/java/com/millionsend/model/ReceivedEmailListResponse.java @@ -0,0 +1,16 @@ +package com.millionsend.model; + +import java.util.List; + +/** + * The {@code receiving().list(...)} page. With {@code waitSeconds}, {@code held} lists the + * matching emails that arrived but are held, by id only; it is {@code null} otherwise. + */ +public final class ReceivedEmailListResponse extends ListResponse { + + private List held; + + public List getHeld() { + return held; + } +} diff --git a/src/main/java/com/millionsend/model/ReplyOptions.java b/src/main/java/com/millionsend/model/ReplyOptions.java new file mode 100644 index 0000000..7e45d6c --- /dev/null +++ b/src/main/java/com/millionsend/model/ReplyOptions.java @@ -0,0 +1,99 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Body of a reply, to a received email or to an email in an inbox thread. Set + * {@code html} and/or {@code text}. {@code from} and {@code inboxId} apply to + * {@code receiving().reply(...)} only. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class ReplyOptions { + + private String html; + private String text; + private String subject; + private List cc; + private List bcc; + private Boolean replyAll; + private Boolean requiresApproval; + private String from; + private String inboxId; + + private ReplyOptions() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final ReplyOptions o = new ReplyOptions(); + + public Builder html(String html) { + o.html = html; + return this; + } + + public Builder text(String text) { + o.text = text; + return this; + } + + /** Defaults to {@code Re: } plus the thread subject. */ + public Builder subject(String subject) { + o.subject = subject; + return this; + } + + public Builder cc(String... cc) { + o.cc = new ArrayList<>(Arrays.asList(cc)); + return this; + } + + public Builder cc(List cc) { + o.cc = cc; + return this; + } + + public Builder bcc(String... bcc) { + o.bcc = new ArrayList<>(Arrays.asList(bcc)); + return this; + } + + public Builder bcc(List bcc) { + o.bcc = bcc; + return this; + } + + /** Also copy the message's other recipients. */ + public Builder replyAll(boolean replyAll) { + o.replyAll = replyAll; + return this; + } + + /** Hold the reply as a draft for a person to approve. */ + public Builder requiresApproval(boolean requiresApproval) { + o.requiresApproval = requiresApproval; + return this; + } + + /** An address on a verified team domain; defaults to the inbox address. */ + public Builder from(String from) { + o.from = from; + return this; + } + + /** Which of the email's inboxes replies, when it reached several. */ + public Builder inboxId(String inboxId) { + o.inboxId = inboxId; + return this; + } + + public ReplyOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/Screening.java b/src/main/java/com/millionsend/model/Screening.java new file mode 100644 index 0000000..7aebf8e --- /dev/null +++ b/src/main/java/com/millionsend/model/Screening.java @@ -0,0 +1,56 @@ +package com.millionsend.model; + +import java.util.List; + +/** + * How an inbox screened a received email. Lists carry {@code verdict} and + * {@code senderTrust} only. Every value is a plain string: the API may add new ones. + */ +public final class Screening { + + private String verdict; + private String senderTrust; + private String quarantineReason; + private List reasons; + private Integer injectionScore; + private Integer phishingScore; + private Boolean hiddenTextRemoved; + + /** {@code pending}, {@code clean}, {@code suspicious}, {@code quarantined} or {@code released}. */ + public String getVerdict() { + return verdict; + } + + /** + * {@code owner}, {@code allowlisted}, {@code known_thread}, {@code external}, + * {@code unauthenticated} or {@code failed}. + */ + public String getSenderTrust() { + return senderTrust; + } + + /** Set only when quarantined or released, e.g. {@code not_allowlisted}, {@code injection}, {@code virus}. */ + public String getQuarantineReason() { + return quarantineReason; + } + + /** Reason keys from the injection screen, e.g. {@code targets_agent}. */ + public List getReasons() { + return reasons; + } + + /** 0-100; {@code null} when the email was not screened. */ + public Integer getInjectionScore() { + return injectionScore; + } + + /** 0-100; {@code null} when the email was not screened. */ + public Integer getPhishingScore() { + return phishingScore; + } + + /** True when text a person could not see was stripped from {@code extractedText}. */ + public Boolean getHiddenTextRemoved() { + return hiddenTextRemoved; + } +} diff --git a/src/main/java/com/millionsend/model/SearchThreadsOptions.java b/src/main/java/com/millionsend/model/SearchThreadsOptions.java new file mode 100644 index 0000000..0860f4b --- /dev/null +++ b/src/main/java/com/millionsend/model/SearchThreadsOptions.java @@ -0,0 +1,131 @@ +package com.millionsend.model; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * The {@link ListThreadsOptions} filters plus search terms for + * {@code inboxes().threads().search(...)}. Search covers subject, sender and + * recipients; every per-email filter must match the same email. + */ +public final class SearchThreadsOptions { + + private final ListThreadsOptions.Builder list = ListThreadsOptions.builder(); + private String query; + private List from; + private List to; + private List cc; + private List bcc; + private Boolean hasAttachment; + private String startDate; + private String endDate; + + private SearchThreadsOptions() {} + + public static Builder builder() { + return new Builder(); + } + + /** Flatten into the wire query map, dropping unset params. */ + public Map toQuery() { + Map q = list.build().toQuery(); + Query.put(q, "query", query); + Query.csv(q, "from", from); + Query.csv(q, "to", to); + Query.csv(q, "cc", cc); + Query.csv(q, "bcc", bcc); + Query.put(q, "has_attachment", hasAttachment); + Query.put(q, "start_date", startDate); + Query.put(q, "end_date", endDate); + return q; + } + + public static final class Builder { + private final SearchThreadsOptions o = new SearchThreadsOptions(); + + /** Every word must match; {@code "a phrase"} and {@code -word} exclusions work. */ + public Builder query(String query) { + o.query = query; + return this; + } + + /** Substrings of the sender's address or name; any may match. */ + public Builder from(String... from) { + o.from = new ArrayList<>(Arrays.asList(from)); + return this; + } + + public Builder to(String... to) { + o.to = new ArrayList<>(Arrays.asList(to)); + return this; + } + + public Builder cc(String... cc) { + o.cc = new ArrayList<>(Arrays.asList(cc)); + return this; + } + + public Builder bcc(String... bcc) { + o.bcc = new ArrayList<>(Arrays.asList(bcc)); + return this; + } + + public Builder hasAttachment(boolean hasAttachment) { + o.hasAttachment = hasAttachment; + return this; + } + + /** {@code YYYY-MM-DD} (the whole day) or ISO 8601. */ + public Builder startDate(String startDate) { + o.startDate = startDate; + return this; + } + + /** {@code YYYY-MM-DD} (the whole day) or ISO 8601. */ + public Builder endDate(String endDate) { + o.endDate = endDate; + return this; + } + + public Builder folders(String... folders) { + o.list.folders(folders); + return this; + } + + public Builder labels(String... labels) { + o.list.labels(labels); + return this; + } + + public Builder read(boolean read) { + o.list.read(read); + return this; + } + + public Builder limit(int limit) { + o.list.limit(limit); + return this; + } + + public Builder after(String after) { + o.list.after(after); + return this; + } + + public Builder before(String before) { + o.list.before(before); + return this; + } + + public Builder includeQuarantined(boolean includeQuarantined) { + o.list.includeQuarantined(includeQuarantined); + return this; + } + + public SearchThreadsOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/SendResult.java b/src/main/java/com/millionsend/model/SendResult.java new file mode 100644 index 0000000..662e6c8 --- /dev/null +++ b/src/main/java/com/millionsend/model/SendResult.java @@ -0,0 +1,47 @@ +package com.millionsend.model; + +/** + * The outcome of an inbox send (reply, forward, draft send): the sent result, or + * the {@link InboxDraft} the inbox's send rules held for a person to approve (the + * API answers 202 and nothing is sent). + */ +public final class SendResult { + + private final T sent; + private final InboxDraft draft; + private final boolean held; + + private SendResult(T sent, InboxDraft draft, boolean held) { + this.sent = sent; + this.draft = draft; + this.held = held; + } + + public static SendResult sent(T sent) { + return new SendResult<>(sent, null, false); + } + + public static SendResult held(InboxDraft draft) { + return new SendResult<>(null, draft, true); + } + + /** True when the send rules held the message as a draft ({@code status: pending_approval}). */ + public boolean isDraft() { + return held; + } + + /** {@code sent} or {@code draft}. */ + public String getKind() { + return held ? "draft" : "sent"; + } + + /** The sent result; {@code null} when {@link #isDraft()}. */ + public T getSent() { + return sent; + } + + /** The held draft; {@code null} when the message was sent. */ + public InboxDraft getDraft() { + return draft; + } +} diff --git a/src/main/java/com/millionsend/model/ThreadEmail.java b/src/main/java/com/millionsend/model/ThreadEmail.java new file mode 100644 index 0000000..88f0bfd --- /dev/null +++ b/src/main/java/com/millionsend/model/ThreadEmail.java @@ -0,0 +1,138 @@ +package com.millionsend.model; + +import java.util.List; + +/** + * One email in an inbox thread, either direction; also the sent email that reply and + * forward return ({@code emailId}, {@code threadId}, {@code inboxId}). From + * {@code receiving().reply(...)} and {@code forward(...)} only {@code object}, + * {@code id}, {@code threadId} and {@code inboxId} are set. + */ +public final class ThreadEmail { + + private String object; + private String id; + private String direction; + private String from; + private List to; + private List cc; + private List bcc; + private List replyTo; + private String subject; + private String messageId; + private String html; + private String text; + private List attachments; + private Boolean read; + private String receivedAt; + private String extractedText; + private Screening screening; + private String spamVerdict; + private String virusVerdict; + private String status; + private String emailId; + private String threadId; + private String inboxId; + + public String getObject() { + return object; + } + + public String getId() { + return id; + } + + /** {@code inbound} or {@code outbound}. */ + public String getDirection() { + return direction; + } + + /** With the display name when there is one: {@code Name
}. */ + public String getFrom() { + return from; + } + + public List getTo() { + return to; + } + + public List getCc() { + return cc; + } + + public List getBcc() { + return bcc; + } + + public List getReplyTo() { + return replyTo; + } + + public String getSubject() { + return subject; + } + + public String getMessageId() { + return messageId; + } + + public String getHtml() { + return html; + } + + /** Inbound text has invisible characters removed. */ + public String getText() { + return text; + } + + public List getAttachments() { + return attachments; + } + + public Boolean getRead() { + return read; + } + + /** When it arrived or was sent. */ + public String getReceivedAt() { + return receivedAt; + } + + /** Inbound: the reply without quotes, signature or hidden text. */ + public String getExtractedText() { + return extractedText; + } + + /** Inbound only. */ + public Screening getScreening() { + return screening; + } + + /** Inbound only. */ + public String getSpamVerdict() { + return spamVerdict; + } + + /** Inbound only. */ + public String getVirusVerdict() { + return virusVerdict; + } + + /** Outbound only: the email's latest delivery event. */ + public String getStatus() { + return status; + } + + /** The sent email's id (reply and forward results). */ + public String getEmailId() { + return emailId; + } + + public String getThreadId() { + return threadId; + } + + public String getInboxId() { + return inboxId; + } +} diff --git a/src/main/java/com/millionsend/model/UpdateDomainOptions.java b/src/main/java/com/millionsend/model/UpdateDomainOptions.java index e9f7e17..bb72d82 100644 --- a/src/main/java/com/millionsend/model/UpdateDomainOptions.java +++ b/src/main/java/com/millionsend/model/UpdateDomainOptions.java @@ -52,6 +52,12 @@ public Builder trackingSubdomain(String trackingSubdomain) { return this; } + /** Accept mail to addresses without an inbox (a paid plan on MillionSend Cloud). */ + public Builder receivingCatchAll(boolean receivingCatchAll) { + o.changes.put("receiving_catch_all", receivingCatchAll); + return this; + } + public UpdateDomainOptions build() { return o; } diff --git a/src/main/java/com/millionsend/model/UpdateDraftOptions.java b/src/main/java/com/millionsend/model/UpdateDraftOptions.java new file mode 100644 index 0000000..39da8f5 --- /dev/null +++ b/src/main/java/com/millionsend/model/UpdateDraftOptions.java @@ -0,0 +1,66 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Options for {@code inboxes().drafts().update(...)}. Only the fields you set are + * sent; {@code null} clears a field. Setting {@code html} or {@code text} replaces + * the whole body: the part you leave out becomes null. + */ +public final class UpdateDraftOptions { + + private final Map changes = new LinkedHashMap<>(); + + private UpdateDraftOptions() {} + + public static Builder builder() { + return new Builder(); + } + + /** The snake_case body to PATCH — only the fields the caller set. */ + @JsonValue + public Map getChanges() { + return changes; + } + + public static final class Builder { + private final UpdateDraftOptions o = new UpdateDraftOptions(); + + public Builder to(List to) { + o.changes.put("to", to); + return this; + } + + public Builder cc(List cc) { + o.changes.put("cc", cc); + return this; + } + + public Builder bcc(List bcc) { + o.changes.put("bcc", bcc); + return this; + } + + public Builder subject(String subject) { + o.changes.put("subject", subject); + return this; + } + + public Builder html(String html) { + o.changes.put("html", html); + return this; + } + + public Builder text(String text) { + o.changes.put("text", text); + return this; + } + + public UpdateDraftOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/UpdateInboxOptions.java b/src/main/java/com/millionsend/model/UpdateInboxOptions.java new file mode 100644 index 0000000..4f0675c --- /dev/null +++ b/src/main/java/com/millionsend/model/UpdateInboxOptions.java @@ -0,0 +1,51 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Options for {@code inboxes().update(...)}. Only the fields you set are sent; + * {@code expiresIn(null)} makes the inbox never expire. + */ +public final class UpdateInboxOptions { + + private final Map changes = new LinkedHashMap<>(); + + private UpdateInboxOptions() {} + + public static Builder builder() { + return new Builder(); + } + + /** The snake_case body to PATCH — only the fields the caller set. */ + @JsonValue + public Map getChanges() { + return changes; + } + + public static final class Builder { + private final UpdateInboxOptions o = new UpdateInboxOptions(); + + public Builder name(String name) { + o.changes.put("name", name); + return this; + } + + /** {@code ""} removes the display name. */ + public Builder fromName(String fromName) { + o.changes.put("from_name", fromName); + return this; + } + + /** Like {@code 1h} or {@code 30d}, counted from now; {@code null} for never. */ + public Builder expiresIn(String expiresIn) { + o.changes.put("expires_in", expiresIn); + return this; + } + + public UpdateInboxOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/UpdateThreadOptions.java b/src/main/java/com/millionsend/model/UpdateThreadOptions.java new file mode 100644 index 0000000..8470592 --- /dev/null +++ b/src/main/java/com/millionsend/model/UpdateThreadOptions.java @@ -0,0 +1,44 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** Options for {@code inboxes().threads().update(...)}: set at least one. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class UpdateThreadOptions { + + private Boolean read; + private String folder; + private String labelId; + + private UpdateThreadOptions() {} + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final UpdateThreadOptions o = new UpdateThreadOptions(); + + /** Marks every message in the thread. */ + public Builder read(boolean read) { + o.read = read; + return this; + } + + /** {@code inbox}, {@code archive}, {@code spam} or {@code trash}; out of trash restores it. */ + public Builder folder(String folder) { + o.folder = folder; + return this; + } + + /** Applies this label to the thread. */ + public Builder labelId(String labelId) { + o.labelId = labelId; + return this; + } + + public UpdateThreadOptions build() { + return o; + } + } +} diff --git a/src/main/java/com/millionsend/model/UpdateWebhookOptions.java b/src/main/java/com/millionsend/model/UpdateWebhookOptions.java index d5846be..1ba2328 100644 --- a/src/main/java/com/millionsend/model/UpdateWebhookOptions.java +++ b/src/main/java/com/millionsend/model/UpdateWebhookOptions.java @@ -1,17 +1,19 @@ package com.millionsend.model; -import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonValue; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; -/** Options for {@code webhooks().update(...)}. All fields optional. */ -@JsonInclude(JsonInclude.Include.NON_NULL) +/** + * Options for {@code webhooks().update(...)}. Only the fields you set are sent; + * {@code inboxIds(null)} removes the inbox filter. + */ public final class UpdateWebhookOptions { - private String endpoint; - private List events; - private String status; + private final Map changes = new LinkedHashMap<>(); private UpdateWebhookOptions() {} @@ -19,27 +21,38 @@ public static Builder builder() { return new Builder(); } + /** The snake_case body to PATCH — only the fields the caller set. */ + @JsonValue + public Map getChanges() { + return changes; + } + public static final class Builder { private final UpdateWebhookOptions o = new UpdateWebhookOptions(); public Builder endpoint(String endpoint) { - o.endpoint = endpoint; + o.changes.put("endpoint", endpoint); return this; } public Builder events(WebhookEvent... events) { - o.events = new ArrayList<>(Arrays.asList(events)); - return this; + return events(new ArrayList<>(Arrays.asList(events))); } public Builder events(List events) { - o.events = events; + o.changes.put("events", events); return this; } /** {@code enabled} or {@code disabled}. */ public Builder status(String status) { - o.status = status; + o.changes.put("status", status); + return this; + } + + /** Deliver inbox events and {@code email.received} for these inboxes only; {@code null} for every inbox. */ + public Builder inboxIds(List inboxIds) { + o.changes.put("inbox_ids", inboxIds); return this; } diff --git a/src/main/java/com/millionsend/model/VerificationCode.java b/src/main/java/com/millionsend/model/VerificationCode.java new file mode 100644 index 0000000..9ab9f1d --- /dev/null +++ b/src/main/java/com/millionsend/model/VerificationCode.java @@ -0,0 +1,61 @@ +package com.millionsend.model; + +/** + * The newest verification code or sign-in link an inbox received + * ({@code inboxes().codes().latest(...)}). The link is never fetched. + */ +public final class VerificationCode { + + private String object; + private String status; + private String kind; + private String code; + private String url; + private Boolean linkDomainMatchesSender; + private String emailId; + private String from; + private String receivedAt; + private String senderTrust; + + public String getObject() { + return object; + } + + /** {@code found}, {@code none}, {@code ambiguous} or {@code timeout} (a wait that ended empty). */ + public String getStatus() { + return status; + } + + /** {@code otp} or {@code link}; null unless found. */ + public String getKind() { + return kind; + } + + public String getCode() { + return code; + } + + public String getUrl() { + return url; + } + + public Boolean getLinkDomainMatchesSender() { + return linkDomainMatchesSender; + } + + public String getEmailId() { + return emailId; + } + + public String getFrom() { + return from; + } + + public String getReceivedAt() { + return receivedAt; + } + + public String getSenderTrust() { + return senderTrust; + } +} diff --git a/src/main/java/com/millionsend/model/Webhook.java b/src/main/java/com/millionsend/model/Webhook.java index 68c2553..20c682f 100644 --- a/src/main/java/com/millionsend/model/Webhook.java +++ b/src/main/java/com/millionsend/model/Webhook.java @@ -15,6 +15,7 @@ public final class Webhook { private String createdAt; private String status; private List events; + private List inboxIds; private String signingSecret; private String previousSecretExpiresAt; @@ -43,6 +44,11 @@ public List getEvents() { return events; } + /** Inbox events and {@code email.received} for these inboxes only; {@code null} for every inbox. */ + public List getInboxIds() { + return inboxIds; + } + public String getSigningSecret() { return signingSecret; } diff --git a/src/main/java/com/millionsend/model/WebhookEvent.java b/src/main/java/com/millionsend/model/WebhookEvent.java index c00bdad..9e241fb 100644 --- a/src/main/java/com/millionsend/model/WebhookEvent.java +++ b/src/main/java/com/millionsend/model/WebhookEvent.java @@ -12,6 +12,8 @@ public enum WebhookEvent { EMAIL_COMPLAINED("email.complained"), EMAIL_OPENED("email.opened"), EMAIL_CLICKED("email.clicked"), + /** Mail received at a receiving domain or inbox. */ + EMAIL_RECEIVED("email.received"), /** MillionSend extension. */ DELIVERABILITY_WARNING("deliverability.warning"), /** MillionSend extension. */ @@ -36,7 +38,24 @@ public enum WebhookEvent { /** MillionSend extension. */ SUPPRESSION_ADDED("suppression.added"), /** MillionSend extension. */ - SUPPRESSION_REMOVED("suppression.removed"); + SUPPRESSION_REMOVED("suppression.removed"), + // Inbox events reach only endpoints that name them; subscribing to all events skips them. + INBOX_CREATED("inbox.created"), + INBOX_UPDATED("inbox.updated"), + INBOX_DELETED("inbox.deleted"), + /** MillionSend extension. */ + INBOX_EXPIRED("inbox.expired"), + INBOX_THREAD_CREATED("inbox.thread.created"), + INBOX_THREAD_FOLDER_UPDATED("inbox.thread.folder.updated"), + INBOX_THREAD_LABELS_UPDATED("inbox.thread.labels.updated"), + INBOX_EMAIL_RECEIVED("inbox.email.received"), + INBOX_EMAIL_SENT("inbox.email.sent"), + /** MillionSend extension. */ + INBOX_EMAIL_QUARANTINED("inbox.email.quarantined"), + INBOX_DRAFT_CREATED("inbox.draft.created"), + INBOX_DRAFT_UPDATED("inbox.draft.updated"), + INBOX_DRAFT_SENT("inbox.draft.sent"), + INBOX_DRAFT_DELETED("inbox.draft.deleted"); private final String value; diff --git a/src/main/java/com/millionsend/services/Inboxes.java b/src/main/java/com/millionsend/services/Inboxes.java new file mode 100644 index 0000000..403424e --- /dev/null +++ b/src/main/java/com/millionsend/services/Inboxes.java @@ -0,0 +1,480 @@ +package com.millionsend.services; + +import static com.millionsend.core.HttpClient.enc; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.millionsend.MillionSendException; +import com.millionsend.core.HttpClient; +import com.millionsend.core.RequestOptions; +import com.millionsend.model.CreateDraftOptions; +import com.millionsend.model.CreateInboxOptions; +import com.millionsend.model.DeletedResponse; +import com.millionsend.model.ForwardOptions; +import com.millionsend.model.Inbox; +import com.millionsend.model.InboxDraft; +import com.millionsend.model.InboxLabel; +import com.millionsend.model.InboxLabelOptions; +import com.millionsend.model.InboxSafety; +import com.millionsend.model.InboxSafetyOptions; +import com.millionsend.model.InboxThread; +import com.millionsend.model.LatestCodeOptions; +import com.millionsend.model.ListInboxesOptions; +import com.millionsend.model.ListOptions; +import com.millionsend.model.ListResponse; +import com.millionsend.model.ListThreadsOptions; +import com.millionsend.model.QuarantineRelease; +import com.millionsend.model.QuarantinedEmail; +import com.millionsend.model.ReplyOptions; +import com.millionsend.model.SearchThreadsOptions; +import com.millionsend.model.SendResult; +import com.millionsend.model.ThreadEmail; +import com.millionsend.model.UpdateDraftOptions; +import com.millionsend.model.UpdateInboxOptions; +import com.millionsend.model.UpdateThreadOptions; +import com.millionsend.model.VerificationCode; +import java.util.Collections; + +/** + * The {@code inboxes} resource: addresses that receive mail for an agent or for + * people, with threads in both directions. Sub-resources: {@link #safety()}, + * {@link #codes()}, {@link #quarantine()}, {@link #labels()}, {@link #threads()} + * (and {@code threads().emails()}) and {@link #drafts()}. + */ +public final class Inboxes { + + private final HttpClient http; + private final Safety safety; + private final Codes codes; + private final Quarantine quarantine; + private final Labels labels; + private final Threads threads; + private final Drafts drafts; + + public Inboxes(HttpClient http) { + this.http = http; + this.safety = new Safety(http); + this.codes = new Codes(http); + this.quarantine = new Quarantine(http); + this.labels = new Labels(http); + this.threads = new Threads(http); + this.drafts = new Drafts(http); + } + + /** Safety rules: who may write in, what the inbox may send (MillionSend extension). */ + public Safety safety() { + return safety; + } + + /** Verification codes and sign-in links the inbox received (MillionSend extension). */ + public Codes codes() { + return codes; + } + + /** Mail the safety rules held (MillionSend extension). */ + public Quarantine quarantine() { + return quarantine; + } + + public Labels labels() { + return labels; + } + + public Threads threads() { + return threads; + } + + public Drafts drafts() { + return drafts; + } + + /** POST /inboxes — 409 when the address is taken, 403 at the plan's inbox limit. */ + public Inbox create(CreateInboxOptions options) throws MillionSendException { + return http.request("POST", "/inboxes", options, null, null, new TypeReference() {}); + } + + /** GET /inboxes — newest first. */ + public ListResponse list() throws MillionSendException { + return list(null); + } + + /** GET /inboxes with pagination and a {@code kind} filter. */ + public ListResponse list(ListInboxesOptions options) throws MillionSendException { + return http.request( + "GET", "/inboxes", null, options == null ? null : options.toQuery(), null, + new TypeReference>() {}); + } + + /** GET /inboxes/{idOrEmail} — by id or by address (case-insensitive). */ + public Inbox get(String idOrEmail) throws MillionSendException { + return http.request("GET", inbox(idOrEmail), null, null, null, new TypeReference() {}); + } + + /** PATCH /inboxes/{id} — returns the updated inbox. */ + public Inbox update(String id, UpdateInboxOptions options) throws MillionSendException { + return http.request("PATCH", inbox(id), options, null, null, new TypeReference() {}); + } + + /** DELETE /inboxes/{id} — removes its threads, drafts and labels and revokes its keys. */ + public DeletedResponse remove(String id) throws MillionSendException { + return http.request( + "DELETE", inbox(id), null, null, null, new TypeReference() {}); + } + + private static String inbox(String id) { + return "/inboxes/" + enc(id); + } + + /** An inbox's safety rules. Inbox-scoped keys can read them but never change them. */ + public static final class Safety { + + private final HttpClient http; + + Safety(HttpClient http) { + this.http = http; + } + + /** GET /inboxes/{inboxId}/safety */ + public InboxSafety get(String inboxId) throws MillionSendException { + return http.request( + "GET", inbox(inboxId) + "/safety", null, null, null, new TypeReference() {}); + } + + /** PATCH /inboxes/{inboxId}/safety — only the rules set on {@code options}; returns them all. */ + public InboxSafety update(String inboxId, InboxSafetyOptions options) + throws MillionSendException { + return http.request( + "PATCH", inbox(inboxId) + "/safety", options, null, null, + new TypeReference() {}); + } + } + + /** The newest verification code or sign-in link an inbox received. Links are never fetched. */ + public static final class Codes { + + private final HttpClient http; + + Codes(HttpClient http) { + this.http = http; + } + + /** GET /inboxes/{inboxId}/codes/latest */ + public VerificationCode latest(String inboxId) throws MillionSendException { + return latest(inboxId, null); + } + + /** + * GET /inboxes/{inboxId}/codes/latest. With {@code waitSeconds} it long-polls for a code to + * arrive; {@code status} is {@code timeout} when none did. + */ + public VerificationCode latest(String inboxId, LatestCodeOptions options) + throws MillionSendException { + return http.longPoll( + inbox(inboxId) + "/codes/latest", + options == null ? null : options.toQuery(), + options == null ? null : options.getWaitSeconds(), + new TypeReference() {}); + } + } + + /** Mail an inbox's safety rules held: kept, listed and releasable by a person. */ + public static final class Quarantine { + + private final HttpClient http; + + Quarantine(HttpClient http) { + this.http = http; + } + + /** GET /inboxes/{inboxId}/quarantine — newest first. */ + public ListResponse list(String inboxId) throws MillionSendException { + return list(inboxId, null); + } + + /** GET /inboxes/{inboxId}/quarantine with pagination. */ + public ListResponse list(String inboxId, ListOptions options) + throws MillionSendException { + return http.request( + "GET", inbox(inboxId) + "/quarantine", null, options == null ? null : options.toQuery(), + null, new TypeReference>() {}); + } + + /** POST /inboxes/{inboxId}/quarantine/{emailId}/release */ + public QuarantineRelease release(String inboxId, String emailId) throws MillionSendException { + return release(inboxId, emailId, false); + } + + /** + * POST /inboxes/{inboxId}/quarantine/{emailId}/release. {@code trustSender} also adds + * the sender's address to the inbox's allowlist. + */ + public QuarantineRelease release(String inboxId, String emailId, boolean trustSender) + throws MillionSendException { + return http.request( + "POST", inbox(inboxId) + "/quarantine/" + enc(emailId) + "/release", + trustSender ? Collections.singletonMap("trust_sender", true) : Collections.emptyMap(), + null, null, new TypeReference() {}); + } + } + + /** An inbox's labels: at most 100, names unique ignoring case. */ + public static final class Labels { + + private final HttpClient http; + + Labels(HttpClient http) { + this.http = http; + } + + /** GET /inboxes/{inboxId}/labels — oldest first, not paginated. */ + public ListResponse list(String inboxId) throws MillionSendException { + return http.request( + "GET", inbox(inboxId) + "/labels", null, null, null, + new TypeReference>() {}); + } + + /** POST /inboxes/{inboxId}/labels */ + public InboxLabel create(String inboxId, InboxLabelOptions options) + throws MillionSendException { + return http.request( + "POST", inbox(inboxId) + "/labels", options, null, null, + new TypeReference() {}); + } + + /** PATCH /inboxes/{inboxId}/labels/{labelId} */ + public InboxLabel update(String inboxId, String labelId, InboxLabelOptions options) + throws MillionSendException { + return http.request( + "PATCH", inbox(inboxId) + "/labels/" + enc(labelId), options, null, null, + new TypeReference() {}); + } + + /** DELETE /inboxes/{inboxId}/labels/{labelId} — also takes it off every thread. */ + public DeletedResponse remove(String inboxId, String labelId) throws MillionSendException { + return http.request( + "DELETE", inbox(inboxId) + "/labels/" + enc(labelId), null, null, null, + new TypeReference() {}); + } + } + + /** An inbox's threads. Their emails live under {@link #emails()}. */ + public static final class Threads { + + private final HttpClient http; + private final ThreadEmails emails; + + Threads(HttpClient http) { + this.http = http; + this.emails = new ThreadEmails(http); + } + + public ThreadEmails emails() { + return emails; + } + + /** GET /inboxes/{inboxId}/threads — most recently active first, the {@code inbox} folder. */ + public ListResponse list(String inboxId) throws MillionSendException { + return list(inboxId, null); + } + + /** GET /inboxes/{inboxId}/threads with folder, label and read filters. */ + public ListResponse list(String inboxId, ListThreadsOptions options) + throws MillionSendException { + return http.request( + "GET", inbox(inboxId) + "/threads", null, options == null ? null : options.toQuery(), + null, new TypeReference>() {}); + } + + /** GET /inboxes/{inboxId}/threads/search */ + public ListResponse search(String inboxId, SearchThreadsOptions options) + throws MillionSendException { + return http.request( + "GET", inbox(inboxId) + "/threads/search", null, + options == null ? null : options.toQuery(), null, + new TypeReference>() {}); + } + + /** GET /inboxes/{inboxId}/threads/{threadId} — the summary, without messages. */ + public InboxThread get(String inboxId, String threadId) throws MillionSendException { + return http.request( + "GET", thread(inboxId, threadId), null, null, null, new TypeReference() {}); + } + + /** PATCH /inboxes/{inboxId}/threads/{threadId} — read state, folder or a label. */ + public InboxThread update(String inboxId, String threadId, UpdateThreadOptions options) + throws MillionSendException { + return http.request( + "PATCH", thread(inboxId, threadId), options, null, null, + new TypeReference() {}); + } + + /** + * DELETE /inboxes/{inboxId}/threads/{threadId} — moves it to trash, purged after 30 + * days; {@code deleted} is false when it was already there. + */ + public DeletedResponse remove(String inboxId, String threadId) throws MillionSendException { + return http.request( + "DELETE", thread(inboxId, threadId), null, null, null, + new TypeReference() {}); + } + } + + /** The emails of one thread, both directions. */ + public static final class ThreadEmails { + + private static final TypeReference SENT = new TypeReference() {}; + + private final HttpClient http; + + ThreadEmails(HttpClient http) { + this.http = http; + } + + /** GET /inboxes/{inboxId}/threads/{threadId}/emails — oldest first; {@code after} pages to newer. */ + public ListResponse list(String inboxId, String threadId) + throws MillionSendException { + return list(inboxId, threadId, null); + } + + /** GET /inboxes/{inboxId}/threads/{threadId}/emails with pagination. */ + public ListResponse list(String inboxId, String threadId, ListOptions options) + throws MillionSendException { + return http.request( + "GET", thread(inboxId, threadId) + "/emails", null, + options == null ? null : options.toQuery(), null, + new TypeReference>() {}); + } + + /** GET /inboxes/{inboxId}/threads/{threadId}/emails/{emailId} */ + public ThreadEmail get(String inboxId, String threadId, String emailId) + throws MillionSendException { + return http.request( + "GET", email(inboxId, threadId, emailId), null, null, null, + new TypeReference() {}); + } + + /** POST …/emails/{emailId}/reply */ + public SendResult reply( + String inboxId, String threadId, String emailId, ReplyOptions options) + throws MillionSendException { + return reply(inboxId, threadId, emailId, options, null); + } + + /** + * POST …/emails/{emailId}/reply with an {@code Idempotency-Key}. When the inbox's send + * rules hold the reply, the result is a draft waiting for approval. + */ + public SendResult reply( + String inboxId, String threadId, String emailId, ReplyOptions options, + RequestOptions requestOptions) + throws MillionSendException { + return http.sendOrHold( + email(inboxId, threadId, emailId) + "/reply", options, requestOptions, SENT); + } + + /** POST …/emails/{emailId}/forward */ + public SendResult forward( + String inboxId, String threadId, String emailId, ForwardOptions options) + throws MillionSendException { + return forward(inboxId, threadId, emailId, options, null); + } + + /** POST …/emails/{emailId}/forward with an {@code Idempotency-Key}. */ + public SendResult forward( + String inboxId, String threadId, String emailId, ForwardOptions options, + RequestOptions requestOptions) + throws MillionSendException { + return http.sendOrHold( + email(inboxId, threadId, emailId) + "/forward", options, requestOptions, SENT); + } + + private static String email(String inboxId, String threadId, String emailId) { + return thread(inboxId, threadId) + "/emails/" + enc(emailId); + } + } + + /** An inbox's drafts, including the ones the send rules hold for approval. */ + public static final class Drafts { + + private final HttpClient http; + + Drafts(HttpClient http) { + this.http = http; + } + + /** GET /inboxes/{inboxId}/drafts — unsent drafts, most recently updated first. */ + public ListResponse list(String inboxId) throws MillionSendException { + return list(inboxId, null); + } + + /** GET /inboxes/{inboxId}/drafts with pagination. */ + public ListResponse list(String inboxId, ListOptions options) + throws MillionSendException { + return http.request( + "GET", inbox(inboxId) + "/drafts", null, options == null ? null : options.toQuery(), + null, new TypeReference>() {}); + } + + /** POST /inboxes/{inboxId}/drafts — the existing one when a reply draft already targets that email. */ + public InboxDraft create(String inboxId, CreateDraftOptions options) + throws MillionSendException { + return http.request( + "POST", inbox(inboxId) + "/drafts", options, null, null, + new TypeReference() {}); + } + + /** GET /inboxes/{inboxId}/drafts/{draftId} — also after it was sent. */ + public InboxDraft get(String inboxId, String draftId) throws MillionSendException { + return http.request( + "GET", draft(inboxId, draftId), null, null, null, new TypeReference() {}); + } + + /** PATCH /inboxes/{inboxId}/drafts/{draftId} — 409 once sent or when it changed meanwhile. */ + public InboxDraft update(String inboxId, String draftId, UpdateDraftOptions options) + throws MillionSendException { + return http.request( + "PATCH", draft(inboxId, draftId), options, null, null, + new TypeReference() {}); + } + + /** DELETE /inboxes/{inboxId}/drafts/{draftId} */ + public DeletedResponse remove(String inboxId, String draftId) throws MillionSendException { + return http.request( + "DELETE", draft(inboxId, draftId), null, null, null, + new TypeReference() {}); + } + + /** POST /inboxes/{inboxId}/drafts/{draftId}/send */ + public SendResult send(String inboxId, String draftId) + throws MillionSendException { + return send(inboxId, draftId, null); + } + + /** + * POST /inboxes/{inboxId}/drafts/{draftId}/send with an {@code Idempotency-Key}. Sent, + * the result carries the draft's {@code emailId} and {@code threadId}; the send rules + * may hold it for approval instead. An inbox-scoped key gets 403 + * {@code approval_required} on a pending draft. + */ + public SendResult send(String inboxId, String draftId, RequestOptions requestOptions) + throws MillionSendException { + return http.sendOrHold( + draft(inboxId, draftId) + "/send", null, requestOptions, + new TypeReference() {}); + } + + /** POST /inboxes/{inboxId}/drafts/{draftId}/reject — approvers only (MillionSend extension). */ + public InboxDraft reject(String inboxId, String draftId) throws MillionSendException { + return http.request( + "POST", draft(inboxId, draftId) + "/reject", null, null, null, + new TypeReference() {}); + } + + private static String draft(String inboxId, String draftId) { + return inbox(inboxId) + "/drafts/" + enc(draftId); + } + } + + private static String thread(String inboxId, String threadId) { + return inbox(inboxId) + "/threads/" + enc(threadId); + } +} diff --git a/src/main/java/com/millionsend/services/Receiving.java b/src/main/java/com/millionsend/services/Receiving.java new file mode 100644 index 0000000..c951a5f --- /dev/null +++ b/src/main/java/com/millionsend/services/Receiving.java @@ -0,0 +1,148 @@ +package com.millionsend.services; + +import static com.millionsend.core.HttpClient.enc; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.millionsend.MillionSendException; +import com.millionsend.core.HttpClient; +import com.millionsend.core.RequestOptions; +import com.millionsend.model.DeletedResponse; +import com.millionsend.model.ForwardOptions; +import com.millionsend.model.ListOptions; +import com.millionsend.model.ListReceivedEmailsOptions; +import com.millionsend.model.ListResponse; +import com.millionsend.model.ReceivedAttachment; +import com.millionsend.model.ReceivedEmail; +import com.millionsend.model.ReceivedEmailListResponse; +import com.millionsend.model.ReplyOptions; +import com.millionsend.model.SendResult; +import com.millionsend.model.ThreadEmail; +import java.util.Collections; + +/** + * The {@code receiving} resource: mail received at the team's receiving domains and + * inboxes. Everything in a received email was written by its sender, so show its HTML + * only in a sandbox. Attachments live under {@link #attachments()}. + */ +public final class Receiving { + + private static final TypeReference SENT = new TypeReference() {}; + + private final HttpClient http; + private final Attachments attachments; + + public Receiving(HttpClient http) { + this.http = http; + this.attachments = new Attachments(http); + } + + /** A received email's attachments ({@code receiving().attachments().list(emailId)}). */ + public Attachments attachments() { + return attachments; + } + + /** GET /emails/receiving — newest first. */ + public ReceivedEmailListResponse list() throws MillionSendException { + return list(null); + } + + /** + * GET /emails/receiving with filters. With {@code waitSeconds} the call long-polls: it returns + * as soon as matching mail arrives, or an empty page when the wait ends, and lists held + * matches by id under {@code held}. + */ + public ReceivedEmailListResponse list(ListReceivedEmailsOptions options) + throws MillionSendException { + return http.longPoll( + "/emails/receiving", + options == null ? null : options.toQuery(), + options == null ? null : options.getWaitSeconds(), + new TypeReference() {}); + } + + /** GET /emails/receiving/{id} — 403 {@code email_quarantined} for held mail read by an agent. */ + public ReceivedEmail get(String id) throws MillionSendException { + return get(id, null); + } + + /** + * GET /emails/receiving/{id}. + * + * @param htmlFormat {@code data_uri} (the default) inlines cid images; {@code cid} leaves the references + */ + public ReceivedEmail get(String id, String htmlFormat) throws MillionSendException { + return http.request( + "GET", "/emails/receiving/" + enc(id), null, + htmlFormat == null ? null : Collections.singletonMap("html_format", htmlFormat), null, + new TypeReference() {}); + } + + /** POST /emails/receiving/{id}/reply */ + public SendResult reply(String id, ReplyOptions options) + throws MillionSendException { + return reply(id, options, null); + } + + /** + * POST /emails/receiving/{id}/reply with an {@code Idempotency-Key}. When the inbox's + * send rules hold the reply, the result is a draft waiting for approval. + */ + public SendResult reply(String id, ReplyOptions options, RequestOptions requestOptions) + throws MillionSendException { + return http.sendOrHold("/emails/receiving/" + enc(id) + "/reply", options, requestOptions, SENT); + } + + /** POST /emails/receiving/{id}/forward */ + public SendResult forward(String id, ForwardOptions options) + throws MillionSendException { + return forward(id, options, null); + } + + /** + * POST /emails/receiving/{id}/forward with an {@code Idempotency-Key}. 403 + * {@code email_quarantined} for an email that carries malware. + */ + public SendResult forward( + String id, ForwardOptions options, RequestOptions requestOptions) + throws MillionSendException { + return http.sendOrHold("/emails/receiving/" + enc(id) + "/forward", options, requestOptions, SENT); + } + + /** DELETE /emails/receiving/{id} — erases the email now (a full access key not scoped to an inbox). */ + public DeletedResponse remove(String id) throws MillionSendException { + return http.request( + "DELETE", "/emails/receiving/" + enc(id), null, null, null, + new TypeReference() {}); + } + + /** Attachments of a received email, each with a signed download link valid for one hour. */ + public static final class Attachments { + + private final HttpClient http; + + Attachments(HttpClient http) { + this.http = http; + } + + /** GET /emails/receiving/{emailId}/attachments — all of them unless a limit is given. */ + public ListResponse list(String emailId) throws MillionSendException { + return list(emailId, null); + } + + /** GET /emails/receiving/{emailId}/attachments with pagination. */ + public ListResponse list(String emailId, ListOptions options) + throws MillionSendException { + return http.request( + "GET", "/emails/receiving/" + enc(emailId) + "/attachments", null, + options == null ? null : options.toQuery(), null, + new TypeReference>() {}); + } + + /** GET /emails/receiving/{emailId}/attachments/{id} */ + public ReceivedAttachment get(String emailId, String id) throws MillionSendException { + return http.request( + "GET", "/emails/receiving/" + enc(emailId) + "/attachments/" + enc(id), null, null, null, + new TypeReference() {}); + } + } +} diff --git a/src/test/java/com/millionsend/ParityTest.java b/src/test/java/com/millionsend/ParityTest.java index 978aa5d..92fe8b6 100644 --- a/src/test/java/com/millionsend/ParityTest.java +++ b/src/test/java/com/millionsend/ParityTest.java @@ -34,6 +34,34 @@ import com.millionsend.model.CreateWebhookOptions; import com.millionsend.model.CreateWebhookResponse; import com.millionsend.model.Domain; +import com.millionsend.model.CreateDraftOptions; +import com.millionsend.model.CreateInboxOptions; +import com.millionsend.model.DeletedResponse; +import com.millionsend.model.ForwardOptions; +import com.millionsend.model.Inbox; +import com.millionsend.model.InboxDraft; +import com.millionsend.model.InboxLabel; +import com.millionsend.model.InboxLabelOptions; +import com.millionsend.model.InboxSafety; +import com.millionsend.model.InboxSafetyOptions; +import com.millionsend.model.InboxThread; +import com.millionsend.model.LatestCodeOptions; +import com.millionsend.model.ListInboxesOptions; +import com.millionsend.model.ListReceivedEmailsOptions; +import com.millionsend.model.ListThreadsOptions; +import com.millionsend.model.QuarantineRelease; +import com.millionsend.model.QuarantinedEmail; +import com.millionsend.model.ReceivedAttachment; +import com.millionsend.model.ReceivedEmail; +import com.millionsend.model.ReceivedEmailListResponse; +import com.millionsend.model.ReplyOptions; +import com.millionsend.model.SearchThreadsOptions; +import com.millionsend.model.SendResult; +import com.millionsend.model.ThreadEmail; +import com.millionsend.model.UpdateDraftOptions; +import com.millionsend.model.UpdateInboxOptions; +import com.millionsend.model.UpdateThreadOptions; +import com.millionsend.model.VerificationCode; import com.millionsend.model.ListContactsOptions; import com.millionsend.model.ListOptions; import com.millionsend.model.ListResponse; @@ -832,4 +860,730 @@ void usage() throws Exception { assertNull(u.getPlan()); assertNull(u.getLimits().getEmailsPerDay()); } + // ---- receiving -------------------------------------------------------- + + static final String RECEIVED_JSON = + "{\"object\":\"email\",\"id\":\"r1\",\"to\":[\"bot@x.dev\"],\"from\":\"ada@y.dev\"," + + "\"created_at\":\"2026-10-12T10:00:00.000Z\",\"subject\":\"Your code\",\"bcc\":[],\"cc\":[]," + + "\"reply_to\":[],\"received_for\":[\"bot@x.dev\"],\"message_id\":\"\"," + + "\"attachments\":[{\"id\":\"a1\",\"filename\":\"a.pdf\",\"content_type\":\"application/pdf\"," + + "\"content_id\":null,\"content_disposition\":\"attachment\",\"size\":42}]," + + "\"html\":\"

482913

\",\"html_format\":\"cid\",\"text\":\"482913\"," + + "\"headers\":{\"from\":\"Ada \"}," + + "\"authentication\":{\"spf\":\"pass\",\"dkim\":\"gray\",\"dmarc\":\"pass\"}," + + "\"raw\":{\"download_url\":\"https://api.x.dev/raw/r1\",\"expires_at\":\"2026-10-12T11:00:00.000Z\"}," + + "\"inbox_id\":\"i1\",\"thread_id\":\"t1\"," + + "\"inboxes\":[{\"inbox_id\":\"i1\",\"thread_id\":\"t1\",\"verdict\":\"clean\"}]," + + "\"screening\":{\"verdict\":\"clean\",\"sender_trust\":\"external\",\"quarantine_reason\":null," + + "\"reasons\":[\"targets_agent\"],\"injection_score\":3,\"phishing_score\":null," + + "\"hidden_text_removed\":true}," + + "\"spam_verdict\":\"pass\",\"spam_score\":4,\"virus_verdict\":\"pass\",\"virus_signature\":null," + + "\"extracted_text\":\"Your code is 482913\"," + + "\"extracted\":{\"kinds\":[\"code\"],\"code\":\"482913\",\"link\":null," + + "\"link_domain_matches_sender\":null,\"ambiguous\":false},\"read\":false," + + "\"a_field_from_a_newer_server\":{\"x\":1}}"; + + static final String DRAFT_JSON = + "{\"object\":\"inbox_draft\",\"id\":\"d1\",\"type\":\"reply\",\"to\":[\"ada@y.dev\"],\"cc\":[]," + + "\"bcc\":[],\"subject\":\"Re: Hi\",\"html\":null,\"text\":\"Thanks\",\"thread_id\":\"t1\"," + + "\"reply_to_email_id\":\"r1\",\"email_id\":null,\"created_at\":\"2026-10-12T10:00:00.000Z\"," + + "\"updated_at\":\"2026-10-12T10:00:00.000Z\",\"status\":\"pending_approval\"," + + "\"approval_reasons\":[\"send_policy\",\"ramp\"],\"forward\":false,\"decided_by\":null," + + "\"decided_at\":null,\"expires_at\":\"2026-10-19T10:00:00.000Z\"}"; + + @Test + void receivingListCarriesEveryFilterAndHeldIds() throws Exception { + server.responseBody = + "{\"object\":\"list\",\"has_more\":false,\"data\":[" + RECEIVED_JSON + "]," + + "\"held\":[{\"id\":\"r2\",\"inbox_id\":\"i1\",\"reason\":\"not_allowlisted\"," + + "\"created_at\":\"2026-10-12T10:00:01.000Z\"}]}"; + ReceivedEmailListResponse res = + ms.receiving() + .list( + ListReceivedEmailsOptions.builder() + .limit(5) + .after("r0") + .waitSeconds(20) + .since("2026-10-12T10:00:00Z") + .inboxId("i1") + .to("bot@x.dev") + .from("y.dev") + .subjectContains("code") + .unread(true) + .includeQuarantined(true) + .build()); + assertEquals("GET", server.method); + assertEquals("/emails/receiving", server.path); + assertEquals( + "limit=5&after=r0&wait=20&since=2026-10-12T10%3A00%3A00Z&inbox_id=i1&to=bot%40x.dev" + + "&from=y.dev&subject_contains=code&unread=true&include_quarantined=true", + server.query); + assertEquals("r2", res.getHeld().get(0).getId()); + assertEquals("not_allowlisted", res.getHeld().get(0).getReason()); + ReceivedEmail e = res.getData().get(0); + assertEquals("clean", e.getScreening().getVerdict()); + assertEquals("external", e.getScreening().getSenderTrust()); + + server.responseBody = "{\"object\":\"list\",\"has_more\":false,\"data\":[]}"; + res = ms.receiving().list(); + assertNull(server.query); + assertNull(res.getHeld()); + ms.receiving().list(ListReceivedEmailsOptions.builder().includeQuarantined(false).build()); + assertNull(server.query); + } + + @Test + void receivingGetDecodesTheExtensionFields() throws Exception { + server.responseBody = RECEIVED_JSON; + ReceivedEmail e = ms.receiving().get("r1", "cid"); + assertEquals("GET", server.method); + assertEquals("/emails/receiving/r1", server.path); + assertEquals("html_format=cid", server.query); + assertEquals("cid", e.getHtmlFormat()); + assertEquals("pass", e.getAuthentication().get("spf")); + assertEquals("https://api.x.dev/raw/r1", e.getRaw().getDownloadUrl()); + assertEquals("Ada ", e.getHeaders().get("from")); + assertEquals("attachment", e.getAttachments().get(0).getContentDisposition()); + assertEquals(42L, e.getAttachments().get(0).getSize()); + assertEquals("i1", e.getInboxId()); + assertEquals("clean", e.getInboxes().get(0).getVerdict()); + assertEquals(3, e.getScreening().getInjectionScore()); + assertNull(e.getScreening().getPhishingScore()); + assertTrue(e.getScreening().getHiddenTextRemoved()); + assertEquals("targets_agent", e.getScreening().getReasons().get(0)); + assertEquals(4, e.getSpamScore()); + assertEquals("482913", e.getExtracted().getCode()); + assertEquals("code", e.getExtracted().getKinds().get(0)); + assertFalse(e.getExtracted().getAmbiguous()); + assertEquals("Your code is 482913", e.getExtractedText()); + assertFalse(e.getRead()); + + ms.receiving().get("r1"); + assertNull(server.query); + } + + @Test + void receivingReplySentOrHeld() throws Exception { + server.responseBody = "{\"object\":\"email\",\"id\":\"e9\",\"thread_id\":\"t1\",\"inbox_id\":\"i1\"}"; + SendResult sent = + ms.receiving() + .reply( + "r1", + ReplyOptions.builder() + .text("Thanks") + .html("

Thanks

") + .subject("Re: Hi") + .cc("cc@x.dev") + .bcc("bcc@x.dev") + .replyAll(true) + .requiresApproval(false) + .from("bot@x.dev") + .inboxId("i1") + .build(), + RequestOptions.idempotencyKey("reply-1")); + assertEquals("POST", server.method); + assertEquals("/emails/receiving/r1/reply", server.path); + assertEquals("reply-1", server.header("Idempotency-Key")); + assertEquals( + json( + "{\"text\":\"Thanks\",\"html\":\"

Thanks

\",\"subject\":\"Re: Hi\",\"cc\":[\"cc@x.dev\"]," + + "\"bcc\":[\"bcc@x.dev\"],\"reply_all\":true,\"requires_approval\":false," + + "\"from\":\"bot@x.dev\",\"inbox_id\":\"i1\"}"), + body()); + assertFalse(sent.isDraft()); + assertEquals("sent", sent.getKind()); + assertEquals("e9", sent.getSent().getId()); + assertEquals("t1", sent.getSent().getThreadId()); + assertNull(sent.getDraft()); + + server.status = 202; + server.responseBody = DRAFT_JSON; + SendResult held = ms.receiving().reply("r1", ReplyOptions.builder().text("Thanks").build()); + assertNull(server.header("Idempotency-Key")); + assertEquals(json("{\"text\":\"Thanks\"}"), body()); + assertTrue(held.isDraft()); + assertEquals("draft", held.getKind()); + assertNull(held.getSent()); + assertEquals("pending_approval", held.getDraft().getStatus()); + assertEquals(Arrays.asList("send_policy", "ramp"), held.getDraft().getApprovalReasons()); + } + + @Test + void receivingForwardAndRemove() throws Exception { + server.responseBody = "{\"object\":\"email\",\"id\":\"e9\",\"thread_id\":null,\"inbox_id\":null}"; + SendResult res = + ms.receiving() + .forward( + "r1", + ForwardOptions.builder() + .to("ops@x.dev") + .cc("cc@x.dev") + .bcc("bcc@x.dev") + .text("FYI") + .html("

FYI

") + .subject("Fwd: Hi") + .requiresApproval(true) + .from("bot@x.dev") + .inboxId("i1") + .mode("attachment") + .build(), + RequestOptions.idempotencyKey("fwd-1")); + assertEquals("POST", server.method); + assertEquals("/emails/receiving/r1/forward", server.path); + assertEquals("fwd-1", server.header("Idempotency-Key")); + assertEquals( + json( + "{\"to\":[\"ops@x.dev\"],\"cc\":[\"cc@x.dev\"],\"bcc\":[\"bcc@x.dev\"],\"text\":\"FYI\"," + + "\"html\":\"

FYI

\",\"subject\":\"Fwd: Hi\",\"requires_approval\":true," + + "\"from\":\"bot@x.dev\",\"inbox_id\":\"i1\",\"mode\":\"attachment\"}"), + body()); + assertFalse(res.isDraft()); + assertNull(res.getSent().getThreadId()); + + server.responseBody = "{\"object\":\"email\",\"id\":\"r1\",\"deleted\":true}"; + DeletedResponse removed = ms.receiving().remove("r1"); + assertEquals("DELETE", server.method); + assertEquals("/emails/receiving/r1", server.path); + assertTrue(removed.getDeleted()); + } + + @Test + void receivingAttachments() throws Exception { + server.responseBody = + "{\"object\":\"list\",\"has_more\":false,\"data\":[{\"id\":\"a1\",\"filename\":\"a.pdf\"," + + "\"size\":42,\"content_type\":\"application/pdf\",\"content_disposition\":\"attachment\"," + + "\"download_url\":\"https://api.x.dev/dl/a1\",\"expires_at\":\"2026-10-12T11:00:00.000Z\"}]}"; + ListResponse list = ms.receiving().attachments().list("r1"); + assertEquals("GET", server.method); + assertEquals("/emails/receiving/r1/attachments", server.path); + assertNull(server.query); + assertEquals("https://api.x.dev/dl/a1", list.getData().get(0).getDownloadUrl()); + + ms.receiving().attachments().list("r1", ListOptions.builder().limit(2).build()); + assertEquals("limit=2", server.query); + + server.responseBody = + "{\"object\":\"attachment\",\"id\":\"a1\",\"size\":42,\"content_type\":\"application/pdf\"," + + "\"content_disposition\":\"inline\",\"content_id\":\"cid-1\"," + + "\"download_url\":\"https://api.x.dev/dl/a1\",\"expires_at\":\"2026-10-12T11:00:00.000Z\"}"; + ReceivedAttachment a = ms.receiving().attachments().get("r1", "a1"); + assertEquals("/emails/receiving/r1/attachments/a1", server.path); + assertEquals("attachment", a.getObject()); + assertEquals("cid-1", a.getContentId()); + assertNull(a.getFilename()); + } + + // ---- inboxes ---------------------------------------------------------- + + static final String INBOX_JSON = + "{\"object\":\"inbox\",\"id\":\"i1\",\"name\":\"Signup bot\",\"email_address\":\"bot-7f3k@acme.agents.dev\"," + + "\"domain_id\":\"sd1\",\"receiving_address\":null,\"from_name\":null,\"unread\":2,\"drafts\":1," + + "\"last_received\":null,\"created_at\":\"2026-10-12T10:00:00.000Z\",\"kind\":\"agent\"," + + "\"shared\":true,\"expires_at\":\"2026-10-13T10:00:00.000Z\",\"quarantined\":3," + + "\"pending_approval\":1}"; + + @Test + void inboxesCrud() throws Exception { + server.status = 201; + server.responseBody = INBOX_JSON; + Inbox created = + ms.inboxes() + .create( + CreateInboxOptions.builder() + .emailAddress("bot@x.dev") + .name("Signup bot") + .fromName("Acme") + .forwarding(false) + .kind("agent") + .expiresIn("24h") + .safety(InboxSafetyOptions.builder().acceptFrom("anyone").sendCap(null).build()) + .build()); + assertEquals("POST", server.method); + assertEquals("/inboxes", server.path); + assertEquals( + json( + "{\"email_address\":\"bot@x.dev\",\"name\":\"Signup bot\",\"from_name\":\"Acme\"," + + "\"forwarding\":false,\"kind\":\"agent\",\"expires_in\":\"24h\"," + + "\"safety\":{\"accept_from\":\"anyone\",\"send_cap\":null}}"), + body()); + assertEquals("i1", created.getId()); + assertEquals("agent", created.getKind()); + assertTrue(created.getShared()); + assertEquals(3, created.getQuarantined()); + assertEquals(1, created.getPendingApproval()); + assertEquals(2, created.getUnread()); + + server.status = 200; + ms.inboxes().create(CreateInboxOptions.builder().build()); + assertEquals(json("{}"), body()); + + server.responseBody = + "{\"object\":\"list\",\"has_more\":true,\"data\":[{\"id\":\"i1\",\"name\":null," + + "\"email_address\":\"bot@x.dev\",\"from_name\":null,\"unread\":0,\"last_received\":null," + + "\"kind\":\"human\",\"shared\":false,\"expires_at\":null}]}"; + ListResponse list = + ms.inboxes().list(ListInboxesOptions.builder().limit(10).before("i0").kind("human").build()); + assertEquals("GET", server.method); + assertEquals("/inboxes", server.path); + assertEquals("limit=10&before=i0&kind=human", server.query); + assertTrue(list.isHasMore()); + assertEquals("human", list.getData().get(0).getKind()); + assertNull(list.getData().get(0).getDrafts()); + + server.responseBody = INBOX_JSON; + ms.inboxes().get("Bot+1@x.dev"); + assertEquals("/inboxes/Bot%2B1%40x.dev", server.rawPath); + + ms.inboxes() + .update("i1", UpdateInboxOptions.builder().name("Bot").fromName("").expiresIn(null).build()); + assertEquals("PATCH", server.method); + assertEquals("/inboxes/i1", server.path); + assertEquals(json("{\"name\":\"Bot\",\"from_name\":\"\",\"expires_in\":null}"), body()); + + server.responseBody = "{\"object\":\"inbox\",\"id\":\"i1\",\"deleted\":true}"; + assertTrue(ms.inboxes().remove("i1").getDeleted()); + assertEquals("DELETE", server.method); + assertEquals("/inboxes/i1", server.path); + } + + @Test + void inboxSafety() throws Exception { + server.responseBody = + "{\"object\":\"inbox_safety\",\"inbox_id\":\"i1\",\"accept_from\":\"verified\",\"allowlist\":[\"*@y.dev\"]," + + "\"owner_addresses\":[],\"screening\":true,\"extract_codes\":true,\"send_policy\":\"reply_only\"," + + "\"require_approval\":false,\"private_codes\":true,\"send_cap\":null,\"forward_to\":[]}"; + InboxSafety safety = ms.inboxes().safety().get("i1"); + assertEquals("GET", server.method); + assertEquals("/inboxes/i1/safety", server.path); + assertEquals("verified", safety.getAcceptFrom()); + assertEquals("*@y.dev", safety.getAllowlist().get(0)); + assertNull(safety.getSendCap()); + assertTrue(safety.getPrivateCodes()); + + ms.inboxes() + .safety() + .update( + "i1", + InboxSafetyOptions.builder() + .acceptFrom("allowlist") + .allowlist("ada@y.dev", "*@z.dev") + .ownerAddresses(Collections.singletonList("me@x.dev")) + .screening(true) + .extractCodes(false) + .sendPolicy("allowlist") + .requireApproval(true) + .privateCodes(true) + .sendCap(50) + .forwardTo(Collections.singletonList("me@x.dev")) + .build()); + assertEquals("PATCH", server.method); + assertEquals("/inboxes/i1/safety", server.path); + assertEquals( + json( + "{\"accept_from\":\"allowlist\",\"allowlist\":[\"ada@y.dev\",\"*@z.dev\"]," + + "\"owner_addresses\":[\"me@x.dev\"],\"screening\":true,\"extract_codes\":false," + + "\"send_policy\":\"allowlist\",\"require_approval\":true,\"private_codes\":true," + + "\"send_cap\":50,\"forward_to\":[\"me@x.dev\"]}"), + body()); + + ms.inboxes().safety().update("i1", InboxSafetyOptions.builder().sendCap(null).build()); + assertEquals(json("{\"send_cap\":null}"), body()); + } + + @Test + void inboxCodesAndQuarantine() throws Exception { + server.responseBody = + "{\"object\":\"verification_code\",\"status\":\"found\",\"kind\":\"otp\",\"code\":\"482913\"," + + "\"url\":null,\"link_domain_matches_sender\":true,\"email_id\":\"r1\",\"from\":\"ada@y.dev\"," + + "\"received_at\":\"2026-10-12T10:00:00.000Z\",\"sender_trust\":\"external\"}"; + VerificationCode code = + ms.inboxes() + .codes() + .latest("i1", LatestCodeOptions.builder().from("y.dev").waitSeconds(10).maxAge(600).build()); + assertEquals("GET", server.method); + assertEquals("/inboxes/i1/codes/latest", server.path); + assertEquals("from=y.dev&wait=10&max_age=600", server.query); + assertEquals("found", code.getStatus()); + assertEquals("482913", code.getCode()); + assertTrue(code.getLinkDomainMatchesSender()); + + server.responseBody = + "{\"object\":\"verification_code\",\"status\":\"timeout\",\"kind\":null,\"code\":null,\"url\":null," + + "\"link_domain_matches_sender\":null,\"email_id\":null,\"from\":null,\"received_at\":null," + + "\"sender_trust\":null}"; + assertEquals("timeout", ms.inboxes().codes().latest("i1").getStatus()); + assertNull(server.query); + + server.responseBody = + "{\"object\":\"list\",\"has_more\":false,\"data\":[{\"id\":\"r2\",\"thread_id\":\"t2\"," + + "\"from\":\"eve@z.dev\",\"subject\":\"Hi\",\"received_at\":\"2026-10-12T10:00:00.000Z\"," + + "\"quarantine_reason\":\"injection\",\"sender_trust\":\"unauthenticated\"," + + "\"injection_score\":97,\"phishing_score\":12,\"virus_signature\":null}]}"; + ListResponse held = + ms.inboxes().quarantine().list("i1", ListOptions.builder().limit(5).build()); + assertEquals("GET", server.method); + assertEquals("/inboxes/i1/quarantine", server.path); + assertEquals("limit=5", server.query); + assertEquals("injection", held.getData().get(0).getQuarantineReason()); + assertEquals(97, held.getData().get(0).getInjectionScore()); + + server.responseBody = + "{\"object\":\"inbox_release\",\"inbox_id\":\"i1\",\"email_id\":\"r2\"," + + "\"released_at\":\"2026-10-12T10:05:00.000Z\",\"trusted_sender\":\"eve@z.dev\"}"; + QuarantineRelease released = ms.inboxes().quarantine().release("i1", "r2", true); + assertEquals("POST", server.method); + assertEquals("/inboxes/i1/quarantine/r2/release", server.path); + assertEquals(json("{\"trust_sender\":true}"), body()); + assertEquals("eve@z.dev", released.getTrustedSender()); + + ms.inboxes().quarantine().release("i1", "r2"); + assertEquals(json("{}"), body()); + } + + @Test + void inboxLabels() throws Exception { + server.responseBody = + "{\"object\":\"list\",\"data\":[{\"id\":\"l1\",\"name\":\"Billing\",\"color\":\"#FF0000\"," + + "\"created_at\":\"2026-10-12T10:00:00.000Z\"}]}"; + ListResponse labels = ms.inboxes().labels().list("i1"); + assertEquals("GET", server.method); + assertEquals("/inboxes/i1/labels", server.path); + assertEquals("#FF0000", labels.getData().get(0).getColor()); + assertFalse(labels.isHasMore()); + + server.status = 201; + server.responseBody = + "{\"object\":\"inbox_label\",\"id\":\"l1\",\"name\":\"Billing\",\"color\":\"#FF0000\"," + + "\"created_at\":\"2026-10-12T10:00:00.000Z\"}"; + InboxLabel created = + ms.inboxes().labels().create("i1", InboxLabelOptions.builder().name("Billing").color("#ff0000").build()); + assertEquals("POST", server.method); + assertEquals("/inboxes/i1/labels", server.path); + assertEquals(json("{\"name\":\"Billing\",\"color\":\"#ff0000\"}"), body()); + assertEquals("inbox_label", created.getObject()); + + server.status = 200; + ms.inboxes().labels().update("i1", "l1", InboxLabelOptions.builder().color("#00FF00").build()); + assertEquals("PATCH", server.method); + assertEquals("/inboxes/i1/labels/l1", server.path); + assertEquals(json("{\"color\":\"#00FF00\"}"), body()); + + server.responseBody = "{\"object\":\"inbox_label\",\"id\":\"l1\",\"deleted\":true}"; + ms.inboxes().labels().remove("i1", "l1"); + assertEquals("DELETE", server.method); + assertEquals("/inboxes/i1/labels/l1", server.path); + } + + static final String THREAD_ITEM_JSON = + "{\"id\":\"t1\",\"subject\":null,\"from\":\"Ada \",\"to\":[\"bot@x.dev\"],\"cc\":[]," + + "\"bcc\":[],\"labels\":[{\"id\":\"l1\",\"name\":\"Billing\",\"color\":\"#FF0000\"}]," + + "\"message_count\":2,\"has_attachment\":false,\"has_draft\":true,\"read\":false," + + "\"received_at\":\"2026-10-12T10:00:00.000Z\",\"folder\":\"a_folder_from_a_newer_server\""; + + @Test + void inboxThreads() throws Exception { + server.responseBody = "{\"object\":\"list\",\"has_more\":false,\"data\":[" + THREAD_ITEM_JSON + "}]}"; + ListResponse list = + ms.inboxes() + .threads() + .list( + "i1", + ListThreadsOptions.builder() + .folders("inbox", "archive") + .labels("l1", "l2") + .read(false) + .limit(10) + .after("t0") + .includeQuarantined(true) + .build()); + assertEquals("GET", server.method); + assertEquals("/inboxes/i1/threads", server.path); + assertEquals( + "folders=inbox%2Carchive&labels=l1%2Cl2&read=false&limit=10&after=t0&include_quarantined=true", + server.query); + InboxThread t = list.getData().get(0); + assertEquals("a_folder_from_a_newer_server", t.getFolder()); + assertEquals("Billing", t.getLabels().get(0).getName()); + assertEquals(2, t.getMessageCount()); + assertTrue(t.getHasDraft()); + assertNull(t.getSubject()); + + ms.inboxes().threads().list("i1"); + assertNull(server.query); + + server.responseBody = + "{\"object\":\"list\",\"has_more\":false,\"data\":[" + THREAD_ITEM_JSON + + ",\"matched_email_id\":\"r1\",\"highlights\":{\"subject\":[\"Your **code**\"]}}]}"; + list = + ms.inboxes() + .threads() + .search( + "i1", + SearchThreadsOptions.builder() + .query("code -spam") + .from("ada", "y.dev") + .to("bot") + .cc("c") + .bcc("b") + .hasAttachment(false) + .startDate("2026-10-01") + .endDate("2026-10-12") + .folders("sent") + .labels("l1") + .read(true) + .limit(5) + .before("t9") + .includeQuarantined(true) + .build()); + assertEquals("/inboxes/i1/threads/search", server.path); + assertEquals( + "folders=sent&labels=l1&read=true&limit=5&before=t9&include_quarantined=true&query=code+-spam" + + "&from=ada%2Cy.dev&to=bot&cc=c&bcc=b&has_attachment=false&start_date=2026-10-01" + + "&end_date=2026-10-12", + server.query); + assertEquals("r1", list.getData().get(0).getMatchedEmailId()); + assertEquals("Your **code**", list.getData().get(0).getHighlights().get("subject").get(0)); + + server.responseBody = + "{\"object\":\"inbox_thread\",\"id\":\"t1\",\"subject\":\"Hi\",\"folder\":\"inbox\"," + + "\"labels\":[],\"read\":true,\"quarantined\":0}"; + InboxThread summary = ms.inboxes().threads().get("i1", "t1"); + assertEquals("GET", server.method); + assertEquals("/inboxes/i1/threads/t1", server.path); + assertEquals("inbox_thread", summary.getObject()); + assertEquals(0, summary.getQuarantined()); + + ms.inboxes() + .threads() + .update("i1", "t1", UpdateThreadOptions.builder().read(true).folder("archive").labelId("l1").build()); + assertEquals("PATCH", server.method); + assertEquals("/inboxes/i1/threads/t1", server.path); + assertEquals(json("{\"read\":true,\"folder\":\"archive\",\"label_id\":\"l1\"}"), body()); + + server.responseBody = "{\"object\":\"inbox_thread\",\"id\":\"t1\",\"deleted\":false}"; + assertFalse(ms.inboxes().threads().remove("i1", "t1").getDeleted()); + assertEquals("DELETE", server.method); + assertEquals("/inboxes/i1/threads/t1", server.path); + } + + static final String SENT_JSON = + "{\"id\":\"e9\",\"email_id\":\"e9\",\"direction\":\"outbound\",\"from\":\"Acme \"," + + "\"to\":[\"ada@y.dev\"],\"cc\":[],\"bcc\":[],\"reply_to\":[],\"subject\":\"Re: Hi\",\"message_id\":null," + + "\"html\":null,\"text\":\"Thanks\",\"attachments\":[{\"id\":\"0\",\"filename\":\"a.pdf\",\"size\":42}]," + + "\"read\":true,\"received_at\":\"2026-10-12T10:00:00.000Z\",\"status\":\"queued\"," + + "\"thread_id\":\"t1\",\"inbox_id\":\"i1\"}"; + + @Test + void inboxThreadEmails() throws Exception { + server.responseBody = + "{\"object\":\"list\",\"has_more\":false,\"data\":[{\"id\":\"r1\",\"direction\":\"inbound\"," + + "\"from\":\"Ada \",\"to\":[\"bot@x.dev\"],\"cc\":[],\"bcc\":[],\"reply_to\":[]," + + "\"subject\":\"Hi\",\"message_id\":\"\",\"html\":null,\"text\":\"Hi\"," + + "\"attachments\":[],\"read\":false,\"received_at\":\"2026-10-12T10:00:00.000Z\"," + + "\"extracted_text\":\"Hi\",\"screening\":{\"verdict\":\"suspicious\",\"sender_trust\":\"unauthenticated\"}," + + "\"spam_verdict\":\"gray\",\"virus_verdict\":null}]}"; + ListResponse list = + ms.inboxes().threads().emails().list("i1", "t1", ListOptions.builder().after("r0").build()); + assertEquals("GET", server.method); + assertEquals("/inboxes/i1/threads/t1/emails", server.path); + assertEquals("after=r0", server.query); + ThreadEmail inbound = list.getData().get(0); + assertEquals("inbound", inbound.getDirection()); + assertEquals("suspicious", inbound.getScreening().getVerdict()); + assertEquals("gray", inbound.getSpamVerdict()); + + server.responseBody = SENT_JSON; + ThreadEmail sentEmail = ms.inboxes().threads().emails().get("i1", "t1", "e9"); + assertEquals("/inboxes/i1/threads/t1/emails/e9", server.path); + assertEquals("0", sentEmail.getAttachments().get(0).getId()); + assertEquals("queued", sentEmail.getStatus()); + + SendResult reply = + ms.inboxes() + .threads() + .emails() + .reply( + "i1", "t1", "r1", + ReplyOptions.builder().text("Thanks").cc("cc@x.dev").replyAll(true).requiresApproval(true).build(), + RequestOptions.idempotencyKey("k-1")); + assertEquals("POST", server.method); + assertEquals("/inboxes/i1/threads/t1/emails/r1/reply", server.path); + assertEquals("k-1", server.header("Idempotency-Key")); + assertEquals( + json("{\"text\":\"Thanks\",\"cc\":[\"cc@x.dev\"],\"reply_all\":true,\"requires_approval\":true}"), + body()); + assertFalse(reply.isDraft()); + assertEquals("e9", reply.getSent().getEmailId()); + assertEquals("i1", reply.getSent().getInboxId()); + + server.status = 202; + server.responseBody = DRAFT_JSON; + SendResult forward = + ms.inboxes() + .threads() + .emails() + .forward("i1", "t1", "r1", ForwardOptions.builder().to("ops@x.dev", "cto@x.dev").build()); + assertEquals("/inboxes/i1/threads/t1/emails/r1/forward", server.path); + assertEquals(json("{\"to\":[\"ops@x.dev\",\"cto@x.dev\"]}"), body()); + assertTrue(forward.isDraft()); + assertEquals("d1", forward.getDraft().getId()); + } + + @Test + void inboxDrafts() throws Exception { + server.responseBody = + "{\"object\":\"list\",\"has_more\":false,\"data\":[{\"id\":\"d1\",\"type\":\"standalone\",\"to\":null," + + "\"cc\":[],\"bcc\":[],\"subject\":null,\"snippet\":\"Hello\",\"thread_id\":null," + + "\"reply_to_email_id\":null,\"updated_at\":\"2026-10-12T10:00:00.000Z\",\"status\":\"draft\"," + + "\"approval_reasons\":[]}]}"; + ListResponse list = ms.inboxes().drafts().list("i1", ListOptions.builder().limit(3).build()); + assertEquals("GET", server.method); + assertEquals("/inboxes/i1/drafts", server.path); + assertEquals("limit=3", server.query); + assertEquals("Hello", list.getData().get(0).getSnippet()); + assertNull(list.getData().get(0).getTo()); + + server.status = 201; + server.responseBody = DRAFT_JSON; + InboxDraft created = + ms.inboxes() + .drafts() + .create( + "i1", + CreateDraftOptions.builder() + .to("ada@y.dev") + .cc("cc@x.dev") + .bcc("bcc@x.dev") + .subject("Re: Hi") + .html("

Thanks

") + .text("Thanks") + .threadId("t1") + .replyToEmailId("r1") + .build()); + assertEquals("POST", server.method); + assertEquals("/inboxes/i1/drafts", server.path); + assertEquals( + json( + "{\"to\":[\"ada@y.dev\"],\"cc\":[\"cc@x.dev\"],\"bcc\":[\"bcc@x.dev\"],\"subject\":\"Re: Hi\"," + + "\"html\":\"

Thanks

\",\"text\":\"Thanks\",\"thread_id\":\"t1\",\"reply_to_email_id\":\"r1\"}"), + body()); + assertEquals("reply", created.getType()); + assertEquals("2026-10-19T10:00:00.000Z", created.getExpiresAt()); + + server.status = 200; + ms.inboxes().drafts().get("i1", "d1"); + assertEquals("GET", server.method); + assertEquals("/inboxes/i1/drafts/d1", server.path); + + ms.inboxes() + .drafts() + .update( + "i1", "d1", + UpdateDraftOptions.builder() + .to(null) + .cc(Collections.emptyList()) + .bcc(Arrays.asList("b@x.dev")) + .subject(null) + .text("New") + .html(null) + .build()); + assertEquals("PATCH", server.method); + assertEquals("/inboxes/i1/drafts/d1", server.path); + assertEquals( + json("{\"to\":null,\"cc\":[],\"bcc\":[\"b@x.dev\"],\"subject\":null,\"text\":\"New\",\"html\":null}"), + body()); + + server.responseBody = "{\"object\":\"inbox_draft\",\"id\":\"d1\",\"thread_id\":\"t1\",\"email_id\":\"e9\"}"; + SendResult sent = + ms.inboxes().drafts().send("i1", "d1", RequestOptions.idempotencyKey("send-1")); + assertEquals("POST", server.method); + assertEquals("/inboxes/i1/drafts/d1/send", server.path); + assertEquals("", server.body); + assertNull(server.header("Content-Type")); + assertEquals("send-1", server.header("Idempotency-Key")); + assertFalse(sent.isDraft()); + assertEquals("e9", sent.getSent().getEmailId()); + + server.status = 202; + server.responseBody = DRAFT_JSON; + SendResult held = ms.inboxes().drafts().send("i1", "d1"); + assertTrue(held.isDraft()); + assertEquals("pending_approval", held.getDraft().getStatus()); + + server.status = 200; + server.responseBody = DRAFT_JSON.replace("pending_approval", "rejected"); + assertEquals("rejected", ms.inboxes().drafts().reject("i1", "d1").getStatus()); + assertEquals("POST", server.method); + assertEquals("/inboxes/i1/drafts/d1/reject", server.path); + + server.responseBody = "{\"object\":\"inbox_draft\",\"id\":\"d1\",\"deleted\":true}"; + ms.inboxes().drafts().remove("i1", "d1"); + assertEquals("DELETE", server.method); + assertEquals("/inboxes/i1/drafts/d1", server.path); + } + + @Test + void approvalRequiredSurfacesAsTypedError() throws Exception { + server.status = 403; + server.responseBody = + "{\"statusCode\":403,\"name\":\"approval_required\",\"message\":\"A person must approve this draft\"}"; + MillionSendException e = + assertThrows(MillionSendException.class, () -> ms.inboxes().drafts().send("i1", "d1")); + assertEquals("approval_required", e.getName()); + assertEquals(403, e.getStatusCode()); + } + + @Test + void inboxFieldsOnDomainsKeysAndWebhooks() throws Exception { + server.responseBody = DOMAIN_JSON.replace("\"records\"", "\"receiving_catch_all\":true,\"records\""); + assertTrue(ms.domains().get("d1").getReceivingCatchAll()); + ms.domains().update(UpdateDomainOptions.builder().id("d1").receivingCatchAll(false).build()); + assertEquals(json("{\"receiving_catch_all\":false}"), body()); + + server.responseBody = "{\"id\":\"k1\",\"token\":\"ms_secret\"}"; + ms.apiKeys().create(CreateApiKeyOptions.builder().name("agent").permission("full_access").inboxId("i1").build()); + assertEquals(json("{\"name\":\"agent\",\"permission\":\"full_access\",\"inbox_id\":\"i1\"}"), body()); + server.responseBody = + "{\"object\":\"list\",\"has_more\":false,\"data\":[{\"id\":\"k1\",\"name\":\"agent\"," + + "\"created_at\":\"2026-01-01T00:00:00.000Z\",\"last_used_at\":null,\"inbox_id\":\"i1\"}]}"; + assertEquals("i1", ms.apiKeys().list().getData().get(0).getInboxId()); + + server.responseBody = "{\"object\":\"webhook\",\"id\":\"w1\",\"signing_secret\":\"whsec_abc\"}"; + ms.webhooks() + .create( + CreateWebhookOptions.builder() + .endpoint("https://x.dev/hook") + .events(WebhookEvent.EMAIL_RECEIVED, WebhookEvent.INBOX_EMAIL_QUARANTINED) + .inboxIds(Collections.singletonList("i1")) + .build()); + assertEquals( + json( + "{\"endpoint\":\"https://x.dev/hook\",\"events\":[\"email.received\",\"inbox.email.quarantined\"]," + + "\"inbox_ids\":[\"i1\"]}"), + body()); + ms.webhooks().update("w1", UpdateWebhookOptions.builder().inboxIds(null).build()); + assertEquals(json("{\"inbox_ids\":null}"), body()); + + server.responseBody = + "{\"object\":\"webhook\",\"id\":\"w1\",\"endpoint\":\"https://x.dev/hook\"," + + "\"created_at\":\"2026-01-01T00:00:00.000Z\",\"status\":\"enabled\"," + + "\"events\":[\"inbox.thread.created\"],\"inbox_ids\":[\"i1\",\"i2\"]}"; + Webhook w = ms.webhooks().get("w1"); + assertEquals(Arrays.asList("i1", "i2"), w.getInboxIds()); + } + + @Test + void webhookEventsCoverReceivingAndInboxes() throws Exception { + String[] wire = { + "email.received", "inbox.created", "inbox.updated", "inbox.deleted", "inbox.expired", + "inbox.thread.created", "inbox.thread.folder.updated", "inbox.thread.labels.updated", + "inbox.email.received", "inbox.email.sent", "inbox.email.quarantined", + "inbox.draft.created", "inbox.draft.updated", "inbox.draft.sent", "inbox.draft.deleted" + }; + for (String value : wire) { + WebhookEvent e = WebhookEvent.fromValue(value); + assertEquals(value, e.getValue()); + } + assertNull(WebhookEvent.fromValue("inbox.thread.assigned")); + } } From e3dc01f015b780f18a9a6762b61015dab81134f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Mon, 12 Oct 2026 15:58:26 -0300 Subject: [PATCH 2/5] Refuse empty and dot ids in request paths URL normalization resolves an empty, "." or ".." path segment away, so an id such as ".." turned DELETE /inboxes/{id}/labels/.. into DELETE /inboxes/{id}/. The HTTP client now checks every path once, and such a call throws IllegalArgumentException without sending anything. HeldEmail gains getQuarantineReason(), as the long-poll sends it; getReason() is pending or quarantined. Co-Authored-By: Claude Opus 5.5 --- src/main/java/com/millionsend/core/HttpClient.java | 8 ++++++++ src/main/java/com/millionsend/model/HeldEmail.java | 11 ++++++++++- src/test/java/com/millionsend/ClientTest.java | 14 ++++++++++++++ src/test/java/com/millionsend/ParityTest.java | 7 ++++--- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/millionsend/core/HttpClient.java b/src/main/java/com/millionsend/core/HttpClient.java index 4901a3d..23f46c9 100644 --- a/src/main/java/com/millionsend/core/HttpClient.java +++ b/src/main/java/com/millionsend/core/HttpClient.java @@ -134,6 +134,14 @@ private HttpResponse execute( RequestOptions options, Duration timeout) throws MillionSendException { + // URL normalization resolves an empty, "." or ".." segment away, so an id such as + // ".." would send the request to another resource (DELETE /inboxes/{id}/labels/.. + // deletes the inbox). + for (String segment : path.substring(1).split("/", -1)) { + if (segment.isEmpty() || segment.equals(".") || segment.equals("..")) { + throw new IllegalArgumentException("An id in the request path is empty, \".\" or \"..\"."); + } + } HttpRequest.Builder rb = HttpRequest.newBuilder(URI.create(baseUrl + path + queryString(query))) diff --git a/src/main/java/com/millionsend/model/HeldEmail.java b/src/main/java/com/millionsend/model/HeldEmail.java index 9eefbe7..dc3d2eb 100644 --- a/src/main/java/com/millionsend/model/HeldEmail.java +++ b/src/main/java/com/millionsend/model/HeldEmail.java @@ -1,11 +1,16 @@ package com.millionsend.model; -/** A matching email a long-poll found held (quarantined or being screened): its id only. */ +/** + * A matching email a long-poll found held: its id and why only. The reason is {@code pending} + * (still being screened) or {@code quarantined}, and the quarantine reason says why (null while + * pending). The inbox id is null for a catch-all copy held for a virus. + */ public final class HeldEmail { private String id; private String inboxId; private String reason; + private String quarantineReason; private String createdAt; public String getId() { @@ -20,6 +25,10 @@ public String getReason() { return reason; } + public String getQuarantineReason() { + return quarantineReason; + } + public String getCreatedAt() { return createdAt; } diff --git a/src/test/java/com/millionsend/ClientTest.java b/src/test/java/com/millionsend/ClientTest.java index 79eb50f..57837c2 100644 --- a/src/test/java/com/millionsend/ClientTest.java +++ b/src/test/java/com/millionsend/ClientTest.java @@ -167,6 +167,20 @@ void transportFailureHasNullStatusCode() throws Exception { assertEquals("application_error", e.getName()); } + @Test + void neverSendsAPathAnEmptyOrDotIdWouldRedirect() throws Exception { + try (MockServer server = new MockServer()) { + MillionSend ms = server.client(); + assertThrows(IllegalArgumentException.class, () -> ms.inboxes().labels().remove("i1", "..")); + assertThrows(IllegalArgumentException.class, () -> ms.inboxes().threads().remove("i1", ".")); + assertThrows(IllegalArgumentException.class, () -> ms.emails().get("")); + assertNull(server.method); + server.responseBody = "{\"object\":\"inbox_label\",\"id\":\"...\",\"deleted\":true}"; + ms.inboxes().labels().remove("i1", "..."); + assertEquals("/inboxes/i1/labels/...", server.rawPath); + } + } + @Test void stripsTrailingSlashFromBaseUrl() throws Exception { try (MockServer server = new MockServer()) { diff --git a/src/test/java/com/millionsend/ParityTest.java b/src/test/java/com/millionsend/ParityTest.java index 92fe8b6..8edecaa 100644 --- a/src/test/java/com/millionsend/ParityTest.java +++ b/src/test/java/com/millionsend/ParityTest.java @@ -895,8 +895,8 @@ void usage() throws Exception { void receivingListCarriesEveryFilterAndHeldIds() throws Exception { server.responseBody = "{\"object\":\"list\",\"has_more\":false,\"data\":[" + RECEIVED_JSON + "]," - + "\"held\":[{\"id\":\"r2\",\"inbox_id\":\"i1\",\"reason\":\"not_allowlisted\"," - + "\"created_at\":\"2026-10-12T10:00:01.000Z\"}]}"; + + "\"held\":[{\"id\":\"r2\",\"inbox_id\":\"i1\",\"reason\":\"quarantined\"," + + "\"quarantine_reason\":\"not_allowlisted\",\"created_at\":\"2026-10-12T10:00:01.000Z\"}]}"; ReceivedEmailListResponse res = ms.receiving() .list( @@ -919,7 +919,8 @@ void receivingListCarriesEveryFilterAndHeldIds() throws Exception { + "&from=y.dev&subject_contains=code&unread=true&include_quarantined=true", server.query); assertEquals("r2", res.getHeld().get(0).getId()); - assertEquals("not_allowlisted", res.getHeld().get(0).getReason()); + assertEquals("quarantined", res.getHeld().get(0).getReason()); + assertEquals("not_allowlisted", res.getHeld().get(0).getQuarantineReason()); ReceivedEmail e = res.getData().get(0); assertEquals("clean", e.getScreening().getVerdict()); assertEquals("external", e.getScreening().getSenderTrust()); From 2af2eca8515532b225129b9d3e732d9608bf5402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Mon, 12 Oct 2026 16:56:39 -0300 Subject: [PATCH 3/5] Match the inboxes API: quarantine filters on received mail and thread emails - receiving().list: ListReceivedEmailsOptions.quarantined(true) sends quarantined=true (held mail only), which the API takes next to include_quarantined. - threads().emails().list/get: overloads with includeQuarantined send include_quarantined=true, so an owner view can read the held messages of a thread that threads().list(...includeQuarantined) shows. - README: email.received still reaches endpoints subscribed to all events; only the inbox.* events are opt-in. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++++--- .../model/ListReceivedEmailsOptions.java | 12 ++++++- .../com/millionsend/services/Inboxes.java | 34 ++++++++++++++++--- src/test/java/com/millionsend/ParityTest.java | 13 +++++-- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ae51705..b4b589b 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,8 @@ ms.receiving().attachments().get(id, attachmentId); ``` Held mail is invisible to inbox-scoped keys: a detail read answers 403 -`email_quarantined`. `includeQuarantined(true)` lists it for full-access keys. +`email_quarantined`. For full-access keys, `includeQuarantined(true)` lists it +too and `quarantined(true)` lists only held mail. ### Inboxes @@ -437,10 +438,11 @@ holding either verifies. `deliverability.*`, `quota.*`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`, `contact.topic_opt_out` and `suppression.*` events are MillionSend extensions. -`email.received` and the `inbox.*` events (`INBOX_CREATED` … `INBOX_DRAFT_DELETED`) -are delivered only to endpoints that name them; an endpoint subscribed to all -events skips them. `inboxIds(...)` narrows them to some inboxes -(`UpdateWebhookOptions.builder().inboxIds(null)` removes the filter). +The `inbox.*` events (`INBOX_CREATED` … `INBOX_DRAFT_DELETED`) are delivered +only to endpoints that name them; an endpoint subscribed to all events skips +them (it still gets `email.received`). `inboxIds(...)` narrows them and +`email.received` to some inboxes (`UpdateWebhookOptions.builder().inboxIds(null)` +removes the filter). `inbox.expired` and `inbox.email.quarantined` are MillionSend extensions. ### API keys diff --git a/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java b/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java index ea6caae..1205fe9 100644 --- a/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java +++ b/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java @@ -21,6 +21,7 @@ public final class ListReceivedEmailsOptions { private String subjectContains; private Boolean unread; private boolean includeQuarantined; + private boolean quarantined; private ListReceivedEmailsOptions() {} @@ -48,6 +49,9 @@ public Map toQuery() { if (includeQuarantined) { q.put("include_quarantined", "true"); } + if (quarantined) { + q.put("quarantined", "true"); + } return q; } @@ -76,7 +80,7 @@ public Builder waitSeconds(int seconds) { return this; } - /** ISO 8601: only mail received after this instant. */ + /** ISO 8601 with an offset, like {@code Instant.toString()}: only mail received after it. */ public Builder since(String since) { o.since = since; return this; @@ -116,6 +120,12 @@ public Builder includeQuarantined(boolean includeQuarantined) { return this; } + /** List held mail only; needs a full access key that is not scoped to an inbox. */ + public Builder quarantined(boolean quarantined) { + o.quarantined = quarantined; + return this; + } + public ListReceivedEmailsOptions build() { return o; } diff --git a/src/main/java/com/millionsend/services/Inboxes.java b/src/main/java/com/millionsend/services/Inboxes.java index 403424e..5364088 100644 --- a/src/main/java/com/millionsend/services/Inboxes.java +++ b/src/main/java/com/millionsend/services/Inboxes.java @@ -33,6 +33,8 @@ import com.millionsend.model.UpdateThreadOptions; import com.millionsend.model.VerificationCode; import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; /** * The {@code inboxes} resource: addresses that receive mail for an agent or for @@ -338,18 +340,42 @@ public ListResponse list(String inboxId, String threadId) /** GET /inboxes/{inboxId}/threads/{threadId}/emails with pagination. */ public ListResponse list(String inboxId, String threadId, ListOptions options) throws MillionSendException { + return list(inboxId, threadId, options, false); + } + + /** + * GET /inboxes/{inboxId}/threads/{threadId}/emails. {@code includeQuarantined} also lists + * held messages (owner views: a full access key not scoped to an inbox). + */ + public ListResponse list( + String inboxId, String threadId, ListOptions options, boolean includeQuarantined) + throws MillionSendException { + Map query = options == null ? new LinkedHashMap<>() : options.toQuery(); + if (includeQuarantined) { + query.put("include_quarantined", "true"); + } return http.request( - "GET", thread(inboxId, threadId) + "/emails", null, - options == null ? null : options.toQuery(), null, + "GET", thread(inboxId, threadId) + "/emails", null, query, null, new TypeReference>() {}); } /** GET /inboxes/{inboxId}/threads/{threadId}/emails/{emailId} */ public ThreadEmail get(String inboxId, String threadId, String emailId) throws MillionSendException { + return get(inboxId, threadId, emailId, false); + } + + /** + * GET /inboxes/{inboxId}/threads/{threadId}/emails/{emailId}. {@code includeQuarantined} + * also reads a held message (owner views: a full access key not scoped to an inbox). + */ + public ThreadEmail get( + String inboxId, String threadId, String emailId, boolean includeQuarantined) + throws MillionSendException { return http.request( - "GET", email(inboxId, threadId, emailId), null, null, null, - new TypeReference() {}); + "GET", email(inboxId, threadId, emailId), null, + includeQuarantined ? Collections.singletonMap("include_quarantined", "true") : null, + null, new TypeReference() {}); } /** POST …/emails/{emailId}/reply */ diff --git a/src/test/java/com/millionsend/ParityTest.java b/src/test/java/com/millionsend/ParityTest.java index 8edecaa..3715a47 100644 --- a/src/test/java/com/millionsend/ParityTest.java +++ b/src/test/java/com/millionsend/ParityTest.java @@ -911,12 +911,13 @@ void receivingListCarriesEveryFilterAndHeldIds() throws Exception { .subjectContains("code") .unread(true) .includeQuarantined(true) + .quarantined(true) .build()); assertEquals("GET", server.method); assertEquals("/emails/receiving", server.path); assertEquals( "limit=5&after=r0&wait=20&since=2026-10-12T10%3A00%3A00Z&inbox_id=i1&to=bot%40x.dev" - + "&from=y.dev&subject_contains=code&unread=true&include_quarantined=true", + + "&from=y.dev&subject_contains=code&unread=true&include_quarantined=true&quarantined=true", server.query); assertEquals("r2", res.getHeld().get(0).getId()); assertEquals("quarantined", res.getHeld().get(0).getReason()); @@ -929,7 +930,8 @@ void receivingListCarriesEveryFilterAndHeldIds() throws Exception { res = ms.receiving().list(); assertNull(server.query); assertNull(res.getHeld()); - ms.receiving().list(ListReceivedEmailsOptions.builder().includeQuarantined(false).build()); + ms.receiving() + .list(ListReceivedEmailsOptions.builder().includeQuarantined(false).quarantined(false).build()); assertNull(server.query); } @@ -1392,12 +1394,19 @@ void inboxThreadEmails() throws Exception { assertEquals("inbound", inbound.getDirection()); assertEquals("suspicious", inbound.getScreening().getVerdict()); assertEquals("gray", inbound.getSpamVerdict()); + ms.inboxes().threads().emails().list("i1", "t1", ListOptions.builder().after("r0").build(), true); + assertEquals("after=r0&include_quarantined=true", server.query); + ms.inboxes().threads().emails().list("i1", "t1"); + assertNull(server.query); server.responseBody = SENT_JSON; ThreadEmail sentEmail = ms.inboxes().threads().emails().get("i1", "t1", "e9"); assertEquals("/inboxes/i1/threads/t1/emails/e9", server.path); + assertNull(server.query); assertEquals("0", sentEmail.getAttachments().get(0).getId()); assertEquals("queued", sentEmail.getStatus()); + ms.inboxes().threads().emails().get("i1", "t1", "e9", true); + assertEquals("include_quarantined=true", server.query); SendResult reply = ms.inboxes() From 483c12ae99284a4dbde3efc14f267a6dcfb11f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Fri, 16 Oct 2026 10:07:57 -0300 Subject: [PATCH 4/5] Errors carry getRetryAfter() from the retry-after header MillionSendException.getRetryAfter() is the seconds the response's retry-after header asks to wait, or null without one (and on client-side and transport failures). MillionSend sends the header on its 429s (rate limits, the shared domain's daily cap, the name-review budget) and on 503 name_review_unavailable. The three-argument constructor stays. Tests pin the header parsing and the 404 of an unknown route: JSON not_found from current servers, application_error from older ones that answer in plain text. Co-Authored-By: Claude Opus 5.5 --- .../com/millionsend/MillionSendException.java | 17 ++++++- .../java/com/millionsend/core/HttpClient.java | 15 ++++-- src/test/java/com/millionsend/ClientTest.java | 49 +++++++++++++++++++ src/test/java/com/millionsend/MockServer.java | 5 ++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/millionsend/MillionSendException.java b/src/main/java/com/millionsend/MillionSendException.java index 5360923..b3a6724 100644 --- a/src/main/java/com/millionsend/MillionSendException.java +++ b/src/main/java/com/millionsend/MillionSendException.java @@ -6,7 +6,8 @@ * {@code { statusCode, name, message }}: {@link #getName()} is the stable * snake_case discriminant (e.g. {@code validation_error}, {@code not_found}), * and {@link #getStatusCode()} is {@code null} when the request never reached - * the API (a client-side or transport failure). + * the API (a client-side or transport failure). {@link #getRetryAfter()} carries + * the response's {@code retry-after} header. */ public class MillionSendException extends Exception { @@ -15,11 +16,17 @@ public class MillionSendException extends Exception { private final String name; private final Integer statusCode; + private final Integer retryAfter; public MillionSendException(String name, String message, Integer statusCode) { + this(name, message, statusCode, null); + } + + public MillionSendException(String name, String message, Integer statusCode, Integer retryAfter) { super(message); this.name = name; this.statusCode = statusCode; + this.retryAfter = retryAfter; } /** Stable snake_case error code you can switch on. */ @@ -31,4 +38,12 @@ public String getName() { public Integer getStatusCode() { return statusCode; } + + /** + * Seconds the response's {@code retry-after} header asks to wait before trying again; + * {@code null} when it sent none, and on client-side or transport failures. + */ + public Integer getRetryAfter() { + return retryAfter; + } } diff --git a/src/main/java/com/millionsend/core/HttpClient.java b/src/main/java/com/millionsend/core/HttpClient.java index 23f46c9..fbe0a31 100644 --- a/src/main/java/com/millionsend/core/HttpClient.java +++ b/src/main/java/com/millionsend/core/HttpClient.java @@ -182,11 +182,18 @@ private HttpResponse execute( int status = response.statusCode(); if (status < 200 || status >= 300) { - throw toException(response.body(), status); + throw toException(response.body(), status, retryAfterSeconds(response)); } return response; } + /** {@code retry-after} in whole seconds, the only form the API sends; {@code null} otherwise. */ + private static Integer retryAfterSeconds(HttpResponse response) { + String value = response.headers().firstValue("retry-after").orElse("").trim(); + // At most nine digits, so the value always fits an int. + return value.matches("\\d{1,9}") ? Integer.valueOf(value) : null; + } + private T read(String text, TypeReference type) throws MillionSendException { try { return mapper.readValue(text, type); @@ -204,7 +211,7 @@ private String serialize(Object body) throws MillionSendException { } /** Coerce an error body into the canonical shape; fall back to a generic error. */ - private MillionSendException toException(String text, int status) { + private MillionSendException toException(String text, int status, Integer retryAfter) { if (text != null && !text.isEmpty()) { try { JsonNode n = mapper.readTree(text); @@ -218,14 +225,14 @@ private MillionSendException toException(String text, int status) { n.hasNonNull("statusCode") && n.get("statusCode").isNumber() ? n.get("statusCode").asInt() : status; - return new MillionSendException(name, message, code); + return new MillionSendException(name, message, code, retryAfter); } } catch (IOException ignored) { // Not JSON — fall through to the generic error. } } return new MillionSendException( - "application_error", "Request failed with status " + status, status); + "application_error", "Request failed with status " + status, status, retryAfter); } private static String queryString(Map query) { diff --git a/src/test/java/com/millionsend/ClientTest.java b/src/test/java/com/millionsend/ClientTest.java index 57837c2..6d7196a 100644 --- a/src/test/java/com/millionsend/ClientTest.java +++ b/src/test/java/com/millionsend/ClientTest.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.millionsend.model.CreateEmailOptions; import com.millionsend.model.CreateEmailResponse; +import com.millionsend.model.CreateInboxOptions; import org.junit.jupiter.api.Test; /** Transport-level wiring: headers, casing, idempotency, error parsing. */ @@ -154,6 +155,53 @@ void fallsBackToGenericErrorWhenBodyIsNotCanonical() throws Exception { } } + @Test + void readsRetryAfterInWholeSecondsElseNull() throws Exception { + try (MockServer server = new MockServer()) { + CreateInboxOptions options = CreateInboxOptions.builder().build(); + server.status = 503; + server.responseBody = + "{\"statusCode\":503,\"name\":\"name_review_unavailable\"," + + "\"message\":\"try again shortly\"}"; + server.responseHeaders.put("Retry-After", "300"); + MillionSendException e = + assertThrows(MillionSendException.class, () -> server.client().inboxes().create(options)); + assertEquals("name_review_unavailable", e.getName()); + assertEquals(503, e.getStatusCode()); + assertEquals(300, e.getRetryAfter()); + + server.responseHeaders.put("Retry-After", "Fri, 16 Oct 2026 12:00:00 GMT"); + e = assertThrows(MillionSendException.class, () -> server.client().inboxes().create(options)); + assertNull(e.getRetryAfter()); + + server.responseHeaders.clear(); + e = assertThrows(MillionSendException.class, () -> server.client().inboxes().create(options)); + assertNull(e.getRetryAfter()); + } + } + + @Test + void unknownRouteIsNotFoundAsJsonAndApplicationErrorAsPlainText() throws Exception { + try (MockServer server = new MockServer()) { + server.status = 404; + server.responseHeaders.put("Content-Type", "text/plain"); + server.responseBody = "404 Not Found"; + MillionSendException e = + assertThrows(MillionSendException.class, () -> server.client().inboxes().list()); + assertEquals(404, e.getStatusCode()); + assertEquals("application_error", e.getName()); + assertEquals("Request failed with status 404", e.getMessage()); + + server.responseHeaders.clear(); + server.responseBody = + "{\"statusCode\":404,\"name\":\"not_found\"," + + "\"message\":\"The requested endpoint does not exist.\"}"; + e = assertThrows(MillionSendException.class, () -> server.client().inboxes().list()); + assertEquals(404, e.getStatusCode()); + assertEquals("not_found", e.getName()); + } + } + @Test void transportFailureHasNullStatusCode() throws Exception { String closedBaseUrl; @@ -164,6 +212,7 @@ void transportFailureHasNullStatusCode() throws Exception { MillionSendException e = assertThrows(MillionSendException.class, () -> ms.emails().send(email())); assertNull(e.getStatusCode()); + assertNull(e.getRetryAfter()); assertEquals("application_error", e.getName()); } diff --git a/src/test/java/com/millionsend/MockServer.java b/src/test/java/com/millionsend/MockServer.java index 215830e..cf3156a 100644 --- a/src/test/java/com/millionsend/MockServer.java +++ b/src/test/java/com/millionsend/MockServer.java @@ -7,6 +7,8 @@ import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; /** * A throwaway loopback HTTP server that records the last request it received and @@ -20,6 +22,8 @@ final class MockServer implements AutoCloseable { int status = 200; String responseBody = "{\"id\":\"id_1\"}"; + /** Extra response headers; a {@code Content-Type} here replaces the JSON one. */ + final Map responseHeaders = new HashMap<>(); volatile String method; volatile String path; @@ -43,6 +47,7 @@ final class MockServer implements AutoCloseable { body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); byte[] out = responseBody.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json"); + responseHeaders.forEach(exchange.getResponseHeaders()::set); exchange.sendResponseHeaders(status, out.length); try (OutputStream os = exchange.getResponseBody()) { os.write(out); From 986d1dbaca85f74463b8919561181b5b4a02e067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Fri, 16 Oct 2026 10:08:26 -0300 Subject: [PATCH 5/5] Match MillionSend v0.14.0: the shared domain row, username and domainId - inboxes().create takes username (the part before the @) and domainId (a domains().list() row instead of emailAddress); codes().latest takes since. - Domain has isShared() (false when a server before v0.14.0 sends no shared), and the shared row's getSendingMode() and getSendingModeReason(), plain strings. - Contacts batch errors carry the statusCode and name strict mode would answer; null on email batches and older servers. - Docs: inboxes never expire by default, the shared domain's sending rules and refusals, name review (503 name_review_unavailable, 429), newest-first lists with incremental sync and dead cursors, retries, recipients that can never receive mail, the 998-character subject, the read-only shared row, the API key domainId rule, the optional quarantined count on thread items, and the MillionSend version each feature needs. - The shared inbox fixture no longer expires, and the e2e contact uses the SES mailbox simulator: the API refuses example.com. Co-Authored-By: Claude Opus 5.5 --- README.md | 179 ++++++++++++++++-- .../com/millionsend/model/BatchError.java | 23 +++ .../model/CreateApiKeyOptions.java | 5 +- .../millionsend/model/CreateInboxOptions.java | 38 +++- .../java/com/millionsend/model/Domain.java | 38 +++- .../java/com/millionsend/model/Inbox.java | 10 +- .../com/millionsend/model/InboxDraft.java | 5 +- .../millionsend/model/InboxSafetyOptions.java | 15 +- .../com/millionsend/model/InboxThread.java | 6 +- .../millionsend/model/LatestCodeOptions.java | 16 +- .../com/millionsend/model/ListOptions.java | 5 + .../model/ListReceivedEmailsOptions.java | 3 +- .../com/millionsend/model/ListResponse.java | 1 + .../millionsend/model/ListThreadsOptions.java | 5 +- .../millionsend/model/UpdateInboxOptions.java | 5 +- .../com/millionsend/services/ApiKeys.java | 5 +- .../java/com/millionsend/services/Batch.java | 6 +- .../com/millionsend/services/Broadcasts.java | 6 +- .../com/millionsend/services/Domains.java | 11 +- .../java/com/millionsend/services/Emails.java | 12 +- .../com/millionsend/services/Inboxes.java | 16 +- src/test/java/com/millionsend/E2ETest.java | 3 +- src/test/java/com/millionsend/ParityTest.java | 93 ++++++++- 23 files changed, 465 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 5b9b85b..5f5f378 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,10 @@ new MillionSend(apiKey, baseUrl, true); // also accept a non-loopback http:// b Every call throws a single checked `MillionSendException` on a non-2xx response. It carries the API's error shape: -- `getName()` — a stable snake_case code you can switch on (`validation_error`, `not_found`, `restricted_api_key`, `sending_paused`, `all_recipients_suppressed`, …) -- `getMessage()` — a human-readable description +- `getName()` — a stable snake_case code you can switch on (`validation_error`, `not_found`, `forbidden`, `restricted_api_key`, `rate_limit_exceeded`, `sending_paused`, `all_recipients_suppressed`, …) +- `getMessage()` — a human-readable description; it may change, so don't parse it - `getStatusCode()` — the HTTP status, or `null` when the request never reached the API (a client-side or transport failure) +- `getRetryAfter()` — the response's `retry-after` header in seconds, or `null` when it has none ```java try { @@ -88,6 +89,33 @@ try { ("All recipients are suppressed") when every `to` recipient is on the suppression list or opted out of the send's `topicId`. +Inboxes add `inbox_send_policy` (403, a send the inbox's rules or the shared domain +refuse; one they hold is a 202 draft), `inbox_send_cap_reached` (429, the inbox's daily +cap, or the team's on the shared domain), `approval_required` (403), `email_quarantined` +(403), `email_blocked` (410, a download of an email that carries malware), `too_many_waits` +(429, too many concurrent long-polls) and `name_review_unavailable` (503, a name on the +shared domain can't be reviewed right now). + +An unknown route answers `404 not_found`. Servers before v0.13.0 answer it in plain +text, which surfaces as `application_error` with status 404. + +### Retries + +The SDK never retries on its own. Worth retrying after `getRetryAfter()` seconds: +`503 name_review_unavailable`, `429 too_many_waits`, and `429 rate_limit_exceeded` when +`getRetryAfter()` is 60 or less (the per-minute limits). Not worth retrying in the same +process: `429 inbox_send_cap_reached` and the daily name-review budget (also +`rate_limit_exceeded`, with a `getRetryAfter()` that runs to the next UTC midnight), and +any 403, 404, 409 or 422. + +Retry a send with the same idempotency key (`emails().send`, `batch().send`, replies, +forwards, `drafts().send`): a batch the API already accepted gets its stored response +back, even if a recipient was refused or suppressed since. A held draft (`isDraft()`) is +not a failure; never send it again. `inboxes().create` takes no idempotency key: after a +429 or 503 nothing was created, but after a timeout a retry can create a second inbox. +With a chosen `username`, a 409 on the retry usually means the first attempt succeeded: +read it with `inboxes().get(address)`. + ## Resources Every request field you set reaches the wire — nothing is dropped client-side. @@ -113,6 +141,32 @@ ms.emails().send(options, opts); ms.emails().send(options, "order-123"); // the plain idempotency-key overload still works ``` +### Pagination + +List methods take `limit`, `after` and `before` (keyset cursors) and return a +`ListResponse` (`getData()`, `isHasMore()`). Lists run newest first (MillionSend v0.13.0 +and later): `after` takes the last id of a page and pages toward older items, `before` +takes the first id and pages toward newer ones, and `isHasMore()` refers to that +direction. Threads sort by last activity, drafts by last update, a thread's emails run +oldest first, and labels and topics are not paginated. + +```java +// Incremental sync: page toward newer items, starting from the newest id you stored +String before = newestStoredId; +while (true) { + ListResponse page = ms.emails().list(ListOptions.builder().before(before).limit(100).build()); + if (page.getData().isEmpty()) break; + save(page.getData()); // newest first within the page + if (!page.isHasMore()) break; + before = page.getData().get(0).getId(); +} +``` + +A cursor whose item was deleted answers `422 validation_error` (`404 not_found` on the +`inboxes()` lists): start again without a cursor and stop at the ids you already have. +Servers before v0.13.0 list oldest first; walking every page with `after` returns every +item on both. + ### Emails ```java @@ -134,6 +188,13 @@ accept one or more addresses. Also available: `.tag(new Tag(name, value))`, `.template(...)` (forwarded; the server answers 422 — send `html`/`text`). `resend-java`'s `addTag`/`addAttachment`/`addHeader` spellings work too. +`subject` takes up to 998 characters. A `to`, `cc` or `bcc` address on a domain that +can never receive mail (`example.com`, `.test`, `.invalid`, `.localhost`, a TLD that +doesn't exist, …) answers `422 validation_error`; to test sending without emailing +anyone, use `success@simulator.amazonses.com`. An inbox's address on the shared domain +can be the `from` too, under the rules in +[Sending from the shared domain](#sending-from-the-shared-domain). + ### Batch ```java @@ -144,6 +205,9 @@ res.getErrors(); // permissive mode: [{ index, message }] for rejected items; n ``` Strict mode (the default) rejects the whole batch on the first invalid item. +Batch items never send from the shared domain: a strict batch fails with +`403 inbox_send_policy` (the message starts with `emails.: `), a permissive one +lists the item in `getErrors()`. ### Contacts @@ -192,7 +256,7 @@ CreateBatchContactsResponse res = ms.contacts().batch().create(items, RequestOptions.builder().batchValidation(BatchValidation.PERMISSIVE).build()); res.getData(); // [{ index, id, status: created|updated|skipped }] res.getCounts(); // created / updated / skipped / failed -res.getErrors(); // permissive mode: rejected items +res.getErrors(); // permissive mode: [{ index, statusCode, name, message }], as strict mode would answer // Bulk lookup (MillionSend extension): up to 1000 contacts by id or email in one request, // in request order; unknown entries are listed, not errors — one request against the rate limit @@ -254,6 +318,9 @@ ms.broadcasts().cancel(id); // scheduled only ms.broadcasts().remove(id); // draft only ``` +`subject` takes up to 998 characters. A broadcast never sends from the shared domain: +`create` with `send(true)` and `send` answer `403 inbox_send_policy`. + ### Segments (MillionSend extension) Dynamic segments are a saved filter over your contacts — a MillionSend @@ -322,7 +389,9 @@ ms.receiving().attachments().get(id, attachmentId); Held mail is invisible to inbox-scoped keys: a detail read answers 403 `email_quarantined`. For full-access keys, `includeQuarantined(true)` lists it -too and `quarantined(true)` lists only held mail. +too and `quarantined(true)` lists only held mail. `unread(...)` needs `inboxId` with a +team key; an inbox-scoped key reads its own inbox. An `inboxId` that doesn't exist +answers `404 not_found` at once, even with `waitSeconds`. ### Inboxes @@ -333,9 +402,12 @@ quarantine and codes are MillionSend extensions. ```java Inbox inbox = ms.inboxes().create(CreateInboxOptions.builder() - .name("Signup bot").expiresIn("24h") // no emailAddress: a random address on the shared domain + .name("Signup bot") + .username("signup-bot") // no emailAddress: signup-bot@.; omit it for a random one + .expiresIn("24h") // "1h" to "365d"; left out, the inbox never expires .safety(InboxSafetyOptions.builder().acceptFrom("anyone").build()) .build()); +ms.inboxes().create(CreateInboxOptions.builder().domainId(domainId).username("orders").build()); // a domains().list() id: orders@ that domain ms.inboxes().list(ListInboxesOptions.builder().kind("agent").build()); ms.inboxes().get("bot@acme.dev"); // by id or address ms.inboxes().update(inbox.getId(), UpdateInboxOptions.builder().expiresIn(null).build()); // null: never expires @@ -347,6 +419,9 @@ VerificationCode code = ms.inboxes().codes().latest(inboxId, if ("found".equals(code.getStatus())) { code.getCode(); // or code.getUrl() for a sign-in link } +// The next sign-in: only a code received after the one already used +ms.inboxes().codes().latest(inboxId, + LatestCodeOptions.builder().since(code.getReceivedAt()).waitSeconds(30).build()); // Safety rules (inbox-scoped keys can read them, never change them) ms.inboxes().safety().get(inboxId); @@ -359,6 +434,7 @@ ms.inboxes().quarantine().release(inboxId, emailId, true); // true also allowlis // Threads and their emails ms.inboxes().threads().list(inboxId, ListThreadsOptions.builder().folders("inbox", "archive").read(false).build()); +ms.inboxes().threads().list(inboxId, ListThreadsOptions.builder().includeQuarantined(true).build()); // owner views: getQuarantined() per thread ms.inboxes().threads().search(inboxId, SearchThreadsOptions.builder().query("invoice -draft").from("acme").build()); ms.inboxes().threads().get(inboxId, threadId); ms.inboxes().threads().update(inboxId, threadId, UpdateThreadOptions.builder().read(true).folder("archive").build()); @@ -376,7 +452,7 @@ ms.inboxes().labels().remove(inboxId, label.getId()); // Drafts, including the ones the send rules hold for approval InboxDraft d = ms.inboxes().drafts().create(inboxId, CreateDraftOptions.builder() - .to("ada@example.com").subject("Hello").text("Hi Ada").build()); + .to("ada@acme.dev").subject("Hello").text("Hi Ada").build()); ms.inboxes().drafts().list(inboxId); ms.inboxes().drafts().get(inboxId, d.getId()); ms.inboxes().drafts().update(inboxId, d.getId(), UpdateDraftOptions.builder().subject(null).build()); // null clears @@ -387,10 +463,49 @@ ms.inboxes().drafts().remove(inboxId, d.getId()); Reply, forward and draft send return a `SendResult`: `isDraft()` is true when the inbox's send rules held the message for a person (the API answers 202); an -inbox-scoped key sending a pending draft gets 403 `approval_required`. Folder, -verdict, kind and status values are plain strings, so new server values never -break decoding. Hosted agent settings (`/inboxes/{id}/agent`) are not exposed: -MillionSend runs no agent of its own. +inbox-scoped key sending a pending draft gets 403 `approval_required`. An agent's +reply to a Reply-To at another organization is held that way too, with +`reply_to_redirect` among its approval reasons. Folder, verdict, kind and status +values are plain strings, so new server values never break decoding. Hosted agent +settings (`/inboxes/{id}/agent`) are not exposed: MillionSend runs no agent of its own. + +An inbox never expires unless `expiresIn` says so, and its `name` takes up to 320 +characters. `domainId` takes a `domains().list()` row instead of `emailAddress`: the +shared row works like leaving both out, and one of your own domains needs a `username` +(and receiving on, or `forwarding(true)`). + +On the shared domain, an inbox unused for 90 days (no mail in or out, no API call on +it) is deleted, which fires `inbox.expired`, then `inbox.deleted`. A chosen `username` +or `fromName` there is reviewed first: reserved names (`postmaster`, `admin`, `support`, +`noreply`, …), a `fwd-` prefix and look-alikes of brands answer 422, a username in use +409, and `429 rate_limit_exceeded` means today's review budget is spent. +`503 name_review_unavailable` means try again after `getRetryAfter()` seconds: the +review is down, or the team's new subdomain still waits for its own, and then every +shared or forwarding create gets it. Safety rules differ there too: +`sendPolicy("allowlist")` answers 422, and so does `"open"` unless the shared row's +`sendingMode` is `open`; `ownerAddresses` don't make senders trusted; `forwardTo` takes +team members only. + +#### Sending from the shared domain + +What the team's shared addresses may send is the `getSendingMode()` of the shared row of +`domains().list()`: `open` starts conversations, `reply_only` only answers inside a +thread to its own correspondents, and `receive_only` sends nothing. An inbox whose name +looks like a brand only replies, even under `open`. The same answers come back from +`emails().send`, replies, forwards and drafts: + +| Status | `getName()` | When | +| --- | --- | --- | +| 403 | `inbox_send_policy` | the mode doesn't allow this send (`getSendingModeReason()` says whether the plan or the instance holds it), the inbox's name looks like a brand, or it is a batch or broadcast send | +| 429 | `inbox_send_cap_reached` | the team's daily cap for mail from the shared domain; `getRetryAfter()` runs to the next UTC midnight | +| 422 | `validation_error` | more than 10 distinct recipients across `to`, `cc` and `bcc` (`drafts().create` too); on `emails().send`, a `from` or a field the shared domain doesn't take | + +`emails().send` takes a shared address in `open` mode only: the inbox's exact +`emailAddress` (no +tag), a display name left out or equal to its `fromName`, and no +`replyTo`, `attachments`, `headers` or `scheduledAt`. A key restricted to a domain gets +`403 restricted_api_key`. A person or a team key sending a draft skips the inbox's own +rules, never the shared domain's. Switch on `getName()` and `getStatusCode()`; the +messages may change. ### Domains @@ -410,6 +525,27 @@ ms.domains().remove(id); `Domain.getReceivingCatchAll()`: on, mail to addresses without an inbox lands in `receiving().list()`; off, those addresses are refused at SMTP time. +On an instance with a shared inbox domain, the team's subdomain of it is the last row +of `domains().list()` (`isShared()` is true; MillionSend v0.14.0 and later). It is +verified, has no DNS records, and its id is the `domainId` of the team's shared +inboxes. Its `getSendingMode()` (`open`, `reply_only` or `receive_only`) says what those +inboxes may send, and `getSendingModeReason()` (`plan` or `instance`, `null` under +`open`) why it is held below `open`. It is read-only: `update`, `verify` and `remove` +answer `403 forbidden`, and an API key can't be restricted to it. It comes after every +own domain whatever its `createdAt`, alone on the next page when they exactly fill one. +A team whose new subdomain still waits for its name review has no such row yet. Older +servers send no `shared`, which reads as `false`. + +It sends only from its inboxes' own addresses, so leave it out when picking a domain to +send from: + +```java +Domain sender = ms.domains().list(ListOptions.builder().limit(100).build()).getData().stream() + .filter(d -> "verified".equals(d.getStatus()) && !d.isShared()) + .findFirst() + .orElse(null); +``` + ### Webhooks ```java @@ -443,7 +579,8 @@ only to endpoints that name them; an endpoint subscribed to all events skips them (it still gets `email.received`). `inboxIds(...)` narrows them and `email.received` to some inboxes (`UpdateWebhookOptions.builder().inboxIds(null)` removes the filter). -`inbox.expired` and `inbox.email.quarantined` are MillionSend extensions. +`inbox.expired` and `inbox.email.quarantined` are MillionSend extensions. Inbox events +from sends made in the dashboard carry `data.source: "dashboard"`. ### API keys @@ -458,6 +595,9 @@ ms.apiKeys().remove(id); ms.apiKeys().create(CreateApiKeyOptions.builder().name("support-agent").inboxId(inboxId).build()); ``` +`domainId` takes one of your own domains: the shared row of `domains().list()` answers +`422 validation_error`. + ### Templates Every identifier argument accepts the template id or its alias. @@ -496,6 +636,21 @@ u.getLimits().getEmailsPerDay(); // null when unlimited u.getToday().getEmailsSent(); ``` +## Server versions + +Each feature needs this MillionSend version or later. Older instances answer 404 on +paths they lack and ignore options they don't know, so an inbox created with `username` +or `domainId` on one can get another address (check its `getEmailAddress()`), and before +v0.13.0 `since` is ignored, so the code already used can come back. + +| MillionSend | Features | +| --- | --- | +| v0.10.0 | `inboxes()`; the receiving long-poll, inbox filters, `reply` and `forward` | +| v0.11.0 | 422 for recipients that can never receive mail; a batch idempotency key replayed before validation | +| v0.12.0 | `username`; inboxes that never expire by default; sends from the shared domain under its mode; `name_review_unavailable`; `statusCode` and `name` on contacts batch errors | +| v0.13.0 | newest-first lists; `since` on `codes().latest`; `quarantined` on thread items; inbox names up to 320 characters; `unread` with an inbox-scoped key; a JSON 404 for unknown routes | +| v0.14.0 | the shared row of `domains().list()` (`isShared()`, `getSendingMode()`, `getSendingModeReason()`); `domainId` on `inboxes().create`; `subject` up to 998 characters | + ## Migrating from Resend ```diff @@ -517,7 +672,7 @@ Notes: - **No audiences**: contacts are team-global, so there is no `.audiences()` resource and no `audienceId` anywhere. The server keeps `/audiences/*` only as a compatibility shim; it is not part of this SDK. `.segments()` is MillionSend's dynamic-filter feature, not Resend's audience alias. - **Request options** use the same `RequestOptions` builder shape (`setIdempotencyKey`, `add(header, value)`), plus `batchValidation(...)`. - Errors throw `MillionSendException` (in place of Resend's `ResendException`) with the same `{ statusCode, name, message }` fields. -- **MillionSend extensions** (no Resend counterpart): `segments()`, `deliverability()`, `usage()`, `contacts().batch()`, `contacts().preferencesLink()`, `webhooks().rotate()`, `emails().remove()`, `emails().getInsights()`, `Email.getScore()`, and in `receiving()` and `inboxes()` the long-poll `waitSeconds`, the reply/forward/remove of received mail, inbox `safety()`, `codes()`, `quarantine()`, draft `reject()` and the extension fields. +- **MillionSend extensions** (no Resend counterpart): `segments()`, `deliverability()`, `usage()`, `contacts().batch()`, `contacts().preferencesLink()`, `webhooks().rotate()`, `emails().remove()`, `emails().getInsights()`, `Email.getScore()`, `MillionSendException.getRetryAfter()`, and in `receiving()` and `inboxes()` the long-poll `waitSeconds`, the reply/forward/remove of received mail, inbox `safety()`, `codes()`, `quarantine()`, draft `reject()`, the `username` and `domainId` of `inboxes().create` and the extension fields. ## Build & test diff --git a/src/main/java/com/millionsend/model/BatchError.java b/src/main/java/com/millionsend/model/BatchError.java index 79bb144..bc9fc8f 100644 --- a/src/main/java/com/millionsend/model/BatchError.java +++ b/src/main/java/com/millionsend/model/BatchError.java @@ -1,15 +1,38 @@ package com.millionsend.model; +import com.fasterxml.jackson.annotation.JsonProperty; + /** One rejected item of a permissive-mode batch: its index in the request and the reason. */ public final class BatchError { private int index; + + @JsonProperty("statusCode") + private Integer statusCode; + + private String name; private String message; public int getIndex() { return index; } + /** + * Contacts batch only: the HTTP status strict mode would answer for the item; {@code null} + * on email batches and on servers before v0.12.0. + */ + public Integer getStatusCode() { + return statusCode; + } + + /** + * Contacts batch only: the error name strict mode would answer, like {@code validation_error}; + * {@code null} on email batches and on servers before v0.12.0. + */ + public String getName() { + return name; + } + public String getMessage() { return message; } diff --git a/src/main/java/com/millionsend/model/CreateApiKeyOptions.java b/src/main/java/com/millionsend/model/CreateApiKeyOptions.java index 4b08ad6..67db6f1 100644 --- a/src/main/java/com/millionsend/model/CreateApiKeyOptions.java +++ b/src/main/java/com/millionsend/model/CreateApiKeyOptions.java @@ -31,7 +31,10 @@ public Builder permission(String permission) { return this; } - /** Restrict a {@code sending_access} key to one domain. */ + /** + * Restrict a {@code sending_access} key to one of your own domains; the + * {@link Domain#isShared() shared} row answers 422. + */ public Builder domainId(String domainId) { o.domainId = domainId; return this; diff --git a/src/main/java/com/millionsend/model/CreateInboxOptions.java b/src/main/java/com/millionsend/model/CreateInboxOptions.java index 633ccaf..363b1c9 100644 --- a/src/main/java/com/millionsend/model/CreateInboxOptions.java +++ b/src/main/java/com/millionsend/model/CreateInboxOptions.java @@ -3,13 +3,16 @@ import com.fasterxml.jackson.annotation.JsonInclude; /** - * Options for {@code inboxes().create(...)}. Without {@code emailAddress} the - * inbox gets a random address on the shared domain (when the instance has one). + * Options for {@code inboxes().create(...)}. Without {@code emailAddress} or {@code domainId} + * the inbox gets an address on the instance's shared domain (when it has one): + * {@code @.}, or a random one without {@code username}. */ @JsonInclude(JsonInclude.Include.NON_NULL) public final class CreateInboxOptions { private String emailAddress; + private String domainId; + private String username; private String name; private String fromName; private Boolean forwarding; @@ -26,18 +29,43 @@ public static Builder builder() { public static final class Builder { private final CreateInboxOptions o = new CreateInboxOptions(); - /** An address on a verified team domain with receiving on. */ + /** + * An address on a verified team domain with receiving on; not combinable with + * {@code domainId} or {@code username}. + */ public Builder emailAddress(String emailAddress) { o.emailAddress = emailAddress; return this; } + /** + * Instead of {@code emailAddress}, the id of a {@code domains().list()} row: the + * {@link Domain#isShared() shared} row works like leaving both out, and one of your own + * domains needs {@link #username(String)} and receiving on (or {@code forwarding}). + */ + public Builder domainId(String domainId) { + o.domainId = domainId; + return this; + } + + /** + * The part before the @: 1-64 of {@code a-z 0-9 . _ -}, starting and ending with a letter + * or digit (the server trims and lowercases it). Optional on the shared domain, where + * reserved names, a {@code fwd-} prefix and brand look-alikes answer 422 and a name in + * use 409; required with the {@code domainId} of one of your own domains. + */ + public Builder username(String username) { + o.username = username; + return this; + } + + /** 1-320 characters. */ public Builder name(String name) { o.name = name; return this; } - /** The display name used when the inbox sends. */ + /** The display name used when the inbox sends; reviewed on the shared domain. */ public Builder fromName(String fromName) { o.fromName = fromName; return this; @@ -55,7 +83,7 @@ public Builder kind(String kind) { return this; } - /** Like {@code 1h} or {@code 30d}. Shared-domain inboxes default to 24 hours, at most 30 days. */ + /** From {@code 1h} to {@code 365d}; left out, the inbox never expires. */ public Builder expiresIn(String expiresIn) { o.expiresIn = expiresIn; return this; diff --git a/src/main/java/com/millionsend/model/Domain.java b/src/main/java/com/millionsend/model/Domain.java index 12b58a6..262bf17 100644 --- a/src/main/java/com/millionsend/model/Domain.java +++ b/src/main/java/com/millionsend/model/Domain.java @@ -6,6 +6,10 @@ /** * A sending domain. {@code records} (the DNS records to publish) is only * populated when a single domain is fetched, created or verified. + * + *

On an instance with a shared inbox domain, {@code domains().list()} ends with the + * team's subdomain of it: {@link #isShared()} is true, it is verified, has no DNS records + * and is read-only, and its id is the {@code domainId} of the team's shared inboxes. */ public final class Domain { @@ -20,6 +24,9 @@ public final class Domain { private String trackingSubdomain; private Map capabilities; private Boolean receivingCatchAll; + private boolean shared; + private String sendingMode; + private String sendingModeReason; private List records; public String getObject() { @@ -59,7 +66,10 @@ public String getTrackingSubdomain() { return trackingSubdomain; } - /** {@code sending} / {@code receiving} → {@code enabled} / {@code disabled}. */ + /** + * {@code sending} / {@code receiving} → {@code enabled} / {@code disabled}. The shared row's + * sending is {@code disabled} under {@code receive_only}. + */ public Map getCapabilities() { return capabilities; } @@ -72,6 +82,32 @@ public Boolean getReceivingCatchAll() { return receivingCatchAll; } + /** + * True for the team's read-only subdomain of the shared inbox domain, which sends only + * from its inboxes' own addresses: leave it out when picking a domain to send from. + * False on the team's own domains, also when a server before v0.14.0 sends no {@code shared}. + */ + public boolean isShared() { + return shared; + } + + /** + * Shared row only, else {@code null}: what its inboxes may send. {@code open} starts + * conversations, {@code reply_only} replies inside threads, {@code receive_only} sends + * nothing. A plain string, so new values never break decoding. + */ + public String getSendingMode() { + return sendingMode; + } + + /** + * Shared row only: what holds {@link #getSendingMode()} below {@code open}, {@code plan} + * or {@code instance}; {@code null} under {@code open} and on the team's own domains. + */ + public String getSendingModeReason() { + return sendingModeReason; + } + public List getRecords() { return records; } diff --git a/src/main/java/com/millionsend/model/Inbox.java b/src/main/java/com/millionsend/model/Inbox.java index f00f0bd..69e9bed 100644 --- a/src/main/java/com/millionsend/model/Inbox.java +++ b/src/main/java/com/millionsend/model/Inbox.java @@ -41,7 +41,10 @@ public String getEmailAddress() { return emailAddress; } - /** The custom domain's id, or for a shared-domain inbox the team's shared subdomain id. */ + /** + * The custom domain's id, or for a shared-domain inbox the id of the team's + * {@link Domain#isShared() shared} row of {@code domains().list()}. + */ public String getDomainId() { return domainId; } @@ -83,7 +86,10 @@ public Boolean getShared() { return shared; } - /** When the inbox stops receiving and is deleted; null for never. */ + /** + * When the inbox stops receiving and is deleted; null for never. A shared-domain inbox + * unused for 90 days is deleted either way. + */ public String getExpiresAt() { return expiresAt; } diff --git a/src/main/java/com/millionsend/model/InboxDraft.java b/src/main/java/com/millionsend/model/InboxDraft.java index 5760e79..8cfb5da 100644 --- a/src/main/java/com/millionsend/model/InboxDraft.java +++ b/src/main/java/com/millionsend/model/InboxDraft.java @@ -99,7 +99,10 @@ public String getStatus() { return status; } - /** Why the send rules held it, e.g. {@code send_policy}, {@code ramp}, {@code private_code}. */ + /** + * Why the send rules held it, e.g. {@code send_policy}, {@code ramp}, {@code private_code}, + * or {@code reply_to_redirect} for an agent's reply to a Reply-To at another organization. + */ public List getApprovalReasons() { return approvalReasons; } diff --git a/src/main/java/com/millionsend/model/InboxSafetyOptions.java b/src/main/java/com/millionsend/model/InboxSafetyOptions.java index 092304e..6beba62 100644 --- a/src/main/java/com/millionsend/model/InboxSafetyOptions.java +++ b/src/main/java/com/millionsend/model/InboxSafetyOptions.java @@ -47,6 +47,10 @@ public Builder allowlist(List allowlist) { return this; } + /** + * Owners besides the team members' login addresses; on the shared domain they don't make + * senders trusted. + */ public Builder ownerAddresses(List ownerAddresses) { o.changes.put("owner_addresses", ownerAddresses); return this; @@ -62,7 +66,11 @@ public Builder extractCodes(boolean extractCodes) { return this; } - /** {@code reply_only}, {@code allowlist} or {@code open}. */ + /** + * {@code reply_only}, {@code allowlist} or {@code open}. On the shared domain + * {@code allowlist} answers 422, and so does {@code open} unless the shared row's + * {@code sendingMode} is {@code open}. + */ public Builder sendPolicy(String sendPolicy) { o.changes.put("send_policy", sendPolicy); return this; @@ -84,7 +92,10 @@ public Builder sendCap(Integer sendCap) { return this; } - /** Team members' login addresses or owner addresses that get a copy of every message. */ + /** + * Team members' login addresses or owner addresses that get a copy of every message; on + * the shared domain, team members only. + */ public Builder forwardTo(List forwardTo) { o.changes.put("forward_to", forwardTo); return this; diff --git a/src/main/java/com/millionsend/model/InboxThread.java b/src/main/java/com/millionsend/model/InboxThread.java index a4a972c..0b0bba0 100644 --- a/src/main/java/com/millionsend/model/InboxThread.java +++ b/src/main/java/com/millionsend/model/InboxThread.java @@ -56,7 +56,11 @@ public Boolean getRead() { return read; } - /** Held messages in the thread; owner views only, else 0. */ + /** + * Messages held in quarantine and not released; mail still being screened isn't counted. + * The summary always carries it (0 outside owner views); list and search items only with + * {@code includeQuarantined} in owner views, else {@code null}. + */ public Integer getQuarantined() { return quarantined; } diff --git a/src/main/java/com/millionsend/model/LatestCodeOptions.java b/src/main/java/com/millionsend/model/LatestCodeOptions.java index 3eda853..450d245 100644 --- a/src/main/java/com/millionsend/model/LatestCodeOptions.java +++ b/src/main/java/com/millionsend/model/LatestCodeOptions.java @@ -9,6 +9,7 @@ public final class LatestCodeOptions { private String from; private Integer wait; private Integer maxAge; + private String since; private LatestCodeOptions() {} @@ -26,6 +27,7 @@ public Map toQuery() { Query.put(q, "from", from); Query.put(q, "wait", wait); Query.put(q, "max_age", maxAge); + Query.put(q, "since", since); return q; } @@ -44,12 +46,24 @@ public Builder waitSeconds(int seconds) { return this; } - /** Ignore codes older than this many seconds (60-86400; the API defaults to 600). */ + /** + * Ignore codes delivered (or released from quarantine) more than this many seconds ago + * (60-86400; the API defaults to 600). + */ public Builder maxAge(int seconds) { o.maxAge = seconds; return this; } + /** + * ISO 8601 with an offset, like {@code Instant.toString()}: only mail received after it. + * Pass the previous code's {@code receivedAt} so a second sign-in waits for its own code. + */ + public Builder since(String since) { + o.since = since; + return this; + } + public LatestCodeOptions build() { return o; } diff --git a/src/main/java/com/millionsend/model/ListOptions.java b/src/main/java/com/millionsend/model/ListOptions.java index ff2c3ca..c35febe 100644 --- a/src/main/java/com/millionsend/model/ListOptions.java +++ b/src/main/java/com/millionsend/model/ListOptions.java @@ -6,6 +6,9 @@ /** * Keyset pagination for list endpoints. {@code after} and {@code before} are * mutually exclusive UUID cursors; {@code limit} is 1–100 (server default 20). + * Lists run newest first (MillionSend v0.13.0 and later), so {@code after} pages + * toward older items and {@code before} toward newer ones; a thread's emails run + * oldest first, so there {@code after} pages toward newer ones. */ public final class ListOptions { @@ -40,11 +43,13 @@ public Builder limit(int limit) { return this; } + /** The last id of a page: the page after it. */ public Builder after(String after) { o.after = after; return this; } + /** The first id of a page: the page before it, still in list order. */ public Builder before(String before) { o.before = before; return this; diff --git a/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java b/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java index 1205fe9..05d765d 100644 --- a/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java +++ b/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java @@ -86,6 +86,7 @@ public Builder since(String since) { return this; } + /** An inbox that doesn't exist answers 404 {@code not_found} at once, even with a wait. */ public Builder inboxId(String inboxId) { o.inboxId = inboxId; return this; @@ -108,7 +109,7 @@ public Builder subjectContains(String subjectContains) { return this; } - /** Needs {@link #inboxId(String)}. */ + /** Needs {@link #inboxId(String)}, except with an inbox-scoped key (its own inbox). */ public Builder unread(boolean unread) { o.unread = unread; return this; diff --git a/src/main/java/com/millionsend/model/ListResponse.java b/src/main/java/com/millionsend/model/ListResponse.java index 6a3b82b..e59b2db 100644 --- a/src/main/java/com/millionsend/model/ListResponse.java +++ b/src/main/java/com/millionsend/model/ListResponse.java @@ -17,6 +17,7 @@ public List getData() { return data; } + /** Whether more items follow in the direction of the cursor ({@code after} or {@code before}). */ public boolean isHasMore() { return hasMore; } diff --git a/src/main/java/com/millionsend/model/ListThreadsOptions.java b/src/main/java/com/millionsend/model/ListThreadsOptions.java index f60a8b6..ca755a6 100644 --- a/src/main/java/com/millionsend/model/ListThreadsOptions.java +++ b/src/main/java/com/millionsend/model/ListThreadsOptions.java @@ -77,7 +77,10 @@ public Builder before(String before) { return this; } - /** Also count held mail (owner views: a full access key not scoped to an inbox). */ + /** + * Also count held mail, so each item carries {@code quarantined} (owner views: a full + * access key not scoped to an inbox; an inbox-scoped key gets 403). + */ public Builder includeQuarantined(boolean includeQuarantined) { o.includeQuarantined = includeQuarantined; return this; diff --git a/src/main/java/com/millionsend/model/UpdateInboxOptions.java b/src/main/java/com/millionsend/model/UpdateInboxOptions.java index 4f0675c..20c9393 100644 --- a/src/main/java/com/millionsend/model/UpdateInboxOptions.java +++ b/src/main/java/com/millionsend/model/UpdateInboxOptions.java @@ -27,18 +27,19 @@ public Map getChanges() { public static final class Builder { private final UpdateInboxOptions o = new UpdateInboxOptions(); + /** 1-320 characters. */ public Builder name(String name) { o.changes.put("name", name); return this; } - /** {@code ""} removes the display name. */ + /** {@code ""} removes the display name; a new one is reviewed on the shared domain. */ public Builder fromName(String fromName) { o.changes.put("from_name", fromName); return this; } - /** Like {@code 1h} or {@code 30d}, counted from now; {@code null} for never. */ + /** From {@code 1h} to {@code 365d}, counted from now; {@code null} for never. */ public Builder expiresIn(String expiresIn) { o.changes.put("expires_in", expiresIn); return this; diff --git a/src/main/java/com/millionsend/services/ApiKeys.java b/src/main/java/com/millionsend/services/ApiKeys.java index 9a95a60..a5d61e8 100644 --- a/src/main/java/com/millionsend/services/ApiKeys.java +++ b/src/main/java/com/millionsend/services/ApiKeys.java @@ -21,7 +21,10 @@ public ApiKeys(HttpClient http) { this.http = http; } - /** POST /api-keys — the returned {@code token} is shown only once. */ + /** + * POST /api-keys — the returned {@code token} is shown only once. A {@code domainId} must be + * one of your own domains: the shared row answers 422. + */ public CreateApiKeyResponse create(CreateApiKeyOptions options) throws MillionSendException { return http.request( "POST", "/api-keys", options, null, null, new TypeReference() {}); diff --git a/src/main/java/com/millionsend/services/Batch.java b/src/main/java/com/millionsend/services/Batch.java index 0701b46..76156a6 100644 --- a/src/main/java/com/millionsend/services/Batch.java +++ b/src/main/java/com/millionsend/services/Batch.java @@ -8,7 +8,11 @@ import com.millionsend.model.CreateEmailOptions; import java.util.List; -/** The {@code batch} resource: send up to 100 emails in one call. */ +/** + * The {@code batch} resource: send up to 100 emails in one call. Items never send from the + * shared domain: such an item gets 403 {@code inbox_send_policy}, which fails the whole batch + * in strict mode and lands in {@code errors} in permissive mode. + */ public final class Batch { private final HttpClient http; diff --git a/src/main/java/com/millionsend/services/Broadcasts.java b/src/main/java/com/millionsend/services/Broadcasts.java index 905c13e..15c572f 100644 --- a/src/main/java/com/millionsend/services/Broadcasts.java +++ b/src/main/java/com/millionsend/services/Broadcasts.java @@ -14,7 +14,11 @@ import com.millionsend.model.SendBroadcastOptions; import com.millionsend.model.UpdateBroadcastOptions; -/** The {@code broadcasts} resource: create, get, list, update, remove, send, cancel. */ +/** + * The {@code broadcasts} resource: create, get, list, update, remove, send, cancel. A broadcast + * never sends from the shared domain: {@code create} with {@code send(true)} and {@code send} + * answer 403 {@code inbox_send_policy}. + */ public final class Broadcasts { private final HttpClient http; diff --git a/src/main/java/com/millionsend/services/Domains.java b/src/main/java/com/millionsend/services/Domains.java index 96b0db0..3746d01 100644 --- a/src/main/java/com/millionsend/services/Domains.java +++ b/src/main/java/com/millionsend/services/Domains.java @@ -12,7 +12,11 @@ import com.millionsend.model.ListResponse; import com.millionsend.model.UpdateDomainOptions; -/** The {@code domains} resource: create, get, list, verify, update, remove. */ +/** + * The {@code domains} resource: create, get, list, verify, update, remove. The team's + * subdomain of the shared inbox domain is listed too, read-only ({@link Domain#isShared()}): + * update, verify and remove answer 403 {@code forbidden} on it. + */ public final class Domains { private final HttpClient http; @@ -37,7 +41,10 @@ public ListResponse list() throws MillionSendException { return list(null); } - /** GET /domains with pagination. */ + /** + * GET /domains with pagination — the team's own domains newest first, then the shared row, + * alone on the next page when they exactly fill one. + */ public ListResponse list(ListOptions options) throws MillionSendException { return http.request( "GET", "/domains", null, options == null ? null : options.toQuery(), null, diff --git a/src/main/java/com/millionsend/services/Emails.java b/src/main/java/com/millionsend/services/Emails.java index f267c9f..f017763 100644 --- a/src/main/java/com/millionsend/services/Emails.java +++ b/src/main/java/com/millionsend/services/Emails.java @@ -24,7 +24,17 @@ public Emails(HttpClient http) { this.http = http; } - /** POST /emails */ + /** + * POST /emails. {@code subject} takes up to 998 characters, and a {@code to}, {@code cc} or + * {@code bcc} address on a domain that can never receive mail ({@code example.com}, + * {@code .test}, a TLD that doesn't exist, …) answers 422 {@code validation_error}. + * + *

{@code from} may be an inbox's address on the shared domain while the shared row's + * {@code sendingMode} is {@code open}: the inbox's exact address (no +tag), a display name + * left out or equal to its {@code fromName}, no {@code replyTo}, {@code attachments}, + * {@code headers} or {@code scheduledAt}, and at most 10 distinct recipients. A key + * restricted to a domain gets 403 {@code restricted_api_key} for such a send. + */ public CreateEmailResponse send(CreateEmailOptions options) throws MillionSendException { return send(options, (RequestOptions) null); } diff --git a/src/main/java/com/millionsend/services/Inboxes.java b/src/main/java/com/millionsend/services/Inboxes.java index 5364088..a78e9c4 100644 --- a/src/main/java/com/millionsend/services/Inboxes.java +++ b/src/main/java/com/millionsend/services/Inboxes.java @@ -89,7 +89,16 @@ public Drafts drafts() { return drafts; } - /** POST /inboxes — 409 when the address is taken, 403 at the plan's inbox limit. */ + /** + * POST /inboxes — 409 when the address is taken, 403 at the plan's inbox limit. On the + * shared domain a chosen {@code username} or {@code fromName} is reviewed first: 503 + * {@code name_review_unavailable} means try again after + * {@link MillionSendException#getRetryAfter()} seconds (the review is down, or the team's + * new subdomain still awaits its own, which holds every shared or forwarding create), and + * 429 {@code rate_limit_exceeded} that today's review budget is spent. There is no + * idempotency key: after a 503 or 429 nothing was created, but after a timeout a retry can + * create a second inbox. + */ public Inbox create(CreateInboxOptions options) throws MillionSendException { return http.request("POST", "/inboxes", options, null, null, new TypeReference() {}); } @@ -111,7 +120,10 @@ public Inbox get(String idOrEmail) throws MillionSendException { return http.request("GET", inbox(idOrEmail), null, null, null, new TypeReference() {}); } - /** PATCH /inboxes/{id} — returns the updated inbox. */ + /** + * PATCH /inboxes/{id} — returns the updated inbox. A new {@code fromName} on the shared + * domain is reviewed as on {@link #create}, with the same 503 and 429. + */ public Inbox update(String id, UpdateInboxOptions options) throws MillionSendException { return http.request("PATCH", inbox(id), options, null, null, new TypeReference() {}); } diff --git a/src/test/java/com/millionsend/E2ETest.java b/src/test/java/com/millionsend/E2ETest.java index 497d2c0..cd96b5e 100644 --- a/src/test/java/com/millionsend/E2ETest.java +++ b/src/test/java/com/millionsend/E2ETest.java @@ -27,7 +27,8 @@ class E2ETest { @Test void contactLifecycle() throws Exception { - String email = "sdk-e2e-" + System.currentTimeMillis() + "@example.com"; + // The API refuses contacts at domains that can never receive mail, example.com included. + String email = "success+sdk-e2e-" + System.currentTimeMillis() + "@simulator.amazonses.com"; Id created = ms.contacts().create(CreateContactOptions.builder().email(email).firstName("Ada").build()); diff --git a/src/test/java/com/millionsend/ParityTest.java b/src/test/java/com/millionsend/ParityTest.java index 3715a47..687fa17 100644 --- a/src/test/java/com/millionsend/ParityTest.java +++ b/src/test/java/com/millionsend/ParityTest.java @@ -209,6 +209,8 @@ void batchSendsValidationHeaderAndTypesErrors() throws Exception { assertEquals("e1", res.getData().get(0).getId()); assertEquals(1, res.getErrors().get(0).getIndex()); assertEquals("emails.1: bad", res.getErrors().get(0).getMessage()); + assertNull(res.getErrors().get(0).getStatusCode()); + assertNull(res.getErrors().get(0).getName()); server.responseBody = "{\"data\":[{\"id\":\"e1\"}]}"; res = ms.batch().send(Collections.singletonList( @@ -276,7 +278,8 @@ void contactsBatch() throws Exception { server.responseBody = "{\"data\":[{\"object\":\"contact\",\"index\":0,\"id\":\"c1\",\"status\":\"created\"}]," + "\"counts\":{\"created\":1,\"updated\":0,\"skipped\":0,\"failed\":1}," - + "\"errors\":[{\"index\":1,\"message\":\"invalid email\"}]}"; + + "\"errors\":[{\"index\":1,\"statusCode\":422,\"name\":\"validation_error\"," + + "\"message\":\"invalid email\"}]}"; CreateBatchContactsResponse res = ms.contacts() .batch() @@ -296,6 +299,8 @@ void contactsBatch() throws Exception { assertEquals(1, res.getCounts().getCreated()); assertEquals(1, res.getCounts().getFailed()); assertEquals("invalid email", res.getErrors().get(0).getMessage()); + assertEquals(422, res.getErrors().get(0).getStatusCode()); + assertEquals("validation_error", res.getErrors().get(0).getName()); ms.contacts().batch().create(Collections.singletonList(CreateContactOptions.builder().email("a@x.dev").build())); assertNull(server.query); @@ -623,6 +628,61 @@ void domains() throws Exception { assertEquals("/domains/d1", server.path); } + static final String SHARED_DOMAIN_JSON = + "{\"object\":\"domain\",\"id\":\"sd1\",\"name\":\"acme.agents.dev\",\"status\":\"verified\"," + + "\"created_at\":\"2026-10-01T00:00:00.000Z\",\"region\":\"us-east-1\"," + + "\"open_tracking\":false,\"click_tracking\":false,\"tracking_subdomain\":null," + + "\"capabilities\":{\"sending\":\"enabled\",\"receiving\":\"enabled\"}," + + "\"receiving_catch_all\":false,\"shared\":true,\"sending_mode\":\"reply_only\"," + + "\"sending_mode_reason\":\"plan\",\"records\":[]}"; + + @Test + void sharedDomainRow() throws Exception { + server.responseBody = SHARED_DOMAIN_JSON; + Domain shared = ms.domains().get("sd1"); + assertEquals("domain", shared.getObject()); + assertTrue(shared.isShared()); + assertEquals("verified", shared.getStatus()); + assertEquals("reply_only", shared.getSendingMode()); + assertEquals("plan", shared.getSendingModeReason()); + assertEquals("enabled", shared.getCapabilities().get("receiving")); + assertFalse(shared.getReceivingCatchAll()); + assertNull(shared.getTrackingSubdomain()); + assertTrue(shared.getRecords().isEmpty()); + + // A server before v0.14.0 sends no shared key; own domains never carry a sending mode. + server.responseBody = DOMAIN_JSON; + Domain own = ms.domains().get("d1"); + assertFalse(own.isShared()); + assertNull(own.getSendingMode()); + assertNull(own.getSendingModeReason()); + + server.responseBody = + "{\"object\":\"list\",\"has_more\":false,\"data\":[" + + DOMAIN_JSON.replace("\"records\"", "\"shared\":false,\"records\"") + "," + + SHARED_DOMAIN_JSON + .replace("reply_only", "a_mode_from_a_newer_server") + .replace("\"plan\"", "null") + + "]}"; + ListResponse list = ms.domains().list(); + assertFalse(list.getData().get(0).isShared()); + Domain last = list.getData().get(1); + assertTrue(last.isShared()); + assertEquals("a_mode_from_a_newer_server", last.getSendingMode()); + assertNull(last.getSendingModeReason()); + + server.status = 403; + server.responseBody = + "{\"statusCode\":403,\"name\":\"forbidden\",\"message\":\"acme.agents.dev is your team's " + + "subdomain of the shared domain: it can't be changed or deleted\"}"; + MillionSendException e = + assertThrows( + MillionSendException.class, + () -> ms.domains().update(UpdateDomainOptions.builder().id("sd1").openTracking(true).build())); + assertEquals("forbidden", e.getName()); + assertEquals(403, e.getStatusCode()); + } + @Test void webhooks() throws Exception { server.responseBody = "{\"object\":\"webhook\",\"id\":\"w1\",\"signing_secret\":\"whsec_abc\"}"; @@ -933,6 +993,19 @@ void receivingListCarriesEveryFilterAndHeldIds() throws Exception { ms.receiving() .list(ListReceivedEmailsOptions.builder().includeQuarantined(false).quarantined(false).build()); assertNull(server.query); + + // An inbox-scoped key reads its own inbox's unread mail without naming it. + ms.receiving().list(ListReceivedEmailsOptions.builder().unread(false).build()); + assertEquals("unread=false", server.query); + + server.status = 404; + server.responseBody = "{\"statusCode\":404,\"name\":\"not_found\",\"message\":\"Inbox not found.\"}"; + MillionSendException missing = + assertThrows( + MillionSendException.class, + () -> ms.receiving().list(ListReceivedEmailsOptions.builder().inboxId("i9").waitSeconds(30).build())); + assertEquals("not_found", missing.getName()); + assertEquals(404, missing.getStatusCode()); } @Test @@ -1082,7 +1155,7 @@ void receivingAttachments() throws Exception { "{\"object\":\"inbox\",\"id\":\"i1\",\"name\":\"Signup bot\",\"email_address\":\"bot-7f3k@acme.agents.dev\"," + "\"domain_id\":\"sd1\",\"receiving_address\":null,\"from_name\":null,\"unread\":2,\"drafts\":1," + "\"last_received\":null,\"created_at\":\"2026-10-12T10:00:00.000Z\",\"kind\":\"agent\"," - + "\"shared\":true,\"expires_at\":\"2026-10-13T10:00:00.000Z\",\"quarantined\":3," + + "\"shared\":true,\"expires_at\":null,\"quarantined\":3," + "\"pending_approval\":1}"; @Test @@ -1112,6 +1185,8 @@ void inboxesCrud() throws Exception { assertEquals("i1", created.getId()); assertEquals("agent", created.getKind()); assertTrue(created.getShared()); + assertEquals("sd1", created.getDomainId()); + assertNull(created.getExpiresAt()); assertEquals(3, created.getQuarantined()); assertEquals(1, created.getPendingApproval()); assertEquals(2, created.getUnread()); @@ -1119,6 +1194,12 @@ void inboxesCrud() throws Exception { server.status = 200; ms.inboxes().create(CreateInboxOptions.builder().build()); assertEquals(json("{}"), body()); + ms.inboxes().create(CreateInboxOptions.builder().username("signup-bot").build()); + assertEquals(json("{\"username\":\"signup-bot\"}"), body()); + ms.inboxes().create(CreateInboxOptions.builder().domainId("sd1").build()); + assertEquals(json("{\"domain_id\":\"sd1\"}"), body()); + ms.inboxes().create(CreateInboxOptions.builder().domainId("d1").username("support").build()); + assertEquals(json("{\"domain_id\":\"d1\",\"username\":\"support\"}"), body()); server.responseBody = "{\"object\":\"list\",\"has_more\":true,\"data\":[{\"id\":\"i1\",\"name\":null," @@ -1206,6 +1287,8 @@ void inboxCodesAndQuarantine() throws Exception { assertEquals("GET", server.method); assertEquals("/inboxes/i1/codes/latest", server.path); assertEquals("from=y.dev&wait=10&max_age=600", server.query); + ms.inboxes().codes().latest("i1", LatestCodeOptions.builder().since("2026-10-16T12:00:00.000Z").build()); + assertEquals("since=2026-10-16T12%3A00%3A00.000Z", server.query); assertEquals("found", code.getStatus()); assertEquals("482913", code.getCode()); assertTrue(code.getLinkDomainMatchesSender()); @@ -1310,6 +1393,12 @@ void inboxThreads() throws Exception { assertEquals(2, t.getMessageCount()); assertTrue(t.getHasDraft()); assertNull(t.getSubject()); + assertNull(t.getQuarantined()); + + server.responseBody = + "{\"object\":\"list\",\"has_more\":false,\"data\":[" + THREAD_ITEM_JSON + ",\"quarantined\":2}]}"; + list = ms.inboxes().threads().list("i1", ListThreadsOptions.builder().includeQuarantined(true).build()); + assertEquals(2, list.getData().get(0).getQuarantined()); ms.inboxes().threads().list("i1"); assertNull(server.query);