BetterIAM

Email and SMS

How Better IAM queues email and SMS in a transactional outbox, hands each message to your sendEmail or sendSms callback by template name, and which templates exist.

Better IAM never talks to a mail or SMS provider itself. Every message it needs to send (a verification link, a password reset, an invitation, a security notice, a reminder) is written to the with a template name and a payload, in the same transaction as the change that caused it. A worker then hands each message to a callback you supply, authentication.sendEmail or authentication.sendSms, which renders it and sends it through your own provider, sender domain, and branding.

Use this page to wire up delivery, render the built-in templates, and look up every template name your callback can receive.

  • Transactional. A rolled-back change sends nothing, and a committed change is never lost.
  • Sealed at rest. The payload (which can hold a single-use token) is encrypted with the deployment secret while it waits, and erased once the message is delivered or abandoned.
  • Outside the request. Your callback runs from the worker, after commit, so a slow provider never holds a write lock or delays a sign-in.
  • At least once. A message can reach your callback more than once; deduplicate by message.id.

Setup

Add the delivery callbacks

authentication.sendEmail and authentication.sendSms receive one DeliveryMessage each. Resolve when the provider accepted the message; throw to have the outbox retry it later.

iam.ts
import { betterIam } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';
import { renderDeliveryMessage } from 'better-iam/auth/templates';
import { mailer, sms } from './providers';

const origin = 'https://app.example.com';

export const iam = betterIam({
  database: sqliteAdapter({ filename: './iam.db' }),
  secret: process.env.BETTER_IAM_SECRET!,
  baseURL: origin,
  authentication: {
    async sendEmail(message) {
      const rendered = renderDeliveryMessage(message, {
        appName: 'Acme Cloud',
        links: {
          invitation: ({ kind, tenantId, token }) => `${origin}/join?kind=${kind}&tenant=${tenantId}&token=${token}`,
          passwordReset: ({ tenantId, token }) => `${origin}/reset?tenant=${tenantId}&token=${token}`,
          verifyEmail: ({ tenantId, token }) => `${origin}/verify?tenant=${tenantId}&token=${token}`,
          emailChange: ({ tenantId, token }) => `${origin}/email?tenant=${tenantId}&token=${token}`,
          magicLink: ({ tenantId, token, destination }) =>
            `${origin}/magic?tenant=${tenantId}&token=${token}&to=${encodeURIComponent(destination)}`,
          account: ({ tenantId }) => `${origin}/${tenantId}/account`,
        },
      });
      // Templates the renderer does not know (plugin templates, phone-verify, ...) return undefined.
      if (!rendered) throw new Error(`No renderer for ${message.template}`); // retried later
      await mailer.send({ to: message.to, idempotencyKey: message.id, ...rendered });
    },
    async sendSms(message) {
      // SMS templates are `code` (passwordless sign-in) and `phone-verify`; both carry a six-digit `token`.
      await sms.send({ to: message.to, body: `Your Acme code is ${message.payload.token}` });
    },
  },
});

Features that send email fail or switch off without sendEmail; see Errors.

Schedule the outbox worker

Nothing leaves the outbox until something dispatches it. Run iam.auth.dispatchOutbox() from your worker every minute (or more often, if people wait for sign-in codes), or the outbox CLI command from cron.

worker.ts
import { iam } from './iam';

setInterval(async () => {
  const { delivered, failed, abandoned } = await iam.auth.dispatchOutbox();
  if (abandoned > 0) console.warn(`outbox: ${abandoned} message(s) abandoned`);
  await iam.events.dispatch(); // audit hooks and subscribers share the schedule
}, 60_000);
* * * * *  better-iam outbox --config better-iam.config.mjs

The CLI command needs a configuration module whose authentication.sendEmail and sendSms are the same callbacks your server uses. See background jobs.

Token-carrying templates link to pages in your application. Each page reads the token from the URL and calls the matching public method:

