Skip to content
emailkit
Esc
navigateopen⌘Jpreview
On this page

Gmail

Connect Gmail inboxes with OAuth and receive new mail through Pub/Sub.

Use Gmail when users should send and receive through their own Google inbox. Gmail integrations are famously fiddly — OAuth, MIME encoding, Pub/Sub notifications, history cursors, expiring watches. emailkit does all of that. Your job is one Google Cloud setup and two Prisma models.

1. Configure Google Cloud

Once per project:

  1. Enable the Gmail API and Cloud Pub/Sub API.
  2. Configure the OAuth consent screen and create a Web application OAuth client.
  3. Add https://your-app.com/api/email/gmail as an authorized redirect URI.
  4. Create a Pub/Sub topic, e.g. projects/acme/topics/emailkit-gmail.
  5. Grant gmail-api-push@system.gserviceaccount.com the Pub/Sub Publisher role on that topic.
  6. Create a push subscription pointing to https://your-app.com/api/email/gmail?token=YOUR_RANDOM_TOKEN.

That’s it — every Gmail inbox your users connect shares this one topic. Nothing per user.

2. Add server-side secrets

EMAILKIT_SECRET="a-long-random-application-secret"
PUBLIC_BASE_URL="https://your-app.com"
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."
GOOGLE_PUBSUB_TOPIC="projects/acme/topics/emailkit-gmail"
GMAIL_WEBHOOK_TOKEN="another-long-random-secret"

EMAILKIT_SECRET signs the OAuth callback state; GMAIL_WEBHOOK_TOKEN verifies Pub/Sub pushes. Use two different secrets.

3. Add the two models

One row per connected inbox, one row per Gmail watch:

model ConnectedMailbox {
  id          String @id @default(cuid())
  emailDriver String
  email       String @unique
  mailbox     Json   // the Mailbox object emailkit hands you
  auth        String // encrypted OAuth tokens + Gmail history cursor
  userId      String
}

model EmailWebhook {
  id           String    @id @default(cuid())
  providerId   String    @unique
  mailboxEmail String
  expiresAt    DateTime?
  renewAfter   DateTime?

  @@index([renewAfter])
}

encrypt()/decrypt() in the examples are two small helpers of yours — the auth column holds OAuth tokens, so don’t store it readable (Connect mailboxes covers them).

4. Configure emailkit

import { EmailKit, GmailDriver, isGmailAuth } from "emailkit";
import { prisma } from "./db";
import { encrypt, decrypt } from "./crypto";

const saveWebhook = async ({ webhook, target }) => {
  if (!webhook?.providerId || !target?.mailboxEmail) return;

  await prisma.emailWebhook.upsert({
    where: { providerId: webhook.providerId },
    create: {
      providerId: webhook.providerId,
      mailboxEmail: target.mailboxEmail,
      expiresAt: webhook.expiresAt,
      renewAfter: webhook.renewAfter,
    },
    update: {
      expiresAt: webhook.expiresAt,
      renewAfter: webhook.renewAfter,
    },
  });
};

const gmail = GmailDriver({
  id: "gmail",
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  pubsubTopic: process.env.GOOGLE_PUBSUB_TOPIC!,
  verificationToken: process.env.GMAIL_WEBHOOK_TOKEN!,
  autoSubscribeInbound: true,
  autoRenewOnNotification: true,
  webhookAuthResolver: async ({ mailboxEmail }) => {
    if (!mailboxEmail) return undefined;
    const connected = await prisma.connectedMailbox.findUnique({
      where: { email: mailboxEmail },
    });
    if (!connected) return undefined;
    const auth = decrypt(connected.auth);
    return isGmailAuth(auth) ? auth : undefined;
  },
  onAuthUpdated: async ({ mailbox, auth }) => {
    if (!mailbox?.email) return;
    await prisma.connectedMailbox.updateMany({
      where: { email: mailbox.email },
      data: { auth: encrypt(auth) },
    });
  },
});

