/packs / google-mail

google-mail

Gmail itself, in the workspace: mail read, drafts written, nothing sent.

  • integration
  • sources
  • Gmail

As of yardr at commit 67389f7, 11 Nov 2026. The pack's own files keep the author's words: issue · bead, repo · depot, workspace · yard, agents · crew, agent · crew member.

01 / apply it

yardr pack apply google-mail

yardr carries this pack in its binary; the name is enough. yardr pack check google-mail shows what it would change first. It goes into the workspace itself, and into no repo · depot.

yardr pack apply https://yardr.dev/packs/google-mail/

From this site, as the files below.

02 / the files

Every file, as it ships.

# Gmail as a pack of kind source: the integration, imported into the
# yard and into no depot. What Gmail is here: the declared source
# google-mail, which reads mail and threads, lists drafts, and writes
# and edits drafts, and sends nothing. Its credential mints from a gws
# login on this machine, by
# google-mint, the mint command the pack brings beside its declaration:
# applying the pack sets it, with no command typed, and a mint_command
# the operator sets on a source wins over it. One command mints every
# account, choosing the login by the account it is given: the yard's
# login for the yard's calls, that account's own login beside it for a
# call --as a user. The login, the account, the mint and calling as a
# user are the four google packs' shared setup, said once in
# docs/google.md.
#
#   yardr pack apply google-mail
#
# A person applies the packs they want, each by one card or typed. A
# pack of kind source asks no grants at import, so what a crew member
# may call is granted afterwards, by the setup notes: read for the
# reads, each update by its name, plain by the operator and as a user
# by that user alone.
name: google-mail
kind: source
setup: SETUP.md

sources:
  google-mail:
    declaration: google-mail.yaml

# Suggested grants: named sets of the source's calls, printed numbered by
# pack show. Suggesting grants nothing: the operator grants them, and
# terminal pack apply ignores them.
presets:
  read:
    about: Read mail, threads and drafts; write nothing
    grants:
      google-mail: [read]
  drafts:
    about: Read mail, and write drafts the person sends
    grants:
      google-mail: [read, draft]
  everything:
    about: Read mail, and write and edit drafts the person sends
    grants:
      google-mail: [read, draft, draft_edit]
