Dyrected
Deployment & OperationsInfrastructure

Overview

Configure transactional email, customize the built-in auth emails, and understand when Dyrected sends them.

Use this page when you need Dyrected to send real email for authentication flows such as invites and password resets. By the end, you should know what the top-level email config does, which built-in emails Dyrected sends for you, how development fallback behaves, and where to customize the message content.

The mental model

Dyrected does not try to be your email provider. It does two narrower jobs:

  • it decides when an auth flow should send an email
  • it renders the built-in HTML for those auth emails unless you override it

You decide how the message is actually delivered by wiring your provider into the top-level email.send function.

That split is the main thing to keep in mind:

  • email.send is the transport boundary
  • email.templates is the content override boundary
  • your auth flows trigger the built-in messages

When to configure email

Configure email when your project uses any flow that sends mail to the user:

  • password reset
  • invitations
  • first-account or invited-account welcome emails
  • password-changed notifications

If you are not using those flows yet, you can leave email unset while you are still building locally. In development, Dyrected falls back to an Ethereal preview inbox when nodemailer is available, so you can still test the flow without a real provider.

For production, treat email as required anywhere those flows are part of the real user journey. Password reset, invites, and account-notification flows should use a real provider before you ship.

The recommended path is to wire one provider first, keep the built-in templates, and only customize the template content once the flow itself is working.

Here is the smallest complete setup with Resend:

import { defineConfig } from '@dyrected/core'
import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

export default defineConfig({
  email: {
    from: '[email protected]',
    send: async (msg) => {
      if (msg.type === 'template') {
        // If using provider-hosted templates:
        return
      }
      await resend.emails.send({
        from: '[email protected]',
        to: msg.to,
        subject: msg.subject,
        html: msg.html,
      })
    },
  },
  collections: [],
  globals: [],
})

Once this is in place, Dyrected hands your send function an OutboundEmail object containing the recipient, subject, and either rendered HTML or external template metadata.

What the top-level email config does

The top-level email config has four important pieces:

  • from: the sender address your provider should use
  • send: the function Dyrected calls with an OutboundEmail ({ type: 'html', to, subject, html } or { type: 'template', to, templateId, variables, subject? })
  • templates: optional global overrides for the built-in auth emails
  • adminEditable: when true, enables the built-in __email_templates collection so administrators can edit templates directly in the Admin UI

Dyrected does not scaffold provider-specific email environment variables for you. Add the provider secrets your transport needs yourself, and keep them in your normal environment management flow.

For the exact type signature, the root configuration reference still lives on Configuration Overview.

Delivery transport: HTML and external templates

Dyrected supports two delivery types through a polymorphic OutboundEmail contract:

  1. Direct HTML (msg.type === 'html' or omitted): Dyrected renders the email body (either the built-in template or your custom HTML) and gives your transport { to, subject, html }.
  2. External template (msg.type === 'template'): When you use a provider like Postmark, SendGrid, or Seamailer that hosts templates externally, Dyrected sends { to, templateId, variables, subject? } directly to your send function.

This means you never have to serialize JSON payloads into fake HTML strings or parse HTML back into template arguments.

Provider recipe: Resend (Direct HTML)

import { defineConfig } from '@dyrected/core'
import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

export default defineConfig({
  email: {
    from: '[email protected]',
    send: async (msg) => {
      if (msg.type === 'template') {
        throw new Error(`Resend template delivery not configured for ID: ${msg.templateId}`)
      }
      await resend.emails.send({
        from: '[email protected]',
        to: msg.to,
        subject: msg.subject,
        html: msg.html,
      })
    },
  },
  collections: [],
  globals: [],
})

Provider recipe: Postmark (Polymorphic templates + HTML)

Postmark supports both template-based delivery (by numeric ID or string alias) and standard HTML:

import { defineConfig } from '@dyrected/core'
import * as postmark from 'postmark'

const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN!)

