I Built a Gmail AI Responder in Node.js (and It Actually Works)
You know the situation.
You have 30 unread emails.
You’ve written a few replies.
They technically work… but they feel clunky, too long, slightly awkward, or just off in tone.
I wanted a way to fix that:
- Without switching tools
- Without copying emails into ChatGPT
- Without paying for another SaaS
- Without building a heavy UI
So I built Gmail Responder, a tiny Node.js CLI that:
- Watches for a Gmail label
- Finds your draft reply
- Polishes it with GPT-4.1
- Puts the improved version back into Gmail
No UI. No dashboard. No browser extensions.
Just a script you run when you're ready.
Here’s how it works and how you can build your own.
The Idea
The workflow is intentionally dead simple:
- Write a rough draft reply in Gmail
- Add a label such as
znoteto the thread - Run the script
- Reopen your draft and see the improved version
The AI gets the full thread context, so it:
- Matches the tone of the conversation
- Keeps your intent intact
- Fixes grammar
- Trims fluff
- Makes the message clearer and more professional
It feels like Gmail has a “Make this better” button, except you control everything ✨.
Tech Stack
Minimal and boring in a good way:
- Node.js 18+
-
googleapisfor the Gmail API -
@google-cloud/local-authfor OAuth2 desktop authentication -
openaifor GPT-4.1 -
dotenvfor environment configuration
That’s it.
Under 200 lines of actual logic 🚀.
Project Structure
gmail-responder/
├── src/
│ ├── index.js # orchestration
│ ├── auth.js # Google OAuth2
│ ├── gmail.js # Gmail API wrapper
│ └── openai.js # prompt + OpenAI call
├── .env
└── google-credentials.json
Each file has a single responsibility. No abstractions for the sake of abstractions.
Let’s walk through the interesting parts.
Step 1: Authenticate with Gmail
Google’s OAuth2 flow for desktop apps is surprisingly smooth.
@google-cloud/local-auth handles most of the complexity.
// src/auth.js
import { authenticate } from '@google-cloud/local-auth';
import { google } from 'googleapis';
import fs from 'fs';
import path from 'path';
const SCOPES = ['https://www.googleapis.com/auth/gmail.modify'];
const TOKEN_PATH = path.resolve('./google-credentials-token.json');
const CREDENTIALS_PATH = path.resolve(process.env.GOOGLE_CREDENTIALS_PATH);
export async function getGmailClient() {
let client = await loadSavedToken();
if (!client) {
client = await authenticate({ scopes: SCOPES, keyfilePath: CREDENTIALS_PATH });
await saveToken(client);
}
return google.gmail({ version: 'v1', auth: client });
}
On first run, a browser window opens for authorization.
The token is cached locally. After that, authentication is instant.
Step 2: Find Threads with the Label
We use labels as triggers. If a thread has the configured label, we process it.
// src/gmail.js
export async function getLabelId(gmail, labelName) {
const res = await gmail.users.labels.list({ userId: 'me' });
const label = res.data.labels.find(l => l.name === labelName);
return label?.id ?? null;
}
export async function getThreadsWithLabel(gmail, labelId) {
const res = await gmail.users.threads.list({
userId: 'me',
labelIds: [labelId],
maxResults: 10,
});
return res.data.threads ?? [];
}
Small, focused functions.
Each does exactly one thing.
Step 3: Extract the Draft and Thread History
This is where Gmail gets slightly annoying.
Messages are base64 encoded MIME structures. You have to:
- Decode them
- Navigate nested parts
- Extract the
text/plaincontent
Here’s a minimal parser:
export function parseMessage(msg) {
const headers = msg.payload.headers.reduce((acc, h) => {
acc[h.name] = h.value;
return acc;
}, {});
const getBody = (payload) => {
if (payload.body?.data) {
return Buffer.from(payload.body.data, 'base64url').toString('utf-8');
}
if (payload.parts) {
const textPart = payload.parts.find(p => p.mimeType === 'text/plain');
if (textPart?.body?.data) {
return Buffer.from(textPart.body.data, 'base64url').toString('utf-8');
}
}
return '';
};
return { headers, body: getBody(msg.payload) };
}
Then we match drafts to threads:
export async function getDraftForThread(gmail, threadId) {
const res = await gmail.users.drafts.list({ userId: 'me' });
const drafts = res.data.drafts ?? [];
for (const draft of drafts) {
const full = await gmail.users.drafts.get({ userId: 'me', id: draft.id });
if (full.data.message.threadId === threadId) {
return { id: draft.id, message: full.data.message };
}
}
return null;
}
Not glamorous, but reliable.
Step 4: Improve the Draft with OpenAI
Now the fun part.
We build a prompt that includes the full thread context, so GPT-4.1 understands tone and intent.
// src/openai.js
export async function improveDraft(client, threadMessages, draft) {
const threadContext = threadMessages
.map(m => `From: ${m.headers.From}\n\n${m.body}`)
.join('\n\n---\n\n');
const prompt = `
You are a professional email assistant.
Here is the email thread for context:
${threadContext}
Here is the draft reply to improve:
${draft}
Rewrite the draft to be clearer, more concise, and professional.
Keep the original intent. Return only the improved email body.
`;
const res = await client.chat.completions.create({
model: process.env.OPENAI_MODEL ?? 'gpt-4.1',
messages: [{ role: 'user', content: prompt }],
});
return res.choices[0].message.content.trim();
}
The important part isn’t the API call.
It’s the prompt discipline:
- Provide full context
- Give a clear role
- Be explicit about constraints
- Return only what you need
Step 5: Update the Draft and Clean Up
After receiving the improved text, we rebuild the MIME message and update the draft:
export async function updateDraft(gmail, draftId, originalMessage, newBody) {
const headers = [
`From: ${originalMessage.headers.From}`,
`To: ${originalMessage.headers.To}`,
`Subject: ${originalMessage.headers.Subject}`,
`References: ${originalMessage.headers.References ?? ''}`,
`In-Reply-To: ${originalMessage.headers['In-Reply-To'] ?? ''}`,
'Content-Type: text/plain; charset=utf-8',
'',
newBody,
].join('\r\n');
const encoded = Buffer.from(headers).toString('base64url');
await gmail.users.drafts.update({
userId: 'me',
id: draftId,
requestBody: {
message: {
raw: encoded,
threadId: originalMessage.threadId,
},
},
});
}
Then we remove the label so the thread isn’t processed twice.
Simple state management via Gmail labels.
Putting It All Together
The main loop in src/index.js:
const threads = await getThreadsWithLabel(gmail, labelId);
for (const thread of threads) {
const draft = await getDraftForThread(gmail, thread.id);
if (!draft) continue;
const messages = await getThreadMessages(gmail, thread.id);
const draftMessage = parseMessage(draft.message);
const improved = await improveDraft(openai, messages, draftMessage.body);
await updateDraft(gmail, draft.id, draftMessage, improved);
await removeLabelFromThread(gmail, thread.id, labelId);
console.log(`Updated draft for thread ${thread.id}`);
}
Readable. Predictable. Easy to extend.
Setup in 5 Minutes
1️⃣ Clone and install
git clone https://github.com/alagrede/gmail-responder
cd gmail-responder
npm install
2️⃣ Configure .env
GMAIL_LABEL=znote
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4.1
GOOGLE_CREDENTIALS_PATH=./google-credentials.json
3️⃣ Enable the Gmail API
- Create a project in Google Cloud Console
- Enable the Gmail API
- Create an OAuth2 Desktop App credential
- Download it as
google-credentials.json
4️⃣ Create the Gmail label
In Gmail, create a label named znote or whatever you configured.
5️⃣ Run it
npm start
First run opens the browser for authorization.
After that it runs instantly.
Label a thread.
Run the script.
Your draft is ready.
What’s Next?
Some ideas to extend it:
- Run it via cron every hour
- Multiple labels for multiple AI personas such as formal, casual, or technical
- Add a
--dry-runflag - Swap OpenAI for a local model via Ollama
- Add logging and rate limiting
- Convert it into a GitHub Action for shared inboxes
Why This Is Powerful
The Gmail API is massively underused.
Combine it with an LLM and suddenly:
- Labels become automation triggers
- Drafts become editable AI objects
- Your inbox becomes programmable
And you don’t need:
- A Chrome extension
- A heavy SaaS
- A no code automation platform
Just Node.js.
Final Thoughts
This project is small.
Under 200 lines.
Zero runtime infrastructure.
But it saves me time every single day.
That’s my favorite kind of software.
If you build something on top of this, I’d love to see it. Drop your ideas in the comments 👇
Top comments (0)