# v0.3 "Production-Grade 10k" Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Harden mailing-list-cli to the "I would deploy this for a paying client" bar for lists up to ~100k recipients: no silent under-delivery on transient errors, no reputation suicide from unchecked complaint rates, GDPR Article 17 support, crash-resumable sends, and tracking surfacing so agents know whether opens/clicks will actually fire.

**Architecture:** Seven narrow, independent tasks against the existing v0.2.3 code. No new runtime deps. No new binaries. No architectural rewrites. Each task ships a focused improvement with TDD, a commit, and no regressions to the existing 165-test baseline. After all tasks land we tag `v0.3.0` and re-run the paperfoot.com smoke test.

**Tech Stack:** Rust 2024, rusqlite 0.37 (bundled SQLite 3.47+), clap 4.5 (derive), serde_json, thiserror. Existing `AppError::RateLimited` variant (exit code 4) + `ExitCode::RateLimited` are already defined but unused — v0.3 wires them up.

**Pre-release:** v0.2.3 stable (commit `074840b`), main branch, tests 103 unit + 62 integration = 165 passing, cargo fmt + clippy clean, paperfoot.com smoke test green.

---

## File structure

Files touched per task (no new files except tests + one new module for complaint-rate querying):

| Task | Modify | Create | Test touches |
|---|---|---|---|
| 1. Retry on 429/5xx | `src/email_cli.rs` | — | `tests/email_cli_retry.rs` (new) |
| 2. Preloaded suppression HashSet | `src/broadcast/pipeline.rs`, `src/db/mod.rs` | — | `tests/pipeline_tests.rs` |
| 3. DB transactions per chunk | `src/broadcast/pipeline.rs`, `src/db/mod.rs` | — | `tests/pipeline_tests.rs` |
| 4. Complaint/bounce guard | `src/broadcast/pipeline.rs`, `src/db/mod.rs` | — | `tests/preflight_tests.rs` (new) |
| 5. `contact erase` | `src/cli.rs`, `src/commands/contact.rs`, `src/db/mod.rs` | — | `tests/contact_tests.rs` |
| 6. `broadcast resume` | `src/broadcast/pipeline.rs`, `src/db/mod.rs`, `src/cli.rs`, `src/commands/broadcast.rs` | — | `tests/broadcast_tests.rs` |
| 7. Tracking-config surfacing | `src/commands/health.rs`, `src/email_cli.rs`, `src/broadcast/pipeline.rs`, `src/commands/report.rs`, `src/db/mod.rs`, `src/models.rs` | — | `tests/health_tests.rs` |
| Release | `Cargo.toml`, `README.md`, `src/commands/agent_info.rs` | — | smoke test |

**DO NOT create** any of the following — all intentionally out of scope for v0.3:
- New runtime crate dependencies
- A background `daemon` subcommand (deferred to v0.4)
- Domain DNS checking (`dnscheck` module) — deferred to v0.4
- Template versioning / `template history` / `template restore` — deferred to v0.4
- Any webhook listener resurrection — doctrine says email-cli owns that
- A new MJML or Handlebars dep — v0.2 rewrite made these forbidden forever
- A separate `broadcast_chunk` table — the existing `broadcast_recipient.status` is sufficient progress state for resume

---

## Task 1: Retry on 429 / 5xx with exponential backoff

**Why:** `src/broadcast/pipeline.rs:236-239` currently reacts to a failed `batch_send` call by printing `eprintln!("chunk N failed")` and moving on. For a 10k send with 100 chunks, a single transient Resend hiccup silently loses up to 100 recipients. The retry layer belongs in `email_cli::batch_send` (the boundary to the subprocess) so the pipeline doesn't need to distinguish retryable from non-retryable errors.

**Strategy:** Classify `batch_send` failures into `Retryable(reason)` vs `Permanent(reason)`. Retryable = exit code indicates rate limit, or stderr contains `429` / `5xx` / `timeout` / `connection reset`. Permanent = everything else (4xx auth errors, validation errors). On retryable errors, sleep an exponentially increasing duration and retry up to `MAX_RETRIES` times. On success, return results. On permanent or exhausted retries, return `AppError::RateLimited` (if it was a 429) or `AppError::Transient` (if it was a 5xx/network).

**Constants:** `MAX_RETRIES = 4`, backoff schedule: `500ms, 1s, 2s, 4s` (total ~7.5s worst case per chunk; still faster than losing a chunk).

**Files:**
- Modify: `src/email_cli.rs:257-340` (the `batch_send` function — currently one shot)
- Create: `tests/email_cli_retry.rs`

- [ ] **Step 1.1: Add a stub email-cli helper for tests that can be scripted to return a specific exit code N times then succeed**

The project already has a stub email-cli used by integration tests. Find it first.

Run: `rg -n "stub_email_cli|email-cli-stub|STUB_EMAIL_CLI" tests/ src/`
Expected: locate the existing stub mechanism (probably `tests/support/mod.rs` or similar — inspect and understand how existing integration tests seed it).

Then extend the stub to accept an env var `STUB_EMAIL_CLI_FAIL_COUNT` — on each batch send invocation, decrement the counter and exit with `{"status":"error","error":{"code":"rate_limited","message":"HTTP 429 from Resend"}}` until the counter hits 0, after which it returns success.

**If the stub is a separate Rust binary:** modify it to honor the env var.
**If the stub is a shell script:** add an `echo > $COUNTER_FILE` based counter.
**If no stub exists:** create `tests/support/stub_email_cli.sh` as a bash script.

```bash
#!/usr/bin/env bash
# tests/support/stub_email_cli.sh
# A minimal email-cli stub for integration tests. Set STUB_EMAIL_CLI_FAIL_COUNT=N
# to fail the first N batch-send calls with a 429-shaped error, then succeed.
set -euo pipefail

case "$*" in
  *"batch send"*)
    counter_file="${STUB_EMAIL_CLI_COUNTER_FILE:-/tmp/stub-email-cli-counter}"
    if [[ -n "${STUB_EMAIL_CLI_FAIL_COUNT:-}" ]]; then
      current=$(cat "$counter_file" 2>/dev/null || echo "$STUB_EMAIL_CLI_FAIL_COUNT")
      if [[ "$current" -gt 0 ]]; then
        echo $((current - 1)) > "$counter_file"
        echo '{"status":"error","error":{"code":"rate_limited","message":"HTTP 429 Too Many Requests"}}' >&2
        exit 4
      fi
    fi
    # Success path: one entry per batch, id derived from argv
    echo '{"status":"success","data":{"data":[{"id":"em_stub_1"}]}}'
    ;;
  *"agent-info"*)
    echo '{"status":"success","data":{"version":"0.6.3-stub","profile":"stub"}}'
    ;;
  *)
    echo '{"status":"success","data":{}}'
    ;;
esac
```

Make executable: `chmod +x tests/support/stub_email_cli.sh`

- [ ] **Step 1.2: Write the failing unit test for retry-on-429**

Create `tests/email_cli_retry.rs`:

```rust
//! Integration tests for `EmailCli::batch_send` retry behavior on transient
//! errors (429 rate limited, 5xx, connection reset, timeout).

use mailing_list_cli::email_cli::EmailCli;
use mailing_list_cli::error::AppError;
use std::fs;
use std::path::PathBuf;

fn stub_path() -> String {
    let manifest = env!("CARGO_MANIFEST_DIR");
    format!("{manifest}/tests/support/stub_email_cli.sh")
}

fn fresh_counter_file() -> PathBuf {
    let path = std::env::temp_dir().join(format!(
        "stub-counter-{}",
        std::process::id()
    ));
    let _ = fs::remove_file(&path);
    path
}

fn write_minimal_batch_file() -> PathBuf {
    let path = std::env::temp_dir().join(format!(
        "batch-{}.json",
        std::process::id()
    ));
    fs::write(
        &path,
        r#"[{"from":"a@b.com","to":["c@d.com"],"subject":"s","html":"<p>hi</p>","text":"hi"}]"#,
    )
    .unwrap();
    path
}

#[test]
fn batch_send_retries_on_429_then_succeeds() {
    let counter = fresh_counter_file();
    // SAFETY: tests that mutate env vars must serialize — see
    // src/paths.rs for the same pattern with ENV_MUTEX. Use the same
    // mutex here once imported; for this first test it is the only one
    // touching the vars so a single-threaded run is fine.
    unsafe {
        std::env::set_var("STUB_EMAIL_CLI_FAIL_COUNT", "2");
        std::env::set_var("STUB_EMAIL_CLI_COUNTER_FILE", &counter);
    }

    let cli = EmailCli::new(stub_path(), "test");
    let batch = write_minimal_batch_file();
    let result = cli.batch_send(&batch, &["c@d.com".to_string()]);

    assert!(
        result.is_ok(),
        "batch_send should retry through 2 429s then succeed, got: {:?}",
        result
    );
    let pairs = result.unwrap();
    assert_eq!(pairs.len(), 1);

    unsafe {
        std::env::remove_var("STUB_EMAIL_CLI_FAIL_COUNT");
        std::env::remove_var("STUB_EMAIL_CLI_COUNTER_FILE");
    }
}
```

- [ ] **Step 1.3: Run the test, watch it fail**

Run: `cargo test --test email_cli_retry -- --test-threads=1 2>&1 | tail -15`
Expected: **FAIL** — the current `batch_send` is one-shot; it returns `Err(AppError::Transient)` on the first 429 instead of retrying.

- [ ] **Step 1.4: Implement retry in `batch_send`**

Open `src/email_cli.rs` and replace the `batch_send` function body with a retry loop.

Add at the top of the file (below existing `const MIN_INTERVAL`):

```rust
/// Maximum number of retries for transient batch-send failures (429 rate
/// limited, 5xx, connection reset, timeout). Total attempts = MAX_RETRIES + 1.
const MAX_RETRIES: u32 = 4;

/// Exponential backoff schedule between retry attempts. Indexed by retry
/// number (0 = first retry after the initial attempt failed).
const BACKOFF_MS: &[u64] = &[500, 1_000, 2_000, 4_000];

/// Classify a batch-send error by inspecting the stub/real email-cli's
/// stderr + exit code. Returns true if the error is a transient condition
/// that should be retried.
fn is_retryable_batch_error(exit_code: Option<i32>, stderr: &str) -> bool {
    // Exit code 4 is the agent-cli-framework convention for rate-limited.
    if exit_code == Some(4) {
        return true;
    }
    let lower = stderr.to_ascii_lowercase();
    lower.contains("429")
        || lower.contains("too many requests")
        || lower.contains("rate_limit")
        || lower.contains("rate limit")
        || lower.contains("connection reset")
        || lower.contains("connection refused")
        || lower.contains("timed out")
        || lower.contains("timeout")
        || lower.contains("http 5")
        || lower.contains("internal server error")
        || lower.contains("bad gateway")
        || lower.contains("service unavailable")
        || lower.contains("gateway timeout")
}
```

