A Contact Form That Actually Sends Email: Next.js, nodemailer and Postfix
Mohammed Moutawakkil
6 min read
Next.jsnodemailerPostfixSecurity
The contact form on this site looked like it worked. You filled it in, pressed send, and got a green "Message sent" banner. The problem: the email notification never reached my inbox. In this article I'll show why it failed silently, and how I replaced the third-party form service with the mail server already running on my VM.
The Old Setup
The form sent two requests in parallel from the browser: one to my own API, which saved the message in PostgreSQL, and one to a hosted form service (Web3Forms), which was supposed to email it to me.
That last line is the first mistake. If the database save worked, the visitor saw a success message, even when the email part failed. The UI could not tell me that half of the feature was broken.
Why the Email Never Left the Browser
The site sends a Content Security Policy header. Its connect-src directive lists the origins the page is allowed to call with fetch:
Look closely: the policy allows web3forms.com, but the form posts to . A host in CSP matches that exact host only. Subdomains need an explicit entry or a wildcard like . So the browser blocked the request before it was sent, logged a CSP violation in the console, and the swallowed the error.
CSP host sources are exact. When you add a third-party API, copy the exact origin from the request URL, not from the marketing site.
Don't merge results that mean different things. "Saved" and "emailed" are two outcomes. Treating either as success hides the other one.
The New Design: The Server Sends the Email
I could have fixed the CSP line. But sending email from the browser has other problems: the access key is public, ad blockers can block the call, and the visitor's browser decides whether the notification happens. The server is a better place for it:
Browser โโโถ POST /api/contact (Next.js)
โ
โโโถ save to PostgreSQL
โโโถ nodemailer โโโถ Postfix (127.0.0.1:25) โโโถ SMTP relay โโโถ my inbox
The browser now makes one request, to its own origin. There is nothing to add to the CSP, no key in the bundle, and the server knows whether the email was sent.
Step 1: Postfix as a Local Relay
My VM already runs Postfix for other apps. It listens on the loopback interface only and forwards everything to an SMTP relay provider (I use Brevo) over TLS. The relevant part of /etc/postfix/main.cf:
INI
# Only accept mail from this machine
inet_interfaces = loopback-only
mynetworks = 127.0.0.0/8
# Forward everything to the relay, authenticated, over TLS
relayhost = [smtp-relay.brevo.com]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt
Why not let the app talk to the relay directly? Postfix gives you a queue for free. If the relay is down for a minute, Postfix keeps the message and retries; your API call has already returned. The app only needs to reach 127.0.0.1:25, which is always up.
Because my container uses the host network (network_mode: host), 127.0.0.1 inside the container is the VM itself, so the app reaches Postfix without any extra configuration.
Step 2: A Small Mail Helper
On the Next.js side I use nodemailer. Version 10 ships its own TypeScript types, so there is no @types package to add.
TypeScript
// lib/mail.ts
import { createTransport } from "nodemailer";
export type ContactMail = { name: string; email: string; subject: string; message: string };
// Header values must stay on one line.
const oneLine = (value: string) => value.replace(/[\r\n]+/g, " ").trim();
export async function sendContactEmail(msg: ContactMail) {
const to = process.env.CONTACT_TO;
if (!to) throw new Error("CONTACT_TO is not set");
const host = process.env.SMTP_HOST || "127.0.0.1";
const port = Number(process.env.SMTP_PORT || 25);
const isLocal = host === "127.0.0.1" || host === "localhost";
const transport = createTransport({
host,
port,
secure: port === 465,
// Local Postfix: plain SMTP on loopback (its STARTTLS uses a self-signed certificate).
ignoreTLS: isLocal,
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
await transport.sendMail({
from: { name: "moutawakkil.me contact form", address: process.env.SMTP_FROM || to },
to,
replyTo: { name: oneLine(msg.name), address: msg.email },
subject: `[moutawakkil.me] ${oneLine(msg.subject)}`,
text: `Name: ${oneLine(msg.name)}\nEmail: ${msg.email}\n\n${msg.message}`,
});
}
A few details matter here:
replyTo is the visitor, from is me. Never put the visitor's address in from. Your relay will reject it (it is not a verified sender), and if it doesn't, SPF and DMARC checks will send the email to spam. With replyTo, pressing Reply in Gmail answers the visitor directly.
ignoreTLS only for loopback. The traffic never leaves the machine, and the local Postfix uses a self-signed certificate. For a remote SMTP host, TLS stays on.
Timeouts. Nodemailer's default connection timeout is two minutes. A visitor should not wait that long, so I cut it to ten seconds.
One-line headers. The name and subject come from a form. Stripping line breaks keeps them from adding headers of their own.
Step 3: Save First, Then Email
The API route validates the body with Zod, saves the message, and only then sends the email:
TypeScript
// app/api/contact/route.ts
export async function POST(request: NextRequest) {
const { data, error } = await parseBody(request, contactCreate);
if (error) return error;
try {
const message = await prisma.contactMessage.create({ data, select: { id: true } });
// The message is already saved, so a mail failure is logged, not shown to the visitor.
try {
await sendContactEmail(data);
} catch (mailError) {
console.error(`Contact message ${message.id} saved, but the email notification failed:`, mailError);
}
return NextResponse.json(message, { status: 201 });
} catch (error) {
return handleError(error);
}
}
The order is deliberate. The database is the source of truth: if the email fails, the message is still in my inbox table and the error is in the logs with its id. If the save fails, no email goes out for a message that doesn't exist, and the visitor gets an error they can act on.
The client becomes much simpler too: one fetch, and response.ok means the message is really stored.
Step 4: Test It for Real
Unit tests mock nodemailer and check the transport options, the replyTo, and that a mail failure still returns 201. But a mock can't tell you whether your relay accepts the sender. For that, send one real email and read the Postfix log:
status=sent means the relay accepted the message. status=deferred or bounced comes with the reason, usually an unverified sender address.
Conclusion
The bug was one missing subdomain in a CSP header, but the real problem was a design that could not report it. Moving the email to the server fixed both: the browser talks only to its own origin, the server knows whether the email left, and Postfix retries when the relay has a bad minute.
If your contact form uses a hosted service, open your browser console, submit it once, and look for a CSP error. It takes ten seconds and might save you a lot of silence.