MailKite
Get your API key
All guides

Send newsletters with Python

Reviewed

Build a Python newsletter job that takes confirmed subscribers through contact import, list selection, broadcast draft, scheduling, and cancellation. The result is a testable campaign lifecycle rather than a loop around individual sends. MailKite, which we build, handles broadcast fan-out and unsubscribe insertion; your application keeps consent and preferences. The companion script defaults to a local mock and sends no email.

python3 -m venv .venv
. .venv/bin/activate
python -m pip install mailkite-dev==0.20.0
# Download newsletter.py and test_newsletter.py into this directory.
python newsletter.py
python newsletter.py --schedule-cancel
python -m unittest -v test_newsletter.py

Download the Python worker and tests. Use Python 3.11 or newer for this example. The distribution is named mailkite-dev; the import is from mailkite import MailKite. Both registries and repository manifests showed version 0.20.0 when checked on October 14, 2026.

Connect a verified account

Use an existing account whose email has been verified. Configure an outbound-verified sender domain using Domains & DNS, a monitored reply address, and your real physical mailing address. A newsletter needs a subject, body, eligible audience, and footer address before the server will schedule or send it. Draft creation alone does not establish that those checks pass.

The shared API spec marks list, broadcast, and suppression methods as Session token operations. Current auth middleware also accepts unrestricted account API keys, but the management routes additionally require full account scope. A domain-scoped sending key cannot manage these resources. Keep management auth and sending auth distinct, even though both travel as bearer credentials.

import os
from mailkite import MailKite

management = MailKite(accessToken=os.environ["MAILKITE_MANAGEMENT_TOKEN"])
# Separate application sending credential, only if that worker needs it:
# sending = MailKite(os.environ["MAILKITE_API_KEY"])

The demo supports two ways to obtain management auth. Supply an existing session/OAuth access token as MAILKITE_MANAGEMENT_TOKEN, or supply MAILKITE_ACCOUNT_EMAIL and MAILKITE_ACCOUNT_PASSWORD for an account with password login. In the second case it calls MailKite("").request("POST", "/api/auth/login", body), requires a verified account in the response, and keeps the returned token in memory. No registration or welcome email occurs.

For Google-only accounts and customer-owned integrations, follow server-side OAuth. Register the callback with registerOauthClient, retain random state and an S256 PKCE verifier in your server session, redirect to authorization, validate the returned state, and exchange the code with exchangeOauthToken. Store access and refresh tokens in your server secret store. A long-running worker can construct MailKite(getToken=...) to obtain the current token per request. Do not put API keys, passwords, or refresh tokens in browser code.

The local fixture contains an eligible subscriber, a case-variant duplicate, an opted-out subscriber, and a suppressed subscriber. Its addresses are placeholders used only by the mock. In your app, produce equivalent records from confirmed signups, including a confirmation timestamp and subscription purpose. A contact discovered through inbound mail has not automatically requested a newsletter.

The worker normalizes email addresses, builds a set of withdrawn-consent addresses, and lets withdrawal override older positive entries. It then reads management.listSuppressions()["suppressions"] and excludes those addresses before writing contacts. Existing inactive contacts are skipped. These checks are separate: consent means permission for this subscription, while account suppression means the transport must not send to the address at all.

The SDK intentionally shares camelCase method names with Node. It has createList and createBroadcast; it does not currently have an upsertContact method. Nor does the shared spec have a contact-write request schema. Source inspection confirms the server’s contact routes, so the demo uses the SDK request method for this narrow extension:

contact = management.request("POST", "/api/contacts", {
    "email": subscriber["email"], "name": subscriber["name"],
})
if contact["status"] == "active":
    management.request("PATCH", f'/api/contacts/{contact["id"]}', {
        "subscribed": True,
    })

The server returns contact fields such as first_name and integer subscribed. Do not pass guessed newsletter flags to contact creation. Also avoid status: "active" during routine import: the update handler removes suppression when explicitly reactivating a contact. A fresh affirmative preference change deserves its own reviewed workflow, not a side effect of an import.

