scripts/gmail_backend.py — Approval-Gated Mail

From version 0.2.1 of Approval-Gated Mail, shown publicly by its creator. 37.0 KiB · SHA-256 c8a137339adf1d270508fa9232ac7179e8d7a3d4e5b8588f3ba6c8e0e72fe654.

#!/usr/bin/env python3
# Gmail backend for Approval-Gated Mail 0.2.1.
# By Zero Width Lab (zerowidthlab.com)
from __future__ import annotations

import base64
import hashlib
import hmac
import http.client
import json
import os
import re
import secrets
import socket
import stat
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from email import policy
from email.errors import MessageError
from email.message import EmailMessage
from email.utils import parsedate_to_datetime
from email.parser import BytesParser
from email.utils import getaddresses
from email.utils import make_msgid
from email.header import decode_header, make_header
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, Sequence

__author__ = "Zero Width Lab"
SERVICE = "approval-gated-mail-gmail"
SCOPES = ("https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.compose")
API = "https://gmail.googleapis.com/gmail/v1/users/me"
TOKEN = "https://oauth2.googleapis.com/token"
REVOKE = "https://oauth2.googleapis.com/revoke"
HOSTS = {"gmail.googleapis.com", "oauth2.googleapis.com"}
GUIDANCE = "run auth login again; External apps in Testing expire every 7 days"


class RetryableRead(Exception):
  def __init__(self, status: int, delay: float) -> None:
    self.status = status
    self.delay = delay


def b64decode(value: str) -> bytes:
  return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))


def pkce_pair() -> tuple[str, str]:
  verifier = secrets.token_urlsafe(48)
  challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
  return verifier, challenge


def quoted_query(value: str) -> str:
  if '"' in value or "\\" in value:
    raise ValueError('Gmail search text cannot contain a quote or backslash')
  return '"' + value + '"'


def checked_url(url: str, error_type: type[Exception]) -> None:
  parsed = urllib.parse.urlsplit(url)
  if parsed.scheme != "https" or parsed.hostname not in HOSTS or parsed.username is not None or parsed.password is not None:
    raise error_type("refusing network access outside approved Gmail hosts")


def retry_after_seconds(value: str | None, fallback: float) -> float:
  if not value:
    return fallback
  return min(int(value), 30) if value.isascii() and value.isdecimal() else fallback


def decoded_header(value: str) -> str:
  try:
    return str(make_header(decode_header(value)))
  except (UnicodeError, ValueError, LookupError, MessageError, RecursionError):
    return value


def validate_mailbox(value: str, label: str) -> str:
  rule = f"invalid {label} mailbox: encoded words, comments, group syntax, '@' in the display name and non-ASCII addresses are not accepted"
  if any(ord(char) < 32 or 0x7f <= ord(char) <= 0x9f for char in value) or any(char in value for char in (":", ";", "(", ")", "\\", "=?")):
    raise ValueError(rule)
  parsed = getaddresses([value])
  if len(parsed) != 1 or "@" in parsed[0][0] or not parsed[0][1].isascii() or parsed[0][1].count("@") != 1 or not all(parsed[0][1].split("@")) or any(char.isspace() for char in parsed[0][1]):
    raise ValueError(rule)
  return value


def validate_header(value: str, label: str) -> str:
  if any(ord(char) < 32 or 0x7f <= ord(char) <= 0x9f for char in value):
    raise ValueError(f"invalid {label} header")
  return value


def validate_message_id(value: str, strict: bool = False) -> str:
  pattern = r"<[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+@[A-Za-z0-9.-]+>" if strict else r"<[^<>\s\x00-\x1f\x7f-\x9f@]+@[^<>\s\x00-\x1f\x7f-\x9f@]+>"
  if not re.fullmatch(pattern, value):
    raise ValueError("invalid --message-id; expected <local@domain> without whitespace")
  return value


class RedirectGuard(urllib.request.HTTPRedirectHandler):
  def __init__(self, error_type: type[Exception]) -> None:
    self.error_type = error_type

  def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> Any:
    checked_url(newurl, self.error_type)
    if urllib.parse.urlsplit(req.full_url).hostname != urllib.parse.urlsplit(newurl).hostname:
      raise self.error_type("refusing a cross-host redirect")
    return super().redirect_request(req, fp, code, msg, headers, newurl)


