Impressionist painting of a farm road past a red barn and a sunlit tree
← Back to blog

Integrations

How to Send Gmail From an AI Agent on Behalf of Your Users

Per-user OAuth, the Gmail scope that avoids a security assessment, MIME and base64url in Python, refresh-token traps, quotas, and why your agent should draft before it sends.

Sep 21, 202612 min
OAuthConnectorsEngineering

TL;DR

Ask each user for gmail.send through their own OAuth consent, and nothing broader, unless you are ready to pay for an annual security assessment. Google classifies gmail.send as sensitive. Every other Gmail scope that can create a draft or read a message is restricted.

Restricted scopes trigger a third-party security assessment that has to be redone at least every 12 months. Most early-stage teams reach for gmail.modify or gmail.compose because they sound like what an agent needs, and only find the bill when they submit for verification.

That doesn't mean your agent can't draft. It means the draft should live in your product, where the user reads it and clicks Send, and only then does your backend call users.messages.send.

Build the MIME message with Python's email package, base64url-encode it, request access_type=offline, and publish out of Testing mode before real users arrive, because Testing-mode refresh tokens die after seven days.

Overview

Take a small startup building a sales-assistant agent. After a demo call, the agent reads the transcript and writes a follow-up to the prospect. The founders want that email to come from the rep's own Gmail address, sitting in the rep's own Sent folder, threaded with whatever came before. Not from noreply@ and not through a transactional email provider.

That requirement settles most of the architecture before any code is written. The mail has to go out through the Gmail API under the rep's own authorization, which means an OAuth grant per end user, a stored refresh token per end user, and a scope choice that decides whether Google makes you pass a security assessment. The protocol side of OAuth is covered in our OAuth 2.1 guide for agent builders. This piece stays on Gmail: which scopes, which API calls, and which traps.

We'll also argue for a position. An agent writing email in a real person's name should produce drafts, and a human should press Send. We think that's the right product default, and it also happens to fit the cheapest scope Google offers.

The short version: gmail.send is the only Gmail API scope that can send mail without being classified restricted. Design the feature around it and you avoid the assessment entirely.

Why Per-User OAuth, Not a Service Account

There are two ways to get a token that can send as rep@customer.com. The rep can consent through OAuth, or a Google Workspace super admin at the customer can grant your service account domain-wide delegation.

Delegation is tempting because it removes the consent screen. Google describes it as a feature that lets you "grant client applications permission to access your Workspace users' data without requiring their consent," and warns that "the app has access to the data belonging to all of your users." Only a super administrator can set it up. For a startup selling to other companies, that's the problem. You'd be asking every customer's IT team to hand an unknown vendor mailbox access for the entire company so that one sales rep can send follow-ups. Most won't. And it doesn't work at all for anyone on a personal @gmail.com account, since there's no domain to delegate from.

Per-user OAuth scales with how you actually sell. Each rep connects their own account, sees exactly what they granted, and can revoke it from their Google account settings without involving anyone. The authority your agent holds for a given user is exactly what that user agreed to, which is the property you want when the thing holding the authority is a language model.

Gmail APIGoogle OAuthYour backendEnd userGmail APIGoogle OAuthYour backendEnd userlater, when the user clicks SendConnect Gmailauthorize (scope=gmail.send, access_type=offline, prompt=consent)consent screenapprovecode at redirect_uriexchange codeaccess token + refresh tokenencrypt and store refresh token, keyed by userrefreshfresh access tokenusers.messages.send (raw MIME)

Backend --authorize(gmail.send, offline, prompt=consent)--> Google OAuth Google --consent screen--> User --approve--> Google --code--> Backend Backend --exchange--> Google --> access token + refresh token Backend stores refresh token encrypted, keyed by user id Later: Backend --refresh--> Google --> access token --> Gmail API messages.send -->

Figure 1 — One consent per end user. The refresh token is the durable credential; access tokens are minted on demand when a send actually happens.

Choosing Gmail Scopes

Google's scope reference says to "choose the most narrowly focused scope possible and avoid requesting scopes that your app doesn't require." For Gmail that advice carries real money, because the classification of each scope decides what verification you go through.

