# /mailbox — ACG M365 mailbox (read + send as you)

Read and send mail for an Arizona Computer Guru mailbox via Microsoft Graph, using the dedicated **ComputerGuru Mailbox** app (`1873b1b0-3377-485c-a848-bae9b2f8f1f5`). Defaults to the mailbox of the user running it (from `identity.json`).

> **Inbox-rule capability (added 2026-06-23).** The app now also holds **`MailboxSettings.ReadWrite`** (Graph app role `6931bccd-447a-43d1-b442-00a195474933`, admin-consented via the Tenant Admin app `709e6eed`). This is what enables creating/modifying mailbox **inbox rules** (`messageRules`) — `Mail.ReadWrite` alone returns 403 on that endpoint. First use: a "keep DMARC reports in Inbox" rule on `rua@azcomputerguru.com`.
>
> **Mail path (working — repointed 2026-06-17).** `/mailbox` uses the dedicated single-tenant **ComputerGuru Mailbox** app (`1873b1b0-3377-485c-a848-bae9b2f8f1f5`; vault `msp-tools/computerguru-mailbox.sops.yaml`; Mail.ReadWrite + Mail.Send + Contacts.ReadWrite + MailboxSettings.ReadWrite; azcomputerguru.com only). Tokens come from the suite tool: `bash .claude/skills/remediation-tool/scripts/get-token.sh azcomputerguru.com mailbox` (cert-preferred, secret fallback, 55-min cache). This **replaces the deleted `fabb3421`** (Claude-MSP-Access), removed from the tenant 2026-06-14 — it returns **AADSTS700016**; do NOT reintroduce it. The mailbox app's service principal is **disabled when idle**: on a token 401 "account is disabled", enable the SP, then retry.

## Usage

```
/mailbox                         Recent inbox messages
/mailbox unread                  Unread messages
/mailbox search <query>          Search (Graph $search: from:, subject:, or free text)
/mailbox from <email>            Messages from a sender
/mailbox read <id|search>        Full body of a message
/mailbox send                    Compose + send (GATED — preview + confirm)
/mailbox reply <id>              Reply to a message (GATED — preview + confirm)
/mailbox --as <email> <subcmd>   Target a different ACG mailbox (e.g. howard@azcomputerguru.com)
```

## What it does

Microsoft Graph access to ACG's own mailboxes (azcomputerguru.com tenant). Reading is unrestricted; **sending and replying are always gated** behind a full draft preview + explicit confirmation. Sends go out *as* the mailbox owner (your `From:`), saved to your Sent Items.

**Scope boundary (by design — kept split, decided 2026-06-21):** this is for ACG's OWN mailboxes only. The ComputerGuru Mailbox app (`1873b1b0`) is **single-tenant (azcomputerguru.com)** — it has no service principal in client tenants, so `/mailbox` structurally cannot read or send for clients. For a CLIENT tenant's mail (breach checks, rule audits, IR victim-notification sends), use `/remediation-tool` — the multi-tenant suite, where **Exchange Operator (`b43e7342`) holds `Mail.Send`**. The two stay separate on purpose: own-mail rides a least-privilege ACG-only app, not the suite's tenant-wide EXO-write app.

## API Configuration

- **App:** ComputerGuru Mailbox (dedicated single-tenant Graph app), `client_id = 1873b1b0-3377-485c-a848-bae9b2f8f1f5` (Mail.ReadWrite + Mail.Send + Contacts.ReadWrite + MailboxSettings.ReadWrite, azcomputerguru.com only). Replaces the deleted `fabb3421`.
- **Tenant:** `azcomputerguru.com`
- **Token:** acquire via the suite tool — `bash .claude/skills/remediation-tool/scripts/get-token.sh azcomputerguru.com mailbox` (cert-preferred, secret fallback, 55-min cache in `/tmp/remediation-tool/<tenant>/mailbox.jwt`). Do NOT roll your own `client_credentials` here. Credential vault entry: `msp-tools/computerguru-mailbox.sops.yaml`.
- **SP idle-toggle:** the mailbox app's service principal is disabled when idle. On a token 401 "account is disabled", enable the SP, then retry.
- **Mailbox (default):** resolved from `identity.json` `.user` -> `mike` = `mike@azcomputerguru.com`, `howard` = `howard@azcomputerguru.com`. Override with `--as <email>`.
- **Graph base:** `https://graph.microsoft.com/v1.0`

## Hard Rules (sending email is irreversible — no exceptions)