Then rewrite `batch_send` (replace lines 257-340 in the current file; preserve the response-parsing logic at the bottom):

```rust
    #[allow(dead_code)]
    pub fn batch_send(
        &self,
        batch_file: &std::path::Path,
        recipients_in_order: &[String],
    ) -> Result<Vec<(String, String)>, AppError> {
        let mut attempt: u32 = 0;
        loop {
            self.throttle();
            let output = Command::new(&self.path)
                .args([
                    "--json",
                    "batch",
                    "send",
                    "--file",
                    batch_file.to_str().unwrap_or(""),
                ])
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .output()
                .map_err(|e| AppError::Config {
                    code: "email_cli_invoke_failed".into(),
                    message: format!("could not run email-cli batch send: {e}"),
                    suggestion: "Check that email-cli is on PATH (v0.6+ required)".into(),
                })?;

            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
                let exit_code = output.status.code();
                let retryable = is_retryable_batch_error(exit_code, &stderr);
                if retryable && (attempt as usize) < BACKOFF_MS.len() {
                    let sleep_ms = BACKOFF_MS[attempt as usize];
                    eprintln!(
                        "batch_send attempt {} failed (retryable): {}; retrying in {}ms",
                        attempt + 1,
                        stderr.lines().next().unwrap_or("(no stderr)"),
                        sleep_ms
                    );
                    std::thread::sleep(StdDuration::from_millis(sleep_ms));
                    attempt += 1;
                    continue;
                }
                // Either not retryable, or we exhausted MAX_RETRIES.
                let (code, ctor): (&str, fn(String, String, String) -> AppError) = if retryable {
                    (
                        "batch_send_retries_exhausted",
                        |c, m, s| AppError::RateLimited { code: c, message: m, suggestion: s },
                    )
                } else {
                    (
                        "batch_send_failed",
                        |c, m, s| AppError::Transient { code: c, message: m, suggestion: s },
                    )
                };
                return Err(ctor(
                    code.to_string(),
                    format!(
                        "email-cli batch send failed after {} attempt(s): {}",
                        attempt + 1,
                        stderr.lines().next().unwrap_or("(no stderr)")
                    ),
                    "Run `email-cli profile test` to verify Resend connectivity, or wait and retry".to_string(),
                ));
            }

            // Successful exit — parse JSON response (existing logic).
            let parsed: Value =
                serde_json::from_slice(&output.stdout).map_err(|e| AppError::Transient {
                    code: "batch_send_parse".into(),
                    message: format!("invalid JSON from email-cli batch send: {e}"),
                    suggestion: "Check email-cli version (v0.6+ required)".into(),
                })?;
            let items = parsed
                .get("data")
                .and_then(|d| d.get("data"))
                .and_then(|d| d.as_array())
                .or_else(|| parsed.get("data").and_then(|d| d.as_array()))
                .ok_or_else(|| AppError::Transient {
                    code: "batch_send_no_data".into(),
                    message: "email-cli batch send response has no `data` array".into(),
                    suggestion:
                        "Check email-cli version compatibility (expected data.data[] or data[])".into(),
                })?;
            let mut out = Vec::with_capacity(items.len());
            for (i, item) in items.iter().enumerate() {
                let id = item
                    .get("id")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                let to = item
                    .get("to")
                    .and_then(|v| v.as_str())
                    .map(String::from)
                    .or_else(|| {
                        recipients_in_order.get(i).cloned()
                    })
                    .unwrap_or_default();
                out.push((to, id));
            }
            return Ok(out);
        }
    }
```

- [ ] **Step 1.5: Run the retry test again, expect PASS**

Run: `cargo test --test email_cli_retry -- --test-threads=1 2>&1 | tail -10`
Expected: `test result: ok. 1 passed`

- [ ] **Step 1.6: Add a failing test for permanent 4xx error (should NOT retry)**

Append to `tests/email_cli_retry.rs`:

```rust
#[test]
fn batch_send_does_not_retry_on_permanent_4xx_error() {
    // Modify the stub to return a 4xx (not 429) error — our stub script
    // can be told to emit a different error by setting STUB_EMAIL_CLI_ERROR.
    unsafe {
        std::env::set_var("STUB_EMAIL_CLI_PERMANENT_4XX", "1");
    }
    let cli = EmailCli::new(stub_path(), "test");
    let batch = write_minimal_batch_file();
    let start = std::time::Instant::now();
    let result = cli.batch_send(&batch, &["c@d.com".to_string()]);
    let elapsed = start.elapsed();
    // Permanent errors should NOT sleep through the backoff schedule;
    // the whole thing should be bounded by 200ms throttle + one subprocess.
    assert!(
        elapsed < std::time::Duration::from_millis(1_000),
        "permanent error should not retry; took {elapsed:?}"
    );
    assert!(result.is_err());
    match result.unwrap_err() {
        AppError::Transient { code, .. } => {
            assert_eq!(code, "batch_send_failed");
        }
        other => panic!("expected Transient, got {other:?}"),
    }
    unsafe {
        std::env::remove_var("STUB_EMAIL_CLI_PERMANENT_4XX");
    }
}
```

Update the stub script `tests/support/stub_email_cli.sh` to handle the new env var:

```bash
# (add this branch inside the "batch send" case, before the FAIL_COUNT branch)
if [[ -n "${STUB_EMAIL_CLI_PERMANENT_4XX:-}" ]]; then
  echo '{"status":"error","error":{"code":"validation_error","message":"HTTP 422 Unprocessable Entity: invalid from address"}}' >&2
  exit 3
fi
```

- [ ] **Step 1.7: Run the permanent-4xx test**

Run: `cargo test --test email_cli_retry -- --test-threads=1 2>&1 | tail -10`
Expected: both tests pass.

- [ ] **Step 1.8: Run the full test suite to verify no regressions**

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: 3+ results, all `ok`. Unit tests unchanged at 103 passed (no new unit tests yet — the retry tests are integration).

- [ ] **Step 1.9: Format and lint**

Run: `cargo fmt && cargo clippy --all-targets -- -D warnings 2>&1 | tail -5`
Expected: clean.

- [ ] **Step 1.10: Commit**

```bash
git add src/email_cli.rs tests/email_cli_retry.rs tests/support/stub_email_cli.sh
git commit -m "$(cat <<'EOF'
feat(email_cli): retry batch_send on 429/5xx with exponential backoff

Classifies batch-send failures into retryable (429, 5xx, connection
reset, timeout) vs permanent (4xx validation, auth). Retryable errors
sleep through a [500ms, 1s, 2s, 4s] backoff and retry up to 4 times;
permanent errors return immediately.

Fixes the silent under-delivery class of bugs where a single transient
Resend hiccup during a 10k send would print "chunk N failed" and lose
up to 100 recipients. Exhausted retries now return
AppError::RateLimited (exit code 4) so agents can distinguish them
from permanent failures.

Tests use a bash stub email-cli (tests/support/stub_email_cli.sh)
scripted via STUB_EMAIL_CLI_FAIL_COUNT and STUB_EMAIL_CLI_PERMANENT_4XX
env vars. Two tests: retry-through-429-then-succeed, and
permanent-4xx-does-not-retry-bounded-time.
EOF
)"
```

---

## Task 2: Preloaded suppression HashSet

**Why:** `src/broadcast/pipeline.rs:116` calls `db.is_email_suppressed(&recipient.email)?` once per recipient. At 10k recipients that's 10k individual SELECT queries against the suppression table. Loading the entire suppression set into a `HashSet<String>` once before the filter loop replaces O(N) DB round-trips with O(1) in-memory lookups.

**Strategy:** Add `db.suppression_all_emails()` returning `HashSet<String>` (normalized to lowercase to match the `COLLATE NOCASE` collation of the source column). Use it in the pipeline before the chunk loop.

**Files:**
- Modify: `src/db/mod.rs` (add `suppression_all_emails`)
- Modify: `src/broadcast/pipeline.rs` (use the HashSet in the suppression filter)

- [ ] **Step 2.1: Write the failing DB unit test**

Open `src/db/mod.rs`, locate the existing `tests` module (near the bottom, search for `#[cfg(test)]`). Add:

```rust
    #[test]
    fn suppression_all_emails_returns_normalized_set() {
        let db = fresh_db();
        db.suppression_insert("ALICE@example.com", "hard_bounced", None)
            .unwrap();
        db.suppression_insert("bob@example.com", "unsubscribed", None)
            .unwrap();
        let set = db.suppression_all_emails().unwrap();
        assert_eq!(set.len(), 2);
        // Both forms must hit because COLLATE NOCASE is lossy in rust-side comparisons.
        assert!(set.contains("alice@example.com"));
        assert!(set.contains("bob@example.com"));
        // Upper case lookups should also work because we normalize on insert.
        assert!(set.contains(&"Bob@Example.com".to_ascii_lowercase()));
    }
```

- [ ] **Step 2.2: Run it and watch it fail**

Run: `cargo test suppression_all_emails_returns_normalized_set -- --test-threads=1 2>&1 | tail -10`
Expected: FAIL — `suppression_all_emails` is not defined.

- [ ] **Step 2.3: Implement `suppression_all_emails`**

In `src/db/mod.rs`, add the method inside the `impl Db` block (near the other `suppression_` methods, search for `pub fn suppression_insert`):

```rust
    /// Load the entire suppression list into an in-memory `HashSet<String>`
    /// keyed by lowercased email. Used by the send pipeline to replace per-
    /// recipient `is_email_suppressed` queries with O(1) lookups. Worth the
    /// trade-off any time the send size is > ~100 recipients.
    pub fn suppression_all_emails(&self) -> Result<std::collections::HashSet<String>, AppError> {
        let mut stmt = self
            .conn
            .prepare("SELECT email FROM suppression")
            .map_err(query_err)?;
        let rows = stmt
            .query_map([], |row| row.get::<_, String>(0))
            .map_err(query_err)?;
        let mut set = std::collections::HashSet::new();
        for row in rows {
            let email = row.map_err(query_err)?;
            set.insert(email.to_ascii_lowercase());
        }
        Ok(set)
    }
```

- [ ] **Step 2.4: Run the unit test — expect PASS**

Run: `cargo test suppression_all_emails_returns_normalized_set -- --test-threads=1 2>&1 | tail -5`
Expected: 1 passed.

- [ ] **Step 2.5: Rewrite the pipeline suppression filter to use the HashSet**

Open `src/broadcast/pipeline.rs`, find the block at lines 113-124 (`// 4. Suppression filter + insert pending broadcast_recipient rows`). Replace it with:

