diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fcfa96..7c9205b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2026-05-28 + +### Added + +- **Campaigns** service covering six endpoints, reached via `lettr.campaigns()`: + - `list()` / `list(ListCampaignsParams)` — paginated list with optional `status` filter (`GET /campaigns`) + - `get(id)` — single campaign with rendered `htmlContent` (`GET /campaigns/{id}`) + - `listEvents(id)` / `listEvents(id, ListCampaignEventsParams)` — cursor-paginated engagement events (`GET /campaigns/{id}/events`) + - `send(id)` — dispatch a draft now (`POST /campaigns/{id}/send`) + - `schedule(id, ScheduleCampaignOptions)` — schedule or reschedule (`POST /campaigns/{id}/schedule`) + - `unschedule(id)` — cancel a scheduled send (`POST /campaigns/{id}/unschedule`) +- `CampaignStatus` and `CampaignEventType` enums (with `getValue()` for query building), plus `CampaignView`, `CampaignStats`, `CampaignEvent`, and `CampaignPagination` models +- Unit tests covering campaign Gson deserialization, enum round-tripping, query-param building, and argument validation + +### Changed + +- `HttpClient.post(String path, Type responseType)` — new no-body overload, used by `Campaigns.send` / `unschedule` instead of passing a dummy empty map +- `HttpClient.encodePathSegment(String)` — percent-encodes path segments so callers can safely interpolate arbitrary identifiers; adopted by `Campaigns` (id-taking methods) and `Emails.get` / `getScheduled` / `cancelScheduled` +- `USER_AGENT` now reads the SDK version from a generated `com/lettr/version.properties` resource (templated by Gradle from `gradle.properties`) instead of a hardcoded string — single source of truth across releases +- `OffsetPagination` (`com.lettr.core.model`) — shared pagination shape for new code; `ListCampaignsResponse` uses it. `AudiencePagination` is unchanged and remains the return type of the audience list responses (both classes have identical shape) +- `PageParams` (`com.lettr.core.model`) — shared listing parameters for new code; `ListCampaignsParams` composes it. `com.lettr.services.audience.model.PageParams` is unchanged and continues to work for existing audience usage (both classes are behaviourally identical) +- `WireValues.of(Enum)` (`com.lettr.core.util`) reads `@SerializedName` reflectively for URL query building, so enums declare each wire value exactly once +- `Args.requireNonEmpty(name, value)` / `Args.requireNonNull(name, value)` (`com.lettr.core.util`) — shared validators; adopted by `Campaigns` and `ScheduleCampaignOptions` +- `getCampaigns()` / `getEvents()` (and the audience list responses) defensively return `Collections.emptyList()` when the API omits the list field, so `@Nonnull` getters never return null + +### Notes + +- `CampaignView.htmlContent` is only populated by `get(...)`; it is `null` on list, send, schedule, and unschedule responses +- Campaign events use cursor pagination — keep requesting with `getNextCursor()` until it is `null`; a filtered page may return an empty `events` list with a non-null cursor, meaning more pages remain + ## [1.2.0] - 2026-05-25 ### Added diff --git a/README.md b/README.md index 45d90ae..b259471 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The official Java SDK for the [Lettr](https://lettr.com) Email API. Send transac ### Gradle ```groovy -implementation 'com.lettr:lettr-java:1.2.0' +implementation 'com.lettr:lettr-java:1.3.0' ``` ### Maven @@ -16,7 +16,7 @@ implementation 'com.lettr:lettr-java:1.2.0' com.lettr lettr-java - 1.2.0 + 1.3.0 ``` @@ -425,6 +425,99 @@ System.out.println("Subject: " + html.getSubject()); System.out.println("HTML: " + html.getHtml()); ``` +## Campaigns + +### List Campaigns + +```java +import com.lettr.services.campaigns.model.*; + +// List with default pagination +ListCampaignsResponse campaigns = lettr.campaigns().list(); + +// List with filters +ListCampaignsResponse drafts = lettr.campaigns().list( + ListCampaignsParams.builder() + .status(CampaignStatus.DRAFT) + .perPage(50) + .build() +); + +for (CampaignView c : drafts.getCampaigns()) { + System.out.println(c.getName() + " - " + c.getStatus() + " (sent: " + c.getSentCount() + ")"); +} +System.out.println("Page " + drafts.getPagination().getCurrentPage() + + " of " + drafts.getPagination().getLastPage()); +``` + +### Get a Campaign + +```java +CampaignView campaign = lettr.campaigns().get("0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0"); + +System.out.println("Status: " + campaign.getStatus()); +System.out.println("HTML: " + campaign.getHtmlContent()); // only populated by get(...) + +CampaignStats stats = campaign.getStats(); +System.out.println("Opens: " + stats.getUniqueOpens() + " / Clicks: " + stats.getUniqueClicks()); +``` + +### List Campaign Events + +Events use cursor-based pagination. Keep requesting with the returned cursor until +it is `null`. A filtered page may come back with no events but a non-null cursor — +that means more pages remain, so keep going. + +```java +String cursor = null; +do { + ListCampaignEventsResponse page = lettr.campaigns().listEvents( + campaign.getId(), + ListCampaignEventsParams.builder() + .eventType(CampaignEventType.CLICK) + .email("user@example.com") + .startDate("2026-05-01") + .limit(100) + .cursor(cursor) + .build() + ); + + for (CampaignEvent event : page.getEvents()) { + System.out.println(event.getEventType() + " at " + event.getTimestamp() + + " -> " + event.getTargetLinkUrl()); + } + + cursor = page.getNextCursor(); +} while (cursor != null); +``` + +### Send a Campaign + +```java +// Immediately dispatches a draft; the campaign transitions to "preparing". +CampaignView sending = lettr.campaigns().send(campaign.getId()); +System.out.println("Status: " + sending.getStatus()); +``` + +### Schedule a Campaign + +```java +// Schedule (or reschedule) for future delivery. Include a timezone offset. +CampaignView scheduled = lettr.campaigns().schedule( + campaign.getId(), + ScheduleCampaignOptions.of("2026-06-01T09:00:00+00:00") +); +System.out.println("Scheduled for: " + scheduled.getScheduledAt()); +``` + +### Unschedule a Campaign + +```java +// Cancel a scheduled send, returning the campaign to "draft". +CampaignView draft = lettr.campaigns().unschedule(campaign.getId()); +System.out.println("Status: " + draft.getStatus()); +``` + ## System ### Health Check diff --git a/build.gradle b/build.gradle index d69cb94..64492ec 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,12 @@ test { useJUnitPlatform() } +processResources { + filesMatching('com/lettr/version.properties') { + expand(version: project.version) + } +} + publishing { publications { mavenJava(MavenPublication) { diff --git a/gradle.properties b/gradle.properties index 4ec527c..403c4d6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP=com.lettr -VERSION=1.2.0 +VERSION=1.3.0 POM_ARTIFACT_ID=lettr-java POM_NAME=Lettr Java SDK POM_DESCRIPTION=Java SDK for the Lettr Email API diff --git a/src/main/java/com/lettr/Lettr.java b/src/main/java/com/lettr/Lettr.java index 225ba81..c518dae 100644 --- a/src/main/java/com/lettr/Lettr.java +++ b/src/main/java/com/lettr/Lettr.java @@ -1,6 +1,7 @@ package com.lettr; import com.lettr.services.audience.Audience; +import com.lettr.services.campaigns.Campaigns; import com.lettr.services.domains.Domains; import com.lettr.services.emails.Emails; import com.lettr.services.projects.Projects; @@ -67,4 +68,7 @@ public Lettr(@Nonnull String apiKey) { /** Returns the Audience namespace for managing lists, contacts, topics, properties, and segments. */ @Nonnull public Audience audience() { return new Audience(apiKey); } + + /** Returns the Campaigns service for listing, sending, and scheduling campaigns. */ + @Nonnull public Campaigns campaigns() { return new Campaigns(apiKey); } } diff --git a/src/main/java/com/lettr/core/model/OffsetPagination.java b/src/main/java/com/lettr/core/model/OffsetPagination.java new file mode 100644 index 0000000..4e01129 --- /dev/null +++ b/src/main/java/com/lettr/core/model/OffsetPagination.java @@ -0,0 +1,45 @@ +package com.lettr.core.model; + +import com.google.gson.annotations.SerializedName; + +/** + * Offset-based pagination metadata returned alongside paginated list responses + * across the SDK (audience, campaigns, …). + */ +public class OffsetPagination { + + private int total; + + @SerializedName("per_page") + private int perPage; + + @SerializedName("current_page") + private int currentPage; + + @SerializedName("last_page") + private int lastPage; + + public int getTotal() { + return total; + } + + public int getPerPage() { + return perPage; + } + + public int getCurrentPage() { + return currentPage; + } + + public int getLastPage() { + return lastPage; + } + + @Override + public String toString() { + return "OffsetPagination{total=" + total + + ", perPage=" + perPage + + ", currentPage=" + currentPage + + ", lastPage=" + lastPage + '}'; + } +} diff --git a/src/main/java/com/lettr/core/model/PageParams.java b/src/main/java/com/lettr/core/model/PageParams.java new file mode 100644 index 0000000..f465d22 --- /dev/null +++ b/src/main/java/com/lettr/core/model/PageParams.java @@ -0,0 +1,71 @@ +package com.lettr.core.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Shared {@code page} / {@code per_page} parameters used by every listing + * endpoint that supports offset-based pagination. Composable into service-specific + * param classes that add further filters (see e.g. {@code ListCampaignsParams}). + */ +public class PageParams { + + private final Integer page; + private final Integer perPage; + + private PageParams(Builder builder) { + this.page = builder.page; + this.perPage = builder.perPage; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nullable + public Integer getPage() { + return page; + } + + @Nullable + public Integer getPerPage() { + return perPage; + } + + @Nonnull + public Map toQueryParams() { + Map params = new LinkedHashMap<>(); + if (page != null) params.put("page", page.toString()); + if (perPage != null) params.put("per_page", perPage.toString()); + return params; + } + + public static class Builder { + private Integer page; + private Integer perPage; + + private Builder() {} + + /** (optional) Page number (min 1, default 1). */ + @Nonnull + public Builder page(@Nullable Integer page) { + this.page = page; + return this; + } + + /** (optional) Items per page (1–100, default 20). */ + @Nonnull + public Builder perPage(@Nullable Integer perPage) { + this.perPage = perPage; + return this; + } + + @Nonnull + public PageParams build() { + return new PageParams(this); + } + } +} diff --git a/src/main/java/com/lettr/core/net/HttpClient.java b/src/main/java/com/lettr/core/net/HttpClient.java index 4350937..7ea1f45 100644 --- a/src/main/java/com/lettr/core/net/HttpClient.java +++ b/src/main/java/com/lettr/core/net/HttpClient.java @@ -11,6 +11,7 @@ import com.lettr.core.exception.LettrValidationException; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.Type; import java.net.URI; import java.net.http.HttpRequest; @@ -20,6 +21,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Properties; /** * Internal HTTP client for communicating with the Lettr API. @@ -27,7 +29,8 @@ public class HttpClient { private static final String BASE_URL = "https://app.lettr.com/api"; - private static final String USER_AGENT = "lettr-java/1.2.0"; + private static final String SDK_VERSION = loadVersion(); + private static final String USER_AGENT = "lettr-java/" + SDK_VERSION; private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); private final String apiKey; @@ -96,6 +99,32 @@ public T post(String path, Object body, Type responseType) throws LettrExcep return execute(request, responseType); } + /** + * Perform a POST request with no body, returning a deserialized response. + * Used by endpoints whose only input is the path parameter (e.g. + * {@code /campaigns/{id}/send}). + * + * @param path API path + * @param responseType the type to deserialize the "data" field into + * @param response data type + * @return deserialized response data + * @throws LettrException on error + */ + public T post(String path, Type responseType) throws LettrException { + String url = buildUrl(path, null); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(DEFAULT_TIMEOUT) + .header("Authorization", "Bearer " + apiKey) + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + return execute(request, responseType); + } + /** * Perform a PUT request with a JSON body. * @@ -235,6 +264,23 @@ public T delete(String path, Object body, Type responseType) throws LettrExc return execute(request, responseType); } + /** + * Percent-encode a single URL path segment so callers can safely interpolate + * arbitrary identifiers ({@code /campaigns/" + encodePathSegment(id)}). + * Encodes reserved characters including {@code /}, {@code ?}, {@code #}, + * and spaces; produces RFC 3986-compatible output. + */ + public static String encodePathSegment(String segment) { + if (segment == null) { + return ""; + } + // URLEncoder is form-encoded ('+' for space, '*' un-encoded); fix to RFC 3986. + return java.net.URLEncoder.encode(segment, java.nio.charset.StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~"); + } + private void executeNoResponse(HttpRequest request) throws LettrException { try { HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); @@ -354,4 +400,22 @@ private String encodeParam(String value) { public Gson getGson() { return gson; } + + private static String loadVersion() { + try (InputStream in = HttpClient.class.getResourceAsStream("/com/lettr/version.properties")) { + if (in == null) { + return "unknown"; + } + Properties props = new Properties(); + props.load(in); + String v = props.getProperty("version"); + // Guard against unprocessed templates: "@version@", "${version}", or empty. + if (v == null || v.isEmpty() || v.startsWith("@") || v.contains("${")) { + return "unknown"; + } + return v; + } catch (IOException e) { + return "unknown"; + } + } } diff --git a/src/main/java/com/lettr/core/util/Args.java b/src/main/java/com/lettr/core/util/Args.java new file mode 100644 index 0000000..f8bf9e2 --- /dev/null +++ b/src/main/java/com/lettr/core/util/Args.java @@ -0,0 +1,40 @@ +package com.lettr.core.util; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Argument validation helpers used at SDK entry points to reject obviously + * invalid input early, with a consistent {@code " is required"} message. + */ +public final class Args { + + private Args() {} + + /** + * Throws {@link IllegalArgumentException} if {@code value} is {@code null} + * or empty. + * + * @param name the parameter name, used in the exception message + * @param value the value to check + * @return {@code value} unchanged, so this can be used as an expression + */ + @Nonnull + public static String requireNonEmpty(@Nonnull String name, @Nullable String value) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(name + " is required"); + } + return value; + } + + /** + * Throws {@link IllegalArgumentException} if {@code value} is {@code null}. + */ + @Nonnull + public static T requireNonNull(@Nonnull String name, @Nullable T value) { + if (value == null) { + throw new IllegalArgumentException(name + " is required"); + } + return value; + } +} diff --git a/src/main/java/com/lettr/core/util/WireValues.java b/src/main/java/com/lettr/core/util/WireValues.java new file mode 100644 index 0000000..e5f2078 --- /dev/null +++ b/src/main/java/com/lettr/core/util/WireValues.java @@ -0,0 +1,37 @@ +package com.lettr.core.util; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import java.lang.reflect.Field; + +/** + * Reads the {@link SerializedName} value of an enum constant for use in URL + * query parameters. Lets enum classes declare each wire value exactly once + * (on {@code @SerializedName}) instead of duplicating it in a {@code value} + * field for query building. + */ +public final class WireValues { + + private WireValues() {} + + /** + * Returns the {@code @SerializedName} value of the given enum constant, + * matching how Gson serializes/deserializes it. Falls back to {@link Enum#name()} + * if the constant has no {@code @SerializedName} annotation. + */ + @Nonnull + public static String of(@Nonnull Enum value) { + if (value == null) { + throw new IllegalArgumentException("enum value is required"); + } + try { + Field field = value.getDeclaringClass().getField(value.name()); + SerializedName annotation = field.getAnnotation(SerializedName.class); + return annotation != null ? annotation.value() : value.name(); + } catch (NoSuchFieldException e) { + // Should be unreachable: Enum#name() always corresponds to a declared field. + return value.name(); + } + } +} diff --git a/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsResponse.java b/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsResponse.java index d0bb5ea..9551454 100644 --- a/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsResponse.java +++ b/src/main/java/com/lettr/services/audience/contacts/model/ListAudienceContactsResponse.java @@ -3,6 +3,7 @@ import com.lettr.services.audience.model.AudiencePagination; import javax.annotation.Nonnull; +import java.util.Collections; import java.util.List; public class ListAudienceContactsResponse { @@ -12,7 +13,7 @@ public class ListAudienceContactsResponse { @Nonnull public List getContacts() { - return contacts; + return contacts != null ? contacts : Collections.emptyList(); } @Nonnull diff --git a/src/main/java/com/lettr/services/audience/lists/model/ListAudienceListsResponse.java b/src/main/java/com/lettr/services/audience/lists/model/ListAudienceListsResponse.java index bcb7f19..e77ef31 100644 --- a/src/main/java/com/lettr/services/audience/lists/model/ListAudienceListsResponse.java +++ b/src/main/java/com/lettr/services/audience/lists/model/ListAudienceListsResponse.java @@ -3,6 +3,7 @@ import com.lettr.services.audience.model.AudiencePagination; import javax.annotation.Nonnull; +import java.util.Collections; import java.util.List; public class ListAudienceListsResponse { @@ -12,7 +13,7 @@ public class ListAudienceListsResponse { @Nonnull public List getLists() { - return lists; + return lists != null ? lists : Collections.emptyList(); } @Nonnull diff --git a/src/main/java/com/lettr/services/audience/model/AudiencePagination.java b/src/main/java/com/lettr/services/audience/model/AudiencePagination.java index cdd4384..c9d451a 100644 --- a/src/main/java/com/lettr/services/audience/model/AudiencePagination.java +++ b/src/main/java/com/lettr/services/audience/model/AudiencePagination.java @@ -4,6 +4,10 @@ /** * Pagination metadata returned alongside paginated audience list responses. + * + *