#!/bin/bash
# build-draft: the draft and draft_edit calls' request body, built from to,
# cc, subject, body and an optional thread, or passed through from raw. One
# JSON object on stdin, {"params": {...}}, one on stdout, {message: {raw[,
# threadId]}} for a draft, {id, message: {...}} for a draft_edit, where the
# id names the draft to replace. Where a thread is given the script reads
# its last message first, with the yard's credential: GOOGLE_TOKEN and
# YARDR_SOURCE_BASE, the declaration's base, are in the environment. Needs
# curl and jq; base64 and tr from the system's core tools.
#
# Two shapes, never mixed: the composed shape (to, subject and body, with
# cc and a thread to answer beside them) or raw alone, the whole RFC 2822
# message base64url-encoded, wrapped untouched. A draft_edit gives its id
# beside either shape. Anything else is refused in words, and nothing is
# sent.
#
# The yard writes the draft; sending stays the person's. No From is set:
# the account the credential names is the sender, and Gmail fills it in.
set -euo pipefail
in=$(cat)
refuse() { echo "$1" >&2; exit 1; }
param_into() {
  local key="$1" name="$2" value
  # Command substitution drops trailing newlines. Keep a sentinel after the
  # exact JSON string so headers can reject them and the body can preserve them.
  value=$(jq -rj --arg k "$key" '.params[$k] // ""' <<<"$in"; printf '\034')
  printf -v "$name" '%s' "${value%$'\034'}"
}
given() { jq -e --arg k "$1" '(.params // {}) | has($k)' <<<"$in" >/dev/null; }
curl_quote() {
  local value=$1
  value=${value//\\/\\\\}
  value=${value//\"/\\\"}
  printf '"%s"' "$value"
}
gmail_get() { # $1 path and query, $2 what the words name: one GET with the credential
  # The token goes to curl as a config line on its stdin, as telegram's
  # thread script hands it: an argument can be read by every process on the
  # machine while curl runs.
  local out code detail
  # Silent, not show-errors: the refusal below carries the words, and
  # curl's own line would stand in front of them.
  out=$(
    {
      printf 'url = '
      curl_quote "$YARDR_SOURCE_BASE$1"
      printf '\nheader = '
      curl_quote "Authorization: Bearer $GOOGLE_TOKEN"
      printf '\n'
    } | curl -s --fail-with-body -K -
  ) || {
    code=$?
    detail=$(jq -r '.error.message // empty' <<<"$out" 2>/dev/null || true)
    [ -n "$detail" ] || detail="curl exit $code"
    refuse "$2 could not be read: $detail"
  }
  printf '%s' "$out"
}

raw_given=""
if given raw; then raw_given=1; fi
composed=""
for p in to cc subject body thread; do
  if given "$p"; then composed="$composed$p, "; fi
done
if [ -n "$raw_given" ] && [ -n "$composed" ]; then
  refuse "raw was given with ${composed%, }: pass raw alone, or to, subject and body"
fi
# The draft to replace, given by a draft_edit beside either shape and by no
# draft: where it is there the body carries it, where not the draft's shape
# goes out unchanged.
draft_id=""
if given id; then param_into id draft_id; fi
if [ -n "$raw_given" ]; then
  # The escape hatch: raw is the whole message already, base64url-encoded
  # by the caller, and goes to Gmail untouched, with no thread of its own.
  param_into raw raw
  if [ -n "$draft_id" ]; then
    jq -c -n --arg id "$draft_id" --arg raw "$raw" '{id: $id, message: {raw: $raw}}'
  else
    jq -c -n --arg raw "$raw" '{message: {raw: $raw}}'
  fi
  exit 0
fi
missing=""
for p in to subject body; do
  if ! given "$p"; then missing="$missing$p, "; fi
done
if [ -n "$missing" ]; then
  refuse "a draft is to, subject and body, or raw alone; missing: ${missing%, }"
fi
param_into to to
param_into cc cc
param_into subject subject
param_into body body
param_into thread thread

# A header is one line: a line break in to, cc or subject would smuggle a
# header of the caller's choosing (a Bcc) into the message. An address is
# ASCII: an addr-spec holds no encoded word, so one outside it is refused
# rather than encoded.
linebreak() { # $1 field, $2 value
  case "$2" in *$'\n'*|*$'\r'*) refuse "$1 holds a line break: a header is one line";; esac
}
linebreak to "$to"
[ -z "$cc" ] || linebreak cc "$cc"
linebreak subject "$subject"
ascii() { # $1 field, $2 value
  if [[ "$2" == *[^[:ascii:]]* ]]; then refuse "$1 holds a character outside ASCII: addresses are ASCII"; fi
}
ascii to "$to"
[ -z "$cc" ] || ascii cc "$cc"
case "$to" in *[[:space:][:cntrl:]]*) refuse "to holds whitespace or a control character: addresses are one token";; esac
[ -z "$cc" ] || case "$cc" in *[[:space:][:cntrl:]]*) refuse "cc holds whitespace or a control character: addresses are one token";; esac

msgid=""
if [ -n "$thread" ]; then
  : "${GOOGLE_TOKEN:?GOOGLE_TOKEN is not set}"
  : "${YARDR_SOURCE_BASE:?YARDR_SOURCE_BASE is not set}"
  # The reply answers the thread's last message: its id from the thread in
  # minimal form (ids alone, however long the thread), then its headers for
  # the Message-ID the reply points at.
  list=$(gmail_get "/gmail/v1/users/me/threads/$thread?format=minimal" "thread $thread")
  last=$(jq -rj '(.messages // []) | if length == 0 then "" else (.[-1].id // "") end' <<<"$list"; printf '\034')
  last=${last%$'\034'}
  [ -n "$last" ] || refuse "thread $thread holds no message to answer"
  case "$last" in *[!0123456789abcdef]*) refuse "thread $thread holds an invalid message id";; esac
  [ "${#last}" -le 64 ] || refuse "thread $thread holds an invalid message id"
  msg=$(gmail_get "/gmail/v1/users/me/messages/$last?format=metadata" "message $last")
  msgid=$(jq -rj '(.payload.headers // []) | map(select((.name | ascii_downcase) == "message-id") | .value) | first // ""' <<<"$msg"; printf '\034')
  msgid=${msgid%$'\034'}
  [ -n "$msgid" ] || refuse "message $last has no Message-ID header to answer"
  linebreak "message $last Message-ID" "$msgid"
