Skip to content
emailkit
Esc
navigateopen⌘Jpreview
On this page

Let users bring their own email

Users connect their own domain or their Gmail/Outlook — your app sends as them through one API.

Building an email agent platform, a CRM, an outreach tool? Your users want mail to come from their address, not yours. Some will connect a whole domain, others will sign into their Gmail or Outlook. emailkit makes both look identical to the rest of your app.

The shape

Two tables in your database answer the only routing question that exists — “how is this address connected?”:

model Domain {
  id          String  @id @default(cuid())
  domain      String  @unique
  emailDriver String
  verified    Boolean @default(false)
  userId      String
}

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
  userId      String
}

resolveEmailDriver is just a lookup in those tables. That’s the entire routing logic — no address parsing, no special cases:

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

export const emailkit = EmailKit({
  emailDrivers: [
    ResendDriver({
      id: "resend",
      apiKey: process.env.RESEND_API_KEY!,
      webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
    }),
    GmailDriver({
      id: "gmail",
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    OutlookDriver({
      id: "outlook",
      clientId: process.env.OUTLOOK_CLIENT_ID!,
      clientSecret: process.env.OUTLOOK_CLIENT_SECRET!,
    }),
  ],
  resolveEmailDriver: async (context) => {
    // Mailbox and domain calls name their driver explicitly ({ emailDriver: "gmail" }),
    // so this resolver only routes sends — anything else falls back to the API driver.
    if (context.operation !== "sendEmail") return "resend";

    const from = context.message.from.email.toLowerCase();

    // Their own inbox — sends through their connected account
    const connected = await prisma.connectedMailbox.findUnique({
      where: { email: from },
    });
    if (connected) {
      return {
        emailDriver: connected.emailDriver, // "gmail" or "outlook"
        mailbox: connected.mailbox,
        auth: decrypt(connected.auth),
      };
    }

    // Their own domain — sends through your email API account
    const domain = await prisma.domain.findUnique({
      where: { domain: from.split("@")[1] },
    });
    if (domain?.verified) return domain.emailDriver;

    throw new Error(`${from} is not connected`);
  },
});

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

Way in #1: they connect a domain

The user owns acme.com and wants to send as agent@acme.com through your provider account. Create the domain, show them the DNS records, verify:

await prisma.domain.create({
  data: { domain: "acme.com", emailDriver: "resend", userId: user.id },
});

const { domain } = await emailkit.domains.ensure({
  emailDriver: "resend",
  domain: "acme.com",
});

// render domain.verification.records in your UI, then poll:
const { status } = await emailkit.domains.verify({
  emailDriver: "resend",
  domain: "acme.com",
});

if (status === "verified") {
  await prisma.domain.update({
    where: { domain: "acme.com" },
    data: { verified: true },
  });
}

Way in #2: they connect their inbox

The user clicks “Connect Gmail” and signs in. emailkit runs the OAuth flow and hands you what to save:

const { redirectUrl } = await emailkit.mailboxes.connect({
  emailDriver: "gmail",
  email: userProvidedEmail,
  context: { userId: user.id },
});

redirect(redirectUrl!);
hooks: {
  mailbox: {
    onConnected: async ({ emailDriver, mailbox, auth, context }) => {
      await prisma.connectedMailbox.upsert({
        where: { email: mailbox.email },
        create: {
          emailDriver,
          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) },
      });
    },
  },
},

Same flow for Outlook — only the emailDriver differs. Details, including keeping inbound subscriptions alive, in Gmail and Outlook.

Now sending is identical everywhere

Your agent, your campaign runner, your notification code — none of it knows or cares how the address is connected:

await emailkit.sendEmail({
  from: { email: "sara@brightco.io" },
  to: { email: "lead@example.com" },
  subject: "Following up",
  text: "Hi! Just checking in on last week's demo.",
});

The resolver looks up sara@brightco.io, finds a Gmail connection, and the message goes out from her real inbox — threaded, in her sent folder.

Receiving stays unified too

Replies to a connected Gmail, a connected Outlook, and a connected domain all arrive in the same onInbound hook. Route them to the right user with the same two tables:

hooks: {
  email: {
    onInbound: async (email) => {
      const to = email.to[0].email.toLowerCase();

      const owner =
        (await prisma.connectedMailbox.findUnique({ where: { email: to } })) ??
        (await prisma.domain.findUnique({
          where: { domain: to.split("@")[1] },
        }));
      if (!owner) return;

      await prisma.inboundEmail.upsert({
        where: { messageId: email.messageId },
        create: {
          messageId: email.messageId,
          userId: owner.userId,
          from: email.from.email,
          subject: email.subject,
          text: email.text,
        },
        update: {},
      });
    },
  },
},

This is the whole pattern: two tables, one resolver, one send call, one inbound hook — for every way your users bring their email.

Last updated on July 24, 2026

Was this page helpful?