Maildriply
All posts
11 min readby Maildriply Team

How to Send Email With the Gmail API in Node.js (Without the Usual Pitfalls)

A working Node.js example that sends email through the Gmail API with only the gmail.send scope. It covers building the MIME message, base64url encoding, non-ASCII subjects, HTML with a plain-text part, replying in a thread, and the production mistakes that the short tutorials skip.

engineeringgmailoauth
Illustration of colored code blocks assembling into an envelope on a conveyor belt

You want your Node.js app to send email from a user's own Gmail account, so it lands in their Sent folder and replies come back to them. The short version: get an OAuth token with the gmail.send scope, build an RFC 5322 message yourself, encode it as base64url, and pass it as raw to users.messages.send. That's one HTTP call.

The call is the easy part. The message you hand it is where things break: subjects with an accent in them, display names with a quote, replies that start a new thread instead of joining the old one. We send every Maildriply campaign through this endpoint, so this guide includes the mistakes we made along the way.

What you need before you write code

  • A Google Cloud project with the Gmail API enabled, and an OAuth client ID of type "Web application" (or "Desktop" for a local script).
  • Node.js 22 or later. The current googleapis (v188) and google-auth-library (v11) packages both declare node >=22 in their engines field.
  • One scope: https://www.googleapis.com/auth/gmail.send.

That last point deserves a moment. Google's own Node.js send sample requests four scopes, including https://mail.google.com/, which is full access to the mailbox. You don't need any of them except gmail.send. The Gmail API discovery document lists gmail.send as sufficient for users.messages.send, and it can't read, list, or delete mail. Your users see a much shorter consent screen, and a leaked token can't expose their inbox. We wrote up why we only request gmail.send and what it costs us.

npm install googleapis
Diagram: refresh token with the gmail.send scope, build the MIME message, encode it as base64url, call messages.send, and reply in the thread with threadId, References and the same Subject
The whole pipeline this guide walks through, step by step.

Step 1: Get a refresh token with only gmail.send

Send the user through Google's consent screen once and store the refresh token you get back.

import { google } from "googleapis";
 
const oauth2 = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  "http://localhost:3000/oauth/callback",
);
 
const url = oauth2.generateAuthUrl({
  access_type: "offline", // ask for a refresh token
  prompt: "consent", // re-issue one even if the user consented before
  scope: ["https://www.googleapis.com/auth/gmail.send"],
});
 
// In your callback route, after Google redirects back with ?code=...
const { tokens } = await oauth2.getToken(code);
// Store tokens.refresh_token encrypted. You won't get it again by default.

The google-auth-library README states that the refresh token "will only be returned for the first authorization by the user" unless you set prompt: "consent". Forgetting this gives you a working first login and then a null refresh token every time after.

Treat the refresh token like a password. We encrypt it with AES-256-GCM before it touches the database, and we keep short-lived access tokens only in memory.

Step 2: Build the MIME message

The raw field is described in the API's own schema as "the entire email message in an RFC 2822 formatted and base64url encoded string." No JSON fields for to, subject, or body exist. You write the headers and body yourself.

Here's a small builder that handles the cases most tutorials leave out:

import { randomBytes } from "node:crypto";
 
const CRLF = "\r\n";
 
// RFC 2047: one encoded-word is at most 75 characters, so long subjects
// are split into several words without cutting a UTF-8 character in half.
function encodeHeader(value) {
  if (/^[\x20-\x7E]*$/.test(value)) return value;
  const words = [];
  let chunk = "";
  for (const char of value) {
    if (Buffer.byteLength(chunk + char) > 39) {
      words.push(chunk);
      chunk = "";
    }
    chunk += char;
  }
  words.push(chunk);
  return words
    .map((w) => `=?UTF-8?B?${Buffer.from(w).toString("base64")}?=`)
    .join(`${CRLF} `);
}
 
function formatAddress({ name, email }) {
  if (!name) return email;
  const encoded = encodeHeader(name);
  if (encoded !== name) return `${encoded} <${email}>`;
  // Escape backslashes as well as quotes, or a name ending in "\" breaks the header.
  return `"${name.replace(/[\\"]/g, "\\$&")}" <${email}>`;
}
 
function base64Lines(text) {
  return Buffer.from(text).toString("base64").replace(/(.{76})/g, `$1${CRLF}`);
}
 
