Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

Email: SMTP Relays and the Outbox

Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated

nauthera-server sends a small set of transactional messages through an SMTP relay you provide. Without a relay it sends nothing: the features that need email quietly do without it, except an invitation, an administrator's password-reset link and the emailed sign-in code, which are refused.

MessageSent whenLinks to
Password resetA user asks for one on /forgot-password, or an administrator sends one/reset-password
Address verificationPOST /v1/auth/verify-email/resend is called for an unverified address/verify-email
New-sign-in alertA user signs in from a browser or network they have not used before/forgot-password
InvitationAn administrator creates a user with an invitation, or sends a new one; or a user who waits on one signs in, signs up, or asks for a reset or verification link/accept-invitation
Sign-in codeA user enrols the emailed second factor, or signs in with it; the code stays out of the subject line—
Role granted, role revokedAn administrator grants a user a role they did not hold, or revokes one they held—
Account suspended, blocked, reactivatedAn administrator changes the account's status—
Password, second factor or recovery codes changedThe user or an administrator changes the password, or the user adds or removes a second factor or generates new recovery codes; sent only to a verified address—
Relay testAn administrator sends one from the dashboard's email page—

The flows behind the first three are described in Sign-in Experience, and invitations and roles in Admin API & Users. Every link points at the organization's own issuer.

An invitation names the administrator who sent it, or the organization when the user asked for it, and how many days its link works (auth.invitation.validity, default 7 days). Creating a user with an invitation, or sending a new one, is refused 409 mail_relay_missing when the organization has no relay, so no account is left waiting on a message that cannot be sent.

A role change tells the user which role was granted or revoked, by its title, by whom and when, and to contact their organization's administrator if they did not expect it. It is sent only when the user has an address and the organization a relay, and only when their roles actually changed. A failure to queue it is logged and never undoes the change; the audit record says whether the user was notified.

A credential change notice says what changed and when, and where the request came from, or that an administrator made the change. It carries no link, because a link in a message about a credential is what a phishing copy of it would carry.

Relays

Each organization's email goes through one relay, chosen in this order:

  1. the organization's own relay, from mail.organizations, by organization slug;
  2. otherwise the deployment's relay, mail.smtp;
  3. otherwise none: the organization cannot send email.

Relays are configuration only. They are not stored in the database, and neither the admin API, the dashboard nor the operator can create or change one.

The deployment's relay

mail:
  smtp:
    host: smtp.example.com      # empty = no deployment relay (the default)
    port: 587
    tls: starttls               # starttls | implicit | none
    allow_plaintext_auth: false
    username: nauthera
    password: ""                # secret: use NAUTHERA_SERVER_MAIL_SMTP_PASSWORD_FILE
  from:
    address: no-reply@example.com   # required when a host is set
    name: Nauthera
  reply_to: ""                  # optional
  local_name: ""                # EHLO name; empty sends "localhost"
  timeout: 30s                  # per stage: dial, TLS handshake, each command

Set the username and password together, or neither for a relay that authorizes by network. mail.timeout applies to organizations' relays too. Relays may be in-cluster addresses. With the Helm chart, set these keys under config.mail and supply the password through secrets.smtpPassword (an inline value, or existingSecret and existingSecretKey).

Per-organization relays

An organization listed in mail.organizations sends through its own relay, as its own sender. Its credentials are never configuration: smtp.username_file and smtp.password_file name files the server reads at startup, where a missing file stops it, and again at every delivery, so a rotated Secret takes effect without a restart.

With the Helm chart, use mailOrganizations. The chart mounts each entry's Secret under secrets.mountPath/smtp/<slug>/ and renders mail.organizations pointing at those files, so only file paths reach the ConfigMap:

mailOrganizations:
  - slug: acme
    smtp:
      host: smtp.acme.example
      port: 587
      tls: starttls              # starttls | implicit | none
      allowPlaintextAuth: false  # only with tls: none and credentials
    from:
      address: no-reply@acme.example
      name: Acme
    replyTo: support@acme.example
    credentials:                 # omit for a relay without AUTH
      existingSecret: acme-smtp
      usernameKey: username      # default
      passwordKey: password      # default

TLS

