ZingMail – AI Copilot for Gmail

ZingMail – AI Copilot for Gmail (Tutorial)
In this tutorial you'll build a Gmail client with an embedded AI copilot: users sign in with Google, see their inbox in a familiar UI, and use a sidebar chat to summarize, compose, reply, forward, search, and filter mail. Every sendable action shows a Send now / Edit first card — nothing is sent without confirmation (human-in-the-loop).
What you'll need: A Google Cloud project with OAuth consent screen, OAuth client credentials, Gmail API and Gemini API enabled, and a Gemini API key. We'll set those up first, then wire the chat API with streaming and tool-calling.
Part 1: Google Cloud setup
Do these steps once; they apply to any Gmail + Gemini app you self-host.
Step 1.1 – Create a project
- Go to Google Cloud Console.
- Top bar → project dropdown → New Project → name it (e.g. ZingMail) → Create.
- Select the new project so all steps below use it.
Step 1.2 – OAuth consent screen
- APIs & Services → OAuth consent screen.
- External → Create.
- Fill App name, User support email, Developer contact, and (optional) Application home page.
- Scopes → Add:
https://www.googleapis.com/auth/gmail.readonlyhttps://www.googleapis.com/auth/gmail.sendhttps://www.googleapis.com/auth/gmail.composehttps://www.googleapis.com/auth/gmail.modifyhttps://www.googleapis.com/auth/userinfo.emailhttps://www.googleapis.com/auth/userinfo.profile
- Test users: add the Google accounts that will sign in (required while in Testing).
Step 1.3 – OAuth client credentials
- APIs & Services → Credentials → Create Credentials → OAuth client ID.
- Application type: Web application. Authorized JavaScript origins:
http://localhost:3000,https://yourdomain.com. Authorized redirect URIs:http://localhost:3000/api/auth/callback/google,https://yourdomain.com/api/auth/callback/google. - Copy Client ID and Client Secret into your
.env(see below).
Step 1.4 – Enable APIs
- Gmail API: APIs & Services → Library → search “Gmail API” → Enable.
- Gemini: Library → search “Generative Language API” → Enable. Then Credentials → Create Credentials → API key. Copy the key into
.env.
Part 2: Project env and route
Create a .env.local (or .env) and never commit it:
# Google OAuth (from OAuth client ID)
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
# Gemini (from API key)
GEMINI_API_KEY=your-gemini-api-key
Your chat endpoint can live at app/api/chat/route.ts (Next.js App Router). It receives messages and optional context (current view, selected email, filters), then streams back AI responses and tool calls.
Part 3: Chat API with Gemini and tool-calling
Use the Vercel AI SDK (ai, @ai-sdk/google) and Zod for tool schemas. Install:
npm i ai @ai-sdk/google zod
The handler uses streamText with a Gemini model and a set of tools. Each tool corresponds to an action in the UI (compose, reply, forward, filter, open, star, delete, etc.). The frontend renders tool invocations as cards and only sends mail when the user confirms.
System prompt (context for the copilot)
Pass the current UI state so the model can act on “current email”, “inbox”, etc. For example:
const systemPrompt = `You are an AI assistant inside a Gmail client. The user sees their inbox, mail list, and thread view.
Current context: view=${context.view}, selectedEmailId=${context.selectedEmailId ?? 'none'}, filters=${JSON.stringify(context.filters)}.
When the user asks to send, reply, or forward, use the appropriate tool. For sending, always use sendImmediately=false unless they explicitly say to send without review.`;
Full route: streamText + tools
Below is the core of the chat route: streamText with Google Gemini and all the tools. The response is streamed as a UI message stream so the client can show streaming text and tool calls.
import { streamText } from 'ai';
import { google } from '@ai-sdk/google';
import { tool } from 'ai';
import { z } from 'zod';
// import convertToModelMessages from your chat format if needed
export async function POST(req: Request) {
const { messages, context } = await req.json();
const systemPrompt = buildSystemPrompt(context); // e.g. view, selectedEmailId, filters
const result = streamText({
model: google('gemini-3-flash-preview'),
system: systemPrompt,
messages: await convertToModelMessages(messages),
tools: {
composeEmail: tool({
description:
'Opens the compose form and visibly fills in all fields. The user sees the form being filled. ALWAYS set sendImmediately=false unless user explicitly says to send without review.',
parameters: z.object({
to: z.string().describe('Recipient email address'),
subject: z.string().describe('Email subject line'),
body: z.string().describe('Email body text'),
cc: z
.string()
.nullable()
.describe('CC recipients, comma-separated. Null if none.'),
sendImmediately: z
.boolean()
.nullable()
.describe(
'If true, triggers send confirmation immediately. Default false so user can review.',
),
}),
}),
forwardEmail: tool({
description:
'Forward the currently open email (or a specific email) to a new recipient. Opens compose pre-filled with the forwarded content.',
parameters: z.object({
to: z.string().describe('Recipient to forward to'),
additionalMessage: z
.string()
.nullable()
.describe('Optional note to include above the forwarded content'),
emailId: z
.string()
.nullable()
.describe(
'Specific email ID to forward. If null, forwards the currently open email.',
),
}),
}),
filterEmails: tool({
description:
'Updates the inbox UI to show filtered emails. Use for search, date filtering, showing unread, etc.',
parameters: z.object({
query: z
.string()
.nullable()
.describe(
'Search keyword to filter by subject, sender, or content',
),
sender: z
.string()
.nullable()
.describe('Filter by sender name or email'),
unreadOnly: z
.boolean()
.nullable()
.describe('Show only unread emails'),
hasAttachments: z
.boolean()
.nullable()
.describe('Show only emails with attachments'),
dateFrom: z
.string()
.nullable()
.describe('Start date ISO string for date range'),
dateTo: z
.string()
.nullable()
.describe('End date ISO string for date range'),
folder: z
.string()
.nullable()
.describe('Folder: inbox, sent, drafts, trash'),
}),
}),
openEmail: tool({
description:
'Opens a specific email in the detail view. Find by ID, sender name, or subject keyword.',
parameters: z.object({
emailId: z.string().nullable().describe('Exact email ID if known'),
senderName: z
.string()
.nullable()
.describe('Sender name to find the latest email from'),
subjectContains: z
.string()
.nullable()
.describe('Part of subject line to match'),
}),
}),
replyToEmail: tool({
description:
'Opens a reply to the currently viewed or specific email. The compose form opens pre-filled with reply context.',
parameters: z.object({
body: z.string().describe('The reply message body content'),
emailId: z
.string()
.nullable()
.describe(
'Specific email ID. If null, replies to currently open email.',
),
}),
}),
navigateToView: tool({
description:
'Navigate to a different mail view: inbox, sent, drafts, or trash.',
parameters: z.object({
view: z
.string()
.describe('View to navigate to: inbox, sent, drafts, trash'),
}),
}),
clearFilters: tool({
description:
'Clear all active filters and show all emails in current folder.',
parameters: z.object({}),
}),
markEmailRead: tool({
description: 'Mark a specific email as read or unread.',
parameters: z.object({
emailId: z.string().describe('The email ID to update'),
read: z.boolean().describe('true=read, false=unread'),
}),
}),
deleteEmail: tool({
description: 'Move an email to trash.',
parameters: z.object({
emailId: z
.string()
.nullable()
.describe('Email ID. If null, deletes currently open email.'),
}),
}),
starEmail: tool({
description: 'Toggle star on an email.',
parameters: z.object({
emailId: z
.string()
.nullable()
.describe('Email ID. If null, stars currently open email.'),
}),
}),
createDraft: tool({
description: "Creates a draft email in the user's Gmail account.",
parameters: z.object({
to: z.string().describe('Recipient email'),
subject: z.string().describe('Subject line'),
body: z.string().describe('Email body text'),
}),
}),
},
});
return result.toDataStreamResponse();
}
Note: The Vercel AI SDK uses parameters (not inputSchema) for the Zod schema in tool(). If your SDK version uses inputSchema, swap the property name accordingly. Return type can be toDataStreamResponse() or toUIMessageStreamResponse() depending on how your client consumes the stream.
What each tool does in the UI
| Tool | Purpose |
|---|---|
composeEmail | Fills the compose form; user sees it and chooses Send now or Edit. |
forwardEmail | Pre-fills compose with forwarded content and optional note. |
filterEmails | Updates list filters (query, sender, unread, dates, folder). |
openEmail | Opens an email in the detail view by ID, sender, or subject. |
replyToEmail | Opens reply compose with body; user confirms before send. |
navigateToView | Switches to inbox / sent / drafts / trash. |
clearFilters | Clears filters and shows all in current folder. |
markEmailRead | Marks one email read or unread. |
deleteEmail | Moves one (or current) email to trash. |
starEmail | Toggles star on one (or current) email. |
createDraft | Creates a draft in Gmail; user can edit and send later. |
Your frontend subscribes to the stream, renders assistant messages, and when a tool call arrives (e.g. composeEmail), shows a confirmation card with Send now / Edit first and only then calls the Gmail API if the user confirms.
Part 4: Summary
- Google Cloud: one project, OAuth consent screen (with Gmail + userinfo scopes), OAuth Web client (origins + redirect URIs), Gmail API and Generative Language API enabled, Gemini API key in
.env. - Backend: one chat route using
streamText, Gemini, and the tools above; system prompt includes current view and selected email so the model can act in context. - Frontend: mail UI (inbox/sent/drafts/trash, list, thread, compose) plus sidebar chat that sends messages + context and displays streamed replies and tool cards with human-in-the-loop confirmations.
No special Google plan is required — standard Gmail API and Gemini API are enough. You can self-host on your domain and reuse the same OAuth client and env vars for production.