export function buildMessage({ from, to, subject, text, html, inReplyTo }) {
  const boundary = `b_${randomBytes(12).toString("hex")}`;
  const domain = from.email.split("@")[1];
  const messageId = `<${randomBytes(16).toString("hex")}@${domain}>`;
 
  const headers = [
    `From: ${formatAddress(from)}`,
    `To: ${formatAddress(to)}`,
    `Subject: ${encodeHeader(subject)}`,
    `Date: ${new Date().toUTCString()}`,
    `Message-ID: ${messageId}`,
    "MIME-Version: 1.0",
    `Content-Type: multipart/alternative; boundary="${boundary}"`,
  ];
  if (inReplyTo) headers.push(`In-Reply-To: ${inReplyTo}`, `References: ${inReplyTo}`);
 
  const part = (type, body) =>
    `--${boundary}${CRLF}` +
    `Content-Type: ${type}; charset=UTF-8${CRLF}` +
    `Content-Transfer-Encoding: base64${CRLF}${CRLF}` +
    `${base64Lines(body)}${CRLF}`;
 
  const raw =
    headers.join(CRLF) + CRLF + CRLF +
    part("text/plain", text) +
    part("text/html", html) +
    `--${boundary}--${CRLF}`;
 
  return { raw, messageId };
}

A few choices in there are deliberate:

  • multipart/alternative with the plain-text part first. Clients show the last part they can render, so HTML goes last. If you only have HTML, generate the text part from it (we use the html-to-text package) rather than sending HTML alone.
  • Base64 bodies, wrapped at 76 characters. Base64 makes non-ASCII body text safe without having to think about quoted-printable, and the line wrapping keeps every line under the length limits mail software expects.
  • CRLF line endings. Google's sample joins lines with \n and Gmail accepts it, but the RFC says CRLF, and a message that's correct everywhere is easier to debug than one that happens to work in one place.
  • You generate the Message-ID. This matters later for replies, and it matters a lot if you only hold gmail.send (see step 4).

We ran this builder through the mailparser package with a German subject containing an emoji and a sender named Jordan "JJ" Lee \. The subject, both display names, and both body parts came back intact.

The display-name bug we shipped

Our first version escaped double quotes in display names but not backslashes. A name ending in \ then escaped the closing quote, the From header became malformed, and Gmail rejected the message. RFC 5322 quoted strings need both characters escaped. The formatAddress function above does that.

Step 3: Encode as base64url and send

Gmail wants base64url, not standard base64: - and _ instead of + and /, with no = padding. Whether standard base64 output contains a + or / at all depends on the content, so this mistake can pass your first test and only show up on a later email.

Google's sample does the conversion with three .replace() calls. Node has a built-in encoding for it:

import { google } from "googleapis";
import { buildMessage } from "./mime.js";
 
oauth2.setCredentials({ refresh_token: storedRefreshToken });
const gmail = google.gmail({ version: "v1", auth: oauth2 });
 
const { raw, messageId } = buildMessage({
  from: { name: "Jordan Lee", email: "jordan@example.com" },
  to: { name: "Sam Rivera", email: "sam@example.org" },
  subject: "Proposal for the Q4 onboarding redesign",
  text: "Hi Sam,\n\nThe proposal is attached as a link below...",
  html: "<p>Hi Sam,</p><p>The proposal is attached as a link below...</p>",
});
 
const res = await gmail.users.messages.send({
  userId: "me",
  requestBody: { raw: Buffer.from(raw).toString("base64url") },
});
 
// Save all three: you need them to reply in the same thread later.
console.log(res.data.id, res.data.threadId, messageId);

userId: "me" means the account that owns the token. The library refreshes the access token from the refresh token on its own.

The From address must be the connected account (or an alias the user has already set up in Gmail). We check this before queueing anything: if from doesn't match the email of the account that granted the token, the request fails on our side with a clear message instead of producing mail that won't align for DMARC.

