Skip to content

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

Merged
voj-tech-j merged 2 commits 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 2 commits 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 and lettr-node (#19).

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

What's in here

Per-contact bulk create. bulk_create() accepts a second shape where each contact carries its own properties, lists and topic subscriptions:

client.audience.contacts.bulk_create(
    contacts=[
        BulkContactRow(email="cara@example.com", properties={"plan": "pro"}),
        BulkContactRow(email="dan@example.com", topics=[TopicSubscription.opt_out("01h-promos")]),
    ],
    list_ids=["01h-everyone"],
    update_existing=True,
)

emails became optional so it can host the alternative; exactly one of the two must be given, and an empty call raises ValueError rather than round-tripping to the API. New types: BulkContactRow, TopicSubscription (with .opt_in() / .opt_out()), TopicSubscriptionState.

TopicSubscriptionState is deliberately separate from a topic's default_subscription — one is what a request should do with a topic, the other is how the topic behaves for a contact that says nothing. An opt_out 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. BulkContactImportResult gains updated, error_count, errors, contacts, plus has_errors / contact_ids / id_for(email). Ids come back in submission order, so chaining into bulk_attach_lists() / bulk_subscribe_topics() needs no lookup.

Two things worth knowing, both covered by tests:

  • Partial success. A row that fails validation is skipped and reported in errors; the rest of the batch still commits and the call still returns 201. Nothing raises — callers must check result.has_errors.
  • already_existed and updated 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 four new fields default when the API omits them, so the result also parses a pre-TPL-2105 body.

Bulk topic subscribe/unsubscribebulk_subscribe_topics() / bulk_unsubscribe_topics(), mirroring the bulk_attach_lists() / bulk_detach_lists() pair. Both process every contact_ids × topic_ids combination (up to 1000 × 50). The unsubscribe is a DELETE with a body; httpx handles that, as it already does for bulk_detach_lists().

Duplicate creates are now a 409, not a 500. create() raises ContactAlreadyExistsError, carrying the colliding .email. It subclasses ConflictError, so existing except ConflictError / except LettrError handlers keep working; a 409 with any other error code stays a plain ConflictError.

⚠️ 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

  • Changelog entries are under [Unreleased]; per RELEASING.md the version bump happens at release time, so no version files are touched here.
  • README is unchanged — it points at the hosted docs.

Test plan

  • pytest — 148 pass, including 10 new cases: the per-contact shape, the legacy flat payload going out byte-identical, defaulting when the API omits the new fields, partial success, the ValueError guard, the typed 409 (and a 409 with another code not being specialised), and both topic endpoints.
  • ruff check src tests
  • mypy src (strict)

🤖 Generated with Claude Code

voj-tech-j and others added 2 commits August 14, 2026 13:29
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: `emails` becomes optional on bulk_create() so it can host
the alternative `contacts` shape, and the four new result fields default
when the API omits them, so the result also parses a pre-TPL-2105 body.

ContactAlreadyExistsError subclasses ConflictError, so existing
`except ConflictError` handlers keep catching duplicates unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs `ruff format --check` alongside `ruff check`; three call sites
in the new code fit on one line under the 100-char limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@voj-tech-j
voj-tech-j merged commit 8c60984 into main Aug 14, 2026
7 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