export default defineConfig({
  email: {
    from: '[email protected]',
    send: async (msg) => {
      if (msg.type === 'template') {
        await client.sendEmailWithTemplate({
          From: '[email protected]',
          To: msg.to,
          TemplateId: typeof msg.templateId === 'number' ? msg.templateId : undefined,
          TemplateAlias: typeof msg.templateId === 'string' ? msg.templateId : undefined,
          TemplateModel: msg.variables,
        })
      } else {
        await client.sendEmail({
          From: '[email protected]',
          To: msg.to,
          Subject: msg.subject,
          HtmlBody: msg.html,
        })
      }
    },
  },
  collections: [],
  globals: [],
})

Development fallback

If email is not configured and NODE_ENV is not production, Dyrected tries to send through Ethereal using nodemailer.

On first send, it logs:

  • that it is using Ethereal for development preview
  • the Ethereal login details
  • a preview URL for the rendered message

That gives you a safe way to test invite and reset flows before you connect a real provider.

If nodemailer is not available, Dyrected logs a warning and skips sending in development.

The built-in emails Dyrected sends

Dyrected currently sends four auth-related emails for you:

EmailTriggerWhat the template receives
Welcomefirst user registration or invite acceptance{ email }
Inviteauthenticated invite call{ token, invitedByEmail?, url? }
Reset passwordforgot-password{ token, url? }
Password changedsuccessful reset-password{ email }

All four are best-effort. The API flow still completes if sending fails, and the failure is logged on the server.

Four details matter here:

  • forgot-password resolves the action URL using the collection's auth.urls.resetPassword, the request's resetUrl, or falls back to the default admin reset URL; the template receives both the raw token and the full link
  • invite resolves the action URL using the collection's auth.urls.invite, the request's inviteUrl, or falls back to the default admin invite URL; the template receives both the raw token and the full link
  • invite tokens default to 7 days (configurable via auth.inviteExpiration), and reset tokens default to 1 hour (configurable via auth.resetPasswordExpiration)
  • a successful password reset also sends the password-changed notification as a security alert

For the endpoint-by-endpoint behavior, use Authentication Operations.

Template cascade resolution

Dyrected resolves email templates through a 4-tier cascade:

  1. Database overrides: Records in the __email_templates collection (active when adminEditable: true).
  2. Collection config overrides: Templates defined on collection.auth.email.templates.
  3. Global config overrides: Templates defined on config.email.templates.
  4. Core defaults: Built-in responsive HTML templates with security escaping.

The first tier that defines a template for an action wins. This allows you to set global defaults in code while customizing templates for specific customer collections or letting marketing edit copies in the Admin UI.

Customize templates in code

You can customize templates globally in config.email.templates or per collection in collection.auth.email.templates.

Each template can return either direct HTML or an external template configuration:

import { defineConfig } from '@dyrected/core'
import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

export default defineConfig({
  email: {
    from: '[email protected]',
    send: async (msg) => {
      if (msg.type === 'template') return
      await resend.emails.send({
        from: '[email protected]',
        to: msg.to,
        subject: msg.subject,
        html: msg.html,
      })
    },
    templates: {
      welcome: ({ email }) => ({
        subject: 'Welcome to Acme',
        html: `<p>Your account is ready for ${email}.</p>`,
      }),
      invite: ({ token, invitedByEmail, url }) => ({
        subject: "You've been invited",
        html: url
          ? `
            <p>${invitedByEmail ?? 'Someone'} invited you to join.</p>
            <p><a href="${url}">Accept your invitation</a></p>
            <p>If the button does not work, copy this link:</p>
            <pre>${url}</pre>
          `
          : `
            <p>${invitedByEmail ?? 'Someone'} invited you to join.</p>
            <p>Your invite token is:</p>
            <pre>${token}</pre>
          `,
      }),
      resetPassword: ({ token, url }) => ({
        subject: 'Reset your password',
        html: url
          ? `<p><a href="${url}">Reset your password</a></p>`
          : `<p>Your reset token is:</p><pre>${token}</pre>`,
      }),
      passwordChanged: ({ email }) => ({
        subject: 'Your password was changed',
        html: `<p>The password for ${email} was just changed.</p>`,
      }),
    },
  },
  collections: [],
  globals: [],
})