For large messages, the discovery document lists a separate upload endpoint (/upload/gmail/v1/users/{userId}/messages/send) that accepts message/* content up to 36,700,160 bytes (35 MB). With googleapis, pass the unencoded MIME as media: { mimeType: "message/rfc822", body: raw } instead of requestBody.raw.

Step 4: Reply in the same thread

The API's schema spells out three conditions for adding a message to an existing thread:

  1. Pass the threadId on the message you send.
  2. Set References and In-Reply-To headers per the RFC.
  3. The Subject headers must match.
const reply = buildMessage({
  from: { name: "Jordan Lee", email: "jordan@example.com" },
  to: { email: "sam@example.org" },
  subject: "Re: Proposal for the Q4 onboarding redesign",
  text: "Hi Sam, following up on the proposal below.",
  html: "<p>Hi Sam, following up on the proposal below.</p>",
  inReplyTo: originalMessageId, // the Message-ID you generated in step 2
});
 
await gmail.users.messages.send({
  userId: "me",
  requestBody: {
    raw: Buffer.from(reply.raw).toString("base64url"),
    threadId: originalThreadId, // from the first send's response
  },
});

Here's the catch with a send-only scope: you can't look up the original message's Message-ID later. Every scope that allows messages.get (gmail.readonly, gmail.metadata, gmail.modify, full access) can see the mailbox. So you have to generate the ID yourself when you build the first message and store it next to the id and threadId the send call returns. That's what we do for every recipient, and it's how our follow-ups thread correctly without ever reading the inbox.

Two more details from building follow-ups:

  • Threads are per recipient. If you send one email to 50 people as 50 separate messages, you get 50 threads. A follow-up to Sam goes in Sam's thread, so store thread IDs per recipient, not per campaign.
  • Add the "Re: " prefix once. We prefix the original subject with "Re: " for follow-ups and Gmail threads them correctly. Make the prefix idempotent, or a follow-up to a follow-up turns into "Re: Re: ".

Step 5: Handle errors like a queue, not a script

A script can crash and you rerun it. A sending service needs to know which failures to retry. This is the split we use:

What you seeMeaningWhat to do
HTTP 429, or reason rateLimitExceeded / userRateLimitExceededToo many requestsRetry with exponential backoff and jitter
HTTP 5xx, or reason backendErrorGoogle-side failureRetry with backoff
Network error (no response)UnknownRetry, but see the duplicate-send note below
invalid_grant from the token endpointRefresh token revoked or expiredStop. Mark the account disconnected and ask the user to reconnect
Other 4xxYour message or request is wrongDon't retry. Log it and fix the input

With googleapis, a failed token refresh surfaces as the error of your send() call, so check which one you have. The thrown GaxiosError has err.status; a dead refresh token shows err.response.data.error === "invalid_grant", while a Gmail API error carries the reason in err.response.data.error.errors[0].reason.

We learned two of these the hard way:

  • A failed refresh isn't always a revoked grant. We used to treat any refresh failure as "disconnected." A network blip or a 5xx from Google's token endpoint then told users to reconnect a perfectly healthy account and failed their sends for good. Now only invalid_grant disconnects; everything else is retried.
  • Never retry after Gmail has accepted the message. If send() returned an id and then your own code threw (a database write, a webhook), the email is already in the recipient's inbox. Retrying that job sends it twice. Record the success before anything else can fail, and keep any bookkeeping after it out of the retry path.

If you're sending more than a handful of emails, also respect the account's daily limits. Personal Gmail and Workspace accounts have different caps, which we cover in Gmail sending limits. Our Gmail API vs. SMTP guide has a full backoff-with-jitter example you can drop into the send function above.

Should you build this or use a library for the MIME part?

The builder above is about 60 lines and covers text, HTML, UTF-8, and threading. Attachments add a multipart/mixed wrapper around it. If you'd rather not own MIME code, Nodemailer's MailComposer can produce the raw message for you, and you still send it through the Gmail API the same way.

What a library won't decide for you is everything around the call: storing tokens safely, checking the From address, threading per recipient, and not sending twice. That's most of the work in a real sending service.

If what you need is tracked email from Gmail rather than a pipeline of your own, that's what Maildriply is: campaigns, templates, and open and click tracking, sent through the Gmail API with only gmail.send. See the full feature list, or read which scopes we request first.

FAQ

Can I send email with the Gmail API without OAuth?

Not from a regular Gmail account. The API needs an OAuth access token for the user whose mailbox sends the message. Google Workspace admins can authorize a service account with domain-wide delegation to send as users in their domain, but that doesn't work for personal @gmail.com accounts.

Why does Gmail reject my raw message as invalid?

The most common causes are standard base64 instead of base64url, a missing blank line between headers and body, or a malformed header such as an unescaped quote or backslash in a display name. Decode your raw string and read the result as plain text. The problem is usually visible in the first few lines.

Is the gmail.send scope enough to send attachments?

Yes. Attachments are more MIME parts inside the message you build, so they need no extra scope. For large files, use the upload endpoint described in step 3, which accepts messages up to 35 MB.

Do emails sent through the Gmail API appear in the Sent folder?

Yes. The message is sent from the user's own account, so Gmail files it in their Sent folder and replies arrive in their inbox, the same as an email sent from the web interface.

Should I use Nodemailer with SMTP instead?

For a personal script on your own account, SMTP with an app password is quicker to set up. For an app that sends on behalf of other users, the Gmail API with gmail.send is the better choice: users never hand you a password, they can revoke access at any time, and you never get read access to their mail.

Explore Maildriply

Keep reading