Link builderPage calls
verifyEmailauth.verifyEmail({ tenantId, token })
passwordResetauth.resetPassword({ tenantId, token, password })
emailChangeauth.confirmEmailChange({ tenantId, token })
magicLinkauth.finishPasswordless({ tenantId, destination, token })
invitation (kind: 'owner')tenants.acceptInvitation({ tenantId, token, name, password })
invitation (kind: 'member')identities.acceptInvitation({ tenantId, token, password })
guestInvitationguests.redeem({ tenantId, token, name, password })

Usage

The delivery message

Your callback receives one DeliveryMessage:

Prop

Type

Never log payload: several templates carry single-use tokens or codes that sign a person in.

Render the built-in templates

renderDeliveryMessage(message, { appName, links }) from better-iam/auth/templates turns a message into { subject, text, html } with HTML escaping, header-safe subjects, and a plain layout. The subpath has no native dependencies, so it also runs in email workers and edge runtimes. It is also exported from better-iam/auth and @better-iam/auth (not from the better-iam root).

render.ts
import { renderDeliveryMessage, type TemplateLinks } from 'better-iam/auth/templates';

const links: TemplateLinks = {
  account: ({ tenantId, signInUrl }) => `${signInUrl ?? 'https://app.example.com'}/${tenantId}/account`,
};

const rendered = renderDeliveryMessage(
  { template: 'new-sign-in', to: 'alice@example.com', tenantId: 'ten_1', payload: { time: new Date().toISOString(), method: 'password' } },
  { appName: 'Acme Cloud', links },
);
// rendered?.subject, rendered?.text, rendered?.html
  • appName (default 'Better IAM') names your product in subjects and the footer. It is a render option, not authentication.appName.
  • A missing link builder falls back to showing the raw token or code, or to the account link for notices.
  • Templates the renderer does not know return undefined; render those yourself (see the table below).
  • describeUserAgent(userAgent) turns a User-Agent into a short phrase such as "Chrome on Windows". The built-in security notices use it so a crafted header never reaches a message verbatim; do the same in your own templates.

The links builders are:

BuilderReceivesUsed by
invitation{ kind: 'owner' | 'member', tenantId, token, signInUrl? }owner-invitation, member-invitation
passwordReset{ tenantId, token, signInUrl? }password-reset
verifyEmail{ tenantId, token, signInUrl? }verify-email
emailChange{ tenantId, token, signInUrl? }email-change
magicLink{ tenantId, token, destination, signInUrl? }magic-link
account{ tenantId, signInUrl? }Security notices, and the fallback of the feature builders in this table
certification{ tenantId, campaignId, signInUrl? }certification-review, certification-reminder
delegation{ tenantId, delegationId, signInUrl? }delegation-request, delegation-confirmation
team{ tenantId, teamId, signInUrl? }team-join-request, team-join-decided, team-review-requested
billing{ tenantId, statementId?, signInUrl? }Billing templates
privacy{ tenantId, requestId?, token?, handler?, signInUrl? }Privacy templates
threats{ tenantId, incidentId?, signInUrl? }threat-alert
guestInvitation{ tenantId, token, signInUrl? }guest-invitation
guests{ tenantId, guestId?, signInUrl? }guest-review, guest-sponsor-missing
clearances{ tenantId, identityId?, signInUrl? }clearance-reminder, clearance-status

Template reference

Every template name the server can queue. Rendered says whether renderDeliveryMessage knows it; for the others, build the message yourself from the payload.

Sign-in and account (from @better-iam/auth):