fi

# The subject goes out plain where it is ASCII, and as RFC 2047 encoded
# words where it is not: =?UTF-8?B?<base64>?= in pieces of at most 45
# UTF-8 bytes, each whole characters (a character split across two words
# reads as two replacements in a decoder that decodes each word alone),
# folded one word per line.
subject_header="Subject: $subject"
if [[ "$subject" == *[^[:ascii:]]* ]]; then
  words=$(jq -n -r --arg s "$subject" '
    def bytelen: if . < 128 then 1 elif . < 2048 then 2 elif . < 65536 then 3 else 4 end;
    ($s | explode) as $cps
    | (reduce range(0; $cps | length) as $i ([[], [], 0];
        ($cps[$i] | bytelen) as $b
        | .[2] as $n
        | if $n + $b > 45 then [.[0] + [.[1]], [$cps[$i]], $b]
          else [.[0], .[1] + [$cps[$i]], $n + $b] end)) as $acc
    | (($acc[0] + [$acc[1]]) | map(select(length > 0) | implode)) as $words
    | $words | map("=?UTF-8?B?\(. | @base64)?=") | join("\n ")')
  subject_header="Subject: $words"
fi
mime="To: $to"
[ -z "$cc" ] || mime+=$'\n'"Cc: $cc"
mime+=$'\n'"$subject_header"
[ -z "$msgid" ] || mime+=$'\n'"In-Reply-To: $msgid"$'\n'"References: $msgid"
# Encode the text part as well: a UTF-8 charset alone does not change MIME's
# default 7-bit transfer encoding, and bodies may contain non-ASCII text.
encoded_body=$(printf '%s' "$body" | base64)
mime+=$'\n'"MIME-Version: 1.0"$'\n'"Content-Type: text/plain; charset=UTF-8"$'\n'"Content-Transfer-Encoding: base64"$'\n\n'"$encoded_body"$'\n'

# base64url, Gmail's raw: standard base64 without padding or newlines, +/
# as -_. The recipe is the pack's SETUP.md's.
raw=$(printf '%s' "$mime" | base64 | tr -d '=\n' | tr '+/' '-_')
if [ -n "$draft_id" ]; then
  if [ -n "$thread" ]; then
    jq -c -n --arg id "$draft_id" --arg raw "$raw" --arg thread "$thread" '{id: $id, message: {raw: $raw, threadId: $thread}}'
  else
    jq -c -n --arg id "$draft_id" --arg raw "$raw" '{id: $id, message: {raw: $raw}}'
  fi
elif [ -n "$thread" ]; then
  jq -c -n --arg raw "$raw" --arg thread "$thread" '{message: {raw: $raw, threadId: $thread}}'
else
  jq -c -n --arg raw "$raw" '{message: {raw: $raw}}'
fi
# Gmail as a declared source: reading mail and writing drafts, through
# users.messages, users.threads, users.drafts.list, users.drafts.create
# and users.drafts.update. What it never does is send: no messages.send,
# no drafts.send, no drafts.delete, no modify of labels, no trash and no
# delete. Sending stays the person's; the yard writes the draft, the
# person presses send.
#
# Checked against the discovery document
# (https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest) on 16
# October 2026: list takes q and maxResults (default 100) and answers
# one page with messages, nextPageToken and resultSizeEstimate; get
# takes format (minimal, full, raw, metadata; default full); threads.get
# takes the same formats but full, metadata and minimal; drafts.create
# takes {message: {raw}} and answers {id, message}; drafts.list takes q
# and maxResults and answers one page with drafts, nextPageToken and
# resultSizeEstimate; drafts.update takes {id, message: {raw}} and
# answers {id, message}.
#
# A credential that expires is minted, not kept: GOOGLE_ACCOUNT names
# the account (an address, no secret), and google-mint, the file beside
# this one, turns it into a token from the gws login on this machine:
# run with {inputs, scope} on stdin for a {token, expires_at}. The
# pack brings it (credential.mint_command), so applying the pack sets
# the mint with no command typed; the card shows its sha256, and a
# mint_command the operator sets on google-mail wins over it. The token
# is handed to each call under GOOGLE_TOKEN, and kept in memory alone.
# Gmail takes no idempotency key, so the file names none: a
# draft tried again after a lost answer is written twice.
name: google-mail
about: Gmail read and drafts. Searches, reads messages and threads, lists drafts, and writes and edits drafts; sends nothing.
base: https://gmail.googleapis.com
credential:
  env: GOOGLE_TOKEN
  header: "Authorization: Bearer {credential}"
  mint:
    - name: GOOGLE_ACCOUNT
      secret: false               # an address, asked in the clear
  mint_command: [google-mint]       # the file beside this one; the source's own mint_command wins
calls:
  search:
    kind: read
    about: Search mail, one page of at most 50
    params:
      q: { type: string, max: 1000, optional: true }   # free text, Gmail's search syntax; left out, the newest mail
      maxResults: { type: string, pattern: "^([1-9]|[1-4][0-9]|50)$" }   # 1 to 50, always given: Gmail's own default is 100
    request:
      method: GET
      path: /gmail/v1/users/me/messages
      query: { q: "{q}", maxResults: "{maxResults}" }
    answer: { select: [messages, nextPageToken, resultSizeEstimate] }   # nextPageToken says there is more; paging is left out, so one page is all a call reads
  message:
    kind: read
    about: Read one message, headers alone or with its text
    params:
      id: { type: string, pattern: "^[0-9a-f]+$", max: 64, log: true }
      format: { type: enum, values: [metadata, full] }   # metadata keeps the headers, full keeps the text part too
    request:
      method: GET
      path: /gmail/v1/users/me/messages/{id}
      query: { format: "{format}" }
    answer: { select: [id, threadId, labelIds, snippet, payload], max: 262144 }   # headers and the text part are in payload
  thread:
    kind: read
    about: Read one thread, whole
    params:
      id: { type: string, pattern: "^[0-9a-f]+$", max: 64, log: true }
      format: { type: enum, values: [metadata, full] }
    request:
      method: GET
      path: /gmail/v1/users/me/threads/{id}
      query: { format: "{format}" }
    answer: { select: [id, messages], max: 524288 }
  drafts:
    kind: read
    about: List drafts, one page of at most 50
    params:
      q: { type: string, max: 1000, optional: true }   # free text, Gmail's search syntax; left out, the newest drafts
      maxResults: { type: string, pattern: "^([1-9]|[1-4][0-9]|50)$" }   # 1 to 50, always given: Gmail's own default is 100
    request:
      method: GET
      path: /gmail/v1/users/me/drafts
      query: { q: "{q}", maxResults: "{maxResults}" }
    answer: { select: [drafts, nextPageToken, resultSizeEstimate] }   # each draft with its id and its message's id and threadId; nextPageToken says there is more; paging is left out, so one page is all a call reads
  draft:
    kind: update
    about: Write a draft, never send one
    params:
      # Two shapes, never mixed: to, subject and body, with cc and a
      # thread to answer beside them, or raw alone. Every field is
      # optional here so raw alone reaches the build script, which
      # refuses a shape that is neither and builds the raw message
      # itself; "never sends" stands.
      to: { type: string, pattern: "^[^@[:space:]]+@[^@[:space:]]+$", max: 254, optional: true, log: true }   # one address, as drive's share takes one
      cc: { type: string, pattern: "^[^@[:space:]]+@[^@[:space:]]+$", max: 254, optional: true, log: true }   # one address beside to, or none
      subject: { type: string, max: 500, optional: true }   # free text, as long as calendar's summary; encoded where it is not ASCII
      body: { type: string, max: 100000, optional: true }   # plain text; even 4 UTF-8 bytes per character plus MIME and base64 fits the 1 MiB build bound
      thread: { type: string, pattern: "^[0-9a-f]+$", max: 64, optional: true, log: true }   # the thread the draft answers, whose last message the script reads for its reply headers
      # The escape hatch: the whole RFC 2822 message (headers, a blank
      # line, the body), base64url-encoded with or without padding,
      # wrapped untouched. How to make one is in the pack's SETUP.md.
      # At most a million characters, about 750 KB of message, under
      # Gmail's 35 MB.
      raw: { type: string, pattern: "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2}(?:==)?|[A-Za-z0-9_-]{3}=?|[A-Za-z0-9_-]{4})$", max: 1000000, optional: true }
    request:
      method: POST
      path: /gmail/v1/users/me/drafts
    build: [build-draft]
    answer: { select: [id, message] }
  draft_edit:
    kind: update
    about: Edit a draft, never send one
    params:
      id: { type: string, pattern: "^r?[0-9a-zA-Z_-]+$", max: 64, log: true }   # the draft to replace, as drafts lists it
      # Either shape beside the id: to, subject and body, with cc and a
      # thread to answer beside them, or raw alone. Every field but the
      # id is optional here so raw alone reaches the build script, which
      # refuses a shape that is neither and builds the raw message
      # itself; "never sends" stands.
      to: { type: string, pattern: "^[^@[:space:]]+@[^@[:space:]]+$", max: 254, optional: true, log: true }   # one address, as drive's share takes one
      cc: { type: string, pattern: "^[^@[:space:]]+@[^@[:space:]]+$", max: 254, optional: true, log: true }   # one address beside to, or none
      subject: { type: string, max: 500, optional: true }   # free text, as long as calendar's summary; encoded where it is not ASCII
      body: { type: string, max: 100000, optional: true }   # plain text; even 4 UTF-8 bytes per character plus MIME and base64 fits the 1 MiB build bound
      thread: { type: string, pattern: "^[0-9a-f]+$", max: 64, optional: true, log: true }   # the thread the draft answers, whose last message the script reads for its reply headers
      # The escape hatch: the whole RFC 2822 message (headers, a blank
      # line, the body), base64url-encoded with or without padding,
      # wrapped untouched. How to make one is in the pack's SETUP.md.
      # At most a million characters, about 750 KB of message, under
      # Gmail's 35 MB.
      raw: { type: string, pattern: "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2}(?:==)?|[A-Za-z0-9_-]{3}=?|[A-Za-z0-9_-]{4})$", max: 1000000, optional: true }
    request:
      method: PUT
      path: /gmail/v1/users/me/drafts/{id}
    build: [build-draft]
    answer: { select: [id, message] }
#!/bin/sh
# Mints a Google access token from a gws login: the mint command the
# google packs' declarations bring (credential.mint_command), whose
# credential names GOOGLE_ACCOUNT under credential.mint. Applying a
# pack copies it under <home>/sources/<source>/; the core runs it with
# {inputs, scope} on stdin and reads {token, expires_at} on stdout.
#
# GOOGLE_ACCOUNT is the account's address, no secret. The refresh
# credentials come from the local gws login for that account: the
# default login in ~/.config/gws where it is for that account, else
# that account's own login in ~/.config/gws-accounts/<account>,
# chosen by this script with
# GOOGLE_WORKSPACE_CLI_CONFIG_DIR for the gws it starts alone. gws auth
# status tells whose login one is, and gws auth export --unmasked prints
# the client id, client secret and refresh token, which are exchanged at
# the token endpoint for an access token. No new copy of a secret is
# stored: the three are held in this process's memory alone, sent in the
# exchange's body, and never printed, neither on stdout nor on stderr,
# nor written anywhere.
#
# Needs curl on PATH and gws: GWS_BIN names another gws, else the one on
# PATH, else the first gws in the common install directories of both
# platforms, the operator's own before the machine's.
# GOOGLE_TOKEN_ENDPOINT may name a loopback HTTP endpoint for a test's
# stand-in. The scope is the call's;
# Google's token narrows by the gws grant, not by call, so it is read
# and ignored.
set -eu

fail() { echo "google-mint: $*" >&2; exit 1; }

command -v curl >/dev/null 2>&1 || fail "needs curl on PATH"
if [ -n "${GWS_BIN:-}" ]; then
  case $GWS_BIN in "~/"*) GWS_BIN=$HOME/${GWS_BIN#"~/"} ;; "~") GWS_BIN=$HOME ;; esac
  [ -x "$GWS_BIN" ] || fail "GWS_BIN=$GWS_BIN is no program to run"
  GWS=$GWS_BIN
elif command -v gws >/dev/null 2>&1; then
  GWS=gws
else
  # A process started with a short PATH (the daemon's own) still finds
  # the gws its operator installed: the directories gws's install
  # methods use on either platform, the operator's own before the
  # machine's.
  GWS=
  for dir in "${HOME:-}/.local/bin" "${HOME:-}/.cargo/bin" /opt/homebrew/bin /usr/local/bin; do
    if [ -x "$dir/gws" ]; then
      GWS=$dir/gws
      break
    fi
  done
  [ -n "$GWS" ] || fail "needs gws on PATH or in a common install directory (or GWS_BIN=/path/to/gws): the operator's gws login is what the token is minted from"
fi

input=$(cat)
[ -n "$input" ] || fail "reads {inputs, scope} on stdin, and got nothing"

# first <text> <key> is the value of the first "key": "value" in text, ""
# when there is none. Values with an escaped quote in them are not read:
# an account, a scope, gws's answers and Google's have none. A line gws
# prints outside its answer (Using keyring backend: keyring, on stderr)
# has no quoted pair in it, so it is passed over either way.
first() {
  printf '%s' "$1" | grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" |
    head -n 1 | sed 's/^[^:]*:[[:space:]]*"//;s/"$//'
}

# num <text> <key> is the number of the first "key": <number> in text, ""
# when there is none.
num() {
  printf '%s' "$1" | grep -o "\"$2\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" |
    head -n 1 | sed -n 's/[^0-9]*\([0-9][0-9]*\)/\1/p'
}

# shell_quote <text> is text as one shell word: as it is when nothing in
# it is special, single-quoted otherwise. For the login command a
# refusal names: the account that names its directory is data, so a
# ';', '$' or space in it must not become a command when it is copied.
shell_quote() {
  case $1 in
    *[!A-Za-z0-9/._+:,@%=-]*|'')
      printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
      ;;
    *)
      printf '%s' "$1"
      ;;
  esac
}

