# Draft-Only Outbox

**Give every agent its own address and keep it on drafts by default. Routine notes to people you listed can go out under a daily cap; anything else waits for your yes, and the log shows who said yes, when, and to which exact text.**

Version 1.0, 2026-11-04. By PaperChaseWebb Inc. Free. Works with Grok Bot, Claude Code, Codex and Hermes, or by hand. Python 3.8 or newer, no installs.

## The problem

Agents that write email usually share one inbox, often the owner's. When a wrong message goes out, nobody can say which agent wrote it, who allowed it, or whether the text that went out is the text that was approved. The usual fix, turning sending off entirely, stalls the routine reminders that were the point of the agent.

This kit draws the line in a file. Each agent sends as itself. Drafts addressed to people you listed, under a daily cap and free of money or contract words, may go. Everything else waits for the owner, and a yes covers one draft exactly as it read.

## What you get out of it

- queue.md: every draft sorted into clear, needs your yes, or blocked, with the reason.
- A gate your sender calls before each send. Exit 0 means go; anything else means stop. A draft goes once.
- sendlog.jsonl: each yes and each release, with who, when, the recipients and a fingerprint of the text.
- A setup your agents can run without being able to approve their own mail.

## Who it is for

Owners and small teams running one or more agents that draft or send email for the business, and the people who set those agents up.

## The rules file

| Field | What to write |
| --- | --- |
| `owner` | the one person who says yes |
| `owner_addresses` | your own addresses. No agent may use them |
| `agents[].name`, `address` | one entry per agent, each with an address of its own |
| `agents[].mode` | `draft_only` (the default you should start with) or `send_allowed` |
| `agents[].allow` | exact addresses or `@domain` entries a sending agent may write to. No wildcards |
| `agents[].daily_cap` | the most clear drafts a sending agent may release per day |
| `hold_words` | words that hold a draft for a yes even when everything else is fine: refund, invoice, contract, price |

outbox.csv has one row per draft: id, agent, from, to, subject, body, created (YYYY-MM-DD). Separate several recipients with a semicolon.

## How drafts are sorted

| Code | Result | When |
| --- | --- | --- |
| B1 | blocked | the agent is not in the rules |
| B2 | blocked | the from address is not that agent's own |
| B3 | blocked | there is no recipient |
| H1 | needs yes | the agent is draft only |
| H2 | needs yes | any recipient is off the agent's allow list |
| H3 | needs yes | the subject or body has a hold word |
| H4 | needs yes | the agent is past its daily cap |
| | clear | a sending agent, every recipient listed, no hold word, under the cap |

A yes cannot clear a blocked draft. Fix the draft or the rules and scan again.

## The owner's call

`outbox.py approve` records a yes for one held draft. It is refused inside an agent session, without a terminal, for a blocked draft and from anyone but the owner, and the owner has to type send after seeing the sender, recipients and subject. The yes is tied to the exact row. If the draft changes afterward, `gate` says no-go and `status` shows CHANGED.

## Set up (10 minutes)

1. Copy `extract_from_pack.py` from the appendix by hand, then run `python3 extract_from_pack.py draft-only-outbox-every-agent-its-own-address-nothing-sent-without-a-logged-yes.md outbox/` (the file you downloaded) to unpack the rest.
2. In `outbox/`, run the tests: `python3 -m unittest discover -s tests -p 'test_*.py'`.
3. Try the example, a made-up firm with two agents and eight drafts: `python3 outbox.py scan example/rules.json example/outbox.csv out/`. Two are clear, four need a yes (one per reason) and two are blocked.
4. Give each agent its own address with your mail provider. Copy templates/rules.json and list them, all draft only.
5. Wire your sender so it calls `gate` before every send and stops on anything but exit 0.
6. Give your agent AGENTS.md. Claude Code and Codex read it from the folder (CLAUDE.md is the same text); for Grok Bot use agents/grok-standing-order.md, and for Hermes load agents/SKILL.md.
7. After a clean week, move one agent to send_allowed with a short allow list and a low cap.

## Run it once before you rely on it

Approve one held draft, edit one word, and call gate. It must say no-go. Call gate twice on a clear draft; the second must say no-go. Stop and fix the setup if an agent recorded a yes, sent as you, edited rules.json or the log, or sent something gate refused. That is the kill rule.