class GmailTokenStore:
  def __init__(self, error_type: type[Exception], runner: Any = None) -> None:
    self.error_type = error_type
    if runner is not None:
      if os.environ.get("GMAIL_FAKE_SECURITY_RUNNER") != "1" or os.environ.get("GMAIL_ALLOW_FAKE_SECURITY") != "1":
        raise error_type("fake security runner requires both GMAIL_FAKE_SECURITY_RUNNER=1 and GMAIL_ALLOW_FAKE_SECURITY=1")
      print("warning: fake Gmail security runner active; real Keychain is not used", file=sys.stderr)
    self.runner = runner or subprocess.run
    self.file_mode = os.environ.get("GMAIL_TOKEN_STORE") == "file" or sys.platform != "darwin"
    self.path = Path(os.environ.get("GMAIL_TOKEN_FILE", "~/.config/approval-gated-mail/gmail-token.json")).expanduser()

  def _security(self, args: list[str], input_data: bytes | None = None, *, missing_ok: bool = False) -> str | None:
    try:
      result = self.runner(["/usr/bin/security", *args], input=input_data, capture_output=True, timeout=10, check=False)
    except (OSError, subprocess.TimeoutExpired) as exc:
      raise self.error_type("Gmail Keychain operation failed") from exc
    if result.returncode == 44 and missing_ok:
      return None
    if result.returncode:
      raise self.error_type("Gmail Keychain item is unavailable")
    return result.stdout.decode("utf-8").strip()

  def _identity(self, account: str) -> None:
    if account.startswith("-") or not re.fullmatch(r"[A-Za-z0-9@._+-]{1,128}", account) or not re.fullmatch(r"[A-Za-z0-9@._+-]{1,128}", SERVICE):
      raise self.error_type("invalid Gmail Keychain account or service")

  def _check_file(self) -> None:
    try:
      details = self.path.lstat()
    except FileNotFoundError:
      return
    if not stat.S_ISREG(details.st_mode) or (os.name != "nt" and details.st_mode & 0o077):
      raise self.error_type("Gmail token file must be a regular 0600 file, not a symlink")

  def read(self, account: str = "default") -> dict[str, Any] | None:
    if self.file_mode:
      self._check_file()
      try:
        flags = os.O_RDONLY | (os.O_NOFOLLOW if os.name == "posix" else 0)
        descriptor = os.open(self.path, flags)
      except FileNotFoundError:
        return None
      except OSError as exc:
        raise self.error_type("cannot read Gmail token file") from exc
      opened = os.fstat(descriptor)
      if not stat.S_ISREG(opened.st_mode) or (os.name != "nt" and opened.st_mode & 0o077):
        os.close(descriptor)
        raise self.error_type("Gmail token file must remain a regular 0600 file")
      with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
        try:
          data = json.load(handle)
        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
          raise self.error_type("invalid Gmail token file JSON") from exc
    else:
      self._identity(account)
      secret = self._security(["find-generic-password", "-a", account, "-s", SERVICE, "-w"], missing_ok=True)
      if secret is None:
        return None
      try:
        data = json.loads(secret)
      except (UnicodeError, ValueError) as exc:
        raise self.error_type("invalid Gmail token store JSON") from exc
    if not isinstance(data, dict):
      raise self.error_type("invalid Gmail token store")
    return data

  def write(self, data: Mapping[str, Any], account: str = "default") -> None:
    secret = json.dumps(data, separators=(",", ":"), ensure_ascii=True)
    if not self.file_mode:
      self._identity(account)
      command = f"add-generic-password -a {account} -s {SERVICE} -U -X {secret.encode('utf-8').hex()}\n"
      self._security(["-i"], command.encode("ascii"))
      try:
        stored = self._security(["find-generic-password", "-a", account, "-s", SERVICE, "-w"])
      except self.error_type as exc:
        raise self.error_type("token was not stored in the Keychain") from exc
      if stored != secret:
        raise self.error_type("token was not stored in the Keychain")
      return
    self._check_file()
    self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
    if not self.path.exists():
      flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | (os.O_NOFOLLOW if os.name == "posix" else 0)
      try:
        descriptor = os.open(self.path, flags, 0o600)
        with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
          handle.write(secret)
        return
      except FileExistsError:
        self._check_file()
    descriptor, temporary = tempfile.mkstemp(prefix=".gmail-token-", dir=self.path.parent)
    try:
      if os.name != "nt":
        os.fchmod(descriptor, 0o600)
      with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
        handle.write(secret)
      self._check_file()
      os.replace(temporary, self.path)
    finally:
      if os.path.exists(temporary):
        os.unlink(temporary)

  def delete(self, account: str = "default") -> None:
    if self.file_mode:
      self._check_file()
      try:
        self.path.unlink()
      except FileNotFoundError:
        pass
    else:
      self._identity(account)
      self._security(["delete-generic-password", "-a", account, "-s", SERVICE])


