From 843ceb2998fe7e8a1b16ebc6900c81cd6fe48385 Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Fri, 29 May 2026 10:07:44 +0200 Subject: [PATCH] refactor: split CampaignView into summary + detail --- CHANGELOG.md | 12 +++++ README.md | 12 +++-- gradle.properties | 2 +- .../lettr/services/campaigns/Campaigns.java | 10 ++-- .../campaigns/model/CampaignDetail.java | 29 +++++++++++ .../campaigns/model/CampaignView.java | 16 ++---- .../services/campaigns/CampaignsTest.java | 50 +++++++++++++++++-- 7 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/lettr/services/campaigns/model/CampaignDetail.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9205b..cc89e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index b259471..2611e36 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.3.0' +implementation 'com.lettr:lettr-java:1.4.0' ``` ### Maven @@ -16,7 +16,7 @@ implementation 'com.lettr:lettr-java:1.3.0' com.lettr lettr-java - 1.3.0 + 1.4.0 ``` @@ -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()); diff --git a/gradle.properties b/gradle.properties index 403c4d6..4529c05 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/src/main/java/com/lettr/services/campaigns/Campaigns.java b/src/main/java/com/lettr/services/campaigns/Campaigns.java index 029b884..01a9c17 100644 --- a/src/main/java/com/lettr/services/campaigns/Campaigns.java +++ b/src/main/java/com/lettr/services/campaigns/Campaigns.java @@ -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); } /** diff --git a/src/main/java/com/lettr/services/campaigns/model/CampaignDetail.java b/src/main/java/com/lettr/services/campaigns/model/CampaignDetail.java new file mode 100644 index 0000000..ebb9a70 --- /dev/null +++ b/src/main/java/com/lettr/services/campaigns/model/CampaignDetail.java @@ -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) + '}'; + } +} diff --git a/src/main/java/com/lettr/services/campaigns/model/CampaignView.java b/src/main/java/com/lettr/services/campaigns/model/CampaignView.java index 104699a..dfdd179 100644 --- a/src/main/java/com/lettr/services/campaigns/model/CampaignView.java +++ b/src/main/java/com/lettr/services/campaigns/model/CampaignView.java @@ -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 { @@ -43,9 +42,6 @@ public class CampaignView { @SerializedName("created_at") private String createdAt; - @SerializedName("html_content") - private String htmlContent; - private CampaignStats stats; @Nonnull @@ -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; diff --git a/src/test/java/com/lettr/services/campaigns/CampaignsTest.java b/src/test/java/com/lettr/services/campaigns/CampaignsTest.java index a586926..c591f62 100644 --- a/src/test/java/com/lettr/services/campaigns/CampaignsTest.java +++ b/src/test/java/com/lettr/services/campaigns/CampaignsTest.java @@ -17,7 +17,7 @@ class CampaignsTest { .create(); @Test - void campaignViewDeserializesWithStats() { + void campaignDetailDeserializesWithHtmlContentAndStats() { String json = "{" + "\"id\":\"0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0\"," + "\"name\":\"Spring Newsletter\"," @@ -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()); @@ -57,7 +59,47 @@ 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\"," @@ -65,7 +107,7 @@ void campaignViewWithoutHtmlContentLeavesItNull() { + "\"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());