Skip to content
Merged
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
292 changes: 284 additions & 8 deletions README.md

Large diffs are not rendered by default.

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;
}
}
17 changes: 16 additions & 1 deletion src/main/java/com/millionsend/MillionSendException.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* {@code { statusCode, name, message }}: {@link #getName()} is the stable
* snake_case discriminant (e.g. {@code validation_error}, {@code not_found}),
* and {@link #getStatusCode()} is {@code null} when the request never reached
* the API (a client-side or transport failure).
* the API (a client-side or transport failure). {@link #getRetryAfter()} carries
* the response's {@code retry-after} header.
*/
public class MillionSendException extends Exception {

Expand All @@ -15,11 +16,17 @@ public class MillionSendException extends Exception {

private final String name;
private final Integer statusCode;
private final Integer retryAfter;

public MillionSendException(String name, String message, Integer statusCode) {
this(name, message, statusCode, null);
}

public MillionSendException(String name, String message, Integer statusCode, Integer retryAfter) {
super(message);
this.name = name;
this.statusCode = statusCode;
this.retryAfter = retryAfter;
}

/** Stable snake_case error code you can switch on. */
Expand All @@ -31,4 +38,12 @@ public String getName() {
public Integer getStatusCode() {
return statusCode;
}

/**
* Seconds the response's {@code retry-after} header asks to wait before trying again;
* {@code null} when it sent none, and on client-side or transport failures.
*/
public Integer getRetryAfter() {
return retryAfter;
}
}
75 changes: 67 additions & 8 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,22 @@ 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, retryAfterSeconds(response));
}
return response;
}

/** {@code retry-after} in whole seconds, the only form the API sends; {@code null} otherwise. */
private static Integer retryAfterSeconds(HttpResponse<String> response) {
String value = response.headers().firstValue("retry-after").orElse("").trim();
// At most nine digits, so the value always fits an int.
return value.matches("\\d{1,9}") ? Integer.valueOf(value) : null;
}

private <T> 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 All @@ -152,7 +211,7 @@ private String serialize(Object body) throws MillionSendException {
}

/** Coerce an error body into the canonical shape; fall back to a generic error. */
private MillionSendException toException(String text, int status) {
private MillionSendException toException(String text, int status, Integer retryAfter) {
if (text != null && !text.isEmpty()) {
try {
JsonNode n = mapper.readTree(text);
Expand All @@ -166,14 +225,14 @@ private MillionSendException toException(String text, int status) {
n.hasNonNull("statusCode") && n.get("statusCode").isNumber()
? n.get("statusCode").asInt()
: status;
return new MillionSendException(name, message, code);
return new MillionSendException(name, message, code, retryAfter);
}
} catch (IOException ignored) {
// Not JSON — fall through to the generic error.
}
}
return new MillionSendException(
"application_error", "Request failed with status " + status, status);
"application_error", "Request failed with status " + status, status, retryAfter);
}

private static String queryString(Map<String, String> query) {
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;
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/millionsend/model/BatchError.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
package com.millionsend.model;

import com.fasterxml.jackson.annotation.JsonProperty;

/** One rejected item of a permissive-mode batch: its index in the request and the reason. */
public final class BatchError {

private int index;

@JsonProperty("statusCode")
private Integer statusCode;

private String name;
private String message;

public int getIndex() {
return index;
}

/**
* Contacts batch only: the HTTP status strict mode would answer for the item; {@code null}
* on email batches and on servers before v0.12.0.
*/
public Integer getStatusCode() {
return statusCode;
}

/**
* Contacts batch only: the error name strict mode would answer, like {@code validation_error};
* {@code null} on email batches and on servers before v0.12.0.
*/
public String getName() {
return name;
}

public String getMessage() {
return message;
}
Expand Down
15 changes: 14 additions & 1 deletion 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 All @@ -30,12 +31,24 @@ public Builder permission(String permission) {
return this;
}

/** Restrict a {@code sending_access} key to one domain. */
/**
* Restrict a {@code sending_access} key to one of your own domains; the
* {@link Domain#isShared() shared} row answers 422.
*/
public Builder domainId(String domainId) {
o.domainId = domainId;
return this;
}

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

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