diff --git a/README.md b/README.md index be84a1f..5f5f378 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+. @@ -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. @@ -96,8 +124,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; @@ -112,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 @@ -133,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 @@ -143,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 @@ -191,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 @@ -253,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 @@ -285,6 +353,160 @@ 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`. For full-access keys, `includeQuarantined(true)` lists it +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 + +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") + .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 +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 +} +// 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); +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().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()); +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@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 +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`. 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 ```java @@ -296,9 +518,34 @@ 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. + +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 @@ -327,6 +574,14 @@ holding either verifies. `deliverability.*`, `quota.*`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`, `contact.topic_opt_out` and `suppression.*` events are MillionSend extensions. +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. Inbox events +from sends made in the dashboard carry `data.source: "dashboard"`. + ### API keys ```java @@ -335,8 +590,14 @@ 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()); ``` +`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. @@ -375,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 @@ -396,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()`. +- **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/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 736354d..ab0248e 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/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 66d9aa5..fbe0a31 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,57 @@ 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 { + // 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))) - .timeout(REQUEST_TIMEOUT) + .timeout(timeout) .header("Authorization", "Bearer " + apiKey) .header("Accept", "application/json") .header("User-Agent", userAgent); @@ -132,12 +181,22 @@ 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, 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, returnType); + return mapper.readValue(text, type); } catch (IOException e) { throw new MillionSendException("application_error", e.getMessage(), null); } @@ -152,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); @@ -166,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/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/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 4e1cfc3..67db6f1 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() {} @@ -30,12 +31,24 @@ 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; } + /** + * 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..363b1c9 --- /dev/null +++ b/src/main/java/com/millionsend/model/CreateInboxOptions.java @@ -0,0 +1,102 @@ +package com.millionsend.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * 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; + 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; 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; reviewed on the shared domain. */ + 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; + } + + /** From {@code 1h} to {@code 365d}; left out, the inbox never expires. */ + 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..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 { @@ -19,6 +23,10 @@ public final class Domain { private boolean clickTracking; private String trackingSubdomain; private Map capabilities; + private Boolean receivingCatchAll; + private boolean shared; + private String sendingMode; + private String sendingModeReason; private List records; public String getObject() { @@ -58,11 +66,48 @@ 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; } + /** + * 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; + } + + /** + * 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/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..dc3d2eb --- /dev/null +++ b/src/main/java/com/millionsend/model/HeldEmail.java @@ -0,0 +1,35 @@ +package com.millionsend.model; + +/** + * 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() { + return id; + } + + public String getInboxId() { + return inboxId; + } + + public String getReason() { + return reason; + } + + public String getQuarantineReason() { + return quarantineReason; + } + + 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..69e9bed --- /dev/null +++ b/src/main/java/com/millionsend/model/Inbox.java @@ -0,0 +1,106 @@ +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 id of the team's + * {@link Domain#isShared() shared} row of {@code domains().list()}. + */ + 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. A shared-domain inbox + * unused for 90 days is deleted either way. + */ + 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..8cfb5da --- /dev/null +++ b/src/main/java/com/millionsend/model/InboxDraft.java @@ -0,0 +1,127 @@ +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}, + * or {@code reply_to_redirect} for an agent's reply to a Reply-To at another organization. + */ + 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..6beba62 --- /dev/null +++ b/src/main/java/com/millionsend/model/InboxSafetyOptions.java @@ -0,0 +1,108 @@ +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; + } + + /** + * 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; + } + + 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}. 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; + } + + 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; on + * the shared domain, team members only. + */ + 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..0b0bba0 --- /dev/null +++ b/src/main/java/com/millionsend/model/InboxThread.java @@ -0,0 +1,109 @@ +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; + } + + /** + * 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; + } + + 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..450d245 --- /dev/null +++ b/src/main/java/com/millionsend/model/LatestCodeOptions.java @@ -0,0 +1,71 @@ +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 String since; + + 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); + Query.put(q, "since", since); + 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 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/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/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 new file mode 100644 index 0000000..05d765d --- /dev/null +++ b/src/main/java/com/millionsend/model/ListReceivedEmailsOptions.java @@ -0,0 +1,134 @@ +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 boolean quarantined; + + 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"); + } + if (quarantined) { + q.put("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 with an offset, like {@code Instant.toString()}: only mail received after it. */ + public Builder since(String since) { + o.since = 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; + } + + /** 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)}, except with an inbox-scoped key (its own inbox). */ + 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; + } + + /** 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/model/ListResponse.java b/src/main/java/com/millionsend/model/ListResponse.java index 89241ff..e59b2db 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; @@ -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 new file mode 100644 index 0000000..ca755a6 --- /dev/null +++ b/src/main/java/com/millionsend/model/ListThreadsOptions.java @@ -0,0 +1,93 @@ +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, 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; + } + + 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..20c9393 --- /dev/null +++ b/src/main/java/com/millionsend/model/UpdateInboxOptions.java @@ -0,0 +1,52 @@ +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(); + + /** 1-320 characters. */ + public Builder name(String name) { + o.changes.put("name", name); + return this; + } + + /** {@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; + } + + /** 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; + } + + 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/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 new file mode 100644 index 0000000..a78e9c4 --- /dev/null +++ b/src/main/java/com/millionsend/services/Inboxes.java @@ -0,0 +1,518 @@ +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; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 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. 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() {}); + } + + /** 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. 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() {}); + } + + /** 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 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, 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, + includeQuarantined ? Collections.singletonMap("include_quarantined", "true") : 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/ClientTest.java b/src/test/java/com/millionsend/ClientTest.java index 79eb50f..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,9 +212,24 @@ void transportFailureHasNullStatusCode() throws Exception { MillionSendException e = assertThrows(MillionSendException.class, () -> ms.emails().send(email())); assertNull(e.getStatusCode()); + assertNull(e.getRetryAfter()); 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/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/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); diff --git a/src/test/java/com/millionsend/ParityTest.java b/src/test/java/com/millionsend/ParityTest.java index 978aa5d..687fa17 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; @@ -181,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( @@ -248,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() @@ -268,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); @@ -595,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\"}"; @@ -832,4 +920,769 @@ 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\":\"quarantined\"," + + "\"quarantine_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) + .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&quarantined=true", + server.query); + assertEquals("r2", res.getHeld().get(0).getId()); + 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()); + + 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).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 + 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\":null,\"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("sd1", created.getDomainId()); + assertNull(created.getExpiresAt()); + assertEquals(3, created.getQuarantined()); + assertEquals(1, created.getPendingApproval()); + assertEquals(2, created.getUnread()); + + 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," + + "\"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); + 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()); + + 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()); + 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); + + 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()); + 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() + .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")); + } }