export const emailkit = EmailKit({
  emailDrivers: [gmail],
  secret: process.env.EMAILKIT_SECRET!,
  publicRoutes: {
    baseUrl: process.env.PUBLIC_BASE_URL!,
    connectLandingRoutes: {
      success: "/settings/email?connected=true",
      failure: "/settings/email?connected=false",
    },
  },
  resolveEmailDriver: async (context) => {
    if (context.operation !== "sendEmail") return "gmail";

    const connected = await prisma.connectedMailbox.findUnique({
      where: { email: context.message.from.email },
    });
    if (!connected) throw new Error("Gmail mailbox is not connected");

    return {
      emailDriver: "gmail",
      mailbox: connected.mailbox,
      auth: decrypt(connected.auth),
    };
  },
  hooks: {
    mailbox: {
      onConnected: async ({ mailbox, auth, context }) => {
        if (!auth) return;
        await prisma.connectedMailbox.upsert({
          where: { email: mailbox.email },
          create: {
            emailDriver: "gmail",
            email: mailbox.email,
            mailbox,
            auth: encrypt(auth),
            userId: context.userId,
          },
          update: { mailbox, auth: encrypt(auth) },
        });
      },
      onAuthUpdated: async ({ mailbox, auth }) => {
        if (!mailbox?.email) return;
        await prisma.connectedMailbox.updateMany({
          where: { email: mailbox.email },
          data: { auth: encrypt(auth) },
        });
      },
    },
    webhook: {
      onCreated: saveWebhook,
      onUpdated: saveWebhook,
      onSyncRequired: async (event) => {
        // a notification gap — run emailkit.mailboxes.sync() for this mailbox
        await queueMailboxSync(event);
      },
    },
    email: {
      onInbound: async (email) => {
        await prisma.inboundEmail.upsert({
          where: { messageId: email.messageId },
          create: {
            messageId: email.messageId,
            from: email.from.email,
            subject: email.subject,
            text: email.text,
          },
          update: {},
        });
      },
    },
  },
});

Gmail may refresh tokens or advance its history cursor at any time — while sending, syncing, or handling a notification. The updateMany on onAuthUpdated always replaces the saved auth with the newest value.

5. Add the public route

import { createNextEmailKitHandler } from "emailkit/nextjs";
import { emailkit } from "@/src/emailkit";

export const { GET, POST } = createNextEmailKitHandler(emailkit, {
  emailDriver: async (_request, context) =>
    (await context.params).emailDriver,
});

Keep the ?token=... on the Pub/Sub push URL — emailkit rejects pushes that don’t match verificationToken.

6. Connect a Gmail inbox

Call this from a protected server action, passing your own user or workspace ID as context so your hooks know who owns the mailbox:

const connection = await emailkit.mailboxes.connect({
  emailDriver: "gmail",
  email: "acme.support@gmail.com",
  context: { userId: user.id },
});

redirect(connection.redirectUrl!);

The user approves access, and emailkit exchanges the tokens, starts the Gmail watch, and calls mailbox.onConnected and webhook.onCreated. New mail now arrives in your onInbound hook.

7. Send through the inbox

await emailkit.sendEmail({
  from: { email: "acme.support@gmail.com" },
  to: { email: "customer@example.com" },
  subject: "How can we help?",
  text: "Reply directly to this message.",
});

The resolver finds the right mailbox from the from address.

8. Keep quiet inboxes alive

Gmail watches expire after about seven days. emailkit renews busy inboxes automatically while handling notifications, but a silent inbox has no notification to trigger that. Run one small daily job over the webhooks whose renewAfter is due:

export const renewGmailWatches = async () => {
  const due = await prisma.emailWebhook.findMany({
    where: { renewAfter: { lte: new Date() } },
  });

  for (const record of due) {
    const connected = await prisma.connectedMailbox.findUnique({
      where: { email: record.mailboxEmail },
    });
    if (!connected) continue;

    const auth = decrypt(connected.auth);
    if (!isGmailAuth(auth)) continue;

    await emailkit.mailboxes.webhooks.refresh({
      emailDriver: "gmail",
      providerId: record.providerId,
      mailbox: connected.mailbox,
      auth,
    });
  }
};

Each refresh calls your webhook.onUpdated and mailbox.onAuthUpdated hooks, so everything stays saved automatically.

If notifications ever slip

Gmail can drop notifications, and old history cursors expire. When that happens, emailkit calls webhook.onSyncRequired — recover with a mailbox sync from your last safe time:

const connected = await prisma.connectedMailbox.findUnique({
  where: { email: mailboxEmail },
});

await emailkit.mailboxes.sync({
  emailDriver: "gmail",
  mailbox: connected!.mailbox,
  auth: decrypt(connected!.auth),
  since: lastSuccessfulInboundAt,
});

Recovered messages run through the same onInbound hook, so nothing else changes.

Last updated on July 24, 2026

Was this page helpful?