FerrisKeyDocs

Email & Templates

FerrisKey sends transactional email for password resets, magic link sign-in, and email verification. Delivery is configured per realm, and each email type needs a template assigned to it before anything is sent.

Configure SMTP

SMTP settings live in the database, on the realm, not in a global environment variable. Two realms in the same deployment can use completely different mail providers.

No global SMTP

SMTP is configured per realm, not globally. See the Configuration guide for the short overview. This page covers the full field reference and API.

SMTP fields

FieldTypeNotes
hostStringSMTP server hostname
portu16Port number, 1 to 65535
usernameStringSMTP authentication username. Required
passwordStringSMTP authentication password. Required, and write-only: the API never returns it
from_emailStringSender address, must be a valid email
from_nameStringSender display name
encryptionEnumtls | starttls | none

The API requires a username and a password even for a relay that does not authenticate, so give such a relay placeholder values.

When SECRETS_ENCRYPTION_KEYS is set, the stored password is sealed. See Secrets at rest.

Encryption modes:

ModePortWhen to use
tls465Implicit TLS from the first byte. What most modern providers want
starttls587Plain connection upgraded to TLS. Common on corporate relays
none25 / anyNo encryption. Only on a trusted local network, or for testing

SMTP API endpoints

All endpoints are scoped to a realm and require authentication.

MethodPathDescription
GET/realms/{realm_name}/smtp-configRead the current config (password omitted)
PUT/realms/{realm_name}/smtp-configCreate or replace the config
DELETE/realms/{realm_name}/smtp-configRemove the config

Required permissions: reading the SMTP config requires view access to the realm. Creating, updating, or deleting it requires ManageRealm. There are no SMTP-specific permissions beyond the realm gates.

Configure SMTP in the console

Open the Emails page

In the left sidebar of the admin console, open Emails, then the SMTP tab. In the second console panel the same page is under Branding, as Email templates. See The admin console.

Fill in the provider details

Enter the host, port, encryption mode, username and password, then the sender address and name. Select the encryption mode that matches your provider.

Common provider settings:

ProviderHostPortEncryption
Gmail (App Password)smtp.gmail.com465tls
Mailgunsmtp.mailgun.org587starttls
Resendsmtp.resend.com465tls
Local (MailHog / MailPit)localhost1025none

Save and check

Click Save configuration, or Save changes when one already exists. Saving replaces the whole configuration, password included, so type the password again.

There is no test-send button. Trigger a real flow instead, for example a password reset for a test account, then look for an email_sent or email_not_sent event in SeaWatch or in the Message delivery view of the console.

Password is write-only

The API never returns the SMTP password. If you need to rotate credentials, submit a full PUT with the new password included.


Transactional emails

There are three types of transactional email, each gated by a realm toggle.

Email typeIdentifierSent whenRealm toggle required
Password resetreset_passwordA user requests a password reset linkforgot_password_enabled
Magic linkmagic_linkA user requests passwordless email loginmagic_link_enabled
Email verificationemail_verificationA user must verify their email addressemail_verification_enabled

The toggles are in Realm Settings, on the Login tab, under Authentication capabilities. Turning one on activates the matching flow, but it does not send anything by itself: an email leaves only when the type has a template assigned and the realm has an SMTP configuration.

FerrisKey email templates page showing per-type assignment and the Import action FerrisKey email templates page showing per-type assignment and the Import action

Assignment is per email type, and a type with nothing assigned sends no email. FerrisKey has no built-in default template to fall back on. What you see when something is missing depends on the type:

MissingPassword reset and magic linkEmail verification
TemplateNothing is sent. The request still succeeds for the person asking, and SeaWatch records email_not_sent with the error code TEMPLATE_NOT_CONFIGUREDSending fails with 400 and the reason email_verification_template_not_configured
SMTP configurationNothing is sent, and SeaWatch records email_not_sent with SMTP_NOT_CONFIGUREDSending fails with 503

For email verification the failure is not silent. A self-registration in a realm with email verification on fails with that error, and the account it was creating is rolled back, so nobody can register until the template and the SMTP configuration are in place. The resend endpoint fails the same way.

When the mail server refuses or fails to deliver a password reset or a magic link, SeaWatch records email_not_sent with SMTP_SEND_FAILED and the reason. The person who asked gets the same answer whether or not the mail left, and whether or not the address belongs to an account, so email_not_sent events are where you find out. Watch for them.

Customizing templates

Template engine

Templates are rendered by a small interpolation engine written for the purpose, not Handlebars or Tera. Placeholders use double braces:

{{variable_name}}