External template overrides

To delegate an email to a provider-hosted template, return { type: 'template', templateId, variables }:

templates: {
  invite: (args) => ({
    type: 'template',
    templateId: 'user-invitation',
    variables: {
      invite_url: args.url,
      invited_by: args.invitedByEmail ?? 'Our Team',
      token: args.token,
    },
  }),
}

Admin UI template management

When you want non-developers to edit email templates without deploying code, enable adminEditable: true:

export default defineConfig({
  email: {
    from: '[email protected]',
    adminEditable: true,
    send: async (msg) => { /* ... */ },
  },
  // ...
})

When adminEditable: true is enabled, Dyrected automatically adds the __email_templates collection to your Admin UI under the System group.

In the Admin UI, administrators can:

  • Edit HTML templates: Write raw HTML with full syntax highlighting powered by CodeMirror.
  • Quick Format HTML: Click the Format HTML button in the top-right corner of the code editor to tidy and re-indent nested HTML elements, tables, and conditional comments.
  • Live Preview: See real-time iframe rendering with instant mock variable interpolation for both desktop and mobile (375px) screens.
  • Variable Chips: Click dynamic chips ({{url}}, {{token}}, {{email}}, {{invitedByEmail}}, {{siteName}}) to insert placeholders at the cursor.
  • External Template Mode: Switch a template type to external to specify a provider template ID (e.g. Postmark numeric ID or SendGrid d- template ID) instead of HTML.
  • Test Delivery: Click Send Test to dispatch a test email directly to any inbox before saving. In production, this delivers through your configured email provider; in development without a configured provider, Dyrected dispatches to an Ethereal preview inbox and provides a click-to-open preview link in the toast.
  • Revert to Defaults: Restore the built-in system template anytime.

Collection-specific action URLs and templates

If your application has multiple auth collections (e.g., internal staff in __admins and end-users in customers), you do not want your customers receiving invite links that point to the /admin dashboard.

Configure auth.urls and auth.email.templates on the collection:

import { defineCollection, defineTextField } from '@dyrected/core'

export const Customers = defineCollection({
  slug: 'customers',
  auth: {
    urls: {
      invite: 'https://store.myapp.com/accept-invite',
      resetPassword: 'https://store.myapp.com/reset-password',
    },
    email: {
      templates: {
        invite: ({ url, token }) => ({
          subject: 'Welcome to the Store - Activate your account',
          html: `<p>Activate your store account <a href="${url}">here</a>.</p>`,
        }),
      },
    },
  },
  fields: [
    defineTextField({ name: 'name', label: 'Name' }),
  ],
})

When an invite is sent for a customers document, Dyrected automatically resolves the action URL to https://store.myapp.com/accept-invite?token=... instead of the admin dashboard, and renders the customer-specific invite template.

Advanced paths

Most projects only need one provider and the four built-in auth emails. If you need more control, there are two common advanced paths.

Send transactional and product emails from hooks

Use built-in auth emails for auth flows, and send product-specific notifications (such as order confirmations, booking reminders, or contact form alerts) from collection lifecycle hooks.

You can dispatch emails directly using a third-party SDK or route them through Dyrected's centralized sendEmail transport with database-managed templates.

Option A: Direct provider dispatch

When you want direct control inside a specific collection hook using your email provider's client:

import { defineCollection, defineConfig, defineEmailField } from '@dyrected/core'
import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

const Orders = defineCollection({
  slug: 'orders',
  hooks: {
    afterChange: [
      async ({ doc, operation }) => {
        if (operation !== 'create') return

        await resend.emails.send({
          from: '[email protected]',
          to: doc.customerEmail,
          subject: `Order #${doc.id} confirmed`,
          html: `<p>Thanks for your order, ${doc.customerName}!</p>`,
        })
      },
    ],
  },
  fields: [
    defineEmailField({
      name: 'customerEmail',
      label: 'Customer email',
      required: true,
    }),
  ],
})

