# Bulk and programmatic email examples

Audience: backend developers implementing private fan-out and event-driven reminders.
Docs: `website/docs/content/bulk-email-language-series.md`.

## Contract and design choice

- Every bulk CLI persists intent before calling the SDK and maps validated, ordered outcomes to recipient rows.
- Live dependencies: released MailKite SDK 0.20.0, server-side API key, verified sender domain, verified account. Mock mode needs only its native runtime.
- Invariants: at most 50 recipients per request; accepted/scheduled and unknown rows are never selected for retries; mock and live jobs use distinct ledger identities.
- Existing SDK examples demonstrate single sends, not checkpointed bulk jobs. These standalone downloads model local recovery without importing private repository helpers.

The editorial plan compares architecture options (language × workflow scores 4.60/5). The user's first-wave request selects that plan for these four guides. For the demo worker, correctness/readability has weight 60%, operational completeness 25%, dependency cost 15%: serial local ledger 4.35/5 (easy to run, one worker only), database queue 3.70/5 (concurrent/durable, needs infrastructure), uncheckpointed loop 2.45/5 (small, loses request intent). Scores are judgments. We use the serial ledger and explain its boundaries.

## Run without credentials

Download files into a private working directory outside your web root. Node 18+, Python 3.9+, or PHP 7.4+ can run the respective mock. The tests need all three runtimes and Python 3.9+.

```sh
node node.mjs
node node.mjs --retry-failed
python3 python.py
python3 python.py --retry-failed
php php.php
php php.php --retry-failed
node programmatic.mjs
python3 test.py
```

Each bulk mock generates 53 unique, **non-sending** example.com fixtures. First run: 51 sent, 2 failed, requests of 50 and 3. Restart: no additional requests. Explicit retry: only person1 (invented `mock_transient` code) is sent; the suppressed person2 stays failed. `sent` is a simulated API outcome, not evidence of inbox delivery. Live mode uses only `test-bulk-guide@mailkite.dev`; programmatic events use only `test-programmatic-guide@mailkite.dev`. These are controlled project test addresses, not inboxes readers own or should expect to read.

Use a fresh ledger per fault experiment:

```sh
node node.mjs --scenario timeout --ledger timeout.json
python3 python.py --scenario short --ledger short.json
php php.php --scenario crash --ledger crash.json
php php.php --ledger crash.json
```

Fault scenarios (mock only): `timeout`, `short` (missing result), `reordered`, `badstatus`, `crash` (exit 75 immediately after checkpoint), `scheduled`. Unknown first chunks stop that invocation; restarting can process the three untouched pending rows but cannot resend the 50 unknown rows. Inspect the JSON rather than assuming exit 0 means every recipient succeeded. `requests` records request intent, not proof a request reached the server.

## Opt into live SDK calls

Install beside the downloaded files, only when preparing live mode:

```sh
npm install mailkite@0.20.0
python3 -m venv .venv
.venv/bin/python -m pip install mailkite-dev==0.20.0
composer require mailkite/mailkite:0.20.0
```

Set `MAILKITE_API_KEY` and `MAILKITE_FROM` through your server environment/secret manager. Never put a key into a browser or ledger. Use a sender on your account's verified domain. Then choose **one** native worker:

```sh
node node.mjs --live
.venv/bin/python python.py --live
php php.php --live
```

Each language has a separate live ledger, so running all three is three potential real messages. There is no live bulk list import. `--retry-failed` retries only the mock code, never inferred live error categories. Review provider errors, suppressions and ambiguous responses before changing a retry policy.

Programmatic live actions are separate:

```sh
node programmatic.mjs --live --action draft
node programmatic.mjs --live --action failed
node programmatic.mjs --live --action paid
node programmatic.mjs --live --action cancel
```

`draft` creates a non-enrolling sequence. Review/activate it deliberately in the dashboard before posting events if testing reminders. The name is `guide-payment-recovery`. Events can also trigger other existing account sequences listening for those names. Cancellation targets this sequence and the controlled email, not an invented event-to-cancelKey mapping. The fixed invoice filter is for one sample invoice; don't use the definition unchanged for multi-invoice billing. The mock models one wait, deduplication and cancellation, without running timers or sending reminder mail. Default `demo` proves a repeated event remains one enrollment and completion ends its wait. Rerunning the same completed ledger reuses saved outcomes.

## Sources and boundaries

Checked 2026-10-14: [sending](https://mailkite.dev/docs/sending), [sequences](https://mailkite.dev/docs/sequences), [batch request schema](https://api.mailkite.dev/v1/schemas/batch-send-request.json), [batch response schema](https://api.mailkite.dev/v1/schemas/batch-send-response.json), [npm](https://registry.npmjs.org/mailkite/latest), [PyPI](https://pypi.org/pypi/mailkite-dev/json), [Packagist](https://repo.packagist.org/p2/mailkite/mailkite.json). Local implementations inspected: `sdks/node/index.js`, `sdks/python/mailkite/__init__.py`, `sdks/php/src/Client.php`; method/auth contract: `sdks/spec/api.json` and referenced JSON schemas.

The SDKs make one request and expose no automatic retry policy for these calls. Event `dedupeKey` is documented server-side deduplication; raw MIME headers are not a verified batch idempotency mechanism. A failed/invalid whole response is conservatively unknown, including HTTP errors. Do not log raw credential-bearing exceptions.

Repository-only content validation (after building the website): `node website/public/guides/examples/bulk-email/check-content.mjs`. This uses the website's installed Markdown processor, validates frontmatter and prose length, compares executable excerpts against downloads, and checks built assets and internal links. Parent-owned planned routes are reported separately. `test.py` remains dependency-free. Keel's duplicate-name/placement warnings for mock SDK methods and local save helpers are accepted: these are intentionally standalone downloads, not shared application modules.

Local temporary-file rename reduces torn JSON updates; this is **single-process recovery**, not production exactly-once delivery. No fsync/power-loss guarantee, lock, concurrent writers, atomic server/local commit, full schema validation of edited ledgers, or reconciliation API is implemented. Keep the ledger and its directory private; don't reuse/delete a live ledger to force a resend. Stop the process between requests to pause new work; interrupting an active call leaves uncertainty. The worker loads a tiny fixture in memory and rewrites the entire ledger. A production queue needs database transactions/leases, bounded retries/backoff, request deadlines, preference checks, signed feedback processing and observability. Changing content requires a new job identity/version and a deliberate audience decision.
