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
Conversation
…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.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
iammukeshm
requested changes
Sep 28, 2026
iammukeshm
left a comment
Member
There was a problem hiding this comment.
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:
- Don't hardcode the SMTP target to Mailpit. With
MailOptions__Smtp__Host: mailpitfixed 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 loudauthentication Requiredin the logs to silent non-delivery. Please make it env-driven with Mailpit defaults:FSH_SMTP_HOST(defaultmailpit),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.envonly, never the compose file. - Put Mailpit behind a compose profile (
profiles: [mail-catcher], with the api'sdepends_onon 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 realFSH_SMTP_HOSTdoesn't run an inbox full of reset links it never needed. - It now conflicts with #1402 in
docker-compose.yml/.env.example/README.md. Please mergemainin.
Also noted from your write-up, both separate from this PR:
appsettings.Production.jsonnests the SMTP keys directly underMailOptionsinstead ofMailOptions: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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1408. Docs: fullstackhero/docs#258.
Problem
Two defects in
deploy/docker/docker-compose.yml, one of them rooted insrc/BuildingBlocks/Mailing.E-mail cannot work in the compose stack. The API inherits
MailOptions:Smtp:Host = smtp.ethereal.emailwith empty credentials fromappsettings.json, and nothing in the compose file overrides it. Every confirmation, password-reset and welcome e-mail fails withauthentication 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:SmtpMailServicehardcodesSecureSocketOptions.StartTlsinConnectAsync, 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 nolibgssapi_krb5.so.2. Npgsql'sGSS Encryption Modedefaults toPrefer(connection string parameters, "Security and encryption"), so the first connection tries to load the library and logsCannot 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=Disableappended, and started normally in both cases.Fix
MailOptions:Smtp:Security(BuildingBlocks/Mailing). A newSecureSocketOptions Securityproperty onSmtpOptions, bound by name (None,Auto,SslOnConnect,StartTls,StartTlsWhenAvailable) and passed toConnectAsync. It defaults toStartTls, 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 noIValidateOptions<MailOptions>to keep in step, butAddHeroMailingalready callsValidateOnStart(), which forces the bind at startup: a throwaway host withMailOptions:Smtp:Security=PlainfailedStartAsyncwithFailed 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 insideSmtpMailService.axllent/mailpit:v1.31.3, the latest release (published 2026-09-27; the Docker Hub tag carries thevprefix,1.31.3does not exist). The API getsMailOptions__Smtp__Host: mailpit,MailOptions__Smtp__Port: "1025",MailOptions__Smtp__Security: "None"and aservice_starteddependency. SMTP 1025 is not published. The inbox UI is published as127.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.MailOptions__From: ${FSH_MAIL_FROM:-no-reply@fsh.local}on theapiservice, andFSH_MAIL_FROMin.env.example.appsettings.Production.jsonsetsMailOptions:Fromto"", and a probe against the test's SMTP listener shows what that sent:MAIL FROM:<>and an emptyFrom:header. A catcher accepts it; a real provider rejects it.migratorandapi) append;GSS Encryption Mode=Disable, each with a one-line comment saying why..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, andFSH_MAILPIT_PORT.Not changed, on purpose:
deploy/terraform/apps/starter/app_stack/main.tfbuilds 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*.jsondoes not gain aSecuritykey: the code default already is the old behaviour.appsettings.Production.jsonputsHost/Port/UserName/Passworddirectly underMailOptionsinstead of underSmtp, where they bind to nothing. That is older than this PR and outside its scope.Tests
New
Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs(7 tests).SmtpMailServiceconstructs its MailKit client itself, so the connect call can only be observed through a real socket. The test starts a loopbackTcpListenerthat speaks minimal SMTP and, like Mailpit, never advertises STARTTLS:Securitydefaults toStartTlswhen absent;None,SslOnConnectandStartTlsWhenAvailablebind by name; an unknown name (Plain) throws on binding.AddHeroMailingand the resolvedIMailService: withSecurity=Nonethe send completes, and the listener recordsMAIL FROM:<sender@x.com>,RCPT TO:<dest@x.com>, the subject inDATA, and a cleanQUIT. WithSecurityabsent, the send throwsInvalidOperationExceptionwrapping MailKit'sNotSupportedException, and noMAIL FROMreaches the server. That is the default kept exactly as it was.Mutation gate: with only
SmtpMailService.csreverted tomain(hardcodedStartTls), the filtered run (taken before the seventh test, the unknown-name binding one, was added) isFailed: 1, Passed: 5, Total: 6. The failing test isSendAsync_Should_DeliverOverPlainSmtp_When_SecurityIsNone, withThe SMTP server does not support the STARTTLS extension. After restoring the fix the run isPassed: 6, and the file's sha256 is identical before and after. The binding tests are not covered by that revert, sinceMailOptions.csstays in place so the project compiles.Suites,
-c Release, each run on its own, every test project insrc/Tests(the BuildingBlocks rule), all exit 0:MailOptionsbinds at startup)Compose:
docker compose -f deploy/docker/docker-compose.yml --env-file <dummy values> config -qexits 0. The renderedmailpitservice publishes only127.0.0.1:8025->8025(and18025whenFSH_MAILPIT_PORT=18025), with no mapping for 1025. The renderedapihas the threeMailOptions__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.