Part of the Gmail epic. No new OAuth scope — gmail.readonly already covers everything here.
Why
findMatchingMessage (lib/gmail.ts:208) is the only reader in the codebase and it is shaped entirely around one job: find a confirmation link. It fetches ?format=full and then throws away everything that is not prose:
// 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")) {
out += decodeBody(part.body.data);
}
for (const child of part.parts ?? []) out += `\n${collectText(child)}`;
GmailPart (gmail.ts:62-66) declares only mimeType, body.data and parts. There is no filename, no body.attachmentId, and no call anywhere to /messages/{id}/attachments/{id}. So an agent cannot answer "what was attached to that email", which is the first step of every document-handling task.
It also returns only the single newest match (list.messages?.[0], gmail.ts:216-218) and truncates the body to 20 000 chars (gmail.ts:239) — right for link-hunting, wrong for "show me the thread I need to reply to".
Scope
Add to lib/gmail.ts (pure, unit-testable, no new deps):
- extend
GmailPart with filename? and body.attachmentId?
collectAttachments(part) → { attachmentId, filename, mimeType, size }[]
listMessages(accessToken, query, max) → several matches, not just the newest
getMessage(accessToken, id) → headers incl. Message-ID/References, threadId, decoded body, attachment manifest
downloadAttachment(accessToken, messageId, attachmentId) → bytes
Then three connector tools on GMAIL_CONNECTOR, declared as a manifest in the GOOGLE_SHEETS_MANIFEST style (lib/connectors/google-sheets.ts:56), all scope: "read", mutates: false:
gmail_search — Gmail query syntax, returns id/threadId/from/subject/date/snippet/attachment names
gmail_read_message — full body + attachment manifest for one id
gmail_download_attachment — saves into the instance's own file store and returns the file id, so the bytes land where every other surface (RAG, fill_pdf_form, the send tool) already looks. It must NOT return raw base64 into the model's context.
Gate
Every one of these checks the owner's permissions.email flag before doing anything, the same precondition find_confirmation_link enforces at lib/storage-tools.ts:553:
if (!ctx.emailPermitted) {
return fail(call.name, "Email access is not enabled for this agent.");
}
Declaring a Gmail tool in capabilities.tools must not be sufficient on its own — that would widen an owner-facing control silently for any agent that declares it.
Done when
- an agent with Gmail connected and
permissions.email on can find a message, read it, and land its PDF attachment in the instance file store
- the tools are refused with a clear message when
permissions.email is off, when Gmail is not connected, and when the deployment has no KEY_ENCRYPTION_KEY
- unit tests cover the MIME walk against a real multipart-with-attachment payload fixture, including a nested
multipart/alternative inside multipart/mixed
Part of the Gmail epic. No new OAuth scope —
gmail.readonlyalready covers everything here.Why
findMatchingMessage(lib/gmail.ts:208) is the only reader in the codebase and it is shaped entirely around one job: find a confirmation link. It fetches?format=fulland then throws away everything that is not prose:GmailPart(gmail.ts:62-66) declares onlymimeType,body.dataandparts. There is nofilename, nobody.attachmentId, and no call anywhere to/messages/{id}/attachments/{id}. So an agent cannot answer "what was attached to that email", which is the first step of every document-handling task.It also returns only the single newest match (
list.messages?.[0],gmail.ts:216-218) and truncates the body to 20 000 chars (gmail.ts:239) — right for link-hunting, wrong for "show me the thread I need to reply to".Scope
Add to
lib/gmail.ts(pure, unit-testable, no new deps):GmailPartwithfilename?andbody.attachmentId?collectAttachments(part)→{ attachmentId, filename, mimeType, size }[]listMessages(accessToken, query, max)→ several matches, not just the newestgetMessage(accessToken, id)→ headers incl.Message-ID/References,threadId, decoded body, attachment manifestdownloadAttachment(accessToken, messageId, attachmentId)→ bytesThen three connector tools on
GMAIL_CONNECTOR, declared as a manifest in theGOOGLE_SHEETS_MANIFESTstyle (lib/connectors/google-sheets.ts:56), allscope: "read",mutates: false:gmail_search— Gmail query syntax, returns id/threadId/from/subject/date/snippet/attachment namesgmail_read_message— full body + attachment manifest for one idgmail_download_attachment— saves into the instance's own file store and returns the file id, so the bytes land where every other surface (RAG,fill_pdf_form, the send tool) already looks. It must NOT return raw base64 into the model's context.Gate
Every one of these checks the owner's
permissions.emailflag before doing anything, the same preconditionfind_confirmation_linkenforces atlib/storage-tools.ts:553:Declaring a Gmail tool in
capabilities.toolsmust not be sufficient on its own — that would widen an owner-facing control silently for any agent that declares it.Done when
permissions.emailon can find a message, read it, and land its PDF attachment in the instance file storepermissions.emailis off, when Gmail is not connected, and when the deployment has noKEY_ENCRYPTION_KEYmultipart/alternativeinsidemultipart/mixed