ShipVeryFastShipVeryFast
Documentation

Transactional email

ShipVeryFast sends transactional mail through Mailgun over SMTP using nodemailer. This page covers the low-level sendEmail helper, the higher-level flows in libs/emailService.ts, the built-in templates, how magic-link sign-in reuses the same transport, and the Mailgun event webhook.

Mailgun configuration

Three environment variables drive everything email-related. They are validated by Zod in libs/config.ts at import time, so a missing or malformed value throws at startup rather than failing silently on the first send.

VariableRequiredPurpose
MAILGUN_API_KEYYesMailgun API key, used as the SMTP password (z.string()).
MAILGUN_DOMAINYesYour verified sending domain. The SMTP user is derived as postmaster@${MAILGUN_DOMAIN} (z.string()).
MAILGUN_FROM_EMAILYesDefault From address. Validated as an email (z.string().email()).

Copy .env.example to .env.local and fill in the real values:

MAILGUN_API_KEY=your_mailgun_api_key
MAILGUN_DOMAIN=your_mailgun_domain
MAILGUN_FROM_EMAIL=your_default_from_email

SMTP credentials, not the REST API

The boilerplate talks to smtp.mailgun.org on port 587 with postmaster@<your-domain> as the user and your API key as the password, no separate SMTP password needed.

The low-level sendEmail helper

libs/mailer.tscreates a single nodemailer transporter pointed at Mailgun's SMTP relay and exports one function, sendEmail. Every outbound message in the app ultimately goes through it. It accepts a MailOptions object and adds Mailgun open/click tracking headers automatically.

import { sendEmail } from '@/libs/mailer';

await sendEmail({
  to: 'jane@example.com',
  subject: 'Hello from ShipVeryFast',
  html: '<p>Your account is ready.</p>',
  text: 'Your account is ready.', // optional plain-text part
  // from defaults to MAILGUN_FROM_EMAIL when omitted
});

The MailOptions interface is { to, subject, html, text?, from? }. When from is not supplied it falls back to env.MAILGUN_FROM_EMAIL. The transporter injects these tracking headers on every send: X-Mailgun-Track, X-Mailgun-Track-Clicks, and X-Mailgun-Track-Opens, all set to yes.

Higher-level flows in emailService.ts

libs/emailService.ts wraps sendEmail with the common account lifecycle flows. Each one renders a template, persists any needed token to Supabase via supabaseAdmin, and sends. These are the functions you call from your own routes and server actions.

FunctionWhat it does
sendEmailVerification(email, userName)Generates a token, inserts it into email_verifications (24h expiry), and emails a link to /verify-email?token=....
sendPasswordResetEmail(email, userName)Inserts a token into password_resets (1h expiry) and emails a link to /reset-password?token=....
sendWelcomeEmailWorkflow(email, userName)Renders and sends the welcome email. No token, no DB write.
sendNotificationEmail(email, subject, htmlContent, textContent)Sends a generic notification with caller-supplied subject and body.

The same module also exports the token-consuming counterparts: verifyEmailToken(token) validates a verification token, marks the user email_verified, and deletes the row; resetPassword(token, newPassword) validates a reset token, updates the password through supabaseAdmin.auth.admin.updateUserById, and deletes the row. Both throw Invalid or expired token on a bad or stale token.

import { sendEmailVerification } from '@/libs/emailService';

// e.g. right after creating a user account
await sendEmailVerification('jane@example.com', 'Jane');

Built-in templates and customizing them

libs/emailTemplates.ts holds the markup. Every renderer returns an EmailTemplate object, { subject, html, text }, so each email ships with both an HTML and a plain-text part. The four renderers are renderWelcomeEmail, renderVerificationEmail, renderPasswordResetEmail, and renderNotificationEmail.

import { renderVerificationEmail } from '@/libs/emailTemplates';

const { subject, html, text } = renderVerificationEmail(
  'Jane',
  'https://app.example.com/verify-email?token=abc123',
);
// -> subject: "Please verify your email for ShipVeryFast"

To customize, edit the template strings directly in libs/emailTemplates.ts. The markup is intentionally minimal (plain <div> and <p> tags), so you can drop in your own layout, inline styles, or branding without touching the service layer, emailService.ts only depends on the returned { subject, html, text } shape.

Magic-link sign-in shares the same Mailgun transport

Magic-link auth is wired in libs/auth.ts. When a Supabase backend is configured, the NextAuth EmailProvider is registered with the exact same SMTP settings as the mailer (smtp.mailgun.org, port 587, postmaster@${MAILGUN_DOMAIN}, env.MAILGUN_FROM_EMAIL as the sender). Its sendVerificationRequest calls the same sendEmail helper, after passing the request through magicLinkRateLimiter.

// libs/auth.ts (excerpt)
sendVerificationRequest: async ({ identifier, url }) => {
  try {
    await magicLinkRateLimiter.consume(identifier);
  } catch {
    throw new Error("Too many verification requests, please try again later.");
  }
  const html = `<p>Sign in with this link: <a href="${url}">${url}</a></p>`;
  await sendEmail({ to: identifier, subject: "Your sign-in link", html });
},

Because magic links and transactional mail go out over one transport, a correctly verified Mailgun domain unblocks both at once. See Authentication for the full magic-link flow.

Inbound and event handling via the Mailgun webhook

Mailgun delivery events (delivered, opened, clicked, failed, bounced) are received at app/api/webhooks/mailgun/route.ts. The route parses the JSON payload and hands it to handleMailgunEvent in libs/emailWebhookService.ts.

// app/api/webhooks/mailgun/route.ts
export async function POST(req: Request) {
  const payload = await req.json();
  try {
    await handleMailgunEvent(payload);
    return NextResponse.json({ received: true });
  } catch (error) {
    logger.critical(error as Error, { context: 'mailgun-webhook' });
    return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 });
  }
}

handleMailgunEvent reads event-data from the payload, logs every event into the email_events table (recipient, event_type, occurred_at, raw payload), and, for failed or bounced events, inserts a row into email_retries scheduled five minutes out.

Point your Mailgun webhook at https://your-domain.com/api/webhooks/mailgun and create the email_events and email_retries tables in Supabase. Note: the route does not currently verify the Mailgun webhook signature, so add signature validation before relying on it in production.

Testing email locally and verifying your domain

Real sends require a verified Mailgun domain. Before that, you can keep development moving:

  • Use Mailgun's sandbox domain for early testing, it only delivers to authorized recipients you add in the Mailgun dashboard. Put that sandbox domain in MAILGUN_DOMAIN.
  • For production deliverability, add your real domain in Mailgun and publish the DNS records they provide (SPF TXT, DKIM TXT, and tracking CNAME). Once Mailgun marks the domain verified, swap it into MAILGUN_DOMAIN.
  • To smoke-test a single send, import sendEmail in a server route or action and trigger it, then watch the Mailgun Logs tab for the result. Delivery and bounce events will also flow into email_events through the webhook.

Because libs/config.ts validates the Mailgun variables at import time, the app will refuse to boot until all three are set, a quick way to catch a missing key before you ship.

Next steps