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
36 changes: 31 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.9.0</version>
<version>0.10.0</version>
</dependency>
```

Gradle:

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

Requires Java 11+.
Expand Down Expand Up @@ -87,7 +87,10 @@ try {

`POST /emails` and `POST /emails/batch` answer `422 all_recipients_suppressed`
("All recipients are suppressed") when every `to` recipient is on the
suppression list or opted out of the send's `topicId`.
suppression list or opted out of the send's `topicId`. From v0.15.1, when the
suppression list alone empties `to`, `POST /emails` also keeps a metadata-only
`suppressed` email that fires `email.suppressed`; each retry keeps another, even with
the same `Idempotency-Key` (a refused send doesn't hold its key).

Inboxes add `inbox_send_policy` (403, a send the inbox's rules or the shared domain
refuse; one they hold is a 202 draft), `inbox_send_cap_reached` (429, the inbox's daily
Expand Down Expand Up @@ -571,8 +574,30 @@ r.getPreviousSecretExpiresAt(); // when the previous secret stops signing; nul

During the overlap every delivery carries both signatures, so a receiver
holding either verifies. `deliverability.*`, `quota.*`, `contact.unsubscribed`,
`contact.resubscribed`, `contact.topic_opt_in`, `contact.topic_opt_out` and
`suppression.*` events are MillionSend extensions.
`contact.resubscribed`, `contact.topic_opt_in` and `contact.topic_opt_out` events
are MillionSend extensions.

`EMAIL_SCHEDULED`, `EMAIL_FAILED` and `EMAIL_SUPPRESSED` need MillionSend v0.15.1 or
later: an older server answers `422 validation_error` to a webhook naming them.

- `email.scheduled`: an email was accepted with a future `scheduledAt`; rescheduling
it fires nothing.
- `email.failed`: the email failed for good (it won't be sent, or SES refused it after
`email.sent`); `data.failed.reason` says why.
- `email.suppressed`: the email won't be sent (the suppression list, an unsubscribe or
a topic opt-out stopped it, or its contact, broadcast or topic was deleted or changed
first); `data.suppressed` carries `type`, `message` and `reason`. It also fires when
the suppression list alone makes a `POST /emails` answer
`422 all_recipients_suppressed`. Dropping only some recipients fires nothing.

Treat `reason` and `type` as open sets. `suppression.added` and `suppression.removed`
carry `source_id`: the email whose bounce or complaint created the entry, else `null`.

`EMAIL_PREFETCHED`, a MillionSend extension, is opt-in: it reaches only endpoints that
name it. It fires when a machine fetched the tracking pixel or followed a link (for
example Apple Mail Privacy Protection, Gmail's prefetch, a security scanner or a fetch
seconds after delivery);
`data.open.reason` or `data.click.reason` says which.

The `inbox.*` events (`INBOX_CREATED` … `INBOX_DRAFT_DELETED`) are delivered
only to endpoints that name them; an endpoint subscribed to all events skips
Expand Down Expand Up @@ -650,6 +675,7 @@ v0.13.0 `since` is ignored, so the code already used can come back.
| v0.12.0 | `username`; inboxes that never expire by default; sends from the shared domain under its mode; `name_review_unavailable`; `statusCode` and `name` on contacts batch errors |
| v0.13.0 | newest-first lists; `since` on `codes().latest`; `quarantined` on thread items; inbox names up to 320 characters; `unread` with an inbox-scoped key; a JSON 404 for unknown routes |
| v0.14.0 | the shared row of `domains().list()` (`isShared()`, `getSendingMode()`, `getSendingModeReason()`); `domainId` on `inboxes().create`; `subject` up to 998 characters |
| v0.15.1 | the `EMAIL_SCHEDULED`, `EMAIL_FAILED` and `EMAIL_SUPPRESSED` webhook events; `source_id` on `suppression.*` events; the metadata-only `suppressed` email kept when the suppression list alone refuses an `emails().send` |

## Migrating from Resend

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.9.0</version>
<version>0.10.0</version>
<packaging>jar</packaging>

<name>millionsend-java</name>
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/millionsend/core/HttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,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.9.0";
public static final String VERSION = "0.10.0";
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);

Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/millionsend/model/Suppression.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public SuppressionOrigin getOrigin() {
return origin;
}

/** The email whose bounce/complaint created the entry; {@code null} for manual ones. */
/** The email whose bounce or complaint created the entry; {@code null} otherwise. */
public String getSourceId() {
return sourceId;
}
Expand Down
13 changes: 11 additions & 2 deletions src/main/java/com/millionsend/model/WebhookEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@

/** An event type a webhook can subscribe to. */
public enum WebhookEvent {
/** Accepted with a future {@code scheduledAt}. MillionSend v0.15.1 and later. */
EMAIL_SCHEDULED("email.scheduled"),
EMAIL_SENT("email.sent"),
EMAIL_DELIVERED("email.delivered"),
EMAIL_DELIVERY_DELAYED("email.delivery_delayed"),
EMAIL_BOUNCED("email.bounced"),
EMAIL_COMPLAINED("email.complained"),
/** Failed for good; {@code data.failed.reason} says why. MillionSend v0.15.1 and later. */
EMAIL_FAILED("email.failed"),
/** Won't be sent; {@code data.suppressed} says why. MillionSend v0.15.1 and later. */
EMAIL_SUPPRESSED("email.suppressed"),
EMAIL_OPENED("email.opened"),
EMAIL_CLICKED("email.clicked"),
/**
* MillionSend extension, opt-in: delivered only to endpoints that name it. A machine fetched
* the pixel or followed a link; {@code data.open.reason} or {@code data.click.reason} says which.
*/
EMAIL_PREFETCHED("email.prefetched"),
/** Mail received at a receiving domain or inbox. */
EMAIL_RECEIVED("email.received"),
/** MillionSend extension. */
Expand All @@ -35,9 +46,7 @@ public enum WebhookEvent {
CONTACT_TOPIC_OPT_IN("contact.topic_opt_in"),
/** MillionSend extension. */
CONTACT_TOPIC_OPT_OUT("contact.topic_opt_out"),
/** MillionSend extension. */
SUPPRESSION_ADDED("suppression.added"),
/** MillionSend extension. */
SUPPRESSION_REMOVED("suppression.removed"),
// Inbox events reach only endpoints that name them; subscribing to all events skips them.
INBOX_CREATED("inbox.created"),
Expand Down
39 changes: 39 additions & 0 deletions src/test/java/com/millionsend/ParityTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -1685,4 +1685,43 @@ void webhookEventsCoverReceivingAndInboxes() throws Exception {
}
assertNull(WebhookEvent.fromValue("inbox.thread.assigned"));
}

@Test
void webhookEventsCoverScheduledFailedAndSuppressed() throws Exception {
String[] wire = {"email.scheduled", "email.failed", "email.suppressed"};
for (String value : wire) {
WebhookEvent e = WebhookEvent.fromValue(value);
assertEquals(value, e.getValue());
}

server.responseBody = "{\"object\":\"webhook\",\"id\":\"w1\",\"signing_secret\":\"whsec_abc\"}";
ms.webhooks()
.create(
CreateWebhookOptions.builder()
.endpoint("https://x.dev/hook")
.events(
WebhookEvent.EMAIL_SCHEDULED,
WebhookEvent.EMAIL_FAILED,
WebhookEvent.EMAIL_SUPPRESSED)
.build());
assertEquals(
json(
"{\"endpoint\":\"https://x.dev/hook\","
+ "\"events\":[\"email.scheduled\",\"email.failed\",\"email.suppressed\"]}"),
body());
}

@Test
void webhookEventsCoverPrefetched() throws Exception {
assertEquals(WebhookEvent.EMAIL_PREFETCHED, WebhookEvent.fromValue("email.prefetched"));

server.responseBody = "{\"object\":\"webhook\",\"id\":\"w1\",\"signing_secret\":\"whsec_abc\"}";
ms.webhooks()
.create(
CreateWebhookOptions.builder()
.endpoint("https://x.dev/hook")
.events(WebhookEvent.EMAIL_PREFETCHED)
.build());
assertEquals(json("{\"endpoint\":\"https://x.dev/hook\",\"events\":[\"email.prefetched\"]}"), body());
}
}
Loading