Files
claudetools/.claude/commands/mailbox.md
Mike Swanson f8c00d3615 feat(skill): add /mailbox — ACG M365 mailbox read + gated send-as
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) <noreply@anthropic.com>
2026-05-27 11:00:01 -07:00

10 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).

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. 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 <email>.
  • 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/<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

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.

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):

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

  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)
  1. On yes, POST sendMail (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}")
  1. 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:

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/<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.