npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mailkite/better-auth

v0.3.0

Published

Better Auth plugin: send magic links, OTPs, verification, password-reset and organization-invite emails through MailKite.

Readme

@mailkite/better-auth

A Better Auth plugin that sends the framework's transactional email through MailKite.

Better Auth ships no email transport — it generates a token and a URL, then calls a callback you write. This plugin is that callback, for all five surfaces: magic links, email OTP, verification, password reset, and organization invitations.

npm install @mailkite/better-auth

Setup

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

const mk = mailkite({
  apiKey: process.env.MAILKITE_API_KEY,   // required — pass it explicitly
  from: "[email protected]",                   // must be on a domain verified for sending
  appName: "Acme",
  appUrl: "https://acme.com",              // used to build the invitation link
});

export const auth = betterAuth({
  emailAndPassword: { enabled: true },
  plugins: [
    mk,                                                    // verification + reset, wired for you
    magicLink({ sendMagicLink: mk.sendMagicLink }),
    emailOTP({ sendVerificationOTP: mk.sendVerificationOTP }),
    organization({ sendInvitationEmail: mk.sendInvitationEmail }),
  ],
});

That's the whole integration. Verify a sending domain at mailkite.dev/docs/domains and you're done.

No migration to run: this plugin declares no schema and no endpoints. It is a transport, and sending a password-reset email shouldn't cost you a database table.

What the plugin wires, and what stays explicit

init() returns an options patch that Better Auth merges with defu, which fills gaps without overwriting. So installing the plugin supplies:

| Surface | How | |---|---| | emailVerification.sendVerificationEmail | Automatic, via init(). Your own value wins if you set one. | | emailAndPassword.sendResetPassword | Automatic, via init(). Your own value wins if you set one. |

The other three are read from the closure of the magicLink, emailOTP and organization plugins rather than from root options, so no plugin can inject them. Pass them by hand off the same object — mk.sendMagicLink, mk.sendVerificationOTP, mk.sendInvitationEmail.

Installing the plugin never turns an auth method on. emailAndPassword: { enabled: true } stays your call.

Just the callbacks

If you'd rather wire every surface yourself, mailkiteAdapter() returns the five callbacks with no plugin wrapper:

import { mailkiteAdapter } from "@mailkite/better-auth";

const mk = mailkiteAdapter({ apiKey: process.env.MAILKITE_API_KEY, from: "[email protected]" });
export const auth = betterAuth({
  emailVerification: { sendVerificationEmail: mk.sendVerificationEmail },
  emailAndPassword: { enabled: true, sendResetPassword: mk.sendResetPassword },
});

Why sends are backgrounded

Better Auth's docs warn against awaiting the send: if the response is slower when the account exists, the timing itself tells an attacker who has an account.

So every callback here dispatches and returns immediately. Failures go to onError (default: console.error) and never to the caller — a thrown error is the same side channel wearing a different hat.

On serverless, pass waitUntil or the runtime may kill the request before the send completes:

// Cloudflare Workers / Next.js on the edge
const mk = mailkite({
  apiKey: env.MAILKITE_API_KEY,
  from: "[email protected]",
  waitUntil: (p) => ctx.waitUntil(p),
});

If you would rather await (and accept the timing channel), set awaitSend: true.

Options

| Option | Type | Notes | |---|---|---| | from | string | Required. Address on a domain verified for sending (SPF + DKIM). | | apiKey | string | Required (unless getToken). No environment fallback — see below. | | getToken | () => string \| Promise<string> | Use instead of apiKey for short-lived OAuth access tokens. | | appName | string | Shown in the emails. Defaults to the from domain. | | appUrl | string | Builds the default invite link, {appUrl}/accept-invitation/{id}. | | logoUrl | string | Logo in the emails. Falls back to the app name as text. | | brandColor | string | CTA button colour. Default #2f6fe0. | | replyTo | string | Applied to every message. | | invitationUrl | (id, data) => string | Build the invite URL yourself. Beats appUrl. | | templates | Record<type, templateId> | Send from your own MailKite templates instead of the built-ins. | | waitUntil | (p) => void | Serverless keep-alive. | | onError | (err, { type, to }) => void | Send failures land here. Default logs. | | awaitSend | boolean | Await instead of backgrounding. Off by default. |

Templates

The built-in emails render under your app name and colours — no MailKite branding. They cover magic link, OTP (with per-context copy for all four of Better Auth's types: sign-in, email-verification, forget-password and change-email), verification, password reset, and invitations.

To use your own, create a template in MailKite and map it per type:

mailkite({
  apiKey: process.env.MAILKITE_API_KEY,
  from: "[email protected]",
  templates: {
    magicLink: "tpl_abc123",
    otp: "tpl_def456",
    // verify, reset, invitation — unmapped types keep the built-ins
  },
});

Merge tags passed to your template:

| Type | Tags | |---|---| | magicLink | login_url, app_name | | verify | verify_url, name, app_name | | reset | reset_url, name, app_name | | otp | code, app_name | | invitation | invite_url, team, inviter, app_name |

Escape hatch

Same credentials, arbitrary message — for the welcome email Better Auth doesn't send:

await mk.send({ to: user.email, subject: "Welcome", text: "Glad you're here." });

Receiving email

Auth email is one-way. If you want the app to receive mail too — replies, support, an inbox per user or per organization — install @mailkite/better-auth-inbox alongside this. That one does add a schema and endpoints, because receiving needs both.

Security notes

  • Every interpolated value (app name, user name, organization, inviter) is HTML-escaped.
  • CTA URLs that aren't http(s) render as #, so a poisoned callback URL can't become javascript:.
  • Send failures never reject to the caller, and error copy never differs by whether the account exists.
  • The plugin's public options (reachable via auth.options.plugins) carry the resolved from, appName and appUrl — never the API key.

Compatibility

Requires better-auth >= 1.0.0 as a peer dependency; CI tests against 1.6.x.

npm test boots a real betterAuth() on the memory adapter and drives the plugin through actual sign-up and password-reset requests. npm run typecheck compiles the plugin against Better Auth's own BetterAuthPlugin type and against the option types of magicLink, emailOTP and organization, so a signature drift upstream fails the build.

Upgrading from 0.2.x

apiKey is now required. Earlier versions fell back to process.env.MAILKITE_API_KEY, which read as convenience and behaved as a trap: on any runtime without a populated process.env — Workers, Deno, most edge runtimes — the plugin looked configured and sent nothing. Because sends are backgrounded and failures route to onError, the app kept working while every auth email silently vanished.

Pass it explicitly: mailkite({ apiKey: process.env.MAILKITE_API_KEY, from }). Nothing else changed.

Upgrading from 0.1.x

Nothing breaks. mailkite() now returns a plugin object that still carries all five callbacks, so existing config keeps working unchanged. To adopt the plugin form, move mk into plugins: [] and drop the two root callbacks it now supplies.

Two fixes worth knowing about: OTP emails for Better Auth's change-email type used to fall through to sign-in copy, and sendVerificationOTP / sendInvitationEmail now return Promise<void> to match the option types those plugins declare.

License

MIT