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()); + } }