Emails
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:
| Section | Content |
|---|---|
| Banner | A configurable image at the top of the email (banner_image) |
| Code display | The 7-digit OTP code shown in a bordered button that also links to the magic link URL |
| Metadata row | Three columns showing Device (browser + OS), Location (IP-based), and Date (server timestamp) with configurable icons |
| Security warning | A 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 button | A "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:
| Section | Content |
|---|---|
| Header image | A configurable banner (main_image) |
| Title and action | Bold title with a subtitle instruction line |
| Body | A 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 button | A configurable button label and link |
| Footer | Privacy 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
'macOS'). Falls back to 'Unknown Device'.'Chrome 125'). Falls back to 'Unknown Browser'.'Berlin, DE'). Falls back to 'Unknown Location'.new Date().toLocaleString().'Verify Here' for MFA emails.Notification variables
'Please reset your password').<%-). Use <b>, <br/>, and <a> tags for formatting.'Change Password', 'Contact Support').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
)
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.
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.
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 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.
Notification email
Text values used by notification templates. Configured under magic_links.notificationEmail.