diff --git a/README.md b/README.md
index 5f5f378..239919b 100644
--- a/README.md
+++ b/README.md
@@ -15,14 +15,14 @@ Maven:
com.millionsend
millionsend-java
- 0.9.0
+ 0.10.0
```
Gradle:
```groovy
-implementation 'com.millionsend:millionsend-java:0.9.0'
+implementation 'com.millionsend:millionsend-java:0.10.0'
```
Requires Java 11+.
@@ -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
@@ -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
@@ -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
diff --git a/pom.xml b/pom.xml
index 4f58a0b..d11fba7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
com.millionsend
millionsend-java
- 0.9.0
+ 0.10.0
jar
millionsend-java
diff --git a/src/main/java/com/millionsend/core/HttpClient.java b/src/main/java/com/millionsend/core/HttpClient.java
index fbe0a31..e5616f5 100644
--- a/src/main/java/com/millionsend/core/HttpClient.java
+++ b/src/main/java/com/millionsend/core/HttpClient.java
@@ -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);
diff --git a/src/main/java/com/millionsend/model/Suppression.java b/src/main/java/com/millionsend/model/Suppression.java
index 65997ce..98bf18b 100644
--- a/src/main/java/com/millionsend/model/Suppression.java
+++ b/src/main/java/com/millionsend/model/Suppression.java
@@ -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;
}
diff --git a/src/main/java/com/millionsend/model/WebhookEvent.java b/src/main/java/com/millionsend/model/WebhookEvent.java
index 9e241fb..3e1fe03 100644
--- a/src/main/java/com/millionsend/model/WebhookEvent.java
+++ b/src/main/java/com/millionsend/model/WebhookEvent.java
@@ -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. */
@@ -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"),
diff --git a/src/test/java/com/millionsend/ParityTest.java b/src/test/java/com/millionsend/ParityTest.java
index 687fa17..db3e0ba 100644
--- a/src/test/java/com/millionsend/ParityTest.java
+++ b/src/test/java/com/millionsend/ParityTest.java
@@ -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());
+ }
}