
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.
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.
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.
| Scope | What it grants | Google's classification | Can send? | Can create drafts? |
|---|---|---|---|---|
gmail.send | Send email on the user's behalf | Sensitive | Yes | No |
gmail.compose | Manage drafts and send | Restricted | Yes | Yes |
gmail.modify | Read, compose, send (no permanent delete) | Restricted | Yes | Yes |
gmail.readonly | View messages and settings | Restricted | No | No |
gmail.metadata | Labels and headers, no bodies | Restricted | No | No |
https://mail.google.com/ | Everything, including permanent delete | Restricted | Yes | Yes |
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.
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.
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.
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.sendis 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.
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 credsQuotas, 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.
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.
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
- Choose Gmail API scopes — Google; scope descriptions and sensitive / restricted classification.
- Gmail API usage limits — Google; per-project and per-user quota units, per-method costs, 500-recipient limit.
- Create and send email messages — Google; RFC 2822, base64url
rawfield, threading requirements. - Work with drafts — Google; draft immutability and send behavior.
- Method: users.drafts.create — Google; accepted scopes.
- Method: users.messages.send — Google; accepted scopes.
- Using OAuth 2.0 to Access Google APIs — Google; refresh token expiration and the 100-token limit.
- Using OAuth 2.0 for Web Server Applications — Google;
access_type,prompt, first-authorization refresh tokens. - Restricted scope verification — Google; security assessment and 12-month reverification.
- Unverified apps — Google Cloud Help; the unverified app screen and 100-new-user cap.
- Manage app audience — Google Cloud Help; Testing status, 100 test users, seven-day authorizations.
- Control API access with domain-wide delegation — Google Workspace Admin Help.
- Gmail sending limits in Google Workspace — Google Workspace Admin Help; daily per-user limits.
- Limits for sending & getting mail — Gmail Help; personal-account limits.
- Google Workspace API user data and developer policy — Google; restriction on using user data for generalized AI/ML models.