ScopeWhat it grantsGoogle's classificationCan send?Can create drafts?
gmail.sendSend email on the user's behalfSensitiveYesNo
gmail.composeManage drafts and sendRestrictedYesYes
gmail.modifyRead, compose, send (no permanent delete)RestrictedYesYes
gmail.readonlyView messages and settingsRestrictedNoNo
gmail.metadataLabels and headers, no bodiesRestrictedNoNo
https://mail.google.com/Everything, including permanent deleteRestrictedYesYes

Table 1 — Gmail scopes an email agent is likely to consider. All scopes except the last are prefixed https://www.googleapis.com/auth/. Classifications are from Google's Gmail scope reference; send and draft support are from the users.messages.send and users.drafts.create reference pages.

Two details in that table catch people. First, gmail.metadata is restricted even though it can't see a message body. Headers are enough to reconstruct who someone talks to and when, and Google treats that as wide access. Second, users.drafts.create accepts only mail.google.com, gmail.modify and gmail.compose. There is no sensitive-tier scope that can put a draft in a user's Gmail.

What restricted costs you is spelled out on Google's restricted-scope verification page: "Every app that requests access to Google users' restricted data and has the ability to access data from or through a third-party server must go through a security assessment," and apps must "complete a security assessment at least every 12 months." The assessment runs under the App Defense Alliance's CASA framework through approved third-party assessors, and Google notes the verification process "can potentially take several weeks." A sensitive scope like gmail.send still needs OAuth app verification, but not the assessment.

There's one more policy worth reading before you design anything agentic. Google's Workspace API user data policy prohibits using Workspace user data to create, train or improve an ML or AI model beyond that specific user's personalized model. If your roadmap includes fine-tuning on customers' email, that clause answers the question.

yes

in your app's UI

in the user's Gmail Drafts

no, it must read mail too

headers

bodies

read, label, archive

What must the agent do in Gmail?

Send only?

Where does the draft live?

gmail.send
sensitive: verification, no assessment

gmail.compose
restricted: annual assessment

Bodies or headers?

gmail.metadata
restricted

gmail.readonly + gmail.send
restricted

gmail.modify
restricted

Figure 2 — Scope decision tree. Every branch that touches existing mail, or writes into the Drafts folder, lands on a restricted scope.

Our sales-assistant startup gets its content from a call transcript, not the inbox. So it needs gmail.send and nothing else, and it should resist the urge to add gmail.readonly "for context" until a paying customer asks for inbox-aware replies and the assessment is worth it.

Building the MIME Message

Gmail's API doesn't take a JSON email object. It takes a complete RFC 2822 message, base64url-encoded, in a field called raw. Google's sending guide: "Gmail messages are sent as base64URL encoded strings within the raw field of a messages resource."

Python's standard library does the MIME part properly, including headers with non-ASCII characters and multipart bodies. Don't concatenate header strings by hand.

python
import base64
from email.message import EmailMessage
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

def build_raw(to: str, subject: str, text: str, html: str | None = None) -> str:
    msg = EmailMessage()
    msg["To"] = to
    msg["Subject"] = subject
    msg.set_content(text)                    # text/plain part
    if html:
        msg.add_alternative(html, subtype="html")  # becomes multipart/alternative
    # urlsafe, not standard base64: '+' and '/' break the raw field
    return base64.urlsafe_b64encode(msg.as_bytes()).decode()

def send_as_user(creds: Credentials, raw: str, thread_id: str | None = None) -> dict:
    gmail = build("gmail", "v1", credentials=creds, cache_discovery=False)
    body = {"raw": raw}
    if thread_id:
        body["threadId"] = thread_id
    return gmail.users().messages().send(userId="me", body=body).execute()

userId="me" means whoever the access token belongs to, which is exactly the per-user property you want. We leave From unset so Gmail uses the authenticated account.

Replies are where most homegrown senders go wrong. Passing threadId isn't enough on its own. Google's guide says the Subject headers must match and the References and In-Reply-To headers must follow RFC 2822. So a follow-up to an existing thread needs the original message's Message-ID, which means reading it, which means a restricted scope. That's another reason our hypothetical startup sends the demo follow-up as a new thread.