## Limits

- It controls the drafts your sender passes through it. Mail sent outside the gate is not covered, so remove direct send access where you can.
- Hold words are plain substring matches. They catch the obvious, not every risky message.
- The daily cap counts clear drafts by their created date.
- It does not check the sending address's mail setup. Pair it with your provider's sender settings.

## Where this came from

Owners trying the new agent mail tools keep asking the same two things in public threads: who answers for a wrong send, and can an agent be kept to drafts. This kit is our own answer, written from how we run our own agents, with nothing copied from any product.

## Want this run for you

An agent outbox holds up when every agent has a lane and the yes stays with you. If you would rather have agents set up and run with those limits in place, that is Managed Ops: our agents work inside the tools you already use, and every change is drafted for your yes. Managed Ops starts at $5,000/mo with $2,500 onboarding and a 3-month minimum. Growth Fleet, starting at $2,500 setup + $750/mo, covers a smaller set of agents. Book a call on the paperchasewebb.ai homepage. Every price is a starting price.

## License

Free for personal and commercial use inside your own business. Do not resell or republish the kit.

## Appendix: the files

#### file: extract_from_pack.py
~~~~python
#!/usr/bin/env python3
"""Unpack every '#### file: PATH' block from pack.md (or FREE-SAMPLE.md) into a folder."""
import os, re, sys
src, dst = sys.argv[1], sys.argv[2]
t = open(src, encoding="utf-8").read()
n = 0
for m in re.finditer(r"^#### file: (\S+)\n~~~~[a-z]*\n(.*?)\n~~~~$", t, re.S | re.M):
    p = os.path.join(dst, m.group(1))
    os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
    open(p, "w", encoding="utf-8").write(m.group(2) + "\n")
    n += 1
print(n, "files")
~~~~

#### file: outbox.py
~~~~python
#!/usr/bin/env python3
"""outbox.py: every agent gets its own address, drafts wait for the owner, and every send has a logged yes.

  python3 outbox.py check RULES.json                         check the rules
  python3 outbox.py scan RULES.json OUTBOX.csv OUT/          sort every draft: CLEAR, NEEDS YES or BLOCKED; write OUT/queue.md
  python3 outbox.py approve RULES.json OUT/ DRAFT_ID         owner only, at their own terminal
  python3 outbox.py gate RULES.json OUTBOX.csv OUT/ DRAFT_ID your sender calls this right before sending; exit 0 means go
  python3 outbox.py status OUT/                              clear, waiting, approved, changed, released, blocked

OUTBOX.csv columns: id, agent, from, to, subject, body, created (YYYY-MM-DD). Separate several recipients with ;
This tool never sends mail. It decides whether a draft may go, and it keeps the log of who said yes and when.
Exit codes: 0 ok or go, 1 errors or no-go, 2 usage, 3 refused (agent session or no terminal), 4 unknown draft, 5 owner did not confirm.
Python 3.8 or newer, standard library only.
"""
import csv, hashlib, json, os, sys
from datetime import datetime

AGENT_ENV = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED",
             "CODEX_MANAGED_BY_NPM", "HERMES_AGENT", "HERMES_SESSION_ID", "GROK_BOT", "CURSOR_AGENT"]
COLS = ["id", "agent", "from", "to", "subject", "body", "created"]
MODES = ("draft_only", "send_allowed")


def jload(p):
    with open(p, encoding="utf-8") as f:
        return json.load(f)


def wtext(p, t):
    with open(p, "w", encoding="utf-8") as f:
        f.write(t)


def low(x):
    return str(x or "").strip().lower()


