MailKite
Get your API key
All guides

Send bulk email with Python

Reviewed

To send bulk email with Python, create one personalized message per recipient, submit bounded batches, and persist each outcome before moving on. This guide uses MailKite (which we build) to implement that workflow in a standalone CLI. Its default mock mixes successes and failures so you can test restart behavior without installing an SDK or sending mail.

The SDK call inside the complete Python worker is:

response = mk.sendBatch({
    "from": sender,
    "subject": "Your update, {{name}}",
    "text": "Hi {{name}}, your requested product update is ready.",
    "recipients": [{"to": r["to"], "templateData": {"name": r["name"]}} for r in chunk],
})

This excerpt assumes the worker’s sender, chunk, and mk variables. The Python SDK uses sendBatch, not send_batch, and the dictionary keys retain their API spelling. Each recipient entry produces a separate private message. Don’t turn this into a single message with the entire audience in to, and don’t assume BCC provides individual result IDs.

Execute the mock before adding credentials

Download python.py into a private working directory. The example requires Python 3.9 or newer; the mock uses the standard library:

python3 python.py
python3 python.py
python3 python.py --retry-failed

The first run creates python-mock.json, submits chunks of 50 and 3 synthetic addresses, and records 51 sent outcomes plus two failures. One failure is a mock temporary refusal; the other is a mock suppression. The second command submits nothing. The explicit retry selects only the temporary refusal and succeeds, leaving 52 accepted outcomes and one held failure.

This is a deliberate local simulation, not a delivery benchmark. The example.com fixtures never enter the live branch. Inspect the requests arrays and attempts fields: they show that previously accepted recipients weren’t selected again. requests records saved intent, which can exist even when the SDK call never happened.

Install the released Python SDK for live mode

The distribution name is mailkite-dev; the import is mailkite. PyPI version 0.20.0 was available when checked on October 14, 2026. Keep the installation in a virtual environment:

python3 -m venv .venv
.venv/bin/python -m pip install mailkite-dev==0.20.0

The worker imports the SDK only in its live branch:

from mailkite import MailKite
mk = MailKite(os.environ["MAILKITE_API_KEY"])

Inject MAILKITE_API_KEY and MAILKITE_FROM through your server environment. Verify the account email and sending domain first; see authentication and domains. Keep the key out of notebook output, logs, HTML, and browser-side code.

.venv/bin/python python.py --live opts into a real SDK request using only test-bulk-guide@mailkite.dev. Its checkpoint is python-live.json, distinct from the mock ledger. This controlled project address is not your inbox. There is deliberately no arbitrary live recipient import in this download. Running the Node and PHP live examples as well would create additional real test messages with independent ledgers.

Turn input records into a stable audience

The complete example starts with a small list of dictionaries, validates bare addresses and names, then deduplicates email addresses case-insensitively. That matches an exported SaaS account audience without involving a mail parser. It isn’t a full RFC validator, and it doesn’t prove delivery or permission.

For a real CSV import, use csv.DictReader in your import job, validate required columns, and write stable recipient records before dispatching. Keep that import separate from network sending: an invalid row halfway through a file shouldn’t produce an audience whose first half already received mail. Preview counts, excluded preferences, and duplicate rows before committing the job.

Do not hold millions of records in this example’s list. Its entire JSON ledger is rewritten at each checkpoint. A production pipeline should store recipients in a database and read bounded pages or queue tasks. The important Python adaptation is the record-to-outcome mapping, not an unbounded list comprehension over your customer table.

Shared subject and text contain {{name}}; each recipient supplies a value through templateData. If you add HTML, MailKite escapes merge values, but you still need an intentional template and correct links. Keep a text part and test names with apostrophes, long names, and empty optional fields.

Save intent before invoking the SDK

These lines run before each request:

for row in chunk:
    row["state"] = "inflight"
    row["attempts"] += 1
ledger["requests"].append([r["to"] for r in chunk])
save()  # Persist intent before the SDK request.

The file is written through a private temporary file and replaced with os.replace. On restart, every leftover inflight row becomes unknown. That is the necessary conservative choice: a lost response doesn’t prove the server rejected the messages, and a local crash doesn’t prove the request reached it.

The job identity includes mode, sender, recipients, and version. A mismatched ledger fails closed. Changing copy or recipient semantics requires an updated identity and a deliberate decision about whether a new message should exist. Deleting a live ledger is not a retry strategy.

This is single-process recovery. os.replace doesn’t create an atomic transaction with MailKite, coordinate concurrent writers, or guarantee persistence after power loss. Run one worker per ledger, outside your served website directory. The ledger contains addresses and message IDs, so treat it as private application data.

Check the complete result before applying it

The batch response schema specifies one result per request recipient, in the same order. Before the worker zips rows and results, it checks that the results list has the expected length. It then validates recipient identity, the three supported statuses, IDs for successful/scheduled results, and summary counts.

Without the length check, Python’s zip silently truncates to the shorter list. You would lose outcomes while your loop appeared to succeed. This worker treats a short, reordered, or unexpected response as unknown for the whole chunk instead of guessing which addresses were accepted.

StateSelected on the next run?Reason
pendingYesNo prior request intent
sentNoAPI reported success; preserve the message ID
scheduledNoAlready parked for later
failedOnly the mock allowlist with --retry-failedExplicit refusal needs a reviewed policy
unknownNoReconcile the uncertain request first

The mock retry code is mock_transient, an invented demonstration code, not a provider promise. Suppressed recipients remain excluded. Whole-request exceptions are conservatively unknown, including HTTP failures; the download doesn’t infer a live retry taxonomy or retry automatically.

Try python3 python.py --scenario short --ledger short.json, then rerun with that ledger normally. The first 50 stay unknown, while the three untouched pending records can proceed. timeout models a lost response; crash exits 75 after saving intent. The test download verifies these cases across all three native runtimes.

Move this into a Python job system

The batch size is capped at 50, independently of concurrency. A synchronous CLI with one request in flight is a useful starting point. In Django/Celery, enqueue stable recipient/job IDs after your database transaction commits, and make tasks load their current eligible state. Queue retries alone are insufficient: a replayed task must still exclude accepted and unknown recipients.

Use capped backoff and jitter for known temporary refusals and rate limits, with a retry budget and next-attempt timestamp. Stop claiming new chunks when the job is canceled. A timeout or forced shutdown during an active call still creates uncertainty. The inspected SDK uses synchronous requests without automatic retries or a per-call timeout parameter.

scheduledAt parks each message with a separate ssnd_… ID; scheduled isn’t permission to submit another copy. Read the sending reference for cancellation. Verify signed webhook feedback before suppressing hard bounces or complaints. sent in a batch response is not proof of inbox placement.

Pick transport and orchestration separately

Python pathAppropriate jobRemaining responsibility
MailKite SDKPersonalized application fan-out with reply webhooksAudience state and request recovery
SES through boto3Integrate sending into an AWS pipelineAWS identity, feedback, and endpoint-specific outcomes
smtplib with an SMTP providerUse an existing SMTP transportMIME creation, per-envelope tracking, ambiguous disconnects

For SMTP mechanics, see Python’s smtplib documentation. Compare providers in the bulk API guide, and use the bulk hub to choose your worker architecture. Subscriber acquisition and unsubscribe belong to the Python newsletter guide; outreach suitability and stop conditions belong to the Python cold-email guide. For application events, continue with programmatic email.

Sources checked October 14, 2026: Send API, request schema, Python SDK source, and PyPI above. Keep the README beside the executable and use community discussions for integration questions.