Self-hosting
Sign-in is by emailed code, so mail has to work: SMTP for self-hosted, and the console transport for evaluation.
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
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:
┌─ [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. |