For an authenticated preference withdrawal, save the change in your app. management.addSuppression({"email": email, "reason": "unsubscribe"}) records an account-wide stop and marks the contact unsubscribed; it affects other outbound mail too. Use removeListContact(list_id, contact_id) when the preference concerns only that list. A test withdraws consent after creating a draft and proves scheduling is rejected. The mock mirrors the real server’s empty-audience precheck.

Build the list and draft

Create a dedicated list, add the eligible returned contact IDs, and inspect its members. This keeps the test audience separate from every other contact in your account.

audience_list = management.createList({"name": "Product notes test"})
management.addListContacts(audience_list["id"], {"contactIds": contact_ids})
members = management.listListContacts(audience_list["id"])
draft = management.createBroadcast({
    "from": os.environ["MAILKITE_FROM"],
    "replyTo": os.environ["MAILKITE_REPLY_TO"],
    "subject": "Product notes for {{first_name}}",
    "text": "Hello {{first_name}}, here are this week's product notes.",
    "html": "<p>Hello {{first_name}}, here are this week's product notes.</p>",
    "audience": {"type": "list", "id": audience_list["id"]},
    "footerAddress": os.environ["MAILKITE_POSTAL_ADDRESS"],
})
preview = management.getBroadcast(draft["id"])

This excerpt shows the lifecycle calls; the download defines the subscriber filtering and ID collection. listListContacts returns a bare array. The demo fits one page. For a larger job, paginate using before and limit, batch imports deliberately, and persist their progress rather than assuming that an SDK call drains an unlimited audience.

Inspect the returned draft’s content and audience before approval. This preview is stored data, not a rendered inbox screenshot. Standard merge fields are filled on the server’s broadcast path; verify the final personalized copy using the controlled live inbox. Both plaintext and HTML are supplied. MailKite inserts the unsubscribe footer and one-click headers when sending the broadcast.

Exercise scheduling and cancellation

--schedule-cancel schedules one day ahead using a timezone-aware ISO timestamp, reads the campaign state, and cancels it. sendBroadcast(id, {"scheduledAt": when}) returns a status and camelCase audienceCount. Broadcast reads expose snake_case fields including scheduled_at, sent_count, and fail_count.

The server implements POST /api/broadcasts/:id/cancel, but the shared spec and SDK have no named cancel method. The example calls management.request("POST", path, {}). The source handler accepts only scheduled campaigns and returns them to draft with the schedule cleared. This extension was verified in repository source, not against production. If your deployment doesn’t expose it, stay in draft mode until you confirm the interface. Deleting a campaign is not the cancellation contract.

Live mode requires the credential and MAILKITE_TEST_DOMAIN, MAILKITE_TEST_RECIPIENT, MAILKITE_FROM, MAILKITE_REPLY_TO, and MAILKITE_POSTAL_ADDRESS. Use one receiving test-* inbox on a domain you control. The script rejects example domains. --live makes a real draft; --live --send explicitly sends only to that inbox. Scheduling with --live --schedule-cancel briefly creates a real pending send. A crash can leave it scheduled, so inspect and cancel it immediately through the dashboard if necessary.

Operate the job honestly

The unit tests cover suppression exclusion, duplicate collapse, withdrawal precedence, existing-unsubscribe preservation, draft-only defaults, and schedule cancellation. A mocked urlopen also checks the real SDK’s login body and management Authorization header. No tests send mail or establish delivery rates.

For Django or Celery, persist campaign IDs and job status before handing work to another process. The demo does not implement durable newsletter retries. If create or send times out, inspect the known campaign instead of retrying the whole lifecycle. Keep server acceptance, provider delivery, and inbox placement separate in your logs. Inspect campaign counters and recipient outcomes after sending; don’t report a 202 response as delivered mail.

listmonk is an alternative when you want a self-hosted newsletter manager with PostgreSQL and a separately configured messenger. Either system still needs consent acquisition and preference ownership. MailKite’s terms exclude unsolicited bulk/commercial email; this subscriber workflow is not a cold-outreach workaround.

Sources retrieved 2026-10-14: PyPI package, Python SDK, server login, API reference, and listmonk documentation. Source review included the shared contact/list/broadcast/suppression schemas, Python constructor and request methods, live auth middleware implementation, and contact/cancel route handlers.

Next: newsletter hub, Node.js newsletter, Python outreach simulation, and JavaScript outreach simulation. Bring implementation questions to community discussions.