TemplateChannelSent whenPayloadRendered
verify-emailemailauth.signUp (when verification is required) and auth.requestEmailVerificationtokenYes
password-resetemailauth.requestPasswordReset and identities.requestPasswordResettokenYes
email-changeemail (to the new address)auth.requestEmailChangetokenYes
magic-linkemailauth.startPasswordless with kind: 'magic-link'tokenYes
codeemail or SMSauth.startPasswordless with kind: 'code'token (six digits)Yes
phone-verifySMSauth.startPhoneVerificationtoken (six digits)No
mfa-codeemailauth.requestMfaCode (emailed MFA codes)codeYes
new-sign-inemailA session starts from an unfamiliar client (signInNotifications)sessionId, time, method, optional userAgent, ip, labelYes
sign-in-failuresemailFailed attempts since the last sign-in reached failedSignInAlertsattempts, time, optional ip, userAgentYes

Organizations and members:

TemplateChannelSent whenPayloadRendered
owner-invitationemailtenants.create, tenants.resendInvitationtoken, tenantId, tenantNameYes
member-invitationemailidentities.invite, identities.resendInvitationtoken, tenantId, tenantName, inviterNameYes
access-digestemailiam.sendAccessDigest() (CLI digest), to each organization ownertenantName, generatedAt, withinMs, unusedForMs, finding counts, report (JSON)No
expiry-reminderemailiam.sendExpiryReminders() (CLI remind), to people whose access ends soontenantId, tenantName, identityName, count, earliest, items (JSON)No

Access requests and governance (sent only when sendEmail is configured):

TemplateSent whenRendered
activation-requestAn eligible-binding activation needs approval; sent to each approverNo
activation-decidedAn activation request was approved or denied; sent to the requesterNo
package-requestA member requested an access package; sent to each approverNo
package-decidedA package request was approved or denied; sent to the requesterNo
certification-reviewcertifications.create started a campaign; sent to reviewersYes
certification-remindercertifications.remind; sent to reviewers with pending itemsYes
delegation-requestAn AI agent asked a person to delegate accessYes
delegation-confirmationAn agent acting for a person asked them to confirm an actionYes
team-join-requestteams.requestToJoin; sent to the team's maintainersYes
team-join-decidedA join request was decided; sent to the requesterYes
team-review-requestedteams.startReview opened a membership reviewYes
workflow-messageA joiner, mover, or leaver workflow step sends an emailYes

