Files
magnus919_agent-skills/email/scripts/email-cli
T
Magnus HedemarkGitHubfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
3256a87bcb feat(skill): add collaboration & business-app tool layer (Slack, Notion, email, CRM, payments) (#269)
Adds five top-level operational tool skills, one per named tool:

- slack: messages, channels, threads, search, files, and webhook signature
  verification (HMAC-SHA256) via a bounded, stdlib-only slack-cli.
- notion: pages, database queries, search, and guarded page updates via
  notion-cli.
- email: transactional email via Twilio SendGrid (send, deliverability
  bounces/spam reports, Signed Event Webhook verification with a
  self-contained ECDSA P-256 verifier) via email-cli.
- crm: HubSpot CRM records, contact search, and deal pipeline views with
  guarded stage updates via crm-cli.
- stripe: read-only-first balance, payment, and subscription queries with
  a guarded period-end subscription cancellation via stripe-cli.

Each skill ships an executable script (--json output, --limit bounded reads,
--dry-run/--yes mutation gate), a human README with the five required
sections, a schema-v1 evals/evals.json with six output-quality cases, a dated
source index + operations reference, and a deterministic unittest suite run
by check-artifacts. All five are indexed in the top-level README and the
generated catalogs were regenerated. Eval coverage rises from 78/139 to
83/144.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 20:19:26 -04:00

412 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""email-cli - bounded, agent-first command line for transactional email.
Sends transactional email through Twilio SendGrid and checks deliverability
(bounces, spam reports) using only the Python standard library. Includes
verification of SendGrid's Signed Event Webhook (ECDSA P-256) with a
self-contained implementation — no third-party crypto dependency.
Design rules:
- Read-only by default. Sending email is a guarded mutation: it requires
--dry-run to preview, then --yes to confirm. Mutation requires explicit
confirmation.
- Bounded reads: deliverability listings cap results with --limit and never
page past the requested cap.
- --json emits machine-readable JSON; the default is human-readable text.
- --help works with no SENDGRID_API_KEY set and makes no network calls.
Environment:
SENDGRID_API_KEY SendGrid API key with mail.send + suppression access
Webhook signature verification (the Signed Event Webhook) follows Twilio's
reference implementation: data = timestamp + raw body (bytes concatenated,
no separator), SHA-256 digest, verified as an ECDSA signature over the P-256
curve with the webhook public key; the header signature is base64-decoded
ASN.1 DER (r, s).
Exit codes: 0 success, 1 API error or failed verification, 2 usage error.
"""
import argparse
import base64
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
from typing import Any, Dict, Optional, Tuple
API_BASE = os.environ.get("SENDGRID_API_BASE", "https://api.sendgrid.com/v3")
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
REQUEST_TIMEOUT = 15
DEFAULT_MAX_AGE_SECONDS = 300
TEXT_TRUNCATE = 500
class EmailError(Exception):
"""Raised when the SendGrid API returns an error or verification fails."""
def get_api_key() -> str:
key = os.environ.get("SENDGRID_API_KEY", "")
if not key:
raise EmailError("SENDGRID_API_KEY environment variable is not set")
return key
# --------------------------------------------------------------------------
# ECDSA P-256 verification (stdlib-only)
# --------------------------------------------------------------------------
# secp256r1 / prime256v1 domain parameters
_P256 = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
_P256_A = _P256 - 3
_P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
_P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
_P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
_P256_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
_P256_G = (_P256_GX, _P256_GY)
_POINT_AT_INFINITY = None # represented by None
def _mod_inverse(value: int, modulus: int) -> int:
return pow(value % modulus, -1, modulus)
def _point_add(p1: Optional[Tuple[int, int]], p2: Optional[Tuple[int, int]]) -> Optional[Tuple[int, int]]:
if p1 is None:
return p2
if p2 is None:
return p1
x1, y1 = p1
x2, y2 = p2
if x1 == x2 and (y1 + y2) % _P256 == 0:
return None
if p1 == p2:
lam = (3 * x1 * x1 + _P256_A) * _mod_inverse(2 * y1, _P256) % _P256
else:
lam = (y2 - y1) * _mod_inverse(x2 - x1, _P256) % _P256
x3 = (lam * lam - x1 - x2) % _P256
y3 = (lam * (x1 - x3) - y1) % _P256
return (x3, y3)
def _point_mul(scalar: int, point: Optional[Tuple[int, int]]) -> Optional[Tuple[int, int]]:
result = None
addend = point
while scalar:
if scalar & 1:
result = _point_add(result, addend)
addend = _point_add(addend, addend)
scalar >>= 1
return result
def _parse_public_key(pem: str) -> Tuple[int, int]:
"""Extract the uncompressed P-256 point (0x04 + x + y) from a PEM key."""
lines = [line.strip() for line in pem.splitlines() if line.strip() and "-----" not in line]
try:
der = base64.b64decode("".join(lines))
except (ValueError, TypeError) as error:
raise EmailError("public key is not valid base64 PEM") from error
marker = bytes([0x04])
idx = der.find(marker)
if idx < 0 or idx + 65 > len(der):
raise EmailError("public key is not a valid EC P-256 uncompressed point")
point = der[idx + 1 : idx + 65]
x = int.from_bytes(point[:32], "big")
y = int.from_bytes(point[32:], "big")
# Point-on-curve check: y^2 == x^3 + ax + b (mod p)
if (y * y - (x * x * x + _P256_A * x + _P256_B)) % _P256 != 0:
raise EmailError("public key point is not on the P-256 curve")
return (x, y)
def _parse_der_signature(raw: bytes) -> Tuple[int, int]:
"""Parse a minimal DER SEQUENCE of two INTEGERs into (r, s)."""
if len(raw) < 8 or raw[0] != 0x30:
raise EmailError("signature is not a DER SEQUENCE")
offset = 2 # skip 0x30 + length byte (short-form lengths only)
integers = []
for _ in range(2):
if offset >= len(raw) or raw[offset] != 0x02:
raise EmailError("signature is not two DER INTEGERs")
length = raw[offset + 1]
start = offset + 2
if start + length > len(raw):
raise EmailError("signature INTEGER overruns the blob")
integers.append(int.from_bytes(raw[start : start + length], "big"))
offset = start + length
if len(integers) != 2:
raise EmailError("signature must contain r and s")
return (integers[0], integers[1])
def verify_webhook_signature(public_key_pem: str, body: bytes, signature: str, timestamp: str,
max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS) -> Dict[str, Any]:
"""Verify a SendGrid Signed Event Webhook request.
Data is `timestamp + raw body` (bytes, no separator), SHA-256 digest,
ECDSA P-256 verification against the webhook public key; the
X-Twilio-Email-Event-Webhook-Signature header value is base64-decoded
ASN.1 DER (r, s).
"""
if max_age_seconds > 0:
try:
if abs(int(time.time()) - int(timestamp)) > max_age_seconds:
raise EmailError(
f"webhook timestamp is outside the {max_age_seconds}s replay window"
)
except ValueError as error:
raise EmailError(f"webhook timestamp is not a valid Unix timestamp: {timestamp!r}") from error
try:
decoded = base64.b64decode(signature, validate=True)
except (ValueError, TypeError) as error:
raise EmailError("signature header is not valid base64") from error
point = _parse_public_key(public_key_pem)
r, s = _parse_der_signature(decoded)
if not (1 <= r < _P256_N and 1 <= s < _P256_N):
raise EmailError("webhook signature (r, s) values are out of range")
digest = hashlib.sha256(timestamp.encode("utf-8") + body).digest()
e = int.from_bytes(digest, "big")
w = _mod_inverse(s, _P256_N)
u1 = (e * w) % _P256_N
u2 = (r * w) % _P256_N
result = _point_add(_point_mul(u1, _P256_G), _point_mul(u2, point))
if result is None or result[0] % _P256_N != r:
raise EmailError("webhook signature does not match the public key")
return {"ok": True, "verified": True, "timestamp": timestamp}
# --------------------------------------------------------------------------
# SendGrid API helpers
# --------------------------------------------------------------------------
def api_request(method: str, path: str, api_key: str,
body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(
f"{API_BASE}/{path.lstrip('/')}",
data=data,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method=method,
)
try:
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
raw = response.read().decode("utf-8")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as error:
detail = ""
try:
detail = json.loads(error.read().decode("utf-8")).get("errors", "")
except (json.JSONDecodeError, UnicodeDecodeError):
pass
raise EmailError(f"SendGrid API HTTP {error.code}: {detail or error.reason}") from error
except urllib.error.URLError as error:
raise EmailError(f"SendGrid API unreachable: {error.reason}") from error
except json.JSONDecodeError as error:
raise EmailError(f"SendGrid API returned non-JSON: {error}") from error
def truncate(text: str, limit: int = TEXT_TRUNCATE) -> str:
if len(text) <= limit:
return text
return text[: limit - 1] + "…"
def summarize_bounce(item: Dict[str, Any]) -> Dict[str, Any]:
return {"email": item.get("email", ""), "created": item.get("created", ""),
"reason": truncate(item.get("reason", "")), "status": item.get("status", "")}
def summarize_spam_report(item: Dict[str, Any]) -> Dict[str, Any]:
return {"email": item.get("email", ""), "created": item.get("created", ""),
"ip": item.get("ip", ""), "reason": truncate(item.get("reason", ""))}
# --------------------------------------------------------------------------
# Command implementations
# --------------------------------------------------------------------------
def cmd_send(args: argparse.Namespace, api_key: str) -> Dict[str, Any]:
if not args.dry_run and not args.yes:
raise EmailError(
"refusing to send email without confirmation: pass --dry-run to "
"preview or --yes to confirm the mutation"
)
payload = {
"from": {"email": args.from_addr},
"personalizations": [{"to": [{"email": to} for to in args.to], "subject": args.subject}],
"content": [{"type": "text/plain", "value": args.body}],
}
if args.html:
payload["content"].append({"type": "text/html", "value": args.html})
if args.dry_run:
return {"ok": True, "dry_run": True, "would_send": {
"from": args.from_addr, "to": args.to, "subject": args.subject,
"body_preview": truncate(args.body),
}}
# SendGrid returns 202 with an empty body; the message id is an HTTP header.
request = urllib.request.Request(
f"{API_BASE}/mail/send",
data=json.dumps(payload).encode("utf-8"),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
message_id = ""
try:
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
message_id = response.headers.get("X-Message-Id", "")
except urllib.error.HTTPError as error:
detail = ""
try:
detail = json.loads(error.read().decode("utf-8")).get("errors", "")
except (json.JSONDecodeError, UnicodeDecodeError):
pass
raise EmailError(f"SendGrid API HTTP {error.code}: {detail or error.reason}") from error
except urllib.error.URLError as error:
raise EmailError(f"SendGrid API unreachable: {error.reason}") from error
return {"ok": True, "message_id": message_id}
def cmd_deliverability(args: argparse.Namespace, api_key: str) -> Dict[str, Any]:
if args.action == "bounces":
payload = api_request("GET", f"suppression/bounces?limit={args.limit}", api_key)
items = [summarize_bounce(i) for i in payload]
return {"ok": True, "kind": "bounces", "items": items, "count": len(items)}
payload = api_request("GET", f"suppression/spam_reports?limit={args.limit}", api_key)
items = [summarize_spam_report(i) for i in payload]
return {"ok": True, "kind": "spam_reports", "items": items, "count": len(items)}
def cmd_webhook_verify(args: argparse.Namespace, unused_api_key: str) -> Dict[str, Any]:
try:
body = open(args.body_file, "rb").read()
except OSError as error:
raise EmailError(f"cannot read body file {args.body_file}: {error}") from error
try:
public_key = open(args.public_key_file, "r", encoding="utf-8").read()
except OSError as error:
raise EmailError(f"cannot read public key file {args.public_key_file}: {error}") from error
return verify_webhook_signature(public_key, body, args.signature, args.timestamp, args.max_age)
# --------------------------------------------------------------------------
# Output helpers
# --------------------------------------------------------------------------
def emit(data: Dict[str, Any], json_mode: bool) -> int:
if json_mode:
print(json.dumps(data, indent=2, sort_keys=True))
else:
_emit_human(data)
return 0
def _emit_human(data: Dict[str, Any]) -> None:
if data.get("dry_run"):
print("DRY RUN (no email sent):")
print(f" from: {data['would_send']['from']}")
print(f" to: {', '.join(data['would_send']['to'])}")
print(f" subject: {data['would_send']['subject']}")
elif "message_id" in data:
print(f"sent: message_id={data['message_id']}")
elif "items" in data:
print(f"{data['kind']} ({data['count']}):")
for item in data["items"]:
if "status" in item:
print(f" {item['email']} {item['status']} {item['reason']}")
else:
print(f" {item['email']} {item['reason']}")
elif data.get("verified"):
print("webhook signature verified (ECDSA P-256 match)")
else:
print(json.dumps(data, indent=2, sort_keys=True))
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="email-cli",
description=(
"Bounded, agent-first CLI for transactional email via Twilio "
"SendGrid: send, deliverability checks (bounces, spam reports), "
"and Signed Event Webhook signature verification. Sending is a "
"guarded mutation requiring --dry-run then --yes."
),
)
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON output")
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N",
help=f"cap reads at N results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})")
sub = parser.add_subparsers(dest="command", required=True)
send = sub.add_parser("send", help="send a transactional email (guarded mutation)")
send.add_argument("--to", action="append", required=True, metavar="EMAIL",
help="recipient email (repeatable)")
send.add_argument("--from", dest="from_addr", required=True, metavar="EMAIL",
help="verified sender email")
send.add_argument("--subject", required=True, help="email subject")
send.add_argument("--body", required=True, help="plain-text body")
send.add_argument("--html", help="optional HTML body")
send.add_argument("--dry-run", action="store_true", help="preview the email without sending")
send.add_argument("--yes", action="store_true", help="confirm the mutation and send")
deliverability = sub.add_parser("deliverability", help="check deliverability signals (read-only)")
del_sub = deliverability.add_subparsers(dest="action", required=True)
del_bounces = del_sub.add_parser("bounces", help="list bounced recipients")
del_spam = del_sub.add_parser("spam-reports", help="list spam-reporting recipients")
webhook = sub.add_parser("webhook", help="verify a SendGrid webhook request (read-only)")
wv_sub = webhook.add_subparsers(dest="action", required=True)
wv_verify = wv_sub.add_parser("verify", help="verify X-Twilio-Email-Event-Webhook-Signature")
wv_verify.add_argument("--body-file", required=True, help="path to the raw webhook request body")
wv_verify.add_argument("--signature", required=True,
help="X-Twilio-Email-Event-Webhook-Signature header value (base64)")
wv_verify.add_argument("--timestamp", required=True,
help="X-Twilio-Email-Event-Webhook-Timestamp header value (Unix seconds)")
wv_verify.add_argument("--public-key-file", required=True,
help="path to the webhook public verification key (PEM)")
wv_verify.add_argument("--max-age", type=int, default=DEFAULT_MAX_AGE_SECONDS,
help=f"replay window in seconds (0 disables; default {DEFAULT_MAX_AGE_SECONDS})")
return parser
def main(argv: Optional[list] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.limit < 1 or args.limit > MAX_LIMIT:
parser.error(f"--limit must be between 1 and {MAX_LIMIT}")
try:
if args.command == "webhook":
result = cmd_webhook_verify(args, "")
else:
api_key = get_api_key()
if args.command == "send":
result = cmd_send(args, api_key)
elif args.command == "deliverability":
result = cmd_deliverability(args, api_key)
else: # pragma: no cover - argparse prevents this
parser.error(f"unknown command: {args.command}")
return emit(result, args.json)
except EmailError as error:
if args.json:
print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True))
else:
print(f"email-cli: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())