account=$(first "$input" GOOGLE_ACCOUNT)
[ -n "$account" ] || fail "its inputs name no GOOGLE_ACCOUNT"
printf '%s' "$input" | grep -q '"scope"' || fail "reads {inputs, scope} on stdin, and got no scope"
endpoint=${GOOGLE_TOKEN_ENDPOINT:-https://oauth2.googleapis.com/token}
if [ "$endpoint" != "https://oauth2.googleapis.com/token" ]; then
  # The override is for the test stand-in only. Keep refresh credentials
  # from being sent to an endpoint supplied by the process environment.
  case $endpoint in
    http://127.0.0.1:*)
      port=${endpoint#http://127.0.0.1:}
      case $port in ''|*[!0-9]*) fail "GOOGLE_TOKEN_ENDPOINT is only allowed for a loopback test endpoint" ;; esac
      ;;
    *) fail "GOOGLE_TOKEN_ENDPOINT is only allowed for a loopback test endpoint" ;;
  esac
fi

# Whose login gws holds: the exchange below mints for whoever that is,
# so another account than the inputs name is refused, not minted. The
# default login is tried first, exactly as before; only where it answers
# another account is that account's own login tried, in the directory the
# pack declares beside gws's own.
status_out=$("$GWS" auth status 2>&1) || fail "gws auth status failed (exit $?): $(printf '%s' "$status_out" | head -c 500)"
user=$(first "$status_out" user)
[ -n "$user" ] || fail "gws auth status answered with no user"
if [ "$user" = "$account" ]; then
  # The refresh credentials, decrypted by gws: masked without
  # --unmasked, so that flag is the point. What fails is said in one
  # line: the three values reach neither stdout nor stderr from here on.
  export_out=$("$GWS" auth export --unmasked 2>&1) || fail "gws auth export failed (exit $?)"
