Guides

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.

yatta/func/mail.tsts
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.

1. Environmentbash
SMTP_USER=resendSMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxMAIL_FROM="Yatta App <hello@yourdomain.com>"
2. Configure the mailerts
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!,  },});
3. Add your domaintext
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.

Environmentbash
SMTP_HOST=smtp.postmarkapp.comSMTP_PORT=587SMTP_USER=apikeySMTP_PASSWORD=xxxxMAIL_FROM="Yatta <noreply@yourdomain.com>"
Configurets
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:

presetstext
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     STARTTLS
Note
Port 465 uses implicit TLS (secure: 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.

tsts
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.

Warning
A 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.

yatta/func/mail.tsts
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.

Previewts
// 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.

escapingts
// 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>
Note
Use the escaped {{value}} form for anything a user supplied. The raw triple form is for trusted values only.

Rate limits are handled for you:

tsts
export const mailer = createMailer({  defaultFrom,  mode: "smtp",  provider: "resend",  rateLimit: {    max: 100,          // messages allowed …    window: "1h",      // … within this window  },});
dry run — validate without sendingts
// Compiles templates, validates headers and addresses, sends nothing.export const mailer = createMailer({  defaultFrom,  mode: "smtp",  dryRun: process.env.MAIL_DRY_RUN === "1",});

Checklist

  • mode is your provider, not terminal
  • API key or SMTP credentials are set in the environment
  • defaultFrom uses 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.