Send real email
Out of the box Yatta prints email to the terminal instead of sending it. This guide walks through connecting a real provider, and the four things that usually stop delivery.
Start here
The mailer reads its transport from NODE_ENV. That single line is why your email is not arriving — the scaffold sets mode: "terminal" when NODE_ENV is anything other than production.
export const mailer = createMailer({ defaultFrom: "Yatta App <hello@yatta.dev>", mode: process.env.NODE_ENV === "production" ? "smtp" : "terminal",});Pick one of the options below and change that line. Every real provider goes through mode: "smtp" — the difference is the provider preset, which supplies the right host, port and TLS setting.
Why nothing arrives yet
Four causes, in the order they usually bite:
- Mode is still
terminal. The message is printed to the server console and never transmitted. - No credentials. The provider defaults to empty strings and the send fails.
- Unverified From domain. Every major provider rejects mail claiming a domain you have not proved you own.
- The mail lands in spam. SPF, DKIM and DMARC are missing.
Option A — Resend
The shortest path if you have no mail provider yet. Resend requires only a domain and an API key, and its SMTP endpoint expects the API key as the username.
SMTP_USER=resendSMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxMAIL_FROM="Yatta App <hello@yourdomain.com>"export const mailer = createMailer({ defaultFrom: process.env.MAIL_FROM || "Yatta <hello@localhost>", mode: process.env.NODE_ENV === "production" ? "smtp" : "terminal", provider: "resend", // host, port and TLS are filled in for you host: process.env.SMTP_HOST || "smtp.resend.com", port: 465, secure: true, auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASSWORD!, },});Resend dashboard → Domains → Add Domain → add the DNS records they give you → wait for the green check Until it verifies, Resend will only send TO your own address.Option B — any SMTP relay
Works with Postmark, SendGrid, Mailgun, SES or your own relay. Use the provider preset and only override host and port if your relay differs.
SMTP_HOST=smtp.postmarkapp.comSMTP_PORT=587SMTP_USER=apikeySMTP_PASSWORD=xxxxMAIL_FROM="Yatta <noreply@yourdomain.com>"export const mailer = createMailer({ defaultFrom: process.env.MAIL_FROM!, mode: process.env.NODE_ENV === "production" ? "smtp" : "terminal", provider: "postmark", // → smtp.postmarkapp.com:587, secure:false host: process.env.SMTP_HOST!, port: Number(process.env.SMTP_PORT ?? 587), auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASSWORD!, }, retries: 3, // transient failures are retried});Available presets and their defaults:
gmail → smtp.gmail.com:465 secureresend → smtp.resend.com:465 secureses → email-smtp.us-east-1.amazonaws.com:465 securepostmark → smtp.postmarkapp.com:587 STARTTLSsendgrid → smtp.sendgrid.net:587 STARTTLSmailgun → smtp.mailgun.org:587 STARTTLSbrevo → smtp-relay.brevo.com:587 STARTTLSsecure: true). Port 587 uses STARTTLS, which the transport negotiates when secure is false.Option C — Gmail
Fine for a side project, not for production. Google caps sending at roughly 500 messages a day and flags the mail as suspicious. It also requires an app password rather than your account password.
export const mailer = createMailer({ defaultFrom: "Yatta <you@gmail.com>", mode: "smtp", provider: "gmail", port: 465, secure: true, auth: { user: "you@gmail.com", pass: process.env.GMAIL_APP_PASSWORD!, // not your account password },});The From address
This is the single most common cause of silent rejection. Whatever you put in defaultFrom must belong to a domain you have verified with your provider.
noreply@localhost sender will be rejected or silently dropped by every provider. Use a real domain you control.Testing without users
Do not test against your colleagues. Use the ethereal mode, which sends a real message and gives you a preview URL instead of delivering it.
export const mailer = createMailer({ defaultFrom: "Yatta <you@ethereal.email>", mode: process.env.MAIL_ETHEREAL === "1" ? "ethereal" : "terminal",});ethereal sends a real message over the network and returns a preview URL instead of delivering it — so you test the full path without spamming anyone.
// Compiles the template and returns the rendered email — no network.const preview = await mailer.preview({ to: "test@example.com", template: "welcome", data: { name: "Ada", verifyUrl: "https://app.dev/verify?t=abc" },}); console.log(preview.subject);console.log(preview.html);Delivery safety
Yatta strips CR and LF from header values, so a subject containing a newline cannot inject extra headers. That protection is automatic — but it only covers headers, not your template body.
// Escaped — a name containing <b> renders as literal text.<p>Hello {{name}}</p> // Raw — only for URLs and values you built yourself.<a href="{{{verifyUrl}}}">Verify</a>{{value}} form for anything a user supplied. The raw triple form is for trusted values only.Rate limits are handled for you:
export const mailer = createMailer({ defaultFrom, mode: "smtp", provider: "resend", rateLimit: { max: 100, // messages allowed … window: "1h", // … within this window },});// Compiles templates, validates headers and addresses, sends nothing.export const mailer = createMailer({ defaultFrom, mode: "smtp", dryRun: process.env.MAIL_DRY_RUN === "1",});Checklist
modeis your provider, notterminal- API key or SMTP credentials are set in the environment
defaultFromuses a domain verified with the provider- SPF, DKIM and DMARC records are published for that domain
- You checked the provider's dashboard for rejected messages
- Send goes through a job, so a mail failure cannot fail a user request
For the last point, see Send email from a job.