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' com.lettr lettr-java - 0.1.0 + 0.2.0 ``` @@ -34,16 +34,22 @@ import com.lettr.Lettr; Lettr lettr = new Lettr("your-api-key"); ``` -## Usage +## Required vs Optional Fields + +Three signals tell you what's required: + +1. **IDE inspections** — every builder setter and getter is annotated with `@Nonnull` or `@Nullable` (JSR-305). IntelliJ, Eclipse, and SpotBugs surface nullability inline as you type. +2. **Builder Javadoc** — each setter is prefixed with **(required)**, **(optional)**, or **(required if X)**. Constraints (max length, ranges, mutual exclusion) follow on a second line. +3. **Builder `build()` validation** — missing required fields throw `IllegalArgumentException` with a descriptive message at construction time. + +Source of truth: the [OpenAPI spec](https://app.lettr.com/openapi.json). + +## Emails ### Send an Email ```java -import com.lettr.Lettr; -import com.lettr.services.emails.model.CreateEmailOptions; -import com.lettr.services.emails.model.CreateEmailResponse; - -Lettr lettr = new Lettr("your-api-key"); +import com.lettr.services.emails.model.*; CreateEmailOptions params = CreateEmailOptions.builder() .from("sender@example.com") @@ -52,27 +58,33 @@ CreateEmailOptions params = CreateEmailOptions.builder() .html("

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("

Welcome

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` form (e.g. `v0.2.0`), but the **source of truth is `VERSION=` in `gradle.properties`** — that's what ends up in the published artifact. The tag is convention; the property is law. They must match. + +While on `0.x`, breaking changes may ship in minor versions. Once we cut `1.0.0`, the full semver contract applies. + +## Release Checklist + +1. **Update the changelog** + - Move items from `[Unreleased]` into a new version section in `CHANGELOG.md` + - Use the headings `Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security` + - Update the compare links at the bottom + +2. **Bump the version** + - Edit `VERSION=` in `gradle.properties` + - Edit the `User-Agent` constant in `src/main/java/com/lettr/core/net/HttpClient.java` + - Edit the install snippets in `README.md` + +3. **Verify locally** + ```bash + ./gradlew build + ``` + +4. **Commit and push to `main`** + ```bash + git add gradle.properties src/main/java/com/lettr/core/net/HttpClient.java README.md CHANGELOG.md + git commit -m "Release 0.2.0" + git push origin main + ``` + +5. **Create a GitHub Release** + - Go to the repo → **Releases → Draft a new release** + - **Choose a tag** → type `v0.2.0` → **Create new tag: v0.2.0 on publish** + - **Release title**: `v0.2.0` + - **Description**: paste the relevant section from `CHANGELOG.md` + - Click **Publish release** + + Creating the Release creates the tag — no separate `git tag` / `git push --tags` needed. + +6. **Watch the workflow** + - The `Publish to Maven Central` workflow starts automatically + - It runs `./gradlew publish`, then POSTs to the Central Portal to finalise the deployment + - Maven Central indexes the artifact within ~15–30 minutes; [central.sonatype.com](https://central.sonatype.com/artifact/com.lettr/lettr-java) updates first + +## One-Time Setup + +Only needed when setting up the repo the first time. + +1. **Register the `com.lettr` namespace** at [central.sonatype.com](https://central.sonatype.com/) (requires DNS TXT verification). +2. **Generate a Central Portal user token**: click your name → **View Account** → **Generate User Token**. +3. **Generate a GPG key** for signing artifacts: + ```bash + gpg --gen-key + gpg --list-keys --keyid-format long + gpg --keyserver keyserver.ubuntu.com --send-keys YOUR_KEY_ID + ``` +4. **Add 4 secrets** to the 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 set during `gpg --gen-key` | + +## Publishing Locally (optional) + +Useful for dry-runs against the staging repository. + +```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 +``` + +This stages the artifact on the Central Portal but does not finalise it — log in to the portal to inspect or drop the staged deployment. + +## Troubleshooting + +- **Validation failed on the Central Portal** — missing POM metadata, missing Javadoc/sources JARs, or unsigned artifacts. Check the workflow log. +- **`401 Unauthorized` during publish** — the `CENTRAL_PORTAL_TOKEN_*` secrets are wrong or expired. Regenerate in the portal. +- **Finalisation step fails but publish succeeded** — the artifact is staged but not live. Log in to the Central Portal and publish manually, or re-run the workflow's finalise step. +- **Version already exists** — Maven Central is immutable. Bump the version and release again; you cannot overwrite. diff --git a/build.gradle b/build.gradle index 97bca5e..d69cb94 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,7 @@ repositories { dependencies { implementation 'com.google.code.gson:gson:2.11.0' + compileOnly 'com.google.code.findbugs:jsr305:3.0.2' testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/gradle.properties b/gradle.properties index 57ad7fe..936d180 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP=com.lettr -VERSION=0.1.0 +VERSION=0.2.0 POM_ARTIFACT_ID=lettr-java POM_NAME=Lettr Java SDK POM_DESCRIPTION=Java SDK for the Lettr Email API diff --git a/src/main/java/com/lettr/Lettr.java b/src/main/java/com/lettr/Lettr.java index f90cb93..5121abb 100644 --- a/src/main/java/com/lettr/Lettr.java +++ b/src/main/java/com/lettr/Lettr.java @@ -2,9 +2,13 @@ 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 javax.annotation.Nonnull; + /** * Main entry point for the Lettr Java SDK. * @@ -13,7 +17,6 @@ *
{@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 T post(String path, Object body, Type responseType) throws LettrExcep return execute(request, responseType); } + /** + * Perform a PUT request with a JSON body. + * + * @param path API path (e.g. "/webhooks/abc123") + * @param body request body object (will be serialized to JSON) + * @param responseType the type to deserialize the "data" field into + * @param response data type + * @return deserialized response data + * @throws LettrException on error + */ + public T put(String path, Object body, Type responseType) throws LettrException { + String url = buildUrl(path, null); + String jsonBody = gson.toJson(body); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(DEFAULT_TIMEOUT) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", USER_AGENT) + .PUT(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + return execute(request, responseType); + } + /** * Perform a DELETE request. * @@ -103,7 +130,18 @@ public T post(String path, Object body, Type responseType) throws LettrExcep * @throws LettrException on error */ public void delete(String path) throws LettrException { - String url = buildUrl(path, null); + delete(path, null); + } + + /** + * Perform a DELETE request with optional query parameters. + * + * @param path API path + * @param queryParams optional query parameters + * @throws LettrException on error + */ + public void delete(String path, Map queryParams) throws LettrException { + String url = buildUrl(path, queryParams); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) diff --git a/src/main/java/com/lettr/services/domains/Domains.java b/src/main/java/com/lettr/services/domains/Domains.java index 477c0ae..2bef762 100644 --- a/src/main/java/com/lettr/services/domains/Domains.java +++ b/src/main/java/com/lettr/services/domains/Domains.java @@ -6,35 +6,21 @@ import com.lettr.services.domains.model.CreateDomainResponse; import com.lettr.services.domains.model.Domain; import com.lettr.services.domains.model.ListDomainsResponse; +import com.lettr.services.domains.model.VerifyDomainResponse; + +import javax.annotation.Nonnull; /** * Service for managing sending domains via the Lettr API. - * - *

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 List nameservers; + private String error; + + @Nonnull public String getProvider() { return provider; } + @Nonnull public String getProviderLabel() { return providerLabel; } + @Nonnull public List getNameservers() { return nameservers; } + @Nullable public String getError() { return error; } + } + @Override public String toString() { - return "Domain{" + - "domain='" + domain + '\'' + - ", status='" + status + '\'' + - ", canSend=" + canSend + - '}'; + return "Domain{domain='" + domain + "', status='" + status + "', canSend=" + canSend + '}'; } } diff --git a/src/main/java/com/lettr/services/domains/model/ListDomainsResponse.java b/src/main/java/com/lettr/services/domains/model/ListDomainsResponse.java index 1c94948..e115d88 100644 --- a/src/main/java/com/lettr/services/domains/model/ListDomainsResponse.java +++ b/src/main/java/com/lettr/services/domains/model/ListDomainsResponse.java @@ -1,5 +1,6 @@ package com.lettr.services.domains.model; +import javax.annotation.Nonnull; import java.util.List; /** @@ -9,17 +10,11 @@ public class ListDomainsResponse { private List domains; - /** - * Returns the list of registered sending domains. - */ - public List getDomains() { - return domains; - } + /** Returns the list of registered sending domains. */ + @Nonnull public List getDomains() { return domains; } @Override public String toString() { - return "ListDomainsResponse{" + - "domains=" + domains + - '}'; + return "ListDomainsResponse{domains=" + domains + '}'; } } diff --git a/src/main/java/com/lettr/services/domains/model/VerifyDomainResponse.java b/src/main/java/com/lettr/services/domains/model/VerifyDomainResponse.java new file mode 100644 index 0000000..1424531 --- /dev/null +++ b/src/main/java/com/lettr/services/domains/model/VerifyDomainResponse.java @@ -0,0 +1,114 @@ +package com.lettr.services.domains.model; + +import com.google.gson.annotations.SerializedName; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Response from verifying a sending domain's DNS configuration. + */ +public class VerifyDomainResponse { + + private String domain; + + @SerializedName("dkim_status") + private String dkimStatus; + + @SerializedName("cname_status") + private String cnameStatus; + + @SerializedName("dmarc_status") + private String dmarcStatus; + + @SerializedName("spf_status") + private String spfStatus; + + @SerializedName("is_primary_domain") + private boolean isPrimaryDomain; + + @SerializedName("ownership_verified") + private String ownershipVerified; + + private DnsVerification dns; + private DmarcValidation dmarc; + private SpfValidation spf; + + @Nonnull public String getDomain() { return domain; } + /** {@code valid}, {@code unverified}, or {@code invalid}. */ + @Nonnull public String getDkimStatus() { return dkimStatus; } + /** {@code valid}, {@code unverified}, {@code invalid}, or {@code not_applicable}. */ + @Nonnull public String getCnameStatus() { return cnameStatus; } + /** {@code valid}, {@code invalid}, {@code missing}, or {@code unverified}. */ + @Nonnull public String getDmarcStatus() { return dmarcStatus; } + /** {@code valid}, {@code invalid}, {@code missing}, or {@code unverified}. */ + @Nonnull public String getSpfStatus() { return spfStatus; } + public boolean isPrimaryDomain() { return isPrimaryDomain; } + @Nullable public String getOwnershipVerified() { return ownershipVerified; } + @Nullable public DnsVerification getDns() { return dns; } + @Nullable public DmarcValidation getDmarc() { return dmarc; } + @Nullable public SpfValidation getSpf() { return spf; } + + /** DNS verification details. */ + public static class DnsVerification { + @SerializedName("dkim_record") private String dkimRecord; + @SerializedName("cname_record") private String cnameRecord; + @SerializedName("dkim_error") private String dkimError; + @SerializedName("cname_error") private String cnameError; + @SerializedName("dmarc_record") private String dmarcRecord; + @SerializedName("dmarc_error") private String dmarcError; + @SerializedName("spf_record") private String spfRecord; + @SerializedName("spf_error") private String spfError; + + @Nullable public String getDkimRecord() { return dkimRecord; } + @Nullable public String getCnameRecord() { return cnameRecord; } + @Nullable public String getDkimError() { return dkimError; } + @Nullable public String getCnameError() { return cnameError; } + @Nullable public String getDmarcRecord() { return dmarcRecord; } + @Nullable public String getDmarcError() { return dmarcError; } + @Nullable public String getSpfRecord() { return spfRecord; } + @Nullable public String getSpfError() { return spfError; } + } + + /** DMARC validation result. */ + public static class DmarcValidation { + @SerializedName("is_valid") private boolean isValid; + private String status; + @SerializedName("found_at_domain") private String foundAtDomain; + private String record; + private String policy; + @SerializedName("subdomain_policy") private String subdomainPolicy; + private String error; + @SerializedName("covered_by_parent_policy") private boolean coveredByParentPolicy; + + public boolean isValid() { return isValid; } + @Nonnull public String getStatus() { return status; } + @Nullable public String getFoundAtDomain() { return foundAtDomain; } + @Nullable public String getRecord() { return record; } + /** DMARC policy: {@code none}, {@code quarantine}, or {@code reject}. */ + @Nullable public String getPolicy() { return policy; } + @Nullable public String getSubdomainPolicy() { return subdomainPolicy; } + @Nullable public String getError() { return error; } + public boolean isCoveredByParentPolicy() { return coveredByParentPolicy; } + } + + /** SPF validation result. */ + public static class SpfValidation { + @SerializedName("is_valid") private boolean isValid; + private String status; + private String record; + private String error; + @SerializedName("includes_sparkpost") private boolean includesSparkpost; + + public boolean isValid() { return isValid; } + @Nonnull public String getStatus() { return status; } + @Nullable public String getRecord() { return record; } + @Nullable public String getError() { return error; } + public boolean isIncludesSparkpost() { return includesSparkpost; } + } + + @Override + public String toString() { + return "VerifyDomainResponse{domain='" + domain + "', dkimStatus='" + dkimStatus + "'}"; + } +} diff --git a/src/main/java/com/lettr/services/emails/Emails.java b/src/main/java/com/lettr/services/emails/Emails.java index 5c55e3b..4b73832 100644 --- a/src/main/java/com/lettr/services/emails/Emails.java +++ b/src/main/java/com/lettr/services/emails/Emails.java @@ -2,32 +2,19 @@ import com.lettr.core.exception.LettrException; import com.lettr.services.BaseService; -import com.lettr.services.emails.model.CreateEmailOptions; -import com.lettr.services.emails.model.CreateEmailResponse; -import com.lettr.services.emails.model.GetEmailResponse; -import com.lettr.services.emails.model.ListEmailsParams; -import com.lettr.services.emails.model.ListEmailsResponse; +import com.lettr.services.emails.model.*; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.Map; /** - * Service for sending and retrieving emails via the Lettr API. - * - *

Example:

- *
{@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); + Map params = null; + if (from != null || to != null) { + params = new LinkedHashMap<>(); + if (from != null) params.put("from", from); + if (to != null) params.put("to", to); + } + return httpClient.get("/emails/" + requestId, params, GetEmailResponse.class); + } + + /** + * Schedule an email for future delivery. + * + * @param options schedule email options including {@code scheduledAt} + * @return response containing the request ID and acceptance counts + * @throws LettrException if the request fails + */ + @Nonnull + public CreateEmailResponse schedule(@Nonnull ScheduleEmailOptions options) throws LettrException { + return httpClient.post("/emails/scheduled", options, CreateEmailResponse.class); + } + + /** + * Get details of a scheduled email transmission. + * + * @param transmissionId the transmission ID + * @return scheduled email details + * @throws LettrException if the request fails + * @throws IllegalArgumentException if {@code transmissionId} is null or empty + */ + @Nonnull + public ScheduledEmail getScheduled(@Nonnull String transmissionId) throws LettrException { + if (transmissionId == null || transmissionId.isEmpty()) { + throw new IllegalArgumentException("transmissionId is required"); + } + return httpClient.get("/emails/scheduled/" + transmissionId, null, ScheduledEmail.class); + } + + /** + * Cancel a scheduled email. + * + * @param transmissionId the transmission ID to cancel + * @throws LettrException if the request fails + * @throws IllegalArgumentException if {@code transmissionId} is null or empty + */ + public void cancelScheduled(@Nonnull String transmissionId) throws LettrException { + if (transmissionId == null || transmissionId.isEmpty()) { + throw new IllegalArgumentException("transmissionId is required"); + } + httpClient.delete("/emails/scheduled/" + transmissionId); } } diff --git a/src/main/java/com/lettr/services/emails/model/Attachment.java b/src/main/java/com/lettr/services/emails/model/Attachment.java index 302285e..9971bf9 100644 --- a/src/main/java/com/lettr/services/emails/model/Attachment.java +++ b/src/main/java/com/lettr/services/emails/model/Attachment.java @@ -1,8 +1,10 @@ package com.lettr.services.emails.model; +import javax.annotation.Nonnull; + /** * Represents a file attachment for an email. - * The file data must be base64 encoded. + * All three fields ({@code name}, {@code type}, {@code data}) are required. */ public class Attachment { @@ -16,21 +18,14 @@ private Attachment(Builder builder) { this.data = builder.data; } + @Nonnull public static Builder builder() { return new Builder(); } - public String getName() { - return name; - } - - public String getType() { - return type; - } - - public String getData() { - return data; - } + @Nonnull public String getName() { return name; } + @Nonnull public String getType() { return type; } + @Nonnull public String getData() { return data; } public static class Builder { private String name; @@ -40,29 +35,40 @@ public static class Builder { private Builder() {} /** - * Sets the filename of the attachment (e.g. "invoice.pdf"). + * (required) Sets the filename of the attachment (e.g. "invoice.pdf"). + * Max length: 255 characters. */ - public Builder name(String name) { + @Nonnull + public Builder name(@Nonnull String name) { this.name = name; return this; } /** - * Sets the MIME type of the attachment (e.g. "application/pdf"). + * (required) Sets the MIME type of the attachment (e.g. "application/pdf"). + * Max length: 255 characters. */ - public Builder type(String type) { + @Nonnull + public Builder type(@Nonnull String type) { this.type = type; return this; } /** - * Sets the base64-encoded file content. + * (required) Sets the base64-encoded file content (no line breaks). */ - public Builder data(String data) { + @Nonnull + public Builder data(@Nonnull String data) { this.data = data; return this; } + /** + * Builds the {@link Attachment} instance. + * + * @throws IllegalArgumentException if {@code name}, {@code type}, or {@code data} is missing + */ + @Nonnull public Attachment build() { if (name == null || name.isEmpty()) { throw new IllegalArgumentException("Attachment name is required"); diff --git a/src/main/java/com/lettr/services/emails/model/CreateEmailOptions.java b/src/main/java/com/lettr/services/emails/model/CreateEmailOptions.java index 108c33b..4be2a36 100644 --- a/src/main/java/com/lettr/services/emails/model/CreateEmailOptions.java +++ b/src/main/java/com/lettr/services/emails/model/CreateEmailOptions.java @@ -1,5 +1,7 @@ package com.lettr.services.emails.model; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -7,9 +9,10 @@ /** * Options for sending an email via the Lettr API. * - *

At 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