Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 95 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,7 +16,7 @@ implementation 'com.lettr:lettr-java:1.2.0'
<dependency>
<groupId>com.lettr</groupId>
<artifactId>lettr-java</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</dependency>
```

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ test {
useJUnitPlatform()
}

processResources {
filesMatching('com/lettr/version.properties') {
expand(version: project.version)
}
}

publishing {
publications {
mavenJava(MavenPublication) {
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/lettr/Lettr.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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); }
}
45 changes: 45 additions & 0 deletions src/main/java/com/lettr/core/model/OffsetPagination.java
Original file line number Diff line number Diff line change
@@ -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 + '}';
}
}
71 changes: 71 additions & 0 deletions src/main/java/com/lettr/core/model/PageParams.java
Original file line number Diff line number Diff line change
@@ -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<String, String> toQueryParams() {
Map<String, String> 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() {}

/** <b>(optional)</b> Page number (min 1, default 1). */
@Nonnull
public Builder page(@Nullable Integer page) {
this.page = page;
return this;
}

/** <b>(optional)</b> 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);
}
}
}
Loading
Loading