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"));
+ }
}