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