Every relay connection is encrypted unless you explicitly say otherwise:

  • starttls (port 587, RFC 3207) upgrades the connection before anything but EHLO is sent. A relay that does not offer STARTTLS is refused, never used in the clear.
  • implicit (port 465, RFC 8314) opens TLS before the SMTP greeting.

Neither falls back to plaintext. The relay's certificate is verified against the configured host, and TLS 1.2 is the minimum.

none is the explicit exception, for a relay that offers no TLS. Messages, reset links included, then cross the network unencrypted. Credentials are sent over it only with allow_plaintext_auth: true (in the chart, smtp.allowPlaintextAuth); without it, a none relay with credentials is a startup error. The server logs a warning for every none relay at each startup. allow_plaintext_auth with any other mode is refused.

The outbox

Nothing touches SMTP while a user waits. A message is rendered, sealed under the key-encryption key that also seals organization signing keys (keys.encryption_key), and written to the database. A reset or verification request is answered first and the message queued after the response.

Every replica runs a delivery worker. A worker claims due messages with an expiring lease, so two replicas never hold the same message, and a message held by a replica that stopped is claimed again when its lease runs out. Messages for one organization share an SMTP session.

  • Retries. A temporary failure (a 4xx reply, a timeout, a dropped connection) is retried on an exponential schedule with jitter, starting at 30 seconds and capped at 2 hours. A permanent failure (a 5xx reply, a refused certificate, a relay without TLS) or a tenth failed attempt fails the message for good.
  • Duplicates. Delivery is once, except when a replica hands a message to the relay and stops before it records the send. The message keeps its Message-ID across attempts, so a relay or mail client can tell the copies apart.
  • Retention. A message's body is dropped once it is sent or has failed for good, and the remaining row is deleted after seven days.

Each delivery attempt is counted in nauthera_mail_deliveries_total, labelled result sent, retried or failed. See Observability.

Branding and languages

Every message is one layout, sent as both HTML and plain text. It carries the organization's display name (its slug when it has none) and, from the organization's dashboard theme, its logo when that is an https URL and its accent colour when that is a #rrggbb value. Per-client sign-in branding does not apply to email.

Messages are translated into English, German and French, the sign-in app's languages. The language is the first supported one in the Accept-Language header of the request that caused the message, or English. A test message uses the dashboard's language, and an invitation the language the administrator chose for it (the locale of the request). A role-change message has no recipient's request to read a language from, so it is written in English.

The dashboard's email page

The admin dashboard shows the organization's relay, read only: whether it is the organization's own or the deployment's, its host, port, TLS mode and sender, and whether credentials are configured, never the credentials themselves. An organization without a relay sees how to configure one. The same is available from GET /v1/admin/orgs/{slug}/mail.

Send a test message sends the relay test to the signed-in administrator's own address, never to an address the request names. It is sent at once, not queued, and the page reports what the relay answered: delivered, or the stage that failed, the SMTP reply code and whether retrying could help. It is POST /v1/admin/orgs/{slug}/mail/test, needs nauthera.orgs:write, and is recorded as organization.mail.tested. It answers 409 when the organization has no relay, when the relay's credentials cannot be read, or when the administrator's account has no address.

Where a sign-in came from

An optional city database in the MaxMind DB format (GeoLite2-City or GeoIP2-City) lets a new-sign-in alert name the city and country of the sign-in's address, in the alert's language when the database has it. Without one, the alert shows the address alone.

geoip:
  database: /var/lib/geoip/GeoLite2-City.mmdb   # empty = no location (the default)

The file is read at the first lookup and again whenever it changes, so refreshing it needs no restart. Replace it by renaming the new file into place, as geoipupdate does, not by rewriting it. A missing or unreadable file is logged and locates nothing; it never fails a sign-in.

With the Helm chart, set geoip.volume to the volume that holds the file (for example a PersistentVolumeClaim geoipupdate writes to) and geoip.fileName to its name. The chart mounts it read-only at geoip.mountPath (/etc/nauthera/geoip) and renders geoip.database.

geoip:
  volume:
    persistentVolumeClaim:
      claimName: geoip
  fileName: GeoLite2-City.mmdb