else
  # The account names a directory below, so one that is no single name
  # is refused before it names one.
  case $account in
    *"/"*|"."|"..") fail "GOOGLE_ACCOUNT=$account names no per-account login (~/.config/gws-accounts/<account>)" ;;
  esac
  [ -n "${HOME:-}" ] || fail "HOME is not set: cannot find the login for GOOGLE_ACCOUNT=$account"
  acct_dir=$HOME/.config/gws-accounts/$account
  login_cmd="GOOGLE_WORKSPACE_CLI_CONFIG_DIR=$(shell_quote "$acct_dir") gws auth login -s gmail,drive,docs,slides,calendar"
  # gws just answered above, so what fails here is the login in that
  # directory, not gws: the refusal names the login that makes it, and
  # not what gws said.
  status_out=$(GOOGLE_WORKSPACE_CLI_CONFIG_DIR=$acct_dir "$GWS" auth status 2>&1) || fail "no gws login for GOOGLE_ACCOUNT=$account in $acct_dir ($login_cmd makes it)"
  user=$(first "$status_out" user)
  [ -n "$user" ] || fail "no gws login for GOOGLE_ACCOUNT=$account in $acct_dir ($login_cmd makes it)"
  [ "$user" = "$account" ] || fail "the gws login in $acct_dir is for $user, not GOOGLE_ACCOUNT=$account ($login_cmd makes it)"
  export_out=$(GOOGLE_WORKSPACE_CLI_CONFIG_DIR=$acct_dir "$GWS" auth export --unmasked 2>&1) || fail "gws auth export for GOOGLE_ACCOUNT=$account in $acct_dir failed (exit $?)"