- **NEVER send or reply without showing the FULL draft (To / Cc / Subject / Body) and getting an explicit `yes`.** A permission prompt is not enough — confirm the content in chat.
- **One send per confirmation.** No bulk sends, no loops, no auto-replies. Re-confirm every individual message.
- **External recipients:** call out in the preview any recipient outside `azcomputerguru.com` so the user sees exactly who receives it.
- **Sends go out as the identity user** (From = your address) with `saveToSentItems: true`. Do not spoof a different From.
- **After an ambiguous send result** (no 202, timeout, error): do NOT retry. Verify by checking Sent Items (`GET /users/<mbx>/mailFolders/sentitems/messages?$top=3`) before any further action — Graph sendMail is not idempotent.
- **Reading is fine** (list/search/read) — no confirmation needed.
- **Windows:** use `py` for Graph HTTP/JSON (urllib), `jq` for parsing where used, ASCII markers (`[OK]`/`[ERROR]`). Do NOT use `/tmp` (use `$REPO_ROOT/.claude/tmp/`). Never echo the secret or bearer token.
- **No automatic mailbox actions** (no auto-archive/delete/flag) — this skill reads and, on confirmation, sends. Nothing else.

## Implementation

### Setup — resolve repo root, mailbox, and token

```bash
IDENTITY_PATH="${HOME}/.claude/identity.json"
[ -f "$IDENTITY_PATH" ] || IDENTITY_PATH="$(git rev-parse --show-toplevel 2>/dev/null)/.claude/identity.json"
REPO_ROOT=$(jq -r '.claudetools_root // empty' "$IDENTITY_PATH"); [ -z "$REPO_ROOT" ] && REPO_ROOT=$(git rev-parse --show-toplevel)
USER_ID=$(jq -r '.user // empty' "$IDENTITY_PATH")
case "$USER_ID" in
  mike)   MAILBOX="mike@azcomputerguru.com" ;;
  howard) MAILBOX="howard@azcomputerguru.com" ;;
  *)      echo "[ERROR] Unknown identity user '$USER_ID' — pass --as <email>"; ;;
esac
# --as override: if the user passed --as <email>, set MAILBOX to it.
VAULT="$REPO_ROOT/.claude/scripts/vault.sh"
```

### Token (cached, ~55 min) and Graph helper — run via py

All Graph calls go through this `py` helper. It reads the secret from the vault, caches the token, and exposes `get`/`post`. Reuse the pattern per command.

```bash
bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" - "$MAILBOX" "$1" <<'PY'
import os, sys, json, time, subprocess, urllib.request, urllib.parse, re
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
MAILBOX = sys.argv[1]
REPO = subprocess.run(["bash","-lc","jq -r .claudetools_root \"$HOME/.claude/identity.json\" 2>/dev/null || git rev-parse --show-toplevel"],
                      capture_output=True, text=True).stdout.strip() or r"D:\claudetools"
GRAPH = "https://graph.microsoft.com/v1.0"

def token():
    # Delegate to the suite token tool: dedicated mailbox app (1873b1b0) via vault
    # msp-tools/computerguru-mailbox.sops.yaml. Cert-preferred, secret fallback, 55-min cache.
    # Do NOT reintroduce the deleted fabb3421 / inline client_credentials here.
    gt = os.path.join(REPO, ".claude", "skills", "remediation-tool", "scripts", "get-token.sh")
    r = subprocess.run(["bash", gt, "azcomputerguru.com", "mailbox"], capture_output=True, text=True)
    tok = (r.stdout.strip().splitlines() or [""])[-1].strip()
    if not tok:
        raise SystemExit("[ERROR] mailbox token failed (SP disabled? enable it, then retry). stderr: " + r.stderr[-400:])
    return tok

def graph(method, path, body=None):
    h = {"Authorization": f"Bearer {token()}", "Content-Type":"application/json"}
    url = path if path.startswith("http") else GRAPH + path
    url = url.replace(" ", "%20")   # OData params (e.g. $orderby=... desc) must encode spaces
    req = urllib.request.Request(url, headers=h, method=method,
                                 data=json.dumps(body).encode() if body is not None else None)
    try:
        r = urllib.request.urlopen(req, timeout=30)
        raw = r.read().decode("utf-8","replace")
        return r.status, (json.loads(raw) if raw.strip() else {})
    except urllib.error.HTTPError as e:
        return e.code, {"error": e.read().decode("utf-8","replace")[:600]}

def strip_html(s):
    s = re.sub(r"<[^>]+>", " ", s or ""); return re.sub(r"\s+", " ", s).strip()

# --- dispatch on sys.argv[2] (subcommand) in the per-command blocks below ---
PY
```

