From 2fc2ae3be035ad296834fbcf7e9dc93d22c8898d Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:45:22 -0300 Subject: [PATCH 1/5] fix(deploy): disable Npgsql GSS encryption in the compose connection 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. --- deploy/docker/docker-compose.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 0d5e575432..a89c7a39f4 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -108,7 +108,8 @@ services: postgres: { condition: service_healthy } environment: DOTNET_ENVIRONMENT: Production - DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD} + # The chiseled image has no libgssapi_krb5; Npgsql's default GSS Prefer logs a load failure per start. + DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD};GSS Encryption Mode=Disable CachingOptions__Redis: redis,password=${REDIS_PASSWORD} JwtOptions__SigningKey: ${JWT_SIGNING_KEY:?JWT_SIGNING_KEY is required} Seed__DefaultAdminPassword: ${SEED_ADMIN_PASSWORD:?SEED_ADMIN_PASSWORD is required} @@ -130,7 +131,8 @@ services: environment: DOTNET_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 - DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD} + # Same chiseled-image reason as the migrator: no libgssapi_krb5, so skip the GSS attempt. + DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD};GSS Encryption Mode=Disable CachingOptions__Redis: redis,password=${REDIS_PASSWORD} JwtOptions__SigningKey: ${JWT_SIGNING_KEY} Seed__DefaultAdminPassword: ${SEED_ADMIN_PASSWORD} From 7d7e96ae50a48738b03bcfcbc9fb7f8553034d51 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:53:41 -0300 Subject: [PATCH 2/5] fix(mailing): make the SMTP connection security configurable 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. --- src/BuildingBlocks/Mailing/MailOptions.cs | 8 +- .../Mailing/Services/SmtpMailService.cs | 3 +- .../Mailing/SmtpConnectionSecurityTests.cs | 202 ++++++++++++++++++ 3 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs diff --git a/src/BuildingBlocks/Mailing/MailOptions.cs b/src/BuildingBlocks/Mailing/MailOptions.cs index 9657cc19fd..f5a7fcb848 100644 --- a/src/BuildingBlocks/Mailing/MailOptions.cs +++ b/src/BuildingBlocks/Mailing/MailOptions.cs @@ -1,4 +1,6 @@ -namespace FSH.Framework.Mailing; +using MailKit.Security; + +namespace FSH.Framework.Mailing; public sealed class MailOptions { @@ -15,6 +17,10 @@ public sealed class SmtpOptions public int Port { get; set; } public string? UserName { get; set; } public string? Password { get; set; } + + // StartTls keeps every existing deployment's behaviour; a local catcher that speaks plain SMTP + // (Mailpit, MailHog, smtp4dev) needs None, and implicit-TLS port 465 needs SslOnConnect. + public SecureSocketOptions Security { get; set; } = SecureSocketOptions.StartTls; } public sealed class SendGridOptions diff --git a/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs index de4592fddb..7f109dad0a 100644 --- a/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs +++ b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs @@ -1,4 +1,3 @@ -using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MimeKit; @@ -136,7 +135,7 @@ private async Task SendEmailAsync(MimeMessage email, CancellationToken ct) try { - await client.ConnectAsync(_settings.Smtp!.Host!, _settings.Smtp.Port, SecureSocketOptions.StartTls, ct); + await client.ConnectAsync(_settings.Smtp!.Host!, _settings.Smtp.Port, _settings.Smtp.Security, ct); if (!string.IsNullOrWhiteSpace(_settings.Smtp.UserName) && !string.IsNullOrWhiteSpace(_settings.Smtp.Password)) { diff --git a/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs b/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs new file mode 100644 index 0000000000..7505373db1 --- /dev/null +++ b/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using FSH.Framework.Mailing; +using FSH.Framework.Mailing.Services; +using MailKit.Security; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Framework.Tests.Mailing; + +// MailOptions:Smtp:Security decides how SmtpMailService opens the connection. The connect call lives +// inside the service with a MailKit client it constructs itself, so the only honest observation point +// is a real socket: a loopback listener that speaks plain SMTP and, like Mailpit, never offers STARTTLS. +public sealed class SmtpConnectionSecurityTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); + + private static ServiceProvider BuildProvider(int port, string? security) + { + var settings = new Dictionary + { + ["MailOptions:UseSendGrid"] = "false", + ["MailOptions:From"] = "noreply@x.com", + ["MailOptions:Smtp:Host"] = "127.0.0.1", + ["MailOptions:Smtp:Port"] = port.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + if (security is not null) + { + settings["MailOptions:Smtp:Security"] = security; + } + + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection(settings).Build()); + services.AddLogging(); + services.AddHeroMailing(); + return services.BuildServiceProvider(); + } + + private static MailRequest Request() => + new(to: ["dest@x.com"], subject: "Confirm your e-mail", body: "