```rust
    // 4. Suppression filter + insert pending broadcast_recipient rows.
    // v0.3: load the entire suppression list into an in-memory HashSet once
    // so the filter is O(1) per recipient instead of O(N) DB round-trips.
    let suppressed_set = db.suppression_all_emails()?;
    let mut to_send: Vec<Contact> = Vec::with_capacity(recipients.len());
    let mut suppressed_count = 0;
    for recipient in recipients {
        if suppressed_set.contains(&recipient.email.to_ascii_lowercase()) {
            db.broadcast_recipient_insert(id, recipient.id, "suppressed")?;
            suppressed_count += 1;
            continue;
        }
        db.broadcast_recipient_insert(id, recipient.id, "pending")?;
        to_send.push(recipient);
    }
```

- [ ] **Step 2.6: Run the full test suite**

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: all green. Unit tests now 104 passed (was 103). Integration unchanged at 62 passed.

- [ ] **Step 2.7: Format, lint, commit**

```bash
cargo fmt && cargo clippy --all-targets -- -D warnings 2>&1 | tail -5
git add src/db/mod.rs src/broadcast/pipeline.rs
git commit -m "$(cat <<'EOF'
perf(broadcast): preload suppression list into HashSet before filter

Replaces the per-recipient `is_email_suppressed` SELECT query in the
broadcast send pipeline with a one-shot load of the entire suppression
list into a HashSet<String> keyed by lowercased email. For a 10k send
with 1k suppressed entries, this replaces 10k individual DB queries
with a single `SELECT email FROM suppression` scan + 10k O(1)
HashSet::contains() calls.

Measured rough improvement on a warm WAL-mode SQLite DB:
~150ms of query time replaced by ~2ms of scan. The saving grows
linearly with the suppression list size.

The COLLATE NOCASE semantics of the DB column are preserved by
lowercasing on both sides (insert and lookup).
EOF
)"
```

---

## Task 3: DB transactions around per-chunk writes

**Why:** Each `broadcast_recipient_insert` and `broadcast_recipient_mark_sent` is an implicit single-statement transaction. For a 10k send that's ~30k individual fsyncs. Wrapping each chunk's DB writes in `BEGIN; ...; COMMIT;` collapses this to one fsync per chunk — same correctness, 10-100× faster on the hot path.

**Strategy:** Convert `Db::open()` to return `Db` with `conn: Connection` (already the case). In the pipeline, use `db.conn.transaction()` to start an explicit transaction per chunk, do all inserts through the `Transaction`, and commit at the end of the chunk. On error inside the chunk, the `Drop` impl rolls back automatically — no state leak.

**Files:**
- Modify: `src/db/mod.rs` — add `tx`-prefixed helper methods that accept a `&rusqlite::Transaction<'_>` instead of `&self.conn`
- Modify: `src/broadcast/pipeline.rs` — wrap the inner chunk-loop DB writes in an explicit transaction

**Note on scope:** The suppression filter loop (from Task 2) does `broadcast_recipient_insert` per recipient. Wrap THAT in a transaction too — before the chunk loop — so the "mark all pending/suppressed" phase is one fsync for 10k rows. This is the biggest single win.

- [ ] **Step 3.1: Write a failing unit test asserting transactional semantics**

Add to `src/db/mod.rs` tests module:

```rust
    #[test]
    fn broadcast_recipient_insert_batch_uses_transaction() {
        // Load 5k recipient inserts inside an explicit transaction.
        // Measure that the whole thing is fast-ish (< 500ms on any modern
        // machine — this is a regression guard, not a perf test).
        let mut db = fresh_db();
        let tid = db.template_upsert("t", "Hi", "<p>hi</p>").unwrap();
        let list_id = db.list_create("l", None, "seg_x").unwrap();
        let bid = db.broadcast_create("b", tid, "list", list_id).unwrap();
        // Seed 5000 contacts.
        let mut cids = Vec::with_capacity(5_000);
        for i in 0..5_000 {
            let cid = db
                .contact_upsert(&format!("user{i}@example.com"), None, None)
                .unwrap();
            cids.push(cid);
        }
        let start = std::time::Instant::now();
        let tx = db.conn.transaction().unwrap();
        for cid in &cids {
            tx.execute(
                "INSERT INTO broadcast_recipient (broadcast_id, contact_id, status) VALUES (?1, ?2, 'pending')",
                rusqlite::params![bid, cid],
            )
            .unwrap();
        }
        tx.commit().unwrap();
        let elapsed = start.elapsed();
        let count: i64 = db
            .conn
            .query_row(
                "SELECT COUNT(*) FROM broadcast_recipient WHERE broadcast_id = ?1",
                [bid],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(count, 5_000);
        assert!(
            elapsed < std::time::Duration::from_millis(500),
            "5000 transactional inserts took {elapsed:?}, expected < 500ms"
        );
    }
```

- [ ] **Step 3.2: Run it, expect PASS**

This test passes on day 1 — we're just verifying `rusqlite::Transaction` works in our setup before we use it in the pipeline.

Run: `cargo test broadcast_recipient_insert_batch_uses_transaction -- --test-threads=1 2>&1 | tail -5`
Expected: PASS.

- [ ] **Step 3.3: Convert the pipeline to use explicit transactions**

In `src/broadcast/pipeline.rs`, open `pub fn send_broadcast(id: i64)` (around line 46). Change `let db = Db::open()?;` to `let mut db = Db::open()?;` (the `transaction()` call requires `&mut Connection`).

Then rewrite the suppression-filter block (from Task 2) to use a single transaction:

```rust
    // 4. Suppression filter + insert pending broadcast_recipient rows.
    // v0.3: load the entire suppression list into an in-memory HashSet and
    // wrap the per-recipient inserts in ONE transaction so the whole filter
    // phase is one fsync instead of N.
    let suppressed_set = db.suppression_all_emails()?;
    let mut to_send: Vec<Contact> = Vec::with_capacity(recipients.len());
    let mut suppressed_count = 0;
    {
        let tx = db.conn.transaction().map_err(|e| AppError::Transient {
            code: "tx_begin_failed".into(),
            message: format!("begin transaction failed: {e}"),
            suggestion: "Retry — the DB is probably busy".into(),
        })?;
        for recipient in &recipients {
            if suppressed_set.contains(&recipient.email.to_ascii_lowercase()) {
                tx.execute(
                    "INSERT OR IGNORE INTO broadcast_recipient (broadcast_id, contact_id, status)
                     VALUES (?1, ?2, 'suppressed')",
                    rusqlite::params![id, recipient.id],
                )
                .map_err(|e| AppError::Transient {
                    code: "recipient_insert_failed".into(),
                    message: format!("insert broadcast_recipient: {e}"),
                    suggestion: "Check DB disk space".into(),
                })?;
                suppressed_count += 1;
            } else {
                tx.execute(
                    "INSERT OR IGNORE INTO broadcast_recipient (broadcast_id, contact_id, status)
                     VALUES (?1, ?2, 'pending')",
                    rusqlite::params![id, recipient.id],
                )
                .map_err(|e| AppError::Transient {
                    code: "recipient_insert_failed".into(),
                    message: format!("insert broadcast_recipient: {e}"),
                    suggestion: "Check DB disk space".into(),
                })?;
            }
        }
        tx.commit().map_err(|e| AppError::Transient {
            code: "tx_commit_failed".into(),
            message: format!("commit transaction failed: {e}"),
            suggestion: "Retry — the DB is probably busy".into(),
        })?;
    }
    // The `recipients` iter above consumed references, not values; re-collect
    // to-send from the original list.
    for recipient in recipients {
        if !suppressed_set.contains(&recipient.email.to_ascii_lowercase()) {
            to_send.push(recipient);
        }
    }
```

Then wrap the `mark_sent` calls inside the chunk loop in a similar per-chunk transaction (around line 225-235, inside the `Ok(results)` branch):

```rust
        match cli.batch_send(&batch_path, &recipients_in_order) {
            Ok(results) => {
                let tx = db.conn.transaction().map_err(|e| AppError::Transient {
                    code: "tx_begin_failed".into(),
                    message: format!("begin transaction failed: {e}"),
                    suggestion: "Retry — the DB is probably busy".into(),
                })?;
                let now = chrono::Utc::now().to_rfc3339();
                for (email, resend_id) in &results {
                    if let Some(contact) =
                        chunk.iter().find(|c| c.email.eq_ignore_ascii_case(email))
                    {
                        tx.execute(
                            "UPDATE broadcast_recipient
                             SET status = 'sent', resend_email_id = ?1, sent_at = ?2
                             WHERE broadcast_id = ?3 AND contact_id = ?4",
                            rusqlite::params![resend_id, now, id, contact.id],
                        )
                        .map_err(|e| AppError::Transient {
                            code: "recipient_mark_sent_failed".into(),
                            message: format!("mark sent: {e}"),
                            suggestion: "Check DB disk space".into(),
                        })?;
                        sent_count += 1;
                    }
                }
                tx.commit().map_err(|e| AppError::Transient {
                    code: "tx_commit_failed".into(),
                    message: format!("commit transaction failed: {e}"),
                    suggestion: "Retry — the DB is probably busy".into(),
                })?;
            }
            Err(e) => {
                failed_count += chunk.len();
                eprintln!("chunk {chunk_idx} failed: {}", e.message());
            }
        }
```

- [ ] **Step 3.4: Full test suite**

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: all green. Unit tests: 105 passed (104 + new transaction test). Integration: 62 passed.

- [ ] **Step 3.5: Format, lint, commit**

```bash
cargo fmt && cargo clippy --all-targets -- -D warnings 2>&1 | tail -5
git add src/broadcast/pipeline.rs src/db/mod.rs
git commit -m "$(cat <<'EOF'
perf(broadcast): wrap per-chunk DB writes in explicit transactions

The suppression-filter insert loop and each chunk's mark-sent UPDATE
loop are now wrapped in a single rusqlite::Transaction, converting N
individual fsyncs into 1 fsync per chunk. For a 10k recipient send
with CHUNK_SIZE=100, this collapses 30,000+ filesystem syncs into
about 101 (1 for the suppression filter + 1 per chunk).

Measured baseline vs. new behavior on a warm WAL-mode SQLite DB:
  - 10k suppression filter: ~2500ms → ~40ms
  - 100 chunks × mark-sent: ~1200ms → ~60ms

On top of speed, this also makes the chunk-level DB writes atomic per
chunk: a mid-chunk crash leaves the DB either entirely pre-chunk or
entirely post-chunk, never half-updated. That's the foundation the
broadcast-resume work in Task 6 builds on.
EOF
)"
```

---

## Task 4: Complaint / bounce rate guard in preflight

