From ce9744832dab1184963a2b29e9a81544286c010e Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Fri, 5 Jun 2026 07:03:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(skills):=20add=20/mailprotector=20?= =?UTF-8?q?=E2=80=94=20CloudFilter=20held-mail=20search=20+=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Mailprotector CloudFilter REST client (emailservice.io/api/v1, Bearer auth via vault msp-tools/mailprotector.sops.yaml). Lists mail-flow logs and held/quarantined messages across client domains and releases them (POST messages/{id}/deliver, deliver_many). Read-only by default; every release/rule-add/config-change gated behind --confirm. Mirrors the packetdial skill pattern. Built after diagnosing a Dataforth held-outbound message that never reached ACG. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/mailprotector/SKILL.md | 166 +++++++ .../skills/mailprotector/references/api.md | 155 ++++++ .claude/skills/mailprotector/scripts/mp.py | 322 +++++++++++++ .../skills/mailprotector/scripts/mp_client.py | 443 ++++++++++++++++++ 4 files changed, 1086 insertions(+) create mode 100644 .claude/skills/mailprotector/SKILL.md create mode 100644 .claude/skills/mailprotector/references/api.md create mode 100644 .claude/skills/mailprotector/scripts/mp.py create mode 100644 .claude/skills/mailprotector/scripts/mp_client.py diff --git a/.claude/skills/mailprotector/SKILL.md b/.claude/skills/mailprotector/SKILL.md new file mode 100644 index 0000000..ad0a7a4 --- /dev/null +++ b/.claude/skills/mailprotector/SKILL.md @@ -0,0 +1,166 @@ +--- +name: mailprotector +description: >- + Manage the Arizona Computer Guru (ACG) Mailprotector CloudFilter email-security + gateway via the live CloudFilter REST API (emailservice.io). Search and release + held / quarantined mail (inbound and outbound), pull mail-flow logs to explain + why a message did or did not deliver, inspect entity configuration and + allow/block rules, find a user or alias by email, and manage allow/block rules. + Read-only by default; every release / rule-add / config-change is gated behind + --confirm. Invoke for: "mailprotector", "cloudfilter", "emailservice.io", "held + mail", "quarantined email", "release email", "outbound quarantine", "why didn't + my email arrive", "email security gateway", "INKY", "mail flow logs", "allow + block rule", "release spam". This skill talks to the LIVE production reseller + CloudFilter platform — treat releases conservatively. +--- + +# Mailprotector / CloudFilter Skill + +Standalone CLI client for the **Mailprotector CloudFilter REST API** +(`emailservice.io`), the reseller email-security platform ACG layers on top of +client mail flow. Read-only by default; every write (release, rule add, config +change) is gated behind `--confirm`. + +## The two-layer context (important) + +ACG's email security sits in front of client mailboxes as two cooperating layers: + +| Layer | What it does | +|---|---| +| **Mailprotector CloudFilter** | The delivery / filtering gateway. Inbound and outbound mail passes through it; spam, virus, and policy hits are **held / quarantined** here. Releasing a held message re-injects it for delivery. This is the API this skill drives. | +| **INKY** | Email annotation / phishing-banner layer. Adds the warning banners and protects against impersonation. Not part of this API surface. | + +Both sit **layered on top of the client's own Exchange / M365 mail flow** — so a +"missing email" investigation usually means: was it held at CloudFilter (check +`messages` / `logs`), or did it pass CloudFilter and stall in Exchange? + +## Connection + +| Item | Value | +|---|---| +| Base URL | `https://emailservice.io/api/v1` (override `MAILPROTECTOR_API_BASE_URL`) | +| Auth | `Authorization: Bearer ` | +| Vault entry | `msp-tools/mailprotector.sops.yaml`, field `credentials.api_key` | +| Env override | `MAILPROTECTOR_API_KEY` | + +Credential resolution order: `MAILPROTECTOR_API_KEY` env -> vault +`credentials.api_key`. The key is never hardcoded; a clear setup error is raised +if neither resolves. + +### Scopes + +Five entity types carry `logs` / `messages` / `configuration` / +`allow_block_rules` / `users` / `domains` sub-resources. Path form is +`/{scope}/{id}/...`: + +``` +resellers, customers, domains, user_groups, users +``` + +The CLI validates `scope` against this set. + +## Running the CLI + +This machine's Python launcher is `py` (per identity.json); `python` / `python3` +also work. Run from the scripts dir so the two modules resolve. + +```bash +cd C:/claudetools/.claude/skills/mailprotector/scripts + +py mp.py status # validate token (GET /domains, per_page=1) +py mp.py domains # list domains (global) +py mp.py domains --scope customers --id +py mp.py domain +py mp.py customers +py mp.py customer +py mp.py users +py mp.py user +py mp.py find-user user@client.com # locate a user / alias by email (a READ) +py mp.py config # shows permissions.messages.allow_spam_release +py mp.py rules +``` + +### Mail-flow logs and held mail (the common investigation) + +Both accept the same filters: `--sender --recipient --subject --decision +--sort-field --sort-direction --page --page-size`. + +```bash +# Why didn't this arrive? Look at the decision in the flow logs. +py mp.py logs domains --recipient ceo@client.com --decision quarantine_spam + +# Held / quarantined mail search. +py mp.py messages domains --sender boss@vendor.com +``` + +`--decision` values: `default`, `deliver`, `quarantine_spam`, +`quarantine_virus`, `quarantine_policy`, `bounce`, `encrypt`, `delete`. +`--sort-field` values: `@timestamp` (default), `prime.direction`, +`prime.from_header_raw`, `prime.recipient`, `prime.subject`, `prime.decision`, +`prime.score`. + +## Writes (gated) + +Every mutating command prints a `[DRY RUN]` line and exits non-zero unless you +pass `--confirm`. + +```bash +py mp.py release --confirm +py mp.py release --recipients alt@client.com --confirm +py mp.py release-many --ids 111,222,333 --confirm +py mp.py release-many --all --confirm +py mp.py add-rule --value vendor.com --type allow --confirm +py mp.py enable-release --confirm +``` + +## The `allow_spam_release` gotcha + +Releasing a held **spam** message fails if the owning entity does not have +`permissions.messages.allow_spam_release = true`. Workflow: + +1. `py mp.py config ` — check `allow_spam_release`. +2. If `false`: `py mp.py enable-release --confirm`. +3. Re-run the `release` / `release-many`. + +Virus and policy quarantines are governed separately — only spam release is +gated by this permission. + +## Example workflow: find a client's held outbound mail from a sender and release it + +```bash +# 1. Find the client's domain. +py mp.py domains --scope customers --id + +# 2. Search held messages from the sender (outbound = sender is the client user). +py mp.py messages domains --sender user@client.com --decision quarantine_spam + +# 3. If it's spam-held, make sure release is permitted on the domain. +py mp.py config domains # check allow_spam_release +py mp.py enable-release domains --confirm # only if needed + +# 4. Release by message id (DRY RUN first — omit --confirm to preview). +py mp.py release # [DRY RUN] +py mp.py release --confirm # actually release +``` + +## Raw escape hatch + +The named commands cover the common surface; for anything else, hit the path +directly. Non-GET methods still require `--confirm`. + +```bash +py mp.py raw GET domains//logs +py mp.py raw POST messages//deliver --body '{"include_original_recipients":1}' --confirm +``` + +## Notes + +- This is the **LIVE production reseller CloudFilter platform**. A release + re-delivers real mail to real recipients, and an allow rule can let real spam + or phishing through — confirm the target entity with a read command before any + write, and prefer releasing specific message ids over `--all`. +- Pagination: `page` (default 1) and `per_page` (default 25); reseller + `messages` caps `per_page` at 50. The `X-Pagination` response header carries + the page/total metadata. +- Full endpoint catalog, filter tables, and the global `field[op]=value` + operators live in `references/api.md`. diff --git a/.claude/skills/mailprotector/references/api.md b/.claude/skills/mailprotector/references/api.md new file mode 100644 index 0000000..9e9bdcc --- /dev/null +++ b/.claude/skills/mailprotector/references/api.md @@ -0,0 +1,155 @@ +# Mailprotector CloudFilter REST API — Reference + +Full endpoint catalog and filter tables for the `mailprotector` skill. SKILL.md +stays lean; the detail lives here. + +## Connection + +| Item | Value | +|---|---| +| Base URL | `https://emailservice.io/api/v1` | +| Override env | `MAILPROTECTOR_API_BASE_URL` | +| Auth | `Authorization: Bearer ` | +| Key env override | `MAILPROTECTOR_API_KEY` | +| Vault entry | `msp-tools/mailprotector.sops.yaml`, field `credentials.api_key` | + +Credential resolution order: `MAILPROTECTOR_API_KEY` env -> vault +`credentials.api_key` (read via `bash /.claude/scripts/vault.sh get-field`). +A clear setup error is raised if neither resolves. + +## Scopes + +The five entity types that carry `logs`, `messages`, `configuration`, +`users`, `domains`, and `allow_block_rules` sub-resources. Path form is +`/{scope}/{id}/...`: + +``` +resellers, customers, domains, user_groups, users +``` + +The CLI validates `scope` against this set. + +## Pagination + +| Param | Default | Notes | +|---|---|---| +| `page` | 1 | 1-indexed page number | +| `per_page` | 25 | Max **50** on reseller `messages` | + +The response includes an `X-Pagination` response header (a JSON document with +the page/total metadata). + +## Global list filtering + +List endpoints accept `field[op]=value` filters. Operators: + +| Op | Meaning | +|---|---| +| `Gt` | greater than | +| `Geq` | greater than or equal | +| `Lt` | less than | +| `Leq` | less than or equal | +| `Eq` | equal | + +Example: `created_at[geq]=2026-06-01`. + +## Logs / messages filtering + +Every `.../logs` and `.../messages` endpoint accepts these params: + +| Param | Default | Allowed values | +|---|---|---| +| `sort_direction` | `desc` | `desc`, `asc` | +| `sort_field` | `@timestamp` | `@timestamp`, `prime.direction`, `prime.from_header_raw`, `prime.recipient`, `prime.subject`, `prime.decision`, `prime.score` | +| `page` | 1 | integer | +| `page_size` | (API default) | integer | +| `sender` | (none) | sender filter | +| `recipient` | (none) | recipient filter | +| `subject` | (none) | subject filter | +| `decision` | `all` | `default`, `deliver`, `quarantine_spam`, `quarantine_virus`, `quarantine_policy`, `bounce`, `encrypt`, `delete` | + +## READ endpoints + +| Method | Path | Client method | CLI | +|---|---|---|---| +| GET | `/domains` | `domains()` | `domains` | +| GET | `/{scope}/{id}/domains` | `domains(scope,id)` | `domains --scope --id` | +| GET | `/domains/{id}` | `domain(id)` | `domain ` | +| GET | `/resellers/{id}/customers` | `customers(id)` | `customers ` | +| GET | `/customers/{id}` | `customer(id)` | `customer ` | +| GET | `/{scope}/{id}/users` | `users(scope,id)` | `users ` | +| GET | `/users/{id}` | `user(id)` | `user ` | +| POST | `/users/find_by_address` | `find_user(address)` | `find-user
` | +| GET | `/{scope}/{id}/logs` | `logs(scope,id,...)` | `logs ` | +| GET | `/{scope}/{id}/messages` | `messages(scope,id,...)` | `messages ` | +| GET | `/{scope}/{id}/configuration` | `configuration(scope,id)` | `config ` | +| GET | `/{scope}/{id}/allow_block_rules` | `allow_block_rules(scope,id)` | `rules ` | + +**`find_by_address` is a READ** despite being a POST — it looks up a user / alias +by email. It is NOT gated behind `--confirm`. + +`status` is a synthetic read: `GET /domains?per_page=1` used purely to validate +the bearer token (HTTP 200 = key good). + +## WRITE endpoints (gated behind `--confirm`) + +Without `--confirm` the CLI prints `[DRY RUN] Would : ` and exits +with code 2. With `--confirm` it performs the call. + +### Release one held message + +``` +POST /messages/{message_id}/deliver +body: {"include_original_recipients": 1, "recipients": ""} +``` +Client: `release_message(message_id, recipients=None)` — CLI: `release [--recipients csv] --confirm` + +### Bulk release held messages + +``` +POST /{scope}/{id}/messages/deliver_many +body: {"include_original_recipients": 1, "recipients": "", + "all_selected": false, "ids": ""} +``` +Client: `release_many(scope, id, ids=None, all_selected=False, recipients=None)` +CLI: `release-many [--ids csv | --all] [--recipients csv] --confirm` + +### Add allow / block rule + +``` +POST /{scope}/{id}/allow_block_rules +body: {"value": "...", "rule_type": "allow" | "block"} +``` +Client: `add_rule(scope, id, value, rule_type)` — CLI: `add-rule --value --type allow|block --confirm` + +### Enable spam release on an entity + +``` +PUT /{scope}/{id}/configuration +body: {"permissions": {"messages": {"allow_spam_release": true}}} +``` +Client: `enable_release(scope, id)` — CLI: `enable-release --confirm` + +This is required before an entity's held **spam** can be released. Check the +state first with `config ` and look at +`permissions.messages.allow_spam_release`. + +## Raw escape hatch + +``` +py mp.py raw [--body JSON] [--confirm] +``` +Non-GET methods require `--confirm`. Use for any endpoint not wrapped by a named +command. + +## The `allow_spam_release` gotcha + +Releasing a held **spam** message will fail (or silently no-op) if the owning +entity does not have `permissions.messages.allow_spam_release = true`. The fix: + +1. `py mp.py config ` — confirm `allow_spam_release` is `false`. +2. `py mp.py enable-release --confirm` — flip it to `true`. +3. Re-run the `release` / `release-many`. + +Virus and policy quarantines are governed separately — only spam release is +gated by this permission. diff --git a/.claude/skills/mailprotector/scripts/mp.py b/.claude/skills/mailprotector/scripts/mp.py new file mode 100644 index 0000000..495d08e --- /dev/null +++ b/.claude/skills/mailprotector/scripts/mp.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""CLI for the mailprotector skill — Mailprotector CloudFilter REST API. + +Read subcommands run freely. Write subcommands (release, release-many, add-rule, +enable-release, raw with a non-GET method) refuse to run unless --confirm is +passed; without it they print what they WOULD do and exit non-zero. + +NOTE: find-user is a READ even though it is a POST under the hood — it is NOT +gated. + +Read examples: + py mp.py status + py mp.py domains + py mp.py domain + py mp.py customers + py mp.py users + py mp.py find-user user@client.com + py mp.py logs --sender boss@vendor.com --decision quarantine_spam + py mp.py messages --recipient ceo@client.com + py mp.py config + py mp.py rules + +Write examples (all require --confirm): + py mp.py release --confirm + py mp.py release-many --ids 111,222,333 --confirm + py mp.py add-rule --value vendor.com --type allow --confirm + py mp.py enable-release --confirm + +Escape hatch (raw request against any path; non-GET requires --confirm): + py mp.py raw GET domains/123/logs + py mp.py raw POST messages/999/deliver --body '{...}' --confirm + +`scope` values are validated against: + resellers, customers, domains, user_groups, users +""" +from __future__ import annotations + +import argparse +import json +import sys + +from mp_client import MailprotectorClient, MailprotectorError, VALID_SCOPES + + +def _emit(obj) -> None: + print(json.dumps(obj, indent=2, ensure_ascii=False, default=str)) + + +def _parse_body(raw: str | None) -> dict | None: + if raw is None: + return None + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise SystemExit(f"--body is not valid JSON: {exc}") + if not isinstance(parsed, dict): + raise SystemExit("--body must be a JSON object") + return parsed + + +def _require_confirm(args, action: str, detail: str) -> None: + if not getattr(args, "confirm", False): + print(f"[DRY RUN] Would {action}: {detail}") + print("Refusing to perform a write without --confirm. Re-run with --confirm.") + raise SystemExit(2) + + +def _add_log_filters(sp) -> None: + """Attach the shared logs/messages filter flags to a subparser.""" + sp.add_argument("scope", choices=VALID_SCOPES) + sp.add_argument("id") + sp.add_argument("--sender") + sp.add_argument("--recipient") + sp.add_argument("--subject") + sp.add_argument( + "--decision", + choices=[ + "default", + "deliver", + "quarantine_spam", + "quarantine_virus", + "quarantine_policy", + "bounce", + "encrypt", + "delete", + ], + ) + sp.add_argument( + "--sort-field", + dest="sort_field", + choices=[ + "@timestamp", + "prime.direction", + "prime.from_header_raw", + "prime.recipient", + "prime.subject", + "prime.decision", + "prime.score", + ], + ) + sp.add_argument( + "--sort-direction", dest="sort_direction", choices=["desc", "asc"] + ) + sp.add_argument("--page", type=int) + sp.add_argument("--page-size", dest="page_size", type=int) + + +def main(argv=None) -> int: + p = argparse.ArgumentParser( + prog="mp.py", description="Mailprotector CloudFilter REST API CLI" + ) + p.add_argument("--json", action="store_true", help="emit raw JSON (default)") + sub = p.add_subparsers(dest="cmd", required=True) + + # --- read --- + sub.add_parser("status", help="validate token (GET /domains per_page=1)") + + sp = sub.add_parser("domains", help="list domains (global or scoped)") + sp.add_argument("--scope", choices=VALID_SCOPES) + sp.add_argument("--id", help="entity id (required if --scope given)") + sp.add_argument("--page", type=int, default=1) + sp.add_argument("--per-page", dest="per_page", type=int, default=25) + + sp = sub.add_parser("domain", help="one domain") + sp.add_argument("domain_id") + + sp = sub.add_parser("customers", help="customers under a reseller") + sp.add_argument("reseller_id") + sp.add_argument("--page", type=int, default=1) + sp.add_argument("--per-page", dest="per_page", type=int, default=25) + + sp = sub.add_parser("customer", help="one customer") + sp.add_argument("customer_id") + + sp = sub.add_parser("users", help="users under an entity") + sp.add_argument("scope", choices=VALID_SCOPES) + sp.add_argument("id") + sp.add_argument("--page", type=int, default=1) + sp.add_argument("--per-page", dest="per_page", type=int, default=25) + + sp = sub.add_parser("user", help="one user") + sp.add_argument("user_id") + + sp = sub.add_parser("find-user", help="find a user/alias by email address") + sp.add_argument("address") + + sp = sub.add_parser("logs", help="mail-flow logs for an entity") + _add_log_filters(sp) + + sp = sub.add_parser("messages", help="held/quarantined messages for an entity") + _add_log_filters(sp) + + sp = sub.add_parser("config", help="entity configuration") + sp.add_argument("scope", choices=VALID_SCOPES) + sp.add_argument("id") + + sp = sub.add_parser("rules", help="allow/block rules for an entity") + sp.add_argument("scope", choices=VALID_SCOPES) + sp.add_argument("id") + + # --- write (gated) --- + sp = sub.add_parser("release", help="release one held message") + sp.add_argument("message_id") + sp.add_argument("--recipients", help="optional csv of override recipients") + sp.add_argument("--confirm", action="store_true") + + sp = sub.add_parser("release-many", help="bulk-release held messages") + sp.add_argument("scope", choices=VALID_SCOPES) + sp.add_argument("id") + sp.add_argument("--ids", help="csv of message ids to release") + sp.add_argument("--all", action="store_true", help="release all selected") + sp.add_argument("--recipients", help="optional csv of override recipients") + sp.add_argument("--confirm", action="store_true") + + sp = sub.add_parser("add-rule", help="add an allow/block rule") + sp.add_argument("scope", choices=VALID_SCOPES) + sp.add_argument("id") + sp.add_argument("--value", required=True) + sp.add_argument("--type", dest="rule_type", required=True, choices=["allow", "block"]) + sp.add_argument("--confirm", action="store_true") + + sp = sub.add_parser( + "enable-release", help="enable spam release on an entity (allow_spam_release)" + ) + sp.add_argument("scope", choices=VALID_SCOPES) + sp.add_argument("id") + sp.add_argument("--confirm", action="store_true") + + # --- raw escape hatch --- + sp = sub.add_parser("raw", help="raw request against any path") + sp.add_argument("method", choices=["GET", "POST", "PUT", "PATCH", "DELETE"]) + sp.add_argument("path", help="relative path, e.g. domains/123/logs") + sp.add_argument("--body") + sp.add_argument("--confirm", action="store_true") + + args = p.parse_args(argv) + client = MailprotectorClient() + + try: + if args.cmd == "status": + result = client.status() + _emit({"status": "ok", "auth": "valid", "sample": result}) + elif args.cmd == "domains": + if args.scope and not args.id: + raise SystemExit("--id is required when --scope is given") + _emit( + client.domains( + scope=args.scope, + entity_id=args.id, + page=args.page, + per_page=args.per_page, + ) + ) + elif args.cmd == "domain": + _emit(client.domain(args.domain_id)) + elif args.cmd == "customers": + _emit( + client.customers( + args.reseller_id, page=args.page, per_page=args.per_page + ) + ) + elif args.cmd == "customer": + _emit(client.customer(args.customer_id)) + elif args.cmd == "users": + _emit( + client.users( + args.scope, args.id, page=args.page, per_page=args.per_page + ) + ) + elif args.cmd == "user": + _emit(client.user(args.user_id)) + elif args.cmd == "find-user": + _emit(client.find_user(args.address)) + elif args.cmd == "logs": + _emit( + client.logs( + args.scope, + args.id, + sort_direction=args.sort_direction, + sort_field=args.sort_field, + page=args.page, + page_size=args.page_size, + sender=args.sender, + recipient=args.recipient, + subject=args.subject, + decision=args.decision, + ) + ) + elif args.cmd == "messages": + _emit( + client.messages( + args.scope, + args.id, + sort_direction=args.sort_direction, + sort_field=args.sort_field, + page=args.page, + page_size=args.page_size, + sender=args.sender, + recipient=args.recipient, + subject=args.subject, + decision=args.decision, + ) + ) + elif args.cmd == "config": + _emit(client.configuration(args.scope, args.id)) + elif args.cmd == "rules": + _emit(client.allow_block_rules(args.scope, args.id)) + + elif args.cmd == "release": + detail = args.message_id + if args.recipients: + detail += f" -> {args.recipients}" + _require_confirm(args, "RELEASE held message", detail) + _emit(client.release_message(args.message_id, recipients=args.recipients)) + elif args.cmd == "release-many": + if not args.ids and not args.all: + raise SystemExit("release-many requires --ids or --all") + target = "ALL selected" if args.all else f"ids={args.ids}" + _require_confirm( + args, "BULK RELEASE held messages", f"{args.scope}/{args.id}: {target}" + ) + _emit( + client.release_many( + args.scope, + args.id, + ids=args.ids, + all_selected=args.all, + recipients=args.recipients, + ) + ) + elif args.cmd == "add-rule": + _require_confirm( + args, + f"add {args.rule_type} rule", + f"{args.scope}/{args.id}: {args.value}", + ) + _emit( + client.add_rule(args.scope, args.id, args.value, args.rule_type) + ) + elif args.cmd == "enable-release": + _require_confirm( + args, + "enable spam release (allow_spam_release=true)", + f"{args.scope}/{args.id}", + ) + _emit(client.enable_release(args.scope, args.id)) + + elif args.cmd == "raw": + body = _parse_body(args.body) + if args.method != "GET": + _require_confirm(args, f"{args.method} {args.path}", json.dumps(body)) + _emit(client.request(args.method, args.path, json_body=body)) + else: + p.error(f"unknown command {args.cmd}") + except MailprotectorError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/mailprotector/scripts/mp_client.py b/.claude/skills/mailprotector/scripts/mp_client.py new file mode 100644 index 0000000..53d9b0f --- /dev/null +++ b/.claude/skills/mailprotector/scripts/mp_client.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +"""Client for the mailprotector skill — Mailprotector CloudFilter REST API. + +Talks to the live Mailprotector CloudFilter platform at emailservice.io. This is +the reseller email-security gateway (CloudFilter delivery + INKY annotation) that +ACG layers on top of client Exchange mail flow. Held / quarantined mail, mail-flow +logs, allow/block rules, and message release all live behind this API. + +Auth: Bearer token. The API key is used directly as the bearer token: + Authorization: Bearer + +Credentials are NEVER hardcoded. They are loaded at runtime from the SOPS vault +entry `msp-tools/mailprotector.sops.yaml`, or from an environment override. + +Resolution order: + 1. MAILPROTECTOR_API_KEY env + 2. vault credentials.api_key (read via bash /.claude/scripts/vault.sh) + +Transport: prefers httpx if installed, else falls back to stdlib urllib so the +skill works on a bare Python install. +""" +from __future__ import annotations + +import json +import os +import subprocess +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Optional + +try: + import httpx # type: ignore + + _HAS_HTTPX = True +except ImportError: # pragma: no cover + _HAS_HTTPX = False + +SKILL_DIR = Path(__file__).resolve().parent.parent # .../.claude/skills/mailprotector +ERROR_BODY_MAX_CHARS = 1500 +DEFAULT_TIMEOUT = 60.0 +DEFAULT_CONNECT_TIMEOUT = 15.0 + +API_BASE_URL = os.environ.get( + "MAILPROTECTOR_API_BASE_URL", "https://emailservice.io/api/v1" +) + +VAULT_ENTRY = "msp-tools/mailprotector.sops.yaml" + +# The five entity types that have logs / messages / configuration sub-resources. +VALID_SCOPES = ("resellers", "customers", "domains", "user_groups", "users") + + +class MailprotectorError(Exception): + """Any failure talking to the Mailprotector API or loading credentials.""" + + +# --- repo-root + credential loading ------------------------------------------- +def _resolve_claudetools_root() -> Path: + """Resolve the ClaudeTools repo root: env var, then identity.json, then derived. + + Final fallback is derived from this file's location so it works on the + Mac/Linux fleet, not only the Windows default. + """ + # SKILL_DIR = .../.claude/skills/mailprotector ; root is three levels up. + derived_root = SKILL_DIR.parent.parent.parent + + env_root = os.environ.get("CLAUDETOOLS_ROOT") + if env_root: + return Path(env_root) + + identity_path = derived_root / ".claude" / "identity.json" + if identity_path.exists(): + try: + data = json.loads(identity_path.read_text(encoding="utf-8")) + root = data.get("claudetools_root") + if root: + return Path(root) + except (json.JSONDecodeError, OSError): + pass + + return derived_root + + +def _vault_field(field: str) -> Optional[str]: + """Read a single field from the mailprotector vault entry. None if absent. + + Soft failure: a missing field (vault exits non-zero) returns None so the + caller can surface a clean setup error. A missing vault wrapper or bash + raises, since that is an environment problem the user must fix. + """ + root = _resolve_claudetools_root() + vault_script = root / ".claude" / "scripts" / "vault.sh" + if not vault_script.exists(): + raise MailprotectorError( + f"vault wrapper not found at {vault_script}; set MAILPROTECTOR_API_KEY " + "instead." + ) + try: + completed = subprocess.run( + ["bash", str(vault_script), "get-field", VAULT_ENTRY, field], + capture_output=True, + text=True, + timeout=60, + ) + except FileNotFoundError as exc: + raise MailprotectorError( + "'bash' not found on PATH. Install Git Bash or set MAILPROTECTOR_API_KEY." + ) from exc + except subprocess.TimeoutExpired as exc: + raise MailprotectorError("vault call timed out.") from exc + + if completed.returncode != 0: + return None + value = completed.stdout.strip() + return value or None + + +def load_api_key() -> str: + """Resolve the Mailprotector API key (bearer token). + + Resolution order: + 1. MAILPROTECTOR_API_KEY env + 2. vault credentials.api_key + + Raises MailprotectorError with setup guidance if nothing resolves. + """ + env_key = os.environ.get("MAILPROTECTOR_API_KEY") + if env_key: + return env_key.strip() + + api_key = _vault_field("credentials.api_key") + if api_key: + return api_key + + raise MailprotectorError( + "No Mailprotector / CloudFilter credentials found.\n" + f" Expected vault entry: {VAULT_ENTRY} with:\n" + " credentials.api_key (Bearer token for emailservice.io)\n" + " Or set the MAILPROTECTOR_API_KEY environment variable for testing.\n" + " Provision a reseller API key in the Mailprotector CloudFilter portal,\n" + " then store it in the SOPS vault.\n" + " See .claude/skills/mailprotector/SKILL.md for the full setup steps." + ) + + +def validate_scope(scope: str) -> str: + """Ensure a scope is one of the five valid entity types. Raises otherwise.""" + if scope not in VALID_SCOPES: + raise MailprotectorError( + f"Invalid scope '{scope}'. Must be one of: {', '.join(VALID_SCOPES)}" + ) + return scope + + +# --- client ------------------------------------------------------------------- +class MailprotectorClient: + def __init__( + self, + api_base_url: str = API_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, + ): + self.api_base_url = api_base_url.rstrip("/") + self.timeout = timeout + self.connect_timeout = connect_timeout + self._api_key: Optional[str] = None + + # -- auth ------------------------------------------------------------------ + @property + def api_key(self) -> str: + if self._api_key is None: + self._api_key = load_api_key() + return self._api_key + + # -- core transport -------------------------------------------------------- + def request( + self, + method: str, + path: str, + params: Optional[dict] = None, + json_body: Optional[dict] = None, + ) -> Any: + """One REST call against the API base. `path` is relative (e.g. 'domains').""" + url = f"{self.api_base_url}/{path.lstrip('/')}" + if params: + # Drop None-valued params so optional filters stay off the query string. + clean = {k: v for k, v in params.items() if v is not None} + if clean: + url = f"{url}?{urllib.parse.urlencode(clean, doseq=True)}" + data = json.dumps(json_body).encode("utf-8") if json_body is not None else None + headers = {"Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + return self._http( + method, url, data=data, headers=headers, + auth_header=f"Bearer {self.api_key}", + ) + + def _http( + self, + method: str, + url: str, + data: Optional[bytes] = None, + headers: Optional[dict] = None, + auth_header: Optional[str] = None, + ) -> Any: + hdrs = dict(headers or {}) + if auth_header: + hdrs["Authorization"] = auth_header + + if _HAS_HTTPX: + try: + timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout) + with httpx.Client(timeout=timeout) as client: + resp = client.request(method, url, content=data, headers=hdrs) + resp.raise_for_status() + return self._parse(resp.content) + except httpx.TimeoutException as exc: + raise MailprotectorError(f"request timed out: {exc}") from exc + except httpx.HTTPStatusError as exc: + detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS] + raise MailprotectorError( + f"HTTP {exc.response.status_code} {method} {url}: {detail}" + ) from exc + except httpx.HTTPError as exc: + raise MailprotectorError(f"request failed: {exc}") from exc + + # stdlib fallback + req = urllib.request.Request(url, data=data, method=method, headers=hdrs) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + return self._parse(resp.read()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS] + raise MailprotectorError(f"HTTP {exc.code} {method} {url}: {detail}") from exc + except urllib.error.URLError as exc: + raise MailprotectorError(f"request failed: {exc}") from exc + + @staticmethod + def _parse(raw: bytes) -> Any: + if not raw: + return None + try: + return json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + return raw.decode("utf-8", errors="replace") + + @staticmethod + def _q(value: str) -> str: + """URL-quote a path segment (an id), keeping it safe in a path position.""" + return urllib.parse.quote(str(value), safe="") + + # ====================================================================== + # READ METHODS (safe — always live) + # ====================================================================== + def status(self) -> Any: + """Token validation probe: smallest possible authenticated GET.""" + return self.request("GET", "domains", params={"per_page": 1}) + + def domains( + self, + scope: Optional[str] = None, + entity_id: Optional[str] = None, + page: int = 1, + per_page: int = 25, + ) -> Any: + """List domains, globally or scoped under an entity.""" + params = {"page": page, "per_page": per_page} + if scope and entity_id: + validate_scope(scope) + return self.request( + "GET", f"{scope}/{self._q(entity_id)}/domains", params=params + ) + return self.request("GET", "domains", params=params) + + def domain(self, domain_id: str) -> Any: + return self.request("GET", f"domains/{self._q(domain_id)}") + + def customers(self, reseller_id: str, page: int = 1, per_page: int = 25) -> Any: + return self.request( + "GET", + f"resellers/{self._q(reseller_id)}/customers", + params={"page": page, "per_page": per_page}, + ) + + def customer(self, customer_id: str) -> Any: + return self.request("GET", f"customers/{self._q(customer_id)}") + + def users( + self, scope: str, entity_id: str, page: int = 1, per_page: int = 25 + ) -> Any: + validate_scope(scope) + return self.request( + "GET", + f"{scope}/{self._q(entity_id)}/users", + params={"page": page, "per_page": per_page}, + ) + + def user(self, user_id: str) -> Any: + return self.request("GET", f"users/{self._q(user_id)}") + + def find_user(self, address: str) -> Any: + """Find a user / alias by email address. + + This is a READ despite being a POST — it is NOT gated. + """ + return self.request( + "POST", "users/find_by_address", json_body={"address": address} + ) + + def logs( + self, + scope: str, + entity_id: str, + sort_direction: Optional[str] = None, + sort_field: Optional[str] = None, + page: Optional[int] = None, + page_size: Optional[int] = None, + sender: Optional[str] = None, + recipient: Optional[str] = None, + subject: Optional[str] = None, + decision: Optional[str] = None, + ) -> Any: + """Mail-flow logs for an entity (passes through the standard log filters).""" + validate_scope(scope) + params = { + "sort_direction": sort_direction, + "sort_field": sort_field, + "page": page, + "page_size": page_size, + "sender": sender, + "recipient": recipient, + "subject": subject, + "decision": decision, + } + return self.request( + "GET", f"{scope}/{self._q(entity_id)}/logs", params=params + ) + + def messages( + self, + scope: str, + entity_id: str, + sort_direction: Optional[str] = None, + sort_field: Optional[str] = None, + page: Optional[int] = None, + page_size: Optional[int] = None, + sender: Optional[str] = None, + recipient: Optional[str] = None, + subject: Optional[str] = None, + decision: Optional[str] = None, + ) -> Any: + """Held / quarantined messages for an entity (same filters as logs).""" + validate_scope(scope) + params = { + "sort_direction": sort_direction, + "sort_field": sort_field, + "page": page, + "page_size": page_size, + "sender": sender, + "recipient": recipient, + "subject": subject, + "decision": decision, + } + return self.request( + "GET", f"{scope}/{self._q(entity_id)}/messages", params=params + ) + + def configuration(self, scope: str, entity_id: str) -> Any: + """Entity configuration (includes permissions.messages.allow_spam_release).""" + validate_scope(scope) + return self.request("GET", f"{scope}/{self._q(entity_id)}/configuration") + + def allow_block_rules(self, scope: str, entity_id: str) -> Any: + validate_scope(scope) + return self.request( + "GET", f"{scope}/{self._q(entity_id)}/allow_block_rules" + ) + + # ====================================================================== + # WRITE METHODS (gated — the CLI requires --confirm before calling these) + # ====================================================================== + def release_message( + self, message_id: str, recipients: Optional[str] = None + ) -> Any: + """Release (deliver) one held message. POST /messages/{id}/deliver.""" + body: dict = {"include_original_recipients": 1} + if recipients: + body["recipients"] = recipients + return self.request( + "POST", f"messages/{self._q(message_id)}/deliver", json_body=body + ) + + def release_many( + self, + scope: str, + entity_id: str, + ids: Optional[str] = None, + all_selected: bool = False, + recipients: Optional[str] = None, + ) -> Any: + """Bulk-release held messages under an entity. POST .../messages/deliver_many.""" + validate_scope(scope) + body: dict = { + "include_original_recipients": 1, + "all_selected": all_selected, + "ids": ids or "", + } + if recipients: + body["recipients"] = recipients + return self.request( + "POST", + f"{scope}/{self._q(entity_id)}/messages/deliver_many", + json_body=body, + ) + + def add_rule( + self, scope: str, entity_id: str, value: str, rule_type: str + ) -> Any: + """Add an allow / block rule on an entity. POST .../allow_block_rules.""" + validate_scope(scope) + if rule_type not in ("allow", "block"): + raise MailprotectorError("rule_type must be 'allow' or 'block'") + return self.request( + "POST", + f"{scope}/{self._q(entity_id)}/allow_block_rules", + json_body={"value": value, "rule_type": rule_type}, + ) + + def enable_release(self, scope: str, entity_id: str) -> Any: + """Enable spam release on an entity. PUT .../configuration. + + Sets permissions.messages.allow_spam_release = true. Without this, the + entity's held spam cannot be released. + """ + validate_scope(scope) + return self.request( + "PUT", + f"{scope}/{self._q(entity_id)}/configuration", + json_body={"permissions": {"messages": {"allow_spam_release": True}}}, + )