hi

", from: "sender@x.com"); + + #region Binding + + [Fact] + public void Security_Should_DefaultToStartTls_When_NotConfigured() + { + // Arrange + using var provider = BuildProvider(port: 587, security: null); + + // Act + SecureSocketOptions security = provider.GetRequiredService>().Value.Smtp!.Security; + + // Assert — every deployment configured before the option existed keeps connecting with STARTTLS. + security.ShouldBe(SecureSocketOptions.StartTls); + } + + [Theory] + [InlineData("None", SecureSocketOptions.None)] + [InlineData("SslOnConnect", SecureSocketOptions.SslOnConnect)] + [InlineData("StartTlsWhenAvailable", SecureSocketOptions.StartTlsWhenAvailable)] + public void Security_Should_BindByName_When_Configured(string configured, SecureSocketOptions expected) + { + // Arrange + using var provider = BuildProvider(port: 587, security: configured); + + // Act + SecureSocketOptions security = provider.GetRequiredService>().Value.Smtp!.Security; + + // Assert + security.ShouldBe(expected); + } + + #endregion + + #region Connection + + [Fact] + public async Task SendAsync_Should_DeliverOverPlainSmtp_When_SecurityIsNone() + { + // Arrange + using var cts = new CancellationTokenSource(Timeout); + using var server = PlainSmtpServer.Start(cts.Token); + using var provider = BuildProvider(server.Port, security: "None"); + IMailService mail = provider.GetRequiredService(); + + // Act + await mail.SendAsync(Request(), cts.Token); + IReadOnlyList commands = await server.CompletedSessionAsync(cts.Token); + + // Assert — the envelope and the message reached the server, which is what a local catcher needs. + commands.ShouldContain(c => c.StartsWith("MAIL FROM:", StringComparison.Ordinal)); + commands.ShouldContain(c => c.StartsWith("RCPT TO:", StringComparison.Ordinal)); + server.Data.ShouldContain("Subject: Confirm your e-mail"); + server.ClientHungUp.ShouldBeFalse(); + commands[^1].ShouldBe("QUIT"); + } + + [Fact] + public async Task SendAsync_Should_Fail_When_SecurityDefaultsToStartTlsAndTheServerOffersNone() + { + // Arrange + using var cts = new CancellationTokenSource(Timeout); + using var server = PlainSmtpServer.Start(cts.Token); + using var provider = BuildProvider(server.Port, security: null); + IMailService mail = provider.GetRequiredService(); + + // Act + InvalidOperationException ex = await Should.ThrowAsync( + () => mail.SendAsync(Request(), cts.Token)); + IReadOnlyList commands = await server.CompletedSessionAsync(cts.Token); + + // Assert — the pre-option behaviour, kept as the default: a plain catcher is refused before any + // envelope is sent, so nothing is delivered. + ex.InnerException.ShouldBeOfType(); + commands.ShouldNotContain(c => c.StartsWith("MAIL FROM", StringComparison.Ordinal)); + } + + #endregion + + // One-connection SMTP server: greets, answers 250 to everything (so EHLO advertises no extension, + // STARTTLS included), takes one DATA block and ends on QUIT or when the client hangs up. + private sealed class PlainSmtpServer : IDisposable + { + private readonly TcpListener _listener; + private readonly List _commands = []; + private readonly StringBuilder _data = new(); + private Task _session = Task.CompletedTask; + + private PlainSmtpServer(TcpListener listener) => _listener = listener; + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + public string Data => _data.ToString(); + + public bool ClientHungUp { get; private set; } + + public static PlainSmtpServer Start(CancellationToken ct) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var server = new PlainSmtpServer(listener); + server._session = server.RunAsync(ct); + return server; + } + + public async Task> CompletedSessionAsync(CancellationToken ct) + { + await _session.WaitAsync(ct); + return _commands; + } + + private async Task RunAsync(CancellationToken ct) + { + using TcpClient client = await _listener.AcceptTcpClientAsync(ct); + await using NetworkStream stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII); + await using var writer = new StreamWriter(stream, Encoding.ASCII) { NewLine = "\r\n", AutoFlush = true }; + + try + { + await writer.WriteLineAsync("220 localhost ESMTP test"); + while (await reader.ReadLineAsync(ct) is { } line) + { + _commands.Add(line); + string verb = line.Split(' ', 2)[0].ToUpperInvariant(); + if (verb == "QUIT") + { + await writer.WriteLineAsync("221 bye"); + return; + } + + if (verb == "DATA") + { + await writer.WriteLineAsync("354 end with ."); + await ReadDataAsync(reader, ct); + } + + await writer.WriteLineAsync("250 ok"); + } + } + // A client dropping the socket mid-dialogue is the refused-connection path under test, not a + // server fault: the commands recorded up to that point are the evidence. + catch (IOException) + { + ClientHungUp = true; + } + } + + private async Task ReadDataAsync(StreamReader reader, CancellationToken ct) + { + while (await reader.ReadLineAsync(ct) is { } line && line != ".") + { + _data.AppendLine(line); + } + } + + // Both tests await the session, so its outcome is observed there; disposing only frees the port. + public void Dispose() => _listener.Dispose(); + } +} From c72fea63375d7c1538e2764bcca8f7b11277d056 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:59:38 -0300 Subject: [PATCH 3/5] feat(deploy): ship Mailpit so the compose stack can deliver e-mail 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. --- deploy/docker/.env.example | 4 ++++ deploy/docker/README.md | 15 +++++++++++++-- deploy/docker/docker-compose.yml | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 169155e5d4..31b9cef010 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -24,6 +24,10 @@ FSH_API_PORT=8080 FSH_ADMIN_PORT=8081 FSH_DASHBOARD_PORT=8082 +# Mailpit inbox UI (catches every e-mail the API sends). Bound to the +# host loopback only: open http://localhost:8025 on the Docker host. +FSH_MAILPIT_PORT=8025 + # ── Secrets ───────────────────────────────────────────────────────── # JWT signing key — must be 32+ chars and NOT contain the substring # "replace-with" (the framework's placeholder detector blocks that). diff --git a/deploy/docker/README.md b/deploy/docker/README.md index f4cfbfc304..881297f4e3 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -11,6 +11,7 @@ This brings up the full stack on a single host: | `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas | | `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | | `rustfs` | `rustfs/rustfs:1.0.0` | (internal) | S3-compatible blob store for the Files module ([RustFS](https://rustfs.com)) | +| `mailpit` | `axllent/mailpit:v1.31.3` | `127.0.0.1:FSH_MAILPIT_PORT` (default 8025), SMTP 1025 internal | Local mail catcher: every e-mail the API sends lands here ([Mailpit](https://mailpit.axllent.org)) | The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine. @@ -18,7 +19,7 @@ The compose file does **not** include a reverse proxy or TLS terminator. You bri - Docker Engine 24+ with the Compose plugin (`docker compose version` should print v2.x). - 2 GB free RAM, 5 GB disk for first-run images + builds. -- Ports 8080–8082 free on the host (or set custom ports in `.env`). +- Ports 8080–8082 and 8025 free on the host (or set custom ports in `.env`). ## Five-minute deploy @@ -46,6 +47,14 @@ curl -fsS http://localhost:8081/config.json # admin runtime config — shows F curl -fsSI http://localhost:8082/ | head -1 # dashboard SPA ``` +## Reading e-mail + +The API sends every e-mail (confirmation, password reset, welcome) to the bundled `mailpit` service, so nothing leaves the host and no SMTP account is needed. Open **http://localhost:8025** on the Docker host to read them and follow the links. A user an operator registers must confirm the e-mail before signing in, and the confirmation link is in that inbox. + +The inbox UI is published on the host loopback only, because it holds live password-reset and confirmation links. From another machine, use an SSH tunnel (`ssh -L 8025:127.0.0.1:8025 `). + +To deliver real mail, set `MailOptions__Smtp__Host`, `MailOptions__Smtp__Port`, `MailOptions__Smtp__UserName`, `MailOptions__Smtp__Password` and `MailOptions__Smtp__Security` on the `api` service to your provider's values (`StartTls` for port 587, `SslOnConnect` for 465), and remove the `mailpit` service and its `depends_on` entry. + ## Wire up your external proxy Point three TLS subdomains at the published ports: @@ -96,7 +105,7 @@ Single-host compose is the default story; production deployments often point at 1. Comment out the `postgres` / `redis` / `rustfs` service blocks (and `rustfs-init`) AND remove them from the `depends_on:` of `api` and `migrator`. 2. Swap the matching env vars on `api` and `migrator`: - - `DatabaseOptions__ConnectionString` → your managed Postgres connection string + - `DatabaseOptions__ConnectionString` → your managed Postgres connection string (keep `GSS Encryption Mode=Disable` unless your server uses Kerberos: the chiseled images have no `libgssapi_krb5`) - `CachingOptions__Redis` → your managed Redis connection string (`host:port,password=...,ssl=True` etc.) - `Storage__Provider`, `Storage__S3__*` → your S3-compatible store 3. `docker compose up -d`. @@ -112,4 +121,6 @@ The data-plane volumes (`pg_data`, `redis_data`, `rustfs_data`) can be deleted o | `OptionsValidationException: SigningKey looks like a sample placeholder` | `JWT_SIGNING_KEY` contains `replace-with` (the framework's placeholder detector). Generate a real key: `openssl rand -base64 48`. | | API up but admin shows a CORS error | `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` in `.env` doesn't match what your external proxy serves. Both go on the CORS allow-list. | | A reset or confirmation e-mail links to the API instead of the app | Same cause: `FSH_ADMIN_URL` / `FSH_DASHBOARD_URL` don't match the origins the browser actually uses. Both also feed `FrontendOptions__AllowedOrigins`, and `FSH_DASHBOARD_URL` feeds `FrontendOptions__DefaultOrigin`. | +| No e-mail arrives, or the API logs `The SMTP server does not support the STARTTLS extension` | `MailOptions__Smtp__Security` does not match the server. The bundled Mailpit needs `None`; a provider on port 587 needs `StartTls`, on 465 `SslOnConnect`. | +| `Cannot load library libgssapi_krb5.so.2` in the API or migrator log | A connection string without `GSS Encryption Mode=Disable`. Npgsql tries GSS encryption by default and the chiseled images do not ship the Kerberos library. Harmless, but append the setting to silence it. | | `migrator` retries Postgres for 2 minutes then fails | Postgres didn't come up — check `docker compose logs postgres`. Most often a `POSTGRES_PASSWORD` change against an existing `pg_data` volume; delete the volume with `docker compose down -v` (destructive) and start over. | diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index a89c7a39f4..08ab77bb03 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -6,6 +6,7 @@ # Operator owns the edge (TLS / subdomain routing). This file publishes # only the FSH services on host ports; the data plane (postgres/redis/ # rustfs) stays compose-internal unless you uncomment their ports blocks. +# The one exception is the Mailpit inbox UI, bound to the host loopback only. name: fsh @@ -97,6 +98,19 @@ services: aws --endpoint-url http://rustfs:9000 s3api head-bucket --bucket fsh 2>/dev/null || aws --endpoint-url http://rustfs:9000 s3api create-bucket --bucket fsh + # Local mail catcher: every e-mail the API sends (confirmation, password + # reset, welcome) lands here and never leaves the host. Read it at + # http://localhost:8025 (FSH_MAILPIT_PORT). For real delivery, point the + # api's MailOptions__Smtp__* at your provider and remove this service. + # SMTP (1025) stays on the compose network. + mailpit: + image: axllent/mailpit:v1.31.3 + container_name: fsh-mailpit + restart: unless-stopped + ports: + # Loopback only: the inbox holds live password-reset and confirmation links. + - "127.0.0.1:${FSH_MAILPIT_PORT:-8025}:8025" + migrator: build: context: ../.. @@ -128,6 +142,7 @@ services: rustfs: { condition: service_healthy } rustfs-init: { condition: service_completed_successfully } migrator: { condition: service_completed_successfully } + mailpit: { condition: service_started } environment: DOTNET_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 @@ -142,6 +157,10 @@ services: Storage__S3__SecretKey: ${RUSTFS_SECRET_KEY} Storage__S3__Bucket: fsh Storage__S3__ForcePathStyle: "true" + # Plain SMTP to the compose-local Mailpit, which offers no STARTTLS to upgrade to. + MailOptions__Smtp__Host: mailpit + MailOptions__Smtp__Port: "1025" + MailOptions__Smtp__Security: "None" AllowedHosts: "*" HangfireOptions__UserName: ${HANGFIRE_USERNAME:?HANGFIRE_USERNAME is required} HangfireOptions__Password: ${HANGFIRE_PASSWORD:?HANGFIRE_PASSWORD is required} From bb8d7b7fdb9fe4fde3ec23f7fb395c42480b500e Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:06:42 -0300 Subject: [PATCH 4/5] test(mailing): an unknown SMTP security name fails the binding 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. --- .../Mailing/SmtpConnectionSecurityTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs b/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs index 7505373db1..891a037ce2 100644 --- a/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs +++ b/src/Tests/Framework.Tests/Mailing/SmtpConnectionSecurityTests.cs @@ -72,6 +72,18 @@ public void Security_Should_BindByName_When_Configured(string configured, Secure security.ShouldBe(expected); } + [Fact] + public void Security_Should_FailToBind_When_TheNameIsUnknown() + { + // Arrange — a typo must stop the options from binding rather than silently fall back to a + // mode the operator did not ask for. + using var provider = BuildProvider(port: 587, security: "Plain"); + IOptions options = provider.GetRequiredService>(); + + // Act + Assert + Should.Throw(() => options.Value); + } + #endregion #region Connection From 4e7e6c2f7951ee9d3d2d8c6382794802019229db Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:57:23 -0300 Subject: [PATCH 5/5] fix(deploy): give the compose API a sender address 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. --- deploy/docker/.env.example | 4 ++++ deploy/docker/README.md | 2 +- deploy/docker/docker-compose.yml | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 31b9cef010..f72923a3b5 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -28,6 +28,10 @@ FSH_DASHBOARD_PORT=8082 # host loopback only: open http://localhost:8025 on the Docker host. FSH_MAILPIT_PORT=8025 +# Sender address on every e-mail the API sends. Use a domain your SMTP provider +# accepts once you point MailOptions__Smtp__* at real delivery. +FSH_MAIL_FROM=no-reply@fsh.local + # ── Secrets ───────────────────────────────────────────────────────── # JWT signing key — must be 32+ chars and NOT contain the substring # "replace-with" (the framework's placeholder detector blocks that). diff --git a/deploy/docker/README.md b/deploy/docker/README.md index 881297f4e3..ebba4e46b2 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -53,7 +53,7 @@ The API sends every e-mail (confirmation, password reset, welcome) to the bundle The inbox UI is published on the host loopback only, because it holds live password-reset and confirmation links. From another machine, use an SSH tunnel (`ssh -L 8025:127.0.0.1:8025 `). -To deliver real mail, set `MailOptions__Smtp__Host`, `MailOptions__Smtp__Port`, `MailOptions__Smtp__UserName`, `MailOptions__Smtp__Password` and `MailOptions__Smtp__Security` on the `api` service to your provider's values (`StartTls` for port 587, `SslOnConnect` for 465), and remove the `mailpit` service and its `depends_on` entry. +To deliver real mail, set `MailOptions__Smtp__Host`, `MailOptions__Smtp__Port`, `MailOptions__Smtp__UserName`, `MailOptions__Smtp__Password` and `MailOptions__Smtp__Security` on the `api` service to your provider's values (`StartTls` for port 587, `SslOnConnect` for 465), set `FSH_MAIL_FROM` in `.env` to a sender your provider accepts, and remove the `mailpit` service and its `depends_on` entry. ## Wire up your external proxy diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 08ab77bb03..aff473ae7c 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -161,6 +161,8 @@ services: MailOptions__Smtp__Host: mailpit MailOptions__Smtp__Port: "1025" MailOptions__Smtp__Security: "None" + # appsettings.Production.json leaves the sender blank, and a real provider rejects an empty MAIL FROM. + MailOptions__From: ${FSH_MAIL_FROM:-no-reply@fsh.local} AllowedHosts: "*" HangfireOptions__UserName: ${HANGFIRE_USERNAME:?HANGFIRE_USERNAME is required} HangfireOptions__Password: ${HANGFIRE_PASSWORD:?HANGFIRE_PASSWORD is required}