python
def build_reply(to: str, subject: str, text: str, parent_message_id: str) -> str:
    msg = EmailMessage()
    msg["To"] = to
    msg["Subject"] = subject if subject.lower().startswith("re:") else f"Re: {subject}"
    msg["In-Reply-To"] = parent_message_id
    msg["References"] = parent_message_id
    msg.set_content(text)
    return base64.urlsafe_b64encode(msg.as_bytes()).decode()

If you do hold gmail.compose, the draft path uses the same encoding wrapped one level deeper: users.drafts.create with {"message": {"raw": raw}}. Drafts are immutable once created; per Google's drafts guide, an update means "the message contained in the draft is destroyed and replaced," and sending one via drafts.send deletes the draft and creates a new message with the SENT label and a new ID. If you track agent-drafted messages by ID, track the ID the send call returns.

Refresh Tokens and Where They Break

The authorize request needs access_type=offline, which Google describes as whether "your application can refresh access tokens when the user is not present at the browser." Without it you get no refresh token and your agent can only send while the user is looking at a browser tab.

Then the quirk. Google's web-server guide says "the refresh token is only returned on the first authorization." A user who reconnects after you lost or deleted their token comes back without one. Adding prompt=consent forces the consent screen and gets you a fresh refresh token, so we'd use it on every connect flow, because reconnecting is precisely when you need one.

Google lists several reasons a refresh token stops working. Four bite email agents specifically:

  • Testing mode. An external-audience project in "Testing" status is issued refresh tokens that expire in 7 days unless the only scopes are name, email and profile. gmail.send is not on that list. Every user you onboard during a private beta silently disconnects a week later.
  • Password changes. Refresh tokens carrying Gmail scopes are invalidated when the user changes their password.
  • Six months unused. A rep who goes quiet for half a year has to reconnect.
  • The 100-token ceiling. There's a limit of 100 refresh tokens per Google Account per OAuth client ID, and "creating a new refresh token automatically invalidates the oldest refresh token without warning." A bug that mints a new token on every login will eventually knock out a live one.

Treat invalid_grant from the token endpoint as "this user must reconnect," surface that in your UI, and stop retrying. Encrypt refresh tokens at rest, key them by your own user id, and never let one reach the model's context. Token storage and refresh mechanics have their own posts on this blog, so we won't repeat them.

python
from google.auth.exceptions import RefreshError
from google.auth.transport.requests import Request

def creds_for(user_id: str) -> Credentials:
    rt = vault.get_refresh_token(user_id)  # your encrypted store
    creds = Credentials(None, refresh_token=rt, token_uri="https://oauth2.googleapis.com/token",
                        client_id=CLIENT_ID, client_secret=CLIENT_SECRET,
                        scopes=["https://www.googleapis.com/auth/gmail.send"])
    try:
        creds.refresh(Request())
    except RefreshError:
        vault.mark_needs_reconnect(user_id)
        raise
    return creds

Quotas, Sending Limits and the Unverified-App Cap

The Gmail API meters by quota units. Per Google's usage-limits page, a project gets 1,200,000 units per minute and each user gets 6,000 units per minute per project. messages.send and drafts.send cost 100 units each; drafts.create costs 10. That works out to at most 60 sends per user per minute, which an agent sending one follow-up after a call will never approach. A runaway loop will. The API also caps a single message at 500 recipients.

The limit that matters more is Gmail's own daily sending cap, which applies to the account rather than your project. Google Workspace lists 2,000 messages per day per user on paid accounts and 500 on trial accounts. Google's help page for personal Gmail accounts cites 500. An agent that blows through that locks the user out of their own email until the limit resets, which Google says can take up to 24 hours. That's a far worse outcome than a 429, and your per-user rate limit should sit well below it.

Then there's verification. While your consent screen is in Testing, you're limited to 100 test users you list by hand. Push to production without verification and users see the "unverified app" screen, and Google caps you at "100 new users in total, after the app presents the unverified app screen." That cap covers the project's entire lifetime and doesn't reset. The practical order is: submit gmail.send for verification before launch, while you're still in Testing with a handful of design partners, so the seven-day expiry and the lifetime cap never touch real customers.

