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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 126 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ Maven:
<dependency>
<groupId>com.millionsend</groupId>
<artifactId>millionsend-java</artifactId>
<version>0.8.0</version>
<version>0.9.0</version>
</dependency>
```

Gradle:

```groovy
implementation 'com.millionsend:millionsend-java:0.8.0'
implementation 'com.millionsend:millionsend-java:0.9.0'
```

Requires Java 11+.
Expand Down Expand Up @@ -96,8 +96,9 @@ on templates) are forwarded as-is and answered with a 422.

### Request options

`emails().send`, `batch().send` and `contacts().batch().create` accept a
`RequestOptions` (the same builder shape as `resend-java`):
`emails().send`, `batch().send`, `contacts().batch().create` and the inbox
sends (`receiving().reply`/`forward`, `threads().emails().reply`/`forward`,
`drafts().send`) accept a `RequestOptions` (the same builder shape as `resend-java`):

```java
import com.millionsend.core.RequestOptions;
Expand Down Expand Up @@ -285,6 +286,112 @@ ms.suppressions().batch().add(AddSuppressionsOptions.builder().emails(List.of(a,
ms.suppressions().batch().remove(RemoveSuppressionsOptions.builder().emails(List.of(a)).build()); // or .ids(...)
```

### Receiving

Mail received at your receiving domains and inboxes. Everything in a received
email was written by its sender, so show its HTML only in a sandbox.

```java
ReceivedEmailListResponse page = ms.receiving().list(ListReceivedEmailsOptions.builder()
.inboxId(inboxId).from("acme.dev").subjectContains("code").limit(20).build()); // newest first
ReceivedEmail email = ms.receiving().get(id); // GET /emails/receiving/{id}
ms.receiving().get(id, "cid"); // leave inline images as cid: references
email.getExtractedText(); // the reply without quotes, signature or hidden text
email.getScreening().getSenderTrust(); // owner, allowlisted, known_thread, external, …
email.getExtracted().getCode(); // a verification code, when one was found

// Long-poll: returns as soon as matching mail arrives (or an empty page after 30 s)
ReceivedEmailListResponse next = ms.receiving().list(ListReceivedEmailsOptions.builder()
.inboxId(inboxId).waitSeconds(30).build());
next.getHeld(); // matching mail held in quarantine, ids only

SendResult<ThreadEmail> r = ms.receiving().reply(id,
ReplyOptions.builder().text("Thanks, confirmed.").build(),
RequestOptions.idempotencyKey("reply-" + id));
if (r.isDraft()) {
r.getDraft().getApprovalReasons(); // held for a person to approve (HTTP 202)
} else {
r.getSent().getThreadId();
}
ms.receiving().forward(id, ForwardOptions.builder().to("ops@acme.dev").mode("attachment").build());
ms.receiving().remove(id); // erase now

ms.receiving().attachments().list(id); // signed download links, valid one hour
ms.receiving().attachments().get(id, attachmentId);
```

Held mail is invisible to inbox-scoped keys: a detail read answers 403
`email_quarantined`. For full-access keys, `includeQuarantined(true)` lists it
too and `quarantined(true)` lists only held mail.

### Inboxes

An inbox is one address that receives mail, read by an agent (`kind` `agent`,
the default) or by people (`human`), with threads in both directions. The
paths mirror Resend's Inboxes API; `kind`, `shared`, `expiresAt`, safety,
quarantine and codes are MillionSend extensions.

```java
Inbox inbox = ms.inboxes().create(CreateInboxOptions.builder()
.name("Signup bot").expiresIn("24h") // no emailAddress: a random address on the shared domain
.safety(InboxSafetyOptions.builder().acceptFrom("anyone").build())
.build());
ms.inboxes().list(ListInboxesOptions.builder().kind("agent").build());
ms.inboxes().get("bot@acme.dev"); // by id or address
ms.inboxes().update(inbox.getId(), UpdateInboxOptions.builder().expiresIn(null).build()); // null: never expires
ms.inboxes().remove(inbox.getId());

// Verification codes and sign-in links (links are never fetched)
VerificationCode code = ms.inboxes().codes().latest(inboxId,
LatestCodeOptions.builder().from("github.com").waitSeconds(30).build());
if ("found".equals(code.getStatus())) {
code.getCode(); // or code.getUrl() for a sign-in link
}

// Safety rules (inbox-scoped keys can read them, never change them)
ms.inboxes().safety().get(inboxId);
ms.inboxes().safety().update(inboxId, InboxSafetyOptions.builder()
.acceptFrom("allowlist").allowlist("*@partner.dev").sendPolicy("reply_only").sendCap(100).build());

// Quarantine: held mail is kept and listed until a person releases it
ms.inboxes().quarantine().list(inboxId);
ms.inboxes().quarantine().release(inboxId, emailId, true); // true also allowlists the sender

// Threads and their emails
ms.inboxes().threads().list(inboxId, ListThreadsOptions.builder().folders("inbox", "archive").read(false).build());
ms.inboxes().threads().search(inboxId, SearchThreadsOptions.builder().query("invoice -draft").from("acme").build());
ms.inboxes().threads().get(inboxId, threadId);
ms.inboxes().threads().update(inboxId, threadId, UpdateThreadOptions.builder().read(true).folder("archive").build());
ms.inboxes().threads().remove(inboxId, threadId); // to trash, purged after 30 days
ms.inboxes().threads().emails().list(inboxId, threadId); // oldest first
ms.inboxes().threads().emails().get(inboxId, threadId, emailId);
ms.inboxes().threads().emails().reply(inboxId, threadId, emailId, ReplyOptions.builder().text("On it").build());
ms.inboxes().threads().emails().forward(inboxId, threadId, emailId, ForwardOptions.builder().to("ops@acme.dev").build());

// Labels
InboxLabel label = ms.inboxes().labels().create(inboxId, InboxLabelOptions.builder().name("Billing").build());
ms.inboxes().labels().list(inboxId);
ms.inboxes().labels().update(inboxId, label.getId(), InboxLabelOptions.builder().color("#FF8800").build());
ms.inboxes().labels().remove(inboxId, label.getId());

// Drafts, including the ones the send rules hold for approval
InboxDraft d = ms.inboxes().drafts().create(inboxId, CreateDraftOptions.builder()
.to("ada@example.com").subject("Hello").text("Hi Ada").build());
ms.inboxes().drafts().list(inboxId);
ms.inboxes().drafts().get(inboxId, d.getId());
ms.inboxes().drafts().update(inboxId, d.getId(), UpdateDraftOptions.builder().subject(null).build()); // null clears
SendResult<InboxDraft> s = ms.inboxes().drafts().send(inboxId, d.getId());
ms.inboxes().drafts().reject(inboxId, d.getId()); // approvers only
ms.inboxes().drafts().remove(inboxId, d.getId());
```

Reply, forward and draft send return a `SendResult`: `isDraft()` is true when
the inbox's send rules held the message for a person (the API answers 202); an
inbox-scoped key sending a pending draft gets 403 `approval_required`. Folder,
verdict, kind and status values are plain strings, so new server values never
break decoding. Hosted agent settings (`/inboxes/{id}/agent`) are not exposed:
MillionSend runs no agent of its own.

### Domains

```java
Expand All @@ -296,9 +403,13 @@ ms.domains().get(id);
ms.domains().list();
ms.domains().verify(id); // re-check DNS
ms.domains().update(UpdateDomainOptions.builder().id(id).openTracking(true).trackingSubdomain(null).build()); // null clears
ms.domains().update(UpdateDomainOptions.builder().id(id).receivingCatchAll(true).build()); // paid plans on Cloud
ms.domains().remove(id);
```

`Domain.getReceivingCatchAll()`: on, mail to addresses without an inbox lands in
`receiving().list()`; off, those addresses are refused at SMTP time.

### Webhooks

```java
Expand Down Expand Up @@ -327,6 +438,13 @@ holding either verifies. `deliverability.*`, `quota.*`, `contact.unsubscribed`,
`contact.resubscribed`, `contact.topic_opt_in`, `contact.topic_opt_out` and
`suppression.*` events are MillionSend extensions.

The `inbox.*` events (`INBOX_CREATED` … `INBOX_DRAFT_DELETED`) are delivered
only to endpoints that name them; an endpoint subscribed to all events skips
them (it still gets `email.received`). `inboxIds(...)` narrows them and
`email.received` to some inboxes (`UpdateWebhookOptions.builder().inboxIds(null)`
removes the filter).
`inbox.expired` and `inbox.email.quarantined` are MillionSend extensions.

### API keys

```java
Expand All @@ -335,6 +453,9 @@ CreateApiKeyResponse k = ms.apiKeys().create(CreateApiKeyOptions.builder()
k.getToken(); // shown only once
ms.apiKeys().list();
ms.apiKeys().remove(id);

// A key restricted to one inbox: it reads that inbox and sends from its address only
ms.apiKeys().create(CreateApiKeyOptions.builder().name("support-agent").inboxId(inboxId).build());
```

### Templates
Expand Down Expand Up @@ -396,7 +517,7 @@ Notes:
- **No audiences**: contacts are team-global, so there is no `.audiences()` resource and no `audienceId` anywhere. The server keeps `/audiences/*` only as a compatibility shim; it is not part of this SDK. `.segments()` is MillionSend's dynamic-filter feature, not Resend's audience alias.
- **Request options** use the same `RequestOptions` builder shape (`setIdempotencyKey`, `add(header, value)`), plus `batchValidation(...)`.
- Errors throw `MillionSendException` (in place of Resend's `ResendException`) with the same `{ statusCode, name, message }` fields.
- **MillionSend extensions** (no Resend counterpart): `segments()`, `deliverability()`, `usage()`, `contacts().batch()`, `contacts().preferencesLink()`, `webhooks().rotate()`, `emails().remove()`, `emails().getInsights()`, `Email.getScore()`.
- **MillionSend extensions** (no Resend counterpart): `segments()`, `deliverability()`, `usage()`, `contacts().batch()`, `contacts().preferencesLink()`, `webhooks().rotate()`, `emails().remove()`, `emails().getInsights()`, `Email.getScore()`, and in `receiving()` and `inboxes()` the long-poll `waitSeconds`, the reply/forward/remove of received mail, inbox `safety()`, `codes()`, `quarantine()`, draft `reject()` and the extension fields.

## Build & test

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.millionsend</groupId>
<artifactId>millionsend-java</artifactId>
<version>0.8.0</version>
<version>0.9.0</version>
<packaging>jar</packaging>

<name>millionsend-java</name>
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/millionsend/MillionSend.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import com.millionsend.services.Deliverability;
import com.millionsend.services.Domains;
import com.millionsend.services.Emails;
import com.millionsend.services.Inboxes;
import com.millionsend.services.Receiving;
import com.millionsend.services.Segments;
import com.millionsend.services.Suppressions;
import com.millionsend.services.Templates;
Expand Down Expand Up @@ -55,6 +57,8 @@ public final class MillionSend {
private final Templates templates;
private final ContactProperties contactProperties;
private final Usage usage;
private final Receiving receiving;
private final Inboxes inboxes;

/** Reads the API key from {@code MILLIONSEND_API_KEY} and the base URL from the environment. */
public MillionSend() {
Expand Down Expand Up @@ -101,6 +105,8 @@ public MillionSend(String apiKey, String baseUrl, boolean allowInsecureHttp) {
this.templates = new Templates(http);
this.contactProperties = new ContactProperties(http);
this.usage = new Usage(http);
this.receiving = new Receiving(http);
this.inboxes = new Inboxes(http);
}

/** Explicit option, then the environment, then MillionSend Cloud. */
Expand Down Expand Up @@ -170,4 +176,14 @@ public ContactProperties contactProperties() {
public Usage usage() {
return usage;
}

/** Received mail ({@code receiving().list()}, {@code get(id)}, {@code reply(...)}, …). */
public Receiving receiving() {
return receiving;
}

/** Inboxes for agents and people, with threads, labels, drafts and safety rules. */
public Inboxes inboxes() {
return inboxes;
}
}
62 changes: 57 additions & 5 deletions src/main/java/com/millionsend/core/HttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.millionsend.MillionSendException;
import com.millionsend.model.InboxDraft;
import com.millionsend.model.SendResult;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
Expand All @@ -28,7 +30,7 @@
public final class HttpClient {

/** Kept in sync with the Maven {@code version}; surfaced in the User-Agent. */
public static final String VERSION = "0.8.0";
public static final String VERSION = "0.9.0";
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);

Expand Down Expand Up @@ -93,10 +95,57 @@ public <T> T request(
RequestOptions options,
TypeReference<T> returnType)
throws MillionSendException {
return read(execute(method, path, body, query, options, REQUEST_TIMEOUT).body(), returnType);
}

/**
* A GET the API may hold open for up to {@code waitSeconds} (a long-poll), so the
* request timeout stretches by that long.
*/
public <T> T longPoll(
String path, Map<String, String> query, Integer waitSeconds, TypeReference<T> returnType)
throws MillionSendException {
Duration timeout =
waitSeconds == null ? REQUEST_TIMEOUT : REQUEST_TIMEOUT.plusSeconds(Math.max(0, waitSeconds));
return read(execute("GET", path, null, query, null, timeout).body(), returnType);
}

/**
* A POST to an inbox send endpoint (reply, forward, draft send). A 202 means the
* inbox's send rules held the message, so its body is the held {@link InboxDraft};
* any other 2xx body reads as {@code sentType}.
*/
public <T> SendResult<T> sendOrHold(
String path, Object body, RequestOptions options, TypeReference<T> sentType)
throws MillionSendException {
HttpResponse<String> response = execute("POST", path, body, null, options, REQUEST_TIMEOUT);
if (response.statusCode() == 202) {
return SendResult.held(read(response.body(), new TypeReference<InboxDraft>() {}));
}
return SendResult.sent(read(response.body(), sentType));
}

/** Sends the request and returns the 2xx response; any other status throws. */
private HttpResponse<String> execute(
String method,
String path,
Object body,
Map<String, String> query,
RequestOptions options,
Duration timeout)
throws MillionSendException {
// URL normalization resolves an empty, "." or ".." segment away, so an id such as
// ".." would send the request to another resource (DELETE /inboxes/{id}/labels/..
// deletes the inbox).
for (String segment : path.substring(1).split("/", -1)) {
if (segment.isEmpty() || segment.equals(".") || segment.equals("..")) {
throw new IllegalArgumentException("An id in the request path is empty, \".\" or \"..\".");
}
}

HttpRequest.Builder rb =
HttpRequest.newBuilder(URI.create(baseUrl + path + queryString(query)))
.timeout(REQUEST_TIMEOUT)
.timeout(timeout)
.header("Authorization", "Bearer " + apiKey)
.header("Accept", "application/json")
.header("User-Agent", userAgent);
Expand Down Expand Up @@ -132,12 +181,15 @@ public <T> T request(
}

int status = response.statusCode();
String text = response.body();
if (status < 200 || status >= 300) {
throw toException(text, status);
throw toException(response.body(), status);
}
return response;
}

private <T> T read(String text, TypeReference<T> type) throws MillionSendException {
try {
return mapper.readValue(text, returnType);
return mapper.readValue(text, type);
} catch (IOException e) {
throw new MillionSendException("application_error", e.getMessage(), null);
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/millionsend/model/ApiKey.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public final class ApiKey {
private String name;
private String createdAt;
private String lastUsedAt;
private String inboxId;

public String getId() {
return id;
Expand All @@ -24,4 +25,9 @@ public String getCreatedAt() {
public String getLastUsedAt() {
return lastUsedAt;
}

/** Set for a key restricted to one inbox. */
public String getInboxId() {
return inboxId;
}
}
10 changes: 10 additions & 0 deletions src/main/java/com/millionsend/model/CreateApiKeyOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public final class CreateApiKeyOptions {
private String name;
private String permission;
private String domainId;
private String inboxId;

private CreateApiKeyOptions() {}

Expand Down Expand Up @@ -36,6 +37,15 @@ public Builder domainId(String domainId) {
return this;
}

/**
* Restrict a {@code full_access} key to one inbox: it reads that inbox and sends
* from its address only. Not combinable with {@code domainId}.
*/
public Builder inboxId(String inboxId) {
o.inboxId = inboxId;
return this;
}

public CreateApiKeyOptions build() {
return o;
}
Expand Down
Loading
Loading