Skip to content

feat: forward custom headers and send_to_unsubscribed to the SDK - #2

Merged
lukwam merged 1 commit into
mainfrom
feat/headers-and-send-to-unsubscribed
Aug 13, 2026
Merged

feat: forward custom headers and send_to_unsubscribed to the SDK#2
lukwam merged 1 commit into
mainfrom
feat/headers-and-send-to-unsubscribed

Conversation

@lukwam

@lukwam lukwam commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds headers and send_to_unsubscribed passthrough to send_email(), send_text() and send_html(). Both are already supported by the Customer.io API and by the underlying customerio SDK's SendEmailRequest_build_email_request just never forwarded them, so callers had no way to reach them.

Why

RFC 8058 one-click unsubscribe was impossible to send. Gmail and Yahoo only render their one-click unsubscribe button when a message carries both List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click. With no header support, notification-class mail could not meet their bulk-sender rules.

send_to_unsubscribed serves the same feature. When the opt-out of record lives in the caller's own database, Customer.io's global unsubscribed flag tracks marketing consent only, and must not suppress notification mail the user still wants. Setting it per send keeps that decision in reviewable code rather than a dashboard checkbox that's invisible from the repo.

Found while reviewing livedinc/lived#511, which needs exactly this to satisfy its ticket's "independently unsubscribable" acceptance criterion.

Design notes

  • bool | None, forwarded on is not None rather than truthiness. False is a real instruction from the caller, distinct from "leave it unset so the transactional message's own configuration applies". SendEmailRequest._to_dict() omits None fields, so that three-way distinction survives to the wire.
  • Threaded through all three public send methods. Supporting headers on template sends but not inline ones would be an arbitrary seam.
  • Docstring on send_email carries the RFC 8058 detail — both headers required, and the endpoint must answer an unauthenticated POST with a 2xx and no redirect. That's the part which is easy to get wrong.

Testing

  • 11 new tests (TestHeaders, TestSendToUnsubscribed) covering template and inline sends, multi-recipient fan-out, and the unset case.
  • Two of them assert on _to_dict() — the actual POST body — because accepting a kwarg is worthless if it isn't serialized. This pins the SDK contract.
  • Full local gate green: pre-commit run --all-files, 99 passed, coverage 90% (gate 80%), poetry build OK.

mypy src/ reports 2 errors, both pre-existing on main and untouched here (client.py:15 missing customerio stubs, client.py:44 no-any-return). mypy is configured in pyproject.toml but isn't in pre-commit or CI, so it isn't gating.

Version

Bumped to 0.3.0 with a CHANGELOG entry — additive, no breaking changes.

🤖 Generated with Claude Code

The Customer.io API accepts custom email headers and a per-send
`send_to_unsubscribed` override, and the underlying `customerio` SDK
exposes both on `SendEmailRequest` — but this wrapper's
`_build_email_request` forwarded neither, so callers had no way to reach
them.

The practical consequence was that RFC 8058 one-click unsubscribe was
impossible to send. Gmail and Yahoo only render their one-click
unsubscribe button when a message carries *both* `List-Unsubscribe` and
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`; with no header
support, notification-class mail could not meet their bulk-sender rules.

`send_to_unsubscribed` matters for the same feature. When the opt-out of
record lives in the caller's own database rather than in Customer.io,
Customer.io's global `unsubscribed` flag tracks marketing consent only,
and must not suppress notification mail the user still wants. Setting it
per send keeps that decision in reviewable code instead of a dashboard
checkbox invisible from the repo.

Typed `bool | None` and forwarded on `is not None` rather than
truthiness: `False` is a real instruction from the caller, and distinct
from "leave it unset so the transactional message's own configuration
applies". `SendEmailRequest._to_dict()` omits `None` fields, so that
distinction survives to the wire — pinned by a test asserting on the
serialized payload, not just the request attributes.

Both parameters are threaded through `send_email()`, `send_text()` and
`send_html()`; supporting headers on template sends but not inline ones
would have been an arbitrary seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lukwam
lukwam merged commit 47168d1 into main Aug 13, 2026
5 checks passed
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