Skip to content

Gmail send: gmail_reply/gmail_send with attachments — new gmail.send scope, write consent, and the re-consent migration #713

Description

@serge-ivo

Part of the Gmail epic. This is the one that needs a new OAuth scope, and therefore the one with a migration.

Why

PAGS cannot send an email by any route. Verified by exhaustion, not by reading one file:

grep -rn -i "gmail.send\|gmail.compose\|messages/send\|mailchannels\|sendgrid\|resend\.\|smtp\|nodemailer" workers/api/src packages/*/src

returns exactly one hit, and it is an unrelated English word in a CLI prompt string (lib/connector-tool-prompt.ts:218, "Re-capture, wait, and resend."). There is no mail transport, no provider integration, and no Gmail send call.

The connector says so too — scopes: { read: true, write: false } (lib/connectors/connected-accounts.ts:91), which is currently accurate.

Scope

Declaration changes on GMAIL_CONNECTOR:

  • scopes.writetrue. Required for the tool to be reachable at all: assertScope refuses a write-scoped tool on a write:false connector before any handler runs —
// lib/connectors/client.ts:84-87
function assertScope(opts?: TokenOpts): void {
	// A read-only connector (no write scope) can never satisfy a write request.
	if (opts?.scope === "write" && !connector.scopes.write) {

Flipping it is also what brings send under the #90 per-instance write-consent gate, so this makes send consent-gated, not ungated.

  • add https://www.googleapis.com/auth/gmail.send to oauth.scopes. gmail.send is sufficient to reply in-thread — pass threadId and set In-Reply-To/References. gmail.modify is NOT needed and must not be requested.

Tools (both scope: "write", mutates: true):

  • gmail_reply — reply to a message id, in its thread, with optional attachments taken from instance file ids
  • gmail_send — a new message to explicit recipients

Both build RFC-2822 MIME and POST base64url to /messages/send. Threading is the part to get right: correct In-Reply-To and References from the parent's Message-ID, plus threadId on the request body, or the reply lands as a detached message in the recipient's client.

The migration — this is the real work

Adding a scope invalidates nothing stored, but every already-connected user holds a refresh token granted under gmail.readonly alone. Google will mint access tokens from it happily; the send call then fails at the API with an insufficient-scopes 403. gmailErrorReason (lib/gmail.ts:185) already digs Google's real message out, which is why a 403 here says "insufficient scopes" instead of a bare status — that groundwork is done.

Required handling:

  • persist the granted scope set at connect time, so "can this connection send?" is answerable without making a failing API call
  • GET /v1/email/status reports canSend
  • gmail_reply/gmail_send refuse up front with "Gmail is connected but was authorised for reading only — reconnect Gmail to grant send access", never a raw 403
  • reconnecting must re-prompt Google for consent (prompt=consent) or the user silently gets the old grant back

Safety

Sending mail as the owner is among the most consequential things an agent on this platform can do. Non-negotiable:

  • permissions.email remains a hard precondition, as with every Gmail tool
  • write consent for the gmail connector must be granted for the instance ([connectors] Connector consent + write-scope safety + admin visibility #90) — this is what the scopes.write flip enables
  • the tool is NOT creator-selectable by default; an agent from the catalog must not arrive able to send mail as its subscriber
  • recipients come from the message being replied to or from explicit input, never inferred

Done when

  • a reply lands in the sender's client threaded under the original, with the attachment intact
  • a connection granted before this issue gets a clear reconnect instruction rather than a 403
  • consent refusal, permission refusal and scope refusal each have a distinct, actionable message, covered by tests

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workconnectorsConnector + tool frameworkenhancementNew feature or requestsecuritySecurity hardening / audit finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions