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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ 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.4.0] - 2026-05-28

### Added

- `CampaignDetail` — detailed view of a campaign returned by `campaigns().get(id)`. Extends `CampaignView` and adds `getHtmlContent()`. `CampaignDetail` IS-A `CampaignView`, so callers that assign the `get()` result to a `CampaignView` variable keep working

### Changed

- `Campaigns.get(String)` return type narrowed from `CampaignView` to `CampaignDetail`. The rendered HTML is exposed via `CampaignDetail.getHtmlContent()`; existing `get(...).getHtmlContent()` callers are source-compatible
- `CampaignView` no longer carries an `htmlContent` field. The API never populated it on list, send, schedule, or unschedule responses, so its presence on the base type was misleading — calls like `lettr.campaigns().send(id).getHtmlContent()` no longer compile (the branch was always dead anyway)

## [1.3.0] - 2026-05-28

### Added
Expand Down Expand Up @@ -123,6 +134,7 @@ Initial release.
- Bearer token auth, Gson-based JSON serialization
- Structured exceptions: `LettrException`, `LettrApiException`, `LettrValidationException`

[1.4.0]: https://github.com/lettr/lettr-java/compare/v1.3.0...v1.4.0
[1.1.0]: https://github.com/lettr/lettr-java/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/lettr/lettr-java/compare/v0.2.0...v1.0.0
[0.2.0]: https://github.com/lettr/lettr-java/compare/v0.1.0...v0.2.0
Expand Down
12 changes: 8 additions & 4 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.3.0'
implementation 'com.lettr:lettr-java:1.4.0'
```

### Maven
Expand All @@ -16,7 +16,7 @@ implementation 'com.lettr:lettr-java:1.3.0'
<dependency>
<groupId>com.lettr</groupId>
<artifactId>lettr-java</artifactId>
<version>1.3.0</version>
<version>1.4.0</version>
</dependency>
```

