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
npm install @mailkite/better-auth-inbox Set it up
Verify a domain for receiving first, then add the plugin:
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:
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.
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
| Route | Auth | What |
|---|---|---|
POST /mailkite/inbox/webhook | signature | Inbound delivery from MailKite |
POST /mailkite/inbox/provision | session | Claim an address for the user or their active org |
GET /mailkite/inbox/mailboxes | session | The caller's addresses — so an app can show "your address" after a reload |
GET /mailkite/inbox/messages | session | List readable messages, newest first. Pass mailboxId to scope to one address |
GET /mailkite/inbox/message | session | Read one, marks it read |
POST /mailkite/inbox/reply | session | Reply 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.
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.
await authClient.mailkite.inbox.reply({
messageId: message.id,
text: "Thanks — taking a look now.",
}); Reacting to new mail
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
| Option | Notes |
|---|---|
apiKey | Required. MailKite API key. |
domain | Required. Domain new addresses are provisioned on. Must be verified for receiving. |
webhookSecret | Required. Signing secret from the domain's webhook settings. Unsigned deliveries are never accepted. |
baseURL | Public URL of your app. Required only if you call provision — MailKite needs somewhere to POST. |
toleranceSeconds | Reject deliveries older than this. Default 300. |
onMessage | Runs 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
404— identically 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 onprovisionbecause 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.