Emails

How the IAM service sends transactional emails through Resend, which EJS templates ship out of the box, how to create custom templates, and how disposable-email and MX validation protect against abuse.

The IAM service sends transactional emails through the Resend SDK. Every outbound email passes through a single function, sendSystemEmail, which resolves an EJS template by name, renders it with the provided data, and hands the resulting HTML to Resend for delivery.

Two categories of email ship out of the box: OTP emails (used by adaptive MFA, custom MFA, and email update flows) and notification emails (used by password reset confirmations, email change alerts, and any custom notification you define). Both categories use responsive HTML templates tested across major email clients.

Before any email is sent, the service can optionally validate the recipient address in two layers: a disposable-email check against an LMDB database and a DNS MX lookup to confirm the domain accepts mail.


Delivery pipeline

sendSystemEmail is the single entry point for all outbound email. It accepts a recipient (or array of recipients), a subject line, a data object for template interpolation, and a template path relative to the emails directory.

import { sendSystemEmail } from '@riavzon/auth'

await sendSystemEmail(
  '[email protected]',
  'Welcome to Our Service',
  { name: 'Alice', loginUrl: 'https://example.com/login' },
  'welcome'
)

Internally the function performs three steps:

Template resolution

The function appends .ejs to the template name and searches three directories in order: emails/, dist/emails/, and src/jwtAuth/emails/. The first match wins. This search order lets you place custom templates in the top-level emails/ directory without modifying the source tree.

EJS rendering