Feature notices (each feature's own settings decide whether it emails):

TemplateFeatureRendered
spend-alert, spend-anomaly, billing-statement, payment-reminderBillingYes
privacy-request-verify, privacy-request-received, privacy-request-update, privacy-request-duePrivacy requestsYes
threat-alertThreat detectionYes
license-waiting, license-activatedLicensesYes
guest-invitation, guest-review, guest-sponsor-missingGuestsYes
clearance-reminder, clearance-statusSecurity clearancesYes

Plugins can queue their own template names (see Queue messages from a plugin). Your callback should throw, not silently drop, when it meets a template it cannot render, so the message stays in the outbox and doctor reports it.

Dispatch the outbox

iam.auth.dispatchOutbox(limit?) delivers up to limit due messages (default 100, 1 to 1000), oldest first, and returns { delivered, failed, abandoned }.

  • Each message is claimed in its own short transaction with a 60-second lease, so several workers can run at once without sending the same message twice in parallel.
  • A failed attempt waits 30 s × 2^(attempts − 1), capped at one hour, before the next one.
  • After authentication.maxDeliveryAttempts failed attempts (default 25, about 18 hours of retries) the message is abandoned with failedAt and lastError (the error name and message, at most 256 characters).
  • Webhook deliveries travel through the same outbox; see webhooks.

Queue messages from a plugin

A plugin endpoint handler receives deliver, which queues an email or SMS in the endpoint's transaction through the same outbox and callbacks, and returns the message ID.

plugins/reports.ts
import { IamError, type IamPlugin } from 'better-iam/core';

export const reports: IamPlugin = {
  id: 'reports',
  actions: ['reports:share'],
  endpoints: [
    {
      method: 'POST',
      path: 'share',
      action: 'reports:share',
      validate(value) {
        const input = value as { tenantId?: unknown; email?: unknown; reportId?: unknown } | null;
        if (!input || typeof input.email !== 'string' || typeof input.reportId !== 'string')
          throw new IamError('INVALID_INPUT', 'email and reportId required');
        return { tenantId: input.tenantId, email: input.email, reportId: input.reportId };
      },
      async handler({ deliver }, input) {
        const messageId = await deliver({
          kind: 'email',
          to: String(input.email),
          template: 'report-shared', // your own template name; render it in sendEmail
          payload: { reportId: String(input.reportId) },
        });
        return { messageId };
      },
    },
  ],
};

deliver fails with FEATURE_DISABLED when the deployment has no callback for that kind, which rolls back the endpoint's transaction. See extensions.

Options

All delivery options live under authentication in the server options.

OptionTypeDefaultDescription
sendEmail(message: DeliveryMessage) => Promise<void>noneDelivers email. Without it, email sign-in features, invitations, and every email notice are unavailable.
sendSms(message: DeliveryMessage) => Promise<void>noneDelivers SMS: passwordless SMS codes and phone verification.
maxDeliveryAttemptsnumber25Failed attempts (1 to 1000) before a message is abandoned.
requireEmailVerificationbooleanfollows signUpEnabledRefuse sign-in until the address is verified. Requires sendEmail.
passwordlessEmailbooleanfalseEnables magic links and email codes. Requires sendEmail.
passwordlessSmsbooleanfalseEnables SMS codes. Requires sendSms.
mfaEmailCodesbooleanfalseLets people without an authenticator satisfy MFA with an emailed mfa-code. Tenants override it.
signInNotificationsbooleanfalseQueue new-sign-in when a session starts from an unfamiliar client. Tenants override it with notifyNewSignIn.
failedSignInAlertsnumber0 (off)Queue sign-in-failures once this many attempts have failed since the last sign-in (at most 1000). Requires sendEmail.

Construction fails with INVALID_CONFIG when requireEmailVerification, passwordlessEmail, or failedSignInAlerts is set without sendEmail, or passwordlessSms without sendSms. Related settings: observability.metrics.gauges adds the better_iam_outbox_messages{state} gauge, and iam.sweepExpired() deletes delivered and abandoned messages after deliveryRetentionMs (30 days by default).

Schema

Messages are stored in the outbox collection (type OutboxMessage from better-iam/core):

FieldTypeDescription
idstringMessage ID; also DeliveryMessage.id.
tenantIdstringOwning tenant.
kind'email' | 'sms' | 'webhook'Which callback delivers it. For webhooks, to is the subscription ID.
tostringDestination address, phone number, or webhook ID.
templatestringTemplate name (for webhooks, the event action).
payload{ sealed?: string }The sealed payload; emptied after delivery or abandonment.
createdAtnumberQueue time, epoch milliseconds. Dispatch order.
attemptsnumberDelivery attempts so far.
deliveredAtnumberSet when the callback resolved.
failedAtnumberSet when the message was abandoned.
lastErrorstringThe last failure, at most 256 characters.
lease, leaseUntilstring, numberThe claiming worker's lease ID, and until when it holds the message (or when the next retry is due).
referencestringWhat the message carries, such as the audit event ID behind a webhook delivery.

Errors

CodeWhen
DELIVERY_REQUIREDInvitations (tenants.create, identities.invite, their resend calls, and guest invitations), certifications.remind, the access digest, expiry reminders, guest review and clearance reminders, and turning on public privacy intake refuse to run without sendEmail.
FEATURE_DISABLEDA flow or plugin queued a message of a kind with no callback, such as auth.requestEmailVerification without sendEmail or startPhoneVerification without sendSms.
INVALID_CONFIGA delivery-dependent option is on without its callback, or maxDeliveryAttempts is out of range.
INVALID_INPUTdispatchOutbox was called with a limit outside 1 to 1000.
RATE_LIMITEDA flow that sends a message was called too often for one address; see rate limits.

doctor reports outbox-stalled when messages have waited more than 15 minutes and outbox-abandoned for messages abandoned in the last day.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page