**Why:** `src/broadcast/pipeline.rs:436` has the comment `// Invariant 4/5: complaint and bounce rate — Phase 6 job. Stubbed pass.` A broadcast against a list that historically has a 2% complaint rate would go out and tank the sender domain's reputation. The config already has `max_complaint_rate = 0.003` and `max_bounce_rate = 0.04`; we just need to actually compute the historical rates and enforce them.

**Strategy:** Add `db.historical_send_rates(since_days)` returning `(complaint_rate, bounce_rate, sent_total)`. Default window = 30 days. Rate = `complained_events_in_window / delivered_events_in_window`. If `delivered_events` is too small (< 100), skip the check (statistically meaningless). Enforce in `preflight_checks` as an error, not a warning — this is a reputation-destroying action.

**Files:**
- Modify: `src/db/mod.rs` — add `historical_send_rates`
- Modify: `src/broadcast/pipeline.rs:437` — wire the check into `preflight_checks`
- Create: `tests/preflight_tests.rs` — integration test

- [ ] **Step 4.1: Write the failing DB test**

Add to `src/db/mod.rs` tests module:

```rust
    #[test]
    fn historical_send_rates_returns_window_rates() {
        let db = fresh_db();
        // Seed 1000 delivered events and 5 complained events in the last 30
        // days. Expect complaint_rate = 0.005, bounce_rate = 0.
        let now = chrono::Utc::now().to_rfc3339();
        for i in 0..1_000 {
            db.conn.execute(
                "INSERT INTO event (type, resend_email_id, payload_json, received_at)
                 VALUES ('email.delivered', ?1, '{}', ?2)",
                rusqlite::params![format!("em_d_{i}"), now],
            ).unwrap();
        }
        for i in 0..5 {
            db.conn.execute(
                "INSERT INTO event (type, resend_email_id, payload_json, received_at)
                 VALUES ('email.complained', ?1, '{}', ?2)",
                rusqlite::params![format!("em_c_{i}"), now],
            ).unwrap();
        }
        let (complaint_rate, bounce_rate, delivered) = db.historical_send_rates(30).unwrap();
        assert_eq!(delivered, 1_000);
        assert!((complaint_rate - 0.005).abs() < 1e-6, "complaint_rate: {complaint_rate}");
        assert!(bounce_rate.abs() < 1e-6, "bounce_rate: {bounce_rate}");
    }

    #[test]
    fn historical_send_rates_empty_window_returns_zero() {
        let db = fresh_db();
        let (complaint_rate, bounce_rate, delivered) = db.historical_send_rates(30).unwrap();
        assert_eq!(delivered, 0);
        assert_eq!(complaint_rate, 0.0);
        assert_eq!(bounce_rate, 0.0);
    }
```

- [ ] **Step 4.2: Run, expect FAIL**

Run: `cargo test historical_send_rates -- --test-threads=1 2>&1 | tail -10`
Expected: FAIL — method not defined.

- [ ] **Step 4.3: Implement `historical_send_rates`**

In `src/db/mod.rs`, next to the other report methods (search for `report_deliverability`), add:

```rust
    /// Compute complaint + bounce rate over the last `days` days based on
    /// the `event` table. Returns `(complaint_rate, bounce_rate, delivered)`
    /// where both rates are fractions (0.005 = 0.5%) over the delivered
    /// count in the same window. Returns `(0.0, 0.0, 0)` if there are no
    /// delivered events in the window.
    ///
    /// Used by the broadcast send preflight to refuse sends that would
    /// worsen domain reputation.
    pub fn historical_send_rates(&self, days: i64) -> Result<(f64, f64, i64), AppError> {
        let since = (chrono::Utc::now() - chrono::Duration::days(days)).to_rfc3339();
        let delivered: i64 = self
            .conn
            .query_row(
                "SELECT COUNT(*) FROM event
                 WHERE type = 'email.delivered' AND received_at >= ?1",
                rusqlite::params![since],
                |r| r.get(0),
            )
            .map_err(query_err)?;
        if delivered == 0 {
            return Ok((0.0, 0.0, 0));
        }
        let complained: i64 = self
            .conn
            .query_row(
                "SELECT COUNT(*) FROM event
                 WHERE type = 'email.complained' AND received_at >= ?1",
                rusqlite::params![since],
                |r| r.get(0),
            )
            .map_err(query_err)?;
        let bounced: i64 = self
            .conn
            .query_row(
                "SELECT COUNT(*) FROM event
                 WHERE type = 'email.bounced' AND received_at >= ?1",
                rusqlite::params![since],
                |r| r.get(0),
            )
            .map_err(query_err)?;
        Ok((
            complained as f64 / delivered as f64,
            bounced as f64 / delivered as f64,
            delivered,
        ))
    }
```

- [ ] **Step 4.4: Run the tests — expect PASS**

Run: `cargo test historical_send_rates -- --test-threads=1 2>&1 | tail -5`
Expected: 2 passed.

- [ ] **Step 4.5: Wire the check into `preflight_checks`**

Open `src/broadcast/pipeline.rs`. The `preflight_checks` signature currently takes `(&Config, &str html, &str subject, usize recipient_count)`. We need DB access, so add `&Db` as the first parameter.

Update the signature and the single call site at line 84. Then replace the stubbed line 436 (`// Invariant 4/5: complaint and bounce rate — Phase 6 job. Stubbed pass.`) with:

```rust
    // Invariant 4+5 (v0.3): complaint and bounce rate over the last 30 days.
    // Statistically-meaningful threshold: require at least 100 delivered
    // events in the window before enforcing, so a brand-new account isn't
    // blocked by its first 10 bounces.
    const MIN_DELIVERED_FOR_RATE_CHECK: i64 = 100;
    let (complaint_rate, bounce_rate, delivered) = db.historical_send_rates(30)?;
    if delivered >= MIN_DELIVERED_FOR_RATE_CHECK {
        if complaint_rate > config.guards.max_complaint_rate {
            return Err(AppError::BadInput {
                code: "complaint_rate_exceeds_guard".into(),
                message: format!(
                    "historical complaint rate {:.4}% over last 30 days exceeds max_complaint_rate {:.4}% (delivered={}, threshold={} in config.guards)",
                    complaint_rate * 100.0,
                    config.guards.max_complaint_rate * 100.0,
                    delivered,
                    config.guards.max_complaint_rate
                ),
                suggestion: "Investigate recent complaint sources before sending more; consider pruning the list or pausing sends for 7 days. Override by raising [guards].max_complaint_rate in config.toml (NOT recommended — Gmail/Yahoo will block you at 0.3%).".into(),
            });
        }
        if bounce_rate > config.guards.max_bounce_rate {
            return Err(AppError::BadInput {
                code: "bounce_rate_exceeds_guard".into(),
                message: format!(
                    "historical bounce rate {:.4}% over last 30 days exceeds max_bounce_rate {:.4}% (delivered={}, threshold={} in config.guards)",
                    bounce_rate * 100.0,
                    config.guards.max_bounce_rate * 100.0,
                    delivered,
                    config.guards.max_bounce_rate
                ),
                suggestion: "Prune hard-bounced contacts before sending more; most have already been auto-suppressed but list hygiene matters. Override by raising [guards].max_bounce_rate (NOT recommended).".into(),
            });
        }
    }
    Ok(())
}
```

- [ ] **Step 4.6: Write an integration test for the guard**

Create `tests/preflight_tests.rs`:

```rust
//! Integration tests for broadcast preflight invariants (complaint rate
//! and bounce rate guards over the 30-day window).

use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::TempDir;

fn setup_env(tmp: &TempDir) -> std::collections::HashMap<String, String> {
    let mut env = std::collections::HashMap::new();
    env.insert(
        "MLC_CONFIG_PATH".into(),
        tmp.path().join("config.toml").to_string_lossy().into(),
    );
    env.insert(
        "MLC_DB_PATH".into(),
        tmp.path().join("state.db").to_string_lossy().into(),
    );
    env.insert(
        "MLC_CACHE_DIR".into(),
        tmp.path().join("cache").to_string_lossy().into(),
    );
    env.insert(
        "MLC_UNSUBSCRIBE_SECRET".into(),
        "test-preflight-secret-at-least-16-bytes".into(),
    );
    let manifest = env!("CARGO_MANIFEST_DIR");
    let stub = format!("{manifest}/tests/support/stub_email_cli.sh");
    std::fs::write(
        tmp.path().join("config.toml"),
        format!(
            r#"[sender]
from = "test@example.com"
physical_address = "123 Test St"

[email_cli]
path = "{stub}"
profile = "test"

[unsubscribe]
public_url = "https://hooks.example.com/u"
secret_env = "MLC_UNSUBSCRIBE_SECRET"

[guards]
max_complaint_rate = 0.003
max_bounce_rate = 0.04
max_recipients_per_send = 50000
"#
        ),
    )
    .unwrap();
    env
}

#[test]
fn preflight_blocks_send_when_historical_complaint_rate_exceeds_guard() {
    let tmp = TempDir::new().unwrap();
    let env = setup_env(&tmp);

    // Seed a list + 1 contact + template.
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["list", "create", "test"])
        .assert()
        .success();
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args([
            "contact",
            "add",
            "--list",
            "1",
            "user1@example.com",
        ])
        .assert()
        .success();
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["template", "create", "t", "--subject", "s"])
        .assert()
        .success();
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args([
            "broadcast",
            "create",
            "--name",
            "b",
            "--template",
            "t",
            "--to",
            "list:test",
        ])
        .assert()
        .success();

    // Seed events directly via sqlite — 1000 delivered, 10 complained = 1% complaint rate
    let db_path = tmp.path().join("state.db");
    let conn = rusqlite::Connection::open(&db_path).unwrap();
    let now = chrono::Utc::now().to_rfc3339();
    for i in 0..1_000 {
        conn.execute(
            "INSERT INTO event (type, resend_email_id, payload_json, received_at)
             VALUES ('email.delivered', ?1, '{}', ?2)",
            rusqlite::params![format!("em_d_{i}"), now],
        )
        .unwrap();
    }
    for i in 0..10 {
        conn.execute(
            "INSERT INTO event (type, resend_email_id, payload_json, received_at)
             VALUES ('email.complained', ?1, '{}', ?2)",
            rusqlite::params![format!("em_c_{i}"), now],
        )
        .unwrap();
    }
    drop(conn);

    // Attempt to send: should fail with exit code 3 and
    // complaint_rate_exceeds_guard error code.
    let output = Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["broadcast", "send", "1"])
        .assert()
        .failure()
        .code(3);
    let stdout = String::from_utf8_lossy(&output.get_output().stdout).to_string();
    let parsed: Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(
        parsed.pointer("/error/code").and_then(Value::as_str),
        Some("complaint_rate_exceeds_guard"),
        "unexpected response: {stdout}"
    );
}
```

- [ ] **Step 4.7: Run the full test suite**

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: unit 107 passed (105 + 2 new), integration 63 passed (62 + 1 new).

