MailKite
GitHub

A working demo · Better Auth + MailKite

Better Auth sends the email. Now it can receive it too.

Two plugins. One sends every transactional email Better Auth generates; the other gives your app a real mailbox — the half no auth library offers. Everything on this site is live, not a screenshot.

Real emails are sent. Use an address you can actually check.

Every auth email, sent

Verification, password reset, magic links, OTPs and org invites — all five surfaces.

A real mailbox, received

Claim an address, get mail as a signature-verified webhook, read it in-app.

Reply from the app

Answers go out from the address that received them, threaded to the original.

No API key in the browser

Every inbox call is session-scoped against your own auth server.

lib/auth.ts — the whole integration

import { betterAuth } from "better-auth";
import { emailOTP, magicLink } from "better-auth/plugins";
import { mailkite } from "@mailkite/better-auth";
import { mailkiteInbox } from "@mailkite/better-auth-inbox";

// apiKey is required on both plugins — there is no environment fallback.
const apiKey = process.env.MAILKITE_API_KEY!;

const mk = mailkite({ apiKey, from: "auth@yourdomain.com", appName: "Acme" });

export const auth = betterAuth({
  emailAndPassword: { enabled: true },
  plugins: [
    mk,                                    // verification + reset, wired for you
    magicLink({ sendMagicLink: mk.sendMagicLink }),
    emailOTP({ sendVerificationOTP: mk.sendVerificationOTP }),
    mailkiteInbox({
      apiKey,                                  // the same key, declared once
      domain: "yourdomain.com",
      webhookSecret: process.env.MAILKITE_WEBHOOK_SECRET!,
    }),
  ],
});
SEND AND RECEIVE. NOT JUST SEND.

What actually happens

Every email surface Better Auth ships is outbound — a magic link, an OTP, an invitation. When someone replies, there is nowhere for it to land. These two plugins cover both directions.

1

You install two plugins

@mailkite/better-auth is a real BetterAuthPlugin — dropping it into plugins wires sendVerificationEmail and sendResetPassword through its init(). Better Auth merges that with defu, so if you already set either callback, yours wins.

2

Better Auth calls them

Sign up here and Better Auth generates a token and a URL, then calls the plugin. No callback written by hand. Magic link, email OTP and org invites read their callback from their own plugin's closure, so those get passed explicitly off the same object.

3

Sends are backgrounded on purpose

Awaiting the send would make the response slower when an account exists, which leaks who has an account. The plugin dispatches and returns immediately; failures go to onError, never to the caller. On serverless you pass waitUntil so the runtime doesn't kill the send.

4

Mail comes back

Claim an address and the inbox plugin registers a route with MailKite. Incoming mail arrives at /api/auth/mailkite/inbox/webhook, is HMAC-verified against that route's own signing secret, stored against the owning mailbox, and read through session-scoped endpoints.

Neither plugin puts a MailKite API key in the browser, and the sending one adds no database tables — it's a transport, so there's no migration to run just to send a password-reset email.

Questions about the plugins

If it isn't answered here, the docs and the demo source are both public.

The demo

The plugins

Under the hood