Expand Down Expand Up @@ -452,11 +452,15 @@ System.out.println("Page " + drafts.getPagination().getCurrentPage()

### Get a Campaign

`get()` returns `CampaignDetail`, which extends `CampaignView` and adds
`htmlContent`. Action endpoints (`send`, `schedule`, `unschedule`) and `list()`
return the base `CampaignView`, so they cannot accidentally expose HTML content.

```java
CampaignView campaign = lettr.campaigns().get("0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0");
CampaignDetail 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(...)
System.out.println("HTML: " + campaign.getHtmlContent()); // null for drafts with no rendered content yet

CampaignStats stats = campaign.getStats();
System.out.println("Opens: " + stats.getUniqueOpens() + " / Clicks: " + stats.getUniqueClicks());
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.3.0
VERSION=1.4.0
POM_ARTIFACT_ID=lettr-java
POM_NAME=Lettr Java SDK
POM_DESCRIPTION=Java SDK for the Lettr Email API
Expand Down
10 changes: 6 additions & 4 deletions src/main/java/com/lettr/services/campaigns/Campaigns.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,19 @@ public ListCampaignsResponse list() throws LettrException {
}

/**
* Retrieve a single campaign, including its rendered HTML content.
* Retrieve a single campaign. The returned {@link CampaignDetail} exposes
* the rendered HTML via {@link CampaignDetail#getHtmlContent()}, which may
* be {@code null} for drafts that have no rendered content yet.
*
* @param campaignId the campaign ID
* @return the campaign
* @return the campaign detail; {@code getHtmlContent()} may be {@code null} for drafts
* @throws LettrException if the request fails
* @throws IllegalArgumentException if {@code campaignId} is null or empty
*/
@Nonnull
public CampaignView get(@Nonnull String campaignId) throws LettrException {
public CampaignDetail get(@Nonnull String campaignId) throws LettrException {
Args.requireNonEmpty("campaignId", campaignId);
return httpClient.get("/campaigns/" + HttpClient.encodePathSegment(campaignId), null, CampaignView.class);
return httpClient.get("/campaigns/" + HttpClient.encodePathSegment(campaignId), null, CampaignDetail.class);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.lettr.services.campaigns.model;

import com.google.gson.annotations.SerializedName;

import javax.annotation.Nullable;

/**
* Detailed view of a campaign returned by
* {@link com.lettr.services.campaigns.Campaigns#get(String)}, adding the
* rendered {@code htmlContent} on top of {@link CampaignView}.
*/
public class CampaignDetail extends CampaignView {

@SerializedName("html_content")
private String htmlContent;

/** Rendered HTML content of the campaign. May be {@code null} for drafts with no content yet. */
@Nullable
public String getHtmlContent() {
return htmlContent;
}

@Override
public String toString() {
return "CampaignDetail{id='" + getId() + "', name='" + getName()
+ "', status=" + getStatus() + ", sentCount=" + getSentCount()
+ ", hasHtmlContent=" + (htmlContent != null) + '}';
}
}
16 changes: 3 additions & 13 deletions src/main/java/com/lettr/services/campaigns/model/CampaignView.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
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.
* Summary view of a campaign returned by list, send, schedule, and unschedule.
* Does not include the rendered HTML content; use {@link CampaignDetail}
* (returned by {@link com.lettr.services.campaigns.Campaigns#get(String)}) for that.
*/
public class CampaignView {

Expand Down Expand Up @@ -43,9 +42,6 @@ public class CampaignView {
@SerializedName("created_at")
private String createdAt;

@SerializedName("html_content")
private String htmlContent;

private CampaignStats stats;

@Nonnull
Expand Down Expand Up @@ -107,12 +103,6 @@ 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;
Expand Down
50 changes: 46 additions & 4 deletions src/test/java/com/lettr/services/campaigns/CampaignsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class CampaignsTest {
.create();

@Test
void campaignViewDeserializesWithStats() {
void campaignDetailDeserializesWithHtmlContentAndStats() {
String json = "{"
+ "\"id\":\"0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0\","
+ "\"name\":\"Spring Newsletter\","
Expand All @@ -37,11 +37,13 @@ void campaignViewDeserializesWithStats() {
+ "\"clicks\":120,\"unique_clicks\":90,\"unsubscribes\":5}"
+ "}";

CampaignView c = gson.fromJson(json, CampaignView.class);
CampaignDetail c = gson.fromJson(json, CampaignDetail.class);

assertEquals("0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0", c.getId());
assertEquals("Spring Newsletter", c.getName());
assertEquals("Hello", c.getSubject());
assertEquals("news@example.com", c.getFromEmail());
assertEquals("Example", c.getFromName());
assertNull(c.getReplyTo());
assertEquals(CampaignStatus.SENT, c.getStatus());
assertEquals(Integer.valueOf(1000), c.getTotalRecipients());
Expand All @@ -57,15 +59,55 @@ void campaignViewDeserializesWithStats() {
}

@Test
void campaignViewWithoutHtmlContentLeavesItNull() {
void campaignViewDeserializesFromActionResponseShape() {
// Wire shape returned by list/send/schedule/unschedule — no html_content.
// Locks the base-class @SerializedName mappings so regressions surface
// even if the CampaignDetail subclass test still passes.
String json = "{"
+ "\"id\":\"0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0\","
+ "\"name\":\"Spring Newsletter\","
+ "\"subject\":\"Hello\","
+ "\"from_email\":\"news@example.com\","
+ "\"from_name\":\"Example\","
+ "\"reply_to\":\"replies@example.com\","
+ "\"status\":\"scheduled\","
+ "\"scheduled_at\":\"2026-06-01T09:00:00+00:00\","
+ "\"total_recipients\":1000,"
+ "\"sent_count\":0,"
+ "\"sent_at\":null,"
+ "\"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("0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0", c.getId());
assertEquals("Spring Newsletter", c.getName());
assertEquals("Hello", c.getSubject());
assertEquals("news@example.com", c.getFromEmail());
assertEquals("Example", c.getFromName());
assertEquals("replies@example.com", c.getReplyTo());
assertEquals(CampaignStatus.SCHEDULED, c.getStatus());
assertEquals("2026-06-01T09:00:00+00:00", c.getScheduledAt());
assertEquals(Integer.valueOf(1000), c.getTotalRecipients());
assertEquals(0, c.getSentCount());
assertNull(c.getSentAt());
assertEquals("2026-05-19T08:00:00+00:00", c.getCreatedAt());
assertNotNull(c.getStats());
}

@Test
void campaignDetailWithoutHtmlContentLeavesItNull() {
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);
CampaignDetail c = gson.fromJson(json, CampaignDetail.class);

assertEquals(CampaignStatus.DRAFT, c.getStatus());
assertNull(c.getHtmlContent());
Expand Down
Loading