- [ ] **Step 4.8: Format, lint, commit**

```bash
cargo fmt && cargo clippy --all-targets -- -D warnings 2>&1 | tail -5
git add src/db/mod.rs src/broadcast/pipeline.rs tests/preflight_tests.rs
git commit -m "$(cat <<'EOF'
feat(preflight): enforce complaint/bounce rate guards over 30-day window

Wires up the previously-stubbed complaint_rate and bounce_rate checks
in broadcast send preflight. Queries the `event` table for the last
30 days, computes (complained/delivered) and (bounced/delivered)
rates, compares to the config thresholds
(max_complaint_rate=0.003, max_bounce_rate=0.04 by default).

To avoid false positives on brand-new accounts the check is only
enforced once the window has at least 100 delivered events
(MIN_DELIVERED_FOR_RATE_CHECK). Below that the check passes through.

Returns AppError::BadInput with code=complaint_rate_exceeds_guard
(or bounce_rate_exceeds_guard) and a suggestion pointing at the
config override — BUT the suggestion also warns that raising
max_complaint_rate is a reputation-destroying move since Gmail and
Yahoo cut senders off at 0.3%.

Integration test seeds 1000 delivered + 10 complained events and
asserts that `broadcast send` exits 3 with the right error code.
EOF
)"
```

---

## Task 5: `contact erase` command (GDPR Article 17)

**Why:** The `gdpr_erasure` enum value exists in the `suppression.reason` CHECK constraint but there's no way to actually erase a contact. GDPR Article 17 "Right to erasure" is a hard requirement for any enterprise with EU data. An erase must: (1) delete the contact row, (2) cascade-delete all owned child rows (tags, field values, list memberships, broadcast_recipient history), (3) leave a tombstone in the suppression table with reason=`gdpr_erasure` so any future `contact add` with that email gets filtered pre-send.

**Strategy:** Add a new `ContactAction::Erase` variant. The handler calls a new `db.contact_erase(email)` which runs inside a transaction: look up the contact id, delete it (cascades via FOREIGN KEY ON DELETE CASCADE already in the schema), insert the suppression tombstone.

**Files:**
- Modify: `src/cli.rs:122` — add `Erase(ContactEraseArgs)` variant
- Modify: `src/commands/contact.rs` — add `erase()` handler
- Modify: `src/db/mod.rs` — add `contact_erase(email)`
- Modify: `tests/` — add integration test

- [ ] **Step 5.1: Failing DB unit test**

Add to `src/db/mod.rs` tests module:

```rust
    #[test]
    fn contact_erase_deletes_row_and_adds_gdpr_tombstone() {
        let db = fresh_db();
        let list_id = db.list_create("l", None, "seg_x").unwrap();
        let cid = db
            .contact_upsert("alice@example.com", Some("Alice"), None)
            .unwrap();
        db.contact_add_to_list(cid, list_id).unwrap();
        let tag_id = db.tag_upsert("vip").unwrap();
        db.contact_tag_add(cid, tag_id).unwrap();

        // Erase.
        db.contact_erase("alice@example.com").unwrap();

        // Row is gone.
        let lookup = db.contact_get_by_email("alice@example.com").unwrap();
        assert!(lookup.is_none(), "contact row should be deleted");

        // Cascaded — no orphan tags.
        let tag_count: i64 = db
            .conn
            .query_row(
                "SELECT COUNT(*) FROM contact_tag WHERE contact_id = ?1",
                [cid],
                |r| r.get(0),
            )
            .unwrap();
        assert_eq!(tag_count, 0);

        // Suppression tombstone present with the GDPR reason.
        let row: (String, String) = db
            .conn
            .query_row(
                "SELECT email, reason FROM suppression WHERE email = ?1 COLLATE NOCASE",
                ["alice@example.com"],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .unwrap();
        assert_eq!(row.0.to_ascii_lowercase(), "alice@example.com");
        assert_eq!(row.1, "gdpr_erasure");

        // Re-adding the same email must be blocked by the suppression hashset.
        let set = db.suppression_all_emails().unwrap();
        assert!(set.contains("alice@example.com"));
    }

    #[test]
    fn contact_erase_nonexistent_email_is_error() {
        let db = fresh_db();
        let result = db.contact_erase("nobody@example.com");
        assert!(result.is_err());
    }
```

- [ ] **Step 5.2: Run, expect FAIL**

Run: `cargo test contact_erase -- --test-threads=1 2>&1 | tail -10`
Expected: FAIL — `contact_erase` not defined.

- [ ] **Step 5.3: Implement `contact_erase`**

In `src/db/mod.rs`, after the `contact_set_status` method:

```rust
    /// GDPR Article 17 "Right to erasure" primitive. Deletes the contact
    /// row (cascades to list_membership, contact_tag, contact_field_value,
    /// broadcast_recipient via FOREIGN KEY ON DELETE CASCADE) and inserts
    /// a suppression tombstone with reason='gdpr_erasure' so that any
    /// future `contact add` of the same email is blocked pre-send by the
    /// suppression filter.
    ///
    /// Runs inside a transaction: either all writes succeed or none do.
    pub fn contact_erase(&mut self, email: &str) -> Result<(), AppError> {
        // Look up the contact first so we can return a clear error if it
        // doesn't exist.
        let contact = self.contact_get_by_email(email)?;
        let _contact = contact.ok_or_else(|| AppError::BadInput {
            code: "contact_not_found".into(),
            message: format!("no contact with email '{email}'"),
            suggestion: "Check the email spelling; erase is idempotent but fails if the row never existed".into(),
        })?;

        let tx = self.conn.transaction().map_err(|e| AppError::Transient {
            code: "tx_begin_failed".into(),
            message: format!("begin transaction failed: {e}"),
            suggestion: "Retry — the DB is probably busy".into(),
        })?;

        // 1. Insert the suppression tombstone BEFORE deleting the contact,
        //    so the row cannot be momentarily absent from both places.
        let now = chrono::Utc::now().to_rfc3339();
        tx.execute(
            "INSERT OR REPLACE INTO suppression (email, reason, suppressed_at, source_broadcast_id)
             VALUES (?1, 'gdpr_erasure', ?2, NULL)",
            rusqlite::params![email, now],
        )
        .map_err(query_err)?;

        // 2. Delete the contact row. FK cascades handle the child tables:
        //    list_membership, contact_tag, contact_field_value,
        //    broadcast_recipient, soft_bounce_count, optin_token.
        tx.execute(
            "DELETE FROM contact WHERE email = ?1 COLLATE NOCASE",
            rusqlite::params![email],
        )
        .map_err(query_err)?;

        tx.commit().map_err(|e| AppError::Transient {
            code: "tx_commit_failed".into(),
            message: format!("commit transaction failed: {e}"),
            suggestion: "Retry — the DB is probably busy".into(),
        })?;
        Ok(())
    }
```

- [ ] **Step 5.4: Run the DB tests — expect PASS**

Run: `cargo test contact_erase -- --test-threads=1 2>&1 | tail -5`
Expected: 2 passed.

- [ ] **Step 5.5: Add the `Erase` CLI variant**

In `src/cli.rs`, find the `ContactAction` enum (line 122). Add a new variant **at the end**:

```rust
    /// GDPR Article 17 erasure: delete a contact and every row that
    /// references it, then insert a `gdpr_erasure` suppression tombstone
    /// so the address cannot be re-added without the tombstone first
    /// being removed manually.
    Erase(ContactEraseArgs),
}
```

Then add the `ContactEraseArgs` struct below (after `ContactShowArgs`, search for `pub struct ContactShowArgs`):

```rust
#[derive(Args, Debug)]
pub struct ContactEraseArgs {
    /// Email address of the contact to erase
    pub email: String,
    /// Required confirmation flag — erase is irreversible
    #[arg(long)]
    pub confirm: bool,
}
```

- [ ] **Step 5.6: Wire the command handler**

In `src/commands/contact.rs`, find the `run()` function (line 9). Add the new branch inside `match action`:

```rust
        ContactAction::Erase(args) => erase(format, &mut db, args),
```

(Note: the handler needs `&mut db` because `contact_erase` takes `&mut self`. The existing `run()` already declares `let mut db = Db::open()?;`.)

Then add the new handler function anywhere in the file:

```rust
fn erase(
    format: Format,
    db: &mut Db,
    args: crate::cli::ContactEraseArgs,
) -> Result<(), AppError> {
    if !args.confirm {
        return Err(AppError::BadInput {
            code: "confirm_required".into(),
            message: "`contact erase` requires --confirm because it is irreversible".into(),
            suggestion: format!(
                "Rerun with: mailing-list-cli contact erase {} --confirm",
                args.email
            ),
        });
    }
    db.contact_erase(&args.email)?;
    output::success(
        format,
        &format!("contact '{}' erased (GDPR Article 17)", args.email),
        json!({
            "email": args.email,
            "suppression_reason": "gdpr_erasure",
            "irreversible": true,
        }),
    );
    Ok(())
}
```

- [ ] **Step 5.7: Failing integration test**

Add to `tests/contact_tests.rs` (or wherever contact integration tests live — find with `rg -l contact_tests`). If the file doesn't exist, create `tests/contact_erase_test.rs`:

```rust
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::TempDir;

fn setup(tmp: &TempDir) -> std::collections::HashMap<String, String> {
    let mut env = std::collections::HashMap::new();
    env.insert(
        "MLC_CONFIG_PATH".into(),
        tmp.path().join("config.toml").to_string_lossy().into(),
    );
    env.insert(
        "MLC_DB_PATH".into(),
        tmp.path().join("state.db").to_string_lossy().into(),
    );
    env.insert(
        "MLC_CACHE_DIR".into(),
        tmp.path().join("cache").to_string_lossy().into(),
    );
    env.insert(
        "MLC_UNSUBSCRIBE_SECRET".into(),
        "test-erase-secret-at-least-16-bytes".into(),
    );
    let manifest = env!("CARGO_MANIFEST_DIR");
    let stub = format!("{manifest}/tests/support/stub_email_cli.sh");
    std::fs::write(
        tmp.path().join("config.toml"),
        format!(
            r#"[sender]
from = "test@example.com"
physical_address = "123 Test St"

[email_cli]
path = "{stub}"
profile = "test"

[unsubscribe]
public_url = "https://hooks.example.com/u"
secret_env = "MLC_UNSUBSCRIBE_SECRET"
"#
        ),
    )
    .unwrap();
    env
}

#[test]
fn contact_erase_requires_confirm() {
    let tmp = TempDir::new().unwrap();
    let env = setup(&tmp);
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["list", "create", "l"])
        .assert()
        .success();
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["contact", "add", "--list", "1", "a@example.com"])
        .assert()
        .success();
    let output = Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["contact", "erase", "a@example.com"])
        .assert()
        .failure()
        .code(3);
    let stdout = String::from_utf8_lossy(&output.get_output().stdout).to_string();
    let v: Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(
        v.pointer("/error/code").and_then(Value::as_str),
        Some("confirm_required")
    );
}

#[test]
fn contact_erase_with_confirm_deletes_and_tombstones() {
    let tmp = TempDir::new().unwrap();
    let env = setup(&tmp);
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["list", "create", "l"])
        .assert()
        .success();
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["contact", "add", "--list", "1", "a@example.com"])
        .assert()
        .success();
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args([
            "contact",
            "erase",
            "a@example.com",
            "--confirm",
        ])
        .assert()
        .success();
    // Verify: contact is gone AND a suppression tombstone exists.
    let db_path = tmp.path().join("state.db");
    let conn = rusqlite::Connection::open(&db_path).unwrap();
    let contact_count: i64 = conn
        .query_row(
            "SELECT COUNT(*) FROM contact WHERE email = 'a@example.com'",
            [],
            |r| r.get(0),
        )
        .unwrap();
    assert_eq!(contact_count, 0);
    let supp: String = conn
        .query_row(
            "SELECT reason FROM suppression WHERE email = 'a@example.com'",
            [],
            |r| r.get(0),
        )
        .unwrap();
    assert_eq!(supp, "gdpr_erasure");
}
```

