Sending email from an application sounds trivial until your first transactional email lands in spam and you realize the domain reputation is shot. The full setup — provider selection, DNS authentication, async delivery, templating, and bounce handling — takes an afternoon to do right and months to recover from if skipped. Here is the complete guide.
What changed in 2026
- Resend (launched 2023) is now the developer-favorite transactional email provider: clean API, React Email native support, and generous free tier (~3k emails/month).
- Google and Yahoo mandated DMARC for bulk senders starting in 2024; SPF + DKIM + DMARC are now enforced, not optional. Miss them and Gmail routes you to spam.
- React Email 3.x is the dominant template approach — write email templates as React components, preview in a browser, export to HTML.
- Postmark remains the deliverability leader for transactional email (welcome emails, password resets); SendGrid and Mailgun are better for mixed transactional + marketing sends.
Provider comparison
| Provider |
Best for |
Free tier |
Notable feature |
| Resend |
Devs, React Email native |
3k/month |
Clean SDK, React templates |
| Postmark |
Transactional deliverability |
100/month |
Dedicated transactional streams |
| SendGrid |
Mixed transactional + marketing |
100/day |
Large ecosystem |
| Mailgun |
API-first |
1k/month (3 months) |
Europe data residency |
| AWS SES |
AWS-native, high volume |
~1k/month (EC2 free) |
Cheapest at scale (~$0.10/1k) |
For a new app: Resend to ship fast, Postmark for critical transactional (password reset, billing). Move to SES at scale for cost savings.
Sending with Resend (Node.js)
import { Resend } from 'resend';
import { WelcomeEmail } from './emails/WelcomeEmail.js';
const resend = new Resend(process.env.RESEND_API_KEY!);
export async function sendWelcomeEmail(user: { email: string; name: string }) {
const { data, error } = await resend.emails.send({
from: 'ByteLedger <hello@mail.byteledger.app>',
to: user.email,
subject: 'Welcome to ByteLedger',
react: WelcomeEmail({ name: user.name }),
});
if (error) {
throw new Error(`Failed to send welcome email: ${error.message}`);
}
return data;
}
Call this from a background job, not directly from your route handler.
React Email template
// emails/WelcomeEmail.tsx
import {
Body, Button, Container, Head, Heading, Html, Preview, Text,
} from '@react-email/components';
interface Props { name: string; }
export function WelcomeEmail({ name }: Props) {
return (
<Html>
<Head />
<Preview>Welcome to ByteLedger, {name}</Preview>
<Body style={{ fontFamily: 'sans-serif', background: '#f4f4f5' }}>
<Container style={{ maxWidth: 600, margin: '0 auto', padding: 24 }}>
<Heading>Hi {name}, welcome aboard!</Heading>
<Text>Your account is ready. Get started below.</Text>
<Button href="https://app.byteledger.app/dashboard"
style={{ background: '#2563eb', color: '#fff', padding: '12px 24px' }}>
Go to dashboard
</Button>
</Container>
</Body>
</Html>
);
}
Preview locally: npx react-email dev spins up a local preview server.
DNS authentication (do this before any sends)
SPF
Add a TXT record to your sending domain:
TXT mail.yourdomain.com "v=spf1 include:_spf.resend.com ~all"
DKIM
Your provider generates a public/private key pair. Add the CNAME or TXT record they provide. Example (Resend):
CNAME resend1._domainkey.mail.yourdomain.com → resend1._domainkey.resend.com
CNAME resend2._domainkey.mail.yourdomain.com → resend2._domainkey.resend.com
DMARC
TXT _dmarc.yourdomain.com "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
Start with p=none (monitoring only), confirm reports look clean, then move to p=quarantine then p=reject.
Use your provider's domain verification checklist — they walk through each record and confirm propagation.
Async delivery pattern
// With BullMQ (Node.js)
import { Queue } from 'bullmq';
const emailQueue = new Queue('emails', { connection: redis });
// In your route handler — fast, non-blocking
await emailQueue.add('welcome', { userId: user.id, email: user.email });
// In your worker process — slow, runs in background
import { Worker } from 'bullmq';
new Worker('emails', async (job) => {
if (job.name === 'welcome') {
const user = await db.users.findUnique({ where: { id: job.data.userId } });
await sendWelcomeEmail(user!);
}
}, { connection: redis });
Background queues add retry logic automatically — transient provider failures don't lose emails.
How to handle bounces and complaints
- Configure webhooks from your provider for
bounce and complaint events.
- Suppress bounced addresses immediately — add to a blocklist; do not retry hard bounces.
- Handle complaints (spam reports) with unsubscribes — mark the user as opted-out.
- Monitor bounce rate — keep below 2%; above that, providers throttle or suspend your account.
Common mistakes
Sending from a bare domain (@yourdomain.com) for transactional email. Use a subdomain (@mail.yourdomain.com) so a deliverability issue doesn't affect your main domain's reputation.
Awaiting the send inside the request handler. If the email provider is slow (200ms–1s) or down, your API call hangs or fails. Always enqueue.
No unsubscribe link in marketing emails. Required by CAN-SPAM and GDPR; Gmail now enforces it for bulk senders.
Testing with production sends. Use your provider's sandbox mode or an inbox testing service (Mailtrap, Mailpit for local dev) to avoid accidental sends to real addresses.
HTML emails without a plain-text version. Some clients prefer or require plain text; spam filters check both. Always send multipart.
What to skip
- Self-hosted Postfix/Exim for transactional email in 2026 — IP reputation management, bounce processing, and ISP feedback loops are a full-time job.
- Inline styles by hand in HTML emails — use React Email or MJML which handle the cross-client CSS quirks for you.
- Storing email bodies in your database — log the send event (provider message ID, recipient, timestamp); don't store the full HTML unless you have a legal requirement.
FAQ
What is the difference between transactional and marketing email?
Transactional = triggered by a user action (password reset, order confirmation). Marketing = sent to a list. Keep them on separate provider streams/domains — a marketing spam complaint should not affect transactional deliverability.
How do I test emails locally?
Run Mailpit (docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit) — it catches all outgoing SMTP and shows them in a web UI at localhost:8025.
How long does DNS propagation take for DKIM?
Typically minutes to hours; up to 48 hours worst case. Verify propagation with dig TXT resend1._domainkey.mail.yourdomain.com before your first send.
Can I use the same domain for transactional and marketing?
Technically yes but it is risky — a campaign with high unsubscribes or complaints damages the domain reputation your transactional emails depend on. Use a separate subdomain.
Where to go next
See How to build a REST API in Node in 2026, How to cache with Redis in 2026, and How to set up Supabase in 2026.