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-workerbySPRINT_20260722_015NTF-10 when the predecessornotifier-workerretired. 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 row | DefaultSmtpOptions | FallbackOnly | Outcome |
|---|---|---|---|
smtpHost set | (any) | true (default) | Row wins entirely (per-tenant isolation intact). |
smtpHost set | Host set | false | Field-by-field merge: row provides what it has, defaults backfill the rest. |
smtpHost empty | Host set | (any) | Defaults used end-to-end. |
smtpHost empty | Host 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:
- SMTP:
mail.stella-ops.local:1025(aliasmailpit.stella-ops.local) - Web UI:
http://mail.stella-ops.local:8025
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:
- Tenant channels declare their own
smtpHost+fromAddressinnotify.channels.config.properties. Notifier:Email:Smtpenv vars are unset, so any missing-host channel reportsInvalidConfigurationinstead of silently delivering through a shared relay.
If you do set host-wide defaults in production, keep FallbackOnly=true (the default) to preserve per-tenant isolation.
Related
- Notify module overview
- E2E Notify-channel-coverage harness — the e2e harness drives Email deliveries through Mailpit the same way.
