From 9d515bc899fb0e85b1ecaab2d4ab8cb8bfaebce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Sat, 17 Oct 2026 10:02:04 -0300 Subject: [PATCH 1/3] WebhookEvent knows email.scheduled, email.failed and email.suppressed (0.10.0) - EMAIL_SCHEDULED comes before EMAIL_SENT, and EMAIL_FAILED and EMAIL_SUPPRESSED right after EMAIL_COMPLAINED, in the server's order. They need MillionSend v0.15.1 or later; an older server answers 422 validation_error to a webhook naming them. - Docs: what each one means, data.failed.reason and data.suppressed, source_id on suppression.added and suppression.removed, and the v0.15.1 row under Server versions. suppression.* events are no longer called MillionSend extensions: Resend emits them too. Co-Authored-By: Claude Opus 5.5 --- README.md | 25 ++++++++++++++++--- pom.xml | 2 +- .../java/com/millionsend/core/HttpClient.java | 2 +- .../com/millionsend/model/WebhookEvent.java | 8 ++++-- src/test/java/com/millionsend/ParityTest.java | 25 +++++++++++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5f5f378..299c2f5 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+. @@ -571,8 +571,24 @@ 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`. 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 +666,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 | ## 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/WebhookEvent.java b/src/main/java/com/millionsend/model/WebhookEvent.java index 9e241fb..1aeff0b 100644 --- a/src/main/java/com/millionsend/model/WebhookEvent.java +++ b/src/main/java/com/millionsend/model/WebhookEvent.java @@ -5,11 +5,17 @@ /** 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"), /** Mail received at a receiving domain or inbox. */ @@ -35,9 +41,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..3988b8d 100644 --- a/src/test/java/com/millionsend/ParityTest.java +++ b/src/test/java/com/millionsend/ParityTest.java @@ -1685,4 +1685,29 @@ 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()); + } } From d96d9fe5976a50490bfb762d134ef216252e402a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Sat, 17 Oct 2026 10:26:47 -0300 Subject: [PATCH 2/3] WebhookEvent knows the opt-in email.prefetched - EMAIL_PREFETCHED sits between EMAIL_CLICKED and EMAIL_RECEIVED, in the server's order. CreateWebhookOptions takes only WebhookEvent, so Java callers could not subscribe to it before. It is opt-in: an endpoint gets it only by naming it, never through all events. - README: the opt-in note for email.prefetched, and the metadata-only suppressed email an all-suppressed POST /emails keeps from v0.15.1 (each retry keeps another, even with the same Idempotency-Key). Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++++++- .../java/com/millionsend/model/WebhookEvent.java | 5 +++++ src/test/java/com/millionsend/ParityTest.java | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 299c2f5..e28ce17 100644 --- a/README.md +++ b/README.md @@ -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 @@ -590,6 +593,11 @@ later: an older server answers `422 validation_error` to a webhook naming them. 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 (Apple Mail +Privacy Protection, Gmail's prefetch, a security scanner, 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 them (it still gets `email.received`). `inboxIds(...)` narrows them and diff --git a/src/main/java/com/millionsend/model/WebhookEvent.java b/src/main/java/com/millionsend/model/WebhookEvent.java index 1aeff0b..3e1fe03 100644 --- a/src/main/java/com/millionsend/model/WebhookEvent.java +++ b/src/main/java/com/millionsend/model/WebhookEvent.java @@ -18,6 +18,11 @@ public enum WebhookEvent { 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. */ diff --git a/src/test/java/com/millionsend/ParityTest.java b/src/test/java/com/millionsend/ParityTest.java index 3988b8d..db3e0ba 100644 --- a/src/test/java/com/millionsend/ParityTest.java +++ b/src/test/java/com/millionsend/ParityTest.java @@ -1710,4 +1710,18 @@ void webhookEventsCoverScheduledFailedAndSuppressed() throws Exception { + "\"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()); + } } From b101ee795e482a670d1362bc338204aaa3718ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Sat, 17 Oct 2026 10:45:15 -0300 Subject: [PATCH 3/3] Say what source_id holds, and list the all-suppressed email on the v0.15.1 row The server sets a suppression's source_id only when an SES bounce or complaint creates the entry; unsubscribes and entries added through the API carry null too, not only manual ones. The README's v0.15.1 row now names the suppressed email kept when the suppression list alone refuses a send, and the prefetched triggers read as examples. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++--- src/main/java/com/millionsend/model/Suppression.java | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e28ce17..239919b 100644 --- a/README.md +++ b/README.md @@ -594,8 +594,9 @@ Treat `reason` and `type` as open sets. `suppression.added` and `suppression.rem 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 (Apple Mail -Privacy Protection, Gmail's prefetch, a security scanner, a fetch seconds after delivery); +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 @@ -674,7 +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 | +| 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/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; }