Chapter 2
The practical core: how a message travels from your application to the inbox, how to choose and compare email service providers, and how to send your first one. Includes runnable code for sending, personalization, templates, and localization.

Last updated: June 2026
This chapter is the practical core: how a message actually travels from your application to someone's inbox, how to pick the service that carries it, and how to send your first one. By the end you'll have working code, a framework for choosing a provider, and a plan for templates, personalization, and localization.
When your app sends a transactional email, it hands the message to a mail server, which relays it across the internet to the recipient's mailbox provider, which decides whether to place it in the inbox. You can talk to that mail server in one of two ways: the SMTP protocol or an HTTP API.
SMTP (Simple Mail Transfer Protocol) is the standard language of email. Your code opens a connection to a mail server, authenticates, and streams the message. SMTP works everywhere and is well understood, but it's chatty, slower to send at volume, and gives you limited feedback beyond "accepted" or "rejected."
HTTP APIs are what most modern providers offer on top of SMTP. You make a single HTTPS request with a JSON body, and the provider handles the SMTP relay for you. APIs are faster, easier to debug, and return rich responses (message IDs, validation errors, structured status), which is why most new integrations use them.
Behind either interface sits the hard part: actually getting delivered. The recipient's mailbox provider evaluates your domain's reputation, your authentication records, and how recipients have engaged with your past email, then routes the message to the inbox, the spam folder, or nowhere at all. This is why a dedicated email service provider matters. Running the mail server is the easy part. Maintaining the sending reputation and relationships with mailbox providers that get you to the inbox is the work.
Every transactional email starts with an event: a password reset, a cleared payment, a shipped order. The path that event takes to your email system decides two things that matter, how fast the message goes out and how reliably it arrives. Pick the path based on how much each one matters for that message.
The thing to get right is what sits on the critical path. CDPs and event streams are usually quick, but their latency isn't guaranteed and they add a hop between the user and their message, so they're a poor place for something like a login code, where an unpredictable tail and an extra dependency are exactly what you don't want. Keep the messages a user is waiting on close to your app, and let lifecycle messaging flow through the event pipeline where a little variability is fine.
An email service provider (ESP) is a service that sends your email for you and works to keep it landing in the inbox. The right one depends on five things that actually move the needle, not on feature-list length.
A few rules of thumb by volume. Under roughly 50K emails a month, most providers' free tiers are plenty and the decision comes down to developer experience. Between 100K and 1M a month, unit economics start to matter, so compare per-message cost carefully. Above that, dedicated IPs and direct deliverability support become worth paying for. And if you have EU data-residency requirements, confirm the provider offers EU infrastructure specifically, because not all of them do.
A useful mental filter: deliverability and visibility are the things you can't easily build yourself, so weight them heavily. Templating and nice dashboards are conveniences you can work around.
There's no single best provider, only the best fit for your volume, stack, and budget. Here's how the most common transactional email services compare on the criteria that matter.
| Provider | Interface | Templating | Entry pricing (approx.) | Best for |
|---|---|---|---|---|
| Resend | API and SMTP | React Email components | Free 3K/mo; ~$20/mo for 50K | Modern stacks and developer experience |
| SendGrid | API and SMTP | Dynamic templates | Free trial; from ~$20/mo for 50K | Full-featured general-purpose sending |
| Amazon SES | API and SMTP | Minimal (bring your own) | ~$0.10 per 1,000 emails | High volume, AWS-native teams |
| Postmark | API and SMTP | Template editor | Free 100/mo; from ~$15/mo for 10K | Speed-critical transactional email |
| Mailgun | API and SMTP | Handlebars templates | Free trial; ~$35/mo for 10K | Technical teams wanting granular control |
| Mailjet | API and SMTP | Visual builder | Free 200/day; from ~$17/mo for 15K | Mixed technical and non-technical teams |
| MailerSend | API and SMTP | Visual builder | Free 3K/mo; from ~$25/mo for 50K | Modern SaaS wanting approachable tooling |
| Mailchimp Transactional | API and SMTP | Templates | Block pricing; ~$80 for 100K | Existing Mailchimp customers |
Pricing and tiers change constantly, so treat these figures as rough orientation and confirm current rates with each provider before you commit.
A few patterns stand out. Amazon SES is the cheapest at volume but the most barebones: you bring your own templating, suppression handling, and analytics tooling. Postmark optimizes hard for transactional speed and keeps marketing email on a separate stream by design. Resend is the newest and leans into developer experience and React-based templates. SendGrid is the full-featured general-purpose default, and Mailgun gives technical teams granular control with both US and EU regions. For EU data residency specifically, Mailgun and Mailjet offer dedicated EU infrastructure. If cost at scale dominates, SES wins; if developer experience and time-to-first-send dominate, Resend or Postmark tend to win; if you want one well-trodden option, SendGrid is the safe default.
The fastest path to a sent email is a provider's HTTP API and an SDK. Here's a complete, runnable example using Node.js and SendGrid to send an order confirmation.
First, install the SDK and set your API key as an environment variable:
Copied!
npm install @sendgrid/mailexport SENDGRID_API_KEY="your_api_key_here"
Then send the email:
Copied!
const sgMail = require("@sendgrid/mail");sgMail.setApiKey(process.env.SENDGRID_API_KEY);async function sendOrderConfirmation(order) {const message = {to: order.customerEmail,from: "orders@yourstore.com", // must be a verified sendersubject: `Order ${order.id} confirmed`,text: `Thanks for your order. We're preparing it now. Total: $${order.total}.`,html: `<p>Thanks for your order. We're preparing it now.</p><p><strong>Order ${order.id}</strong> · Total: $${order.total}</p>`,};try {await sgMail.send(message);console.log(`Confirmation sent for order ${order.id}`);} catch (error) {console.error("Send failed:", error.response?.body || error.message);throw error;}}
Three details make this production-shaped rather than a toy. The from address has to be a verified sender on a domain you control, or mailbox providers will reject it. You always send both a text and an html version, because some clients and filters prefer plain text. And you handle the error path, because sends fail and your caller needs to know so it can retry. We'll come back to retries and idempotency in chapter 4.
A template is a reusable email design with placeholders for the data that changes per send. Hardcoding HTML strings in your application code works for one email and becomes unmaintainable by the tenth, so you'll want to move to templates early.
You have three broad options. Code-based templates keep the markup in your repository using a templating language like Handlebars or a library like React Email; they version with your code and suit engineering-owned email. Provider-hosted templates live in your ESP's dashboard, so non-engineers can edit copy without a deploy, at the cost of splitting your email across two systems. Dedicated template or notification platforms centralize templates across providers and channels, which helps once you outgrow a single ESP.
Whatever you pick, a few practices hold. Separate content from logic so copy changes don't require code changes. Build HTML email defensively, because email clients render with inconsistent, dated engines: use tables for layout, inline your CSS, and test across clients with a tool like Litmus or Email on Acid. Always include a plain-text version. And keep templates under version control or a clear change history, so a broken receipt template is traceable to the change that broke it.
As your template library grows, you'll notice the same header, footer, button styles, and legal text repeated across every email. Copying that markup into each template means a footer change becomes a find-and-replace across dozens of files, and the one you miss ships broken. Partials solve this: a partial is a reusable fragment, like a header, a footer, or a button, that you define once and include in many templates.
It's the same idea as components in application code. Keep each shared element in one place, reference it everywhere, and a single edit propagates to every template that uses it. Most templating systems support this directly (Handlebars partials, React Email components), and notification platforms usually have an equivalent shared-block concept. Partials are what keep a library of ten or fifty templates maintainable instead of turning every brand tweak into a migration.
Personalization is the practice of inserting per-recipient data into a template at send time: a name, an order total, a list of items, a confirmation link. Done well, it's what makes a transactional email useful rather than generic.
Most personalization is straightforward variable substitution, where you pass a data object and the template renders the values:
Copied!
const sgMail = require("@sendgrid/mail");sgMail.setApiKey(process.env.SENDGRID_API_KEY);async function sendShippingUpdate(user, shipment) {await sgMail.send({to: user.email,from: "shipping@yourstore.com",templateId: "d-1234567890abcdef", // a dynamic template stored in SendGriddynamicTemplateData: {firstName: user.firstName,trackingNumber: shipment.trackingNumber,estimatedDelivery: shipment.eta,items: shipment.items, // an array the template can loop over},});}
Two rules keep personalization from backfiring. Escape and validate any user-provided data you render, because an unescaped value is an injection risk and a rendering bug waiting to happen. And always define fallbacks for missing fields, so a null firstName renders "Hi there" instead of "Hi ,". Conditionals (show a block only when data exists) and loops (render a row per order item) cover the rest of what most transactional email needs.
Internationalization (often shortened to i18n) is designing your email system so it can send the right language and regional formatting to each recipient. Localization is the act of producing those translated, region-aware versions. If you have users outside one country, you'll need both sooner than you think.
There's more to it than translating strings. A localized transactional email has to get several things right:
$1,000.00 versus 1.000,00 €).The naive approach, maintaining a separate template per language, multiplies your template count and gets unmanageable fast: ten emails in eight locales is eighty templates to keep in sync. The better pattern is one template with localized content resolved at send time, so a change to the layout applies everywhere at once. This is one area where notification infrastructure helps: Courier, for example, stores per-locale translations within a single template and resolves the recipient's locale at send time, so you maintain one template instead of one per language.
Whichever approach you choose, store each user's locale as a first-class field on their profile and pass it with every send. The piece that's easiest to get wrong is resolution: what to do when the stored locale is missing or isn't one you support. Always fall back deliberately rather than letting a template render half-translated.
Copied!
function resolveLocale(user, supportedLocales, defaultLocale = "en-US") {const preferred = user.locale; // e.g. "fr-CA", or undefined// 1. Exact match on the stored preferenceif (preferred && supportedLocales.includes(preferred)) {return preferred;}// 2. Fall back to the language without the region ("fr-CA" -> "fr-FR")const language = preferred?.split("-")[0];const languageMatch = supportedLocales.find((locale) => locale.startsWith(`${language}-`));if (language && languageMatch) {return languageMatch;}// 3. Fall back to a safe default rather than rendering untranslatedreturn defaultLocale;}
That three-step fallback (exact locale, then language, then a safe default) keeps a user with an unusual or missing locale from receiving a broken email, which is the failure mode that turns internationalization from a feature into a bug report.
Email is unusually unforgiving to ship blind: once it's sent you can't edit it, and it renders across dozens of clients with inconsistent, dated engines. Build a testing step into your workflow before anything reaches a real user.
A few practices cover most of the risk:
Treat a template change like a code change: review it, test it, and keep a way to trace a broken receipt back to the edit that broke it.
Use an HTTP API when you can. APIs are faster, return richer responses like message IDs and structured errors, and are easier to debug than SMTP. SMTP is the universal fallback that works when a system can only speak SMTP, but for new integrations an API is the better default.
There's no single best provider; it depends on volume, stack, and budget. Amazon SES is cheapest at scale, Postmark optimizes for transactional speed and deliverability, Resend leads on developer experience, and SendGrid is a full-featured general-purpose default. Match the provider to your sending pattern rather than its feature-list length.
Fewer than you'd expect if you use partials and localization. Define shared elements like the header, footer, and buttons once as partials, keep one template per message type, and resolve language at send time instead of duplicating templates per locale. That keeps even a large library maintainable.
Previous chapter
What Transactional Email Is
Transactional email has its own rules, infrastructure expectations, and failure modes. Learn what it is, how it differs from marketing email, the common types you'll send, and why something that looks simple gets complicated fast.
Next chapter
How to Land in the Inbox, Not Spam
Getting accepted by a mail server is easy; reaching the inbox is the real challenge. Covers SPF, DKIM, and DMARC authentication, what drives deliverability, how to avoid the spam folder, and how to handle bounces, complaints, and unsubscribes.
© 2026 Courier. All rights reserved.