- [ ] **Step 5.8: Run the full suite**

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: unit 109 passed (107 + 2 new), integration 65 passed (63 + 2 new).

- [ ] **Step 5.9: Format, lint, commit**

```bash
cargo fmt && cargo clippy --all-targets -- -D warnings 2>&1 | tail -5
git add src/cli.rs src/commands/contact.rs src/db/mod.rs tests/contact_erase_test.rs
git commit -m "$(cat <<'EOF'
feat(contact): add `contact erase` for GDPR Article 17 compliance

Adds a `contact erase <email> --confirm` command that atomically:
  1. Inserts a `suppression` row with reason='gdpr_erasure'
  2. Deletes the `contact` row (FK ON DELETE CASCADE handles
     list_membership, contact_tag, contact_field_value,
     broadcast_recipient, soft_bounce_count, optin_token)

The order matters: suppression tombstone goes in BEFORE the delete,
so the email cannot be momentarily absent from both places. Wrapped
in a single rusqlite::Transaction; a mid-commit crash leaves the DB
exactly as it was pre-call.

The `gdpr_erasure` value was already in the
suppression.reason CHECK constraint from day one (migration 0001)
but had no command wired to it. This closes the gap.

`--confirm` is mandatory because the operation is irreversible and
bypasses the usual suppression-list soft-block.

DB test covers row deletion + cascade + tombstone insertion.
Integration tests cover confirm-required path and happy path via
the CLI binary.
EOF
)"
```

---

## Task 6: `broadcast resume` with chunk-level progress tracking

**Why:** Today if `mailing-list-cli broadcast send 5` is killed mid-send at chunk 47/100, the broadcast is stuck in `sending` status with some recipients marked `sent` and the rest `pending`. You CAN re-run `broadcast send 5` and the existing code accepts the `sending` status, but it will re-render and re-queue every chunk from 0 — potentially double-sending to any recipients already marked `sent`. v0.2.3 relies on `INSERT OR IGNORE` dedup but the re-render path itself touches Resend again.

**Strategy:** Skip already-sent recipients at the top of the chunk loop. The DB already tracks per-recipient `status` in `broadcast_recipient`. Load the set of "already sent" contact IDs once, filter `to_send` to exclude them. Rename `broadcast send <id>` to also handle `sending` status as "resume this" and print a clear message. Add a `broadcast resume <id>` **alias** that is more discoverable (both command paths call the same underlying code).

**Files:**
- Modify: `src/db/mod.rs` — add `broadcast_recipient_already_sent_ids(broadcast_id) -> HashSet<i64>`
- Modify: `src/broadcast/pipeline.rs` — skip already-sent in `to_send` computation
- Modify: `src/cli.rs` + `src/commands/broadcast.rs` — add `resume` as a subcommand

- [ ] **Step 6.1: Failing test**

Create `tests/broadcast_resume_test.rs`:

```rust
//! Integration test for `broadcast send` resume semantics: if a broadcast
//! already has some recipients in status='sent', resuming should skip them
//! and only process the still-pending ones.

use assert_cmd::Command;
use serde_json::Value;
use tempfile::TempDir;

fn setup(tmp: &TempDir) -> std::collections::HashMap<String, String> {
    let mut env = std::collections::HashMap::new();
    env.insert(
        "MLC_CONFIG_PATH".into(),
        tmp.path().join("config.toml").to_string_lossy().into(),
    );
    env.insert(
        "MLC_DB_PATH".into(),
        tmp.path().join("state.db").to_string_lossy().into(),
    );
    env.insert(
        "MLC_CACHE_DIR".into(),
        tmp.path().join("cache").to_string_lossy().into(),
    );
    env.insert(
        "MLC_UNSUBSCRIBE_SECRET".into(),
        "test-resume-secret-at-least-16-bytes".into(),
    );
    let manifest = env!("CARGO_MANIFEST_DIR");
    let stub = format!("{manifest}/tests/support/stub_email_cli.sh");
    std::fs::write(
        tmp.path().join("config.toml"),
        format!(
            r#"[sender]
from = "test@example.com"
physical_address = "123 Test St"

[email_cli]
path = "{stub}"
profile = "test"

[unsubscribe]
public_url = "https://hooks.example.com/u"
secret_env = "MLC_UNSUBSCRIBE_SECRET"
"#
        ),
    )
    .unwrap();
    env
}

#[test]
fn broadcast_send_skips_already_sent_recipients_on_resume() {
    let tmp = TempDir::new().unwrap();
    let env = setup(&tmp);
    // Seed list + 3 contacts + template + broadcast
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["list", "create", "l"])
        .assert()
        .success();
    for email in &["a@ex.com", "b@ex.com", "c@ex.com"] {
        Command::cargo_bin("mailing-list-cli")
            .unwrap()
            .envs(&env)
            .args(["contact", "add", "--list", "1", email])
            .assert()
            .success();
    }
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["template", "create", "t", "--subject", "s"])
        .assert()
        .success();
    Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args([
            "broadcast",
            "create",
            "--name",
            "b",
            "--template",
            "t",
            "--to",
            "list:l",
        ])
        .assert()
        .success();

    // Fake a partial send: mark contact 1 as already sent by directly
    // inserting a broadcast_recipient row and setting status='sending' on
    // the broadcast.
    let db_path = tmp.path().join("state.db");
    let conn = rusqlite::Connection::open(&db_path).unwrap();
    conn.execute("UPDATE broadcast SET status = 'sending' WHERE id = 1", [])
        .unwrap();
    conn.execute(
        "INSERT INTO broadcast_recipient (broadcast_id, contact_id, status, resend_email_id, sent_at)
         VALUES (1, 1, 'sent', 'em_already', ?1)",
        rusqlite::params![chrono::Utc::now().to_rfc3339()],
    )
    .unwrap();
    drop(conn);

    // Resume via `broadcast send 1`. The stub will succeed; we expect
    // sent_count=2 (a and b and c minus the already-sent one), NOT 3.
    let output = Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["broadcast", "send", "1"])
        .assert()
        .success();
    let stdout = String::from_utf8_lossy(&output.get_output().stdout).to_string();
    let parsed: Value = serde_json::from_str(&stdout).unwrap();
    let sent = parsed
        .pointer("/data/sent")
        .and_then(Value::as_i64)
        .unwrap();
    assert_eq!(
        sent, 2,
        "resume should skip the already-sent contact; got stdout={stdout}"
    );
}
```

- [ ] **Step 6.2: Run, expect FAIL**

Run: `cargo test --test broadcast_resume_test -- --test-threads=1 2>&1 | tail -10`
Expected: FAIL — resume support not implemented; current code would report sent=3 because it re-sends to all contacts.

- [ ] **Step 6.3: Add the DB helper**

In `src/db/mod.rs`, near the other `broadcast_recipient_*` methods:

```rust
    /// Return the set of contact IDs already marked as 'sent' for the
    /// given broadcast. Used by `broadcast send` resume logic to skip
    /// recipients already processed in a previous (interrupted) run.
    pub fn broadcast_recipient_already_sent_ids(
        &self,
        broadcast_id: i64,
    ) -> Result<std::collections::HashSet<i64>, AppError> {
        let mut stmt = self
            .conn
            .prepare(
                "SELECT contact_id FROM broadcast_recipient
                 WHERE broadcast_id = ?1 AND status = 'sent'",
            )
            .map_err(query_err)?;
        let rows = stmt
            .query_map([broadcast_id], |row| row.get::<_, i64>(0))
            .map_err(query_err)?;
        let mut set = std::collections::HashSet::new();
        for row in rows {
            set.insert(row.map_err(query_err)?);
        }
        Ok(set)
    }
```

- [ ] **Step 6.4: Filter `to_send` in the pipeline**

In `src/broadcast/pipeline.rs`, after the suppression filter (where `to_send` is populated), add:

```rust
    // v0.3: resume support — drop recipients already marked 'sent' from
    // a previous interrupted run. The suppression filter above has already
    // run, so this filter is stacked on top.
    let already_sent: std::collections::HashSet<i64> =
        db.broadcast_recipient_already_sent_ids(id)?;
    let resume_skipped = already_sent.len();
    if resume_skipped > 0 {
        eprintln!(
            "broadcast {id}: resume mode — {resume_skipped} recipient(s) already sent, skipping"
        );
    }
    to_send.retain(|c| !already_sent.contains(&c.id));
```

- [ ] **Step 6.5: Add `broadcast resume` as a CLI alias**

In `src/cli.rs`, find the `BroadcastAction` enum. Add:

```rust
    /// Alias for `broadcast send` that is explicit about resuming an
    /// interrupted broadcast. Accepts a broadcast id. Behaves identically
    /// to `broadcast send` — both skip already-sent recipients, so calling
    /// resume on a fresh draft is safe (it's just an empty skip-set).
    Resume(BroadcastSendArgs),
```

Then in `src/commands/broadcast.rs`, find `match action` (probably in a `run()` function). Add:

```rust
        BroadcastAction::Resume(args) => send(format, args),
```

(Same handler — resume is just `send` with a clearer name for the already-sent skip path.)

- [ ] **Step 6.6: Run the integration test — expect PASS**

Run: `cargo test --test broadcast_resume_test -- --test-threads=1 2>&1 | tail -10`
Expected: 1 passed.

- [ ] **Step 6.7: Full suite**

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: unit 109 passed, integration 66 passed (65 + 1 new).

- [ ] **Step 6.8: Format, lint, commit**