fi
# (What it said is not repeated: on this line the refresh credentials
# themselves would be the leak.)
client_id=$(first "$export_out" client_id)
client_secret=$(first "$export_out" client_secret)
refresh_token=$(first "$export_out" refresh_token)
[ -n "$client_id" ] && [ -n "$client_secret" ] && [ -n "$refresh_token" ] ||
  fail "gws auth export answered with no refresh credentials"

# The exchange is one form, sent as it is: what gws exports (letters,
# digits, dots, tildes, slashes and hyphens) needs no encoding, and
# anything else is refused rather than sent half-encoded. The body goes
# over the pipe, not in an argument and not in a file.
case $client_id$client_secret$refresh_token in
*[!A-Za-z0-9._~/-]*)
  fail "what gws answered is no refresh credentials" ;;
esac
out=$(printf 'grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s' \
  "$client_id" "$client_secret" "$refresh_token" |
  curl -s --max-time 10 -w '\n%{http_code}' -d @- "$endpoint") ||
  fail "could not reach $endpoint (curl exit $?)"
code=$(printf '%s' "$out" | tail -n 1)
body=$(printf '%s' "$out" | sed '$d')
# The response body is untrusted and could echo the refresh credentials.
# Report only the status code on failure.
[ "$code" = "200" ] || fail "minting a token was answered $code"
token=$(first "$body" access_token)
expires_in=$(num "$body" expires_in)
[ -n "$token" ] && [ -n "$expires_in" ] || fail "minting a token came back with no token"
# The answer is JSON the core reads: a token outside what Google sends
# is refused rather than printed through it.
case $token in *[!A-Za-z0-9._-]*) fail "the token Google answered is no token" ;; esac
now=$(date +%s)
exp=$(date -u -r "$((now + expires_in))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null ||
  date -u -d "@$((now + expires_in))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) ||
  fail "cannot write the time (date reads neither -r nor -d here)"
printf '{"token":"%s","expires_at":"%s"}' "$token" "$exp"
# Setting up Gmail

The pack wires Gmail into the yard as the declared source
`google-mail`: mail searched and read, threads read, drafts listed,
written and edited, nothing sent. Each call goes out with a short-lived
token minted from
a gws login on this machine, so no new copy of a secret is stored
anywhere. The login, the account, the mint and calling as a user are
the four google packs' shared setup, said once in `yardr docs google`.

1. **The gws login.** It needs the Gmail scope,
   `https://www.googleapis.com/auth/gmail.modify`, else the calls
   come back 403. The shared login instructions in `yardr docs google`
   start from a machine with no gws, and show how to check and add
   the `gmail` service while keeping the other applied packs' scopes.
2. **The setup card.** One card carries the pack's whole setup: the
   pack, the account and the grants. Have a crew member ask for it,
   and approve it, in footplate or in `yardr chat` (`ctrl+o`); a
   person sets up the packs they want, one card each:
   ```sh
   yardr bead ask <bead> "Set up Gmail for the yardmaster?" --setup google-mail --crew yardmaster --recommend 1 --why "reading is the job; drafts can follow" --context "The yardmaster reads mail to work beads; drafts only, the person sends."
   ```
   The card shows the declaration whole with its mint, the account
   to type or take (step 3), and one option per preset (step 5).
   By hand it is the operator's to type, step by step, starting
   with the pack:
   ```sh
   yardr pack apply google-mail
   # applied pack google-mail to the yard: 1 created, 0 changed, 0 unchanged
   # source google-mail is declared: 6 calls under https://gmail.googleapis.com (yardr source show google-mail)
   ```
3. **The account.** The yard's Google account for `google-mail` is
   answered on the setup card, as described in `yardr docs google`
   (step 3): typed unseen on the first pack, then picked.
4. **The mint.** Nothing to type: the pack brings `google-mint`
   beside its declaration, and applying it (step 2) copies it for
   `google-mail`, its sha256 on the card. What it needs and refuses,
   and how a mint of the operator's own replaces it, are in
   `yardr docs google`.
5. **The grants.** A pack of kind source asks none at import, so what
   a crew member may call is picked on the setup card as one preset.
   The pack's presets, as `yardr pack show google-mail` prints them
   numbered:
   1. `read`: every read call — mail searched and read, threads read,
      drafts listed — and it covers any read call the source is later
      given; writes nothing.
   2. `drafts`: every read call, and `draft`: drafts written, the
      person pressing send in Gmail.
   3. `everything`: every read call, `draft`, and `draft_edit`:
      drafts written and edited, the person pressing send in Gmail.
   A grant on `draft_edit` is a separate grant from `draft`: one
   covers writing a new draft, the other replacing a draft that is
   there, and neither covers the other.
   A grantee who comes later gets one preset grant card:
   ```sh
   yardr bead ask <bead> "Let the reviewers read Gmail" --run "source grant google-mail --preset read --group reviewers" --context "The reviewers read mail to check a bead's threads; the card names each call it covers."
   ```
   By hand they are the operator's to type:
   ```sh
   yardr source grant google-mail read --crew yardmaster
   yardr source grant google-mail draft --crew yardmaster
   yardr source grant google-mail draft_edit --crew yardmaster
   ```
   As ada, the same grants with `--as ada`, on cards addressed to
   ada and approved by ada alone: the shared card is in
   `yardr docs google` ("Calling as a user"), one card per grant, with
   `read`, `draft` and `draft_edit` from above. There is no typed way
   beside these: a grant `as` ada is ada's to give.
   `yardr source show google-mail` lists each call with its kind and
   who may make it, plain and `as` each user.
6. **Check it.** The source names no check call, so its check looks
   the credential over and sends nothing:
   ```sh
   yardr source check google-mail
   # google-mail: credential: GOOGLE_ACCOUNT is set in /Users/me/.yardr/sources/google.env, minted with /Users/me/.yardr/sources/google-mail/google-mint
   # google-mail: its file names no check call (check: <a read call without parameters>), so nothing was sent
   ```
7. **Try it.** As the operator, who may make any call: a search, a
   message read, the drafts, a draft:
   ```sh
   yardr call google-mail search q="from:me" maxResults=5
   yardr call google-mail message id=<an id the search showed> format=metadata
   yardr call google-mail drafts maxResults=5
   ```
   A draft takes who it is to, its subject and its text, and builds the
   message itself; cc goes beside to, and thread answers the thread whose
   id it names. The yard writes the draft; the person presses send in Gmail:
   ```sh
   yardr call google-mail draft to=someone@example.com subject=hello body="The body."
   yardr call google-mail draft to=someone@example.com subject="Re: hello" body="On it." thread=<a thread id the search showed>
   ```
   An edit takes the id of the draft to replace, as the drafts list
   shows it, beside either shape, and replaces the draft whole:
   ```sh
   yardr call google-mail draft_edit id=<a draft id drafts showed> to=someone@example.com subject=hello body="The new body."
   ```
   And raw still takes the whole RFC 2822 message, base64url-encoded, with
   or without padding — headers, a blank line, the body — for what the
   composed shape cannot say, alone, never beside it:
   ```sh
   raw=$(printf 'From: me\nTo: someone@example.com\nSubject: hello\n\nThe body.\n' | base64 | tr -d '=\n' | tr '+/' '-_')
   yardr call google-mail draft raw="$raw"
   ```

Calling as a user — the yard reading another person's mail with their
own credential — is the shared setup too: `yardr docs google` has it.

What is not there, on purpose: sending mail (no messages.send, no
drafts.send); labels, trash and delete; and no call the pack does not
list. The yard's source format cannot express what is not listed, and
the pack does not change yardr.