Option B: Centralized transport with database-managed templates

When email.adminEditable: true is enabled, administrators can manage non-auth notification templates directly in the Admin UI under __email_templates (for instance, creating a template record with purpose order_confirmation).

Inside your hook or custom API route, look up the active template from the database and dispatch it through Dyrected's sendEmail helper:

import { defineCollection, defineEmailField, sendEmail } from '@dyrected/core'

const Orders = defineCollection({
  slug: 'orders',
  hooks: {
    afterChange: [
      async ({ doc, operation, req }) => {
        if (operation !== 'create') return

        // 1. Fetch template from __email_templates (or fall back to a default)
        const templateDoc = await req.db?.findOne({
          collection: '__email_templates',
          where: { purpose: { equals: 'order_confirmation' } },
        })

        // 2. Interpolate dynamic variables into subject and HTML
        let subject = templateDoc?.subject || `Order #${doc.id} Confirmed`
        let html =
          templateDoc?.html || `<p>Thank you for your order, ${doc.customerName}.</p>`

        const variables: Record<string, string> = {
          '{{orderId}}': String(doc.id),
          '{{customerName}}': String(doc.customerName),
          '{{total}}': `$${Number(doc.total).toFixed(2)}`,
        }

        for (const [tag, val] of Object.entries(variables)) {
          subject = subject.split(tag).join(val)
          html = html.split(tag).join(val)
        }

        // 3. Dispatch through Dyrected's configured email transport
        await sendEmail(req.config, {
          to: doc.customerEmail,
          type: 'html',
          subject,
          html,
          purpose: 'order_confirmation',
          collection: 'orders',
        })
      },
    ],
  },
  fields: [
    defineEmailField({
      name: 'customerEmail',
      label: 'Customer email',
      required: true,
    }),
  ],
})
export default defineConfig({
  collections: [Orders],
  globals: [],
})

Reuse the auth helpers in your own server code

If you build a custom endpoint around the auth flows, you do not need to reimplement the token and email plumbing. Import the same helpers Dyrected uses from @dyrected/core/server:

  • signCollectionToken, verifyCollectionToken, decodeCollectionToken for issuing, checking, and reading session and purpose tokens
  • sendEmail, buildResetPasswordEmail, buildInviteEmail, buildWelcomeEmail, buildPasswordChangedEmail for delivery and the default templates
  • hashPassword, verifyPassword for password storage and checks
  • revokeAuthSession, revokeAllAuthSessions, getAuthSession for server-side session control

For example, a custom forgot-password route can mint a reset token, render the default template (including any templates.resetPassword override you configured), and send it:

import {
  buildResetPasswordEmail,
  sendEmail,
  signCollectionToken,
} from '@dyrected/core/server'

const token = await signCollectionToken(
  { sub: user.id, email: user.email, collection: 'users', purpose: 'reset' },
  '1h',
)
const { subject, html } = buildResetPasswordEmail(config, {
  token,
  url: `https://myapp.com/reset?token=${token}`,
})
await sendEmail(config, { to: user.email, subject, html })

Signing needs DYRECTED_JWT_SECRET, the same secret from Authentication Overview. To read a token without verifying it (for example, to decide which flow it belongs to before doing real work), use decodeCollectionToken — it returns the payload or null when the token is malformed, and it never throws.

Keep transport stable, swap templates later

If different projects need different branding, keep the same send implementation and change only the templates functions per project. That gives you one delivery path with different message content.

If you also need product emails such as order confirmations or marketing messages, treat that as a separate email system in your app. This page stays on Dyrected's built-in auth emails and the top-level transport setup they rely on.

Success check

You are ready to move on when you can answer these plainly:

  • which auth flows in this project need email
  • which provider will handle delivery
  • whether the built-in templates are good enough or need overrides
  • whether you need only auth email or also custom app email from hooks

On this page

Dyrected| Cloud

Get your backend ready in minutes

Use a managed database, storage, APIs, and admin dashboard without setting up the infrastructure yourself.

Set Up My Backend