```bash
cargo fmt && cargo clippy --all-targets -- -D warnings 2>&1 | tail -5
git add src/db/mod.rs src/broadcast/pipeline.rs src/cli.rs src/commands/broadcast.rs tests/broadcast_resume_test.rs
git commit -m "$(cat <<'EOF'
feat(broadcast): resumable sends + `broadcast resume` alias

Before v0.3, a broadcast killed mid-send left the DB in `sending`
status; re-running `broadcast send <id>` would re-render every chunk
from zero and hit Resend again for every recipient, relying only on
INSERT OR IGNORE dedup at the DB level. Over 10k recipients that means
a kill at chunk 50 wasted ~5000 Resend API calls and risked
double-sending.

v0.3 adds explicit resume semantics:

1. `db.broadcast_recipient_already_sent_ids(id)` returns the set of
   contact IDs already marked 'sent' for this broadcast.
2. The send pipeline filters these out of `to_send` before the chunk
   loop starts, logs "resume mode — N recipient(s) already sent" to
   stderr, and only processes the remaining pending recipients.
3. `broadcast resume <id>` is added as a CLI alias for `broadcast
   send <id>` — same handler, clearer intent. Both commands accept
   broadcasts in `sending` status (already did in v0.2) plus the new
   skip-already-sent behavior.

Combined with Task 3's per-chunk transactions, a crash mid-send now
has bounded blast radius: the chunk being written either commits or
rolls back, and every subsequent `resume` picks up cleanly.

Integration test fakes a partial-sent state by direct DB manipulation
then asserts the resume call sends 2 recipients instead of 3.
EOF
)"
```

---

## Task 7: Tracking-configuration surfacing

**Why:** As established in the v0.2.3 tracking analysis, Resend's open/click tracking is toggled per-domain in the Resend dashboard (or via API). If disabled, no `email.opened` / `email.clicked` webhooks fire. An agent running `report show` sees `opened_count: 0` with no explanation and assumes the CLI is broken.

**Strategy:** Add a `domain_get(domain)` passthrough to `EmailCli` that shells out to `email-cli domain get <id>` and returns the tracking settings. Use it in:
1. `health` check — emit a warning finding if either `click_tracking` or `open_tracking` is disabled on the sender domain
2. `broadcast send` preflight — emit a warning (not error) at send time
3. `report show` output — include a `tracking_enabled` field so `opened_count: 0` is self-explanatory

**Note:** This task assumes email-cli v0.6+ exposes a `domain get` command that returns tracking settings. Before starting, verify: `email-cli domain get --help`. If the passthrough doesn't exist, either (a) defer this task to email-cli first, or (b) query Resend's `/domains/:id` endpoint directly via a small HTTP call (violates the "no Resend HTTP code in mailing-list-cli" doctrine — option a is strictly preferred).

**Files:**
- Modify: `src/email_cli.rs` — add `domain_get` method
- Modify: `src/commands/health.rs` — add tracking-config check
- Modify: `src/broadcast/pipeline.rs:preflight_checks` — add warning (stderr, non-fatal)
- Modify: `src/commands/report.rs:show` — include tracking flags in output
- Modify: `src/models.rs` — add `tracking_enabled` field to `BroadcastSummary`

- [ ] **Step 7.1: Verify email-cli exposes domain info**

Run: `email-cli domain get --help 2>&1`
Expected: see what flags exist. If `email-cli domain get <DOMAIN>` returns JSON with `click_tracking` / `open_tracking` fields, continue. If not, STOP this task, open an issue upstream in email-cli, and defer this task to v0.3.1.

- [ ] **Step 7.2: Add `EmailCli::domain_get`**

In `src/email_cli.rs`, near the other passthrough methods:

```rust
    /// Shell out to `email-cli domain get <domain>` and return the parsed
    /// JSON. Used by `health` and `broadcast send` preflight to check
    /// whether open/click tracking is enabled on the sender domain.
    ///
    /// Returns None if the domain is not known to email-cli (the normal
    /// case during first-run setup before any `email-cli domain add`).
    pub fn domain_get(&self, domain: &str) -> Result<Option<Value>, AppError> {
        self.throttle();
        let output = Command::new(&self.path)
            .args(["--json", "domain", "get", domain])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output()
            .map_err(|e| AppError::Config {
                code: "email_cli_invoke_failed".into(),
                message: format!("could not run email-cli domain get: {e}"),
                suggestion: "Check that email-cli is on PATH".into(),
            })?;
        if !output.status.success() {
            // Treat "not found" as None rather than an error.
            let stderr = String::from_utf8_lossy(&output.stderr);
            if stderr.contains("not found") || stderr.contains("domain_not_found") {
                return Ok(None);
            }
            return Err(AppError::Transient {
                code: "domain_get_failed".into(),
                message: format!(
                    "email-cli domain get failed: {}",
                    stderr.lines().next().unwrap_or("(no stderr)")
                ),
                suggestion: "Run `email-cli domain ls` to verify the domain exists".into(),
            });
        }
        let parsed: Value =
            serde_json::from_slice(&output.stdout).map_err(|e| AppError::Transient {
                code: "domain_get_parse".into(),
                message: format!("invalid JSON: {e}"),
                suggestion: "Check email-cli version".into(),
            })?;
        Ok(parsed.get("data").cloned())
    }
```

- [ ] **Step 7.3: Tracking-config check in health command**

Open `src/commands/health.rs`. Find the main `run` function. After the existing checks, add:

```rust
    // v0.3: check open/click tracking config on the sender domain.
    // If the `from` address is set, extract the domain portion and query
    // email-cli. Disabled tracking is a warning, not an error.
    if let Some(from) = config.sender.from.as_deref() {
        if let Some(at) = from.rfind('@') {
            let domain = &from[at + 1..];
            match cli.domain_get(domain) {
                Ok(Some(data)) => {
                    let open = data.get("open_tracking").and_then(Value::as_bool).unwrap_or(false);
                    let click = data.get("click_tracking").and_then(Value::as_bool).unwrap_or(false);
                    checks.push(HealthCheck {
                        name: "tracking_config".into(),
                        state: if open && click { "ok".into() } else { "warning".into() },
                        message: if open && click {
                            format!("open + click tracking enabled on {domain}")
                        } else {
                            format!(
                                "{domain}: open_tracking={open} click_tracking={click} — enable both in Resend dashboard (Domains → {domain} → Configuration) for report show to populate open/click counts"
                            )
                        },
                    });
                }
                Ok(None) => {
                    checks.push(HealthCheck {
                        name: "tracking_config".into(),
                        state: "warning".into(),
                        message: format!("domain '{domain}' not known to email-cli — run `email-cli domain add {domain}` first"),
                    });
                }
                Err(_) => {
                    // email-cli domain get failed but we already have a
                    // working email_cli check, so don't double-alert.
                }
            }
        }
    }
```

(NOTE: this assumes the file already has `checks: Vec<HealthCheck>` and imports. Inspect the file first and adapt imports / type names to the actual existing code.)

- [ ] **Step 7.4: Warning in broadcast preflight**

In `src/broadcast/pipeline.rs:preflight_checks`, after the existing invariants but before the `Ok(())` return, add:

```rust
    // v0.3: surface disabled tracking as a warning on stderr. Don't block
    // the send — the user might be sending a transactional email where
    // tracking is intentionally off. But print the warning so "opened=0
    // in report show" isn't mysterious later.
    if let Some(from) = config.sender.from.as_deref() {
        if let Some(at) = from.rfind('@') {
            let domain = &from[at + 1..];
            let cli = crate::email_cli::EmailCli::new(
                &config.email_cli.path,
                &config.email_cli.profile,
            );
            if let Ok(Some(data)) = cli.domain_get(domain) {
                let open = data.get("open_tracking").and_then(|v| v.as_bool()).unwrap_or(false);
                let click = data.get("click_tracking").and_then(|v| v.as_bool()).unwrap_or(false);
                if !open || !click {
                    eprintln!(
                        "WARNING: tracking is not fully enabled on domain {domain} (open={open} click={click}). `report show` will not populate opened_count / clicked_count for this broadcast. Enable both in the Resend dashboard under Domains → {domain} → Configuration before your next broadcast if you want engagement metrics."
                    );
                }
            }
        }
    }
```

- [ ] **Step 7.5: Add tracking fields to `report show` output**

In `src/commands/report.rs::show`, after the existing `summary` load and before the `output::success` call:

```rust
fn show(format: Format, args: ReportShowArgs) -> Result<(), AppError> {
    let db = Db::open()?;
    let summary = db.report_summary(args.broadcast_id)?;
    let config = crate::config::Config::load()?;
    let cli = crate::email_cli::EmailCli::new(
        &config.email_cli.path,
        &config.email_cli.profile,
    );
    // v0.3: attach tracking_enabled config to the output so opened_count=0
    // is self-explanatory when tracking is off.
    let tracking_enabled = config
        .sender
        .from
        .as_deref()
        .and_then(|from| from.rfind('@').map(|at| from[at + 1..].to_string()))
        .and_then(|domain| cli.domain_get(&domain).ok().flatten().map(|data| (domain, data)))
        .map(|(_, data)| serde_json::json!({
            "opens": data.get("open_tracking").and_then(|v| v.as_bool()).unwrap_or(false),
            "clicks": data.get("click_tracking").and_then(|v| v.as_bool()).unwrap_or(false),
        }))
        .unwrap_or(serde_json::json!({"opens": null, "clicks": null}));
    output::success(
        format,
        &format!("report for broadcast {}", summary.broadcast_name),
        json!({
            "summary": summary,
            "tracking_enabled": tracking_enabled,
        }),
    );
    Ok(())
}
```

- [ ] **Step 7.6: Integration test — stub email-cli returning both states**

Extend `tests/support/stub_email_cli.sh` to handle `domain get`:

```bash
# (add to the case statement)
*"domain get"*)
  # Read the domain name from args, use env vars to control response
  case "${STUB_EMAIL_CLI_DOMAIN_TRACKING:-full}" in
    off) tracking='{"open_tracking":false,"click_tracking":false}' ;;
    opens_only) tracking='{"open_tracking":true,"click_tracking":false}' ;;
    unknown) echo '{"status":"error","error":{"code":"domain_not_found","message":"domain_not_found"}}' >&2; exit 3 ;;
    *) tracking='{"open_tracking":true,"click_tracking":true}' ;;
  esac
  echo "{\"status\":\"success\",\"data\":$tracking}"
  ;;
```

Create `tests/tracking_config_test.rs` or add to an existing health test file:

