Conversation
|
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:
|
34ef4dd to
310904a
Compare
|
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). |
|
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.
|
|
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:
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 Remaining "No"s in the matrix (tracking-event webhooks, |
|
Two more features landed on the MailKite platform this week, and the backend now maps both — pushed to this PR in place: Click tracking ( Status tracking webhooks (
Suite is green (1353 tests), lint clean, live integration tests still pass against production. The feature matrix for MailKite is now Yes everywhere except Nothing further planned before your review — the PR should be stable from here. |
|
Hi @medmunds — gentle check-in, now that we're in late August. This PR is kept up to date in place: scheduled sending ( |
|
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
left a comment
There was a problem hiding this comment.
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ý textI'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?
|
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 reportedOur SES raw-MIME builder hardcoded the literal Fixed by choosing the CTE the content actually qualifies for and encoding to match. We went with quoted-printable rather than Two more of the same class turned up while fixing it:
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 fixedThanks for flagging it; it turned out to be a real and measurable defect. Reproduced and measured against production (warm, repeated calls):
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).
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 Review comments
Feature matrix updated ( |
|
Both issues you raised are fixed and deployed. Unicode CTEOur SES raw-MIME builder hardcoded Fixed: each part now carries the encoding it qualifies for — quoted-printable for 8-bit UTF-8, Two related bugs fell out of the same builder and are also fixed:
Send latencyNot 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
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 commentsAll addressed in the two commits pushed earlier: 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. |
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
1834b40 to
22fbfc3
Compare
|
Heads up: I force-pushed this branch ( Two reasons — Your No content changed — the diff over every MailKite file is identical to before the rebase. The only edit was resolving |


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.mdend-to-end: backend + payload, mock tests, docs + feature matrix, and all the boilerplate entries (pyproject, README, tox, CI matrix).How to configure
The
fromaddress must be on a domain whose ownership is verified in MailKite.What's supported
reply_toextra_headersheaders, string values)metadata/tagsX-Metadata/X-Tagsheaders — MailKite has no dedicated fields)attachmentscontent+ camelCasecontentType)template_id+merge_global_datatemplateId/templateData, server-rendered)esp_extraanymail_status{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. Globaltemplate_id+merge_global_datais the supported path.track_clicks/track_opens(no per-message toggle; configure at account/domain level)How I tested
tests/test_mailkite_backend.py) — standard email features, Anymail features, every unsupported feature, API error handling, recipient-status parsing (incl. responsestatuspass-through), session sharing, and missing-config errors. All pass.black/flake8/isortclean on the new files (matching the project's pre-commit config).send-requestschema field-for-field (correct endpoint, Bearer auth,to/ccarrays, single-stringreplyTo, string-valuedheaders, base64 attachmentcontentwith camelCasecontentType).tests/test_mailkite_integration.pyadds self-skipping live tests gated onANYMAIL_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 indocs/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.