Skip to content

Epic: Gmail becomes a first-class connector — read a message, fill its attached form, reply with it #710

Description

@serge-ivo

The task that motivated this

A real email arrives with a PDF form attached ("return the completed Summer Competition form by 30 Aug"). The wanted behaviour: an agent reads the message, fills the attached form from the owner's stored details, and replies to the sender with the completed form attached.

PAGS cannot do any of the three steps today. This epic makes it possible.

What is actually there now (verified)

  • GMAIL_CONNECTOR is declared and works — lib/connectors/connected-accounts.ts:88, registered at lib/connectors/registry.ts:152. Token is a refresh token in user_api_keys(user_id, provider='gmail'), which is byte-for-byte what connectors/client.ts reads for any auth:"oauth" connector, so ctx.connectorClient("gmail") already resolves an existing connection with no migration.
  • It is declared read-only: scopes: { read: true, write: false } (connected-accounts.ts:91), and the granted OAuth scope is exactly one — GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly" (lib/gmail.ts:13).
  • It declares tools: [] (connected-accounts.ts:110). The only Gmail-backed tool in the codebase is find_confirmation_link (lib/storage-tools.ts:208), gated at runtime by ctx.emailPermitted (storage-tools.ts:553), i.e. the owner's AgentState.permissions.email flag.
  • Live status on the operator account: GET /v1/email/status{"connected":false,"email":null,"configured":true} — the OAuth client is configured in production, no mailbox connected yet.

The three gaps (verified)

1. Attachments are unreachable. findMatchingMessage (lib/gmail.ts:208) does fetch ?format=full, but the part walker only collects text:

// lib/gmail.ts:69-75
function collectText(part: GmailPart | undefined): string {
	if (!part) return "";
	let out = "";
	if (part.body?.data && (part.mimeType === "text/html" || part.mimeType === "text/plain")) {

and the GmailPart interface (gmail.ts:62-66) has no filename and no body.attachmentId, so nothing can even name an attachment, let alone fetch /messages/{id}/attachments/{id}. Closing this needs no new OAuth scopegmail.readonly already covers attachment download.

2. There is no send path at all. grep -rn -i "gmail.send\|gmail.compose\|messages/send\|mailchannels\|sendgrid\|resend\.\|smtp\|nodemailer" workers/api/src packages/*/src returns zero hits outside one unrelated word ("resend" in a CLI prompt string, lib/connector-tool-prompt.ts:218). PAGS cannot send an email by any route. This one does need a new scope (gmail.send) and therefore a re-consent path for anyone already connected.

3. There is no PDF write path. The only PDF code is lib/resume-parse.ts, which sends a PDF to Claude as a document content block to READ it (resume-parse.ts:65). No pdf-lib, no form-field filling, nothing that produces a PDF.

Shape of the work

Four issues, in dependency order. Each is independently useful — (1) alone makes "what did that email say, and what was attached" answerable.

  1. Gmail read tools — gmail_search, gmail_read_message, gmail_download_attachment
  2. fill_pdf_form — AcroForm fill, with an honest fallback when the PDF is flat
  3. Gmail send — gmail_reply / gmail_send with attachments, gmail.send scope, write consent, re-consent migration
  4. Console + docs surface for the new permission, consent and reconnect states

Design decisions taken up front

  • permissions.email stays a hard precondition for every Gmail tool, new ones included — not just find_confirmation_link. Otherwise an agent that declares gmail_search in capabilities.tools gains mailbox reach without the owner's existing toggle, which is a silent widening of an owner-facing control that exists today.
  • Tools go on the connector as a manifest, following GOOGLE_SHEETS_MANIFEST (lib/connectors/google-sheets.ts:56) — declarative params + handlers compiled by compileConnector. This supersedes the tools: [] rationale in the connected-accounts.ts header, which argued the registry had no grant model for find_confirmation_link's permission-flag gate. That argument is about that tool and stays true; it is not a reason for the connector to carry no tools at all. The header comment gets updated rather than left to contradict the code.
  • scopes.write must flip to true for send to be reachable at all: assertScope refuses a write-scoped tool on a connector declaring write:false (lib/connectors/client.ts:84-87). That flip is what makes the [connectors] Connector consent + write-scope safety + admin visibility #90 write-consent gate applicable, so send is consent-gated rather than ungated.
  • Minimal send scope. gmail.send is sufficient to reply in-thread (pass threadId plus In-Reply-To/References headers); gmail.modify is not needed and is not requested.

Not verified / open

  • Whether real-world club forms like the motivating one are AcroForms or flat scans. This decides how much of issue (2) is useful in practice; the fallback is designed for the flat case rather than assuming the good one. Not reproduced — needs the actual attachment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workconnectorsConnector + tool frameworkenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions