# Email

> Sign-in is by emailed code, so mail has to work: SMTP for self-hosted, and the console transport for evaluation.

Source: https://critlab.app/docs/self-hosting/email

critlab has no passwords. Everyone signs in with a six-digit code sent by email (or through SSO), so a working mail transport is part of a production install. Mail also carries invitations and notification emails.

## Pick a transport

web chooses the first one that is configured:

| Order | Set | Transport | Use it for |
| --- | --- | --- | --- |
| 1 | `RESEND_API_KEY` | Resend's HTTP API | critlab cloud. A self-hosted install that never sets it never contacts Resend. |
| 2 | `SMTP_URL` | Any SMTP relay | **Self-hosted and air-gapped installs.** |
| 3 | neither | Console | Evaluation only: messages are printed to web's log, not sent. |

`MAIL_FROM` sets the sender (default `critlab@localhost`, which real mail servers will reject). Use an address your relay may send as, e.g. `critlab <critlab@example.com>`.

## SMTP

```text
SMTP_URL=smtp://critlab%40example.com:app-password@smtp.example.com:587
MAIL_FROM=critlab <critlab@example.com>
```

`SMTP_URL` is a nodemailer connection URL: `smtp://` for plain or STARTTLS (port 587 upgrades when the server offers it), `smtps://` for implicit TLS on port 465. URL-encode `@` and other reserved characters in the user name and password. Keep the whole URL in a Secret — it contains the password. With the Helm chart, set `secrets.smtpUrl` or add `SMTP_URL` to your own Secret and reference it through `web.extraEnv`, and set `config.mailFrom`.

The connection is opened lazily on the first send, so a wrong URL shows up when someone requests a code, not at startup. Check web's log for the error.

For deliverability, publish SPF and DKIM for the sending domain the way your relay's documentation describes; sign-in codes are time-sensitive and should not land in spam.

## Console transport (evaluation)

With neither variable set, every message — sign-in codes and invitation links included — is printed to web's log in a box:

```text
┌─ [critlab:mail] console transport — NOT sent, printed only ─────────────
│ from:    critlab@localhost
│ to:      ana@example.com
│ subject: …
│
│ …
└─ set RESEND_API_KEY or SMTP_URL to send this for real ─────────────────
```

That makes a fresh evaluation install usable without a mail server: sign up, then copy the code from the log (`kubectl logs deploy/critlab-web` or the `pnpm dev` output). Do not run production this way; anyone with log access can read sign-in codes.

## How sign-in codes behave

- A code is valid for 10 minutes and allows 5 attempts; only a hash is stored.
- The same request is answered identically for known and unknown addresses, so the sign-in form cannot be used to find out who has an account.
- Sending is limited per address — 30 seconds between codes and at most 6 an hour — on top of a per-IP limit.
- Signing up and signing in are the same flow: entering a valid code for a new address creates the account.

`AUTH_REQUIRE_EMAIL_VERIFICATION=true` additionally blocks sign-in for accounts whose address is not verified. Accounts created through SSO or SCIM do not use codes at all.

## Troubleshooting

| Symptom | Check |
| --- | --- |
| No code arrives | The relay refused or `SMTP_URL` is wrong: look for the send error in web's log. |
| Code printed in the log instead of sent | Neither `SMTP_URL` nor `RESEND_API_KEY` is set on the web process. |
| Relay rejects the sender | `MAIL_FROM` is still `critlab@localhost` or not an address the relay allows. |
| The form asks you to wait before sending another code | The 30-second per-address cooldown, or the cap of 6 codes an hour. |

> **Open question**
>
> The cooldown and hourly cap are constants in the code, not environment variables. Whether operators need to tune them (for example behind a shared NAT or for bulk onboarding) is not decided.
