How Next Starter sends transactional emails with SMTP2Go, including configuration, the sendEmail function, built-in HTML templates, disposable email blocking, and adding custom templates.
Overview
Next Starter sends transactional emails through SMTP2Go. All email sending is centralized in lib/email.ts, which exposes a sendEmail function and a set of pre-built HTML template helpers.
Emails are only sent from server-side code: Server Actions, Better Auth hooks, or API routes. They never touch the client.
Configuration
The SMTP2Go client is initialized in lib/email.ts using the API key from your environment:
// lib/email.ts
import SMTP2GOApi from "smtp2go-nodejs";
import { env } from "@/lib/validations/env";
const smtp2go = SMTP2GOApi(env.SMTP2GO_API_KEY);Sending an Email
The sendEmail function is the single point of contact for all outbound mail. It accepts a recipient address, subject, and an HTML string:
interface SendEmailOptions {
to: string;
subject: string;
html: string;
}
export async function sendEmail({ to, subject, html }: SendEmailOptions) {
try {
const mailService = smtp2go
.mail()
.to({ email: to })
.from({ email: env.SENDER_EMAIL, name: APP_CONFIG.name })
.subject(subject)
.html(html);
const response = await smtp2go.client().consume(mailService);
return { success: true, messageId: response.id };
} catch (error) {
logger.error(
{
event: "email_send_failure",
err: error,
},
"Failed to send email via SMTP2Go",
);
return { success: false, error };
}
}Errors are caught and logged with Pino. The function does not throw, so a failed email will not break the calling operation. On failure it returns { success: false, error } instead of { success: true, messageId }.
Example: Sending from a Server Action
The built-in contact form action in app/actions/contact.ts is the canonical example. It validates the form data with Zod, optionally checks a reCAPTCHA token, and sends the email to the address configured in APP_CONFIG.email:
"use server";
import { sendEmail, getContactFormEmailHtml } from "@/lib/email";
import { APP_CONFIG } from "@/lib/config";
import { contactSchema } from "@/lib/validations/contact";
export async function submitContactForm(formData: FormData) {
const { name, email, message } = contactSchema.parse({
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
});
const result = await sendEmail({
to: APP_CONFIG.email,
subject: `New contact form submission from ${name}`,
html: getContactFormEmailHtml(name, email, message),
});
if (!result.success) {
return { success: false, error: "Failed to send message" };
}
return { success: true };
}HTML Email Templates
All templates share a common layout built with inline CSS so they render correctly across email clients. The base template is assembled by getEmailTemplate() inside lib/email.ts and uses brand colors and font settings from lib/config.ts.
Built-in Templates
| Function | When it is sent |
|---|---|
getVerificationEmailHtml | On sign-up, to verify the email address |
getPasswordResetEmailHtml | When a user requests a password reset |
getEmailChangeVerificationHtml | When a user requests an email address change, sent to their current address for approval |
getDeleteAccountVerificationHtml | When a user requests account deletion |
getAccountSetupEmailHtml | When an admin creates a user account manually |
getContactFormEmailHtml | When a visitor submits the contact form |
getSubscriptionStartedEmailHtml | After a successful Stripe checkout |
getSubscriptionRenewedEmailHtml | After a subscription renewal invoice is paid |
getPaymentFailedEmailHtml | When a Stripe payment fails |
Adding a New Template
Add a new exported function to lib/email.ts that calls the internal getEmailTemplate helper:
export function getWelcomeEmailHtml(name: string, dashboardUrl: string) {
return getEmailTemplate({
title: "Welcome aboard",
greeting: `Hello ${name},`,
message: "Your account is ready. Click below to get started.",
buttonText: "Go to Dashboard",
buttonLink: dashboardUrl,
footerText: "Thanks for joining us.",
});
}Then import and call it wherever you need:
import { sendEmail, getWelcomeEmailHtml } from "@/lib/email";
await sendEmail({
to: user.email,
subject: "Welcome!",
html: getWelcomeEmailHtml(user.name, `${baseUrl}/dashboard`),
});Disposable Email Blocking
Sign-up attempts from known disposable or temporary email providers are rejected before an account is created. The check lives in app/actions/user.ts:
import blockedDomains from "@/lib/email/blocked-domains.json";
const blockedDomainsSet = new Set(blockedDomains);
export async function isDisposableEmail(email: string): Promise<boolean> {
const domain = email.split("@")[1]?.toLowerCase();
const isBlocked = domain ? blockedDomainsSet.has(domain) : false;
if (isBlocked) {
after(() => {
logger.info(
{ event: "disposable_email_blocked", email, domain },
"Disposable email blocked",
);
});
}
return isBlocked;
}lib/email/blocked-domains.json ships with over 70,000 known disposable domain entries. When a blocked domain is detected, the attempt is logged at info level via Pino and the caller receives true. The registration flow should return an error to the user without revealing that the specific domain is on a blocklist.
To add or remove entries, edit lib/email/blocked-domains.json directly. The file is a plain JSON array of lowercase domain strings.
SMTP2Go Setup
- Create an account at smtp2go.com
- Add and verify your sending domain
- Generate an API key under Sending > API Keys
- Set a verified sender address as
SENDER_EMAIL
Required Environment Variables
SMTP2GO_API_KEY=api-... # From SMTP2Go dashboard
SENDER_EMAIL=noreply@yourdomain.com # Must be a verified sender addressBoth variables are validated at startup in lib/validations/env.ts. The application will refuse to start if either is missing.