11 KiB
/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).
Mail path (working — repointed 2026-06-17).
/mailboxuses the dedicated single-tenant ComputerGuru Mailbox app (1873b1b0-3377-485c-a848-bae9b2f8f1f5; vaultmsp-tools/computerguru-mailbox.sops.yaml; Mail.ReadWrite + Mail.Send + Contacts.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 deletedfabb3421(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: this is for ACG's OWN mailboxes (the dedicated mailbox app, azcomputerguru.com only). For reading a CLIENT tenant's mailboxes (breach checks, rule audits), use /remediation-tool — the tiered Security Investigator / Exchange Operator apps, different apps and purpose.
API Configuration
- App: ComputerGuru Mailbox (dedicated single-tenant Graph app),
client_id = 1873b1b0-3377-485c-a848-bae9b2f8f1f5(Mail.ReadWrite + Mail.Send + Contacts.ReadWrite, azcomputerguru.com only). Replaces the deletedfabb3421. - 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 ownclient_credentialshere. 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.comso 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
pyfor Graph HTTP/JSON (urllib),jqfor 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
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 "$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):
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>".
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>):
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
- Gather: To, Cc (optional), Subject, Body. Draft the body (Ollama allowed for prose; Claude reviews).
- 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)
- On
yes, POSTsendMail(response is 202 Accepted, empty body):
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}")
- If not 202: do NOT retry.
GET /users/{MAILBOX}/mailFolders/sentitems/messages?$top=3&$select=subject,sentDateTime,toRecipientsto confirm whether it actually went.
Reply (GATED) — /mailbox reply <id>
Same preview + confirm. Then:
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.$searchand$filtercannot be combined onmessages. 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/<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.