diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..c8a5a3f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,62 @@
+# Changelog
+
+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).
+
+## [Unreleased]
+
+### Added
+
+- JSR-305 `@Nonnull` / `@Nullable` annotations across every public type (params, return values, getters, setters)
+- Consistent **(required)** / **(optional)** Javadoc prefix on every builder setter, with constraint info (max length, mutual-exclusion, etc.)
+- README section explaining how to tell required vs optional fields
+- `compileOnly` dependency on `com.google.code.findbugs:jsr305:3.0.2`
+
+## [0.2.0] - 2026-04-15
+
+Full sync with the Lettr OpenAPI spec. All 27 documented endpoints are now covered.
+
+### Added
+
+- **Emails**: `listEvents()`, `schedule()`, `getScheduled()`, `cancelScheduled()`
+- **Domains**: `verify()` with detailed DKIM/CNAME/DMARC/SPF validation results
+- **Webhooks**: `create()`, `update()`, `delete()`
+- **Templates**: `get()`, `update()`, `delete()`, `getMergeTags()`, `getHtml()`
+- **Projects** service with `list()`
+- **System** service with `health()` and `authCheck()`
+- `CreateEmailOptions`: `cc`, `bcc`, `replyTo`, `replyToName`, `ampHtml`, `tag`, `headers`
+- `EmailOptions`: `inlineCss`, `performSubstitutions`
+- `EmailEvent`: type-specific fields (`bounceClass`, `targetLinkUrl`, `geoIp`, `userAgentParsed`, etc.) and many missing common properties
+- `Domain`: `dmarcStatus`, `spfStatus`, `isPrimaryDomain`, `dnsProvider`
+- HTTP client: `PUT` support and `DELETE` with query params
+- Test suite (98 tests across 8 files)
+
+### Changed
+
+- `ListEmailsResponse` restructured to match the API's nested `events.data` shape (breaking)
+- `GetEmailResponse` restructured to expose `transmissionId`, `state`, `recipients`, `events` (breaking)
+- `EmailEvent`: `clickTracking`, `openTracking`, `transactional` now nullable `Boolean` (was primitive `boolean`); `msgSize` now nullable `Integer`
+- `CreateEmailOptions`: `subject` no longer required when using `templateSlug`
+- `MergeTag` extracted from `CreateTemplateResponse` into its own class; now exposes `type` and `children`
+
+### Fixed
+
+- DKIM record now includes the `headers` field
+- DKIM response now includes `signingDomain`
+
+## [0.1.0] - 2026-01-15
+
+Initial release.
+
+### Added
+
+- Initial SDK with `Emails`, `Domains`, `Webhooks`, and `Templates` services
+- Basic send, list, and get operations
+- Bearer token auth, Gson-based JSON serialization
+- Structured exceptions: `LettrException`, `LettrApiException`, `LettrValidationException`
+
+[Unreleased]: https://github.com/lettr/lettr-java/compare/v0.2.0...HEAD
+[0.2.0]: https://github.com/lettr/lettr-java/compare/v0.1.0...v0.2.0
+[0.1.0]: https://github.com/lettr/lettr-java/releases/tag/v0.1.0
diff --git a/README.md b/README.md
index 34f55d7..e7bd3de 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:0.1.0'
+implementation 'com.lettr:lettr-java:0.2.0'
```
### Maven
@@ -16,7 +16,7 @@ implementation 'com.lettr:lettr-java:0.1.0'
Hello, world!
") .build(); -try { - CreateEmailResponse response = lettr.emails().send(params); - System.out.println("Email sent! Request ID: " + response.getRequestId()); -} catch (LettrException e) { - e.printStackTrace(); -} +CreateEmailResponse response = lettr.emails().send(params); +System.out.println("Email sent! Request ID: " + response.getRequestId()); ``` -### Send with HTML and Plain Text +### Send with All Options ```java CreateEmailOptions params = CreateEmailOptions.builder() .from("sender@example.com") .fromName("Sender Name") .to("recipient@example.com") + .cc("cc@example.com") + .bcc("bcc@example.com") + .replyTo("reply@example.com") + .replyToName("Reply Name") .subject("Welcome!") .html("Thanks for signing up.
") .text("Welcome\n\nThanks for signing up.") + .tag("welcome-series") + .headers(Map.of("X-Custom-ID", "abc-123")) + .metadata(Map.of("user_id", "12345")) .options(EmailOptions.builder() .clickTracking(true) .openTracking(true) + .transactional(true) + .inlineCss(true) + .performSubstitutions(true) .build()) .build(); @@ -82,8 +94,6 @@ CreateEmailResponse response = lettr.emails().send(params); ### Send with Attachments ```java -import com.lettr.services.emails.model.Attachment; - Attachment invoice = Attachment.builder() .name("invoice.pdf") .type("application/pdf") @@ -92,7 +102,6 @@ Attachment invoice = Attachment.builder() CreateEmailOptions params = CreateEmailOptions.builder() .from("billing@example.com") - .fromName("Billing Department") .to("customer@example.com") .subject("Your Invoice") .html("Please find your invoice attached.
") @@ -105,13 +114,12 @@ CreateEmailResponse response = lettr.emails().send(params); ### Send with Template ```java -import java.util.Map; - CreateEmailOptions params = CreateEmailOptions.builder() .from("hello@example.com") .to("john@example.com") - .subject("Welcome, {{first_name}}!") .templateSlug("welcome-email") + .templateVersion(2) + .projectId(5) .substitutionData(Map.of( "first_name", "John", "company", "Acme Inc" @@ -121,12 +129,37 @@ CreateEmailOptions params = CreateEmailOptions.builder() CreateEmailResponse response = lettr.emails().send(params); ``` -### List Sent Emails +### Schedule an Email + +```java +ScheduleEmailOptions params = ScheduleEmailOptions.builder() + .from("sender@example.com") + .to("recipient@example.com") + .subject("Scheduled Newsletter") + .html("This will arrive later!
") + .scheduledAt("2024-01-16T10:00:00Z") // must be 5min–3days in the future + .build(); + +CreateEmailResponse response = lettr.emails().schedule(params); +``` + +### Get a Scheduled Email ```java -import com.lettr.services.emails.model.ListEmailsParams; -import com.lettr.services.emails.model.ListEmailsResponse; +ScheduledEmail scheduled = lettr.emails().getScheduled("transmission-id"); +System.out.println("State: " + scheduled.getState()); // submitted, scheduled, delivered, etc. +System.out.println("Scheduled at: " + scheduled.getScheduledAt()); +``` + +### Cancel a Scheduled Email + +```java +lettr.emails().cancelScheduled("transmission-id"); +``` +### List Sent Emails + +```java // List with default pagination ListEmailsResponse emails = lettr.emails().list(); @@ -136,85 +169,280 @@ ListEmailsResponse filtered = lettr.emails().list( .perPage(50) .recipients("user@example.com") .from("2024-01-01") + .to("2024-12-31") .build() ); -for (EmailEvent event : filtered.getResults()) { +for (EmailEvent event : filtered.getEvents().getData()) { System.out.println(event.getSubject() + " -> " + event.getRcptTo()); } + +// Pagination +String nextCursor = filtered.getEvents().getPagination().getNextCursor(); ``` -### Get Email Details +### List Email Events ```java -import com.lettr.services.emails.model.GetEmailResponse; +ListEmailEventsResponse events = lettr.emails().listEvents( + ListEmailEventsParams.builder() + .events(List.of("delivery", "bounce", "open")) + .recipients(List.of("user@example.com")) + .from("2024-01-01") + .to("2024-12-31") + .perPage(25) + .build() +); +for (EmailEvent event : events.getEvents().getData()) { + System.out.println(event.getType() + " at " + event.getTimestamp()); + if ("bounce".equals(event.getType())) { + System.out.println(" Bounce class: " + event.getBounceClass()); + System.out.println(" Reason: " + event.getReason()); + } + if ("click".equals(event.getType())) { + System.out.println(" Link: " + event.getTargetLinkUrl()); + System.out.println(" Country: " + event.getGeoIp().getCountry()); + } +} +``` + +### Get Email Details + +```java GetEmailResponse email = lettr.emails().get("request-id-from-send"); -for (EmailEvent event : email.getResults()) { +System.out.println("State: " + email.getState()); // scheduled, delivered, bounced, failed +System.out.println("Recipients: " + email.getRecipients()); + +for (EmailEvent event : email.getEvents()) { System.out.println(event.getType() + " at " + event.getTimestamp()); } ``` -### Manage Domains +## Domains + +### List Domains ```java import com.lettr.services.domains.model.*; -// List all domains ListDomainsResponse domains = lettr.domains().list(); +for (Domain d : domains.getDomains()) { + System.out.println(d.getDomain() + " - " + d.getStatus() + " (can send: " + d.isCanSend() + ")"); +} +``` + +### Create a Domain -// Create a new domain +```java CreateDomainResponse newDomain = lettr.domains().create( CreateDomainOptions.of("example.com") ); System.out.println("DKIM selector: " + newDomain.getDkim().getSelector()); +System.out.println("DKIM public key: " + newDomain.getDkim().getPublicKey()); +``` + +### Get Domain Details -// Get domain details +```java Domain domain = lettr.domains().get("example.com"); -System.out.println("Can send: " + domain.isCanSend()); +System.out.println("DKIM status: " + domain.getDkimStatus()); +System.out.println("DMARC status: " + domain.getDmarcStatus()); +System.out.println("SPF status: " + domain.getSpfStatus()); +System.out.println("Primary domain: " + domain.getIsPrimaryDomain()); +``` -// Delete a domain +### Verify Domain DNS + +```java +VerifyDomainResponse result = lettr.domains().verify("example.com"); +System.out.println("DKIM: " + result.getDkimStatus()); +System.out.println("CNAME: " + result.getCnameStatus()); +System.out.println("DMARC: " + result.getDmarcStatus()); +System.out.println("SPF: " + result.getSpfStatus()); + +if (result.getDns().getDkimError() != null) { + System.out.println("DKIM error: " + result.getDns().getDkimError()); +} +``` + +### Delete a Domain + +```java lettr.domains().delete("example.com"); ``` -### Manage Webhooks +## Webhooks + +### List Webhooks ```java import com.lettr.services.webhooks.model.*; -// List all webhooks ListWebhooksResponse webhooks = lettr.webhooks().list(); +for (Webhook w : webhooks.getWebhooks()) { + System.out.println(w.getName() + " -> " + w.getUrl() + " (enabled: " + w.isEnabled() + ")"); +} +``` + +### Create a Webhook -// Get a specific webhook +```java +Webhook webhook = lettr.webhooks().create( + CreateWebhookOptions.builder() + .name("Order Notifications") + .url("https://example.com/webhook") + .authType("basic") + .authUsername("user") + .authPassword("secret") + .eventsMode("selected") + .events(List.of("delivery", "bounce")) + .build() +); +System.out.println("Webhook ID: " + webhook.getId()); +``` + +### Get a Webhook + +```java Webhook webhook = lettr.webhooks().get("webhook-abc123"); -System.out.println("Webhook URL: " + webhook.getUrl()); ``` -### Manage Templates +### Update a Webhook + +```java +Webhook updated = lettr.webhooks().update("webhook-abc123", + UpdateWebhookOptions.builder() + .name("Updated Webhook") + .target("https://new.example.com/webhook") + .active(false) + .build() +); +``` + +### Delete a Webhook + +```java +lettr.webhooks().delete("webhook-abc123"); +``` + +## Templates + +### List Templates ```java import com.lettr.services.templates.model.*; -// List all templates ListTemplatesResponse templates = lettr.templates().list(); -// List with pagination -ListTemplatesResponse page2 = lettr.templates().list( +// With pagination +ListTemplatesResponse page = lettr.templates().list( ListTemplatesParams.builder() + .projectId(5) .perPage(10) .page(2) .build() ); +``` + +### Get a Template + +```java +TemplateDetail template = lettr.templates().get("welcome-email"); +System.out.println("Name: " + template.getName()); +System.out.println("Active version: " + template.getActiveVersion()); +System.out.println("HTML: " + template.getHtml()); -// Create a new template -CreateTemplateResponse newTemplate = lettr.templates().create( +// With a specific project +TemplateDetail template = lettr.templates().get("welcome-email", 5); +``` + +### Create a Template + +```java +CreateTemplateResponse response = lettr.templates().create( CreateTemplateOptions.builder() .name("Welcome Email") .html("Hello {{FIRST_NAME}}!
") + .projectId(5) + .folderId(10) .build() ); -System.out.println("Template slug: " + newTemplate.getSlug()); +System.out.println("Slug: " + response.getSlug()); +System.out.println("Merge tags: " + response.getMergeTags()); +``` + +### Update a Template + +```java +UpdateTemplateResponse response = lettr.templates().update("welcome-email", + UpdateTemplateOptions.builder() + .name("Updated Welcome Email") + .html("Hello {{FIRST_NAME}}, welcome aboard!
") + .build() +); +``` + +### Delete a Template + +```java +lettr.templates().delete("welcome-email"); + +// With a specific project +lettr.templates().delete("welcome-email", 5); +``` + +### Get Merge Tags + +```java +GetMergeTagsResponse tags = lettr.templates().getMergeTags("welcome-email"); +for (MergeTag tag : tags.getMergeTags()) { + System.out.println(tag.getKey() + " (required: " + tag.isRequired() + ")"); + if (tag.getChildren() != null) { + for (MergeTagChild child : tag.getChildren()) { + System.out.println(" " + child.getKey() + " (" + child.getType() + ")"); + } + } +} + +// With version and project +GetMergeTagsResponse tags = lettr.templates().getMergeTags("welcome-email", + GetMergeTagsParams.builder().projectId(5).version(2).build() +); +``` + +### Get Template HTML + +```java +GetTemplateHtmlResponse html = lettr.templates().getHtml( + GetTemplateHtmlParams.builder() + .projectId(5) + .slug("welcome-email") + .build() +); +System.out.println("Subject: " + html.getSubject()); +System.out.println("HTML: " + html.getHtml()); +``` + +## System + +### Health Check + +```java +import com.lettr.services.system.model.HealthResponse; + +HealthResponse health = lettr.system().health(); +System.out.println("Status: " + health.getStatus()); +``` + +### Validate API Key + +```java +import com.lettr.services.system.model.AuthCheckResponse; + +AuthCheckResponse auth = lettr.system().authCheck(); +System.out.println("Team ID: " + auth.getTeamId()); ``` ## Error Handling @@ -229,72 +457,35 @@ import com.lettr.core.exception.LettrValidationException; try { lettr.emails().send(params); } catch (LettrValidationException e) { - // 422 - Validation errors + // 422 - Validation errors with field-level details System.err.println("Validation failed: " + e.getMessage()); e.getErrors().forEach((field, messages) -> { System.err.println(" " + field + ": " + messages); }); } catch (LettrApiException e) { - // Other API errors (401, 404, 500, etc.) + // Other API errors (400, 401, 404, 409, 429, 500, 502) System.err.println("API error: " + e.getMessage()); System.err.println("Status: " + e.getStatusCode()); - System.err.println("Error code: " + e.getErrorCode()); + System.err.println("Error code: " + e.getErrorCode()); // e.g. "invalid_domain", "quota_exceeded" } catch (LettrException e) { // Network or parsing errors System.err.println("Error: " + e.getMessage()); } ``` -## CI/CD - -This project includes two GitHub Actions workflows: - -- **CI** (`.github/workflows/ci.yml`) -- runs on every push/PR to `main`, builds and tests against Java 17 and 21. -- **Publish** (`.github/workflows/publish.yml`) -- automatically publishes to Maven Central when you create a GitHub Release. - -## Publishing to Maven Central - -### One-Time Setup - -1. **Register with Sonatype**: Create an account at [central.sonatype.com](https://central.sonatype.com/) and claim the `com.lettr` namespace (they verify domain ownership via DNS TXT record). - -2. **Generate a Central Portal user token**: Go to [central.sonatype.com](https://central.sonatype.com) → click your name (top right) → **View Account** → **Generate User Token**. This gives you a token username and token password (both random strings). - -3. **Generate a GPG key** for signing artifacts: - -```bash -gpg --gen-key -gpg --list-keys --keyid-format long # find your key ID -gpg --keyserver keyserver.ubuntu.com --send-keys YOUR_KEY_ID -``` - -4. **Add 4 secrets** to your GitHub repo (`Settings > Secrets and variables > Actions`): - -| Secret | Value | -|--------|-------| -| `CENTRAL_PORTAL_TOKEN_USERNAME` | Token username (from step 2) | -| `CENTRAL_PORTAL_TOKEN_PASSWORD` | Token password (from step 2) | -| `SIGNING_KEY` | Output of `gpg --export-secret-keys --armor YOUR_KEY_ID` | -| `SIGNING_PASSWORD` | The passphrase you set during `gpg --gen-key` | - -### Publishing a New Version - -Every time you want to release: - -1. Update the version in `gradle.properties` -2. Commit, push, and create a **GitHub Release** (e.g. tag `v0.2.0`) -3. The workflow publishes automatically with `publishing_type=automatic` -- if validation passes, it goes straight to Maven Central (~30 min) - -### Publishing Locally (optional) - -```bash -export CENTRAL_PORTAL_TOKEN_USERNAME=your-token-username -export CENTRAL_PORTAL_TOKEN_PASSWORD=your-token-password -export SIGNING_KEY="$(gpg --export-secret-keys --armor YOUR_KEY_ID)" -export SIGNING_PASSWORD=your-passphrase - -./gradlew publish -``` +### Error Codes + +| Code | Description | +|------|-------------| +| `validation_error` | Request validation failed (422) | +| `invalid_domain` | Sender domain could not be determined (400) | +| `unconfigured_domain` | Sender domain not configured or approved (400) | +| `send_error` | General send error (400/500) | +| `transmission_failed` | Upstream provider failure (502) | +| `resource_already_exists` | Resource already exists (409) | +| `template_not_found` | Template/project/version not found (404) | +| `quota_exceeded` | Monthly sending quota exceeded (429) | +| `daily_quota_exceeded` | Daily sending quota exceeded (429) | ## License diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..43747d8 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,96 @@ +# Releasing + +This project publishes to [Maven Central](https://central.sonatype.com/) via the Sonatype Central Portal. The release is driven by GitHub Releases: creating a Release triggers `.github/workflows/publish.yml`, which stages, signs, and auto-finalises the deployment. + +## Versioning + +We follow [Semantic Versioning](https://semver.org/): + +- **MAJOR** — incompatible public API changes +- **MINOR** — backwards-compatible additions (new services, new methods, new optional fields) +- **PATCH** — backwards-compatible bug fixes + +The Git tag and GitHub Release name use the `v{@code
* Lettr lettr = new Lettr("your-api-key");
*
- * // Send an email
* CreateEmailResponse response = lettr.emails().send(
* CreateEmailOptions.builder()
* .from("sender@example.com")
@@ -22,9 +25,6 @@
* .html("Hello, world!
")
* .build()
* );
- *
- * // List domains
- * ListDomainsResponse domains = lettr.domains().list();
* }
*
* @see Lettr Documentation
@@ -37,48 +37,30 @@ public class Lettr {
* Creates a new Lettr client with the given API key.
*
* @param apiKey your Lettr API key (find it at https://app.lettr.com)
- * @throws IllegalArgumentException if apiKey is null or empty
+ * @throws IllegalArgumentException if {@code apiKey} is null or empty
*/
- public Lettr(String apiKey) {
+ public Lettr(@Nonnull String apiKey) {
if (apiKey == null || apiKey.isEmpty()) {
throw new IllegalArgumentException("API key is required. Get yours at https://app.lettr.com");
}
this.apiKey = apiKey;
}
- /**
- * Returns the Emails service for sending and retrieving emails.
- *
- * @return Emails service instance
- */
- public Emails emails() {
- return new Emails(apiKey);
- }
+ /** Returns the Emails service for sending and retrieving emails. */
+ @Nonnull public Emails emails() { return new Emails(apiKey); }
- /**
- * Returns the Domains service for managing sending domains.
- *
- * @return Domains service instance
- */
- public Domains domains() {
- return new Domains(apiKey);
- }
+ /** Returns the Domains service for managing sending domains. */
+ @Nonnull public Domains domains() { return new Domains(apiKey); }
- /**
- * Returns the Webhooks service for managing webhook configurations.
- *
- * @return Webhooks service instance
- */
- public Webhooks webhooks() {
- return new Webhooks(apiKey);
- }
+ /** Returns the Webhooks service for managing webhook configurations. */
+ @Nonnull public Webhooks webhooks() { return new Webhooks(apiKey); }
- /**
- * Returns the Templates service for managing email templates.
- *
- * @return Templates service instance
- */
- public Templates templates() {
- return new Templates(apiKey);
- }
+ /** Returns the Templates service for managing email templates. */
+ @Nonnull public Templates templates() { return new Templates(apiKey); }
+
+ /** Returns the Projects service for listing projects. */
+ @Nonnull public Projects projects() { return new Projects(apiKey); }
+
+ /** Returns the System service for health checks and API key validation. */
+ @Nonnull public System system() { return new System(apiKey); }
}
diff --git a/src/main/java/com/lettr/core/net/HttpClient.java b/src/main/java/com/lettr/core/net/HttpClient.java
index a916ca7..cc1aaf6 100644
--- a/src/main/java/com/lettr/core/net/HttpClient.java
+++ b/src/main/java/com/lettr/core/net/HttpClient.java
@@ -27,7 +27,7 @@
public class HttpClient {
private static final String BASE_URL = "https://app.lettr.com/api";
- private static final String USER_AGENT = "lettr-java/0.1.0";
+ private static final String USER_AGENT = "lettr-java/0.2.0";
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30);
private final String apiKey;
@@ -96,6 +96,33 @@ public Example:
- *{@code
- * Lettr lettr = new Lettr("your-api-key");
- *
- * // List all domains
- * ListDomainsResponse domains = lettr.domains().list();
- *
- * // Create a new domain
- * CreateDomainResponse response = lettr.domains().create(
- * CreateDomainOptions.of("example.com")
- * );
- * }
*/
public class Domains extends BaseService {
- public Domains(String apiKey) {
+ public Domains(@Nonnull String apiKey) {
super(apiKey);
}
- /**
- * List all sending domains.
- *
- * @return response containing list of domains
- * @throws LettrException if the request fails
- */
+ /** List all sending domains. */
+ @Nonnull
public ListDomainsResponse list() throws LettrException {
return httpClient.get("/domains", null, ListDomainsResponse.class);
}
@@ -43,37 +29,44 @@ public ListDomainsResponse list() throws LettrException {
* Get details of a specific domain.
*
* @param domain the domain name (e.g. "example.com")
- * @return domain details including DNS records
- * @throws LettrException if the request fails
+ * @throws IllegalArgumentException if {@code domain} is null or empty
*/
- public Domain get(String domain) throws LettrException {
+ @Nonnull
+ public Domain get(@Nonnull String domain) throws LettrException {
if (domain == null || domain.isEmpty()) {
throw new IllegalArgumentException("domain is required");
}
return httpClient.get("/domains/" + domain, null, Domain.class);
}
- /**
- * Create a new sending domain.
- *
- * @param options domain creation options
- * @return response containing the domain and DKIM configuration
- * @throws LettrException if the request fails
- */
- public CreateDomainResponse create(CreateDomainOptions options) throws LettrException {
+ /** Create a new sending domain. */
+ @Nonnull
+ public CreateDomainResponse create(@Nonnull CreateDomainOptions options) throws LettrException {
return httpClient.post("/domains", options, CreateDomainResponse.class);
}
/**
* Delete a sending domain.
*
- * @param domain the domain name to delete
- * @throws LettrException if the request fails
+ * @throws IllegalArgumentException if {@code domain} is null or empty
*/
- public void delete(String domain) throws LettrException {
+ public void delete(@Nonnull String domain) throws LettrException {
if (domain == null || domain.isEmpty()) {
throw new IllegalArgumentException("domain is required");
}
httpClient.delete("/domains/" + domain);
}
+
+ /**
+ * Verify a domain's DNS configuration (DKIM, CNAME, DMARC, SPF).
+ *
+ * @throws IllegalArgumentException if {@code domain} is null or empty
+ */
+ @Nonnull
+ public VerifyDomainResponse verify(@Nonnull String domain) throws LettrException {
+ if (domain == null || domain.isEmpty()) {
+ throw new IllegalArgumentException("domain is required");
+ }
+ return httpClient.post("/domains/" + domain + "/verify", null, VerifyDomainResponse.class);
+ }
}
diff --git a/src/main/java/com/lettr/services/domains/model/CreateDomainOptions.java b/src/main/java/com/lettr/services/domains/model/CreateDomainOptions.java
index dff1feb..3d076a5 100644
--- a/src/main/java/com/lettr/services/domains/model/CreateDomainOptions.java
+++ b/src/main/java/com/lettr/services/domains/model/CreateDomainOptions.java
@@ -1,5 +1,7 @@
package com.lettr.services.domains.model;
+import javax.annotation.Nonnull;
+
/**
* Options for creating a new sending domain.
*/
@@ -12,19 +14,20 @@ private CreateDomainOptions(String domain) {
}
/**
- * Create options for registering a new sending domain.
+ * (required) Create options for registering a new sending domain.
+ * Max length: 255 characters. Must match a valid domain pattern.
*
* @param domain the domain name (e.g. "example.com")
* @return CreateDomainOptions instance
+ * @throws IllegalArgumentException if {@code domain} is null or empty
*/
- public static CreateDomainOptions of(String domain) {
+ @Nonnull
+ public static CreateDomainOptions of(@Nonnull String domain) {
if (domain == null || domain.isEmpty()) {
throw new IllegalArgumentException("domain is required");
}
return new CreateDomainOptions(domain);
}
- public String getDomain() {
- return domain;
- }
+ @Nonnull public String getDomain() { return domain; }
}
diff --git a/src/main/java/com/lettr/services/domains/model/CreateDomainResponse.java b/src/main/java/com/lettr/services/domains/model/CreateDomainResponse.java
index 8ce4f51..0d4b657 100644
--- a/src/main/java/com/lettr/services/domains/model/CreateDomainResponse.java
+++ b/src/main/java/com/lettr/services/domains/model/CreateDomainResponse.java
@@ -2,6 +2,9 @@
import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
/**
* Response returned after creating a new sending domain.
*/
@@ -15,14 +18,13 @@ public class CreateDomainResponse {
private DkimInfo dkim;
- public String getDomain() { return domain; }
- public String getStatus() { return status; }
- public String getStatusLabel() { return statusLabel; }
- public DkimInfo getDkim() { return dkim; }
+ @Nonnull public String getDomain() { return domain; }
+ /** Domain status: {@code pending}, {@code approved}, or {@code blocked}. */
+ @Nonnull public String getStatus() { return status; }
+ @Nonnull public String getStatusLabel() { return statusLabel; }
+ @Nullable public DkimInfo getDkim() { return dkim; }
- /**
- * DKIM configuration for the newly created domain.
- */
+ /** DKIM configuration for the newly created domain. */
public static class DkimInfo {
@SerializedName("public")
@@ -31,16 +33,18 @@ public static class DkimInfo {
private String selector;
private String headers;
- public String getPublicKey() { return publicKey; }
- public String getSelector() { return selector; }
- public String getHeaders() { return headers; }
+ @SerializedName("signing_domain")
+ private String signingDomain;
+
+ @Nullable public String getPublicKey() { return publicKey; }
+ @Nullable public String getSelector() { return selector; }
+ @Nullable public String getHeaders() { return headers; }
+ /** Domain used for DKIM signing. Normally matches the top-level {@code domain}. */
+ @Nullable public String getSigningDomain() { return signingDomain; }
}
@Override
public String toString() {
- return "CreateDomainResponse{" +
- "domain='" + domain + '\'' +
- ", status='" + status + '\'' +
- '}';
+ return "CreateDomainResponse{domain='" + domain + "', status='" + status + "'}";
}
}
diff --git a/src/main/java/com/lettr/services/domains/model/Domain.java b/src/main/java/com/lettr/services/domains/model/Domain.java
index 7edb10f..d16b1c9 100644
--- a/src/main/java/com/lettr/services/domains/model/Domain.java
+++ b/src/main/java/com/lettr/services/domains/model/Domain.java
@@ -2,6 +2,10 @@
import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+
/**
* Represents a sending domain registered with Lettr.
*/
@@ -22,35 +26,53 @@ public class Domain {
@SerializedName("dkim_status")
private String dkimStatus;
+ @SerializedName("dmarc_status")
+ private String dmarcStatus;
+
+ @SerializedName("spf_status")
+ private String spfStatus;
+
+ @SerializedName("is_primary_domain")
+ private Boolean isPrimaryDomain;
+
@SerializedName("tracking_domain")
private String trackingDomain;
private DnsRecords dns;
+ @SerializedName("dns_provider")
+ private DnsProvider dnsProvider;
+
@SerializedName("created_at")
private String createdAt;
@SerializedName("updated_at")
private String updatedAt;
- public String getDomain() { return domain; }
- public String getStatus() { return status; }
- public String getStatusLabel() { return statusLabel; }
+ @Nonnull public String getDomain() { return domain; }
+ /** Status: {@code pending}, {@code approved}, or {@code blocked}. */
+ @Nonnull public String getStatus() { return status; }
+ @Nonnull public String getStatusLabel() { return statusLabel; }
public boolean isCanSend() { return canSend; }
- public String getCnameStatus() { return cnameStatus; }
- public String getDkimStatus() { return dkimStatus; }
- public String getTrackingDomain() { return trackingDomain; }
- public DnsRecords getDns() { return dns; }
- public String getCreatedAt() { return createdAt; }
- public String getUpdatedAt() { return updatedAt; }
-
- /**
- * DNS records associated with the domain.
- */
+ @Nullable public String getCnameStatus() { return cnameStatus; }
+ /** DKIM status. Null until the domain has been verified at least once. */
+ @Nullable public String getDkimStatus() { return dkimStatus; }
+ @Nullable public String getDmarcStatus() { return dmarcStatus; }
+ @Nullable public String getSpfStatus() { return spfStatus; }
+ /** True if this is a root/apex sending domain (requires SPF instead of CNAME). */
+ @Nullable public Boolean getIsPrimaryDomain() { return isPrimaryDomain; }
+ @Nullable public String getTrackingDomain() { return trackingDomain; }
+ @Nullable public DnsRecords getDns() { return dns; }
+ /** Detected DNS provider, or null if detection hasn't run. */
+ @Nullable public DnsProvider getDnsProvider() { return dnsProvider; }
+ @Nonnull public String getCreatedAt() { return createdAt; }
+ @Nonnull public String getUpdatedAt() { return updatedAt; }
+
+ /** DNS records associated with the domain. */
public static class DnsRecords {
private DkimRecord dkim;
- public DkimRecord getDkim() { return dkim; }
+ @Nullable public DkimRecord getDkim() { return dkim; }
public static class DkimRecord {
private String selector;
@@ -58,17 +80,32 @@ public static class DkimRecord {
@SerializedName("public")
private String publicKey;
- public String getSelector() { return selector; }
- public String getPublicKey() { return publicKey; }
+ private String headers;
+
+ @Nullable public String getSelector() { return selector; }
+ @Nullable public String getPublicKey() { return publicKey; }
+ @Nullable public String getHeaders() { return headers; }
}
}
+ /** Detected DNS provider information. */
+ public static class DnsProvider {
+ private String provider;
+
+ @SerializedName("provider_label")
+ private String providerLabel;
+
+ private ListExample:
- *{@code
- * Lettr lettr = new Lettr("your-api-key");
- *
- * CreateEmailResponse response = lettr.emails().send(
- * CreateEmailOptions.builder()
- * .from("sender@example.com")
- * .to("recipient@example.com")
- * .subject("Hello!")
- * .html("Hello, world!
")
- * .build()
- * );
- * }
+ * Service for sending, scheduling, and retrieving emails via the Lettr API.
*/
public class Emails extends BaseService {
- public Emails(String apiKey) {
+ public Emails(@Nonnull String apiKey) {
super(apiKey);
}
@@ -38,42 +25,123 @@ public Emails(String apiKey) {
* @return response containing the request ID and acceptance counts
* @throws LettrException if the request fails
*/
- public CreateEmailResponse send(CreateEmailOptions options) throws LettrException {
+ @Nonnull
+ public CreateEmailResponse send(@Nonnull CreateEmailOptions options) throws LettrException {
return httpClient.post("/emails", options, CreateEmailResponse.class);
}
/**
* List sent emails with optional filtering and pagination.
*
- * @param params optional query parameters for filtering
+ * @param params optional query parameters; pass null for defaults
* @return paginated list of email events
* @throws LettrException if the request fails
*/
- public ListEmailsResponse list(ListEmailsParams params) throws LettrException {
+ @Nonnull
+ public ListEmailsResponse list(@Nullable ListEmailsParams params) throws LettrException {
return httpClient.get("/emails", params != null ? params.toQueryParams() : null, ListEmailsResponse.class);
}
+ /** List sent emails with default pagination. */
+ @Nonnull
+ public ListEmailsResponse list() throws LettrException {
+ return list(null);
+ }
+
/**
- * List sent emails with default pagination.
+ * List email events with optional filtering and pagination.
*
+ * @param params optional query parameters; pass null for defaults
* @return paginated list of email events
* @throws LettrException if the request fails
*/
- public ListEmailsResponse list() throws LettrException {
- return list(null);
+ @Nonnull
+ public ListEmailEventsResponse listEvents(@Nullable ListEmailEventsParams params) throws LettrException {
+ return httpClient.get("/emails/events", params != null ? params.toQueryParams() : null, ListEmailEventsResponse.class);
+ }
+
+ /** List email events with default parameters. */
+ @Nonnull
+ public ListEmailEventsResponse listEvents() throws LettrException {
+ return listEvents(null);
}
/**
- * Get all events for a specific email transmission.
+ * Get details of a specific email transmission.
*
* @param requestId the request ID returned when the email was sent
- * @return response containing all events (injection, delivery, bounce, etc.)
+ * @return response containing transmission details and events
* @throws LettrException if the request fails
+ * @throws IllegalArgumentException if {@code requestId} is null or empty
*/
- public GetEmailResponse get(String requestId) throws LettrException {
+ @Nonnull
+ public GetEmailResponse get(@Nonnull String requestId) throws LettrException {
+ return get(requestId, null, null);
+ }
+
+ /**
+ * Get details of a specific email transmission with optional date range filtering.
+ *
+ * @param requestId the request ID returned when the email was sent
+ * @param from optional start date for the event search range (ISO 8601). Defaults to 10 days ago.
+ * @param to optional end date for the event search range (ISO 8601). Defaults to now.
+ * @return response containing transmission details and events
+ * @throws LettrException if the request fails
+ * @throws IllegalArgumentException if {@code requestId} is null or empty
+ */
+ @Nonnull
+ public GetEmailResponse get(@Nonnull String requestId, @Nullable String from, @Nullable String to) throws LettrException {
if (requestId == null || requestId.isEmpty()) {
throw new IllegalArgumentException("requestId is required");
}
- return httpClient.get("/emails/" + requestId, null, GetEmailResponse.class);
+ MapAt minimum, {@code from}, {@code to}, {@code subject}, and either {@code html} or {@code text} are required.
+ *At minimum, {@code from}, {@code to}, and at least one of {@code html}, {@code text}, + * or {@code templateSlug} are required. The {@code subject} is required unless using a template.
* - *Example usage:
+ *Example:
*{@code
* CreateEmailOptions params = CreateEmailOptions.builder()
* .from("sender@example.com")
@@ -24,194 +27,331 @@ public class CreateEmailOptions {
private final String from;
private final String from_name;
private final List to;
+ private final List cc;
+ private final List bcc;
private final String subject;
+ private final String reply_to;
+ private final String reply_to_name;
private final String html;
private final String text;
+ private final String amp_html;
private final String template_slug;
private final Integer template_version;
private final Integer project_id;
+ private final String tag;
+ private final Map headers;
private final List attachments;
- private final Map substitution_data;
- private final Map metadata;
+ private final Map substitution_data;
+ private final Map metadata;
private final EmailOptions options;
- private CreateEmailOptions(Builder builder) {
+ protected CreateEmailOptions(Builder builder) {
this.from = builder.from;
this.from_name = builder.fromName;
this.to = builder.to;
+ this.cc = builder.cc;
+ this.bcc = builder.bcc;
this.subject = builder.subject;
+ this.reply_to = builder.replyTo;
+ this.reply_to_name = builder.replyToName;
this.html = builder.html;
this.text = builder.text;
+ this.amp_html = builder.ampHtml;
this.template_slug = builder.templateSlug;
this.template_version = builder.templateVersion;
this.project_id = builder.projectId;
+ this.tag = builder.tag;
+ this.headers = builder.headers;
this.attachments = builder.attachments;
this.substitution_data = builder.substitutionData;
this.metadata = builder.metadata;
this.options = builder.options;
}
+ @Nonnull
public static Builder builder() {
return new Builder();
}
- public String getFrom() { return from; }
- public String getFromName() { return from_name; }
- public List getTo() { return to; }
- public String getSubject() { return subject; }
- public String getHtml() { return html; }
- public String getText() { return text; }
- public String getTemplateSlug() { return template_slug; }
- public Integer getTemplateVersion() { return template_version; }
- public Integer getProjectId() { return project_id; }
- public List getAttachments() { return attachments; }
- public Map getSubstitutionData() { return substitution_data; }
- public Map getMetadata() { return metadata; }
- public EmailOptions getOptions() { return options; }
+ @Nonnull public String getFrom() { return from; }
+ @Nullable public String getFromName() { return from_name; }
+ @Nonnull public List getTo() { return to; }
+ @Nullable public List getCc() { return cc; }
+ @Nullable public List getBcc() { return bcc; }
+ @Nullable public String getSubject() { return subject; }
+ @Nullable public String getReplyTo() { return reply_to; }
+ @Nullable public String getReplyToName() { return reply_to_name; }
+ @Nullable public String getHtml() { return html; }
+ @Nullable public String getText() { return text; }
+ @Nullable public String getAmpHtml() { return amp_html; }
+ @Nullable public String getTemplateSlug() { return template_slug; }
+ @Nullable public Integer getTemplateVersion() { return template_version; }
+ @Nullable public Integer getProjectId() { return project_id; }
+ @Nullable public String getTag() { return tag; }
+ @Nullable public Map getHeaders() { return headers; }
+ @Nullable public List getAttachments() { return attachments; }
+ @Nullable public Map getSubstitutionData() { return substitution_data; }
+ @Nullable public Map getMetadata() { return metadata; }
+ @Nullable public EmailOptions getOptions() { return options; }
public static class Builder {
- private String from;
- private String fromName;
- private List to;
- private String subject;
- private String html;
- private String text;
- private String templateSlug;
- private Integer templateVersion;
- private Integer projectId;
- private List attachments;
- private Map substitutionData;
- private Map metadata;
- private EmailOptions options;
-
- private Builder() {}
-
- /**
- * Sets the sender email address (required).
- */
- public Builder from(String from) {
+ protected String from;
+ protected String fromName;
+ protected List to;
+ protected List cc;
+ protected List bcc;
+ protected String subject;
+ protected String replyTo;
+ protected String replyToName;
+ protected String html;
+ protected String text;
+ protected String ampHtml;
+ protected String templateSlug;
+ protected Integer templateVersion;
+ protected Integer projectId;
+ protected String tag;
+ protected Map headers;
+ protected List attachments;
+ protected Map substitutionData;
+ protected Map metadata;
+ protected EmailOptions options;
+
+ protected Builder() {}
+
+ /**
+ * (required) Sets the sender email address.
+ * Max length: 255 characters. Must be a valid email address.
+ */
+ @Nonnull
+ public Builder from(@Nonnull String from) {
this.from = from;
return this;
}
/**
- * Sets the sender display name.
+ * (optional) Sets the sender display name.
+ * Max length: 255 characters.
*/
- public Builder fromName(String fromName) {
+ @Nonnull
+ public Builder fromName(@Nullable String fromName) {
this.fromName = fromName;
return this;
}
/**
- * Sets recipient email addresses (required). Accepts varargs.
+ * (required) Sets recipient email addresses (1–50 items). Accepts varargs.
*/
- public Builder to(String... to) {
+ @Nonnull
+ public Builder to(@Nonnull String... to) {
this.to = Arrays.asList(to);
return this;
}
/**
- * Sets recipient email addresses (required). Accepts a list.
+ * (required) Sets recipient email addresses (1–50 items). Accepts a list.
*/
- public Builder to(List to) {
+ @Nonnull
+ public Builder to(@Nonnull List to) {
this.to = to;
return this;
}
/**
- * Sets the email subject line (required).
+ * (optional) Sets carbon-copy recipient email addresses.
*/
- public Builder subject(String subject) {
+ @Nonnull
+ public Builder cc(@Nonnull String... cc) {
+ this.cc = Arrays.asList(cc);
+ return this;
+ }
+
+ /**
+ * (optional) Sets carbon-copy recipient email addresses.
+ */
+ @Nonnull
+ public Builder cc(@Nullable List cc) {
+ this.cc = cc;
+ return this;
+ }
+
+ /**
+ * (optional) Sets blind-carbon-copy recipient email addresses.
+ */
+ @Nonnull
+ public Builder bcc(@Nonnull String... bcc) {
+ this.bcc = Arrays.asList(bcc);
+ return this;
+ }
+
+ /**
+ * (optional) Sets blind-carbon-copy recipient email addresses.
+ */
+ @Nonnull
+ public Builder bcc(@Nullable List bcc) {
+ this.bcc = bcc;
+ return this;
+ }
+
+ /**
+ * (required unless using template) Sets the email subject line.
+ * Max length: 998 characters. When using {@link #templateSlug(String)}, this is
+ * optional and defaults to the template's subject.
+ */
+ @Nonnull
+ public Builder subject(@Nullable String subject) {
this.subject = subject;
return this;
}
/**
- * Sets the HTML content of the email. At least one of html or text is required.
+ * (optional) Sets the reply-to email address.
+ * Max length: 255 characters.
+ */
+ @Nonnull
+ public Builder replyTo(@Nullable String replyTo) {
+ this.replyTo = replyTo;
+ return this;
+ }
+
+ /**
+ * (optional) Sets the reply-to display name.
*/
- public Builder html(String html) {
+ @Nonnull
+ public Builder replyToName(@Nullable String replyToName) {
+ this.replyToName = replyToName;
+ return this;
+ }
+
+ /**
+ * (required unless text or template provided) Sets the HTML content of the email.
+ * At least one of {@code html}, {@code text}, or {@link #templateSlug(String)} is required.
+ */
+ @Nonnull
+ public Builder html(@Nullable String html) {
this.html = html;
return this;
}
/**
- * Sets the plain text content of the email. At least one of html or text is required.
+ * (required unless html or template provided) Sets the plain-text content of the email.
*/
- public Builder text(String text) {
+ @Nonnull
+ public Builder text(@Nullable String text) {
this.text = text;
return this;
}
/**
- * Sets the template slug to use for this email.
+ * (optional) Sets AMP HTML content for AMP-supporting email clients.
*/
- public Builder templateSlug(String templateSlug) {
+ @Nonnull
+ public Builder ampHtml(@Nullable String ampHtml) {
+ this.ampHtml = ampHtml;
+ return this;
+ }
+
+ /**
+ * (required unless html or text provided) Sets the template slug to use for this email.
+ * Max length: 255 characters.
+ */
+ @Nonnull
+ public Builder templateSlug(@Nullable String templateSlug) {
this.templateSlug = templateSlug;
return this;
}
/**
- * Sets a specific template version to use.
+ * (optional) Sets a specific template version (minimum 1).
+ * Defaults to the active version when not set.
*/
+ @Nonnull
public Builder templateVersion(int templateVersion) {
this.templateVersion = templateVersion;
return this;
}
/**
- * Sets the project ID for template lookup.
+ * (optional) Sets the project ID for template lookup.
+ * Defaults to the team's default project when not set.
*/
+ @Nonnull
public Builder projectId(int projectId) {
this.projectId = projectId;
return this;
}
/**
- * Sets file attachments for the email.
+ * (optional) Sets a tag for tracking and analytics.
+ * Max length: 64 characters. Auto-set from {@link #templateSlug(String)} if not provided.
+ */
+ @Nonnull
+ public Builder tag(@Nullable String tag) {
+ this.tag = tag;
+ return this;
+ }
+
+ /**
+ * (optional) Sets custom email headers. Up to 10 headers.
+ * Standard envelope headers (From, To, Subject, etc.) cannot be set here.
*/
- public Builder attachments(List attachments) {
+ @Nonnull
+ public Builder headers(@Nullable Map headers) {
+ this.headers = headers;
+ return this;
+ }
+
+ /**
+ * (optional) Sets file attachments.
+ */
+ @Nonnull
+ public Builder attachments(@Nullable List attachments) {
this.attachments = attachments;
return this;
}
/**
- * Sets file attachments for the email. Accepts varargs.
+ * (optional) Sets file attachments. Accepts varargs.
*/
- public Builder attachments(Attachment... attachments) {
+ @Nonnull
+ public Builder attachments(@Nonnull Attachment... attachments) {
this.attachments = Arrays.asList(attachments);
return this;
}
/**
- * Sets substitution data for template variable replacement.
- * Variables in your template like {@code {{first_name}}} will be replaced.
+ * (optional) Sets substitution data for template variable replacement.
*/
- public Builder substitutionData(Map substitutionData) {
+ @Nonnull
+ public Builder substitutionData(@Nullable Map substitutionData) {
this.substitutionData = substitutionData;
return this;
}
/**
- * Sets metadata to attach to the email for tracking purposes.
+ * (optional) Sets metadata to attach to the email for tracking purposes.
*/
- public Builder metadata(Map metadata) {
+ @Nonnull
+ public Builder metadata(@Nullable Map metadata) {
this.metadata = metadata;
return this;
}
/**
- * Sets tracking options (click tracking, open tracking, etc.).
+ * (optional) Sets tracking and delivery options.
*/
- public Builder options(EmailOptions options) {
+ @Nonnull
+ public Builder options(@Nullable EmailOptions options) {
this.options = options;
return this;
}
/**
- * Builds the CreateEmailOptions instance.
+ * Builds the {@link CreateEmailOptions} instance.
*
- * @throws IllegalArgumentException if required fields are missing
+ * @throws IllegalArgumentException if {@code from} or {@code to} is missing,
+ * or if none of {@code html}, {@code text}, or {@code templateSlug} is provided
*/
+ @Nonnull
public CreateEmailOptions build() {
if (from == null || from.isEmpty()) {
throw new IllegalArgumentException("'from' is required");
@@ -219,9 +359,6 @@ public CreateEmailOptions build() {
if (to == null || to.isEmpty()) {
throw new IllegalArgumentException("'to' is required");
}
- if (subject == null || subject.isEmpty()) {
- throw new IllegalArgumentException("'subject' is required");
- }
if (html == null && text == null && templateSlug == null) {
throw new IllegalArgumentException("At least one of 'html', 'text', or 'templateSlug' is required");
}
diff --git a/src/main/java/com/lettr/services/emails/model/CreateEmailResponse.java b/src/main/java/com/lettr/services/emails/model/CreateEmailResponse.java
index 0ea945a..0aa9be3 100644
--- a/src/main/java/com/lettr/services/emails/model/CreateEmailResponse.java
+++ b/src/main/java/com/lettr/services/emails/model/CreateEmailResponse.java
@@ -2,6 +2,8 @@
import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nonnull;
+
/**
* Response returned after successfully queuing an email for delivery.
*/
@@ -13,27 +15,15 @@ public class CreateEmailResponse {
private int accepted;
private int rejected;
- /**
- * Returns the unique request ID for this email transmission.
- * Use this ID to retrieve the email status later.
- */
- public String getRequestId() {
- return requestId;
- }
+ /** Unique identifier for this email transmission. Use it to retrieve the email status later. */
+ @Nonnull
+ public String getRequestId() { return requestId; }
- /**
- * Returns the number of accepted recipients.
- */
- public int getAccepted() {
- return accepted;
- }
+ /** Number of recipients accepted for delivery. */
+ public int getAccepted() { return accepted; }
- /**
- * Returns the number of rejected recipients.
- */
- public int getRejected() {
- return rejected;
- }
+ /** Number of recipients rejected. */
+ public int getRejected() { return rejected; }
@Override
public String toString() {
diff --git a/src/main/java/com/lettr/services/emails/model/EmailEvent.java b/src/main/java/com/lettr/services/emails/model/EmailEvent.java
index 71948a4..88cf5bb 100644
--- a/src/main/java/com/lettr/services/emails/model/EmailEvent.java
+++ b/src/main/java/com/lettr/services/emails/model/EmailEvent.java
@@ -1,13 +1,22 @@
package com.lettr.services.emails.model;
import com.google.gson.annotations.SerializedName;
-import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
/**
* Represents a single email event (injection, delivery, bounce, open, click, etc.).
+ *
+ * The {@code type} field determines which additional fields are present.
+ * All event types share the common fields below; type-specific fields
+ * (e.g. {@code bounceClass} for bounce events, {@code targetLinkUrl} for click events)
+ * are null for other event types.
*/
public class EmailEvent {
+ // Common properties
@SerializedName("event_id")
private String eventId;
@@ -47,19 +56,65 @@ public class EmailEvent {
private String sendingIp;
@SerializedName("click_tracking")
- private boolean clickTracking;
+ private Boolean clickTracking;
@SerializedName("open_tracking")
- private boolean openTracking;
+ private Boolean openTracking;
- private boolean transactional;
+ private Boolean transactional;
@SerializedName("msg_size")
- private int msgSize;
+ private Integer msgSize;
@SerializedName("injection_time")
private String injectionTime;
+ @SerializedName("rcpt_meta")
+ private Object rcptMeta;
+
+ @SerializedName("campaign_id")
+ private String campaignId;
+
+ @SerializedName("template_id")
+ private String templateId;
+
+ @SerializedName("template_version")
+ private String templateVersion;
+
+ @SerializedName("ip_pool")
+ private String ipPool;
+
+ @SerializedName("msg_from")
+ private String msgFrom;
+
+ @SerializedName("rcpt_type")
+ private String rcptType;
+
+ @SerializedName("rcpt_tags")
+ private List rcptTags;
+
+ @SerializedName("amp_enabled")
+ private Boolean ampEnabled;
+
+ @SerializedName("delv_method")
+ private String delvMethod;
+
+ @SerializedName("recv_method")
+ private String recvMethod;
+
+ @SerializedName("routing_domain")
+ private String routingDomain;
+
+ @SerializedName("scheduled_time")
+ private String scheduledTime;
+
+ @SerializedName("ab_test_id")
+ private String abTestId;
+
+ @SerializedName("ab_test_version")
+ private String abTestVersion;
+
+ // Type-specific fields
private String reason;
@SerializedName("raw_reason")
@@ -68,32 +123,127 @@ public class EmailEvent {
@SerializedName("error_code")
private String errorCode;
- @SerializedName("rcpt_meta")
- private Map rcptMeta;
-
- public String getEventId() { return eventId; }
- public String getType() { return type; }
- public String getTimestamp() { return timestamp; }
- public String getRequestId() { return requestId; }
- public String getMessageId() { return messageId; }
- public String getSubject() { return subject; }
- public String getFriendlyFrom() { return friendlyFrom; }
- public String getSendingDomain() { return sendingDomain; }
- public String getRcptTo() { return rcptTo; }
- public String getRawRcptTo() { return rawRcptTo; }
- public String getRecipientDomain() { return recipientDomain; }
- public String getMailboxProvider() { return mailboxProvider; }
- public String getMailboxProviderRegion() { return mailboxProviderRegion; }
- public String getSendingIp() { return sendingIp; }
- public boolean isClickTracking() { return clickTracking; }
- public boolean isOpenTracking() { return openTracking; }
- public boolean isTransactional() { return transactional; }
- public int getMsgSize() { return msgSize; }
- public String getInjectionTime() { return injectionTime; }
- public String getReason() { return reason; }
- public String getRawReason() { return rawReason; }
- public String getErrorCode() { return errorCode; }
- public Map getRcptMeta() { return rcptMeta; }
+ @SerializedName("bounce_class")
+ private Integer bounceClass;
+
+ @SerializedName("num_retries")
+ private Integer numRetries;
+
+ @SerializedName("queue_time")
+ private Integer queueTime;
+
+ @SerializedName("target_link_url")
+ private String targetLinkUrl;
+
+ @SerializedName("target_link_name")
+ private String targetLinkName;
+
+ @SerializedName("user_agent")
+ private String userAgent;
+
+ @SerializedName("geo_ip")
+ private GeoIp geoIp;
+
+ @SerializedName("user_agent_parsed")
+ private UserAgentParsed userAgentParsed;
+
+ @SerializedName("ip_address")
+ private String ipAddress;
+
+ @SerializedName("initial_pixel")
+ private Boolean initialPixel;
+
+ @SerializedName("outbound_tls")
+ private String outboundTls;
+
+ @SerializedName("device_token")
+ private String deviceToken;
+
+ private String fbtype;
+
+ @SerializedName("report_by")
+ private String reportBy;
+
+ @SerializedName("report_to")
+ private String reportTo;
+
+ @SerializedName("remote_addr")
+ private String remoteAddr;
+
+ // Common getters — always present on full event objects (e.g. from /emails/events),
+ // but may be null on list items from GET /emails (which uses a simpler schema).
+ @Nonnull public String getEventId() { return eventId; }
+ @Nonnull public String getType() { return type; }
+ @Nonnull public String getTimestamp() { return timestamp; }
+ @Nullable public String getRequestId() { return requestId; }
+ @Nullable public String getRcptTo() { return rcptTo; }
+ @Nullable public String getRawRcptTo() { return rawRcptTo; }
+ @Nullable public String getRecipientDomain() { return recipientDomain; }
+ @Nullable public String getMailboxProvider() { return mailboxProvider; }
+ @Nullable public String getMailboxProviderRegion() { return mailboxProviderRegion; }
+
+ // Optional common fields
+ @Nullable public String getMessageId() { return messageId; }
+ @Nullable public String getSubject() { return subject; }
+ @Nullable public String getFriendlyFrom() { return friendlyFrom; }
+ @Nullable public String getSendingDomain() { return sendingDomain; }
+ @Nullable public String getSendingIp() { return sendingIp; }
+ @Nullable public Boolean getClickTracking() { return clickTracking; }
+ @Nullable public Boolean getOpenTracking() { return openTracking; }
+ @Nullable public Boolean getTransactional() { return transactional; }
+ @Nullable public Integer getMsgSize() { return msgSize; }
+ @Nullable public String getInjectionTime() { return injectionTime; }
+ @Nullable public Object getRcptMeta() { return rcptMeta; }
+ @Nullable public String getCampaignId() { return campaignId; }
+ @Nullable public String getTemplateId() { return templateId; }
+ @Nullable public String getTemplateVersion() { return templateVersion; }
+ @Nullable public String getIpPool() { return ipPool; }
+ @Nullable public String getMsgFrom() { return msgFrom; }
+ @Nullable public String getRcptType() { return rcptType; }
+ @Nullable public List getRcptTags() { return rcptTags; }
+ @Nullable public Boolean getAmpEnabled() { return ampEnabled; }
+ @Nullable public String getDelvMethod() { return delvMethod; }
+ @Nullable public String getRecvMethod() { return recvMethod; }
+ @Nullable public String getRoutingDomain() { return routingDomain; }
+ @Nullable public String getScheduledTime() { return scheduledTime; }
+ @Nullable public String getAbTestId() { return abTestId; }
+ @Nullable public String getAbTestVersion() { return abTestVersion; }
+
+ // Type-specific getters (null for other event types)
+ /** Bounce/delivery reason. Present on bounce, delay, out_of_band, etc. */
+ @Nullable public String getReason() { return reason; }
+ @Nullable public String getRawReason() { return rawReason; }
+ @Nullable public String getErrorCode() { return errorCode; }
+ /** Bounce classification code. Present on bounce, delay, out_of_band, policy_rejection events. */
+ @Nullable public Integer getBounceClass() { return bounceClass; }
+ @Nullable public Integer getNumRetries() { return numRetries; }
+ /** Time spent in queue in milliseconds. Present on delivery and delay events. */
+ @Nullable public Integer getQueueTime() { return queueTime; }
+ /** Clicked URL. Present only on click and amp_click events. */
+ @Nullable public String getTargetLinkUrl() { return targetLinkUrl; }
+ @Nullable public String getTargetLinkName() { return targetLinkName; }
+ /** Raw user-agent string. Present on click and open events. */
+ @Nullable public String getUserAgent() { return userAgent; }
+ /** Geolocation derived from event IP. Present on click and open events. */
+ @Nullable public GeoIp getGeoIp() { return geoIp; }
+ /** Parsed user-agent. Present on click and open events. */
+ @Nullable public UserAgentParsed getUserAgentParsed() { return userAgentParsed; }
+ /** IP address of the open/click. Present on click, open, initial_open, amp_click, amp_open, amp_initial_open events. */
+ @Nullable public String getIpAddress() { return ipAddress; }
+ /** Whether initial open tracking pixel was included. Present on injection, open, initial_open, amp_open, amp_initial_open events. */
+ @Nullable public Boolean getInitialPixel() { return initialPixel; }
+ /** Whether TLS was used for outbound delivery. Present on delivery and delay events. */
+ @Nullable public String getOutboundTls() { return outboundTls; }
+ /** Device token if applicable. Present on bounce and out_of_band events. */
+ @Nullable public String getDeviceToken() { return deviceToken; }
+ /** Feedback type (e.g. "abuse"). Present on spam_complaint events. */
+ @Nullable public String getFbtype() { return fbtype; }
+ /** Who reported the spam. Present on spam_complaint events. */
+ @Nullable public String getReportBy() { return reportBy; }
+ /** Where the spam report was sent. Present on spam_complaint events. */
+ @Nullable public String getReportTo() { return reportTo; }
+ /** Remote IP address. Present on policy_rejection events. */
+ @Nullable public String getRemoteAddr() { return remoteAddr; }
@Override
public String toString() {
diff --git a/src/main/java/com/lettr/services/emails/model/EmailOptions.java b/src/main/java/com/lettr/services/emails/model/EmailOptions.java
index 94d57d7..161c841 100644
--- a/src/main/java/com/lettr/services/emails/model/EmailOptions.java
+++ b/src/main/java/com/lettr/services/emails/model/EmailOptions.java
@@ -1,67 +1,84 @@
package com.lettr.services.emails.model;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
/**
- * Tracking options for an email.
+ * Tracking and delivery options for an email. All fields are optional;
+ * omitted values fall back to the team's defaults.
*/
public class EmailOptions {
private final Boolean click_tracking;
private final Boolean open_tracking;
private final Boolean transactional;
+ private final Boolean inline_css;
+ private final Boolean perform_substitutions;
private EmailOptions(Builder builder) {
this.click_tracking = builder.clickTracking;
this.open_tracking = builder.openTracking;
this.transactional = builder.transactional;
+ this.inline_css = builder.inlineCss;
+ this.perform_substitutions = builder.performSubstitutions;
}
+ @Nonnull
public static Builder builder() {
return new Builder();
}
- public Boolean getClickTracking() {
- return click_tracking;
- }
-
- public Boolean getOpenTracking() {
- return open_tracking;
- }
-
- public Boolean getTransactional() {
- return transactional;
- }
+ @Nullable public Boolean getClickTracking() { return click_tracking; }
+ @Nullable public Boolean getOpenTracking() { return open_tracking; }
+ @Nullable public Boolean getTransactional() { return transactional; }
+ @Nullable public Boolean getInlineCss() { return inline_css; }
+ @Nullable public Boolean getPerformSubstitutions() { return perform_substitutions; }
public static class Builder {
private Boolean clickTracking;
private Boolean openTracking;
private Boolean transactional;
+ private Boolean inlineCss;
+ private Boolean performSubstitutions;
private Builder() {}
- /**
- * Enable or disable click tracking.
- */
+ /** (optional) Enable or disable click tracking for links in the email. */
+ @Nonnull
public Builder clickTracking(boolean clickTracking) {
this.clickTracking = clickTracking;
return this;
}
- /**
- * Enable or disable open tracking.
- */
+ /** (optional) Enable or disable open tracking via tracking pixel. */
+ @Nonnull
public Builder openTracking(boolean openTracking) {
this.openTracking = openTracking;
return this;
}
- /**
- * Mark the email as transactional or non-transactional.
- */
+ /** (optional) Mark the email as transactional or marketing. */
+ @Nonnull
public Builder transactional(boolean transactional) {
this.transactional = transactional;
return this;
}
+ /** (optional) Inline CSS styles in HTML content before sending. */
+ @Nonnull
+ public Builder inlineCss(boolean inlineCss) {
+ this.inlineCss = inlineCss;
+ return this;
+ }
+
+ /** (optional) Perform variable substitutions in content. */
+ @Nonnull
+ public Builder performSubstitutions(boolean performSubstitutions) {
+ this.performSubstitutions = performSubstitutions;
+ return this;
+ }
+
+ @Nonnull
public EmailOptions build() {
return new EmailOptions(this);
}
diff --git a/src/main/java/com/lettr/services/emails/model/GeoIp.java b/src/main/java/com/lettr/services/emails/model/GeoIp.java
new file mode 100644
index 0000000..d34af09
--- /dev/null
+++ b/src/main/java/com/lettr/services/emails/model/GeoIp.java
@@ -0,0 +1,36 @@
+package com.lettr.services.emails.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nullable;
+
+/**
+ * Geolocation data derived from the IP address of an open/click event.
+ * All fields may be null when the lookup was unavailable or imprecise.
+ */
+public class GeoIp {
+
+ private String country;
+ private String region;
+ private String city;
+ private Double latitude;
+ private Double longitude;
+ private String zip;
+
+ @SerializedName("postal_code")
+ private String postalCode;
+
+ /** ISO 3166-1 alpha-2 country code. */
+ @Nullable public String getCountry() { return country; }
+ @Nullable public String getRegion() { return region; }
+ @Nullable public String getCity() { return city; }
+ @Nullable public Double getLatitude() { return latitude; }
+ @Nullable public Double getLongitude() { return longitude; }
+ @Nullable public String getZip() { return zip; }
+ @Nullable public String getPostalCode() { return postalCode; }
+
+ @Override
+ public String toString() {
+ return "GeoIp{country='" + country + "', city='" + city + "'}";
+ }
+}
diff --git a/src/main/java/com/lettr/services/emails/model/GetEmailResponse.java b/src/main/java/com/lettr/services/emails/model/GetEmailResponse.java
index f1f0f5f..0c070ad 100644
--- a/src/main/java/com/lettr/services/emails/model/GetEmailResponse.java
+++ b/src/main/java/com/lettr/services/emails/model/GetEmailResponse.java
@@ -1,34 +1,60 @@
package com.lettr.services.emails.model;
import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import java.util.List;
/**
- * Response from retrieving details of a specific email.
+ * Response from retrieving details of a specific email transmission.
*/
public class GetEmailResponse {
- private List results;
+ @SerializedName("transmission_id")
+ private String transmissionId;
- @SerializedName("total_count")
- private int totalCount;
+ private String state;
- /**
- * Returns all events for this email (injection, delivery, bounce, open, etc.).
- */
- public List getResults() {
- return results;
- }
+ @SerializedName("scheduled_at")
+ private String scheduledAt;
- public int getTotalCount() {
- return totalCount;
- }
+ private String from;
+
+ @SerializedName("from_name")
+ private String fromName;
+
+ private String subject;
+
+ private List recipients;
+
+ @SerializedName("num_recipients")
+ private int numRecipients;
+
+ private List events;
+
+ @Nonnull public String getTransmissionId() { return transmissionId; }
+
+ /** Derived delivery state: {@code scheduled}, {@code delivered}, {@code bounced}, or {@code failed}. */
+ @Nonnull public String getState() { return state; }
+
+ /** Scheduled delivery time. Usually null for immediately-sent emails. */
+ @Nullable public String getScheduledAt() { return scheduledAt; }
+
+ @Nonnull public String getFrom() { return from; }
+ @Nullable public String getFromName() { return fromName; }
+ @Nonnull public String getSubject() { return subject; }
+ @Nonnull public List getRecipients() { return recipients; }
+ public int getNumRecipients() { return numRecipients; }
+ @Nonnull public List getEvents() { return events; }
@Override
public String toString() {
return "GetEmailResponse{" +
- "totalCount=" + totalCount +
- ", results=" + results +
+ "transmissionId='" + transmissionId + '\'' +
+ ", state='" + state + '\'' +
+ ", subject='" + subject + '\'' +
+ ", numRecipients=" + numRecipients +
'}';
}
}
diff --git a/src/main/java/com/lettr/services/emails/model/ListEmailEventsParams.java b/src/main/java/com/lettr/services/emails/model/ListEmailEventsParams.java
new file mode 100644
index 0000000..2941607
--- /dev/null
+++ b/src/main/java/com/lettr/services/emails/model/ListEmailEventsParams.java
@@ -0,0 +1,130 @@
+package com.lettr.services.emails.model;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Parameters for listing email events. All fields are optional.
+ */
+public class ListEmailEventsParams {
+
+ private final List events;
+ private final List recipients;
+ private final String from;
+ private final String to;
+ private final Integer perPage;
+ private final String cursor;
+ private final String transmissions;
+ private final String bounceClasses;
+
+ private ListEmailEventsParams(Builder builder) {
+ this.events = builder.events;
+ this.recipients = builder.recipients;
+ this.from = builder.from;
+ this.to = builder.to;
+ this.perPage = builder.perPage;
+ this.cursor = builder.cursor;
+ this.transmissions = builder.transmissions;
+ this.bounceClasses = builder.bounceClasses;
+ }
+
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nonnull
+ public Map toQueryParams() {
+ Map params = new LinkedHashMap<>();
+ if (events != null && !events.isEmpty()) {
+ params.put("events", String.join(",", events));
+ }
+ if (recipients != null && !recipients.isEmpty()) {
+ params.put("recipients", String.join(",", recipients));
+ }
+ if (from != null) params.put("from", from);
+ if (to != null) params.put("to", to);
+ if (perPage != null) params.put("per_page", perPage.toString());
+ if (cursor != null) params.put("cursor", cursor);
+ if (transmissions != null) params.put("transmissions", transmissions);
+ if (bounceClasses != null) params.put("bounce_classes", bounceClasses);
+ return params;
+ }
+
+ public static class Builder {
+ private List events;
+ private List recipients;
+ private String from;
+ private String to;
+ private Integer perPage;
+ private String cursor;
+ private String transmissions;
+ private String bounceClasses;
+
+ private Builder() {}
+
+ /** (optional) Filters by event type (e.g. "delivery", "bounce", "open"). */
+ @Nonnull
+ public Builder events(@Nullable List events) {
+ this.events = events;
+ return this;
+ }
+
+ /** (optional) Filters by recipient email addresses. */
+ @Nonnull
+ public Builder recipients(@Nullable List recipients) {
+ this.recipients = recipients;
+ return this;
+ }
+
+ /** (optional) Filters events on or after this date (ISO 8601). */
+ @Nonnull
+ public Builder from(@Nullable String from) {
+ this.from = from;
+ return this;
+ }
+
+ /** (optional) Filters events on or before this date (ISO 8601). */
+ @Nonnull
+ public Builder to(@Nullable String to) {
+ this.to = to;
+ return this;
+ }
+
+ /** (optional) Sets the number of results per page. */
+ @Nonnull
+ public Builder perPage(int perPage) {
+ this.perPage = perPage;
+ return this;
+ }
+
+ /** (optional) Sets the pagination cursor from a previous response. */
+ @Nonnull
+ public Builder cursor(@Nullable String cursor) {
+ this.cursor = cursor;
+ return this;
+ }
+
+ /** (optional) Filters by transmission/request IDs (comma-separated). */
+ @Nonnull
+ public Builder transmissions(@Nullable String transmissions) {
+ this.transmissions = transmissions;
+ return this;
+ }
+
+ /** (optional) Filters by bounce class codes (comma-separated). */
+ @Nonnull
+ public Builder bounceClasses(@Nullable String bounceClasses) {
+ this.bounceClasses = bounceClasses;
+ return this;
+ }
+
+ @Nonnull
+ public ListEmailEventsParams build() {
+ return new ListEmailEventsParams(this);
+ }
+ }
+}
diff --git a/src/main/java/com/lettr/services/emails/model/ListEmailEventsResponse.java b/src/main/java/com/lettr/services/emails/model/ListEmailEventsResponse.java
new file mode 100644
index 0000000..ae14220
--- /dev/null
+++ b/src/main/java/com/lettr/services/emails/model/ListEmailEventsResponse.java
@@ -0,0 +1,55 @@
+package com.lettr.services.emails.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+
+/**
+ * Response from listing email events.
+ */
+public class ListEmailEventsResponse {
+
+ private Events events;
+
+ @Nonnull public Events getEvents() { return events; }
+
+ /** Container for email events with pagination. */
+ public static class Events {
+
+ private List data;
+
+ @SerializedName("total_count")
+ private int totalCount;
+
+ private String from;
+ private String to;
+ private Pagination pagination;
+
+ @Nonnull public List getData() { return data; }
+ public int getTotalCount() { return totalCount; }
+ @Nonnull public String getFrom() { return from; }
+ @Nonnull public String getTo() { return to; }
+ @Nonnull public Pagination getPagination() { return pagination; }
+ }
+
+ /** Cursor-based pagination info. */
+ public static class Pagination {
+
+ @SerializedName("next_cursor")
+ private String nextCursor;
+
+ @SerializedName("per_page")
+ private int perPage;
+
+ /** Cursor for the next page, or null if there are no more results. */
+ @Nullable public String getNextCursor() { return nextCursor; }
+ public int getPerPage() { return perPage; }
+ }
+
+ @Override
+ public String toString() {
+ return "ListEmailEventsResponse{events=" + (events != null ? "totalCount=" + events.totalCount : "null") + '}';
+ }
+}
diff --git a/src/main/java/com/lettr/services/emails/model/ListEmailsParams.java b/src/main/java/com/lettr/services/emails/model/ListEmailsParams.java
index 50f77c1..0f2c6be 100644
--- a/src/main/java/com/lettr/services/emails/model/ListEmailsParams.java
+++ b/src/main/java/com/lettr/services/emails/model/ListEmailsParams.java
@@ -1,18 +1,13 @@
package com.lettr.services.emails.model;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
/**
* Parameters for listing sent emails with optional filtering and pagination.
- *
- * Example usage:
- * {@code
- * ListEmailsParams params = ListEmailsParams.builder()
- * .perPage(50)
- * .recipients("user@example.com")
- * .build();
- * }
+ * All fields are optional.
*/
public class ListEmailsParams {
@@ -30,13 +25,13 @@ private ListEmailsParams(Builder builder) {
this.to = builder.to;
}
+ @Nonnull
public static Builder builder() {
return new Builder();
}
- /**
- * Converts this params object to a map of query parameters.
- */
+ /** Converts this params object to a map of query parameters. */
+ @Nonnull
public Map toQueryParams() {
Map params = new HashMap<>();
if (perPage != null) params.put("per_page", perPage.toString());
@@ -56,46 +51,42 @@ public static class Builder {
private Builder() {}
- /**
- * Sets the number of results per page (1-100).
- */
+ /** (optional) Sets the number of results per page (1–100). */
+ @Nonnull
public Builder perPage(int perPage) {
this.perPage = perPage;
return this;
}
- /**
- * Sets the pagination cursor from a previous response.
- */
- public Builder cursor(String cursor) {
+ /** (optional) Sets the pagination cursor from a previous response. */
+ @Nonnull
+ public Builder cursor(@Nullable String cursor) {
this.cursor = cursor;
return this;
}
- /**
- * Filters by recipient email address.
- */
- public Builder recipients(String recipients) {
+ /** (optional) Filters by recipient email address. */
+ @Nonnull
+ public Builder recipients(@Nullable String recipients) {
this.recipients = recipients;
return this;
}
- /**
- * Filters emails sent on or after this date (ISO 8601 format, e.g. "2024-01-01").
- */
- public Builder from(String from) {
+ /** (optional) Filters emails sent on or after this date (ISO 8601). */
+ @Nonnull
+ public Builder from(@Nullable String from) {
this.from = from;
return this;
}
- /**
- * Filters emails sent on or before this date (ISO 8601 format, e.g. "2024-12-31").
- */
- public Builder to(String to) {
+ /** (optional) Filters emails sent on or before this date (ISO 8601). */
+ @Nonnull
+ public Builder to(@Nullable String to) {
this.to = to;
return this;
}
+ @Nonnull
public ListEmailsParams build() {
return new ListEmailsParams(this);
}
diff --git a/src/main/java/com/lettr/services/emails/model/ListEmailsResponse.java b/src/main/java/com/lettr/services/emails/model/ListEmailsResponse.java
index e1b5a24..07ea010 100644
--- a/src/main/java/com/lettr/services/emails/model/ListEmailsResponse.java
+++ b/src/main/java/com/lettr/services/emails/model/ListEmailsResponse.java
@@ -1,6 +1,9 @@
package com.lettr.services.emails.model;
import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import java.util.List;
/**
@@ -8,28 +11,30 @@
*/
public class ListEmailsResponse {
- private List results;
+ private Events events;
- @SerializedName("total_count")
- private int totalCount;
+ @Nonnull public Events getEvents() { return events; }
- private Pagination pagination;
+ /** Container for email events with pagination. */
+ public static class Events {
- public List getResults() {
- return results;
- }
+ private List data;
- public int getTotalCount() {
- return totalCount;
- }
+ @SerializedName("total_count")
+ private int totalCount;
- public Pagination getPagination() {
- return pagination;
+ private String from;
+ private String to;
+ private Pagination pagination;
+
+ @Nonnull public List getData() { return data; }
+ public int getTotalCount() { return totalCount; }
+ @Nonnull public String getFrom() { return from; }
+ @Nonnull public String getTo() { return to; }
+ @Nonnull public Pagination getPagination() { return pagination; }
}
- /**
- * Cursor-based pagination info.
- */
+ /** Cursor-based pagination info. */
public static class Pagination {
@SerializedName("next_cursor")
@@ -38,20 +43,13 @@ public static class Pagination {
@SerializedName("per_page")
private int perPage;
- public String getNextCursor() {
- return nextCursor;
- }
-
- public int getPerPage() {
- return perPage;
- }
+ /** Cursor for the next page, or null if there are no more results. */
+ @Nullable public String getNextCursor() { return nextCursor; }
+ public int getPerPage() { return perPage; }
}
@Override
public String toString() {
- return "ListEmailsResponse{" +
- "totalCount=" + totalCount +
- ", results=" + results +
- '}';
+ return "ListEmailsResponse{events=" + (events != null ? "totalCount=" + events.totalCount : "null") + '}';
}
}
diff --git a/src/main/java/com/lettr/services/emails/model/ScheduleEmailOptions.java b/src/main/java/com/lettr/services/emails/model/ScheduleEmailOptions.java
new file mode 100644
index 0000000..5b054e3
--- /dev/null
+++ b/src/main/java/com/lettr/services/emails/model/ScheduleEmailOptions.java
@@ -0,0 +1,116 @@
+package com.lettr.services.emails.model;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Options for scheduling an email for future delivery.
+ *
+ * Extends {@link CreateEmailOptions} with a required {@code scheduledAt} field.
+ * The scheduled time must be at least 5 minutes in the future and within 3 days.
+ */
+public class ScheduleEmailOptions extends CreateEmailOptions {
+
+ private final String scheduled_at;
+
+ private ScheduleEmailOptions(Builder builder) {
+ super(builder);
+ this.scheduled_at = builder.scheduledAt;
+ }
+
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nonnull public String getScheduledAt() { return scheduled_at; }
+
+ public static class Builder extends CreateEmailOptions.Builder {
+ private String scheduledAt;
+
+ protected Builder() {}
+
+ /**
+ * (required) Sets the scheduled delivery time in ISO 8601 format.
+ * Must be at least 5 minutes in the future and within 3 days.
+ */
+ @Nonnull
+ public Builder scheduledAt(@Nonnull String scheduledAt) {
+ this.scheduledAt = scheduledAt;
+ return this;
+ }
+
+ @Override @Nonnull
+ public Builder from(@Nonnull String from) { super.from(from); return this; }
+ @Override @Nonnull
+ public Builder fromName(@Nullable String fromName) { super.fromName(fromName); return this; }
+ @Override @Nonnull
+ public Builder to(@Nonnull String... to) { super.to(to); return this; }
+ @Override @Nonnull
+ public Builder to(@Nonnull List to) { super.to(to); return this; }
+ @Override @Nonnull
+ public Builder cc(@Nonnull String... cc) { super.cc(cc); return this; }
+ @Override @Nonnull
+ public Builder cc(@Nullable List cc) { super.cc(cc); return this; }
+ @Override @Nonnull
+ public Builder bcc(@Nonnull String... bcc) { super.bcc(bcc); return this; }
+ @Override @Nonnull
+ public Builder bcc(@Nullable List bcc) { super.bcc(bcc); return this; }
+ @Override @Nonnull
+ public Builder subject(@Nullable String subject) { super.subject(subject); return this; }
+ @Override @Nonnull
+ public Builder replyTo(@Nullable String replyTo) { super.replyTo(replyTo); return this; }
+ @Override @Nonnull
+ public Builder replyToName(@Nullable String replyToName) { super.replyToName(replyToName); return this; }
+ @Override @Nonnull
+ public Builder html(@Nullable String html) { super.html(html); return this; }
+ @Override @Nonnull
+ public Builder text(@Nullable String text) { super.text(text); return this; }
+ @Override @Nonnull
+ public Builder ampHtml(@Nullable String ampHtml) { super.ampHtml(ampHtml); return this; }
+ @Override @Nonnull
+ public Builder templateSlug(@Nullable String templateSlug) { super.templateSlug(templateSlug); return this; }
+ @Override @Nonnull
+ public Builder templateVersion(int templateVersion) { super.templateVersion(templateVersion); return this; }
+ @Override @Nonnull
+ public Builder projectId(int projectId) { super.projectId(projectId); return this; }
+ @Override @Nonnull
+ public Builder tag(@Nullable String tag) { super.tag(tag); return this; }
+ @Override @Nonnull
+ public Builder headers(@Nullable Map headers) { super.headers(headers); return this; }
+ @Override @Nonnull
+ public Builder attachments(@Nullable List attachments) { super.attachments(attachments); return this; }
+ @Override @Nonnull
+ public Builder attachments(@Nonnull Attachment... attachments) { super.attachments(attachments); return this; }
+ @Override @Nonnull
+ public Builder substitutionData(@Nullable Map substitutionData) { super.substitutionData(substitutionData); return this; }
+ @Override @Nonnull
+ public Builder metadata(@Nullable Map metadata) { super.metadata(metadata); return this; }
+ @Override @Nonnull
+ public Builder options(@Nullable EmailOptions options) { super.options(options); return this; }
+
+ /**
+ * Builds the {@link ScheduleEmailOptions} instance.
+ *
+ * @throws IllegalArgumentException if {@code from}, {@code to}, content, or {@code scheduledAt} is missing
+ */
+ @Override @Nonnull
+ public ScheduleEmailOptions build() {
+ if (from == null || from.isEmpty()) {
+ throw new IllegalArgumentException("'from' is required");
+ }
+ if (to == null || to.isEmpty()) {
+ throw new IllegalArgumentException("'to' is required");
+ }
+ if (html == null && text == null && templateSlug == null) {
+ throw new IllegalArgumentException("At least one of 'html', 'text', or 'templateSlug' is required");
+ }
+ if (scheduledAt == null || scheduledAt.isEmpty()) {
+ throw new IllegalArgumentException("'scheduledAt' is required");
+ }
+ return new ScheduleEmailOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/com/lettr/services/emails/model/ScheduledEmail.java b/src/main/java/com/lettr/services/emails/model/ScheduledEmail.java
new file mode 100644
index 0000000..9b49373
--- /dev/null
+++ b/src/main/java/com/lettr/services/emails/model/ScheduledEmail.java
@@ -0,0 +1,64 @@
+package com.lettr.services.emails.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+
+/**
+ * Represents a scheduled email transmission.
+ */
+public class ScheduledEmail {
+
+ @SerializedName("transmission_id")
+ private String transmissionId;
+
+ private String state;
+
+ @SerializedName("scheduled_at")
+ private String scheduledAt;
+
+ private String from;
+
+ @SerializedName("from_name")
+ private String fromName;
+
+ private String subject;
+
+ private List recipients;
+
+ @SerializedName("num_recipients")
+ private int numRecipients;
+
+ private List events;
+
+ @Nonnull public String getTransmissionId() { return transmissionId; }
+
+ /**
+ * Current state: {@code submitted}, {@code generating}, {@code scheduled},
+ * {@code delivered}, {@code bounced}, {@code failed}, or {@code unknown}.
+ */
+ @Nonnull public String getState() { return state; }
+
+ /** Scheduled delivery time. May be null when the transmission has already been delivered. */
+ @Nullable public String getScheduledAt() { return scheduledAt; }
+
+ @Nonnull public String getFrom() { return from; }
+ @Nullable public String getFromName() { return fromName; }
+ @Nonnull public String getSubject() { return subject; }
+ @Nonnull public List getRecipients() { return recipients; }
+ public int getNumRecipients() { return numRecipients; }
+
+ /** Empty array when the email is still scheduled; populated once processed. */
+ @Nonnull public List getEvents() { return events; }
+
+ @Override
+ public String toString() {
+ return "ScheduledEmail{" +
+ "transmissionId='" + transmissionId + '\'' +
+ ", state='" + state + '\'' +
+ ", scheduledAt='" + scheduledAt + '\'' +
+ '}';
+ }
+}
diff --git a/src/main/java/com/lettr/services/emails/model/UserAgentParsed.java b/src/main/java/com/lettr/services/emails/model/UserAgentParsed.java
new file mode 100644
index 0000000..5c4cbb4
--- /dev/null
+++ b/src/main/java/com/lettr/services/emails/model/UserAgentParsed.java
@@ -0,0 +1,51 @@
+package com.lettr.services.emails.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nullable;
+
+/**
+ * Parsed user-agent information from an open/click event.
+ * All fields may be null when parsing was unable to determine the value.
+ */
+public class UserAgentParsed {
+
+ @SerializedName("agent_family")
+ private String agentFamily;
+
+ @SerializedName("device_brand")
+ private String deviceBrand;
+
+ @SerializedName("device_family")
+ private String deviceFamily;
+
+ @SerializedName("os_family")
+ private String osFamily;
+
+ @SerializedName("os_version")
+ private String osVersion;
+
+ @SerializedName("is_mobile")
+ private Boolean isMobile;
+
+ @SerializedName("is_proxy")
+ private Boolean isProxy;
+
+ @SerializedName("is_prefetched")
+ private Boolean isPrefetched;
+
+ @Nullable public String getAgentFamily() { return agentFamily; }
+ @Nullable public String getDeviceBrand() { return deviceBrand; }
+ @Nullable public String getDeviceFamily() { return deviceFamily; }
+ @Nullable public String getOsFamily() { return osFamily; }
+ @Nullable public String getOsVersion() { return osVersion; }
+ @Nullable public Boolean getIsMobile() { return isMobile; }
+ @Nullable public Boolean getIsProxy() { return isProxy; }
+ /** Whether the open was prefetched by the email provider (Gmail proxy, Apple MPP). May not represent a real human open. */
+ @Nullable public Boolean getIsPrefetched() { return isPrefetched; }
+
+ @Override
+ public String toString() {
+ return "UserAgentParsed{agentFamily='" + agentFamily + "', osFamily='" + osFamily + "'}";
+ }
+}
diff --git a/src/main/java/com/lettr/services/projects/Projects.java b/src/main/java/com/lettr/services/projects/Projects.java
new file mode 100644
index 0000000..5558730
--- /dev/null
+++ b/src/main/java/com/lettr/services/projects/Projects.java
@@ -0,0 +1,35 @@
+package com.lettr.services.projects;
+
+import com.lettr.core.exception.LettrException;
+import com.lettr.services.BaseService;
+import com.lettr.services.projects.model.ListProjectsParams;
+import com.lettr.services.projects.model.ListProjectsResponse;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Service for listing projects via the Lettr API.
+ */
+public class Projects extends BaseService {
+
+ public Projects(@Nonnull String apiKey) {
+ super(apiKey);
+ }
+
+ /**
+ * List projects with optional pagination.
+ *
+ * @param params optional query parameters; pass null for defaults
+ */
+ @Nonnull
+ public ListProjectsResponse list(@Nullable ListProjectsParams params) throws LettrException {
+ return httpClient.get("/projects", params != null ? params.toQueryParams() : null, ListProjectsResponse.class);
+ }
+
+ /** List projects with default pagination. */
+ @Nonnull
+ public ListProjectsResponse list() throws LettrException {
+ return list(null);
+ }
+}
diff --git a/src/main/java/com/lettr/services/projects/model/ListProjectsParams.java b/src/main/java/com/lettr/services/projects/model/ListProjectsParams.java
new file mode 100644
index 0000000..e6f0ed7
--- /dev/null
+++ b/src/main/java/com/lettr/services/projects/model/ListProjectsParams.java
@@ -0,0 +1,50 @@
+package com.lettr.services.projects.model;
+
+import javax.annotation.Nonnull;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Parameters for listing projects. All fields are optional.
+ */
+public class ListProjectsParams {
+
+ private final Integer perPage;
+ private final Integer page;
+
+ private ListProjectsParams(Builder builder) {
+ this.perPage = builder.perPage;
+ this.page = builder.page;
+ }
+
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nonnull
+ public Map toQueryParams() {
+ Map params = new LinkedHashMap<>();
+ if (perPage != null) params.put("per_page", perPage.toString());
+ if (page != null) params.put("page", page.toString());
+ return params;
+ }
+
+ public static class Builder {
+ private Integer perPage;
+ private Integer page;
+
+ private Builder() {}
+
+ /** (optional) Sets the number of results per page. */
+ @Nonnull public Builder perPage(int perPage) { this.perPage = perPage; return this; }
+
+ /** (optional) Sets the page number. */
+ @Nonnull public Builder page(int page) { this.page = page; return this; }
+
+ @Nonnull
+ public ListProjectsParams build() {
+ return new ListProjectsParams(this);
+ }
+ }
+}
diff --git a/src/main/java/com/lettr/services/projects/model/ListProjectsResponse.java b/src/main/java/com/lettr/services/projects/model/ListProjectsResponse.java
new file mode 100644
index 0000000..fa8135d
--- /dev/null
+++ b/src/main/java/com/lettr/services/projects/model/ListProjectsResponse.java
@@ -0,0 +1,37 @@
+package com.lettr.services.projects.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import java.util.List;
+
+/**
+ * Response from listing projects.
+ */
+public class ListProjectsResponse {
+
+ private List projects;
+ private Pagination pagination;
+
+ @Nonnull public List getProjects() { return projects; }
+ @Nonnull public Pagination getPagination() { return pagination; }
+
+ /** Page-based pagination info. */
+ public static class Pagination {
+ 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 "ListProjectsResponse{projects=" + projects + '}';
+ }
+}
diff --git a/src/main/java/com/lettr/services/projects/model/Project.java b/src/main/java/com/lettr/services/projects/model/Project.java
new file mode 100644
index 0000000..a90d811
--- /dev/null
+++ b/src/main/java/com/lettr/services/projects/model/Project.java
@@ -0,0 +1,32 @@
+package com.lettr.services.projects.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Represents a project in the Lettr system.
+ */
+public class Project {
+
+ private int id;
+ private String name;
+ private String emoji;
+
+ @SerializedName("team_id") private int teamId;
+ @SerializedName("created_at") private String createdAt;
+ @SerializedName("updated_at") private String updatedAt;
+
+ public int getId() { return id; }
+ @Nonnull public String getName() { return name; }
+ @Nullable public String getEmoji() { return emoji; }
+ public int getTeamId() { return teamId; }
+ @Nonnull public String getCreatedAt() { return createdAt; }
+ @Nonnull public String getUpdatedAt() { return updatedAt; }
+
+ @Override
+ public String toString() {
+ return "Project{id=" + id + ", name='" + name + "'}";
+ }
+}
diff --git a/src/main/java/com/lettr/services/system/System.java b/src/main/java/com/lettr/services/system/System.java
new file mode 100644
index 0000000..51fcb9f
--- /dev/null
+++ b/src/main/java/com/lettr/services/system/System.java
@@ -0,0 +1,40 @@
+package com.lettr.services.system;
+
+import com.lettr.core.exception.LettrException;
+import com.lettr.services.BaseService;
+import com.lettr.services.system.model.AuthCheckResponse;
+import com.lettr.services.system.model.HealthResponse;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Service for system-level operations (health check, API key validation).
+ */
+public class System extends BaseService {
+
+ public System(@Nonnull String apiKey) {
+ super(apiKey);
+ }
+
+ /**
+ * Check the health status of the API. Does not require authentication.
+ *
+ * @return health status with timestamp
+ * @throws LettrException if the request fails
+ */
+ @Nonnull
+ public HealthResponse health() throws LettrException {
+ return httpClient.get("/health", null, HealthResponse.class);
+ }
+
+ /**
+ * Validate the configured API key and return associated team information.
+ *
+ * @return team ID and timestamp
+ * @throws LettrException if the request fails (e.g. 401 for invalid key)
+ */
+ @Nonnull
+ public AuthCheckResponse authCheck() throws LettrException {
+ return httpClient.get("/auth/check", null, AuthCheckResponse.class);
+ }
+}
diff --git a/src/main/java/com/lettr/services/system/model/AuthCheckResponse.java b/src/main/java/com/lettr/services/system/model/AuthCheckResponse.java
new file mode 100644
index 0000000..b011158
--- /dev/null
+++ b/src/main/java/com/lettr/services/system/model/AuthCheckResponse.java
@@ -0,0 +1,24 @@
+package com.lettr.services.system.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Response from the API key validation endpoint.
+ */
+public class AuthCheckResponse {
+
+ @SerializedName("team_id")
+ private int teamId;
+
+ private String timestamp;
+
+ public int getTeamId() { return teamId; }
+ @Nonnull public String getTimestamp() { return timestamp; }
+
+ @Override
+ public String toString() {
+ return "AuthCheckResponse{teamId=" + teamId + ", timestamp='" + timestamp + "'}";
+ }
+}
diff --git a/src/main/java/com/lettr/services/system/model/HealthResponse.java b/src/main/java/com/lettr/services/system/model/HealthResponse.java
new file mode 100644
index 0000000..9855c88
--- /dev/null
+++ b/src/main/java/com/lettr/services/system/model/HealthResponse.java
@@ -0,0 +1,20 @@
+package com.lettr.services.system.model;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Response from the API health check endpoint.
+ */
+public class HealthResponse {
+
+ private String status;
+ private String timestamp;
+
+ @Nonnull public String getStatus() { return status; }
+ @Nonnull public String getTimestamp() { return timestamp; }
+
+ @Override
+ public String toString() {
+ return "HealthResponse{status='" + status + "', timestamp='" + timestamp + "'}";
+ }
+}
diff --git a/src/main/java/com/lettr/services/templates/Templates.java b/src/main/java/com/lettr/services/templates/Templates.java
index 921edd4..5623c7b 100644
--- a/src/main/java/com/lettr/services/templates/Templates.java
+++ b/src/main/java/com/lettr/services/templates/Templates.java
@@ -2,65 +2,139 @@
import com.lettr.core.exception.LettrException;
import com.lettr.services.BaseService;
-import com.lettr.services.templates.model.CreateTemplateOptions;
-import com.lettr.services.templates.model.CreateTemplateResponse;
-import com.lettr.services.templates.model.ListTemplatesParams;
-import com.lettr.services.templates.model.ListTemplatesResponse;
+import com.lettr.services.templates.model.*;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.LinkedHashMap;
+import java.util.Map;
/**
* Service for managing email templates via the Lettr API.
- *
- * Example:
- * {@code
- * Lettr lettr = new Lettr("your-api-key");
- *
- * // List all templates
- * ListTemplatesResponse templates = lettr.templates().list();
- *
- * // Create a new template
- * CreateTemplateResponse response = lettr.templates().create(
- * CreateTemplateOptions.builder()
- * .name("Welcome Email")
- * .html("Hello {{FIRST_NAME}}!
")
- * .build()
- * );
- * }
*/
public class Templates extends BaseService {
- public Templates(String apiKey) {
+ public Templates(@Nonnull String apiKey) {
super(apiKey);
}
/**
* List templates with optional filtering and pagination.
*
- * @param params optional query parameters
- * @return paginated list of templates
- * @throws LettrException if the request fails
+ * @param params optional query parameters; pass null for defaults
*/
- public ListTemplatesResponse list(ListTemplatesParams params) throws LettrException {
+ @Nonnull
+ public ListTemplatesResponse list(@Nullable ListTemplatesParams params) throws LettrException {
return httpClient.get("/templates", params != null ? params.toQueryParams() : null, ListTemplatesResponse.class);
}
+ /** List templates with default pagination. */
+ @Nonnull
+ public ListTemplatesResponse list() throws LettrException {
+ return list(null);
+ }
+
/**
- * List templates with default pagination.
+ * Get a template by slug.
*
- * @return paginated list of templates
- * @throws LettrException if the request fails
+ * @throws IllegalArgumentException if {@code slug} is null or empty
*/
- public ListTemplatesResponse list() throws LettrException {
- return list(null);
+ @Nonnull
+ public TemplateDetail get(@Nonnull String slug) throws LettrException {
+ return get(slug, null);
}
/**
- * Create a new email template.
+ * Get a template by slug within a specific project.
*
- * @param options template creation options
- * @return response containing the created template info
- * @throws LettrException if the request fails
+ * @param projectId optional project ID; null uses the team's default project
+ * @throws IllegalArgumentException if {@code slug} is null or empty
*/
- public CreateTemplateResponse create(CreateTemplateOptions options) throws LettrException {
+ @Nonnull
+ public TemplateDetail get(@Nonnull String slug, @Nullable Integer projectId) throws LettrException {
+ if (slug == null || slug.isEmpty()) {
+ throw new IllegalArgumentException("slug is required");
+ }
+ Map params = null;
+ if (projectId != null) {
+ params = new LinkedHashMap<>();
+ params.put("project_id", projectId.toString());
+ }
+ return httpClient.get("/templates/" + slug, params, TemplateDetail.class);
+ }
+
+ /** Create a new email template. */
+ @Nonnull
+ public CreateTemplateResponse create(@Nonnull CreateTemplateOptions options) throws LettrException {
return httpClient.post("/templates", options, CreateTemplateResponse.class);
}
+
+ /**
+ * Update an existing template.
+ *
+ * @throws IllegalArgumentException if {@code slug} is null or empty
+ */
+ @Nonnull
+ public UpdateTemplateResponse update(@Nonnull String slug, @Nonnull UpdateTemplateOptions options) throws LettrException {
+ if (slug == null || slug.isEmpty()) {
+ throw new IllegalArgumentException("slug is required");
+ }
+ return httpClient.put("/templates/" + slug, options, UpdateTemplateResponse.class);
+ }
+
+ /**
+ * Delete a template by slug.
+ *
+ * @throws IllegalArgumentException if {@code slug} is null or empty
+ */
+ public void delete(@Nonnull String slug) throws LettrException {
+ delete(slug, null);
+ }
+
+ /**
+ * Delete a template by slug within a specific project.
+ *
+ * @throws IllegalArgumentException if {@code slug} is null or empty
+ */
+ public void delete(@Nonnull String slug, @Nullable Integer projectId) throws LettrException {
+ if (slug == null || slug.isEmpty()) {
+ throw new IllegalArgumentException("slug is required");
+ }
+ Map params = null;
+ if (projectId != null) {
+ params = new LinkedHashMap<>();
+ params.put("project_id", projectId.toString());
+ }
+ httpClient.delete("/templates/" + slug, params);
+ }
+
+ /**
+ * Get merge tags for a template.
+ *
+ * @throws IllegalArgumentException if {@code slug} is null or empty
+ */
+ @Nonnull
+ public GetMergeTagsResponse getMergeTags(@Nonnull String slug) throws LettrException {
+ return getMergeTags(slug, null);
+ }
+
+ /**
+ * Get merge tags for a template with optional parameters.
+ *
+ * @throws IllegalArgumentException if {@code slug} is null or empty
+ */
+ @Nonnull
+ public GetMergeTagsResponse getMergeTags(@Nonnull String slug, @Nullable GetMergeTagsParams params) throws LettrException {
+ if (slug == null || slug.isEmpty()) {
+ throw new IllegalArgumentException("slug is required");
+ }
+ return httpClient.get("/templates/" + slug + "/merge-tags",
+ params != null ? params.toQueryParams() : null, GetMergeTagsResponse.class);
+ }
+
+ /** Get the rendered HTML for a template. */
+ @Nonnull
+ public GetTemplateHtmlResponse getHtml(@Nonnull GetTemplateHtmlParams params) throws LettrException {
+ return httpClient.get("/templates/html", params.toQueryParams(), GetTemplateHtmlResponse.class);
+ }
}
diff --git a/src/main/java/com/lettr/services/templates/model/CreateTemplateOptions.java b/src/main/java/com/lettr/services/templates/model/CreateTemplateOptions.java
index 0e4598a..f3e30e3 100644
--- a/src/main/java/com/lettr/services/templates/model/CreateTemplateOptions.java
+++ b/src/main/java/com/lettr/services/templates/model/CreateTemplateOptions.java
@@ -2,18 +2,13 @@
import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
/**
* Options for creating a new email template.
*
* Either {@code html} or {@code json} content must be provided, but not both.
- *
- * Example usage:
- * {@code
- * CreateTemplateOptions options = CreateTemplateOptions.builder()
- * .name("Welcome Email")
- * .html("Hello {{FIRST_NAME}}!
")
- * .build();
- * }
*/
public class CreateTemplateOptions {
@@ -21,11 +16,8 @@ public class CreateTemplateOptions {
private final String html;
private final String json;
- @SerializedName("project_id")
- private final Integer projectId;
-
- @SerializedName("folder_id")
- private final Integer folderId;
+ @SerializedName("project_id") private final Integer projectId;
+ @SerializedName("folder_id") private final Integer folderId;
private CreateTemplateOptions(Builder builder) {
this.name = builder.name;
@@ -35,15 +27,16 @@ private CreateTemplateOptions(Builder builder) {
this.folderId = builder.folderId;
}
+ @Nonnull
public static Builder builder() {
return new Builder();
}
- public String getName() { return name; }
- public String getHtml() { return html; }
- public String getJson() { return json; }
- public Integer getProjectId() { return projectId; }
- public Integer getFolderId() { return folderId; }
+ @Nonnull public String getName() { return name; }
+ @Nullable public String getHtml() { return html; }
+ @Nullable public String getJson() { return json; }
+ @Nullable public Integer getProjectId() { return projectId; }
+ @Nullable public Integer getFolderId() { return folderId; }
public static class Builder {
private String name;
@@ -54,49 +47,32 @@ public static class Builder {
private Builder() {}
- /**
- * Sets the template name (required).
- */
- public Builder name(String name) {
- this.name = name;
- return this;
- }
+ /** (required) Sets the template name. Max length: 255. */
+ @Nonnull public Builder name(@Nonnull String name) { this.name = name; return this; }
/**
- * Sets the HTML content for the template.
+ * (required if json not provided) Sets the HTML content for custom HTML templates.
* Mutually exclusive with {@link #json(String)}.
*/
- public Builder html(String html) {
- this.html = html;
- return this;
- }
+ @Nonnull public Builder html(@Nullable String html) { this.html = html; return this; }
/**
- * Sets the Topol editor JSON content for the template.
+ * (required if html not provided) Sets the Topol JSON content for visual editor templates.
* Mutually exclusive with {@link #html(String)}.
*/
- public Builder json(String json) {
- this.json = json;
- return this;
- }
+ @Nonnull public Builder json(@Nullable String json) { this.json = json; return this; }
- /**
- * Sets the project ID to create the template in.
- * If not specified, uses the team's default project.
- */
- public Builder projectId(int projectId) {
- this.projectId = projectId;
- return this;
- }
+ /** (optional) Sets the project ID. Defaults to the team's default project. */
+ @Nonnull public Builder projectId(int projectId) { this.projectId = projectId; return this; }
+
+ /** (optional) Sets the folder ID. Defaults to the first folder in the project. */
+ @Nonnull public Builder folderId(int folderId) { this.folderId = folderId; return this; }
/**
- * Sets the folder ID within the project.
+ * @throws IllegalArgumentException if {@code name} is missing, neither {@code html} nor
+ * {@code json} is provided, or both are provided
*/
- public Builder folderId(int folderId) {
- this.folderId = folderId;
- return this;
- }
-
+ @Nonnull
public CreateTemplateOptions build() {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("'name' is required");
diff --git a/src/main/java/com/lettr/services/templates/model/CreateTemplateResponse.java b/src/main/java/com/lettr/services/templates/model/CreateTemplateResponse.java
index 5e1f15c..3138380 100644
--- a/src/main/java/com/lettr/services/templates/model/CreateTemplateResponse.java
+++ b/src/main/java/com/lettr/services/templates/model/CreateTemplateResponse.java
@@ -1,6 +1,8 @@
package com.lettr.services.templates.model;
import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
import java.util.List;
/**
@@ -12,52 +14,23 @@ public class CreateTemplateResponse {
private String name;
private String slug;
- @SerializedName("project_id")
- private int projectId;
-
- @SerializedName("folder_id")
- private Integer folderId;
-
- @SerializedName("active_version")
- private int activeVersion;
-
- @SerializedName("merge_tags")
- private List mergeTags;
-
- @SerializedName("created_at")
- private String createdAt;
+ @SerializedName("project_id") private int projectId;
+ @SerializedName("folder_id") private Integer folderId;
+ @SerializedName("active_version") private int activeVersion;
+ @SerializedName("merge_tags") private List mergeTags;
+ @SerializedName("created_at") private String createdAt;
public int getId() { return id; }
- public String getName() { return name; }
- public String getSlug() { return slug; }
+ @Nonnull public String getName() { return name; }
+ @Nonnull public String getSlug() { return slug; }
public int getProjectId() { return projectId; }
- public Integer getFolderId() { return folderId; }
+ @Nonnull public Integer getFolderId() { return folderId; }
public int getActiveVersion() { return activeVersion; }
- public List getMergeTags() { return mergeTags; }
- public String getCreatedAt() { return createdAt; }
-
- /**
- * A merge tag extracted from the template content.
- */
- public static class MergeTag {
- private String key;
- private boolean required;
-
- public String getKey() { return key; }
- public boolean isRequired() { return required; }
-
- @Override
- public String toString() {
- return "MergeTag{key='" + key + "', required=" + required + '}';
- }
- }
+ @Nonnull public List getMergeTags() { return mergeTags; }
+ @Nonnull public String getCreatedAt() { return createdAt; }
@Override
public String toString() {
- return "CreateTemplateResponse{" +
- "id=" + id +
- ", name='" + name + '\'' +
- ", slug='" + slug + '\'' +
- '}';
+ return "CreateTemplateResponse{id=" + id + ", slug='" + slug + "'}";
}
}
diff --git a/src/main/java/com/lettr/services/templates/model/GetMergeTagsParams.java b/src/main/java/com/lettr/services/templates/model/GetMergeTagsParams.java
new file mode 100644
index 0000000..2e642ff
--- /dev/null
+++ b/src/main/java/com/lettr/services/templates/model/GetMergeTagsParams.java
@@ -0,0 +1,50 @@
+package com.lettr.services.templates.model;
+
+import javax.annotation.Nonnull;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Parameters for getting merge tags of a template. All fields are optional.
+ */
+public class GetMergeTagsParams {
+
+ private final Integer projectId;
+ private final Integer version;
+
+ private GetMergeTagsParams(Builder builder) {
+ this.projectId = builder.projectId;
+ this.version = builder.version;
+ }
+
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nonnull
+ public Map toQueryParams() {
+ Map params = new LinkedHashMap<>();
+ if (projectId != null) params.put("project_id", projectId.toString());
+ if (version != null) params.put("version", version.toString());
+ return params;
+ }
+
+ public static class Builder {
+ private Integer projectId;
+ private Integer version;
+
+ private Builder() {}
+
+ /** (optional) Sets the project ID. Defaults to the team's default project. */
+ @Nonnull public Builder projectId(int projectId) { this.projectId = projectId; return this; }
+
+ /** (optional) Sets the template version. Defaults to the active version. */
+ @Nonnull public Builder version(int version) { this.version = version; return this; }
+
+ @Nonnull
+ public GetMergeTagsParams build() {
+ return new GetMergeTagsParams(this);
+ }
+ }
+}
diff --git a/src/main/java/com/lettr/services/templates/model/GetMergeTagsResponse.java b/src/main/java/com/lettr/services/templates/model/GetMergeTagsResponse.java
new file mode 100644
index 0000000..ec174bf
--- /dev/null
+++ b/src/main/java/com/lettr/services/templates/model/GetMergeTagsResponse.java
@@ -0,0 +1,30 @@
+package com.lettr.services.templates.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import java.util.List;
+
+/**
+ * Response containing merge tags for a template version.
+ */
+public class GetMergeTagsResponse {
+
+ @SerializedName("project_id") private int projectId;
+ @SerializedName("template_slug") private String templateSlug;
+
+ private int version;
+
+ @SerializedName("merge_tags")
+ private List mergeTags;
+
+ public int getProjectId() { return projectId; }
+ @Nonnull public String getTemplateSlug() { return templateSlug; }
+ public int getVersion() { return version; }
+ @Nonnull public List getMergeTags() { return mergeTags; }
+
+ @Override
+ public String toString() {
+ return "GetMergeTagsResponse{templateSlug='" + templateSlug + "', version=" + version + '}';
+ }
+}
diff --git a/src/main/java/com/lettr/services/templates/model/GetTemplateHtmlParams.java b/src/main/java/com/lettr/services/templates/model/GetTemplateHtmlParams.java
new file mode 100644
index 0000000..39bde25
--- /dev/null
+++ b/src/main/java/com/lettr/services/templates/model/GetTemplateHtmlParams.java
@@ -0,0 +1,56 @@
+package com.lettr.services.templates.model;
+
+import javax.annotation.Nonnull;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Parameters for getting template HTML content. Both {@code projectId} and {@code slug} are required.
+ */
+public class GetTemplateHtmlParams {
+
+ private final int projectId;
+ private final String slug;
+
+ private GetTemplateHtmlParams(Builder builder) {
+ this.projectId = builder.projectId;
+ this.slug = builder.slug;
+ }
+
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nonnull
+ public Map toQueryParams() {
+ Map params = new LinkedHashMap<>();
+ params.put("project_id", Integer.toString(projectId));
+ params.put("slug", slug);
+ return params;
+ }
+
+ public static class Builder {
+ private int projectId;
+ private String slug;
+
+ private Builder() {}
+
+ /** (required) Sets the project ID containing the template. */
+ @Nonnull public Builder projectId(int projectId) { this.projectId = projectId; return this; }
+
+ /** (required) Sets the template slug. */
+ @Nonnull public Builder slug(@Nonnull String slug) { this.slug = slug; return this; }
+
+ /**
+ * @throws IllegalArgumentException if {@code slug} is missing
+ */
+ @Nonnull
+ public GetTemplateHtmlParams build() {
+ if (slug == null || slug.isEmpty()) {
+ throw new IllegalArgumentException("'slug' is required");
+ }
+ return new GetTemplateHtmlParams(this);
+ }
+ }
+}
diff --git a/src/main/java/com/lettr/services/templates/model/GetTemplateHtmlResponse.java b/src/main/java/com/lettr/services/templates/model/GetTemplateHtmlResponse.java
new file mode 100644
index 0000000..2053075
--- /dev/null
+++ b/src/main/java/com/lettr/services/templates/model/GetTemplateHtmlResponse.java
@@ -0,0 +1,41 @@
+package com.lettr.services.templates.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+
+/**
+ * Response containing template HTML content and merge tags.
+ */
+public class GetTemplateHtmlResponse {
+
+ private String html;
+
+ @SerializedName("merge_tags")
+ private List mergeTags;
+
+ private String subject;
+
+ @Nonnull public String getHtml() { return html; }
+ @Nonnull public List getMergeTags() { return mergeTags; }
+ /** Template subject line, if set. */
+ @Nullable public String getSubject() { return subject; }
+
+ /** A merge tag in the HTML template response. */
+ public static class HtmlMergeTag {
+ private String key;
+ private String name;
+ private boolean required;
+
+ @Nonnull public String getKey() { return key; }
+ @Nonnull public String getName() { return name; }
+ public boolean isRequired() { return required; }
+ }
+
+ @Override
+ public String toString() {
+ return "GetTemplateHtmlResponse{subject='" + subject + "'}";
+ }
+}
diff --git a/src/main/java/com/lettr/services/templates/model/ListTemplatesParams.java b/src/main/java/com/lettr/services/templates/model/ListTemplatesParams.java
index 4c119bb..bbb52f8 100644
--- a/src/main/java/com/lettr/services/templates/model/ListTemplatesParams.java
+++ b/src/main/java/com/lettr/services/templates/model/ListTemplatesParams.java
@@ -1,10 +1,12 @@
package com.lettr.services.templates.model;
+import javax.annotation.Nonnull;
import java.util.HashMap;
import java.util.Map;
/**
* Parameters for listing templates with optional filtering and pagination.
+ * All fields are optional.
*/
public class ListTemplatesParams {
@@ -18,13 +20,12 @@ private ListTemplatesParams(Builder builder) {
this.page = builder.page;
}
+ @Nonnull
public static Builder builder() {
return new Builder();
}
- /**
- * Converts this params object to a map of query parameters.
- */
+ @Nonnull
public Map toQueryParams() {
Map params = new HashMap<>();
if (projectId != null) params.put("project_id", projectId.toString());
@@ -40,30 +41,16 @@ public static class Builder {
private Builder() {}
- /**
- * Sets the project ID to list templates from.
- */
- public Builder projectId(int projectId) {
- this.projectId = projectId;
- return this;
- }
+ /** (optional) Filters templates by project ID. */
+ @Nonnull public Builder projectId(int projectId) { this.projectId = projectId; return this; }
- /**
- * Sets the number of results per page (1-100).
- */
- public Builder perPage(int perPage) {
- this.perPage = perPage;
- return this;
- }
+ /** (optional) Sets the number of results per page (1–100). */
+ @Nonnull public Builder perPage(int perPage) { this.perPage = perPage; return this; }
- /**
- * Sets the page number.
- */
- public Builder page(int page) {
- this.page = page;
- return this;
- }
+ /** (optional) Sets the page number. */
+ @Nonnull public Builder page(int page) { this.page = page; return this; }
+ @Nonnull
public ListTemplatesParams build() {
return new ListTemplatesParams(this);
}
diff --git a/src/main/java/com/lettr/services/templates/model/ListTemplatesResponse.java b/src/main/java/com/lettr/services/templates/model/ListTemplatesResponse.java
index e353b04..23da49f 100644
--- a/src/main/java/com/lettr/services/templates/model/ListTemplatesResponse.java
+++ b/src/main/java/com/lettr/services/templates/model/ListTemplatesResponse.java
@@ -1,6 +1,8 @@
package com.lettr.services.templates.model;
import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
import java.util.List;
/**
@@ -11,28 +13,16 @@ public class ListTemplatesResponse {
private List templates;
private Pagination pagination;
- public List getTemplates() {
- return templates;
- }
-
- public Pagination getPagination() {
- return pagination;
- }
+ @Nonnull public List getTemplates() { return templates; }
+ @Nonnull public Pagination getPagination() { return pagination; }
- /**
- * Page-based pagination info.
- */
+ /** Page-based pagination info. */
public static class Pagination {
private int total;
- @SerializedName("per_page")
- private int perPage;
-
- @SerializedName("current_page")
- private int currentPage;
-
- @SerializedName("last_page")
- private int lastPage;
+ @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; }
@@ -42,9 +32,6 @@ public static class Pagination {
@Override
public String toString() {
- return "ListTemplatesResponse{" +
- "templates=" + templates +
- ", pagination=" + pagination +
- '}';
+ return "ListTemplatesResponse{templates=" + templates + '}';
}
}
diff --git a/src/main/java/com/lettr/services/templates/model/MergeTag.java b/src/main/java/com/lettr/services/templates/model/MergeTag.java
new file mode 100644
index 0000000..18d3a12
--- /dev/null
+++ b/src/main/java/com/lettr/services/templates/model/MergeTag.java
@@ -0,0 +1,28 @@
+package com.lettr.services.templates.model;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+
+/**
+ * A merge tag extracted from template content.
+ */
+public class MergeTag {
+
+ private String key;
+ private boolean required;
+ private String type;
+ private List children;
+
+ @Nonnull public String getKey() { return key; }
+ public boolean isRequired() { return required; }
+ /** Data type ({@code text}, {@code number}, {@code image}, {@code button}). Only present for loop children. */
+ @Nullable public String getType() { return type; }
+ /** Child fields available within a loop iteration. Null for non-loop merge tags. */
+ @Nullable public List getChildren() { return children; }
+
+ @Override
+ public String toString() {
+ return "MergeTag{key='" + key + "', required=" + required + '}';
+ }
+}
diff --git a/src/main/java/com/lettr/services/templates/model/MergeTagChild.java b/src/main/java/com/lettr/services/templates/model/MergeTagChild.java
new file mode 100644
index 0000000..0b5fa49
--- /dev/null
+++ b/src/main/java/com/lettr/services/templates/model/MergeTagChild.java
@@ -0,0 +1,22 @@
+package com.lettr.services.templates.model;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * A child merge tag within a loop block.
+ */
+public class MergeTagChild {
+
+ private String key;
+ private String type;
+
+ @Nonnull public String getKey() { return key; }
+ /** Data type: {@code text}, {@code number}, {@code image}, or {@code button}. */
+ @Nullable public String getType() { return type; }
+
+ @Override
+ public String toString() {
+ return "MergeTagChild{key='" + key + "', type='" + type + "'}";
+ }
+}
diff --git a/src/main/java/com/lettr/services/templates/model/Template.java b/src/main/java/com/lettr/services/templates/model/Template.java
index c81a146..6353d1c 100644
--- a/src/main/java/com/lettr/services/templates/model/Template.java
+++ b/src/main/java/com/lettr/services/templates/model/Template.java
@@ -2,8 +2,10 @@
import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nonnull;
+
/**
- * Represents an email template.
+ * Represents an email template (list view).
*/
public class Template {
@@ -11,32 +13,21 @@ public class Template {
private String name;
private String slug;
- @SerializedName("project_id")
- private int projectId;
-
- @SerializedName("folder_id")
- private Integer folderId;
-
- @SerializedName("created_at")
- private String createdAt;
-
- @SerializedName("updated_at")
- private String updatedAt;
+ @SerializedName("project_id") private int projectId;
+ @SerializedName("folder_id") private Integer folderId;
+ @SerializedName("created_at") private String createdAt;
+ @SerializedName("updated_at") private String updatedAt;
public int getId() { return id; }
- public String getName() { return name; }
- public String getSlug() { return slug; }
+ @Nonnull public String getName() { return name; }
+ @Nonnull public String getSlug() { return slug; }
public int getProjectId() { return projectId; }
- public Integer getFolderId() { return folderId; }
- public String getCreatedAt() { return createdAt; }
- public String getUpdatedAt() { return updatedAt; }
+ @Nonnull public Integer getFolderId() { return folderId; }
+ @Nonnull public String getCreatedAt() { return createdAt; }
+ @Nonnull public String getUpdatedAt() { return updatedAt; }
@Override
public String toString() {
- return "Template{" +
- "id=" + id +
- ", name='" + name + '\'' +
- ", slug='" + slug + '\'' +
- '}';
+ return "Template{id=" + id + ", name='" + name + "', slug='" + slug + "'}";
}
}
diff --git a/src/main/java/com/lettr/services/templates/model/TemplateDetail.java b/src/main/java/com/lettr/services/templates/model/TemplateDetail.java
new file mode 100644
index 0000000..cc75e9c
--- /dev/null
+++ b/src/main/java/com/lettr/services/templates/model/TemplateDetail.java
@@ -0,0 +1,51 @@
+package com.lettr.services.templates.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Detailed view of an email template including content.
+ */
+public class TemplateDetail {
+
+ private int id;
+ private String name;
+ private String slug;
+
+ @SerializedName("project_id") private int projectId;
+ @SerializedName("folder_id") private Integer folderId;
+
+ @SerializedName("active_version")
+ private Integer activeVersion;
+
+ @SerializedName("versions_count")
+ private int versionsCount;
+
+ private String html;
+ private String json;
+
+ @SerializedName("created_at") private String createdAt;
+ @SerializedName("updated_at") private String updatedAt;
+
+ public int getId() { return id; }
+ @Nonnull public String getName() { return name; }
+ @Nonnull public String getSlug() { return slug; }
+ public int getProjectId() { return projectId; }
+ @Nonnull public Integer getFolderId() { return folderId; }
+ /** Active version number, or null if no version is active. */
+ @Nullable public Integer getActiveVersion() { return activeVersion; }
+ public int getVersionsCount() { return versionsCount; }
+ /** HTML content of the active version, or null if no active version exists. */
+ @Nullable public String getHtml() { return html; }
+ /** Topol JSON of the active version, or null for custom HTML templates / no active version. */
+ @Nullable public String getJson() { return json; }
+ @Nonnull public String getCreatedAt() { return createdAt; }
+ @Nonnull public String getUpdatedAt() { return updatedAt; }
+
+ @Override
+ public String toString() {
+ return "TemplateDetail{id=" + id + ", slug='" + slug + "', activeVersion=" + activeVersion + '}';
+ }
+}
diff --git a/src/main/java/com/lettr/services/templates/model/UpdateTemplateOptions.java b/src/main/java/com/lettr/services/templates/model/UpdateTemplateOptions.java
new file mode 100644
index 0000000..1516974
--- /dev/null
+++ b/src/main/java/com/lettr/services/templates/model/UpdateTemplateOptions.java
@@ -0,0 +1,67 @@
+package com.lettr.services.templates.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Options for updating an existing template. All fields are optional;
+ * {@code html} and {@code json} are mutually exclusive.
+ */
+public class UpdateTemplateOptions {
+
+ @SerializedName("project_id") private final Integer projectId;
+ private final String name;
+ private final String html;
+ private final String json;
+
+ private UpdateTemplateOptions(Builder builder) {
+ this.projectId = builder.projectId;
+ this.name = builder.name;
+ this.html = builder.html;
+ this.json = builder.json;
+ }
+
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nullable public Integer getProjectId() { return projectId; }
+ @Nullable public String getName() { return name; }
+ @Nullable public String getHtml() { return html; }
+ @Nullable public String getJson() { return json; }
+
+ public static class Builder {
+ private Integer projectId;
+ private String name;
+ private String html;
+ private String json;
+
+ private Builder() {}
+
+ /** (optional) Sets the project ID. Defaults to the team's default project. */
+ @Nonnull public Builder projectId(int projectId) { this.projectId = projectId; return this; }
+
+ /** (optional) Sets the new template name. Max length: 255. */
+ @Nonnull public Builder name(@Nullable String name) { this.name = name; return this; }
+
+ /** (optional) Sets the new HTML content. Creates a new active version. Mutually exclusive with {@link #json(String)}. */
+ @Nonnull public Builder html(@Nullable String html) { this.html = html; return this; }
+
+ /** (optional) Sets the new Topol JSON content. Creates a new active version. Mutually exclusive with {@link #html(String)}. */
+ @Nonnull public Builder json(@Nullable String json) { this.json = json; return this; }
+
+ /**
+ * @throws IllegalArgumentException if both {@code html} and {@code json} are provided
+ */
+ @Nonnull
+ public UpdateTemplateOptions build() {
+ if (html != null && json != null) {
+ throw new IllegalArgumentException("'html' and 'json' are mutually exclusive");
+ }
+ return new UpdateTemplateOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/com/lettr/services/templates/model/UpdateTemplateResponse.java b/src/main/java/com/lettr/services/templates/model/UpdateTemplateResponse.java
new file mode 100644
index 0000000..058963b
--- /dev/null
+++ b/src/main/java/com/lettr/services/templates/model/UpdateTemplateResponse.java
@@ -0,0 +1,38 @@
+package com.lettr.services.templates.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import java.util.List;
+
+/**
+ * Response returned after updating a template.
+ */
+public class UpdateTemplateResponse {
+
+ private int id;
+ private String name;
+ private String slug;
+
+ @SerializedName("project_id") private int projectId;
+ @SerializedName("folder_id") private Integer folderId;
+ @SerializedName("active_version") private int activeVersion;
+ @SerializedName("merge_tags") private List mergeTags;
+ @SerializedName("created_at") private String createdAt;
+ @SerializedName("updated_at") private String updatedAt;
+
+ public int getId() { return id; }
+ @Nonnull public String getName() { return name; }
+ @Nonnull public String getSlug() { return slug; }
+ public int getProjectId() { return projectId; }
+ @Nonnull public Integer getFolderId() { return folderId; }
+ public int getActiveVersion() { return activeVersion; }
+ @Nonnull public List getMergeTags() { return mergeTags; }
+ @Nonnull public String getCreatedAt() { return createdAt; }
+ @Nonnull public String getUpdatedAt() { return updatedAt; }
+
+ @Override
+ public String toString() {
+ return "UpdateTemplateResponse{id=" + id + ", slug='" + slug + "'}";
+ }
+}
diff --git a/src/main/java/com/lettr/services/webhooks/Webhooks.java b/src/main/java/com/lettr/services/webhooks/Webhooks.java
index 6ecb4c8..84ef8e2 100644
--- a/src/main/java/com/lettr/services/webhooks/Webhooks.java
+++ b/src/main/java/com/lettr/services/webhooks/Webhooks.java
@@ -2,35 +2,24 @@
import com.lettr.core.exception.LettrException;
import com.lettr.services.BaseService;
+import com.lettr.services.webhooks.model.CreateWebhookOptions;
import com.lettr.services.webhooks.model.ListWebhooksResponse;
+import com.lettr.services.webhooks.model.UpdateWebhookOptions;
import com.lettr.services.webhooks.model.Webhook;
+import javax.annotation.Nonnull;
+
/**
* Service for managing webhooks via the Lettr API.
- *
- * Example:
- * {@code
- * Lettr lettr = new Lettr("your-api-key");
- *
- * // List all webhooks
- * ListWebhooksResponse webhooks = lettr.webhooks().list();
- *
- * // Get a specific webhook
- * Webhook webhook = lettr.webhooks().get("webhook-abc123");
- * }
*/
public class Webhooks extends BaseService {
- public Webhooks(String apiKey) {
+ public Webhooks(@Nonnull String apiKey) {
super(apiKey);
}
- /**
- * List all configured webhooks.
- *
- * @return response containing list of webhooks
- * @throws LettrException if the request fails
- */
+ /** List all configured webhooks. */
+ @Nonnull
public ListWebhooksResponse list() throws LettrException {
return httpClient.get("/webhooks", null, ListWebhooksResponse.class);
}
@@ -38,14 +27,44 @@ public ListWebhooksResponse list() throws LettrException {
/**
* Get details of a specific webhook.
*
- * @param webhookId the webhook ID
- * @return webhook details
- * @throws LettrException if the request fails
+ * @throws IllegalArgumentException if {@code webhookId} is null or empty
*/
- public Webhook get(String webhookId) throws LettrException {
+ @Nonnull
+ public Webhook get(@Nonnull String webhookId) throws LettrException {
if (webhookId == null || webhookId.isEmpty()) {
throw new IllegalArgumentException("webhookId is required");
}
return httpClient.get("/webhooks/" + webhookId, null, Webhook.class);
}
+
+ /** Create a new webhook. */
+ @Nonnull
+ public Webhook create(@Nonnull CreateWebhookOptions options) throws LettrException {
+ return httpClient.post("/webhooks", options, Webhook.class);
+ }
+
+ /**
+ * Update an existing webhook.
+ *
+ * @throws IllegalArgumentException if {@code webhookId} is null or empty
+ */
+ @Nonnull
+ public Webhook update(@Nonnull String webhookId, @Nonnull UpdateWebhookOptions options) throws LettrException {
+ if (webhookId == null || webhookId.isEmpty()) {
+ throw new IllegalArgumentException("webhookId is required");
+ }
+ return httpClient.put("/webhooks/" + webhookId, options, Webhook.class);
+ }
+
+ /**
+ * Delete a webhook.
+ *
+ * @throws IllegalArgumentException if {@code webhookId} is null or empty
+ */
+ public void delete(@Nonnull String webhookId) throws LettrException {
+ if (webhookId == null || webhookId.isEmpty()) {
+ throw new IllegalArgumentException("webhookId is required");
+ }
+ httpClient.delete("/webhooks/" + webhookId);
+ }
}
diff --git a/src/main/java/com/lettr/services/webhooks/model/CreateWebhookOptions.java b/src/main/java/com/lettr/services/webhooks/model/CreateWebhookOptions.java
new file mode 100644
index 0000000..c3df278
--- /dev/null
+++ b/src/main/java/com/lettr/services/webhooks/model/CreateWebhookOptions.java
@@ -0,0 +1,112 @@
+package com.lettr.services.webhooks.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+
+/**
+ * Options for creating a new webhook.
+ */
+public class CreateWebhookOptions {
+
+ private final String name;
+ private final String url;
+
+ @SerializedName("auth_type") private final String authType;
+ @SerializedName("auth_username") private final String authUsername;
+ @SerializedName("auth_password") private final String authPassword;
+ @SerializedName("oauth_client_id") private final String oauthClientId;
+ @SerializedName("oauth_client_secret") private final String oauthClientSecret;
+ @SerializedName("oauth_token_url") private final String oauthTokenUrl;
+ @SerializedName("events_mode") private final String eventsMode;
+
+ private final List events;
+
+ private CreateWebhookOptions(Builder builder) {
+ this.name = builder.name;
+ this.url = builder.url;
+ this.authType = builder.authType;
+ this.authUsername = builder.authUsername;
+ this.authPassword = builder.authPassword;
+ this.oauthClientId = builder.oauthClientId;
+ this.oauthClientSecret = builder.oauthClientSecret;
+ this.oauthTokenUrl = builder.oauthTokenUrl;
+ this.eventsMode = builder.eventsMode;
+ this.events = builder.events;
+ }
+
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nonnull public String getName() { return name; }
+ @Nonnull public String getUrl() { return url; }
+ @Nonnull public String getAuthType() { return authType; }
+ @Nullable public String getAuthUsername() { return authUsername; }
+ @Nullable public String getAuthPassword() { return authPassword; }
+ @Nullable public String getOauthClientId() { return oauthClientId; }
+ @Nullable public String getOauthClientSecret() { return oauthClientSecret; }
+ @Nullable public String getOauthTokenUrl() { return oauthTokenUrl; }
+ @Nonnull public String getEventsMode() { return eventsMode; }
+ @Nullable public List getEvents() { return events; }
+
+ public static class Builder {
+ private String name;
+ private String url;
+ private String authType;
+ private String authUsername;
+ private String authPassword;
+ private String oauthClientId;
+ private String oauthClientSecret;
+ private String oauthTokenUrl;
+ private String eventsMode;
+ private List events;
+
+ private Builder() {}
+
+ /** (required) Sets the webhook name. Max length: 255. */
+ @Nonnull public Builder name(@Nonnull String name) { this.name = name; return this; }
+
+ /** (required) Sets the URL where webhook events will be sent. Max length: 2048. */
+ @Nonnull public Builder url(@Nonnull String url) { this.url = url; return this; }
+
+ /** (required) Sets the authentication type: {@code none}, {@code basic}, or {@code oauth2}. */
+ @Nonnull public Builder authType(@Nonnull String authType) { this.authType = authType; return this; }
+
+ /** (optional, required when authType is "basic") Sets the basic-auth username. */
+ @Nonnull public Builder authUsername(@Nullable String authUsername) { this.authUsername = authUsername; return this; }
+
+ /** (optional, required when authType is "basic") Sets the basic-auth password. */
+ @Nonnull public Builder authPassword(@Nullable String authPassword) { this.authPassword = authPassword; return this; }
+
+ /** (optional, required when authType is "oauth2") Sets the OAuth2 client ID. */
+ @Nonnull public Builder oauthClientId(@Nullable String oauthClientId) { this.oauthClientId = oauthClientId; return this; }
+
+ /** (optional, required when authType is "oauth2") Sets the OAuth2 client secret. */
+ @Nonnull public Builder oauthClientSecret(@Nullable String oauthClientSecret) { this.oauthClientSecret = oauthClientSecret; return this; }
+
+ /** (optional, required when authType is "oauth2") Sets the OAuth2 token URL. */
+ @Nonnull public Builder oauthTokenUrl(@Nullable String oauthTokenUrl) { this.oauthTokenUrl = oauthTokenUrl; return this; }
+
+ /** (required) Sets the events mode: {@code all} or {@code selected}. */
+ @Nonnull public Builder eventsMode(@Nonnull String eventsMode) { this.eventsMode = eventsMode; return this; }
+
+ /** (optional, required when eventsMode is "selected") Sets the event types to receive. */
+ @Nonnull public Builder events(@Nullable List events) { this.events = events; return this; }
+
+ /**
+ * @throws IllegalArgumentException if {@code name}, {@code url}, {@code authType}, or {@code eventsMode} is missing
+ */
+ @Nonnull
+ public CreateWebhookOptions build() {
+ if (name == null || name.isEmpty()) throw new IllegalArgumentException("'name' is required");
+ if (url == null || url.isEmpty()) throw new IllegalArgumentException("'url' is required");
+ if (authType == null || authType.isEmpty()) throw new IllegalArgumentException("'authType' is required");
+ if (eventsMode == null || eventsMode.isEmpty()) throw new IllegalArgumentException("'eventsMode' is required");
+ return new CreateWebhookOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/com/lettr/services/webhooks/model/ListWebhooksResponse.java b/src/main/java/com/lettr/services/webhooks/model/ListWebhooksResponse.java
index bdbbfd8..6afcfb0 100644
--- a/src/main/java/com/lettr/services/webhooks/model/ListWebhooksResponse.java
+++ b/src/main/java/com/lettr/services/webhooks/model/ListWebhooksResponse.java
@@ -1,5 +1,6 @@
package com.lettr.services.webhooks.model;
+import javax.annotation.Nonnull;
import java.util.List;
/**
@@ -9,17 +10,11 @@ public class ListWebhooksResponse {
private List webhooks;
- /**
- * Returns the list of configured webhooks.
- */
- public List getWebhooks() {
- return webhooks;
- }
+ /** Returns the list of configured webhooks. */
+ @Nonnull public List getWebhooks() { return webhooks; }
@Override
public String toString() {
- return "ListWebhooksResponse{" +
- "webhooks=" + webhooks +
- '}';
+ return "ListWebhooksResponse{webhooks=" + webhooks + '}';
}
}
diff --git a/src/main/java/com/lettr/services/webhooks/model/UpdateWebhookOptions.java b/src/main/java/com/lettr/services/webhooks/model/UpdateWebhookOptions.java
new file mode 100644
index 0000000..88e1ac5
--- /dev/null
+++ b/src/main/java/com/lettr/services/webhooks/model/UpdateWebhookOptions.java
@@ -0,0 +1,106 @@
+package com.lettr.services.webhooks.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.List;
+
+/**
+ * Options for updating an existing webhook. All fields are optional;
+ * only provided fields will be updated.
+ */
+public class UpdateWebhookOptions {
+
+ private final String name;
+ private final String target;
+
+ @SerializedName("auth_type") private final String authType;
+ @SerializedName("auth_username") private final String authUsername;
+ @SerializedName("auth_password") private final String authPassword;
+ @SerializedName("oauth_token_url") private final String oauthTokenUrl;
+ @SerializedName("oauth_client_id") private final String oauthClientId;
+ @SerializedName("oauth_client_secret") private final String oauthClientSecret;
+
+ private final List events;
+ private final Boolean active;
+
+ private UpdateWebhookOptions(Builder builder) {
+ this.name = builder.name;
+ this.target = builder.target;
+ this.authType = builder.authType;
+ this.authUsername = builder.authUsername;
+ this.authPassword = builder.authPassword;
+ this.oauthTokenUrl = builder.oauthTokenUrl;
+ this.oauthClientId = builder.oauthClientId;
+ this.oauthClientSecret = builder.oauthClientSecret;
+ this.events = builder.events;
+ this.active = builder.active;
+ }
+
+ @Nonnull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nullable public String getName() { return name; }
+ @Nullable public String getTarget() { return target; }
+ @Nullable public String getAuthType() { return authType; }
+ @Nullable public String getAuthUsername() { return authUsername; }
+ @Nullable public String getAuthPassword() { return authPassword; }
+ @Nullable public String getOauthTokenUrl() { return oauthTokenUrl; }
+ @Nullable public String getOauthClientId() { return oauthClientId; }
+ @Nullable public String getOauthClientSecret() { return oauthClientSecret; }
+ @Nullable public List getEvents() { return events; }
+ @Nullable public Boolean getActive() { return active; }
+
+ public static class Builder {
+ private String name;
+ private String target;
+ private String authType;
+ private String authUsername;
+ private String authPassword;
+ private String oauthTokenUrl;
+ private String oauthClientId;
+ private String oauthClientSecret;
+ private List events;
+ private Boolean active;
+
+ private Builder() {}
+
+ /** (optional) Sets the new webhook name. Max length: 255. */
+ @Nonnull public Builder name(@Nullable String name) { this.name = name; return this; }
+
+ /** (optional) Sets the new webhook target URL. Max length: 2048. */
+ @Nonnull public Builder target(@Nullable String target) { this.target = target; return this; }
+
+ /** (optional) Sets the new authentication type. */
+ @Nonnull public Builder authType(@Nullable String authType) { this.authType = authType; return this; }
+
+ /** (optional) Sets the new basic-auth username. */
+ @Nonnull public Builder authUsername(@Nullable String authUsername) { this.authUsername = authUsername; return this; }
+
+ /** (optional) Sets the new basic-auth password. */
+ @Nonnull public Builder authPassword(@Nullable String authPassword) { this.authPassword = authPassword; return this; }
+
+ /** (optional) Sets the new OAuth2 token URL. */
+ @Nonnull public Builder oauthTokenUrl(@Nullable String oauthTokenUrl) { this.oauthTokenUrl = oauthTokenUrl; return this; }
+
+ /** (optional) Sets the new OAuth2 client ID. */
+ @Nonnull public Builder oauthClientId(@Nullable String oauthClientId) { this.oauthClientId = oauthClientId; return this; }
+
+ /** (optional) Sets the new OAuth2 client secret. */
+ @Nonnull public Builder oauthClientSecret(@Nullable String oauthClientSecret) { this.oauthClientSecret = oauthClientSecret; return this; }
+
+ /** (optional) Sets the events that trigger the webhook. */
+ @Nonnull public Builder events(@Nullable List events) { this.events = events; return this; }
+
+ /** (optional) Enables or disables the webhook. */
+ @Nonnull public Builder active(boolean active) { this.active = active; return this; }
+
+ @Nonnull
+ public UpdateWebhookOptions build() {
+ return new UpdateWebhookOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/com/lettr/services/webhooks/model/Webhook.java b/src/main/java/com/lettr/services/webhooks/model/Webhook.java
index 1956717..d631e47 100644
--- a/src/main/java/com/lettr/services/webhooks/model/Webhook.java
+++ b/src/main/java/com/lettr/services/webhooks/model/Webhook.java
@@ -1,6 +1,9 @@
package com.lettr.services.webhooks.model;
import com.google.gson.annotations.SerializedName;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import java.util.List;
/**
@@ -31,24 +34,24 @@ public class Webhook {
@SerializedName("last_status")
private String lastStatus;
- public String getId() { return id; }
- public String getName() { return name; }
- public String getUrl() { return url; }
+ @Nonnull public String getId() { return id; }
+ @Nonnull public String getName() { return name; }
+ @Nonnull public String getUrl() { return url; }
public boolean isEnabled() { return enabled; }
- public List getEventTypes() { return eventTypes; }
- public String getAuthType() { return authType; }
+ /** Specific event types the webhook receives, or null if subscribed to all events. */
+ @Nullable public List getEventTypes() { return eventTypes; }
+ /** {@code none}, {@code basic}, or {@code oauth2}. */
+ @Nonnull public String getAuthType() { return authType; }
public boolean isHasAuthCredentials() { return hasAuthCredentials; }
- public String getLastSuccessfulAt() { return lastSuccessfulAt; }
- public String getLastFailureAt() { return lastFailureAt; }
- public String getLastStatus() { return lastStatus; }
+ /** Timestamp of the last successful webhook delivery, or null if there has been none. */
+ @Nullable public String getLastSuccessfulAt() { return lastSuccessfulAt; }
+ /** Timestamp of the last failed webhook delivery, or null if there has been none. */
+ @Nullable public String getLastFailureAt() { return lastFailureAt; }
+ /** {@code success}, {@code failure}, or null if there has been no delivery yet. */
+ @Nullable public String getLastStatus() { return lastStatus; }
@Override
public String toString() {
- return "Webhook{" +
- "id='" + id + '\'' +
- ", name='" + name + '\'' +
- ", url='" + url + '\'' +
- ", enabled=" + enabled +
- '}';
+ return "Webhook{id='" + id + "', name='" + name + "', enabled=" + enabled + '}';
}
}
diff --git a/src/test/java/com/lettr/LettrTest.java b/src/test/java/com/lettr/LettrTest.java
new file mode 100644
index 0000000..8c84874
--- /dev/null
+++ b/src/test/java/com/lettr/LettrTest.java
@@ -0,0 +1,79 @@
+package com.lettr;
+
+import com.lettr.services.domains.Domains;
+import com.lettr.services.emails.Emails;
+import com.lettr.services.projects.Projects;
+import com.lettr.services.system.System;
+import com.lettr.services.templates.Templates;
+import com.lettr.services.webhooks.Webhooks;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class LettrTest {
+
+ @Test
+ void constructorRequiresApiKey() {
+ assertThrows(IllegalArgumentException.class, () -> new Lettr(null));
+ assertThrows(IllegalArgumentException.class, () -> new Lettr(""));
+ }
+
+ @Test
+ void constructorAcceptsValidApiKey() {
+ Lettr lettr = new Lettr("test-api-key");
+ assertNotNull(lettr);
+ }
+
+ @Test
+ void emailsReturnsServiceInstance() {
+ Lettr lettr = new Lettr("test-api-key");
+ Emails emails = lettr.emails();
+ assertNotNull(emails);
+ }
+
+ @Test
+ void domainsReturnsServiceInstance() {
+ Lettr lettr = new Lettr("test-api-key");
+ Domains domains = lettr.domains();
+ assertNotNull(domains);
+ }
+
+ @Test
+ void webhooksReturnsServiceInstance() {
+ Lettr lettr = new Lettr("test-api-key");
+ Webhooks webhooks = lettr.webhooks();
+ assertNotNull(webhooks);
+ }
+
+ @Test
+ void templatesReturnsServiceInstance() {
+ Lettr lettr = new Lettr("test-api-key");
+ Templates templates = lettr.templates();
+ assertNotNull(templates);
+ }
+
+ @Test
+ void projectsReturnsServiceInstance() {
+ Lettr lettr = new Lettr("test-api-key");
+ Projects projects = lettr.projects();
+ assertNotNull(projects);
+ }
+
+ @Test
+ void systemReturnsServiceInstance() {
+ Lettr lettr = new Lettr("test-api-key");
+ System system = lettr.system();
+ assertNotNull(system);
+ }
+
+ @Test
+ void servicesAreNewInstancesEachCall() {
+ Lettr lettr = new Lettr("test-api-key");
+ assertNotSame(lettr.emails(), lettr.emails());
+ assertNotSame(lettr.domains(), lettr.domains());
+ assertNotSame(lettr.webhooks(), lettr.webhooks());
+ assertNotSame(lettr.templates(), lettr.templates());
+ assertNotSame(lettr.projects(), lettr.projects());
+ assertNotSame(lettr.system(), lettr.system());
+ }
+}
diff --git a/src/test/java/com/lettr/core/net/HttpClientTest.java b/src/test/java/com/lettr/core/net/HttpClientTest.java
new file mode 100644
index 0000000..6d32c99
--- /dev/null
+++ b/src/test/java/com/lettr/core/net/HttpClientTest.java
@@ -0,0 +1,99 @@
+package com.lettr.core.net;
+
+import com.google.gson.Gson;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class HttpClientTest {
+
+ @Test
+ void constructorAcceptsApiKey() {
+ HttpClient client = new HttpClient("test-key");
+ assertNotNull(client);
+ }
+
+ @Test
+ void gsonInstanceAvailable() {
+ HttpClient client = new HttpClient("test-key");
+ Gson gson = client.getGson();
+ assertNotNull(gson);
+ }
+
+ @Test
+ void buildUrlWithoutParams() throws Exception {
+ HttpClient client = new HttpClient("test-key");
+ Method buildUrl = HttpClient.class.getDeclaredMethod("buildUrl", String.class, Map.class);
+ buildUrl.setAccessible(true);
+
+ String url = (String) buildUrl.invoke(client, "/emails", null);
+ assertEquals("https://app.lettr.com/api/emails", url);
+ }
+
+ @Test
+ void buildUrlWithParams() throws Exception {
+ HttpClient client = new HttpClient("test-key");
+ Method buildUrl = HttpClient.class.getDeclaredMethod("buildUrl", String.class, Map.class);
+ buildUrl.setAccessible(true);
+
+ Map params = new LinkedHashMap<>();
+ params.put("per_page", "25");
+ params.put("cursor", "abc");
+
+ String url = (String) buildUrl.invoke(client, "/emails", params);
+ assertEquals("https://app.lettr.com/api/emails?per_page=25&cursor=abc", url);
+ }
+
+ @Test
+ void buildUrlEncodesParams() throws Exception {
+ HttpClient client = new HttpClient("test-key");
+ Method buildUrl = HttpClient.class.getDeclaredMethod("buildUrl", String.class, Map.class);
+ buildUrl.setAccessible(true);
+
+ Map params = new LinkedHashMap<>();
+ params.put("recipients", "user@example.com");
+
+ String url = (String) buildUrl.invoke(client, "/emails", params);
+ assertTrue(url.contains("recipients=user%40example.com"));
+ }
+
+ @Test
+ void buildUrlEmptyParams() throws Exception {
+ HttpClient client = new HttpClient("test-key");
+ Method buildUrl = HttpClient.class.getDeclaredMethod("buildUrl", String.class, Map.class);
+ buildUrl.setAccessible(true);
+
+ Map params = new LinkedHashMap<>();
+ String url = (String) buildUrl.invoke(client, "/emails", params);
+ assertEquals("https://app.lettr.com/api/emails", url);
+ }
+
+ @Test
+ void hasGetMethod() throws Exception {
+ assertNotNull(HttpClient.class.getMethod("get", String.class, Map.class, java.lang.reflect.Type.class));
+ }
+
+ @Test
+ void hasPostMethod() throws Exception {
+ assertNotNull(HttpClient.class.getMethod("post", String.class, Object.class, java.lang.reflect.Type.class));
+ }
+
+ @Test
+ void hasPutMethod() throws Exception {
+ assertNotNull(HttpClient.class.getMethod("put", String.class, Object.class, java.lang.reflect.Type.class));
+ }
+
+ @Test
+ void hasDeleteMethod() throws Exception {
+ assertNotNull(HttpClient.class.getMethod("delete", String.class));
+ }
+
+ @Test
+ void hasDeleteWithParamsMethod() throws Exception {
+ assertNotNull(HttpClient.class.getMethod("delete", String.class, Map.class));
+ }
+}
diff --git a/src/test/java/com/lettr/services/domains/DomainsTest.java b/src/test/java/com/lettr/services/domains/DomainsTest.java
new file mode 100644
index 0000000..77b4c19
--- /dev/null
+++ b/src/test/java/com/lettr/services/domains/DomainsTest.java
@@ -0,0 +1,135 @@
+package com.lettr.services.domains;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.lettr.services.domains.model.*;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class DomainsTest {
+
+ private final Gson gson = new GsonBuilder()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+ .create();
+
+ @Test
+ void createDomainOptionsFactory() {
+ CreateDomainOptions options = CreateDomainOptions.of("example.com");
+ assertNotNull(options);
+ }
+
+ @Test
+ void domainDeserializesAllFields() {
+ String json = "{\"domain\":\"example.com\",\"status\":\"approved\",\"status_label\":\"Approved\"," +
+ "\"can_send\":true,\"cname_status\":\"valid\",\"dkim_status\":\"valid\"," +
+ "\"dmarc_status\":\"valid\",\"spf_status\":\"valid\",\"is_primary_domain\":false," +
+ "\"tracking_domain\":\"tracking.example.com\"," +
+ "\"dns\":{\"dkim\":{\"selector\":\"scph0123\",\"public\":\"MIGfMA0G\",\"headers\":\"from:to:subject\"}}," +
+ "\"dns_provider\":{\"provider\":\"cloudflare\",\"provider_label\":\"Cloudflare\"," +
+ "\"nameservers\":[\"ns1.cf.com\",\"ns2.cf.com\"],\"error\":null}," +
+ "\"created_at\":\"2024-01-15T10:30:00+00:00\",\"updated_at\":\"2024-01-16T14:45:00+00:00\"}";
+
+ Domain domain = gson.fromJson(json, Domain.class);
+ assertEquals("example.com", domain.getDomain());
+ assertEquals("approved", domain.getStatus());
+ assertTrue(domain.isCanSend());
+ assertEquals("valid", domain.getDkimStatus());
+ assertEquals("valid", domain.getDmarcStatus());
+ assertEquals("valid", domain.getSpfStatus());
+ assertFalse(domain.getIsPrimaryDomain());
+ assertEquals("tracking.example.com", domain.getTrackingDomain());
+ assertNotNull(domain.getDns());
+ assertEquals("scph0123", domain.getDns().getDkim().getSelector());
+ assertEquals("MIGfMA0G", domain.getDns().getDkim().getPublicKey());
+ assertEquals("from:to:subject", domain.getDns().getDkim().getHeaders());
+ assertNotNull(domain.getDnsProvider());
+ assertEquals("cloudflare", domain.getDnsProvider().getProvider());
+ assertEquals("Cloudflare", domain.getDnsProvider().getProviderLabel());
+ assertEquals(2, domain.getDnsProvider().getNameservers().size());
+ }
+
+ @Test
+ void createDomainResponseDeserializes() {
+ String json = "{\"domain\":\"example.com\",\"status\":\"pending\",\"status_label\":\"Pending\"," +
+ "\"dkim\":{\"public\":\"MIGfMA0G\",\"selector\":\"scph0123\",\"headers\":\"from:to\"," +
+ "\"signing_domain\":\"example.com\"}}";
+
+ CreateDomainResponse response = gson.fromJson(json, CreateDomainResponse.class);
+ assertEquals("example.com", response.getDomain());
+ assertEquals("pending", response.getStatus());
+ assertNotNull(response.getDkim());
+ assertEquals("MIGfMA0G", response.getDkim().getPublicKey());
+ assertEquals("scph0123", response.getDkim().getSelector());
+ assertEquals("from:to", response.getDkim().getHeaders());
+ assertEquals("example.com", response.getDkim().getSigningDomain());
+ }
+
+ @Test
+ void listDomainsResponseDeserializes() {
+ String json = "{\"domains\":[{\"domain\":\"example.com\",\"status\":\"approved\"," +
+ "\"status_label\":\"Approved\",\"can_send\":true," +
+ "\"created_at\":\"2024-01-15T10:30:00+00:00\",\"updated_at\":\"2024-01-16T14:45:00+00:00\"}]}";
+
+ ListDomainsResponse response = gson.fromJson(json, ListDomainsResponse.class);
+ assertEquals(1, response.getDomains().size());
+ assertEquals("example.com", response.getDomains().get(0).getDomain());
+ }
+
+ @Test
+ void verifyDomainResponseDeserializes() {
+ String json = "{\"domain\":\"example.com\",\"dkim_status\":\"valid\",\"cname_status\":\"valid\"," +
+ "\"dmarc_status\":\"valid\",\"spf_status\":\"valid\",\"is_primary_domain\":false," +
+ "\"ownership_verified\":\"true\"," +
+ "\"dns\":{\"dkim_record\":null,\"cname_record\":null,\"dkim_error\":\"DKIM record not found\"," +
+ "\"cname_error\":null,\"dmarc_record\":null,\"dmarc_error\":null,\"spf_record\":null,\"spf_error\":null}," +
+ "\"dmarc\":{\"is_valid\":true,\"status\":\"valid\",\"found_at_domain\":\"example.com\"," +
+ "\"record\":\"v=DMARC1; p=reject\",\"policy\":\"reject\",\"subdomain_policy\":null," +
+ "\"error\":null,\"covered_by_parent_policy\":false}," +
+ "\"spf\":{\"is_valid\":true,\"status\":\"valid\",\"record\":\"v=spf1 include:_spf.sparkpostmail.com ~all\"," +
+ "\"error\":null,\"includes_sparkpost\":true}}";
+
+ VerifyDomainResponse response = gson.fromJson(json, VerifyDomainResponse.class);
+ assertEquals("example.com", response.getDomain());
+ assertEquals("valid", response.getDkimStatus());
+ assertEquals("valid", response.getCnameStatus());
+ assertEquals("valid", response.getDmarcStatus());
+ assertEquals("valid", response.getSpfStatus());
+ assertFalse(response.isPrimaryDomain());
+ assertEquals("true", response.getOwnershipVerified());
+
+ assertNotNull(response.getDns());
+ assertEquals("DKIM record not found", response.getDns().getDkimError());
+
+ assertNotNull(response.getDmarc());
+ assertTrue(response.getDmarc().isValid());
+ assertEquals("reject", response.getDmarc().getPolicy());
+
+ assertNotNull(response.getSpf());
+ assertTrue(response.getSpf().isValid());
+ assertTrue(response.getSpf().isIncludesSparkpost());
+ }
+
+ // --- Service argument validation ---
+
+ @Test
+ void domainsGetRequiresDomain() {
+ Domains domains = new Domains("test-key");
+ assertThrows(IllegalArgumentException.class, () -> domains.get(null));
+ assertThrows(IllegalArgumentException.class, () -> domains.get(""));
+ }
+
+ @Test
+ void domainsDeleteRequiresDomain() {
+ Domains domains = new Domains("test-key");
+ assertThrows(IllegalArgumentException.class, () -> domains.delete(null));
+ assertThrows(IllegalArgumentException.class, () -> domains.delete(""));
+ }
+
+ @Test
+ void domainsVerifyRequiresDomain() {
+ Domains domains = new Domains("test-key");
+ assertThrows(IllegalArgumentException.class, () -> domains.verify(null));
+ assertThrows(IllegalArgumentException.class, () -> domains.verify(""));
+ }
+}
diff --git a/src/test/java/com/lettr/services/emails/EmailsTest.java b/src/test/java/com/lettr/services/emails/EmailsTest.java
new file mode 100644
index 0000000..44ff3a9
--- /dev/null
+++ b/src/test/java/com/lettr/services/emails/EmailsTest.java
@@ -0,0 +1,438 @@
+package com.lettr.services.emails;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.lettr.services.emails.model.*;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class EmailsTest {
+
+ private final Gson gson = new GsonBuilder()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+ .create();
+
+ // --- CreateEmailOptions builder tests ---
+
+ @Test
+ void createEmailOptionsRequiresFrom() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateEmailOptions.builder()
+ .to("test@example.com")
+ .subject("Hello")
+ .html("Hello
")
+ .build());
+ }
+
+ @Test
+ void createEmailOptionsRequiresTo() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateEmailOptions.builder()
+ .from("sender@example.com")
+ .subject("Hello")
+ .html("Hello
")
+ .build());
+ }
+
+ @Test
+ void createEmailOptionsRequiresContent() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateEmailOptions.builder()
+ .from("sender@example.com")
+ .to("test@example.com")
+ .subject("Hello")
+ .build());
+ }
+
+ @Test
+ void createEmailOptionsAllowsTemplateSlugWithoutSubject() {
+ CreateEmailOptions options = CreateEmailOptions.builder()
+ .from("sender@example.com")
+ .to("test@example.com")
+ .templateSlug("welcome-email")
+ .build();
+ assertNotNull(options);
+ assertNull(options.getSubject());
+ assertEquals("welcome-email", options.getTemplateSlug());
+ }
+
+ @Test
+ void createEmailOptionsBuildsWithAllFields() {
+ Map headers = new HashMap<>();
+ headers.put("X-Custom", "value");
+
+ CreateEmailOptions options = CreateEmailOptions.builder()
+ .from("sender@example.com")
+ .fromName("Sender Name")
+ .to("a@example.com", "b@example.com")
+ .cc("cc@example.com")
+ .bcc("bcc@example.com")
+ .subject("Hello")
+ .replyTo("reply@example.com")
+ .replyToName("Reply Name")
+ .html("Hello
")
+ .text("Hello")
+ .ampHtml("Hello ")
+ .templateSlug("welcome")
+ .templateVersion(2)
+ .projectId(5)
+ .tag("welcome-series")
+ .headers(headers)
+ .attachments(Attachment.builder().name("file.pdf").type("application/pdf").data("base64data").build())
+ .substitutionData(Map.of("name", "John"))
+ .metadata(Map.of("user_id", "123"))
+ .options(EmailOptions.builder().clickTracking(true).openTracking(true).transactional(true).build())
+ .build();
+
+ assertEquals("sender@example.com", options.getFrom());
+ assertEquals("Sender Name", options.getFromName());
+ assertEquals(2, options.getTo().size());
+ assertEquals(1, options.getCc().size());
+ assertEquals(1, options.getBcc().size());
+ assertEquals("Hello", options.getSubject());
+ assertEquals("reply@example.com", options.getReplyTo());
+ assertEquals("Reply Name", options.getReplyToName());
+ assertEquals("Hello
", options.getHtml());
+ assertEquals("Hello", options.getText());
+ assertEquals("welcome", options.getTemplateSlug());
+ assertEquals(2, options.getTemplateVersion());
+ assertEquals(5, options.getProjectId());
+ assertEquals("welcome-series", options.getTag());
+ assertEquals("value", options.getHeaders().get("X-Custom"));
+ assertEquals(1, options.getAttachments().size());
+ }
+
+ @Test
+ void createEmailOptionsToList() {
+ CreateEmailOptions options = CreateEmailOptions.builder()
+ .from("sender@example.com")
+ .to(Arrays.asList("a@example.com", "b@example.com"))
+ .subject("Hello")
+ .html("Hello
")
+ .build();
+
+ assertEquals(2, options.getTo().size());
+ }
+
+ @Test
+ void createEmailOptionsSerializesCorrectFieldNames() {
+ CreateEmailOptions options = CreateEmailOptions.builder()
+ .from("sender@example.com")
+ .fromName("Test Sender")
+ .to("recipient@example.com")
+ .subject("Hello")
+ .html("Hello
")
+ .replyTo("reply@example.com")
+ .replyToName("Reply")
+ .ampHtml("Hello ")
+ .tag("test-tag")
+ .build();
+
+ String json = gson.toJson(options);
+ assertTrue(json.contains("\"from_name\""));
+ assertTrue(json.contains("\"reply_to\""));
+ assertTrue(json.contains("\"reply_to_name\""));
+ assertTrue(json.contains("\"amp_html\""));
+ assertFalse(json.contains("\"fromName\""));
+ assertFalse(json.contains("\"replyTo\""));
+ }
+
+ // --- EmailOptions builder tests ---
+
+ @Test
+ void emailOptionsBuildsWithAllFields() {
+ EmailOptions options = EmailOptions.builder()
+ .clickTracking(true)
+ .openTracking(false)
+ .transactional(true)
+ .inlineCss(true)
+ .performSubstitutions(false)
+ .build();
+
+ assertEquals(true, options.getClickTracking());
+ assertEquals(false, options.getOpenTracking());
+ assertEquals(true, options.getTransactional());
+ assertEquals(true, options.getInlineCss());
+ assertEquals(false, options.getPerformSubstitutions());
+ }
+
+ @Test
+ void emailOptionsSerializesCorrectFieldNames() {
+ EmailOptions options = EmailOptions.builder()
+ .inlineCss(true)
+ .performSubstitutions(false)
+ .build();
+
+ String json = gson.toJson(options);
+ assertTrue(json.contains("\"inline_css\""));
+ assertTrue(json.contains("\"perform_substitutions\""));
+ }
+
+ // --- ListEmailsParams tests ---
+
+ @Test
+ void listEmailsParamsToQueryParams() {
+ ListEmailsParams params = ListEmailsParams.builder()
+ .perPage(50)
+ .cursor("abc123")
+ .recipients("user@example.com")
+ .from("2024-01-01")
+ .to("2024-12-31")
+ .build();
+
+ Map queryParams = params.toQueryParams();
+ assertEquals("50", queryParams.get("per_page"));
+ assertEquals("abc123", queryParams.get("cursor"));
+ assertEquals("user@example.com", queryParams.get("recipients"));
+ assertEquals("2024-01-01", queryParams.get("from"));
+ assertEquals("2024-12-31", queryParams.get("to"));
+ }
+
+ @Test
+ void listEmailsParamsEmptyToQueryParams() {
+ ListEmailsParams params = ListEmailsParams.builder().build();
+ Map queryParams = params.toQueryParams();
+ assertTrue(queryParams.isEmpty());
+ }
+
+ // --- ListEmailEventsParams tests ---
+
+ @Test
+ void listEmailEventsParamsToQueryParams() {
+ ListEmailEventsParams params = ListEmailEventsParams.builder()
+ .events(Arrays.asList("delivery", "bounce"))
+ .recipients(Arrays.asList("a@example.com", "b@example.com"))
+ .from("2024-01-01")
+ .to("2024-12-31")
+ .perPage(25)
+ .cursor("cursor123")
+ .transmissions("trans1")
+ .bounceClasses("10,25")
+ .build();
+
+ Map queryParams = params.toQueryParams();
+ assertEquals("delivery,bounce", queryParams.get("events"));
+ assertEquals("a@example.com,b@example.com", queryParams.get("recipients"));
+ assertEquals("2024-01-01", queryParams.get("from"));
+ assertEquals("2024-12-31", queryParams.get("to"));
+ assertEquals("25", queryParams.get("per_page"));
+ assertEquals("cursor123", queryParams.get("cursor"));
+ assertEquals("trans1", queryParams.get("transmissions"));
+ assertEquals("10,25", queryParams.get("bounce_classes"));
+ }
+
+ // --- ScheduleEmailOptions tests ---
+
+ @Test
+ void scheduleEmailOptionsRequiresScheduledAt() {
+ assertThrows(IllegalArgumentException.class, () ->
+ ScheduleEmailOptions.builder()
+ .from("sender@example.com")
+ .to("test@example.com")
+ .subject("Hello")
+ .html("Hello
")
+ .build());
+ }
+
+ @Test
+ void scheduleEmailOptionsBuildsWithScheduledAt() {
+ ScheduleEmailOptions options = ScheduleEmailOptions.builder()
+ .from("sender@example.com")
+ .to("test@example.com")
+ .subject("Hello")
+ .html("Hello
")
+ .scheduledAt("2024-01-16T10:00:00Z")
+ .build();
+
+ assertEquals("2024-01-16T10:00:00Z", options.getScheduledAt());
+ assertEquals("sender@example.com", options.getFrom());
+ }
+
+ @Test
+ void scheduleEmailOptionsSerializesScheduledAt() {
+ ScheduleEmailOptions options = ScheduleEmailOptions.builder()
+ .from("sender@example.com")
+ .to("test@example.com")
+ .subject("Hello")
+ .html("Hello
")
+ .scheduledAt("2024-01-16T10:00:00Z")
+ .build();
+
+ String json = gson.toJson(options);
+ assertTrue(json.contains("\"scheduled_at\""));
+ assertTrue(json.contains("2024-01-16T10:00:00Z"));
+ }
+
+ // --- EmailEvent deserialization tests ---
+
+ @Test
+ void emailEventDeserializesCommonFields() {
+ String json = "{\"event_id\":\"evt1\",\"type\":\"delivery\",\"timestamp\":\"2024-01-15T10:31:00.000Z\"," +
+ "\"request_id\":\"req1\",\"rcpt_to\":\"user@example.com\",\"subject\":\"Hello\"," +
+ "\"sending_domain\":\"example.com\",\"click_tracking\":true,\"open_tracking\":false," +
+ "\"transactional\":true,\"msg_size\":30823}";
+
+ EmailEvent event = gson.fromJson(json, EmailEvent.class);
+ assertEquals("evt1", event.getEventId());
+ assertEquals("delivery", event.getType());
+ assertEquals("req1", event.getRequestId());
+ assertEquals("user@example.com", event.getRcptTo());
+ assertEquals("Hello", event.getSubject());
+ assertEquals(true, event.getClickTracking());
+ assertEquals(false, event.getOpenTracking());
+ assertEquals(true, event.getTransactional());
+ assertEquals(30823, event.getMsgSize());
+ }
+
+ @Test
+ void emailEventDeserializesBounceFields() {
+ String json = "{\"event_id\":\"evt1\",\"type\":\"bounce\",\"timestamp\":\"2024-01-15T10:31:00.000Z\"," +
+ "\"bounce_class\":10,\"reason\":\"550 User not found\",\"raw_reason\":\"550 User not found\"," +
+ "\"error_code\":\"550\"}";
+
+ EmailEvent event = gson.fromJson(json, EmailEvent.class);
+ assertEquals("bounce", event.getType());
+ assertEquals(Integer.valueOf(10), event.getBounceClass());
+ assertEquals("550 User not found", event.getReason());
+ assertEquals("550", event.getErrorCode());
+ }
+
+ @Test
+ void emailEventDeserializesClickFields() {
+ String json = "{\"event_id\":\"evt1\",\"type\":\"click\",\"timestamp\":\"2024-01-15T10:31:00.000Z\"," +
+ "\"target_link_url\":\"https://example.com\",\"target_link_name\":\"Click here\"," +
+ "\"user_agent\":\"Mozilla/5.0\"," +
+ "\"geo_ip\":{\"country\":\"CZ\",\"city\":\"Prague\",\"latitude\":50.08,\"longitude\":14.41}," +
+ "\"user_agent_parsed\":{\"agent_family\":\"Chrome\",\"os_family\":\"Windows\",\"is_mobile\":false}}";
+
+ EmailEvent event = gson.fromJson(json, EmailEvent.class);
+ assertEquals("click", event.getType());
+ assertEquals("https://example.com", event.getTargetLinkUrl());
+ assertEquals("Click here", event.getTargetLinkName());
+ assertNotNull(event.getGeoIp());
+ assertEquals("CZ", event.getGeoIp().getCountry());
+ assertEquals("Prague", event.getGeoIp().getCity());
+ assertNotNull(event.getUserAgentParsed());
+ assertEquals("Chrome", event.getUserAgentParsed().getAgentFamily());
+ assertEquals(false, event.getUserAgentParsed().getIsMobile());
+ }
+
+ @Test
+ void emailEventNullableFieldsHandled() {
+ String json = "{\"event_id\":\"evt1\",\"type\":\"injection\",\"timestamp\":\"2024-01-15T10:31:00.000Z\"," +
+ "\"click_tracking\":null,\"msg_size\":null}";
+
+ EmailEvent event = gson.fromJson(json, EmailEvent.class);
+ assertNull(event.getClickTracking());
+ assertNull(event.getMsgSize());
+ }
+
+ // --- Response deserialization tests ---
+
+ @Test
+ void createEmailResponseDeserializes() {
+ String json = "{\"request_id\":\"12345\",\"accepted\":1,\"rejected\":0}";
+ CreateEmailResponse response = gson.fromJson(json, CreateEmailResponse.class);
+ assertEquals("12345", response.getRequestId());
+ assertEquals(1, response.getAccepted());
+ assertEquals(0, response.getRejected());
+ }
+
+ @Test
+ void listEmailsResponseDeserializes() {
+ String json = "{\"events\":{\"data\":[{\"event_id\":\"e1\",\"type\":\"injection\",\"timestamp\":\"2024-01-15T10:00:00Z\"}]," +
+ "\"total_count\":1,\"from\":\"2024-01-01T00:00:00Z\",\"to\":\"2024-01-31T23:59:59Z\"," +
+ "\"pagination\":{\"next_cursor\":null,\"per_page\":25}}}";
+
+ ListEmailsResponse response = gson.fromJson(json, ListEmailsResponse.class);
+ assertNotNull(response.getEvents());
+ assertEquals(1, response.getEvents().getTotalCount());
+ assertEquals(1, response.getEvents().getData().size());
+ assertEquals("e1", response.getEvents().getData().get(0).getEventId());
+ assertNull(response.getEvents().getPagination().getNextCursor());
+ assertEquals(25, response.getEvents().getPagination().getPerPage());
+ }
+
+ @Test
+ void getEmailResponseDeserializes() {
+ String json = "{\"transmission_id\":\"123\",\"state\":\"delivered\",\"from\":\"sender@example.com\"," +
+ "\"subject\":\"Hello\",\"recipients\":[\"user@example.com\"],\"num_recipients\":1," +
+ "\"events\":[{\"event_id\":\"e1\",\"type\":\"delivery\",\"timestamp\":\"2024-01-15T10:31:00Z\"}]}";
+
+ GetEmailResponse response = gson.fromJson(json, GetEmailResponse.class);
+ assertEquals("123", response.getTransmissionId());
+ assertEquals("delivered", response.getState());
+ assertEquals("sender@example.com", response.getFrom());
+ assertEquals("Hello", response.getSubject());
+ assertEquals(1, response.getNumRecipients());
+ assertEquals(1, response.getEvents().size());
+ }
+
+ @Test
+ void scheduledEmailDeserializes() {
+ String json = "{\"transmission_id\":\"123\",\"state\":\"submitted\"," +
+ "\"scheduled_at\":\"2024-01-16T10:00:00+00:00\"," +
+ "\"from\":\"sender@example.com\",\"subject\":\"Newsletter\"," +
+ "\"recipients\":[\"user@example.com\"],\"num_recipients\":1,\"events\":[]}";
+
+ ScheduledEmail response = gson.fromJson(json, ScheduledEmail.class);
+ assertEquals("123", response.getTransmissionId());
+ assertEquals("submitted", response.getState());
+ assertEquals("2024-01-16T10:00:00+00:00", response.getScheduledAt());
+ assertTrue(response.getEvents().isEmpty());
+ }
+
+ // --- Attachment builder tests ---
+
+ @Test
+ void attachmentBuilderRequiresAllFields() {
+ assertThrows(IllegalArgumentException.class, () ->
+ Attachment.builder().name("file.pdf").type("application/pdf").build());
+ assertThrows(IllegalArgumentException.class, () ->
+ Attachment.builder().name("file.pdf").data("base64").build());
+ assertThrows(IllegalArgumentException.class, () ->
+ Attachment.builder().type("application/pdf").data("base64").build());
+ }
+
+ @Test
+ void attachmentBuilderBuildsCorrectly() {
+ Attachment attachment = Attachment.builder()
+ .name("invoice.pdf")
+ .type("application/pdf")
+ .data("JVBERi0xLjQ=")
+ .build();
+
+ assertEquals("invoice.pdf", attachment.getName());
+ assertEquals("application/pdf", attachment.getType());
+ assertEquals("JVBERi0xLjQ=", attachment.getData());
+ }
+
+ // --- Service argument validation tests ---
+
+ @Test
+ void emailsGetRequiresRequestId() {
+ Emails emails = new Emails("test-key");
+ assertThrows(IllegalArgumentException.class, () -> emails.get(null));
+ assertThrows(IllegalArgumentException.class, () -> emails.get(""));
+ }
+
+ @Test
+ void emailsGetScheduledRequiresTransmissionId() {
+ Emails emails = new Emails("test-key");
+ assertThrows(IllegalArgumentException.class, () -> emails.getScheduled(null));
+ assertThrows(IllegalArgumentException.class, () -> emails.getScheduled(""));
+ }
+
+ @Test
+ void emailsCancelScheduledRequiresTransmissionId() {
+ Emails emails = new Emails("test-key");
+ assertThrows(IllegalArgumentException.class, () -> emails.cancelScheduled(null));
+ assertThrows(IllegalArgumentException.class, () -> emails.cancelScheduled(""));
+ }
+}
diff --git a/src/test/java/com/lettr/services/projects/ProjectsTest.java b/src/test/java/com/lettr/services/projects/ProjectsTest.java
new file mode 100644
index 0000000..b2af9df
--- /dev/null
+++ b/src/test/java/com/lettr/services/projects/ProjectsTest.java
@@ -0,0 +1,63 @@
+package com.lettr.services.projects;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.lettr.services.projects.model.*;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ProjectsTest {
+
+ private final Gson gson = new GsonBuilder()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+ .create();
+
+ @Test
+ void listProjectsParamsToQueryParams() {
+ ListProjectsParams params = ListProjectsParams.builder()
+ .perPage(10)
+ .page(2)
+ .build();
+
+ Map queryParams = params.toQueryParams();
+ assertEquals("10", queryParams.get("per_page"));
+ assertEquals("2", queryParams.get("page"));
+ }
+
+ @Test
+ void listProjectsParamsEmptyToQueryParams() {
+ ListProjectsParams params = ListProjectsParams.builder().build();
+ assertTrue(params.toQueryParams().isEmpty());
+ }
+
+ @Test
+ void projectDeserializes() {
+ String json = "{\"id\":1,\"name\":\"My Project\",\"emoji\":\"\\uD83D\\uDE80\"," +
+ "\"team_id\":42,\"created_at\":\"2025-01-15T10:00:00+00:00\"," +
+ "\"updated_at\":\"2025-01-20T14:30:00+00:00\"}";
+
+ Project project = gson.fromJson(json, Project.class);
+ assertEquals(1, project.getId());
+ assertEquals("My Project", project.getName());
+ assertEquals(42, project.getTeamId());
+ assertNotNull(project.getCreatedAt());
+ }
+
+ @Test
+ void listProjectsResponseDeserializes() {
+ String json = "{\"projects\":[{\"id\":1,\"name\":\"Default\",\"team_id\":1," +
+ "\"created_at\":\"2025-01-01T00:00:00+00:00\",\"updated_at\":\"2025-01-01T00:00:00+00:00\"}]," +
+ "\"pagination\":{\"total\":1,\"per_page\":25,\"current_page\":1,\"last_page\":1}}";
+
+ ListProjectsResponse response = gson.fromJson(json, ListProjectsResponse.class);
+ assertEquals(1, response.getProjects().size());
+ assertEquals("Default", response.getProjects().get(0).getName());
+ assertEquals(1, response.getPagination().getTotal());
+ assertEquals(25, response.getPagination().getPerPage());
+ assertEquals(1, response.getPagination().getCurrentPage());
+ assertEquals(1, response.getPagination().getLastPage());
+ }
+}
diff --git a/src/test/java/com/lettr/services/system/SystemTest.java b/src/test/java/com/lettr/services/system/SystemTest.java
new file mode 100644
index 0000000..27ea2a9
--- /dev/null
+++ b/src/test/java/com/lettr/services/system/SystemTest.java
@@ -0,0 +1,38 @@
+package com.lettr.services.system;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.lettr.services.system.model.AuthCheckResponse;
+import com.lettr.services.system.model.HealthResponse;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SystemTest {
+
+ private final Gson gson = new GsonBuilder()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+ .create();
+
+ @Test
+ void healthResponseDeserializes() {
+ String json = "{\"status\":\"ok\",\"timestamp\":\"2024-01-15T10:30:00.000Z\"}";
+ HealthResponse response = gson.fromJson(json, HealthResponse.class);
+ assertEquals("ok", response.getStatus());
+ assertEquals("2024-01-15T10:30:00.000Z", response.getTimestamp());
+ }
+
+ @Test
+ void authCheckResponseDeserializes() {
+ String json = "{\"team_id\":123,\"timestamp\":\"2024-01-15T10:30:00.000Z\"}";
+ AuthCheckResponse response = gson.fromJson(json, AuthCheckResponse.class);
+ assertEquals(123, response.getTeamId());
+ assertEquals("2024-01-15T10:30:00.000Z", response.getTimestamp());
+ }
+
+ @Test
+ void systemServiceConstructable() {
+ System system = new System("test-key");
+ assertNotNull(system);
+ }
+}
diff --git a/src/test/java/com/lettr/services/templates/TemplatesTest.java b/src/test/java/com/lettr/services/templates/TemplatesTest.java
new file mode 100644
index 0000000..7a5d8de
--- /dev/null
+++ b/src/test/java/com/lettr/services/templates/TemplatesTest.java
@@ -0,0 +1,280 @@
+package com.lettr.services.templates;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.lettr.services.templates.model.*;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TemplatesTest {
+
+ private final Gson gson = new GsonBuilder()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+ .create();
+
+ // --- CreateTemplateOptions tests ---
+
+ @Test
+ void createTemplateOptionsRequiresName() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateTemplateOptions.builder().html("Hello
").build());
+ }
+
+ @Test
+ void createTemplateOptionsRequiresContent() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateTemplateOptions.builder().name("Test").build());
+ }
+
+ @Test
+ void createTemplateOptionsMutualExclusivity() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateTemplateOptions.builder()
+ .name("Test")
+ .html("Hello
")
+ .json("{\"tag\":\"mj-body\"}")
+ .build());
+ }
+
+ @Test
+ void createTemplateOptionsBuildsWithHtml() {
+ CreateTemplateOptions options = CreateTemplateOptions.builder()
+ .name("Welcome")
+ .html("Hello {{NAME}}
")
+ .projectId(5)
+ .folderId(10)
+ .build();
+
+ assertEquals("Welcome", options.getName());
+ assertEquals("Hello {{NAME}}
", options.getHtml());
+ assertNull(options.getJson());
+ assertEquals(5, options.getProjectId());
+ assertEquals(10, options.getFolderId());
+ }
+
+ @Test
+ void createTemplateOptionsBuildsWithJson() {
+ CreateTemplateOptions options = CreateTemplateOptions.builder()
+ .name("Welcome")
+ .json("{\"tag\":\"mj-body\"}")
+ .build();
+
+ assertNull(options.getHtml());
+ assertEquals("{\"tag\":\"mj-body\"}", options.getJson());
+ }
+
+ // --- UpdateTemplateOptions tests ---
+
+ @Test
+ void updateTemplateOptionsMutualExclusivity() {
+ assertThrows(IllegalArgumentException.class, () ->
+ UpdateTemplateOptions.builder()
+ .html("Hello
")
+ .json("{\"tag\":\"mj-body\"}")
+ .build());
+ }
+
+ @Test
+ void updateTemplateOptionsBuildsWithNoFields() {
+ UpdateTemplateOptions options = UpdateTemplateOptions.builder().build();
+ assertNotNull(options);
+ }
+
+ @Test
+ void updateTemplateOptionsBuildsWithAllFields() {
+ UpdateTemplateOptions options = UpdateTemplateOptions.builder()
+ .projectId(5)
+ .name("Updated")
+ .html("Updated content
")
+ .build();
+
+ assertEquals(5, options.getProjectId());
+ assertEquals("Updated", options.getName());
+ assertEquals("Updated content
", options.getHtml());
+ }
+
+ // --- ListTemplatesParams tests ---
+
+ @Test
+ void listTemplatesParamsToQueryParams() {
+ ListTemplatesParams params = ListTemplatesParams.builder()
+ .projectId(5)
+ .perPage(25)
+ .page(2)
+ .build();
+
+ Map queryParams = params.toQueryParams();
+ assertEquals("5", queryParams.get("project_id"));
+ assertEquals("25", queryParams.get("per_page"));
+ assertEquals("2", queryParams.get("page"));
+ }
+
+ // --- GetMergeTagsParams tests ---
+
+ @Test
+ void getMergeTagsParamsToQueryParams() {
+ GetMergeTagsParams params = GetMergeTagsParams.builder()
+ .projectId(5)
+ .version(2)
+ .build();
+
+ Map queryParams = params.toQueryParams();
+ assertEquals("5", queryParams.get("project_id"));
+ assertEquals("2", queryParams.get("version"));
+ }
+
+ // --- GetTemplateHtmlParams tests ---
+
+ @Test
+ void getTemplateHtmlParamsRequiresSlug() {
+ assertThrows(IllegalArgumentException.class, () ->
+ GetTemplateHtmlParams.builder().projectId(5).build());
+ }
+
+ @Test
+ void getTemplateHtmlParamsToQueryParams() {
+ GetTemplateHtmlParams params = GetTemplateHtmlParams.builder()
+ .projectId(5)
+ .slug("welcome-email")
+ .build();
+
+ Map queryParams = params.toQueryParams();
+ assertEquals("5", queryParams.get("project_id"));
+ assertEquals("welcome-email", queryParams.get("slug"));
+ }
+
+ // --- Template deserialization ---
+
+ @Test
+ void templateDeserializes() {
+ String json = "{\"id\":1,\"name\":\"Welcome\",\"slug\":\"welcome\"," +
+ "\"project_id\":5,\"folder_id\":10," +
+ "\"created_at\":\"2025-01-15T10:00:00+00:00\",\"updated_at\":\"2025-01-20T14:30:00+00:00\"}";
+
+ Template template = gson.fromJson(json, Template.class);
+ assertEquals(1, template.getId());
+ assertEquals("Welcome", template.getName());
+ assertEquals("welcome", template.getSlug());
+ assertEquals(5, template.getProjectId());
+ assertEquals(10, template.getFolderId());
+ }
+
+ @Test
+ void templateDetailDeserializes() {
+ String json = "{\"id\":1,\"name\":\"Welcome\",\"slug\":\"welcome\"," +
+ "\"project_id\":5,\"folder_id\":10,\"active_version\":2,\"versions_count\":3," +
+ "\"html\":\"Hello
\",\"json\":null," +
+ "\"created_at\":\"2025-01-15T10:00:00+00:00\",\"updated_at\":\"2025-01-20T14:30:00+00:00\"}";
+
+ TemplateDetail detail = gson.fromJson(json, TemplateDetail.class);
+ assertEquals(1, detail.getId());
+ assertEquals("welcome", detail.getSlug());
+ assertEquals(2, detail.getActiveVersion());
+ assertEquals(3, detail.getVersionsCount());
+ assertEquals("Hello
", detail.getHtml());
+ assertNull(detail.getJson());
+ }
+
+ @Test
+ void createTemplateResponseDeserializes() {
+ String json = "{\"id\":123,\"name\":\"Welcome\",\"slug\":\"welcome\"," +
+ "\"project_id\":5,\"folder_id\":10,\"active_version\":1," +
+ "\"merge_tags\":[{\"key\":\"first_name\",\"required\":true}]," +
+ "\"created_at\":\"2026-01-28T12:00:00+00:00\"}";
+
+ CreateTemplateResponse response = gson.fromJson(json, CreateTemplateResponse.class);
+ assertEquals(123, response.getId());
+ assertEquals("welcome", response.getSlug());
+ assertEquals(1, response.getActiveVersion());
+ assertEquals(1, response.getMergeTags().size());
+ assertEquals("first_name", response.getMergeTags().get(0).getKey());
+ assertTrue(response.getMergeTags().get(0).isRequired());
+ }
+
+ @Test
+ void mergeTagWithChildrenDeserializes() {
+ String json = "{\"key\":\"items\",\"required\":true,\"type\":\"text\"," +
+ "\"children\":[{\"key\":\"item_name\",\"type\":\"text\"},{\"key\":\"price\",\"type\":\"number\"}]}";
+
+ MergeTag tag = gson.fromJson(json, MergeTag.class);
+ assertEquals("items", tag.getKey());
+ assertTrue(tag.isRequired());
+ assertEquals("text", tag.getType());
+ assertEquals(2, tag.getChildren().size());
+ assertEquals("item_name", tag.getChildren().get(0).getKey());
+ assertEquals("number", tag.getChildren().get(1).getType());
+ }
+
+ @Test
+ void listTemplatesResponseDeserializes() {
+ String json = "{\"templates\":[{\"id\":1,\"name\":\"Welcome\",\"slug\":\"welcome\"," +
+ "\"project_id\":5,\"folder_id\":10," +
+ "\"created_at\":\"2025-01-15T10:00:00+00:00\",\"updated_at\":\"2025-01-20T14:30:00+00:00\"}]," +
+ "\"pagination\":{\"total\":1,\"per_page\":25,\"current_page\":1,\"last_page\":1}}";
+
+ ListTemplatesResponse response = gson.fromJson(json, ListTemplatesResponse.class);
+ assertEquals(1, response.getTemplates().size());
+ assertEquals("welcome", response.getTemplates().get(0).getSlug());
+ assertEquals(1, response.getPagination().getTotal());
+ assertEquals(25, response.getPagination().getPerPage());
+ }
+
+ @Test
+ void getMergeTagsResponseDeserializes() {
+ String json = "{\"project_id\":13,\"template_slug\":\"welcome\",\"version\":2," +
+ "\"merge_tags\":[{\"key\":\"name\",\"required\":true}]}";
+
+ GetMergeTagsResponse response = gson.fromJson(json, GetMergeTagsResponse.class);
+ assertEquals(13, response.getProjectId());
+ assertEquals("welcome", response.getTemplateSlug());
+ assertEquals(2, response.getVersion());
+ assertEquals(1, response.getMergeTags().size());
+ }
+
+ @Test
+ void getTemplateHtmlResponseDeserializes() {
+ String json = "{\"html\":\"Hello
\",\"merge_tags\":[{\"key\":\"name\",\"name\":\"Name\",\"required\":true}]," +
+ "\"subject\":\"Welcome\"}";
+
+ GetTemplateHtmlResponse response = gson.fromJson(json, GetTemplateHtmlResponse.class);
+ assertEquals("Hello
", response.getHtml());
+ assertEquals("Welcome", response.getSubject());
+ assertEquals(1, response.getMergeTags().size());
+ assertEquals("name", response.getMergeTags().get(0).getKey());
+ assertEquals("Name", response.getMergeTags().get(0).getName());
+ }
+
+ // --- Service argument validation ---
+
+ @Test
+ void templatesGetRequiresSlug() {
+ Templates templates = new Templates("test-key");
+ assertThrows(IllegalArgumentException.class, () -> templates.get(null));
+ assertThrows(IllegalArgumentException.class, () -> templates.get(""));
+ }
+
+ @Test
+ void templatesUpdateRequiresSlug() {
+ Templates templates = new Templates("test-key");
+ UpdateTemplateOptions options = UpdateTemplateOptions.builder().name("x").build();
+ assertThrows(IllegalArgumentException.class, () -> templates.update(null, options));
+ assertThrows(IllegalArgumentException.class, () -> templates.update("", options));
+ }
+
+ @Test
+ void templatesDeleteRequiresSlug() {
+ Templates templates = new Templates("test-key");
+ assertThrows(IllegalArgumentException.class, () -> templates.delete(null));
+ assertThrows(IllegalArgumentException.class, () -> templates.delete(""));
+ }
+
+ @Test
+ void templatesGetMergeTagsRequiresSlug() {
+ Templates templates = new Templates("test-key");
+ assertThrows(IllegalArgumentException.class, () -> templates.getMergeTags(null));
+ assertThrows(IllegalArgumentException.class, () -> templates.getMergeTags(""));
+ }
+}
diff --git a/src/test/java/com/lettr/services/webhooks/WebhooksTest.java b/src/test/java/com/lettr/services/webhooks/WebhooksTest.java
new file mode 100644
index 0000000..c3bf2e8
--- /dev/null
+++ b/src/test/java/com/lettr/services/webhooks/WebhooksTest.java
@@ -0,0 +1,180 @@
+package com.lettr.services.webhooks;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.lettr.services.webhooks.model.*;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class WebhooksTest {
+
+ private final Gson gson = new GsonBuilder()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+ .create();
+
+ // --- CreateWebhookOptions tests ---
+
+ @Test
+ void createWebhookOptionsRequiresName() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateWebhookOptions.builder()
+ .url("https://example.com/webhook")
+ .authType("none")
+ .eventsMode("all")
+ .build());
+ }
+
+ @Test
+ void createWebhookOptionsRequiresUrl() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateWebhookOptions.builder()
+ .name("Test")
+ .authType("none")
+ .eventsMode("all")
+ .build());
+ }
+
+ @Test
+ void createWebhookOptionsRequiresAuthType() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateWebhookOptions.builder()
+ .name("Test")
+ .url("https://example.com/webhook")
+ .eventsMode("all")
+ .build());
+ }
+
+ @Test
+ void createWebhookOptionsRequiresEventsMode() {
+ assertThrows(IllegalArgumentException.class, () ->
+ CreateWebhookOptions.builder()
+ .name("Test")
+ .url("https://example.com/webhook")
+ .authType("none")
+ .build());
+ }
+
+ @Test
+ void createWebhookOptionsBuildsWithAllFields() {
+ CreateWebhookOptions options = CreateWebhookOptions.builder()
+ .name("Test Webhook")
+ .url("https://example.com/webhook")
+ .authType("basic")
+ .authUsername("user")
+ .authPassword("pass")
+ .eventsMode("selected")
+ .events(Arrays.asList("delivery", "bounce"))
+ .build();
+
+ assertEquals("Test Webhook", options.getName());
+ assertEquals("https://example.com/webhook", options.getUrl());
+ assertEquals("basic", options.getAuthType());
+ assertEquals("user", options.getAuthUsername());
+ assertEquals("pass", options.getAuthPassword());
+ assertEquals("selected", options.getEventsMode());
+ assertEquals(2, options.getEvents().size());
+ }
+
+ @Test
+ void createWebhookOptionsSerializesCorrectFieldNames() {
+ CreateWebhookOptions options = CreateWebhookOptions.builder()
+ .name("Test")
+ .url("https://example.com/webhook")
+ .authType("basic")
+ .authUsername("user")
+ .authPassword("pass")
+ .eventsMode("all")
+ .build();
+
+ String json = gson.toJson(options);
+ assertTrue(json.contains("\"auth_type\""));
+ assertTrue(json.contains("\"auth_username\""));
+ assertTrue(json.contains("\"auth_password\""));
+ assertTrue(json.contains("\"events_mode\""));
+ }
+
+ // --- UpdateWebhookOptions tests ---
+
+ @Test
+ void updateWebhookOptionsBuildsWithNoFields() {
+ UpdateWebhookOptions options = UpdateWebhookOptions.builder().build();
+ assertNotNull(options);
+ }
+
+ @Test
+ void updateWebhookOptionsBuildsWithAllFields() {
+ UpdateWebhookOptions options = UpdateWebhookOptions.builder()
+ .name("Updated")
+ .target("https://new.example.com/webhook")
+ .authType("oauth2")
+ .oauthClientId("client-id")
+ .oauthClientSecret("secret")
+ .oauthTokenUrl("https://auth.example.com/token")
+ .events(Arrays.asList("message.delivery"))
+ .active(false)
+ .build();
+
+ assertEquals("Updated", options.getName());
+ assertEquals("https://new.example.com/webhook", options.getTarget());
+ assertEquals("oauth2", options.getAuthType());
+ assertEquals(false, options.getActive());
+ }
+
+ // --- Webhook deserialization ---
+
+ @Test
+ void webhookDeserializes() {
+ String json = "{\"id\":\"wh-123\",\"name\":\"Test\",\"url\":\"https://example.com/webhook\"," +
+ "\"enabled\":true,\"event_types\":[\"message.delivery\",\"message.bounce\"]," +
+ "\"auth_type\":\"basic\",\"has_auth_credentials\":true," +
+ "\"last_successful_at\":\"2024-01-15T10:30:00+00:00\"," +
+ "\"last_failure_at\":null,\"last_status\":\"success\"}";
+
+ Webhook webhook = gson.fromJson(json, Webhook.class);
+ assertEquals("wh-123", webhook.getId());
+ assertEquals("Test", webhook.getName());
+ assertTrue(webhook.isEnabled());
+ assertEquals(2, webhook.getEventTypes().size());
+ assertEquals("basic", webhook.getAuthType());
+ assertTrue(webhook.isHasAuthCredentials());
+ assertEquals("success", webhook.getLastStatus());
+ assertNull(webhook.getLastFailureAt());
+ }
+
+ @Test
+ void listWebhooksResponseDeserializes() {
+ String json = "{\"webhooks\":[{\"id\":\"wh-1\",\"name\":\"Test\",\"url\":\"https://example.com\"," +
+ "\"enabled\":true,\"auth_type\":\"none\",\"has_auth_credentials\":false}]}";
+
+ ListWebhooksResponse response = gson.fromJson(json, ListWebhooksResponse.class);
+ assertEquals(1, response.getWebhooks().size());
+ assertEquals("wh-1", response.getWebhooks().get(0).getId());
+ }
+
+ // --- Service argument validation ---
+
+ @Test
+ void webhooksGetRequiresId() {
+ Webhooks webhooks = new Webhooks("test-key");
+ assertThrows(IllegalArgumentException.class, () -> webhooks.get(null));
+ assertThrows(IllegalArgumentException.class, () -> webhooks.get(""));
+ }
+
+ @Test
+ void webhooksUpdateRequiresId() {
+ Webhooks webhooks = new Webhooks("test-key");
+ UpdateWebhookOptions options = UpdateWebhookOptions.builder().name("x").build();
+ assertThrows(IllegalArgumentException.class, () -> webhooks.update(null, options));
+ assertThrows(IllegalArgumentException.class, () -> webhooks.update("", options));
+ }
+
+ @Test
+ void webhooksDeleteRequiresId() {
+ Webhooks webhooks = new Webhooks("test-key");
+ assertThrows(IllegalArgumentException.class, () -> webhooks.delete(null));
+ assertThrows(IllegalArgumentException.class, () -> webhooks.delete(""));
+ }
+}