From f283c90f113d03626cb5013e1b154e2f94d19655 Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Sat, 15 Aug 2026 12:35:44 +0100 Subject: [PATCH] updated layout for webmail and added http dns letsencrypt --- README.md | 54 ++- docker-deploy/.env.example | 1 + docker-deploy/Dockerfile | 6 +- docker-deploy/Dockerfile.rspamd | 4 +- docker-deploy/README.md | 30 +- docker-deploy/docker-compose.yml | 10 +- internal/acmecert/acmecert_test.go | 157 +++++++ internal/acmecert/http01server.go | 82 ++++ internal/acmecert/http01server_test.go | 88 ++++ internal/acmecert/manager.go | 192 +++++++-- internal/acmecert/wanip.go | 36 ++ internal/config/config.go | 89 +++- internal/config/config_test.go | 40 ++ internal/db/count_messages_by_folder_test.go | 44 ++ internal/db/crud_mailbox_messages.go | 86 +++- internal/db/crud_mailboxes.go | 15 +- internal/db/mailbox_models.go | 4 + internal/db/schema.go | 19 +- internal/db/smime_legacy_migration_test.go | 50 +++ internal/mailstore/mailstore_test.go | 78 ++++ internal/mailstore/store.go | 74 +++- internal/relay/bounce.go | 102 +++++ internal/relay/bounce_test.go | 98 +++++ internal/relay/relay.go | 17 +- internal/smtpserver/bounce_test.go | 101 +++++ internal/smtpserver/log_privacy_test.go | 86 ++++ internal/smtpserver/mailbox_delivery_test.go | 47 ++ internal/smtpserver/session.go | 75 +++- internal/webui/letsencrypt.go | 74 +++- internal/webui/render.go | 31 +- internal/webui/templates/letsencrypt.html | 291 ++++++++----- internal/webui/templates/logs.html | 29 +- internal/webui/templates/settings.html | 33 +- .../webui/templates/view_message_content.html | 101 +++-- internal/webui/templates/webmail_account.html | 20 + internal/webui/templates/webmail_folder.html | 403 ++++++++++++------ .../webui/templates/webmail_message_pane.html | 91 ++++ internal/webui/view_message.go | 62 ++- internal/webui/view_message_content_test.go | 87 ++++ internal/webui/webmail_account.go | 40 ++ internal/webui/webmail_bounce_test.go | 74 ++++ internal/webui/webmail_client_test.go | 13 +- internal/webui/webmail_compose.go | 31 +- internal/webui/webmail_mail.go | 219 +++++++++- internal/webui/webmail_pane_bulk_test.go | 153 +++++++ internal/webui/webmail_search_test.go | 220 +++++++++- internal/webui/webui.go | 14 +- internal/webui/webui_test.go | 9 +- main.go | 110 +++-- 49 files changed, 3359 insertions(+), 431 deletions(-) create mode 100644 internal/acmecert/http01server.go create mode 100644 internal/acmecert/http01server_test.go create mode 100644 internal/acmecert/wanip.go create mode 100644 internal/db/count_messages_by_folder_test.go create mode 100644 internal/db/smime_legacy_migration_test.go create mode 100644 internal/relay/bounce.go create mode 100644 internal/relay/bounce_test.go create mode 100644 internal/smtpserver/bounce_test.go create mode 100644 internal/smtpserver/log_privacy_test.go create mode 100644 internal/webui/templates/webmail_message_pane.html create mode 100644 internal/webui/view_message_content_test.go create mode 100644 internal/webui/webmail_bounce_test.go create mode 100644 internal/webui/webmail_pane_bulk_test.go diff --git a/README.md b/README.md index db38425..1016408 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,11 @@ a Python venv + separate services. filter-rule management, and PGP (OpenPGP encrypt/decrypt/sign/verify) and S/MIME (sign/verify) support per mailbox. - **Admin dashboard** (`/pymta-manager`) — manage domains, senders, mailboxes, DKIM - keys, IP whitelisting, TLS (self-signed or Let's Encrypt via DNS-01: Cloudflare, - Route53, DigitalOcean, Google Cloud DNS), and review email + auth logs. + keys, IP whitelisting, TLS (self-signed/custom, or up to two simultaneous Let's + Encrypt certificates — DNS-01 via Cloudflare/Route53/DigitalOcean/Google Cloud DNS, and + HTTP-01 for domains you don't manage DNS for, optionally covering the server's own IP + too — independently assignable per listener, e.g. HTTP-01 for mail and DNS-01 for the + dashboard), and review email + auth logs. - **Security hardening built in** — CSRF protection, security headers (CSP, X-Frame- Options, etc.), Cloudflare-aware trusted-proxy IP resolution, login rate limiting and account lockout, TOTP + WebAuthn/passkey MFA (admin and mailbox owners), and automatic @@ -83,7 +86,9 @@ IMAP `143`, direct-TLS IMAP `993` — the real standard mail ports, so binding t directly needs root or `setcap` (see below), which the Docker deployment already handles for you. Admin/webmail HTTP `5000` / HTTPS `5001` stay deliberately non-privileged; put a reverse proxy or your own `80`/`443` mapping in front of those if -you want the dashboard on standard web ports too. +you want the dashboard on standard web ports too. Port `80` is also used, but only +transiently, if you enable Let's Encrypt's HTTP-01 challenge (see below) — the same +setcap/root/Docker rule applies to it as to the mail ports. ## Build @@ -92,7 +97,7 @@ cd mailgoserver go build -o mailgoserver . ``` -## Bind ports 25/143/465/993 without root +## Bind ports 25/143/465/993 (and 80, for Let's Encrypt) without root The default SMTP/IMAP ports are the real standard ones now, so running the binary directly (not via Docker) needs one of: @@ -103,7 +108,10 @@ sudo setcap 'cap_net_bind_service=+ep' ./mailgoserver or run it as root, or via the systemd unit below (which grants the capability instead of running as root). Same purpose as `script_setup_py_environment.sh`'s `setcap` step -on the Python venv, applied to the compiled binary instead. **Not needed for the Docker +on the Python venv, applied to the compiled binary instead. `cap_net_bind_service` +covers every port under 1024, so this one grant is also what lets Let's Encrypt's +HTTP-01 challenge bind :80 (only while an obtain/renew is actually running, see the +Let's Encrypt page below). **Not needed for the Docker deployment** — those containers run as root, so binding 25/143/465/993 directly just works with no extra setup. @@ -152,6 +160,42 @@ See [`docker-deploy/`](docker-deploy/) — a standalone image and one that bundl latest rspamd in the same container, both via a single `docker-compose.yml` using Compose profiles. +## Certificates + +Three independent listeners need a TLS certificate: SMTP direct-TLS (465), IMAP +direct-TLS (993), and the admin/webmail HTTPS UI (5001). Each can be assigned a +different one, from the admin dashboard's **Settings** page (TLS/SSL Configuration +card): + +- **Custom** — self-signed by default (generated on first run), or your own uploaded + cert/key. +- **Let's Encrypt (DNS-01)** — automatic, needs a supported DNS provider (Cloudflare, + Route53, DigitalOcean, Google Cloud DNS). Configure on the dashboard's **Let's + Encrypt** page. +- **Let's Encrypt (HTTP-01)** — automatic, needs no DNS provider at all, only port 80 + reachable from the internet — the right choice for a domain whose DNS isn't hosted + anywhere this server can automate. Once enabled (needs a restart to take effect), this + binds a small HTTP server that stays up for the life of the process — hit it directly + (`curl http://your-host/`) and you should get a plain `200 ok`, which is the easiest + way to confirm your router/reverse-proxy port-forwarding actually reaches this host, + independent of running a real obtain. Optionally also covers the server's own public IP + address on the same certificate (autodetected, or a manual override) — note this forces + Let's Encrypt's `shortlived` certificate profile (the only one that currently allows IP + identifiers), so those certificates are valid for only ~6 days and renew far more often, + handled automatically. Configure on the **Let's Encrypt** page. The local bind port + defaults to `80` (`[Server] HTTP_LETSENCRYPT_PORT`) — change this only if something + ahead of this host (a router or reverse proxy) forwards the internet-facing port 80 to + a different local port; Let's Encrypt itself always connects to port 80, there's no way + to make it use a different port on the CA side. + +A common setup: HTTP-01 for the mail listeners (SMTP-TLS/IMAP-TLS) since mail clients +rarely validate hostnames strictly, paired with DNS-01 (or a real custom cert) for the +web UI where browsers do. Both Let's Encrypt certificates can be enabled at once — they're +obtained and renewed independently — and switching which listener uses which needs a +restart to take effect. All of this is also settable directly in `settings.ini`: see the +`[TLS]` (`smtp_tls_cert`/`imap_tls_cert`/`web_https_cert`), `[LetsEncrypt]` (DNS-01), and +`[LetsEncryptHTTP]` (HTTP-01) sections. + ## Admin dashboard login First run seeds one account: username `admin`, password `Password123!`. Logging in diff --git a/docker-deploy/.env.example b/docker-deploy/.env.example index cd70645..3e4eb03 100644 --- a/docker-deploy/.env.example +++ b/docker-deploy/.env.example @@ -5,5 +5,6 @@ SMTP_PORT=25 SMTP_TLS_PORT=465 IMAP_PORT=143 IMAP_TLS_PORT=993 +ACME_HTTP_PORT=80 WEB_HTTP_PORT=5000 WEB_HTTPS_PORT=5001 diff --git a/docker-deploy/Dockerfile b/docker-deploy/Dockerfile index a39c47a..b8c5b3c 100644 --- a/docker-deploy/Dockerfile +++ b/docker-deploy/Dockerfile @@ -39,8 +39,10 @@ VOLUME ["/app/data"] # SMTP 465, IMAP 143, direct-TLS IMAP 993 (the actual standard ports — binding them # needs no setcap/capability here since this container runs as root), admin/webmail # HTTP 5000, HTTPS 5001 (deliberately non-privileged; put a reverse proxy or the host's -# own 80/443 in front if you want those too). -EXPOSE 25 465 143 993 5000 5001 +# own 80/443 in front if you want those too). Port 80 is only actually bound while +# [LetsEncrypt] challenge_type=http-01 is enabled and an obtain/renew is in flight — +# harmless to expose even when unused. +EXPOSE 25 465 143 993 80 5000 5001 # --host 0.0.0.0 is required: the binary's own default is 127.0.0.1, which would only # be reachable from inside this container, never through a published port. diff --git a/docker-deploy/Dockerfile.rspamd b/docker-deploy/Dockerfile.rspamd index 272217c..57be317 100644 --- a/docker-deploy/Dockerfile.rspamd +++ b/docker-deploy/Dockerfile.rspamd @@ -39,7 +39,9 @@ RUN chmod +x /usr/local/bin/entrypoint-rspamd.sh WORKDIR /app/data VOLUME ["/app/data", "/var/lib/rspamd"] -EXPOSE 25 465 143 993 5000 5001 +# Port 80 is only actually bound while [LetsEncrypt] challenge_type=http-01 is enabled +# and an obtain/renew is in flight — harmless to expose even when unused. +EXPOSE 25 465 143 993 80 5000 5001 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD curl -fs http://127.0.0.1:5000/health || exit 1 diff --git a/docker-deploy/README.md b/docker-deploy/README.md index 9f7f2cb..5ba2548 100644 --- a/docker-deploy/README.md +++ b/docker-deploy/README.md @@ -23,11 +23,12 @@ docker compose --profile with-rspamd up -d --build Either way, the app itself now binds the real standard mail ports by default — 25 (SMTP), 465 (direct-TLS SMTP), 143 (IMAP), 993 (direct-TLS IMAP) — and the admin/webmail UI on its usual non-privileged 5000/5001 (HTTP/HTTPS); put your own reverse proxy or a -`80:5000`/`443:5001` port mapping in front if you want those on 80/443 too. Binding the -low mail ports needs no extra capability here since the container runs as root. Copy -`.env.example` to `.env` in this folder to change any host-side port — useful if -something else on the host already owns 25/143/etc., or if you want to run both -profiles side by side. +`80:5000`/`443:5001` port mapping in front if you want those on 80/443 too (note port 80 +is already published here for Let's Encrypt HTTP-01, see below — pick a different host +port for the web UI's 80 mapping if you use both). Binding the low mail ports needs no +extra capability here since the container runs as root. Copy `.env.example` to `.env` in +this folder to change any host-side port — useful if something else on the host already +owns 25/143/etc., or if you want to run both profiles side by side. ## What happens on first boot @@ -76,6 +77,25 @@ This bundle intentionally skips Redis — rspamd runs fine without it for SPF/DK regexp-based scoring, but Bayes learning and greylisting need it. Add a `redis` service to `docker-compose.yml` and point rspamd's `redis.conf` at it if you need those. +## Let's Encrypt HTTP-01 (no DNS provider needed) + +If this domain's DNS isn't hosted anywhere the app can automate, enable HTTP-01 on the +admin dashboard's Let's Encrypt page and restart the container — it runs independently +alongside (or instead of) the DNS-01 flow above, obtaining its own separate certificate. +Port 80 (already published by `docker-compose.yml`) stays bound for the container's +whole lifetime once enabled, not just during an obtain — `curl` it and you should get a +plain `200 ok`, the quickest way to confirm your port-forwarding/reverse-proxy setup +actually reaches this container. Optionally also request the certificate for this +container's public IP address (autodetected, or a manual override) so clients connecting +by bare IP get a trusted cert too — note this uses Let's Encrypt's `shortlived` profile, +so those certificates renew roughly every few days instead of every couple months +(handled automatically). + +Which listener actually uses which certificate — the DNS-01 cert, the HTTP-01 cert, or +the custom/self-signed one — is chosen independently per listener (SMTP-TLS, IMAP-TLS, +web UI) on the admin dashboard's Settings page. A common setup: HTTP-01 for +SMTP/IMAP, DNS-01 (or a real custom cert) for the web UI. + ## Persistence | Volume | What's in it | diff --git a/docker-deploy/docker-compose.yml b/docker-deploy/docker-compose.yml index 234ff3f..b8fa6f9 100644 --- a/docker-deploy/docker-compose.yml +++ b/docker-deploy/docker-compose.yml @@ -4,9 +4,11 @@ # docker compose --profile with-rspamd up -d --build # mailserver + rspamd, same container # # Both default to the standard mail ports on the host (25/465/143/993) — the app itself -# now binds those directly, no port remapping needed — plus the app's own non-privileged -# web ports (5000/5001; put a reverse proxy or your own 80/443 mapping in front of those -# if you want the dashboard on standard web ports too). Only run one profile at a time +# now binds those directly, no port remapping needed — plus port 80 (only actually used +# while Let's Encrypt HTTP-01 is enabled, see internal/webui's Let's Encrypt page) and +# the app's own non-privileged web ports (5000/5001; put a reverse proxy or your own +# 80/443 mapping in front of those if you want the dashboard on standard web ports too — +# note port 80 is already claimed here for HTTP-01 if you enable it). Only run one profile at a time # unless you've overridden the host ports for one of them (see .env.example) — they'd # otherwise both try to bind the same host ports. services: @@ -22,6 +24,7 @@ services: - "${SMTP_TLS_PORT:-465}:465" - "${IMAP_PORT:-143}:143" - "${IMAP_TLS_PORT:-993}:993" + - "${ACME_HTTP_PORT:-80}:80" - "${WEB_HTTP_PORT:-5000}:5000" - "${WEB_HTTPS_PORT:-5001}:5001" volumes: @@ -39,6 +42,7 @@ services: - "${SMTP_TLS_PORT:-465}:465" - "${IMAP_PORT:-143}:143" - "${IMAP_TLS_PORT:-993}:993" + - "${ACME_HTTP_PORT:-80}:80" - "${WEB_HTTP_PORT:-5000}:5000" - "${WEB_HTTPS_PORT:-5001}:5001" volumes: diff --git a/internal/acmecert/acmecert_test.go b/internal/acmecert/acmecert_test.go index 93ec6f6..25d6661 100644 --- a/internal/acmecert/acmecert_test.go +++ b/internal/acmecert/acmecert_test.go @@ -1,12 +1,15 @@ package acmecert import ( + "context" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "math/big" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -14,6 +17,8 @@ import ( "github.com/go-acme/lego/v4/registration" "gopkg.in/ini.v1" + + "mailgoserver/internal/tlsutil" ) func TestLoadOrCreateAccountGeneratesAndPersistsKey(t *testing.T) { @@ -102,6 +107,63 @@ func TestNeedsRenewal(t *testing.T) { } } +func TestNeedsRenewalTrueForSelfSignedPlaceholderEvenWithLongExpiry(t *testing.T) { + dir := t.TempDir() + certFile := filepath.Join(dir, "server.crt") + keyFile := filepath.Join(dir, "server.key") + if err := tlsutil.GenerateSelfSignedCert(certFile, keyFile); err != nil { + t.Fatal(err) + } + mgr := &Manager{Cfg: ini.Empty(), CertFile: certFile} + + // tlsutil's self-signed cert is valid for a year — a pure expiry check would say + // "no renewal needed," which is exactly the bug: a restart must still recognize + // this as "no real certificate obtained yet" and trigger the first real obtain. + needs, err := mgr.NeedsRenewal() + if err != nil { + t.Fatal(err) + } + if !needs { + t.Fatal("expected NeedsRenewal=true for the self-signed placeholder despite its long expiry") + } +} + +func TestNeedsRenewalUsesShortThresholdForHTTP01IncludeIP(t *testing.T) { + dir := t.TempDir() + certFile := filepath.Join(dir, "server.crt") + cfg := ini.Empty() + cfg.Section("LetsEncryptHTTP").Key("include_ip").SetValue("true") + mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01", CertFile: certFile} + + // 4 days left: not within the 1-day short threshold, even though it WOULD be + // within the normal 30-day one — proves the short threshold is actually in effect. + writeFixtureCert(t, certFile, time.Now().Add(4*24*time.Hour)) + if needs, err := mgr.NeedsRenewal(); err != nil || needs { + t.Fatalf("expected NeedsRenewal=false with 4 days left under the short threshold, got %v (err=%v)", needs, err) + } + + // 12 hours left: within the 1-day short threshold. + writeFixtureCert(t, certFile, time.Now().Add(12*time.Hour)) + if needs, err := mgr.NeedsRenewal(); err != nil || !needs { + t.Fatalf("expected NeedsRenewal=true with 12 hours left, got %v (err=%v)", needs, err) + } +} + +func TestNeedsRenewalUsesNormalThresholdForHTTP01WithoutIncludeIP(t *testing.T) { + dir := t.TempDir() + certFile := filepath.Join(dir, "server.crt") + cfg := ini.Empty() + cfg.Section("LetsEncryptHTTP").Key("include_ip").SetValue("false") + mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01", CertFile: certFile} + + // 4 days left: within the normal 30-day threshold — confirms include_ip=false + // gets the normal threshold, not the short one. + writeFixtureCert(t, certFile, time.Now().Add(4*24*time.Hour)) + if needs, err := mgr.NeedsRenewal(); err != nil || !needs { + t.Fatalf("expected NeedsRenewal=true with 4 days left under the normal threshold, got %v (err=%v)", needs, err) + } +} + func TestNeedsRenewalMissingCertIsTrue(t *testing.T) { mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(t.TempDir(), "does-not-exist.crt")} needs, err := mgr.NeedsRenewal() @@ -131,6 +193,101 @@ func TestBuildDNSProviderDigitalOceanRequiresToken(t *testing.T) { } } +func TestDetectWANIP(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("203.0.113.42\n")) + })) + defer srv.Close() + old := wanIPServiceURL + wanIPServiceURL = srv.URL + defer func() { wanIPServiceURL = old }() + + ip, err := DetectWANIP(context.Background()) + if err != nil { + t.Fatal(err) + } + if ip != "203.0.113.42" { + t.Fatalf("expected trimmed IP %q, got %q", "203.0.113.42", ip) + } +} + +func TestResolveIdentifiersDNS01IgnoresIPSettings(t *testing.T) { + cfg := ini.Empty() + sec := cfg.Section("LetsEncrypt") + sec.Key("domains").SetValue("mail.example.com") + sec.Key("include_ip").SetValue("true") // should be ignored outside http-01 + mgr := &Manager{Cfg: cfg, Section: "LetsEncrypt", ChallengeType: "dns-01"} + + got, err := mgr.resolveIdentifiers(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != "mail.example.com" { + t.Fatalf("expected just the domain, got %v", got) + } +} + +func TestDomainsAreLowercased(t *testing.T) { + cfg := ini.Empty() + sec := cfg.Section("LetsEncrypt") + sec.Key("domains").SetValue("adsl-1-2-3-4.example.ISP.COM, Mail.Example.com") + mgr := &Manager{Cfg: cfg, Section: "LetsEncrypt", ChallengeType: "dns-01"} + + got := mgr.domains() + want := []string{"adsl-1-2-3-4.example.isp.com", "mail.example.com"} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("expected lowercased %v, got %v", want, got) + } +} + +func TestResolveIdentifiersHTTP01WithManualIPOverride(t *testing.T) { + cfg := ini.Empty() + sec := cfg.Section("LetsEncryptHTTP") + sec.Key("domains").SetValue("mail.example.com") + sec.Key("include_ip").SetValue("true") + sec.Key("ip_override").SetValue("198.51.100.7") + mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01"} + + got, err := mgr.resolveIdentifiers(context.Background()) + if err != nil { + t.Fatal(err) + } + want := []string{"mail.example.com", "198.51.100.7"} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("expected %v, got %v", want, got) + } +} + +func TestResolveIdentifiersHTTP01AutodetectsIPWhenOverrideBlank(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("203.0.113.99")) + })) + defer srv.Close() + old := wanIPServiceURL + wanIPServiceURL = srv.URL + defer func() { wanIPServiceURL = old }() + + cfg := ini.Empty() + sec := cfg.Section("LetsEncryptHTTP") + sec.Key("include_ip").SetValue("true") + mgr := &Manager{Cfg: cfg, Section: "LetsEncryptHTTP", ChallengeType: "http-01"} + + got, err := mgr.resolveIdentifiers(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != "203.0.113.99" { + t.Fatalf("expected autodetected IP as the sole identifier, got %v", got) + } +} + +func TestResolveIdentifiersEmptyIsError(t *testing.T) { + mgr := &Manager{Cfg: ini.Empty(), Section: "LetsEncrypt", ChallengeType: "dns-01"} + if _, err := mgr.resolveIdentifiers(context.Background()); err == nil { + t.Fatal("expected an error when no domains and no IP are configured") + } +} + func TestBuildDNSProviderCloudflare(t *testing.T) { cfg := ini.Empty() sec := cfg.Section("LetsEncrypt") diff --git a/internal/acmecert/http01server.go b/internal/acmecert/http01server.go new file mode 100644 index 0000000..3899140 --- /dev/null +++ b/internal/acmecert/http01server.go @@ -0,0 +1,82 @@ +package acmecert + +import ( + "net/http" + "strings" + "sync" + + "mailgoserver/internal/toolbox" +) + +// HTTP01Server is a long-lived HTTP server dedicated to the [LetsEncryptHTTP] HTTP-01 +// challenge — started once at boot (if enabled) and kept running for the whole process +// lifetime, unlike lego's own http01.ProviderServer (challenge/http01), which binds and +// unbinds the port on every single obtain/renew. Staying up lets an operator behind +// NAT/a reverse proxy verify their port-forwarding actually reaches this host (curl it +// directly, get a 200) without waiting for or burning a real, rate-limited ACME attempt. +// It implements lego's challenge.Provider interface (Present/CleanUp) so a Manager hands +// it token/keyAuth pairs as they come, instead of each obtain spinning up its own +// server. +type HTTP01Server struct { + mu sync.RWMutex + tokens map[string]string // token -> keyAuth + + server *http.Server +} + +func NewHTTP01Server() *HTTP01Server { + s := &HTTP01Server{tokens: map[string]string{}} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/acme-challenge/", s.serveChallenge) + mux.HandleFunc("/", s.serveHealth) + s.server = &http.Server{Handler: mux} + return s +} + +func (s *HTTP01Server) serveHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} + +func (s *HTTP01Server) serveChallenge(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.URL.Path, "/.well-known/acme-challenge/") + s.mu.RLock() + keyAuth, ok := s.tokens[token] + s.mu.RUnlock() + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(keyAuth)) +} + +// Start binds addr (e.g. ":80") and serves in the background until the process exits. +// Call once at boot. A bind failure (port already in use, missing +// CAP_NET_BIND_SERVICE) is logged rather than crashing the process — HTTP-01 +// obtain/renew attempts then fail with a clear error from lego instead, same as any +// other misconfiguration surfaced via Status.LastError. +func (s *HTTP01Server) Start(addr string, logger *toolbox.Logger) { + s.server.Addr = addr + go func() { + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP-01 challenge server: %v", err) + } + }() +} + +// Present and CleanUp implement github.com/go-acme/lego/v4/challenge.Provider. +func (s *HTTP01Server) Present(domain, token, keyAuth string) error { + s.mu.Lock() + s.tokens[token] = keyAuth + s.mu.Unlock() + return nil +} + +func (s *HTTP01Server) CleanUp(domain, token, keyAuth string) error { + s.mu.Lock() + delete(s.tokens, token) + s.mu.Unlock() + return nil +} diff --git a/internal/acmecert/http01server_test.go b/internal/acmecert/http01server_test.go new file mode 100644 index 0000000..da1c5cf --- /dev/null +++ b/internal/acmecert/http01server_test.go @@ -0,0 +1,88 @@ +package acmecert + +import ( + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + "mailgoserver/internal/toolbox" +) + +// freePort asks the OS for an unused TCP port, mirroring the pattern used elsewhere in +// this codebase's tests for binding to an ephemeral port deterministically. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +func waitUntilUp(t *testing.T, url string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if resp, err := http.Get(url); err == nil { + resp.Body.Close() + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("server at %s never came up", url) +} + +func TestHTTP01ServerHealthEndpointReturns200(t *testing.T) { + port := freePort(t) + s := NewHTTP01Server() + s.Start(fmt.Sprintf("127.0.0.1:%d", port), toolbox.GetLogger("test")) + + url := fmt.Sprintf("http://127.0.0.1:%d/anything", port) + waitUntilUp(t, url) + + resp, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestHTTP01ServerServesPresentedChallenge(t *testing.T) { + port := freePort(t) + s := NewHTTP01Server() + s.Start(fmt.Sprintf("127.0.0.1:%d", port), toolbox.GetLogger("test")) + waitUntilUp(t, fmt.Sprintf("http://127.0.0.1:%d/", port)) + + if err := s.Present("example.com", "sometoken", "sometoken.keyauth"); err != nil { + t.Fatal(err) + } + + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/.well-known/acme-challenge/sometoken", port)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK || string(body) != "sometoken.keyauth" { + t.Fatalf("expected 200 with keyAuth body, got %d %q", resp.StatusCode, body) + } + + if err := s.CleanUp("example.com", "sometoken", "sometoken.keyauth"); err != nil { + t.Fatal(err) + } + resp2, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/.well-known/acme-challenge/sometoken", port)) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 after CleanUp, got %d", resp2.StatusCode) + } +} diff --git a/internal/acmecert/manager.go b/internal/acmecert/manager.go index c19b762..0aa78e3 100644 --- a/internal/acmecert/manager.go +++ b/internal/acmecert/manager.go @@ -1,9 +1,9 @@ -// Package acmecert obtains and renews Let's Encrypt certificates via the DNS-01 -// challenge, as an admin-configurable alternative to the self-signed certificate -// tlsutil generates by default. Obtained certificates are written to the same -// cert/key file paths the self-signed generator already uses, so the SMTP/IMAP TLS -// listeners (via tlsutil.CertReloader) never need to know which produced the active -// certificate. +// Package acmecert obtains and renews Let's Encrypt certificates via DNS-01 or HTTP-01, +// as an admin-configurable alternative to the self-signed certificate tlsutil generates +// by default. main.go runs up to two independent Manager instances at once (one per +// challenge type, reading from separate ini sections and writing to separate cert/key +// files) so a DNS-01 cert and an HTTP-01 cert can be obtained simultaneously and +// assigned to different listeners — see [TLS]'s *_cert settings. package acmecert import ( @@ -28,38 +28,62 @@ import ( // renewalThreshold mirrors the standard ACME-client convention (certbot/lego CLI): // renew once a certificate is within 30 days of its (90-day, for Let's Encrypt) expiry. +// Used for every case except shortLivedRenewalThreshold below. const renewalThreshold = 30 * 24 * time.Hour +// shortLivedRenewalThreshold applies only to HTTP-01 with include_ip set, which forces +// Let's Encrypt's "shortlived" profile (~6 day validity — see obtain()'s Profile +// handling). Using the normal 30-day threshold there would mean the cert looks "due for +// renewal" on literally every single renewal check from the moment it's issued, +// hammering the ACME API every 12h instead of renewing roughly once every ~5 days as +// intended — a 1-day buffer before expiry keeps a comfortable margin without that. +const shortLivedRenewalThreshold = 24 * time.Hour + // Status is a read-only snapshot of the current Let's Encrypt configuration and the // last renewal attempt, for the admin settings page. type Status struct { - Enabled bool - Staging bool - Domains []string - Provider string - NotAfter time.Time // parsed live from CertFile each call — never cached - LastAttempt time.Time // zero value = no attempt yet this process run - LastError string // empty if the last attempt succeeded, or none has run yet + Enabled bool + Staging bool + ChallengeType string // "dns-01" or "http-01" + Domains []string + Provider string + IncludeIP bool + NotAfter time.Time // parsed live from CertFile each call — never cached + LastAttempt time.Time // zero value = no attempt yet this process run + LastError string // empty if the last attempt succeeded, or none has run yet } -// Manager obtains and renews certificates for one configured domain set. +// Manager obtains and renews certificates for one configured domain set, using one +// fixed challenge type read from one fixed ini section (both set once at construction, +// via New — never toggled at runtime, since main.go runs one Manager per challenge +// type). ChallengeType is "dns-01" or "http-01". type Manager struct { Cfg *ini.File + Section string + ChallengeType string CertFile, KeyFile string DataDir string Reloader *tlsutil.CertReloader Logger *toolbox.Logger + // HTTP01Server is the long-lived challenge responder (see http01server.go) this + // Manager hands token/keyAuth pairs to during an obtain. Only set (by main.go) on + // the HTTP-01 Manager instance; nil on the DNS-01 one, which never uses it. + HTTP01Server *HTTP01Server + mu sync.Mutex lastAttempt time.Time lastError string } -func New(cfg *ini.File, certFile, keyFile, dataDir string, reloader *tlsutil.CertReloader, logger *toolbox.Logger) *Manager { - return &Manager{Cfg: cfg, CertFile: certFile, KeyFile: keyFile, DataDir: dataDir, Reloader: reloader, Logger: logger} +func New(cfg *ini.File, section, challengeType, certFile, keyFile, dataDir string, reloader *tlsutil.CertReloader, logger *toolbox.Logger) *Manager { + return &Manager{ + Cfg: cfg, Section: section, ChallengeType: challengeType, + CertFile: certFile, KeyFile: keyFile, DataDir: dataDir, Reloader: reloader, Logger: logger, + } } -func (m *Manager) section() *ini.Section { return m.Cfg.Section("LetsEncrypt") } +func (m *Manager) section() *ini.Section { return m.Cfg.Section(m.Section) } func (m *Manager) domains() []string { raw := m.section().Key("domains").String() @@ -69,7 +93,14 @@ func (m *Manager) domains() []string { parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { - if p = strings.TrimSpace(p); p != "" { + // Lowercased: DNS names are case-insensitive, and Let's Encrypt's order + // response always comes back lowercased regardless of what was submitted — + // lego's RFC 8555 §7.4 compliance check then compares the two verbatim, so a + // mixed-case domain (e.g. an ISP-assigned "adsl-1-2-3-4.example.ISP.COM" + // reverse-DNS hostname) fails with a spurious "order identifiers have been + // modified" error unless normalized before submission. Confirmed live: a user + // hit exactly this with an uppercase-suffixed rDNS hostname. + if p = strings.ToLower(strings.TrimSpace(p)); p != "" { out = append(out, p) } } @@ -83,12 +114,19 @@ func (m *Manager) domains() []string { func (m *Manager) Status() Status { m.mu.Lock() s := Status{ - Enabled: m.section().Key("enabled").MustBool(false), - Staging: m.section().Key("staging").MustBool(false), - Domains: m.domains(), - Provider: m.section().Key("dns_provider").String(), - LastAttempt: m.lastAttempt, - LastError: m.lastError, + Enabled: m.section().Key("enabled").MustBool(false), + Staging: m.section().Key("staging").MustBool(false), + ChallengeType: m.ChallengeType, + Domains: m.domains(), + LastAttempt: m.lastAttempt, + LastError: m.lastError, + } + if m.ChallengeType == "http-01" { + httpPort := m.Cfg.Section("Server").Key("HTTP_LETSENCRYPT_PORT").MustString("80") + s.Provider = "HTTP-01 (port " + httpPort + ")" + s.IncludeIP = m.section().Key("include_ip").MustBool(false) + } else { + s.Provider = m.section().Key("dns_provider").String() } m.mu.Unlock() @@ -98,17 +136,30 @@ func (m *Manager) Status() Status { return s } -// NeedsRenewal reports whether the certificate currently at CertFile is within 30 days -// of expiry (or unreadable/unparseable, which is treated as "yes" — nothing usable is -// there to keep). This is a pure expiry check; it makes no attempt to distinguish a -// self-signed cert from an ACME-obtained one (see the caller in main.go's renewal -// ticker for how the very-first-check case is handled instead). +// NeedsRenewal reports whether the certificate currently at CertFile is within +// renewalThreshold (30 days) — or shortLivedRenewalThreshold (1 day) for HTTP-01 with +// include_ip, since that cert is only valid ~6 days to begin with — of expiry, +// unreadable/unparseable (nothing usable there to keep), or is still the self-signed +// placeholder tlsutil generates by default (recognized by its Issuer CN — see +// tlsutil.GenerateSelfSignedCert — since a freshly-generated one has ~1 year left and +// would otherwise never look like it "needs" replacing by the first real certificate). +// This is a pure disk-state check with no dependency on in-memory process state, so it +// gives the same correct answer whether this is the first check after boot or the +// hundredth — restarting the process must never by itself trigger a redundant +// re-obtain of an already-valid, already-real certificate. func (m *Manager) NeedsRenewal() (bool, error) { cert, err := readLeafCertificate(m.CertFile) if err != nil { return true, nil } - return time.Until(cert.NotAfter) < renewalThreshold, nil + if cert.Issuer.CommonName == "localhost" { + return true, nil + } + threshold := renewalThreshold + if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) { + threshold = shortLivedRenewalThreshold + } + return time.Until(cert.NotAfter) < threshold, nil } func readLeafCertificate(certFile string) (*x509.Certificate, error) { @@ -123,14 +174,17 @@ func readLeafCertificate(certFile string) (*x509.Certificate, error) { return x509.ParseCertificate(block.Bytes) } +// Enabled reports whether this manager's ini section has 'enabled = true'. +func (m *Manager) Enabled() bool { return m.section().Key("enabled").MustBool(false) } + // ObtainOrRenew requests a certificate for the configured domains and, on success, // writes it to CertFile/KeyFile and hot-reloads the live TLS listeners. Used for both // first issuance and renewal — lego's Obtain covers both identically, so there is no -// separate renewal code path. A no-op (nil error) if Let's Encrypt isn't enabled. On +// separate renewal code path. A no-op (nil error) if this manager isn't enabled. On // any failure, the cert/key files on disk are left untouched — whatever was already // serving (self-signed or a previous ACME cert) keeps working. func (m *Manager) ObtainOrRenew(ctx context.Context) error { - if !m.section().Key("enabled").MustBool(false) { + if !m.Enabled() { return nil } @@ -149,10 +203,11 @@ func (m *Manager) ObtainOrRenew(ctx context.Context) error { } func (m *Manager) obtain(ctx context.Context) error { - domains := m.domains() - if len(domains) == 0 { - return fmt.Errorf("acmecert: no domains configured") + identifiers, err := m.resolveIdentifiers(ctx) + if err != nil { + return err } + m.Logger.Info("%s: starting obtain/renew for %s", m.ChallengeType, strings.Join(identifiers, ", ")) email := m.section().Key("contact_email").String() user, err := loadOrCreateAccount(m.DataDir, email) @@ -171,12 +226,21 @@ func (m *Manager) obtain(ctx context.Context) error { return fmt.Errorf("create ACME client: %w", err) } - provider, err := buildDNSProvider(m.Cfg) - if err != nil { - return fmt.Errorf("configure DNS provider: %w", err) - } - if err := client.Challenge.SetDNS01Provider(provider); err != nil { - return fmt.Errorf("set DNS-01 provider: %w", err) + if m.ChallengeType == "http-01" { + if m.HTTP01Server == nil { + return fmt.Errorf("HTTP-01 challenge server is not running (enable [LetsEncryptHTTP] and restart)") + } + if err := client.Challenge.SetHTTP01Provider(m.HTTP01Server); err != nil { + return fmt.Errorf("set HTTP-01 provider: %w", err) + } + } else { + provider, err := buildDNSProvider(m.Cfg) + if err != nil { + return fmt.Errorf("configure DNS provider: %w", err) + } + if err := client.Challenge.SetDNS01Provider(provider); err != nil { + return fmt.Errorf("set DNS-01 provider: %w", err) + } } if user.Registration == nil { @@ -190,10 +254,18 @@ func (m *Manager) obtain(ctx context.Context) error { } } - cert, err := client.Certificate.Obtain(certificate.ObtainRequest{ - Domains: domains, - Bundle: true, - }) + req := certificate.ObtainRequest{Domains: identifiers, Bundle: true} + if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) { + // Let's Encrypt's default profile rejects IP identifiers outright ("Default + // profile does not permit IP address identifiers") — only the "shortlived" + // profile currently supports them (mixed with DNS names too), at the cost of a + // much shorter (~6 day) validity. NeedsRenewal's 30-day threshold already + // treats that as "always needs renewal," which is exactly right here — it'll + // just get renewed on essentially every 12h tick instead of sitting idle for + // weeks, which is the correct behavior for a cert this short-lived. + req.Profile = "shortlived" + } + cert, err := client.Certificate.Obtain(req) if err != nil { return fmt.Errorf("obtain certificate: %w", err) } @@ -208,6 +280,34 @@ func (m *Manager) obtain(ctx context.Context) error { return fmt.Errorf("reload TLS certificate: %w", err) } - m.Logger.Info("Let's Encrypt certificate obtained for %s", strings.Join(domains, ", ")) + m.Logger.Info("Let's Encrypt certificate obtained for %s", strings.Join(identifiers, ", ")) return nil } + +// resolveIdentifiers builds the domain/IP list to request a certificate for: the +// configured domains, plus (for http-01 with include_ip set) one IP address — lego's +// ACME client auto-detects an IP-shaped string in this list and requests it as an +// RFC 8738 IP identifier rather than a DNS identifier. The IP is either the manual +// override or, if that's blank, autodetected via DetectWANIP. See obtain()'s Profile +// handling: an IP identifier needs Let's Encrypt's "shortlived" profile, which does +// support mixing DNS names and an IP in one order. +func (m *Manager) resolveIdentifiers(ctx context.Context) ([]string, error) { + identifiers := m.domains() + + if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) { + ip := m.section().Key("ip_override").String() + if ip == "" { + detected, err := DetectWANIP(ctx) + if err != nil { + return nil, fmt.Errorf("autodetect WAN IP: %w", err) + } + ip = detected + } + identifiers = append(identifiers, ip) + } + + if len(identifiers) == 0 { + return nil, fmt.Errorf("acmecert: no domains configured") + } + return identifiers, nil +} diff --git a/internal/acmecert/wanip.go b/internal/acmecert/wanip.go new file mode 100644 index 0000000..140b104 --- /dev/null +++ b/internal/acmecert/wanip.go @@ -0,0 +1,36 @@ +package acmecert + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" +) + +// wanIPServiceURL returns the caller's public IP as plain text. Overridden by tests. +var wanIPServiceURL = "https://api.ipify.org" + +// DetectWANIP asks a public IP-echo service what address this host is reachable from, +// for pre-filling the Let's Encrypt HTTP-01 "certificate for my IP" option. There's no +// stdlib or local way to learn a WAN-facing IP from behind NAT/a cloud LB, so an +// outbound HTTP call is the only option here. +func DetectWANIP(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wanIPServiceURL, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("detect WAN IP: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("detect WAN IP: unexpected status %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) + if err != nil { + return "", fmt.Errorf("detect WAN IP: %w", err) + } + return strings.TrimSpace(string(body)), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 78444dd..605fbde 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,11 @@ var defaults = []struct { {"WEB_HTTP_PORT", "5000", ""}, {"", "", "HTTPS port for the admin web UI (self-signed by default, or the Let's Encrypt cert when enabled)"}, {"WEB_HTTPS_PORT", "5001", ""}, + {"", "", "Port the [LetsEncryptHTTP] HTTP-01 challenge server binds while an obtain/renew is"}, + {"", "", "actually running (never left listening otherwise). Must be 80 for a real Let's"}, + {"", "", "Encrypt HTTP-01 challenge to validate - the CA always connects on port 80. Only"}, + {"", "", "change this if you're proxying/forwarding port 80 to a different port on this host."}, + {"HTTP_LETSENCRYPT_PORT", "80", ""}, {"", "", `Custom server banner (to make it empty use "" must be double quotes)`}, {"server_banner", "", ""}, {"", "", "Time zone for the server"}, @@ -66,8 +71,16 @@ var defaults = []struct { }}, {"TLS", []defaultKV{ {"", "", "TLS/SSL certificate configuration"}, + {"", "", "The 'custom' certificate: self-signed on first run, or your own uploaded cert/key"}, {"TLS_CERT_FILE", "ssl_certs/server.crt", ""}, {"TLS_KEY_FILE", "ssl_certs/server.key", ""}, + {"", "", "Which certificate each TLS-serving listener uses: custom, letsencrypt_dns"}, + {"", "", "(the [LetsEncrypt] DNS-01 cert), or letsencrypt_http (the [LetsEncryptHTTP]"}, + {"", "", "HTTP-01 cert). Independent per listener - e.g. run the HTTP-01 cert on"}, + {"", "", "SMTP/IMAP while the web UI keeps a DNS-01 or custom cert, or vice versa."}, + {"smtp_tls_cert", "custom", ""}, + {"imap_tls_cert", "custom", ""}, + {"web_https_cert", "custom", ""}, }}, {"DKIM", []defaultKV{ {"", "", "DKIM signing configuration"}, @@ -143,8 +156,10 @@ var defaults = []struct { {"reject_score", "15", ""}, }}, {"LetsEncrypt", []defaultKV{ - {"", "", "Let's Encrypt (ACME, DNS-01 only) automatic certificate configuration for the"}, - {"", "", "SMTP/IMAP TLS listeners. Leave 'enabled' false to keep using the self-signed cert."}, + {"", "", "Let's Encrypt (ACME, DNS-01) automatic certificate configuration. This obtains a"}, + {"", "", "separate certificate from [LetsEncryptHTTP] below - assign each independently to"}, + {"", "", "the SMTP/IMAP/web-UI listeners via [TLS]'s *_cert settings. Leave 'enabled' false"}, + {"", "", "to not obtain this one."}, {"enabled", "false", ""}, {"", "", "Use Let's Encrypt's staging directory (untrusted certs, no rate limits) for testing"}, {"staging", "false", ""}, @@ -168,6 +183,31 @@ var defaults = []struct { {"", "", "Path to an uploaded service-account JSON key; leave blank to use Application Default Credentials"}, {"gcloud_service_account_json_path", "", ""}, }}, + {"LetsEncryptHTTP", []defaultKV{ + {"", "", "Let's Encrypt (ACME, HTTP-01) automatic certificate configuration - an"}, + {"", "", "alternative to [LetsEncrypt] above for domains you don't manage DNS for. Needs"}, + {"", "", "no DNS provider, only port 80 reachable from the internet; that port is only"}, + {"", "", "ever bound for the few seconds an obtain/renew is actually running, never left"}, + {"", "", "listening otherwise. Produces a separate certificate from [LetsEncrypt] - assign"}, + {"", "", "each independently to the SMTP/IMAP/web-UI listeners via [TLS]'s *_cert settings."}, + {"", "", "(Both sections share the same underlying ACME account, registered once using"}, + {"", "", "whichever of the two 'contact_email' values is obtained with first.)"}, + {"enabled", "false", ""}, + {"", "", "Use Let's Encrypt's staging directory (untrusted certs, no rate limits) for testing"}, + {"staging", "false", ""}, + {"", "", "Contact email for the ACME account"}, + {"contact_email", "", ""}, + {"", "", "Comma-separated domains to request"}, + {"domains", "", ""}, + {"", "", "Also request this certificate for the server's public IP address (RFC 8738 IP"}, + {"", "", "identifier), so clients connecting by bare IP get a trusted cert too. Note: some"}, + {"", "", "CAs reject an order mixing a domain name and an IP - check this page's status"}, + {"", "", "after enabling this if the domain-only cert stops working."}, + {"include_ip", "false", ""}, + {"", "", "IP address to request the cert for. Leave blank to autodetect this host's WAN IP"}, + {"", "", "on every obtain/renew."}, + {"ip_override", "", ""}, + }}, } // GenerateSettingsIni writes settings.ini with default values and comments if it does @@ -213,11 +253,54 @@ func GenerateSettingsIni(path string) error { // Load reads settings.ini at path, generating it with defaults first if missing. // Mirrors settings_loader.load_settings: always regenerate-if-missing, then read fresh. +// Existing values are never touched (GenerateSettingsIni's "never overwritten or merged +// into" guarantee still holds), but any key added to the defaults table by a later +// version of this program — like [TLS]'s *_cert routing settings or the whole +// [LetsEncryptHTTP] section — is backfilled onto an older, already-existing file, so it +// shows up (and actually saves) on the generic Settings page instead of silently +// behaving as if unset until the file is regenerated from scratch. func Load(path string) (*ini.File, error) { if err := GenerateSettingsIni(path); err != nil { return nil, err } - return ini.Load(path) + cfg, err := ini.Load(path) + if err != nil { + return nil, err + } + if err := backfillMissingDefaults(cfg, path); err != nil { + return nil, err + } + return cfg, nil +} + +// backfillMissingDefaults adds any defaults-table key not already present in cfg, +// leaving every existing key's value untouched, and saves to path only if it actually +// added something. +func backfillMissingDefaults(cfg *ini.File, path string) error { + changed := false + for _, sec := range defaults { + section, err := cfg.NewSection(sec.Section) // no-op if the section already exists + if err != nil { + return err + } + for _, kv := range sec.Keys { + if kv.Key == "" || section.HasKey(kv.Key) { + continue + } + key, err := section.NewKey(kv.Key, kv.Value) + if err != nil { + return err + } + if kv.Comment != "" { + key.Comment = kv.Comment + } + changed = true + } + } + if !changed { + return nil + } + return cfg.SaveTo(path) } // AbsoluteSQLitePath converts a "sqlite:///relative/path" database URL into an absolute diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 957b087..bc05eaf 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -34,6 +35,45 @@ func TestGenerateAndLoadRoundTrip(t *testing.T) { } } +func TestLoadBackfillsMissingKeysWithoutTouchingExistingValues(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.ini") + + // Simulate an older settings.ini written before [LetsEncryptHTTP] and [TLS]'s + // *_cert keys existed, with a deliberately non-default value on a key that IS + // already present — Load must never touch that. + old := "[Server]\nHOSTNAME = old.example.com\n\n[TLS]\ntls_cert_file = custom/path.crt\n" + if err := os.WriteFile(path, []byte(old), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Section("Server").Key("HOSTNAME").String(); got != "old.example.com" { + t.Errorf("existing value clobbered: HOSTNAME = %q", got) + } + if got := cfg.Section("TLS").Key("tls_cert_file").String(); got != "custom/path.crt" { + t.Errorf("existing value clobbered: tls_cert_file = %q", got) + } + if got := cfg.Section("TLS").Key("smtp_tls_cert").String(); got != "custom" { + t.Errorf("smtp_tls_cert not backfilled: got %q, want default %q", got, "custom") + } + if !cfg.Section("LetsEncryptHTTP").HasKey("enabled") { + t.Error("[LetsEncryptHTTP] section was not backfilled") + } + + // The backfill must be persisted to disk, not just held in memory. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "smtp_tls_cert") { + t.Error("backfilled key was not saved back to settings.ini") + } +} + func TestAbsoluteSQLitePath(t *testing.T) { cases := []struct{ url, root, want string }{ {"sqlite:///server_data/db.sqlite", "/app", "/app/server_data/db.sqlite"}, diff --git a/internal/db/count_messages_by_folder_test.go b/internal/db/count_messages_by_folder_test.go new file mode 100644 index 0000000..c684ea1 --- /dev/null +++ b/internal/db/count_messages_by_folder_test.go @@ -0,0 +1,44 @@ +package db + +import ( + "testing" + "time" +) + +func TestCountMessagesByFolder(t *testing.T) { + d := openTestDB(t) + const mailboxID = int64(1) + + insert := func(folder, flags string) { + t.Helper() + if _, err := d.InsertMessage(mailboxID, folder, "", flags, time.Now(), 10, "/dev/null", []byte("nonce"), "a@example.com", "b@example.com", "subj", ""); err != nil { + t.Fatal(err) + } + } + insert("INBOX", "") + insert("INBOX", "") + insert("INBOX", `\Seen`) + insert("Sent", `\Seen`) + + totals, err := d.CountMessagesByFolder(mailboxID) + if err != nil { + t.Fatal(err) + } + if totals["INBOX"] != 3 { + t.Errorf("INBOX total = %d, want 3", totals["INBOX"]) + } + if totals["Sent"] != 1 { + t.Errorf("Sent total = %d, want 1", totals["Sent"]) + } + + unread, err := d.CountUnreadByFolder(mailboxID) + if err != nil { + t.Fatal(err) + } + if unread["INBOX"] != 2 { + t.Errorf("INBOX unread = %d, want 2", unread["INBOX"]) + } + if _, ok := unread["Sent"]; ok { + t.Errorf("expected Sent to have no unread entry, got %d", unread["Sent"]) + } +} diff --git a/internal/db/crud_mailbox_messages.go b/internal/db/crud_mailbox_messages.go index a0ae151..5a3ef63 100644 --- a/internal/db/crud_mailbox_messages.go +++ b/internal/db/crud_mailbox_messages.go @@ -10,23 +10,23 @@ import ( // InsertMessage records a stored message's index row (the ciphertext itself already // lives at storagePath — see internal/mailstore). Returns the new row's id, which // doubles as the IMAP UID in later milestones. -func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, cachedTo, cachedSubject string) (int64, error) { +func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, cachedTo, cachedSubject, cachedPreview string) (int64, error) { res, err := d.Exec(`INSERT INTO esrv_mailbox_messages - (mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject) + (mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject, cached_preview) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject, cachedPreview) if err != nil { return 0, err } return res.LastInsertId() } -const mailboxMessageColumns = `id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_to, cached_subject, storage_path, nonce, created_at` +const mailboxMessageColumns = `id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_to, cached_subject, cached_preview, storage_path, nonce, created_at` func scanMailboxMessage(scan func(dest ...any) error) (MailboxMessage, error) { var m MailboxMessage var internalDate, createdAt string - err := scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedTo, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt) + err := scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedTo, &m.CachedSubject, &m.CachedPreview, &m.StoragePath, &m.Nonce, &createdAt) if err != nil { return m, err } @@ -103,6 +103,17 @@ func (d *DB) ListMessagesForMailbox(mailboxID int64) ([]MailboxMessage, error) { return scanMailboxMessages(rows) } +// UpdateMessageCachedFields overwrites a message's cached_from/cached_to/ +// cached_subject/cached_preview — the display-only fields derived from the message's +// own content at store time. Used by mailstore.RebuildMessageCache to re-derive them +// for messages stored before a caching fix/addition landed (those fields are +// otherwise only ever computed once, at delivery time, never retroactively). +func (d *DB) UpdateMessageCachedFields(id int64, cachedFrom, cachedTo, cachedSubject, cachedPreview string) error { + _, err := d.Exec(`UPDATE esrv_mailbox_messages SET cached_from = ?, cached_to = ?, cached_subject = ?, cached_preview = ? WHERE id = ?`, + cachedFrom, cachedTo, cachedSubject, cachedPreview, id) + return err +} + // SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped // to mailboxID so a session can't touch another mailbox's message by guessing a UID. func (d *DB) SetMessageFlags(mailboxID, uid int64, flags string) error { @@ -124,9 +135,35 @@ func (d *DB) ListMessagesInFolder(mailboxID int64, folder string) ([]MailboxMess // ListMessagesInFolderPage is ListMessagesInFolder with newest-first pagination, for // the webmail client's folder view — a mailbox can accumulate far more mail than is // reasonable to render in one page. -func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, offset, limit int) ([]MailboxMessage, error) { - rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages - WHERE mailbox_id = ? AND folder = ? ORDER BY id DESC LIMIT ? OFFSET ?`, mailboxID, folder, limit, offset) +// sortColumnAndDir maps the folder view's ?sort=/&dir= query params to a safe, +// hardcoded SQL ORDER BY fragment — never interpolates the raw query values +// themselves, only picks between two known-safe literals, so this stays injection-safe +// however sort/dir arrive from the URL. "date" (the default) sorts by id, which tracks +// insertion/received order — the same ordering ListMessagesInFolderPage always used, +// just now also selectable ascending. +func sortColumnAndDir(sortBy, sortDir string) string { + col := "id" + if sortBy == "from" { + col = "cached_from" + } + dir := "DESC" + if sortDir == "asc" { + dir = "ASC" + } + // Tie-break on id in the same direction so same-sender/same-instant rows still + // have a stable, deterministic order across pages. + return col + " " + dir + ", id " + dir +} + +func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly bool, sortBy, sortDir string, offset, limit int) ([]MailboxMessage, error) { + query := `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?` + args := []any{mailboxID, folder} + if unreadOnly { + query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'` + } + query += ` ORDER BY ` + sortColumnAndDir(sortBy, sortDir) + ` LIMIT ? OFFSET ?` + args = append(args, limit, offset) + rows, err := d.Query(query, args...) if err != nil { return nil, err } @@ -134,9 +171,14 @@ func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, offset, li } // CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls. -func (d *DB) CountMessagesInFolder(mailboxID int64, folder string) (int, error) { +func (d *DB) CountMessagesInFolder(mailboxID int64, folder string, unreadOnly bool) (int, error) { + query := `SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?` + args := []any{mailboxID, folder} + if unreadOnly { + query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'` + } var n int - err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`, mailboxID, folder).Scan(&n) + err := d.QueryRow(query, args...).Scan(&n) return n, err } @@ -210,6 +252,28 @@ func (d *DB) CountUnreadByFolder(mailboxID int64) (map[string]int, error) { return out, rows.Err() } +// CountMessagesByFolder returns every folder's total message count in one query — the +// total half of the sidebar's "total / unread" display, mirroring CountUnreadByFolder's +// shape exactly (a folder with zero messages simply has no entry in the returned map). +func (d *DB) CountMessagesByFolder(mailboxID int64) (map[string]int, error) { + rows, err := d.Query(`SELECT folder, COUNT(*) FROM esrv_mailbox_messages + WHERE mailbox_id = ? GROUP BY folder`, mailboxID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var folder string + var n int + if err := rows.Scan(&folder, &n); err != nil { + return nil, err + } + out[folder] = n + } + return out, rows.Err() +} + // SuggestRecipients returns up to 10 distinct addresses (as originally cached — a // display name like "Name " is kept as-is, not parsed apart, since // that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously diff --git a/internal/db/crud_mailboxes.go b/internal/db/crud_mailboxes.go index 530470f..f19156c 100644 --- a/internal/db/crud_mailboxes.go +++ b/internal/db/crud_mailboxes.go @@ -5,13 +5,13 @@ import ( "errors" ) -const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt` +const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt, group_messages` func scanMailbox(row *sql.Row) (*Mailbox, error) { var m Mailbox var createdAt string var createdBy sql.NullInt64 - if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt); err != nil { + if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -31,7 +31,7 @@ type MailboxWithDomain struct { } func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) { - rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, dm.domain_name + rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, m.group_messages, dm.domain_name FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`) if err != nil { return nil, err @@ -42,7 +42,7 @@ func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) { var m MailboxWithDomain var createdAt string var createdBy sql.NullInt64 - if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.DomainName); err != nil { + if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.DomainName); err != nil { return nil, err } m.CreatedAt, _ = parseTime(createdAt) @@ -65,7 +65,7 @@ func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) { var m Mailbox var createdAt string var createdBy sql.NullInt64 - if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt); err != nil { + if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages); err != nil { return nil, err } m.CreatedAt, _ = parseTime(createdAt) @@ -120,6 +120,11 @@ func (d *DB) SetMailboxMFAExempt(id int64, exempt bool) error { return err } +func (d *DB) SetMailboxGroupMessages(id int64, group bool) error { + _, err := d.Exec(`UPDATE esrv_mailboxes SET group_messages = ? WHERE id = ?`, group, id) + return err +} + func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error { _, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id) return err diff --git a/internal/db/mailbox_models.go b/internal/db/mailbox_models.go index 1dd6e68..ce06c65 100644 --- a/internal/db/mailbox_models.go +++ b/internal/db/mailbox_models.go @@ -25,6 +25,9 @@ type Mailbox struct { // MFAExempt overrides [Auth] enforce_mailbox_mfa off for this mailbox specifically, // even if its domain isn't exempt. MFAExempt bool + // GroupMessages collapses a run of same-subject messages in a folder view into one + // expandable row when true. Off by default — a display preference, not a policy. + GroupMessages bool } // MailboxSession is a self-service webmail portal login — a parallel schema to @@ -143,6 +146,7 @@ type MailboxMessage struct { CachedFrom string CachedTo string CachedSubject string + CachedPreview string StoragePath string Nonce []byte CreatedAt time.Time diff --git a/internal/db/schema.go b/internal/db/schema.go index 5f922d6..15bb259 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -208,7 +208,10 @@ CREATE TABLE IF NOT EXISTS esrv_mailboxes ( created_by INTEGER REFERENCES esrv_admin_users(id), totp_secret TEXT NOT NULL DEFAULT '', totp_enabled INTEGER NOT NULL DEFAULT 0, - mfa_exempt INTEGER NOT NULL DEFAULT 0 + mfa_exempt INTEGER NOT NULL DEFAULT 0, + -- Off by default: collapse a run of same-subject messages in a folder view into one + -- expandable row. Per-mailbox, not global, since this is purely a display preference. + group_messages INTEGER NOT NULL DEFAULT 0 ); -- Self-service webmail portal sessions — deliberately a parallel schema to @@ -309,6 +312,10 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_messages ( cached_from TEXT NOT NULL DEFAULT '', cached_to TEXT NOT NULL DEFAULT '', cached_subject TEXT NOT NULL DEFAULT '', + -- First ~150 characters of the plain-text body, cached in plain text (like the + -- other cached_* columns) so the folder list can show a preview snippet without + -- decrypting the full message just to render the list. + cached_preview TEXT NOT NULL DEFAULT '', storage_path TEXT NOT NULL, nonce BLOB NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP @@ -432,6 +439,16 @@ func migrateAddedColumns(db *sql.DB) { // unrecoverable-without-code-that-no-longer-exists) keys, same "not migrated" // treatment as the singular-table identities before them. `ALTER TABLE esrv_mailbox_smime_identities ADD COLUMN key_pem TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE esrv_mailboxes ADD COLUMN group_messages INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_preview TEXT NOT NULL DEFAULT ''`, + } + // The three old columns above were NOT NULL with no default, so simply adding + // key_pem left them behind still blocking every new insert (which only ever sets + // key_pem, never these) on any DB created before this migration — confirmed live: + // "NOT NULL constraint failed: esrv_mailbox_smime_identities.key_ciphertext". Needs + // SQLite 3.35+ for DROP COLUMN; modernc.org/sqlite is well past that. + for _, col := range []string{"key_ciphertext", "key_nonce", "key_salt"} { + db.Exec(`ALTER TABLE esrv_mailbox_smime_identities DROP COLUMN ` + col) } for _, stmt := range stmts { db.Exec(stmt) diff --git a/internal/db/smime_legacy_migration_test.go b/internal/db/smime_legacy_migration_test.go new file mode 100644 index 0000000..5a2971f --- /dev/null +++ b/internal/db/smime_legacy_migration_test.go @@ -0,0 +1,50 @@ +package db + +import ( + "database/sql" + "path/filepath" + "testing" + "time" +) + +// TestSMIMEIdentityInsertWorksAfterLegacyColumnMigration reproduces a live bug: a DB +// created before the S/MIME redesign (passphrase-wrapped key_ciphertext/key_nonce/ +// key_salt, all NOT NULL) only ever got key_pem ADDed by migrateAddedColumns, never had +// the old NOT-NULL columns removed — so CreateSMIMEIdentity (which only sets key_pem) +// failed with "NOT NULL constraint failed: esrv_mailbox_smime_identities.key_ciphertext" +// on any pre-existing installation, confirmed against a real user's database. +func TestSMIMEIdentityInsertWorksAfterLegacyColumnMigration(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + + raw, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := raw.Exec(` + CREATE TABLE esrv_mailbox_smime_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_id INTEGER NOT NULL, + cert_pem TEXT NOT NULL, + key_ciphertext BLOB NOT NULL, + key_nonce BLOB NOT NULL, + key_salt BLOB NOT NULL, + not_after DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + t.Fatal(err) + } + if err := raw.Close(); err != nil { + t.Fatal(err) + } + + database, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + + if _, err := database.CreateSMIMEIdentity(1, "cert-pem", "key-pem", time.Now().Add(365*24*time.Hour)); err != nil { + t.Fatalf("CreateSMIMEIdentity after migrating a legacy DB: %v", err) + } +} diff --git a/internal/mailstore/mailstore_test.go b/internal/mailstore/mailstore_test.go index f0d9d4f..4973536 100644 --- a/internal/mailstore/mailstore_test.go +++ b/internal/mailstore/mailstore_test.go @@ -4,7 +4,9 @@ import ( "bytes" "os" "path/filepath" + "strings" "testing" + "unicode/utf8" "mailgoserver/internal/db" ) @@ -104,6 +106,82 @@ func TestStoreFetchRoundTrip(t *testing.T) { if mbox.UsedBytes != int64(len(raw)) { t.Fatalf("used_bytes = %d, want %d", mbox.UsedBytes, len(raw)) } + if msg.CachedPreview != "hello world" { + t.Errorf("CachedPreview = %q, want %q", msg.CachedPreview, "hello world") + } +} + +// TestStoreMessagePreviewTruncatesLongBodyRuneSafely confirms the cached preview is +// capped at previewSnippetLen characters (not bytes — a naive byte-slice cap could +// split a multi-byte UTF-8 character) and that non-ASCII text survives intact. +func TestStoreMessagePreviewTruncatesLongBodyRuneSafely(t *testing.T) { + s, mailboxID := newTestMailbox(t, 1024*1024) + longBody := strings.Repeat("héllo ", 100) // well over previewSnippetLen once joined + raw := []byte("From: a@example.com\r\nSubject: hi\r\n\r\n" + longBody) + + uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "", "a@example.com", "hi") + if err != nil { + t.Fatal(err) + } + msg, err := s.DB.GetMessageByUID(mailboxID, uid) + if err != nil { + t.Fatal(err) + } + if n := len([]rune(msg.CachedPreview)); n != previewSnippetLen { + t.Errorf("preview length = %d runes, want %d", n, previewSnippetLen) + } + if !utf8.ValidString(msg.CachedPreview) { + t.Error("preview is not valid UTF-8 — truncation split a multi-byte character") + } +} + +// TestRebuildMessageCacheRederivesFromExistingContent simulates a message stored +// before the "cache the From: header's display name" fix existed: cached_from was +// passed as the bare envelope address even though the stored raw content always had +// the full header. RebuildMessageCache should bring it up to date without needing the +// message re-delivered. +func TestRebuildMessageCacheRederivesFromExistingContent(t *testing.T) { + s, mailboxID := newTestMailbox(t, 1024*1024) + raw := []byte("From: Bob Marley \r\nTo: user@example.com\r\nSubject: One love\r\n\r\nHello there, this is the body.") + + // "bob@example.com" mimics what the old (pre-fix) code would have cached — the + // bare envelope address — despite the header above always having the display name. + uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "", "bob@example.com", "One love") + if err != nil { + t.Fatal(err) + } + before, err := s.DB.GetMessageByUID(mailboxID, uid) + if err != nil { + t.Fatal(err) + } + if before.CachedFrom != "bob@example.com" { + t.Fatalf("test setup: expected the stale bare address before rebuild, got %q", before.CachedFrom) + } + + updated, skipped := s.RebuildMessageCache(mailboxID) + if len(skipped) != 0 { + t.Fatalf("expected no skipped messages, got %v", skipped) + } + if updated != 1 { + t.Fatalf("expected 1 message updated, got %d", updated) + } + + after, err := s.DB.GetMessageByUID(mailboxID, uid) + if err != nil { + t.Fatal(err) + } + if after.CachedFrom != "Bob Marley " { + t.Errorf("CachedFrom after rebuild = %q, want the header's display name", after.CachedFrom) + } + if after.CachedPreview != "Hello there, this is the body." { + t.Errorf("CachedPreview after rebuild = %q", after.CachedPreview) + } + + // Re-running is a safe no-op once everything's already correct. + updated2, _ := s.RebuildMessageCache(mailboxID) + if updated2 != 0 { + t.Errorf("expected 0 messages updated on a second run, got %d", updated2) + } } func TestQuotaExceeded(t *testing.T) { diff --git a/internal/mailstore/store.go b/internal/mailstore/store.go index 0e30cdd..2571dec 100644 --- a/internal/mailstore/store.go +++ b/internal/mailstore/store.go @@ -9,7 +9,10 @@ import ( "net/mail" "os" "path/filepath" + "strings" "time" + + "mailgoserver/internal/mailview" ) // extractHeaderValue reads a single header out of raw without parsing the body — used @@ -25,6 +28,29 @@ func extractHeaderValue(raw []byte, name string) string { return msg.Header.Get(name) } +const previewSnippetLen = 150 + +// previewSnippet extracts up to previewSnippetLen characters of the plain-text body +// for the folder list's preview line — cached in plain text alongside cached_from/ +// cached_subject (see schema.go's comment on cached_preview for why that's consistent +// with the existing cached_* columns, not a new exposure). HTML-only mail (no +// text/plain part) gets no preview rather than a crude tag-stripped approximation — +// an accepted scope limit, not a bug: most real mail includes a text/plain +// alternative regardless of whether the sender expects it to be shown. +func previewSnippet(raw []byte) string { + parsed, err := mailview.Parse(raw) + if err != nil { + return "" + } + text := strings.Join(strings.Fields(parsed.TextBody), " ") + // Rune-safe truncation — a plain byte slice could split a multi-byte UTF-8 + // character in half and produce invalid text. + if runes := []rune(text); len(runes) > previewSnippetLen { + text = string(runes[:previewSnippetLen]) + } + return text +} + // ErrQuotaExceeded is returned by StoreMessage when storing raw would push the // mailbox over its quota. No row, file, or used_bytes change occurs in that case. var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded") @@ -66,7 +92,7 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message return 0, err } - uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject) + uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject, previewSnippet(raw)) if err != nil { os.Remove(storagePath) return 0, err @@ -77,6 +103,52 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message return uid, nil } +// RebuildMessageCache re-derives cached_from/cached_to/cached_subject/cached_preview +// for every message already stored in mailboxID, from each message's own decrypted +// content — these fields are otherwise only ever computed once, at delivery time +// (see StoreMessage), so mail stored before a caching fix or addition landed (e.g. +// caching the From: header's display name instead of the bare envelope address, or +// the cached_preview column itself) keeps showing the old/blank value forever unless +// something re-derives it. Returns how many rows actually changed; a message that +// fails to decrypt/parse is skipped (counted in the error map, not fatal to the rest). +func (s *Store) RebuildMessageCache(mailboxID int64) (updated int, skipped map[int64]error) { + skipped = map[int64]error{} + msgs, err := s.DB.ListMessagesForMailbox(mailboxID) + if err != nil { + skipped[0] = err + return 0, skipped + } + for _, m := range msgs { + raw, err := s.FetchMessage(mailboxID, m.ID) + if err != nil { + skipped[m.ID] = err + continue + } + from := extractHeaderValue(raw, "From") + if from == "" { + from = m.CachedFrom + } + to := extractHeaderValue(raw, "To") + if to == "" { + to = m.CachedTo + } + subject := extractHeaderValue(raw, "Subject") + if subject == "" { + subject = m.CachedSubject + } + preview := previewSnippet(raw) + if from == m.CachedFrom && to == m.CachedTo && subject == m.CachedSubject && preview == m.CachedPreview { + continue // already correct — don't churn a write for nothing + } + if err := s.DB.UpdateMessageCachedFields(m.ID, from, to, subject, preview); err != nil { + skipped[m.ID] = err + continue + } + updated++ + } + return updated, skipped +} + // FetchMessage decrypts a stored message on demand. Plaintext is never written to disk // or cached — only returned to the caller. func (s *Store) FetchMessage(mailboxID, uid int64) ([]byte, error) { diff --git a/internal/relay/bounce.go b/internal/relay/bounce.go new file mode 100644 index 0000000..f00108e --- /dev/null +++ b/internal/relay/bounce.go @@ -0,0 +1,102 @@ +package relay + +import ( + "fmt" + "strings" + "time" + + "mailgoserver/internal/mailstore" + "mailgoserver/internal/toolbox" +) + +// buildBounceMessage renders a simplified delivery-status notification: plain +// text/plain, not the full RFC 3464 multipart/report shape (a machine-readable +// message/delivery-status part), since every real mail client just shows the +// human-readable part anyway and this avoids a second MIME structure to maintain for +// something informational, not delivery-critical. +func buildBounceMessage(hostname, to, originalSubject, originalMessageID string, failed []Result) (content, messageID string) { + messageID = toolbox.GenerateMessageID(hostname) + var body strings.Builder + body.WriteString("This is an automatically generated Delivery Status Notification.\r\n\r\n") + body.WriteString("Delivery to the following recipient(s) failed permanently:\r\n\r\n") + for _, f := range failed { + reason := f.ErrorMessage + if reason == "" { + reason = f.ServerResponse + } + if reason == "" { + reason = "unknown error" + } + body.WriteString(fmt.Sprintf(" - %s\r\n", f.Recipient)) + if f.ErrorCode != "" { + body.WriteString(fmt.Sprintf(" Reason: %s (%s)\r\n\r\n", reason, f.ErrorCode)) + } else { + body.WriteString(fmt.Sprintf(" Reason: %s\r\n\r\n", reason)) + } + } + body.WriteString("----- Original message -----\r\n") + if originalSubject != "" { + body.WriteString("Subject: " + originalSubject + "\r\n") + } + if originalMessageID != "" { + body.WriteString("Message-ID: <" + originalMessageID + ">\r\n") + } + body.WriteString("\r\nThis is an automated message from " + hostname + " — please do not reply.\r\n") + + headers := []string{ + "Message-ID: <" + messageID + ">", + "Date: " + time.Now().Format(time.RFC1123Z), + "From: Mail Delivery System ", + "To: " + to, + "Subject: Undelivered Mail Returned to Sender", + // Marks this as an automated notification (RFC 3834) so any auto-responder or + // bounce-of-a-bounce logic on the receiving end knows not to reply to it — + // same anti-loop convention SendBounce itself relies on for its own delivery. + "Auto-Submitted: auto-replied", + `Content-Type: text/plain; charset="UTF-8"`, + "Content-Transfer-Encoding: 8bit", + "MIME-Version: 1.0", + } + return strings.Join(headers, "\r\n") + "\r\n\r\n" + body.String(), messageID +} + +// SendBounce notifies to that delivery failed for the recipients in failed, mirroring +// what a real MTA does when it can't express "delivered to some, not others" as a +// single SMTP response and has to accept-then-notify instead of reject-and-let-the- +// client's-own-MTA-bounce-it. A no-op if to is empty (this itself would be responding +// to a null-sender/already-bounced message — replying to those is the classic bounce- +// loop bug, so it's refused unconditionally, not left to the caller to remember) or if +// there's nothing failed to report. +// +// Delivery is local (straight into to's own INBOX, no SMTP round-trip) when to +// resolves to a mailbox this server hosts, otherwise it's relayed out exactly like any +// other outbound message — using a null reverse-path (empty MAIL FROM, i.e. the wire +// form "MAIL FROM:<>") so a failure bouncing the bounce itself can never recurse. +func (r *Relay) SendBounce(to, originalSubject, originalMessageID string, failed []Result) error { + if to == "" || len(failed) == 0 { + return nil + } + + content, bounceMessageID := buildBounceMessage(r.Hostname, to, originalSubject, originalMessageID, failed) + + if r.Mailstore != nil { + if mbox, err := r.Mailstore.ResolveRecipient(to); err == nil && mbox != nil { + _, err := r.Mailstore.StoreMessage(mbox.ID, "INBOX", []byte(content), bounceMessageID, + "Mail Delivery System ", "Undelivered Mail Returned to Sender") + if err == mailstore.ErrQuotaExceeded { + // Nothing sensible to do — the mailbox that would receive the bounce + // is itself over quota. Drop it; the sender already saw an inline + // error (webmail) or their own MTA is retrying (SMTP), so this isn't + // the only signal they have. + return nil + } + return err + } + } + + results := r.RelayEmailAsync("", []string{to}, content, []string{"to"}) + if len(results) > 0 && results[0].Status != "success" { + return fmt.Errorf("bounce to %s: %s", to, results[0].ErrorMessage) + } + return nil +} diff --git a/internal/relay/bounce_test.go b/internal/relay/bounce_test.go new file mode 100644 index 0000000..db36c88 --- /dev/null +++ b/internal/relay/bounce_test.go @@ -0,0 +1,98 @@ +package relay + +import ( + "path/filepath" + "strings" + "testing" + + "mailgoserver/internal/db" + "mailgoserver/internal/mailstore" +) + +func TestBuildBounceMessageContainsFailureDetails(t *testing.T) { + content, messageID := buildBounceMessage("mail.example.com", "sender@example.com", "Hello", "orig-id@example.com", []Result{ + {Recipient: "nobody@remote.example", ErrorMessage: "MX lookup failed", ErrorCode: "MX"}, + }) + if messageID == "" { + t.Fatal("expected a non-empty bounce Message-ID") + } + for _, want := range []string{ + "To: sender@example.com", + "Subject: Undelivered Mail Returned to Sender", + "From: Mail Delivery System ", + "Auto-Submitted: auto-replied", + "nobody@remote.example", + "MX lookup failed", + "Subject: Hello", + "Message-ID: ", + } { + if !strings.Contains(content, want) { + t.Errorf("expected bounce content to contain %q, got:\n%s", want, content) + } + } +} + +func TestSendBounceNoOpWithoutSenderOrFailures(t *testing.T) { + r := &Relay{Hostname: "mail.example.com"} + if err := r.SendBounce("", "subj", "id", []Result{{Recipient: "x@example.com", ErrorMessage: "boom"}}); err != nil { + t.Fatalf("expected nil error for empty sender, got %v", err) + } + if err := r.SendBounce("sender@example.com", "subj", "id", nil); err != nil { + t.Fatalf("expected nil error for no failures, got %v", err) + } +} + +// newTestMailbox mirrors mailstore's own test helper of the same name — kept local +// since it's unexported there and this package needs its own small Store+mailbox +// fixture to test SendBounce's local-delivery shortcut. +func newTestMailbox(t *testing.T, email string) (*mailstore.Store, int64) { + t.Helper() + database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + + domainID, err := database.CreateDomain("example.com") + if err != nil { + t.Fatal(err) + } + s := mailstore.New(database, mailstore.GenerateDEK(), t.TempDir()) + dek := mailstore.GenerateDEK() + wrapped, nonce, err := s.WrapDEK(dek) + if err != nil { + t.Fatal(err) + } + hash, err := db.HashPassword("irrelevant-portal-password") + if err != nil { + t.Fatal(err) + } + mailboxID, err := database.CreateMailbox(email, hash, domainID, 1<<30, wrapped, nonce) + if err != nil { + t.Fatal(err) + } + return s, mailboxID +} + +func TestSendBounceDeliversLocallyWhenRecipientIsLocalMailbox(t *testing.T) { + store, mailboxID := newTestMailbox(t, "sender@example.com") + r := &Relay{Hostname: "mail.example.com", Mailstore: store, Logger: nil} + + err := r.SendBounce("sender@example.com", "Hello", "orig-id@example.com", []Result{ + {Recipient: "nobody@remote.example", ErrorMessage: "MX lookup failed", ErrorCode: "MX"}, + }) + if err != nil { + t.Fatalf("SendBounce: %v", err) + } + + inbox, err := store.DB.ListMessagesInFolder(mailboxID, "INBOX") + if err != nil { + t.Fatal(err) + } + if len(inbox) != 1 { + t.Fatalf("expected 1 bounce message in INBOX, got %d", len(inbox)) + } + if inbox[0].CachedSubject != "Undelivered Mail Returned to Sender" { + t.Errorf("bounce subject = %q", inbox[0].CachedSubject) + } +} diff --git a/internal/relay/relay.go b/internal/relay/relay.go index d7be57c..60a6020 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -13,6 +13,7 @@ import ( "gopkg.in/ini.v1" "mailgoserver/internal/db" + "mailgoserver/internal/mailstore" "mailgoserver/internal/toolbox" ) @@ -26,15 +27,29 @@ type Result struct { ErrorCode string ErrorMessage string ServerResponse string + // Quarantined is true for a local delivery that landed in Spam rather than INBOX. + // Only ever set by smtpserver's local-delivery path (always false for outbound + // relay results) — it's the signal Data() uses to keep this message's body in the + // admin log despite content-logging otherwise being off by default, so a spam/ + // malicious report can actually be reviewed. + Quarantined bool } type Relay struct { DB *db.DB Timeout time.Duration // Hostname is used as the outbound EHLO/HELO identity, mirroring - // email_relay.py's self.hostname (helo_hostname, falling back to hostname). + // email_relay.py's self.hostname (helo_hostname, falling back to hostname), and as + // the domain part of SendBounce's mailer-daemon@ From address. Hostname string Logger *toolbox.Logger + + // Mailstore backs SendBounce's local-delivery shortcut (straight into a bounce + // recipient's own INBOX when they're a mailbox this server hosts, no SMTP + // round-trip needed). Set by main.go once mailstore.New has run — relay.New runs + // before that, so this is assigned afterward rather than threaded through the + // constructor. Nil-safe: SendBounce falls back to relaying out when unset. + Mailstore *mailstore.Store } // New builds a Relay from settings.ini. Unlike email_relay.py (which reads diff --git a/internal/smtpserver/bounce_test.go b/internal/smtpserver/bounce_test.go new file mode 100644 index 0000000..e062936 --- /dev/null +++ b/internal/smtpserver/bounce_test.go @@ -0,0 +1,101 @@ +package smtpserver + +import ( + "net/smtp" + "testing" + + "mailgoserver/internal/db" + "mailgoserver/internal/mailstore" +) + +// createTestMailboxWithQuota mirrors newTestBackendWithMailbox's inline mailbox setup, +// parameterized by quota so this file can create both a normal and an +// effectively-always-full mailbox. +func createTestMailboxWithQuota(t *testing.T, backend *Backend, store *mailstore.Store, email string, quotaBytes int64) int64 { + t.Helper() + dek := mailstore.GenerateDEK() + wrapped, nonce, err := store.WrapDEK(dek) + if err != nil { + t.Fatal(err) + } + hash, err := db.HashPassword("portal-password-unused") + if err != nil { + t.Fatal(err) + } + mailboxID, err := backend.DB.CreateMailbox(email, hash, 1, quotaBytes, wrapped, nonce) + if err != nil { + t.Fatal(err) + } + return mailboxID +} + +// TestPartialLocalDeliveryFailureBouncesAndAccepts confirms a multi-recipient +// transaction where one local mailbox accepts the message and another can't (quota +// exceeded, discovered only during DATA — RCPT can't catch it) is accepted (250, not +// 550 — the successful recipient already has it, so the client must not retry the +// whole transaction) and that the sender gets a bounce in their own mailbox describing +// the recipient that failed. +func TestPartialLocalDeliveryFailureBouncesAndAccepts(t *testing.T) { + backend, okMailboxID := newTestBackendWithMailbox(t) + store := backend.Mailstore + + fullMailboxID := createTestMailboxWithQuota(t, backend, store, "full@example.com", 1) + senderMailboxID := createTestMailboxWithQuota(t, backend, store, "test@example.com", 5*1024*1024*1024) + + addr := startTestServer(t, backend) + c, err := smtp.Dial(addr) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil { + t.Fatalf("auth: %v", err) + } + if err := c.Mail("test@example.com"); err != nil { + t.Fatalf("MAIL FROM: %v", err) + } + if err := c.Rcpt("inbox@example.com"); err != nil { + t.Fatalf("RCPT (ok mailbox): %v", err) + } + if err := c.Rcpt("full@example.com"); err != nil { + t.Fatalf("RCPT (over-quota mailbox, still accepted at RCPT time): %v", err) + } + w, err := c.Data() + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("Subject: hello\r\n\r\nhi there")); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatalf("expected DATA to succeed (250, partial success) despite one recipient failing, got: %v", err) + } + + okMsgs, err := backend.DB.ListMessagesInFolder(okMailboxID, "INBOX") + if err != nil { + t.Fatal(err) + } + if len(okMsgs) != 1 { + t.Fatalf("expected the message delivered to inbox@example.com, got %d messages", len(okMsgs)) + } + + fullMsgs, err := backend.DB.ListMessagesInFolder(fullMailboxID, "INBOX") + if err != nil { + t.Fatal(err) + } + if len(fullMsgs) != 0 { + t.Fatalf("expected no message delivered to the over-quota mailbox, got %d", len(fullMsgs)) + } + + bounces, err := backend.DB.ListMessagesInFolder(senderMailboxID, "INBOX") + if err != nil { + t.Fatal(err) + } + if len(bounces) != 1 { + t.Fatalf("expected 1 bounce message in the sender's own mailbox, got %d", len(bounces)) + } + if bounces[0].CachedSubject != "Undelivered Mail Returned to Sender" { + t.Errorf("bounce subject = %q", bounces[0].CachedSubject) + } +} diff --git a/internal/smtpserver/log_privacy_test.go b/internal/smtpserver/log_privacy_test.go new file mode 100644 index 0000000..2a497f1 --- /dev/null +++ b/internal/smtpserver/log_privacy_test.go @@ -0,0 +1,86 @@ +package smtpserver + +import "testing" + +// TestEmailLogBodyOmittedByDefault confirms the admin-visible email log gets the +// message headers but never the body by default — only Subject/headers are diagnostic +// metadata; the body is content, which shouldn't sit in a log unless explicitly opted +// into (store_message_content) or the message needed spam review (see +// TestEmailLogBodyKeptWhenQuarantined). +func TestEmailLogBodyOmittedByDefault(t *testing.T) { + backend, _ := newTestBackendWithMailbox(t) // spam_reject_score set sky-high, so nothing quarantines here + addr := startTestServer(t, backend) + + if err := sendTestMessage(t, addr, "hello"); err != nil { + t.Fatalf("send: %v", err) + } + + logs, err := backend.DB.ListEmailLogsPage(0, 10) + if err != nil { + t.Fatal(err) + } + if len(logs) != 1 { + t.Fatalf("expected 1 email log entry, got %d", len(logs)) + } + if logs[0].MessageBody != "" { + t.Errorf("expected no body logged by default, got %q", logs[0].MessageBody) + } + if logs[0].EmailHeaders == "" { + t.Error("expected headers to still be logged even with body omitted") + } +} + +// TestEmailLogBodyKeptWhenStoreMessageContentEnabled confirms the sender's own +// "Store Full Message Content" opt-in (esrv_senders.store_message_content) still works +// despite the new default-off body logging. +func TestEmailLogBodyKeptWhenStoreMessageContentEnabled(t *testing.T) { + backend, _ := newTestBackendWithMailbox(t) + if _, err := backend.DB.Exec(`UPDATE esrv_senders SET store_message_content = 1 WHERE email = 'test@example.com'`); err != nil { + t.Fatal(err) + } + addr := startTestServer(t, backend) + + if err := sendTestMessage(t, addr, "hello"); err != nil { + t.Fatalf("send: %v", err) + } + + logs, err := backend.DB.ListEmailLogsPage(0, 10) + if err != nil { + t.Fatal(err) + } + if len(logs) != 1 || logs[0].MessageBody == "" { + t.Fatalf("expected the opted-in sender's message body to be logged, got %+v", logs) + } +} + +// TestEmailLogBodyKeptWhenQuarantined confirms a message quarantined to Spam still +// gets its body logged even without any opt-in, so an admin can actually review a +// spam/abuse report — the one deliberate exception to the default-off rule. +func TestEmailLogBodyKeptWhenQuarantined(t *testing.T) { + backend, mailboxID := newTestBackendWithMailbox(t) + rspamd := fakeRspamd(t, 20, "add header") + backend.Cfg.Section("Rspamd").Key("enabled").SetValue("true") + backend.Cfg.Section("Rspamd").Key("url").SetValue(rspamd.URL) + backend.Cfg.Section("Rspamd").Key("reject_score").SetValue("15") + addr := startTestServer(t, backend) + + if err := sendTestMessage(t, addr, "hello"); err != nil { + t.Fatalf("send: %v", err) + } + + spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam") + if err != nil { + t.Fatal(err) + } + if len(spamMsgs) != 1 { + t.Fatalf("expected the message quarantined to Spam, got %d Spam messages", len(spamMsgs)) + } + + logs, err := backend.DB.ListEmailLogsPage(0, 10) + if err != nil { + t.Fatal(err) + } + if len(logs) != 1 || logs[0].MessageBody == "" { + t.Fatalf("expected the quarantined message's body to be logged for review, got %+v", logs) + } +} diff --git a/internal/smtpserver/mailbox_delivery_test.go b/internal/smtpserver/mailbox_delivery_test.go index 94160ae..f6e9940 100644 --- a/internal/smtpserver/mailbox_delivery_test.go +++ b/internal/smtpserver/mailbox_delivery_test.go @@ -18,6 +18,7 @@ func newTestBackendWithMailbox(t *testing.T) (*Backend, int64) { store := mailstore.New(backend.DB, mailstore.GenerateDEK(), t.TempDir()) backend.Mailstore = store + backend.Relay.Mailstore = store // mirrors main.go's wiring, needed for SendBounce's local-delivery shortcut // Spam/SPF/DNSBL checks make live DNS calls (see internal/mailstore) — deliberately // so in production, but that makes their exact score environment-dependent (e.g. a // resolver that hijacks NXDOMAIN, or a real SPF record on the test domain). These @@ -80,6 +81,52 @@ func TestLocalDeliveryToKnownMailbox(t *testing.T) { } } +// TestLocalDeliveryCachesFromHeaderDisplayName confirms cached_from is the message's +// own From: header (e.g. "Bob Marley "), not the bare SMTP envelope +// address — the envelope rarely carries a display name, but the header usually does, +// and webmail's folder list wants the display name to show. +func TestLocalDeliveryCachesFromHeaderDisplayName(t *testing.T) { + backend, mailboxID := newTestBackendWithMailbox(t) + addr := startTestServer(t, backend) + + c, err := smtp.Dial(addr) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil { + t.Fatalf("auth: %v", err) + } + if err := c.Mail("test@example.com"); err != nil { + t.Fatalf("MAIL FROM: %v", err) + } + if err := c.Rcpt("inbox@example.com"); err != nil { + t.Fatalf("RCPT: %v", err) + } + w, err := c.Data() + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("From: Bob Marley \r\nSubject: hello\r\n\r\nhi there")); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatalf("DATA: %v", err) + } + + msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].CachedFrom != "Bob Marley " { + t.Errorf("cached_from = %q, want the From: header value with display name", msgs[0].CachedFrom) + } +} + func TestLocalDeliveryUnknownMailboxRejected(t *testing.T) { backend, _ := newTestBackendWithMailbox(t) addr := startTestServer(t, backend) diff --git a/internal/smtpserver/session.go b/internal/smtpserver/session.go index 1fc23cd..2b7968b 100644 --- a/internal/smtpserver/session.go +++ b/internal/smtpserver/session.go @@ -248,6 +248,14 @@ func (s *Session) Data(r io.Reader) error { toHeader := rebuiltHeaders["to"] ccHeader := rebuiltHeaders["cc"] subject := rebuiltHeaders["subject"] + // The message's own From: header (e.g. "Bob Marley "), not the + // bare SMTP envelope address — used only for what's cached/displayed (webmail's + // folder list), never for delivery/auth decisions, which stay on s.mailFrom + // throughout. Falls back to the envelope address if the header's missing/empty. + fromHeader := rebuiltHeaders["from"] + if fromHeader == "" { + fromHeader = s.mailFrom + } // Attachment storage: only if the authenticated sender or whitelisted IP opted in. storeMessage := false @@ -321,23 +329,66 @@ func (s *Session) Data(r io.Reader) error { results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes) } if len(localRcpts) > 0 { - results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject)...) + results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject, fromHeader)...) } - allSucceeded := len(results) > 0 + var failed []relay.Result for _, res := range results { if res.Status != "success" { - allSucceeded = false + failed = append(failed, res) + } + } + allSucceeded := len(results) > 0 && len(failed) == 0 + anySucceeded := len(results) > len(failed) + + // A single SMTP response to DATA can't express "delivered to some recipients, not + // others" — rejecting the whole transaction here would make the connecting + // server's own retry logic re-deliver to the recipients that already succeeded. + // So: accept (below) and bounce the failed subset back to our own sender instead, + // exactly like a real MTA splitting a multi-recipient transaction's outcome. A + // bounce is never sent for a *total* failure — that gets rejected outright (550) + // below instead, letting the connecting server's own MTA generate the bounce to + // its user, avoiding a double notification. Skipped entirely for a null-sender + // message (s.mailFrom == "", already itself a bounce/DSN — replying to one is the + // classic bounce-loop bug) and for a currently-blacklisted peer, so a delivery + // failure never becomes a free "yes, that mailbox doesn't exist" oracle for abuse. + if len(failed) > 0 && anySucceeded && s.mailFrom != "" { + if blacklisted, _ := s.backend.DB.IsIPBlacklisted(s.peerIP); !blacklisted { + if err := s.backend.Relay.SendBounce(s.mailFrom, subject, messageID, failed); err != nil { + s.backend.Logger.Error("send bounce to %s: %v", s.mailFrom, err) + } } } - var emailHeaders, messageBody string + var emailHeaders string if parseErr == nil { emailHeaders = strings.Join(parsed.HeaderLines, "\n") - messageBody = parsed.BodyText } - logID, logErr := s.backend.Relay.LogEmail(s.backend.Cfg, s.peerIP, s.mailFrom, toHeader, ccHeader, "", subject, emailHeaders, messageBody, messageID, s.username, dkimSigned, results) + // Privacy default: only headers (and the Subject field, logged separately below + // regardless) go into the admin-visible log, never the message itself — unless this + // sender/IP explicitly opted in via "Store Full Message Content" (storeMessage + // above), or the message was quarantined to Spam for at least one recipient, in + // which case an admin genuinely needs to see it to judge a spam/abuse report. When + // stored, it's the *entire* raw message (not a plain-text extraction) so the log + // viewer can render the real HTML body, inline images, and attachments — re-parsed + // on demand via internal/mailview, the same parser webmail's own message view uses + // — rather than a degraded text-only approximation. + storeContent := storeMessage + if !storeContent { + for _, res := range results { + if res.Quarantined { + storeContent = true + break + } + } + } + loggedBody := "" + if storeContent { + loggedBody = signedContent + } + + logID, logErr := s.backend.Relay.LogEmail(s.backend.Cfg, s.peerIP, s.mailFrom, toHeader, ccHeader, "", subject, emailHeaders, loggedBody, messageID, s.username, dkimSigned, results) if logErr != nil { s.backend.Logger.Error("Failed to log email: %v", logErr) } else { @@ -353,6 +404,12 @@ func (s *Session) Data(r io.Reader) error { if allSucceeded { return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery"} } + if anySucceeded { + // Some recipients already have the message — 250 it (see the bounce comment + // above for why), not 550, which would tell the connecting server to retry + // the whole thing and re-deliver to those recipients a second time. + return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery to some recipients"} + } return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"} } @@ -361,7 +418,7 @@ func (s *Session) Data(r io.Reader) error { // and stores it into each resolved local mailbox, producing one relay.Result per // recipient so it can be merged into the same LogEmail/allSucceeded logic as relay // results. -func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID, subject string) []relay.Result { +func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID, subject, fromDisplay string) []relay.Result { senderDomain := domainOfAddr(s.mailFrom) dkimPass := senderDomain != "" && dkim.VerifyInbound(signedContent, senderDomain) spfPass := mailstore.CheckSPF(s.mailFrom, s.peerIP) @@ -430,7 +487,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID markRead = action.MarkRead } - uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, s.mailFrom, subject) + uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, fromDisplay, subject) if err != nil { errCode, errMsg := "450", err.Error() if err == mailstore.ErrQuotaExceeded { @@ -448,7 +505,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID if spamGated { serverResponse = "Quarantined to Spam folder" } - results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse}) + results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse, Quarantined: spamGated}) } return results } diff --git a/internal/webui/letsencrypt.go b/internal/webui/letsencrypt.go index 7044908..9c85624 100644 --- a/internal/webui/letsencrypt.go +++ b/internal/webui/letsencrypt.go @@ -7,15 +7,23 @@ import ( "path/filepath" "strings" "time" + + "mailgoserver/internal/acmecert" ) -// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] settings — always -// persisted from the submitted form, same as any other settings.html field. +// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] (DNS-01) settings — +// always persisted from the submitted form, same as any other settings.html field. var leAlwaysOverwriteFields = []string{ "enabled", "staging", "contact_email", "domains", "dns_provider", "route53_region", "route53_hosted_zone_id", "gcloud_project", } +// leHTTPFields are the [LetsEncryptHTTP] (HTTP-01) settings, all plain — no secrets to +// redact, unlike the DNS-01 providers' API credentials. +var leHTTPFields = []string{ + "enabled", "staging", "contact_email", "domains", "include_ip", "ip_override", +} + // leSecretFields hold DNS provider credentials. They're never rendered back into the // form (always blank) and the save handler only overwrites the stored value when the // submitted field is non-empty — "leave blank to keep the current value", the same @@ -25,8 +33,9 @@ var leSecretFields = []string{ "digitalocean_api_token", "gcloud_service_account_json_path", } -// letsEncryptPage shows the current Let's Encrypt status and configuration form. -// Secret fields are always blank in the rendered form — see leSecretFields. +// letsEncryptPage shows the current Let's Encrypt status and configuration forms for +// both the DNS-01 and HTTP-01 managers. Secret fields are always blank in the rendered +// form — see leSecretFields. func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) { sec := a.Cfg.Section("LetsEncrypt") kv := M{} @@ -36,7 +45,18 @@ func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) { for _, k := range leSecretFields { kv[k] = "" } - a.render(w, r, "letsencrypt.html", M{"active": "letsencrypt", "le": kv, "status": a.ACME.Status()}) + + httpSec := a.Cfg.Section("LetsEncryptHTTP") + httpKV := M{} + for _, k := range leHTTPFields { + httpKV[k] = httpSec.Key(k).String() + } + + a.render(w, r, "letsencrypt.html", M{ + "active": "letsencrypt", + "le": kv, "status": a.ACME.Status(), + "leHTTP": httpKV, "statusHTTP": a.ACMEHTTP.Status(), + }) } // letsEncryptSave is a dedicated handler (not the generic settingsUpdate reflection) @@ -79,6 +99,39 @@ func (a *App) letsEncryptObtainNow(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) } +// letsEncryptHTTPSave mirrors letsEncryptSave for the [LetsEncryptHTTP] section — a +// separate handler (not the generic settingsUpdate reflection) purely so it can +// redirect back to /letsencrypt like its DNS-01 sibling; every field here is plain, so +// unlike letsEncryptSave there's no secret-redaction concern. +func (a *App) letsEncryptHTTPSave(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + setFlash(w, "error", "Invalid form data") + http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) + return + } + sec := a.Cfg.Section("LetsEncryptHTTP") + for _, k := range leHTTPFields { + sec.Key(k).SetValue(r.FormValue(k)) + } + if err := a.Cfg.SaveTo(a.ConfigPath); err != nil { + setFlash(w, "error", "Error saving settings: "+err.Error()) + http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) + return + } + setFlash(w, "success", `Let's Encrypt HTTP-01 settings saved. If you just changed "Enable HTTP-01" or the port, restart the server before using "Obtain / Renew Now" — the challenge responder only starts at boot.`) + http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) +} + +// letsEncryptHTTPObtainNow mirrors letsEncryptObtainNow for the HTTP-01 manager. +func (a *App) letsEncryptHTTPObtainNow(w http.ResponseWriter, r *http.Request) { + if err := a.ACMEHTTP.ObtainOrRenew(r.Context()); err != nil { + setFlash(w, "error", "Could not obtain certificate: "+err.Error()) + } else { + setFlash(w, "success", "Certificate obtained successfully") + } + http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound) +} + // uploadGCloudServiceAccount mirrors settings.go's uploadTLSFile two-step flow: upload // the file, return its saved path as JSON, and the browser fills a sibling text input // with that path — the path only actually persists once the surrounding form (Save) @@ -113,3 +166,14 @@ func (a *App) uploadGCloudServiceAccount(w http.ResponseWriter, r *http.Request) } writeJSON(w, http.StatusOK, M{"status": "success", "filepath": filePath}) } + +// detectWANIP backs the "Detect" button next to the HTTP-01 manual IP override field — +// a live lookup, not persisted anywhere until the surrounding form is saved. +func (a *App) detectWANIP(w http.ResponseWriter, r *http.Request) { + ip, err := acmecert.DetectWANIP(r.Context()) + if err != nil { + writeJSON(w, http.StatusBadGateway, M{"status": "error", "message": err.Error()}) + return + } + writeJSON(w, http.StatusOK, M{"status": "success", "ip": ip}) +} diff --git a/internal/webui/render.go b/internal/webui/render.go index c10251b..55aa575 100644 --- a/internal/webui/render.go +++ b/internal/webui/render.go @@ -4,6 +4,7 @@ import ( "fmt" "html/template" "net/http" + "net/mail" "strconv" "strings" "time" @@ -50,6 +51,26 @@ func (a *App) funcMap() template.FuncMap { "lower": strings.ToLower, "safe": func(s string) template.HTML { return template.HTML(s) }, "filesize": humanFileSize, + // senderName shows just the display name from a "Name " + // cached_from value (the message's own From: header, cached verbatim — see + // smtpserver's fromHeader) — falls back to the bare address when there's no + // display name, or the value doesn't parse as one (e.g. an older row cached + // before this, or a plain envelope address with no header form). + "senderName": func(s string) string { + if addr, err := mail.ParseAddress(s); err == nil && addr.Name != "" { + return addr.Name + } + return s + }, + // initial is the avatar-circle letter for a sender name/address — the first + // rune, uppercased; falls back to "?" for an empty value rather than an empty + // circle. + "initial": func(s string) string { + for _, r := range s { + return strings.ToUpper(string(r)) + } + return "?" + }, "dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, @@ -134,7 +155,7 @@ var pages = []string{ "ips.html", "add_ip.html", "edit_ip.html", "blacklist.html", "dkim.html", "edit_dkim.html", - "settings.html", "letsencrypt.html", "logs.html", "view_message_content.html", "error.html", + "settings.html", "letsencrypt.html", "logs.html", "error.html", "account.html", "first_login.html", "admins.html", "add_admin.html", "edit_admin.html", } @@ -148,6 +169,14 @@ var standalonePages = []string{ "login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html", "webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html", "webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html", + // A bare HTML fragment (no /base.html chrome at all), fetched via JS and + // injected into logs.html's full-screen modal — not a page anyone navigates to + // directly, so it doesn't need to look like a standalone document the way the + // other entries in this list (all real standalone pages) do. + "view_message_content.html", + // Same idea as view_message_content.html above, but for webmail_folder.html's + // reading pane instead of the admin log's modal. + "webmail_message_pane.html", } // pagesWithComposeWidget are the standalone pages that show a Compose/Reply/Forward diff --git a/internal/webui/templates/letsencrypt.html b/internal/webui/templates/letsencrypt.html index 39f168d..9e66821 100644 --- a/internal/webui/templates/letsencrypt.html +++ b/internal/webui/templates/letsencrypt.html @@ -6,21 +6,29 @@

Let's Encrypt

+

+ Two independent certificates can be obtained here — DNS-01 (needs a supported DNS provider) and + HTTP-01 (needs nothing but port 80 reachable from the internet). Enable either, both, or + neither. Which listener (SMTP-TLS, IMAP-TLS, or the admin/webmail HTTPS) actually uses which + certificate is chosen on the Settings page's TLS/SSL + section — e.g. run the HTTP-01 cert on mail while the dashboard keeps a DNS-01 or custom cert. +

+
-
Status
+
DNS-01
Mode
{{if .status.Enabled}} - Let's Encrypt {{if .status.Staging}}(staging){{end}} + Enabled {{if .status.Staging}}(staging){{end}} {{else}} - Self-signed (Let's Encrypt disabled) + Disabled {{end}}
Domains
{{if .status.Domains}}{{range .status.Domains}}{{.}} {{end}}{{else}}none configured{{end}}
-
Provider
+
DNS Provider
{{if .status.Provider}}{{.status.Provider}}{{else}}none selected{{end}}
Certificate expires
{{if .status.NotAfter.IsZero}}unknown{{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}
@@ -36,114 +44,188 @@
- + +
+ +
+
+
+ + +
+
+ + +
Recommended while testing a new configuration.
+
+
+ + +
+
+ + +
Comma-separated. Include a wildcard entry (e.g. *.mail.example.com) alongside its bare domain to cover both with one certificate.
+
+
+ + +
+ +
+
+
Cloudflare
+
+ + +
+
+
+ +
+
+
AWS Route53
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
DigitalOcean
+
+ + +
+
+
+ +
+
+
Google Cloud DNS
+
+ + +
+
+ +
+ + + +
+
+
+
+ +
-
-
Configuration
+
HTTP-01
-
- - -
-
- - -
Recommended while testing a new configuration.
-
-
- - -
-
- - -
Comma-separated. Include a wildcard entry (e.g. *.mail.example.com) alongside its bare domain to cover both with one certificate.
-
-
- - -
+
+
Mode
+
+ {{if .statusHTTP.Enabled}} + Enabled + {{else}} + Disabled + {{end}} +
+
Domains
+
{{if .statusHTTP.Domains}}{{range .statusHTTP.Domains}}{{.}} {{end}}{{else}}none configured{{end}}{{if .statusHTTP.IncludeIP}} + server IP{{end}}
+
Certificate expires
+
{{if .statusHTTP.NotAfter.IsZero}}unknown{{else}}{{strftime "%Y-%m-%d %H:%M" .statusHTTP.NotAfter}}{{end}}
+
Last attempt
+
+ {{if .statusHTTP.LastAttempt.IsZero}} + none yet this run + {{else if .statusHTTP.LastError}} + {{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — {{.statusHTTP.LastError}} + {{else}} + {{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — success + {{end}} +
+
+ + + -
-
-
Cloudflare
-
- - +
+
+
+ + +
No DNS provider needed — Let's Encrypt verifies ownership by requesting a token over plain HTTP on this port. Once enabled and the server is restarted, this port stays bound for the life of the process (not just during an obtain) — so you can confirm your router/proxy port-forwarding actually reaches this host by browsing to it directly and expecting a plain "ok" response.
+
+
+ + +
+
+ + +
+
+ + +
Comma-separated.
+
+
+ + +
Adds the IP as a second identifier on the same certificate, so clients connecting by bare IP (no hostname) get a trusted cert too. This automatically switches to Let's Encrypt's "shortlived" certificate profile, the only one that currently allows IP identifiers — those certificates are valid for only about 6 days, so expect much more frequent renewals than the domain-only case (handled automatically by the existing renewal check).
+
+
+ +
+ +
-
-
-
-
AWS Route53
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
DigitalOcean
-
- - -
-
-
- -
-
-
Google Cloud DNS
-
- - -
-
- -
- - - -
-
-
-
- - + +
- {{end}} {{define "extra_js"}} @@ -157,6 +239,15 @@ document.getElementById('le_provider').addEventListener('change', updateProviderFields); updateProviderFields(); + document.getElementById('le_detect_ip_btn').addEventListener('click', function() { + fetch('/pymta-manager/api/letsencrypt/detect_ip') + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { document.getElementById('le_ip_override').value = data.ip; showToast('Detected WAN IP: ' + data.ip, 'success'); } + else { showToast(data.message || 'Failed to detect IP', 'danger'); } + }).catch(() => showToast('Failed to detect IP', 'danger')); + }); + document.getElementById('gcloudKeyUpload').addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; diff --git a/internal/webui/templates/logs.html b/internal/webui/templates/logs.html index e348200..a18dc6c 100644 --- a/internal/webui/templates/logs.html +++ b/internal/webui/templates/logs.html @@ -62,7 +62,7 @@
Message ID: {{$log.MessageID}}
{{if $log.Subject}}
Subject: {{$log.Subject}}
{{end}} - +
{{else}} {{$log := .data}} @@ -119,7 +119,7 @@ {{end}} {{if .Subject}}
Subject: {{.Subject}}
{{end}} - +
{{end}} {{else}} @@ -159,10 +159,35 @@ + + {{end}} {{define "extra_js"}} {{end}} diff --git a/internal/webui/templates/settings.html b/internal/webui/templates/settings.html index ebcdeab..3316348 100644 --- a/internal/webui/templates/settings.html +++ b/internal/webui/templates/settings.html @@ -188,14 +188,14 @@
-
+
-
+
@@ -203,6 +203,35 @@
+
The "custom" certificate: self-signed on first run, or your own uploaded cert/key above.
+
+

Which certificate each TLS listener uses — custom (above), or one of the two Let's + Encrypt certificates managed on the Let's Encrypt page. Independent + per listener, e.g. an HTTP-01 cert for mail while the dashboard keeps a DNS-01 or custom cert.

+
+
+ +
+
+ +
+
+ +
+
+
Changing which certificate a listener uses needs a restart to take effect. Once assigned, that listener's certificate then hot-reloads automatically on every future obtain/renew, no restart needed for that part.
diff --git a/internal/webui/templates/view_message_content.html b/internal/webui/templates/view_message_content.html index f752cd5..09518d1 100644 --- a/internal/webui/templates/view_message_content.html +++ b/internal/webui/templates/view_message_content.html @@ -1,49 +1,72 @@ -{{define "title"}}View Full Message - Email Log{{end}} +{{define "view_message_content.html"}} +
+ From: {{.log.mail_from}}
+ To: {{.log.to_address}}
+ CC: {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}
+ BCC: {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}
+ Subject: {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}
+ Date: {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}
+
-{{define "content"}} -
-

Full Message Content

-
- From: {{.log.mail_from}}
- To: {{.log.to_address}}
- CC: {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}
- BCC: {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}
- Subject: {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}
- Date: {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}
-
- - {{if .log.attachments}} -
-
Attachments:
-
-
    - {{range .log.attachments}} -
  • -
    {{.Filename}} ({{filesize .Size}})
    -
    - View - Download -
    - -
    -
    -
  • - {{end}} -
+{{if .log.attachments}} +
+
Attachments:
+
+ {{range .log.attachments}} +
+
+
{{.Filename}} ({{.ContentType}}, {{filesize .Size}})
+ Download +
+ {{if .IsImage}}{{.Filename}}{{end}}
+ {{end}}
- {{end}} +
+{{end}} -
-
Message Content:
-
{{.log.message_body}}
+{{if .log.legacy_attachments}} +
+
Saved attachment files (from this sender/IP's "Store Full Message Content" setting)
+
+
    + {{range .log.legacy_attachments}} +
  • +
    {{.Filename}} ({{filesize .Size}})
    +
    + View + Download +
    + + +
    +
    +
  • + {{end}} +
+
+{{end}} -
-
Message Headers:
-
{{.log.email_headers}}
+
+
Message Content:
+
+ {{if .log.has_content}} + {{if .log.html_body}} +
{{.log.html_body}}
+ {{else if .log.plain_body}} +
{{.log.plain_body}}
+ {{else}} +

Message stored, but no readable body could be parsed out of it.

+ {{end}} + {{else}} +

Not stored, by design — the message content is only kept in this log when the sender/IP has "Store Full Message Content" enabled, or the message was quarantined as spam. Headers below are always kept.

+ {{end}}
+
- Back to Logs +
+
Message Headers:
+
{{.log.email_headers}}
{{end}} diff --git a/internal/webui/templates/webmail_account.html b/internal/webui/templates/webmail_account.html index 7637352..7906e8c 100644 --- a/internal/webui/templates/webmail_account.html +++ b/internal/webui/templates/webmail_account.html @@ -57,6 +57,26 @@
+
+
Preferences
+
+
+ + + +
+
+
+ +
Mail already in your folders keeps whatever sender name/preview it was stored with — this only changes going forward automatically. Use this once to bring older messages up to date.
+ +
+
+
+
Change Password
diff --git a/internal/webui/templates/webmail_folder.html b/internal/webui/templates/webmail_folder.html index f26df66..45c4a88 100644 --- a/internal/webui/templates/webmail_folder.html +++ b/internal/webui/templates/webmail_folder.html @@ -8,34 +8,71 @@ {{template "csrf_script" .}} -