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.
| Variable | Required | Purpose |
|---|---|---|
MAILGUN_API_KEY | Yes | Mailgun API key, used as the SMTP password (z.string()). |
MAILGUN_DOMAIN | Yes | Your verified sending domain. The SMTP user is derived as postmaster@${MAILGUN_DOMAIN} (z.string()). |
MAILGUN_FROM_EMAIL | Yes | Default 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_emailSMTP 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.
| Function | What 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, DKIMTXT, and trackingCNAME). Once Mailgun marks the domain verified, swap it intoMAILGUN_DOMAIN. - To smoke-test a single send, import
sendEmailin a server route or action and trigger it, then watch the Mailgun Logs tab for the result. Delivery and bounce events will also flow intoemail_eventsthrough 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.