Draft by Default, Send on a Click

Our view, stated plainly: an agent should never send email in a user's name without that user pressing a button for that specific message.

The reasons aren't subtle. Email is irreversible. It goes to people outside your system, under someone else's name, and a prompt-injected transcript or a confused model produces a message that can't be recalled. A wrong CRM field can be fixed. An email to a prospect saying the wrong price can't be.

And the scope table makes this cheap to build. You don't need Gmail's Drafts folder to have drafts. The agent writes into your own database, your UI shows the rendered message with an editable body and an explicit Send button, and only that click calls messages.send. You stay on gmail.send. The user sees exactly what goes out. And you get an audit record of who approved what, because the approval happened in your product.

agent writes to your DB

user edits

user rejects

user clicks Send

messages.send 200

invalid_grant

quota / transient error

Drafted

Edited

Discarded

Sending

Sent

NeedsReconnect

Figure 3 — Draft lifecycle for a send-on-click agent. The model can create and revise drafts; only a user action moves a draft to Sending.

Make the send endpoint take a draft id, not a message body, and check server-side that the draft belongs to the signed-in user and hasn't been sent already. That second check is the idempotency key. A double-click, or a retry after a timeout, shouldn't produce two emails.

There are cases where fully automatic sending is defensible. A user-configured rule like "send my standard reply to every meeting request" is one. Even then we'd make it an explicit per-user setting with a daily cap far below Google's, not an agent decision.

Where the approval lives: if your send step sits behind a button in your product, the Gmail scope can be the narrow one. Put the human in your UI, not in the user's Drafts folder, and you get both safety and the cheaper verification path.

Doing It Through Fabriq Developer

If you'd rather not run the OAuth client, token vault and refresh logic yourself, Fabriq Developer brokers per-end-user connections over REST. You name each end user by your own id, ask for a connect URL for the gmail provider, and send that user to it. Credentials land in Fabriq's vault, not your servers. Your agent then mints a short-lived user token per unit of work.

python
import os, requests
BASE, APP = "https://dashboard.agenticfabriq.com", os.environ["AF_APP_ID"]
H = {"X-App-Id": APP, "X-App-Secret": os.environ["AF_APP_SECRET"]}
users = f"{BASE}/api/v1/apps/{APP}/external-users"
uid = "user_8412"  # your own primary key, never model output

requests.post(users, json={"external_user_id": uid}, headers=H)  # 409 = exists
init = requests.post(f"{users}/{uid}/oauth/gmail/initiate",
                     json={"connection_id": "default"}, headers=H).json()
# redirect this user to init["oauth_url"]

tok = requests.post(f"{users}/{uid}/token", json={}, headers=H).json()  # expires_in: 900
mcp_url = tok.get("mcp_url") or f"{BASE}/mcp/external"

The agent calls tools/list and tools/call at /mcp/external with that token as a Bearer credential, and uses whatever tool names come back rather than guessing them. The initiate call also accepts an optional scopes list, which is where you'd hold the connection to the narrow send scope. Check GET .../providers/capabilities to see whether gmail is connectable on this deployment, and treat a connection as usable only when its status is connected. Fabriq has no approval step, so the draft-then-click flow above is still yours to build in your product.

Conclusion

Sending Gmail from an agent on behalf of real users is mostly a scope decision with some encoding attached. Per-user OAuth, not delegation. gmail.send unless you have a concrete reason to read mail. EmailMessage, urlsafe_b64encode, raw. Offline access with prompt=consent, out of Testing before real users arrive, and a clear reconnect path for invalid_grant.

The draft-first design isn't a compromise we'd accept to save on an assessment. We'd choose it anyway. An agent that writes and a person who sends is the right split of labor for anything leaving your system under someone's name, and it's the one Google prices lowest.

Before you ship: list every Gmail scope you request, check each against Table 1, and ask whether a restricted one is worth an assessment every 12 months. If the answer isn't an obvious yes, drop it.

Sources