updated layout for webmail and added http dns letsencrypt

This commit is contained in:
2026-08-15 12:35:44 +01:00
parent 310700407e
commit f283c90f11
49 changed files with 3359 additions and 431 deletions
+49 -5
View File
@@ -23,8 +23,11 @@ a Python venv + separate services.
filter-rule management, and PGP (OpenPGP encrypt/decrypt/sign/verify) and S/MIME filter-rule management, and PGP (OpenPGP encrypt/decrypt/sign/verify) and S/MIME
(sign/verify) support per mailbox. (sign/verify) support per mailbox.
- **Admin dashboard** (`/pymta-manager`) — manage domains, senders, mailboxes, DKIM - **Admin dashboard** (`/pymta-manager`) — manage domains, senders, mailboxes, DKIM
keys, IP whitelisting, TLS (self-signed or Let's Encrypt via DNS-01: Cloudflare, keys, IP whitelisting, TLS (self-signed/custom, or up to two simultaneous Let's
Route53, DigitalOcean, Google Cloud DNS), and review email + auth logs. 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- - **Security hardening built in** — CSRF protection, security headers (CSP, X-Frame-
Options, etc.), Cloudflare-aware trusted-proxy IP resolution, login rate limiting and Options, etc.), Cloudflare-aware trusted-proxy IP resolution, login rate limiting and
account lockout, TOTP + WebAuthn/passkey MFA (admin and mailbox owners), and automatic 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 directly needs root or `setcap` (see below), which the Docker deployment already
handles for you. Admin/webmail HTTP `5000` / HTTPS `5001` stay deliberately 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 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 ## Build
@@ -92,7 +97,7 @@ cd mailgoserver
go build -o 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 The default SMTP/IMAP ports are the real standard ones now, so running the binary
directly (not via Docker) needs one of: 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 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 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 deployment** — those containers run as root, so binding 25/143/465/993 directly just
works with no extra setup. 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 latest rspamd in the same container, both via a single `docker-compose.yml` using
Compose profiles. 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 ## Admin dashboard login
First run seeds one account: username `admin`, password `Password123!`. Logging in First run seeds one account: username `admin`, password `Password123!`. Logging in
+1
View File
@@ -5,5 +5,6 @@ SMTP_PORT=25
SMTP_TLS_PORT=465 SMTP_TLS_PORT=465
IMAP_PORT=143 IMAP_PORT=143
IMAP_TLS_PORT=993 IMAP_TLS_PORT=993
ACME_HTTP_PORT=80
WEB_HTTP_PORT=5000 WEB_HTTP_PORT=5000
WEB_HTTPS_PORT=5001 WEB_HTTPS_PORT=5001
+4 -2
View File
@@ -39,8 +39,10 @@ VOLUME ["/app/data"]
# SMTP 465, IMAP 143, direct-TLS IMAP 993 (the actual standard ports — binding them # 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 # 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 # 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). # own 80/443 in front if you want those too). Port 80 is only actually bound while
EXPOSE 25 465 143 993 5000 5001 # [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 # --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. # be reachable from inside this container, never through a published port.
+3 -1
View File
@@ -39,7 +39,9 @@ RUN chmod +x /usr/local/bin/entrypoint-rspamd.sh
WORKDIR /app/data WORKDIR /app/data
VOLUME ["/app/data", "/var/lib/rspamd"] 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 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fs http://127.0.0.1:5000/health || exit 1 CMD curl -fs http://127.0.0.1:5000/health || exit 1
+25 -5
View File
@@ -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 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 (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 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 `80:5000`/`443:5001` port mapping in front if you want those on 80/443 too (note port 80
low mail ports needs no extra capability here since the container runs as root. Copy is already published here for Let's Encrypt HTTP-01, see below — pick a different host
`.env.example` to `.env` in this folder to change any host-side port — useful if port for the web UI's 80 mapping if you use both). Binding the low mail ports needs no
something else on the host already owns 25/143/etc., or if you want to run both extra capability here since the container runs as root. Copy `.env.example` to `.env` in
profiles side by side. 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 ## 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 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. 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 ## Persistence
| Volume | What's in it | | Volume | What's in it |
+7 -3
View File
@@ -4,9 +4,11 @@
# docker compose --profile with-rspamd up -d --build # mailserver + rspamd, same container # 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 # 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 # now binds those directly, no port remapping needed — plus port 80 (only actually used
# web ports (5000/5001; put a reverse proxy or your own 80/443 mapping in front of those # while Let's Encrypt HTTP-01 is enabled, see internal/webui's Let's Encrypt page) and
# if you want the dashboard on standard web ports too). Only run one profile at a time # 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 # 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. # otherwise both try to bind the same host ports.
services: services:
@@ -22,6 +24,7 @@ services:
- "${SMTP_TLS_PORT:-465}:465" - "${SMTP_TLS_PORT:-465}:465"
- "${IMAP_PORT:-143}:143" - "${IMAP_PORT:-143}:143"
- "${IMAP_TLS_PORT:-993}:993" - "${IMAP_TLS_PORT:-993}:993"
- "${ACME_HTTP_PORT:-80}:80"
- "${WEB_HTTP_PORT:-5000}:5000" - "${WEB_HTTP_PORT:-5000}:5000"
- "${WEB_HTTPS_PORT:-5001}:5001" - "${WEB_HTTPS_PORT:-5001}:5001"
volumes: volumes:
@@ -39,6 +42,7 @@ services:
- "${SMTP_TLS_PORT:-465}:465" - "${SMTP_TLS_PORT:-465}:465"
- "${IMAP_PORT:-143}:143" - "${IMAP_PORT:-143}:143"
- "${IMAP_TLS_PORT:-993}:993" - "${IMAP_TLS_PORT:-993}:993"
- "${ACME_HTTP_PORT:-80}:80"
- "${WEB_HTTP_PORT:-5000}:5000" - "${WEB_HTTP_PORT:-5000}:5000"
- "${WEB_HTTPS_PORT:-5001}:5001" - "${WEB_HTTPS_PORT:-5001}:5001"
volumes: volumes:
+157
View File
@@ -1,12 +1,15 @@
package acmecert package acmecert
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/pem" "encoding/pem"
"math/big" "math/big"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -14,6 +17,8 @@ import (
"github.com/go-acme/lego/v4/registration" "github.com/go-acme/lego/v4/registration"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
"mailgoserver/internal/tlsutil"
) )
func TestLoadOrCreateAccountGeneratesAndPersistsKey(t *testing.T) { 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) { func TestNeedsRenewalMissingCertIsTrue(t *testing.T) {
mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(t.TempDir(), "does-not-exist.crt")} mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(t.TempDir(), "does-not-exist.crt")}
needs, err := mgr.NeedsRenewal() 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) { func TestBuildDNSProviderCloudflare(t *testing.T) {
cfg := ini.Empty() cfg := ini.Empty()
sec := cfg.Section("LetsEncrypt") sec := cfg.Section("LetsEncrypt")
+82
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+146 -46
View File
@@ -1,9 +1,9 @@
// Package acmecert obtains and renews Let's Encrypt certificates via the DNS-01 // Package acmecert obtains and renews Let's Encrypt certificates via DNS-01 or HTTP-01,
// challenge, as an admin-configurable alternative to the self-signed certificate // as an admin-configurable alternative to the self-signed certificate tlsutil generates
// tlsutil generates by default. Obtained certificates are written to the same // by default. main.go runs up to two independent Manager instances at once (one per
// cert/key file paths the self-signed generator already uses, so the SMTP/IMAP TLS // challenge type, reading from separate ini sections and writing to separate cert/key
// listeners (via tlsutil.CertReloader) never need to know which produced the active // files) so a DNS-01 cert and an HTTP-01 cert can be obtained simultaneously and
// certificate. // assigned to different listeners — see [TLS]'s *_cert settings.
package acmecert package acmecert
import ( import (
@@ -28,38 +28,62 @@ import (
// renewalThreshold mirrors the standard ACME-client convention (certbot/lego CLI): // 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. // 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 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 // Status is a read-only snapshot of the current Let's Encrypt configuration and the
// last renewal attempt, for the admin settings page. // last renewal attempt, for the admin settings page.
type Status struct { type Status struct {
Enabled bool Enabled bool
Staging bool Staging bool
Domains []string ChallengeType string // "dns-01" or "http-01"
Provider string Domains []string
NotAfter time.Time // parsed live from CertFile each call — never cached Provider string
LastAttempt time.Time // zero value = no attempt yet this process run IncludeIP bool
LastError string // empty if the last attempt succeeded, or none has run yet 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 { type Manager struct {
Cfg *ini.File Cfg *ini.File
Section string
ChallengeType string
CertFile, KeyFile string CertFile, KeyFile string
DataDir string DataDir string
Reloader *tlsutil.CertReloader Reloader *tlsutil.CertReloader
Logger *toolbox.Logger 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 mu sync.Mutex
lastAttempt time.Time lastAttempt time.Time
lastError string lastError string
} }
func New(cfg *ini.File, certFile, keyFile, dataDir string, reloader *tlsutil.CertReloader, logger *toolbox.Logger) *Manager { func New(cfg *ini.File, section, challengeType, 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} 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 { func (m *Manager) domains() []string {
raw := m.section().Key("domains").String() raw := m.section().Key("domains").String()
@@ -69,7 +93,14 @@ func (m *Manager) domains() []string {
parts := strings.Split(raw, ",") parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))
for _, p := range 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) out = append(out, p)
} }
} }
@@ -83,12 +114,19 @@ func (m *Manager) domains() []string {
func (m *Manager) Status() Status { func (m *Manager) Status() Status {
m.mu.Lock() m.mu.Lock()
s := Status{ s := Status{
Enabled: m.section().Key("enabled").MustBool(false), Enabled: m.section().Key("enabled").MustBool(false),
Staging: m.section().Key("staging").MustBool(false), Staging: m.section().Key("staging").MustBool(false),
Domains: m.domains(), ChallengeType: m.ChallengeType,
Provider: m.section().Key("dns_provider").String(), Domains: m.domains(),
LastAttempt: m.lastAttempt, LastAttempt: m.lastAttempt,
LastError: m.lastError, 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() m.mu.Unlock()
@@ -98,17 +136,30 @@ func (m *Manager) Status() Status {
return s return s
} }
// NeedsRenewal reports whether the certificate currently at CertFile is within 30 days // NeedsRenewal reports whether the certificate currently at CertFile is within
// of expiry (or unreadable/unparseable, which is treated as "yes" — nothing usable is // renewalThreshold (30 days) — or shortLivedRenewalThreshold (1 day) for HTTP-01 with
// there to keep). This is a pure expiry check; it makes no attempt to distinguish a // include_ip, since that cert is only valid ~6 days to begin with — of expiry,
// self-signed cert from an ACME-obtained one (see the caller in main.go's renewal // unreadable/unparseable (nothing usable there to keep), or is still the self-signed
// ticker for how the very-first-check case is handled instead). // 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) { func (m *Manager) NeedsRenewal() (bool, error) {
cert, err := readLeafCertificate(m.CertFile) cert, err := readLeafCertificate(m.CertFile)
if err != nil { if err != nil {
return true, 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) { func readLeafCertificate(certFile string) (*x509.Certificate, error) {
@@ -123,14 +174,17 @@ func readLeafCertificate(certFile string) (*x509.Certificate, error) {
return x509.ParseCertificate(block.Bytes) 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, // 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 // 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 // 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 // any failure, the cert/key files on disk are left untouched — whatever was already
// serving (self-signed or a previous ACME cert) keeps working. // serving (self-signed or a previous ACME cert) keeps working.
func (m *Manager) ObtainOrRenew(ctx context.Context) error { func (m *Manager) ObtainOrRenew(ctx context.Context) error {
if !m.section().Key("enabled").MustBool(false) { if !m.Enabled() {
return nil return nil
} }
@@ -149,10 +203,11 @@ func (m *Manager) ObtainOrRenew(ctx context.Context) error {
} }
func (m *Manager) obtain(ctx context.Context) error { func (m *Manager) obtain(ctx context.Context) error {
domains := m.domains() identifiers, err := m.resolveIdentifiers(ctx)
if len(domains) == 0 { if err != nil {
return fmt.Errorf("acmecert: no domains configured") return err
} }
m.Logger.Info("%s: starting obtain/renew for %s", m.ChallengeType, strings.Join(identifiers, ", "))
email := m.section().Key("contact_email").String() email := m.section().Key("contact_email").String()
user, err := loadOrCreateAccount(m.DataDir, email) 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) return fmt.Errorf("create ACME client: %w", err)
} }
provider, err := buildDNSProvider(m.Cfg) if m.ChallengeType == "http-01" {
if err != nil { if m.HTTP01Server == nil {
return fmt.Errorf("configure DNS provider: %w", err) return fmt.Errorf("HTTP-01 challenge server is not running (enable [LetsEncryptHTTP] and restart)")
} }
if err := client.Challenge.SetDNS01Provider(provider); err != nil { if err := client.Challenge.SetHTTP01Provider(m.HTTP01Server); err != nil {
return fmt.Errorf("set DNS-01 provider: %w", err) 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 { if user.Registration == nil {
@@ -190,10 +254,18 @@ func (m *Manager) obtain(ctx context.Context) error {
} }
} }
cert, err := client.Certificate.Obtain(certificate.ObtainRequest{ req := certificate.ObtainRequest{Domains: identifiers, Bundle: true}
Domains: domains, if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) {
Bundle: true, // 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 { if err != nil {
return fmt.Errorf("obtain certificate: %w", err) 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) 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 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
}
+36
View File
@@ -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
}
+86 -3
View File
@@ -40,6 +40,11 @@ var defaults = []struct {
{"WEB_HTTP_PORT", "5000", ""}, {"WEB_HTTP_PORT", "5000", ""},
{"", "", "HTTPS port for the admin web UI (self-signed by default, or the Let's Encrypt cert when enabled)"}, {"", "", "HTTPS port for the admin web UI (self-signed by default, or the Let's Encrypt cert when enabled)"},
{"WEB_HTTPS_PORT", "5001", ""}, {"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)`}, {"", "", `Custom server banner (to make it empty use "" must be double quotes)`},
{"server_banner", "", ""}, {"server_banner", "", ""},
{"", "", "Time zone for the server"}, {"", "", "Time zone for the server"},
@@ -66,8 +71,16 @@ var defaults = []struct {
}}, }},
{"TLS", []defaultKV{ {"TLS", []defaultKV{
{"", "", "TLS/SSL certificate configuration"}, {"", "", "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_CERT_FILE", "ssl_certs/server.crt", ""},
{"TLS_KEY_FILE", "ssl_certs/server.key", ""}, {"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", []defaultKV{
{"", "", "DKIM signing configuration"}, {"", "", "DKIM signing configuration"},
@@ -143,8 +156,10 @@ var defaults = []struct {
{"reject_score", "15", ""}, {"reject_score", "15", ""},
}}, }},
{"LetsEncrypt", []defaultKV{ {"LetsEncrypt", []defaultKV{
{"", "", "Let's Encrypt (ACME, DNS-01 only) automatic certificate configuration for the"}, {"", "", "Let's Encrypt (ACME, DNS-01) automatic certificate configuration. This obtains a"},
{"", "", "SMTP/IMAP TLS listeners. Leave 'enabled' false to keep using the self-signed cert."}, {"", "", "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", ""}, {"enabled", "false", ""},
{"", "", "Use Let's Encrypt's staging directory (untrusted certs, no rate limits) for testing"}, {"", "", "Use Let's Encrypt's staging directory (untrusted certs, no rate limits) for testing"},
{"staging", "false", ""}, {"staging", "false", ""},
@@ -168,6 +183,31 @@ var defaults = []struct {
{"", "", "Path to an uploaded service-account JSON key; leave blank to use Application Default Credentials"}, {"", "", "Path to an uploaded service-account JSON key; leave blank to use Application Default Credentials"},
{"gcloud_service_account_json_path", "", ""}, {"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 // 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. // 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. // 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) { func Load(path string) (*ini.File, error) {
if err := GenerateSettingsIni(path); err != nil { if err := GenerateSettingsIni(path); err != nil {
return nil, err 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 // AbsoluteSQLitePath converts a "sqlite:///relative/path" database URL into an absolute
+40
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "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) { func TestAbsoluteSQLitePath(t *testing.T) {
cases := []struct{ url, root, want string }{ cases := []struct{ url, root, want string }{
{"sqlite:///server_data/db.sqlite", "/app", "/app/server_data/db.sqlite"}, {"sqlite:///server_data/db.sqlite", "/app", "/app/server_data/db.sqlite"},
@@ -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"])
}
}
+75 -11
View File
@@ -10,23 +10,23 @@ import (
// InsertMessage records a stored message's index row (the ciphertext itself already // 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 // lives at storagePath — see internal/mailstore). Returns the new row's id, which
// doubles as the IMAP UID in later milestones. // 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 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) (mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject, cached_preview)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject) mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject, cachedPreview)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return res.LastInsertId() 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) { func scanMailboxMessage(scan func(dest ...any) error) (MailboxMessage, error) {
var m MailboxMessage var m MailboxMessage
var internalDate, createdAt string 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 { if err != nil {
return m, err return m, err
} }
@@ -103,6 +103,17 @@ func (d *DB) ListMessagesForMailbox(mailboxID int64) ([]MailboxMessage, error) {
return scanMailboxMessages(rows) 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 // 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. // 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 { 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 // ListMessagesInFolderPage is ListMessagesInFolder with newest-first pagination, for
// the webmail client's folder view — a mailbox can accumulate far more mail than is // the webmail client's folder view — a mailbox can accumulate far more mail than is
// reasonable to render in one page. // reasonable to render in one page.
func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, offset, limit int) ([]MailboxMessage, error) { // sortColumnAndDir maps the folder view's ?sort=/&dir= query params to a safe,
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages // hardcoded SQL ORDER BY fragment — never interpolates the raw query values
WHERE mailbox_id = ? AND folder = ? ORDER BY id DESC LIMIT ? OFFSET ?`, mailboxID, folder, limit, offset) // 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 { if err != nil {
return nil, err return nil, err
} }
@@ -134,9 +171,14 @@ func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, offset, li
} }
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls. // 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 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 return n, err
} }
@@ -210,6 +252,28 @@ func (d *DB) CountUnreadByFolder(mailboxID int64) (map[string]int, error) {
return out, rows.Err() 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 // SuggestRecipients returns up to 10 distinct addresses (as originally cached — a
// display name like "Name <addr@example.com>" is kept as-is, not parsed apart, since // display name like "Name <addr@example.com>" is kept as-is, not parsed apart, since
// that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously // that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously
+10 -5
View File
@@ -5,13 +5,13 @@ import (
"errors" "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) { func scanMailbox(row *sql.Row) (*Mailbox, error) {
var m Mailbox var m Mailbox
var createdAt string var createdAt string
var createdBy sql.NullInt64 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
@@ -31,7 +31,7 @@ type MailboxWithDomain struct {
} }
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) { 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`) FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -42,7 +42,7 @@ func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
var m MailboxWithDomain var m MailboxWithDomain
var createdAt string var createdAt string
var createdBy sql.NullInt64 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 return nil, err
} }
m.CreatedAt, _ = parseTime(createdAt) m.CreatedAt, _ = parseTime(createdAt)
@@ -65,7 +65,7 @@ func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
var m Mailbox var m Mailbox
var createdAt string var createdAt string
var createdBy sql.NullInt64 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 return nil, err
} }
m.CreatedAt, _ = parseTime(createdAt) m.CreatedAt, _ = parseTime(createdAt)
@@ -120,6 +120,11 @@ func (d *DB) SetMailboxMFAExempt(id int64, exempt bool) error {
return err 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 { func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id) _, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
return err return err
+4
View File
@@ -25,6 +25,9 @@ type Mailbox struct {
// MFAExempt overrides [Auth] enforce_mailbox_mfa off for this mailbox specifically, // MFAExempt overrides [Auth] enforce_mailbox_mfa off for this mailbox specifically,
// even if its domain isn't exempt. // even if its domain isn't exempt.
MFAExempt bool 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 // MailboxSession is a self-service webmail portal login — a parallel schema to
@@ -143,6 +146,7 @@ type MailboxMessage struct {
CachedFrom string CachedFrom string
CachedTo string CachedTo string
CachedSubject string CachedSubject string
CachedPreview string
StoragePath string StoragePath string
Nonce []byte Nonce []byte
CreatedAt time.Time CreatedAt time.Time
+18 -1
View File
@@ -208,7 +208,10 @@ CREATE TABLE IF NOT EXISTS esrv_mailboxes (
created_by INTEGER REFERENCES esrv_admin_users(id), created_by INTEGER REFERENCES esrv_admin_users(id),
totp_secret TEXT NOT NULL DEFAULT '', totp_secret TEXT NOT NULL DEFAULT '',
totp_enabled INTEGER NOT NULL DEFAULT 0, 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 -- 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_from TEXT NOT NULL DEFAULT '',
cached_to TEXT NOT NULL DEFAULT '', cached_to TEXT NOT NULL DEFAULT '',
cached_subject 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, storage_path TEXT NOT NULL,
nonce BLOB NOT NULL, nonce BLOB NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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" // unrecoverable-without-code-that-no-longer-exists) keys, same "not migrated"
// treatment as the singular-table identities before them. // 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_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 { for _, stmt := range stmts {
db.Exec(stmt) db.Exec(stmt)
@@ -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)
}
}
+78
View File
@@ -4,7 +4,9 @@ import (
"bytes" "bytes"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"unicode/utf8"
"mailgoserver/internal/db" "mailgoserver/internal/db"
) )
@@ -104,6 +106,82 @@ func TestStoreFetchRoundTrip(t *testing.T) {
if mbox.UsedBytes != int64(len(raw)) { if mbox.UsedBytes != int64(len(raw)) {
t.Fatalf("used_bytes = %d, want %d", mbox.UsedBytes, 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, "<abc@example.com>", "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 <bob@example.com>\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, "<abc@example.com>", "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 <bob@example.com>" {
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) { func TestQuotaExceeded(t *testing.T) {
+73 -1
View File
@@ -9,7 +9,10 @@ import (
"net/mail" "net/mail"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"mailgoserver/internal/mailview"
) )
// extractHeaderValue reads a single header out of raw without parsing the body — used // 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) 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 // 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. // mailbox over its quota. No row, file, or used_bytes change occurs in that case.
var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded") 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 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 { if err != nil {
os.Remove(storagePath) os.Remove(storagePath)
return 0, err return 0, err
@@ -77,6 +103,52 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
return uid, nil 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 // FetchMessage decrypts a stored message on demand. Plaintext is never written to disk
// or cached — only returned to the caller. // or cached — only returned to the caller.
func (s *Store) FetchMessage(mailboxID, uid int64) ([]byte, error) { func (s *Store) FetchMessage(mailboxID, uid int64) ([]byte, error) {
+102
View File
@@ -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 <mailer-daemon@" + hostname + ">",
"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 <mailer-daemon@"+r.Hostname+">", "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
}
+98
View File
@@ -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 <mailer-daemon@mail.example.com>",
"Auto-Submitted: auto-replied",
"nobody@remote.example",
"MX lookup failed",
"Subject: Hello",
"Message-ID: <orig-id@example.com>",
} {
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)
}
}
+16 -1
View File
@@ -13,6 +13,7 @@ import (
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
"mailgoserver/internal/db" "mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
"mailgoserver/internal/toolbox" "mailgoserver/internal/toolbox"
) )
@@ -26,15 +27,29 @@ type Result struct {
ErrorCode string ErrorCode string
ErrorMessage string ErrorMessage string
ServerResponse 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 { type Relay struct {
DB *db.DB DB *db.DB
Timeout time.Duration Timeout time.Duration
// Hostname is used as the outbound EHLO/HELO identity, mirroring // 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 Hostname string
Logger *toolbox.Logger 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 // New builds a Relay from settings.ini. Unlike email_relay.py (which reads
+101
View File
@@ -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)
}
}
+86
View File
@@ -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)
}
}
@@ -18,6 +18,7 @@ func newTestBackendWithMailbox(t *testing.T) (*Backend, int64) {
store := mailstore.New(backend.DB, mailstore.GenerateDEK(), t.TempDir()) store := mailstore.New(backend.DB, mailstore.GenerateDEK(), t.TempDir())
backend.Mailstore = store 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 // 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 // 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 // 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 <bob@example.com>"), 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 <test@example.com>\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 <test@example.com>" {
t.Errorf("cached_from = %q, want the From: header value with display name", msgs[0].CachedFrom)
}
}
func TestLocalDeliveryUnknownMailboxRejected(t *testing.T) { func TestLocalDeliveryUnknownMailboxRejected(t *testing.T) {
backend, _ := newTestBackendWithMailbox(t) backend, _ := newTestBackendWithMailbox(t)
addr := startTestServer(t, backend) addr := startTestServer(t, backend)
+66 -9
View File
@@ -248,6 +248,14 @@ func (s *Session) Data(r io.Reader) error {
toHeader := rebuiltHeaders["to"] toHeader := rebuiltHeaders["to"]
ccHeader := rebuiltHeaders["cc"] ccHeader := rebuiltHeaders["cc"]
subject := rebuiltHeaders["subject"] subject := rebuiltHeaders["subject"]
// The message's own From: header (e.g. "Bob Marley <bob@example.com>"), 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. // Attachment storage: only if the authenticated sender or whitelisted IP opted in.
storeMessage := false storeMessage := false
@@ -321,23 +329,66 @@ func (s *Session) Data(r io.Reader) error {
results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes) results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes)
} }
if len(localRcpts) > 0 { 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 { for _, res := range results {
if res.Status != "success" { 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 { if parseErr == nil {
emailHeaders = strings.Join(parsed.HeaderLines, "\n") 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 { if logErr != nil {
s.backend.Logger.Error("Failed to log email: %v", logErr) s.backend.Logger.Error("Failed to log email: %v", logErr)
} else { } else {
@@ -353,6 +404,12 @@ func (s *Session) Data(r io.Reader) error {
if allSucceeded { if allSucceeded {
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery"} 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"} 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 // 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 // recipient so it can be merged into the same LogEmail/allSucceeded logic as relay
// results. // 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) senderDomain := domainOfAddr(s.mailFrom)
dkimPass := senderDomain != "" && dkim.VerifyInbound(signedContent, senderDomain) dkimPass := senderDomain != "" && dkim.VerifyInbound(signedContent, senderDomain)
spfPass := mailstore.CheckSPF(s.mailFrom, s.peerIP) spfPass := mailstore.CheckSPF(s.mailFrom, s.peerIP)
@@ -430,7 +487,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
markRead = action.MarkRead 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 { if err != nil {
errCode, errMsg := "450", err.Error() errCode, errMsg := "450", err.Error()
if err == mailstore.ErrQuotaExceeded { if err == mailstore.ErrQuotaExceeded {
@@ -448,7 +505,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
if spamGated { if spamGated {
serverResponse = "Quarantined to Spam folder" 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 return results
} }
+69 -5
View File
@@ -7,15 +7,23 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"mailgoserver/internal/acmecert"
) )
// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] settings — always // leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] (DNS-01) settings —
// persisted from the submitted form, same as any other settings.html field. // always persisted from the submitted form, same as any other settings.html field.
var leAlwaysOverwriteFields = []string{ var leAlwaysOverwriteFields = []string{
"enabled", "staging", "contact_email", "domains", "dns_provider", "enabled", "staging", "contact_email", "domains", "dns_provider",
"route53_region", "route53_hosted_zone_id", "gcloud_project", "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 // 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 // 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 // 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", "digitalocean_api_token", "gcloud_service_account_json_path",
} }
// letsEncryptPage shows the current Let's Encrypt status and configuration form. // letsEncryptPage shows the current Let's Encrypt status and configuration forms for
// Secret fields are always blank in the rendered form — see leSecretFields. // 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) { func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) {
sec := a.Cfg.Section("LetsEncrypt") sec := a.Cfg.Section("LetsEncrypt")
kv := M{} kv := M{}
@@ -36,7 +45,18 @@ func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) {
for _, k := range leSecretFields { for _, k := range leSecretFields {
kv[k] = "" 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) // 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) 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 // 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 // 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) // 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}) 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})
}
+30 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"net/mail"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -50,6 +51,26 @@ func (a *App) funcMap() template.FuncMap {
"lower": strings.ToLower, "lower": strings.ToLower,
"safe": func(s string) template.HTML { return template.HTML(s) }, "safe": func(s string) template.HTML { return template.HTML(s) },
"filesize": humanFileSize, "filesize": humanFileSize,
// senderName shows just the display name from a "Name <addr@example.com>"
// 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, ".", "-") }, "dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
"sub": 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", "ips.html", "add_ip.html", "edit_ip.html",
"blacklist.html", "blacklist.html",
"dkim.html", "edit_dkim.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", "account.html", "first_login.html",
"admins.html", "add_admin.html", "edit_admin.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", "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_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", "webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
// A bare HTML fragment (no <html>/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 // pagesWithComposeWidget are the standalone pages that show a Compose/Reply/Forward
+191 -100
View File
@@ -6,21 +6,29 @@
<h2><i class="bi bi-patch-check me-2"></i>Let's Encrypt</h2> <h2><i class="bi bi-patch-check me-2"></i>Let's Encrypt</h2>
</div> </div>
<p class="text-muted">
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 <a href="/pymta-manager/settings">Settings</a> page's TLS/SSL
section — e.g. run the HTTP-01 cert on mail while the dashboard keeps a DNS-01 or custom cert.
</p>
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Status</h5></div> <div class="card-header"><h5 class="mb-0"><i class="bi bi-globe me-2"></i>DNS-01</h5></div>
<div class="card-body"> <div class="card-body">
<dl class="row mb-3"> <dl class="row mb-3">
<dt class="col-sm-3">Mode</dt> <dt class="col-sm-3">Mode</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
{{if .status.Enabled}} {{if .status.Enabled}}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Let's Encrypt {{if .status.Staging}}(staging){{end}}</span> <span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Enabled {{if .status.Staging}}(staging){{end}}</span>
{{else}} {{else}}
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Self-signed (Let's Encrypt disabled)</span> <span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Disabled</span>
{{end}} {{end}}
</dd> </dd>
<dt class="col-sm-3">Domains</dt> <dt class="col-sm-3">Domains</dt>
<dd class="col-sm-9">{{if .status.Domains}}{{range .status.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}</dd> <dd class="col-sm-9">{{if .status.Domains}}{{range .status.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}</dd>
<dt class="col-sm-3">Provider</dt> <dt class="col-sm-3">DNS Provider</dt>
<dd class="col-sm-9">{{if .status.Provider}}{{.status.Provider}}{{else}}<span class="text-muted">none selected</span>{{end}}</dd> <dd class="col-sm-9">{{if .status.Provider}}{{.status.Provider}}{{else}}<span class="text-muted">none selected</span>{{end}}</dd>
<dt class="col-sm-3">Certificate expires</dt> <dt class="col-sm-3">Certificate expires</dt>
<dd class="col-sm-9">{{if .status.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}</dd> <dd class="col-sm-9">{{if .status.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}</dd>
@@ -36,114 +44,188 @@
</dd> </dd>
</dl> </dl>
<form method="post" action="/pymta-manager/letsencrypt/obtain"> <form method="post" action="/pymta-manager/letsencrypt/obtain">
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button> <button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the DNS-01 certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
</form>
<hr>
<form method="POST" action="/pymta-manager/letsencrypt/save">
<div class="mb-3">
<label class="form-label">Enable DNS-01</label>
<select class="form-select" name="enabled">
<option value="false" {{if ne .le.enabled "true"}}selected{{end}}>No</option>
<option value="true" {{if eq .le.enabled "true"}}selected{{end}}>Yes</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Staging mode</label>
<select class="form-select" name="staging">
<option value="false" {{if ne .le.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
<option value="true" {{if eq .le.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
</select>
<div class="form-text">Recommended while testing a new configuration.</div>
</div>
<div class="mb-3">
<label class="form-label">Contact Email</label>
<input type="email" class="form-control" name="contact_email" value="{{.le.contact_email}}">
</div>
<div class="mb-3">
<label class="form-label">Domains</label>
<input type="text" class="form-control font-monospace" name="domains" value="{{.le.domains}}" placeholder="mail.example.com,*.mail.example.com">
<div class="form-text">Comma-separated. Include a wildcard entry (e.g. <code>*.mail.example.com</code>) alongside its bare domain to cover both with one certificate.</div>
</div>
<div class="mb-3">
<label class="form-label">DNS Provider</label>
<select class="form-select" name="dns_provider" id="le_provider">
<option value="">Select a provider...</option>
<option value="cloudflare" {{if eq .le.dns_provider "cloudflare"}}selected{{end}}>Cloudflare</option>
<option value="route53" {{if eq .le.dns_provider "route53"}}selected{{end}}>AWS Route53</option>
<option value="digitalocean" {{if eq .le.dns_provider "digitalocean"}}selected{{end}}>DigitalOcean</option>
<option value="gcloud" {{if eq .le.dns_provider "gcloud"}}selected{{end}}>Google Cloud DNS</option>
</select>
</div>
<div class="provider-fields" id="fields-cloudflare">
<div class="setting-section mb-3">
<h6>Cloudflare</h6>
<div class="mb-3">
<label class="form-label">API Token</label>
<input type="password" class="form-control" name="cloudflare_api_token" placeholder="Leave blank to keep the current value">
</div>
</div>
</div>
<div class="provider-fields" id="fields-route53">
<div class="setting-section mb-3">
<h6>AWS Route53</h6>
<div class="mb-3">
<label class="form-label">Access Key ID</label>
<input type="password" class="form-control" name="route53_access_key_id" placeholder="Leave blank to keep the current value, or blank both keys to use the host's AWS credential chain">
</div>
<div class="mb-3">
<label class="form-label">Secret Access Key</label>
<input type="password" class="form-control" name="route53_secret_access_key" placeholder="Leave blank to keep the current value">
</div>
<div class="mb-3">
<label class="form-label">Region</label>
<input type="text" class="form-control" name="route53_region" value="{{.le.route53_region}}" placeholder="us-east-1">
</div>
<div class="mb-3">
<label class="form-label">Hosted Zone ID (optional)</label>
<input type="text" class="form-control" name="route53_hosted_zone_id" value="{{.le.route53_hosted_zone_id}}" placeholder="Leave blank to auto-discover">
</div>
</div>
</div>
<div class="provider-fields" id="fields-digitalocean">
<div class="setting-section mb-3">
<h6>DigitalOcean</h6>
<div class="mb-3">
<label class="form-label">API Token</label>
<input type="password" class="form-control" name="digitalocean_api_token" placeholder="Leave blank to keep the current value">
</div>
</div>
</div>
<div class="provider-fields" id="fields-gcloud">
<div class="setting-section mb-3">
<h6>Google Cloud DNS</h6>
<div class="mb-3">
<label class="form-label">Project ID</label>
<input type="text" class="form-control" name="gcloud_project" value="{{.le.gcloud_project}}">
</div>
<div class="mb-3">
<label class="form-label">Service Account Key (optional)</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="gcloud_service_account_json_path" id="gcloud_sa_path" placeholder="Leave blank to use Application Default Credentials">
<input type="file" class="d-none" id="gcloudKeyUpload" accept=".json">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('gcloudKeyUpload').click()"><i class="bi bi-upload"></i></button>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save DNS-01 Configuration</button>
</form> </form>
</div> </div>
</div> </div>
<form method="POST" action="/pymta-manager/letsencrypt/save">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h5></div> <div class="card-header"><h5 class="mb-0"><i class="bi bi-hdd-network me-2"></i>HTTP-01</h5></div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <dl class="row mb-3">
<label class="form-label">Enable Let's Encrypt</label> <dt class="col-sm-3">Mode</dt>
<select class="form-select" name="enabled"> <dd class="col-sm-9">
<option value="false" {{if ne .le.enabled "true"}}selected{{end}}>No — keep the self-signed certificate</option> {{if .statusHTTP.Enabled}}
<option value="true" {{if eq .le.enabled "true"}}selected{{end}}>Yes</option> <span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Enabled</span>
</select> {{else}}
</div> <span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Disabled</span>
<div class="mb-3"> {{end}}
<label class="form-label">Staging mode</label> </dd>
<select class="form-select" name="staging"> <dt class="col-sm-3">Domains</dt>
<option value="false" {{if ne .le.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option> <dd class="col-sm-9">{{if .statusHTTP.Domains}}{{range .statusHTTP.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}{{if .statusHTTP.IncludeIP}} <span class="badge bg-info">+ server IP</span>{{end}}</dd>
<option value="true" {{if eq .le.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option> <dt class="col-sm-3">Certificate expires</dt>
</select> <dd class="col-sm-9">{{if .statusHTTP.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .statusHTTP.NotAfter}}{{end}}</dd>
<div class="form-text">Recommended while testing a new configuration.</div> <dt class="col-sm-3">Last attempt</dt>
</div> <dd class="col-sm-9">
<div class="mb-3"> {{if .statusHTTP.LastAttempt.IsZero}}
<label class="form-label">Contact Email</label> <span class="text-muted">none yet this run</span>
<input type="email" class="form-control" name="contact_email" value="{{.le.contact_email}}"> {{else if .statusHTTP.LastError}}
</div> <span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — {{.statusHTTP.LastError}}</span>
<div class="mb-3"> {{else}}
<label class="form-label">Domains</label> <span class="text-success"><i class="bi bi-check-circle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .statusHTTP.LastAttempt}} — success</span>
<input type="text" class="form-control font-monospace" name="domains" value="{{.le.domains}}" placeholder="mail.example.com,*.mail.example.com"> {{end}}
<div class="form-text">Comma-separated. Include a wildcard entry (e.g. <code>*.mail.example.com</code>) alongside its bare domain to cover both with one certificate.</div> </dd>
</div> </dl>
<div class="mb-3"> <form method="post" action="/pymta-manager/letsencrypt/http/obtain">
<label class="form-label">DNS Provider</label> <button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the HTTP-01 certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
<select class="form-select" name="dns_provider" id="le_provider"> </form>
<option value="">Select a provider...</option>
<option value="cloudflare" {{if eq .le.dns_provider "cloudflare"}}selected{{end}}>Cloudflare</option>
<option value="route53" {{if eq .le.dns_provider "route53"}}selected{{end}}>AWS Route53</option>
<option value="digitalocean" {{if eq .le.dns_provider "digitalocean"}}selected{{end}}>DigitalOcean</option>
<option value="gcloud" {{if eq .le.dns_provider "gcloud"}}selected{{end}}>Google Cloud DNS</option>
</select>
</div>
<div class="provider-fields" id="fields-cloudflare"> <hr>
<div class="setting-section mb-3"> <form method="POST" action="/pymta-manager/letsencrypt/http/save">
<h6>Cloudflare</h6> <div class="mb-3">
<div class="mb-3"> <label class="form-label">Enable HTTP-01</label>
<label class="form-label">API Token</label> <select class="form-select" name="enabled">
<input type="password" class="form-control" name="cloudflare_api_token" placeholder="Leave blank to keep the current value"> <option value="false" {{if ne .leHTTP.enabled "true"}}selected{{end}}>No</option>
<option value="true" {{if eq .leHTTP.enabled "true"}}selected{{end}}>Yes</option>
</select>
<div class="form-text">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.</div>
</div>
<div class="mb-3">
<label class="form-label">Staging mode</label>
<select class="form-select" name="staging">
<option value="false" {{if ne .leHTTP.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
<option value="true" {{if eq .leHTTP.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Contact Email</label>
<input type="email" class="form-control" name="contact_email" value="{{.leHTTP.contact_email}}">
</div>
<div class="mb-3">
<label class="form-label">Domains</label>
<input type="text" class="form-control font-monospace" name="domains" value="{{.leHTTP.domains}}" placeholder="mail.example.com">
<div class="form-text">Comma-separated.</div>
</div>
<div class="mb-3">
<label class="form-label">Also get this certificate for the server's IP address</label>
<select class="form-select" name="include_ip">
<option value="false" {{if ne .leHTTP.include_ip "true"}}selected{{end}}>No — domain only</option>
<option value="true" {{if eq .leHTTP.include_ip "true"}}selected{{end}}>Yes</option>
</select>
<div class="form-text">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).</div>
</div>
<div class="mb-3">
<label class="form-label">IP address override</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="ip_override" id="le_ip_override" value="{{.leHTTP.ip_override}}" placeholder="Leave blank to autodetect this host's WAN IP on every obtain/renew">
<button class="btn btn-outline-secondary" type="button" id="le_detect_ip_btn"><i class="bi bi-broadcast me-1"></i>Detect</button>
</div> </div>
</div> </div>
</div>
<div class="provider-fields" id="fields-route53"> <button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save HTTP-01 Configuration</button>
<div class="setting-section mb-3"> </form>
<h6>AWS Route53</h6>
<div class="mb-3">
<label class="form-label">Access Key ID</label>
<input type="password" class="form-control" name="route53_access_key_id" placeholder="Leave blank to keep the current value, or blank both keys to use the host's AWS credential chain">
</div>
<div class="mb-3">
<label class="form-label">Secret Access Key</label>
<input type="password" class="form-control" name="route53_secret_access_key" placeholder="Leave blank to keep the current value">
</div>
<div class="mb-3">
<label class="form-label">Region</label>
<input type="text" class="form-control" name="route53_region" value="{{.le.route53_region}}" placeholder="us-east-1">
</div>
<div class="mb-3">
<label class="form-label">Hosted Zone ID (optional)</label>
<input type="text" class="form-control" name="route53_hosted_zone_id" value="{{.le.route53_hosted_zone_id}}" placeholder="Leave blank to auto-discover">
</div>
</div>
</div>
<div class="provider-fields" id="fields-digitalocean">
<div class="setting-section mb-3">
<h6>DigitalOcean</h6>
<div class="mb-3">
<label class="form-label">API Token</label>
<input type="password" class="form-control" name="digitalocean_api_token" placeholder="Leave blank to keep the current value">
</div>
</div>
</div>
<div class="provider-fields" id="fields-gcloud">
<div class="setting-section mb-3">
<h6>Google Cloud DNS</h6>
<div class="mb-3">
<label class="form-label">Project ID</label>
<input type="text" class="form-control" name="gcloud_project" value="{{.le.gcloud_project}}">
</div>
<div class="mb-3">
<label class="form-label">Service Account Key (optional)</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="gcloud_service_account_json_path" id="gcloud_sa_path" placeholder="Leave blank to use Application Default Credentials">
<input type="file" class="d-none" id="gcloudKeyUpload" accept=".json">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('gcloudKeyUpload').click()"><i class="bi bi-upload"></i></button>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save Configuration</button>
</div> </div>
</div> </div>
</form>
{{end}} {{end}}
{{define "extra_js"}} {{define "extra_js"}}
@@ -157,6 +239,15 @@
document.getElementById('le_provider').addEventListener('change', updateProviderFields); document.getElementById('le_provider').addEventListener('change', updateProviderFields);
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) { document.getElementById('gcloudKeyUpload').addEventListener('change', function(e) {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
+27 -2
View File
@@ -62,7 +62,7 @@
<div class="col-md-6"><strong>Message ID:</strong> <code>{{$log.MessageID}}</code></div> <div class="col-md-6"><strong>Message ID:</strong> <code>{{$log.MessageID}}</code></div>
</div> </div>
{{if $log.Subject}}<div class="mt-2"><strong>Subject:</strong> {{$log.Subject}}</div>{{end}} {{if $log.Subject}}<div class="mt-2"><strong>Subject:</strong> {{$log.Subject}}</div>{{end}}
<div class="mt-2"><a href="/pymta-manager/msg/content/{{$log.ID}}" class="btn btn-sm btn-primary"><i class="bi bi-envelope-open-text"></i> View Message Details</a></div> <div class="mt-2"><button type="button" class="btn btn-sm btn-primary" onclick="openMessageModal({{$log.ID}})"><i class="bi bi-envelope-open-text"></i> View Message Details</button></div>
</div> </div>
{{else}} {{else}}
{{$log := .data}} {{$log := .data}}
@@ -119,7 +119,7 @@
</div> </div>
{{end}} {{end}}
{{if .Subject}}<div class="mt-2"><strong>Subject:</strong> {{.Subject}}</div>{{end}} {{if .Subject}}<div class="mt-2"><strong>Subject:</strong> {{.Subject}}</div>{{end}}
<div class="mt-2"><a href="/pymta-manager/msg/content/{{.ID}}" class="btn btn-outline-info btn-sm"><i class="bi bi-file-earmark-text me-1"></i> View Full Message</a></div> <div class="mt-2"><button type="button" class="btn btn-outline-info btn-sm" onclick="openMessageModal({{.ID}})"><i class="bi bi-file-earmark-text me-1"></i> View Full Message</button></div>
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
@@ -159,10 +159,35 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="messageContentModal" tabindex="-1" aria-labelledby="messageContentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="messageContentModalLabel"><i class="bi bi-envelope-open-text me-2"></i>Full Message</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="messageContentModalBody">
<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>
</div>
</div>
</div>
</div>
{{end}} {{end}}
{{define "extra_js"}} {{define "extra_js"}}
<script> <script>
setInterval(function() { if (document.visibilityState === 'visible') { location.reload(); } }, 30000); setInterval(function() { if (document.visibilityState === 'visible') { location.reload(); } }, 30000);
function openMessageModal(id) {
const modalEl = document.getElementById('messageContentModal');
const body = document.getElementById('messageContentModalBody');
body.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
bootstrap.Modal.getOrCreateInstance(modalEl).show();
fetch('/pymta-manager/msg/content/' + id)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) { body.innerHTML = html; })
.catch(function() { body.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
}
</script> </script>
{{end}} {{end}}
+31 -2
View File
@@ -188,14 +188,14 @@
<div class="card-body"> <div class="card-body">
<div class="setting-section"> <div class="setting-section">
<div class="row"> <div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">TLS Certificate File</label> <div class="col-md-6"><div class="mb-3"><label class="form-label">Custom Certificate File</label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control font-monospace" name="TLS.tls_cert_file" value="{{.settings.TLS.tls_cert_file}}"> <input type="text" class="form-control font-monospace" name="TLS.tls_cert_file" value="{{.settings.TLS.tls_cert_file}}">
<input type="file" class="d-none" id="certFileUpload" accept=".crt,.pem"> <input type="file" class="d-none" id="certFileUpload" accept=".crt,.pem">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('certFileUpload').click()"><i class="bi bi-upload"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('certFileUpload').click()"><i class="bi bi-upload"></i></button>
</div> </div>
</div></div> </div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">TLS Private Key File</label> <div class="col-md-6"><div class="mb-3"><label class="form-label">Custom Private Key File</label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control font-monospace" name="TLS.tls_key_file" value="{{.settings.TLS.tls_key_file}}"> <input type="text" class="form-control font-monospace" name="TLS.tls_key_file" value="{{.settings.TLS.tls_key_file}}">
<input type="file" class="d-none" id="keyFileUpload" accept=".key,.pem"> <input type="file" class="d-none" id="keyFileUpload" accept=".key,.pem">
@@ -203,6 +203,35 @@
</div> </div>
</div></div> </div></div>
</div> </div>
<div class="form-text mb-3">The "custom" certificate: self-signed on first run, or your own uploaded cert/key above.</div>
<hr>
<p class="mb-2">Which certificate each TLS listener uses — <code>custom</code> (above), or one of the two Let's
Encrypt certificates managed on the <a href="/pymta-manager/letsencrypt">Let's Encrypt</a> page. Independent
per listener, e.g. an HTTP-01 cert for mail while the dashboard keeps a DNS-01 or custom cert.</p>
<div class="row">
<div class="col-md-4"><div class="mb-3"><label class="form-label">SMTP (direct-TLS, 465)</label>
<select class="form-select" name="TLS.smtp_tls_cert">
<option value="custom" {{if eq .settings.TLS.smtp_tls_cert "custom"}}selected{{end}}>Custom / self-signed</option>
<option value="letsencrypt_dns" {{if eq .settings.TLS.smtp_tls_cert "letsencrypt_dns"}}selected{{end}}>Let's Encrypt (DNS-01)</option>
<option value="letsencrypt_http" {{if eq .settings.TLS.smtp_tls_cert "letsencrypt_http"}}selected{{end}}>Let's Encrypt (HTTP-01)</option>
</select>
</div></div>
<div class="col-md-4"><div class="mb-3"><label class="form-label">IMAP (direct-TLS, 993)</label>
<select class="form-select" name="TLS.imap_tls_cert">
<option value="custom" {{if eq .settings.TLS.imap_tls_cert "custom"}}selected{{end}}>Custom / self-signed</option>
<option value="letsencrypt_dns" {{if eq .settings.TLS.imap_tls_cert "letsencrypt_dns"}}selected{{end}}>Let's Encrypt (DNS-01)</option>
<option value="letsencrypt_http" {{if eq .settings.TLS.imap_tls_cert "letsencrypt_http"}}selected{{end}}>Let's Encrypt (HTTP-01)</option>
</select>
</div></div>
<div class="col-md-4"><div class="mb-3"><label class="form-label">Admin/webmail HTTPS</label>
<select class="form-select" name="TLS.web_https_cert">
<option value="custom" {{if eq .settings.TLS.web_https_cert "custom"}}selected{{end}}>Custom / self-signed</option>
<option value="letsencrypt_dns" {{if eq .settings.TLS.web_https_cert "letsencrypt_dns"}}selected{{end}}>Let's Encrypt (DNS-01)</option>
<option value="letsencrypt_http" {{if eq .settings.TLS.web_https_cert "letsencrypt_http"}}selected{{end}}>Let's Encrypt (HTTP-01)</option>
</select>
</div></div>
</div>
<div class="form-text">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.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,49 +1,72 @@
{{define "title"}}View Full Message - Email Log{{end}} {{define "view_message_content.html"}}
<div class="mb-3">
<strong>From:</strong> {{.log.mail_from}}<br>
<strong>To:</strong> {{.log.to_address}}<br>
<strong>CC:</strong> {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}<br>
<strong>BCC:</strong> {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}<br>
<strong>Subject:</strong> {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}<br>
<strong>Date:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}<br>
</div>
{{define "content"}} {{if .log.attachments}}
<div class="container mt-4"> <div class="card mb-3">
<h2>Full Message Content</h2> <div class="card-header"><strong>Attachments:</strong></div>
<div class="mb-3"> <div class="card-body">
<strong>From:</strong> {{.log.mail_from}}<br> {{range .log.attachments}}
<strong>To:</strong> {{.log.to_address}}<br> <div class="mb-3">
<strong>CC:</strong> {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}<br> <div class="d-flex justify-content-between align-items-center">
<strong>BCC:</strong> {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}<br> <div><i class="bi bi-paperclip me-1"></i>{{.Filename}} <small class="text-muted">({{.ContentType}}, {{filesize .Size}})</small></div>
<strong>Subject:</strong> {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}<br> <a href="{{.DataURI}}" target="_blank" rel="noopener" download="{{.Filename}}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-download me-1"></i>Download</a>
<strong>Date:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}<br> </div>
</div> {{if .IsImage}}<img src="{{.DataURI}}" alt="{{.Filename}}" class="img-fluid mt-2 border rounded" style="max-height: 400px;">{{end}}
{{if .log.attachments}}
<div class="card mb-3">
<div class="card-header"><strong>Attachments:</strong></div>
<div class="card-body">
<ul class="list-group">
{{range .log.attachments}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div><i class="fas fa-paperclip"></i> {{.Filename}} <small class="text-muted">({{filesize .Size}})</small></div>
<div class="btn-group" role="group">
<a href="/pymta-manager/msg/attachment/{{.ID}}/download" class="btn btn-sm btn-outline-primary" target="_blank" title="Open in new tab"><i class="fas fa-external-link-alt"></i> View</a>
<a href="/pymta-manager/msg/attachment/{{.ID}}/download?download=true" class="btn btn-sm btn-outline-secondary" title="Download file"><i class="fas fa-download"></i> Download</a>
<form method="POST" action="/pymta-manager/msg/attachment/{{.ID}}/delete" style="display: inline;">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete attachment" data-confirm="Are you sure you want to delete this attachment?"><i class="fas fa-trash-alt"></i> Delete</button>
</form>
</div>
</li>
{{end}}
</ul>
</div> </div>
{{end}}
</div> </div>
{{end}} </div>
{{end}}
<div class="card"> {{if .log.legacy_attachments}}
<div class="card-header"><strong>Message Content:</strong></div> <div class="card mb-3">
<div class="card-body"><pre style="white-space: pre-wrap; word-break: break-all;">{{.log.message_body}}</pre></div> <div class="card-header"><strong>Saved attachment files</strong> <small class="text-muted">(from this sender/IP's "Store Full Message Content" setting)</small></div>
<div class="card-body">
<ul class="list-group">
{{range .log.legacy_attachments}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div><i class="bi bi-paperclip me-1"></i>{{.Filename}} <small class="text-muted">({{filesize .Size}})</small></div>
<div class="btn-group" role="group">
<a href="/pymta-manager/msg/attachment/{{.ID}}/download" class="btn btn-sm btn-outline-primary" target="_blank" rel="noopener" title="Open in new tab"><i class="bi bi-box-arrow-up-right"></i> View</a>
<a href="/pymta-manager/msg/attachment/{{.ID}}/download?download=true" class="btn btn-sm btn-outline-secondary" title="Download file"><i class="bi bi-download"></i> Download</a>
<form method="POST" action="/pymta-manager/msg/attachment/{{.ID}}/delete" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this attachment?');">
<input type="hidden" name="csrf_token" value="{{$.csrf_token}}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete attachment"><i class="bi bi-trash"></i> Delete</button>
</form>
</div>
</li>
{{end}}
</ul>
</div> </div>
</div>
{{end}}
<div class="card mt-3"> <div class="card">
<div class="card-header"><strong>Message Headers:</strong></div> <div class="card-header"><strong>Message Content:</strong></div>
<div class="card-body"><pre style="white-space: pre-wrap;">{{.log.email_headers}}</pre></div> <div class="card-body">
{{if .log.has_content}}
{{if .log.html_body}}
<div class="p-2 border rounded bg-white text-dark">{{.log.html_body}}</div>
{{else if .log.plain_body}}
<pre style="white-space: pre-wrap; word-break: break-all;">{{.log.plain_body}}</pre>
{{else}}
<p class="text-muted mb-0">Message stored, but no readable body could be parsed out of it.</p>
{{end}}
{{else}}
<p class="text-muted mb-0"><i class="bi bi-shield-lock me-1"></i>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.</p>
{{end}}
</div> </div>
</div>
<a href="/pymta-manager/logs?type=emails" class="btn btn-secondary mt-3">Back to Logs</a> <div class="card mt-3">
<div class="card-header"><strong>Message Headers:</strong></div>
<div class="card-body"><pre style="white-space: pre-wrap;">{{.log.email_headers}}</pre></div>
</div> </div>
{{end}} {{end}}
@@ -57,6 +57,26 @@
</div> </div>
</div> </div>
<div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-sliders me-2"></i>Preferences</h5></div>
<div class="card-body">
<form method="POST" action="/webmail/account/preferences">
<label class="form-label">Group similar subjects in the message list</label>
<select class="form-select" name="group_messages">
<option value="false" {{if not .mailbox.GroupMessages}}selected{{end}}>No — show every message separately</option>
<option value="true" {{if .mailbox.GroupMessages}}selected{{end}}>Yes — collapse a run of same-subject messages into one expandable row</option>
</select>
<button type="submit" class="btn btn-primary btn-sm mt-2"><i class="bi bi-check-lg me-1"></i>Save</button>
</form>
<hr>
<form method="POST" action="/webmail/account/rebuild-cache">
<label class="form-label">Refresh sender names &amp; previews for existing mail</label>
<div class="form-text mb-2">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.</div>
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-repeat me-1"></i>Refresh now</button>
</form>
</div>
</div>
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div> <div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div>
<div class="card-body"> <div class="card-body">
+282 -121
View File
@@ -8,34 +8,71 @@
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet"> <link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet"> <link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style> <style>
body { background-color: #1a1a1a; color: #e0e0e0; } html, body { background-color: #1a1a1a; color: #e0e0e0; height: 100%; }
.card { background-color: #2d2d2d; border: 1px solid #404040; } .card { background-color: #2d2d2d; border: 1px solid #404040; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
.folder-link.active { background-color: #0d6efd; color: #fff !important; } .folder-link.active { background-color: #0d6efd; color: #fff !important; }
.msg-unread { font-weight: 600; }
.msg-row { cursor: grab; } .msg-row { cursor: grab; }
.msg-row.dragging { opacity: 0.4; } .msg-row.dragging { opacity: 0.4; }
.folder-link.drop-hover { background-color: #0d6efd; color: #fff !important; outline: 2px dashed #6ea8fe; outline-offset: -2px; } .folder-link.drop-hover { background-color: #0d6efd; color: #fff !important; outline: 2px dashed #6ea8fe; outline-offset: -2px; }
.folder-unread-badge { font-size: .7rem; } .folder-unread-badge { font-size: .7rem; }
.msg-row-older { display: none; } .msg-row-older { display: none; }
.msg-group-toggle { cursor: pointer; } .msg-group-toggle { cursor: pointer; }
/* A search result can span multiple folders, and the bulk-action endpoint is
scoped to one folder path — rather than a bulk action silently no-oping on
every row from a different folder, selection/bulk actions are just not
offered while searching (per-row actions in the reading pane still work). */
.search-mode .msg-check, .search-mode #selectAllCheck, .search-mode .bulk-btn { display: none; }
/* Outlook-style three-pane shell: fixed-width sidebar + fixed-width list +
flexible reading pane, instead of a responsive 12-column grid — this is
deliberately a fixed desktop layout to match the reference, not a
mobile-first one. */
.mail-shell { display: flex; align-items: stretch; height: calc(100vh - 56px); overflow: hidden; }
.mail-sidebar { width: 230px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; padding: .75rem; }
.mail-list-pane { width: 380px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; display: flex; flex-direction: column; }
.mail-reading-pane { flex: 1 1 auto; overflow-y: auto; padding: 1.5rem; min-width: 0; }
.mail-toolbar { flex: 0 0 auto; padding: .5rem .75rem; border-bottom: 1px solid #404040; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; }
.mail-list-scroll { flex: 1 1 auto; overflow-y: auto; }
.mail-list-header { padding: .35rem .75rem; font-size: .75rem; text-transform: uppercase; color: #8a8a8a; display: flex; justify-content: space-between; }
.msg-item { display: flex; align-items: flex-start; gap: .6rem; padding: .55rem .75rem; border-bottom: 1px solid #333; cursor: pointer; }
.msg-item:hover { background-color: #262626; }
.msg-item.active { background-color: #0d3860; }
.msg-item.unread .msg-subject { font-weight: 700; color: #fff; }
.msg-item.unread .msg-from { font-weight: 700; color: #fff; }
.msg-check { margin-top: .35rem; flex: 0 0 auto; }
.msg-avatar { width: 34px; height: 34px; border-radius: 50%; background: #495057; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .85rem; font-weight: 600; flex: 0 0 auto; }
.msg-item.unread .msg-avatar { background: #0d6efd; }
.msg-main { min-width: 0; flex: 1 1 auto; }
.msg-row1 { display: flex; justify-content: space-between; gap: .5rem; }
.msg-from { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-date { flex: 0 0 auto; font-size: .75rem; color: #8a8a8a; }
.msg-row2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; color: #adb5bd; }
.msg-subject { color: #e0e0e0; }
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-x: auto; }
.msg-body-text { white-space: pre-wrap; word-break: break-word; }
#readingPaneBody .pane-toolbar { border-bottom: 1px solid #404040; padding-bottom: .75rem; }
</style> </style>
</head> </head>
<body> <body>
{{template "csrf_script" .}} {{template "csrf_script" .}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4"> <nav class="navbar navbar-expand-lg navbar-dark bg-dark px-3" style="height: 56px;">
<div class="container-fluid"> <span class="navbar-brand mb-0 h1"><i class="bi bi-envelope-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span> <form method="get" action="/webmail/mail/search" class="mx-auto" style="width: 360px;">
<div class="navbar-nav flex-row gap-2 ms-auto"> <div class="input-group input-group-sm">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a> <span class="input-group-text bg-body-secondary border-0"><i class="bi bi-search"></i></span>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button> <input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}">
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
</div> </div>
</form>
<div class="navbar-nav flex-row gap-2 ms-auto">
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm" title="Rules"><i class="bi bi-funnel"></i></a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm" title="Certs"><i class="bi bi-shield-lock"></i></a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Account"><i class="bi bi-gear"></i></a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm" title="Sign out"><i class="bi bi-box-arrow-right"></i></button>
</form>
</div> </div>
</nav> </nav>
@@ -53,117 +90,123 @@
{{end}} {{end}}
</div> </div>
<div class="container-fluid pb-5"> <div class="mail-shell">
<div class="row"> <div class="mail-sidebar" id="folderSidebarCol">
<div class="col-lg-2 mb-4"> <div class="d-flex justify-content-between align-items-center mb-2">
<div class="card"> <span class="text-muted small text-uppercase">Folders</span>
<div class="card-body p-2"> <button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0" id="sidebarCollapseBtn" title="Hide folder list"><i class="bi bi-chevron-bar-left"></i></button>
<form method="get" action="/webmail/mail/search" class="mb-2"> </div>
<div class="input-group input-group-sm"> <div class="list-group list-group-flush">
<input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}"> {{$active := .active_folder}}
<button type="submit" class="btn btn-outline-light"><i class="bi bi-search"></i></button> {{$unread := .unread_counts}}
</div> {{$counts := .folder_counts}}
</form> {{range .folders}}
<div class="list-group list-group-flush"> <div class="d-flex align-items-center folder-row">
{{$active := .active_folder}} <a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}">
{{$unread := .unread_counts}} <span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
{{range .folders}} {{$n := index $unread .}}
<div class="d-flex align-items-center folder-row"> {{$total := index $counts .}}
<a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}"> {{if $total}}
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span> <span class="badge {{if $n}}bg-primary{{else}}bg-secondary{{end}} rounded-pill folder-unread-badge" title="{{$total}} total{{if $n}}, {{$n}} unread{{end}}">{{$total}}{{if $n}} / <strong>{{$n}}</strong>{{end}}</span>
{{$n := index $unread .}} {{end}}
{{if $n}}<span class="badge bg-primary rounded-pill folder-unread-badge">{{$n}}</span>{{end}} </a>
</a> {{if not (isStandardFolder .)}}
{{if not (isStandardFolder .)}} <form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline">
<form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline"> <button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder &quot;{{.}}&quot;? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button>
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder &quot;{{.}}&quot;? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button> </form>
</form> {{end}}
{{end}} </div>
</div> {{end}}
{{end}} </div>
</div> <hr class="my-2">
<hr class="my-2"> <form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1">
<form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1"> <input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required>
<input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required> <button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button> </form>
</form> </div>
</div>
<div class="mail-list-pane{{if .search_query}} search-mode{{end}}" id="messageListCol">
<div class="mail-toolbar">
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 d-none" id="sidebarShowBtn" title="Show folder list"><i class="bi bi-chevron-bar-right"></i></button>
<input type="checkbox" class="form-check-input" id="selectAllCheck" title="Select all">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="delete" disabled title="Delete"><i class="bi bi-trash"></i></button>
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="read" disabled title="Mark as read"><i class="bi bi-envelope-open"></i></button>
<button type="button" class="btn btn-outline-secondary bulk-btn" data-action="unread" disabled title="Mark as unread"><i class="bi bi-envelope"></i></button>
</div>
{{if not .search_query}}
<select class="form-select form-select-sm bulk-move-select" id="bulkMoveSelect" disabled style="width: auto;" title="Move selected to&hellip;">
<option value="">Move to&hellip;</option>
{{$folder := .active_folder}}
{{range .folders}}{{if ne . $folder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
</select>
{{end}}
<button type="button" class="btn btn-sm btn-outline-secondary border-0" onclick="location.reload()" title="Refresh"><i class="bi bi-arrow-clockwise"></i></button>
<div class="ms-auto d-flex align-items-center gap-1">
{{if not .search_query}}
<a href="{{.sort_from_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by sender"><i class="bi bi-person{{if eq .sort_by "from"}}-fill{{end}}"></i>{{if eq .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
<a href="{{.sort_date_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by date"><i class="bi bi-calendar3{{if ne .sort_by "from"}}-fill{{end}}"></i>{{if ne .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
<a href="{{.unread_only_href}}" class="btn btn-sm border-0 py-0 {{if .unread_only}}btn-primary{{else}}btn-outline-secondary{{end}}" title="Unread only"><i class="bi bi-envelope-fill"></i></a>
{{end}}
</div> </div>
</div> </div>
<div class="mail-list-header">
<div class="col-lg-10 mb-4"> <span>{{if .search_query}}Search: &ldquo;{{.search_query}}&rdquo;{{else}}{{.active_folder}}{{end}}</span>
<div class="card"> <span>{{.total}} message{{if ne .total 1}}s{{end}}</span>
<div class="card-header d-flex justify-content-between align-items-center"> </div>
<h5 class="mb-0"> <div class="mail-list-scroll" id="mailListScroll">
{{if .search_query}}<i class="bi bi-search me-2"></i>Search results for &ldquo;{{.search_query}}&rdquo; {{if .messages}}
{{else}}<i class="bi bi-folder2-open me-2"></i>{{.active_folder}}{{end}} {{range .messages}}
</h5> {{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
<small class="text-muted">{{.total}} message{{if ne .total 1}}s{{end}}</small> {{$paneHref := printf "/webmail/mail/%s/%d/pane" .Folder .ID}}
</div> {{$isDraft := eq .Folder "Drafts"}}
<div class="card-body p-0"> {{if $isDraft}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
{{if .messages}} {{$displayName := senderName .CachedFrom}}
<div class="table-responsive"> {{if eq .Folder "Sent"}}{{if .CachedTo}}{{$displayName = senderName .CachedTo}}{{else}}{{$displayName = "(no recipient)"}}{{end}}{{end}}
<table class="table table-dark table-hover mb-0"> <div class="msg-item {{if .Unread}}unread{{end}}{{if .Collapsed}} msg-row-older{{end}} msg-row" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}" data-href="{{$rowHref}}" data-pane-href="{{$paneHref}}" data-is-draft="{{$isDraft}}">
<thead> <input type="checkbox" class="form-check-input msg-check" value="{{.ID}}" onclick="event.stopPropagation()">
<tr> <div class="msg-avatar">{{initial $displayName}}</div>
{{if not .search_query}}<th>{{if eq .active_folder "Sent"}}To{{else}}From{{end}}</th>{{else}}<th>From / To</th>{{end}} <div class="msg-main">
<th>Subject</th> <div class="msg-row1">
{{if .search_query}}<th>Folder</th>{{end}} <span class="msg-from" title="{{if eq .Folder "Sent"}}{{.CachedTo}}{{else}}{{.CachedFrom}}{{end}}">{{$displayName}}</span>
<th>Date</th> <span class="msg-date">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</span>
<th></th> </div>
</tr> <div class="msg-row2">
</thead> <span class="msg-subject">{{if .CachedSubject}}{{.CachedSubject}}{{else}}(no subject){{end}}</span>
<tbody> {{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
{{$folders := .folders}} {{if .CachedPreview}} &ndash; {{.CachedPreview}}{{end}}
{{$showFolderCol := .search_query}} </div>
{{range .messages}}
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
{{if eq .Folder "Drafts"}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
<tr class="{{if .Unread}}msg-unread{{end}} msg-row{{if .Collapsed}} msg-row-older{{end}}" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}">
<td><a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if eq .Folder "Sent"}}{{if .CachedTo}}{{.CachedTo}}{{else}}(no recipient){{end}}{{else}}{{.CachedFrom}}{{end}}</a></td>
<td>
<a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if .CachedSubject}}{{.CachedSubject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</a>
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
</td>
{{if $showFolderCol}}<td><small class="text-muted">{{.Folder}}</small></td>{{end}}
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</small></td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/move" class="d-inline-flex">
<select name="target_folder" class="form-select form-select-sm" style="width: auto;" onchange="this.form.submit()">
<option value="">Move to&hellip;</option>
{{$rowFolder := .Folder}}
{{range $folders}}{{if ne . $rowFolder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
</select>
</form>
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .Folder "Trash"}}Delete permanently{{else}}Move to Trash{{end}}" data-confirm="{{if eq .Folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div> </div>
{{if or .has_prev .has_next}}
<div class="d-flex justify-content-between p-3">
{{if .has_prev}}<a href="?page={{sub .page 1}}" class="btn btn-outline-secondary btn-sm">&laquo; Newer</a>{{else}}<span></span>{{end}}
{{if .has_next}}<a href="?page={{add .page 1}}" class="btn btn-outline-secondary btn-sm">Older &raquo;</a>{{end}}
</div>
{{end}}
{{else}}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<h5 class="text-muted mt-3">No messages in {{.active_folder}}</h5>
</div>
{{end}}
</div> </div>
{{end}}
{{else}}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<h6 class="text-muted mt-3">No messages</h6>
</div> </div>
{{end}}
</div>
{{if or .has_prev .has_next}}
<div class="d-flex justify-content-between p-2 border-top" style="border-color: #404040 !important;">
{{if .has_prev}}<a href="{{.prev_href}}" class="btn btn-outline-secondary btn-sm">&laquo; Newer</a>{{else}}<span></span>{{end}}
{{if .has_next}}<a href="{{.next_href}}" class="btn btn-outline-secondary btn-sm">Older &raquo;</a>{{end}}
</div>
{{end}}
</div>
<div class="mail-reading-pane" id="readingPaneBody">
<div class="text-center text-muted py-5">
<i class="bi bi-envelope-open" style="font-size: 3rem;"></i>
<p class="mt-3">Select a message to read</p>
</div> </div>
</div> </div>
</div> </div>
<form method="post" id="bulkActionForm" class="d-none">
<input type="hidden" name="action" id="bulkActionField">
<input type="hidden" name="target_folder" id="bulkTargetFolderField">
</form>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@@ -217,9 +260,9 @@
}); });
// Drag a message row onto a folder in the sidebar to move it there — a // Drag a message row onto a folder in the sidebar to move it there — a
// shortcut for the same "Move to..." dropdown every row already has. Each // shortcut for the toolbar's "Move to..." control. Each row carries its OWN
// row carries its OWN folder (data-folder) rather than assuming the page's // folder (data-folder) rather than assuming the page's active folder, since a
// active folder, since a search result can span multiple folders. // search result can span multiple folders.
(function() { (function() {
let draggedUID = null; let draggedUID = null;
let draggedFolder = null; let draggedFolder = null;
@@ -251,6 +294,7 @@
if (!draggedUID || !targetFolder || targetFolder === draggedFolder) return; if (!draggedUID || !targetFolder || targetFolder === draggedFolder) return;
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set('target_folder', targetFolder); body.set('target_folder', targetFolder);
body.set('csrf_token', window.__csrfToken || '');
await fetch(`/webmail/mail/${draggedFolder}/${draggedUID}/move`, { await fetch(`/webmail/mail/${draggedFolder}/${draggedUID}/move`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -268,7 +312,7 @@
badge.addEventListener('click', function(e) { badge.addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
let sib = badge.closest('tr').nextElementSibling; let sib = badge.closest('.msg-item').nextElementSibling;
while (sib && sib.classList.contains('msg-row-older')) { while (sib && sib.classList.contains('msg-row-older')) {
sib.style.display = ''; sib.style.display = '';
sib = sib.nextElementSibling; sib = sib.nextElementSibling;
@@ -276,6 +320,123 @@
badge.style.display = 'none'; badge.style.display = 'none';
}); });
}); });
// Folder sidebar collapse — a display preference remembered per-browser
// (localStorage), not server state; default is pinned open (nothing stored
// yet == not collapsed).
(function() {
const KEY = 'webmail_sidebar_collapsed';
const sidebarCol = document.getElementById('folderSidebarCol');
const showBtn = document.getElementById('sidebarShowBtn');
function apply(collapsed) {
sidebarCol.style.display = collapsed ? 'none' : '';
showBtn.classList.toggle('d-none', !collapsed);
}
apply(localStorage.getItem(KEY) === '1');
document.getElementById('sidebarCollapseBtn').addEventListener('click', function() {
localStorage.setItem(KEY, '1');
apply(true);
});
showBtn.addEventListener('click', function() {
localStorage.setItem(KEY, '0');
apply(false);
});
})();
// Reading pane: clicking a row loads the message via fetch instead of
// navigating away, mirroring the admin dashboard's message-log modal. Drafts
// still navigate to compose (there's nothing to "read"). The clicked row is
// marked read optimistically client-side — the pane fetch itself is what
// actually marks it read server-side (see webmailMessagePane).
(function() {
const paneBody = document.getElementById('readingPaneBody');
document.querySelectorAll('.msg-item').forEach(function(row) {
row.addEventListener('click', function(e) {
if (e.target.closest('.msg-group-toggle') || e.target.classList.contains('msg-check')) return;
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
document.querySelectorAll('.msg-item.active').forEach(function(r) { r.classList.remove('active'); });
row.classList.add('active');
row.classList.remove('unread');
paneBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
fetch(row.dataset.paneHref)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) { paneBody.innerHTML = html; })
.catch(function() { paneBody.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
});
});
})();
// Selection (checkboxes + select-all + Shift-click range) driving the bulk
// toolbar buttons — enabled only once something's actually selected.
(function() {
const checks = Array.from(document.querySelectorAll('.msg-check'));
const selectAll = document.getElementById('selectAllCheck');
const bulkBtns = document.querySelectorAll('.bulk-btn');
const moveSelect = document.getElementById('bulkMoveSelect');
let lastCheckedIndex = null;
function updateToolbar() {
const any = checks.some(function(c) { return c.checked; });
bulkBtns.forEach(function(b) { b.disabled = !any; });
if (moveSelect) moveSelect.disabled = !any;
selectAll.checked = checks.length > 0 && checks.every(function(c) { return c.checked; });
}
checks.forEach(function(cb, i) {
cb.addEventListener('click', function(e) {
if (e.shiftKey && lastCheckedIndex !== null) {
const [from, to] = [lastCheckedIndex, i].sort(function(a, b) { return a - b; });
for (let j = from; j <= to; j++) checks[j].checked = cb.checked;
}
lastCheckedIndex = i;
updateToolbar();
});
});
selectAll.addEventListener('change', function() {
checks.forEach(function(c) { c.checked = selectAll.checked; });
updateToolbar();
});
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
selectAll.checked = true;
checks.forEach(function(c) { c.checked = true; });
updateToolbar();
}
});
function selectedUIDs() { return checks.filter(function(c) { return c.checked; }).map(function(c) { return c.value; }); }
function submitBulk(action, targetFolder) {
const uids = selectedUIDs();
if (uids.length === 0) return;
const form = document.getElementById('bulkActionForm');
form.action = '/webmail/mail/{{.active_folder}}/bulk';
document.getElementById('bulkActionField').value = action;
document.getElementById('bulkTargetFolderField').value = targetFolder || '';
form.querySelectorAll('input[name="uid"]').forEach(function(el) { el.remove(); });
uids.forEach(function(uid) {
const input = document.createElement('input');
input.type = 'hidden'; input.name = 'uid'; input.value = uid;
form.appendChild(input);
});
const csrf = document.createElement('input');
csrf.type = 'hidden'; csrf.name = 'csrf_token'; csrf.value = window.__csrfToken || '';
form.appendChild(csrf);
form.submit();
}
document.querySelectorAll('.bulk-btn').forEach(function(btn) {
btn.addEventListener('click', async function() {
const action = btn.dataset.action;
if (action === 'delete' && !(await showConfirmation('Move the selected message(s) to Trash?'))) return;
submitBulk(action);
});
});
if (moveSelect) {
moveSelect.addEventListener('change', function() {
if (moveSelect.value) submitBulk('move', moveSelect.value);
});
}
})();
</script> </script>
</body> </body>
</html> </html>
@@ -0,0 +1,91 @@
{{define "webmail_message_pane.html"}}
<div class="pane-toolbar d-flex justify-content-between align-items-center mb-3">
<div class="btn-group btn-group-sm">
<button type="button" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply"><i class="bi bi-reply"></i></button>
<button type="button" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply All"><i class="bi bi-reply-all"></i></button>
<button type="button" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Forward"><i class="bi bi-arrow-right"></i></button>
</div>
<div class="d-flex align-items-center gap-2">
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/move" class="d-flex align-items-center gap-1">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<select name="target_folder" class="form-select form-select-sm" style="width: auto;">
<option value="">Move to&hellip;</option>
{{$folder := .active_folder}}
{{range .folders}}{{if ne . $folder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
</select>
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Move"><i class="bi bi-folder-symlink"></i></button>
</form>
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete" onsubmit="return confirm('{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}');">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}"><i class="bi bi-trash"></i></button>
</form>
</div>
</div>
<h5 class="mb-2">{{if .parsed.Header.Subject}}{{.parsed.Header.Subject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</h5>
{{if or .smime.Signed .smime.Encrypted}}
<div class="mb-2">
{{if .smime.Encrypted}}
{{if .smime.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>Encrypted &amp; decrypted</span>
{{else}}<span class="badge bg-danger" title="{{.smime.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>Encrypted — could not decrypt</span>{{end}}
{{end}}
{{if .smime.Signed}}
{{if .smime.SignatureOK}}<span class="badge bg-success" title="{{.smime.SignerEmail}}"><i class="bi bi-patch-check-fill me-1"></i>Signature verified{{if .smime.SignerEmail}} ({{.smime.SignerEmail}}){{end}}</span>
{{else}}<span class="badge bg-danger" title="{{.smime.SignatureErr}}"><i class="bi bi-exclamation-triangle-fill me-1"></i>Signature invalid</span>{{end}}
{{end}}
</div>
{{end}}
{{if .pgp.Encrypted}}
<div class="mb-2">
{{if .pgp.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>PGP encrypted &amp; decrypted</span>
{{else if .pgp.NeedsUnlock}}<span class="badge bg-warning text-dark"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — enter your passphrase to decrypt</span>
{{else}}<span class="badge bg-danger" title="{{.pgp.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — could not decrypt</span>{{end}}
</div>
{{if .pgp.NeedsUnlock}}
<form method="post" action="/webmail/pgp/unlock" class="row g-2 align-items-end mb-2">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<input type="hidden" name="next" value="{{.message_url}}">
<div class="col-auto">
<select class="form-select form-select-sm" name="identity_id">
{{range .pgp.Identities}}<option value="{{.ID}}">{{if .Label}}{{.Label}}{{else}}Key{{end}} ({{.Fingerprint}})</option>{{end}}
</select>
</div>
<div class="col-auto">
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="Passphrase" required>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-warning btn-sm">Unlock &amp; Decrypt</button>
</div>
</form>
{{end}}
{{end}}
<div class="small text-muted mb-3">
<div><strong>From:</strong> {{.parsed.Header.From}}</div>
<div><strong>To:</strong> {{.parsed.Header.To}}</div>
{{if .parsed.Header.Cc}}<div><strong>Cc:</strong> {{.parsed.Header.Cc}}</div>{{end}}
<div><strong>Date:</strong> {{.parsed.Header.Date}}</div>
</div>
{{if .html_body}}
<div class="msg-body-html">{{.html_body}}</div>
{{else if .parsed.TextBody}}
<div class="msg-body-text">{{.parsed.TextBody}}</div>
{{else}}
<p class="text-muted mb-0">(empty message body)</p>
{{end}}
{{if .parsed.Attachments}}
<hr>
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
<div class="list-group">
{{$folder := .active_folder}}
{{$uid := .uid}}
{{range $i, $att := .parsed.Attachments}}
<a href="/webmail/mail/{{$folder}}/{{$uid}}/attachment/{{$i}}" class="list-group-item list-group-item-action bg-transparent text-white d-flex justify-content-between align-items-center">
<span><i class="bi bi-file-earmark me-2"></i>{{$att.Filename}}</span>
<i class="bi bi-download"></i>
</a>
{{end}}
</div>
{{end}}
{{end}}
+58 -4
View File
@@ -1,11 +1,14 @@
package webui package webui
import ( import (
"encoding/base64"
"html/template"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"mailgoserver/internal/db" "mailgoserver/internal/db"
"mailgoserver/internal/mailview"
) )
// emailLogAccessible checks a scoped admin's domain assignment against the sender // emailLogAccessible checks a scoped admin's domain assignment against the sender
@@ -20,7 +23,25 @@ func (a *App) emailLogAccessible(r *http.Request, mailFrom string) (bool, error)
return isGlobal || names[emailDomain(mailFrom)], nil return isGlobal || names[emailDomain(mailFrom)], nil
} }
// viewMessageContent mirrors view_message.py's view_message_content(). // viewedAttachment is one attachment ready for the log viewer: decoded bytes encoded
// as a data: URI so no separate download route/disk read is needed, and a browser can
// render it as an inline image directly for the review case this is really for
// (a quarantined message an admin needs to actually inspect, images and all).
type viewedAttachment struct {
Filename string
ContentType string
Size int64 // int64 to match humanFileSize's signature (the "filesize" template func)
DataURI template.URL
IsImage bool
}
// viewMessageContent mirrors view_message.py's view_message_content(). log.MessageBody
// holds the *entire* raw message when this log's content was eligible to be stored
// (see session.go's storeContent) — re-parsed here via mailview (the same parser
// webmail's own message view uses) so the real HTML body, inline images, and
// attachments all render, not just a plain-text approximation. Falls back to showing
// message_body as plain preformatted text if it doesn't parse as a MIME message (e.g.
// an older log row stored before this — plain-text-only — capture existed).
func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) { func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) {
log, err := a.DB.GetEmailLogByID(pathID(r)) log, err := a.DB.GetEmailLogByID(pathID(r))
if err != nil || log == nil { if err != nil || log == nil {
@@ -31,12 +52,45 @@ func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
attachments, _ := a.DB.ListAttachmentsForEmail(log.ID)
// The old, file-on-disk attachment mechanism (still opt-in-gated the same way it
// always was) — kept as a fallback list for log rows predating the raw-message
// capture below, where this is the only place attachments exist at all.
legacyAttachments, _ := a.DB.ListAttachmentsForEmail(log.ID)
var htmlBody template.HTML
var plainBody string
var attachments []viewedAttachment
if log.MessageBody != "" {
if parsed, err := mailview.Parse([]byte(log.MessageBody)); err == nil {
if parsed.HTMLBody != "" {
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
}
plainBody = parsed.TextBody
for _, att := range parsed.Attachments {
ct := att.ContentType
if ct == "" {
ct = "application/octet-stream"
}
attachments = append(attachments, viewedAttachment{
Filename: att.Filename, ContentType: ct, Size: int64(len(att.Data)),
DataURI: template.URL("data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(att.Data)),
IsImage: strings.HasPrefix(ct, "image/"),
})
}
} else {
// Doesn't parse as MIME — treat the stored string as plain text as-is
// (the shape a pre-fix log row's message_body was always in).
plainBody = log.MessageBody
}
}
a.render(w, r, "view_message_content.html", M{"active": "logs", "log": M{ a.render(w, r, "view_message_content.html", M{"active": "logs", "log": M{
"id": log.ID, "mail_from": log.MailFrom, "to_address": log.ToAddress, "id": log.ID, "mail_from": log.MailFrom, "to_address": log.ToAddress,
"cc_addresses": log.CcAddresses, "bcc_addresses": log.BccAddresses, "cc_addresses": log.CcAddresses, "bcc_addresses": log.BccAddresses,
"subject": log.Subject, "created_at": log.CreatedAt, "message_body": log.MessageBody, "subject": log.Subject, "created_at": log.CreatedAt,
"email_headers": log.EmailHeaders, "attachments": attachments, "html_body": htmlBody, "plain_body": plainBody, "has_content": log.MessageBody != "",
"email_headers": log.EmailHeaders, "attachments": attachments, "legacy_attachments": legacyAttachments,
}}) }})
} }
@@ -0,0 +1,87 @@
package webui
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"mailgoserver/internal/db"
)
// 1x1 transparent PNG, base64-encoded — a minimal real image for the attachment part.
const testPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
func buildTestMIMEMessageWithImage() string {
boundary := "testboundary123"
return strings.Join([]string{
"From: attacker@evil.example",
"To: victim@example.com",
"Subject: Free money",
"MIME-Version: 1.0",
"Content-Type: multipart/mixed; boundary=\"" + boundary + "\"",
"",
"--" + boundary,
`Content-Type: text/html; charset="UTF-8"`,
"",
"<p>Click <b>here</b> to claim your prize.</p>",
"",
"--" + boundary,
"Content-Type: image/png",
"Content-Transfer-Encoding: base64",
`Content-Disposition: attachment; filename="lure.png"`,
"",
testPNGBase64,
"",
"--" + boundary + "--",
}, "\r\n")
}
// TestViewMessageContentRendersHTMLAndAttachmentInlineForStoredContent confirms that
// when a log's message_body holds a full raw message (the new default for a
// quarantined/opted-in message — see session.go's storeContent), the "View Full
// Message" page actually renders the real HTML body and offers the attachment inline
// (as a data: URI, no separate file/route needed) — not just a plain-text dump, and
// not silently dropping the image the way the old text-only capture always did.
func TestViewMessageContentRendersHTMLAndAttachmentInlineForStoredContent(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
logID, err := app.DB.InsertEmailLog(db.EmailLog{
MessageID: "test-msg-id", Timestamp: time.Now(), PeerIP: "203.0.113.5",
MailFrom: "attacker@evil.example", ToAddress: "victim@example.com", Subject: "Free money",
EmailHeaders: "From: attacker@evil.example\nSubject: Free money",
MessageBody: buildTestMIMEMessageWithImage(),
Status: "failed",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, Prefix+"/msg/content/"+strconv.FormatInt(logID, 10), nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Click") || !strings.Contains(body, "<b>here</b>") {
t.Errorf("expected the sanitized HTML body rendered, got:\n%s", body)
}
if !strings.Contains(body, "data:image/png;base64,") {
t.Error("expected the attachment rendered inline as a data: URI")
}
if !strings.Contains(body, "lure.png") {
t.Error("expected the attachment's filename shown")
}
// This is fetched into a modal on the logs page, not navigated to directly — it
// must be a bare fragment, not a full page with the dashboard's own nav/sidebar.
if strings.Contains(body, "<!DOCTYPE") || strings.Contains(body, "Email Server Management") || strings.Contains(body, "sidebar_email") {
t.Errorf("expected a bare fragment with no dashboard chrome, got:\n%s", body)
}
}
+40
View File
@@ -3,6 +3,7 @@ package webui
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"fmt"
"html/template" "html/template"
"image/png" "image/png"
"net/http" "net/http"
@@ -37,6 +38,45 @@ func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
}) })
} }
// webmailSetGroupMessages toggles the "group similar subjects" folder-view preference
// (see renderFolderOrSearch) — off by default, per-mailbox, purely a display choice.
func (a *App) webmailSetGroupMessages(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxGroupMessages(mbox.ID, r.FormValue("group_messages") == "true"); err != nil {
a.Logger.Error("set group_messages for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save preference")
} else {
setFlash(w, "success", "Preference saved")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailRebuildMessageCache re-derives cached_from/cached_to/cached_subject/
// cached_preview for every message already in this mailbox — see
// mailstore.RebuildMessageCache's doc comment for why this exists: those fields are
// only ever computed once, at delivery time, so mail stored before a caching fix (like
// showing a sender's display name instead of the bare address) or addition (like the
// preview snippet) landed keeps showing the old/blank value until something
// retroactively re-derives it.
func (a *App) webmailRebuildMessageCache(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
updated, skipped := a.Mailstore.RebuildMessageCache(mbox.ID)
if len(skipped) > 0 {
a.Logger.Error("rebuild message cache for mailbox %d: %d skipped: %v", mbox.ID, len(skipped), skipped)
}
msg := fmt.Sprintf("Refreshed %d message(s)", updated)
if len(skipped) > 0 {
msg += fmt.Sprintf(" — %d could not be read and were left as-is", len(skipped))
}
setFlash(w, "success", msg)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailMFASetupRequiredPage is the isolated, no-navigation landing page // webmailMFASetupRequiredPage is the isolated, no-navigation landing page
// requireMailboxAuth sends a mailbox owner to when enforce_mailbox_mfa applies and // requireMailboxAuth sends a mailbox owner to when enforce_mailbox_mfa applies and
// they have no second factor yet — the only page (besides the totp/passkey setup // they have no second factor yet — the only page (besides the totp/passkey setup
+74
View File
@@ -0,0 +1,74 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
// TestWebmailComposeSendBouncesFailedRecipientToSenderInbox confirms that when one
// recipient in a multi-recipient send fails (here: an over-quota local mailbox, caught
// only at delivery time — RCPT-equivalent resolution succeeds), the sender still gets
// their flash "sent, but..." feedback AND a persistent bounce notification lands in
// their own INBOX, mirroring a real mail provider's delivery-failure notice.
func TestWebmailComposeSendBouncesFailedRecipientToSenderInbox(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "sender@example.com", domainID, "sender-password-1!")
// A second local mailbox with an effectively-zero quota, so StoreMessage always
// fails with ErrQuotaExceeded — a hermetic, deterministic delivery failure with no
// network dependency (unlike a relay-to-external-domain failure would be).
hash, err := db.HashPassword("full-password-1!")
if err != nil {
t.Fatal(err)
}
wrapped, nonce, err := app.Mailstore.WrapDEK(mailstore.GenerateDEK())
if err != nil {
t.Fatal(err)
}
fullID, err := app.DB.CreateMailbox("full@example.com", hash, domainID, 1, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"full@example.com"}, "subject": {"Big attachment incoming"}, "body_html": {"body text"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
fullMsgs, err := app.DB.ListMessagesInFolder(fullID, "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 := app.DB.ListMessagesInFolder(senderID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(bounces) != 1 {
t.Fatalf("expected 1 bounce message in the sender's own INBOX, got %d", len(bounces))
}
if bounces[0].CachedSubject != "Undelivered Mail Returned to Sender" {
t.Errorf("bounce subject = %q", bounces[0].CachedSubject)
}
}
+12 -1
View File
@@ -56,6 +56,12 @@ func TestWebmailComposeSendLocalDelivery(t *testing.T) {
if len(senderSent) != 1 { if len(senderSent) != 1 {
t.Fatalf("expected 1 message in sender's Sent folder, got %d", len(senderSent)) t.Fatalf("expected 1 message in sender's Sent folder, got %d", len(senderSent))
} }
if isUnread(senderSent[0].Flags) {
t.Error("expected the Sent copy to be marked read, not unread")
}
if !isUnread(recipientMsgs[0].Flags) {
t.Error("expected the recipient's INBOX copy to still be unread")
}
// Recipient can actually read it via the message view. // Recipient can actually read it via the message view.
recipientCookie := webmailLoginSession(t, app, recipientID) recipientCookie := webmailLoginSession(t, app, recipientID)
@@ -70,12 +76,17 @@ func TestWebmailComposeSendLocalDelivery(t *testing.T) {
t.Error("expected the message body in the rendered view") t.Error("expected the message body in the rendered view")
} }
// It's also recorded in the admin email log for visibility. // It's also recorded in the admin email log for visibility — but never with the
// real body content (privacy default; the Sent folder above already keeps the
// real, encrypted-at-rest copy).
logs, _ := app.DB.ListEmailLogsPage(0, 10) logs, _ := app.DB.ListEmailLogsPage(0, 10)
found := false found := false
for _, l := range logs { for _, l := range logs {
if l.Subject == "Hello there" && l.MailFrom == "sender@example.com" { if l.Subject == "Hello there" && l.MailFrom == "sender@example.com" {
found = true found = true
if strings.Contains(l.MessageBody, "This is the message body.") {
t.Errorf("expected the real body not to be logged, got %q", l.MessageBody)
}
} }
} }
if !found { if !found {
+25 -6
View File
@@ -638,8 +638,14 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
results = append(results, a.deliverWebmailComposeLocally(rcpt, localTypes[i], from, subject, signed, messageID)) results = append(results, a.deliverWebmailComposeLocally(rcpt, localTypes[i], from, subject, signed, messageID))
} }
if _, err := a.Mailstore.StoreMessage(mbox.ID, "Sent", []byte(signed), messageID, from, subject); err != nil { if sentUID, err := a.Mailstore.StoreMessage(mbox.ID, "Sent", []byte(signed), messageID, from, subject); err != nil {
a.Logger.Error("store sent copy for mailbox %d: %v", mbox.ID, err) a.Logger.Error("store sent copy for mailbox %d: %v", mbox.ID, err)
} else if err := a.DB.SetMessageFlags(mbox.ID, sentUID, `\Seen`); err != nil {
// Mail you just sent yourself was never "unread" to begin with — StoreMessage
// has no way to set initial flags, so this mirrors deliverWebmailComposeLocally's
// existing store-then-mark-read pattern rather than threading a flags param
// through StoreMessage for what only these two Sent/Drafts call sites need.
a.Logger.Error("mark sent copy %d read for mailbox %d: %v", sentUID, mbox.ID, err)
} }
// Sending a draft removes it from Drafts, same as any real mail client. // Sending a draft removes it from Drafts, same as any real mail client.
@@ -649,12 +655,13 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
} }
} }
loggedBody := plainText // Privacy default (matches the inbound SMTP path — see session.go's Data()): the
// admin-visible log never gets the body content, webmail-sent mail included. There's
// no per-mailbox "store content" opt-in for outbound webmail sends the way there is
// for inbound senders/IPs, and it would be redundant anyway — the sender's own Sent
// folder already keeps the real, encrypted-at-rest copy of what they sent.
loggedBody := "[content not stored by default — see the sender's Sent folder for the full message]"
if wantEncrypt { if wantEncrypt {
// The whole point of checking "Encrypt" is that nobody but the recipient (and
// the sender's own Sent copy) can read it — logging the plaintext into the
// admin-visible email log would defeat that even though the wire content is
// genuinely encrypted.
loggedBody = "[PGP encrypted — plaintext not logged]" loggedBody = "[PGP encrypted — plaintext not logged]"
} }
if _, err := a.Relay.LogEmail(a.Cfg, a.requestIP(r), from, strings.Join(toAddrs, ", "), strings.Join(ccAddrs, ", "), strings.Join(bccAddrs, ", "), if _, err := a.Relay.LogEmail(a.Cfg, a.requestIP(r), from, strings.Join(toAddrs, ", "), strings.Join(ccAddrs, ", "), strings.Join(bccAddrs, ", "),
@@ -664,6 +671,7 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
allSucceeded := len(results) > 0 allSucceeded := len(results) > 0
var failures []string var failures []string
var failed []relay.Result
for _, res := range results { for _, res := range results {
if res.Status != "success" { if res.Status != "success" {
allSucceeded = false allSucceeded = false
@@ -672,12 +680,20 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
reason = res.ServerResponse reason = res.ServerResponse
} }
failures = append(failures, res.Recipient+": "+reason) failures = append(failures, res.Recipient+": "+reason)
failed = append(failed, res)
} }
} }
if allSucceeded { if allSucceeded {
setFlash(w, "success", "Message sent") setFlash(w, "success", "Message sent")
} else { } else {
setFlash(w, "error", "Sent, but delivery failed — "+strings.Join(failures, "; ")) setFlash(w, "error", "Sent, but delivery failed — "+strings.Join(failures, "; "))
// There's no separate "sending MTA" here to retry/bounce it the way a real
// inbound SMTP client would — the flash message above only exists for the
// moment right after clicking Send, so a persistent copy lands in the
// sender's own INBOX too, same as a real bounce from any other mail provider.
if err := a.Relay.SendBounce(from, subject, messageID, failed); err != nil {
a.Logger.Error("send bounce to %s: %v", from, err)
}
} }
http.Redirect(w, r, MailboxPrefix+"/mail/Sent", http.StatusFound) http.Redirect(w, r, MailboxPrefix+"/mail/Sent", http.StatusFound)
} }
@@ -745,6 +761,9 @@ func (a *App) webmailComposeSaveDraft(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound) http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return return
} }
if err := a.DB.SetMessageFlags(mbox.ID, newUID, `\Seen`); err != nil {
a.Logger.Error("mark draft %d read for mailbox %d: %v", newUID, mbox.ID, err)
}
// Replace, don't accumulate: re-saving an open draft deletes the previous copy. // Replace, don't accumulate: re-saving an open draft deletes the previous copy.
if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" { if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
+200 -19
View File
@@ -1,8 +1,10 @@
package webui package webui
import ( import (
"fmt"
"html/template" "html/template"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
@@ -96,6 +98,35 @@ type folderRow struct {
Collapsed bool Collapsed bool
} }
// sortLink builds the href for a clickable "From"/"Date" column header: clicking an
// inactive column sorts by it descending; clicking the already-active column flips
// direction; unreadOnly (and folder/query, via the caller building this against the
// current URL) carries over so toggling sort never drops the unread filter.
func sortLink(col string, unreadOnly bool, activeSortBy, activeSortDir string) string {
v := url.Values{}
dir := "desc"
if activeSortBy == col {
if activeSortDir == "asc" {
dir = "desc"
} else {
dir = "asc"
}
}
if col != "" {
v.Set("sort", col)
}
if dir != "desc" {
v.Set("dir", dir)
}
if unreadOnly {
v.Set("unread", "1")
}
if encoded := v.Encode(); encoded != "" {
return "?" + encoded
}
return "?"
}
func isUnread(flags string) bool { func isUnread(flags string) bool {
for _, f := range strings.Fields(flags) { for _, f := range strings.Fields(flags) {
if f == `\Seen` { if f == `\Seen` {
@@ -178,12 +209,19 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
if err != nil { if err != nil {
a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err) a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
} }
folderCounts, err := a.DB.CountMessagesByFolder(mbox.ID)
if err != nil {
a.Logger.Error("count messages by folder for mailbox %d: %v", mbox.ID, err)
}
page := atoi(r.URL.Query().Get("page")) page := atoi(r.URL.Query().Get("page"))
if page < 1 { if page < 1 {
page = 1 page = 1
} }
offset := (page - 1) * webmailPageSize offset := (page - 1) * webmailPageSize
unreadOnly := r.URL.Query().Get("unread") == "1"
sortBy := r.URL.Query().Get("sort") // "" (id/date, default) or "from"
sortDir := r.URL.Query().Get("dir") // "" (desc, default) or "asc"
var total int var total int
var rows []db.MailboxMessage var rows []db.MailboxMessage
@@ -194,11 +232,11 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
} }
rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize) rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize)
} else { } else {
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder) total, err = a.DB.CountMessagesInFolder(mbox.ID, folder, unreadOnly)
if err != nil { if err != nil {
a.Logger.Error("count messages in %s for mailbox %d: %v", folder, mbox.ID, err) a.Logger.Error("count messages in %s for mailbox %d: %v", folder, mbox.ID, err)
} }
rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, offset, webmailPageSize) rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, unreadOnly, sortBy, sortDir, offset, webmailPageSize)
} }
if err != nil { if err != nil {
setFlash(w, "error", "Error loading messages") setFlash(w, "error", "Error loading messages")
@@ -209,36 +247,71 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
} }
// Grouping a cross-folder search's results by subject would mix messages that // Grouping a cross-folder search's results by subject would mix messages that
// happen to share a subject across unrelated folders — only group a real, // happen to share a subject across unrelated folders — only group a real,
// single-folder, unfiltered listing. // single-folder, unfiltered listing. Off by default (mbox.GroupMessages) — a
if query == "" && folder != "" { // per-mailbox display preference, toggled from Account.
if query == "" && folder != "" && mbox.GroupMessages {
messages = groupConsecutiveBySubject(messages) messages = groupConsecutiveBySubject(messages)
} }
unreadToggleV := url.Values{}
if !unreadOnly {
unreadToggleV.Set("unread", "1")
}
if sortBy != "" {
unreadToggleV.Set("sort", sortBy)
}
if sortDir != "" {
unreadToggleV.Set("dir", sortDir)
}
unreadOnlyHref := "?" + unreadToggleV.Encode()
pageHref := func(n int) string {
v := url.Values{}
v.Set("page", strconv.Itoa(n))
if unreadOnly {
v.Set("unread", "1")
}
if sortBy != "" {
v.Set("sort", sortBy)
}
if sortDir != "" {
v.Set("dir", sortDir)
}
return "?" + v.Encode()
}
a.render(w, r, "webmail_folder.html", M{ a.render(w, r, "webmail_folder.html", M{
"mailbox": mbox, "folders": folders, "active_folder": folder, "mailbox": mbox, "folders": folders, "active_folder": folder,
"messages": messages, "page": page, "total": total, "messages": messages, "page": page, "total": total,
"has_next": offset+len(rows) < total, "has_prev": page > 1, "has_next": offset+len(rows) < total, "has_prev": page > 1,
"search_query": query, "unread_counts": unreadCounts, "search_query": query, "unread_counts": unreadCounts, "folder_counts": folderCounts,
"flashes": popFlashes(w, r), "unread_only": unreadOnly, "sort_by": sortBy, "sort_dir": sortDir,
"sort_from_href": sortLink("from", unreadOnly, sortBy, sortDir),
"sort_date_href": sortLink("", unreadOnly, sortBy, sortDir),
"unread_only_href": unreadOnlyHref,
"prev_href": pageHref(page - 1),
"next_href": pageHref(page + 1),
"flashes": popFlashes(w, r),
}) })
} }
// webmailMessageView decrypts, parses, and renders one message — and marks it read. // loadMessageForView decrypts, parses, and marks one message read — the shared core
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) { // behind both webmailMessageView (the full standalone page, for direct links/
mbox := mailboxFromContext(r) // bookmarks) and webmailMessagePane (a bare fragment, AJAX-loaded into the folder
folder := r.PathValue("folder") // view's Outlook-style reading pane) so the crypto/parse/mark-read logic exists in
uid := int64(atoi(r.PathValue("uid"))) // exactly one place. Redirects and returns ok=false itself on any failure, so callers
// just need to bail out when ok is false.
func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *db.Mailbox, folder string, uid int64) (data M, ok bool) {
msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid) msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
if !ok { if !ok {
return return nil, false
} }
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid) raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
if err != nil { if err != nil {
a.Logger.Error("fetch message %d for mailbox %d: %v", uid, mbox.ID, err) a.Logger.Error("fetch message %d for mailbox %d: %v", uid, mbox.ID, err)
setFlash(w, "error", "Error loading message") setFlash(w, "error", "Error loading message")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound) http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return return nil, false
} }
unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw) unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped) parsed, err := mailview.Parse(unwrapped)
@@ -246,7 +319,7 @@ func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err) a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err)
setFlash(w, "error", "Error reading message") setFlash(w, "error", "Error reading message")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound) http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return return nil, false
} }
if isUnread(msgRow.Flags) { if isUnread(msgRow.Flags) {
@@ -262,12 +335,42 @@ func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody)) htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
} }
a.render(w, r, "webmail_message.html", M{ return M{
"mailbox": mbox, "folders": folders, "active_folder": folder, "mailbox": mbox, "folders": folders, "active_folder": folder,
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus, "uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10), "message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
"flashes": popFlashes(w, r), }, true
}) }
// webmailMessageView renders one message as its own full page — direct links/
// bookmarks still work even though the folder view's reading pane (webmailMessagePane)
// is how it's normally opened now.
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
if !ok {
return
}
data["flashes"] = popFlashes(w, r)
a.render(w, r, "webmail_message.html", data)
}
// webmailMessagePane is webmailMessageView's bare-fragment twin — AJAX-fetched into
// the folder view's reading pane (see webmail_folder.html) instead of navigating to a
// whole new page, mirroring the admin dashboard's message-log modal (view_message.go).
func (a *App) webmailMessagePane(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
data, ok := a.loadMessageForView(w, r, mbox, folder, uid)
if !ok {
return
}
a.render(w, r, "webmail_message_pane.html", data)
} }
// webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in // webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in
@@ -275,9 +378,21 @@ func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
// *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment // *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment
// as authorization, always re-check server-side. // as authorization, always re-check server-side.
func (a *App) webmailMessageWithAccess(w http.ResponseWriter, r *http.Request, mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) { func (a *App) webmailMessageWithAccess(w http.ResponseWriter, r *http.Request, mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
msg, ok := a.messageAccessible(mailboxID, folder, uid)
if !ok {
http.NotFound(w, r)
return nil, false
}
return msg, true
}
// messageAccessible is webmailMessageWithAccess without the side effect of writing a
// 404 response — for webmailBulkAction, where one stale/mismatched uid among a batch
// selected from the page's own checkboxes should just be skipped, not abort (and
// double-write a response for) the whole request.
func (a *App) messageAccessible(mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
msg, err := a.DB.GetMessageByUID(mailboxID, uid) msg, err := a.DB.GetMessageByUID(mailboxID, uid)
if err != nil || msg == nil || msg.Folder != folder { if err != nil || msg == nil || msg.Folder != folder {
http.NotFound(w, r)
return nil, false return nil, false
} }
return msg, true return msg, true
@@ -334,6 +449,72 @@ func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound) http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
} }
// webmailBulkAction applies one action (delete/move/read/unread) to every uid selected
// via the folder view's checkboxes — the Outlook-style toolbar's bulk equivalent of
// webmailMessageDelete/webmailMessageMove/the auto-mark-read-on-open behavior, all
// through one endpoint rather than four near-identical ones. Every uid is
// independently re-checked against this mailbox+folder (webmailMessageWithAccess) —
// the folder path segment is never trusted as authorization by itself, same as the
// single-message actions.
func (a *App) webmailBulkAction(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
action := r.FormValue("action")
target := strings.TrimSpace(r.FormValue("target_folder"))
if action == "move" && target == "" {
setFlash(w, "error", "Choose a folder to move to")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
n := 0
for _, uidStr := range r.Form["uid"] {
uid := int64(atoi(uidStr))
if uid == 0 {
continue
}
if _, ok := a.messageAccessible(mbox.ID, folder, uid); !ok {
// A mismatched/stale uid here just means stale client state (the page's
// own checkboxes) — skip it, don't hard-fail the whole batch over one bad
// entry the way the single-message actions correctly do for a URL-level uid.
continue
}
var err error
switch action {
case "delete":
if folder == "Trash" {
err = a.Mailstore.DeleteMessage(mbox.ID, uid)
} else {
err = a.DB.MoveMessage(mbox.ID, uid, "Trash")
}
case "move":
err = a.DB.MoveMessage(mbox.ID, uid, target)
case "read":
err = a.DB.SetMessageFlags(mbox.ID, uid, `\Seen`)
case "unread":
err = a.DB.SetMessageFlags(mbox.ID, uid, "")
default:
continue
}
if err != nil {
a.Logger.Error("bulk %s on message %d for mailbox %d: %v", action, uid, mbox.ID, err)
continue
}
n++
}
if n > 0 {
setFlash(w, "success", fmt.Sprintf("%d message(s) updated", n))
} else {
setFlash(w, "error", "No messages were selected")
}
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}
// webmailAttachmentDownload re-decrypts and re-parses the whole message on every // webmailAttachmentDownload re-decrypts and re-parses the whole message on every
// download — there's no separate on-disk attachment cache, and message sizes on a // download — there's no separate on-disk attachment cache, and message sizes on a
// self-hosted mail server are small enough that this is simpler than building one. // self-hosted mail server are small enough that this is simpler than building one.
+153
View File
@@ -0,0 +1,153 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestWebmailMessagePaneMarksReadAndReturnsFragment confirms the Outlook-style reading
// pane's fetch endpoint returns a bare fragment (no dashboard chrome) containing the
// message body, and marks the message read just like opening the full page does.
func TestWebmailMessagePaneMarksReadAndReturnsFragment(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "panetest@example.com", domains[0].ID, "panetest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
uid := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Pane test", "the body of the message")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10)+"/pane", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "the body of the message") {
t.Errorf("expected the message body in the fragment, got:\n%s", body)
}
if strings.Contains(body, "<!DOCTYPE") || strings.Contains(body, "navbar-brand") {
t.Errorf("expected a bare fragment with no page chrome, got:\n%s", body)
}
msg, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil {
t.Fatal(err)
}
if isUnread(msg.Flags) {
t.Error("expected the message marked read after loading it in the pane")
}
}
// TestWebmailBulkActionDeleteAndMarkRead confirms the folder view's bulk toolbar
// (multi-select checkboxes -> POST .../bulk) can delete-to-Trash and mark-read/unread
// several messages in one request.
func TestWebmailBulkActionDeleteAndMarkRead(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "bulktest@example.com", domains[0].ID, "bulktest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
uid1 := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "One", "body1")
uid2 := storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Two", "body2")
uid3 := storeTestMessage(t, app, mailboxID, "INBOX", "c@example.com", "Three", "body3")
post := func(action string, uids ...int64) *httptest.ResponseRecorder {
form := url.Values{"action": {action}}
for _, u := range uids {
form.Add("uid", strconv.FormatInt(u, 10))
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/INBOX/bulk", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
// Mark uid1 and uid2 read in one bulk request.
if rec := post("read", uid1, uid2); rec.Code != http.StatusFound {
t.Fatalf("bulk read: status=%d body=%s", rec.Code, rec.Body.String())
}
for _, uid := range []int64{uid1, uid2} {
msg, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil {
t.Fatal(err)
}
if isUnread(msg.Flags) {
t.Errorf("expected message %d marked read", uid)
}
}
msg3, err := app.DB.GetMessageByUID(mailboxID, uid3)
if err != nil {
t.Fatal(err)
}
if !isUnread(msg3.Flags) {
t.Error("expected message 3 (not in the bulk request) to remain unread")
}
// Bulk-delete uid1 and uid3 (uid2 stays in INBOX).
if rec := post("delete", uid1, uid3); rec.Code != http.StatusFound {
t.Fatalf("bulk delete: status=%d body=%s", rec.Code, rec.Body.String())
}
inbox, err := app.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(inbox) != 1 || inbox[0].ID != uid2 {
t.Fatalf("expected only message 2 left in INBOX, got %+v", inbox)
}
trash, err := app.DB.ListMessagesInFolder(mailboxID, "Trash")
if err != nil {
t.Fatal(err)
}
if len(trash) != 2 {
t.Fatalf("expected 2 messages in Trash, got %d", len(trash))
}
}
// TestWebmailBulkActionSkipsUIDFromAnotherFolder confirms a uid that doesn't actually
// belong to the requested folder is silently skipped rather than aborting the whole
// batch or letting a stale/mismatched selection touch the wrong message.
func TestWebmailBulkActionSkipsUIDFromAnotherFolder(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "bulkskiptest@example.com", domains[0].ID, "bulkskiptest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
inboxUID := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "In inbox", "body")
sentUID := storeTestMessage(t, app, mailboxID, "Sent", "b@example.com", "In sent", "body")
form := url.Values{"action": {"delete"}, "uid": {strconv.FormatInt(inboxUID, 10), strconv.FormatInt(sentUID, 10)}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/INBOX/bulk", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
sent, err := app.DB.ListMessagesInFolder(mailboxID, "Sent")
if err != nil {
t.Fatal(err)
}
if len(sent) != 1 {
t.Fatalf("expected the Sent message untouched (wrong folder for this bulk request), got %d left", len(sent))
}
trash, err := app.DB.ListMessagesInFolder(mailboxID, "Trash")
if err != nil {
t.Fatal(err)
}
if len(trash) != 1 {
t.Fatalf("expected the INBOX message moved to Trash, got %d in Trash", len(trash))
}
}
+216 -4
View File
@@ -80,16 +80,17 @@ func TestWebmailFolderUnreadBadges(t *testing.T) {
return rec.Body.String() return rec.Body.String()
} }
if !strings.Contains(get(), `folder-unread-badge">1<`) { if !strings.Contains(get(), `folder-unread-badge" title="1 total, 1 unread">1 / <strong>1</strong><`) {
t.Fatalf("expected an unread badge showing 1, got: %s", get()) t.Fatalf("expected a badge showing 1 total / 1 unread, got: %s", get())
} }
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil) viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
viewReq.AddCookie(cookie) viewReq.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), viewReq) mux.ServeHTTP(httptest.NewRecorder(), viewReq)
if strings.Contains(get(), `folder-unread-badge">1<`) { // Still 1 total message, but no longer unread — the "/ N unread" part should be gone.
t.Fatal("expected the unread badge gone after reading the message") if !strings.Contains(get(), `folder-unread-badge" title="1 total">1<`) {
t.Fatalf("expected the badge to show just the total (1) with no unread suffix, got: %s", get())
} }
} }
@@ -101,6 +102,9 @@ func TestWebmailFolderGroupsConsecutiveSameSubject(t *testing.T) {
mux := app.Mux() mux := app.Mux()
domains, _ := app.DB.ListDomains() domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "grouper@example.com", domains[0].ID, "grouper-password-1!") mailboxID := createTestMailboxWithPassword(t, app, "grouper@example.com", domains[0].ID, "grouper-password-1!")
if err := app.DB.SetMailboxGroupMessages(mailboxID, true); err != nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, mailboxID) cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1") storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1")
@@ -119,3 +123,211 @@ func TestWebmailFolderGroupsConsecutiveSameSubject(t *testing.T) {
t.Fatal("expected the older grouped row hidden by default via msg-row-older") t.Fatal("expected the older grouped row hidden by default via msg-row-older")
} }
} }
// TestWebmailFolderGroupingOffByDefault confirms grouping is off unless a mailbox
// owner explicitly enables it via Account > Preferences — same three messages as
// TestWebmailFolderGroupsConsecutiveSameSubject, but no toggle call this time.
func TestWebmailFolderGroupingOffByDefault(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "nogroup@example.com", domains[0].ID, "nogroup-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1")
storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Re: Project status", "2")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if strings.Contains(body, "+1 more") {
t.Fatal("expected no grouping by default")
}
// Enabling it via the Account > Preferences form flips the behavior live.
prefReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/preferences", strings.NewReader("group_messages=true"))
prefReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
prefReq.AddCookie(cookie)
prefRec := httptest.NewRecorder()
mux.ServeHTTP(prefRec, prefReq)
if prefRec.Code != http.StatusFound {
t.Fatalf("preferences save: status=%d body=%s", prefRec.Code, prefRec.Body.String())
}
req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req2.AddCookie(cookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if !strings.Contains(rec2.Body.String(), "+1 more") {
t.Fatal("expected grouping enabled after saving the preference")
}
}
// TestWebmailFolderShowsSenderDisplayName confirms the folder list shows just the
// display name from a "Name <addr>" cached_from value, not the raw address string,
// while keeping the full address available via the row's title attribute.
func TestWebmailFolderShowsSenderDisplayName(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "namedisplay@example.com", domains[0].ID, "namedisplay-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "Bob Marley <bob@example.com>", "One love", "body")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, `title="Bob Marley &lt;bob@example.com&gt;"`) {
t.Errorf("expected the full address in the title attribute, got:\n%s", body)
}
if !strings.Contains(body, ">Bob Marley<") {
t.Errorf("expected just the display name shown in the row, got:\n%s", body)
}
}
// TestWebmailFolderUnreadOnlyFilter confirms ?unread=1 hides read messages.
func TestWebmailFolderUnreadOnlyFilter(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "unreadfilter@example.com", domains[0].ID, "unreadfilter-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Unread one", "body")
readUID := storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Already read", "body")
if err := app.DB.SetMessageFlags(mailboxID, readUID, `\Seen`); err != nil {
t.Fatal(err)
}
get := func(path string) string {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec.Body.String()
}
all := get(MailboxPrefix + "/mail/INBOX")
if !strings.Contains(all, "Unread one") || !strings.Contains(all, "Already read") {
t.Fatalf("expected both messages without the filter, got:\n%s", all)
}
unreadOnly := get(MailboxPrefix + "/mail/INBOX?unread=1")
if !strings.Contains(unreadOnly, "Unread one") {
t.Error("expected the unread message still shown")
}
if strings.Contains(unreadOnly, "Already read") {
t.Errorf("expected the read message hidden with ?unread=1, got:\n%s", unreadOnly)
}
}
// TestWebmailFolderSortByFrom confirms ?sort=from&dir=asc orders the list by sender
// instead of the default received-order.
func TestWebmailFolderSortByFrom(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "sortfrom@example.com", domains[0].ID, "sortfrom-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "zzz@example.com", "From Z", "body")
storeTestMessage(t, app, mailboxID, "INBOX", "aaa@example.com", "From A", "body")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX?sort=from&dir=asc", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
idxA := strings.Index(body, "From A")
idxZ := strings.Index(body, "From Z")
if idxA < 0 || idxZ < 0 || idxA > idxZ {
t.Fatalf("expected 'From A' (aaa@) before 'From Z' (zzz@) when sorted by sender ascending, got:\n%s", body)
}
}
// TestWebmailFolderHasCollapsibleSidebarMarkup is a light smoke test for the
// collapsible-sidebar feature's markup/JS anchors — the actual show/hide behavior is
// client-side (localStorage-backed) and not exercisable from a Go test, but a missing
// element ID here would silently break the JS with no visible error.
func TestWebmailFolderHasCollapsibleSidebarMarkup(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "sidebartest@example.com", domains[0].ID, "sidebartest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
for _, id := range []string{`id="folderSidebarCol"`, `id="messageListCol"`, `id="sidebarCollapseBtn"`, `id="sidebarShowBtn"`, "webmail_sidebar_collapsed"} {
if !strings.Contains(body, id) {
t.Errorf("expected %q present in the rendered page", id)
}
}
}
// TestWebmailFolderShowsMessagePreview confirms the folder list shows a short preview
// snippet of the message body under the subject.
func TestWebmailFolderShowsMessagePreview(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "previewtest@example.com", domains[0].ID, "previewtest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Meeting notes", "Here is a summary of what we discussed today in the meeting.")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, "msg-row2") || !strings.Contains(body, "Here is a summary") {
t.Errorf("expected the message preview snippet rendered, got:\n%s", body)
}
}
// TestWebmailRebuildMessageCache confirms the Account > Preferences "Refresh now"
// action re-derives an already-stored message's sender display name from its raw
// content, for mail that predates the fix that started caching it.
func TestWebmailRebuildMessageCache(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "rebuildtest@example.com", domains[0].ID, "rebuildtest-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
// Simulate a stale row: the raw content has the display name, but cached_from was
// stored as the bare address (what pre-fix code would have passed).
raw := "From: Bob Marley <bob@example.com>\r\nTo: rebuildtest@example.com\r\nSubject: One love\r\n\r\nHello there"
if _, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "<one@example.com>", "bob@example.com", "One love"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/rebuild-cache", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("rebuild-cache: status=%d body=%s", rec.Code, rec.Body.String())
}
folderReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
folderReq.AddCookie(cookie)
folderRec := httptest.NewRecorder()
mux.ServeHTTP(folderRec, folderReq)
if !strings.Contains(folderRec.Body.String(), ">Bob Marley<") {
t.Errorf("expected the display name shown after rebuild, got:\n%s", folderRec.Body.String())
}
}
+11 -3
View File
@@ -26,7 +26,8 @@ type App struct {
DB *db.DB DB *db.DB
DKIM *dkim.Manager DKIM *dkim.Manager
Mailstore *mailstore.Store Mailstore *mailstore.Store
ACME *acmecert.Manager ACME *acmecert.Manager // DNS-01
ACMEHTTP *acmecert.Manager // HTTP-01
Relay *relay.Relay // used by the webmail client's compose/send (see webmail_compose.go) Relay *relay.Relay // used by the webmail client's compose/send (see webmail_compose.go)
Cfg *ini.File Cfg *ini.File
ConfigPath string ConfigPath string
@@ -59,10 +60,10 @@ type App struct {
// mailstore's master key is loaded in main.go and threaded in rather than resolved // mailstore's master key is loaded in main.go and threaded in rather than resolved
// internally (both are file paths relative to the app's root working directory, // internally (both are file paths relative to the app's root working directory,
// which this package doesn't otherwise know). // which this package doesn't otherwise know).
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) { func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr, acmeHTTPMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) {
trustedProxies := parseTrustedProxies(cfg.Section("Server").Key("trusted_proxies").MustString(""), logger) trustedProxies := parseTrustedProxies(cfg.Section("Server").Key("trusted_proxies").MustString(""), logger)
a := &App{ a := &App{
DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp, DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, ACMEHTTP: acmeHTTPMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp,
pgpKeys: newPGPKeyCache(), trustedProxies: trustedProxies, loginLimiter: newIPRateLimiter(20, time.Minute), appSecret: appSecret, pgpKeys: newPGPKeyCache(), trustedProxies: trustedProxies, loginLimiter: newIPRateLimiter(20, time.Minute), appSecret: appSecret,
} }
if err := a.loadTemplates(); err != nil { if err := a.loadTemplates(); err != nil {
@@ -139,6 +140,8 @@ func (a *App) Mux() *http.ServeMux {
webmailMux.HandleFunc("GET "+MailboxPrefix+"/account", a.webmailDashboard) webmailMux.HandleFunc("GET "+MailboxPrefix+"/account", a.webmailDashboard)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword) webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/preferences", a.webmailSetGroupMessages)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/rebuild-cache", a.webmailRebuildMessageCache)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin) webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm) webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/disable", a.webmailTOTPDisable) webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/disable", a.webmailTOTPDisable)
@@ -156,8 +159,10 @@ func (a *App) Mux() *http.ServeMux {
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/recipients", a.webmailRecipientSuggest) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/recipients", a.webmailRecipientSuggest)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}", a.webmailFolderView) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}", a.webmailFolderView)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}", a.webmailMessageView) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}", a.webmailMessageView)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/pane", a.webmailMessagePane)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/delete", a.webmailMessageDelete) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/delete", a.webmailMessageDelete)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/bulk", a.webmailBulkAction)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder)
@@ -277,7 +282,10 @@ func (a *App) Mux() *http.ServeMux {
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.requireGlobalAdmin(a.letsEncryptPage)) mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.requireGlobalAdmin(a.letsEncryptPage))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.requireGlobalAdmin(a.letsEncryptSave)) mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.requireGlobalAdmin(a.letsEncryptSave))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.requireGlobalAdmin(a.letsEncryptObtainNow)) mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.requireGlobalAdmin(a.letsEncryptObtainNow))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/http/save", a.requireGlobalAdmin(a.letsEncryptHTTPSave))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/http/obtain", a.requireGlobalAdmin(a.letsEncryptHTTPObtainNow))
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.requireGlobalAdmin(a.uploadGCloudServiceAccount)) mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.requireGlobalAdmin(a.uploadGCloudServiceAccount))
mux.HandleFunc("GET "+Prefix+"/api/letsencrypt/detect_ip", a.requireGlobalAdmin(a.detectWANIP))
mux.HandleFunc("GET "+Prefix+"/logs", a.logs) mux.HandleFunc("GET "+Prefix+"/logs", a.logs)
+7 -2
View File
@@ -117,6 +117,9 @@ func newTestApp(t *testing.T) *App {
tlsSec, _ := cfg.NewSection("TLS") tlsSec, _ := cfg.NewSection("TLS")
tlsSec.NewKey("tls_cert_file", "ssl_certs/server.crt") tlsSec.NewKey("tls_cert_file", "ssl_certs/server.crt")
tlsSec.NewKey("tls_key_file", "ssl_certs/server.key") tlsSec.NewKey("tls_key_file", "ssl_certs/server.key")
tlsSec.NewKey("smtp_tls_cert", "custom")
tlsSec.NewKey("imap_tls_cert", "custom")
tlsSec.NewKey("web_https_cert", "custom")
dkimSec, _ := cfg.NewSection("DKIM") dkimSec, _ := cfg.NewSection("DKIM")
dkimSec.NewKey("dkim_key_size", "2048") dkimSec.NewKey("dkim_key_size", "2048")
dkimSec.NewKey("spf_server_ip", "192.168.1.1") dkimSec.NewKey("spf_server_ip", "192.168.1.1")
@@ -129,13 +132,15 @@ func newTestApp(t *testing.T) *App {
configPath := filepath.Join(dir, "settings.ini") configPath := filepath.Join(dir, "settings.ini")
cfg.SaveTo(configPath) cfg.SaveTo(configPath)
acmeMgr := acmecert.New(cfg, filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test")) acmeMgr := acmecert.New(cfg, "LetsEncrypt", "dns-01", filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
acmeHTTPMgr := acmecert.New(cfg, "LetsEncryptHTTP", "http-01", filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
relayer := relay.New(database, cfg, toolbox.GetLogger("test")) relayer := relay.New(database, cfg, toolbox.GetLogger("test"))
relayer.Mailstore = mstore
appSecret, err := LoadOrCreateAppSecret(filepath.Join(dir, "app_secret.key")) appSecret, err := LoadOrCreateAppSecret(filepath.Join(dir, "app_secret.key"))
if err != nil { if err != nil {
t.Fatalf("LoadOrCreateAppSecret: %v", err) t.Fatalf("LoadOrCreateAppSecret: %v", err)
} }
app, err := New(database, dkimMgr, mstore, acmeMgr, relayer, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true }, appSecret) app, err := New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true }, appSecret)
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
+81 -29
View File
@@ -109,25 +109,74 @@ func main() {
os.Exit(1) os.Exit(1)
} }
mstore := mailstore.New(database, masterKey, mailstoreBase) mstore := mailstore.New(database, masterKey, mailstoreBase)
relayer.Mailstore = mstore
// Shared by both the SMTP and IMAP implicit-TLS listeners, so a single Reload() // Three independent certificate "slots" — custom (self-signed by default, or your
// call (self-signed regeneration today; a Let's Encrypt renewal later) updates // own uploaded cert/key), letsencrypt_dns, letsencrypt_http — each with its own
// both without restarting the process. // file pair and CertReloader. [TLS]'s smtp_tls_cert/imap_tls_cert/web_https_cert
certFile := absPath(root, cfg.Section("TLS").Key("TLS_CERT_FILE").String()) // independently pick which slot each listener uses, e.g. an HTTP-01 cert for mail
keyFile := absPath(root, cfg.Section("TLS").Key("TLS_KEY_FILE").String()) // while a DNS-01 cert serves the dashboard. Every slot is seeded with a self-signed
if err := tlsutil.GenerateSelfSignedCert(certFile, keyFile); err != nil { // cert if missing, so a reloader can always be constructed even before any ACME
logger.Error("generate TLS certificate: %v", err) // manager has obtained anything yet.
os.Exit(1) customCertFile := absPath(root, cfg.Section("TLS").Key("TLS_CERT_FILE").String())
customKeyFile := absPath(root, cfg.Section("TLS").Key("TLS_KEY_FILE").String())
dnsCertFile := absPath(root, "ssl_certs/letsencrypt_dns.crt")
dnsKeyFile := absPath(root, "ssl_certs/letsencrypt_dns.key")
httpCertFile := absPath(root, "ssl_certs/letsencrypt_http.crt")
httpKeyFile := absPath(root, "ssl_certs/letsencrypt_http.key")
for _, pair := range [][2]string{
{customCertFile, customKeyFile}, {dnsCertFile, dnsKeyFile}, {httpCertFile, httpKeyFile},
} {
if err := tlsutil.GenerateSelfSignedCert(pair[0], pair[1]); err != nil {
logger.Error("generate TLS certificate: %v", err)
os.Exit(1)
}
} }
certReloader, err := tlsutil.NewCertReloader(certFile, keyFile) customReloader, err := tlsutil.NewCertReloader(customCertFile, customKeyFile)
if err != nil { if err != nil {
logger.Error("load TLS certificate: %v", err) logger.Error("load TLS certificate: %v", err)
os.Exit(1) os.Exit(1)
} }
tlsConfig := tlsutil.NewReloadableTLSConfig(certReloader) dnsReloader, err := tlsutil.NewCertReloader(dnsCertFile, dnsKeyFile)
if err != nil {
logger.Error("load TLS certificate: %v", err)
os.Exit(1)
}
httpReloader, err := tlsutil.NewCertReloader(httpCertFile, httpKeyFile)
if err != nil {
logger.Error("load TLS certificate: %v", err)
os.Exit(1)
}
certSlots := map[string]*tlsutil.CertReloader{
"custom": customReloader, "letsencrypt_dns": dnsReloader, "letsencrypt_http": httpReloader,
}
resolveCertSlot := func(key string) *tlsutil.CertReloader {
if r, ok := certSlots[cfg.Section("TLS").Key(key).MustString("custom")]; ok {
return r
}
return customReloader
}
smtpTLSConfig := tlsutil.NewReloadableTLSConfig(resolveCertSlot("smtp_tls_cert"))
imapTLSConfig := tlsutil.NewReloadableTLSConfig(resolveCertSlot("imap_tls_cert"))
webHTTPSConfig := tlsutil.NewReloadableTLSConfig(resolveCertSlot("web_https_cert"))
acmeDataDir := absPath(root, "server_data/acme") acmeDataDir := absPath(root, "server_data/acme")
acmeMgr := acmecert.New(cfg, certFile, keyFile, acmeDataDir, certReloader, toolbox.GetLogger("acme")) acmeMgr := acmecert.New(cfg, "LetsEncrypt", "dns-01", dnsCertFile, dnsKeyFile, acmeDataDir, dnsReloader, toolbox.GetLogger("acme"))
acmeHTTPMgr := acmecert.New(cfg, "LetsEncryptHTTP", "http-01", httpCertFile, httpKeyFile, acmeDataDir, httpReloader, toolbox.GetLogger("acme-http"))
// The HTTP-01 challenge responder is long-lived (unlike lego's own ephemeral
// per-obtain listener) so an operator behind NAT/a reverse proxy can verify their
// port-forwarding actually reaches this host before/without triggering a real,
// rate-limited ACME attempt — curling it should return 200 once this is up.
// Started once at boot if enabled; toggling [LetsEncryptHTTP] enabled needs a
// restart to start or stop this listener, same as the *_cert routing settings.
if cfg.Section("LetsEncryptHTTP").Key("enabled").MustBool(false) {
httpChallengeServer := acmecert.NewHTTP01Server()
httpPort := cfg.Section("Server").Key("HTTP_LETSENCRYPT_PORT").MustString("80")
httpChallengeServer.Start(fmt.Sprintf(":%s", httpPort), toolbox.GetLogger("acme-http"))
acmeHTTPMgr.HTTP01Server = httpChallengeServer
logger.Info("HTTP-01 challenge responder listening on :%s", httpPort)
}
backend := &smtpserver.Backend{ backend := &smtpserver.Backend{
DB: database, DKIM: dkimMgr, Relay: relayer, Cfg: cfg, Mailstore: mstore, DB: database, DKIM: dkimMgr, Relay: relayer, Cfg: cfg, Mailstore: mstore,
@@ -147,7 +196,7 @@ func main() {
// setting) — a bare ":port" address binds dual-stack on most systems, // setting) — a bare ":port" address binds dual-stack on most systems,
// which would accept IPv6 connections the Python version never did. // which would accept IPv6 connections the Python version never did.
plainServer := smtpserver.NewPlainServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpPort), banner) plainServer := smtpserver.NewPlainServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpPort), banner)
tlsServer := smtpserver.NewTLSServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpTLSPort), banner, tlsConfig) tlsServer := smtpserver.NewTLSServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpTLSPort), banner, smtpTLSConfig)
smtpRunning.Store(true) smtpRunning.Store(true)
logger.Info("Plain SMTP listening on :%d, direct-TLS SMTP listening on :%d", smtpPort, smtpTLSPort) logger.Info("Plain SMTP listening on :%d, direct-TLS SMTP listening on :%d", smtpPort, smtpTLSPort)
@@ -164,7 +213,7 @@ func main() {
logger.Error("plain SMTP server: %v", err) logger.Error("plain SMTP server: %v", err)
} }
}() }()
tlsListener, err := tls.Listen("tcp", tlsServer.Addr, tlsConfig) tlsListener, err := tls.Listen("tcp", tlsServer.Addr, smtpTLSConfig)
if err != nil { if err != nil {
logger.Error("TLS SMTP listen: %v", err) logger.Error("TLS SMTP listen: %v", err)
return return
@@ -179,7 +228,7 @@ func main() {
imapTLSPort := cfg.Section("IMAP").Key("IMAP_TLS_PORT").MustInt(993) imapTLSPort := cfg.Section("IMAP").Key("IMAP_TLS_PORT").MustInt(993)
plainServer := imapserver.NewPlainServer(imapBackend) plainServer := imapserver.NewPlainServer(imapBackend)
tlsServer := imapserver.NewTLSServer(imapBackend, tlsConfig) tlsServer := imapserver.NewTLSServer(imapBackend, imapTLSConfig)
logger.Info("Plain IMAP listening on :%d, direct-TLS IMAP listening on :%d", imapPort, imapTLSPort) logger.Info("Plain IMAP listening on :%d, direct-TLS IMAP listening on :%d", imapPort, imapTLSPort)
@@ -195,7 +244,7 @@ func main() {
logger.Error("plain IMAP server: %v", err) logger.Error("plain IMAP server: %v", err)
} }
}() }()
imapTLSListener, err := tls.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", imapTLSPort), tlsConfig) imapTLSListener, err := tls.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", imapTLSPort), imapTLSConfig)
if err != nil { if err != nil {
logger.Error("TLS IMAP listen: %v", err) logger.Error("TLS IMAP listen: %v", err)
return return
@@ -207,25 +256,28 @@ func main() {
// runCertRenewal is the first periodic/background job in this codebase — everything // runCertRenewal is the first periodic/background job in this codebase — everything
// else is purely request-driven. Checks soon after boot (so enabling Let's Encrypt // else is purely request-driven. Checks soon after boot (so enabling Let's Encrypt
// and restarting converges quickly) and every 12h thereafter. The first check each // and restarting converges quickly) and every 12h thereafter. NeedsRenewal is a pure
// process run always attempts ObtainOrRenew regardless of NeedsRenewal's expiry // disk-state check (it reads the cert file itself, including recognizing the
// check, since a fresh self-signed cert has ~1 year left and would otherwise never // self-signed placeholder by its issuer) — deliberately not gated on any in-memory
// get replaced by the very first real certificate. // "has this process attempted yet" flag, so restarting an already-working setup
// never triggers a redundant re-obtain of an already-valid real certificate.
runCertRenewal := func() { runCertRenewal := func() {
time.Sleep(1 * time.Minute) time.Sleep(1 * time.Minute)
checkAndRenew := func() { checkAndRenewOne := func(mgr *acmecert.Manager) {
if !cfg.Section("LetsEncrypt").Key("enabled").MustBool(false) { if !mgr.Enabled() {
return return
} }
if !acmeMgr.Status().LastAttempt.IsZero() { if needs, err := mgr.NeedsRenewal(); err != nil || !needs {
if needs, err := acmeMgr.NeedsRenewal(); err != nil || !needs { return
return
}
} }
if err := acmeMgr.ObtainOrRenew(context.Background()); err != nil { if err := mgr.ObtainOrRenew(context.Background()); err != nil {
logger.Error("ACME obtain/renew: %v", err) logger.Error("ACME obtain/renew (%s): %v", mgr.Section, err)
} }
} }
checkAndRenew := func() {
checkAndRenewOne(acmeMgr)
checkAndRenewOne(acmeHTTPMgr)
}
checkAndRenew() checkAndRenew()
ticker := time.NewTicker(12 * time.Hour) ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
@@ -248,7 +300,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
app, err := webui.New(database, dkimMgr, mstore, acmeMgr, relayer, cfg, configPath, toolbox.GetLogger("web"), smtpRunning.Load, appSecret) app, err := webui.New(database, dkimMgr, mstore, acmeMgr, acmeHTTPMgr, relayer, cfg, configPath, toolbox.GetLogger("web"), smtpRunning.Load, appSecret)
if err != nil { if err != nil {
logger.Error("init web UI: %v", err) logger.Error("init web UI: %v", err)
os.Exit(1) os.Exit(1)
@@ -280,7 +332,7 @@ func main() {
httpsPort := cfg.Section("Server").Key("WEB_HTTPS_PORT").MustInt(5001) httpsPort := cfg.Section("Server").Key("WEB_HTTPS_PORT").MustInt(5001)
httpsAddr := fmt.Sprintf("%s:%d", *host, httpsPort) httpsAddr := fmt.Sprintf("%s:%d", *host, httpsPort)
httpsServer := &http.Server{Addr: httpsAddr, Handler: handler, TLSConfig: tlsConfig} httpsServer := &http.Server{Addr: httpsAddr, Handler: handler, TLSConfig: webHTTPSConfig}
go func() { go func() {
logger.Info("Web interface (HTTPS) starting at https://%s", httpsAddr) logger.Info("Web interface (HTTPS) starting at https://%s", httpsAddr)
// Empty cert/key paths: TLSConfig.GetCertificate (backed by certReloader) supplies // Empty cert/key paths: TLSConfig.GetCertificate (backed by certReloader) supplies