Email configuration¶
GRCFlow sends a small, high-consequence set of messages: password resets, user
invitations, KRI / A2A alerts, and task and evidence-request notifications.
All of it is driven by one set of environment variables in the .env file that sits
next to your docker-compose.yml.
A stock install does not send mail. It writes each message to the backend log instead. That is deliberate — it is what an air-gapped install wants, and it means a fresh download never tries to reach a mail server you did not configure. You turn sending on by naming a provider and giving it credentials.
What happens by default¶
The shipped compose file sets EMAIL_PROVIDER=none. In that state:
- Every message is written to the backend log with its recipient, subject and full plain-text body — including the reset link or invite link, so it can still be delivered by hand.
- The send is reported as successful, because logging is the configured destination. Nothing failed.
- Invitations created in air-gap mode return the
invite_linkin the API response for out-of-band delivery, rather than mailing it.
Air-gap mode overrides everything else: with AIRGAP_MODE=true (or an air-gap
licence), mail is logged no matter which provider is configured.
Nothing is sent until you choose a provider
Setting SMTP_HOST or an API key is not enough on its own if EMAIL_PROVIDER
is left at none — none means log, do not send. Either set EMAIL_PROVIDER
to the provider you want, or set it to auto and let GRCFlow pick from the
credentials you supplied.
Choosing a provider¶
EMAIL_PROVIDER accepts:
| Value | Behaviour |
|---|---|
none |
Log instead of sending. The shipped default. console, off and disabled are accepted as synonyms. |
auto |
Pick a provider from whichever credentials are actually configured, in the order Resend → SendGrid → SMTP. The first one whose credentials are present wins. |
smtp |
Generic SMTP. Requires SMTP_HOST. |
resend |
Resend HTTP API. Requires RESEND_API_KEY. |
sendgrid |
SendGrid v3 API. Requires SENDGRID_API_KEY. |
aws_ses |
AWS SES API via the standard AWS credential chain. Must be named explicitly. |
Two rules are worth committing to memory:
aws_sesis never chosen byauto.SES_REGIONcarries a default (us-east-1), so its presence cannot signal that you intended to use SES. If you want SES, name it.- Naming a provider whose credentials are missing does not fall back to sending.
It logs an
ERRORat startup and sends nothing. A silent fallback to console logging is exactly how a broken mail path stays invisible for months, so this build refuses to do it quietly. An unrecognised value logs aWARNINGand auto-detects instead.
The From address — set this first¶
SMTP_FROM_EMAIL is the sender for every provider, not just SMTP. It defaults to
noreply@sovereign-grc.local, which is an obvious placeholder on a non-routable
domain. Change it to an address on a domain you control and that your provider
is authorised to send for.
Do not leave this empty
Blanking SMTP_FROM_EMAIL makes every backend report itself unconfigured, and
GRCFlow falls back to logging. Set it to a real address, or leave the value out
of .env entirely so the compose default applies.
DMARC alignment — the usual reason mail vanishes¶
If your domain publishes DMARC, the receiving server checks that the domain in the
From: header aligns with the domain that DKIM-signed the message (the d=
value your provider uses). How strictly it must match is set by your own DNS:
| Your DMARC policy | What must match | Example that passes |
|---|---|---|
adkim=r (relaxed — the default when unspecified) |
The organizational domain. A subdomain of the same registered domain is fine. | DKIM d=mail.example.com, From noreply@example.com ✅ |
adkim=s (strict) |
The domain must be exactly equal. | DKIM d=mail.example.com, From noreply@example.com ❌ — must be noreply@mail.example.com |
So if your provider signs as d=mail.example.com and your DMARC record says
p=reject; adkim=s, then sending as noreply@example.com fails outright. The
receiver rejects it at SMTP time. It does not land in a spam folder, and GRCFlow's
own logs will happily read "Email sent via SMTP" — because the provider accepted the
message; it was the recipient that refused it.
This is the single most common cause of "email is configured but nothing arrives". When you hit it, you have three fixes, in order of preference:
- Send from the exact domain your provider signs for (
noreply@mail.example.com). - Have your provider sign for the exact domain in your
From:(addexample.comas an authenticated sending domain and publish its DKIM record). - Relax alignment to
adkim=rin your DMARC record — only if that is acceptable to your security policy.
Whichever you choose, confirm SPF and DKIM for the sending domain are published exactly as your provider instructs before blaming GRCFlow. Your provider's own delivery log will show the rejection.
Links in outbound mail come from FRONTEND_URL
Password-reset and invitation links are built from the server-configured
FRONTEND_URL (default http://localhost:3000), never from request headers. If
recipients get a working email with an unreachable link, that is the variable to
fix.
SMTP¶
| Variable | Purpose |
|---|---|
SMTP_HOST |
Server hostname. Its presence is what makes SMTP selectable. |
SMTP_PORT |
Default 587. |
SMTP_USERNAME |
Authentication username. This is the correct name. SMTP_USER is still honoured by the compose file as a legacy fallback, but the setting itself is SMTP_USERNAME. |
SMTP_PASSWORD |
Authentication password. |
SMTP_USE_TLS |
Implicit TLS — the connection is encrypted from the first byte. |
SMTP_START_TLS |
STARTTLS — connect in plaintext, then upgrade. |
SMTP_FROM_EMAIL |
Sender address (see above). |
SMTP_FROM_NAME |
Sender display name, default GRCFlow. |
Port and TLS combinations¶
These two flags are not independent, and getting them wrong is the most common SMTP-side failure:
| Port | SMTP_USE_TLS |
SMTP_START_TLS |
Mode |
|---|---|---|---|
| 587 (submission — most providers) | false |
true |
STARTTLS |
| 465 | true |
false |
Implicit TLS |
| 25 (internal relay, no encryption) | false |
false |
Plaintext — only on a trusted network |
Port 587 needs SMTP_USE_TLS=false
This reads backwards, but it is correct: on 587 the connection starts
unencrypted and is upgraded by STARTTLS, so the flag that must be on is
SMTP_START_TLS, not SMTP_USE_TLS.
The shipped defaults match the default port: SMTP_PORT=587 with
SMTP_USE_TLS=false and SMTP_START_TLS=true. So if you use 587 you only need
to set host, username, password and from-address. If you change the port, set
both flags to match it — the defaults are only correct for 587.
Setting both to true selects neither mode and attempts a plaintext
connection. Exactly one of them should be true.
Example .env for a provider on port 587:
EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.your-provider.example
SMTP_PORT=587
SMTP_USERNAME=postmaster@mail.example.com
SMTP_PASSWORD=<your-smtp-password>
SMTP_USE_TLS=false
SMTP_START_TLS=true
SMTP_FROM_EMAIL=noreply@mail.example.com
SMTP_FROM_NAME=Example Corp GRC
Resend¶
EMAIL_PROVIDER=resend # or `auto` — Resend wins auto-detection
RESEND_API_KEY=<your-resend-api-key>
SMTP_FROM_EMAIL=noreply@example.com
SMTP_FROM_NAME=Example Corp GRC
The From domain must be verified in your Resend account. GRCFlow posts to
https://api.resend.com/emails; anything other than a 200 is surfaced as a failed
send carrying Resend's own error message.
SendGrid¶
EMAIL_PROVIDER=sendgrid # or `auto`, if no Resend key is set
SENDGRID_API_KEY=<your-sendgrid-api-key>
SMTP_FROM_EMAIL=noreply@example.com
SMTP_FROM_NAME=Example Corp GRC
The From address must be a verified single sender or belong to an authenticated
domain. GRCFlow posts to https://api.sendgrid.com/v3/mail/send and treats 202
Accepted as success; any other status is surfaced as a failure with SendGrid's error
text.
AWS SES¶
EMAIL_PROVIDER=aws_ses # must be explicit — `auto` never picks SES
SES_REGION=us-east-1
AWS_ACCESS_KEY_ID=<your-access-key-id>
AWS_SECRET_ACCESS_KEY=<your-secret-access-key>
SMTP_FROM_EMAIL=noreply@example.com
Credentials come from the standard AWS chain, so an EC2/ECS instance role works instead of static keys. The From identity must be verified in SES, and the account must be out of the SES sandbox before it can mail arbitrary recipients.
SES credentials are not checked when the provider is selected
Resend, SendGrid and SMTP all fail loudly at startup when their credentials are missing. SES cannot: its credentials are ambient, resolved by the AWS SDK at send time. If the credential chain turns out to be empty, the SES backend reports itself unavailable, GRCFlow logs the message instead, and the send is reported as successful.
So for SES specifically, do not trust the absence of an error — confirm you see
Email sent via AWS SES in the log, not EMAIL_LOGGED.
Applying changes¶
Configuration is read once per backend process and cached. After editing .env:
Verifying it works¶
-
Check which backend was selected. On the first message it needs to handle, the backend logs its choice:
"backend": "console"means nothing will be sent, whatever else is set. -
Trigger a real send. The quickest is a password reset for an account that exists and uses local auth (requires
AUTH_MODE=localorhybrid):curl -sS -X POST http://localhost:8000/api/v1/auth/forgot-password \ -H 'Content-Type: application/json' \ -d '{"email":"you@example.com"}'This endpoint always returns success, whether or not the address exists — that is an anti-enumeration measure, not a delivery confirmation. The logs are the source of truth. Inviting a user (
POST /api/v1/orgs/{org_id}/users/invite) exercises the same path. -
Read the log line for that send, using the table below.
Telling "sent" from "only logged"¶
Backend logs are structured JSON, one object per line, with the message in the
event field:
event |
Level | What it means |
|---|---|---|
Email backend selected |
INFO | Which backend is active. Carries provider (what you asked for) and backend (what was actually built). |
Email sent via SMTP / ... via Resend / ... via SendGrid / ... via AWS SES |
INFO | Handed to the provider and accepted. |
EMAIL_LOGGED (air-gap/console mode) |
INFO | Not sent. The message was written to the log. Expected when EMAIL_PROVIDER=none or in air-gap mode. |
Email routed to console (air-gap mode) |
INFO | Air-gap mode forced logging, overriding the configured provider. |
Primary email backend FAILED - message logged but NOT delivered |
ERROR | A provider was configured and the send genuinely failed. Carries the underlying error. The content is still logged so it is recoverable. |
SMTP send failed |
ERROR | The SMTP-level error behind the line above. |
EMAIL_PROVIDER requests a backend that is not configured |
ERROR | You named a provider but its credentials are absent. Nothing is being sent. |
Email backend is configured but unavailable - message logged but NOT delivered |
ERROR | The backend was built but reports itself unusable — most often EMAIL_PROVIDER=aws_ses with an empty AWS credential chain. Nothing was sent. |
EMAIL_PROVIDER is set but unconfigured - message logged but NOT delivered |
ERROR | Emitted per send, when the provider you named could not be built at all. The startup line above says it once; this says it every time. |
Unrecognised EMAIL_PROVIDER, auto-detecting instead |
WARNING | Typo in the value; auto-detection is being used. |
Failed sends are now reported as failures
Until recently, a send that failed fell back to console logging and inherited
that backend's success result — a password reset that never left the building
looked identical to one that arrived. That is fixed: a failed send now returns
success=false with the provider's error attached, and the content is still
written to the log so nothing is lost. The invitation record likewise stores an
honest email_sent value.
Console reporting success is still correct in the one case where it is true: when console is the configured destination.
Recovering a message that was only logged
The log entry carries to, subject and the full body_text, which includes
the reset or invite link — enough to deliver it by hand on an air-gapped install.
The HTML part is logged at DEBUG level as EMAIL_FULL_CONTENT; set
LOG_LEVEL=DEBUG if you need it.
"Sent" means your provider accepted the message. Rejections that happen after that —
DMARC alignment failures, recipient-side blocks — appear only in your provider's
delivery log, not here. If GRCFlow says Email sent via ... and the message never
arrived, that is where to look next, and
DMARC alignment is the first thing
to check.
Air-gapped installs¶
Leave EMAIL_PROVIDER=none. Everything keeps working:
- Invitations return the raw
invite_linkin the API response for manual, secure transfer, and are audit-logged asdelivery_method=manual. - Password resets and alerts are written to the log with their links intact.
- No outbound connection is ever attempted.
See Offline & air-gapped activation for the licensing side of a disconnected deployment, and System Logs for reading backend logs from the UI.
The Admin panel's SMTP tab¶
Outbound mail is configured in .env, not in the Admin panel
Admin → SMTP Settings stores SMTP values in the database, and its test button
opens a connection using those stored values. Outbound sending, however, is
selected entirely from the environment (EMAIL_PROVIDER and the SMTP_* /
API-key variables described on this page).
Configure delivery here, in .env, and use the log lines above to confirm it.
See Admin Settings.