Gmail¶
Give an agent safe Gmail access — structured inbox reads plus a guarded
outbox. lazytools.connectors.gmail ships a thin Gmail REST client plus a
ToolProvider that exposes four tools: two read tools (gmail_list_emails,
gmail_get_email) and two write tools — a harmless gmail_create_draft and a
guarded gmail_send — the latter wired to LazyTools' allow-list and one-shot
confirmation gate so an LLM can read and draft freely but can never send a
flood.
Compliance & liability — your responsibility
This connector accesses Gmail through Google's APIs. You are solely responsible for ensuring your use complies with Google's Terms of Service and the API Services User Data Policy, and with any applicable laws (privacy, anti-spam, data protection). Automated access and bulk sending can get an account rate-limited or suspended. LazyTools is provided "as is", without warranty, and the authors accept no liability for how it is used (see LICENSE). Use least-privilege OAuth scopes and obtain the necessary consent before deploying.
Status & install
Status: alpha. Install the Gmail extra:
The package islazytoolkit (PyPI); the import root is lazytools. Only
building a real GmailClient.from_credentials(...) needs the extra — every
other symbol (GmailTools, parse_authentication_results, the
GmailService protocol) imports cleanly without Google libraries, so tests
inject a fake client and never touch the network.
Synopsis¶
Gmail is the canonical dangerous outbound connector: drafting is harmless, but sending an email is an irreversible external action. LazyTools models that split directly with the dry-run-first pattern:
gmail_create_draft— never gated. The draft lands in the Gmail UI; a human still has to press send. Safe to expose to any agent.gmail_send— gated by anAllowlist(recipient must be permitted) and aConfirmationGate(one outstanding, one-shot approval per send). A blocked send raisesGmailSendBlocked.
Reading is harmless and ungated: gmail_list_emails runs a structured Gmail
search (filter by sender / subject / contains / unread, or a raw
query) and gmail_get_email fetches one message's headers + snippet — so
an agent can triage an inbox before deciding what to draft.
This is the same connector LazyPulse uses to let an always-on agent answer mail — which is why the confirmation grants can be bound to a task id, so under concurrency an approval issued for one task can never be spent by another.
How it works¶
The module is two cleanly separated layers:
GmailClient (REST wrapper) GmailTools (ToolProvider)
───────────────────────── ─────────────────────────
from_credentials(...) as_tools() ─┬─ gmail_list_emails (read)
list_message_ids() ├─ gmail_get_email (read)
get_message() ├─ gmail_create_draft (ungated)
create_draft() └─ gmail_send (gated)
send_message() confirm_once() / confirm_send()
── Allowlist(allowed_recipients)
── ConfirmationGate(require_confirmation)
- Duck-typed seam.
GmailToolsdepends only on theGmailServiceprotocol (list_message_ids,get_message,create_draft,send_message), not on the concrete client. Inject any object with that shape — a fake in tests, an alternative backend in production. - Lazy Google import. The
googleapiclient/google-authlibraries are imported insideGmailClient.from_credentials, so importing the module is cheap and gives a friendlyImportError(pointing at thegmailextra) only when you actually build a credentialed client. - Async send, threaded API call.
gmail_sendis anasynctool so the task-bound grant check can read the worker's task context (LazyBridge runs async tools in-context); the blocking Gmail API call is offloaded withasyncio.to_threadso it never stalls an orchestrator's tick loop. - Metadata-scope reads.
get_messagerequestsformat="metadata"for a fixed header set (From,To,Subject,Date,Authentication-Results), which is what lets a deployment stay on the narrowgmail.metadataOAuth scope.
Signature¶
from lazytools.connectors.gmail import (
GmailClient, # production GmailService (googleapiclient-backed)
GmailService, # Protocol — the duck-typed seam
GmailTools, # ToolProvider — drop into Agent(tools=[...])
GmailSendBlocked, # raised on a denied send (subclass of ActionBlocked)
parse_authentication_results # DKIM/SPF/DMARC header parser
)
# Build a credentialed client (needs the gmail extra).
GmailClient.from_credentials(
*,
credentials_path, # str — path to the OAuth client-secret JSON
token_path, # str — cached token file (created/refreshed; chmod 0600)
scopes, # list[str] — e.g. ["https://www.googleapis.com/auth/gmail.modify"]
)
# Wrap it as a tool provider.
GmailTools(
client, # GmailService
*,
allowed_recipients=None, # list[str] | None — None permits all; [] denies all
require_confirmation=True, # bool — gate gmail_send on a one-shot grant
)
# Grant exactly one send (call after a human approves):
tools.confirm_once(*, task_id=None) # one send to any allowed recipient
tools.confirm_send(*, to="a@x.com", task_id=None) # one send to a specific recipient
tools.require_confirmation # bool property
GmailTools parameters¶
| Parameter | Type | Default | Meaning |
|---|---|---|---|
client |
GmailService |
— | The Gmail client (real or fake) implementing the protocol. |
allowed_recipients |
list[str] \| None |
None |
Case-insensitive recipient allow-list. None → permit any recipient; [] → deny everything. |
require_confirmation |
bool |
True |
When True, every gmail_send must consume an outstanding grant. Set False only when the allow-list alone is sufficient guard. |
GmailClient.from_credentials parameters¶
| Parameter | Type | Meaning |
|---|---|---|
credentials_path |
str |
OAuth client-secret JSON downloaded from Google Cloud. |
token_path |
str |
Where the long-lived user token is cached. Created on first run (browser consent), refreshed thereafter, and chmod-ed to 0600. |
scopes |
list[str] |
OAuth scopes. Use gmail.metadata for read-only metadata; gmail.modify / gmail.send to draft and send. |
Tools it exposes¶
| Tool | Gated? | Args | Returns | Raises |
|---|---|---|---|---|
gmail_list_emails |
No | sender: str, subject: str, contains: str, unread: bool = False, query: str, max_results: int = 10 (all optional; AND-combined into one Gmail query) |
Matching message ids + headers, or "No messages found…" |
— |
gmail_get_email |
No | message_id: str (from gmail_list_emails) |
The message's headers + snippet | — |
gmail_create_draft |
No | to: str, subject: str, body: str |
"draft created: <id>" |
— |
gmail_send |
Yes | to: str, subject: str, body: str |
"sent: <id>" |
GmailSendBlocked |
Scopes. gmail_get_email works on the narrow gmail.metadata scope — the
client fetches messages with format="metadata" (headers + snippet, no body).
gmail_list_emails, however, issues a Gmail search (q=), and the
users.messages.list API rejects q under gmail.metadata — so a
read-and-triage deployment that uses the search tool needs gmail.readonly
(or gmail.modify). Use gmail.metadata only if you fetch known message ids
without searching.
gmail_send runs two checks in order: (1) Allowlist.permits(to) — else
GmailSendBlocked("… recipient … not in the allow-list"); (2)
ConfirmationGate.consume(to, scope=current_scope()) — else
GmailSendBlocked("… no outstanding confirmation …"). Both messages are
audit-friendly and never leak the body.
When to use it¶
- You want an agent to handle email but a draft-only workflow is too weak — it should be able to send, under explicit human approval per message.
- You're on LazyPulse and want an always-on assistant that triages an inbox and replies, with each reply gated by a task-bound confirmation.
- You want least-privilege reads — pair
gmail.metadatascope withparse_authentication_resultsto tell genuine senders from spoofs before acting.
When NOT to use it¶
- Draft-only is enough. If a human always sends from the Gmail UI, expose just
the draft tool (or drop
gmail_sendfrom the agent) and skip the OAuth send scope entirely. - High-volume transactional mail. This is an assistant outbox, not a bulk mailer — use a transactional email API (SES/SendGrid) for campaigns.
- You need labels/threading/attachments. The client surface is intentionally
minimal (list ids, get metadata, draft, send). Extend
GmailServiceyourself if you need more of the Gmail API.
Example¶
from lazybridge import Agent
from lazytools.connectors.gmail import GmailClient, GmailTools
client = GmailClient.from_credentials(
credentials_path="credentials.json",
token_path="token.json",
scopes=["https://www.googleapis.com/auth/gmail.modify"],
)
# Drafting is always allowed; sending is allow-listed + confirmation-gated.
tools = GmailTools(client, allowed_recipients=["teammate@example.com"])
agent = Agent("claude-opus-4-8", tools=[tools])
# The agent can draft freely:
agent("Draft a thank-you note to teammate@example.com")
# A send attempt is blocked until you authorize exactly one:
tools.confirm_send(to="teammate@example.com") # human-in-the-loop approval
agent("Now send it") # consumes the single grant
# A bot that only ever emails one address can drop confirmation and rely on
# the allow-list as the sole guard.
tools = GmailTools(
client,
allowed_recipients=["owner@example.com"],
require_confirmation=False,
)
# gmail_send now succeeds for owner@example.com without an explicit grant,
# and still raises GmailSendBlocked for anyone else.
# Under max_concurrent_inbound > 1, bind the grant to the approved task so a
# concurrent task can never spend it. task_id is the id returned by
# PulseAgent's approve_task / schedule.
tools.confirm_send(to="customer@example.com", task_id=task_id)
# gmail_send reads the active task via current_scope() and only consumes a
# grant whose scope matches.
from lazytools.connectors.gmail import parse_authentication_results
msg = client.get_message(message_id) # metadata scope is enough
headers = {h["name"]: h["value"] for h in msg["payload"]["headers"]}
auth = parse_authentication_results(
headers.get("Authentication-Results"),
trusted_authserv_id="mx.google.com", # pin to your receiving MTA
)
if not (auth["dkim"] and auth["spf"] and auth["dmarc"]):
... # treat as unverified — do not auto-action
Authentication-results parsing¶
parse_authentication_results(header, *, trusted_authserv_id=None) returns
{"dkim": bool, "spf": bool, "dmarc": bool}. A method is True only when an
authoritative result token for it is exactly pass; fail, none, neutral,
softfail, a missing header, or an unparseable one all yield False. Three
hardening rules defend against forged pass tokens:
- Authserv-id pinning. With
trusted_authserv_id="mx.google.com", only a header whose leading authserv-id is exactly that value is parsed. The match is exact, so a look-alike likemx.google.com.evil.comis rejected. Pass the top-mostAuthentication-Resultsheader — the one your receiving MTA prepended. - Comments stripped first. RFC 8601 CFWS comments (
spf=fail (note: spf=pass)) are removed before parsing, so apassburied in a comment is never read. - Standalone-token anchoring. Matching is anchored to a
;/whitespace boundary, sox-dkim=passorreason-spf=passcan't impersonate a real result.
Security & safety¶
- OAuth token hardening.
from_credentialschmods the cached token file to0600whenever it exists — including a still-valid token written by an older, looser version — so another local user can't steal the long-lived refresh token. - Two independent guards.
AllowlistandConfirmationGatecompose; a send must pass both. See Safety for the full model. - One-shot, not sticky. Each
confirm_*authorizes exactly one send and is consumed on use — an approved message can't silently authorize more. - Audit-friendly denials.
GmailSendBlocked(aPermissionErrorviaActionBlocked) names the action and reason and never includes the body. - Verify before you act on inbound mail. Use
parse_authentication_resultswithtrusted_authserv_idpinned to your MTA before trusting a sender.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
ImportError: requires the 'gmail' extra |
Google libs not installed | pip install 'lazytoolkit[gmail]' |
GmailSendBlocked: recipient … not in the allow-list |
to not in allowed_recipients |
Add the address, or use allowed_recipients=None for trusted contexts |
GmailSendBlocked: no outstanding confirmation |
No grant, or it was already spent / scope-mismatched | Call confirm_once() / confirm_send(to=…) before sending; match task_id under concurrency |
All parse_authentication_results values False |
Header missing, or trusted_authserv_id didn't match exactly |
Pass the top-most header; check the pinned authserv-id equals your MTA's exactly |
| Browser consent prompt every run | token_path not writable / not persisted |
Point token_path at a stable, writable location |
Pitfalls¶
allowed_recipients=[]denies everything (vs.None, which permits all). An empty list is a deny-all, not a no-op.- Scope binding only works in the async send.
gmail_sendis async on purpose; a task-bound grant relies oncurrent_scope()being readable in the worker's context. A sync re-implementation would not see the task id. - Pass the right header to the auth parser. A later/forwarded
Authentication-Resultsheader can be attacker-controlled — always use the top-most one and pintrusted_authserv_id. require_confirmation=Falseremoves the gate, not the allow-list. Sends still must passallowed_recipients; don't combineFalsewithallowed_recipients=Noneunless the agent is fully trusted.
See also¶
- Safety — the
Allowlist+ConfirmationGateprimitives this connector composes, with the full one-shot / scope-binding semantics. - Telegram — the same gated-outbound pattern for chat messages.
- Tools overview — every connector at a glance.
- Tool — how a
ToolProviderexpands into the LazyBridge tool surface.