Skip to content

Add MailKite email backend (transactional send) - #481

Open
bucabay wants to merge 11 commits into
anymail:mainfrom
bucabay:mailkite-backend
Open

bucabay wants to merge 11 commits into
anymail:mainfrom
bucabay:mailkite-backend

Conversation

@bucabay

@bucabay bucabay commented Jul 30, 2026

Copy link
Copy Markdown

What this adds

A new Anymail email backend for MailKite (mailkite.dev) — an inbound-first developer email platform (transactional send and parsed inbound over signed webhooks). This PR implements the transactional send API (POST /v1/send), modeled closely on the existing Resend and Postmark JSON backends.

It follows ADDING_ESPS.md end-to-end: backend + payload, mock tests, docs + feature matrix, and all the boilerplate entries (pyproject, README, tox, CI matrix).

How to configure

# settings.py
EMAIL_BACKEND = "anymail.backends.mailkite.EmailBackend"

ANYMAIL = {
    "MAILKITE_API_KEY": "<your mk_live_… key>",  # or MAILKITE_API_KEY at root
}

The from address must be on a domain whose ownership is verified in MailKite.

What's supported

Anymail feature MailKite
from / to / cc / bcc / subject / text / html ✅
reply_to ✅ (single string — multiple addresses are joined)
extra_headers ✅ (MailKite headers, string values)
metadata / tags ✅ (carried as JSON in X-Metadata / X-Tags headers — MailKite has no dedicated fields)
attachments ✅ (base64 content + camelCase contentType)
template_id + merge_global_data ✅ (→ templateId / templateData, server-rendered)
esp_extra ✅
anymail_status ✅ (from {id, status})