Identical in shape to {@link com.lettr.core.model.OffsetPagination}; both + * are supported. New code may prefer the core type; existing imports of this + * class continue to work unchanged.

*/ public class AudiencePagination { diff --git a/src/main/java/com/lettr/services/audience/model/PageParams.java b/src/main/java/com/lettr/services/audience/model/PageParams.java index 3d521d5..c030552 100644 --- a/src/main/java/com/lettr/services/audience/model/PageParams.java +++ b/src/main/java/com/lettr/services/audience/model/PageParams.java @@ -6,8 +6,11 @@ import java.util.Map; /** - * Shared page/per_page parameters used by listing endpoints that only - * support pagination (no other filters). + * Shared page/per_page parameters used by the audience listing endpoints. + * + *

Identical in behaviour to {@link com.lettr.core.model.PageParams}; both are + * supported. New code may prefer the core type; existing imports of this class + * continue to work unchanged.

*/ public class PageParams { diff --git a/src/main/java/com/lettr/services/audience/properties/model/ListAudiencePropertiesResponse.java b/src/main/java/com/lettr/services/audience/properties/model/ListAudiencePropertiesResponse.java index 3308f3c..0b190fc 100644 --- a/src/main/java/com/lettr/services/audience/properties/model/ListAudiencePropertiesResponse.java +++ b/src/main/java/com/lettr/services/audience/properties/model/ListAudiencePropertiesResponse.java @@ -3,6 +3,7 @@ import com.lettr.services.audience.model.AudiencePagination; import javax.annotation.Nonnull; +import java.util.Collections; import java.util.List; public class ListAudiencePropertiesResponse { @@ -10,7 +11,7 @@ public class ListAudiencePropertiesResponse { private List properties; private AudiencePagination pagination; - @Nonnull public List getProperties() { return properties; } + @Nonnull public List getProperties() { return properties != null ? properties : Collections.emptyList(); } @Nonnull public AudiencePagination getPagination() { return pagination; } @Override diff --git a/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsResponse.java b/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsResponse.java index bec03ce..442ee8d 100644 --- a/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsResponse.java +++ b/src/main/java/com/lettr/services/audience/segments/model/ListAudienceSegmentsResponse.java @@ -3,6 +3,7 @@ import com.lettr.services.audience.model.AudiencePagination; import javax.annotation.Nonnull; +import java.util.Collections; import java.util.List; public class ListAudienceSegmentsResponse { @@ -10,7 +11,7 @@ public class ListAudienceSegmentsResponse { private List segments; private AudiencePagination pagination; - @Nonnull public List getSegments() { return segments; } + @Nonnull public List getSegments() { return segments != null ? segments : Collections.emptyList(); } @Nonnull public AudiencePagination getPagination() { return pagination; } @Override diff --git a/src/main/java/com/lettr/services/audience/topics/model/ListAudienceTopicsResponse.java b/src/main/java/com/lettr/services/audience/topics/model/ListAudienceTopicsResponse.java index 364cb80..1683716 100644 --- a/src/main/java/com/lettr/services/audience/topics/model/ListAudienceTopicsResponse.java +++ b/src/main/java/com/lettr/services/audience/topics/model/ListAudienceTopicsResponse.java @@ -3,6 +3,7 @@ import com.lettr.services.audience.model.AudiencePagination; import javax.annotation.Nonnull; +import java.util.Collections; import java.util.List; public class ListAudienceTopicsResponse { @@ -10,7 +11,7 @@ public class ListAudienceTopicsResponse { private List topics; private AudiencePagination pagination; - @Nonnull public List getTopics() { return topics; } + @Nonnull public List getTopics() { return topics != null ? topics : Collections.emptyList(); } @Nonnull public AudiencePagination getPagination() { return pagination; } @Override diff --git a/src/main/java/com/lettr/services/campaigns/Campaigns.java b/src/main/java/com/lettr/services/campaigns/Campaigns.java new file mode 100644 index 0000000..029b884 --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/Campaigns.java @@ -0,0 +1,125 @@ +package com.lettr.services.campaigns; + +import com.lettr.core.exception.LettrException; +import com.lettr.core.net.HttpClient; +import com.lettr.core.util.Args; +import com.lettr.services.BaseService; +import com.lettr.services.campaigns.model.*; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Service for listing, inspecting, sending, and scheduling campaigns via the + * Lettr API. Read operations require the {@code campaigns:read} scope; send, + * schedule, and unschedule require {@code campaigns:write} and are not available + * to sandbox keys. + */ +public class Campaigns extends BaseService { + + public Campaigns(@Nonnull String apiKey) { + super(apiKey); + } + + /** + * List campaigns with optional filtering and pagination. + * + * @param params optional query parameters; pass null for defaults + * @return a page of campaigns with pagination metadata + * @throws LettrException if the request fails + */ + @Nonnull + public ListCampaignsResponse list(@Nullable ListCampaignsParams params) throws LettrException { + return httpClient.get("/campaigns", params != null ? params.toQueryParams() : null, ListCampaignsResponse.class); + } + + /** List campaigns with default pagination. */ + @Nonnull + public ListCampaignsResponse list() throws LettrException { + return list(null); + } + + /** + * Retrieve a single campaign, including its rendered HTML content. + * + * @param campaignId the campaign ID + * @return the campaign + * @throws LettrException if the request fails + * @throws IllegalArgumentException if {@code campaignId} is null or empty + */ + @Nonnull + public CampaignView get(@Nonnull String campaignId) throws LettrException { + Args.requireNonEmpty("campaignId", campaignId); + return httpClient.get("/campaigns/" + HttpClient.encodePathSegment(campaignId), null, CampaignView.class); + } + + /** + * List engagement events for a campaign with optional filtering. Uses + * cursor-based pagination via {@link ListCampaignEventsResponse#getNextCursor()}. + * + * @param campaignId the campaign ID + * @param params optional query parameters; pass null for defaults + * @return a page of events with a next-page cursor + * @throws LettrException if the request fails + * @throws IllegalArgumentException if {@code campaignId} is null or empty + */ + @Nonnull + public ListCampaignEventsResponse listEvents(@Nonnull String campaignId, @Nullable ListCampaignEventsParams params) throws LettrException { + Args.requireNonEmpty("campaignId", campaignId); + return httpClient.get("/campaigns/" + HttpClient.encodePathSegment(campaignId) + "/events", + params != null ? params.toQueryParams() : null, + ListCampaignEventsResponse.class); + } + + /** List campaign engagement events with default parameters. */ + @Nonnull + public ListCampaignEventsResponse listEvents(@Nonnull String campaignId) throws LettrException { + return listEvents(campaignId, null); + } + + /** + * Immediately send a draft campaign. Sending is asynchronous; the campaign + * transitions to {@code preparing}. + * + * @param campaignId the campaign ID + * @return the updated campaign + * @throws LettrException if the request fails + * @throws IllegalArgumentException if {@code campaignId} is null or empty + */ + @Nonnull + public CampaignView send(@Nonnull String campaignId) throws LettrException { + Args.requireNonEmpty("campaignId", campaignId); + return httpClient.post("/campaigns/" + HttpClient.encodePathSegment(campaignId) + "/send", CampaignView.class); + } + + /** + * Schedule a campaign for future delivery, or reschedule an already-scheduled + * campaign to a new time. + * + * @param campaignId the campaign ID + * @param options schedule options including {@code scheduledAt} + * @return the updated campaign + * @throws LettrException if the request fails + * @throws IllegalArgumentException if {@code campaignId} is null/empty or {@code options} is null + */ + @Nonnull + public CampaignView schedule(@Nonnull String campaignId, @Nonnull ScheduleCampaignOptions options) throws LettrException { + Args.requireNonEmpty("campaignId", campaignId); + Args.requireNonNull("options", options); + return httpClient.post("/campaigns/" + HttpClient.encodePathSegment(campaignId) + "/schedule", options, CampaignView.class); + } + + /** + * Cancel a scheduled send, returning the campaign to {@code draft}. + * + * @param campaignId the campaign ID + * @return the updated campaign + * @throws LettrException if the request fails + * @throws IllegalArgumentException if {@code campaignId} is null or empty + */ + @Nonnull + public CampaignView unschedule(@Nonnull String campaignId) throws LettrException { + Args.requireNonEmpty("campaignId", campaignId); + return httpClient.post("/campaigns/" + HttpClient.encodePathSegment(campaignId) + "/unschedule", CampaignView.class); + } +} diff --git a/src/main/java/com/lettr/services/campaigns/model/CampaignEvent.java b/src/main/java/com/lettr/services/campaigns/model/CampaignEvent.java new file mode 100644 index 0000000..6b4326f --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/CampaignEvent.java @@ -0,0 +1,82 @@ +package com.lettr.services.campaigns.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A single campaign engagement event (open, click, bounce, etc.). + */ +public class CampaignEvent { + + @SerializedName("event_id") + private String eventId; + + @SerializedName("event_type") + private CampaignEventType eventType; + + private String email; + private String timestamp; + + @SerializedName("bounce_class") + private String bounceClass; + + private String reason; + + @SerializedName("target_link_url") + private String targetLinkUrl; + + @SerializedName("user_agent") + private String userAgent; + + @Nonnull + public String getEventId() { + return eventId; + } + + @Nonnull + public CampaignEventType getEventType() { + return eventType; + } + + @Nonnull + public String getEmail() { + return email; + } + + @Nonnull + public String getTimestamp() { + return timestamp; + } + + /** SparkPost bounce classification code; only set on bounce events. */ + @Nullable + public String getBounceClass() { + return bounceClass; + } + + /** Failure or bounce reason, if any. */ + @Nullable + public String getReason() { + return reason; + } + + /** Clicked link URL; only set on click events. */ + @Nullable + public String getTargetLinkUrl() { + return targetLinkUrl; + } + + /** Recipient user agent; only set on open/click events. */ + @Nullable + public String getUserAgent() { + return userAgent; + } + + @Override + public String toString() { + return "CampaignEvent{eventId='" + eventId + "', eventType=" + eventType + + ", email='" + email + "', timestamp='" + timestamp + "'}"; + } +} diff --git a/src/main/java/com/lettr/services/campaigns/model/CampaignEventType.java b/src/main/java/com/lettr/services/campaigns/model/CampaignEventType.java new file mode 100644 index 0000000..cd80312 --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/CampaignEventType.java @@ -0,0 +1,17 @@ +package com.lettr.services.campaigns.model; + +import com.google.gson.annotations.SerializedName; + +/** + * Type of campaign engagement event. Wire values are defined by {@code @SerializedName}; + * use {@link com.lettr.core.util.WireValues#of(Enum)} to retrieve them. + */ +public enum CampaignEventType { + @SerializedName("injection") INJECTION, + @SerializedName("delivery") DELIVERY, + @SerializedName("bounce") BOUNCE, + @SerializedName("spam_complaint") SPAM_COMPLAINT, + @SerializedName("open") OPEN, + @SerializedName("click") CLICK, + @SerializedName("list_unsubscribe") LIST_UNSUBSCRIBE +} diff --git a/src/main/java/com/lettr/services/campaigns/model/CampaignStats.java b/src/main/java/com/lettr/services/campaigns/model/CampaignStats.java new file mode 100644 index 0000000..c9c39b1 --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/CampaignStats.java @@ -0,0 +1,77 @@ +package com.lettr.services.campaigns.model; + +import com.google.gson.annotations.SerializedName; + +/** + * Aggregated engagement statistics embedded in every campaign response. + */ +public class CampaignStats { + + private int injections; + private int deliveries; + private int bounces; + + @SerializedName("spam_complaints") + private int spamComplaints; + + private int opens; + + @SerializedName("unique_opens") + private int uniqueOpens; + + private int clicks; + + @SerializedName("unique_clicks") + private int uniqueClicks; + + private int unsubscribes; + + public int getInjections() { + return injections; + } + + public int getDeliveries() { + return deliveries; + } + + public int getBounces() { + return bounces; + } + + public int getSpamComplaints() { + return spamComplaints; + } + + public int getOpens() { + return opens; + } + + public int getUniqueOpens() { + return uniqueOpens; + } + + public int getClicks() { + return clicks; + } + + public int getUniqueClicks() { + return uniqueClicks; + } + + public int getUnsubscribes() { + return unsubscribes; + } + + @Override + public String toString() { + return "CampaignStats{injections=" + injections + + ", deliveries=" + deliveries + + ", bounces=" + bounces + + ", spamComplaints=" + spamComplaints + + ", opens=" + opens + + ", uniqueOpens=" + uniqueOpens + + ", clicks=" + clicks + + ", uniqueClicks=" + uniqueClicks + + ", unsubscribes=" + unsubscribes + '}'; + } +} diff --git a/src/main/java/com/lettr/services/campaigns/model/CampaignStatus.java b/src/main/java/com/lettr/services/campaigns/model/CampaignStatus.java new file mode 100644 index 0000000..34497cb --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/CampaignStatus.java @@ -0,0 +1,17 @@ +package com.lettr.services.campaigns.model; + +import com.google.gson.annotations.SerializedName; + +/** + * Lifecycle status of a campaign. Wire values are defined by {@code @SerializedName}; + * use {@link com.lettr.core.util.WireValues#of(Enum)} to retrieve them. + */ +public enum CampaignStatus { + @SerializedName("draft") DRAFT, + @SerializedName("scheduled") SCHEDULED, + @SerializedName("preparing") PREPARING, + @SerializedName("in_review") IN_REVIEW, + @SerializedName("sending") SENDING, + @SerializedName("sent") SENT, + @SerializedName("failed") FAILED +} diff --git a/src/main/java/com/lettr/services/campaigns/model/CampaignView.java b/src/main/java/com/lettr/services/campaigns/model/CampaignView.java new file mode 100644 index 0000000..104699a --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/CampaignView.java @@ -0,0 +1,126 @@ +package com.lettr.services.campaigns.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Represents a single campaign as returned by the API, with embedded engagement + * stats. The {@code htmlContent} field is only populated by + * {@link com.lettr.services.campaigns.Campaigns#get(String)}; it is {@code null} + * on list, send, schedule, and unschedule responses. + */ +public class CampaignView { + + private String id; + private String name; + private String subject; + + @SerializedName("from_email") + private String fromEmail; + + @SerializedName("from_name") + private String fromName; + + @SerializedName("reply_to") + private String replyTo; + + private CampaignStatus status; + + @SerializedName("scheduled_at") + private String scheduledAt; + + @SerializedName("total_recipients") + private Integer totalRecipients; + + @SerializedName("sent_count") + private int sentCount; + + @SerializedName("sent_at") + private String sentAt; + + @SerializedName("created_at") + private String createdAt; + + @SerializedName("html_content") + private String htmlContent; + + private CampaignStats stats; + + @Nonnull + public String getId() { + return id; + } + + @Nonnull + public String getName() { + return name; + } + + @Nullable + public String getSubject() { + return subject; + } + + @Nullable + public String getFromEmail() { + return fromEmail; + } + + @Nullable + public String getFromName() { + return fromName; + } + + @Nullable + public String getReplyTo() { + return replyTo; + } + + @Nonnull + public CampaignStatus getStatus() { + return status; + } + + @Nullable + public String getScheduledAt() { + return scheduledAt; + } + + @Nullable + public Integer getTotalRecipients() { + return totalRecipients; + } + + public int getSentCount() { + return sentCount; + } + + @Nullable + public String getSentAt() { + return sentAt; + } + + @Nonnull + public String getCreatedAt() { + return createdAt; + } + + /** Rendered HTML content. Only present on {@code get}; {@code null} otherwise. */ + @Nullable + public String getHtmlContent() { + return htmlContent; + } + + @Nonnull + public CampaignStats getStats() { + return stats; + } + + @Override + public String toString() { + return "CampaignView{id='" + id + "', name='" + name + "', status=" + status + + ", sentCount=" + sentCount + '}'; + } +} diff --git a/src/main/java/com/lettr/services/campaigns/model/ListCampaignEventsParams.java b/src/main/java/com/lettr/services/campaigns/model/ListCampaignEventsParams.java new file mode 100644 index 0000000..8f41773 --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/ListCampaignEventsParams.java @@ -0,0 +1,105 @@ +package com.lettr.services.campaigns.model; + +import com.lettr.core.util.WireValues; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Parameters for listing campaign engagement events. All fields are optional. + */ +public class ListCampaignEventsParams { + + private final CampaignEventType eventType; + private final String email; + private final String startDate; + private final String endDate; + private final Integer limit; + private final String cursor; + + private ListCampaignEventsParams(Builder builder) { + this.eventType = builder.eventType; + this.email = builder.email; + this.startDate = builder.startDate; + this.endDate = builder.endDate; + this.limit = builder.limit; + this.cursor = builder.cursor; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull + public Map toQueryParams() { + Map params = new LinkedHashMap<>(); + if (eventType != null) params.put("event_type", WireValues.of(eventType)); + if (email != null) params.put("email", email); + if (startDate != null) params.put("start_date", startDate); + if (endDate != null) params.put("end_date", endDate); + if (limit != null) params.put("limit", limit.toString()); + if (cursor != null) params.put("cursor", cursor); + return params; + } + + public static class Builder { + private CampaignEventType eventType; + private String email; + private String startDate; + private String endDate; + private Integer limit; + private String cursor; + + private Builder() {} + + /** (optional) Filters by event type. */ + @Nonnull + public Builder eventType(@Nullable CampaignEventType eventType) { + this.eventType = eventType; + return this; + } + + /** (optional) Filters by recipient email address. */ + @Nonnull + public Builder email(@Nullable String email) { + this.email = email; + return this; + } + + /** (optional) Only events at or after this time (ISO 8601). A date-only value is treated as the start of that day in UTC. */ + @Nonnull + public Builder startDate(@Nullable String startDate) { + this.startDate = startDate; + return this; + } + + /** (optional) Only events at or before this time, inclusive (ISO 8601). A date-only value covers the whole of that day in UTC. */ + @Nonnull + public Builder endDate(@Nullable String endDate) { + this.endDate = endDate; + return this; + } + + /** (optional) Max events per page (1–100, default 25). */ + @Nonnull + public Builder limit(@Nullable Integer limit) { + this.limit = limit; + return this; + } + + /** (optional) Pagination cursor from a previous response. */ + @Nonnull + public Builder cursor(@Nullable String cursor) { + this.cursor = cursor; + return this; + } + + @Nonnull + public ListCampaignEventsParams build() { + return new ListCampaignEventsParams(this); + } + } +} diff --git a/src/main/java/com/lettr/services/campaigns/model/ListCampaignEventsResponse.java b/src/main/java/com/lettr/services/campaigns/model/ListCampaignEventsResponse.java new file mode 100644 index 0000000..cfb7d89 --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/ListCampaignEventsResponse.java @@ -0,0 +1,41 @@ +package com.lettr.services.campaigns.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.List; + +/** + * Response from listing campaign engagement events. Uses cursor-based pagination: + * keep requesting with {@link #getNextCursor()} until it is {@code null}. + * + *

When a filter is applied, a page may come back with an empty {@code events} + * list and a non-null {@code nextCursor} — that means more pages remain, + * not that there are no matching events, so continue paginating until + * {@code nextCursor} is {@code null}.

+ */ +public class ListCampaignEventsResponse { + + private List events; + + @SerializedName("next_cursor") + private String nextCursor; + + @Nonnull + public List getEvents() { + return events != null ? events : Collections.emptyList(); + } + + /** Cursor for the next page, or {@code null} if there are no more results. */ + @Nullable + public String getNextCursor() { + return nextCursor; + } + + @Override + public String toString() { + return "ListCampaignEventsResponse{events=" + events + ", nextCursor='" + nextCursor + "'}"; + } +} diff --git a/src/main/java/com/lettr/services/campaigns/model/ListCampaignsParams.java b/src/main/java/com/lettr/services/campaigns/model/ListCampaignsParams.java new file mode 100644 index 0000000..5de85b9 --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/ListCampaignsParams.java @@ -0,0 +1,70 @@ +package com.lettr.services.campaigns.model; + +import com.lettr.core.model.PageParams; +import com.lettr.core.util.WireValues; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Parameters for listing campaigns. All fields are optional. Composes + * {@link PageParams} for {@code page} / {@code per_page} and adds a + * {@code status} filter. + */ +public class ListCampaignsParams { + + private final PageParams pageParams; + private final CampaignStatus status; + + private ListCampaignsParams(Builder builder) { + this.pageParams = builder.pageParams.build(); + this.status = builder.status; + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + @Nonnull + public Map toQueryParams() { + Map params = new LinkedHashMap<>(pageParams.toQueryParams()); + if (status != null) params.put("status", WireValues.of(status)); + return params; + } + + public static class Builder { + private final PageParams.Builder pageParams = PageParams.builder(); + private CampaignStatus status; + + private Builder() {} + + /** (optional) Page number (min 1, default 1). */ + @Nonnull + public Builder page(@Nullable Integer page) { + pageParams.page(page); + return this; + } + + /** (optional) Items per page (1–100, default 20). */ + @Nonnull + public Builder perPage(@Nullable Integer perPage) { + pageParams.perPage(perPage); + return this; + } + + /** (optional) Filters by campaign status. */ + @Nonnull + public Builder status(@Nullable CampaignStatus status) { + this.status = status; + return this; + } + + @Nonnull + public ListCampaignsParams build() { + return new ListCampaignsParams(this); + } + } +} diff --git a/src/main/java/com/lettr/services/campaigns/model/ListCampaignsResponse.java b/src/main/java/com/lettr/services/campaigns/model/ListCampaignsResponse.java new file mode 100644 index 0000000..d9c2812 --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/ListCampaignsResponse.java @@ -0,0 +1,31 @@ +package com.lettr.services.campaigns.model; + +import com.lettr.core.model.OffsetPagination; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.List; + +/** + * Response from listing campaigns: a page of campaigns plus pagination metadata. + */ +public class ListCampaignsResponse { + + private List campaigns; + private OffsetPagination pagination; + + @Nonnull + public List getCampaigns() { + return campaigns != null ? campaigns : Collections.emptyList(); + } + + @Nonnull + public OffsetPagination getPagination() { + return pagination; + } + + @Override + public String toString() { + return "ListCampaignsResponse{campaigns=" + campaigns + ", pagination=" + pagination + '}'; + } +} diff --git a/src/main/java/com/lettr/services/campaigns/model/ScheduleCampaignOptions.java b/src/main/java/com/lettr/services/campaigns/model/ScheduleCampaignOptions.java new file mode 100644 index 0000000..f1a5045 --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/ScheduleCampaignOptions.java @@ -0,0 +1,34 @@ +package com.lettr.services.campaigns.model; + +import com.google.gson.annotations.SerializedName; +import com.lettr.core.util.Args; + +import javax.annotation.Nonnull; + +/** + * Request body for scheduling (or rescheduling) a campaign. + */ +public class ScheduleCampaignOptions { + + @SerializedName("scheduled_at") + private final String scheduledAt; + + private ScheduleCampaignOptions(String scheduledAt) { + this.scheduledAt = scheduledAt; + } + + /** + * @param scheduledAt future delivery time (ISO 8601). Include a timezone offset + * (e.g. {@code +02:00} or {@code Z}); a value without an offset + * is interpreted as UTC. Must be in the future. + */ + @Nonnull + public static ScheduleCampaignOptions of(@Nonnull String scheduledAt) { + return new ScheduleCampaignOptions(Args.requireNonEmpty("scheduledAt", scheduledAt)); + } + + @Nonnull + public String getScheduledAt() { + return scheduledAt; + } +} diff --git a/src/main/java/com/lettr/services/emails/Emails.java b/src/main/java/com/lettr/services/emails/Emails.java index 4b73832..843cb30 100644 --- a/src/main/java/com/lettr/services/emails/Emails.java +++ b/src/main/java/com/lettr/services/emails/Emails.java @@ -1,6 +1,7 @@ package com.lettr.services.emails; import com.lettr.core.exception.LettrException; +import com.lettr.core.net.HttpClient; import com.lettr.services.BaseService; import com.lettr.services.emails.model.*; @@ -100,7 +101,7 @@ public GetEmailResponse get(@Nonnull String requestId, @Nullable String from, @N if (from != null) params.put("from", from); if (to != null) params.put("to", to); } - return httpClient.get("/emails/" + requestId, params, GetEmailResponse.class); + return httpClient.get("/emails/" + HttpClient.encodePathSegment(requestId), params, GetEmailResponse.class); } /** @@ -128,7 +129,7 @@ public ScheduledEmail getScheduled(@Nonnull String transmissionId) throws LettrE if (transmissionId == null || transmissionId.isEmpty()) { throw new IllegalArgumentException("transmissionId is required"); } - return httpClient.get("/emails/scheduled/" + transmissionId, null, ScheduledEmail.class); + return httpClient.get("/emails/scheduled/" + HttpClient.encodePathSegment(transmissionId), null, ScheduledEmail.class); } /** @@ -142,6 +143,6 @@ public void cancelScheduled(@Nonnull String transmissionId) throws LettrExceptio if (transmissionId == null || transmissionId.isEmpty()) { throw new IllegalArgumentException("transmissionId is required"); } - httpClient.delete("/emails/scheduled/" + transmissionId); + httpClient.delete("/emails/scheduled/" + HttpClient.encodePathSegment(transmissionId)); } } diff --git a/src/main/resources/com/lettr/version.properties b/src/main/resources/com/lettr/version.properties new file mode 100644 index 0000000..a50bf5c --- /dev/null +++ b/src/main/resources/com/lettr/version.properties @@ -0,0 +1 @@ +version=${version} diff --git a/src/test/java/com/lettr/LettrTest.java b/src/test/java/com/lettr/LettrTest.java index 8c84874..f76d816 100644 --- a/src/test/java/com/lettr/LettrTest.java +++ b/src/test/java/com/lettr/LettrTest.java @@ -1,5 +1,7 @@ package com.lettr; +import com.lettr.services.audience.Audience; +import com.lettr.services.campaigns.Campaigns; import com.lettr.services.domains.Domains; import com.lettr.services.emails.Emails; import com.lettr.services.projects.Projects; @@ -66,6 +68,20 @@ void systemReturnsServiceInstance() { assertNotNull(system); } + @Test + void audienceReturnsServiceInstance() { + Lettr lettr = new Lettr("test-api-key"); + Audience audience = lettr.audience(); + assertNotNull(audience); + } + + @Test + void campaignsReturnsServiceInstance() { + Lettr lettr = new Lettr("test-api-key"); + Campaigns campaigns = lettr.campaigns(); + assertNotNull(campaigns); + } + @Test void servicesAreNewInstancesEachCall() { Lettr lettr = new Lettr("test-api-key"); @@ -75,5 +91,7 @@ void servicesAreNewInstancesEachCall() { assertNotSame(lettr.templates(), lettr.templates()); assertNotSame(lettr.projects(), lettr.projects()); assertNotSame(lettr.system(), lettr.system()); + assertNotSame(lettr.audience(), lettr.audience()); + assertNotSame(lettr.campaigns(), lettr.campaigns()); } } diff --git a/src/test/java/com/lettr/core/net/HttpClientTest.java b/src/test/java/com/lettr/core/net/HttpClientTest.java index 6d32c99..3375dce 100644 --- a/src/test/java/com/lettr/core/net/HttpClientTest.java +++ b/src/test/java/com/lettr/core/net/HttpClientTest.java @@ -17,6 +17,28 @@ void constructorAcceptsApiKey() { assertNotNull(client); } + @Test + void encodePathSegmentEscapesReservedCharacters() { + // Reserved path characters must be percent-encoded so callers can safely + // interpolate arbitrary identifiers. + assertEquals("foo%2Fbar", HttpClient.encodePathSegment("foo/bar")); + assertEquals("foo%3Fbar", HttpClient.encodePathSegment("foo?bar")); + assertEquals("foo%23bar", HttpClient.encodePathSegment("foo#bar")); + assertEquals("hello%20world", HttpClient.encodePathSegment("hello world")); + assertEquals("100%25", HttpClient.encodePathSegment("100%")); + } + + @Test + void encodePathSegmentLeavesUuidUnchanged() { + String uuid = "0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0"; + assertEquals(uuid, HttpClient.encodePathSegment(uuid)); + } + + @Test + void encodePathSegmentHandlesNull() { + assertEquals("", HttpClient.encodePathSegment(null)); + } + @Test void gsonInstanceAvailable() { HttpClient client = new HttpClient("test-key"); diff --git a/src/test/java/com/lettr/core/util/ArgsTest.java b/src/test/java/com/lettr/core/util/ArgsTest.java new file mode 100644 index 0000000..b1c0629 --- /dev/null +++ b/src/test/java/com/lettr/core/util/ArgsTest.java @@ -0,0 +1,37 @@ +package com.lettr.core.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ArgsTest { + + @Test + void requireNonEmptyAcceptsNonEmptyAndReturnsValue() { + assertEquals("hi", Args.requireNonEmpty("name", "hi")); + } + + @Test + void requireNonEmptyRejectsNullAndEmpty() { + IllegalArgumentException ex1 = assertThrows(IllegalArgumentException.class, + () -> Args.requireNonEmpty("listId", null)); + assertEquals("listId is required", ex1.getMessage()); + + IllegalArgumentException ex2 = assertThrows(IllegalArgumentException.class, + () -> Args.requireNonEmpty("listId", "")); + assertEquals("listId is required", ex2.getMessage()); + } + + @Test + void requireNonNullAcceptsAndReturnsValue() { + Object o = new Object(); + assertSame(o, Args.requireNonNull("options", o)); + } + + @Test + void requireNonNullRejectsNull() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> Args.requireNonNull("options", null)); + assertEquals("options is required", ex.getMessage()); + } +} diff --git a/src/test/java/com/lettr/core/util/WireValuesTest.java b/src/test/java/com/lettr/core/util/WireValuesTest.java new file mode 100644 index 0000000..081a0c2 --- /dev/null +++ b/src/test/java/com/lettr/core/util/WireValuesTest.java @@ -0,0 +1,34 @@ +package com.lettr.core.util; + +import com.google.gson.annotations.SerializedName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class WireValuesTest { + + enum Annotated { + @SerializedName("kebab-case") KEBAB, + @SerializedName("snake_case") SNAKE + } + + enum Bare { + FALLBACK + } + + @Test + void returnsSerializedNameValue() { + assertEquals("kebab-case", WireValues.of(Annotated.KEBAB)); + assertEquals("snake_case", WireValues.of(Annotated.SNAKE)); + } + + @Test + void fallsBackToEnumNameWhenUnannotated() { + assertEquals("FALLBACK", WireValues.of(Bare.FALLBACK)); + } + + @Test + void rejectsNull() { + assertThrows(IllegalArgumentException.class, () -> WireValues.of(null)); + } +} diff --git a/src/test/java/com/lettr/services/campaigns/CampaignsTest.java b/src/test/java/com/lettr/services/campaigns/CampaignsTest.java new file mode 100644 index 0000000..a586926 --- /dev/null +++ b/src/test/java/com/lettr/services/campaigns/CampaignsTest.java @@ -0,0 +1,229 @@ +package com.lettr.services.campaigns; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.lettr.core.util.WireValues; +import com.lettr.services.campaigns.model.*; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CampaignsTest { + + private final Gson gson = new GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + .create(); + + @Test + void campaignViewDeserializesWithStats() { + String json = "{" + + "\"id\":\"0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0\"," + + "\"name\":\"Spring Newsletter\"," + + "\"subject\":\"Hello\"," + + "\"from_email\":\"news@example.com\"," + + "\"from_name\":\"Example\"," + + "\"reply_to\":null," + + "\"status\":\"sent\"," + + "\"scheduled_at\":null," + + "\"total_recipients\":1000," + + "\"sent_count\":980," + + "\"sent_at\":\"2026-05-20T10:00:00+00:00\"," + + "\"created_at\":\"2026-05-19T08:00:00+00:00\"," + + "\"html_content\":\"

Hi

\"," + + "\"stats\":{\"injections\":1000,\"deliveries\":980,\"bounces\":20," + + "\"spam_complaints\":1,\"opens\":500,\"unique_opens\":400," + + "\"clicks\":120,\"unique_clicks\":90,\"unsubscribes\":5}" + + "}"; + + CampaignView c = gson.fromJson(json, CampaignView.class); + + assertEquals("0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0", c.getId()); + assertEquals("Spring Newsletter", c.getName()); + assertEquals("news@example.com", c.getFromEmail()); + assertNull(c.getReplyTo()); + assertEquals(CampaignStatus.SENT, c.getStatus()); + assertEquals(Integer.valueOf(1000), c.getTotalRecipients()); + assertEquals(980, c.getSentCount()); + assertEquals("

Hi

", c.getHtmlContent()); + + CampaignStats stats = c.getStats(); + assertNotNull(stats); + assertEquals(980, stats.getDeliveries()); + assertEquals(1, stats.getSpamComplaints()); + assertEquals(400, stats.getUniqueOpens()); + assertEquals(90, stats.getUniqueClicks()); + } + + @Test + void campaignViewWithoutHtmlContentLeavesItNull() { + String json = "{\"id\":\"abc\",\"name\":\"Draft\",\"status\":\"draft\"," + + "\"total_recipients\":null,\"sent_count\":0," + + "\"created_at\":\"2026-05-19T08:00:00+00:00\"," + + "\"stats\":{\"injections\":0,\"deliveries\":0,\"bounces\":0," + + "\"spam_complaints\":0,\"opens\":0,\"unique_opens\":0," + + "\"clicks\":0,\"unique_clicks\":0,\"unsubscribes\":0}}"; + + CampaignView c = gson.fromJson(json, CampaignView.class); + + assertEquals(CampaignStatus.DRAFT, c.getStatus()); + assertNull(c.getHtmlContent()); + assertNull(c.getTotalRecipients()); + assertEquals(0, c.getSentCount()); + } + + @Test + void listCampaignsResponseDeserializes() { + String json = "{\"campaigns\":[{\"id\":\"abc\",\"name\":\"C1\",\"status\":\"draft\"," + + "\"sent_count\":0,\"created_at\":\"2026-05-19T08:00:00+00:00\"," + + "\"stats\":{\"injections\":0,\"deliveries\":0,\"bounces\":0," + + "\"spam_complaints\":0,\"opens\":0,\"unique_opens\":0," + + "\"clicks\":0,\"unique_clicks\":0,\"unsubscribes\":0}}]," + + "\"pagination\":{\"total\":1,\"per_page\":20,\"current_page\":1,\"last_page\":1}}"; + + ListCampaignsResponse response = gson.fromJson(json, ListCampaignsResponse.class); + + assertEquals(1, response.getCampaigns().size()); + assertEquals("C1", response.getCampaigns().get(0).getName()); + assertEquals(20, response.getPagination().getPerPage()); + assertEquals(1, response.getPagination().getLastPage()); + } + + @Test + void listCampaignsResponseMissingCampaignsReturnsEmptyList() { + ListCampaignsResponse response = gson.fromJson("{\"pagination\":{\"total\":0,\"per_page\":20,\"current_page\":1,\"last_page\":0}}", ListCampaignsResponse.class); + assertNotNull(response.getCampaigns()); + assertTrue(response.getCampaigns().isEmpty()); + } + + @Test + void campaignEventAndResponseDeserialize() { + String json = "{\"events\":[{\"event_id\":\"evt_1\",\"event_type\":\"click\"," + + "\"email\":\"user@example.com\",\"timestamp\":\"2026-05-20T10:05:00+00:00\"," + + "\"bounce_class\":null,\"reason\":null," + + "\"target_link_url\":\"https://example.com\",\"user_agent\":\"Mozilla\"}]," + + "\"next_cursor\":\"cursor-123\"}"; + + ListCampaignEventsResponse response = gson.fromJson(json, ListCampaignEventsResponse.class); + + assertEquals(1, response.getEvents().size()); + CampaignEvent e = response.getEvents().get(0); + assertEquals("evt_1", e.getEventId()); + assertEquals(CampaignEventType.CLICK, e.getEventType()); + assertEquals("https://example.com", e.getTargetLinkUrl()); + assertNull(e.getBounceClass()); + assertEquals("cursor-123", response.getNextCursor()); + } + + @Test + void campaignEventsResponseNullCursorMeansLastPage() { + String json = "{\"events\":[],\"next_cursor\":null}"; + + ListCampaignEventsResponse response = gson.fromJson(json, ListCampaignEventsResponse.class); + + assertTrue(response.getEvents().isEmpty()); + assertNull(response.getNextCursor()); + } + + @Test + void campaignEventsResponseMissingEventsReturnsEmptyList() { + ListCampaignEventsResponse response = gson.fromJson("{\"next_cursor\":null}", ListCampaignEventsResponse.class); + assertNotNull(response.getEvents()); + assertTrue(response.getEvents().isEmpty()); + } + + @Test + void enumsRoundTripWithWireValues() { + assertEquals(CampaignStatus.IN_REVIEW, gson.fromJson("\"in_review\"", CampaignStatus.class)); + assertEquals("in_review", WireValues.of(CampaignStatus.IN_REVIEW)); + assertEquals(CampaignEventType.SPAM_COMPLAINT, gson.fromJson("\"spam_complaint\"", CampaignEventType.class)); + assertEquals("spam_complaint", WireValues.of(CampaignEventType.SPAM_COMPLAINT)); + assertEquals("list_unsubscribe", WireValues.of(CampaignEventType.LIST_UNSUBSCRIBE)); + } + + @Test + void listCampaignsParamsBuildQueryParams() { + Map params = ListCampaignsParams.builder() + .page(2) + .perPage(50) + .status(CampaignStatus.SCHEDULED) + .build() + .toQueryParams(); + + assertEquals("2", params.get("page")); + assertEquals("50", params.get("per_page")); + assertEquals("scheduled", params.get("status")); + } + + @Test + void listCampaignsParamsOmitsNulls() { + Map params = ListCampaignsParams.builder().build().toQueryParams(); + assertTrue(params.isEmpty()); + } + + @Test + void listCampaignEventsParamsBuildQueryParams() { + Map params = ListCampaignEventsParams.builder() + .eventType(CampaignEventType.OPEN) + .email("user@example.com") + .startDate("2026-05-01") + .endDate("2026-05-31") + .limit(100) + .cursor("abc") + .build() + .toQueryParams(); + + assertEquals("open", params.get("event_type")); + assertEquals("user@example.com", params.get("email")); + assertEquals("2026-05-01", params.get("start_date")); + assertEquals("2026-05-31", params.get("end_date")); + assertEquals("100", params.get("limit")); + assertEquals("abc", params.get("cursor")); + } + + @Test + void scheduleOptionsRequiresScheduledAt() { + assertThrows(IllegalArgumentException.class, () -> ScheduleCampaignOptions.of(null)); + assertThrows(IllegalArgumentException.class, () -> ScheduleCampaignOptions.of("")); + assertEquals("2026-06-01T09:00:00+00:00", + ScheduleCampaignOptions.of("2026-06-01T09:00:00+00:00").getScheduledAt()); + } + + @Test + void getRequiresCampaignId() { + Campaigns svc = new Campaigns("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.get(null)); + assertThrows(IllegalArgumentException.class, () -> svc.get("")); + } + + @Test + void listEventsRequiresCampaignId() { + Campaigns svc = new Campaigns("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.listEvents(null)); + assertThrows(IllegalArgumentException.class, () -> svc.listEvents("")); + } + + @Test + void sendRequiresCampaignId() { + Campaigns svc = new Campaigns("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.send(null)); + assertThrows(IllegalArgumentException.class, () -> svc.send("")); + } + + @Test + void unscheduleRequiresCampaignId() { + Campaigns svc = new Campaigns("test-key"); + assertThrows(IllegalArgumentException.class, () -> svc.unschedule(null)); + assertThrows(IllegalArgumentException.class, () -> svc.unschedule("")); + } + + @Test + void scheduleRequiresCampaignIdAndOptions() { + Campaigns svc = new Campaigns("test-key"); + ScheduleCampaignOptions valid = ScheduleCampaignOptions.of("2026-06-01T09:00:00+00:00"); + assertThrows(IllegalArgumentException.class, () -> svc.schedule(null, valid)); + assertThrows(IllegalArgumentException.class, () -> svc.schedule("", valid)); + assertThrows(IllegalArgumentException.class, () -> svc.schedule("campaign-id", null)); + } +}