Skip to content

StartSign-in emails

Send sign-in emails with Shipmail

Password resets, verification emails and magic links are transactional email. These recipes send them from your Shipmail sender, so they go out from your sending subdomain and replies reach your Shipmail mailbox.

Last updated

Before you start

  • A verified sending subdomain, such as notify.example.com, with an active sender. See Transactional email.
  • An API key with send access to the mailbox that receives replies, stored as SHIPMAIL_API_KEY.
  • The sender address, such as hello@notify.example.com, stored as EMAIL_FROM. Use the bare address. Shipmail adds the sender name.

Add a sendEmail helper

The Better Auth, Auth.js and Clerk recipes call this helper. It throws if Shipmail refuses the send. The fetch version needs no dependency.

bun add shipmail
// lib/email.ts
import { ShipmailClient } from "shipmail";

const shipmail = new ShipmailClient(process.env.SHIPMAIL_API_KEY!);

type Email = { to: string; subject: string; text?: string; html?: string };

export async function sendEmail({ to, ...content }: Email) {
  await shipmail.messages.send({ from: process.env.EMAIL_FROM, to: [to], ...content });
}

Better Auth

Call the helper from sendResetPassword, sendVerificationEmail and the magic link plugin's sendMagicLink. sendOnSignUp sends the verification email when an account is created.

On Vercel, pass waitUntil as the background task handler. Better Auth then sends reset and verification emails without making the request wait, which also blocks timing attacks. Magic links are still sent before the response.

// auth.ts
import { waitUntil } from "@vercel/functions";
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";
import { sendEmail } from "@/lib/email";

export const auth = betterAuth({
  advanced: {
    backgroundTasks: { handler: waitUntil },
  },
  emailAndPassword: {
    enabled: true,
    sendResetPassword: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: "Reset your password",
        text: `Reset your password: ${url}`,
      });
    },
  },
  emailVerification: {
    sendOnSignUp: true,
    sendVerificationEmail: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: "Verify your email address",
        text: `Verify your email address: ${url}`,
      });
    },
  },
  plugins: [
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        await sendEmail({
          to: email,
          subject: "Your sign-in link",
          text: `Sign in: ${url}`,
        });
      },
    }),
  ],
});

Auth.js

Add an email provider with its own sendVerificationRequest, as in the Auth.js HTTP email guide. Email providers need a database adapter.

// auth.ts
import NextAuth from "next-auth";
import { sendEmail } from "@/lib/email";

export const { handlers, signIn, signOut, auth } = NextAuth({
  // adapter: your database adapter (email sign-in needs one)
  providers: [
    {
      id: "http-email",
      name: "Email",
      type: "email",
      maxAge: 60 * 60 * 24, // the link expires after 24 hours
      async sendVerificationRequest({ identifier: email, url }) {
        await sendEmail({
          to: email,
          subject: "Sign in to your account",
          text: `Sign in: ${url}`,
        });
      },
    },
  ],
});

Start sign-in with signIn("http-email", { email }).

Clerk

Add a webhook endpoint subscribed to email.created and store its signing secret as CLERK_WEBHOOK_SIGNING_SECRET, which verifyWebhook reads. If your middleware protects every route, make /api/webhooks/clerk public. Once the handler is live, turn off Delivered by Clerk on each template you want to send yourself.

// app/api/webhooks/clerk/route.ts
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import type { NextRequest } from "next/server";
import { sendEmail } from "@/lib/email";

export async function POST(req: NextRequest) {
  try {
    const evt = await verifyWebhook(req);

    if (
      evt.type === "email.created" &&
      !evt.data.delivered_by_clerk &&
      evt.data.to_email_address
    ) {
      await sendEmail({
        to: evt.data.to_email_address,
        subject: evt.data.subject ?? "",
        html: evt.data.body,
        text: evt.data.body_plain ?? undefined,
      });
    }

    return new Response("Webhook received", { status: 200 });
  } catch (err) {
    console.error("Webhook failed:", err);
    return new Response("Webhook failed", { status: 400 });
  }
}

The event carries the email Clerk rendered, so the handler sends its subject and bodies as they are. It skips emails Clerk still delivers.

Supabase Auth

The Send Email Hook makes Supabase Auth call an HTTP endpoint, such as an Edge Function, instead of sending through SMTP. While the hook is on, it replaces your SMTP settings. Transactional senders don't accept SMTP, so Supabase's SMTP settings can't send from one.

The function verifies the request's Standard Webhooks signature, builds the link from the token hash, action type and redirect, and adds the one-time code as a fallback.

// supabase/functions/send-email/index.ts
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";

const hookSecret = (Deno.env.get("SEND_EMAIL_HOOK_SECRET") as string).replace("v1,whsec_", "");

const subjects: Record<string, string> = {
  signup: "Confirm your email address",
  recovery: "Reset your password",
  magiclink: "Your sign-in link",
  invite: "You've been invited",
  email_change: "Confirm your new email address",
};

Deno.serve(async (req) => {
  const payload = await req.text();
  const headers = Object.fromEntries(req.headers);

  try {
    const { user, email_data } = new Webhook(hookSecret).verify(payload, headers) as {
      user: { email: string };
      email_data: {
        token: string;
        token_hash: string;
        redirect_to: string;
        email_action_type: string;
      };
    };

    const link = new URL(`${Deno.env.get("SUPABASE_URL")}/auth/v1/verify`);
    link.searchParams.set("token", email_data.token_hash);
    link.searchParams.set("type", email_data.email_action_type);
    link.searchParams.set("redirect_to", email_data.redirect_to);

    const res = await fetch("https://shipmail.to/api/v1/messages", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${Deno.env.get("SHIPMAIL_API_KEY")}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        from: Deno.env.get("EMAIL_FROM"),
        to: [user.email],
        subject: subjects[email_data.email_action_type] ?? "Your verification code",
        text: `Open this link: ${link}\n\nOr enter this code: ${email_data.token}`,
      }),
    });
    if (!res.ok) throw new Error(`Shipmail returned ${res.status}: ${await res.text()}`);
  } catch (error) {
    return new Response(
      JSON.stringify({ error: { http_code: 500, message: String(error) } }),
      { status: 500, headers: { "Content-Type": "application/json" } },
    );
  }

  return new Response(JSON.stringify({}), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
});
  1. Deploy the function without JWT checks, since the signature protects it.

    supabase functions deploy send-email --no-verify-jwt
  2. In Authentication > Hooks, add a Send Email hook of type HTTP with the URL https://<project-ref>.supabase.co/functions/v1/send-email.
  3. Generate the hook secret and put it in supabase/functions/.env as SEND_EMAIL_HOOK_SECRET, with SHIPMAIL_API_KEY and EMAIL_FROM.
  4. Set them as function secrets. They apply without a redeploy.

    supabase secrets set --env-file supabase/functions/.env

Test before you launch

Put a test key (sm_test_...) in SHIPMAIL_API_KEY, then sign up, reset a password and request a magic link. Nothing is delivered. Each send appears under Transactional > Activity with the toggle set to Test, where you can open it and check the link. Before launch, switch to your live key and send one reset to your own address.

If you handle webhooks, add sandbox_outcome: "bounced" to a test send to see how your app handles a bounce.

Clerk and Supabase call your endpoint over the internet. To test a handler running on your machine, give it a public URL with a tunnel such as ngrok.