class GmailBackend:
  is_gmail = True
  def __init__(self, shared: Any, config: Mapping[str, Any]) -> None:
    self.shared = shared
    self.config = config
    self.fake_path = os.environ.get("GMAIL_FAKE_TRANSPORT")
    self.fake_responses: list[dict[str, Any]] = []
    if self.fake_path:
      if os.environ.get("GMAIL_ALLOW_FAKE") != "1":
        raise shared.CliError("GMAIL_FAKE_TRANSPORT requires GMAIL_ALLOW_FAKE=1")
      print("warning: Gmail fake transport active; no real network requests will be made", file=sys.stderr)
      self.fake_responses = shared.GraphClient._load_fake_responses(Path(self.fake_path))
    self.opener = urllib.request.build_opener(RedirectGuard(shared.CliError))
    self.store = GmailTokenStore(shared.CliError)
    self.access_token: str | None = None
    self.approved_account: str | None = None
    self.skipped: list[str] = []

  def _client(self) -> dict[str, str]:
    path = os.environ.get("GMAIL_CLIENT_FILE")
    if not path:
      raise self.shared.CliError("GMAIL_CLIENT_FILE must point to your Desktop OAuth client JSON")
    try:
      installed = json.loads(Path(path).read_text(encoding="utf-8"))["installed"]
      client_id = installed["client_id"]
    except (OSError, ValueError, KeyError, TypeError) as exc:
      raise self.shared.CliError("invalid GMAIL_CLIENT_FILE Desktop OAuth client JSON") from exc
    if not isinstance(client_id, str) or not client_id:
      raise self.shared.CliError("Desktop OAuth client ID is missing")
    result = {"client_id": client_id}
    if isinstance(installed.get("client_secret"), str):
      result["client_secret"] = installed["client_secret"]
    return result

  def request(self, method: str, url: str, *, json_body: Any = None, form: Mapping[str, str] | None = None, authenticated: bool = True, _bearer: str | None = None) -> Any:
    checked_url(url, self.shared.CliError)
    method = method.upper()
    token_endpoint = url == TOKEN
    bearer = _bearer
    if authenticated and not self.fake_path:
      bearer = bearer or self._token()
    if authenticated and method != "GET" and url.startswith(API + "/"):
      if not self.approved_account or self.account_address(_bearer=bearer) != self.approved_account:
        raise self.shared.CliError("Gmail account changed or approval missing; no write made")
    attempts = 3 if method == "GET" or token_endpoint else 1
    if self.fake_path:
      for attempt in range(attempts):
        try:
          return self._fake(method, url, json_body)
        except RetryableRead as exc:
          if attempt == attempts - 1:
            raise self.shared.CliError(f"Gmail HTTP {exc.status}; read retries exhausted") from exc
          time.sleep(exc.delay)
    headers = {"Accept": "application/json"}
    if authenticated:
      headers["Authorization"] = "Bearer " + bearer
    payload = None
    if json_body is not None:
      payload = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
      headers["Content-Type"] = "application/json"
    elif form is not None:
      payload = urllib.parse.urlencode(form).encode("utf-8")
      headers["Content-Type"] = "application/x-www-form-urlencoded"
    request = urllib.request.Request(url, data=payload, headers=headers, method=method)
    for attempt in range(attempts):
      try:
        with self.opener.open(request, timeout=30) as response:
          raw = response.read()
        return json.loads(raw) if raw else None
      except urllib.error.HTTPError as exc:
        try:
          body = exc.read()
        except (OSError, http.client.HTTPException, ValueError, RecursionError) as read_error:
          if token_endpoint and attempt < attempts - 1:
            time.sleep(attempt + 1)
            continue
          raise self._connection_error(method, url) from read_error
        finally:
          exc.close()
        try:
          details = json.loads(body)
        except (ValueError, UnicodeError):
          details = {}
        error = details.get("error", {}) if isinstance(details, dict) else {}
        reason = error.get("message") or error.get("status") or error.get("code") or "" if isinstance(error, dict) else str(error)
        reasons = error.get("errors", []) if isinstance(error, dict) else []
        quota = isinstance(reasons, list) and any(isinstance(item, dict) and item.get("reason") in ("rateLimitExceeded", "userRateLimitExceeded") for item in reasons)
        if token_endpoint and reason in ("invalid_grant", "invalid_client", "invalid_request"):
          raise self.shared.CliError(GUIDANCE if reason == "invalid_grant" else "OAuth token request " + reason) from exc
        if authenticated and exc.code == 401:
          raise self.shared.CliError("Gmail authorization rejected; run auth login") from exc
        retryable = exc.code == 429 or 500 <= exc.code <= 599 or (exc.code == 403 and quota)
        if (method == "GET" or token_endpoint) and retryable and attempt < attempts - 1:
          retry_after = exc.headers.get("Retry-After") if exc.headers else None
          delay = retry_after_seconds(retry_after, attempt + 1)
          time.sleep(delay)
          continue
        if token_endpoint:
          raise self.shared.CliError(f"OAuth token HTTP {exc.code}: {self.shared.display_text(reason)}") from exc
        if url == REVOKE:
          raise self.shared.CliError(f"token revoke failed: HTTP {exc.code}: {self.shared.display_text(reason)}") from exc
        if method != "GET":
          if 500 <= exc.code <= 599:
            raise self.shared.CliError("outcome unknown; run verify-sent with the Message-ID" + (" and check the Drafts folder" if "/drafts" in url else "")) from exc
          raise self.shared.CliError(f"refused, not sent (HTTP {exc.code}): {self.shared.display_text(reason)}") from exc
        raise self.shared.CliError(f"Gmail HTTP {exc.code}: {self.shared.display_text(reason)}") from exc
      except (urllib.error.URLError, OSError, http.client.HTTPException, ValueError, RecursionError) as exc:
        if token_endpoint and attempt < attempts - 1:
          time.sleep(attempt + 1)
          continue
        raise self._connection_error(method, url) from exc
    raise self.shared.CliError("Gmail read retries exhausted")

  def _connection_error(self, method: str, url: str) -> Exception:
    if url == REVOKE:
      return self.shared.CliError("token revoke failed: request failed")
    if method != "GET" and url != TOKEN:
      return self.shared.CliError("outcome unknown; run verify-sent with the Message-ID" + (" and check the Drafts folder" if "/drafts" in url else ""))
    return self.shared.CliError("Gmail read request failed")

  def _fake(self, method: str, url: str, json_body: Any) -> Any:
    for index, response in enumerate(self.fake_responses):
      if str(response.get("method", method)).upper() != method:
        continue
      if "url" in response and response["url"] != url:
        continue
      if "url_contains" in response and response["url_contains"] not in url:
        continue
      if "path" in response and response["path"] != urllib.parse.urlsplit(url).path:
        continue
      if "request_json" in response and response["request_json"] != json_body:
        continue
      item = self.fake_responses.pop(index)
      if int(item.get("status", 200)) >= 400:
        status = int(item["status"])
        details = item.get("json", item.get("body", ""))
        reason = details.get("error", "") if isinstance(details, dict) else str(details)
        if url == REVOKE:
          raise self.shared.CliError(f"token revoke failed: HTTP {status}: {self.shared.display_text(reason)}")
        if "invalid_grant" in str(reason):
          raise self.shared.CliError(GUIDANCE)
        if url == TOKEN and str(reason) in ("invalid_client", "invalid_request"):
          raise self.shared.CliError("OAuth token request " + str(reason))
        if status == 401 and url != TOKEN:
          raise self.shared.CliError("Gmail authorization rejected; run auth login")
        if method == "GET" or url == TOKEN:
          quota = isinstance(reason, dict) and any(isinstance(error, dict) and error.get("reason") in ("rateLimitExceeded", "userRateLimitExceeded") for error in reason.get("errors", []))
          if status not in (429, 500, 502, 503, 504) and not (status == 403 and quota):
            raise self.shared.CliError(f"Gmail HTTP {status}: {self.shared.display_text(reason)}")
          headers = item.get("headers") or {}
          delay = str(headers.get("Retry-After", ""))
          raise RetryableRead(status, retry_after_seconds(delay, 0))
        if status >= 500:
          raise self.shared.CliError("outcome unknown; run verify-sent with the Message-ID" + (" and check the Drafts folder" if "/drafts" in url else ""))
        raise self.shared.CliError(f"refused, not sent (HTTP {status}): {self.shared.display_text(reason)}")
      return item.get("json", item.get("body"))
    raise self.shared.CliError(f"fake transport has no matching response for {method} {url}")

  def _token(self) -> str:
    if self.access_token:
      return self.access_token
    data = self.store.read()
    if not data:
      raise self.shared.CliError("Gmail authorization missing; run auth login")
    if data.get("expires_at", 0) > time.time() + 60 and data.get("access_token"):
      self.access_token = data["access_token"]
      return self.access_token
    refresh = data.get("refresh_token")
    if not refresh:
      raise self.shared.CliError(GUIDANCE)
    response = self.request("POST", TOKEN, form={**self._client(), "refresh_token": refresh, "grant_type": "refresh_token"}, authenticated=False)
    if not isinstance(response, dict) or not response.get("access_token"):
      raise self.shared.CliError(GUIDANCE)
    data.update(response)
    data["expires_at"] = time.time() + int(response.get("expires_in", 3600))
    self.store.write(data)
    self.access_token = data["access_token"]
    return self.access_token

  def account_address(self, *, _bearer: str | None = None) -> str:
    result = self.request("GET", API + "/profile", _bearer=_bearer)
    address = result.get("emailAddress") if isinstance(result, dict) else None
    if not isinstance(address, str):
      raise self.shared.CliError("cannot read Gmail account identity; no write made")
    address = address.strip().casefold()
    try:
      validate_mailbox(address, "authenticated account")
    except ValueError as exc:
      raise self.shared.CliError("invalid Gmail account identity; no write made") from exc
    if getaddresses([address])[0][1] != address:
      raise self.shared.CliError("invalid Gmail account identity; no write made")
    return address

  def auth_status(self) -> dict[str, Any]:
    data = self.store.read()
    return {"authorized": bool(data and data.get("refresh_token")), "account": str(data.get("account") or "default") if data else "default", "expired": bool(data and data.get("expires_at", 0) < time.time())}

  def auth_revoke(self) -> str | None:
    data = self.store.read()
    if not data:
      return "no local Gmail token"
    token = data.get("refresh_token") or data.get("access_token")
    remote_error = None
    try:
      if token:
        self.request("POST", REVOKE, form={"token": token}, authenticated=False)
    except self.shared.CliError as exc:
      remote_error = str(exc)
    finally:
      self.store.delete()
    return remote_error

  def auth_login(self, timeout: int = 180) -> None:
    client = self._client()
    verifier, challenge = pkce_pair()
    state = secrets.token_urlsafe(32)
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
      listener.bind(("127.0.0.1", 0))
      listener.listen(1)
      deadline = time.monotonic() + timeout
      redirect = f"http://127.0.0.1:{listener.getsockname()[1]}/callback"
      params = {"client_id": client["client_id"], "redirect_uri": redirect, "response_type": "code", "scope": " ".join(SCOPES), "state": state, "code_challenge": challenge, "code_challenge_method": "S256", "access_type": "offline", "prompt": "consent"}
      consent = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params)
      print("Open this consent URL in your browser: " + consent)
      while True:
        remaining = deadline - time.monotonic()
        if remaining <= 0:
          raise self.shared.CliError("Gmail authorization timed out")
        listener.settimeout(remaining)
        try:
          connection, peer = listener.accept()
        except socket.timeout as exc:
          raise self.shared.CliError("Gmail authorization timed out") from exc
        except OSError:
          time.sleep(min(0.05, remaining))
          continue
        valid = False
        with connection:
          try:
            connection.settimeout(min(10, remaining))
            request_line = connection.recv(8192).split(b"\r\n", 1)[0].decode("ascii", "replace")
            pieces = request_line.split(" ")
            target = pieces[1] if len(pieces) >= 2 else ""
            parsed = urllib.parse.urlsplit(target)
            query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
            valid = peer[0] == "127.0.0.1" and parsed.path == "/callback" and hmac.compare_digest(query.get("state", [""])[0].encode(), state.encode())
            body = b"Return to Approval-Gated Mail to continue." if valid else b"Not found."
            status = b"200 OK" if valid else b"404 Not Found"
            connection.sendall(b"HTTP/1.1 " + status + b"\r\nContent-Type: text/plain\r\nContent-Length: " + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
          except (socket.timeout, OSError, ValueError, UnicodeError):
            pass
        if valid:
          break
      if query.get("error"):
        raise self.shared.CliError("authorization denied: " + self.shared.display_text(query["error"][0]))
      granted = set(query.get("scope", [""])[0].split())
      missing = set(SCOPES) - granted
      if missing:
        raise self.shared.CliError("authorization missing scope: " + ", ".join(sorted(missing)))
      code = query.get("code", [""])[0]
      if not code:
        raise self.shared.CliError("OAuth authorization code missing")
    response = self.request("POST", TOKEN, form={**client, "code": code, "code_verifier": verifier, "redirect_uri": redirect, "grant_type": "authorization_code"}, authenticated=False)
    if not isinstance(response, dict) or not response.get("access_token") or not response.get("refresh_token"):
      raise self.shared.CliError("OAuth token response lacked access or refresh token")
    response["expires_at"] = time.time() + int(response.get("expires_in", 3600))
    self.store.write(response)

  def _collection(self, path: str, count: int, params: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
    records: list[dict[str, Any]] = []
    token: str | None = None
    while len(records) < count:
      query = {"maxResults": str(min(100, count - len(records))), **(params or {})}
      if token:
        query["pageToken"] = token
      result = self.request("GET", API + path + "?" + urllib.parse.urlencode(query))
      if not isinstance(result, dict):
        raise self.shared.CliError("invalid Gmail list response")
      messages = result.get("messages", [])
      if not isinstance(messages, list):
        raise self.shared.CliError("invalid Gmail message list")
      for item in messages:
        if isinstance(item, dict):
          records.append(item)
        else:
          self.skipped.append("invalid Gmail message list item")
      token = result.get("nextPageToken")
      if not token or not isinstance(token, str) or not messages:
        break
    return records[:count]

  def list_messages(self, mailbox: str, count: int, search: str | None = None, sender: str | None = None) -> list[dict[str, Any]]:
    del mailbox
    if (search is not None and ('"' in search or "\\" in search)) or (sender is not None and ('"' in sender or "\\" in sender)):
      raise self.shared.CliError("Gmail search text cannot contain a quote or backslash")
    q = "from:" + quoted_query(sender) if sender is not None else quoted_query(search) if search is not None else None
    items = self._collection("/messages", count, {"q": q} if q else None)
    records: list[dict[str, Any]] = []
    for item in items:
      message_id = item.get("id") if isinstance(item, dict) else None
      if not isinstance(message_id, str):
        self.skipped.append("message without ID")
        continue
      try:
        records.append(self.get_message("", message_id, include_body=False))
      except (self.shared.CliError, MessageError, RecursionError, ValueError, TypeError, KeyError, AttributeError) as exc:
        self.skipped.append("message " + self.shared.display_text(message_id) + ": " + self.shared.display_text(exc))
    return records

  def _header(self, payload: Mapping[str, Any], name: str) -> str:
    headers = payload.get("headers", [])
    if not isinstance(headers, list):
      raise self.shared.CliError("invalid Gmail headers")
    if any(not isinstance(item, dict) for item in headers):
      raise self.shared.CliError("invalid Gmail header entry")
    return next((str(item.get("value") or "") for item in headers if isinstance(item, dict) and str(item.get("name") or "").casefold() == name.casefold()), "")

  def _parts(self, payload: Mapping[str, Any], *, body_only: bool = False) -> list[Mapping[str, Any]]:
    stack = [(payload, 0)]
    result = []
    while stack:
      part, depth = stack.pop()
      if depth > 50:
        raise self.shared.CliError("Gmail MIME depth exceeds 50")
      if body_only and self._attachment_part(part):
        continue
      result.append(part)
      children = part.get("parts", []) or []
      if not isinstance(children, list):
        raise self.shared.CliError("invalid Gmail MIME parts")
      if any(not isinstance(child, dict) for child in children):
        raise self.shared.CliError("invalid Gmail MIME child")
      stack.extend((child, depth + 1) for child in reversed(children))
    return result

  def _attachment_part(self, part: Mapping[str, Any]) -> bool:
    body = part.get("body") or {}
    return bool(part.get("filename") or (isinstance(body, dict) and body.get("attachmentId")) or
          self._header(part, "Content-Disposition").split(";", 1)[0].strip().casefold() == "attachment")

  def _body(self, payload: Mapping[str, Any]) -> str:
    candidates: list[tuple[str, str]] = []
    for part in self._parts(payload, body_only=True):
      mime = part.get("mimeType") or ""
      if not isinstance(mime, str):
        raise self.shared.CliError("invalid Gmail MIME type")
      body = part.get("body") or {}
      if not isinstance(body, dict):
        raise self.shared.CliError("invalid Gmail MIME body")
      if isinstance(body, dict) and isinstance(body.get("data"), str):
        try:
          raw = b64decode(body["data"])
        except (ValueError, base64.binascii.Error) as exc:
          raise self.shared.CliError("invalid Gmail MIME data") from exc
        content_type = self._header(part, "Content-Type") if "headers" in part else mime
        match = re.search(r"charset\s*=\s*['\"]?([^;'\"\s]+)", content_type, re.I)
        charset = match.group(1) if match else "utf-8"
        try:
          decoded = raw.decode(charset, "replace")
        except LookupError:
          decoded = raw.decode("utf-8", "replace")
        candidates.append((mime, decoded))
    for mime in ("text/plain", "text/html"):
      for kind, value in candidates:
        if kind.casefold() == mime:
          return self.shared.html_to_text(value) if mime == "text/html" else value.replace("\r\n", "\n").replace("\r", "\n")
    return ""

  def _has_attachments(self, payload: Mapping[str, Any]) -> bool:
    return any(self._attachment_part(part) for part in self._parts(payload))

  def _record(self, value: Mapping[str, Any], include_body: bool) -> dict[str, Any]:
    payload = value.get("payload") or {}
    if not isinstance(payload, dict):
      raise self.shared.CliError("invalid Gmail payload")
    def address(name: str) -> list[dict[str, str]]:
      try:
        return [{"name": decoded_header(display), "address": addr} for display, addr in getaddresses([self._header(payload, name)]) if addr]
      except RecursionError as exc:
        raise self.shared.CliError("invalid Gmail address header") from exc
    labels = value.get("labelIds") or []
    if not isinstance(labels, list):
      raise self.shared.CliError("invalid Gmail labels")
    timestamp = value.get("internalDate")
    try:
      date = datetime.fromtimestamp(int(timestamp) / 1000, timezone.utc).isoformat().replace("+00:00", "Z") if timestamp else ""
    except (ValueError, TypeError, OverflowError) as exc:
      raise self.shared.CliError("invalid Gmail date") from exc
    record = {"id": str(value.get("id") or ""), "subject": decoded_header(self._header(payload, "Subject")), "from": (address("From") or [{}])[0], "to": address("To"), "cc": address("Cc"), "bcc": address("Bcc"), "receivedDateTime": date, "sentDateTime": date if "SENT" in labels else "", "isRead": "UNREAD" not in labels, "hasAttachments": self._has_attachments(payload), "conversationId": str(value.get("threadId") or ""), "rfc822msgid": self._header(payload, "Message-ID"), "labelIds": labels}
    if include_body:
      record["body"] = self._body(payload)
    return record

  def get_message(self, mailbox: str, message_id: str, include_body: bool = True) -> dict[str, Any]:
    del mailbox
    url = API + "/messages/" + urllib.parse.quote(message_id, safe="") + "?format=" + ("full" if include_body else "metadata")
    value = self.request("GET", url)
    if not isinstance(value, dict):
      raise self.shared.CliError("invalid Gmail message response")
    return self._record(value, include_body)

  def thread_messages(self, mailbox: str, thread_id: str) -> list[dict[str, Any]]:
    del mailbox
    result = self.request("GET", API + "/threads/" + urllib.parse.quote(thread_id, safe="") + "?format=full")
    if not isinstance(result, dict):
      raise self.shared.CliError("invalid Gmail thread response")
    messages = result.get("messages", [])
    if not isinstance(messages, list):
      raise self.shared.CliError("invalid Gmail thread messages")
    records = []
    for item in messages:
      message_id = item.get("id") if isinstance(item, dict) else None
      if not isinstance(message_id, str):
        self.skipped.append("invalid Gmail thread message without ID")
        continue
      try:
        records.append(self._record(item, True))
      except (self.shared.CliError, MessageError, RecursionError, ValueError, TypeError, KeyError, AttributeError) as exc:
        self.skipped.append("message " + self.shared.display_text(message_id) + ": " + self.shared.display_text(exc))
    return sorted(records, key=lambda item: item["receivedDateTime"])

  def attachments(self, message_id: str) -> list[dict[str, Any]]:
    fields = "partId,filename,mimeType,body(size,attachmentId)"
    for _ in range(50):
      fields = "partId,filename,mimeType,body(size,attachmentId),parts(" + fields + ")"
    query = urllib.parse.urlencode({"format": "full", "fields": "payload(" + fields + ")"})
    result = self.request("GET", API + "/messages/" + urllib.parse.quote(message_id, safe="") + "?" + query)
    records: list[dict[str, Any]] = []
    payload = result.get("payload") if isinstance(result, dict) else None
    if not isinstance(payload, dict):
      raise self.shared.CliError("invalid Gmail attachment payload")
    for part in self._parts(payload):
      if part.get("filename"):
        body = part.get("body") or {}
        if not isinstance(body, dict):
          raise self.shared.CliError("invalid Gmail attachment body")
        try:
          size = int(body.get("size") or 0)
        except (ValueError, TypeError, OverflowError) as exc:
          raise self.shared.CliError("invalid Gmail attachment size") from exc
        records.append({"id": str(body.get("attachmentId") or ""), "name": str(part.get("filename") or ""), "size": size, "contentType": str(part.get("mimeType") or "")})
    return records

  def sent_items(self, count: int) -> list[dict[str, Any]]:
    items = self._collection("/messages", count, {"labelIds": "SENT"})
    if any(not isinstance(item.get("id"), str) for item in items):
      raise self.shared.CliError("invalid Gmail Sent message ID")
    return [self.get_message("", item["id"], include_body=False) for item in items]

  def refuse_replay(self, message_id: str) -> None:
    query = urllib.parse.urlencode({"q": "rfc822msgid:" + message_id, "includeSpamTrash": "true"})
    result = self.request("GET", API + "/messages?" + query)
    if not isinstance(result, dict) or not isinstance(result.get("messages", []), list):
      raise self.shared.CliError("invalid Gmail replay guard response")
    if result.get("messages"):
      raise self.shared.CliError("a message with this Message-ID already exists; run verify-sent")

  def reply_metadata(self, message_id: str) -> dict[str, str]:
    result = self.request("GET", API + "/messages/" + urllib.parse.quote(message_id, safe="") + "?format=metadata&metadataHeaders=Message-ID&metadataHeaders=References&metadataHeaders=Subject")
    if not isinstance(result, dict):
      raise self.shared.CliError("invalid reply target response")
    payload = result.get("payload") or {}
    if not isinstance(payload, dict):
      raise self.shared.CliError("invalid reply target payload")
    return {"message_id": self._header(payload, "Message-ID"), "references": self._header(payload, "References"), "subject": self._header(payload, "Subject"), "thread_id": str(result.get("threadId") or "")}

  def build_request(self, kind: str, sender: str, to_values: Sequence[str], cc_values: Sequence[str], bcc_values: Sequence[str], subject: str, body: str, reply: Mapping[str, str] | None = None, message_id: str | None = None) -> dict[str, Any]:
    validate_mailbox(sender, "sender")
    for label, values in (("to", to_values), ("cc", cc_values), ("bcc", bcc_values)):
      for value in values:
        validate_mailbox(value, label)
    validate_header(subject, "subject")
    if message_id is None:
      domain = getaddresses([sender])[0][1].rsplit("@", 1)[1]
      message_id = make_msgid(domain=domain)
    validate_message_id(message_id, strict=True)
    message = EmailMessage(policy=policy.SMTP)
    message["From"] = sender
    message["To"] = ", ".join(to_values)
    if cc_values:
      message["Cc"] = ", ".join(cc_values)
    if bcc_values:
      message["Bcc"] = ", ".join(bcc_values)
    message["Subject"] = subject
    message["Message-ID"] = message_id
    if reply:
      if not reply.get("message_id") or not reply.get("thread_id"):
        raise self.shared.CliError("reply target lacks Message-ID or threadId")
      for key in ("message_id", "references", "subject", "thread_id"):
        validate_header(reply.get(key, ""), "reply " + key)
      validate_message_id(reply["message_id"])
      message["In-Reply-To"] = reply["message_id"]
      message["References"] = (reply.get("references", "") + " " + reply["message_id"]).strip()
    message.set_content(body)
    raw_bytes = message.as_bytes()
    parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
    for name, values in (("From", [sender]), ("To", to_values), ("Cc", cc_values), ("Bcc", bcc_values)):
      addresses = [item.addr_spec for header in parsed.get_all(name, []) for item in header.addresses]
      expected = [getaddresses([value])[0][1] for value in values]
      if addresses != expected:
        raise ValueError(f"invalid {name.lower()} mailbox after encoding")
    for name, value in parsed.items():
      validate_header(str(value), name.lower())
    raw = base64.urlsafe_b64encode(raw_bytes).rstrip(b"=").decode("ascii")
    json_body = {"message": {"raw": raw}} if kind == "draft" else {"raw": raw}
    if reply:
      (json_body["message"] if kind == "draft" else json_body)["threadId"] = reply["thread_id"]
    return {"method": "POST", "url": API + ("/drafts" if kind == "draft" else "/messages/send"), "jsonBody": json_body, "replyTarget": dict(reply or {}), "messageId": message_id}

  def raw_message_id(self, raw: str) -> str:
    return str(BytesParser(policy=policy.default).parsebytes(b64decode(raw)).get("Message-ID") or "")

License: MIT (read it).

MITMIT License

Copyright (c) 2026 Zero Width Lab

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Back to the listing