The data object is spread into the template as local variables. Templates use standard EJS syntax: <%= variable %> for escaped output and <%- variable %> for raw HTML (used by the notification template's message field).

Resend delivery

The rendered HTML is sent via resend.emails.send() using the API key and sender address from the email configuration block. The function logs the Resend response and throws on render failures, but logs and returns silently on Resend API errors to avoid crashing the caller.


Built-in templates

OTP template

Path: OTP/index.ejs

The OTP template is used by sendTempMfaLink (adaptive MFA) and generateCustomMfaFlow (custom MFA and email update). It renders a responsive email with five sections:

SectionContent
BannerA configurable image at the top of the email (banner_image)
Code displayThe 7-digit OTP code shown in a bordered button that also links to the magic link URL
Metadata rowThree columns showing Device (browser + OS), Location (IP-based), and Date (server timestamp) with configurable icons
Security warningA message warning the user to reset their password if they did not request the code, with a link to the configured reset-password page
CTA buttonA "Verify Here" button linking to the magic link URL

The template uses the Montserrat font family and is built with table-based layout for maximum email client compatibility, including Outlook (MSO conditional comments).

Notification template

Path: nottifications/index.ejs

The notification template is used by resetPasswordEmail (password reset confirmation) and sendEmailNotification (generic notifications such as the email-change alert). It renders a simpler layout:

SectionContent
Header imageA configurable banner (main_image)
Title and actionBold title with a subtitle instruction line
BodyA letter-style block: "Dear {username}", followed by the message field (supports raw HTML via <%-), a security warning, and a signature with the website name
CTA buttonA configurable button label and link
FooterPrivacy policy and contact page links

The template uses the Raleway font family.


Template variables

Each template type expects a specific data shape. The service defines two TypeScript interfaces, OTPEmails and NotificationEmails, unified under the EmailData type.

OTP variables

link
string required
The full magic link URL the button points to.
code
string | number required
The 7-digit OTP code displayed in the email body.
device
string
OS string shown in the Device column (e.g. 'macOS'). Falls back to 'Unknown Device'.
browser
string
Browser name shown in the Device column (e.g. 'Chrome 125'). Falls back to 'Unknown Browser'.
location
string
IP-based location string shown in the Location column (e.g. 'Berlin, DE'). Falls back to 'Unknown Location'.
date
string
Formatted timestamp shown in the Date column. Generated server-side via new Date().toLocaleString().
cta
string
Button label text. Default 'Verify Here' for MFA emails.
banner_image
string
URL to the banner image displayed at the top of the email.
device_image
string
URL to the icon displayed above the Device column.
location_image
string
URL to the icon displayed above the Location column.
date_image
string
URL to the icon displayed above the Date column.
link_to_reset_password
string
URL to the password reset page, shown in the security warning section.

Notification variables

title
string required
The big header text at the top of the email body.
action
string required
Sub-header instruction line below the title (e.g. 'Please reset your password').
subject
string required
The email subject line. Also rendered inside the body as a "Subject:" heading.
username
string required
The recipient's display name, used in the "Dear {username}" greeting.
message
string required
The main body text. Supports raw HTML (rendered with <%-). Use <b>, <br/>, and <a> tags for formatting.
cta
string required
Button label text (e.g. 'Change Password', 'Contact Support').
cta_link
string required
URL the CTA button points to.
websiteName
string required
Your website name, shown in the email signature.
privacy_link
string required
URL to your privacy policy page, linked in the footer.
contact_link
string required
URL to your contact page, linked in the footer.
main_image
string required
URL to the header banner image.

Helper functions

Three helper functions wrap sendSystemEmail with pre-configured templates and data shapes. Each one reads configuration values from magic_links.emailImages and magic_links.notificationEmail, so the caller only needs to provide the dynamic fields.

mfaEmail

Sends an OTP email using the OTP/index template. Called internally by sendTempMfaLink and generateCustomMfaFlow. This is an internal helper and is not exported from @riavzon/auth. Use sendSystemEmail for custom email delivery.

// internal — called by sendTempMfaLink and generateCustomMfaFlow
await mfaEmail(
  1234567,                               // 7-digit OTP code
  '[email protected]',                    // recipient
  'https://example.com/auth/bounce?...', // full magic link URL
  {
    device: 'Chrome on macOS',
    browser: 'Chrome 125',
    location: 'Berlin, DE',
  }
)

The function reads emailImages.otp for the four image URLs and linkToResetPasswordPage for the security warning link. The date field is generated automatically from new Date().toLocaleString(). The email subject is formatted as Security Code - {code}.

resetPasswordEmail

Sends a notification email using the nottifications/index template. Called internally by sendTempPasswordResetLink when a user requests a password reset. This is an internal helper and is not exported from @riavzon/auth.

// internal — called by sendTempPasswordResetLink
await resetPasswordEmail(
  'Alice',                               // user's display name
  '[email protected]',                   // recipient
  'https://example.com/auth/bounce?...'  // password reset URL
)

The function reads emailImages.notificationBanner for the header image and notificationEmail.* for the website name, privacy link, contact link, and change-password page link. The CTA button label is 'Change Password' and the subject is 'Password Reset Request'.

sendEmailNotification

Sends a customizable notification email. Called by the email update controller to alert the user that their email address has changed. This is an internal helper and is not exported from @riavzon/auth. Use sendSystemEmail for custom notifications.

// internal — called by updateEmailController and verifyPasswordReset
await sendEmailNotification(
  '[email protected]',
  'Alice',
  {
    title: 'Your Email Has Changed',
    action: 'Notice of Change',
    subject: 'Security Alert: Email Address Updated',
    message:
      'Your email has been updated to <b>[email protected]</b>.<br/>If you did not authorize this, contact support.',
    cta: 'Contact Support',
    cta_link: 'https://example.com/contact',
  }
)

The function merges the provided fields with defaults pulled from notificationEmail and emailImages.notificationBanner. Any field you omit falls back to the password-reset defaults (title, action, subject, message, CTA).


Custom templates

The template manager lets you register, list, and remove custom EJS templates at runtime. Custom templates are written as .ejs files and stored alongside the built-in templates.

Creating a template

import { makeEmailTemplate } from '@riavzon/auth'

await makeEmailTemplate(
  '<h1>Welcome, <%= name %>!</h1><p><a href="<%= loginUrl %>">Sign in</a></p>',
  'welcome'
)

The function writes a welcome.ejs file to the emails directory. If a template with the same name already exists, it throws an error to prevent accidental overwrites.

Listing templates

import { listTemplates } from '@riavzon/auth'

const templates = await listTemplates()
// ['OTP/index.ejs', 'nottifications/index.ejs', 'welcome.ejs']

Returns all .ejs files found recursively in the emails directory, including subdirectories.

Deleting a template

import { deleteTemplate } from '@riavzon/auth'

await deleteTemplate('welcome')

Removes the welcome.ejs file from the emails directory. Throws if the file does not exist.

Using a custom template

Pass the template name (without .ejs) to sendSystemEmail. For templates inside subdirectories, include the path relative to the emails root:

await sendSystemEmail(
  '[email protected]',
  'Welcome!',
  { name: 'Alice', loginUrl: 'https://example.com/login' },
  'welcome'              // resolves to welcome.ejs
)

await sendSystemEmail(
  '[email protected]',
  'Your Invoice',
  invoiceData,
  'billing/invoice'      // resolves to billing/invoice.ejs
)
The template resolver searches emails/, dist/emails/, and src/jwtAuth/emails/ in order. Place custom templates in emails/ at the project root so they are found first and survive rebuilds.

Disposable email detection

isDisposable checks whether an email address belongs to a known disposable-email provider. The function extracts the domain, lowercases it, and looks it up in a pre-loaded LMDB map. If the domain exists in the map, the address is considered disposable.

import { isDisposable } from '@riavzon/auth'

const blocked = await isDisposable('[email protected]', log)

if (blocked) {
  res.status(400).json({ error: 'Disposable email addresses are not accepted.' })
  return
}

The disposable-email list is loaded once at startup from a local database file managed by the Shield Base CLI. The list is compiled from multiple open-source sources and updated by running the CLI with the --email flag.

The LMDB database must be present on disk before the IAM service starts. If the file is missing, isDisposable returns false for all lookups (fails open).

The signup controller calls isDisposable automatically before creating a new user account. You can also call it from any custom route that accepts an email address.


MX record validation

isValidDomain verifies that the email domain has valid mail exchange records by performing a DNS MX lookup. Results are cached in an LRU cache with a 24-hour TTL to avoid repeated DNS queries for the same domain.

import { isValidDomain } from '@riavzon/auth'

const hasMx = await isValidDomain('[email protected]', log)

if (!hasMx) {
  res.status(400).json({ error: 'Email domain does not accept mail.' })
  return
}

The function handles internationalized domain names by converting them to ASCII with domainToASCII before the lookup. If a transient DNS failure occurs (anything other than ENODATA or ENOTFOUND), the domain is allowed through to avoid blocking legitimate users during DNS outages.

Use isValidDomain alongside isDisposable for a two-layer email validation pipeline. The signup controller runs both checks in sequence before creating any user record.

Configuration reference

Resend credentials

All outbound email uses the credentials in the email configuration block.

email.resend_key
string required
Your Resend API key. Used to authenticate all outbound email requests.
email.email
string required
The sender address for all outbound emails (e.g. '[email protected]'). Must be a verified sender in your Resend account.

Email images

Image URLs used by the built-in templates. Configured under magic_links.emailImages.

emailImages.otp.bannerImage
string
URL to the banner image displayed at the top of OTP emails.
emailImages.otp.device_image
string
URL to the icon displayed above the Device metadata column in OTP emails.
emailImages.otp.location_image
string
URL to the icon displayed above the Location metadata column in OTP emails.
emailImages.otp.date_image
string
URL to the icon displayed above the Date metadata column in OTP emails.
emailImages.notificationBanner
string
URL to the header banner image used by notification emails (password reset, email change alerts).

Notification email

Text values used by notification templates. Configured under magic_links.notificationEmail.

notificationEmail.websiteName
string
Your website name, shown in the email signature.
notificationEmail.privacyPolicyLink
string
URL to your privacy policy page, linked in the email footer.
notificationEmail.contactPageLink
string
URL to your contact page, linked in the email footer.
notificationEmail.changePasswordPageLink
string
URL to your change-password page, used as the CTA link in password reset notifications.
notificationEmail.loginPageLink
string
URL to your login page, linked in notification emails.
Logo