def check(r):
    e = []
    if not low(r.get("owner")):
        e.append(("R01", "owner is missing (the one person who says yes)"))
    ag = r.get("agents")
    if not isinstance(ag, list) or not ag:
        e.append(("R02", "agents must list at least one agent")); return e
    owner_addr = [low(a) for a in r.get("owner_addresses") or []]
    seen = {}
    for a in ag:
        n, ad = low(a.get("name")), low(a.get("address"))
        if not n or "@" not in ad:
            e.append(("R02", "every agent needs a name and its own address")); continue
        if ad in seen:
            e.append(("R03", "%s and %s share %s; give each agent its own address" % (seen[ad], n, ad)))
        seen[ad] = n
        if ad in owner_addr:
            e.append(("R04", "%s uses the owner's own address %s" % (n, ad)))
        if a.get("mode") not in MODES:
            e.append(("R05", "%s mode must be draft_only or send_allowed" % n))
        allow = [low(x) for x in a.get("allow") or []]
        if a.get("mode") == "send_allowed" and not allow:
            e.append(("R06", "%s may send but has an empty allow list" % n))
        if any(x in ("*", "@", "@*") or x.startswith("*") for x in allow):
            e.append(("R07", "%s has a wildcard in its allow list; list addresses or @domains" % n))
        cap = a.get("daily_cap")
        if a.get("mode") == "send_allowed" and (not isinstance(cap, int) or isinstance(cap, bool) or cap < 1):
            e.append(("R08", "%s may send but has no daily_cap of 1 or more" % n))
    return e


def read_outbox(path):
    with open(path, newline="", encoding="utf-8-sig") as f:
        rd = csv.DictReader(f)
        miss = [c for c in COLS if c not in (rd.fieldnames or [])]
        if miss:
            raise ValueError("OUTBOX.csv is missing column(s): " + ", ".join(miss))
        rows = [{c: (row.get(c) or "").strip() for c in COLS} for row in rd]
    seen, errs = set(), []
    for i, row in enumerate(rows, 2):
        if not row["id"]:
            errs.append("line %d has no id" % i)
        elif row["id"] in seen:
            errs.append("id %s is listed twice" % row["id"])
        seen.add(row["id"])
        try:
            datetime.strptime(row["created"], "%Y-%m-%d")
        except ValueError:
            errs.append("id %s created is not YYYY-MM-DD" % (row["id"] or "line %d" % i))
    if errs:
        raise ValueError("; ".join(errs))
    return rows


def dhash(row):
    return hashlib.sha256(json.dumps([row[c] for c in COLS]).encode("utf-8")).hexdigest()


def allowed(addr, allow):
    a = low(addr)
    return any(a == x or (x.startswith("@") and a.endswith(x)) for x in allow)


def sort_drafts(r, rows):
    agents = {low(a["name"]): a for a in r["agents"]}
    words = [low(w) for w in r.get("hold_words") or [] if low(w)]
    used, res = {}, []
    for row in rows:
        why, blocked = [], False
        a = agents.get(low(row["agent"]))
        if not a:
            why.append("B1 unknown agent %s" % (row["agent"] or "(blank)")); blocked = True
        elif low(row["from"]) != low(a["address"]):
            why.append("B2 sent as %s, not the agent's own %s" % (row["from"] or "(blank)", a["address"])); blocked = True
        to = [t.strip() for t in row["to"].split(";") if t.strip()]
        if not to:
            why.append("B3 no recipient"); blocked = True
        if not blocked:
            allow = [low(x) for x in a.get("allow") or []]
            if a["mode"] == "draft_only":
                why.append("H1 %s is draft only" % a["name"])
            off = [t for t in to if not allowed(t, allow)]
            if off:
                why.append("H2 not on the allow list: %s" % ", ".join(off))
            hit = [w for w in words if w in (row["subject"] + " " + row["body"]).lower()]
            if hit:
                why.append("H3 hold word: %s" % ", ".join(hit))
            if a["mode"] == "send_allowed" and not why:
                k = (low(a["name"]), row["created"])
                used[k] = used.get(k, 0) + 1
                if used[k] > a["daily_cap"]:
                    why.append("H4 over the daily cap of %d for %s" % (a["daily_cap"], a["name"]))
        verdict = "BLOCKED" if blocked else ("NEEDS YES" if why else "CLEAR")
        res.append(dict(row, verdict=verdict, why="; ".join(why), hash=dhash(row)))
    return res