Every interpolated value is HTML-escaped before insertion (&, <, >, ", '), so a template cannot be turned into an injection vector.

HTML escaping

Values inserted via {{...}} are always HTML-escaped. If a user’s name is Alice <script>, it renders as Alice &lt;script&gt; in the final email, safe to embed in HTML.

Templates are stored as a JSON structure that FerrisKey renders to MJML and then to HTML before sending. The HTML part travels with a plain-text version of the message, which a mail client falls back on. If a template cannot be rendered when the email is sent, only the plain-text version goes out.

What the server removes from a template

The builder is only one way to produce a structure. Another one is an import, or a direct call to the API, so FerrisKey cleans the structure on the server rather than trusting the editor. It does so when a template is created, updated or imported, and again each time it renders an email. Most of the cleaning is silent: the template saves, minus whatever was removed. Two cases are refused instead.

WhatRule
Node typesmjml, root, or a name starting with mj-. Any other type is refused with 400
NestingAt most 32 levels. A deeper structure is refused with 400
Text contentOnly these tags stay: p, br, hr, strong, b, em, i, u, s, a, ul, ol, li, h1 to h6, blockquote, code, pre, span, table, thead, tbody, tfoot, tr, td, th. Scripts, comments and every other tag go
URLsOnly http, https, mailto and tel. A URL with another scheme, such as javascript:, is removed from href, src and any attribute ending in -url or -src. Relative URLs and {{placeholders}} have no scheme and pass
Inline CSSOnly color, font-size, font-weight, font-style, font-family, text-align, line-height and text-decoration, and not with url(, expression( or @import in the value
AttributesNames that start with on, and names that are not letters, digits and hyphens, are dropped
mj-styleEmptied whole if it contains <, a backslash, url(, expression(, @import, javascript:, behavior or binding

Links in text content also get rel="noopener noreferrer".

Available variables

These variables are available in every template, whatever its type:

VariableDescription
user.first_nameUser’s first name
user.last_nameUser’s last name
user.emailUser’s email address
expirationHow long the link stays valid, written out: 30 minutes for a password reset, the realm’s magic link validity in minutes (15 by default), 24 hours by default for an email verification
requested_atWhen the email was requested, in UTC and RFC 3339, for example 2026-10-17T12:34:56Z
user_agentThe User-Agent of the request that triggered the email, or unknown when it carried none

requested_at and user_agent let a security notice tell its recipient when and from what client the request came, so they can spot one they never made.

Each email type also provides one link variable:

Email typeLink variableDescription
reset_passwordreset_linkThe password-reset URL
magic_linkmagic_linkThe passwordless login URL
email_verificationverification_linkThe email verification URL

You can query the available variables for any type without authentication:

GET /email-templates/variables/{email_type}

email_type must be one of: reset_password, magic_link, email_verification.

Example response for reset_password:

{
  "data": [
    { "name": "user.first_name", "description": "User's first name" },
    { "name": "user.last_name", "description": "User's last name" },
    { "name": "user.email", "description": "User's email address" },
    { "name": "expiration", "description": "Expiration time" },
    { "name": "requested_at", "description": "Date and time the email was requested (UTC, RFC 3339)" },
    { "name": "user_agent", "description": "User agent of the request that triggered the email" },
    { "name": "reset_link", "description": "Password reset link" }
  ]
}

Language

FerrisKey does not choose a template or a language from the user’s locale. The locale on a user and on a realm sets the language of the console and of the default sign-in pages. It plays no part in email; see Localization. A realm has one template per email type, so every recipient of that type gets the same one. The subject lines are fixed English strings (Reset your password, Your magic link and Verify your email address), and so is the plain-text version. Write the template itself in the language your users read.

Template API endpoints

MethodPathDescription
GET/realms/{realm_name}/email-templatesList the templates of the realm, paginated
POST/realms/{realm_name}/email-templatesCreate a new template
GET/realms/{realm_name}/email-templates/{template_id}Get a template by ID
PUT/realms/{realm_name}/email-templates/{template_id}Update a template
DELETE/realms/{realm_name}/email-templates/{template_id}Delete a template

The list takes search, name, email_type, created_from and created_to, and sorts on name, email_type, created_at (the default) or updated_at. See Paginated listings.

Request body for POST: name, email_type, structure.

Request body for PUT: name, structure (email_type cannot be changed after creation).

Required permissions: ViewEmailTemplates to read. ManageEmailTemplates to create, update, or delete. ManageRealm also grants both.

Linking a template to an email type

Creating a template does not activate it. Point the realm at it by updating realm settings, with PUT /realms/{realm_name}/settings, or by choosing a template in the Assignment section of the Emails page:

Realm setting fieldApplies to
reset_password_template_idreset_password emails
magic_link_template_idmagic_link emails
email_verification_template_idemail_verification emails

Set the field to the template UUID to activate it. Set it to null and that email type stops sending.


Example

Template snippet

A minimal password-reset message, written as the content of a text block in the builder and using placeholder syntax:

<p>Hi {{user.first_name}},</p>

<p>
  Someone requested a password reset for your account (<strong>{{user.email}}</strong>)
  at {{requested_at}}, from {{user_agent}}. This link expires in {{expiration}}.
</p>

<p>
  <a href="{{reset_link}}">Reset your password</a>
</p>

<p>If you did not request this, you can safely ignore this email.</p>

Variables response

{
  "data": [
    { "name": "user.first_name", "description": "User's first name" },
    { "name": "user.last_name", "description": "User's last name" },
    { "name": "user.email", "description": "User's email address" },
    { "name": "expiration", "description": "Expiration time" },
    { "name": "requested_at", "description": "Date and time the email was requested (UTC, RFC 3339)" },
    { "name": "user_agent", "description": "User agent of the request that triggered the email" },
    { "name": "magic_link", "description": "Magic link URL" }
  ]
}