# Email: SMTP Relays and the Outbox

> How nauthera-server sends email: deployment and per-organization SMTP relays, TLS, the durable outbox, each message, its branding and languages, and test mail.

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.

| Message | Sent when | Links to |
|---|---|---|
| Password reset | A user asks for one on `/forgot-password`, or an administrator sends one | `/reset-password` |
| Address verification | `POST /v1/auth/verify-email/resend` is called for an unverified address | `/verify-email` |
| New-sign-in alert | A user signs in from a browser or network they have not used before | `/forgot-password` |
| Invitation | An 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 code | A user enrols the emailed second factor, or signs in with it; the code stays out of the subject line | — |
| Role granted, role revoked | An administrator grants a user a role they did not hold, or revokes one they held | — |
| Account suspended, blocked, reactivated | An administrator changes the account's status | — |
| Password, second factor or recovery codes changed | The 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 test | An administrator sends one from the dashboard's email page | — |

The flows behind the first three are described in
[Sign-in Experience](https://www.nauthera.io/docs/v1/login-experience#password-reset-and-address-verification),
and invitations and roles in [Admin API & Users](https://www.nauthera.io/docs/v1/user-management#inviting-a-user).
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

```yaml
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:

```yaml
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](https://www.nauthera.io/docs/v1/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](https://www.nauthera.io/docs/v1/login-experience#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](https://www.nauthera.io/docs/v1/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.

```yaml
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`.

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