From f8c00d3615ffa98c53e6113bf57b0addd641b63c Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Wed, 27 May 2026 11:00:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(skill):=20add=20/mailbox=20=E2=80=94=20ACG?= =?UTF-8?q?=20M365=20mailbox=20read=20+=20gated=20send-as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read and send mail for an ACG mailbox via the shared Claude-MSP-Access Graph app (fabb3421), defaulting to the running user's mailbox from identity.json (mike/howard). Send and reply are hard-gated: full To/Cc/Subject/Body preview + explicit confirm, external recipients flagged, no retries/bulk, saved to Sent. Read path verified live; token cached to .claude/tmp (gitignored), secret from SOPS vault. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/mailbox.md | 193 ++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 .claude/commands/mailbox.md diff --git a/.claude/commands/mailbox.md b/.claude/commands/mailbox.md new file mode 100644 index 0000000..0912a40 --- /dev/null +++ b/.claude/commands/mailbox.md @@ -0,0 +1,193 @@ +# /mailbox — ACG M365 mailbox (read + send as you) + +Read and send mail for an Arizona Computer Guru mailbox via Microsoft Graph, using the shared **Claude-MSP-Access** app. Defaults to the mailbox of the user running it (from `identity.json`). + +## Usage + +``` +/mailbox Recent inbox messages +/mailbox unread Unread messages +/mailbox search Search (Graph $search: from:, subject:, or free text) +/mailbox from Messages from a sender +/mailbox read Full body of a message +/mailbox send Compose + send (GATED — preview + confirm) +/mailbox reply Reply to a message (GATED — preview + confirm) +/mailbox --as 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:** this is for ACG's OWN mailboxes. For reading a CLIENT tenant's mailboxes (breach checks, rule audits), use `/remediation-tool` — same Graph app (`fabb3421`), different purpose. + +## API Configuration + +- **App:** Claude-MSP-Access (Graph API), `client_id = fabb3421-8b34-484b-bc17-e46de9703418` (application permissions incl. Mail.ReadWrite + Mail.Send, tenant-wide). +- **Tenant:** `azcomputerguru.com` +- **Secret:** SOPS vault `msp-tools/claude-msp-access-graph-api.sops.yaml` -> `credentials.credential`. Read with command substitution; never hardcode, never print it. +- **Mailbox (default):** resolved from `identity.json` `.user` -> `mike` = `mike@azcomputerguru.com`, `howard` = `howard@azcomputerguru.com`. Override with `--as `. +- **Token:** `client_credentials`, scope `https://graph.microsoft.com/.default`. Cache to `$REPO_ROOT/.claude/tmp/mailbox-token.json` (gitignored), ~55 min TTL. +- **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//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 "; ;; +esac +# --as override: if the user passed --as , 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 +py - "$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" +CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418" +TENANT = "azcomputerguru.com" +GRAPH = "https://graph.microsoft.com/v1.0" +CACHE = os.path.join(REPO, ".claude", "tmp", "mailbox-token.json") + +def secret(): + r = subprocess.run(["bash", os.path.join(REPO,".claude","scripts","vault.sh"), + "get-field","msp-tools/claude-msp-access-graph-api.sops.yaml","credentials.credential"], + capture_output=True, text=True) + return r.stdout.strip() + +def token(): + try: + c = json.load(open(CACHE)) + if c.get("exp",0) - time.time() > 120: return c["tok"] + except Exception: pass + data = urllib.parse.urlencode({"client_id":CLIENT_ID,"client_secret":secret(), + "scope":"https://graph.microsoft.com/.default","grant_type":"client_credentials"}).encode() + t = json.loads(urllib.request.urlopen(urllib.request.Request( + f"https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token", data=data), timeout=20).read()) + os.makedirs(os.path.dirname(CACHE), exist_ok=True) + json.dump({"tok":t["access_token"],"exp":time.time()+int(t.get("expires_in",3600))}, open(CACHE,"w")) + return t["access_token"] + +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 ` / `/mailbox from `): use `$search`. For sender: `$search="from:"`. For text: `$search=""`. +```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 `): +```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 ) +From: +To: <- flag any non-azcomputerguru.com recipient +Cc: +Subject: +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 ` + +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 shared **Claude-MSP-Access** app, but a `sendMail`/`reply` from `/users//...` 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.