Raised as AnymailUnsupportedFeature (the send API genuinely can't express these):

  • envelope_sender, send_at (no scheduling field on /v1/send)
  • merge_data / merge_metadata / merge_headers — MailKite sends one message to all recipients (there's no per-recipient batch), so per-recipient merge can't be expressed. Global template_id + merge_global_data is the supported path.
  • inline attachments (the send API has no Content-ID field)
  • track_clicks / track_opens (no per-message toggle; configure at account/domain level)

How I tested

  • 43 mock tests (tests/test_mailkite_backend.py) — standard email features, Anymail features, every unsupported feature, API error handling, recipient-status parsing (incl. response status pass-through), session sharing, and missing-config errors. All pass.
  • Full suite green — 1308 tests, 0 failures, no regressions from the config/matrix changes (80 live-integration tests skip without creds, as expected).
  • black / flake8 / isort clean on the new files (matching the project's pre-commit config).
  • Payload verified against the published contract — the JSON this backend emits for a full-options message matches MailKite's documented send-request schema field-for-field (correct endpoint, Bearer auth, to/cc arrays, single-string replyTo, string-valued headers, base64 attachment content with camelCase contentType).

tests/test_mailkite_integration.py adds self-skipping live tests gated on ANYMAIL_TEST_MAILKITE_API_KEY / ANYMAIL_TEST_MAILKITE_DOMAIN, and the CI matrix + secret wiring are in place — you'd just add the secrets when you have a test account.

Scope & roadmap

This is send-only, intentionally. MailKite's headline capability is inbound (parsed body + auth verdict as one signed webhook) and delivery-status tracking — adding Anymail inbound (AnymailInboundEvent) and tracking (AnymailTrackingEvent) webhook handling is the natural follow-up. I've marked both "Not yet" in the feature matrix and noted it in docs/esps/mailkite.rst. Happy to tackle inbound next if there's interest.

MailKite has a free tier, so live integration testing is straightforward.

Maintenance

I'm with the MailKite team and can maintain this backend going forward — keeping it current with MailKite API changes, adding inbound/tracking support, and responding to issues.


This contribution was prepared by MailKite's development team (with AI assistance, as disclosed in the commit trailer). No promotional or referral content is included in the code or docs.

@medmunds

Copy link
Copy Markdown
Contributor

Thanks for the submission. People wanting support for MailKite in Anymail can use GitHub's 👍 reaction on the first post in this PR to express interest.

This seems to be a duplicate of PR #480 with some changes. Could you (or your agentic team) please close one of them?

I probably won't have a chance to review this closely until late August. In the meantime:

  • If there are any updates, please just update this PR in place (rather than opening a new one).
  • Please run the integration tests yourself, locally, using your own test account, and verify the email received. (It's helpful to temporarily change one of the to addresses in each integration test to your own email for this.)
  • Your agents seem to have some disagreement over whether scheduled sending is supported (it was in the other PR, but not in this PR). If your product capabilities are still evolving rapidly, it might be a good idea to let it stabilize a bit before adding django-anymail integration. (As a reminder, so long as MailKite supports SMTP, it can be used with Django even without django-anymail integration.)

@bucabay

bucabay commented Jul 31, 2026

Copy link
Copy Markdown
Author

Scheduled sending (send_at) is now supported and pushed to this PR in place. Integration tests were run locally against a live MailKite account with receipt verified (including a send_at message delivered on schedule).

@bucabay

bucabay commented Jul 31, 2026 •

Copy link
Copy Markdown
Author

Thanks for the review notes @medmunds. Updates, all pushed to this PR in place:

Duplicate: #480 is closed — this PR is the only one going forward.

Scheduled sending is now supported. The earlier disagreement was real: MailKite's /v1/send accepts scheduledAt, but at the time it rejected scheduled sends that carried custom headers — and this backend transports metadata/tags as X-Metadata/X-Tags headers, so I'd left send_at unsupported rather than ship a half-working feature. We've since removed that limitation on the platform side (headers are now persisted and replayed at fire time), so the backend now implements set_send_at (ISO 8601 scheduledAt, same serialization approach as the Resend backend) with no caveats. The API's "scheduled" response status is normalized to Anymail's "queued". Docs and the feature matrix are updated to match.

Integration tests: run locally against the live API with our production account, including with a to address temporarily pointed at my own inbox as you suggested. All pass. I verified receipt of the actual emails end-to-end: the immediate sends, and a send_at (+2 min) message that was delivered on schedule with subject, body, and the metadata/tags headers intact (SPF/DKIM pass on the receiving side). The scheduled-send integration test also exercises metadata + tags together with send_at to keep that combination covered.

On product stability: fair point. The send API surface this backend depends on (/v1/send, scheduledAt, templates, attachments) is the stable core and is contract-tested against our published JSON schemas, so I don't expect further churn here. Happy to hold further changes until your review — no rush on our end, late August is fine.

Below are integration tests, the screenshots in Gmail and raw email headers/body.

Screenshot 2026-07-31 at 3 45 13 PM Screenshot 2026-07-31 at 3 44 50 PM

Anymail MailKite scheduled-send integration test.eml

Anymail MailKite integration test.eml

@bucabay

bucabay commented Jul 31, 2026

Copy link
Copy Markdown
Author

Thanks for the earlier guidance @medmunds — a status update on everything pushed to this PR in place since (no new PRs; #480 stays closed).

Feature support has filled out considerably. Since your review note, MailKite's send API gained the pieces that were missing, and the backend now maps them:

  • Scheduled sending (send_at → scheduledAt, ISO 8601, same serialization approach as the Resend backend). The API's "scheduled" status normalizes to "queued". No caveats — custom headers (which this backend uses for metadata/tags) are carried on scheduled sends.
  • Inbound webhook (MailKiteInboundWebhookView at anymail/mailkite/inbound/): HMAC-SHA256 signature verification (X-MailKite-Signature, timestamp tolerance, new MAILKITE_WEBHOOK_SECRET setting), mapping MailKite's pre-parsed JSON (no raw MIME) onto AnymailInboundMessage — envelope, display names, bodies, attachments (signed-URL and inlined-base64 variants), spam verdict → spam_detected, SPF/DKIM/DMARC via esp_event. Unknown event types are ignored for forward compatibility.
  • track_opens as a per-message override of the domain default (click tracking remains unsupported and documented as such).
  • Batch sending: merge_data, merge_metadata, and merge_headers now switch to MailKite's batch endpoint (POST /v1/send/batch) — one personalized message per to recipient, per-recipient message ids, partial-success reporting (rejected for suppressed addresses), max 50 recipients per batch, cc/bcc not combinable with batch. merge_global_data stays the shared default; per-recipient values win key-by-key.

Testing. Full suite passes locally (1337 tests, Python 3.12 / Django 6.0); lint clean. Integration tests run against the live API with our production account — including, per your suggestion, with a to address temporarily pointed at my own inbox — and I verified actual receipt end-to-end for each path: immediate sends, a send_at (+2 min) message delivered on schedule with headers intact, and a live batch received as two individual messages with correct per-recipient template rendering (SPF/DKIM pass on the receiving side). The inbound webhook tests use the exact payload shape MailKite's published email.received JSON schema and webhook-test endpoint produce, and the batch shape is contract-tested against our published schemas across nine SDK languages, so the surface this backend depends on is stable.

Remaining "No"s in the matrix (tracking-event webhooks, track_clicks, envelope_sender, AMP) reflect genuine ESP scope and are documented per row. Happy to adjust anything for the review — late August is fine on our end.

@bucabay

bucabay commented Aug 1, 2026

Copy link
Copy Markdown
Author

Two more features landed on the MailKite platform this week, and the backend now maps both — pushed to this PR in place:

Click tracking (track_clicks). MailKite added opt-in click tracking (links rewritten to a signed redirect that records the click and 302s to the original URL; mailto:/tel:/anchors never rewritten). The backend maps track_clicks to the per-send trackClicks override, exactly like track_opens. Verified live: a tracked send's links were rewritten in the received message, the redirect resolved to the exact original URL (query string intact), and the click was recorded.

Status tracking webhooks (AnymailTrackingEvent). MailKite now emits signed engagement events — email.sent, email.bounced, email.complained, email.opened, email.clicked — to an opt-in per-domain tracking webhook (documented at https://mailkite.dev/api/webhooks). New MailKiteTrackingWebhookView at anymail/mailkite/tracking/:

  • Same signature scheme and account secret as the inbound webhook, so the one MAILKITE_WEBHOOK_SECRET setting covers both views (shared validation refactored into a base class).
  • Bounces map to reject_reason: bounced with the DSN diagnostic in mta_response; complaints to spam; clicks carry click_url; opens/clicks carry user_agent. Unknown future event types normalize to unknown rather than erroring, and email.delivered (reserved by MailKite) is mapped ahead of time.
  • Documented caveat: bounce/complaint events originate from provider notifications and carry a null message_id — recipient is the reliable key for those. MailKite's scanner/proxy machine-flags on opens and clicks ride along in esp_event.
  • Swapped inbound/tracking URLs raise a helpful AnymailConfigurationError in both directions.

Suite is green (1353 tests), lint clean, live integration tests still pass against production. The feature matrix for MailKite is now Yes everywhere except envelope_sender and AMP — both genuine ESP scope, documented on the ESP page. The tracking-event payloads are contract-tested against MailKite's published JSON schema (https://api.mailkite.dev/v1/schemas/tracking-event.json), so the shape this webhook view depends on is pinned.

Nothing further planned before your review — the PR should be stable from here.

@bucabay bucabay mentioned this pull request Aug 3, 2026
@bucabay

bucabay commented Aug 27, 2026

Copy link
Copy Markdown
Author

Hi @medmunds — gentle check-in, now that we're in late August. This PR is kept up to date in place: scheduled sending (send_at) is supported, the integration tests were run locally against a live account with receipt verified, and #480 stays closed so this is the only PR going forward. No rush — happy to make any changes whenever you've had a chance to look.

@medmunds

Copy link
Copy Markdown
Contributor

Hi @bucabay. I'm slowly working through backlog from before my holiday. This is on the list, but there are a bunch of other things before it.

@medmunds medmunds left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @bucabay, I appreciate your patience. I've set up an Anymail test account on MailKite and explored the APIs a bit.

I pushed a couple of commits to this PR:

  • Merged in current main branch
  • Added a workaround for non-ASCII custom header values (and a test for non-ASCII headers in general)

And I've left review comments on some other things that need attention.

Incidentally, while testing international email support, I noticed a bug with Unicode bodies: MailKite seems to send 8bit utf-8 content (and correctly identifies the charset in the Content-Type), but incorrectly sets the CTE to 7bit. This may cause messages to be rejected or corrupted depending on the MTAs along the delivery path:

--mailkite-alt-kddxyvcz
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: 7bit

Prostý text

I'm guessing that's something you'll want to fix in MailKite's API, but if not we should probably note it in the "limitations and quirks" section of Anymail's docs.

Finally, send API calls seem to take several seconds (4000-8000+ ms). Is that expected?

Comment thread anymail/backends/mailkite.py
Comment thread anymail/backends/mailkite.py Outdated
Comment thread anymail/backends/mailkite.py Outdated
Comment thread anymail/backends/mailkite.py Outdated
Comment thread anymail/webhooks/mailkite.py Outdated
Comment thread docs/esps/mailkite.rst Outdated
Comment thread docs/esps/mailkite.rst
Comment thread docs/esps/mailkite.rst Outdated
Comment thread tests/test_mailkite_webhooks.py
@bucabay

bucabay commented Sep 13, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review — and for setting up an account and digging into the APIs. That found two real bugs on our side, not just doc issues. All review comments are addressed in the two commits just pushed.

The Unicode CTE bug — you were right, and it was worse than reported

Our SES raw-MIME builder hardcoded the literal 'Content-Transfer-Encoding: 7bit' on all six of its body-part branches while emitting the body string verbatim. A part may claim 7bit only when every octet is < 128 and no line exceeds 998 octets (RFC 2045 §6.2, RFC 5322 §2.1.1). Our self-hosted-relay builder had the same bug implicitly, by omitting the header — which RFC 2045 §6.1 defaults to 7bit.

Fixed by choosing the CTE the content actually qualifies for and encoding to match. We went with quoted-printable rather than 8bit: it is 7-bit clean so no hop needs 8BITMIME, and it folds over-long lines as a side effect (a minified HTML body is one line of several thousand octets — already illegal regardless of charset, which we hadn't noticed either). Pure-ASCII bodies still go out as 7bit with content untouched.

Two more of the same class turned up while fixing it:

  • Unencoded 8-bit header values — the thing you worked around in 6c2ba95. Confirmed on the wire: SES silently re-encodes Subject for us, which is why only custom headers showed it. Now RFC 2047 encoded server-side, so your workaround is redundant (harmless, though — it only touches non-ASCII values, and an already-encoded ASCII value passes through untouched). Non-ASCII attachment filenames now use RFC 2231, per your note in test_attachments.
  • A header-injection hole. A header value containing CR/LF went straight into the header block, so a caller-supplied header could append arbitrary headers — or, after a blank line, a body. Now sanitized.

These are committed with tests on our side. I'll follow up here once they're live in production so you can re-test — the fix isn't deployed as I write this, and I'd rather you not re-test against the old behaviour and conclude nothing changed.

Send latency — no, 4–8s is not expected, and it's largely fixed

Thanks for flagging it; it turned out to be a real and measurable defect. Reproduced and measured against production (warm, repeated calls):

Probe Total Adds
Unauthed 404 route ~0.55s Network + edge (TLS connect is 0.18s of it)
GET /api/domains ~1.28s + auth and one read — two DB statements
POST /v1/send with scheduledAt ~2.3s + the gate chain, no provider call
POST /v1/send immediate 3.0–3.6s + the synchronous SES SendEmail

The useful number is the second row: two DB reads cost ~0.73s over the bare 404, so one database round trip from this Worker costs 0.2–0.35s — our D1 primary isn't in the colo serving the request. That makes the number of serial statements the dominant term, not the cost of any single query.

Looking at it with that lens, the send path was issuing 13 reads in 11 serial round trips — and four of those were the same user row, four the same domain row, because each gate independently re-fetched them. Now fixed two ways: a per-request read memo, and overlapping the gates that have no dependency on each other (each still awaited at its original gate, so which error a blocked caller sees is unchanged).

Reads Serial round trips
Before 13 11
After 8 5

At the measured per-hop cost, six fewer serial hops should be worth well over a second. I'll post real numbers once it's deployed rather than quote the arithmetic.

Two things that remain: our Worker bundle is 16 MB, 12 MB of which is a docs corpus embedded for an internal agent — that's a cold-start cost and the likely explanation for the slow end of your 4000–8000ms range; it needs a separate change. And ~0.8–1.3s is SES's own SendEmail, which we await because the response carries a real provider message id.

Review comments

  • f-strings — done, including the Bearer header.

  • metadata — now uses the metadata API field, verified round-tripping through GET /api/messages/:messageId. Documented that it can be retrieved from a tracking-webhook handler, with your note that this needs an account-level key. No auto-retrieval in the webhook view, per your recommendation. Treated as unsupported in restructure_data_for_batch().

  • tags — now unsupported. Agreed: a custom header gives neither analytics segmentation nor a webhook value, so it was pretending to support something it didn't.

  • merge_metadata — now unsupported, same reason.

  • Combined webhook view — added MailKiteCombinedWebhookView at anymail/mailkite/, and made it what the docs recommend. The event→AnymailEvent conversions moved into mixins shared with the split views.

    One correction: MailKite does support more than one webhook per domain. There's a separate per-domain tracking-webhook URL (the split layout, which is what the paired views were written for) and a single-URL mode where engagement events are opted into the domain's main webhook. So I've kept all three views — combined for the single-URL default, and the pair for the split layout — rather than dropping working configurations. Happy to delete the split pair if you'd still rather carry one view; it's a small change.

  • API key override in esp_extra — you're right, that was never implemented. Claim removed.

  • Domain-scoped API key — good call; now recommended, noting that an account-level key is needed only for account-wide APIs like retrieving a message.

  • Single reply-to — removed. Multiple reply-to addresses work, so it isn't a quirk.

  • test_mailkite_tracking.py → test_mailkite_webhooks.py.

Feature matrix updated (tags and merge_metadata → No, metadata → caveated). Integration tests pass against a live account, including the batch and scheduled-send cases. The 8 test_amazon_ses_inbound errors in a full local run are a missing optional dependency in my environment, unrelated to this branch.

@bucabay

bucabay commented Sep 14, 2026 •

Copy link
Copy Markdown
Author

Both issues you raised are fixed and deployed.

Unicode CTE

Our SES raw-MIME builder hardcoded Content-Transfer-Encoding: 7bit on every body part while emitting the body verbatim. A part can only claim 7bit when every octet is ASCII and no line exceeds 998 octets, so any 8-bit body was mislabelled.

Fixed: each part now carries the encoding it qualifies for — quoted-printable for 8-bit UTF-8, 7bit only when the content genuinely is. Verified end-to-end against production; the body decodes cleanly, so label and content agree.

Two related bugs fell out of the same builder and are also fixed:

  • Custom header values weren't RFC 2047 encoded — the thing you worked around in 6c2ba95. X-Custom: Prostý now goes out as =?UTF-8?B?UHJvc3TDvQ==?=. Your workaround is no longer needed, but it's harmless if you'd rather keep it — it only touches non-ASCII values, and an already-encoded value passes through untouched.
  • Non-ASCII attachment filenames now use RFC 2231, per your note in test_attachments.

Send latency

Not expected, and a real defect. The gate chain was making 11 serial database round trips, four of them re-reading the same user row and four the same domain row. Now 5, by memoizing those reads per request and overlapping the gates that don't depend on each other.

Profiled on POST /v1/send after the fix (n=10):

Before After
p50 3.0–3.6s 0.97s
p95 — 1.06s
Gate chain only (scheduled send, no provider call) 2.30s 0.53s

Conditions were better when I re-measured — an endpoint I didn't touch also improved — so not all of that delta is the fix. Isolating by time above baseline, the gate chain went +1.75s → +0.42s. The condition-independent number is the round-trip count, 11 → 5, which now has a test budgeting it.

Most of the remaining second is the synchronous SES call, which we await because the response carries a real provider message id.

Review comments

All addressed in the two commits pushed earlier: metadata moved to MailKite's API field (unsupported for batch), tags and merge_metadata now unsupported, combined webhook view added, docs corrections, f-strings, test file renamed. Integration tests pass against a live account.

Thanks for pushing on the MIME issues — Gmail accepts the mislabelled message without complaint, so nothing surfaced it until you read the raw source.


Edit: made this comment more concise.

Gabe (MailKite) and others added 11 commits September 16, 2026 02:47
Add an Anymail backend for MailKite (mailkite.dev), an inbound-first
developer email platform. This implements the transactional send API
(`POST /v1/send`), modeled on the existing Resend/Postmark JSON backends.

What's included:
- anymail/backends/mailkite.py: EmailBackend + MailKitePayload
  (AnymailRequestsBackend). Bearer auth, JSON payload, single send
  endpoint (no batch variant -- `to` is an array on one send).
- Supported: from/to/cc/bcc, subject, text+html, reply_to (single string),
  extra headers, metadata & tags (carried as JSON in X-Metadata/X-Tags
  headers, since MailKite has no dedicated fields), attachments
  (base64 `content` + camelCase `contentType`), template_id +
  merge_global_data (-> templateId/templateData), esp_extra, recipient
  status parsing from {id, status}.
- Unsupported (raised as AnymailUnsupportedFeature): envelope_sender,
  send_at (no scheduling field on /v1/send), merge_data/merge_metadata/
  merge_headers (single send, no per-recipient batch), inline attachments
  (no Content-ID field), track_clicks/track_opens (no per-message toggle).
- tests/test_mailkite_backend.py: 43 mock tests covering standard email
  features, Anymail features, unsupported features, error handling,
  recipient-status parsing, session sharing, and config errors.
- tests/test_mailkite_integration.py: live integration tests
  (self-skip without ANYMAIL_TEST_MAILKITE_API_KEY/DOMAIN).
- docs/esps/mailkite.rst + index/feature-matrix entries.
- Boilerplate: pyproject.toml (description/keywords/[mailkite] extra),
  README ESP list, tox.ini (ANYMAIL_ONLY_TEST=mailkite),
  integration-test.yml matrix entry + secret wiring.

Scope: transactional send only. MailKite's inbound webhook and delivery
tracking support are a planned follow-up (the ESP is inbound-first, so
inbound is the natural next Anymail addition).

Tested: 43 mailkite mock tests pass; full suite (1308 tests) green with
no regressions; black/flake8/isort clean; JSON payload verified against
MailKite's published send-request contract.

Co-authored-by: Claude <noreply@anthropic.com>
MailKite's /v1/send accepts a scheduledAt parameter (ISO 8601 or
ms-epoch): a future time parks the message with the scheduler and
responds 202 {id: "ssnd_...", status: "scheduled"}; a past or omitted
time sends immediately.

- Add MailKitePayload.set_send_at, serializing datetimes as ISO 8601
  (same approach as the Resend backend; strings pass through as-is).
- Normalize the "scheduled" response status to Anymail's "queued" in
  parse_recipient_status.
- Docs: mark send_at supported (with caveat) in the feature matrix and
  ESP page. Caveat: MailKite doesn't yet accept custom headers on
  scheduled sends, and this backend carries metadata/tags as headers,
  so send_at can't be combined with metadata, tags, or extra_headers
  (the API rejects with a clear 400).
- Tests: send_at serialization cases + scheduled-response status
  normalization (mock); scheduled-send live integration test.

Verified live against api.mailkite.dev: all integration tests pass,
and both an immediate and a send_at (+2 min) message were confirmed
received (SPF/DKIM pass) with intact content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MailKite now carries custom headers on scheduled sends, so send_at
combines freely with metadata, tags, and extra headers. Drop the
documented caveat and exercise metadata/tags in the scheduled-send
integration test. Verified live: a send_at message with X-Metadata,
X-Tags, and an extra header was delivered on schedule with all
headers intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Inbound: add MailKiteInboundWebhookView handling MailKite's
email.received events at anymail/mailkite/inbound/. Verifies the
X-MailKite-Signature header (HMAC-SHA256 over "{t}.{body}", 5-minute
timestamp tolerance, matching MailKite's own SDK verifiers) against a
new MAILKITE_WEBHOOK_SECRET setting. MailKite delivers pre-parsed JSON
(no raw MIME): decoded subject/text/html, envelope sender/recipient,
display names, SPF/DKIM/DMARC/spam verdicts, and attachments as either
signed URLs (fetched on receipt) or inlined base64 (zero-retention /
encrypted domains) — mapped onto AnymailInboundMessage, with the spam
verdict driving spam_detected and the full event in esp_event. Unknown
event types are ignored (200, no signal) for forward compatibility.

track_opens: /v1/send accepts a per-message trackOpens override of the
sending domain's open-tracking default (HTML only) — map it instead of
raising unsupported. (Click tracking remains unavailable.)

Payload shape verified against MailKite's published email.received
JSON schema and the live API's own webhook test event. 21 new inbound
tests; full suite passes (1331 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…_headers)

MailKite now has a batch-send endpoint (POST /v1/send/batch): one
personalized message per recipient, with per-recipient templateData
and headers merged over the shared base (per-recipient wins). Map
Anymail's batch attributes onto it:

- Setting merge_data, merge_metadata, or merge_headers switches the
  payload to the batch endpoint; each `to` recipient becomes a
  recipients[] entry and gets an individual message (and message_id).
- merge_data -> per-recipient templateData (merge_global_data stays
  the shared templateData default).
- merge_metadata -> per-recipient X-Metadata header carrying
  `metadata` updated with the recipient's entry.
- merge_headers -> per-recipient headers (shared extra headers stay
  shared; per-recipient wins key-by-key).
- Batch responses parse per-recipient: sent -> sent, scheduled ->
  queued, failed -> failed (recipient_suppressed -> rejected), with
  partial success reported through anymail_status rather than raised.
- cc/bcc can't combine with a batch send (one message per recipient);
  raises AnymailUnsupportedFeature. MailKite caps a batch at 50
  recipients.

Docs + feature matrix updated (merge_* now Yes with caveats). Mock
tests for all merge attributes, partial failure, scheduling, and the
cc/bcc restriction; live batch integration test passes, and a live
batch was verified received with correct per-recipient rendering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MailKite now supports opt-in click tracking: with trackClicks on, it
rewrites http(s) links to a signed redirect that records the click and
302s to the original URL (mailto:/tel:/anchors untouched; scanner
clicks flagged so they can be excluded from click counts). Map
Anymail's track_clicks onto the per-send trackClicks override, same
shape as track_opens. Docs and feature matrix updated.

Verified live: a tracked send's links were rewritten in the received
message, the redirect resolved to the exact original URL, and the
click was recorded with client/OS/device attribution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MailKite now emits signed engagement events for outbound mail —
email.sent / email.bounced / email.complained / email.opened /
email.clicked — to an opt-in per-domain tracking webhook (a separate
URL from the inbound webhook, so inbound consumers never receive event
types they don't expect). Add MailKiteTrackingWebhookView at
anymail/mailkite/tracking/:

- Shared signature validation with the inbound view (same account
  secret and x-mailkite-signature scheme → one MAILKITE_WEBHOOK_SECRET
  setting covers both), refactored into MailKiteBaseWebhookView.
- Event mapping: sent/delivered/bounced/complained/opened/clicked →
  Anymail normalized types (email.delivered is reserved by MailKite
  but mapped ahead of time); bounces carry the DSN diagnostic as
  mta_response with reject_reason bounced; complaints report spam;
  clicks carry click_url; opens/clicks carry user_agent; unknown
  future types map to "unknown" rather than erroring.
- Bounce/complaint events originate from provider notifications and
  carry a null message_id — recipient is the reliable key (documented).
- Swapped-URL misconfiguration hints in both directions (inbound event
  on the tracking URL and vice versa raise AnymailConfigurationError).
- 13 new tracking tests; docs section; feature matrix tracking row
  flips to Yes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MailKite fails to apply rfc2047 encoding to custom header values.
Work around by applying it before calling the API.

(The API correctly rfc2047 encodes Subject and address headers.)
- metadata: use MailKite's `metadata` API field (stored with the message and
  returned by its get message API) instead of an X-Metadata header. Header
  values aren't retrievable from the API or webhooks, so the header carried it
  nowhere useful. Treated as unsupported for batch sends, which have no
  metadata field.
- tags: now unsupported. A custom header gives neither ESP-side analytics
  segmentation nor a value in tracking webhooks, which is what tags are for.
- merge_metadata: now unsupported, for the same reason as batch metadata.
- webhooks: add MailKiteCombinedWebhookView at `mailkite/`, handling inbound
  and tracking events on one URL (MailKite's default setup). The event-to-
  AnymailEvent conversions move into mixins shared with the split
  inbound/tracking views, which stay for MailKite's separate-tracking-URL mode.
- docs: drop the claim that esp_extra can override the API key (it can't);
  recommend a domain-scoped key; drop the single-reply-to "quirk" (multiple
  reply-to addresses work, so it isn't one); document how to retrieve metadata
  from a tracking webhook handler; document both webhook layouts.
- f-strings in place of %-formatting.
- Rename test_mailkite_tracking.py to test_mailkite_webhooks.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PNvtuw9HcTsqRfWmCYsB6m
Verified against a live MailKite account (all 4 tests pass).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PNvtuw9HcTsqRfWmCYsB6m
@bucabay

bucabay commented Sep 16, 2026

Copy link
Copy Markdown
Author

Heads up: I force-pushed this branch (1834b40 → 22fbfc3).

Two reasons — main requires verified signatures and none of my commits were signed, and the branch had fallen 2 commits behind. So I rebased onto 787c7ed with SSH signing enabled. All 11 commits now verify.

Your 6c2ba95 is preserved as 8cf5c77, still authored by you. Your merge commit 37aae1c is gone, since the rebase supersedes it and main is linear anyway.

No content changed — the diff over every MailKite file is identical to before the rebase. The only edit was resolving anymail/urls.py, where Mailtrap's inbound webhook had landed in the same spot as MailKite's; both are registered now. Checks are green.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants