MFA fix, added IP blacklist, update webmail client

This commit is contained in:
2026-08-14 13:04:55 +01:00
parent 6063f95504
commit 892f366a16
122 changed files with 13362 additions and 251 deletions
+7
View File
@@ -15,6 +15,7 @@ require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
@@ -30,9 +31,11 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
github.com/aws/smithy-go v1.25.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/boombuler/barcode v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
@@ -51,7 +54,9 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
@@ -59,6 +64,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mozilla.org/pkcs7 v0.10.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
@@ -78,4 +84,5 @@ require (
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
software.sslmate.com/src/go-pkcs12 v0.7.3 // indirect
)
+14
View File
@@ -4,6 +4,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
@@ -34,6 +36,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcu
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
@@ -42,6 +46,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -93,10 +99,14 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -125,6 +135,8 @@ github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77ro
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg=
go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
@@ -230,3 +242,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+96
View File
@@ -0,0 +1,96 @@
// Package abuseguard automatically blacklists IPs that rack up too many failed
// SMTP/IMAP auth attempts, and rejects connections from already-blacklisted IPs before
// the SMTP/IMAP banner is ever sent. Deliberately separate from the web admin/webmail
// login lockout (internal/webui/ratelimit.go) and from the relay-authorization
// whitelist (esrv_whitelisted_ips) — see the [Security] section of settings.ini.
package abuseguard
import (
"net"
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
"mailgoserver/internal/toolbox"
)
// RecordFailureAndMaybeBlacklist should be called after every failed SMTP AUTH or IMAP
// login. It counts recent failures from ip and blacklists it once the configured
// threshold is hit. Fails open (does nothing) on a DB error rather than blocking auth
// over a transient issue.
func RecordFailureAndMaybeBlacklist(database *db.DB, cfg *ini.File, logger *toolbox.Logger, ip string) {
if ip == "" || cfg == nil {
return
}
sec := cfg.Section("Security")
if !sec.Key("abuse_detection_enabled").MustBool(true) {
return
}
if whitelisted, err := database.IsIPAbuseWhitelisted(ip); err != nil || whitelisted {
return
}
threshold := sec.Key("abuse_failure_threshold").MustInt(8)
windowMinutes := sec.Key("abuse_detection_window_minutes").MustInt(10)
since := time.Now().Add(-time.Duration(windowMinutes) * time.Minute)
n, err := database.CountFailedAuthAttemptsByIP(ip, since)
if err != nil || n < threshold {
return
}
baseHours := sec.Key("abuse_blacklist_base_hours").MustInt(12)
maxHours := sec.Key("abuse_blacklist_max_hours").MustInt(168)
reason := "automatic: too many failed SMTP/IMAP auth attempts"
if err := database.BlacklistIP(ip, reason, baseHours, maxHours); err != nil && logger != nil {
logger.Error("abuseguard: failed to blacklist %s: %v", ip, err)
return
}
if logger != nil {
logger.Warning("abuseguard: blacklisted %s after %d failed attempts in %dm", ip, n, windowMinutes)
}
}
// guardedListener wraps a net.Listener so Accept() silently drops connections from
// blacklisted IPs (never returning them to the caller) before any protocol banner is
// written, and keeps looping rather than returning an error.
type guardedListener struct {
net.Listener
database *db.DB
logger *toolbox.Logger
}
// GuardListener wraps inner so every accepted connection is checked against the IP
// blacklist (skipping the check entirely for abuse-whitelisted IPs) before the caller
// ever sees it.
func GuardListener(inner net.Listener, database *db.DB, logger *toolbox.Logger) net.Listener {
return &guardedListener{Listener: inner, database: database, logger: logger}
}
func (g *guardedListener) Accept() (net.Conn, error) {
for {
conn, err := g.Listener.Accept()
if err != nil {
return nil, err
}
host, _, splitErr := net.SplitHostPort(conn.RemoteAddr().String())
if splitErr != nil {
host = conn.RemoteAddr().String()
}
if whitelisted, wErr := g.database.IsIPAbuseWhitelisted(host); wErr == nil && whitelisted {
return conn, nil
}
blocked, bErr := g.database.IsIPBlacklisted(host)
if bErr != nil {
return conn, nil // fail open on a DB error
}
if !blocked {
return conn, nil
}
if g.logger != nil {
g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", host)
}
conn.Close()
}
}
+146
View File
@@ -0,0 +1,146 @@
package abuseguard
import (
"net"
"path/filepath"
"strconv"
"testing"
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
)
func testCfg(t *testing.T, threshold int) *ini.File {
t.Helper()
cfg := ini.Empty()
sec, _ := cfg.NewSection("Security")
sec.NewKey("abuse_detection_enabled", "true")
sec.NewKey("abuse_failure_threshold", strconv.Itoa(threshold))
sec.NewKey("abuse_detection_window_minutes", "10")
sec.NewKey("abuse_blacklist_base_hours", "12")
sec.NewKey("abuse_blacklist_max_hours", "168")
return cfg
}
func openTestDB(t *testing.T) *db.DB {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
return database
}
func TestRecordFailureAndMaybeBlacklistTripsThreshold(t *testing.T) {
database := openTestDB(t)
cfg := testCfg(t, 3)
const ip = "203.0.113.50"
for i := 0; i < 2; i++ {
database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password")
RecordFailureAndMaybeBlacklist(database, cfg, nil, ip)
}
if blocked, _ := database.IsIPBlacklisted(ip); blocked {
t.Fatal("should not be blacklisted before threshold")
}
database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password")
RecordFailureAndMaybeBlacklist(database, cfg, nil, ip)
blocked, err := database.IsIPBlacklisted(ip)
if err != nil {
t.Fatal(err)
}
if !blocked {
t.Fatal("expected IP to be blacklisted after hitting the threshold")
}
}
func TestRecordFailureAndMaybeBlacklistSkipsWhitelisted(t *testing.T) {
database := openTestDB(t)
cfg := testCfg(t, 2)
const ip = "203.0.113.51"
if err := database.AddAbuseWhitelist(ip, "trusted"); err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password")
RecordFailureAndMaybeBlacklist(database, cfg, nil, ip)
}
if blocked, _ := database.IsIPBlacklisted(ip); blocked {
t.Fatal("whitelisted IP should never be blacklisted")
}
}
// fakeListener yields exactly one already-open in-memory connection pair, then EOF-like
// closed errors, letting GuardListener's Accept loop be tested without real sockets.
type fakeListener struct {
conns chan net.Conn
done chan struct{}
}
func newFakeListener(conns ...net.Conn) *fakeListener {
ch := make(chan net.Conn, len(conns))
for _, c := range conns {
ch <- c
}
return &fakeListener{conns: ch, done: make(chan struct{})}
}
func (f *fakeListener) Accept() (net.Conn, error) {
select {
case c := <-f.conns:
return c, nil
case <-f.done:
return nil, net.ErrClosed
}
}
func (f *fakeListener) Close() error { close(f.done); return nil }
func (f *fakeListener) Addr() net.Addr { return dummyAddr{} }
type dummyAddr struct{}
func (dummyAddr) Network() string { return "tcp" }
func (dummyAddr) String() string { return "0.0.0.0:0" }
func TestGuardListenerRejectsBlacklistedIP(t *testing.T) {
database := openTestDB(t)
const blockedIP = "198.51.100.77"
if err := database.AddManualBlacklistEntry(blockedIP, "test", 1); err != nil {
t.Fatal(err)
}
blockedConn, blockedPeer := net.Pipe()
defer blockedPeer.Close()
inner := newFakeListener(&addrOverrideConn{Conn: blockedConn, remote: hostPortAddr(blockedIP)})
guarded := GuardListener(inner, database, nil)
go func() {
guarded.Accept()
inner.Close()
}()
// The blocked connection's peer end should observe the connection close rather
// than any protocol banner, since GuardListener closes it before returning it.
buf := make([]byte, 1)
if _, err := blockedPeer.Read(buf); err == nil {
t.Fatal("expected blocked connection to be closed by GuardListener, got readable data instead")
}
}
type addrOverrideConn struct {
net.Conn
remote net.Addr
}
func (c *addrOverrideConn) RemoteAddr() net.Addr { return c.remote }
type hostPortAddr string
func (hostPortAddr) Network() string { return "tcp" }
func (a hostPortAddr) String() string { return string(a) + ":12345" }
+21
View File
@@ -42,6 +42,10 @@ var defaults = []struct {
{"server_banner", "", ""},
{"", "", "Time zone for the server"},
{"TIME_ZONE", "Europe/London", ""},
{"", "", `Comma-separated CIDRs and/or the word "cloudflare" -- only these peers'`},
{"", "", "X-Forwarded-For/X-Real-IP/CF-Connecting-IP headers are trusted for the real"},
{"", "", "client IP (audit logs, lockouts). Leave blank if nothing proxies this server."},
{"trusted_proxies", "", ""},
}},
{"Database", []defaultKV{
{"", "", "Database configuration"},
@@ -87,6 +91,23 @@ var defaults = []struct {
{"", "", "Require every mailbox's self-service webmail login to have TOTP/passkey MFA"},
{"", "", "(overridable per-domain or per-mailbox — see the Domains/Mailboxes edit pages)"},
{"enforce_mailbox_mfa", "false", ""},
{"", "", "Failed login attempts (for one account, from any IP) allowed within the window below"},
{"", "", "before that account is temporarily locked out, independent of the per-IP rate limit"},
{"login_attempt_limit", "8", ""},
{"login_attempt_window_minutes", "15", ""},
}},
{"Security", []defaultKV{
{"", "", "Generated automatically on first run if this file doesn't exist yet — used to sign"},
{"", "", "CSRF tokens. Back this up like any other secret; don't share it or commit it."},
{"app_secret_path", "server_data/app_secret.key", ""},
{"", "", "Automatically block an IP for a while after too many failed SMTP/IMAP login"},
{"", "", "attempts within the window below (see the Blacklist page in the admin dashboard)"},
{"abuse_detection_enabled", "true", ""},
{"abuse_failure_threshold", "8", ""},
{"abuse_detection_window_minutes", "10", ""},
{"", "", "First-offense block duration; doubles on each repeat offense up to the cap below"},
{"abuse_blacklist_base_hours", "12", ""},
{"abuse_blacklist_max_hours", "168", ""},
}},
{"IMAP", []defaultKV{
{"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"},
+184
View File
@@ -0,0 +1,184 @@
package db
import (
"database/sql"
"errors"
"time"
)
// IPBlacklistEntry is a temporary (or manual) block on SMTP/IMAP traffic from one IP.
// Deliberately separate from WhitelistedIP (esrv_whitelisted_ips), which authorizes
// unauthenticated relay for a domain — a completely different concern.
type IPBlacklistEntry struct {
ID int64
IPAddress string
Reason string
OffenseCount int
Manual bool
BlacklistedAt time.Time
ExpiresAt time.Time
}
// IPAbuseWhitelistEntry exempts one IP from abuse detection entirely.
type IPAbuseWhitelistEntry struct {
ID int64
IPAddress string
Note string
CreatedAt time.Time
}
// smtpImapAuthTypes are the esrv_auth_logs auth_type values that count toward abuse
// detection: SMTP/IMAP traffic, not admin/webmail dashboard logins (those already have
// their own lockout in internal/webui/ratelimit.go).
const smtpImapAuthTypesSQL = `auth_type IN ('sender', 'mailbox', 'sender_validation', 'mailbox_validation', 'ip', 'imap_login')`
// CountFailedAuthAttemptsByIP mirrors CountRecentFailedAttempts (queries.go:140) exactly,
// including its documented since.UTC().Format("2006-01-02 15:04:05") requirement, but
// scoped by ip_address across all SMTP/IMAP auth types instead of by identifier+one type.
func (d *DB) CountFailedAuthAttemptsByIP(ip string, since time.Time) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs
WHERE ip_address = ? AND success = 0 AND created_at >= ? AND `+smtpImapAuthTypesSQL,
ip, since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
return n, err
}
// IsIPBlacklisted reports whether ip has a currently-active (unexpired) blacklist entry.
func (d *DB) IsIPBlacklisted(ip string) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_blacklist WHERE ip_address = ? AND expires_at > ?`,
ip, time.Now().UTC().Format("2006-01-02 15:04:05")).Scan(&n)
return n > 0, err
}
// IsIPAbuseWhitelisted reports whether ip is exempt from abuse detection.
func (d *DB) IsIPAbuseWhitelisted(ip string) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_abuse_whitelist WHERE ip_address = ?`, ip).Scan(&n)
return n > 0, err
}
// BlacklistIP upserts ip's blacklist entry, escalating the block duration on repeat
// offenses: duration = min(baseHours * 2^(offenseCount-1), maxHours), where offenseCount
// is incremented on every call regardless of whether the previous entry had expired.
func (d *DB) BlacklistIP(ip, reason string, baseHours, maxHours int) error {
var existingCount int
err := d.QueryRow(`SELECT offense_count FROM esrv_ip_blacklist WHERE ip_address = ?`, ip).Scan(&existingCount)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
newCount := existingCount + 1
hours := baseHours
for i := 1; i < newCount; i++ {
hours *= 2
if hours >= maxHours {
hours = maxHours
break
}
}
if hours > maxHours {
hours = maxHours
}
expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05")
_, err = d.Exec(`INSERT INTO esrv_ip_blacklist (ip_address, reason, offense_count, manual, blacklisted_at, expires_at)
VALUES (?, ?, ?, 0, CURRENT_TIMESTAMP, ?)
ON CONFLICT(ip_address) DO UPDATE SET
reason = excluded.reason,
offense_count = excluded.offense_count,
manual = 0,
blacklisted_at = CURRENT_TIMESTAMP,
expires_at = excluded.expires_at`,
ip, reason, newCount, expiresAt)
return err
}
// AddManualBlacklistEntry is an admin-initiated block: fixed duration, no escalation math.
func (d *DB) AddManualBlacklistEntry(ip, reason string, hours int) error {
expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05")
_, err := d.Exec(`INSERT INTO esrv_ip_blacklist (ip_address, reason, offense_count, manual, blacklisted_at, expires_at)
VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, ?)
ON CONFLICT(ip_address) DO UPDATE SET
reason = excluded.reason,
manual = 1,
blacklisted_at = CURRENT_TIMESTAMP,
expires_at = excluded.expires_at`,
ip, reason, expiresAt)
return err
}
func (d *DB) ListBlacklist() ([]IPBlacklistEntry, error) {
rows, err := d.Query(`SELECT id, ip_address, reason, offense_count, manual, blacklisted_at, expires_at
FROM esrv_ip_blacklist ORDER BY blacklisted_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []IPBlacklistEntry
for rows.Next() {
var e IPBlacklistEntry
var blacklistedAt, expiresAt string
if err := rows.Scan(&e.ID, &e.IPAddress, &e.Reason, &e.OffenseCount, &e.Manual, &blacklistedAt, &expiresAt); err != nil {
return nil, err
}
e.BlacklistedAt, _ = parseTime(blacklistedAt)
e.ExpiresAt, _ = parseTime(expiresAt)
out = append(out, e)
}
return out, rows.Err()
}
func (d *DB) RemoveBlacklistEntry(id int64) error {
_, err := d.Exec(`DELETE FROM esrv_ip_blacklist WHERE id = ?`, id)
return err
}
func (d *DB) ListAbuseWhitelist() ([]IPAbuseWhitelistEntry, error) {
rows, err := d.Query(`SELECT id, ip_address, note, created_at FROM esrv_ip_abuse_whitelist ORDER BY ip_address`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []IPAbuseWhitelistEntry
for rows.Next() {
var e IPAbuseWhitelistEntry
var createdAt string
if err := rows.Scan(&e.ID, &e.IPAddress, &e.Note, &createdAt); err != nil {
return nil, err
}
e.CreatedAt, _ = parseTime(createdAt)
out = append(out, e)
}
return out, rows.Err()
}
func (d *DB) AddAbuseWhitelist(ip, note string) error {
_, err := d.Exec(`INSERT INTO esrv_ip_abuse_whitelist (ip_address, note) VALUES (?, ?)
ON CONFLICT(ip_address) DO UPDATE SET note = excluded.note`, ip, note)
return err
}
func (d *DB) RemoveAbuseWhitelist(id int64) error {
_, err := d.Exec(`DELETE FROM esrv_ip_abuse_whitelist WHERE id = ?`, id)
return err
}
// CountBlacklistEventsSince counts blacklist entries (auto or manual) created since the
// given cutoff, for the dashboard's attack-count tiles.
func (d *DB) CountBlacklistEventsSince(since time.Time) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_blacklist WHERE blacklisted_at >= ?`,
since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
return n, err
}
// CountFailedAuthSince counts failed SMTP/IMAP auth attempts (any IP) since the given
// cutoff, for the dashboard's attack-count tiles.
func (d *DB) CountFailedAuthSince(since time.Time) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs
WHERE success = 0 AND created_at >= ? AND `+smtpImapAuthTypesSQL,
since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
return n, err
}
+99
View File
@@ -0,0 +1,99 @@
package db
import (
"path/filepath"
"testing"
"time"
)
func openTestDB(t *testing.T) *DB {
t.Helper()
database, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
return database
}
// TestBlacklistIPEscalation confirms repeat offenses double the block duration up to the cap.
func TestBlacklistIPEscalation(t *testing.T) {
d := openTestDB(t)
const ip = "203.0.113.7"
wantHours := []int{12, 24, 48, 96, 168, 168} // caps at 168 (7 days)
for i, want := range wantHours {
if err := d.BlacklistIP(ip, "test", 12, 168); err != nil {
t.Fatalf("offense %d: %v", i+1, err)
}
list, err := d.ListBlacklist()
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("offense %d: expected 1 entry, got %d", i+1, len(list))
}
e := list[0]
if e.OffenseCount != i+1 {
t.Errorf("offense %d: OffenseCount = %d, want %d", i+1, e.OffenseCount, i+1)
}
gotHours := e.ExpiresAt.Sub(e.BlacklistedAt).Hours()
if diff := gotHours - float64(want); diff < -1 || diff > 1 {
t.Errorf("offense %d: duration = %.1fh, want ~%dh", i+1, gotHours, want)
}
}
}
// TestCountFailedAuthAttemptsByIPMatchesCurrentTimestamp guards against the exact
// SQLite time.Time/CURRENT_TIMESTAMP format mismatch already found once in
// CountRecentFailedAttempts: a row inserted via CURRENT_TIMESTAMP must be found by a
// since-cutoff comparison using a Go-side time.Time a moment earlier.
func TestCountFailedAuthAttemptsByIPMatchesCurrentTimestamp(t *testing.T) {
d := openTestDB(t)
const ip = "198.51.100.9"
since := time.Now().Add(-1 * time.Minute)
if err := d.LogAuthAttempt("sender", "someone@example.com", ip, false, "bad password"); err != nil {
t.Fatal(err)
}
n, err := d.CountFailedAuthAttemptsByIP(ip, since)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("CountFailedAuthAttemptsByIP = %d, want 1 (CURRENT_TIMESTAMP/time.Time format mismatch?)", n)
}
blacklisted, err := d.IsIPBlacklisted(ip)
if err != nil {
t.Fatal(err)
}
if blacklisted {
t.Fatal("IP should not be blacklisted yet")
}
}
func TestIPAbuseWhitelist(t *testing.T) {
d := openTestDB(t)
const ip = "192.0.2.55"
whitelisted, err := d.IsIPAbuseWhitelisted(ip)
if err != nil {
t.Fatal(err)
}
if whitelisted {
t.Fatal("should not be whitelisted before AddAbuseWhitelist")
}
if err := d.AddAbuseWhitelist(ip, "trusted scanner"); err != nil {
t.Fatal(err)
}
whitelisted, err = d.IsIPAbuseWhitelisted(ip)
if err != nil {
t.Fatal(err)
}
if !whitelisted {
t.Fatal("should be whitelisted after AddAbuseWhitelist")
}
}
+45
View File
@@ -0,0 +1,45 @@
package db
// CreateMailboxFolder records a custom folder's existence even before it holds any
// messages — idempotent (a folder a filter rule already delivered into can be
// explicitly created too, without erroring on the duplicate).
func (d *DB) CreateMailboxFolder(mailboxID int64, name string) error {
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_folders (mailbox_id, name) VALUES (?, ?)`, mailboxID, name)
return err
}
// DeleteMailboxFolder removes a custom folder's record. Callers are responsible for
// relocating any messages still in it first (see MoveAllMessagesInFolder) — this
// alone doesn't touch esrv_mailbox_messages.
func (d *DB) DeleteMailboxFolder(mailboxID int64, name string) error {
_, err := d.Exec(`DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ? AND name = ?`, mailboxID, name)
return err
}
// ListMailboxFolders returns a mailbox's explicitly-created custom folders — combine
// with DistinctFoldersForMailbox (message-derived) for the full folder list, since a
// folder can exist via either path (or both).
func (d *DB) ListMailboxFolders(mailboxID int64) ([]string, error) {
rows, err := d.Query(`SELECT name FROM esrv_mailbox_folders WHERE mailbox_id = ? ORDER BY name`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
// MoveAllMessagesInFolder reassigns every message in one folder to another — used
// when deleting a custom folder, so its messages land in INBOX instead of becoming
// orphaned in a folder nothing lists anymore.
func (d *DB) MoveAllMessagesInFolder(mailboxID int64, from, to string) error {
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE mailbox_id = ? AND folder = ?`, to, mailboxID, from)
return err
}
+159 -34
View File
@@ -3,36 +3,47 @@ package db
import (
"database/sql"
"errors"
"strings"
"time"
)
// InsertMessage records a stored message's index row (the ciphertext itself already
// lives at storagePath — see internal/mailstore). Returns the new row's id, which
// doubles as the IMAP UID in later milestones.
func (d *DB) InsertMessage(mailboxID int64, folder, messageIDHeader, flags string, internalDate time.Time, sizeBytes int64, storagePath string, nonce []byte, cachedFrom, 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 string) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_mailbox_messages
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedSubject)
(mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, storage_path, nonce, cached_from, cached_to, cached_subject)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
mailboxID, folder, messageIDHeader, flags, internalDate, sizeBytes, storagePath, nonce, cachedFrom, cachedTo, cachedSubject)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (d *DB) GetMessageByUID(mailboxID, uid int64) (*MailboxMessage, error) {
row := d.QueryRow(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at
FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID)
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`
func scanMailboxMessage(scan func(dest ...any) error) (MailboxMessage, error) {
var m MailboxMessage
var internalDate, createdAt string
if err := row.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil {
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)
if err != nil {
return m, err
}
m.InternalDate, _ = parseTime(internalDate)
m.CreatedAt, _ = parseTime(createdAt)
return m, nil
}
func (d *DB) GetMessageByUID(mailboxID, uid int64) (*MailboxMessage, error) {
row := d.QueryRow(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE id = ? AND mailbox_id = ?`, uid, mailboxID)
m, err := scanMailboxMessage(row.Scan)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
m.InternalDate, _ = parseTime(internalDate)
m.CreatedAt, _ = parseTime(createdAt)
return &m, nil
}
@@ -41,6 +52,13 @@ func (d *DB) DeleteMessage(mailboxID, uid int64) error {
return err
}
// MoveMessage reassigns a message to a different folder — pure metadata change, the
// on-disk ciphertext at storage_path never moves.
func (d *DB) MoveMessage(mailboxID, uid int64, newFolder string) error {
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET folder = ? WHERE id = ? AND mailbox_id = ?`, newFolder, uid, mailboxID)
return err
}
// ListMessageUIDsForMailbox returns every stored message's UID for mailboxID — used by
// mailbox removal to delete each one's on-disk ciphertext via mailstore before the
// mailbox row itself is removed.
@@ -61,28 +79,28 @@ func (d *DB) ListMessageUIDsForMailbox(mailboxID int64) ([]int64, error) {
return out, rows.Err()
}
func scanMailboxMessages(rows *sql.Rows) ([]MailboxMessage, error) {
defer rows.Close()
var out []MailboxMessage
for rows.Next() {
m, err := scanMailboxMessage(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// ListMessagesForMailbox returns every stored message's full row for mailboxID,
// ordered ascending by UID (id) — this ordering IS the IMAP sequence-number mapping
// (index+1 == seqNum) that internal/imapserver relies on.
func (d *DB) ListMessagesForMailbox(mailboxID int64) ([]MailboxMessage, error) {
rows, err := d.Query(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at
FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID)
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE mailbox_id = ? ORDER BY id ASC`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MailboxMessage
for rows.Next() {
var m MailboxMessage
var internalDate, createdAt string
if err := rows.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil {
return nil, err
}
m.InternalDate, _ = parseTime(internalDate)
m.CreatedAt, _ = parseTime(createdAt)
out = append(out, m)
}
return out, rows.Err()
return scanMailboxMessages(rows)
}
// SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped
@@ -96,22 +114,129 @@ func (d *DB) SetMessageFlags(mailboxID, uid int64, flags string) error {
// uses this (not the unscoped version) so a filter rule's move_to_folder action produces
// mail that's actually browsable in its own folder, not mixed into every SELECT.
func (d *DB) ListMessagesInFolder(mailboxID int64, folder string) ([]MailboxMessage, error) {
rows, err := d.Query(`SELECT id, mailbox_id, folder, message_id_header, flags, internal_date, size_bytes, cached_from, cached_subject, storage_path, nonce, created_at
FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? ORDER BY id ASC`, mailboxID, folder)
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? ORDER BY id ASC`, mailboxID, folder)
if err != nil {
return nil, err
}
return scanMailboxMessages(rows)
}
// ListMessagesInFolderPage is ListMessagesInFolder with newest-first pagination, for
// the webmail client's folder view — a mailbox can accumulate far more mail than is
// reasonable to render in one page.
func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, offset, limit int) ([]MailboxMessage, error) {
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages
WHERE mailbox_id = ? AND folder = ? ORDER BY id DESC LIMIT ? OFFSET ?`, mailboxID, folder, limit, offset)
if err != nil {
return nil, err
}
return scanMailboxMessages(rows)
}
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls.
func (d *DB) CountMessagesInFolder(mailboxID int64, folder string) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`, mailboxID, folder).Scan(&n)
return n, err
}
// escapeLike backslash-escapes a user-supplied LIKE pattern's own special characters
// (%, _, and the escape character itself) so a search for e.g. "50% off" or a
// filename with an underscore doesn't get interpreted as a wildcard.
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
// SearchMessagesInFolder finds messages whose cached subject/from/to contain query
// (case-insensitive substring, not a full-text index — see the webmail search
// handler's doc comment for why that's the deliberate scope here), newest first.
// folder == "" searches every folder in the mailbox.
func (d *DB) SearchMessagesInFolder(mailboxID int64, folder, query string, offset, limit int) ([]MailboxMessage, error) {
like := "%" + escapeLike(query) + "%"
args := []any{mailboxID}
folderClause := ""
if folder != "" {
folderClause = "AND folder = ? "
args = append(args, folder)
}
args = append(args, like, like, like, limit, offset)
rows, err := d.Query(`SELECT `+mailboxMessageColumns+` FROM esrv_mailbox_messages
WHERE mailbox_id = ? `+folderClause+`AND (cached_subject LIKE ? ESCAPE '\' OR cached_from LIKE ? ESCAPE '\' OR cached_to LIKE ? ESCAPE '\')
ORDER BY id DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, err
}
return scanMailboxMessages(rows)
}
// CountSearchMessagesInFolder backs SearchMessagesInFolder's pagination controls.
func (d *DB) CountSearchMessagesInFolder(mailboxID int64, folder, query string) (int, error) {
like := "%" + escapeLike(query) + "%"
args := []any{mailboxID}
folderClause := ""
if folder != "" {
folderClause = "AND folder = ? "
args = append(args, folder)
}
args = append(args, like, like, like)
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_messages
WHERE mailbox_id = ? `+folderClause+`AND (cached_subject LIKE ? ESCAPE '\' OR cached_from LIKE ? ESCAPE '\' OR cached_to LIKE ? ESCAPE '\')`, args...).Scan(&n)
return n, err
}
// CountUnreadByFolder returns every folder's unread count in one query (GROUP BY,
// not one query per folder) — mirrors isUnread's own check
// (internal/webui/webmail_mail.go) but done in SQL so the sidebar's badge counts are
// cheap to compute on every folder-view render without a full row fetch. A folder
// with zero unread messages simply has no entry in the returned map.
func (d *DB) CountUnreadByFolder(mailboxID int64) (map[string]int, error) {
rows, err := d.Query(`SELECT folder, COUNT(*) FROM esrv_mailbox_messages
WHERE mailbox_id = ? AND flags NOT LIKE '%\Seen%' GROUP BY folder`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MailboxMessage
out := map[string]int{}
for rows.Next() {
var m MailboxMessage
var internalDate, createdAt string
if err := rows.Scan(&m.ID, &m.MailboxID, &m.Folder, &m.MessageIDHeader, &m.Flags, &internalDate, &m.SizeBytes, &m.CachedFrom, &m.CachedSubject, &m.StoragePath, &m.Nonce, &createdAt); err != nil {
var folder string
var n int
if err := rows.Scan(&folder, &n); err != nil {
return nil, err
}
m.InternalDate, _ = parseTime(internalDate)
m.CreatedAt, _ = parseTime(createdAt)
out = append(out, m)
out[folder] = n
}
return out, rows.Err()
}
// 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
// that's exactly what a To/Cc/Bcc field already accepts) this mailbox has previously
// exchanged mail with — its own Sent "To" list plus INBOX "From" senders — whose
// value contains prefix. Backs the compose recipient autocomplete; deliberately
// reuses message history already stored rather than a dedicated contacts table.
func (d *DB) SuggestRecipients(mailboxID int64, prefix string) ([]string, error) {
like := "%" + escapeLike(prefix) + "%"
rows, err := d.Query(`
SELECT addr FROM (
SELECT cached_to AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'Sent' AND cached_to != ''
UNION
SELECT cached_from AS addr FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = 'INBOX' AND cached_from != ''
)
WHERE addr LIKE ? ESCAPE '\'
ORDER BY addr LIMIT 10`, mailboxID, mailboxID, like)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var addr string
if err := rows.Scan(&addr); err != nil {
return nil, err
}
out = append(out, addr)
}
return out, rows.Err()
}
+125
View File
@@ -0,0 +1,125 @@
package db
import (
"database/sql"
"errors"
)
// CreatePGPIdentity adds a new PGP identity for a mailbox — a mailbox may hold
// several at once (see esrv_mailbox_pgp_identities in schema.go).
func (d *DB) CreatePGPIdentity(mailboxID int64, label, email, fingerprint, publicKeyArmor, privateKeyArmor string) (int64, error) {
res, err := d.Exec(`
INSERT INTO esrv_mailbox_pgp_identities (mailbox_id, label, email, fingerprint, public_key_armor, private_key_armor)
VALUES (?, ?, ?, ?, ?, ?)
`, mailboxID, label, email, fingerprint, publicKeyArmor, privateKeyArmor)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// ListPGPIdentities returns a mailbox's PGP identities, most recent first.
func (d *DB) ListPGPIdentities(mailboxID int64) ([]MailboxPGPIdentity, error) {
rows, err := d.Query(`SELECT id, mailbox_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ? ORDER BY created_at DESC, id DESC`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MailboxPGPIdentity
for rows.Next() {
var id MailboxPGPIdentity
if err := rows.Scan(&id.ID, &id.MailboxID, &id.Label, &id.Email, &id.Fingerprint, &id.PublicKeyArmor, &id.PrivateKeyArmor, &id.CreatedAt); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// GetPGPIdentity returns nil, nil if no such identity exists for this mailbox —
// scoped to mailboxID so one mailbox owner can't reach another's identity by
// guessing its ID.
func (d *DB) GetPGPIdentity(mailboxID, identityID int64) (*MailboxPGPIdentity, error) {
row := d.QueryRow(`SELECT id, mailbox_id, label, email, fingerprint, public_key_armor, private_key_armor, created_at
FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID)
var id MailboxPGPIdentity
if err := row.Scan(&id.ID, &id.MailboxID, &id.Label, &id.Email, &id.Fingerprint, &id.PublicKeyArmor, &id.PrivateKeyArmor, &id.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &id, nil
}
// DeletePGPIdentity removes one identity, scoped to mailboxID.
func (d *DB) DeletePGPIdentity(mailboxID, identityID int64) error {
_, err := d.Exec(`DELETE FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID)
return err
}
// UpsertPGPContact adds a contact's PGP public key, replacing any existing key
// already on file for that email (e.g. after the contact rotates their key).
func (d *DB) UpsertPGPContact(mailboxID int64, email, label, fingerprint, publicKeyArmor string) error {
_, err := d.Exec(`
INSERT INTO esrv_mailbox_pgp_contacts (mailbox_id, email, label, fingerprint, public_key_armor)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(mailbox_id, email) DO UPDATE SET label = excluded.label, fingerprint = excluded.fingerprint, public_key_armor = excluded.public_key_armor
`, mailboxID, email, label, fingerprint, publicKeyArmor)
return err
}
// GetPGPContact returns nil, nil if no key is on file for that email.
func (d *DB) GetPGPContact(mailboxID int64, email string) (*MailboxPGPContact, error) {
row := d.QueryRow(`SELECT id, mailbox_id, email, label, public_key_armor, fingerprint, created_at FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? AND email = ?`, mailboxID, email)
var c MailboxPGPContact
if err := row.Scan(&c.ID, &c.MailboxID, &c.Email, &c.Label, &c.PublicKeyArmor, &c.Fingerprint, &c.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &c, nil
}
// GetPGPContactByID returns nil, nil if no such contact exists for this mailbox —
// scoped to mailboxID so one mailbox owner can't reach another's contact by guessing
// its ID. Used by compose's recipient-key picker, which selects contacts by ID
// rather than matching a To/Cc/Bcc address against GetPGPContact's stored email.
func (d *DB) GetPGPContactByID(mailboxID, contactID int64) (*MailboxPGPContact, error) {
row := d.QueryRow(`SELECT id, mailbox_id, email, label, public_key_armor, fingerprint, created_at FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? AND id = ?`, mailboxID, contactID)
var c MailboxPGPContact
if err := row.Scan(&c.ID, &c.MailboxID, &c.Email, &c.Label, &c.PublicKeyArmor, &c.Fingerprint, &c.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &c, nil
}
// ListPGPContacts returns a mailbox's collected contact keys, alphabetical by email.
func (d *DB) ListPGPContacts(mailboxID int64) ([]MailboxPGPContact, error) {
rows, err := d.Query(`SELECT id, mailbox_id, email, label, public_key_armor, fingerprint, created_at FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? ORDER BY email`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MailboxPGPContact
for rows.Next() {
var c MailboxPGPContact
if err := rows.Scan(&c.ID, &c.MailboxID, &c.Email, &c.Label, &c.PublicKeyArmor, &c.Fingerprint, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// DeletePGPContact removes one contact key, scoped to mailboxID so one mailbox
// owner can't delete another's contact by guessing its ID.
func (d *DB) DeletePGPContact(mailboxID, contactID int64) error {
_, err := d.Exec(`DELETE FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ? AND id = ?`, mailboxID, contactID)
return err
}
+25 -4
View File
@@ -1,7 +1,9 @@
package db
import "encoding/json"
func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) {
rows, err := d.Query(`SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, created_at
rows, err := d.Query(`SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, conditions_json, match_type, created_at
FROM esrv_mailbox_filter_rules WHERE mailbox_id = ? ORDER BY priority ASC, id ASC`, mailboxID)
if err != nil {
return nil, err
@@ -11,7 +13,7 @@ func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) {
for rows.Next() {
var r MailboxFilterRule
var createdAt string
if err := rows.Scan(&r.ID, &r.MailboxID, &r.Priority, &r.ConditionField, &r.ConditionOp, &r.ConditionValue, &r.Action, &r.ActionValue, &r.IsActive, &createdAt); err != nil {
if err := rows.Scan(&r.ID, &r.MailboxID, &r.Priority, &r.ConditionField, &r.ConditionOp, &r.ConditionValue, &r.Action, &r.ActionValue, &r.IsActive, &r.ConditionsJSON, &r.MatchType, &createdAt); err != nil {
return nil, err
}
r.CreatedAt, _ = parseTime(createdAt)
@@ -20,9 +22,28 @@ func (d *DB) ListRulesForMailbox(mailboxID int64) ([]MailboxFilterRule, error) {
return out, rows.Err()
}
// CreateRule creates a single-condition rule — a thin wrapper over CreateRuleMulti
// for the common one-condition case (and for existing callers/tests written before
// multi-condition rules existed).
func (d *DB) CreateRule(mailboxID int64, priority int, field, op, value, action, actionValue string) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value)
VALUES (?, ?, ?, ?, ?, ?, ?)`, mailboxID, priority, field, op, value, action, actionValue)
return d.CreateRuleMulti(mailboxID, priority, []RuleCondition{{Field: field, Op: op, Value: value}}, "all", action, actionValue)
}
// CreateRuleMulti creates a rule with one or more conditions combined per matchType
// ("all"=AND, "any"=OR, defaulting to "all" for anything else). The first condition
// also mirrors into the legacy condition_field/op/value columns so old code paths
// reading them directly still see something sane.
func (d *DB) CreateRuleMulti(mailboxID int64, priority int, conditions []RuleCondition, matchType, action, actionValue string) (int64, error) {
if matchType != "any" {
matchType = "all"
}
conditionsJSON, err := json.Marshal(conditions)
if err != nil {
return 0, err
}
first := conditions[0]
res, err := d.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, conditions_json, match_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, mailboxID, priority, first.Field, first.Op, first.Value, action, actionValue, string(conditionsJSON), matchType)
if err != nil {
return 0, err
}
+111
View File
@@ -0,0 +1,111 @@
package db
import (
"database/sql"
"errors"
"time"
)
// CreateSMIMEIdentity adds a new S/MIME identity for a mailbox — a mailbox may hold
// several at once (see esrv_mailbox_smime_identities in schema.go).
func (d *DB) CreateSMIMEIdentity(mailboxID int64, certPEM, keyPEM string, notAfter time.Time) (int64, error) {
res, err := d.Exec(`
INSERT INTO esrv_mailbox_smime_identities (mailbox_id, cert_pem, key_pem, not_after)
VALUES (?, ?, ?, ?)
`, mailboxID, certPEM, keyPEM, notAfter)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// ListSMIMEIdentities returns a mailbox's S/MIME identities, most recent first.
func (d *DB) ListSMIMEIdentities(mailboxID int64) ([]MailboxSMIMEIdentity, error) {
rows, err := d.Query(`SELECT id, mailbox_id, cert_pem, key_pem, not_after, created_at
FROM esrv_mailbox_smime_identities WHERE mailbox_id = ? ORDER BY created_at DESC, id DESC`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MailboxSMIMEIdentity
for rows.Next() {
var id MailboxSMIMEIdentity
if err := rows.Scan(&id.ID, &id.MailboxID, &id.CertPEM, &id.KeyPEM, &id.NotAfter, &id.CreatedAt); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// GetSMIMEIdentity returns nil, nil if no such identity exists for this mailbox —
// scoped to mailboxID so one mailbox owner can't reach another's identity by
// guessing its ID.
func (d *DB) GetSMIMEIdentity(mailboxID, identityID int64) (*MailboxSMIMEIdentity, error) {
row := d.QueryRow(`SELECT id, mailbox_id, cert_pem, key_pem, not_after, created_at
FROM esrv_mailbox_smime_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID)
var id MailboxSMIMEIdentity
if err := row.Scan(&id.ID, &id.MailboxID, &id.CertPEM, &id.KeyPEM, &id.NotAfter, &id.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &id, nil
}
// DeleteSMIMEIdentity removes one identity, scoped to mailboxID.
func (d *DB) DeleteSMIMEIdentity(mailboxID, identityID int64) error {
_, err := d.Exec(`DELETE FROM esrv_mailbox_smime_identities WHERE mailbox_id = ? AND id = ?`, mailboxID, identityID)
return err
}
// UpsertSMIMEContact adds a contact certificate, replacing any existing certificate
// already on file for that email (e.g. after the contact renews their cert).
func (d *DB) UpsertSMIMEContact(mailboxID int64, email, certPEM string) error {
_, err := d.Exec(`
INSERT INTO esrv_mailbox_smime_contacts (mailbox_id, email, cert_pem)
VALUES (?, ?, ?)
ON CONFLICT(mailbox_id, email) DO UPDATE SET cert_pem = excluded.cert_pem
`, mailboxID, email, certPEM)
return err
}
// GetSMIMEContact returns nil, nil if no certificate is on file for that email.
func (d *DB) GetSMIMEContact(mailboxID int64, email string) (*MailboxSMIMEContact, error) {
row := d.QueryRow(`SELECT id, mailbox_id, email, cert_pem, created_at FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ? AND email = ?`, mailboxID, email)
var c MailboxSMIMEContact
if err := row.Scan(&c.ID, &c.MailboxID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &c, nil
}
// ListSMIMEContacts returns a mailbox's collected contact certificates, alphabetical
// by email.
func (d *DB) ListSMIMEContacts(mailboxID int64) ([]MailboxSMIMEContact, error) {
rows, err := d.Query(`SELECT id, mailbox_id, email, cert_pem, created_at FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ? ORDER BY email`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MailboxSMIMEContact
for rows.Next() {
var c MailboxSMIMEContact
if err := rows.Scan(&c.ID, &c.MailboxID, &c.Email, &c.CertPEM, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// DeleteSMIMEContact removes one contact certificate, scoped to mailboxID so one
// mailbox owner can't delete another's contact by guessing its ID.
func (d *DB) DeleteSMIMEContact(mailboxID, contactID int64) error {
_, err := d.Exec(`DELETE FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ? AND id = ?`, mailboxID, contactID)
return err
}
+5
View File
@@ -195,6 +195,11 @@ func (d *DB) RemoveMailboxCascade(id int64) error {
`DELETE FROM esrv_mailbox_filter_rules WHERE mailbox_id = ?`,
`DELETE FROM esrv_mailbox_sessions WHERE mailbox_id = ?`,
`DELETE FROM esrv_mailbox_webauthn_credentials WHERE mailbox_id = ?`,
`DELETE FROM esrv_mailbox_folders WHERE mailbox_id = ?`,
`DELETE FROM esrv_mailbox_smime_identities WHERE mailbox_id = ?`,
`DELETE FROM esrv_mailbox_smime_contacts WHERE mailbox_id = ?`,
`DELETE FROM esrv_mailbox_pgp_identities WHERE mailbox_id = ?`,
`DELETE FROM esrv_mailbox_pgp_contacts WHERE mailbox_id = ?`,
`DELETE FROM esrv_mailbox_messages WHERE mailbox_id = ?`,
`DELETE FROM esrv_mailboxes WHERE id = ?`,
} {
+91 -4
View File
@@ -1,6 +1,9 @@
package db
import "time"
import (
"encoding/json"
"time"
)
// Mailbox is a real, IMAP-retrievable local mailbox — distinct from Sender (which is
// relay/auth-only). PasswordHash authenticates the self-service web portal only;
@@ -68,6 +71,8 @@ type MailboxAllowBlockEntry struct {
}
// MailboxFilterRule is one priority-ordered, first-match-wins delivery rule.
// ConditionField/Op/Value are the legacy single-condition columns; ConditionsJSON
// (when non-empty) is the current multi-condition representation — see Conditions().
type MailboxFilterRule struct {
ID int64
MailboxID int64
@@ -75,12 +80,40 @@ type MailboxFilterRule struct {
ConditionField string // "from" | "to" | "subject"
ConditionOp string // "contains" | "equals" | "starts_with"
ConditionValue string
Action string // "move_to_folder" | "delete" | "mark_read"
Action string // "move_to_folder" | "delete" | "mark_read" | "mark_as_spam"
ActionValue string
IsActive bool
ConditionsJSON string
MatchType string // "all" (AND, default) | "any" (OR)
CreatedAt time.Time
}
// RuleCondition is one condition within a filter rule's "if" clause.
type RuleCondition struct {
Field string `json:"field"`
Op string `json:"op"`
Value string `json:"value"`
}
// Conditions returns this rule's conditions and how they combine ("all"=AND,
// "any"=OR) — parses ConditionsJSON when present, falling back to the single legacy
// condition_field/op/value columns for rules created before multi-condition support
// existed. Shared by mailstore.ApplyRules (evaluation) and the webui (display), so
// both stay in sync with the same fallback rule.
func (r MailboxFilterRule) Conditions() ([]RuleCondition, string) {
if r.ConditionsJSON != "" {
var parsed []RuleCondition
if err := json.Unmarshal([]byte(r.ConditionsJSON), &parsed); err == nil && len(parsed) > 0 {
matchType := r.MatchType
if matchType != "any" {
matchType = "all"
}
return parsed, matchType
}
}
return []RuleCondition{{Field: r.ConditionField, Op: r.ConditionOp, Value: r.ConditionValue}}, "all"
}
// MailboxAppPassword is the only credential an IMAP/SMTP client ever uses. Plaintext
// is shown once at creation and never stored. ExpiresAt is nil for a password that
// never expires (the default).
@@ -95,8 +128,10 @@ type MailboxAppPassword struct {
ExpiresAt *time.Time
}
// MailboxMessage is one stored message. CachedFrom/CachedSubject are plaintext by
// design (see schema.go); the rest of the message lives encrypted at StoragePath.
// MailboxMessage is one stored message. CachedFrom/CachedTo/CachedSubject are
// plaintext by design (see schema.go); the rest of the message lives encrypted at
// StoragePath. CachedTo exists purely so folder listings (e.g. Sent) can show the
// recipient without decrypting every message just to render a list.
type MailboxMessage struct {
ID int64
MailboxID int64
@@ -106,8 +141,60 @@ type MailboxMessage struct {
InternalDate time.Time
SizeBytes int64
CachedFrom string
CachedTo string
CachedSubject string
StoragePath string
Nonce []byte
CreatedAt time.Time
}
// MailboxSMIMEIdentity is one of a mailbox's own S/MIME certificate + private key
// pairs — a mailbox may hold several. Both halves are stored plain: S/MIME is
// sign-only in this codebase, so the key never protects anything beyond what the
// server already has access to.
type MailboxSMIMEIdentity struct {
ID int64
MailboxID int64
CertPEM string
KeyPEM string
NotAfter time.Time
CreatedAt time.Time
}
// MailboxSMIMEContact is another address's public certificate a mailbox owner has
// collected, either added by hand or auto-captured off a verified signature.
type MailboxSMIMEContact struct {
ID int64
MailboxID int64
Email string
CertPEM string
CreatedAt time.Time
}
// MailboxPGPIdentity is one of a mailbox's own PGP keypairs — a mailbox may hold
// several. PrivateKeyArmor is stored exactly as the pgp package serializes it,
// already passphrase-protected via OpenPGP's own native key-encryption format (no
// separate ciphertext/nonce/salt columns needed, unlike MailboxSMIMEIdentity).
// Label is a free-text user note distinguishing keys (PGP keys have no expiry).
type MailboxPGPIdentity struct {
ID int64
MailboxID int64
Label string
Email string
Fingerprint string
PublicKeyArmor string
PrivateKeyArmor string
CreatedAt time.Time
}
// MailboxPGPContact is another address's PGP public key a mailbox owner has
// collected — mirrors MailboxSMIMEContact.
type MailboxPGPContact struct {
ID int64
MailboxID int64
Email string
Label string
PublicKeyArmor string
Fingerprint string
CreatedAt time.Time
}
+27
View File
@@ -132,6 +132,33 @@ func (d *DB) LogAuthAttempt(authType, identifier, ipAddress string, success bool
return err
}
// CountRecentFailedAttempts counts failed esrv_auth_logs rows for one identifier
// (independent of which IP each attempt came from — a distributed credential-
// stuffing attempt against a single account should still trip this) within
// authType and since the given cutoff, powering the per-account lockout in
// internal/webui/login.go and webmail_login.go.
func (d *DB) CountRecentFailedAttempts(authType, identifier string, since time.Time) (int, error) {
var n int
// created_at is populated by SQLite's own CURRENT_TIMESTAMP: a plain
// "YYYY-MM-DD HH:MM:SS" UTC string, space-separated, no fractional seconds, no
// offset. modernc.org/sqlite instead binds a Go time.Time query parameter as
// RFC3339Nano with a zone offset (e.g. "2026-08-14T06:57:50.497566315+01:00") —
// a live check confirmed this by inserting a time.Time into a real column and
// reading the stored text back. That format is structurally different from
// CURRENT_TIMESTAMP's own (different separator, precision, and offset), so a
// plain text >= comparison between the two doesn't reflect chronological order at
// all (confirmed: it silently matched zero rows). Two Go-bound time.Time values
// compared against each other DO work correctly, since the driver formats both
// identically — this only breaks when one side is a raw SQL CURRENT_TIMESTAMP
// default and the other is a Go-bound parameter, which happens on THIS column but
// nowhere else in this codebase (checked every other DATETIME comparison).
// Formatting since into CURRENT_TIMESTAMP's exact layout makes both sides match.
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs
WHERE auth_type = ? AND identifier = ? AND success = 0 AND created_at >= ?`,
authType, identifier, since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
return n, err
}
func parseTime(s string) (time.Time, error) {
for _, layout := range []string{"2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05", time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
+141 -1
View File
@@ -85,6 +85,40 @@ CREATE TABLE IF NOT EXISTS esrv_auth_logs (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Matches CountRecentFailedAttempts' lockout-check query.
CREATE INDEX IF NOT EXISTS idx_auth_logs_lockout ON esrv_auth_logs(identifier, auth_type, created_at);
-- Matches CountFailedAuthAttemptsByIP's abuse-detection query (internal/abuseguard) —
-- a different access pattern than the lockout index above (by IP, not identifier).
CREATE INDEX IF NOT EXISTS idx_auth_logs_by_ip ON esrv_auth_logs(ip_address, created_at);
-- Temporary IP blocks, auto-created by internal/abuseguard when one IP racks up too
-- many failed SMTP/IMAP auth attempts within a short window (see
-- CountFailedAuthAttemptsByIP), or manually by an admin from the Blacklist page.
-- offense_count drives escalating block duration on repeat offenders — see
-- BlacklistIP's doc comment for the exact formula. Deliberately separate from
-- esrv_whitelisted_ips (which authorizes unauthenticated relay for a domain, a
-- completely different concern) and from the web login lockout in
-- internal/webui/ratelimit.go (which never touches this table).
CREATE TABLE IF NOT EXISTS esrv_ip_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
reason TEXT NOT NULL DEFAULT '',
offense_count INTEGER NOT NULL DEFAULT 1,
manual INTEGER NOT NULL DEFAULT 0,
blacklisted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expiry ON esrv_ip_blacklist(ip_address, expires_at);
-- IPs exempt from abuse detection (internal/abuseguard never blacklists or blocks
-- these) — again deliberately separate from esrv_whitelisted_ips.
CREATE TABLE IF NOT EXISTS esrv_ip_abuse_whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
note TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS esrv_dkim_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
@@ -239,6 +273,12 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock (
-- Simple first-match-wins filter rules, evaluated in priority order (lower first) at
-- delivery time, before a message is encrypted and stored — so from/to/subject
-- matching works against the real message, not just the plaintext cache columns below.
-- condition_field/op/value are the legacy single-condition columns, kept for rows
-- created before multi-condition support existed. Every rule created since then
-- stores its full condition list in conditions_json (a JSON array of
-- {field,op,value}) instead, combined per match_type ("all"=AND, "any"=OR); a rule
-- with an empty conditions_json falls back to the legacy columns as a single
-- condition — see MailboxFilterRule.Conditions() in mailbox_models.go.
CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
@@ -246,9 +286,11 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject')),
condition_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')),
condition_value TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read')),
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read','mark_as_spam')),
action_value TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1,
conditions_json TEXT NOT NULL DEFAULT '',
match_type TEXT NOT NULL DEFAULT 'all',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -265,11 +307,96 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_messages (
internal_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
size_bytes INTEGER NOT NULL,
cached_from TEXT NOT NULL DEFAULT '',
cached_to TEXT NOT NULL DEFAULT '',
cached_subject TEXT NOT NULL DEFAULT '',
storage_path TEXT NOT NULL,
nonce BLOB NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Matches the folder view's exact WHERE mailbox_id = ? AND folder = ? ORDER BY
-- internal_date pattern — the single hottest query in the whole webmail client, and
-- previously unindexed (this schema had no indexes at all before this one).
CREATE INDEX IF NOT EXISTS idx_mailbox_messages_folder ON esrv_mailbox_messages(mailbox_id, folder, internal_date);
-- Explicit record of a mailbox's custom folders, so a freshly created (still empty)
-- one shows up in the folder list — esrv_mailbox_messages.folder alone can only prove
-- a folder exists once it holds at least one message. Standard folders (INBOX, Spam,
-- Sent, Drafts, Trash) are never stored here; they're always shown by the webui
-- regardless of this table.
CREATE TABLE IF NOT EXISTS esrv_mailbox_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(mailbox_id, name)
);
-- A mailbox's own S/MIME identities — a mailbox may hold several at once (e.g. one
-- per external party it corresponds with, or after rotating an expiring one while
-- keeping the old one around to read old mail). S/MIME is sign-only in this
-- codebase (PGP handles encryption — see esrv_mailbox_pgp_identities below), so the
-- private key is stored plain, same trust model as the PGP private key column: the
-- server already holds everything needed to use it, with no separate
-- passphrase-derived wrapper (that was tried and removed — see git history — it was
-- pure friction for an asset that was never actually protecting anything a server
-- compromise wouldn't already expose).
-- Superseded esrv_mailbox_smime_identity (singular, one auto-unwrapped identity per
-- mailbox) is left in place unused rather than migrated.
CREATE TABLE IF NOT EXISTS esrv_mailbox_smime_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
cert_pem TEXT NOT NULL,
key_pem TEXT NOT NULL,
not_after DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Other people's public certificates a mailbox owner has collected — added by hand
-- or auto-captured off a verified incoming signature. Used to offer "Encrypt" for a
-- recipient in compose and to flag a known signer on read; never chain-validated
-- against a CA (see internal/smime package doc).
CREATE TABLE IF NOT EXISTS esrv_mailbox_smime_contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
email TEXT NOT NULL,
cert_pem TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(mailbox_id, email)
);
-- A mailbox's own PGP keys — used only for encryption in this codebase (S/MIME,
-- above, handles signing). A mailbox may hold several. OpenPGP's own private key
-- packet format carries its own passphrase protection natively (see
-- pgp.GenerateKeyPair's doc comment) — private_key_armor is stored exactly as the
-- library serializes it, already passphrase-protected (unlike S/MIME's key_pem,
-- which is stored plain).
-- label is a free-text user note (PGP keys have no expiry to distinguish them by the
-- way generated S/MIME certs do).
CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
label TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL,
fingerprint TEXT NOT NULL,
public_key_armor TEXT NOT NULL,
private_key_armor TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Other people's PGP public keys a mailbox owner has collected, added by hand —
-- mirrors esrv_mailbox_smime_contacts. Used to offer "Encrypt (PGP)" for a
-- recipient in compose.
CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
email TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
public_key_armor TEXT NOT NULL,
fingerprint TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(mailbox_id, email)
);
`
// migrateAddedColumns best-effort ALTER TABLEs the columns added to esrv_domains
@@ -292,6 +419,19 @@ func migrateAddedColumns(db *sql.DB) {
`ALTER TABLE esrv_admin_users ADD COLUMN must_change_username INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_domains ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_mailboxes ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_to TEXT NOT NULL DEFAULT ''`,
// conditions_json/match_type are retrofittable via ALTER TABLE, but the action
// CHECK constraint (adding 'mark_as_spam') is not — SQLite doesn't support
// altering a CHECK on an existing table. A dev DB created before this change
// would need recreating to accept a mark_as_spam rule; a fresh install gets it
// for free from the CREATE TABLE above.
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN conditions_json TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN match_type TEXT NOT NULL DEFAULT 'all'`,
// key_pem replaces the old passphrase-wrapped key_ciphertext/key_nonce/key_salt
// columns — a dev DB with pre-existing identities just loses their (now
// unrecoverable-without-code-that-no-longer-exists) keys, same "not migrated"
// treatment as the singular-table identities before them.
`ALTER TABLE esrv_mailbox_smime_identities ADD COLUMN key_pem TEXT NOT NULL DEFAULT ''`,
}
for _, stmt := range stmts {
db.Exec(stmt)
+5 -2
View File
@@ -10,6 +10,8 @@ import (
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
"mailgoserver/internal/toolbox"
"gopkg.in/ini.v1"
)
// Backend holds the shared dependencies every connection's Session uses, mirroring
@@ -18,8 +20,9 @@ type Backend struct {
DB *db.DB
Mailstore *mailstore.Store
Logger *toolbox.Logger
Cfg *ini.File
}
func (b *Backend) NewSession() *Session {
return &Session{backend: b}
func (b *Backend) NewSession(peerIP string) *Session {
return &Session{backend: b, peerIP: peerIP}
}
+6 -1
View File
@@ -2,13 +2,18 @@ package imapserver
import (
"crypto/tls"
"net"
goimapserver "github.com/emersion/go-imap/v2/imapserver"
)
func newSessionFunc(backend *Backend) func(*goimapserver.Conn) (goimapserver.Session, *goimapserver.GreetingData, error) {
return func(c *goimapserver.Conn) (goimapserver.Session, *goimapserver.GreetingData, error) {
return backend.NewSession(), nil, nil
host, _, err := net.SplitHostPort(c.NetConn().RemoteAddr().String())
if err != nil {
host = c.NetConn().RemoteAddr().String()
}
return backend.NewSession(host), nil, nil
}
}
+7
View File
@@ -9,6 +9,7 @@ import (
"github.com/emersion/go-imap/v2"
goimapserver "github.com/emersion/go-imap/v2/imapserver"
"mailgoserver/internal/abuseguard"
"mailgoserver/internal/db"
)
@@ -24,6 +25,7 @@ var _ goimapserver.Session = (*Session)(nil)
// just not an instant push. Add a tracker if that matters.
type Session struct {
backend *Backend
peerIP string
mailbox *db.Mailbox // set once Login succeeds
selectedFolder string // set by Select; defaults to INBOX if empty
}
@@ -35,11 +37,16 @@ func (s *Session) Close() error { return nil }
func (s *Session) Login(username, password string) error {
mbox, err := s.backend.DB.VerifyMailboxAppPassword(username, password)
if err != nil {
_ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Authentication error: "+err.Error())
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return err
}
if mbox == nil {
_ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Invalid credentials")
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return goimapserver.ErrAuthFailed
}
_ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, true, "Successful IMAP app-password authentication")
s.mailbox = mbox
return nil
}
+31 -2
View File
@@ -1,6 +1,10 @@
package mailstore
import "strings"
import (
"strings"
"mailgoserver/internal/db"
)
// FilterAction is the outcome of evaluating a mailbox's filter rules against one
// incoming message.
@@ -24,12 +28,17 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc
if !r.IsActive {
continue
}
if !matchCondition(r.ConditionOp, headers[r.ConditionField], r.ConditionValue) {
if !ruleMatches(r, headers) {
continue
}
switch r.Action {
case "move_to_folder":
return FilterAction{Folder: r.ActionValue}, nil
case "mark_as_spam":
// Reuses the same Spam folder score-based quarantine already delivers
// into (see smtpserver/session.go) — from the mailbox owner's
// perspective it's the same "goes to Spam" outcome either way.
return FilterAction{Folder: "Spam"}, nil
case "delete":
return FilterAction{Drop: true}, nil
case "mark_read":
@@ -39,6 +48,26 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc
return FilterAction{}, nil
}
// ruleMatches combines a rule's conditions per its match type: "all" requires every
// condition to match (AND), "any" requires at least one (OR).
func ruleMatches(r db.MailboxFilterRule, headers map[string]string) bool {
conditions, matchType := r.Conditions()
if matchType == "any" {
for _, c := range conditions {
if matchCondition(c.Op, headers[c.Field], c.Value) {
return true
}
}
return false
}
for _, c := range conditions {
if !matchCondition(c.Op, headers[c.Field], c.Value) {
return false
}
}
return true
}
func matchCondition(op, value, target string) bool {
value = strings.ToLower(value)
target = strings.ToLower(target)
+107
View File
@@ -0,0 +1,107 @@
package mailstore
import (
"testing"
"mailgoserver/internal/db"
)
// TestApplyRulesMultiConditionAnd confirms an "all" (AND) rule only matches when
// every condition matches.
func TestApplyRulesMultiConditionAnd(t *testing.T) {
s, mailboxID := newTestMailbox(t, 1024*1024)
conditions := []db.RuleCondition{
{Field: "to", Op: "contains", Value: "sales"},
{Field: "subject", Op: "contains", Value: "invoice"},
}
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "move_to_folder", "Invoices"); err != nil {
t.Fatal(err)
}
// Matches "to" only — AND rule should not fire.
action, err := s.ApplyRules(mailboxID, map[string]string{"to": "sales@example.com", "subject": "hello"})
if err != nil {
t.Fatal(err)
}
if action.Folder != "" {
t.Fatalf("expected no match with only one AND condition satisfied, got folder=%q", action.Folder)
}
// Matches both — AND rule should fire.
action, err = s.ApplyRules(mailboxID, map[string]string{"to": "sales@example.com", "subject": "your invoice"})
if err != nil {
t.Fatal(err)
}
if action.Folder != "Invoices" {
t.Fatalf("expected move to Invoices when both AND conditions match, got %+v", action)
}
}
// TestApplyRulesMultiConditionOr confirms an "any" (OR) rule matches when at least
// one condition matches.
func TestApplyRulesMultiConditionOr(t *testing.T) {
s, mailboxID := newTestMailbox(t, 1024*1024)
conditions := []db.RuleCondition{
{Field: "from", Op: "contains", Value: "boss@work.example"},
{Field: "subject", Op: "contains", Value: "urgent"},
}
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "any", "mark_read", ""); err != nil {
t.Fatal(err)
}
action, err := s.ApplyRules(mailboxID, map[string]string{"from": "nobody@example.com", "subject": "urgent: read me"})
if err != nil {
t.Fatal(err)
}
if !action.MarkRead {
t.Fatalf("expected OR rule to fire on subject match alone, got %+v", action)
}
action, err = s.ApplyRules(mailboxID, map[string]string{"from": "nobody@example.com", "subject": "hello"})
if err != nil {
t.Fatal(err)
}
if action.MarkRead {
t.Fatalf("expected OR rule not to fire when neither condition matches, got %+v", action)
}
}
// TestApplyRulesMarkAsSpam confirms the mark_as_spam action routes into the Spam
// folder, same as score-based quarantine.
func TestApplyRulesMarkAsSpam(t *testing.T) {
s, mailboxID := newTestMailbox(t, 1024*1024)
conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "viagra"}}
if _, err := s.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "mark_as_spam", ""); err != nil {
t.Fatal(err)
}
action, err := s.ApplyRules(mailboxID, map[string]string{"subject": "cheap viagra now"})
if err != nil {
t.Fatal(err)
}
if action.Folder != "Spam" {
t.Fatalf("expected mark_as_spam to route into the Spam folder, got %+v", action)
}
}
// TestApplyRulesLegacySingleConditionFallback confirms a rule row with an empty
// ConditionsJSON (as any rule created before multi-condition support existed would
// have) still evaluates correctly via the legacy condition_field/op/value columns.
func TestApplyRulesLegacySingleConditionFallback(t *testing.T) {
s, mailboxID := newTestMailbox(t, 1024*1024)
// CreateRule (not CreateRuleMulti) still writes conditions_json today, so to
// simulate genuinely old pre-migration data we insert directly with an empty
// conditions_json, exactly as an old row would look on disk.
if _, err := s.DB.Exec(`INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value)
VALUES (?, 0, 'subject', 'contains', 'newsletter', 'delete', '')`, mailboxID); err != nil {
t.Fatal(err)
}
action, err := s.ApplyRules(mailboxID, map[string]string{"subject": "weekly newsletter"})
if err != nil {
t.Fatal(err)
}
if !action.Drop {
t.Fatalf("expected the legacy single-condition rule to still match, got %+v", action)
}
}
+16 -1
View File
@@ -1,15 +1,30 @@
package mailstore
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/mail"
"os"
"path/filepath"
"time"
)
// extractHeaderValue reads a single header out of raw without parsing the body — used
// to compute StoreMessage's cached_to column cheaply (no MIME/multipart walk needed
// just to cache a header for fast folder-listing display). Returns "" on any parse
// failure or if the header is absent, never an error — this is a display convenience,
// not something delivery should ever fail over.
func extractHeaderValue(raw []byte, name string) string {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return ""
}
return msg.Header.Get(name)
}
// ErrQuotaExceeded is returned by StoreMessage when storing raw would push the
// mailbox over its quota. No row, file, or used_bytes change occurs in that case.
var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded")
@@ -51,7 +66,7 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
return 0, err
}
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, subject)
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject)
if err != nil {
os.Remove(storagePath)
return 0, err
+175
View File
@@ -0,0 +1,175 @@
// Package mailview parses a raw RFC822 message into a structure a web UI can render:
// separate plain-text and HTML bodies, plus a flat list of attachments. It exists
// because internal/smtpserver's own MIME walker (parseMessage in attachments.go) is
// unexported, SMTP-inbound-specific, and only concatenates every text/* part into one
// blob — a webmail reader needs to keep text/plain and text/html distinct (so it can
// prefer HTML but still offer a plain-text view) and needs real attachment metadata
// for download links, not just a body string.
package mailview
import (
"bytes"
"encoding/base64"
"io"
"mime"
"mime/multipart"
"net/mail"
"path/filepath"
"strings"
)
// Header is the small set of top-level headers a message view needs — never the full
// header block (this isn't a general-purpose header inspector).
type Header struct {
From, To, Cc, Subject, Date, MessageID string
}
// Attachment is one file extracted from the message, decoded to its real bytes (never
// left as raw base64/quoted-printable text).
type Attachment struct {
Filename string
ContentType string
Data []byte
}
// Message is the parsed result. TextBody/HTMLBody are independently populated when
// present (e.g. a multipart/alternative body yields both) — never merged — so a
// caller can prefer HTML but still fall back to plain text.
type Message struct {
Header Header
TextBody string
HTMLBody string
Attachments []Attachment
}
// Parse walks raw's MIME structure (recursing into nested multiparts, e.g. a
// multipart/alternative inside a multipart/mixed) and classifies every leaf part as
// the text body, the HTML body, or an attachment.
func Parse(raw []byte) (*Message, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, err
}
m := &Message{Header: Header{
From: msg.Header.Get("From"),
To: msg.Header.Get("To"),
Cc: msg.Header.Get("Cc"),
Subject: msg.Header.Get("Subject"),
Date: msg.Header.Get("Date"),
MessageID: msg.Header.Get("Message-Id"),
}}
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
mediaType = "text/plain"
}
if strings.HasPrefix(mediaType, "multipart/") {
if err := walkMultipart(m, msg.Body, params["boundary"]); err != nil {
return nil, err
}
return m, nil
}
data, _ := io.ReadAll(msg.Body)
data = decodeContentTransferEncoding(msg.Header.Get("Content-Transfer-Encoding"), data)
if mediaType == "text/html" {
m.HTMLBody = string(data)
} else {
m.TextBody = string(data)
}
return m, nil
}
func walkMultipart(m *Message, r io.Reader, boundary string) error {
if boundary == "" {
return nil
}
mr := multipart.NewReader(r, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
return nil
}
if err != nil {
// Tolerate a malformed trailing part rather than losing everything
// already parsed — a webmail reader should show what it can.
return nil
}
mediaType, params, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
mediaType = "text/plain"
}
disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
data, _ := io.ReadAll(part)
data = decodeContentTransferEncoding(part.Header.Get("Content-Transfer-Encoding"), data)
if strings.HasPrefix(mediaType, "multipart/") {
walkMultipart(m, bytes.NewReader(data), params["boundary"])
continue
}
filename := dispParams["filename"]
if filename == "" {
filename = params["name"]
}
switch {
case disp == "attachment" || (filename != "" && disp != "inline"):
m.Attachments = append(m.Attachments, Attachment{
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data,
})
case mediaType == "text/html":
m.HTMLBody += string(data)
case strings.HasPrefix(mediaType, "text/"):
if m.TextBody != "" {
m.TextBody += "\n"
}
m.TextBody += string(data)
case filename != "":
// Inline non-text part (e.g. an embedded image) with no explicit
// disposition — still worth surfacing as a downloadable attachment
// rather than silently dropping it.
m.Attachments = append(m.Attachments, Attachment{
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data,
})
}
}
}
// contentTypeFor mirrors smtpserver's getContentType: prefer the part's own
// declared type, fall back to extension sniffing for the generic default.
func contentTypeFor(mediaType, filename string) string {
if mediaType != "" && mediaType != "application/octet-stream" {
return mediaType
}
if guessed := mime.TypeByExtension(filepath.Ext(filename)); guessed != "" {
return guessed
}
return "application/octet-stream"
}
// decodeContentTransferEncoding mirrors smtpserver's identically-named helper:
// mime/multipart.Reader only auto-decodes quoted-printable transparently, never
// base64, so that case needs manual decoding or attachments/HTML bodies come out as
// raw base64 text instead of their real bytes.
func decodeContentTransferEncoding(cte string, data []byte) []byte {
if !strings.EqualFold(strings.TrimSpace(cte), "base64") {
return data
}
cleaned := make([]byte, 0, len(data))
for _, b := range data {
switch b {
case ' ', '\t', '\r', '\n':
continue
default:
cleaned = append(cleaned, b)
}
}
decoded, err := base64.StdEncoding.DecodeString(string(cleaned))
if err != nil {
return data
}
return decoded
}
+91
View File
@@ -0,0 +1,91 @@
package mailview
import (
"strings"
"testing"
)
func TestParseSimpleTextMessage(t *testing.T) {
raw := "From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n\r\nhello there"
m, err := Parse([]byte(raw))
if err != nil {
t.Fatal(err)
}
if m.Header.From != "a@example.com" || m.Header.Subject != "hi" {
t.Errorf("headers = %+v", m.Header)
}
if m.TextBody != "hello there" {
t.Errorf("TextBody = %q", m.TextBody)
}
if m.HTMLBody != "" || len(m.Attachments) != 0 {
t.Errorf("expected no HTML body or attachments, got HTMLBody=%q attachments=%d", m.HTMLBody, len(m.Attachments))
}
}
func TestParseMultipartAlternativeKeepsBothBodies(t *testing.T) {
raw := "" +
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
"Content-Type: multipart/alternative; boundary=\"B\"\r\n\r\n" +
"--B\r\nContent-Type: text/plain\r\n\r\nplain version\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<p>html version</p>\r\n" +
"--B--\r\n"
m, err := Parse([]byte(raw))
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(m.TextBody) != "plain version" {
t.Errorf("TextBody = %q", m.TextBody)
}
if strings.TrimSpace(m.HTMLBody) != "<p>html version</p>" {
t.Errorf("HTMLBody = %q", m.HTMLBody)
}
}
func TestParseAttachmentDecodesBase64(t *testing.T) {
raw := "" +
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
"Content-Type: multipart/mixed; boundary=\"B\"\r\n\r\n" +
"--B\r\nContent-Type: text/plain\r\n\r\nsee attached\r\n" +
"--B\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=\"a.txt\"\r\n" +
"Content-Transfer-Encoding: BASE64\r\n\r\nSGVsbG8sIHdvcmxkIQ==\r\n" +
"--B--\r\n"
m, err := Parse([]byte(raw))
if err != nil {
t.Fatal(err)
}
if len(m.Attachments) != 1 {
t.Fatalf("got %d attachments, want 1", len(m.Attachments))
}
if got := string(m.Attachments[0].Data); got != "Hello, world!" {
t.Errorf("attachment data = %q, want decoded base64", got)
}
if m.Attachments[0].Filename != "a.txt" {
t.Errorf("filename = %q", m.Attachments[0].Filename)
}
}
func TestParseNestedMultipartMixedWithAlternativeBody(t *testing.T) {
raw := "" +
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
"Content-Type: multipart/mixed; boundary=\"OUTER\"\r\n\r\n" +
"--OUTER\r\nContent-Type: multipart/alternative; boundary=\"INNER\"\r\n\r\n" +
"--INNER\r\nContent-Type: text/plain\r\n\r\nplain body\r\n" +
"--INNER\r\nContent-Type: text/html\r\n\r\n<p>html body</p>\r\n" +
"--INNER--\r\n" +
"--OUTER\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; filename=\"notes.txt\"\r\n\r\n" +
"attached notes\r\n" +
"--OUTER--\r\n"
m, err := Parse([]byte(raw))
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(m.TextBody) != "plain body" {
t.Errorf("TextBody = %q", m.TextBody)
}
if strings.TrimSpace(m.HTMLBody) != "<p>html body</p>" {
t.Errorf("HTMLBody = %q", m.HTMLBody)
}
if len(m.Attachments) != 1 || m.Attachments[0].Filename != "notes.txt" {
t.Fatalf("attachments = %+v", m.Attachments)
}
}
+176
View File
@@ -0,0 +1,176 @@
package pgp
import (
"bytes"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/textproto"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
// Entity is a MIME entity: its own part-level headers plus its body — deliberately
// the same shape as smime.Entity, so webui's compose/read handlers can pass the same
// value between either package's Encrypt/Decrypt without conversion glue.
type Entity struct {
Headers []string
Body []byte
}
func (e Entity) bytes() []byte {
var buf bytes.Buffer
for _, h := range e.Headers {
buf.WriteString(h)
buf.WriteString("\r\n")
}
buf.WriteString("\r\n")
buf.Write(e.Body)
return buf.Bytes()
}
// parseEntity splits raw bytes (headers, a blank line, then body) back into an
// Entity — used to recover the inner MIME entity after Decrypt.
func parseEntity(raw []byte) Entity {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
sep := 4
if idx < 0 {
idx = bytes.Index(raw, []byte("\n\n"))
sep = 2
}
if idx < 0 {
return Entity{Body: raw}
}
var headers []string
for _, line := range strings.Split(string(raw[:idx]), "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
headers = append(headers, line)
}
return Entity{Headers: headers, Body: raw[idx+sep:]}
}
func headerValue(headers []string, name string) string {
for _, h := range headers {
if i := strings.Index(h, ":"); i >= 0 && strings.EqualFold(strings.TrimSpace(h[:i]), name) {
return strings.TrimSpace(h[i+1:])
}
}
return ""
}
// EncryptEntity wraps entity's bytes as an RFC 3156 PGP/MIME multipart/encrypted
// structure, encrypted to recipients. Pass every recipient's public key, including
// the sender's own, so a copy kept in Sent stays readable — mirrors
// smime.Encrypt's same convention.
func EncryptEntity(entity Entity, recipients []*openpgp.Entity) (Entity, error) {
if len(recipients) == 0 {
return Entity{}, errors.New("pgp: no recipient keys provided")
}
var armored bytes.Buffer
aw, err := armor.Encode(&armored, "PGP MESSAGE", nil)
if err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
pt, err := openpgp.Encrypt(aw, recipients, nil, nil, defaultConfig())
if err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
if _, err := pt.Write(entity.bytes()); err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
if err := pt.Close(); err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
if err := aw.Close(); err != nil {
return Entity{}, fmt.Errorf("pgp: encrypt: %w", err)
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
ctrlPart, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {"application/pgp-encrypted"},
"Content-Transfer-Encoding": {"7bit"},
})
if err != nil {
return Entity{}, err
}
if _, err := ctrlPart.Write([]byte("Version: 1\r\n")); err != nil {
return Entity{}, err
}
dataPart, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {`application/octet-stream; name="encrypted.asc"`},
"Content-Disposition": {`inline; filename="encrypted.asc"`},
"Content-Transfer-Encoding": {"7bit"},
})
if err != nil {
return Entity{}, err
}
if _, err := dataPart.Write(armored.Bytes()); err != nil {
return Entity{}, err
}
if err := mw.Close(); err != nil {
return Entity{}, err
}
return Entity{
Headers: []string{
fmt.Sprintf(`Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; boundary="%s"`, mw.Boundary()),
},
Body: body.Bytes(),
}, nil
}
// DecryptEntity reverses EncryptEntity, decrypting with unlockedIdentity (its
// private key material must already be unlocked via UnlockPrivateKey — this
// function never takes a passphrase itself).
func DecryptEntity(entity Entity, unlockedIdentity *openpgp.Entity) (Entity, error) {
ct := headerValue(entity.Headers, "Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil || mediaType != "multipart/encrypted" {
return Entity{}, errors.New("pgp: not a multipart/encrypted message")
}
if !strings.EqualFold(params["protocol"], "application/pgp-encrypted") {
return Entity{}, fmt.Errorf("pgp: unsupported multipart/encrypted protocol %q", params["protocol"])
}
boundary := params["boundary"]
if boundary == "" {
return Entity{}, errors.New("pgp: missing multipart boundary")
}
mr := multipart.NewReader(bytes.NewReader(entity.Body), boundary)
// First part is the application/pgp-encrypted control part ("Version: 1") — not
// needed, the actual ciphertext is the second part.
if _, err := mr.NextPart(); err != nil {
return Entity{}, fmt.Errorf("pgp: read control part: %w", err)
}
dataPart, err := mr.NextPart()
if err != nil {
return Entity{}, fmt.Errorf("pgp: read data part: %w", err)
}
armoredCiphertext, err := io.ReadAll(dataPart)
if err != nil {
return Entity{}, err
}
block, err := armor.Decode(bytes.NewReader(armoredCiphertext))
if err != nil {
return Entity{}, fmt.Errorf("pgp: invalid armored ciphertext: %w", err)
}
md, err := openpgp.ReadMessage(block.Body, openpgp.EntityList{unlockedIdentity}, nil, defaultConfig())
if err != nil {
return Entity{}, fmt.Errorf("pgp: decrypt: %w", err)
}
plaintext, err := io.ReadAll(md.UnverifiedBody)
if err != nil {
return Entity{}, fmt.Errorf("pgp: decrypt: %w", err)
}
return parseEntity(plaintext), nil
}
+175
View File
@@ -0,0 +1,175 @@
// Package pgp implements OpenPGP key generation/import and message encryption for
// the webmail client's PGP encryption feature. Deliberately encryption-only: this
// codebase uses S/MIME (internal/smime) for signing, PGP only for confidentiality
// (an explicit design split) — no signature generation or verification code lives
// here.
//
// Uses github.com/ProtonMail/go-crypto/openpgp, the actively maintained replacement
// for the deprecated (and explicitly "unsafe by design", per its own doc comment)
// golang.org/x/crypto/openpgp.
package pgp
import (
"bytes"
"errors"
"fmt"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
)
// defaultConfig pins AES-256 (the library defaults to AES-128) — same "always pick
// the strong option explicitly" posture as internal/smime. RSA-2048 (the library's
// own zero-value default when Config.RSABits is unset) matches this codebase's
// existing key-size convention (internal/smime, internal/tlsutil both use RSA-2048).
func defaultConfig() *packet.Config {
return &packet.Config{DefaultCipher: packet.CipherAES256}
}
// GenerateKeyPair creates a fresh RSA-2048 OpenPGP keypair for email, protects the
// private key material with passphrase (the library's own native S2K passphrase
// protection, part of the OpenPGP private-key packet format itself — no separate
// wrapping layer needed, unlike internal/smime's hand-rolled scrypt+AES-GCM), and
// returns both halves ASCII-armored.
func GenerateKeyPair(email, passphrase string) (publicArmor, privateArmor []byte, err error) {
cfg := defaultConfig()
entity, err := openpgp.NewEntity(email, "", email, cfg)
if err != nil {
return nil, nil, fmt.Errorf("pgp: generate key: %w", err)
}
if err := entity.EncryptPrivateKeys([]byte(passphrase), cfg); err != nil {
return nil, nil, fmt.Errorf("pgp: protect private key: %w", err)
}
if publicArmor, err = serializePublic(entity); err != nil {
return nil, nil, err
}
if privateArmor, err = serializePrivateWithoutSigning(entity, cfg); err != nil {
return nil, nil, err
}
return publicArmor, privateArmor, nil
}
// ImportPrivateKey parses an ASCII-armored private key export (e.g. from `gpg
// --export-secret-keys --armor`). If it isn't already passphrase-protected,
// passphrase is used to protect it before storing (same posture as generate — never
// store an unprotected private key). If it's already protected, passphrase must be
// the one that already unlocks it — verified here (by actually unlocking it) so a
// wrong passphrase is caught at import time rather than silently producing a
// permanently unusable stored key.
func ImportPrivateKey(armoredData []byte, passphrase string) (publicArmor, privateArmor []byte, err error) {
entity, err := readArmoredEntity(armoredData)
if err != nil {
return nil, nil, err
}
if entity.PrivateKey == nil {
return nil, nil, errors.New("pgp: no private key found in the uploaded file")
}
cfg := defaultConfig()
if entity.PrivateKey.Encrypted {
if err := UnlockPrivateKey(entity, passphrase); err != nil {
return nil, nil, fmt.Errorf("pgp: wrong passphrase for the imported key: %w", err)
}
}
// Re-encrypt (or encrypt for the first time) with passphrase — verified live
// that decrypting and re-encrypting the same in-memory Entity, then serializing
// without signing, round-trips correctly.
if err := entity.EncryptPrivateKeys([]byte(passphrase), cfg); err != nil {
return nil, nil, fmt.Errorf("pgp: protect private key: %w", err)
}
if publicArmor, err = serializePublic(entity); err != nil {
return nil, nil, err
}
if privateArmor, err = serializePrivateWithoutSigning(entity, cfg); err != nil {
return nil, nil, err
}
return publicArmor, privateArmor, nil
}
// ParsePublicKey parses an ASCII-armored public key block — used for a contact's
// key, added by hand (PGP has no signature here to auto-capture a contact from the
// way S/MIME does).
func ParsePublicKey(armoredData []byte) (*openpgp.Entity, error) {
return readArmoredEntity(armoredData)
}
// ParsePrivateKey parses a stored (already passphrase-protected) armored private
// key back into an Entity, still locked — call UnlockPrivateKey with the passphrase
// before using it to decrypt anything.
func ParsePrivateKey(armoredData []byte) (*openpgp.Entity, error) {
return readArmoredEntity(armoredData)
}
// UnlockPrivateKey decrypts entity's primary private key AND every subkey's private
// key with passphrase — the actual encryption-capable key lives on a subkey in
// modern OpenPGP layout (confirmed via a live round-trip test), so both must be
// unlocked before Decrypt can use entity as a recipient key.
func UnlockPrivateKey(entity *openpgp.Entity, passphrase string) error {
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("pgp: %w", err)
}
}
for _, sk := range entity.Subkeys {
if sk.PrivateKey != nil && sk.PrivateKey.Encrypted {
if err := sk.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
return fmt.Errorf("pgp: %w", err)
}
}
}
return nil
}
// Fingerprint returns entity's primary key fingerprint as uppercase hex, for
// display — distinguishing keys beyond just their user-supplied label.
func Fingerprint(entity *openpgp.Entity) string {
return fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint)
}
func readArmoredEntity(armoredData []byte) (*openpgp.Entity, error) {
block, err := armor.Decode(bytes.NewReader(armoredData))
if err != nil {
return nil, fmt.Errorf("pgp: invalid armored data: %w", err)
}
entity, err := openpgp.ReadEntity(packet.NewReader(block.Body))
if err != nil {
return nil, fmt.Errorf("pgp: parse key: %w", err)
}
return entity, nil
}
func serializePublic(entity *openpgp.Entity) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
if err != nil {
return nil, err
}
if err := entity.Serialize(w); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// serializePrivateWithoutSigning uses SerializePrivateWithoutSigning, not
// SerializePrivate — the latter re-signs identities/subkeys using the private key
// as a crypto.Signer, which panics once the key material is encrypted (confirmed
// via a live round-trip test; SerializePrivate is only safe to call before
// EncryptPrivateKeys, which isn't a option here since every caller wants the
// already-protected key serialized).
func serializePrivateWithoutSigning(entity *openpgp.Entity, cfg *packet.Config) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil {
return nil, err
}
if err := entity.SerializePrivateWithoutSigning(w, cfg); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+248
View File
@@ -0,0 +1,248 @@
package pgp
import (
"bytes"
"testing"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
// serializeUnencryptedPrivate serializes an as-yet-unprotected private key — only
// safe to call before EncryptPrivateKeys (see the gotcha documented on
// serializePrivateWithoutSigning in identity.go). No production code path needs
// this (every stored key goes through EncryptPrivateKeys first); it exists here
// purely to simulate a genuinely unprotected "gpg --export-secret-keys" output for
// TestImportPrivateKeyUnencrypted.
func serializeUnencryptedPrivate(entity *openpgp.Entity) ([]byte, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil {
return nil, err
}
if err := entity.SerializePrivate(w, nil); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func testEntity() Entity {
return Entity{
Headers: []string{"Content-Type: text/plain; charset=utf-8"},
Body: []byte("hello world\r\nsecond line\r\n"),
}
}
func TestGenerateEncryptDecryptRoundTrip(t *testing.T) {
pubPEM, privPEM, err := GenerateKeyPair("alice@example.com", "correct horse battery staple")
if err != nil {
t.Fatalf("GenerateKeyPair: %v", err)
}
if bytes.Contains(privPEM, []byte("correct horse")) {
t.Fatal("stored private key armor should not contain the plaintext passphrase")
}
recipient, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
orig := testEntity()
encrypted, err := EncryptEntity(orig, []*openpgp.Entity{recipient})
if err != nil {
t.Fatalf("EncryptEntity: %v", err)
}
if ct := headerValue(encrypted.Headers, "Content-Type"); ct == "" {
t.Fatal("expected a Content-Type header on the encrypted entity")
}
if bytes.Contains(encrypted.Body, orig.Body) {
t.Fatal("encrypted body should not contain the plaintext")
}
identity, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
if err := UnlockPrivateKey(identity, "correct horse battery staple"); err != nil {
t.Fatalf("UnlockPrivateKey: %v", err)
}
decrypted, err := DecryptEntity(encrypted, identity)
if err != nil {
t.Fatalf("DecryptEntity: %v", err)
}
if !bytes.Equal(decrypted.Body, orig.Body) {
t.Fatalf("body mismatch: got %q want %q", decrypted.Body, orig.Body)
}
if headerValue(decrypted.Headers, "Content-Type") != headerValue(orig.Headers, "Content-Type") {
t.Fatalf("header mismatch: got %v want %v", decrypted.Headers, orig.Headers)
}
}
func TestUnlockPrivateKeyWrongPassphraseFails(t *testing.T) {
_, privPEM, err := GenerateKeyPair("alice@example.com", "right passphrase")
if err != nil {
t.Fatal(err)
}
identity, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatal(err)
}
if err := UnlockPrivateKey(identity, "wrong passphrase"); err == nil {
t.Fatal("expected the wrong passphrase to fail")
}
}
func TestEncryptMultipleRecipientsBothCanDecrypt(t *testing.T) {
senderPub, senderPriv, err := GenerateKeyPair("sender@example.com", "sender pass")
if err != nil {
t.Fatal(err)
}
recipPub, recipPriv, err := GenerateKeyPair("recipient@example.com", "recipient pass")
if err != nil {
t.Fatal(err)
}
senderPubEntity, _ := ParsePublicKey(senderPub)
recipPubEntity, _ := ParsePublicKey(recipPub)
orig := testEntity()
encrypted, err := EncryptEntity(orig, []*openpgp.Entity{senderPubEntity, recipPubEntity})
if err != nil {
t.Fatal(err)
}
senderIdentity, _ := ParsePrivateKey(senderPriv)
if err := UnlockPrivateKey(senderIdentity, "sender pass"); err != nil {
t.Fatal(err)
}
senderCopy, err := DecryptEntity(encrypted, senderIdentity)
if err != nil {
t.Fatalf("sender DecryptEntity: %v", err)
}
if !bytes.Equal(senderCopy.Body, orig.Body) {
t.Fatal("sender's own copy did not decrypt to the original body")
}
recipIdentity, _ := ParsePrivateKey(recipPriv)
if err := UnlockPrivateKey(recipIdentity, "recipient pass"); err != nil {
t.Fatal(err)
}
recipCopy, err := DecryptEntity(encrypted, recipIdentity)
if err != nil {
t.Fatalf("recipient DecryptEntity: %v", err)
}
if !bytes.Equal(recipCopy.Body, orig.Body) {
t.Fatal("recipient's copy did not decrypt to the original body")
}
}
func TestImportPrivateKeyUnencrypted(t *testing.T) {
// Simulate a raw, not-yet-passphrase-protected export by generating a key and
// serializing it before EncryptPrivateKeys is ever called.
entity, err := openpgp.NewEntity("bob@example.com", "", "bob@example.com", defaultConfig())
if err != nil {
t.Fatal(err)
}
unprotectedArmor, err := serializeUnencryptedPrivate(entity)
if err != nil {
t.Fatal(err)
}
pubPEM, privPEM, err := ImportPrivateKey(unprotectedArmor, "new passphrase")
if err != nil {
t.Fatalf("ImportPrivateKey: %v", err)
}
identity, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatal(err)
}
if !identity.PrivateKey.Encrypted {
t.Fatal("expected the imported key to be encrypted after import")
}
if err := UnlockPrivateKey(identity, "new passphrase"); err != nil {
t.Fatalf("expected the new passphrase to unlock the imported key: %v", err)
}
recipient, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatal(err)
}
orig := testEntity()
encrypted, err := EncryptEntity(orig, []*openpgp.Entity{recipient})
if err != nil {
t.Fatal(err)
}
decrypted, err := DecryptEntity(encrypted, identity)
if err != nil {
t.Fatalf("DecryptEntity after import: %v", err)
}
if !bytes.Equal(decrypted.Body, orig.Body) {
t.Fatal("round trip through an imported unencrypted key failed")
}
}
func TestImportPrivateKeyAlreadyEncrypted(t *testing.T) {
_, existingArmor, err := GenerateKeyPair("carol@example.com", "original passphrase")
if err != nil {
t.Fatal(err)
}
if _, _, err := ImportPrivateKey(existingArmor, "wrong passphrase"); err == nil {
t.Fatal("expected import with the wrong passphrase for an already-encrypted key to fail")
}
pubPEM, privPEM, err := ImportPrivateKey(existingArmor, "original passphrase")
if err != nil {
t.Fatalf("ImportPrivateKey with the correct passphrase: %v", err)
}
identity, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatal(err)
}
if err := UnlockPrivateKey(identity, "original passphrase"); err != nil {
t.Fatalf("expected the original passphrase to still unlock after re-import: %v", err)
}
recipient, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatal(err)
}
orig := testEntity()
encrypted, err := EncryptEntity(orig, []*openpgp.Entity{recipient})
if err != nil {
t.Fatal(err)
}
decrypted, err := DecryptEntity(encrypted, identity)
if err != nil {
t.Fatalf("DecryptEntity after re-import: %v", err)
}
if !bytes.Equal(decrypted.Body, orig.Body) {
t.Fatal("round trip through a re-imported already-encrypted key failed")
}
}
func TestFingerprintIsStableAndNonEmpty(t *testing.T) {
pubPEM, _, err := GenerateKeyPair("alice@example.com", "pass")
if err != nil {
t.Fatal(err)
}
entity, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatal(err)
}
fp := Fingerprint(entity)
if len(fp) == 0 {
t.Fatal("expected a non-empty fingerprint")
}
entity2, err := ParsePublicKey(pubPEM)
if err != nil {
t.Fatal(err)
}
if Fingerprint(entity2) != fp {
t.Fatal("expected the fingerprint to be stable across re-parses of the same key")
}
}
+64
View File
@@ -0,0 +1,64 @@
package smime
import (
"crypto"
"crypto/x509"
"errors"
"fmt"
"mime"
"strings"
"go.mozilla.org/pkcs7"
)
// Encrypt wraps entity's canonical bytes as CMS EnvelopedData addressed to
// recipients, per RFC 8551 application/pkcs7-mime; smime-type=enveloped-data. Pass
// every recipient's certificate, including the sender's own, so a copy kept in Sent
// stays readable.
func Encrypt(entity Entity, recipients []*x509.Certificate) (Entity, error) {
if len(recipients) == 0 {
return Entity{}, errors.New("smime: no recipient certificates provided")
}
envelopedDER, err := pkcs7.Encrypt(entity.bytes(), recipients)
if err != nil {
return Entity{}, fmt.Errorf("smime: encrypt: %w", err)
}
return Entity{
Headers: []string{
`Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"`,
"Content-Transfer-Encoding: base64",
`Content-Disposition: attachment; filename="smime.p7m"`,
},
Body: []byte(wrapBase64(envelopedDER)),
}, nil
}
// Decrypt reverses Encrypt, returning the inner MIME entity that was originally
// wrapped.
func Decrypt(entity Entity, cert *x509.Certificate, key crypto.PrivateKey) (Entity, error) {
ct := HeaderValue(entity.Headers, "Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil || mediaType != "application/pkcs7-mime" {
return Entity{}, errors.New("smime: not an application/pkcs7-mime message")
}
if st := params["smime-type"]; st != "" && !strings.EqualFold(st, "enveloped-data") {
return Entity{}, fmt.Errorf("smime: unsupported smime-type %q", st)
}
raw := entity.Body
if isBase64CTE(HeaderValue(entity.Headers, "Content-Transfer-Encoding")) {
if raw, err = decodeBase64(entity.Body); err != nil {
return Entity{}, fmt.Errorf("smime: decode envelope: %w", err)
}
}
p7, err := pkcs7.Parse(raw)
if err != nil {
return Entity{}, fmt.Errorf("smime: parse envelope: %w", err)
}
plaintext, err := p7.Decrypt(cert, key)
if err != nil {
return Entity{}, fmt.Errorf("smime: decrypt: %w", err)
}
return parseEntity(plaintext)
}
+151
View File
@@ -0,0 +1,151 @@
package smime
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
)
// Entity is a MIME entity: its own part-level headers (Content-Type,
// Content-Transfer-Encoding, Content-Disposition — never envelope headers like
// From/To/Subject/Date) plus its body. Sign/Encrypt/Decrypt/VerifySigned all operate
// on an Entity, not a flat raw RFC822 message — the caller (webui's compose/read
// handlers) is responsible for keeping envelope headers separate, since S/MIME only
// ever transforms the message body's own MIME entity, never the envelope.
type Entity struct {
Headers []string
Body []byte
}
// bytes renders the entity as it would appear on the wire: headers, a blank line,
// then the body normalized to CRLF line endings — MIME's canonical form, which is
// what gets hashed/signed/encrypted. Both Sign and Encrypt must operate on exactly
// this rendering so a receiving client's own canonicalization matches ours.
func (e Entity) bytes() []byte {
var buf bytes.Buffer
for _, h := range e.Headers {
buf.WriteString(h)
buf.WriteString("\r\n")
}
buf.WriteString("\r\n")
buf.Write(toCRLF(e.Body))
return buf.Bytes()
}
// parseEntity splits raw bytes (headers, a blank line, then body) back into an
// Entity — used to recover the inner MIME entity after Decrypt or the signed part
// after VerifySigned, both of which hand back a full "headers+body" byte blob.
func parseEntity(raw []byte) (Entity, error) {
idx := bytes.Index(raw, []byte("\r\n\r\n"))
sep := 4
if idx < 0 {
idx = bytes.Index(raw, []byte("\n\n"))
sep = 2
}
if idx < 0 {
return Entity{Body: raw}, nil
}
var headers []string
for _, line := range strings.Split(string(raw[:idx]), "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
headers = append(headers, line)
}
return Entity{Headers: headers, Body: raw[idx+sep:]}, nil
}
// toCRLF normalizes line endings to CRLF — first collapsing any existing CRLF to a
// bare LF so a mixed or already-CRLF input doesn't end up double-terminated.
func toCRLF(b []byte) []byte {
b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
return bytes.ReplaceAll(b, []byte("\n"), []byte("\r\n"))
}
// HeaderValue is a case-insensitive lookup over a MIME entity's header lines —
// exported so callers outside this package (e.g. webui's read-integration, which
// must inspect a message's Content-Type before deciding whether to unwrap it) don't
// need to reimplement it.
func HeaderValue(headers []string, name string) string {
for _, h := range headers {
idx := strings.Index(h, ":")
if idx < 0 {
continue
}
if strings.EqualFold(strings.TrimSpace(h[:idx]), name) {
return strings.TrimSpace(h[idx+1:])
}
}
return ""
}
func isBase64CTE(cte string) bool {
return strings.EqualFold(strings.TrimSpace(cte), "base64")
}
func decodeBase64(data []byte) ([]byte, error) {
return base64.StdEncoding.DecodeString(stripWhitespace(string(data)))
}
func stripWhitespace(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case ' ', '\t', '\r', '\n':
continue
default:
b.WriteRune(r)
}
}
return b.String()
}
func newBoundary() string {
b := make([]byte, 16)
rand.Read(b)
return "----=_SMIME_" + hex.EncodeToString(b)
}
// wrapBase64 base64-encodes data at the RFC 2045-recommended 76 characters per line —
// cosmetic (a decoder doesn't care), but matches what every real MTA/MUA produces.
func wrapBase64(data []byte) string {
encoded := base64.StdEncoding.EncodeToString(data)
var b strings.Builder
for i := 0; i < len(encoded); i += 76 {
end := min(i+76, len(encoded))
b.WriteString(encoded[i:end])
b.WriteString("\r\n")
}
return strings.TrimRight(b.String(), "\r\n")
}
// splitMultipartRaw extracts each part's *exact* original bytes between boundary
// delimiters — deliberately not using mime/multipart.Reader, whose Part API parses
// headers away from the raw body and would require re-serializing them to recover
// signable bytes. A detached S/MIME signature covers the literal octets of the
// signed part (RFC 8551 §3.4.3), so reconstruction-from-parsed-headers risks a
// byte-for-byte mismatch (header order, casing, whitespace) that breaks verification
// even for semantically-identical content. Real S/MIME implementations extract raw
// byte ranges for exactly this reason.
func splitMultipartRaw(body []byte, boundary string) ([][]byte, error) {
delim := []byte("--" + boundary)
segments := bytes.Split(body, delim)
if len(segments) < 3 {
return nil, errors.New("smime: malformed multipart body")
}
// segments[0] is the preamble (ignored); the last segment starts with "--" (the
// closing delimiter) and anything after is the epilogue (ignored). Everything in
// between is one part, each still wrapped in the CRLF that separated it from its
// boundary line.
parts := make([][]byte, 0, len(segments)-2)
for _, seg := range segments[1 : len(segments)-1] {
seg = bytes.TrimPrefix(seg, []byte("\r\n"))
seg = bytes.TrimSuffix(seg, []byte("\r\n"))
parts = append(parts, seg)
}
return parts, nil
}
+129
View File
@@ -0,0 +1,129 @@
// Package smime implements S/MIME certificate-based email signing and encryption:
// generating or importing a mailbox's own identity (certificate + private key),
// signing outbound mail (RFC 8551 multipart/signed, detached CMS SignedData),
// verifying a signature, encrypting outbound mail (application/pkcs7-mime, CMS
// EnvelopedData), and decrypting it again.
//
// This package is deliberately certificate-chain-agnostic: it does not validate a
// certificate against any CA trust store. A verified signature here means "this
// message was cryptographically signed by the private key matching this exact
// certificate," not "this certificate is trusted by a PKI" — the same posture this
// codebase's own self-signed TLS certificate already has. Callers that want to
// display a warning for unrecognized signers should compare the signer's certificate
// against their own address book (see the mailbox_smime_contacts table in
// internal/db), not chain validation.
package smime
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"time"
"go.mozilla.org/pkcs7"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
func init() {
// go.mozilla.org/pkcs7 defaults ContentEncryptionAlgorithm to legacy DES-CBC for
// backward compatibility with old clients — AES-256-GCM is the only acceptable
// choice for anything generated here.
pkcs7.ContentEncryptionAlgorithm = pkcs7.EncryptionAlgorithmAES256GCM
}
// DefaultValidity mirrors a typical S/MIME certificate lifetime (1 year), matching
// what most CAs issue for individual email certificates.
const DefaultValidity = 365 * 24 * time.Hour
// GenerateSelfSigned creates a fresh RSA-2048 keypair and a self-signed certificate
// scoped to email — same key size internal/tlsutil already uses for the server's own
// TLS certificate. KeyUsage/ExtKeyUsage/EmailAddresses are set per RFC 8551 so
// mainstream mail clients recognize it as a valid S/MIME certificate.
func GenerateSelfSigned(email string, validity time.Duration) (certPEM, keyPEM []byte, err error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, fmt.Errorf("smime: generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("smime: generate serial: %w", err)
}
subject := pkix.Name{CommonName: email}
template := x509.Certificate{
SerialNumber: serial,
Subject: subject,
Issuer: subject,
NotBefore: time.Now(),
NotAfter: time.Now().Add(validity),
SignatureAlgorithm: x509.SHA256WithRSA,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
EmailAddresses: []string{email},
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, nil, fmt.Errorf("smime: create certificate: %w", err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, nil, fmt.Errorf("smime: marshal private key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// ImportPKCS12 parses a .p12/.pfx bundle — the usual export format from a CA or
// another mail client — into a certificate + private key. Only RSA keys are
// supported (the only key type go.mozilla.org/pkcs7's encrypt/decrypt operations
// actually support for key transport).
func ImportPKCS12(data []byte, password string) (certPEM, keyPEM []byte, err error) {
priv, cert, err := pkcs12.Decode(data, password)
if err != nil {
return nil, nil, fmt.Errorf("smime: decode PKCS#12: %w", err)
}
rsaKey, ok := priv.(*rsa.PrivateKey)
if !ok {
return nil, nil, errors.New("smime: only RSA keys are supported")
}
keyDER, err := x509.MarshalPKCS8PrivateKey(rsaKey)
if err != nil {
return nil, nil, fmt.Errorf("smime: marshal private key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// ParseCertPEM decodes a stored certificate back into a usable *x509.Certificate.
func ParseCertPEM(certPEM []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, errors.New("smime: invalid certificate PEM")
}
return x509.ParseCertificate(block.Bytes)
}
// ParseKeyPEM decodes a stored private key back into a usable crypto.PrivateKey.
// Tries PKCS#8 first (what GenerateSelfSigned/ImportPKCS12 both produce), falling
// back to PKCS#1 for a hand-imported PEM that used the older format.
func ParseKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, errors.New("smime: invalid private key PEM")
}
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
return nil, errors.New("smime: unrecognized private key format")
}
+99
View File
@@ -0,0 +1,99 @@
package smime
import (
"crypto"
"crypto/x509"
"errors"
"fmt"
"mime"
"go.mozilla.org/pkcs7"
)
// Sign wraps entity in RFC 8551 multipart/signed: part 1 is the entity's own
// canonical bytes (unmodified — this is what the signature covers), part 2 is a
// detached CMS SignedData over those same bytes.
func Sign(entity Entity, cert *x509.Certificate, key crypto.PrivateKey) (Entity, error) {
content := entity.bytes()
sd, err := pkcs7.NewSignedData(content)
if err != nil {
return Entity{}, fmt.Errorf("smime: sign: %w", err)
}
sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil {
return Entity{}, fmt.Errorf("smime: sign: %w", err)
}
sd.Detach()
sigDER, err := sd.Finish()
if err != nil {
return Entity{}, fmt.Errorf("smime: sign: %w", err)
}
boundary := newBoundary()
body := make([]byte, 0, len(content)+len(sigDER)*2)
body = append(body, []byte("--"+boundary+"\r\n")...)
body = append(body, content...)
body = append(body, []byte("\r\n--"+boundary+"\r\n")...)
body = append(body, []byte("Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")...)
body = append(body, []byte("Content-Transfer-Encoding: base64\r\n")...)
body = append(body, []byte("Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")...)
body = append(body, []byte(wrapBase64(sigDER))...)
body = append(body, []byte("\r\n--"+boundary+"--\r\n")...)
return Entity{
Headers: []string{
fmt.Sprintf(`Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256; boundary="%s"`, boundary),
},
Body: body,
}, nil
}
// VerifySigned parses a multipart/signed entity produced by Sign (or any RFC
// 8551-compliant sender), checks the detached signature against the exact original
// bytes of part 1, and returns that inner entity plus the signer's certificate. On a
// signature mismatch it still returns the inner entity — so a tampered or
// unverifiable message can be shown with a warning rather than hidden — alongside a
// non-nil error and a nil signer.
func VerifySigned(entity Entity) (inner Entity, signer *x509.Certificate, err error) {
ct := HeaderValue(entity.Headers, "Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil || mediaType != "multipart/signed" {
return Entity{}, nil, errors.New("smime: not a multipart/signed message")
}
boundary := params["boundary"]
if boundary == "" {
return Entity{}, nil, errors.New("smime: missing multipart boundary")
}
parts, err := splitMultipartRaw(entity.Body, boundary)
if err != nil || len(parts) < 2 {
return Entity{}, nil, errors.New("smime: malformed signed message")
}
signedContent := parts[0]
sigPart, err := parseEntity(parts[1])
if err != nil {
return Entity{}, nil, err
}
sigDER := sigPart.Body
if isBase64CTE(HeaderValue(sigPart.Headers, "Content-Transfer-Encoding")) {
if sigDER, err = decodeBase64(sigPart.Body); err != nil {
return Entity{}, nil, fmt.Errorf("smime: decode signature: %w", err)
}
}
p7, err := pkcs7.Parse(sigDER)
if err != nil {
return Entity{}, nil, fmt.Errorf("smime: parse signature: %w", err)
}
p7.Content = signedContent
inner, perr := parseEntity(signedContent)
if perr != nil {
return Entity{}, nil, perr
}
if err := p7.Verify(); err != nil {
return inner, nil, fmt.Errorf("smime: signature verification failed: %w", err)
}
return inner, p7.GetOnlySigner(), nil
}
+215
View File
@@ -0,0 +1,215 @@
package smime
import (
"bytes"
"crypto"
"crypto/x509"
"strings"
"testing"
)
func testIdentity(t *testing.T, email string) (*x509.Certificate, crypto.PrivateKey) {
t.Helper()
certPEM, keyPEM, err := GenerateSelfSigned(email, DefaultValidity)
if err != nil {
t.Fatalf("GenerateSelfSigned: %v", err)
}
cert, err := ParseCertPEM(certPEM)
if err != nil {
t.Fatalf("ParseCertPEM: %v", err)
}
key, err := ParseKeyPEM(keyPEM)
if err != nil {
t.Fatalf("ParseKeyPEM: %v", err)
}
return cert, key
}
// testEntity uses CRLF line endings already, since Sign/Encrypt canonicalize the
// body to CRLF (MIME's wire form) before signing/encrypting — a round trip through
// either normalizes bare LF to CRLF, so tests compare against the canonical form.
func testEntity() Entity {
return Entity{
Headers: []string{"Content-Type: text/plain; charset=utf-8"},
Body: []byte("hello world\r\nsecond line\r\n"),
}
}
func TestSignVerifyRoundTrip(t *testing.T) {
cert, key := testIdentity(t, "alice@example.com")
orig := testEntity()
signed, err := Sign(orig, cert, key)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if ct := HeaderValue(signed.Headers, "Content-Type"); !strings.HasPrefix(ct, "multipart/signed") {
t.Fatalf("unexpected Content-Type: %q", ct)
}
inner, signer, err := VerifySigned(signed)
if err != nil {
t.Fatalf("VerifySigned: %v", err)
}
if signer == nil || signer.Subject.CommonName != "alice@example.com" {
t.Fatalf("unexpected signer: %+v", signer)
}
if !bytes.Equal(inner.Body, orig.Body) {
t.Fatalf("body mismatch: got %q want %q", inner.Body, orig.Body)
}
if HeaderValue(inner.Headers, "Content-Type") != HeaderValue(orig.Headers, "Content-Type") {
t.Fatalf("header mismatch: got %v want %v", inner.Headers, orig.Headers)
}
}
func TestVerifySignedDetectsTampering(t *testing.T) {
cert, key := testIdentity(t, "alice@example.com")
signed, err := Sign(testEntity(), cert, key)
if err != nil {
t.Fatalf("Sign: %v", err)
}
tampered := string(signed.Body)
tampered = strings.Replace(tampered, "hello world", "hello WORLD", 1)
signed.Body = []byte(tampered)
inner, signer, err := VerifySigned(signed)
if err == nil {
t.Fatal("expected verification error for tampered content, got nil")
}
if signer != nil {
t.Fatalf("expected nil signer on failed verification, got %+v", signer)
}
// The tampered body should still come back for display purposes even though
// verification failed.
if !bytes.Contains(inner.Body, []byte("hello WORLD")) {
t.Fatalf("expected tampered body returned alongside the error, got %q", inner.Body)
}
}
func TestVerifySignedWrongSignerCert(t *testing.T) {
cert, key := testIdentity(t, "alice@example.com")
other, _ := testIdentity(t, "mallory@example.com")
signed, err := Sign(testEntity(), cert, key)
if err != nil {
t.Fatalf("Sign: %v", err)
}
_, signer, err := VerifySigned(signed)
if err != nil {
t.Fatalf("VerifySigned: %v", err)
}
if signer.Subject.CommonName == other.Subject.CommonName {
t.Fatal("signer should not match an unrelated certificate")
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
cert, key := testIdentity(t, "bob@example.com")
orig := testEntity()
encrypted, err := Encrypt(orig, []*x509.Certificate{cert})
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
if ct := HeaderValue(encrypted.Headers, "Content-Type"); !strings.HasPrefix(ct, "application/pkcs7-mime") {
t.Fatalf("unexpected Content-Type: %q", ct)
}
if bytes.Contains(encrypted.Body, orig.Body) {
t.Fatal("encrypted body should not contain the plaintext")
}
decrypted, err := Decrypt(encrypted, cert, key)
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if !bytes.Equal(decrypted.Body, orig.Body) {
t.Fatalf("body mismatch: got %q want %q", decrypted.Body, orig.Body)
}
if HeaderValue(decrypted.Headers, "Content-Type") != HeaderValue(orig.Headers, "Content-Type") {
t.Fatalf("header mismatch: got %v want %v", decrypted.Headers, orig.Headers)
}
}
func TestDecryptWrongKeyFails(t *testing.T) {
cert, _ := testIdentity(t, "bob@example.com")
otherCert, otherKey := testIdentity(t, "mallory@example.com")
encrypted, err := Encrypt(testEntity(), []*x509.Certificate{cert})
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
if _, err := Decrypt(encrypted, otherCert, otherKey); err == nil {
t.Fatal("expected decryption with the wrong key to fail")
}
}
func TestEncryptMultipleRecipientsBothCanDecrypt(t *testing.T) {
senderCert, senderKey := testIdentity(t, "alice@example.com")
recipCert, recipKey := testIdentity(t, "bob@example.com")
orig := testEntity()
encrypted, err := Encrypt(orig, []*x509.Certificate{senderCert, recipCert})
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
senderCopy, err := Decrypt(encrypted, senderCert, senderKey)
if err != nil {
t.Fatalf("sender Decrypt: %v", err)
}
if !bytes.Equal(senderCopy.Body, orig.Body) {
t.Fatal("sender's own copy did not decrypt to the original body")
}
recipCopy, err := Decrypt(encrypted, recipCert, recipKey)
if err != nil {
t.Fatalf("recipient Decrypt: %v", err)
}
if !bytes.Equal(recipCopy.Body, orig.Body) {
t.Fatal("recipient's copy did not decrypt to the original body")
}
}
// TestSignThenEncryptNestedRoundTrip covers the "sign and encrypt" compose option:
// the plaintext is signed, then the whole signed entity is encrypted (opaque
// nesting), matching how webui's compose handler applies both transforms together.
func TestSignThenEncryptNestedRoundTrip(t *testing.T) {
senderCert, senderKey := testIdentity(t, "alice@example.com")
recipCert, recipKey := testIdentity(t, "bob@example.com")
orig := testEntity()
signed, err := Sign(orig, senderCert, senderKey)
if err != nil {
t.Fatalf("Sign: %v", err)
}
encrypted, err := Encrypt(signed, []*x509.Certificate{recipCert})
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
decrypted, err := Decrypt(encrypted, recipCert, recipKey)
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if !strings.HasPrefix(HeaderValue(decrypted.Headers, "Content-Type"), "multipart/signed") {
t.Fatalf("expected the decrypted layer to still be multipart/signed, got %q", HeaderValue(decrypted.Headers, "Content-Type"))
}
inner, signer, err := VerifySigned(decrypted)
if err != nil {
t.Fatalf("VerifySigned: %v", err)
}
if signer.Subject.CommonName != "alice@example.com" {
t.Fatalf("unexpected signer: %+v", signer)
}
if !bytes.Equal(inner.Body, orig.Body) {
t.Fatalf("body mismatch after unwrapping both layers: got %q want %q", inner.Body, orig.Body)
}
}
func TestImportPKCS12RejectsBadPassword(t *testing.T) {
if _, _, err := ImportPKCS12([]byte("not a real pkcs12 file"), "whatever"); err == nil {
t.Fatal("expected an error decoding garbage PKCS#12 data")
}
}
@@ -0,0 +1,42 @@
package smtpserver
import (
"net/smtp"
"testing"
)
// TestRepeatedFailedAuthBlacklistsIP is the live-flow check for the abuseguard wiring
// (see auth.go's authenticate): enough real failed AUTH PLAIN attempts over real TCP
// against a real smtpserver.Backend should land the source IP in esrv_ip_blacklist,
// exactly as internal/abuseguard's own unit tests confirm in isolation — this confirms
// the actual auth.go call site is wired up, not just the abuseguard package itself.
func TestRepeatedFailedAuthBlacklistsIP(t *testing.T) {
backend := newTestBackend(t)
sec, _ := backend.Cfg.NewSection("Security")
sec.NewKey("abuse_detection_enabled", "true")
sec.NewKey("abuse_failure_threshold", "3")
sec.NewKey("abuse_detection_window_minutes", "10")
sec.NewKey("abuse_blacklist_base_hours", "12")
sec.NewKey("abuse_blacklist_max_hours", "168")
addr := startTestServer(t, backend)
for i := 0; i < 3; i++ {
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
if authErr := c.Auth(smtp.PlainAuth("", "test@example.com", "wrongpassword", "127.0.0.1")); authErr == nil {
t.Fatal("expected auth failure")
}
c.Close()
}
blocked, err := backend.DB.IsIPBlacklisted("127.0.0.1")
if err != nil {
t.Fatal(err)
}
if !blocked {
t.Fatal("expected 127.0.0.1 to be blacklisted after 3 failed AUTH attempts (threshold=3)")
}
}
+4
View File
@@ -6,6 +6,7 @@ import (
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"mailgoserver/internal/abuseguard"
"mailgoserver/internal/db"
)
@@ -76,6 +77,7 @@ func (s *Session) authenticate(username, password string) error {
if err != nil {
s.backend.Logger.Error("Authentication error: %v", err)
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", err))
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return s.failAuth(451, "Internal server error")
}
if sender != nil && db.CheckPassword(password, sender.PasswordHash) {
@@ -91,6 +93,7 @@ func (s *Session) authenticate(username, password string) error {
if merr != nil {
s.backend.Logger.Error("Mailbox authentication error: %v", merr)
_ = s.backend.DB.LogAuthAttempt("mailbox", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", merr))
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return s.failAuth(451, "Internal server error")
}
if mbox != nil {
@@ -103,6 +106,7 @@ func (s *Session) authenticate(username, password string) error {
}
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Invalid credentials for %s", username))
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return s.failAuth(535, "Authentication failed")
}
+16 -4
View File
@@ -34,9 +34,13 @@ func TestBlockedSenderRejectedAtRcpt(t *testing.T) {
}
}
func TestAllowListBypassesSpamRejection(t *testing.T) {
// TestAllowListBypassesSpamQuarantine confirms a zero reject threshold quarantines a
// non-allow-listed sender's mail into Spam (still accepted at SMTP level — spam is
// stored for review, not silently bounced), while an allow-listed sender's mail
// skips scoring entirely and lands in INBOX as normal.
func TestAllowListBypassesSpamQuarantine(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
// Force every non-allow-listed message to be rejected as spam.
// Force every non-allow-listed message to be quarantined as spam.
backend.Cfg.Section("Mailstore").Key("spam_reject_score").SetValue("0")
send := func(t *testing.T) error {
@@ -63,8 +67,12 @@ func TestAllowListBypassesSpamRejection(t *testing.T) {
return w.Close()
}
if err := send(t); err == nil {
t.Fatal("expected delivery to fail as spam with a zero reject threshold and no allow-list entry")
if err := send(t); err != nil {
t.Fatalf("expected delivery accepted (quarantined) with a zero reject threshold and no allow-list entry, got: %v", err)
}
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
if err != nil || len(spamMsgs) != 1 {
t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err)
}
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "allow", "test@example.com"); err != nil {
@@ -73,6 +81,10 @@ func TestAllowListBypassesSpamRejection(t *testing.T) {
if err := send(t); err != nil {
t.Fatalf("expected delivery to succeed once the sender is allow-listed, got: %v", err)
}
inboxMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil || len(inboxMsgs) != 1 {
t.Fatalf("expected 1 message in INBOX once allow-listed (spam scoring skipped), got %d (err=%v)", len(inboxMsgs), err)
}
}
func TestFilterRuleDeleteDropsMessage(t *testing.T) {
+85
View File
@@ -0,0 +1,85 @@
package smtpserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/smtp"
"testing"
)
// fakeRspamd stands in for a real rspamd instance, always returning the fixed
// score/action given — enough to exercise deliverLocally's rspamd branch without a
// live rspamd deployment.
func fakeRspamd(t *testing.T, score float64, action string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"score": score, "action": action})
}))
t.Cleanup(srv.Close)
return srv
}
func sendTestMessage(t *testing.T, addr, subject string) error {
t.Helper()
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)
}
w.Write([]byte("Subject: " + subject + "\r\n\r\nhi"))
return w.Close()
}
// TestRspamdExplicitRejectActionStillHardRejects confirms rspamd's own "reject"
// action still hard-rejects at SMTP time (unlike a bare score-threshold hit, which is
// quarantined to Spam instead — see TestRspamdScoreThresholdQuarantinesInsteadOfRejecting).
func TestRspamdExplicitRejectActionStillHardRejects(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
rspamd := fakeRspamd(t, 20, "reject")
backend.Cfg.Section("Rspamd").Key("enabled").SetValue("true")
backend.Cfg.Section("Rspamd").Key("url").SetValue(rspamd.URL)
addr := startTestServer(t, backend)
if err := sendTestMessage(t, addr, "hi"); err == nil {
t.Fatal("expected delivery to be hard-rejected when rspamd's action is \"reject\"")
}
inboxMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
spamMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
if len(inboxMsgs) != 0 || len(spamMsgs) != 0 {
t.Fatalf("expected nothing stored anywhere for a hard reject, got INBOX=%d Spam=%d", len(inboxMsgs), len(spamMsgs))
}
}
// TestRspamdScoreThresholdQuarantinesInsteadOfRejecting confirms a bare rspamd score
// over the configured threshold (action something other than "reject") is accepted
// and quarantined into Spam, not bounced.
func TestRspamdScoreThresholdQuarantinesInsteadOfRejecting(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, "hi"); err != nil {
t.Fatalf("expected delivery accepted (quarantined), got: %v", err)
}
spamMsgs, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
if err != nil || len(spamMsgs) != 1 {
t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err)
}
}
+49 -20
View File
@@ -11,6 +11,7 @@ import (
"github.com/emersion/go-smtp"
"gopkg.in/ini.v1"
"mailgoserver/internal/abuseguard"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/mailstore"
@@ -122,6 +123,7 @@ func (s *Session) validateSenderAuthorization(mailFrom string) (accept, authoriz
return true, true, fmt.Sprintf("Sender authorized to send as %s", mailFrom)
}
_ = s.backend.DB.LogAuthAttempt("sender_validation", fmt.Sprintf("%s -> %s", sender.Email, mailFrom), s.peerIP, false, "")
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return false, false, fmt.Sprintf("Sender %s not authorized to send as %s", sender.Email, mailFrom)
}
@@ -137,6 +139,7 @@ func (s *Session) validateSenderAuthorization(mailFrom string) (accept, authoriz
return true, true, fmt.Sprintf("Mailbox authorized to send as alias %s", mailFrom)
}
_ = s.backend.DB.LogAuthAttempt("mailbox_validation", fmt.Sprintf("%s -> %s", mbox.Email, mailFrom), s.peerIP, false, "")
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return false, false, fmt.Sprintf("Mailbox %s not authorized to send as %s", mbox.Email, mailFrom)
}
@@ -153,6 +156,7 @@ func (s *Session) validateSenderAuthorization(mailFrom string) (accept, authoriz
return true, true, fmt.Sprintf("IP authorized for domain %s", fromDomain)
}
_ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, false, fmt.Sprintf("IP %s not authorized for domain %s", s.peerIP, fromDomain))
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
}
@@ -370,39 +374,60 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
results := make([]relay.Result, 0, len(rcpts))
for i, rcpt := range rcpts {
mbox := s.localMailboxes[strings.ToLower(rcpt)]
folder := "INBOX"
markRead := false
// An explicit per-mailbox allow-list entry bypasses spam scoring entirely —
// the built-in heuristic and optional rspamd check both run regardless of each
// other (additive, not either/or), but neither runs at all once allow-listed.
spamGated := false
if allowed, _ := s.backend.DB.IsAllowed(mbox.ID, s.mailFrom); !allowed {
reject := heuristicScore >= rejectScore
if !reject && rspamdEnabled {
if score, action, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil {
if action == "reject" || score >= float64(rspamdRejectScore) {
reject = true
quarantine := heuristicScore >= rejectScore
hardReject := false
if rspamdEnabled {
if score, rAction, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil {
// rspamd's own "reject" action is a considered policy decision
// (DNSBL hit, greylisting, etc.) worth still hard-rejecting at
// SMTP time to avoid backscatter; a bare score threshold hit
// (from either scorer) is quarantined instead of rejected, so a
// false positive is recoverable from the Spam folder rather than
// silently bounced with no trace.
if rAction == "reject" {
hardReject = true
} else if score >= float64(rspamdRejectScore) {
quarantine = true
}
}
// rspamd unreachable/erroring must not block mail — errors are swallowed,
// the built-in heuristic above is still the baseline gate either way.
}
if reject {
if hardReject {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "Message rejected as spam"})
continue
}
if quarantine {
folder = "Spam"
spamGated = true
}
}
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{"from": s.mailFrom, "to": rcpt, "subject": subject})
if err != nil {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()})
continue
}
if action.Drop {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
continue
}
folder := "INBOX"
if action.Folder != "" {
folder = action.Folder
// Filter rules organize legitimate mail the recipient already trusts arriving
// in their INBOX — a quarantined message skips them entirely and always lands
// in Spam, rather than a rule accidentally routing spam back into view.
if !spamGated {
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{"from": s.mailFrom, "to": rcpt, "subject": subject})
if err != nil {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()})
continue
}
if action.Drop {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
continue
}
if action.Folder != "" {
folder = action.Folder
}
markRead = action.MarkRead
}
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, s.mailFrom, subject)
@@ -414,12 +439,16 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: errCode, ErrorMessage: errMsg})
continue
}
if action.MarkRead {
if markRead {
if err := s.backend.DB.SetMessageFlags(mbox.ID, uid, `\Seen`); err != nil {
s.backend.Logger.Error("mark_read rule failed to set flag for message %d: %v", uid, err)
}
}
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Delivered to local mailbox"})
serverResponse := "Delivered to local mailbox"
if spamGated {
serverResponse = "Quarantined to Spam folder"
}
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse})
}
return results
}
+8 -3
View File
@@ -3,6 +3,7 @@ package webui
import (
"bytes"
"encoding/base64"
"html/template"
"image/png"
"net/http"
"strings"
@@ -96,7 +97,11 @@ func (a *App) totpSetupBegin(w http.ResponseWriter, r *http.Request) {
qrDataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
}
a.render(w, r, "totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": qrDataURI})
// html/template's URL-context escaper only allows http/https/mailto schemes for a
// plain string in a src="..." attribute — anything else, including data: URIs,
// gets silently replaced with "#ZgotmplZ" (confirmed live). template.URL marks
// this value as pre-approved so the actual QR image renders instead of nothing.
a.render(w, r, "totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": template.URL(qrDataURI)})
}
// totpSetupConfirm verifies a code against the pending secret and, if correct, flips
@@ -114,7 +119,7 @@ func (a *App) totpSetupConfirm(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "TOTP authenticator enabled")
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "TOTP authenticator enabled")
setFlash(w, "success", "Authenticator app MFA enabled")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
}
@@ -124,7 +129,7 @@ func (a *App) totpDisable(w http.ResponseWriter, r *http.Request) {
if err := a.DB.DisableAdminTOTP(user.ID); err != nil {
setFlash(w, "error", "Something went wrong")
} else {
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "TOTP authenticator disabled")
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "TOTP authenticator disabled")
setFlash(w, "success", "Authenticator app MFA disabled")
}
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
+1 -1
View File
@@ -232,7 +232,7 @@ func (a *App) resetAdminMFA(w http.ResponseWriter, r *http.Request) {
if err := a.DB.ResetAdminMFA(target.ID); err != nil {
setFlash(w, "error", "Error resetting MFA")
} else {
_ = a.DB.LogAuthAttempt("admin_mfa", target.Username, requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username)
_ = a.DB.LogAuthAttempt("admin_mfa", target.Username, a.requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username)
setFlash(w, "success", "MFA reset for "+target.Username)
}
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
+106
View File
@@ -0,0 +1,106 @@
package webui
import (
"net"
"net/http"
)
// blacklistPage lists both currently/recently blacklisted IPs (auto or manual) and the
// abuse-detection whitelist — global-admin only, since a blacklist entry isn't
// attributable to one domain the way a mailbox or relay-whitelist row is.
func (a *App) blacklistPage(w http.ResponseWriter, r *http.Request) {
entries, err := a.DB.ListBlacklist()
if err != nil {
setFlash(w, "error", "Error loading blacklist")
}
whitelist, err := a.DB.ListAbuseWhitelist()
if err != nil {
setFlash(w, "error", "Error loading abuse whitelist")
}
a.render(w, r, "blacklist.html", M{"active": "blacklist", "entries": entries, "whitelist": whitelist})
}
// addBlacklistEntry is an admin-initiated manual block: fixed duration, no escalation.
func (a *App) addBlacklistEntry(w http.ResponseWriter, r *http.Request) {
ip := r.FormValue("ip_address")
reason := r.FormValue("reason")
hours := atoi(r.FormValue("hours"))
if net.ParseIP(ip) == nil || hours <= 0 {
setFlash(w, "error", "A valid IP address and a positive duration in hours are required")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
return
}
if err := a.DB.AddManualBlacklistEntry(ip, reason, hours); err != nil {
setFlash(w, "error", "Error blacklisting IP")
} else {
setFlash(w, "success", "IP blacklisted")
}
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
func (a *App) removeBlacklistEntry(w http.ResponseWriter, r *http.Request) {
if err := a.DB.RemoveBlacklistEntry(pathID(r)); err != nil {
setFlash(w, "error", "Error removing blacklist entry")
} else {
setFlash(w, "success", "Blacklist entry removed")
}
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
// whitelistBlacklistedIP removes ip from the blacklist and adds it to the abuse
// whitelist in one action, so an admin can undo a false-positive auto-block without
// two separate trips.
func (a *App) whitelistBlacklistedIP(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
entries, err := a.DB.ListBlacklist()
if err != nil {
setFlash(w, "error", "Error loading blacklist")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
return
}
var ip string
for _, e := range entries {
if e.ID == id {
ip = e.IPAddress
break
}
}
if ip == "" {
http.NotFound(w, r)
return
}
if err := a.DB.AddAbuseWhitelist(ip, "whitelisted from a blacklist entry"); err != nil {
setFlash(w, "error", "Error whitelisting IP")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
return
}
_ = a.DB.RemoveBlacklistEntry(id)
setFlash(w, "success", ip+" whitelisted and removed from the blacklist")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
func (a *App) addAbuseWhitelistEntry(w http.ResponseWriter, r *http.Request) {
ip := r.FormValue("ip_address")
note := r.FormValue("note")
if net.ParseIP(ip) == nil {
setFlash(w, "error", "A valid IP address is required")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
return
}
if err := a.DB.AddAbuseWhitelist(ip, note); err != nil {
setFlash(w, "error", "Error adding to abuse whitelist")
} else {
setFlash(w, "success", "IP added to the abuse-detection whitelist")
}
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
func (a *App) removeAbuseWhitelistEntry(w http.ResponseWriter, r *http.Request) {
if err := a.DB.RemoveAbuseWhitelist(pathID(r)); err != nil {
setFlash(w, "error", "Error removing abuse whitelist entry")
} else {
setFlash(w, "success", "Removed from the abuse-detection whitelist")
}
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
+83
View File
@@ -0,0 +1,83 @@
package webui
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
)
// csrfSessionCookieNames are tried in order to find whatever session-identifying
// cookie the current request carries — the full admin session, the full mailbox
// session, or either's pending-MFA cookie (covers the MFA step of login, which
// happens before the full session exists but after a pending cookie is set). The
// bare initial login POST, before any of these cookies exist yet, has no source
// token and so is intentionally not CSRF-checked — standard practice, since there's
// no session yet for a forged request to act against.
var csrfSessionCookieNames = []string{
sessionCookieName, mailboxSessionCookieName, pendingMFACookieName, mailboxPendingMFACookieName,
}
func csrfSourceToken(r *http.Request) (string, bool) {
for _, name := range csrfSessionCookieNames {
if c, err := r.Cookie(name); err == nil && c.Value != "" {
return c.Value, true
}
}
return "", false
}
// csrfTokenFor derives this request's expected CSRF token: an HMAC over whatever
// session-identifying cookie is present, keyed by the app secret. Deterministic and
// unstored — recomputed fresh on both render (render.go injects it into every page)
// and validation (CSRFProtect below), so there's no server-side token table to
// manage or expire.
func (a *App) csrfTokenFor(r *http.Request) string {
token, ok := csrfSourceToken(r)
if !ok {
return ""
}
mac := hmac.New(sha256.New, a.appSecret)
mac.Write([]byte(token))
return hex.EncodeToString(mac.Sum(nil))
}
// csrfProtectedMethods are the only ones CSRFProtect checks — GET/HEAD/OPTIONS never
// mutate state in this app (see the M2 fix that removed the one exception that used
// to exist) so they're exempt, matching standard CSRF-defense scope.
var csrfProtectedMethods = map[string]bool{http.MethodPost: true, http.MethodPut: true, http.MethodPatch: true, http.MethodDelete: true}
// CSRFProtect rejects state-changing requests whose csrf_token doesn't match what
// csrfTokenFor computes for the request's own session cookie. Every authenticated
// HTML form gets the token auto-injected as a hidden field, and every same-origin
// fetch() call gets it auto-attached as an X-CSRF-Token header — both via the shared
// csrf_script.html partial parsed into every page (see render.go/loadTemplates) —
// so no individual handler or template needed to change for this to apply
// uniformly across the whole app, admin and webmail alike.
func (a *App) CSRFProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !csrfProtectedMethods[r.Method] {
next.ServeHTTP(w, r)
return
}
expected := a.csrfTokenFor(r)
if expected == "" {
// No session cookie at all — an unauthenticated route (e.g. the login POST
// itself). Nothing to protect yet.
next.ServeHTTP(w, r)
return
}
got := r.Header.Get("X-CSRF-Token")
if got == "" {
// Only fall back to parsing the body if the header wasn't already present —
// keeps the common fetch()-with-header path from ever triggering an implicit
// multipart parse here (the handler still parses it normally afterward).
got = r.FormValue("csrf_token")
}
if got == "" || !hmac.Equal([]byte(got), []byte(expected)) {
http.Error(w, "Forbidden: missing or invalid CSRF token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
+155
View File
@@ -0,0 +1,155 @@
package webui
import (
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
)
var csrfTokenInPage = regexp.MustCompile(`window\.__csrfToken\s*=\s*"([0-9a-f]+)"`)
// TestCSRFProtectionAppliesAcrossAdminAndWebmail is a live-HTTP test (real
// httptest.NewServer wrapped exactly like main.go composes it —
// SecurityHeaders(app.CSRFProtect(mux)) — not just httptest.NewRecorder against the
// bare mux) confirming: a forged/missing CSRF token on a state-changing POST is
// rejected for BOTH an admin route and a webmail route, a real page-driven
// submission (token scraped from the actual rendered page, exactly as the injected
// csrf_script.html partial would hand it to a real form) succeeds, and every
// response carries the new security headers.
func TestCSRFProtectionAppliesAcrossAdminAndWebmail(t *testing.T) {
app := newTestApp(t)
srv := httptest.NewServer(SecurityHeaders(app.CSRFProtect(app.Mux())))
defer srv.Close()
// Security headers present on a plain unauthenticated GET too.
headResp, err := http.Get(srv.URL + Prefix + "/login")
if err != nil {
t.Fatal(err)
}
headResp.Body.Close()
if headResp.Header.Get("X-Frame-Options") != "SAMEORIGIN" {
t.Fatalf("expected X-Frame-Options on every response, got headers: %v", headResp.Header)
}
if headResp.Header.Get("Content-Security-Policy") == "" {
t.Fatal("expected a Content-Security-Policy header")
}
adminCookie := loginSession(t, app)
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "csrf-mailbox@example.com", domains[0].ID, "csrf-password-1!")
mailboxCookie := webmailLoginSession(t, app, mailboxID)
jarClient := func(cookie *http.Cookie) *http.Client {
jar, _ := cookiejar.New(nil)
u, _ := url.Parse(srv.URL)
jar.SetCookies(u, []*http.Cookie{cookie})
return &http.Client{Jar: jar}
}
// Regression check: the compose popup (webmail_compose_widget.html) loads
// /webmail/mail/compose in a same-origin <iframe> — X-Frame-Options: DENY or
// frame-ancestors 'none' would silently break that popup (this exact regression
// shipped once already), so explicitly confirm the compose route itself allows
// same-origin framing.
composeResp, err := jarClient(mailboxCookie).Get(srv.URL + MailboxPrefix + "/mail/compose")
if err != nil {
t.Fatal(err)
}
composeResp.Body.Close()
if fo := composeResp.Header.Get("X-Frame-Options"); fo == "DENY" {
t.Fatalf("compose route sets X-Frame-Options: DENY — this breaks the compose popup's own same-origin iframe")
}
if csp := composeResp.Header.Get("Content-Security-Policy"); strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("compose route's CSP sets frame-ancestors 'none' — this breaks the compose popup's own same-origin iframe, got: %s", csp)
}
scrapeCSRFToken := func(client *http.Client, path string) string {
t.Helper()
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
m := csrfTokenInPage.FindSubmatch(body)
if m == nil {
t.Fatalf("no CSRF token found in rendered page %s: %s", path, body)
}
return string(m[1])
}
// --- Admin route: use the always-present, state-changing "logout" POST (changing
// an account setting would need extra setup like enabling TOTP first).
adminClient := jarClient(adminCookie)
adminToken := scrapeCSRFToken(adminClient, Prefix+"/")
forgedResp, err := adminClient.PostForm(srv.URL+Prefix+"/logout", url.Values{"csrf_token": {"forged-not-real"}})
if err != nil {
t.Fatal(err)
}
forgedResp.Body.Close()
if forgedResp.StatusCode != http.StatusForbidden {
t.Fatalf("admin route: expected 403 for a forged CSRF token, got %d", forgedResp.StatusCode)
}
missingResp, err := adminClient.PostForm(srv.URL+Prefix+"/logout", url.Values{})
if err != nil {
t.Fatal(err)
}
missingResp.Body.Close()
if missingResp.StatusCode != http.StatusForbidden {
t.Fatalf("admin route: expected 403 for a missing CSRF token, got %d", missingResp.StatusCode)
}
realResp, err := adminClient.PostForm(srv.URL+Prefix+"/logout", url.Values{"csrf_token": {adminToken}})
if err != nil {
t.Fatal(err)
}
realResp.Body.Close()
if realResp.StatusCode != http.StatusOK && realResp.StatusCode != http.StatusFound {
t.Fatalf("admin route: expected the real-token logout to succeed, got %d", realResp.StatusCode)
}
// --- Webmail route: use the webmail logout POST the same way.
mailboxClient := jarClient(mailboxCookie)
mailboxToken := scrapeCSRFToken(mailboxClient, MailboxPrefix+"/mail/INBOX")
forgedMail, err := mailboxClient.PostForm(srv.URL+MailboxPrefix+"/logout", url.Values{"csrf_token": {"forged-not-real"}})
if err != nil {
t.Fatal(err)
}
forgedMail.Body.Close()
if forgedMail.StatusCode != http.StatusForbidden {
t.Fatalf("webmail route: expected 403 for a forged CSRF token, got %d", forgedMail.StatusCode)
}
realMail, err := mailboxClient.PostForm(srv.URL+MailboxPrefix+"/logout", url.Values{"csrf_token": {mailboxToken}})
if err != nil {
t.Fatal(err)
}
realMail.Body.Close()
if realMail.StatusCode != http.StatusOK && realMail.StatusCode != http.StatusFound {
t.Fatalf("webmail route: expected the real-token logout to succeed, got %d", realMail.StatusCode)
}
// Also confirm the header-based path works (what the fetch() wrapper uses) —
// re-login first since the account above just logged itself out.
mailboxCookie2 := webmailLoginSession(t, app, mailboxID)
mailboxClient2 := jarClient(mailboxCookie2)
mailboxToken2 := scrapeCSRFToken(mailboxClient2, MailboxPrefix+"/mail/INBOX")
req, _ := http.NewRequest(http.MethodPost, srv.URL+MailboxPrefix+"/logout", strings.NewReader(""))
req.Header.Set("X-CSRF-Token", mailboxToken2)
headerResp, err := mailboxClient2.Do(req)
if err != nil {
t.Fatal(err)
}
headerResp.Body.Close()
if headerResp.StatusCode != http.StatusOK && headerResp.StatusCode != http.StatusFound {
t.Fatalf("webmail route: expected the header-tokened logout to succeed, got %d", headerResp.StatusCode)
}
}
+27 -2
View File
@@ -3,6 +3,7 @@ package webui
import (
"net/http"
"strings"
"time"
"mailgoserver/internal/db"
)
@@ -62,12 +63,36 @@ func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
}
}
a.render(w, r, "dashboard.html", M{
data := M{
"active": "dashboard",
"mailboxes_near_quota": mailboxesNearQuota,
"recent_emails": recentEmails,
"recent_auths": recentAuths,
})
}
// Attack-count tiles: blacklist entries aren't attributable to a single domain
// (see blacklist.go's doc comment), so this is global-admin-only, matching the
// Blacklist page and sidebar section's own gating.
if isGlobal {
now := time.Now()
since24h := now.Add(-24 * time.Hour)
since7d := now.Add(-7 * 24 * time.Hour)
data["failed_auth_24h"], _ = a.DB.CountFailedAuthSince(since24h)
data["failed_auth_7d"], _ = a.DB.CountFailedAuthSince(since7d)
data["blacklist_events_24h"], _ = a.DB.CountBlacklistEventsSince(since24h)
data["blacklist_events_7d"], _ = a.DB.CountBlacklistEventsSince(since7d)
var activeBlacklistCount int
if entries, err := a.DB.ListBlacklist(); err == nil {
for _, e := range entries {
if e.ExpiresAt.After(now) {
activeBlacklistCount++
}
}
}
data["active_blacklist_count"] = activeBlacklistCount
}
a.render(w, r, "dashboard.html", data)
}
// authLogDomain best-effort extracts a domain name from an AuthLog identifier, whose
+15 -4
View File
@@ -43,6 +43,9 @@ func (a *App) loginForm(w http.ResponseWriter, r *http.Request) {
// loginSubmit checks username+password, then either starts a fully-verified session
// (no second factor enabled) or a pending-MFA state that requires /login/mfa next.
func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
if !a.rateLimitLogin(w, r) {
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
next := r.FormValue("next")
@@ -51,6 +54,11 @@ func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "login.html", M{"error": msg, "username": username, "next": next})
}
if a.accountLocked("admin_login", username) {
fail("Too many failed attempts for this account. Try again later.")
return
}
user, err := a.DB.GetAdminUserByUsername(username)
if err != nil {
a.Logger.Error("login lookup: %v", err)
@@ -58,7 +66,7 @@ func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
return
}
if user == nil || !db.CheckPassword(password, user.PasswordHash) {
_ = a.DB.LogAuthAttempt("admin_login", username, requestIP(r), false, "Incorrect username or password")
_ = a.DB.LogAuthAttempt("admin_login", username, a.requestIP(r), false, "Incorrect username or password")
fail("Incorrect username or password.")
return
}
@@ -76,7 +84,7 @@ func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
fail("Something went wrong. Try again.")
return
}
_ = a.DB.LogAuthAttempt("admin_login", username, requestIP(r), true, "Login successful")
_ = a.DB.LogAuthAttempt("admin_login", username, a.requestIP(r), true, "Login successful")
setSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, redirectTarget(next), http.StatusFound)
return
@@ -114,6 +122,9 @@ func (a *App) mfaForm(w http.ResponseWriter, r *http.Request) {
// mfaSubmit verifies the TOTP code for the pending login and, on success, promotes
// the pending state into a real, fully-verified session.
func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) {
if !a.rateLimitLogin(w, r) {
return
}
userID := pendingMFAUserID(r)
next := r.FormValue("next")
if userID == 0 {
@@ -129,7 +140,7 @@ func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.FormValue("code"))
if !user.TOTPEnabled || !totp.Validate(code, user.TOTPSecret) {
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), false, "Invalid MFA code")
_ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), false, "Invalid MFA code")
hasPasskeys, _ := a.DB.CountWebAuthnCredentials(userID)
a.render(w, r, "login_mfa.html", M{
"next": next, "totp_enabled": user.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code.",
@@ -143,7 +154,7 @@ func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), true, "Login successful (authenticator app)")
_ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), true, "Login successful (authenticator app)")
clearPendingMFACookie(w)
setSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, redirectTarget(next), http.StatusFound)
+35 -4
View File
@@ -42,6 +42,11 @@ func (a *App) logs(w http.ResponseWriter, r *http.Request) {
page = 1
}
offset := (page - 1) * perPage
// Read once here (not just inside the "auth" case) so every branch's M literal
// can set it unconditionally — logs.html's pagination links reference it regardless
// of filter_type, and a map[string]any with the key entirely absent renders
// inconsistently across template functions versus one that's always present as "".
authCategory := r.URL.Query().Get("auth_category")
switch filterType {
case "emails":
@@ -58,7 +63,8 @@ func (a *App) logs(w http.ResponseWriter, r *http.Request) {
recipientMap, attachMap := a.buildLogMaps(emails)
a.render(w, r, "logs.html", M{
"active": "logs", "logs": emails, "filter_type": filterType, "page": page,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
"auth_category": authCategory,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
"recipient_logs_map": recipientMap, "attachments_map": attachMap,
})
case "auth":
@@ -68,13 +74,14 @@ func (a *App) logs(w http.ResponseWriter, r *http.Request) {
}
var auths []db.AuthLog
for _, au := range fetched {
if authAllowed(au) {
if authAllowed(au) && authCategoryMatches(au.AuthType, authCategory) {
auths = append(auths, au)
}
}
a.render(w, r, "logs.html", M{
"active": "logs", "logs": auths, "filter_type": filterType, "page": page,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
"auth_category": authCategory,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
})
default:
half := perPage / 2
@@ -109,11 +116,35 @@ func (a *App) logs(w http.ResponseWriter, r *http.Request) {
}
a.render(w, r, "logs.html", M{
"active": "logs", "logs": logs, "filter_type": filterType, "page": page,
"has_next": len(logs) > perPage, "has_prev": page > 1,
"auth_category": authCategory,
"has_next": len(logs) > perPage, "has_prev": page > 1,
})
}
}
// authCategoryMatches buckets esrv_auth_logs.auth_type values into "admin" (dashboard
// login/MFA), "webmail" (mailbox portal login/MFA), or "mailserver" (SMTP/IMAP — the
// same set abuseguard counts, see crud_ip_blacklist.go's smtpImapAuthTypesSQL). An
// empty category matches everything (no filter applied).
func authCategoryMatches(authType, category string) bool {
switch category {
case "", "all":
return true
case "admin":
return authType == "admin_login" || authType == "admin_mfa"
case "webmail":
return authType == "webmail_login" || authType == "mailbox_mfa"
case "mailserver":
switch authType {
case "sender", "mailbox", "sender_validation", "mailbox_validation", "ip", "imap_login":
return true
}
return false
default:
return true
}
}
func (a *App) buildLogMaps(emails []db.EmailLog) (map[int64][]db.EmailRecipientLog, map[int64][]db.EmailAttachment) {
recipientMap := map[int64][]db.EmailRecipientLog{}
attachMap := map[int64][]db.EmailAttachment{}
+62
View File
@@ -0,0 +1,62 @@
package webui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestAuthCategoryMatches(t *testing.T) {
cases := []struct {
authType, category string
want bool
}{
{"admin_login", "admin", true},
{"admin_mfa", "admin", true},
{"webmail_login", "admin", false},
{"webmail_login", "webmail", true},
{"mailbox_mfa", "webmail", true},
{"sender", "webmail", false},
{"sender", "mailserver", true},
{"mailbox", "mailserver", true},
{"sender_validation", "mailserver", true},
{"mailbox_validation", "mailserver", true},
{"ip", "mailserver", true},
{"imap_login", "mailserver", true},
{"admin_login", "mailserver", false},
{"anything", "", true},
{"anything", "all", true},
}
for _, c := range cases {
if got := authCategoryMatches(c.authType, c.category); got != c.want {
t.Errorf("authCategoryMatches(%q, %q) = %v, want %v", c.authType, c.category, got, c.want)
}
}
}
// TestLogsAuthCategoryFilterEndToEnd confirms the ?auth_category= query param actually
// filters the rendered auth-log rows, not just the pure bucketing function above.
func TestLogsAuthCategoryFilterEndToEnd(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
app.DB.LogAuthAttempt("admin_login", "someadmin", "203.0.113.1", false, "bad password")
app.DB.LogAuthAttempt("sender", "someone@example.com", "203.0.113.2", false, "bad password")
req := httptest.NewRequest(http.MethodGet, Prefix+"/logs?type=auth&auth_category=mailserver", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "someone@example.com") {
t.Error("mailserver category should include the sender auth failure")
}
if strings.Contains(body, "someadmin") {
t.Error("mailserver category should exclude the admin_login failure")
}
}
+50 -6
View File
@@ -4,11 +4,51 @@ import (
"net/http"
"strconv"
"strings"
"mailgoserver/internal/db"
)
var validConditionFields = map[string]bool{"from": true, "to": true, "subject": true}
var validConditionOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
var validActions = map[string]bool{"move_to_folder": true, "delete": true, "mark_read": true}
var validActions = map[string]bool{"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true}
// parseRuleConditions reads the rule-builder's parallel condition_field/op/value
// arrays (one value per condition row, same index across all three) — shared by the
// admin and self-service "add rule" handlers, which both submit the same form shape.
// r.ParseForm() must already have been called.
func parseRuleConditions(r *http.Request) ([]db.RuleCondition, bool) {
fields := r.PostForm["condition_field"]
ops := r.PostForm["condition_op"]
values := r.PostForm["condition_value"]
if len(fields) == 0 || len(fields) != len(ops) || len(fields) != len(values) {
return nil, false
}
conditions := make([]db.RuleCondition, 0, len(fields))
for i, field := range fields {
op := ops[i]
value := strings.TrimSpace(values[i])
if !validConditionFields[field] || !validConditionOps[op] || value == "" {
return nil, false
}
conditions = append(conditions, db.RuleCondition{Field: field, Op: op, Value: value})
}
return conditions, true
}
// summarizeConditions renders a rule's condition list as a human-readable string for
// display, e.g. `to contains "sales" AND subject contains "invoice"`.
func summarizeConditions(r db.MailboxFilterRule) string {
conditions, matchType := r.Conditions()
joiner := " AND "
if matchType == "any" {
joiner = " OR "
}
parts := make([]string, len(conditions))
for i, c := range conditions {
parts[i] = c.Field + " " + strings.ReplaceAll(c.Op, "_", " ") + ` "` + c.Value + `"`
}
return strings.Join(parts, joiner)
}
func (a *App) rulesList(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
@@ -29,14 +69,18 @@ func (a *App) addRule(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form submission")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
return
}
priority, _ := strconv.Atoi(r.FormValue("priority"))
field := r.FormValue("condition_field")
op := r.FormValue("condition_op")
value := strings.TrimSpace(r.FormValue("condition_value"))
matchType := r.FormValue("match_type")
action := r.FormValue("action")
actionValue := strings.TrimSpace(r.FormValue("action_value"))
if !validConditionFields[field] || !validConditionOps[op] || value == "" || !validActions[action] {
conditions, ok := parseRuleConditions(r)
if !ok || !validActions[action] {
setFlash(w, "error", "Please fill in a valid condition and action")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
return
@@ -46,7 +90,7 @@ func (a *App) addRule(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
return
}
if _, err := a.DB.CreateRule(mailbox.ID, priority, field, op, value, action, actionValue); err != nil {
if _, err := a.DB.CreateRuleMulti(mailbox.ID, priority, conditions, matchType, action, actionValue); err != nil {
setFlash(w, "error", "Error creating rule")
} else {
setFlash(w, "success", "Rule added")
+55
View File
@@ -0,0 +1,55 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestAdminRulesAddMultiConditionAndRenders confirms the admin-side rule builder
// (mirroring the self-service one) accepts a multi-condition submission and that the
// rules list page actually renders it (ruleSummary executes correctly at runtime,
// not just parses at template-load time).
func TestAdminRulesAddMultiConditionAndRenders(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
domains, _ := app.DB.ListDomains()
mbox := createMailboxFor(t, app, "adminruler@example.com", domains[0].ID)
form := url.Values{
"priority": {"0"},
"match_type": {"all"},
"condition_field": {"to", "subject"},
"condition_op": {"contains", "contains"},
"condition_value": {"sales", "invoice"},
"action": {"mark_as_spam"},
"action_value": {""},
}
addReq := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mbox.ID, 10)+"/rules/add", strings.NewReader(form.Encode()))
addReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
addReq.AddCookie(cookie)
addRec := httptest.NewRecorder()
mux.ServeHTTP(addRec, addReq)
if addRec.Code != http.StatusFound {
t.Fatalf("add rule: status=%d body=%s", addRec.Code, addRec.Body.String())
}
listReq := httptest.NewRequest(http.MethodGet, Prefix+"/mailboxes/"+strconv.FormatInt(mbox.ID, 10)+"/rules", nil)
listReq.AddCookie(cookie)
listRec := httptest.NewRecorder()
mux.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("rules list: status=%d body=%s", listRec.Code, listRec.Body.String())
}
body := listRec.Body.String()
if !strings.Contains(body, "to contains &#34;sales&#34;") || !strings.Contains(body, "AND") {
t.Fatalf("expected the rendered condition summary to show both AND'd conditions, got: %s", body)
}
if !strings.Contains(body, "Mark as Spam") {
t.Fatal("expected the mark_as_spam action to render")
}
}
+1 -1
View File
@@ -141,7 +141,7 @@ func (a *App) resetMailboxMFA(w http.ResponseWriter, r *http.Request) {
if err := a.DB.ResetMailboxMFA(mailbox.ID); err != nil {
setFlash(w, "error", "Error resetting MFA")
} else {
_ = a.DB.LogAuthAttempt("mailbox_mfa", mailbox.Email, requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username)
_ = a.DB.LogAuthAttempt("mailbox_mfa", mailbox.Email, a.requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username)
setFlash(w, "success", "MFA reset for "+mailbox.Email)
}
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
+13 -6
View File
@@ -134,9 +134,9 @@ func TestMailboxMFAEnforcementLetsLoginThroughButIsolatesEverythingElse(t *testi
t.Fatal("expected a session cookie despite no MFA configured")
}
// The dashboard, password change, and app-password creation are ALL redirected
// to the isolated setup page — nothing else is reachable in the browser.
blockedGets := []string{MailboxPrefix + "/"}
// The mailbox, account page, password change, and app-password creation are ALL
// redirected to the isolated setup page — nothing else is reachable in the browser.
blockedGets := []string{MailboxPrefix + "/", MailboxPrefix + "/account"}
for _, path := range blockedGets {
req = httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
@@ -193,8 +193,8 @@ func TestMailboxMFAEnforcementLetsLoginThroughButIsolatesEverythingElse(t *testi
t.Error("expected no other portal sections on the isolated setup page")
}
// Once TOTP is configured, everything works normally again — dashboard, password
// change, and app passwords.
// Once TOTP is configured, everything works normally again — mailbox, account
// page, password change, and app passwords.
if err := app.DB.SetMailboxTOTPSecret(mboxID, "JBSWY3DPEHPK3PXP", true); err != nil {
t.Fatal(err)
}
@@ -202,8 +202,15 @@ func TestMailboxMFAEnforcementLetsLoginThroughButIsolatesEverythingElse(t *testi
req.AddCookie(cookie)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != MailboxPrefix+"/mail/INBOX" {
t.Fatalf("mailbox root: expected reachable (redirect to inbox) after enabling MFA, got %d Location=%q", rec.Code, rec.Header().Get("Location"))
}
req = httptest.NewRequest(http.MethodGet, MailboxPrefix+"/account", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard: expected reachable after enabling MFA, got %d", rec.Code)
t.Fatalf("account page: expected reachable after enabling MFA, got %d", rec.Code)
}
req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/password", strings.NewReader(pwForm.Encode()))
+13 -2
View File
@@ -1,12 +1,15 @@
package webui
import "net/http"
import (
"net/http"
"time"
)
// navCounts are the small per-resource counts shown as sidebar badges on every page
// (and reused by the dashboard's own stat tiles, which use the same numbers) — scoped
// to the current admin exactly like every list page already is.
type navCounts struct {
DomainCount, SenderCount, MailboxCount, IPCount, DKIMCount int
DomainCount, SenderCount, MailboxCount, IPCount, DKIMCount, BlacklistCount int
}
func (a *App) computeNavCounts(r *http.Request) navCounts {
@@ -17,6 +20,14 @@ func (a *App) computeNavCounts(r *http.Request) navCounts {
c.DomainCount, _ = a.DB.CountActiveDomains()
c.SenderCount, _ = a.DB.CountActiveSenders()
c.DKIMCount, _ = a.DB.CountActiveDKIMKeys()
if entries, err := a.DB.ListBlacklist(); err == nil {
now := time.Now()
for _, e := range entries {
if e.ExpiresAt.After(now) {
c.BlacklistCount++
}
}
}
} else {
domains, _ := a.DB.ListDomains()
for _, d := range domains {
+74
View File
@@ -0,0 +1,74 @@
package webui
import (
"net/http"
"sync"
"time"
)
// ipRateLimiter is a small in-memory fixed-window counter — same map+mutex shape as
// pgpKeyCache elsewhere in this package. Bounds how many login POSTs a single source
// IP can make per window, independent of the per-account lockout in login.go/
// webmail_login.go (that one tracks failures against one identifier from any IP;
// this one bounds request volume from one IP regardless of which account(s) it's
// trying — the two layers catch different attack shapes: a botnet spreading guesses
// across many accounts, versus one machine hammering a single account).
type ipRateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
counts map[string]*ipWindow
}
type ipWindow struct {
count int
windowEnds time.Time
}
func newIPRateLimiter(limit int, window time.Duration) *ipRateLimiter {
return &ipRateLimiter{limit: limit, window: window, counts: map[string]*ipWindow{}}
}
// allow reports whether ip may make another request right now, incrementing its
// count as a side effect. Expired windows reset lazily on next access rather than
// via a background sweep — fine at this app's scale (a handful of login attempts
// per real user), and avoids a goroutine that outlives the App's own lifecycle.
func (l *ipRateLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
w, ok := l.counts[ip]
if !ok || now.After(w.windowEnds) {
w = &ipWindow{count: 0, windowEnds: now.Add(l.window)}
l.counts[ip] = w
}
w.count++
return w.count <= l.limit
}
// rateLimitLogin replies 429 and returns false if the request's source IP has
// exceeded the per-IP login rate limit — callers should return immediately without
// touching the DB or checking a password when this returns false.
func (a *App) rateLimitLogin(w http.ResponseWriter, r *http.Request) bool {
if a.loginLimiter.allow(a.requestIP(r)) {
return true
}
http.Error(w, "Too many login attempts — try again in a minute.", http.StatusTooManyRequests)
return false
}
// accountLocked reports whether authType/identifier has accumulated enough recent
// failures (from any IP — see ratelimit.go's doc comment for why that's the point)
// to refuse another attempt right now, per the [Auth] login_attempt_limit/window_minutes
// config. Fails open (returns false) on a DB error rather than locking everyone out
// over a transient issue.
func (a *App) accountLocked(authType, identifier string) bool {
limit := a.Cfg.Section("Auth").Key("login_attempt_limit").MustInt(8)
windowMinutes := a.Cfg.Section("Auth").Key("login_attempt_window_minutes").MustInt(15)
since := time.Now().Add(-time.Duration(windowMinutes) * time.Minute)
n, err := a.DB.CountRecentFailedAttempts(authType, identifier, since)
if err != nil {
return false
}
return n >= limit
}
+95
View File
@@ -0,0 +1,95 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestWebmailLoginAccountLockout confirms repeated wrong-password attempts against
// one account eventually get refused with a generic lockout message rather than
// checking the password at all, and that the lockout doesn't touch a different
// account from the same IP (the per-IP throttle, not this per-account layer, would
// apply there).
func TestWebmailLoginAccountLockout(t *testing.T) {
app := newTestApp(t)
app.Cfg.Section("Auth").Key("login_attempt_limit").SetValue("3")
mux := app.Mux()
domains, _ := app.DB.ListDomains()
victimID := createTestMailboxWithPassword(t, app, "lockout-victim@example.com", domains[0].ID, "the-real-password-1!")
otherID := createTestMailboxWithPassword(t, app, "lockout-other@example.com", domains[0].ID, "another-password-1!")
attempt := func(email, password string) *httptest.ResponseRecorder {
form := url.Values{"email": {email}, "password": {password}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "203.0.113.9:12345"
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
for i := 0; i < 3; i++ {
rec := attempt("lockout-victim@example.com", "wrong password")
if !strings.Contains(rec.Body.String(), "Incorrect email or password") {
t.Fatalf("attempt %d: expected a normal wrong-password error, got: %s", i, rec.Body.String())
}
}
locked := attempt("lockout-victim@example.com", "the-real-password-1!") // even the CORRECT password now
if !strings.Contains(locked.Body.String(), "Too many failed attempts") {
t.Fatalf("expected the account locked out after repeated failures, got: %s", locked.Body.String())
}
if locked.Result().Cookies() != nil {
for _, c := range locked.Result().Cookies() {
if c.Name == mailboxSessionCookieName && c.Value != "" {
t.Fatal("expected no session granted while locked out, even with the correct password")
}
}
}
// A different account from the same IP is unaffected by the per-account lockout.
rec := attempt("lockout-other@example.com", "another-password-1!")
found := false
for _, c := range rec.Result().Cookies() {
if c.Name == mailboxSessionCookieName {
found = true
}
}
if !found {
t.Fatal("expected a different account from the same IP to log in normally")
}
_ = victimID
_ = otherID
}
// TestLoginRateLimitPerIP confirms the per-IP throttle kicks in independent of which
// account is being tried, once enough requests arrive from one source IP.
func TestLoginRateLimitPerIP(t *testing.T) {
app := newTestApp(t)
app.loginLimiter = newIPRateLimiter(3, 1<<62) // tiny limit, effectively-infinite window for a deterministic test
mux := app.Mux()
domains, _ := app.DB.ListDomains()
createTestMailboxWithPassword(t, app, "ratelimit@example.com", domains[0].ID, "correct-password-1!")
attempt := func() *httptest.ResponseRecorder {
form := url.Values{"email": {"ratelimit@example.com"}, "password": {"correct-password-1!"}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "203.0.113.10:12345"
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
for i := 0; i < 3; i++ {
if rec := attempt(); rec.Code == http.StatusTooManyRequests {
t.Fatalf("attempt %d: unexpectedly rate-limited early", i)
}
}
if rec := attempt(); rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 past the per-IP limit, got %d", rec.Code)
}
}
+60 -13
View File
@@ -43,16 +43,17 @@ func (a *App) funcMap() template.FuncMap {
// isPast reports whether a nullable expiry timestamp has already passed —
// used to badge an app password as "Expired" even while is_active is
// still 1 (expiry and revocation are independent states).
"isPast": func(t *time.Time) bool { return t != nil && t.Before(time.Now()) },
"title": strings.Title,
"upper": strings.ToUpper,
"lower": strings.ToLower,
"safe": func(s string) template.HTML { return template.HTML(s) },
"filesize": humanFileSize,
"dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"eq2": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
"isPast": func(t *time.Time) bool { return t != nil && t.Before(time.Now()) },
"ruleSummary": summarizeConditions,
"title": strings.Title,
"upper": strings.ToUpper,
"lower": strings.ToLower,
"safe": func(s string) template.HTML { return template.HTML(s) },
"filesize": humanFileSize,
"dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"eq2": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
// dget looks up an optional map key, returning "" if absent — mirrors Jinja's
// `x if x is defined else ''` pattern used for context vars only some pages set
// (e.g. sidebar badge counts, which only dashboard passes).
@@ -62,7 +63,8 @@ func (a *App) funcMap() template.FuncMap {
}
return ""
},
"list": func(items ...string) []string { return items },
"list": func(items ...string) []string { return items },
"isStandardFolder": isStandardFolder,
// emailOverallStatus mirrors the delivered/failed selectattr computation
// dashboard.html and logs.html both do in the Python templates.
"emailOverallStatus": func(recipients []db.EmailRecipientLog) string {
@@ -130,6 +132,7 @@ var pages = []string{
"mailboxes.html", "add_mailbox.html", "edit_mailbox.html", "mailbox_apppasswords.html", "mailbox_aliases.html",
"mailbox_lists.html", "mailbox_rules.html",
"ips.html", "add_ip.html", "edit_ip.html",
"blacklist.html",
"dkim.html", "edit_dkim.html",
"settings.html", "letsencrypt.html", "logs.html", "view_message_content.html", "error.html",
"account.html", "first_login.html",
@@ -144,6 +147,39 @@ var pages = []string{
var standalonePages = []string{
"login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html",
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html",
"webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
}
// pagesWithComposeWidget are the standalone pages that show a Compose/Reply/Forward
// entry point and so need webmail_compose_widget.html's floating-popup markup+JS
// parsed alongside them (see webmail_compose_widget.html's {{define "compose_widget"}}).
// webmail_compose.html itself is excluded — it's what opens inside the popup, not
// something that opens a popup of its own.
var pagesWithComposeWidget = []string{
"webmail_folder.html", "webmail_message.html", "webmail_rules.html", "webmail_certs.html", "webmail_account.html",
}
func hasComposeWidget(page string) bool {
for _, p := range pagesWithComposeWidget {
if p == page {
return true
}
}
return false
}
// pagesWithShortcuts are the two pages keyboard shortcuts make sense on — the
// message list (j/k/Enter/o) and a single open message (r/a/f/#). See
// webmail_shortcuts.html's {{define "webmail_shortcuts"}}.
var pagesWithShortcuts = []string{"webmail_folder.html", "webmail_message.html"}
func hasShortcuts(page string) bool {
for _, p := range pagesWithShortcuts {
if p == page {
return true
}
}
return false
}
// loadTemplates parses from the embedded assets FS (see embed.go), not the
@@ -153,7 +189,7 @@ func (a *App) loadTemplates() error {
a.templates = map[string]*template.Template{}
for _, page := range pages {
t := template.New("base.html").Funcs(a.funcMap())
t, err := t.ParseFS(assets, "templates/base.html", "templates/sidebar_email.html", "templates/"+page)
t, err := t.ParseFS(assets, "templates/base.html", "templates/sidebar_email.html", "templates/csrf_script.html", "templates/"+page)
if err != nil {
return fmt.Errorf("parse %s: %w", page, err)
}
@@ -161,7 +197,14 @@ func (a *App) loadTemplates() error {
}
for _, page := range standalonePages {
t := template.New(page).Funcs(a.funcMap())
t, err := t.ParseFS(assets, "templates/"+page)
files := []string{"templates/" + page, "templates/csrf_script.html"}
if hasComposeWidget(page) {
files = append(files, "templates/webmail_compose_widget.html")
}
if hasShortcuts(page) {
files = append(files, "templates/webmail_shortcuts.html")
}
t, err := t.ParseFS(assets, files...)
if err != nil {
return fmt.Errorf("parse %s: %w", page, err)
}
@@ -190,6 +233,9 @@ func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M
if data == nil {
data = M{}
}
// Set unconditionally for every page — pages with no session cookie yet (login)
// just get "", which csrf_script.html's injected script treats as a no-op.
data["csrf_token"] = a.csrfTokenFor(r)
if isStandalonePage(page) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, page, data); err != nil {
@@ -214,6 +260,7 @@ func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M
data["mailbox_count"] = counts.MailboxCount
data["ip_count"] = counts.IPCount
data["dkim_count"] = counts.DKIMCount
data["blacklist_count"] = counts.BlacklistCount
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base.html", data); err != nil {
a.Logger.Error("template render error (%s): %v", page, err)
+39
View File
@@ -0,0 +1,39 @@
package webui
import (
"crypto/rand"
"fmt"
"os"
"path/filepath"
)
const appSecretSize = 32
// LoadOrCreateAppSecret reads the app's CSRF-signing secret from path, generating a
// fresh random one on first run if the file doesn't exist yet — mirrors
// mailstore.LoadOrCreateMasterKey's identical generate-if-missing pattern for the
// mailstore encryption key. Unlike that key, losing this one has no data-loss
// consequence: every outstanding CSRF token just stops validating, so users get
// logged-out-feeling form-submit errors until they reload a page for a fresh one.
func LoadOrCreateAppSecret(path string) ([]byte, error) {
if b, err := os.ReadFile(path); err == nil {
if len(b) != appSecretSize {
return nil, fmt.Errorf("app secret at %s is %d bytes, want %d", path, len(b), appSecretSize)
}
return b, nil
} else if !os.IsNotExist(err) {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
secret := make([]byte, appSecretSize)
if _, err := rand.Read(secret); err != nil {
return nil, err
}
if err := os.WriteFile(path, secret, 0o600); err != nil {
return nil, err
}
return secret, nil
}
+47
View File
@@ -0,0 +1,47 @@
package webui
import "net/http"
// contentSecurityPolicy — Bootstrap/Bootstrap Icons/Quill are all vendored locally
// under static/vendor/ (no CDN dependency left anywhere), so this only needs 'self'.
//
// frame-ancestors 'self' (not 'none'): the webmail compose popup
// (webmail_compose_widget.html) legitimately loads /webmail/mail/compose in an
// <iframe> on the SAME origin — 'none' blocks ALL framing including that, which
// broke the popup entirely (it's framing, just not the third-party clickjacking
// kind this header exists to stop). 'self' still blocks any other site from
// framing this app.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"font-src 'self'; " +
"img-src 'self' data: https:; " +
"frame-ancestors 'self'"
// SecurityHeaders sets standard hardening headers on every response — wrapped once
// around the whole app's handler in main.go so admin and webmail routes (and login,
// static assets, /health) all get it uniformly, rather than duplicating the wrap at
// multiple mux-registration points.
//
// 'unsafe-inline' is required for both script-src and style-src: every template in
// this codebase uses inline <script>/<style> blocks (no nonce or hash pipeline
// exists), so a strict CSP would break every page. This still meaningfully narrows
// the attack surface versus no CSP at all — it blocks loading script/style/fonts
// from any origin other than this server and jsdelivr, which is what actually
// matters against a stored-XSS-via-inbound-HTML-mail scenario (the message view
// sanitizes HTML mail with bluemonday before rendering, but CSP is defense in depth
// for exactly that class of bug).
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
// SAMEORIGIN, not DENY: the compose popup frames /webmail/mail/compose in an
// iframe on this same origin — DENY blocked that too (see
// contentSecurityPolicy's frame-ancestors comment for the matching CSP fix).
h.Set("X-Frame-Options", "SAMEORIGIN")
h.Set("Referrer-Policy", "same-origin")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
h.Set("Content-Security-Policy", contentSecurityPolicy)
next.ServeHTTP(w, r)
})
}
+35
View File
@@ -0,0 +1,35 @@
package webui
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestSecurityHeadersSetOnEveryResponse confirms the hardening headers are present
// regardless of which underlying handler produced the response — admin, webmail, or
// anything else, since main.go wraps the whole app's handler once with this rather
// than per-route.
func TestSecurityHeadersSetOnEveryResponse(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
handler := SecurityHeaders(inner)
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
cases := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "SAMEORIGIN",
"Referrer-Policy": "same-origin",
"Content-Security-Policy": contentSecurityPolicy,
}
for header, want := range cases {
if got := rec.Header().Get(header); got != want {
t.Errorf("header %s = %q, want %q", header, got, want)
}
}
if rec.Header().Get("Permissions-Policy") == "" {
t.Error("expected a Permissions-Policy header")
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -13,8 +13,8 @@
}
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root { --sidebar-width: 280px; }
@@ -65,6 +65,7 @@
{{block "extra_css" .}}{{end}}
</head>
<body>
{{template "csrf_script" .}}
<div class="main-container">
{{template "sidebar_email.html" .}}
@@ -128,7 +129,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/pymta-manager/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
function updateTime() {
+116
View File
@@ -0,0 +1,116 @@
{{define "title"}}Blacklist - Email Server{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-shield-x me-2"></i>SMTP/IMAP Abuse Blacklist</h2>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list me-2"></i>Blacklisted IP Addresses</h5></div>
<div class="card-body">
{{if .entries}}
<div class="table-responsive">
<table class="table table-striped">
<thead><tr><th>IP Address</th><th>Reason</th><th>Offense #</th><th>Type</th><th>Blacklisted</th><th>Expires</th><th>Actions</th></tr></thead>
<tbody>
{{range .entries}}
<tr>
<td><div class="fw-bold font-monospace">{{.IPAddress}}</div></td>
<td><small class="text-muted">{{.Reason}}</small></td>
<td>{{.OffenseCount}}</td>
<td>{{if .Manual}}<span class="badge bg-secondary">Manual</span>{{else}}<span class="badge bg-warning text-dark">Auto</span>{{end}}</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .BlacklistedAt}}</small></td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .ExpiresAt}}</small></td>
<td>
<div class="btn-group" role="group">
<form method="post" action="/pymta-manager/blacklist/{{.ID}}/whitelist" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm" title="Whitelist this IP" data-confirm="Remove {{.IPAddress}} from the blacklist and exempt it from abuse detection?"><i class="bi bi-shield-check"></i></button>
</form>
<form method="post" action="/pymta-manager/blacklist/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove entry" data-confirm="Remove the blacklist entry for {{.IPAddress}}?"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-4">
<i class="bi bi-shield-check text-muted" style="font-size: 3rem;"></i>
<h5 class="text-muted mt-3">No IPs currently blacklisted</h5>
</div>
{{end}}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Manually Blacklist an IP</h6></div>
<div class="card-body">
<form method="post" action="/pymta-manager/blacklist/add" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">IP Address</label>
<input type="text" name="ip_address" class="form-control font-monospace" placeholder="203.0.113.7" required>
</div>
<div class="col-md-4">
<label class="form-label small">Reason</label>
<input type="text" name="reason" class="form-control" placeholder="Optional">
</div>
<div class="col-md-2">
<label class="form-label small">Hours</label>
<input type="number" name="hours" class="form-control" value="12" min="1" required>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-danger w-100"><i class="bi bi-shield-x me-1"></i>Blacklist</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Abuse-Detection Whitelist</h5></div>
<div class="card-body">
<div class="alert alert-info small">
IPs here are exempt from automatic blacklisting for failed SMTP/IMAP auth. This is separate
from the <a href="/pymta-manager/ips">relay whitelist</a>, which authorizes unauthenticated sending for a domain.
</div>
{{if .whitelist}}
<div class="table-responsive mb-3">
<table class="table table-striped">
<thead><tr><th>IP Address</th><th>Note</th><th>Added</th><th>Actions</th></tr></thead>
<tbody>
{{range .whitelist}}
<tr>
<td><div class="fw-bold font-monospace">{{.IPAddress}}</div></td>
<td><small class="text-muted">{{.Note}}</small></td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
<td>
<form method="post" action="/pymta-manager/abuse-whitelist/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove {{.IPAddress}} from the abuse-detection whitelist?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form method="post" action="/pymta-manager/abuse-whitelist/add" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">IP Address</label>
<input type="text" name="ip_address" class="form-control font-monospace" placeholder="203.0.113.7" required>
</div>
<div class="col-md-5">
<label class="form-label small">Note</label>
<input type="text" name="note" class="form-control" placeholder="Optional">
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus-circle me-1"></i>Add</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
+41
View File
@@ -0,0 +1,41 @@
{{define "csrf_script"}}
<script>
// Auto-applies CSRF protection to every plain <form method=post> and every
// same-origin mutating fetch() call on this page — no per-form or per-fetch-call
// changes needed anywhere else in this codebase. See internal/webui/csrf.go for
// the server-side check this token has to satisfy.
window.__csrfToken = {{.csrf_token}};
(function() {
var token = window.__csrfToken;
if (!token) return; // no session cookie yet (e.g. the login page itself)
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('form').forEach(function(form) {
if ((form.getAttribute('method') || '').toLowerCase() !== 'post') return;
if (form.querySelector('input[name="csrf_token"]')) return;
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'csrf_token';
input.value = token;
form.appendChild(input);
});
});
var mutating = { POST: true, PUT: true, PATCH: true, DELETE: true };
var originalFetch = window.fetch.bind(window);
window.fetch = function(url, options) {
options = options || {};
var method = (options.method || 'GET').toUpperCase();
var isAbsolute = /^https?:\/\//i.test(url);
var isSameOrigin = !isAbsolute || url.indexOf(window.location.origin) === 0;
if (mutating[method] && isSameOrigin) {
options = Object.assign({}, options);
var headers = new Headers(options.headers || {});
headers.set('X-CSRF-Token', token);
options.headers = headers;
}
return originalFetch(url, options);
};
})();
</script>
{{end}}
+37
View File
@@ -181,6 +181,43 @@
</div>
</div>
{{if dget . "is_global_admin"}}
<div class="row">
<div class="col-12 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-shield-x me-2"></i>Attack Activity</h5>
<a href="/pymta-manager/blacklist" class="btn btn-outline-light btn-sm">View Blacklist</a>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 col-md-2 mb-3 mb-md-0">
<div class="fs-3 {{if gt (dget . "active_blacklist_count") 0}}text-danger{{else}}text-secondary{{end}}">{{dget . "active_blacklist_count"}}</div>
<small class="text-muted">Active Blocks</small>
</div>
<div class="col-6 col-md-2 mb-3 mb-md-0">
<div class="fs-3 text-warning">{{dget . "failed_auth_24h"}}</div>
<small class="text-muted">Failed Auth (24h)</small>
</div>
<div class="col-6 col-md-2 mb-3 mb-md-0">
<div class="fs-3 text-warning">{{dget . "failed_auth_7d"}}</div>
<small class="text-muted">Failed Auth (7d)</small>
</div>
<div class="col-6 col-md-2 mb-3 mb-md-0">
<div class="fs-3 text-danger">{{dget . "blacklist_events_24h"}}</div>
<small class="text-muted">Blacklist Events (24h)</small>
</div>
<div class="col-6 col-md-2">
<div class="fs-3 text-danger">{{dget . "blacklist_events_7d"}}</div>
<small class="text-muted">Blacklist Events (7d)</small>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
<div class="row">
<div class="col-12">
<div class="card">
+3 -2
View File
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in - mailgoserver</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-envelope-fill" style="font-size: 2.5rem;"></i>
+3 -2
View File
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify it's you - mailgoserver</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
+13 -2
View File
@@ -22,6 +22,17 @@
</div>
</div>
{{if eq .filter_type "auth"}}
<div class="d-flex justify-content-end mb-3">
<div class="btn-group btn-group-sm">
<a href="/pymta-manager/logs?type=auth" class="btn {{if or (eq .auth_category "") (eq .auth_category "all")}}btn-secondary{{else}}btn-outline-secondary{{end}}">All</a>
<a href="/pymta-manager/logs?type=auth&auth_category=admin" class="btn {{if eq .auth_category "admin"}}btn-secondary{{else}}btn-outline-secondary{{end}}">Admin</a>
<a href="/pymta-manager/logs?type=auth&auth_category=webmail" class="btn {{if eq .auth_category "webmail"}}btn-secondary{{else}}btn-outline-secondary{{end}}">Webmail</a>
<a href="/pymta-manager/logs?type=auth&auth_category=mailserver" class="btn {{if eq .auth_category "mailserver"}}btn-secondary{{else}}btn-outline-secondary{{end}}">Mail Server</a>
</div>
</div>
{{end}}
<div class="row">
<div class="col-12">
<div class="card">
@@ -134,9 +145,9 @@
{{if or .has_prev .has_next}}
<nav aria-label="Log pagination" class="mt-4">
<ul class="pagination justify-content-center">
{{if .has_prev}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&page={{sub .page 1}}"><i class="bi bi-chevron-left"></i> Previous</a></li>{{end}}
{{if .has_prev}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&auth_category={{.auth_category}}&page={{sub .page 1}}"><i class="bi bi-chevron-left"></i> Previous</a></li>{{end}}
<li class="page-item active"><span class="page-link">Page {{.page}}</span></li>
{{if .has_next}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&page={{add .page 1}}">Next <i class="bi bi-chevron-right"></i></a></li>{{end}}
{{if .has_next}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&auth_category={{.auth_category}}&page={{add .page 1}}">Next <i class="bi bi-chevron-right"></i></a></li>{{end}}
</ul>
</nav>
{{end}}
+75 -36
View File
@@ -14,44 +14,68 @@
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Rule</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules/add" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Priority</label>
<input type="number" class="form-control" name="priority" value="0" style="width: 90px;">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules/add">
<div class="row g-2 align-items-end mb-3">
<div class="col-auto">
<label class="form-label">Priority</label>
<input type="number" class="form-control" name="priority" value="0" style="width: 90px;">
</div>
<div class="col-auto">
<label class="form-label">Match</label>
<select class="form-select" name="match_type">
<option value="all">ALL of the following (AND)</option>
<option value="any">ANY of the following (OR)</option>
</select>
</div>
</div>
<div class="col-auto">
<label class="form-label">If</label>
<select class="form-select" name="condition_field">
<option value="from">From</option>
<option value="to">To</option>
<option value="subject">Subject</option>
</select>
</div>
<div class="col-auto">
<select class="form-select" name="condition_op">
<option value="contains">contains</option>
<option value="equals">equals</option>
<option value="starts_with">starts with</option>
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control" name="condition_value" placeholder="value" required>
</div>
<div class="col-auto">
<label class="form-label">Then</label>
<select class="form-select" name="action" id="rule_action">
<option value="move_to_folder">Move to folder</option>
<option value="delete">Delete</option>
<option value="mark_read">Mark as read</option>
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control" name="action_value" id="rule_action_value" placeholder="folder name">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>Add Rule</button>
<div id="conditions_container"></div>
<button type="button" id="add_condition" class="btn btn-outline-secondary btn-sm mb-3"><i class="bi bi-plus-lg me-1"></i>Add condition</button>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Then</label>
<select class="form-select" name="action" id="rule_action">
<option value="move_to_folder">Move to folder</option>
<option value="mark_as_spam">Mark as Spam</option>
<option value="delete">Delete</option>
<option value="mark_read">Mark as read</option>
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control" name="action_value" id="rule_action_value" placeholder="folder name">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>Add Rule</button>
</div>
</div>
</form>
<template id="condition_row_template">
<div class="row g-2 align-items-end mb-2 condition-row">
<div class="col-auto">
<label class="form-label">If</label>
<select class="form-select" name="condition_field">
<option value="from">From</option>
<option value="to">To</option>
<option value="subject">Subject</option>
</select>
</div>
<div class="col-auto">
<select class="form-select" name="condition_op">
<option value="contains">contains</option>
<option value="equals">equals</option>
<option value="starts_with">starts with</option>
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control" name="condition_value" placeholder="value" required>
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-danger btn-sm remove-condition" title="Remove condition"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</template>
</div>
</div>
@@ -66,9 +90,10 @@
{{range .rules}}
<tr>
<td>{{.Priority}}</td>
<td><code>{{.ConditionField}} {{.ConditionOp}} "{{.ConditionValue}}"</code></td>
<td><code>{{ruleSummary .}}</code></td>
<td>
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
{{else}}Mark as read{{end}}
</td>
@@ -100,5 +125,19 @@ document.getElementById('rule_action').addEventListener('change', function(e) {
const valueInput = document.getElementById('rule_action_value');
valueInput.style.display = e.target.value === 'move_to_folder' ? '' : 'none';
});
function addConditionRow() {
const tpl = document.getElementById('condition_row_template');
const container = document.getElementById('conditions_container');
const clone = document.importNode(tpl.content, true);
clone.querySelector('.remove-condition').addEventListener('click', function() {
if (container.children.length > 1) {
this.closest('.condition-row').remove();
}
});
container.appendChild(clone);
}
document.getElementById('add_condition').addEventListener('click', addConditionRow);
addConditionRow();
</script>
{{end}}
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up two-factor authentication - mailgoserver</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.setup-card { max-width: 480px; margin: 0 auto; width: 100%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
@@ -55,7 +56,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/pymta-manager/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 6000}).show(); });
@@ -86,6 +86,22 @@
</a>
</li>
{{if dget . "is_global_admin"}}
<li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3">
<i class="bi bi-shield-lock me-1"></i>
Security
</h6>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/blacklist" class="nav-link text-white {{if eq (dget . "active") "blacklist"}}active{{end}}">
<i class="bi bi-shield-x me-2"></i>
Blacklist
<span class="badge bg-secondary ms-auto">{{dget . "blacklist_count"}}</span>
</a>
</li>
{{end}}
<li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3">
<i class="bi bi-gear me-1"></i>
+3 -2
View File
@@ -5,14 +5,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up authenticator app - mailgoserver</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6">
+15 -6
View File
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.mailbox.Email}} - Webmail</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
@@ -14,12 +14,19 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<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="post" action="/webmail/logout" class="ms-auto">
<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 class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<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>
<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>
<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>
</nav>
@@ -170,7 +177,9 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{{template "compose_widget" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
const TOAST_AUTOHIDE_MS = 5000;
function armToastAutoDismiss(toastEl, bsToast) {
+327
View File
@@ -0,0 +1,327 @@
{{define "webmail_certs.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Certs - Webmail</title>
<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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<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>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<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>
<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-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>
</nav>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
{{.Message}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
</div>
<div class="container pb-5">
<h4 class="mb-4"><i class="bi bi-shield-lock me-2"></i>Certs</h4>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>Two separate systems live here, each doing one job: <strong>S/MIME certificates sign</strong> outgoing mail (proves it came from you and wasn't altered) — <strong>PGP keys encrypt</strong> it (only the recipient can read it). They're different standards with different key formats; a message can use either, both, or neither. PGP private keys are protected by their own passphrase (never stored anywhere), so you'll be asked for it the first time you use one each session.
</div>
<h5 class="mb-3"><i class="bi bi-pen me-2"></i>S/MIME Certificates <small class="text-muted fs-6">— for signing</small></h5>
<div class="card mb-4">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-person-badge me-2"></i>Your Certificates</h6></div>
<div class="card-body">
{{if .identities}}
<div class="table-responsive mb-4">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Expires</th><th>Actions</th></tr></thead>
<tbody>
{{range .identities}}
<tr>
<td>{{.NotAfter.Format "2006-01-02"}}</td>
<td class="d-flex gap-2">
<a href="/webmail/smime/identity/{{.ID}}/download" class="btn btn-outline-light btn-sm"><i class="bi bi-download me-1"></i>Download</a>
<form method="post" action="/webmail/smime/identity/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove this S/MIME certificate? Mail signed with it will no longer verify."><i class="bi bi-trash me-1"></i>Remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted">No S/MIME certificates yet. Generate a free self-signed certificate, or import one you already have (.p12/.pfx).</p>
{{end}}
<hr class="my-4">
<div class="row g-4">
<div class="col-md-6">
<h6>Generate New</h6>
<form method="post" action="/webmail/smime/identity/generate" class="row g-2">
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-magic me-1"></i>Generate Self-Signed Certificate</button>
</div>
</form>
</div>
<div class="col-md-6">
<h6>Import Existing (.p12 / .pfx)</h6>
<form method="post" action="/webmail/smime/identity/import" enctype="multipart/form-data" class="row g-2">
<div class="col-12">
<input type="file" class="form-control form-control-sm" name="p12_file" accept=".p12,.pfx" required>
</div>
<div class="col-12">
<input type="password" class="form-control form-control-sm" name="p12_password" placeholder=".p12 export password (if any)">
</div>
<div class="col-12">
<button type="submit" class="btn btn-secondary btn-sm">Import</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="card mb-5">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>S/MIME Contact Certificates</h6></div>
<div class="card-body">
<form method="post" action="/webmail/smime/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-4">
<div class="col-auto">
<label class="form-label">Email</label>
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
</div>
<div class="col-auto">
<label class="form-label">Certificate (.pem/.crt/.cer)</label>
<input type="file" class="form-control form-control-sm" name="cert_file" accept=".pem,.crt,.cer" required>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success btn-sm"><i class="bi bi-plus-circle me-1"></i>Add Contact</button>
</div>
</form>
{{if .contacts}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Email</th><th>Added</th><th>Actions</th></tr></thead>
<tbody>
{{range .contacts}}
<tr>
<td>{{.Email}}</td>
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
<td>
<form method="post" action="/webmail/smime/contacts/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this contact's certificate?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted mb-0">No contact certificates yet. They're also captured automatically when you open a validly signed email from someone new.</p>
{{end}}
</div>
</div>
<h5 class="mb-3"><i class="bi bi-lock me-2"></i>PGP Keys <small class="text-muted fs-6">— for encryption</small></h5>
<div class="card mb-4">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-key me-2"></i>Your Keys</h6></div>
<div class="card-body">
{{if .pgp_identities}}
<div class="table-responsive mb-4">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Label</th><th>Fingerprint</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{{range .pgp_identities}}
<tr>
<td>{{if .Label}}{{.Label}}{{else}}<span class="text-muted">(no label)</span>{{end}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{if index $.pgp_unlocked .ID}}<span class="badge bg-success">Unlocked this session</span>{{else}}<span class="badge bg-secondary">Locked</span>{{end}}</td>
<td class="d-flex gap-2">
<a href="/webmail/pgp/identity/{{.ID}}/download" class="btn btn-outline-light btn-sm"><i class="bi bi-download me-1"></i>Download Public Key</a>
<form method="post" action="/webmail/pgp/identity/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove this PGP key? Mail encrypted to it will no longer decrypt."><i class="bi bi-trash me-1"></i>Remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted">No PGP keys yet. Generate a new keypair, or import one you already have (an ASCII-armored .asc export from e.g. GnuPG).</p>
{{end}}
<hr class="my-4">
<div class="row g-4">
<div class="col-md-6">
<h6>Generate New</h6>
<form method="post" action="/webmail/pgp/identity/generate" class="row g-2">
<div class="col-12">
<input type="text" class="form-control form-control-sm" name="label" placeholder="Label (e.g. &quot;Work&quot;) — optional, helps tell keys apart">
</div>
<div class="col-12">
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="Choose a passphrase (min 8 chars)" required minlength="8">
</div>
<div class="col-12">
<input type="password" class="form-control form-control-sm" name="passphrase_confirm" placeholder="Confirm passphrase" required minlength="8">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-magic me-1"></i>Generate PGP Key</button>
</div>
</form>
</div>
<div class="col-md-6">
<h6>Import Existing (.asc)</h6>
<form method="post" action="/webmail/pgp/identity/import" enctype="multipart/form-data" class="row g-2">
<div class="col-12">
<input type="text" class="form-control form-control-sm" name="label" placeholder="Label — optional">
</div>
<div class="col-12">
<input type="file" class="form-control form-control-sm" name="key_file" accept=".asc,.pem,.gpg" required>
</div>
<div class="col-12">
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="The key's passphrase (its own, or a new one if it has none)" required>
</div>
<div class="col-12">
<button type="submit" class="btn btn-secondary btn-sm">Import</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>PGP Contact Keys</h6></div>
<div class="card-body">
<form method="post" action="/webmail/pgp/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-4">
<div class="col-auto">
<label class="form-label">Email</label>
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
</div>
<div class="col-auto">
<label class="form-label">Label</label>
<input type="text" class="form-control form-control-sm" name="label" placeholder="optional">
</div>
<div class="col-auto">
<label class="form-label">Public Key (.asc)</label>
<input type="file" class="form-control form-control-sm" name="key_file" accept=".asc,.pem,.gpg" required>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success btn-sm"><i class="bi bi-plus-circle me-1"></i>Add Contact</button>
</div>
</form>
{{if .pgp_contacts}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Email</th><th>Label</th><th>Fingerprint</th><th>Added</th><th>Actions</th></tr></thead>
<tbody>
{{range .pgp_contacts}}
<tr>
<td>{{.Email}}</td>
<td>{{.Label}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
<td>
<form method="post" action="/webmail/pgp/contacts/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this contact's PGP key?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted mb-0">No contact keys yet. Ask a sender for their public key export, or grab it from a keyserver, and add it here before you can encrypt mail to them.</p>
{{end}}
</div>
</div>
</div>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
</div>
</div>
</div>
</div>
{{template "compose_widget" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
function showConfirmation(message) {
return new Promise((resolve) => {
const modal = document.getElementById('confirmationModal');
document.getElementById('confirmationModalBody').textContent = message;
const confirmButton = document.getElementById('confirmationModalConfirm');
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
const handleCancel = () => { resolve(false); cleanup(); };
const cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
modal.removeEventListener('hidden.bs.modal', handleCancel);
};
confirmButton.addEventListener('click', handleConfirm);
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
new bootstrap.Modal(modal).show();
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-confirm]').forEach(function(button) {
button.addEventListener('click', async function(e) {
e.preventDefault();
if (await showConfirmation(this.getAttribute('data-confirm'))) {
const form = this.closest('form');
if (form) form.submit();
}
});
});
});
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,408 @@
{{define "webmail_compose.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Compose - Webmail</title>
<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/quill/quill.snow.css" rel="stylesheet">
<style>
:root { --cw-bg: #1e1e1e; --cw-panel: #262626; --cw-border: #404040; --cw-text: #e0e0e0; --cw-muted: #9a9a9a; }
html, body { height: 100%; margin: 0; overflow: hidden; }
body { background-color: var(--cw-bg); color: var(--cw-text); font-size: .875rem; }
.compose-shell { display: flex; flex-direction: column; height: 100vh; }
.compose-actionbar { flex: 0 0 auto; display: flex; align-items: center; gap: .5rem; padding: .5rem .75rem; border-bottom: 1px solid var(--cw-border); background-color: var(--cw-panel); flex-wrap: wrap; }
.compose-actionbar .from-email { font-size: .8rem; color: var(--cw-muted); }
.compose-actionbar select.from-select { max-width: 220px; }
.compose-fields { flex: 0 0 auto; padding: 0 .75rem; }
.field-row { display: flex; align-items: center; border-bottom: 1px solid var(--cw-border); padding: .3rem 0; }
.field-row .field-label { width: 42px; flex: 0 0 auto; color: var(--cw-muted); font-size: .8rem; }
.field-row input.bare-input { flex: 1 1 auto; min-width: 0; border: 0; background: transparent; color: var(--cw-text); outline: none; font-size: .85rem; padding: .2rem 0; }
.field-row input.bare-input::placeholder { color: var(--cw-muted); }
.cc-bcc-toggle { flex: 0 0 auto; font-size: .75rem; color: #6ea8fe; cursor: pointer; margin-left: .5rem; white-space: nowrap; }
.cc-bcc-toggle:hover { text-decoration: underline; }
.subject-row input.bare-input { font-size: .95rem; padding: .4rem 0; }
.crypto-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .4rem 0; font-size: .78rem; color: var(--cw-muted); }
.crypto-row .form-check { margin-bottom: 0; }
.crypto-row .form-check-label { font-size: .8rem; }
.editor-wrap { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; padding: 0 .75rem .5rem; }
.editor-wrap.drag-over { outline: 2px dashed #6ea8fe; outline-offset: -4px; }
#editor { flex: 1 1 auto; min-height: 0; background-color: #fff; color: #000; }
.ql-toolbar.ql-snow { flex: 0 0 auto; background-color: #333; border-color: var(--cw-border); border-top-left-radius: .375rem; border-top-right-radius: .375rem; }
.ql-container.ql-snow { border-color: var(--cw-border); border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; }
/* Quill's default snow-theme icons are near-black (#444) — invisible on a dark
toolbar. Light stroke/fill + light picker text restores contrast. */
.ql-snow .ql-stroke { stroke: #c8c8c8; }
.ql-snow .ql-fill, .ql-snow .ql-stroke.ql-fill { fill: #c8c8c8; }
.ql-snow .ql-picker { color: #c8c8c8; }
.ql-snow .ql-picker-label { color: #c8c8c8; border-color: transparent; }
.ql-snow .ql-picker-options { background-color: var(--cw-panel); border-color: var(--cw-border); }
.ql-snow .ql-picker-item { color: #c8c8c8; }
.ql-toolbar.ql-snow button:hover, .ql-toolbar.ql-snow button.ql-active,
.ql-toolbar.ql-snow .ql-picker-label:hover, .ql-toolbar.ql-snow .ql-picker-label.ql-active,
.ql-toolbar.ql-snow .ql-picker-item:hover { color: #fff; }
.ql-toolbar.ql-snow button:hover .ql-stroke, .ql-toolbar.ql-snow button.ql-active .ql-stroke,
.ql-toolbar.ql-snow button:hover .ql-fill, .ql-toolbar.ql-snow button.ql-active .ql-fill { stroke: #fff; }
.ql-snow .ql-tooltip { background-color: var(--cw-panel); color: var(--cw-text); border-color: var(--cw-border); box-shadow: 0 2px 8px rgba(0,0,0,.4); }
.ql-snow .ql-tooltip input[type="text"] { background-color: var(--cw-bg); color: var(--cw-text); border-color: var(--cw-border); }
.attachment-list:not(:empty) { padding: .4rem 0; display: flex; flex-wrap: wrap; gap: .4rem; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
{{.Message}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
</div>
<form id="composeForm" method="POST" action="/webmail/mail/compose" enctype="multipart/form-data" class="compose-shell">
<input type="hidden" name="in_reply_to" value="{{.in_reply_to}}">
<input type="hidden" name="draft_id" value="{{.draft_id}}">
<div class="compose-actionbar">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-send me-1"></i>Send</button>
<button type="submit" formaction="/webmail/mail/save-draft" formnovalidate class="btn btn-outline-light btn-sm"><i class="bi bi-save2 me-1"></i>Save</button>
<small class="from-email" id="autosaveStatus" style="display: none;"></small>
<button type="button" id="attachBtn" class="btn btn-outline-light btn-sm" title="Attach files"><i class="bi bi-paperclip"></i></button>
<input type="file" id="attachment_input" name="attachments" multiple style="display: none;">
<div class="ms-auto d-flex align-items-center gap-2">
{{if .send_as_options}}
<select class="form-select form-select-sm from-select" name="from">
<option value="{{.mailbox.Email}}">{{.mailbox.Email}}</option>
{{range .send_as_options}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
{{else}}
<span class="from-email">{{.mailbox.Email}}</span>
{{end}}
<a href="/webmail/mail/INBOX" class="btn btn-sm btn-outline-light" title="Close"><i class="bi bi-x-lg"></i></a>
</div>
</div>
<div class="compose-fields">
<div class="field-row">
<label class="field-label">To</label>
<input type="text" class="bare-input recipient-input" name="to" value="{{.to}}" placeholder="recipient@example.com, another@example.com" list="recipientSuggestions" autocomplete="off" required>
<span class="cc-bcc-toggle" id="showCc">Cc</span>
<span class="cc-bcc-toggle" id="showBcc">Bcc</span>
</div>
<div class="field-row" id="ccRow" style="display: none;">
<label class="field-label">Cc</label>
<input type="text" class="bare-input recipient-input" name="cc" value="{{.cc}}" list="recipientSuggestions" autocomplete="off">
</div>
<div class="field-row" id="bccRow" style="display: none;">
<label class="field-label">Bcc</label>
<input type="text" class="bare-input recipient-input" name="bcc" value="{{.bcc}}" list="recipientSuggestions" autocomplete="off">
</div>
<datalist id="recipientSuggestions"></datalist>
<div class="field-row subject-row">
<input type="text" class="bare-input" name="subject" value="{{.subject}}" placeholder="Add a subject">
</div>
<div class="crypto-row">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="smime_sign" value="1" id="smime_sign">
<label class="form-check-label" for="smime_sign"><i class="bi bi-pen me-1"></i>Sign (S/MIME)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="pgp_encrypt" value="1" id="pgp_encrypt">
<label class="form-check-label" for="pgp_encrypt"><i class="bi bi-lock me-1"></i>Encrypt (PGP)</label>
</div>
{{if gt (len .smime_identities) 1}}
<div id="smime_identity_row" style="display: none;">
<select class="form-select form-select-sm" name="smime_identity_id">
{{range .smime_identities}}<option value="{{.ID}}">Certificate expiring {{.NotAfter.Format "2006-01-02"}}</option>{{end}}
</select>
</div>
{{end}}
<div id="pgp_recipient_row" style="display: none;">
{{if .pgp_contacts}}
<select class="form-select form-select-sm" name="pgp_recipient_id" multiple size="3" style="min-width: 220px;">
{{range .pgp_contacts}}<option value="{{.ID}}">{{if .Label}}{{.Label}}{{else}}{{.Email}}{{end}} — {{.Fingerprint}}</option>{{end}}
</select>
{{else}}
<span>No PGP contacts yet — add one on the Certs page.</span>
{{end}}
</div>
<a href="/webmail/certs" class="ms-auto text-muted">Manage certificates</a>
</div>
<div id="attachment_list" class="attachment-list"></div>
</div>
<div class="editor-wrap" id="editorWrap">
<div id="editor"></div>
<textarea name="body_html" style="display: none;"></textarea>
</div>
<div id="body_html_seed" style="display: none;">{{.body_html}}</div>
</form>
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/webmail/static/vendor/quill/quill.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
// Quill's default clipboard module already turns a pasted screenshot into an
// embedded base64 <img> — that's what makes "paste a screenshot like Outlook"
// work with no extra code here.
var quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }, { background: [] }],
[{ size: ['small', false, 'large', 'huge'] }],
['link', 'image'],
['clean'],
],
},
});
(function() {
const seed = document.getElementById('body_html_seed');
if (seed && seed.innerHTML.trim()) {
quill.root.innerHTML = seed.innerHTML;
// Reply/forward seeds start with an empty line above the quoted
// original (see composeCursorHome, webmail_compose.go) — put the
// cursor there so typing starts above the quote, not inside or after it.
quill.setSelection(0, 0);
quill.focus();
}
})();
// Cc/Bcc start hidden (Outlook-style) unless prefilled (e.g. reply-all sets
// Cc) — clicking a toggle reveals its row and focuses the field, same as
// clicking "Cc"/"Bcc" in a real mail client.
(function() {
function wireToggle(toggleId, rowId, fieldName) {
const toggle = document.getElementById(toggleId);
const row = document.getElementById(rowId);
const field = row.querySelector('[name="' + fieldName + '"]');
if (field.value.trim()) {
row.style.display = 'flex';
toggle.style.display = 'none';
} else {
toggle.addEventListener('click', function() {
row.style.display = 'flex';
toggle.style.display = 'none';
field.focus();
});
}
}
wireToggle('showCc', 'ccRow', 'cc');
wireToggle('showBcc', 'bccRow', 'bcc');
})();
// Attachments accumulate across multiple picks/drops instead of the native
// file input's default "replace the whole selection" behavior — a DataTransfer
// is the only way to build an editable FileList, so it's kept as the source of
// truth and re-assigned onto the real (hidden) input before every render.
// Drag-and-drop works anywhere over the message area, matching a real client —
// there's no dedicated dropzone box taking up space when empty.
(function() {
const form = document.getElementById('composeForm');
const dropTarget = document.getElementById('editorWrap');
const attachBtn = document.getElementById('attachBtn');
const input = document.getElementById('attachment_input');
const list = document.getElementById('attachment_list');
let staged = new DataTransfer();
function render() {
input.files = staged.files;
list.innerHTML = '';
Array.from(staged.files).forEach(function(file, i) {
const chip = document.createElement('span');
chip.className = 'badge text-bg-secondary d-flex align-items-center gap-1 py-2 px-2';
chip.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'btn-close btn-close-white ms-1';
remove.style.fontSize = '0.65rem';
remove.setAttribute('aria-label', 'Remove');
remove.addEventListener('click', function() { removeFile(i); });
chip.appendChild(remove);
list.appendChild(chip);
});
}
function addFiles(fileList) {
Array.from(fileList).forEach(function(file) { staged.items.add(file); });
render();
}
function removeFile(index) {
const next = new DataTransfer();
Array.from(staged.files).forEach(function(file, i) { if (i !== index) next.items.add(file); });
staged = next;
render();
}
attachBtn.addEventListener('click', function() { input.click(); });
input.addEventListener('change', function() { addFiles(input.files); });
form.addEventListener('dragover', function(e) { e.preventDefault(); dropTarget.classList.add('drag-over'); });
form.addEventListener('dragleave', function(e) { if (e.target === form) dropTarget.classList.remove('drag-over'); });
form.addEventListener('drop', function(e) {
e.preventDefault();
dropTarget.classList.remove('drag-over');
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
});
window.__composeAttachmentCount = function() { return staged.files.length; };
})();
// The identity picker (when there's more than one S/MIME certificate) only
// matters for signing — encrypting alone never touches it.
(function() {
const signCheckbox = document.getElementById('smime_sign');
const identityRow = document.getElementById('smime_identity_row');
if (!identityRow) return;
function update() { identityRow.style.display = signCheckbox.checked ? '' : 'none'; }
signCheckbox.addEventListener('change', update);
update();
})();
// The recipient-key picker only matters for encrypting.
(function() {
const encryptCheckbox = document.getElementById('pgp_encrypt');
const recipientRow = document.getElementById('pgp_recipient_row');
function update() { recipientRow.style.display = encryptCheckbox.checked ? '' : 'none'; }
encryptCheckbox.addEventListener('change', update);
update();
})();
// Recipient autocomplete: suggests addresses this mailbox has exchanged mail
// with, matching the fragment being typed after the last comma (a
// To/Cc/Bcc field holds a comma-separated address list, so only the
// in-progress fragment should be matched/replaced, not the whole value).
(function() {
const datalist = document.getElementById('recipientSuggestions');
let debounceTimer = null;
document.querySelectorAll('.recipient-input').forEach(function(input) {
input.addEventListener('input', function() {
clearTimeout(debounceTimer);
const value = input.value;
const splitAt = value.lastIndexOf(',') + 1;
const fragment = value.slice(splitAt).trim();
if (!fragment) { datalist.innerHTML = ''; return; }
debounceTimer = setTimeout(function() {
fetch('/webmail/mail/recipients?q=' + encodeURIComponent(fragment))
.then(function(r) { return r.json(); })
.then(function(suggestions) {
datalist.innerHTML = '';
const prefix = value.slice(0, splitAt);
(suggestions || []).forEach(function(addr) {
const opt = document.createElement('option');
opt.value = (prefix ? prefix + ' ' : '') + addr;
datalist.appendChild(opt);
});
})
.catch(function() {});
}, 200);
});
});
})();
// Draft autosave: periodically saves in the background if anything's
// changed since the last autosave, reusing the existing Save Draft endpoint
// exactly as a manual click would submit it — no backend change needed. The
// browser follows the redirect to /webmail/mail/compose?draft=ID&folder=Drafts,
// and that URL's draft param becomes the new draft_id so the next autosave (or
// manual Send) updates the same draft instead of creating a new one.
// Deliberately text-only: attachments aren't re-uploaded on every tick (a
// real Save or Send still includes them) to avoid repeatedly re-sending
// large file data in the background.
(function() {
const form = document.getElementById('composeForm');
const draftIdInput = document.querySelector('[name="draft_id"]');
const status = document.getElementById('autosaveStatus');
let lastSaved = null;
function snapshot() {
return JSON.stringify({
to: document.querySelector('[name="to"]').value,
cc: document.querySelector('[name="cc"]').value,
bcc: document.querySelector('[name="bcc"]').value,
subject: document.querySelector('[name="subject"]').value,
body: quill.root.innerHTML,
});
}
async function autosave() {
const current = snapshot();
if (current === lastSaved) return;
const subject = document.querySelector('[name="subject"]').value.trim();
const to = document.querySelector('[name="to"]').value.trim();
if (!to && !subject && !quill.getText().trim()) return; // nothing worth saving yet
document.querySelector('[name="body_html"]').value = quill.root.innerHTML;
const formData = new FormData(form);
formData.delete('attachments');
try {
const resp = await fetch('/webmail/mail/save-draft', { method: 'POST', body: formData });
if (resp.ok) {
const draftId = new URL(resp.url).searchParams.get('draft');
if (draftId) draftIdInput.value = draftId;
lastSaved = current;
if (status) {
status.textContent = 'Saved ' + new Date().toLocaleTimeString();
status.style.display = '';
}
}
} catch (e) { /* offline or a transient error — retried next interval */ }
}
setInterval(autosave, 30000);
})();
// Enter in a single-line field (To/Cc/Bcc/Subject) implicitly submits the
// form — that's how a stray keypress used to send a blank email.
['to', 'cc', 'bcc', 'subject'].forEach(function(name) {
const el = document.querySelector('[name="' + name + '"]');
if (el) {
el.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); }
});
}
});
document.getElementById('composeForm').addEventListener('submit', function(e) {
document.querySelector('[name="body_html"]').value = quill.root.innerHTML;
// Save (formaction=save-draft) deliberately skips the subject/body-required
// guard below — a draft can be incomplete by definition.
const isDraftSave = e.submitter && e.submitter.getAttribute('formaction') === '/webmail/mail/save-draft';
if (isDraftSave) return;
const subject = document.querySelector('[name="subject"]').value.trim();
const body = quill.getText().trim();
const hasAttachments = window.__composeAttachmentCount && window.__composeAttachmentCount() > 0;
if (!subject) {
e.preventDefault();
alert('Please add a subject before sending.');
return;
}
if (!body && !hasAttachments) {
e.preventDefault();
alert('Please write a message or add an attachment before sending.');
return;
}
});
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,122 @@
{{define "compose_widget"}}
<div id="composePopup" class="card shadow" style="display: none; position: fixed; z-index: 1080; min-width: 320px; min-height: 200px; resize: none; overflow: hidden; background-color: #2d2d2d; border: 1px solid #404040;">
<div id="composePopupHandle" class="card-header d-flex justify-content-between align-items-center py-1" style="cursor: move; user-select: none;">
<span class="small"><i class="bi bi-pencil-square me-1"></i>Compose</span>
<div>
<button type="button" id="composePopupClose" class="btn btn-sm btn-outline-light border-0 py-0 px-2" title="Close"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<iframe id="composePopupFrame" style="border: 0; width: 100%; flex: 1 1 auto;"></iframe>
<div id="composePopupResize" style="position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize;"></div>
</div>
<script>
// A floating window over the existing /webmail/mail/compose page (loaded as-is
// in an iframe) — Outlook-style compose without rewriting compose itself as a
// modal. Position/size persist in localStorage so it reopens where it was left.
(function() {
const POS_KEY = 'webmail_compose_popup_pos';
const popup = document.getElementById('composePopup');
const handle = document.getElementById('composePopupHandle');
const frame = document.getElementById('composePopupFrame');
const resizeHandle = document.getElementById('composePopupResize');
const closeBtn = document.getElementById('composePopupClose');
function defaultRect() {
const w = Math.min(720, window.innerWidth - 40);
const h = Math.min(600, window.innerHeight - 40);
return { top: Math.max(20, (window.innerHeight - h) / 2), left: Math.max(20, (window.innerWidth - w) / 2), width: w, height: h };
}
function clampRect(r) {
const width = Math.max(320, Math.min(r.width, window.innerWidth - 20));
const height = Math.max(200, Math.min(r.height, window.innerHeight - 20));
const left = Math.max(0, Math.min(r.left, window.innerWidth - width));
const top = Math.max(0, Math.min(r.top, window.innerHeight - height));
return { top, left, width, height };
}
function loadRect() {
try {
const saved = JSON.parse(localStorage.getItem(POS_KEY));
if (saved && typeof saved.top === 'number') return clampRect(saved);
} catch (e) { /* fall through to default */ }
return defaultRect();
}
function saveRect(r) {
localStorage.setItem(POS_KEY, JSON.stringify(r));
}
function applyRect(r) {
popup.style.top = r.top + 'px';
popup.style.left = r.left + 'px';
popup.style.width = r.width + 'px';
popup.style.height = r.height + 'px';
}
function currentRect() {
return {
top: parseFloat(popup.style.top) || 0,
left: parseFloat(popup.style.left) || 0,
width: parseFloat(popup.style.width) || 0,
height: parseFloat(popup.style.height) || 0,
};
}
window.openCompose = function(url) {
applyRect(loadRect());
popup.style.display = 'flex';
popup.style.flexDirection = 'column';
frame.src = url;
};
function closePopup() {
popup.style.display = 'none';
frame.src = 'about:blank';
}
closeBtn.addEventListener('click', closePopup);
// The compose form navigates the iframe on success (send/save-draft both
// redirect away from /mail/compose) — treat that as "done": close the popup
// and refresh the page underneath so the new Sent/Drafts entry shows up.
frame.addEventListener('load', function() {
let path;
try { path = frame.contentWindow.location.pathname; } catch (e) { return; }
if (frame.src === 'about:blank' || path.indexOf('/webmail/mail/compose') !== -1) return;
closePopup();
window.location.reload();
});
let dragOffset = null;
handle.addEventListener('mousedown', function(e) {
if (e.target.closest('button')) return;
const r = popup.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
e.preventDefault();
});
let resizing = false;
resizeHandle.addEventListener('mousedown', function(e) {
resizing = true;
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (dragOffset) {
const r = clampRect({ top: e.clientY - dragOffset.y, left: e.clientX - dragOffset.x, width: currentRect().width, height: currentRect().height });
applyRect(r);
} else if (resizing) {
const r = popup.getBoundingClientRect();
const rect = clampRect({ top: r.top, left: r.left, width: e.clientX - r.left, height: e.clientY - r.top });
applyRect(rect);
}
});
document.addEventListener('mouseup', function() {
if (dragOffset || resizing) saveRect(currentRect());
dragOffset = null;
resizing = false;
});
})();
</script>
{{end}}
@@ -0,0 +1,282 @@
{{define "webmail_folder.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{if .search_query}}Search: {{.search_query}}{{else}}{{.active_folder}}{{end}} - Webmail</title>
<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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.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; }
.msg-unread { font-weight: 600; }
.msg-row { cursor: grab; }
.msg-row.dragging { opacity: 0.4; }
.folder-link.drop-hover { background-color: #0d6efd; color: #fff !important; outline: 2px dashed #6ea8fe; outline-offset: -2px; }
.folder-unread-badge { font-size: .7rem; }
.msg-row-older { display: none; }
.msg-group-toggle { cursor: pointer; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<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>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<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>
<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>
</nav>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
{{.Message}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
</div>
<div class="container-fluid pb-5">
<div class="row">
<div class="col-lg-2 mb-4">
<div class="card">
<div class="card-body p-2">
<form method="get" action="/webmail/mail/search" class="mb-2">
<div class="input-group input-group-sm">
<input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}">
<button type="submit" class="btn btn-outline-light"><i class="bi bi-search"></i></button>
</div>
</form>
<div class="list-group list-group-flush">
{{$active := .active_folder}}
{{$unread := .unread_counts}}
{{range .folders}}
<div class="d-flex align-items-center folder-row">
<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}}">
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
{{$n := index $unread .}}
{{if $n}}<span class="badge bg-primary rounded-pill folder-unread-badge">{{$n}}</span>{{end}}
</a>
{{if not (isStandardFolder .)}}
<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>
</form>
{{end}}
</div>
{{end}}
</div>
<hr class="my-2">
<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>
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
</form>
</div>
</div>
</div>
<div class="col-lg-10 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
{{if .search_query}}<i class="bi bi-search me-2"></i>Search results for &ldquo;{{.search_query}}&rdquo;
{{else}}<i class="bi bi-folder2-open me-2"></i>{{.active_folder}}{{end}}
</h5>
<small class="text-muted">{{.total}} message{{if ne .total 1}}s{{end}}</small>
</div>
<div class="card-body p-0">
{{if .messages}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
{{if not .search_query}}<th>{{if eq .active_folder "Sent"}}To{{else}}From{{end}}</th>{{else}}<th>From / To</th>{{end}}
<th>Subject</th>
{{if .search_query}}<th>Folder</th>{{end}}
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{{$folders := .folders}}
{{$showFolderCol := .search_query}}
{{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>
{{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>
</div>
</div>
</div>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
</div>
</div>
</div>
</div>
{{template "compose_widget" .}}
{{template "webmail_shortcuts" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
function showConfirmation(message) {
return new Promise((resolve) => {
const modal = document.getElementById('confirmationModal');
document.getElementById('confirmationModalBody').textContent = message;
const confirmButton = document.getElementById('confirmationModalConfirm');
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
const handleCancel = () => { resolve(false); cleanup(); };
const cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
modal.removeEventListener('hidden.bs.modal', handleCancel);
};
confirmButton.addEventListener('click', handleConfirm);
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
new bootstrap.Modal(modal).show();
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-confirm]').forEach(function(button) {
button.addEventListener('click', async function(e) {
e.preventDefault();
if (await showConfirmation(this.getAttribute('data-confirm'))) {
const form = this.closest('form');
if (form) form.submit();
}
});
});
});
// 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
// row carries its OWN folder (data-folder) rather than assuming the page's
// active folder, since a search result can span multiple folders.
(function() {
let draggedUID = null;
let draggedFolder = null;
document.querySelectorAll('.msg-row').forEach(function(row) {
row.addEventListener('dragstart', function() {
draggedUID = row.dataset.uid;
draggedFolder = row.dataset.folder;
row.classList.add('dragging');
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
});
});
document.querySelectorAll('.folder-link').forEach(function(link) {
link.addEventListener('dragover', function(e) {
if (!draggedUID || link.dataset.folder === draggedFolder) return;
e.preventDefault();
link.classList.add('drop-hover');
});
link.addEventListener('dragleave', function() {
link.classList.remove('drop-hover');
});
link.addEventListener('drop', async function(e) {
e.preventDefault();
link.classList.remove('drop-hover');
const targetFolder = link.dataset.folder;
if (!draggedUID || !targetFolder || targetFolder === draggedFolder) return;
const body = new URLSearchParams();
body.set('target_folder', targetFolder);
await fetch(`/webmail/mail/${draggedFolder}/${draggedUID}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
window.location.reload();
});
});
})();
// "+N more" toggle for a collapsed same-subject run — reveals every
// immediately-following row marked as part of that group, self-terminating
// at the first row that isn't (no need to track the count client-side).
document.querySelectorAll('[data-group-toggle]').forEach(function(badge) {
badge.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
let sib = badge.closest('tr').nextElementSibling;
while (sib && sib.classList.contains('msg-row-older')) {
sib.style.display = '';
sib = sib.nextElementSibling;
}
badge.style.display = 'none';
});
});
</script>
</body>
</html>
{{end}}
+3 -2
View File
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in - Webmail</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-inbox-fill" style="font-size: 2.5rem;"></i>
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify it's you - Webmail</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
@@ -0,0 +1,201 @@
{{define "webmail_message.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.parsed.Header.Subject}} - Webmail</title>
<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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.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; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<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>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<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>
<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>
</nav>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
{{.Message}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
</div>
<div class="container pb-5">
<div class="d-flex justify-content-between align-items-center mb-3">
<a href="/webmail/mail/{{.active_folder}}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-1"></i>Back to {{.active_folder}}</a>
<div class="btn-group btn-group-sm">
<button type="button" id="replyBtn" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-reply me-1"></i>Reply</button>
<button type="button" id="replyAllBtn" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-reply-all me-1"></i>Reply All</button>
<button type="button" id="forwardBtn" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-arrow-right me-1"></i>Forward</button>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<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">
<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">
<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>
</div>
<div class="card-body">
{{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}}
</div>
</div>
<div class="card">
<div class="card-body d-flex justify-content-between align-items-center flex-wrap gap-2">
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/move" class="d-flex align-items-center gap-2">
<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">Move</button>
</form>
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash me-1"></i>{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}</button>
</form>
</div>
</div>
</div>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
</div>
</div>
</div>
</div>
{{template "compose_widget" .}}
{{template "webmail_shortcuts" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
function showConfirmation(message) {
return new Promise((resolve) => {
const modal = document.getElementById('confirmationModal');
document.getElementById('confirmationModalBody').textContent = message;
const confirmButton = document.getElementById('confirmationModalConfirm');
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
const handleCancel = () => { resolve(false); cleanup(); };
const cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
modal.removeEventListener('hidden.bs.modal', handleCancel);
};
confirmButton.addEventListener('click', handleConfirm);
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
new bootstrap.Modal(modal).show();
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-confirm]').forEach(function(button) {
button.addEventListener('click', async function(e) {
e.preventDefault();
if (await showConfirmation(this.getAttribute('data-confirm'))) {
const form = this.closest('form');
if (form) form.submit();
}
});
});
});
</script>
</body>
</html>
{{end}}
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up two-factor authentication - Webmail</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.setup-card { max-width: 480px; margin: 0 auto; width: 100%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
@@ -55,7 +56,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 6000}).show(); });
+236
View File
@@ -0,0 +1,236 @@
{{define "webmail_rules.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Filter Rules - Webmail</title>
<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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<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>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<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>
<a href="/webmail/rules" class="btn btn-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>
</nav>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
{{.Message}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
</div>
<div class="container pb-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-funnel me-2"></i>Filter Rules</h4>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>Rules run in priority order (lowest first) at delivery time; the first match wins. "Move to folder" delivers into a separate folder instead of INBOX — check <a href="/webmail/mail/INBOX" class="alert-link">Mail</a> once something's actually landed there.
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Rule</h5></div>
<div class="card-body">
<form method="POST" action="/webmail/rules/add">
<div class="row g-2 align-items-end mb-3">
<div class="col-auto">
<label class="form-label">Priority</label>
<input type="number" class="form-control" name="priority" value="0" style="width: 90px;">
</div>
<div class="col-auto">
<label class="form-label">Match</label>
<select class="form-select" name="match_type">
<option value="all">ALL of the following (AND)</option>
<option value="any">ANY of the following (OR)</option>
</select>
</div>
</div>
<div id="conditions_container"></div>
<button type="button" id="add_condition" class="btn btn-outline-light btn-sm mb-3"><i class="bi bi-plus-lg me-1"></i>Add condition</button>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Then</label>
<select class="form-select" name="action" id="rule_action">
<option value="move_to_folder">Move to folder</option>
<option value="mark_as_spam">Mark as Spam</option>
<option value="delete">Delete</option>
<option value="mark_read">Mark as read</option>
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control" name="action_value" id="rule_action_value" placeholder="folder name">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>Add Rule</button>
</div>
</div>
</form>
<template id="condition_row_template">
<div class="row g-2 align-items-end mb-2 condition-row">
<div class="col-auto">
<label class="form-label">If</label>
<select class="form-select" name="condition_field">
<option value="from">From</option>
<option value="to">To</option>
<option value="subject">Subject</option>
</select>
</div>
<div class="col-auto">
<select class="form-select" name="condition_op">
<option value="contains">contains</option>
<option value="equals">equals</option>
<option value="starts_with">starts with</option>
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control" name="condition_value" placeholder="value" required>
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-danger btn-sm remove-condition" title="Remove condition"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</template>
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing Rules</h5></div>
<div class="card-body p-0">
{{if .rules}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Priority</th><th>Condition</th><th>Action</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{{range .rules}}
<tr>
<td>{{.Priority}}</td>
<td><code>{{ruleSummary .}}</code></td>
<td>
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
{{else}}Mark as read{{end}}
</td>
<td>{{if .IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-secondary">Inactive</span>{{end}}</td>
<td>
<form method="post" action="/webmail/rules/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this rule?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-5">
<i class="bi bi-funnel text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No rules yet</h4>
<p class="text-muted">Add one above to automatically sort or act on incoming mail.</p>
</div>
{{end}}
</div>
</div>
</div>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
</div>
</div>
</div>
</div>
{{template "compose_widget" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
function showConfirmation(message) {
return new Promise((resolve) => {
const modal = document.getElementById('confirmationModal');
document.getElementById('confirmationModalBody').textContent = message;
const confirmButton = document.getElementById('confirmationModalConfirm');
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
const handleCancel = () => { resolve(false); cleanup(); };
const cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
modal.removeEventListener('hidden.bs.modal', handleCancel);
};
confirmButton.addEventListener('click', handleConfirm);
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
new bootstrap.Modal(modal).show();
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-confirm]').forEach(function(button) {
button.addEventListener('click', async function(e) {
e.preventDefault();
if (await showConfirmation(this.getAttribute('data-confirm'))) {
const form = this.closest('form');
if (form) form.submit();
}
});
});
});
document.getElementById('rule_action').addEventListener('change', function(e) {
const valueInput = document.getElementById('rule_action_value');
valueInput.style.display = e.target.value === 'move_to_folder' ? '' : 'none';
});
function addConditionRow() {
const tpl = document.getElementById('condition_row_template');
const container = document.getElementById('conditions_container');
const clone = document.importNode(tpl.content, true);
clone.querySelector('.remove-condition').addEventListener('click', function() {
if (container.children.length > 1) {
this.closest('.condition-row').remove();
}
});
container.appendChild(clone);
}
document.getElementById('add_condition').addEventListener('click', addConditionRow);
addConditionRow();
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,74 @@
{{define "webmail_shortcuts"}}
<style>
.msg-row-selected { outline: 2px solid #6ea8fe; outline-offset: -2px; background-color: rgba(110, 168, 254, 0.12); }
</style>
<script>
// Keyboard shortcuts for the folder list (j/k/Enter/o/c//) and the single-message
// view (c/r/a/f/#). Feature-detects which page it's on by which elements exist,
// so one shared partial covers both without a page-specific flag.
(function() {
function isTypingTarget(el) {
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
document.addEventListener('keydown', function(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (isTypingTarget(document.activeElement)) return;
const rows = Array.from(document.querySelectorAll('.msg-row:not(.msg-row-older)'));
const searchInput = document.getElementById('mailSearchInput');
if (e.key === '/') {
if (searchInput) { e.preventDefault(); searchInput.focus(); }
return;
}
if (e.key === 'c' && typeof openCompose === 'function') {
e.preventDefault();
openCompose('/webmail/mail/compose');
return;
}
// --- Folder list: j/k select, Enter/o open, #/Delete trash the selected row.
if (rows.length) {
let idx = rows.findIndex(function(r) { return r.classList.contains('msg-row-selected'); });
if (e.key === 'j' || e.key === 'k') {
e.preventDefault();
if (idx >= 0) rows[idx].classList.remove('msg-row-selected');
idx = e.key === 'j' ? Math.min(idx + 1, rows.length - 1) : Math.max(idx - 1, 0);
rows[idx].classList.add('msg-row-selected');
rows[idx].scrollIntoView({ block: 'nearest' });
return;
}
if ((e.key === 'Enter' || e.key === 'o') && idx >= 0) {
e.preventDefault();
const link = rows[idx].querySelector('a[href]');
if (link) window.location = link.href;
return;
}
if ((e.key === '#' || e.key === 'Delete') && idx >= 0) {
e.preventDefault();
const deleteBtn = rows[idx].querySelector('form[action*="/delete"] button[type=submit]');
if (deleteBtn) deleteBtn.click();
return;
}
}
// --- Single-message view: r/a/f reply/reply-all/forward, #/Delete trash.
const replyBtn = document.getElementById('replyBtn');
if (replyBtn) {
if (e.key === 'r') { e.preventDefault(); replyBtn.click(); return; }
if (e.key === 'a') { e.preventDefault(); document.getElementById('replyAllBtn').click(); return; }
if (e.key === 'f') { e.preventDefault(); document.getElementById('forwardBtn').click(); return; }
if (e.key === '#' || e.key === 'Delete') {
e.preventDefault();
const deleteBtn = document.querySelector('form[action*="/delete"] button[type=submit]');
if (deleteBtn) deleteBtn.click();
return;
}
}
});
})();
</script>
{{end}}
@@ -5,14 +5,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up authenticator app - Webmail</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6">
@@ -31,7 +32,7 @@
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
</div>
<div class="d-flex justify-content-between">
<a href="/webmail/" class="btn btn-secondary">Cancel</a>
<a href="/webmail/account" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Confirm and enable</button>
</div>
</form>
+61
View File
@@ -0,0 +1,61 @@
package webui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestTOTPSetupRendersRealQRImage guards against a real html/template bug found live:
// html/template's URL-context escaper only allows http/https/mailto schemes for a plain
// string in a src="..." attribute — a data: URI (how the QR code image is embedded, see
// totpSetupBegin) gets silently replaced with "#ZgotmplZ" unless typed as template.URL,
// making the QR code invisible with no server-side error at all.
func TestTOTPSetupRendersRealQRImage(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
req := httptest.NewRequest(http.MethodPost, Prefix+"/account/totp/setup", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "ZgotmplZ") {
t.Fatal("QR image src was stripped to #ZgotmplZ — qr_data_uri must be typed as template.URL")
}
if !strings.Contains(body, "src=\"data:image/png;base64,") {
t.Fatalf("expected a real data:image/png;base64 QR image src in the response, got: %s", body)
}
}
// TestWebmailTOTPSetupRendersRealQRImage is the mailbox self-service equivalent of the
// admin-side test above — same bug, same fix, in webmailTOTPSetupBegin.
func TestWebmailTOTPSetupRendersRealQRImage(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "totpuser@example.com", domains[0].ID, "portal-password-123!")
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/totp/setup", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "ZgotmplZ") {
t.Fatal("QR image src was stripped to #ZgotmplZ — qr_data_uri must be typed as template.URL")
}
if !strings.Contains(body, "src=\"data:image/png;base64,") {
t.Fatalf("expected a real data:image/png;base64 QR image src in the response, got: %s", body)
}
}
+116
View File
@@ -0,0 +1,116 @@
package webui
import (
"net"
"net/http"
"net/netip"
"strings"
"mailgoserver/internal/toolbox"
)
// cloudflareRanges are Cloudflare's published proxy IP ranges (fetched live from
// https://www.cloudflare.com/ips-v4 and /ips-v6 rather than trusted from memory,
// since a stale list here would either wrongly trust an attacker-controlled hop or
// wrongly distrust Cloudflare's own edge) — expanded when "cloudflare" appears in
// the trusted_proxies config value. Cloudflare rotates these occasionally; re-fetch
// and update this list if IP resolution behind Cloudflare ever looks wrong.
var cloudflareRanges = []string{
"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22",
"141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20",
"197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
"104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
"2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32",
"2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32",
}
// parseTrustedProxies reads the [Server] trusted_proxies config value — a
// comma-separated list of CIDRs and/or the literal word "cloudflare" — into parsed
// prefixes. Unparseable entries are skipped (logged by the caller) rather than
// failing startup over a typo in a security-adjacent but non-fatal setting.
func parseTrustedProxies(raw string, logger *toolbox.Logger) []netip.Prefix {
var out []netip.Prefix
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if strings.EqualFold(entry, "cloudflare") {
for _, cidr := range cloudflareRanges {
if p, err := netip.ParsePrefix(cidr); err == nil {
out = append(out, p)
}
}
continue
}
p, err := netip.ParsePrefix(entry)
if err != nil {
// A bare IP (no /mask) is a common typo for "trust this one proxy" —
// accept it as a /32 or /128 host route rather than silently dropping it.
if addr, addrErr := netip.ParseAddr(entry); addrErr == nil {
bits := 32
if addr.Is6() {
bits = 128
}
out = append(out, netip.PrefixFrom(addr, bits))
continue
}
if logger != nil {
logger.Error("trusted_proxies: skipping unparseable entry %q: %v", entry, err)
}
continue
}
out = append(out, p)
}
return out
}
func isTrustedProxy(trusted []netip.Prefix, addr netip.Addr) bool {
for _, p := range trusted {
if p.Contains(addr) {
return true
}
}
return false
}
// requestIP returns the best-effort real client IP for r. Forwarded headers
// (CF-Connecting-IP, X-Forwarded-For, X-Real-IP) are only honored when the direct
// TCP peer (r.RemoteAddr) is itself a configured trusted proxy — otherwise a client
// with no proxy in front of it could simply set these headers itself and spoof any
// IP for every audit log entry and IP-based check in the app. When trusted,
// X-Forwarded-For is walked from the right (the hop closest to us) skipping any
// entries that are themselves trusted proxies, landing on the first untrusted (i.e.
// real client) address — the standard correct algorithm, since the leftmost entry is
// client-supplied and trivially spoofable even through a legitimate proxy.
func (a *App) requestIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
peer, parseErr := netip.ParseAddr(host)
if parseErr != nil || !isTrustedProxy(a.trustedProxies, peer) {
return host
}
if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); cf != "" {
return cf
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
hops := strings.Split(fwd, ",")
for i := len(hops) - 1; i >= 0; i-- {
hop := strings.TrimSpace(hops[i])
if hop == "" {
continue
}
if addr, err := netip.ParseAddr(hop); err == nil && isTrustedProxy(a.trustedProxies, addr) {
continue // another hop we also trust — keep walking left for the real client
}
return hop
}
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
return host
}
+79
View File
@@ -0,0 +1,79 @@
package webui
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRequestIPTrustedProxy(t *testing.T) {
a := &App{trustedProxies: parseTrustedProxies("10.0.0.0/8", nil)}
cases := []struct {
name string
remoteAddr string
headers map[string]string
want string
}{
{
name: "untrusted RemoteAddr ignores X-Forwarded-For entirely",
remoteAddr: "203.0.113.5:12345",
headers: map[string]string{"X-Forwarded-For": "1.2.3.4"},
want: "203.0.113.5", // a client with nothing in front of it can't spoof its own IP
},
{
name: "trusted proxy: X-Forwarded-For honored",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Forwarded-For": "198.51.100.9"},
want: "198.51.100.9",
},
{
name: "trusted proxy: walks from the right, skipping other trusted hops",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Forwarded-For": "198.51.100.9, 10.0.0.2"},
want: "198.51.100.9", // 10.0.0.2 is itself trusted (in 10.0.0.0/8) — skip it, land on the real client
},
{
name: "trusted proxy: CF-Connecting-IP preferred over X-Forwarded-For",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"CF-Connecting-IP": "198.51.100.9", "X-Forwarded-For": "attacker-spoofed-should-be-ignored"},
want: "198.51.100.9",
},
{
name: "no trusted_proxies configured: header ignored even from that same peer",
remoteAddr: "203.0.113.5:12345",
headers: map[string]string{"X-Real-IP": "1.2.3.4"},
want: "203.0.113.5",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = tc.remoteAddr
for k, v := range tc.headers {
req.Header.Set(k, v)
}
if got := a.requestIP(req); got != tc.want {
t.Errorf("requestIP() = %q, want %q", got, tc.want)
}
})
}
}
func TestParseTrustedProxiesCloudflarePreset(t *testing.T) {
prefixes := parseTrustedProxies("cloudflare", nil)
if len(prefixes) != len(cloudflareRanges) {
t.Fatalf("expected %d cloudflare ranges parsed, got %d", len(cloudflareRanges), len(prefixes))
}
}
func TestParseTrustedProxiesBareIP(t *testing.T) {
prefixes := parseTrustedProxies("192.0.2.10", nil)
if len(prefixes) != 1 {
t.Fatalf("expected 1 prefix, got %d", len(prefixes))
}
if prefixes[0].Bits() != 32 {
t.Fatalf("expected a bare IPv4 to become a /32, got /%d", prefixes[0].Bits())
}
}
-21
View File
@@ -45,27 +45,6 @@ func fetchBody(client *http.Client, url string) string {
return string(b)
}
// requestIP returns the best-effort client IP for an HTTP request — the first hop of
// X-Forwarded-For if present (this app is documented to run behind a reverse proxy),
// falling back to the direct connection's address with its port stripped.
func requestIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.Index(fwd, ","); i >= 0 {
fwd = fwd[:i]
}
if ip := strings.TrimSpace(fwd); ip != "" {
return ip
}
}
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
return realIP
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// resolverAt builds a resolver pinned to a specific DNS server, mirroring
// utils.check_dns_record's hardcoded Cloudflare resolver (1.1.1.1), 5s timeout.
func resolverAt(serverIP string) *net.Resolver {
+36
View File
@@ -0,0 +1,36 @@
package webui
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestVendoredAssetsServedUnderBothPrefixes confirms Bootstrap/Bootstrap
// Icons/Quill are served locally (no CDN dependency) under both the admin and
// webmail static routes — templates in each tree reference their own prefix.
func TestVendoredAssetsServedUnderBothPrefixes(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
paths := []string{
Prefix + "/static/vendor/bootstrap/css/bootstrap.min.css",
Prefix + "/static/vendor/bootstrap/js/bootstrap.bundle.min.js",
Prefix + "/static/vendor/bootstrap-icons/font/bootstrap-icons.css",
Prefix + "/static/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff2",
MailboxPrefix + "/static/vendor/bootstrap/css/bootstrap.min.css",
MailboxPrefix + "/static/vendor/quill/quill.js",
MailboxPrefix + "/static/vendor/quill/quill.snow.css",
}
for _, p := range paths {
req := httptest.NewRequest(http.MethodGet, p, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("%s: status=%d", p, rec.Code)
}
if rec.Body.Len() == 0 {
t.Errorf("%s: empty body", p)
}
}
}
+4 -4
View File
@@ -150,7 +150,7 @@ func (a *App) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"})
return
}
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "Passkey added: "+name)
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "Passkey added: "+name)
writeJSON(w, http.StatusOK, M{"success": true})
}
@@ -159,7 +159,7 @@ func (a *App) passkeyRemove(w http.ResponseWriter, r *http.Request) {
if err := a.DB.DeleteWebAuthnCredential(pathID(r), user.ID); err != nil {
setFlash(w, "error", "Could not remove passkey")
} else {
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "Passkey removed")
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "Passkey removed")
setFlash(w, "success", "Passkey removed")
}
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
@@ -230,7 +230,7 @@ func (a *App) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
}
if _, err := wa.FinishLogin(wu, *session, r); err != nil {
clearWebauthnSession(w)
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), false, "Passkey verification failed")
_ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), false, "Passkey verification failed")
writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"})
return
}
@@ -241,7 +241,7 @@ func (a *App) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"})
return
}
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), true, "Login successful (passkey)")
_ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), true, "Login successful (passkey)")
clearPendingMFACookie(w)
setSessionCookie(w, token, r.TLS != nil)
writeJSON(w, http.StatusOK, M{"success": true})
+22 -19
View File
@@ -3,6 +3,7 @@ package webui
import (
"bytes"
"encoding/base64"
"html/template"
"image/png"
"net/http"
"strings"
@@ -55,32 +56,32 @@ func (a *App) webmailChangePassword(w http.ResponseWriter, r *http.Request) {
if !db.CheckPassword(current, mbox.PasswordHash) {
setFlash(w, "error", "Current password is incorrect")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if !isStrongPassword(newPassword) {
setFlash(w, "error", "New password must be at least 10 characters and include a letter, a number, and a symbol")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if newPassword != confirm {
setFlash(w, "error", "New passwords don't match")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
hash, err := db.HashPassword(newPassword)
if err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxPasswordHash(mbox.ID, hash); err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
setFlash(w, "success", "Password updated")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
@@ -88,12 +89,12 @@ func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
key, err := totp.Generate(totp.GenerateOpts{Issuer: "mailgoserver", AccountName: mbox.Email})
if err != nil {
setFlash(w, "error", "Could not generate a TOTP secret")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, key.Secret(), false); err != nil {
setFlash(w, "error", "Could not save the TOTP secret")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
img, err := key.Image(256, 256)
@@ -104,7 +105,9 @@ func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
qrDataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
}
a.render(w, r, "webmail_totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": qrDataURI})
// See totpSetupBegin's matching comment in account.go — data: URIs need to be
// typed as template.URL or html/template silently strips them to "#ZgotmplZ".
a.render(w, r, "webmail_totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": template.URL(qrDataURI)})
}
func (a *App) webmailTOTPSetupConfirm(w http.ResponseWriter, r *http.Request) {
@@ -112,17 +115,17 @@ func (a *App) webmailTOTPSetupConfirm(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.FormValue("code"))
if mbox.TOTPSecret == "" || !totp.Validate(code, mbox.TOTPSecret) {
setFlash(w, "error", "That code didn't match — try scanning the QR code again")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, mbox.TOTPSecret, true); err != nil {
setFlash(w, "error", "Something went wrong enabling MFA")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "TOTP authenticator enabled")
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "TOTP authenticator enabled")
setFlash(w, "success", "Authenticator app MFA enabled")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailTOTPDisable(w http.ResponseWriter, r *http.Request) {
@@ -130,10 +133,10 @@ func (a *App) webmailTOTPDisable(w http.ResponseWriter, r *http.Request) {
if err := a.DB.DisableMailboxTOTP(mbox.ID); err != nil {
setFlash(w, "error", "Something went wrong")
} else {
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "TOTP authenticator disabled")
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "TOTP authenticator disabled")
setFlash(w, "success", "Authenticator app MFA disabled")
}
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailAddAppPassword mirrors addAppPassword (mailbox_apppasswords.go) but for
@@ -150,16 +153,16 @@ func (a *App) webmailAddAppPassword(w http.ResponseWriter, r *http.Request) {
hash, err := db.HashPassword(secret)
if err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if _, err := a.DB.CreateAppPassword(mbox.ID, label, hash, nil); err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
setFlash(w, "success", "App password created — copy it now, it will not be shown again: "+secret)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailRevokeAppPassword(w http.ResponseWriter, r *http.Request) {
@@ -170,5 +173,5 @@ func (a *App) webmailRevokeAppPassword(w http.ResponseWriter, r *http.Request) {
} else {
setFlash(w, "success", "App password revoked")
}
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
+524
View File
@@ -0,0 +1,524 @@
package webui
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/mailview"
)
// TestWebmailComposeSendLocalDelivery confirms a composed message reaches another
// local mailbox's INBOX with the right content, and a copy lands in the sender's own
// Sent folder — the core send/receive round trip.
func TestWebmailComposeSendLocalDelivery(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!")
recipientID := createTestMailboxWithPassword(t, app, "recipient@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"recipient@example.com"}, "subject": {"Hello there"}, "body_html": {"This is the message body."},
}
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())
}
recipientMsgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(recipientMsgs) != 1 {
t.Fatalf("expected 1 message in recipient's INBOX, got %d", len(recipientMsgs))
}
if recipientMsgs[0].CachedSubject != "Hello there" {
t.Errorf("recipient subject = %q", recipientMsgs[0].CachedSubject)
}
senderSent, err := app.DB.ListMessagesInFolder(senderID, "Sent")
if err != nil {
t.Fatal(err)
}
if len(senderSent) != 1 {
t.Fatalf("expected 1 message in sender's Sent folder, got %d", len(senderSent))
}
// Recipient can actually read it via the message view.
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(recipientMsgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view message: status=%d", viewRec.Code)
}
if !strings.Contains(viewRec.Body.String(), "This is the message body.") {
t.Error("expected the message body in the rendered view")
}
// It's also recorded in the admin email log for visibility.
logs, _ := app.DB.ListEmailLogsPage(0, 10)
found := false
for _, l := range logs {
if l.Subject == "Hello there" && l.MailFrom == "sender@example.com" {
found = true
}
}
if !found {
t.Error("expected the webmail send to show up in the admin email log")
}
}
// TestWebmailMessageHTMLBodyIsSanitized confirms a malicious HTML body (e.g. from a
// received message, not something webmail's own plain-text compose can produce) never
// reaches the page unsanitized — this is the actual stored-XSS defense.
func TestWebmailMessageHTMLBodyIsSanitized(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "victim@example.com", domains[0].ID, "victim-password-1!")
raw := "From: attacker@evil.example\r\nTo: victim@example.com\r\nSubject: gotcha\r\n" +
"Content-Type: text/html\r\n\r\n" +
`<p>hello</p><script>alert(document.cookie)</script><img src=x onerror="alert(1)">`
uid, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "x@example.com", "attacker@evil.example", "gotcha")
if err != nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("view message: status=%d", rec.Code)
}
body := rec.Body.String()
// The page legitimately has its own <script> tags (Bootstrap/toast JS) — check
// for the actual injected payload surviving, not the literal substring "<script>".
if strings.Contains(body, "alert(document.cookie)") || strings.Contains(body, "onerror=") {
t.Error("HTML body was not sanitized — script/event-handler survived into the rendered page")
}
if !strings.Contains(body, "<p>hello</p>") {
t.Error("expected the safe formatting to survive sanitization")
}
}
// TestWebmailAttachmentRoundTrip confirms a file attached during compose survives
// send, local delivery, and download with identical bytes.
func TestWebmailAttachmentRoundTrip(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "sender2@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "recipient2@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("to", "recipient2@example.com")
mw.WriteField("subject", "With attachment")
mw.WriteField("body_html", "see attached")
fw, err := mw.CreateFormFile("attachments", "notes.txt")
if err != nil {
t.Fatal(err)
}
fw.Write([]byte("attachment file contents"))
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("compose send with attachment: status=%d body=%s", rec.Code, rec.Body.String())
}
recipientMsgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(recipientMsgs) != 1 {
t.Fatalf("recipient INBOX: got %d messages, err=%v", len(recipientMsgs), err)
}
uid := recipientMsgs[0].ID
recipientCookie := webmailLoginSession(t, app, recipientID)
dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10)+"/attachment/0", nil)
dlReq.AddCookie(recipientCookie)
dlRec := httptest.NewRecorder()
mux.ServeHTTP(dlRec, dlReq)
if dlRec.Code != http.StatusOK {
t.Fatalf("download attachment: status=%d", dlRec.Code)
}
if got := dlRec.Body.String(); got != "attachment file contents" {
t.Errorf("downloaded attachment = %q, want original content", got)
}
}
// TestWebmailComposeSendMultipleAttachments confirms sending 2+ files under the
// "attachments" field name — the shape the accumulating dropzone picker in
// webmail_compose.html produces — delivers all of them, not just the first. The
// server-side loop over r.MultipartForm.File["attachments"] already handled this
// before the dropzone UI existed; this closes the test-coverage gap that let "only
// one attachment works" go unnoticed (it was a frontend picker limitation, not a
// server one — see the accumulating DataTransfer-backed picker in Milestone C).
func TestWebmailComposeSendMultipleAttachments(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "multisender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "multirecip@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("to", "multirecip@example.com")
mw.WriteField("subject", "Three files")
mw.WriteField("body_html", "see attached")
for i, name := range []string{"a.txt", "b.txt", "c.txt"} {
fw, err := mw.CreateFormFile("attachments", name)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte("contents of file " + strconv.Itoa(i)))
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
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())
}
recipientMsgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(recipientMsgs) != 1 {
t.Fatalf("recipient INBOX: got %d messages, err=%v", len(recipientMsgs), err)
}
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(recipientMsgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view message: status=%d", viewRec.Code)
}
for _, name := range []string{"a.txt", "b.txt", "c.txt"} {
if !strings.Contains(viewRec.Body.String(), name) {
t.Errorf("expected attachment %q listed on the message page", name)
}
}
if !strings.Contains(viewRec.Body.String(), "see attached") {
t.Errorf("expected the message body rendered alongside the attachments, got: %s", viewRec.Body.String())
}
for i, name := range []string{"a.txt", "b.txt", "c.txt"} {
dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(recipientMsgs[0].ID, 10)+"/attachment/"+strconv.Itoa(i), nil)
dlReq.AddCookie(recipientCookie)
dlRec := httptest.NewRecorder()
mux.ServeHTTP(dlRec, dlReq)
if dlRec.Code != http.StatusOK {
t.Fatalf("download attachment %d (%s): status=%d", i, name, dlRec.Code)
}
want := "contents of file " + strconv.Itoa(i)
if got := dlRec.Body.String(); got != want {
t.Errorf("attachment %d content = %q, want %q", i, got, want)
}
}
}
// TestWebmailComposeRejectsEmptySend confirms the server-side guard (not just the
// client-side JS, which a test can't exercise) refuses to send a message with no
// subject, and separately one with no body and no attachments.
func TestWebmailComposeRejectsEmptySend(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "emptysender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "emptyrecip@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
send := func(t *testing.T, form url.Values) *httptest.ResponseRecorder {
t.Helper()
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)
return rec
}
// A rejected send redisplays the compose form (status 200) with the posted
// content still filled in, rather than redirecting to a blank one.
noSubject := url.Values{"to": {"emptyrecip@example.com"}, "subject": {""}, "body_html": {"hello"}}
rec := send(t, noSubject)
if rec.Code != http.StatusOK {
t.Fatalf("no-subject send: status=%d", rec.Code)
}
noBody := url.Values{"to": {"emptyrecip@example.com"}, "subject": {"hi"}, "body_html": {" "}}
rec = send(t, noBody)
if rec.Code != http.StatusOK {
t.Fatalf("no-body send: status=%d", rec.Code)
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 0 {
t.Fatalf("expected neither blank send delivered, got %d messages (err=%v)", len(msgs), err)
}
}
// TestWebmailComposeHTMLBodyRoundTrip confirms an HTML compose body (what Quill
// submits) survives send/delivery as a proper multipart/alternative — both the
// formatted HTML and a derived plain-text fallback reach the recipient — and renders
// with its formatting intact on the message page.
func TestWebmailComposeHTMLBodyRoundTrip(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "htmlsender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "htmlrecip@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"htmlrecip@example.com"}, "subject": {"Formatted"},
"body_html": {"<p><strong>Bold</strong> and <em>italic</em> text.</p>"},
}
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())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
raw, err := app.Mailstore.FetchMessage(recipientID, msgs[0].ID)
if err != nil {
t.Fatal(err)
}
parsed, err := mailview.Parse(raw)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(parsed.HTMLBody, "<strong>Bold</strong>") {
t.Errorf("expected the HTML body to survive formatting, got %q", parsed.HTMLBody)
}
if !strings.Contains(parsed.TextBody, "Bold and italic text") {
t.Errorf("expected a plain-text fallback part derived from the HTML, got %q", parsed.TextBody)
}
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view: status=%d", viewRec.Code)
}
if !strings.Contains(viewRec.Body.String(), "<strong>Bold</strong>") {
t.Error("expected the formatted HTML rendered on the message page")
}
}
// TestWebmailComposePastedImageSurvivesRoundTrip confirms a pasted screenshot
// (Quill's clipboard module embeds it as a base64 data: URI <img>) survives compose,
// delivery, and sanitize-on-view unchanged — this is what makes "paste a screenshot"
// actually work end to end, not just accepted at compose time.
func TestWebmailComposePastedImageSurvivesRoundTrip(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "imgsender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "imgrecip@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
imgSrc := "data:image/png;base64,aGVsbG8="
form := url.Values{
"to": {"imgrecip@example.com"}, "subject": {"Screenshot"},
"body_html": {`<p>See attached: <img src="` + imgSrc + `"></p>`},
}
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())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view: status=%d", viewRec.Code)
}
if !strings.Contains(viewRec.Body.String(), imgSrc) {
t.Errorf("expected the pasted base64 image to survive sanitize-on-view, got body: %s", viewRec.Body.String())
}
}
// TestWebmailComposeReplyPrefill confirms replying prefills To/Subject/quoted body
// from the original message.
func TestWebmailComposeReplyPrefill(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "replier@example.com", domains[0].ID, "replier-password-1!")
raw := "From: original@example.com\r\nTo: replier@example.com\r\nSubject: Original subject\r\nMessage-Id: <orig123@example.com>\r\n\r\noriginal body text"
uid, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "orig123@example.com", "original@example.com", "Original subject")
if err != nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/compose?reply="+strconv.FormatInt(uid, 10)+"&folder=INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("compose reply prefill: status=%d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "original@example.com") {
t.Error("expected the To field prefilled with the original sender")
}
if !strings.Contains(body, "Re: Original subject") {
t.Error("expected the subject prefilled with a Re: prefix")
}
if !strings.Contains(body, "original body text") {
t.Error("expected the original body quoted")
}
// The new message goes ABOVE the quoted original, not mixed into or after it —
// composeCursorHome (webmail_compose.go) prepends an empty line the cursor gets
// placed in (see webmail_compose.html's seed-loading JS), so the seed must start
// with that empty paragraph before the "On ... wrote:" quote line.
seedIdx := strings.Index(body, `id="body_html_seed"`)
quoteIdx := strings.Index(body, "wrote:")
emptyLineIdx := strings.Index(body, "<p><br></p>")
if seedIdx < 0 || quoteIdx < 0 || emptyLineIdx < 0 || !(seedIdx < emptyLineIdx && emptyLineIdx < quoteIdx) {
t.Errorf("expected an empty line before the quoted original (for the new message to go above it), got: %s", body)
}
}
// TestWebmailMoveAndDeleteMessage confirms moving a message changes its folder, and
// deleting from a non-Trash folder moves to Trash first, requiring a second delete to
// actually remove it.
func TestWebmailMoveAndDeleteMessage(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "organizer@example.com", domains[0].ID, "organizer-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
raw := "From: a@example.com\r\nTo: organizer@example.com\r\nSubject: sort me\r\n\r\nbody"
uid, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "m1@example.com", "a@example.com", "sort me")
if err != nil {
t.Fatal(err)
}
uidStr := strconv.FormatInt(uid, 10)
moveReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/INBOX/"+uidStr+"/move", strings.NewReader("target_folder=Work"))
moveReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
moveReq.AddCookie(cookie)
moveRec := httptest.NewRecorder()
mux.ServeHTTP(moveRec, moveReq)
if moveRec.Code != http.StatusFound {
t.Fatalf("move: status=%d", moveRec.Code)
}
moved, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil || moved == nil || moved.Folder != "Work" {
t.Fatalf("expected message moved to Work, got %+v (err=%v)", moved, err)
}
delReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/Work/"+uidStr+"/delete", nil)
delReq.AddCookie(cookie)
delRec := httptest.NewRecorder()
mux.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusFound {
t.Fatalf("delete (to trash): status=%d", delRec.Code)
}
trashed, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil || trashed == nil || trashed.Folder != "Trash" {
t.Fatalf("expected message moved to Trash, got %+v (err=%v)", trashed, err)
}
del2Req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/Trash/"+uidStr+"/delete", nil)
del2Req.AddCookie(cookie)
del2Rec := httptest.NewRecorder()
mux.ServeHTTP(del2Rec, del2Req)
if del2Rec.Code != http.StatusFound {
t.Fatalf("delete (permanent): status=%d", del2Rec.Code)
}
gone, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil || gone != nil {
t.Fatalf("expected message permanently gone, got %+v (err=%v)", gone, err)
}
}
// TestWebmailMessageAccessControlAcrossMailboxes confirms one mailbox owner can't
// view another mailbox's message by guessing its UID, even in a folder name they
// both happen to have.
func TestWebmailMessageAccessControlAcrossMailboxes(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
ownerID := createTestMailboxWithPassword(t, app, "owner3@example.com", domainID, "owner-password-1!")
attackerID := createTestMailboxWithPassword(t, app, "attacker3@example.com", domainID, "attacker-password-1!")
raw := "From: a@example.com\r\nTo: owner3@example.com\r\nSubject: private\r\n\r\nsecret body"
uid, err := app.Mailstore.StoreMessage(ownerID, "INBOX", []byte(raw), "m2@example.com", "a@example.com", "private")
if err != nil {
t.Fatal(err)
}
attackerCookie := webmailLoginSession(t, app, attackerID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
req.AddCookie(attackerCookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for another mailbox's message, got %d", rec.Code)
}
}
+812
View File
@@ -0,0 +1,812 @@
package webui
import (
"bytes"
"encoding/base64"
"encoding/json"
"html"
"html/template"
"io"
"mime/multipart"
"net/http"
"net/mail"
"net/textproto"
"strconv"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"mailgoserver/internal/db"
"mailgoserver/internal/mailview"
"mailgoserver/internal/pgp"
"mailgoserver/internal/relay"
"mailgoserver/internal/smime"
"mailgoserver/internal/toolbox"
)
const maxComposeUploadBytes = 25 << 20 // 25MB, matching a typical provider's attachment cap
// composeFormData builds the template data every compose-page render needs
// regardless of why it's rendering (a fresh GET, a reply/forward prefill, or
// redisplaying the form after a failed send) — shared so those three paths can't
// drift out of sync with each other.
func (a *App) composeFormData(mbox *db.Mailbox) M {
aliases, _ := a.DB.ListAliasesForMailbox(mbox.ID)
var sendAsOptions []string
for _, al := range aliases {
if al.CanSendAs && al.IsActive {
sendAsOptions = append(sendAsOptions, al.Email)
}
}
identities, _ := a.DB.ListSMIMEIdentities(mbox.ID)
pgpContacts, _ := a.DB.ListPGPContacts(mbox.ID)
return M{"mailbox": mbox, "send_as_options": sendAsOptions, "smime_identities": identities, "pgp_contacts": pgpContacts}
}
// webmailComposeForm shows the compose page, optionally prefilled for a reply,
// reply-all, or forward (query params: reply=uid&folder=X, replyall=..., forward=...).
func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
data := a.composeFormData(mbox)
data["flashes"] = popFlashes(w, r)
q := r.URL.Query()
folder := q.Get("folder")
var uidStr, mode string
switch {
case q.Get("reply") != "":
uidStr, mode = q.Get("reply"), "reply"
case q.Get("replyall") != "":
uidStr, mode = q.Get("replyall"), "replyall"
case q.Get("forward") != "":
uidStr, mode = q.Get("forward"), "forward"
case q.Get("draft") != "":
uidStr, mode = q.Get("draft"), "draft"
}
if mode != "" && folder != "" {
if parsed := a.webmailLoadForPrefill(mbox.ID, folder, int64(atoi(uidStr))); parsed != nil {
switch mode {
case "reply":
data["to"] = parsed.Header.From
data["subject"] = replySubject(parsed.Header.Subject)
data["body_html"] = template.HTML(quoteBodyHTML(parsed))
data["in_reply_to"] = parsed.Header.MessageID
case "replyall":
to, cc := replyAllRecipients(parsed, mbox.Email)
data["to"] = to
data["cc"] = cc
data["subject"] = replySubject(parsed.Header.Subject)
data["body_html"] = template.HTML(quoteBodyHTML(parsed))
data["in_reply_to"] = parsed.Header.MessageID
case "draft":
// Unlike reply/forward, a draft's own To/Cc/Subject/body are reloaded
// as-is (not quoted) — continuing to edit the same message, not
// replying to it. Bcc isn't recoverable: it's deliberately never
// written into the stored message content (see buildEnvelopeHeaders).
data["to"] = parsed.Header.To
data["cc"] = parsed.Header.Cc
data["subject"] = parsed.Header.Subject
body := parsed.HTMLBody
if body == "" && parsed.TextBody != "" {
body = `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0;">` + html.EscapeString(parsed.TextBody) + `</pre>`
}
data["body_html"] = template.HTML(htmlBodyPolicy.Sanitize(body))
data["draft_id"] = uidStr
case "forward":
data["subject"] = forwardSubject(parsed.Header.Subject)
data["body_html"] = template.HTML(forwardBodyHTML(parsed))
}
}
}
a.render(w, r, "webmail_compose.html", data)
}
// webmailRecipientSuggest backs the To/Cc/Bcc autocomplete — addresses this mailbox
// has previously exchanged mail with, matching the current fragment being typed.
func (a *App) webmailRecipientSuggest(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
var suggestions []string
if q != "" {
suggestions, _ = a.DB.SuggestRecipients(mbox.ID, q)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(suggestions)
}
// webmailLoadForPrefill fetches+parses a message for reply/forward prefill, scoped to
// this mailbox and folder — returns nil (silently, no flash) on any failure, since
// the worst case is just an unprefilled compose form, not something worth erroring
// the whole page over.
func (a *App) webmailLoadForPrefill(mailboxID int64, folder string, uid int64) *mailview.Message {
msgRow, err := a.DB.GetMessageByUID(mailboxID, uid)
if err != nil || msgRow == nil || msgRow.Folder != folder {
return nil
}
raw, err := a.Mailstore.FetchMessage(mailboxID, uid)
if err != nil {
return nil
}
parsed, err := mailview.Parse(raw)
if err != nil {
return nil
}
return parsed
}
func replySubject(s string) string {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "re:") {
return s
}
return "Re: " + s
}
func forwardSubject(s string) string {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "fwd:") {
return s
}
return "Fwd: " + s
}
// plainBody prefers the parsed message's text body; an HTML-only message can't be
// meaningfully quoted into a plain-text compose box, so it's called out instead.
func plainBody(parsed *mailview.Message) string {
if parsed.TextBody != "" {
return parsed.TextBody
}
if parsed.HTMLBody != "" {
return "(original message was HTML-only — open it in its folder to view)"
}
return ""
}
// quotedBodyHTML renders the original message's body for embedding into a
// reply/forward compose: the original HTML body (sanitized) when present, otherwise
// the plain-text body escaped and wrapped in a <pre> so line breaks survive.
func quotedBodyHTML(parsed *mailview.Message) string {
if parsed.HTMLBody != "" {
return htmlBodyPolicy.Sanitize(parsed.HTMLBody)
}
return `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0;">` + html.EscapeString(plainBody(parsed)) + `</pre>`
}
// composeCursorHome is an empty paragraph — Quill's own canonical markup for a
// blank line — prepended to reply/forward bodies so the new message has somewhere
// to go ABOVE the quoted original, with the cursor placed there automatically (see
// the body_html_seed handling in webmail_compose.html) instead of landing inside or
// after the quote.
const composeCursorHome = "<p><br></p>"
func quoteBodyHTML(parsed *mailview.Message) string {
return composeCursorHome +
"<p>On " + html.EscapeString(parsed.Header.Date) + ", " + html.EscapeString(parsed.Header.From) + " wrote:</p>" +
`<blockquote style="border-left: 2px solid #999; margin: 0; padding-left: 1em;">` + quotedBodyHTML(parsed) + "</blockquote>"
}
func forwardBodyHTML(parsed *mailview.Message) string {
return composeCursorHome +
"<p>---------- Forwarded message ----------<br>" +
"From: " + html.EscapeString(parsed.Header.From) + "<br>" +
"Date: " + html.EscapeString(parsed.Header.Date) + "<br>" +
"Subject: " + html.EscapeString(parsed.Header.Subject) + "<br>" +
"To: " + html.EscapeString(parsed.Header.To) + "</p>" +
quotedBodyHTML(parsed)
}
// replyAllRecipients puts the original sender in To and everyone else who received
// the original (To+Cc, minus the replying mailbox itself) in Cc — standard
// reply-all semantics.
func replyAllRecipients(parsed *mailview.Message, ownEmail string) (to, cc string) {
seen := map[string]bool{strings.ToLower(ownEmail): true, strings.ToLower(bareAddress(parsed.Header.From)): true}
var ccList []string
for _, addr := range append(splitAddressList(parsed.Header.To), splitAddressList(parsed.Header.Cc)...) {
bare := strings.ToLower(bareAddress(addr))
if bare == "" || seen[bare] {
continue
}
seen[bare] = true
ccList = append(ccList, addr)
}
return parsed.Header.From, strings.Join(ccList, ", ")
}
func splitAddressList(raw string) []string {
addrs, err := mail.ParseAddressList(raw)
if err != nil {
return nil
}
out := make([]string, len(addrs))
for i, a := range addrs {
out[i] = a.Address
}
return out
}
func bareAddress(raw string) string {
if a, err := mail.ParseAddress(raw); err == nil {
return a.Address
}
return raw
}
func domainOfAddress(addr string) string {
if i := strings.LastIndex(addr, "@"); i >= 0 {
return strings.ToLower(addr[i+1:])
}
return ""
}
type composeAttachment struct {
Filename, ContentType string
Data []byte
}
// buildEnvelopeHeaders returns a composed message's envelope headers — everything
// except the MIME entity's own Content-Type/Content-Transfer-Encoding, which come
// from buildMessageEntity instead. Kept separate so S/MIME's Sign/Encrypt
// (internal/smime) can transform just the entity, never touching From/To/Subject.
// Deliberately never includes a Bcc header (real mail clients never put one in the
// transmitted DATA either) — Bcc recipients still receive the mail via the
// envelope-level recipient list built by the caller, they just don't appear in the
// message content itself, matching standard practice.
func buildEnvelopeHeaders(from string, to, cc []string, subject, messageID, inReplyTo string) []string {
headers := []string{
"Message-ID: <" + messageID + ">",
"Date: " + time.Now().Format(time.RFC1123Z),
"From: " + from,
"To: " + strings.Join(to, ", "),
}
if len(cc) > 0 {
headers = append(headers, "Cc: "+strings.Join(cc, ", "))
}
headers = append(headers, "Subject: "+subject)
if inReplyTo != "" {
headers = append(headers, "In-Reply-To: "+inReplyTo, "References: "+inReplyTo)
}
headers = append(headers, "MIME-Version: 1.0")
return headers
}
// buildBodyEntity builds just the message body part: a flat text/plain part when
// htmlBody is empty, otherwise multipart/alternative (plainText fallback + htmlBody)
// — the standard shape for an HTML-composed email so a plain-text-only mail client
// still gets something readable.
func buildBodyEntity(plainText, htmlBody string) (smime.Entity, error) {
if htmlBody == "" {
return smime.Entity{
Headers: []string{`Content-Type: text/plain; charset="UTF-8"`, "Content-Transfer-Encoding: 8bit"},
Body: []byte(plainText),
}, nil
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
tp, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {`text/plain; charset="UTF-8"`}})
if err != nil {
return smime.Entity{}, err
}
if _, err := tp.Write([]byte(plainText)); err != nil {
return smime.Entity{}, err
}
hp, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {`text/html; charset="UTF-8"`}})
if err != nil {
return smime.Entity{}, err
}
if _, err := hp.Write([]byte(htmlBody)); err != nil {
return smime.Entity{}, err
}
if err := mw.Close(); err != nil {
return smime.Entity{}, err
}
return smime.Entity{
Headers: []string{`Content-Type: multipart/alternative; boundary="` + mw.Boundary() + `"`},
Body: buf.Bytes(),
}, nil
}
// headerLinesToMIMEHeader converts an smime.Entity's flat "Name: value" header
// lines into textproto.MIMEHeader, for embedding one entity's headers+body as a
// nested part inside another multipart.Writer (mw.CreatePart wants that shape).
func headerLinesToMIMEHeader(headers []string) textproto.MIMEHeader {
h := textproto.MIMEHeader{}
for _, line := range headers {
if i := strings.Index(line, ":"); i >= 0 {
h.Add(strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]))
}
}
return h
}
// buildMessageEntity builds the MIME entity (Content-Type/CTE headers + body) for a
// composed message: the body (buildBodyEntity — plain, or multipart/alternative when
// htmlBody is set) alone if there are no attachments, otherwise nested as the first
// part of a multipart/mixed alongside each attachment.
func buildMessageEntity(plainText, htmlBody string, attachments []composeAttachment) (smime.Entity, error) {
bodyEntity, err := buildBodyEntity(plainText, htmlBody)
if err != nil {
return smime.Entity{}, err
}
if len(attachments) == 0 {
return bodyEntity, nil
}
var bodyBuf bytes.Buffer
mw := multipart.NewWriter(&bodyBuf)
bodyPart, err := mw.CreatePart(headerLinesToMIMEHeader(bodyEntity.Headers))
if err != nil {
return smime.Entity{}, err
}
if _, err := bodyPart.Write(bodyEntity.Body); err != nil {
return smime.Entity{}, err
}
for _, att := range attachments {
ct := att.ContentType
if ct == "" {
ct = "application/octet-stream"
}
part, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {ct + `; name="` + att.Filename + `"`},
"Content-Disposition": {`attachment; filename="` + att.Filename + `"`},
"Content-Transfer-Encoding": {"base64"},
})
if err != nil {
return smime.Entity{}, err
}
encoded := base64.StdEncoding.EncodeToString(att.Data)
for i := 0; i < len(encoded); i += 76 {
end := min(i+76, len(encoded))
part.Write([]byte(encoded[i:end]))
part.Write([]byte("\r\n"))
}
}
if err := mw.Close(); err != nil {
return smime.Entity{}, err
}
// A top-level Content-Transfer-Encoding is redundant on a multipart container by
// MIME rules (RFC 2045 permits only 7bit/8bit/binary there anyway) but DKIM's
// FixedHeaders list always includes it in the signed header set — omitting it
// would leave that entry signing an absent header instead of a real one.
return smime.Entity{
Headers: []string{`Content-Type: multipart/mixed; boundary="` + mw.Boundary() + `"`, "Content-Transfer-Encoding: 7bit"},
Body: bodyBuf.Bytes(),
}, nil
}
// assembleMessage concatenates envelope headers and a MIME entity (its own headers,
// then body) into a flat raw RFC822 message.
func assembleMessage(envelopeHeaders []string, entity smime.Entity) string {
var b strings.Builder
for _, h := range envelopeHeaders {
b.WriteString(h)
b.WriteString("\r\n")
}
for _, h := range entity.Headers {
b.WriteString(h)
b.WriteString("\r\n")
}
b.WriteString("\r\n")
b.Write(entity.Body)
return b.String()
}
// webmailComposeSend builds, signs, and delivers a composed message: local
// recipients go straight into their mailbox (through their own filter rules), the
// rest go out via the same direct-to-MX relay used for SMTP-received mail. A copy is
// always saved to the sender's own Sent folder, and the send is logged the same way
// an SMTP-relayed message is, so it shows up in the admin's email log too.
func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
// ErrNotMultipart is expected (and harmless) whenever the browser submits the
// compose form without picking any attachment file — ParseMultipartForm still
// calls ParseForm internally in that case, so every other field is available;
// only a genuine parse/size failure should abort the send. This one failure mode
// redirects (rather than redisplaying, like every other failure below) since a
// parse failure means the posted fields can't be trusted to recover from.
if err := r.ParseMultipartForm(maxComposeUploadBytes); err != nil && err != http.ErrNotMultipart {
setFlash(w, "error", "Message (with attachments) is too large, or the form data was invalid")
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
from := strings.TrimSpace(r.FormValue("from"))
if from == "" {
from = mbox.Email
}
toAddrs, errTo := parseComposeAddrs(r.FormValue("to"))
ccAddrs, errCc := parseComposeAddrs(r.FormValue("cc"))
bccAddrs, errBcc := parseComposeAddrs(r.FormValue("bcc"))
subject := strings.TrimSpace(r.FormValue("subject"))
// body_html is the Quill editor's content, sanitized the same way an incoming
// message's HTML body is before display — cheap hygiene even though it's the
// user's own browser-originated content. plainText is derived from it (not the
// raw field) so the plain-text fallback part reflects exactly what actually got
// sent, and so it can't itself carry anything sanitization would have stripped.
htmlBody := htmlBodyPolicy.Sanitize(r.FormValue("body_html"))
plainText := strings.TrimSpace(plainTextPolicy.Sanitize(htmlBody))
inReplyTo := strings.TrimSpace(r.FormValue("in_reply_to"))
// fail redisplays the compose form in place with everything already typed still
// filled in (a failed send used to wipe the form via a redirect to a blank GET —
// attachments can't be restored into a native file input by a server response,
// browsers don't allow it, so that's the one thing the user is asked to redo).
fail := func(msg string) {
if r.MultipartForm != nil && len(r.MultipartForm.File["attachments"]) > 0 {
msg += " (attachments will need to be re-picked — browsers don't allow restoring them automatically)"
}
data := a.composeFormData(mbox)
data["flashes"] = append(popFlashes(w, r), Flash{Category: "error", Message: msg})
data["to"] = r.FormValue("to")
data["cc"] = r.FormValue("cc")
data["bcc"] = r.FormValue("bcc")
data["subject"] = subject
data["body_html"] = template.HTML(htmlBody)
data["in_reply_to"] = inReplyTo
data["draft_id"] = r.FormValue("draft_id")
a.render(w, r, "webmail_compose.html", data)
}
if !strings.EqualFold(from, mbox.Email) {
if canSendAs, err := a.DB.MailboxCanSendAs(mbox.ID, from); err != nil || !canSendAs {
fail("You're not authorized to send as " + from)
return
}
}
if errTo != nil || errCc != nil || errBcc != nil {
fail("One or more recipient addresses is invalid")
return
}
if len(toAddrs) == 0 {
fail("At least one recipient is required")
return
}
var attachments []composeAttachment
if r.MultipartForm != nil {
for _, fh := range r.MultipartForm.File["attachments"] {
f, err := fh.Open()
if err != nil {
continue
}
data, err := io.ReadAll(f)
f.Close()
if err != nil {
continue
}
attachments = append(attachments, composeAttachment{Filename: fh.Filename, ContentType: fh.Header.Get("Content-Type"), Data: data})
}
}
// Mirrors the client-side check in webmail_compose.html — enforced again here
// since a blank send (e.g. via an accidental Enter-key form submit) must never
// succeed even with JS disabled or bypassed.
if subject == "" {
fail("Please add a subject before sending")
return
}
if plainText == "" && len(attachments) == 0 {
fail("Please write a message or add an attachment before sending")
return
}
heloHostname := a.Cfg.Section("Server").Key("helo_hostname").String()
if heloHostname == "" {
heloHostname = a.Cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
}
messageID := toolbox.GenerateMessageID(heloHostname)
entity, err := buildMessageEntity(plainText, htmlBody, attachments)
if err != nil {
a.Logger.Error("build outbound webmail message: %v", err)
fail("Could not build the message: " + err.Error())
return
}
wantSign := r.FormValue("smime_sign") != ""
wantEncrypt := r.FormValue("pgp_encrypt") != ""
if wantSign {
identities, err := a.DB.ListSMIMEIdentities(mbox.ID)
if err != nil || len(identities) == 0 {
fail("Signing requires your own S/MIME certificate — set one up on the Certs page first")
return
}
chosen := identities[0] // most recently created, unless the form named a specific one
if idStr := r.FormValue("smime_identity_id"); idStr != "" {
wanted := int64(atoi(idStr))
for _, id := range identities {
if id.ID == wanted {
chosen = id
break
}
}
}
cert, err := smime.ParseCertPEM([]byte(chosen.CertPEM))
if err != nil {
a.Logger.Error("parse own smime cert for mailbox %d: %v", mbox.ID, err)
fail("Your S/MIME certificate is corrupted")
return
}
key, err := smime.ParseKeyPEM([]byte(chosen.KeyPEM))
if err != nil {
a.Logger.Error("parse own smime key for mailbox %d: %v", mbox.ID, err)
fail("Your S/MIME certificate is corrupted")
return
}
if entity, err = smime.Sign(entity, cert, key); err != nil {
a.Logger.Error("smime sign for mailbox %d: %v", mbox.ID, err)
fail("Could not sign the message: " + err.Error())
return
}
}
if wantEncrypt {
// PGP handles encryption in this codebase (S/MIME, above, handles signing) —
// encrypting only ever needs public keys, so unlike signing this never
// prompts for a passphrase at compose time.
pgpIdentities, err := a.DB.ListPGPIdentities(mbox.ID)
if err != nil || len(pgpIdentities) == 0 {
fail("Encrypting requires your own PGP key (so your Sent copy stays readable) — set one up on the Certs page first")
return
}
ownPub, err := pgp.ParsePublicKey([]byte(pgpIdentities[0].PublicKeyArmor))
if err != nil {
a.Logger.Error("parse own pgp public key for mailbox %d: %v", mbox.ID, err)
fail("Your PGP key is corrupted")
return
}
recipKeys := []*openpgp.Entity{ownPub} // include the sender's own key so the Sent copy stays readable
// Recipients are chosen explicitly by picking contacts from the dropdown, not
// by matching a To/Cc/Bcc address against a contact's stored email — a
// recipient's PGP key can be filed under any email, and this way there's no
// silent "no key for this exact address" failure.
pickedIDs := r.Form["pgp_recipient_id"]
if len(pickedIDs) == 0 {
fail("Select at least one PGP recipient key to encrypt to")
return
}
var missing []string
for _, idStr := range pickedIDs {
contact, err := a.DB.GetPGPContactByID(mbox.ID, int64(atoi(idStr)))
if err != nil || contact == nil {
missing = append(missing, idStr)
continue
}
rcptKey, err := pgp.ParsePublicKey([]byte(contact.PublicKeyArmor))
if err != nil {
missing = append(missing, contact.Email)
continue
}
recipKeys = append(recipKeys, rcptKey)
}
if len(missing) > 0 {
fail("Could not use the selected PGP key(s) for: " + strings.Join(missing, ", "))
return
}
// pgp.Entity and smime.Entity are deliberately identical structs (see both
// packages' doc comments) so this conversion is just a type-name formality,
// not a data transformation.
pgpEntity, err := pgp.EncryptEntity(pgp.Entity(entity), recipKeys)
if err != nil {
a.Logger.Error("pgp encrypt for mailbox %d: %v", mbox.ID, err)
fail("Could not encrypt the message: " + err.Error())
return
}
entity = smime.Entity(pgpEntity)
}
raw := assembleMessage(buildEnvelopeHeaders(from, toAddrs, ccAddrs, subject, messageID, inReplyTo), entity)
signed := raw
dkimSigned := false
if senderDomain := domainOfAddress(from); senderDomain != "" {
s := a.DKIM.Sign(raw, senderDomain)
dkimSigned = s != raw
signed = s
}
allRcpts := append(append(append([]string{}, toAddrs...), ccAddrs...), bccAddrs...)
types := make([]string, 0, len(allRcpts))
for range toAddrs {
types = append(types, "to")
}
for range ccAddrs {
types = append(types, "cc")
}
for range bccAddrs {
types = append(types, "bcc")
}
var localRcpts, localTypes, relayRcpts, relayTypes []string
for i, rcpt := range allRcpts {
if lm, err := a.Mailstore.ResolveRecipient(rcpt); err == nil && lm != nil {
localRcpts = append(localRcpts, rcpt)
localTypes = append(localTypes, types[i])
} else {
relayRcpts = append(relayRcpts, rcpt)
relayTypes = append(relayTypes, types[i])
}
}
var results []relay.Result
if len(relayRcpts) > 0 {
results = a.Relay.RelayEmailAsync(from, relayRcpts, signed, relayTypes)
}
for i, rcpt := range localRcpts {
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 {
a.Logger.Error("store sent copy for mailbox %d: %v", mbox.ID, err)
}
// Sending a draft removes it from Drafts, same as any real mail client.
if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
if err := a.Mailstore.DeleteMessage(mbox.ID, int64(atoi(draftIDStr))); err != nil {
a.Logger.Error("delete sent draft %s for mailbox %d: %v", draftIDStr, mbox.ID, err)
}
}
loggedBody := plainText
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]"
}
if _, err := a.Relay.LogEmail(a.Cfg, a.requestIP(r), from, strings.Join(toAddrs, ", "), strings.Join(ccAddrs, ", "), strings.Join(bccAddrs, ", "),
subject, "", loggedBody, messageID, mbox.Email, dkimSigned, results); err != nil {
a.Logger.Error("log webmail send: %v", err)
}
allSucceeded := len(results) > 0
var failures []string
for _, res := range results {
if res.Status != "success" {
allSucceeded = false
reason := res.ErrorMessage
if reason == "" {
reason = res.ServerResponse
}
failures = append(failures, res.Recipient+": "+reason)
}
}
if allSucceeded {
setFlash(w, "success", "Message sent")
} else {
setFlash(w, "error", "Sent, but delivery failed — "+strings.Join(failures, "; "))
}
http.Redirect(w, r, MailboxPrefix+"/mail/Sent", http.StatusFound)
}
// webmailComposeSaveDraft stores the current compose form into the Drafts folder
// without sending it — deliberately skips the recipient/subject/body validation
// webmailComposeSend enforces (a draft can be incomplete by definition) and never
// signs or encrypts (a draft isn't going anywhere yet, so there's nothing to sign or
// encrypt to). Re-saving an already-open draft (draft_id set) replaces the old copy
// rather than accumulating duplicates.
func (a *App) webmailComposeSaveDraft(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseMultipartForm(maxComposeUploadBytes); err != nil && err != http.ErrNotMultipart {
setFlash(w, "error", "Message (with attachments) is too large, or the form data was invalid")
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
from := strings.TrimSpace(r.FormValue("from"))
if from == "" {
from = mbox.Email
}
toAddrs, _ := parseComposeAddrs(r.FormValue("to"))
ccAddrs, _ := parseComposeAddrs(r.FormValue("cc"))
subject := strings.TrimSpace(r.FormValue("subject"))
htmlBody := htmlBodyPolicy.Sanitize(r.FormValue("body_html"))
plainText := strings.TrimSpace(plainTextPolicy.Sanitize(htmlBody))
inReplyTo := strings.TrimSpace(r.FormValue("in_reply_to"))
var attachments []composeAttachment
if r.MultipartForm != nil {
for _, fh := range r.MultipartForm.File["attachments"] {
f, err := fh.Open()
if err != nil {
continue
}
data, err := io.ReadAll(f)
f.Close()
if err != nil {
continue
}
attachments = append(attachments, composeAttachment{Filename: fh.Filename, ContentType: fh.Header.Get("Content-Type"), Data: data})
}
}
heloHostname := a.Cfg.Section("Server").Key("helo_hostname").String()
if heloHostname == "" {
heloHostname = a.Cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
}
messageID := toolbox.GenerateMessageID(heloHostname)
entity, err := buildMessageEntity(plainText, htmlBody, attachments)
if err != nil {
a.Logger.Error("build draft for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save the draft: "+err.Error())
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
raw := assembleMessage(buildEnvelopeHeaders(from, toAddrs, ccAddrs, subject, messageID, inReplyTo), entity)
newUID, err := a.Mailstore.StoreMessage(mbox.ID, "Drafts", []byte(raw), messageID, from, subject)
if err != nil {
a.Logger.Error("save draft for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save the draft: "+err.Error())
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
// Replace, don't accumulate: re-saving an open draft deletes the previous copy.
if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
if oldUID := int64(atoi(draftIDStr)); oldUID != newUID {
if err := a.Mailstore.DeleteMessage(mbox.ID, oldUID); err != nil {
a.Logger.Error("delete superseded draft %d for mailbox %d: %v", oldUID, mbox.ID, err)
}
}
}
setFlash(w, "success", "Draft saved")
http.Redirect(w, r, MailboxPrefix+"/mail/compose?draft="+strconv.FormatInt(newUID, 10)+"&folder=Drafts", http.StatusFound)
}
// deliverWebmailComposeLocally stores a composed message into another local
// mailbox's own folder per their filter rules — mirrors smtpserver's deliverLocally,
// minus spam/DKIM-verification scoring, which is for untrusted inbound mail; a
// message an authenticated webmail user just composed doesn't need to be
// heuristically judged as spam against itself.
func (a *App) deliverWebmailComposeLocally(rcpt, rcptType, from, subject, signed, messageID string) relay.Result {
lm, err := a.Mailstore.ResolveRecipient(rcpt)
if err != nil || lm == nil {
return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "failed", ErrorMessage: "recipient not found"}
}
folder := "INBOX"
markRead := false
if action, err := a.Mailstore.ApplyRules(lm.ID, map[string]string{"from": from, "to": rcpt, "subject": subject}); err == nil {
if action.Drop {
return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "success", ServerResponse: "Discarded by recipient's filter rule"}
}
if action.Folder != "" {
folder = action.Folder
}
markRead = action.MarkRead
}
uid, err := a.Mailstore.StoreMessage(lm.ID, folder, []byte(signed), messageID, from, subject)
if err != nil {
return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "failed", ErrorMessage: err.Error()}
}
if markRead {
if err := a.DB.SetMessageFlags(lm.ID, uid, `\Seen`); err != nil {
a.Logger.Error("mark_read rule failed for message %d: %v", uid, err)
}
}
return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "success"}
}
// parseComposeAddrs parses a comma-separated address list, dropping any display name
// — every downstream consumer (ResolveRecipient, MailboxCanSendAs, RelayEmailAsync)
// expects bare addresses. Empty input is not an error (an empty Cc/Bcc is normal).
func parseComposeAddrs(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
addrs, err := mail.ParseAddressList(raw)
if err != nil {
return nil, err
}
out := make([]string, len(addrs))
for i, a := range addrs {
out[i] = a.Address
}
return out, nil
}
+98
View File
@@ -0,0 +1,98 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestWebmailSaveDraftThenSend confirms saving a draft stores it in Drafts without
// sending, reopening it via the Drafts row prefills the compose form, re-saving
// replaces the old copy (no duplicates), and finally sending it delivers the message
// and removes it from Drafts.
func TestWebmailSaveDraftThenSend(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "draft-sender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "draft-recip@example.com", domainID, "recip-password-1!")
cookie := webmailLoginSession(t, app, senderID)
// Save a draft with no recipient at all — a draft can be incomplete.
saveForm := url.Values{"subject": {"WIP"}, "body_html": {"not done yet"}}
saveReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/save-draft", strings.NewReader(saveForm.Encode()))
saveReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
saveReq.AddCookie(cookie)
saveRec := httptest.NewRecorder()
mux.ServeHTTP(saveRec, saveReq)
if saveRec.Code != http.StatusFound {
t.Fatalf("save draft: status=%d body=%s", saveRec.Code, saveRec.Body.String())
}
drafts, err := app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(drafts) != 1 {
t.Fatalf("expected 1 draft, got %d (err=%v)", len(drafts), err)
}
draftID := drafts[0].ID
// Reopening it via the Drafts prefill route shows the saved content.
openReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/compose?draft="+strconv.FormatInt(draftID, 10)+"&folder=Drafts", nil)
openReq.AddCookie(cookie)
openRec := httptest.NewRecorder()
mux.ServeHTTP(openRec, openReq)
if openRec.Code != http.StatusOK {
t.Fatalf("open draft: status=%d", openRec.Code)
}
body := openRec.Body.String()
if !strings.Contains(body, "WIP") || !strings.Contains(body, "not done yet") {
t.Fatalf("expected the draft's subject/body prefilled, got body: %s", body)
}
if !strings.Contains(body, `value="`+strconv.FormatInt(draftID, 10)+`"`) {
t.Fatalf("expected draft_id round-tripped into the form, got body: %s", body)
}
// Re-save with the recipient now filled in and draft_id carried — should replace,
// not duplicate.
resaveForm := url.Values{
"to": {"draft-recip@example.com"}, "subject": {"WIP"}, "body_html": {"still not done"},
"draft_id": {strconv.FormatInt(draftID, 10)},
}
resaveReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/save-draft", strings.NewReader(resaveForm.Encode()))
resaveReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resaveReq.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), resaveReq)
drafts, err = app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(drafts) != 1 {
t.Fatalf("expected still 1 draft after re-save, got %d (err=%v)", len(drafts), err)
}
newDraftID := drafts[0].ID
// Finally send it — the message is delivered and the draft is gone.
sendForm := url.Values{
"to": {"draft-recip@example.com"}, "subject": {"WIP"}, "body_html": {"finally done"},
"draft_id": {strconv.FormatInt(newDraftID, 10)},
}
sendReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(sendForm.Encode()))
sendReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
sendReq.AddCookie(cookie)
sendRec := httptest.NewRecorder()
mux.ServeHTTP(sendRec, sendReq)
if sendRec.Code != http.StatusFound {
t.Fatalf("send: status=%d body=%s", sendRec.Code, sendRec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message delivered, got %d (err=%v)", len(msgs), err)
}
remainingDrafts, err := app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(remainingDrafts) != 0 {
t.Fatalf("expected the draft gone after sending, got %d (err=%v)", len(remainingDrafts), err)
}
}
+128
View File
@@ -0,0 +1,128 @@
package webui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestWebmailFolderCreateShowsUpEvenWhenEmpty confirms a freshly created folder is
// listed in the sidebar before it holds any messages — the reason
// esrv_mailbox_folders exists at all (DistinctFoldersForMailbox alone can't prove a
// folder exists until something's actually stored in it).
func TestWebmailFolderCreateShowsUpEvenWhenEmpty(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "organizer2@example.com", domains[0].ID, "organizer-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/folders/add", strings.NewReader("name=Receipts"))
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("add folder: status=%d body=%s", rec.Code, rec.Body.String())
}
folders, err := app.DB.ListMailboxFolders(mailboxID)
if err != nil || len(folders) != 1 || folders[0] != "Receipts" {
t.Fatalf("expected [Receipts] in ListMailboxFolders, got %v (err=%v)", folders, err)
}
inboxReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
inboxReq.AddCookie(cookie)
inboxRec := httptest.NewRecorder()
mux.ServeHTTP(inboxRec, inboxReq)
if !strings.Contains(inboxRec.Body.String(), `data-folder="Receipts"`) {
t.Error("expected the empty new folder to appear in the sidebar")
}
}
// TestWebmailFolderCreateRejectsStandardAndDuplicateNames confirms you can't create a
// folder that collides with a standard folder or an existing custom one.
func TestWebmailFolderCreateRejectsStandardAndDuplicateNames(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "organizer3@example.com", domains[0].ID, "organizer-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
create := func(name string) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/folders/add", strings.NewReader("name="+name))
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("create %s: status=%d", name, rec.Code)
}
}
create("Sent") // standard folder name — should be rejected, not duplicated
create("Work")
create("Work") // duplicate — should be rejected, not duplicated
folders, err := app.DB.ListMailboxFolders(mailboxID)
if err != nil {
t.Fatal(err)
}
if len(folders) != 1 || folders[0] != "Work" {
t.Fatalf("expected exactly [Work] (Sent rejected as standard, duplicate Work rejected), got %v", folders)
}
}
// TestWebmailFolderDeleteMovesMessagesToInboxAndCannotDeleteStandard confirms
// deleting a custom folder relocates its messages to INBOX, and that a standard
// folder can't be deleted via the same route even if requested directly.
func TestWebmailFolderDeleteMovesMessagesToInboxAndCannotDeleteStandard(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "organizer4@example.com", domains[0].ID, "organizer-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
if err := app.DB.CreateMailboxFolder(mailboxID, "Newsletters"); err != nil {
t.Fatal(err)
}
raw := "From: a@example.com\r\nTo: organizer4@example.com\r\nSubject: hi\r\n\r\nbody"
if _, err := app.Mailstore.StoreMessage(mailboxID, "Newsletters", []byte(raw), "m@example.com", "a@example.com", "hi"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/folders/Newsletters/remove", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("delete folder: status=%d", rec.Code)
}
remaining, err := app.DB.ListMailboxFolders(mailboxID)
if err != nil || len(remaining) != 0 {
t.Fatalf("expected the folder record gone, got %v (err=%v)", remaining, err)
}
inboxMsgs, err := app.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil || len(inboxMsgs) != 1 {
t.Fatalf("expected the message relocated to INBOX, got %d (err=%v)", len(inboxMsgs), err)
}
// Attempting to delete a standard folder must be rejected, not silently succeed.
stdReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/folders/Sent/remove", nil)
stdReq.AddCookie(cookie)
stdRec := httptest.NewRecorder()
mux.ServeHTTP(stdRec, stdReq)
if stdRec.Code != http.StatusFound {
t.Fatalf("delete standard folder: status=%d", stdRec.Code)
}
sentReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/Sent", nil)
sentReq.AddCookie(cookie)
sentRec := httptest.NewRecorder()
mux.ServeHTTP(sentRec, sentReq)
if sentRec.Code != http.StatusOK {
t.Fatalf("Sent folder should still exist and render normally, status=%d", sentRec.Code)
}
}
+16 -4
View File
@@ -42,6 +42,9 @@ func (a *App) webmailLoginForm(w http.ResponseWriter, r *http.Request) {
// webmailLoginSubmit checks email+password against the mailbox's own portal
// password (never an app password — that's for IMAP/SMTP clients only).
func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
if !a.rateLimitLogin(w, r) {
return
}
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
@@ -49,6 +52,11 @@ func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "webmail_login.html", M{"error": msg, "email": email})
}
if a.accountLocked("webmail_login", email) {
fail("Too many failed attempts for this account. Try again later.")
return
}
mbox, err := a.DB.GetMailboxByEmail(email)
if err != nil {
a.Logger.Error("webmail login lookup: %v", err)
@@ -56,7 +64,7 @@ func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
return
}
if mbox == nil || !db.CheckPassword(password, mbox.PasswordHash) {
_ = a.DB.LogAuthAttempt("webmail_login", email, requestIP(r), false, "Incorrect email or password")
_ = a.DB.LogAuthAttempt("webmail_login", email, a.requestIP(r), false, "Incorrect email or password")
fail("Incorrect email or password.")
return
}
@@ -74,7 +82,7 @@ func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
fail("Something went wrong. Try again.")
return
}
_ = a.DB.LogAuthAttempt("webmail_login", email, requestIP(r), true, "Login successful")
_ = a.DB.LogAuthAttempt("webmail_login", email, a.requestIP(r), true, "Login successful")
setMailboxSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
@@ -101,6 +109,9 @@ func (a *App) webmailMFAForm(w http.ResponseWriter, r *http.Request) {
}
func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
if !a.rateLimitLogin(w, r) {
return
}
mailboxID := pendingMailboxMFAID(r)
if mailboxID == 0 {
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
@@ -115,7 +126,7 @@ func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.FormValue("code"))
if !mbox.TOTPEnabled || !totp.Validate(code, mbox.TOTPSecret) {
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), false, "Invalid MFA code")
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, a.requestIP(r), false, "Invalid MFA code")
hasPasskeys, _ := a.DB.CountMailboxWebAuthnCredentials(mailboxID)
a.render(w, r, "webmail_login_mfa.html", M{"totp_enabled": mbox.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code."})
return
@@ -127,7 +138,7 @@ func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), true, "Login successful (authenticator app)")
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, a.requestIP(r), true, "Login successful (authenticator app)")
clearMailboxPendingMFACookie(w)
setMailboxSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
@@ -136,6 +147,7 @@ func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
func (a *App) webmailLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(mailboxSessionCookieName); err == nil {
_ = a.DB.DeleteMailboxSession(c.Value)
a.pgpKeys.clearSession(c.Value)
}
clearMailboxSessionCookie(w)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
+433
View File
@@ -0,0 +1,433 @@
package webui
import (
"html/template"
"net/http"
"strconv"
"strings"
"github.com/microcosm-cc/bluemonday"
"mailgoserver/internal/db"
"mailgoserver/internal/mailview"
)
const webmailPageSize = 25
// htmlBodyPolicy sanitizes an HTML email body before it's ever embedded into a page
// as template.HTML — an email body is attacker-controlled content (anyone can send a
// mailbox a message), so rendering it unsanitized would be a straightforward stored
// XSS vector. UGCPolicy allows the common formatting tags/attributes a real email
// body uses while stripping <script>, event handlers, javascript: URLs, etc.
// AllowDataURIImages additionally permits img[src] as a base64 data: URI, restricted
// to actual decodable image/{gif,jpeg,png,webp} content (not a blanket data: URI
// allowance) — needed so a screenshot pasted into the Quill compose editor (which
// embeds pastes as inline base64 images) still renders once sanitized, both in the
// sender's own Sent view and the recipient's inbox.
var htmlBodyPolicy = func() *bluemonday.Policy {
p := bluemonday.UGCPolicy()
p.AllowDataURIImages()
return p
}()
// plainTextPolicy strips all HTML tags, leaving only text content — used to derive a
// plain-text fallback part from an HTML compose body (multipart/alternative) and to
// quote a plain-text-only original message's body when replying/forwarding.
var plainTextPolicy = bluemonday.StrictPolicy()
// standardMailFolders are always shown in the folder sidebar even when empty — the
// rest of a mailbox's folder list is whatever filter-rule move_to_folder actions (or,
// later, explicit folder creation) have actually produced messages in.
var standardMailFolders = []string{"INBOX", "Spam", "Sent", "Drafts", "Trash"}
func mergeFolders(custom []string) []string {
seen := make(map[string]bool, len(standardMailFolders)+len(custom))
out := make([]string, 0, len(standardMailFolders)+len(custom))
for _, f := range standardMailFolders {
seen[f] = true
out = append(out, f)
}
for _, f := range custom {
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out
}
// isStandardFolder reports whether name is one of the built-in folders every mailbox
// always has — these can never be created, renamed, or deleted through the folder
// management UI.
func isStandardFolder(name string) bool {
for _, f := range standardMailFolders {
if f == name {
return true
}
}
return false
}
// allFoldersFor is the full folder list for a mailbox: standard folders, plus every
// folder that either holds at least one message (DistinctFoldersForMailbox) or was
// explicitly created and is still empty (ListMailboxFolders) — a folder can exist via
// either path, sometimes both.
func (a *App) allFoldersFor(mailboxID int64) ([]string, error) {
fromMessages, err := a.DB.DistinctFoldersForMailbox(mailboxID)
if err != nil {
return nil, err
}
explicit, err := a.DB.ListMailboxFolders(mailboxID)
if err != nil {
return nil, err
}
return mergeFolders(append(fromMessages, explicit...)), nil
}
// folderRow adds template-ready fields to a listed message so webmail_folder.html
// stays dumb (no string-searching Flags, no subject-comparison logic, itself).
type folderRow struct {
db.MailboxMessage
Unread bool
// GroupExtra is set on the newest row of a same-subject run: how many older
// messages are collapsed under it (0 = not part of a group). Collapsed is set on
// each of those older rows, which the template hides until the group's expand
// toggle is clicked.
GroupExtra int
Collapsed bool
}
func isUnread(flags string) bool {
for _, f := range strings.Fields(flags) {
if f == `\Seen` {
return false
}
}
return true
}
// normalizeSubjectForGrouping strips Re:/Fwd:/Fw: prefixes and case for comparison.
// This is subject-based grouping, not References/In-Reply-To thread reconstruction
// — a reply with a hand-edited subject line won't group with its original, and two
// unrelated messages that happen to share a subject long after each other in
// mailbox history won't either, since grouping only ever joins consecutive rows in
// the already-sorted page (see groupConsecutiveBySubject). That's the accepted
// tradeoff for not needing a schema change or store-time header parsing.
func normalizeSubjectForGrouping(subject string) string {
s := strings.TrimSpace(subject)
for {
lower := strings.ToLower(s)
switch {
case strings.HasPrefix(lower, "re:"):
s = strings.TrimSpace(s[3:])
case strings.HasPrefix(lower, "fwd:"):
s = strings.TrimSpace(s[4:])
case strings.HasPrefix(lower, "fw:"):
s = strings.TrimSpace(s[3:])
default:
return strings.ToLower(s)
}
}
}
// groupConsecutiveBySubject annotates rows in place (same order, same length) with
// GroupExtra/Collapsed rather than restructuring them into a nested shape — the
// template can then render it exactly like a flat row list, just hiding Collapsed
// rows by default and showing a "+N more" toggle on the row above them.
func groupConsecutiveBySubject(rows []folderRow) []folderRow {
out := make([]folderRow, len(rows))
copy(out, rows)
i := 0
for i < len(out) {
key := normalizeSubjectForGrouping(out[i].CachedSubject)
j := i + 1
for key != "" && j < len(out) && normalizeSubjectForGrouping(out[j].CachedSubject) == key {
out[j].Collapsed = true
j++
}
out[i].GroupExtra = j - i - 1
i = j
}
return out
}
// webmailMailRoot sends a bare /webmail/mail visit to the inbox — there's no
// meaningful "all folders" view.
func (a *App) webmailMailRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}
// webmailFolderView lists one folder's messages, newest first, paginated — or, when
// ?q= is set, a search across that folder's (or every folder's, if active_folder is
// empty — see webmailSearch) cached subject/from/to instead.
func (a *App) webmailFolderView(w http.ResponseWriter, r *http.Request) {
a.renderFolderOrSearch(w, r, r.PathValue("folder"), strings.TrimSpace(r.URL.Query().Get("q")))
}
// renderFolderOrSearch is shared by webmailFolderView (folder browsing) and
// webmailSearch (all-folders search, webmail_search.go) — same page template, same
// pagination shape, differing only in which folder (if any) is scoped and whether a
// query narrows the result set.
func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folder, query string) {
mbox := mailboxFromContext(r)
folders, err := a.allFoldersFor(mbox.ID)
if err != nil {
a.Logger.Error("list folders for mailbox %d: %v", mbox.ID, err)
}
unreadCounts, err := a.DB.CountUnreadByFolder(mbox.ID)
if err != nil {
a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
}
page := atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
offset := (page - 1) * webmailPageSize
var total int
var rows []db.MailboxMessage
if query != "" {
total, err = a.DB.CountSearchMessagesInFolder(mbox.ID, folder, query)
if err != nil {
a.Logger.Error("count search results for mailbox %d: %v", mbox.ID, err)
}
rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize)
} else {
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder)
if err != nil {
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)
}
if err != nil {
setFlash(w, "error", "Error loading messages")
}
messages := make([]folderRow, 0, len(rows))
for _, m := range rows {
messages = append(messages, folderRow{MailboxMessage: m, Unread: isUnread(m.Flags)})
}
// 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,
// single-folder, unfiltered listing.
if query == "" && folder != "" {
messages = groupConsecutiveBySubject(messages)
}
a.render(w, r, "webmail_folder.html", M{
"mailbox": mbox, "folders": folders, "active_folder": folder,
"messages": messages, "page": page, "total": total,
"has_next": offset+len(rows) < total, "has_prev": page > 1,
"search_query": query, "unread_counts": unreadCounts,
"flashes": popFlashes(w, r),
})
}
// webmailMessageView decrypts, parses, and renders one message — and marks it read.
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
if !ok {
return
}
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
if err != nil {
a.Logger.Error("fetch message %d for mailbox %d: %v", uid, mbox.ID, err)
setFlash(w, "error", "Error loading message")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped)
if err != nil {
a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err)
setFlash(w, "error", "Error reading message")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
if isUnread(msgRow.Flags) {
newFlags := strings.TrimSpace(msgRow.Flags + ` \Seen`)
if err := a.DB.SetMessageFlags(mbox.ID, uid, newFlags); err != nil {
a.Logger.Error("mark message %d read: %v", uid, err)
}
}
folders, _ := a.allFoldersFor(mbox.ID)
var htmlBody template.HTML
if parsed.HTMLBody != "" {
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
}
a.render(w, r, "webmail_message.html", M{
"mailbox": mbox, "folders": folders, "active_folder": folder,
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
"flashes": popFlashes(w, r),
})
}
// webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in
// this mailbox, or isn't in the folder the URL claims — mirrors the admin side's
// *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment
// 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) {
msg, err := a.DB.GetMessageByUID(mailboxID, uid)
if err != nil || msg == nil || msg.Folder != folder {
http.NotFound(w, r)
return nil, false
}
return msg, true
}
// webmailMessageDelete moves a message to Trash — or, if it's already in Trash,
// permanently deletes it (ciphertext, index row, and frees the quota).
func (a *App) webmailMessageDelete(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
return
}
if folder == "Trash" {
if err := a.Mailstore.DeleteMessage(mbox.ID, uid); err != nil {
setFlash(w, "error", "Error deleting message")
} else {
setFlash(w, "success", "Message permanently deleted")
}
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
return
}
if err := a.DB.MoveMessage(mbox.ID, uid, "Trash"); err != nil {
setFlash(w, "error", "Error moving message to Trash")
} else {
setFlash(w, "success", "Message moved to Trash")
}
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}
// webmailMessageMove reassigns a message to a different (existing or freshly named)
// folder, e.g. from the message view's "Move to..." control.
func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
return
}
target := strings.TrimSpace(r.FormValue("target_folder"))
if target == "" {
setFlash(w, "error", "Choose a folder to move to")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder+"/"+strconv.FormatInt(uid, 10), http.StatusFound)
return
}
if err := a.DB.MoveMessage(mbox.ID, uid, target); err != nil {
setFlash(w, "error", "Error moving message")
} else {
setFlash(w, "success", "Message moved to "+target)
}
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}
// 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
// self-hosted mail server are small enough that this is simpler than building one.
func (a *App) webmailAttachmentDownload(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
idx := atoi(r.PathValue("idx"))
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
return
}
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
if err != nil {
http.NotFound(w, r)
return
}
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped)
if err != nil || idx < 0 || idx >= len(parsed.Attachments) {
http.NotFound(w, r)
return
}
att := parsed.Attachments[idx]
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(att.Filename, `"`, "")+`"`)
w.Header().Set("Content-Type", att.ContentType)
w.Write(att.Data)
}
const maxFolderNameLen = 60
// webmailAddFolder creates a new custom folder from the sidebar's "+ New folder"
// form. A standard folder name, an empty name, or a name that already exists is
// rejected with a flash rather than silently accepted/ignored.
func (a *App) webmailAddFolder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
name := strings.TrimSpace(r.FormValue("name"))
fail := func(msg string) {
setFlash(w, "error", msg)
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}
switch {
case name == "":
fail("Folder name is required")
return
case len(name) > maxFolderNameLen:
fail("Folder name is too long")
return
case isStandardFolder(name):
fail(name + " already exists")
return
}
existing, err := a.allFoldersFor(mbox.ID)
if err != nil {
fail("Error creating folder")
return
}
for _, f := range existing {
if strings.EqualFold(f, name) {
fail("A folder named " + f + " already exists")
return
}
}
if err := a.DB.CreateMailboxFolder(mbox.ID, name); err != nil {
fail("Error creating folder")
return
}
setFlash(w, "success", "Folder "+name+" created")
http.Redirect(w, r, MailboxPrefix+"/mail/"+name, http.StatusFound)
}
// webmailDeleteFolder removes a custom folder, moving any messages still in it to
// INBOX first — a folder is never left holding mail nothing can browse to anymore.
// Standard folders (checked server-side, not just hidden client-side) can't be
// removed this way.
func (a *App) webmailDeleteFolder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
name := r.PathValue("name")
if isStandardFolder(name) {
setFlash(w, "error", name+" is a standard folder and can't be removed")
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
return
}
if err := a.DB.MoveAllMessagesInFolder(mbox.ID, name, "INBOX"); err != nil {
setFlash(w, "error", "Error removing folder")
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
return
}
if err := a.DB.DeleteMailboxFolder(mbox.ID, name); err != nil {
setFlash(w, "error", "Error removing folder")
} else {
setFlash(w, "success", "Folder "+name+" removed — any mail in it moved to INBOX")
}
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}

Some files were not shown because too many files have changed in this diff Show More