Skip to content

feat: support the reworked bulk contact import (TPL-2105) - #8

Merged
voj-tech-j merged 1 commit into
mainfrom
feat/bulk-contacts-tpl-2105
Aug 14, 2026
Merged

feat: support the reworked bulk contact import (TPL-2105)#8
voj-tech-j merged 1 commit into
mainfrom
feat/bulk-contacts-tpl-2105

Conversation

@voj-tech-j

Copy link
Copy Markdown
Contributor

Tracks the API changes on lettr-api vojta/tpl-2105-contact-import-bulk (PR #412). Mirrors what shipped in lettr-php 2.5.0, lettr-node (#19), lettr-python (#8) and lettr-go (#7).

Everything is additive — code written against 1.4.0 keeps compiling and sends the exact same payloads.

What's in here

Per-contact bulk create. BulkCreateAudienceContactsOptions takes a second shape where each contact carries its own properties, lists and topic subscriptions:

BulkCreateAudienceContactsOptions.builder()
    .contacts(List.of(
        BulkAudienceContactRow.builder()
            .email("cara@example.com")
            .properties(Map.of("plan", "pro"))
            .build(),
        BulkAudienceContactRow.builder()
            .email("dan@example.com")
            .topic(AudienceTopicSubscription.optOut("01h-promos"))
            .build()))
    .listIds(List.of("01h-everyone"))
    .updateExisting(true)
    .build();

emails became optional so the builder can host the alternative; build() enforces that exactly one of the two is present. New types: BulkAudienceContactRow, AudienceTopicSubscription (with optIn / optOut), AudienceTopicSubscriptionState.

AudienceTopicSubscriptionState is deliberately separate from AudienceTopicDefaultSubscription — one is what a request should do with a topic, the other is how the topic behaves for a contact that says nothing. An optOut on an auto-subscribe topic suppresses the auto-subscription in the same request instead of needing a second call.

Bulk create reports what happened per row. BulkCreateAudienceContactsResponse gains getUpdated(), getErrorCount(), getErrors(), getContacts(), plus hasErrors() / getContactIds() / findIdFor(email). Ids come back in submission order, so chaining into bulkAttachToLists / bulkSubscribeToTopics needs no lookup.

Two things worth knowing, both covered by tests:

  • Partial success. A row that fails validation is skipped and reported in getErrors(); the rest of the batch still commits and the call still returns 201. Nothing throws — callers must check hasErrors().
  • getAlreadyExisted() and getUpdated() overlap by design. They answer different questions, so they don't sum to the row count: a contact that already existed and got attached to a list is counted in both.

The collection getters never return null, so the response also reads a pre-TPL-2105 body.

Bulk topic subscribe/unsubscribebulkSubscribeToTopics / bulkUnsubscribeFromTopics, mirroring the bulkAttachToLists / bulkDetachFromLists pair. Both process every contactIds × topicIds combination (up to 1000 × 50). The unsubscribe reuses the existing HttpClient.delete(path, body, type) overload that bulkDetachFromLists already relies on, so DELETE-with-a-body needed no plumbing.

Duplicate creates are now a 409, not a 500. create() throws ContactAlreadyExistsException, carrying the colliding getEmail(). It extends LettrApiException, so existing catch blocks keep working; a 409 with any other error code stays a plain LettrApiException.

⚠️ If your retry policy retries 5xx, duplicate creates are no longer retried — 409 here must not be retried. Any error mapping that names send_error for this endpoint should be corrected.

Notes

  • BulkAudienceContactError.getErrorCode() stays a raw String (with getCode() for the typed BulkAudienceContactErrorCode) so a code added server-side doesn't fail to parse — Gson would otherwise deserialize an unknown enum constant to null silently.
  • update_existing is a boxed Boolean left null when false, so a legacy payload serializes byte-identically. Pinned by a test asserting the exact JSON string.
  • Changelog entries are under a new [Unreleased] section; per RELEASING.md the gradle.properties version bump happens at release time, so no version files are touched here.
  • README is unchanged — it points at the hosted docs.

Test plan

  • ./gradlew clean test — 189 pass, including 12 new cases: the per-contact shape's serialization, the legacy payload's exact JSON, the either/or builder guard, deserializing the new response fields, omitted-field tolerance, partial success, an unknown per-row error code, both topic responses, and the exception hierarchy.

🤖 Generated with Claude Code

Adds the per-contact bulk create shape, the new bulk create response
fields, the two bulk topic endpoints, and the duplicate-create 409.

All additive: the builder now accepts either `emails` or `contacts` and
validates that exactly one is present, and the response's collection
getters never return null, so it also reads a pre-TPL-2105 body.

ContactAlreadyExistsException extends LettrApiException, so existing
catch blocks keep catching duplicates unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@voj-tech-j
voj-tech-j merged commit a9256f2 into main Aug 14, 2026
2 checks passed
@voj-tech-j
voj-tech-j deleted the feat/bulk-contacts-tpl-2105 branch August 14, 2026 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant