Skip to content

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_link in 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 nonenone 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_ses is never chosen by auto. SES_REGION carries 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 ERROR at 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 a WARNING and 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:

  1. Send from the exact domain your provider signs for (noreply@mail.example.com).
  2. Have your provider sign for the exact domain in your From: (add example.com as an authenticated sending domain and publish its DKIM record).
  3. Relax alignment to adkim=r in 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:

docker compose up -d backend      # or: docker compose restart backend

Verifying it works

  1. Check which backend was selected. On the first message it needs to handle, the backend logs its choice:

    docker compose logs backend | grep "Email backend selected"
    

    "backend": "console" means nothing will be sent, whatever else is set.

  2. Trigger a real send. The quickest is a password reset for an account that exists and uses local auth (requires AUTH_MODE=local or hybrid):

    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.

  3. 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:

docker compose logs -f backend | grep -i email
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_link in the API response for manual, secure transfer, and are audit-logged as delivery_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.