#!/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())