Get your API key
Integrations

Better Auth inbox

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. @mailkite/better-auth-inbox adds the other half.

Sending is the other plugin. This one is about receiving, and the two are independent — install either, or both.

See it working: better-auth.mailkite.dev/inbox claims a real address and receives real mail, using this plugin. Source: github.com/mailkite/better-auth-demo.

Install

install
npm install @mailkite/better-auth-inbox

Set it up

Verify a domain for receiving first, then add the plugin:

lib/auth.ts
import { betterAuth } from "better-auth";
import { mailkiteInbox } from "@mailkite/better-auth-inbox";

export const auth = betterAuth({
plugins: [
mailkiteInbox({
apiKey: process.env.MAILKITE_API_KEY!,
domain: "acme.com", // verified for receiving
webhookSecret: process.env.MAILKITE_WEBHOOK_SECRET!,
baseURL: "https://acme.com", // where MailKite POSTs deliveries
}),
],
});

Unlike the sending plugin, this one owns data, so it declares a schema. Run the migration to create the two tables:

terminal
npx @better-auth/cli migrate

Then point the domain's webhook at /api/auth/mailkite/inbox/webhook. Deliveries are rejected unless they carry a valid signature and a fresh timestamp — there is no unsigned mode, and webhookSecret is required.

Reading from the browser

The client plugin gives you typed methods for each endpoint. The browser never holds a MailKite API key: every call is session-authenticated against your own auth server, which does the privileged work.

lib/auth-client.ts
import { createAuthClient } from "better-auth/client";
import { mailkiteInboxClient } from "@mailkite/better-auth-inbox/client";

export const authClient = createAuthClient({
plugins: [mailkiteInboxClient()],
});

// Claim an address, then read the mailbox — all session-authenticated.
await authClient.mailkite.inbox.provision({ localPart: "ada" });
const { data } = await authClient.mailkite.inbox.messages();

Endpoints

RouteAuthWhat
POST /mailkite/inbox/webhooksignatureInbound delivery from MailKite
POST /mailkite/inbox/provisionsessionClaim an address for the user or their active org
GET /mailkite/inbox/mailboxessessionThe caller's addresses — so an app can show "your address" after a reload
GET /mailkite/inbox/messagessessionList readable messages, newest first. Pass mailboxId to scope to one address
GET /mailkite/inbox/messagesessionRead one, marks it read
POST /mailkite/inbox/replysessionReply from the mailbox that received it

Per-organization inboxes

Pair it with Better Auth's organization plugin and a team gets a shared address. A session can read its own personal mailboxes plus the mailbox of its active organization — switching orgs switches the inbox.

shared inbox
import { organization } from "better-auth/plugins";

export const auth = betterAuth({
plugins: [
organization(),
mailkiteInbox({ /* … */ }),
],
});

// Give the caller's active organization a shared inbox rather than a personal one.
await authClient.mailkite.inbox.provision({
localPart: "support",
forOrganization: true,
});

Replying

A reply is sent from the mailbox that received the message, never from the caller's own address — the mailbox is the identity the other party already knows — and it threads to the original.

reply
await authClient.mailkite.inbox.reply({
messageId: message.id,
text: "Thanks — taking a look now.",
});

Reacting to new mail

onMessage
mailkiteInbox({
// …
onMessage: async (message) => {
// The message is already stored. Throwing here does NOT fail the webhook —
// a non-2xx would make MailKite redeliver mail we already have.
await notifySlack(message.subject, message.fromAddress);
},
});

Options

OptionNotes
apiKeyRequired. MailKite API key.
domainRequired. Domain new addresses are provisioned on. Must be verified for receiving.
webhookSecretRequired. Signing secret from the domain's webhook settings. Unsigned deliveries are never accepted.
baseURLPublic URL of your app. Required only if you call provision — MailKite needs somewhere to POST.
toleranceSecondsReject deliveries older than this. Default 300.
onMessageRuns after a message is stored. Throwing does not fail the webhook.

Security

  • Every delivery is verified by HMAC-SHA256 over {timestamp}.{payload} and rejected outside the replay window.
  • Reads are scoped by one shared rule rather than per-endpoint filtering, so there is a single place to get authorization right.
  • A mailbox id the caller doesn't own returns 404identically to one that doesn't exist, and identically whether or not the caller owns any mailbox at all. An earlier build short-circuited on "caller owns no mailboxes" before validating the id, which leaked which ids existed. There is a regression test asserting both callers get byte-identical responses.
  • Rate limits apply to /mailkite/inbox/*, with a tighter bucket on provision because it creates real routes upstream.

Sending too

For the outbound half — magic links, OTPs, verification, password resets and organization invitations — see @mailkite/better-auth. The two plugins are independent and compose.