def scan(rpath, opath, out):
    r = jload(rpath)
    res = sort_drafts(r, read_outbox(opath))
    os.makedirs(out, exist_ok=True)
    wtext(os.path.join(out, "queue.json"), json.dumps({"owner": r["owner"], "drafts": res}, indent=1) + "\n")
    n = {v: sum(1 for x in res if x["verdict"] == v) for v in ("CLEAR", "NEEDS YES", "BLOCKED")}
    L = ["# Outbox queue", "", "Owner: %s. %d drafts: %d clear, %d need your yes, %d blocked." % (r["owner"], len(res), n["CLEAR"], n["NEEDS YES"], n["BLOCKED"]),
         "Nothing has been sent. A sender may only send a draft after `outbox.py gate` says go.", "",
         "## Needs your yes", "", "| Draft | Agent | To | Subject | Why |", "| --- | --- | --- | --- | --- |"]
    for x in res:
        if x["verdict"] == "NEEDS YES":
            L.append("| %s | %s | %s | %s | %s |" % (x["id"], x["agent"], x["to"], x["subject"], x["why"]))
    L += ["", "## Blocked (fix the draft or the rules; a yes cannot clear these)", ""]
    L += ["- %s %s: %s" % (x["id"], x["subject"], x["why"]) for x in res if x["verdict"] == "BLOCKED"] or ["- none"]
    L += ["", "## Clear to send", ""]
    L += ["- %s %s to %s" % (x["id"], x["subject"], x["to"]) for x in res if x["verdict"] == "CLEAR"] or ["- none"]
    L += ["", "## Your yes", "", "At your own terminal: `python3 outbox.py approve %s %s DRAFT_ID`" % (rpath, out.rstrip("/") + "/")]
    wtext(os.path.join(out, "queue.md"), "\n".join(L) + "\n")
    return res


def log(out):
    p = os.path.join(out, "sendlog.jsonl")
    if not os.path.exists(p):
        return []
    with open(p, encoding="utf-8") as f:
        return [json.loads(x) for x in f if x.strip()]


def append(out, rec):
    with open(os.path.join(out, "sendlog.jsonl"), "a", encoding="utf-8") as f:
        f.write(json.dumps(rec, sort_keys=True) + "\n")


