diff --git a/src/main/java/com/resend/Resend.java b/src/main/java/com/resend/Resend.java index ee21de3..0de7de9 100644 --- a/src/main/java/com/resend/Resend.java +++ b/src/main/java/com/resend/Resend.java @@ -7,6 +7,7 @@ import com.resend.services.contacts.Contacts; import com.resend.services.domains.Domains; import com.resend.services.emails.Emails; +import com.resend.services.webhooks.Webhooks; import com.resend.services.receiving.Receiving; import com.resend.services.topics.Topics; import com.resend.services.templates.Templates; @@ -94,6 +95,15 @@ public Broadcasts broadcasts() { } /** + * Returns a Webhooks object that can be used to interact with the Webhooks service. + * + * @return A Webhooks object. + */ + public Webhooks webhooks() { + return new Webhooks(apiKey); + } + + /** * Returns a Receiving object that can be used to interact with the Receiving service for inbound emails. * * @return A Receiving object. diff --git a/src/main/java/com/resend/services/webhooks/Webhooks.java b/src/main/java/com/resend/services/webhooks/Webhooks.java new file mode 100644 index 0000000..08b5612 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/Webhooks.java @@ -0,0 +1,281 @@ +package com.resend.services.webhooks; + +import com.resend.core.exception.ResendException; +import com.resend.core.helper.URLHelper; +import com.resend.core.net.AbstractHttpResponse; +import com.resend.core.net.HttpMethod; +import com.resend.core.net.ListParams; +import com.resend.core.service.BaseService; +import com.resend.services.webhooks.model.CreateWebhookOptions; +import com.resend.services.webhooks.model.CreateWebhookResponseSuccess; +import com.resend.services.webhooks.model.ListWebhooksResponseSuccess; +import com.resend.services.webhooks.model.RemoveWebhookResponseSuccess; +import com.resend.services.webhooks.model.UpdateWebhookOptions; +import com.resend.services.webhooks.model.UpdateWebhookResponseSuccess; +import com.resend.services.webhooks.model.GetWebhookResponseSuccess; +import com.resend.services.webhooks.model.VerifyWebhookOptions; +import okhttp3.MediaType; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.MessageDigest; +import java.util.Base64; + +/** + * Represents the Resend Webhooks module. + */ +public final class Webhooks extends BaseService { + + /** + * Constructs an instance of the {@code Webhooks} class. + * + * @param apiKey The apiKey used for authentication. + */ + public Webhooks(final String apiKey) { + super(apiKey); + } + + /** + * Creates a webhook based on the provided CreateWebhookOptions and returns a CreateWebhookResponseSuccess. + * + * @param createWebhookOptions The request object containing the webhook creation details. + * @return A CreateWebhookResponseSuccess representing the result of the webhook creation operation. + * @throws ResendException If an error occurs during the webhook creation process. + */ + public CreateWebhookResponseSuccess create(CreateWebhookOptions createWebhookOptions) throws ResendException { + String payload = super.resendMapper.writeValue(createWebhookOptions); + AbstractHttpResponse response = httpClient.perform("/webhooks", super.apiKey, HttpMethod.POST, payload, MediaType.get("application/json")); + + if (!response.isSuccessful()) { + throw new ResendException(response.getCode(), response.getBody()); + } + + String responseBody = response.getBody(); + return resendMapper.readValue(responseBody, CreateWebhookResponseSuccess.class); + } + + /** + * Updates a webhook based on the provided webhook ID and UpdateWebhookOptions, and returns an UpdateWebhookResponseSuccess. + * + * @param webhookId The unique identifier of the webhook to update. + * @param updateWebhookOptions The object containing the information to be updated. + * @return An UpdateWebhookResponseSuccess representing the result of the webhook update operation. + * @throws ResendException If an error occurs during the webhook update process. + */ + public UpdateWebhookResponseSuccess update(String webhookId, UpdateWebhookOptions updateWebhookOptions) throws ResendException { + String payload = super.resendMapper.writeValue(updateWebhookOptions); + AbstractHttpResponse response = httpClient.perform("/webhooks/" + webhookId, super.apiKey, HttpMethod.PATCH, payload, MediaType.get("application/json")); + + if (!response.isSuccessful()) { + throw new ResendException(response.getCode(), response.getBody()); + } + + String responseBody = response.getBody(); + return resendMapper.readValue(responseBody, UpdateWebhookResponseSuccess.class); + } + + /** + * Retrieves a webhook based on the provided webhook ID and returns a Webhook object. + * + * @param webhookId The unique identifier of the webhook to retrieve. + * @return A Webhook object representing the retrieved webhook. + * @throws ResendException If an error occurs during the webhook retrieval process. + */ + public GetWebhookResponseSuccess get(String webhookId) throws ResendException { + AbstractHttpResponse response = httpClient.perform("/webhooks/" + webhookId, super.apiKey, HttpMethod.GET, null, MediaType.get("application/json")); + + if (!response.isSuccessful()) { + throw new ResendException(response.getCode(), response.getBody()); + } + + String responseBody = response.getBody(); + return resendMapper.readValue(responseBody, GetWebhookResponseSuccess.class); + } + + /** + * Retrieves a list of webhooks and returns a ListWebhooksResponseSuccess. + * + * @return A ListWebhooksResponseSuccess containing the list of webhooks. + * @throws ResendException If an error occurs during the webhook list retrieval process. + */ + public ListWebhooksResponseSuccess list() throws ResendException { + AbstractHttpResponse response = this.httpClient.perform("/webhooks", super.apiKey, HttpMethod.GET, null, MediaType.get("application/json")); + + if (!response.isSuccessful()) { + throw new ResendException(response.getCode(), response.getBody()); + } + + String responseBody = response.getBody(); + return resendMapper.readValue(responseBody, ListWebhooksResponseSuccess.class); + } + + /** + * Retrieves a paginated list of webhooks and returns a ListWebhooksResponseSuccess. + * + * @param params The params used to customize the list. + * @return A ListWebhooksResponseSuccess containing the paginated list of webhooks. + * @throws ResendException If an error occurs during the webhook list retrieval process. + */ + public ListWebhooksResponseSuccess list(ListParams params) throws ResendException { + String pathWithQuery = "/webhooks" + URLHelper.parse(params); + AbstractHttpResponse response = this.httpClient.perform(pathWithQuery, super.apiKey, HttpMethod.GET, null, MediaType.get("application/json")); + + if (!response.isSuccessful()) { + throw new ResendException(response.getCode(), response.getBody()); + } + + String responseBody = response.getBody(); + return resendMapper.readValue(responseBody, ListWebhooksResponseSuccess.class); + } + + /** + * Deletes a webhook based on the provided webhook ID and returns a RemoveWebhookResponseSuccess. + * + * @param webhookId The unique identifier of the webhook to delete. + * @return A RemoveWebhookResponseSuccess representing the result of the webhook deletion operation. + * @throws ResendException If an error occurs during the webhook deletion process. + */ + public RemoveWebhookResponseSuccess remove(String webhookId) throws ResendException { + AbstractHttpResponse response = httpClient.perform("/webhooks/" + webhookId, super.apiKey, HttpMethod.DELETE, "", null); + + if (!response.isSuccessful()) { + throw new ResendException(response.getCode(), response.getBody()); + } + + String responseBody = response.getBody(); + return resendMapper.readValue(responseBody, RemoveWebhookResponseSuccess.class); + } + + /** + * Verifies the signature of a webhook request to ensure it was sent by Resend. + * This method validates both the HMAC-SHA256 signature and the timestamp to prevent + * replay attacks. + * + * @param options The verification options containing payload, headers, and secret. + * @throws ResendException If the signature is invalid or the timestamp is outside the tolerance window. + */ + public void verify(VerifyWebhookOptions options) throws ResendException { + if (options == null) { + throw new ResendException(400, "VerifyWebhookOptions cannot be null"); + } + + if (options.getPayload() == null || options.getPayload().isEmpty()) { + throw new ResendException(400, "Webhook payload cannot be null or empty"); + } + + if (options.getHeaders() == null) { + throw new ResendException(400, "Webhook headers cannot be null"); + } + + if (options.getSecret() == null || options.getSecret().isEmpty()) { + throw new ResendException(400, "Webhook secret cannot be null or empty"); + } + + String id = options.getHeaders().get("svix-id"); + String timestamp = options.getHeaders().get("svix-timestamp"); + String signature = options.getHeaders().get("svix-signature"); + + if (id == null || id.isEmpty()) { + throw new ResendException(400, "Webhook ID (svix-id) cannot be null or empty"); + } + + if (timestamp == null || timestamp.isEmpty()) { + throw new ResendException(400, "Webhook timestamp (svix-timestamp) cannot be null or empty"); + } + + if (signature == null || signature.isEmpty()) { + throw new ResendException(400, "Webhook signature (svix-signature) cannot be null or empty"); + } + + // Validate timestamp (within 5 minutes tolerance) + try { + long webhookTimestamp = Long.parseLong(timestamp); + long currentTimestamp = System.currentTimeMillis() / 1000; + long timeDifference = Math.abs(currentTimestamp - webhookTimestamp); + + if (timeDifference > 300) { // 5 minutes in seconds + throw new ResendException(400, "Webhook timestamp is outside the tolerance window (5 minutes)"); + } + } catch (NumberFormatException e) { + throw new ResendException(400, "Invalid webhook timestamp format"); + } + + // Extract the secret key (remove "whsec_" prefix) + String secretKey = options.getSecret(); + if (secretKey.startsWith("whsec_")) { + secretKey = secretKey.substring(6); + } + + try { + // Decode the base64 secret + byte[] decodedSecret = Base64.getDecoder().decode(secretKey); + + // Create the signed content: {id}.{timestamp}.{payload} + String signedContent = id + "." + timestamp + "." + options.getPayload(); + + // Generate HMAC-SHA256 signature + Mac hmac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(decodedSecret, "HmacSHA256"); + hmac.init(secretKeySpec); + byte[] hash = hmac.doFinal(signedContent.getBytes("UTF-8")); + + // Encode to base64 + String expectedSignature = Base64.getEncoder().encodeToString(hash); + + // Parse the signature header (format: "v1,signature1 v1,signature2") + String[] signatureParts = signature.split(" "); + boolean signatureMatches = false; + + for (String signaturePart : signatureParts) { + String[] versionAndSignature = signaturePart.split(",", 2); + if (versionAndSignature.length == 2) { + String version = versionAndSignature[0]; + String sig = versionAndSignature[1]; + + // Only support v1 for now + if ("v1".equals(version)) { + if (constantTimeEquals(expectedSignature, sig)) { + signatureMatches = true; + break; + } + } + } + } + + if (!signatureMatches) { + throw new ResendException(401, "Webhook signature verification failed"); + } + + } catch (ResendException e) { + throw e; + } catch (Exception e) { + throw new ResendException(500, "Error verifying webhook signature: " + e.getMessage()); + } + } + + /** + * Constant-time string comparison to prevent timing attacks. + * + * @param a First string to compare. + * @param b Second string to compare. + * @return true if the strings are equal, false otherwise. + */ + private boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + + byte[] aBytes = a.getBytes(); + byte[] bBytes = b.getBytes(); + + if (aBytes.length != bBytes.length) { + return false; + } + + int result = 0; + for (int i = 0; i < aBytes.length; i++) { + result |= aBytes[i] ^ bBytes[i]; + } + + return result == 0; + } +} diff --git a/src/main/java/com/resend/services/webhooks/dto/WebhookDTO.java b/src/main/java/com/resend/services/webhooks/dto/WebhookDTO.java new file mode 100644 index 0000000..f05ebf0 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/dto/WebhookDTO.java @@ -0,0 +1,139 @@ +package com.resend.services.webhooks.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.resend.services.webhooks.model.WebhookStatus; +import java.util.List; + +/** + * Data Transfer Object for webhook data in list responses. + */ +public class WebhookDTO { + + @JsonProperty("id") + private String id; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("status") + private WebhookStatus status; + + @JsonProperty("endpoint") + private String endpoint; + + @JsonProperty("events") + private List events; + + /** + * Default constructor. + */ + public WebhookDTO() { + } + + /** + * Constructor with all fields. + * + * @param id The webhook ID. + * @param createdAt The creation timestamp. + * @param status The webhook status. + * @param endpoint The webhook endpoint URL. + * @param events The list of event names. + */ + public WebhookDTO(String id, String createdAt, WebhookStatus status, String endpoint, List events) { + this.id = id; + this.createdAt = createdAt; + this.status = status; + this.endpoint = endpoint; + this.events = events; + } + + /** + * Gets the webhook ID. + * + * @return The webhook ID. + */ + public String getId() { + return id; + } + + /** + * Sets the webhook ID. + * + * @param id The webhook ID. + */ + public void setId(String id) { + this.id = id; + } + + /** + * Gets the creation timestamp. + * + * @return The creation timestamp. + */ + public String getCreatedAt() { + return createdAt; + } + + /** + * Sets the creation timestamp. + * + * @param createdAt The creation timestamp. + */ + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + /** + * Gets the webhook status. + * + * @return The webhook status. + */ + public WebhookStatus getStatus() { + return status; + } + + /** + * Sets the webhook status. + * + * @param status The webhook status. + */ + public void setStatus(WebhookStatus status) { + this.status = status; + } + + /** + * Gets the webhook endpoint URL. + * + * @return The endpoint URL. + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Sets the webhook endpoint URL. + * + * @param endpoint The endpoint URL. + */ + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + /** + * Gets the list of events that trigger the webhook. + * + * @return The list of event names. + */ + public List getEvents() { + return events; + } + + /** + * Sets the list of events that trigger the webhook. + * + * @param events The list of event names. + */ + public void setEvents(List events) { + this.events = events; + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/CreateWebhookOptions.java b/src/main/java/com/resend/services/webhooks/model/CreateWebhookOptions.java new file mode 100644 index 0000000..dc81f37 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/CreateWebhookOptions.java @@ -0,0 +1,98 @@ +package com.resend.services.webhooks.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Represents the options for creating a webhook. + */ +public class CreateWebhookOptions { + + @JsonProperty("endpoint") + private final String endpoint; + + @JsonProperty("events") + private final List events; + + private CreateWebhookOptions(Builder builder) { + this.endpoint = builder.endpoint; + this.events = builder.events; + } + + /** + * Gets the webhook endpoint URL. + * + * @return The endpoint URL. + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Gets the list of events that will trigger the webhook. + * + * @return The list of webhook events. + */ + public List getEvents() { + return events; + } + + /** + * Creates a new builder instance. + * + * @return A new Builder instance. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for CreateWebhookOptions. + */ + public static class Builder { + private String endpoint; + private List events; + + /** + * Sets the webhook endpoint URL. + * + * @param endpoint The endpoint URL. + * @return This builder instance. + */ + public Builder endpoint(String endpoint) { + this.endpoint = endpoint; + return this; + } + + /** + * Sets the list of events that will trigger the webhook. + * + * @param events The list of webhook events. + * @return This builder instance. + */ + public Builder events(List events) { + this.events = events; + return this; + } + + /** + * Sets the events that will trigger the webhook (varargs). + * + * @param events The webhook events. + * @return This builder instance. + */ + public Builder events(WebhookEvent... events) { + this.events = java.util.Arrays.asList(events); + return this; + } + + /** + * Builds the CreateWebhookOptions instance. + * + * @return A new CreateWebhookOptions instance. + */ + public CreateWebhookOptions build() { + return new CreateWebhookOptions(this); + } + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/CreateWebhookResponseSuccess.java b/src/main/java/com/resend/services/webhooks/model/CreateWebhookResponseSuccess.java new file mode 100644 index 0000000..4df7cf1 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/CreateWebhookResponseSuccess.java @@ -0,0 +1,72 @@ +package com.resend.services.webhooks.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a successful webhook creation response. + */ +public class CreateWebhookResponseSuccess { + + @JsonProperty("object") + private String object; + + @JsonProperty("id") + private String id; + + @JsonProperty("signing_secret") + private String signingSecret; + + /** + * Gets the object type (should be "webhook"). + * + * @return The object type. + */ + public String getObject() { + return object; + } + + /** + * Sets the object type. + * + * @param object The object type. + */ + public void setObject(String object) { + this.object = object; + } + + /** + * Gets the webhook ID. + * + * @return The webhook ID. + */ + public String getId() { + return id; + } + + /** + * Sets the webhook ID. + * + * @param id The webhook ID. + */ + public void setId(String id) { + this.id = id; + } + + /** + * Gets the signing secret for webhook verification. + * + * @return The signing secret. + */ + public String getSigningSecret() { + return signingSecret; + } + + /** + * Sets the signing secret. + * + * @param signingSecret The signing secret. + */ + public void setSigningSecret(String signingSecret) { + this.signingSecret = signingSecret; + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/GetWebhookResponseSuccess.java b/src/main/java/com/resend/services/webhooks/model/GetWebhookResponseSuccess.java new file mode 100644 index 0000000..37c4482 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/GetWebhookResponseSuccess.java @@ -0,0 +1,157 @@ +package com.resend.services.webhooks.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Represents a webhook. + */ +public class GetWebhookResponseSuccess { + + @JsonProperty("object") + private String object; + + @JsonProperty("id") + private String id; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("status") + private WebhookStatus status; + + @JsonProperty("endpoint") + private String endpoint; + + @JsonProperty("events") + private List events; + + @JsonProperty("signing_secret") + private String signingSecret; + + /** + * Gets the object type (should be "webhook"). + * + * @return The object type. + */ + public String getObject() { + return object; + } + + /** + * Sets the object type. + * + * @param object The object type. + */ + public void setObject(String object) { + this.object = object; + } + + /** + * Gets the webhook ID. + * + * @return The webhook ID. + */ + public String getId() { + return id; + } + + /** + * Sets the webhook ID. + * + * @param id The webhook ID. + */ + public void setId(String id) { + this.id = id; + } + + /** + * Gets the creation timestamp. + * + * @return The creation timestamp. + */ + public String getCreatedAt() { + return createdAt; + } + + /** + * Sets the creation timestamp. + * + * @param createdAt The creation timestamp. + */ + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + /** + * Gets the webhook status. + * + * @return The webhook status. + */ + public WebhookStatus getStatus() { + return status; + } + + /** + * Sets the webhook status. + * + * @param status The webhook status. + */ + public void setStatus(WebhookStatus status) { + this.status = status; + } + + /** + * Gets the webhook endpoint URL. + * + * @return The endpoint URL. + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Sets the webhook endpoint URL. + * + * @param endpoint The endpoint URL. + */ + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + /** + * Gets the list of events that trigger the webhook. + * + * @return The list of event names. + */ + public List getEvents() { + return events; + } + + /** + * Sets the list of events that trigger the webhook. + * + * @param events The list of event names. + */ + public void setEvents(List events) { + this.events = events; + } + + /** + * Gets the signing secret for webhook verification. + * + * @return The signing secret. + */ + public String getSigningSecret() { + return signingSecret; + } + + /** + * Sets the signing secret. + * + * @param signingSecret The signing secret. + */ + public void setSigningSecret(String signingSecret) { + this.signingSecret = signingSecret; + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/ListWebhooksResponseSuccess.java b/src/main/java/com/resend/services/webhooks/model/ListWebhooksResponseSuccess.java new file mode 100644 index 0000000..74efb28 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/ListWebhooksResponseSuccess.java @@ -0,0 +1,93 @@ +package com.resend.services.webhooks.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.resend.services.webhooks.dto.WebhookDTO; +import java.util.List; + +/** + * Represents a response object for listing webhooks. + */ +public class ListWebhooksResponseSuccess { + + @JsonProperty("object") + private String object; + + @JsonProperty("has_more") + private Boolean hasMore; + + @JsonProperty("data") + private List data; + + /** + * Default constructor. + */ + public ListWebhooksResponseSuccess() { + } + + /** + * Constructor with all fields. + * + * @param object The object type (should be "list"). + * @param hasMore Indicates if there are more items to be returned. + * @param data The list of webhook data. + */ + public ListWebhooksResponseSuccess(String object, Boolean hasMore, List data) { + this.object = object; + this.hasMore = hasMore; + this.data = data; + } + + /** + * Gets the object type. + * + * @return The object type. + */ + public String getObject() { + return object; + } + + /** + * Sets the object type. + * + * @param object The object type. + */ + public void setObject(String object) { + this.object = object; + } + + /** + * Gets whether there are more items available for pagination. + * + * @return Whether there are more items available. + */ + public Boolean hasMore() { + return hasMore; + } + + /** + * Sets whether there are more items available. + * + * @param hasMore Whether there are more items available. + */ + public void setHasMore(Boolean hasMore) { + this.hasMore = hasMore; + } + + /** + * Gets the list of webhook data. + * + * @return The list of webhook data. + */ + public List getData() { + return data; + } + + /** + * Sets the list of webhook data. + * + * @param data The list of webhook data. + */ + public void setData(List data) { + this.data = data; + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/RemoveWebhookResponseSuccess.java b/src/main/java/com/resend/services/webhooks/model/RemoveWebhookResponseSuccess.java new file mode 100644 index 0000000..81cbc67 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/RemoveWebhookResponseSuccess.java @@ -0,0 +1,91 @@ +package com.resend.services.webhooks.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a successful webhook removal response. + */ +public class RemoveWebhookResponseSuccess { + + @JsonProperty("object") + private String object; + + @JsonProperty("id") + private String id; + + @JsonProperty("deleted") + private Boolean deleted; + + /** + * Default constructor. + */ + public RemoveWebhookResponseSuccess() { + } + + /** + * Constructor with all fields. + * + * @param object The object type (should be "webhook"). + * @param id The webhook ID. + * @param deleted Whether the webhook was deleted. + */ + public RemoveWebhookResponseSuccess(String object, String id, Boolean deleted) { + this.object = object; + this.id = id; + this.deleted = deleted; + } + + /** + * Gets the object type. + * + * @return The object type. + */ + public String getObject() { + return object; + } + + /** + * Sets the object type. + * + * @param object The object type. + */ + public void setObject(String object) { + this.object = object; + } + + /** + * Gets the webhook ID. + * + * @return The webhook ID. + */ + public String getId() { + return id; + } + + /** + * Sets the webhook ID. + * + * @param id The webhook ID. + */ + public void setId(String id) { + this.id = id; + } + + /** + * Gets whether the webhook was deleted. + * + * @return Whether the webhook was deleted. + */ + public Boolean getDeleted() { + return deleted; + } + + /** + * Sets whether the webhook was deleted. + * + * @param deleted Whether the webhook was deleted. + */ + public void setDeleted(Boolean deleted) { + this.deleted = deleted; + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/UpdateWebhookOptions.java b/src/main/java/com/resend/services/webhooks/model/UpdateWebhookOptions.java new file mode 100644 index 0000000..fa92df9 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/UpdateWebhookOptions.java @@ -0,0 +1,123 @@ +package com.resend.services.webhooks.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Represents the options for updating a webhook. + */ +public class UpdateWebhookOptions { + + @JsonProperty("endpoint") + private final String endpoint; + + @JsonProperty("events") + private final List events; + + @JsonProperty("status") + private final WebhookStatus status; + + private UpdateWebhookOptions(Builder builder) { + this.endpoint = builder.endpoint; + this.events = builder.events; + this.status = builder.status; + } + + /** + * Gets the webhook endpoint URL. + * + * @return The endpoint URL. + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Gets the list of events that will trigger the webhook. + * + * @return The list of webhook events. + */ + public List getEvents() { + return events; + } + + /** + * Gets the webhook status. + * + * @return The webhook status. + */ + public WebhookStatus getStatus() { + return status; + } + + /** + * Creates a new builder instance. + * + * @return A new Builder instance. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for UpdateWebhookOptions. + */ + public static class Builder { + private String endpoint; + private List events; + private WebhookStatus status; + + /** + * Sets the webhook endpoint URL. + * + * @param endpoint The endpoint URL. + * @return This builder instance. + */ + public Builder endpoint(String endpoint) { + this.endpoint = endpoint; + return this; + } + + /** + * Sets the list of events that will trigger the webhook. + * + * @param events The list of webhook events. + * @return This builder instance. + */ + public Builder events(List events) { + this.events = events; + return this; + } + + /** + * Sets the events that will trigger the webhook (varargs). + * + * @param events The webhook events. + * @return This builder instance. + */ + public Builder events(WebhookEvent... events) { + this.events = java.util.Arrays.asList(events); + return this; + } + + /** + * Sets the webhook status. + * + * @param status The webhook status. + * @return This builder instance. + */ + public Builder status(WebhookStatus status) { + this.status = status; + return this; + } + + /** + * Builds the UpdateWebhookOptions instance. + * + * @return A new UpdateWebhookOptions instance. + */ + public UpdateWebhookOptions build() { + return new UpdateWebhookOptions(this); + } + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/UpdateWebhookResponseSuccess.java b/src/main/java/com/resend/services/webhooks/model/UpdateWebhookResponseSuccess.java new file mode 100644 index 0000000..e635374 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/UpdateWebhookResponseSuccess.java @@ -0,0 +1,51 @@ +package com.resend.services.webhooks.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a successful webhook update response. + */ +public class UpdateWebhookResponseSuccess { + + @JsonProperty("object") + private String object; + + @JsonProperty("id") + private String id; + + /** + * Gets the object type (should be "webhook"). + * + * @return The object type. + */ + public String getObject() { + return object; + } + + /** + * Sets the object type. + * + * @param object The object type. + */ + public void setObject(String object) { + this.object = object; + } + + /** + * Gets the webhook ID. + * + * @return The webhook ID. + */ + public String getId() { + return id; + } + + /** + * Sets the webhook ID. + * + * @param id The webhook ID. + */ + public void setId(String id) { + this.id = id; + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/VerifyWebhookOptions.java b/src/main/java/com/resend/services/webhooks/model/VerifyWebhookOptions.java new file mode 100644 index 0000000..05f96f6 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/VerifyWebhookOptions.java @@ -0,0 +1,121 @@ +package com.resend.services.webhooks.model; + +import java.util.HashMap; +import java.util.Map; + +/** + * Represents the options for verifying a webhook signature. + */ +public class VerifyWebhookOptions { + + private final String payload; + private final Map headers; + private final String secret; + + private VerifyWebhookOptions(Builder builder) { + this.payload = builder.payload; + this.headers = new HashMap(builder.headers); + this.secret = builder.secret; + } + + /** + * Gets the raw webhook payload (request body). + * + * @return The payload string. + */ + public String getPayload() { + return payload; + } + + /** + * Gets the webhook headers containing signature information. + * + * @return A map of header names to values. + */ + public Map getHeaders() { + return new HashMap(headers); + } + + /** + * Gets the webhook signing secret. + * + * @return The secret string (including whsec_ prefix). + */ + public String getSecret() { + return secret; + } + + /** + * Creates a new builder instance. + * + * @return A new Builder instance. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for VerifyWebhookOptions. + */ + public static class Builder { + private String payload; + private Map headers = new HashMap(); + private String secret; + + /** + * Sets the raw webhook payload (request body). + * + * @param payload The payload string. + * @return This builder instance. + */ + public Builder payload(String payload) { + this.payload = payload; + return this; + } + + /** + * Adds a single header to the webhook headers. + * + * @param name The header name. + * @param value The header value. + * @return This builder instance. + */ + public Builder addHeader(String name, String value) { + this.headers.put(name, value); + return this; + } + + /** + * Adds multiple headers to the webhook headers. + * + * @param headers A map of header names to values. + * @return This builder instance. + */ + public Builder addHeaders(Map headers) { + if (headers != null) { + this.headers.putAll(headers); + } + return this; + } + + /** + * Sets the webhook signing secret. + * + * @param secret The secret string (including whsec_ prefix). + * @return This builder instance. + */ + public Builder secret(String secret) { + this.secret = secret; + return this; + } + + /** + * Builds the VerifyWebhookOptions instance. + * + * @return A new VerifyWebhookOptions instance. + */ + public VerifyWebhookOptions build() { + return new VerifyWebhookOptions(this); + } + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/WebhookEvent.java b/src/main/java/com/resend/services/webhooks/model/WebhookEvent.java new file mode 100644 index 0000000..e4733f4 --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/WebhookEvent.java @@ -0,0 +1,40 @@ +package com.resend.services.webhooks.model; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Represents the types of events that can trigger a webhook. + */ +public enum WebhookEvent { + EMAIL_SENT("email.sent"), + EMAIL_DELIVERED("email.delivered"), + EMAIL_DELIVERY_DELAYED("email.delivery_delayed"), + EMAIL_COMPLAINED("email.complained"), + EMAIL_BOUNCED("email.bounced"), + EMAIL_OPENED("email.opened"), + EMAIL_CLICKED("email.clicked"), + EMAIL_RECEIVED("email.received"), + EMAIL_FAILED("email.failed"), + CONTACT_CREATED("contact.created"), + CONTACT_UPDATED("contact.updated"), + CONTACT_DELETED("contact.deleted"), + DOMAIN_CREATED("domain.created"), + DOMAIN_UPDATED("domain.updated"), + DOMAIN_DELETED("domain.deleted"); + + private final String value; + + WebhookEvent(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/com/resend/services/webhooks/model/WebhookStatus.java b/src/main/java/com/resend/services/webhooks/model/WebhookStatus.java new file mode 100644 index 0000000..0756eee --- /dev/null +++ b/src/main/java/com/resend/services/webhooks/model/WebhookStatus.java @@ -0,0 +1,27 @@ +package com.resend.services.webhooks.model; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Represents the status of a webhook. + */ +public enum WebhookStatus { + ENABLED("enabled"), + DISABLED("disabled"); + + private final String value; + + WebhookStatus(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/test/java/com/resend/services/util/WebhooksUtil.java b/src/test/java/com/resend/services/util/WebhooksUtil.java new file mode 100644 index 0000000..21d7828 --- /dev/null +++ b/src/test/java/com/resend/services/util/WebhooksUtil.java @@ -0,0 +1,93 @@ +package com.resend.services.util; + +import com.resend.services.webhooks.dto.WebhookDTO; +import com.resend.services.webhooks.model.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class WebhooksUtil { + + public static final CreateWebhookOptions createWebhookRequest() { + return CreateWebhookOptions.builder() + .endpoint("https://example.com/webhook") + .events( + WebhookEvent.EMAIL_SENT, + WebhookEvent.EMAIL_DELIVERED, + WebhookEvent.EMAIL_BOUNCED + ) + .build(); + } + + public static final CreateWebhookResponseSuccess createWebhookResponse() { + CreateWebhookResponseSuccess response = new CreateWebhookResponseSuccess(); + response.setObject("webhook"); + response.setId("4dd369bc-aa82-4ff3-97de-514ae3000ee0"); + response.setSigningSecret("whsec_xxxxxxxxxx"); + return response; + } + + public static final UpdateWebhookOptions updateWebhookRequest() { + return UpdateWebhookOptions.builder() + .endpoint("https://example.com/updated-webhook") + .events( + WebhookEvent.EMAIL_SENT, + WebhookEvent.EMAIL_DELIVERED + ) + .status(WebhookStatus.ENABLED) + .build(); + } + + public static final UpdateWebhookResponseSuccess updateWebhookResponse() { + UpdateWebhookResponseSuccess response = new UpdateWebhookResponseSuccess(); + response.setObject("webhook"); + response.setId("4dd369bc-aa82-4ff3-97de-514ae3000ee0"); + return response; + } + + public static final GetWebhookResponseSuccess getWebhookResponse() { + GetWebhookResponseSuccess webhook = new GetWebhookResponseSuccess(); + webhook.setObject("webhook"); + webhook.setId("4dd369bc-aa82-4ff3-97de-514ae3000ee0"); + webhook.setCreatedAt("2023-08-22T15:28:00.000Z"); + webhook.setStatus(WebhookStatus.ENABLED); + webhook.setEndpoint("https://webhook.example.com/handler"); + webhook.setEvents(Arrays.asList("email.sent", "email.received")); + webhook.setSigningSecret("whsec_xxxxxxxxxx"); + return webhook; + } + + public static final ListWebhooksResponseSuccess listWebhooksResponse() { + List data = new ArrayList<>(); + + WebhookDTO webhook1 = new WebhookDTO( + "7ab123cd-ef45-6789-abcd-ef0123456789", + "2023-09-10T10:15:30.000Z", + WebhookStatus.DISABLED, + "https://first-webhook.example.com/handler", + Arrays.asList("email.delivered", "email.bounced") + ); + + WebhookDTO webhook2 = new WebhookDTO( + "4dd369bc-aa82-4ff3-97de-514ae3000ee0", + "2023-08-22T15:28:00.000Z", + WebhookStatus.ENABLED, + "https://second-webhook.example.com/receive", + Arrays.asList("email.received") + ); + + data.add(webhook1); + data.add(webhook2); + + return new ListWebhooksResponseSuccess("list", false, data); + } + + public static final RemoveWebhookResponseSuccess removeWebhookResponse() { + return new RemoveWebhookResponseSuccess( + "webhook", + "4dd369bc-aa82-4ff3-97de-514ae3000ee0", + true + ); + } +} diff --git a/src/test/java/com/resend/services/webhooks/WebhooksTest.java b/src/test/java/com/resend/services/webhooks/WebhooksTest.java new file mode 100644 index 0000000..444f4db --- /dev/null +++ b/src/test/java/com/resend/services/webhooks/WebhooksTest.java @@ -0,0 +1,316 @@ +package com.resend.services.webhooks; + +import com.resend.core.exception.ResendException; +import com.resend.core.net.ListParams; +import com.resend.services.util.WebhooksUtil; +import com.resend.services.webhooks.dto.WebhookDTO; +import com.resend.services.webhooks.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class WebhooksTest { + + @Mock + private Webhooks webhooks; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + webhooks = mock(Webhooks.class); + } + + @Test + public void testCreateWebhook_Success() throws ResendException { + CreateWebhookResponseSuccess expectedResponse = WebhooksUtil.createWebhookResponse(); + + CreateWebhookOptions request = WebhooksUtil.createWebhookRequest(); + when(webhooks.create(request)) + .thenReturn(expectedResponse); + + CreateWebhookResponseSuccess response = webhooks.create(request); + + assertNotNull(response); + assertEquals(expectedResponse.getId(), response.getId()); + assertEquals(expectedResponse.getObject(), response.getObject()); + assertEquals(expectedResponse.getSigningSecret(), response.getSigningSecret()); + assertEquals("webhook", response.getObject()); + assertEquals("4dd369bc-aa82-4ff3-97de-514ae3000ee0", response.getId()); + } + + @Test + public void testUpdateWebhook_Success() throws ResendException { + UpdateWebhookResponseSuccess expectedResponse = WebhooksUtil.updateWebhookResponse(); + String webhookId = "4dd369bc-aa82-4ff3-97de-514ae3000ee0"; + + UpdateWebhookOptions request = WebhooksUtil.updateWebhookRequest(); + when(webhooks.update(webhookId, request)) + .thenReturn(expectedResponse); + + UpdateWebhookResponseSuccess response = webhooks.update(webhookId, request); + + assertNotNull(response); + assertEquals(expectedResponse.getId(), response.getId()); + assertEquals(expectedResponse.getObject(), response.getObject()); + assertEquals("webhook", response.getObject()); + assertEquals("4dd369bc-aa82-4ff3-97de-514ae3000ee0", response.getId()); + } + + @Test + public void testGetWebhook_Success() throws ResendException { + GetWebhookResponseSuccess expectedWebhook = WebhooksUtil.getWebhookResponse(); + + when(webhooks.get(expectedWebhook.getId())) + .thenReturn(expectedWebhook); + + GetWebhookResponseSuccess response = webhooks.get(expectedWebhook.getId()); + + assertNotNull(response); + assertEquals(expectedWebhook, response); + assertEquals(expectedWebhook.getId(), response.getId()); + assertEquals("webhook", response.getObject()); + assertEquals(WebhookStatus.ENABLED, response.getStatus()); + assertEquals("https://webhook.example.com/handler", response.getEndpoint()); + } + + @Test + public void testListWebhooks_Success() throws ResendException { + ListWebhooksResponseSuccess expectedResponse = WebhooksUtil.listWebhooksResponse(); + + when(webhooks.list()).thenReturn(expectedResponse); + + ListWebhooksResponseSuccess response = webhooks.list(); + + assertNotNull(response); + assertEquals(expectedResponse.getData().size(), response.getData().size()); + assertEquals(2, response.getData().size()); + assertEquals("list", response.getObject()); + assertFalse(response.hasMore()); + } + + @Test + public void testListWebhooksWithPagination_Success() throws ResendException { + ListParams params = ListParams.builder().limit(1).build(); + ListWebhooksResponseSuccess expectedResponse = WebhooksUtil.listWebhooksResponse(); + WebhookDTO paginatedData = expectedResponse.getData().get(0); + ListWebhooksResponseSuccess paginatedResponse = new ListWebhooksResponseSuccess( + "list", + true, + java.util.Collections.singletonList(paginatedData) + ); + + when(webhooks.list(params)).thenReturn(paginatedResponse); + + ListWebhooksResponseSuccess response = webhooks.list(params); + + assertNotNull(response); + assertEquals(1, response.getData().size()); + assertTrue(response.hasMore()); + } + + @Test + public void testRemoveWebhook_Success() throws ResendException { + RemoveWebhookResponseSuccess expectedResponse = WebhooksUtil.removeWebhookResponse(); + + when(webhooks.remove(expectedResponse.getId())) + .thenReturn(expectedResponse); + + RemoveWebhookResponseSuccess response = webhooks.remove(expectedResponse.getId()); + + assertNotNull(response); + assertEquals(expectedResponse, response); + assertEquals(expectedResponse.getId(), response.getId()); + assertEquals("webhook", response.getObject()); + assertTrue(response.getDeleted()); + } + + @Test + public void testVerifyWebhook_Success() throws Exception { + Webhooks webhooksService = new Webhooks("test-api-key"); + + // Test data + String secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + String payload = "{\"type\":\"email.sent\",\"created_at\":\"2024-01-01T00:00:00.000Z\"}"; + String msgId = "msg_test123"; + long currentTimestamp = System.currentTimeMillis() / 1000; + String timestamp = String.valueOf(currentTimestamp); + + // Generate valid signature + String signedContent = msgId + "." + timestamp + "." + payload; + String secretKey = secret.substring(6); // Remove "whsec_" prefix + byte[] decodedSecret = Base64.getDecoder().decode(secretKey); + + Mac hmac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(decodedSecret, "HmacSHA256"); + hmac.init(secretKeySpec); + byte[] hash = hmac.doFinal(signedContent.getBytes("UTF-8")); + String signature = "v1," + Base64.getEncoder().encodeToString(hash); + + // Create verification options + VerifyWebhookOptions options = VerifyWebhookOptions.builder() + .payload(payload) + .addHeader("svix-id", msgId) + .addHeader("svix-timestamp", timestamp) + .addHeader("svix-signature", signature) + .secret(secret) + .build(); + + // Should not throw exception + assertDoesNotThrow(() -> webhooksService.verify(options)); + } + + @Test + public void testVerifyWebhook_InvalidSignature() { + Webhooks webhooksService = new Webhooks("test-api-key"); + + String secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + String payload = "{\"type\":\"email.sent\",\"created_at\":\"2024-01-01T00:00:00.000Z\"}"; + String msgId = "msg_test123"; + long currentTimestamp = System.currentTimeMillis() / 1000; + String timestamp = String.valueOf(currentTimestamp); + + // Invalid signature + String invalidSignature = "v1,invalid_signature_here"; + + VerifyWebhookOptions options = VerifyWebhookOptions.builder() + .payload(payload) + .addHeader("svix-id", msgId) + .addHeader("svix-timestamp", timestamp) + .addHeader("svix-signature", invalidSignature) + .secret(secret) + .build(); + + ResendException exception = assertThrows(ResendException.class, () -> { + webhooksService.verify(options); + }); + + assertEquals(401, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("signature verification failed")); + } + + @Test + public void testVerifyWebhook_ExpiredTimestamp() { + Webhooks webhooksService = new Webhooks("test-api-key"); + + String secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + String payload = "{\"type\":\"email.sent\"}"; + String msgId = "msg_test123"; + + // Timestamp from 10 minutes ago (should fail 5-minute tolerance) + long expiredTimestamp = (System.currentTimeMillis() / 1000) - 600; + String timestamp = String.valueOf(expiredTimestamp); + + VerifyWebhookOptions options = VerifyWebhookOptions.builder() + .payload(payload) + .addHeader("svix-id", msgId) + .addHeader("svix-timestamp", timestamp) + .addHeader("svix-signature", "v1,dummy_signature") + .secret(secret) + .build(); + + ResendException exception = assertThrows(ResendException.class, () -> { + webhooksService.verify(options); + }); + + assertEquals(400, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("outside the tolerance window")); + } + + @Test + public void testVerifyWebhook_NullOptions() { + Webhooks webhooksService = new Webhooks("test-api-key"); + + ResendException exception = assertThrows(ResendException.class, () -> { + webhooksService.verify(null); + }); + + assertEquals(400, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("cannot be null")); + } + + @Test + public void testVerifyWebhook_NullPayload() { + Webhooks webhooksService = new Webhooks("test-api-key"); + + VerifyWebhookOptions options = VerifyWebhookOptions.builder() + .payload(null) + .addHeader("svix-id", "msg_123") + .addHeader("svix-timestamp", String.valueOf(System.currentTimeMillis() / 1000)) + .addHeader("svix-signature", "v1,signature") + .secret("whsec_test") + .build(); + + ResendException exception = assertThrows(ResendException.class, () -> { + webhooksService.verify(options); + }); + + assertEquals(400, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("payload cannot be null")); + } + + @Test + public void testVerifyWebhook_EmptySecret() { + Webhooks webhooksService = new Webhooks("test-api-key"); + + VerifyWebhookOptions options = VerifyWebhookOptions.builder() + .payload("{\"test\":\"data\"}") + .addHeader("svix-id", "msg_123") + .addHeader("svix-timestamp", String.valueOf(System.currentTimeMillis() / 1000)) + .addHeader("svix-signature", "v1,signature") + .secret("") + .build(); + + ResendException exception = assertThrows(ResendException.class, () -> { + webhooksService.verify(options); + }); + + assertEquals(400, exception.getStatusCode()); + assertTrue(exception.getMessage().contains("secret cannot be null or empty")); + } + + @Test + public void testVerifyWebhook_MultipleSignatures() throws Exception { + // Test that verification works with multiple signatures in the header + Webhooks webhooksService = new Webhooks("test-api-key"); + + String secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + String payload = "{\"type\":\"email.delivered\"}"; + String msgId = "msg_multi_sig"; + long currentTimestamp = System.currentTimeMillis() / 1000; + String timestamp = String.valueOf(currentTimestamp); + + // Generate valid signature + String signedContent = msgId + "." + timestamp + "." + payload; + String secretKey = secret.substring(6); + byte[] decodedSecret = Base64.getDecoder().decode(secretKey); + + Mac hmac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(decodedSecret, "HmacSHA256"); + hmac.init(secretKeySpec); + byte[] hash = hmac.doFinal(signedContent.getBytes("UTF-8")); + String validSignature = Base64.getEncoder().encodeToString(hash); + + // Multiple signatures: one invalid, one valid + String multipleSignatures = "v1,invalid_sig v1," + validSignature; + + VerifyWebhookOptions options = VerifyWebhookOptions.builder() + .payload(payload) + .addHeader("svix-id", msgId) + .addHeader("svix-timestamp", timestamp) + .addHeader("svix-signature", multipleSignatures) + .secret(secret) + .build(); + + // Should not throw exception (one valid signature is enough) + assertDoesNotThrow(() -> webhooksService.verify(options)); + } +}