DEV Community

Cover image for I Built a Gmail AI Responder in Node.js (and it Actually Works)
Anthony Lagrede
Anthony Lagrede

Posted on

I Built a Gmail AI Responder in Node.js (and it Actually Works)

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:

  1. Watches for a Gmail label
  2. Finds your draft reply
  3. Polishes it with GPT-4.1
  4. 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:

  1. Write a rough draft reply in Gmail
  2. Add a label such as znote to the thread
  3. Run the script
  4. 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+
  • googleapis for the Gmail API
  • @google-cloud/local-auth for OAuth2 desktop authentication
  • openai for GPT-4.1
  • dotenv for 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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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 ?? [];
}
Enter fullscreen mode Exit fullscreen mode

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/plain content

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) };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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,
      },
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2️⃣ Configure .env

GMAIL_LABEL=znote
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4.1
GOOGLE_CREDENTIALS_PATH=./google-credentials.json
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-run flag
  • 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)