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

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5168

    #1

    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






    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/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) };
    }







    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-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 👇




    More...
Working...