def approve(rpath, out, did, ask=input, env=None, tty=None):
    env = os.environ if env is None else env
    tty = sys.stdin.isatty() if tty is None else tty
    for k in AGENT_ENV:
        if env.get(k):
            print("refused: agent session detected (%s); only the owner says yes" % k); return 3
    if not tty:
        print("refused: no terminal; run this yourself"); return 3
    qp = os.path.join(out, "queue.json")
    if not os.path.exists(qp):
        print("no scan yet; run scan first"); return 4
    d = [x for x in jload(qp)["drafts"] if x["id"] == did]
    if not d:
        print("%s is not in the last scan" % did); return 4
    d = d[0]
    if d["verdict"] == "BLOCKED":
        print("refused: %s is blocked (%s); fix it and scan again" % (did, d["why"])); return 1
    owner = jload(rpath)["owner"]
    if ask("Your name, as owner in the rules: ").strip() != owner:
        print("refused: that is not the owner in the rules"); return 5
    print("%s from %s to %s: %s" % (did, d["from"], d["to"], d["subject"]))
    if ask("Type send to approve this draft as it reads now: ").strip().lower() != "send":
        print("not recorded"); return 5
    append(out, {"event": "yes", "id": did, "by": owner, "at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), "hash": d["hash"], "to": d["to"]})
    print("yes recorded for %s" % did); return 0


def gate(rpath, opath, out, did, now=None):
    r = jload(rpath)
    cur = [x for x in sort_drafts(r, read_outbox(opath)) if x["id"] == did]
    if not cur:
        print("no-go: %s is not in the outbox" % did); return 4
    cur = cur[0]
    lg = log(out)
    if any(x["event"] == "released" and x["id"] == did and x["hash"] == cur["hash"] for x in lg):
        print("no-go: %s was already released once" % did); return 1
    if cur["verdict"] == "BLOCKED":
        print("no-go: %s is blocked (%s)" % (did, cur["why"])); return 1
    if cur["verdict"] == "NEEDS YES":
        yes = [x for x in lg if x["event"] == "yes" and x["id"] == did]
        if not yes:
            print("no-go: %s needs the owner's yes (%s)" % (did, cur["why"])); return 1
        if yes[-1]["hash"] != cur["hash"]:
            print("no-go: %s changed after %s said yes; approve again" % (did, yes[-1]["by"])); return 1
        basis = "yes from %s at %s" % (yes[-1]["by"], yes[-1]["at"])
    else:
        basis = "clear under the rules"
    append(out, {"event": "released", "id": did, "at": (now or datetime.now()).strftime("%Y-%m-%dT%H:%M:%S"), "hash": cur["hash"], "basis": basis})
    print("go: %s (%s)" % (did, basis)); return 0


def status(out, show=True):
    q = jload(os.path.join(out, "queue.json"))["drafts"]
    lg = log(out)
    rows = []
    for d in q:
        rel = [x for x in lg if x["event"] == "released" and x["id"] == d["id"]]
        yes = [x for x in lg if x["event"] == "yes" and x["id"] == d["id"]]
        if rel and rel[-1]["hash"] == d["hash"]:
            s = "released (%s)" % rel[-1]["basis"]
        elif d["verdict"] == "BLOCKED":
            s = "BLOCKED: %s" % d["why"]
        elif yes and yes[-1]["hash"] != d["hash"]:
            s = "CHANGED since %s said yes, approve again" % yes[-1]["by"]
        elif yes:
            s = "approved by %s at %s" % (yes[-1]["by"], yes[-1]["at"])
        elif d["verdict"] == "CLEAR":
            s = "clear"
        else:
            s = "waiting for yes: %s" % d["why"]
        rows.append((d["id"], s))
        if show:
            print("%-10s %s" % (d["id"], s))
    return rows


def main(argv):
    if not argv or argv[0] not in ("check", "scan", "approve", "gate", "status"):
        print(__doc__); return 2
    cmd, a = argv[0], argv[1:]
    if cmd == "status" and len(a) == 1:
        status(a[0]); return 0
    if not a:
        print(__doc__); return 2
    errs = check(jload(a[0]))
    if errs or cmd == "check":
        for c, m in errs:
            print("%s %s" % (c, m))
        print("rules %s: %d error(s)" % ("OK" if not errs else "NOT OK", len(errs)))
        return 1 if errs else 0
    try:
        if cmd == "scan" and len(a) == 3:
            res = scan(a[0], a[1], a[2])
            n = [sum(1 for x in res if x["verdict"] == v) for v in ("CLEAR", "NEEDS YES", "BLOCKED")]
            print("wrote %s/queue.md: %d clear, %d need a yes, %d blocked" % (a[2].rstrip("/"), n[0], n[1], n[2]))
            return 0
        if cmd == "approve" and len(a) == 3:
            return approve(a[0], a[1], a[2])
        if cmd == "gate" and len(a) == 4:
            return gate(a[0], a[1], a[2], a[3])
    except ValueError as e:
        print("E01 %s" % e); return 1
    print(__doc__); return 2


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
~~~~

#### file: AGENTS.md
~~~~markdown
# AGENTS.md: draft-only outbox

You write email drafts under your own address. The owner decides what goes out. Your sender only sends a draft after `outbox.py gate` says go.

## You may
- Write drafts into outbox.csv with your own agent name and your own address in from, one row per draft, with today's date in created.
- Run `python3 outbox.py scan rules.json outbox.csv out/` and send the owner out/queue.md, with blocked drafts at the top.
- Call `python3 outbox.py gate rules.json outbox.csv out/ DRAFT_ID` right before sending, and send only on exit 0.
- Run `python3 outbox.py status out/` and tell the owner what is waiting, changed or blocked.

## You never
- Run `outbox.py approve`, write to out/sendlog.jsonl, or edit rules.json. Only the owner does those.
- Send as the owner, as another agent, or from an address that is not yours.
- Change a draft after the owner said yes and send it anyway. Edit it, scan again and ask again.
- Split one message into several drafts to stay under a daily cap, or reword it to dodge a hold word.
- Send a draft the gate refused, or retry it through another tool.
~~~~

#### file: CLAUDE.md
~~~~markdown
# AGENTS.md: draft-only outbox

You write email drafts under your own address. The owner decides what goes out. Your sender only sends a draft after `outbox.py gate` says go.

## You may
- Write drafts into outbox.csv with your own agent name and your own address in from, one row per draft, with today's date in created.
- Run `python3 outbox.py scan rules.json outbox.csv out/` and send the owner out/queue.md, with blocked drafts at the top.
- Call `python3 outbox.py gate rules.json outbox.csv out/ DRAFT_ID` right before sending, and send only on exit 0.
- Run `python3 outbox.py status out/` and tell the owner what is waiting, changed or blocked.

## You never
- Run `outbox.py approve`, write to out/sendlog.jsonl, or edit rules.json. Only the owner does those.
- Send as the owner, as another agent, or from an address that is not yours.
- Change a draft after the owner said yes and send it anyway. Edit it, scan again and ask again.
- Split one message into several drafts to stay under a daily cap, or reword it to dodge a hold word.
- Send a draft the gate refused, or retry it through another tool.
~~~~

#### file: agents/grok-standing-order.md
~~~~markdown
# Grok Bot standing order: draft-only outbox

Each time you have mail to send:
1. Add the draft to outbox.csv under your own agent name and address.
2. Run `python3 outbox.py scan rules.json outbox.csv out/` and send the owner out/queue.md if anything needs a yes or is blocked.
3. Before each send, run `python3 outbox.py gate rules.json outbox.csv out/ DRAFT_ID`. Send only on exit 0. On any other exit, stop and tell the owner.
4. Each morning, run `python3 outbox.py status out/` and list what still waits for a yes.
Never approve, never edit rules.json or the send log, never send from an address that is not yours, never retry a refused send another way.
~~~~

#### file: agents/SKILL.md
~~~~markdown
---
name: draft-only-outbox
description: Give each agent its own sending address, keep agents draft-only unless the owner allows sending to a listed set of recipients under a daily cap, hold drafts with money or contract words, and require the owner's logged yes (who and when, tied to the exact text) before a held draft goes out. Use whenever an agent writes email.
---

# Draft-only outbox

1. Fill rules.json from templates/rules.json: the owner, the owner's own addresses, one entry per agent with its own address, mode, allow list and daily cap, and hold words.
2. `python3 outbox.py check rules.json` until it says OK.
3. Agents add drafts to outbox.csv: id, agent, from, to, subject, body, created.
4. `python3 outbox.py scan rules.json outbox.csv out/` writes queue.md and queue.json.
5. The owner approves held drafts at their own terminal: `python3 outbox.py approve rules.json out/ DRAFT_ID`.
6. The sender calls `python3 outbox.py gate rules.json outbox.csv out/ DRAFT_ID` and sends only on exit 0.

Hard rules: never approve for the owner, never send from someone else's address, never send a draft the gate refused, never edit the rules or the log.
~~~~

#### file: templates/rules.json
~~~~json
{
 "owner": "Your name",
 "owner_addresses": ["you@yourfirm.example"],
 "agents": [
  {"name": "first-agent", "address": "first-agent@yourfirm.example", "mode": "draft_only", "allow": [], "daily_cap": 0}
 ],
 "hold_words": ["refund", "invoice", "contract", "discount", "price", "legal"]
}
~~~~

#### file: example/rules.json
~~~~json
{
 "owner": "Dana Okafor",
 "owner_addresses": ["dana@harborline.example"],
 "agents": [
  {"name": "intake", "address": "intake@harborline.example", "mode": "draft_only", "allow": ["@tidewater.example"], "daily_cap": 0},
  {"name": "reminders", "address": "reminders@harborline.example", "mode": "send_allowed", "allow": ["@tidewater.example", "frontdesk@pierclinic.example"], "daily_cap": 2}
 ],
 "hold_words": ["refund", "invoice", "contract", "discount", "price"]
}
~~~~

#### file: example/outbox.csv
~~~~csv
id,agent,from,to,subject,body,created
D-101,reminders,reminders@harborline.example,frontdesk@pierclinic.example,Visit reminder for Thursday,Your service visit is Thursday at 9. Reply here if that time no longer works.,2026-11-04
D-102,reminders,reminders@harborline.example,ops@tidewater.example,Crew arrives at 8,The crew will be on site at 8 tomorrow.,2026-11-04
D-103,reminders,reminders@harborline.example,ops@tidewater.example,Gate code check,Please confirm the gate code is unchanged.,2026-11-04
D-104,intake,intake@harborline.example,ops@tidewater.example,Thanks for the walkthrough,Thank you for the walkthrough today. Notes to follow.,2026-11-04
D-105,reminders,reminders@harborline.example,newlead@gmail.example,Following up,Checking in on your request from last week.,2026-11-04
D-106,reminders,reminders@harborline.example,frontdesk@pierclinic.example,Your invoice,Your invoice for October is attached.,2026-11-04
D-107,intake,dana@harborline.example,ops@tidewater.example,Quick note,Sending this from the owner's address.,2026-11-04
D-108,billing,billing@harborline.example,ops@tidewater.example,Statement,Statement attached.,2026-11-04
~~~~

#### file: tests/test_outbox.py
~~~~python
"""Tests for outbox.py. Run from the pack folder: python3 -m unittest discover -s tests -p 'test_*.py'"""
import contextlib, csv, io, json, os, shutil, sys, tempfile, unittest

HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, HERE)
import outbox as ob


def quiet(fn, *a, **k):
    with contextlib.redirect_stdout(io.StringIO()) as buf:
        rc = fn(*a, **k)
    return rc, buf.getvalue()


def answers(*xs):
    it = iter(xs)
    return lambda prompt="": next(it)


class Base(unittest.TestCase):
    def setUp(self):
        self.d = tempfile.mkdtemp()
        self.rules = os.path.join(self.d, "rules.json")
        self.box = os.path.join(self.d, "outbox.csv")
        shutil.copy(os.path.join(HERE, "example", "rules.json"), self.rules)
        shutil.copy(os.path.join(HERE, "example", "outbox.csv"), self.box)
        self.out = os.path.join(self.d, "out")

    def tearDown(self):
        shutil.rmtree(self.d)

    def R(self):
        return ob.jload(self.rules)

    def save(self, r):
        with open(self.rules, "w") as f:
            json.dump(r, f)

    def codes(self):
        return [c for c, _ in ob.check(self.R())]

    def scan(self):
        return {x["id"]: x for x in ob.scan(self.rules, self.box, self.out)}

    def edit(self, did, col, val):
        with open(self.box, newline="", encoding="utf-8") as f:
            rows = list(csv.DictReader(f))
        for r in rows:
            if r["id"] == did:
                r[col] = val
        with open(self.box, "w", newline="", encoding="utf-8") as f:
            w = csv.DictWriter(f, fieldnames=ob.COLS); w.writeheader(); w.writerows(rows)

    def approve(self, did="D-104", name="Dana Okafor", typed="send", env=None, tty=True):
        return quiet(ob.approve, self.rules, self.out, did, ask=answers(name, typed), env=env or {}, tty=tty)

    def gate(self, did):
        return quiet(ob.gate, self.rules, self.box, self.out, did)


class TestRules(Base):
    def test_example_clean(self):
        self.assertEqual(self.codes(), [])

    def test_template_is_clean_draft_only(self):
        self.assertEqual(ob.check(ob.jload(os.path.join(HERE, "templates", "rules.json"))), [])

    def test_owner_required(self):
        r = self.R(); r["owner"] = ""; self.save(r)
        self.assertIn("R01", self.codes())

    def test_agent_needs_address(self):
        r = self.R(); r["agents"][0]["address"] = "intake"; self.save(r)
        self.assertIn("R02", self.codes())

    def test_shared_address(self):
        r = self.R(); r["agents"][1]["address"] = r["agents"][0]["address"]; self.save(r)
        self.assertIn("R03", self.codes())

    def test_owner_address_refused(self):
        r = self.R(); r["agents"][0]["address"] = "dana@harborline.example"; self.save(r)
        self.assertIn("R04", self.codes())

    def test_mode_checked(self):
        r = self.R(); r["agents"][0]["mode"] = "auto"; self.save(r)
        self.assertIn("R05", self.codes())

    def test_sender_needs_allow_list(self):
        r = self.R(); r["agents"][1]["allow"] = []; self.save(r)
        self.assertIn("R06", self.codes())

    def test_wildcard_refused(self):
        r = self.R(); r["agents"][1]["allow"].append("*"); self.save(r)
        self.assertIn("R07", self.codes())

    def test_sender_needs_cap(self):
        r = self.R(); r["agents"][1]["daily_cap"] = 0; self.save(r)
        self.assertIn("R08", self.codes())


class TestScan(Base):
    def test_example_verdicts(self):
        s = self.scan()
        self.assertEqual([k for k in s if s[k]["verdict"] == "CLEAR"], ["D-101", "D-102"])
        self.assertEqual([k for k in s if s[k]["verdict"] == "BLOCKED"], ["D-107", "D-108"])

    def test_draft_only_needs_yes(self):
        self.assertIn("H1", self.scan()["D-104"]["why"])

    def test_off_allow_list(self):
        self.assertIn("H2", self.scan()["D-105"]["why"])

    def test_hold_word(self):
        self.assertIn("H3", self.scan()["D-106"]["why"])

    def test_daily_cap(self):
        self.assertIn("H4", self.scan()["D-103"]["why"])

    def test_cap_resets_next_day(self):
        self.edit("D-103", "created", "2026-11-05")
        self.assertEqual(self.scan()["D-103"]["verdict"], "CLEAR")

    def test_wrong_sender_blocked(self):
        self.assertIn("B2", self.scan()["D-107"]["why"])

    def test_unknown_agent_blocked(self):
        self.assertIn("B1", self.scan()["D-108"]["why"])

    def test_one_bad_recipient_holds_the_draft(self):
        self.edit("D-102", "to", "ops@tidewater.example; someone@else.example")
        self.assertEqual(self.scan()["D-102"]["verdict"], "NEEDS YES")

    def test_lookalike_domain_not_allowed(self):
        self.edit("D-102", "to", "ops@nottidewater.example")
        self.assertEqual(self.scan()["D-102"]["verdict"], "NEEDS YES")

    def test_bad_outbox(self):
        with open(self.box, "a") as f:
            f.write("D-101,reminders,a,b,c,d,2026-11-04\n")
        with self.assertRaises(ValueError):
            self.scan()


class TestYes(Base):
    def test_owner_yes_then_gate_go(self):
        self.scan()
        self.assertEqual(self.approve()[0], 0)
        rc, out = self.gate("D-104")
        self.assertEqual(rc, 0); self.assertIn("yes from Dana Okafor", out)

    def test_refused_in_agent_session(self):
        self.scan()
        self.assertEqual(self.approve(env={"HERMES_AGENT": "1"})[0], 3)

    def test_refused_without_terminal(self):
        self.scan()
        self.assertEqual(self.approve(tty=False)[0], 3)

    def test_wrong_name(self):
        self.scan()
        self.assertEqual(self.approve(name="reminders")[0], 5)

    def test_must_type_send(self):
        self.scan()
        self.assertEqual(self.approve(typed="ok")[0], 5)
        self.assertEqual(ob.log(self.out), [])

    def test_blocked_cannot_be_approved(self):
        self.scan()
        self.assertEqual(self.approve("D-107")[0], 1)

    def test_unknown_draft(self):
        self.scan()
        self.assertEqual(self.approve("D-999")[0], 4)

    def test_edit_after_yes_is_no_go(self):
        self.scan(); self.approve()
        self.edit("D-104", "body", "Different text now.")
        rc, out = self.gate("D-104")
        self.assertEqual(rc, 1); self.assertIn("changed after", out)

    def test_no_yes_is_no_go(self):
        self.scan()
        self.assertEqual(self.gate("D-105")[0], 1)

    def test_clear_goes_once(self):
        self.scan()
        self.assertEqual(self.gate("D-101")[0], 0)
        self.assertEqual(self.gate("D-101")[0], 1)

    def test_blocked_is_no_go(self):
        self.scan()
        self.assertEqual(self.gate("D-108")[0], 1)

    def test_status_reads_the_log(self):
        self.scan(); self.approve(); self.gate("D-104")
        self.edit("D-106", "body", "x")
        st = dict(ob.status(self.out, show=False))
        self.assertTrue(st["D-104"].startswith("released (yes from Dana Okafor"))
        self.assertTrue(st["D-105"].startswith("waiting for yes"))
        self.assertTrue(st["D-107"].startswith("BLOCKED"))


class TestCli(Base):
    def test_usage(self):
        self.assertEqual(quiet(ob.main, [])[0], 2)

    def test_scan_cli(self):
        rc, out = quiet(ob.main, ["scan", self.rules, self.box, self.out])
        self.assertEqual(rc, 0); self.assertIn("2 clear, 4 need a yes, 2 blocked", out)


if __name__ == "__main__":
    unittest.main()
~~~~
