Guide · 8 min

Send email from a Bolt.new app

The preview cannot send mail no matter what you configure. Here is why, and where the code has to go instead.

If you have been fighting a connection timeout in the Bolt preview, stop: nothing is wrong with your settings. WebContainers have no raw TCP, so SMTP only works once the app is deployed and the send moved into a serverless function.

Why it never works in the preview

Bolt runs your app in a WebContainer — a Node runtime compiled to WebAssembly, running inside the browser tab. It is a genuinely impressive trick, and it has one hard limit that matters here: it cannot open raw TCP connections. The browser only grants HTTP and WebSockets.

SMTP is raw TCP. So mail code in the Bolt preview does not fail because of your settings — it cannot connect at all, usually surfacing as a connection timeout or an unhelpful ECONNREFUSED. Every minute spent checking the host and password is wasted; the code has to run somewhere else.

“Somewhere else” means deployed. Bolt deploys to Netlify in one click, so the shortest path is a Netlify function, which is ordinary Node and can open sockets like anything else.

The four settings

Wherever the send ends up running, it needs the same four values. Create them under SMTP credentials — one per app, so you can revoke one without breaking the others.

.env
SMTP_HOST=smtp.email4vibecoder.com
SMTP_PORT=587
SMTP_USER=your-username
SMTP_PASS=your-password
MAIL_FROM="Your App <[email protected]>"

MAIL_FROM has to be on a domain you have verified in Domains. That is what lets us DKIM-sign the message; send from an unverified domain and the message is rejected rather than delivered unsigned.

The serverless function

Create netlify/functions/send-email.ts. This is real Node, so Nodemailer works:

netlify/functions/send-email.ts
import nodemailer from 'nodemailer';
import type { Handler } from '@netlify/functions';

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});

export const handler: Handler = async (event) => {
  if (event.httpMethod !== 'POST') {
    return { statusCode: 405, body: 'Method not allowed' };
  }

  const { to, subject, html } = JSON.parse(event.body ?? '{}');

  try {
    await transporter.sendMail({ from: process.env.MAIL_FROM, to, subject, html });
    return { statusCode: 200, body: JSON.stringify({ ok: true }) };
  } catch (err) {
    // Log the real reason; return something the UI can show.
    console.error('send failed', err);
    return { statusCode: 502, body: JSON.stringify({ error: 'Could not send email' }) };
  }
};

Add SMTP_HOST, SMTP_USER, SMTP_PASS and MAIL_FROM under Site configuration → Environment variables in Netlify, then redeploy so the function picks them up.

Never put these in client-side code or in a variable your framework exposes to the browser. An SMTP password in a frontend bundle is readable by anyone who opens devtools, and a leaked credential gets used to send spam within hours — which costs you the credential and your domain's reputation with it.

Calling it from your app

src/lib/email.ts
export async function sendEmail(to: string, subject: string, html: string) {
  const res = await fetch('/.netlify/functions/send-email', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ to, subject, html }),
  });
  if (!res.ok) throw new Error('Could not send email');
}

That endpoint is public. Before you ship it, make it check something — a signed-in session, or at minimum that the recipient is an address your app chose rather than one the caller supplied. An unguarded send endpoint is an open relay, and it will be found.

The prompt to give Bolt

prompt
Add transactional email to this app.

Important: SMTP cannot run in the WebContainer preview, because it
needs a raw TCP socket. Put the send in a Netlify function at
netlify/functions/send-email.ts using Nodemailer, and call it from the
client with fetch('/.netlify/functions/send-email').

Read SMTP_HOST, SMTP_USER, SMTP_PASS and MAIL_FROM from process.env
inside the function only — never in client code, never hard-coded.
Use port 587. Reuse a single transporter across invocations.

Reject requests that are not POST, and do not let the caller choose an
arbitrary recipient. Surface send failures in the UI instead of
swallowing them.

Check that it actually sent

Send one message to yourself, then open Activity. Every message we accept is listed there with the receiving server's own reply, so you can tell the difference between three very different failures: the code never ran, we rejected it, or the recipient's server did.

If it was accepted and delivered but landed in spam, that is a separate problem with a separate fix — see why emails go to spam.

Common failures

What you seeWhat it means
Timeout or ECONNREFUSED in the previewExpected — WebContainers cannot open TCP sockets. Deploy and test the deployed URL.
404 on /.netlify/functions/send-emailThe function was not deployed. Check it sits under netlify/functions/ and that the build published it.
Works locally, fails once deployedEnvironment variables set in one place only. Netlify needs its own copy.
Function times out after 10 secondsA transporter created per request on a cold start. Create it once at module scope, as above.