Skip to content

fix(deploy): working e-mail in the compose stack (Mailpit + MailOptions:Smtp:Security) and no GSS load noise - #1409

Open
marcelo-maciel wants to merge 6 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/compose-gss-and-local-mail
Open

marcelo-maciel wants to merge 6 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/compose-gss-and-local-mail

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1408. Docs: fullstackhero/docs#258.

Problem

Two defects in deploy/docker/docker-compose.yml, one of them rooted in src/BuildingBlocks/Mailing.

E-mail cannot work in the compose stack. The API inherits MailOptions:Smtp:Host = smtp.ethereal.email with empty credentials from appsettings.json, and nothing in the compose file overrides it. Every confirmation, password-reset and welcome e-mail fails with authentication Required, and a user an operator registers cannot sign in until someone confirms the address by hand (the operator's Confirm email action in the user detail page). The obvious fix, a local SMTP catcher, is not reachable either: SmtpMailService hardcodes SecureSocketOptions.StartTls in ConnectAsync, so any server that does not advertise STARTTLS is refused before the envelope is sent. That covers every local catcher (Mailpit, MailHog, smtp4dev) and implicit-TLS providers on port 465.

Every start logs a Kerberos load failure. The API and DbMigrator run on aspnet:10.0-noble-chiseled, which ships no libgssapi_krb5.so.2. Npgsql's GSS Encryption Mode defaults to Prefer (connection string parameters, "Security and encryption"), so the first connection tries to load the library and logs Cannot load library libgssapi_krb5.so.2 / libgssapi_krb5.so.2: cannot open shared object file. The connection then succeeds, so this is noise, but it reads like a failure to anyone looking at the log. Probe, run on a downstream compose stack with the same image before this branch was cut: a throwaway API container printed the line twice with the shipped connection string and zero times with ;GSS Encryption Mode=Disable appended, and started normally in both cases.

Fix

  • MailOptions:Smtp:Security (BuildingBlocks/Mailing). A new SecureSocketOptions Security property on SmtpOptions, bound by name (None, Auto, SslOnConnect, StartTls, StartTlsWhenAvailable) and passed to ConnectAsync. It defaults to StartTls, which is exactly what the hardcoded call did, so every existing configuration behaves the same. An unknown name makes the options fail to bind instead of falling back to a mode nobody asked for. There is no IValidateOptions<MailOptions> to keep in step, but AddHeroMailing already calls ValidateOnStart(), which forces the bind at startup: a throwaway host with MailOptions:Smtp:Security=Plain failed StartAsync with Failed to convert configuration value 'Plain' at 'MailOptions:Smtp:Security' to type 'MailKit.Security.SecureSocketOptions', so a typo stops the API from starting. No module code changes: the property is read only inside SmtpMailService.
  • Mailpit in the compose stack. axllent/mailpit:v1.31.3, the latest release (published 2026-09-27; the Docker Hub tag carries the v prefix, 1.31.3 does not exist). The API gets MailOptions__Smtp__Host: mailpit, MailOptions__Smtp__Port: "1025", MailOptions__Smtp__Security: "None" and a service_started dependency. SMTP 1025 is not published. The inbox UI is published as 127.0.0.1:${FSH_MAILPIT_PORT:-8025}:8025, loopback only, which is a deliberate departure from the other published ports: the inbox holds live password-reset and confirmation links for every account, including the root admin, so exposing it on all interfaces would hand an account takeover to anyone who can reach the host port. From another machine, an SSH tunnel reaches it.
  • Sender. MailOptions__From: ${FSH_MAIL_FROM:-no-reply@fsh.local} on the api service, and FSH_MAIL_FROM in .env.example. appsettings.Production.json sets MailOptions:From to "", and a probe against the test's SMTP listener shows what that sent: MAIL FROM:<> and an empty From: header. A catcher accepts it; a real provider rejects it.
  • GSS. Both compose connection strings (migrator and api) append ;GSS Encryption Mode=Disable, each with a one-line comment saying why.
  • README and .env.example. Services table row, a "Reading e-mail" section (inbox URL, why it is loopback-only, how to switch to a real provider), the GSS note in "Swapping in managed services", two troubleshooting rows, and FSH_MAILPIT_PORT.

Not changed, on purpose:

  • deploy/terraform/apps/starter/app_stack/main.tf builds its connection strings for the same chiseled images against RDS (SSL Mode=Require), so it may log the same GSS line. I could not exercise that path, so it is left alone and only mentioned here.
  • appsettings*.json does not gain a Security key: the code default already is the old behaviour.
  • appsettings.Production.json puts Host/Port/UserName/Password directly under MailOptions instead of under Smtp, where they bind to nothing. That is older than this PR and outside its scope.

Tests

New Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs (7 tests). SmtpMailService constructs its MailKit client itself, so the connect call can only be observed through a real socket. The test starts a loopback TcpListener that speaks minimal SMTP and, like Mailpit, never advertises STARTTLS:

  • Binding: Security defaults to StartTls when absent; None, SslOnConnect and StartTlsWhenAvailable bind by name; an unknown name (Plain) throws on binding.
  • Connection, through AddHeroMailing and the resolved IMailService: with Security=None the send completes, and the listener records MAIL FROM:<sender@x.com>, RCPT TO:<dest@x.com>, the subject in DATA, and a clean QUIT. With Security absent, the send throws InvalidOperationException wrapping MailKit's NotSupportedException, and no MAIL FROM reaches the server. That is the default kept exactly as it was.

Mutation gate: with only SmtpMailService.cs reverted to main (hardcoded StartTls), the filtered run (taken before the seventh test, the unknown-name binding one, was added) is Failed: 1, Passed: 5, Total: 6. The failing test is SendAsync_Should_DeliverOverPlainSmtp_When_SecurityIsNone, with The SMTP server does not support the STARTTLS extension. After restoring the fix the run is Passed: 6, and the file's sha256 is identical before and after. The binding tests are not covered by that revert, since MailOptions.cs stays in place so the project compiles.

Suites, -c Release, each run on its own, every test project in src/Tests (the BuildingBlocks rule), all exit 0:

Project Passed / Total
Architecture.Tests 55 / 55
Auditing.Tests 66 / 66
Billing.Tests 125 / 125
Caching.Tests 31 / 31
Catalog.Tests 65 / 65
Chat.Tests 36 / 36
Files.Tests 23 / 23
Framework.Tests 251 / 251
Generic.Tests 43 / 43
Identity.Tests 319 / 319
Integration.Middleware.Tests 5 / 5
Integration.Tests 764 / 764 (Testcontainers; boots the API, so MailOptions binds at startup)
Multitenancy.Tests 92 / 92
Webhooks.Tests 69 / 69

Compose: docker compose -f deploy/docker/docker-compose.yml --env-file <dummy values> config -q exits 0. The rendered mailpit service publishes only 127.0.0.1:8025->8025 (and 18025 when FSH_MAILPIT_PORT=18025), with no mapping for 1025. The rendered api has the three MailOptions__Smtp__* values, MailOptions__From: no-reply@fsh.local, depends_on.mailpit: service_started, and both connection strings end in ;GSS Encryption Mode=Disable. The stack itself was not brought up in this PR.

…strings

The API and DbMigrator images are .NET chiseled and ship no
libgssapi_krb5.so.2. Npgsql defaults GSS Encryption Mode to Prefer, so
the first connection tries to load the library and logs "Cannot load
library libgssapi_krb5.so.2" on every start. The compose Postgres does
not use Kerberos, so disable the attempt explicitly.
SmtpMailService hardcoded SecureSocketOptions.StartTls in ConnectAsync,
so any server that does not offer STARTTLS was refused before the
envelope, including every local catcher (Mailpit, MailHog, smtp4dev)
and implicit-TLS port 465.

Add MailOptions:Smtp:Security (MailKit SecureSocketOptions, bound by
name). It defaults to StartTls, so existing configuration keeps the
exact same behaviour.

The new tests drive the real connect path against a loopback SMTP
listener that never advertises STARTTLS: Security=None delivers, and
the default is still refused with the MailKit NotSupportedException.
The compose API inherited the smtp.ethereal.email host with empty
credentials, so every confirmation, password-reset and welcome e-mail
failed ("authentication Required") and a user registered by an
operator could not sign in until someone confirmed the address by hand.

Add a pinned Mailpit (axllent/mailpit:v1.31.3) and point the API at it
over plain SMTP (MailOptions__Smtp__Security=None). SMTP 1025 stays on
the compose network; the inbox UI is published on the host loopback
only (FSH_MAILPIT_PORT, default 8025) because it holds live reset and
confirmation links. The README documents reading the inbox, switching
to a real provider, and the GSS log line.
A typo in MailOptions:Smtp:Security must stop the options from binding
(and, through ValidateOnStart, the host from starting) instead of
falling back to a mode the operator did not ask for.
appsettings.Production.json blanks MailOptions:From, so every e-mail went out as
MAIL FROM:<> with an empty From header. Mailpit accepts that, a real provider
rejects it. FSH_MAIL_FROM (default no-reply@fsh.local) now feeds MailOptions__From.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@iammukeshm iammukeshm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MailKit Security option is the right fix, and I'm happy to take it in BuildingBlocks/Mailing: the default is unchanged, a typo fails at startup, and the socket-level tests are solid. GSS Encryption Mode=Disable and the loopback-only inbox are both good calls.

What I want changed is how the compose side wires mail, because this compose file is our production single-host path:

  1. Don't hardcode the SMTP target to Mailpit. With MailOptions__Smtp__Host: mailpit fixed in the file, a production operator who forgets to edit the compose file has password resets "succeed" into a local catcher. The failure moves from a loud authentication Required in the logs to silent non-delivery. Please make it env-driven with Mailpit defaults: FSH_SMTP_HOST (default mailpit), FSH_SMTP_PORT (1025), FSH_SMTP_SECURITY (None), FSH_SMTP_USERNAME, FSH_SMTP_PASSWORD, all in .env.example. Switching to a real provider then means editing .env only, never the compose file.
  2. Put Mailpit behind a compose profile (profiles: [mail-catcher], with the api's depends_on on it removed), or at least have the README's first-deploy section say plainly that mail goes to a catcher by default. I prefer the profile. Then a stack with a real FSH_SMTP_HOST doesn't run an inbox full of reset links it never needed.
  3. It now conflicts with #1402 in docker-compose.yml / .env.example / README.md. Please merge main in.

Also noted from your write-up, both separate from this PR:

  • appsettings.Production.json nests the SMTP keys directly under MailOptions instead of MailOptions:Smtp. That's a real bug and deserves its own issue.
  • The Terraform GSS line: fine to leave alone here.

…ocal-mail

# Conflicts:
#	deploy/docker/README.md
#	deploy/docker/docker-compose.yml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docker Compose: no e-mail can be delivered (hardcoded STARTTLS, no SMTP) and every start logs a libgssapi load failure

2 participants