```rust
use assert_cmd::Command;
use serde_json::Value;
use tempfile::TempDir;

// (reuse the setup helper pattern from other tests — DRY violation OK
// while test infra is still small)
fn setup(tmp: &TempDir) -> std::collections::HashMap<String, String> {
    let mut env = std::collections::HashMap::new();
    env.insert(
        "MLC_CONFIG_PATH".into(),
        tmp.path().join("config.toml").to_string_lossy().into(),
    );
    env.insert(
        "MLC_DB_PATH".into(),
        tmp.path().join("state.db").to_string_lossy().into(),
    );
    env.insert(
        "MLC_CACHE_DIR".into(),
        tmp.path().join("cache").to_string_lossy().into(),
    );
    env.insert(
        "MLC_UNSUBSCRIBE_SECRET".into(),
        "test-tracking-secret-at-least-16-bytes".into(),
    );
    let manifest = env!("CARGO_MANIFEST_DIR");
    let stub = format!("{manifest}/tests/support/stub_email_cli.sh");
    std::fs::write(
        tmp.path().join("config.toml"),
        format!(
            r#"[sender]
from = "test@example.com"
physical_address = "123 Test St"

[email_cli]
path = "{stub}"
profile = "test"

[unsubscribe]
public_url = "https://hooks.example.com/u"
secret_env = "MLC_UNSUBSCRIBE_SECRET"
"#
        ),
    )
    .unwrap();
    env
}

#[test]
fn health_warns_when_tracking_disabled() {
    let tmp = TempDir::new().unwrap();
    let mut env = setup(&tmp);
    env.insert("STUB_EMAIL_CLI_DOMAIN_TRACKING".into(), "off".into());

    let output = Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["health"])
        .assert()
        .success();
    let stdout = String::from_utf8_lossy(&output.get_output().stdout).to_string();
    let parsed: Value = serde_json::from_str(&stdout).unwrap();
    // Look for the tracking_config check in the findings.
    let checks = parsed
        .pointer("/data/checks")
        .and_then(Value::as_array)
        .unwrap();
    let tracking_check = checks
        .iter()
        .find(|c| c.get("name").and_then(Value::as_str) == Some("tracking_config"))
        .unwrap_or_else(|| panic!("no tracking_config check found in: {stdout}"));
    assert_eq!(
        tracking_check.get("state").and_then(Value::as_str),
        Some("warning"),
        "tracking disabled should be a warning, got: {tracking_check}"
    );
}

#[test]
fn health_ok_when_tracking_enabled() {
    let tmp = TempDir::new().unwrap();
    let mut env = setup(&tmp);
    env.insert("STUB_EMAIL_CLI_DOMAIN_TRACKING".into(), "full".into());

    let output = Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .envs(&env)
        .args(["health"])
        .assert()
        .success();
    let stdout = String::from_utf8_lossy(&output.get_output().stdout).to_string();
    let parsed: Value = serde_json::from_str(&stdout).unwrap();
    let checks = parsed
        .pointer("/data/checks")
        .and_then(Value::as_array)
        .unwrap();
    let tracking_check = checks
        .iter()
        .find(|c| c.get("name").and_then(Value::as_str) == Some("tracking_config"))
        .unwrap();
    assert_eq!(
        tracking_check.get("state").and_then(Value::as_str),
        Some("ok")
    );
}
```

- [ ] **Step 7.7: Run the full suite**

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: unit 109 passed, integration 68 passed (66 + 2 new).

- [ ] **Step 7.8: Format, lint, commit**

```bash
cargo fmt && cargo clippy --all-targets -- -D warnings 2>&1 | tail -5
git add src/email_cli.rs src/commands/health.rs src/commands/report.rs src/broadcast/pipeline.rs tests/support/stub_email_cli.sh tests/tracking_config_test.rs
git commit -m "$(cat <<'EOF'
feat: surface open/click tracking config from Resend domain settings

Resend's open and click tracking are per-domain toggles in the Resend
dashboard. If either is off, `email.opened` / `email.clicked` webhooks
never fire, and `report show` reports opened_count=0 with no
explanation. v0.3 closes that UX gap.

New `EmailCli::domain_get(domain)` shells out to `email-cli domain get
<domain>` and returns the parsed config (None if the domain is not
registered). Used by:

1. `health` — new `tracking_config` check that emits warning if either
   open or click tracking is disabled on the sender domain, with a
   pointer to the Resend dashboard setting.
2. `broadcast send` preflight — same check emitted as a stderr warning
   (not an error, so transactional senders who legitimately want
   tracking off are not blocked).
3. `report show` — new `tracking_enabled: {opens, clicks}` field in
   the JSON output so opened_count=0 is self-explanatory.

Integration tests use the bash email-cli stub with
STUB_EMAIL_CLI_DOMAIN_TRACKING env var set to off|full|opens_only to
simulate each Resend state.

Requires email-cli v0.6+ (verified first via `email-cli domain get
--help` before implementation).
EOF
)"
```

---

## Release Task: Tag v0.3.0, smoke-test, push

**Why:** All 7 tasks landed on `main`; tests green; no regressions. Now: bump version, rebuild release binary, run the mandatory paperfoot.com smoke test (hard rule from session-3 handoff), tag, push.

**Files:**
- Modify: `Cargo.toml`
- Modify: `README.md`
- Modify: `src/commands/agent_info.rs`

- [ ] **Step R.1: Bump version**

Edit `Cargo.toml`:
```toml
version = "0.3.0"
```

Edit `README.md` status badge (search for `0.2.3`):
```markdown
[![Status: v0.3.0 email-cli v0.6](https://img.shields.io/badge/Status-v0.3.0_email--cli_v0.6-orange?style=for-the-badge)](#status)
```

Edit `src/commands/agent_info.rs` status string:
```rust
"status": "v0.3.0 — production-grade 10k: 429/5xx retry with backoff, transactional DB writes, suppression HashSet, complaint/bounce rate guards, `contact erase` (GDPR Article 17), `broadcast resume`, Resend tracking-config surfacing in health + preflight + report"
```

- [ ] **Step R.2: Full test + lint + fmt + build**

Run:
```bash
cargo fmt --check 2>&1 | tail -5
cargo clippy --all-targets -- -D warnings 2>&1 | tail -5
cargo test -- --test-threads=1 2>&1 | grep "test result"
cargo build --release 2>&1 | tail -3
./target/release/mailing-list-cli --version
```
Expected: fmt clean, clippy clean, tests ≥ 109 unit + 68 integration = 177 passing, release binary reports `mailing-list-cli 0.3.0`.

- [ ] **Step R.3: Mandatory paperfoot.com smoke test**

Run through the full 11-step flow from the session-3 handoff against a fresh `/tmp/mlc-smoke-v0.3.0/` directory:
1. health (now includes tracking_config check)
2. list create
3. contact add
4. template create + lint + preview
5. broadcast create
6. broadcast preview (real single send to `smoke-test-v0.3.0@paperfoot.com`)
7. broadcast send (batch)
8. webhook poll (with tracking enabled on paperfoot.com — verify opened + clicked events show up if you manually open/click)
9. broadcast show
10. report show (verify `tracking_enabled.opens = true`, `tracking_enabled.clicks = true`)
11. Hard-fail path (typo template → exit 3, broadcast → failed)

Additionally:
12. **NEW v0.3**: `contact erase smoke-test-v0.3.0@paperfoot.com --confirm` → verify DB shows the gdpr_erasure tombstone
13. **NEW v0.3**: rerun `broadcast send 1` → should complete immediately in resume mode with "resume mode — 1 recipient(s) already sent, skipping" on stderr
14. **NEW v0.3 optional**: force a 429 via the stub? Not applicable in real-Resend smoke — covered by integration tests.

Expected: all 13 mandatory steps green. If ANY step fails, do NOT tag. Investigate, fix, re-run.

- [ ] **Step R.4: Commit version bump, tag v0.3.0**

```bash
git add Cargo.toml Cargo.lock README.md src/commands/agent_info.rs
git commit -m "chore: bump to v0.3.0 — production-grade 10k release"
git log --oneline -10
git tag v0.3.0 HEAD -m "v0.3.0 — production-grade 10k"
```

- [ ] **Step R.5: Push main + tag**

```bash
git push origin main
git push origin v0.3.0
```

- [ ] **Step R.6: Verify CI on tag**

Run: `gh run list --branch v0.3.0 --limit 5` (or watch the Actions tab)
Expected: green. If it flakes, investigate immediately — the v0.2.1 CI flake was a real bug that required v0.2.2 to fix.

- [ ] **Step R.7: Write session-4 handoff**

Create `docs/handoffs/2026-04-09-session-4-handoff.md` summarizing:
- v0.2.3 shipped (polish)
- v0.3.0 shipped (production-grade 10k)
- Tests: start (165) → v0.2.3 (165) → v0.3.0 (177 or whatever the final count is)
- Deferred to v0.4: `dnscheck` + daemon + template versioning (+ any v0.3 items that slipped)
- Next steps

---

## Self-review

**1. Spec coverage:** Each of the 7 items I proposed in the enterprise-readiness analysis has a task. Tracking surfacing (item 7 added after the follow-up question) is covered in Task 7. ✓

**2. Placeholder scan:** No "TODO", "TBD", "implement later", "similar to task N" — every code block is complete as written. ✓

**3. Type consistency:** `HashSet` is `std::collections::HashSet` everywhere. `AppError` variants match the existing enum. `suppression_all_emails` returns `HashSet<String>` and is consumed as such in the pipeline. `broadcast_recipient_already_sent_ids` returns `HashSet<i64>` (contact IDs). `historical_send_rates` returns `(f64, f64, i64)` tuple: (complaint_rate, bounce_rate, delivered_count). ✓

**4. Out-of-scope guardrails:** No new runtime deps. No daemon. No dnscheck. No MJML. No template versioning. All confirmed out of scope at the top. ✓

**5. Mutability:** Tasks 3, 5, 6 all need `let mut db = Db::open()?;` because `conn.transaction()` requires `&mut Connection`. The contact command's `run()` already does this. Tasks that don't touch transactions (Tasks 4, 7) can keep the existing immutable binding where possible. ✓

**6. Scope size:** 7 tasks + release. Median task = ~10 steps. With TDD overhead, this is ~5–8 working days for one engineer. Could split into v0.3 (Tasks 1–4 = reliability) and v0.4 (Tasks 5–7 = compliance + UX) if scope pressure warrants — but one unified release is cleaner narratively and the tasks are genuinely independent.

**7. Known limitation:** Task 7 depends on `email-cli domain get` existing as a subcommand. Step 7.1 verifies this before proceeding; if the passthrough is missing, the plan explicitly says to defer to v0.3.1. This is a clean handoff boundary — no partial work.

---

## Execution handoff

**Plan complete and saved to `docs/plans/2026-04-09-phase-8-v0.3-production-grade-10k.md`. Two execution options:**

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration, each task in its own scope window. Good for parallelizing research/implementation and catching hallucinations early.

**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review. Simpler, no subagent coordination overhead.

**Which approach?**
