Dev SMTP Loop (Mailpit Fallback)

Audience: Notifier developers and integration testers who need to render and inspect outbound email locally.

Source: Sprint 20260513_002 gap-fix G5 Status: Implemented (StellaOps.Notifier.Worker.Channels.DefaultSmtpOptions)

This guide explains how to route every Notifier Email channel through a local Mailpit container so you can see rendered messages without provisioning a real per-tenant MTA.

The Notifier EmailChannelAdapter resolves SMTP host, port, and credentials from the per-channel notify.channels row by default. That works well in production (tenant-specific MTAs, distinct From addresses, etc.) but makes the local dev loop awkward — you cannot see a rendered email without inserting a channel row that points at a reachable SMTP server.

DefaultSmtpOptions adds a host-wide fall-back source bound from configuration section Notifier:Email:Smtp, so a single env-var pair routes every Email channel through the dev Mailpit container.

Precedence

EmailChannelAdapter.TryResolveSmtpConfig layers the two sources in this order:

notify.channels rowDefaultSmtpOptionsFallbackOnlyOutcome
smtpHost set(any)true (default)Row wins entirely (per-tenant isolation intact).
smtpHost setHost setfalseField-by-field merge: row provides what it has, defaults backfill the rest.
smtpHost emptyHost set(any)Defaults used end-to-end.
smtpHost emptyHost empty(any)No SMTP available — adapter logs a warning and returns InvalidConfiguration. The delivery is not retried as a transient failure.

FallbackOnly=true is the default. Set FallbackOnly=false in dev when you want a partially-populated tenant row (e.g. one that declares a custom From address but no SMTP server) to inherit the dev relay.

Federation bundle notifications

Concelier federation bundle events are emitted before most local dev stacks have tenant Email channel rows. For that path, the federation email dispatcher uses the same Notifier:Email:Smtp defaults to create a synthetic in-memory Email channel when no enabled persisted Email channel exists. Persisted tenant channels still win; the synthetic channel is only a dev/e2e escape hatch so Mailpit can capture the rendered bundle-ready email without a database seed step.

Activating Mailpit in dev

Mailpit is already included in devops/compose/docker-compose.dev.yml and exposes:

To route the Notifier worker through Mailpit, add the override file that ships alongside this doc:

docker compose \
  -f devops/compose/docker-compose.dev.yml \
  -f devops/compose/docker-compose.stella-services.yml \
  -f devops/compose/docker-compose.notifier-mailpit.override.yml \
  --profile mailpit \
  up -d mailpit notifier-worker

The override sets these env vars on notifier-worker:

NOTIFIER__EMAIL__SMTP__HOST=mail.stella-ops.local
NOTIFIER__EMAIL__SMTP__PORT=1025
NOTIFIER__EMAIL__SMTP__FROMADDRESS=stellaops@stella-ops.local
NOTIFIER__EMAIL__SMTP__FROMNAME=StellaOps (dev)
NOTIFIER__EMAIL__SMTP__ENABLESSL=false
NOTIFIER__EMAIL__SMTP__FALLBACKONLY=true

The worker strips the NOTIFIER_ prefix before binding, so the keys land in IConfiguration as Notifier:Email:Smtp:Host, :Port, etc. — the section name on DefaultSmtpOptions.

Running Notifier outside Docker

If you run the worker on the host (dotnet run --project src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker), export the same env-var path before launching:

export NOTIFIER_NOTIFIER__EMAIL__SMTP__HOST=mail.stella-ops.local
export NOTIFIER_NOTIFIER__EMAIL__SMTP__PORT=1025
export NOTIFIER_NOTIFIER__EMAIL__SMTP__FROMADDRESS=stellaops@stella-ops.local

Notice the double NOTIFIER_NOTIFIER_ — the outer NOTIFIER_ is the prefix the worker’s environment-variable provider strips; the inner NOTIFIER is the literal first segment of the configuration path. Or use appsettings.Development.json:

{
  "Notifier": {
    "Email": {
      "Smtp": {
        "Host": "mail.stella-ops.local",
        "Port": 1025,
        "FromAddress": "stellaops@stella-ops.local",
        "FromName": "StellaOps (dev)",
        "EnableSsl": false
      }
    }
  }
}

Inspecting rendered emails

Once a notification fires (e.g. a Concelier federation export emits bundle.ready or a vex risk-event fires), open http://mail.stella-ops.local:8025to see the captured message. Mailpit retains the most recent 5000 messages.

Production guidance

In production, leave DefaultSmtpOptions unset or use it only as a tenant- neutral last-resort relay. The intended posture is:

If you do set host-wide defaults in production, keep FallbackOnly=true (the default) to preserve per-tenant isolation.