### Read ops

**Inbox / recent** (`/mailbox`):
```python
st, d = graph("GET", f"/users/{MAILBOX}/mailFolders/inbox/messages?$top=15&$orderby=receivedDateTime%20desc&$select=id,subject,from,receivedDateTime,isRead,bodyPreview")
for m in d.get("value", []):
    f = (m.get("from",{}).get("emailAddress",{}) or {}).get("address","")
    flag = " " if m.get("isRead") else "*"
    print(f"{flag} {m['receivedDateTime'][:16]}  {f:32.32}  {m.get('subject','')}")
    print(f"    id={m['id']}")
```

**Unread:** add `&$filter=isRead eq false` (drop `$search` — can't combine `$search` + `$filter`).

**Search** (`/mailbox search <q>` / `/mailbox from <email>`): use `$search`. For sender: `$search="from:<email>"`. For text: `$search="<terms>"`.
```python
import urllib.parse
q = urllib.parse.quote(f'"{QUERY}"')   # e.g. "from:sheila@quantumwms.com" or "intermedia migration"
st, d = graph("GET", f"/users/{MAILBOX}/messages?$search={q}&$top=10&$select=id,subject,from,receivedDateTime,bodyPreview")
```

**Read full body** (`/mailbox read <id>`):
```python
st, d = graph("GET", f"/users/{MAILBOX}/messages/{MSG_ID}?$select=subject,from,toRecipients,ccRecipients,receivedDateTime,body")
print("FROM:", (d.get("from",{}).get("emailAddress",{}) or {}).get("address"))
print("SUBJECT:", d.get("subject"))
b = d.get("body",{}); print("BODY:", strip_html(b.get("content")) if b.get("contentType")=="html" else b.get("content"))
```

### Send (GATED) — `/mailbox send`

1. Gather: To, Cc (optional), Subject, Body. Draft the body (Ollama allowed for prose; Claude reviews).
2. **Show the full preview in chat** and wait for explicit `yes`:
```
DRAFT (send as <MAILBOX>)
From:    <MAILBOX>
To:      <recipients>            <- flag any non-azcomputerguru.com recipient
Cc:      <recipients or none>
Subject: <subject>
Body:
<body>

Send this? (yes/no)
```
3. On `yes`, POST `sendMail` (response is **202 Accepted, empty body**):
```python
msg = {"message": {
        "subject": SUBJECT,
        "body": {"contentType": "HTML", "content": BODY_HTML},
        "toRecipients": [{"emailAddress":{"address":a}} for a in TO],
        "ccRecipients": [{"emailAddress":{"address":a}} for a in CC]},
       "saveToSentItems": True}
st, d = graph("POST", f"/users/{MAILBOX}/sendMail", msg)
print("[OK] sent (202)" if st == 202 else f"[ERROR] send returned {st}: {d}")
```
4. If not 202: do NOT retry. `GET /users/{MAILBOX}/mailFolders/sentitems/messages?$top=3&$select=subject,sentDateTime,toRecipients` to confirm whether it actually went.

### Reply (GATED) — `/mailbox reply <id>`

Same preview + confirm. Then:
```python
st, d = graph("POST", f"/users/{MAILBOX}/messages/{MSG_ID}/reply",
              {"comment": REPLY_TEXT})        # replies to sender, quotes original; 202 expected
# replyAll: use /reply -> /replyAll. To add/override recipients, pass {"message":{"toRecipients":[...]}, "comment": "..."}.
```

## Response shapes / notes

- List/search: `{ "value": [ {message}, ... ] }`. Single message: the object directly.
- `sendMail` / `reply` / `replyAll`: **HTTP 202, empty body** — success is the status code, not a body.
- `$search` and `$filter` cannot be combined on `messages`. Use one or the other.
- Message bodies are usually HTML — strip tags for readable display (helper `strip_html`).
- GuruProtect banner text ("External / Spam / Phish ...") is injected into inbound bodies; ignore it when summarizing.

## Attribution

API calls authenticate as the dedicated **ComputerGuru Mailbox** app (`1873b1b0`, vault `msp-tools/computerguru-mailbox.sops.yaml`) — NOT the deleted `fabb3421`/Claude-MSP-Access — but a `sendMail`/`reply` from `/users/<mailbox>/...` goes out with that mailbox as the `From:` and lands in that mailbox's Sent Items — i.e. it genuinely sends *as you*. Only the identity user's own mailbox is targeted by default; `--as` is for deliberately operating another ACG mailbox.
