Dev SMTP Loop (Mailpit Fallback)

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

Source: Sprint 20260513_002 gap-fix G5; retargeted onto notify-worker by SPRINT_20260722_015 NTF-10 when the predecessor notifier-worker retired. Status: Implemented (StellaOps.Notify.Delivery.Channels.DefaultSmtpOptions)

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

The delivery runtime’s 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 Notify 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.notify.yml \
  -f devops/compose/docker-compose.notify-activation.yml \
  -f devops/compose/docker-compose.notifier-mailpit.override.yml \
  --profile mailpit \
  up -d mailpit notify-worker

The override sets these env vars on notify-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 section name is Notifier:Email:Smtp and does not change — it is DefaultSmtpOptions.SectionName, and the delivery library kept its configuration contract across the host merge. The env-var spelling did change with the host: the retired predecessor bound environment variables under a NOTIFIER_ prefix, so every setting had to be written twice. notify-worker adds no prefix, so the plain Notifier__Email__Smtp__* form above is the only one that binds.

Running the worker outside Docker

If you run the worker on the host (dotnet run --project src/Notify/StellaOps.Notify.Worker), export the same keys before launching:

export Notifier__Email__Smtp__Host=mail.stella-ops.local
export Notifier__Email__Smtp__Port=1025
export Notifier__Email__Smtp__FromAddress=stellaops@stella-ops.local

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.