updating rspamd and adding RAM reduction fixes

This commit is contained in:
2026-08-21 06:07:11 +01:00
parent 5b2936cf9d
commit a66530d1bd
25 changed files with 742 additions and 129 deletions
+80 -24
View File
@@ -64,21 +64,28 @@ type guardedListener struct {
database *db.DB
logger *toolbox.Logger
maxPerIP int // <=0 means unlimited
maxTotal int // <=0 means unlimited
mu sync.Mutex
counts map[string]int
total int
}
// GuardListener wraps inner so every accepted connection is checked against the IP
// blacklist (skipping the check entirely for abuse-whitelisted IPs) and the per-IP
// concurrent-connection cap ([Security] max_connections_per_ip, default 20) before the
// caller ever sees it.
// blacklist (skipping the check entirely for abuse-whitelisted IPs), the per-IP
// concurrent-connection cap ([Security] max_connections_per_ip, default 20), and the
// total concurrent-connection cap across every source IP combined on this listener
// ([Security] max_total_connections, default 1000) before the caller ever sees it. Like
// max_connections_per_ip, this is enforced per listener instance (SMTP plain, SMTP TLS,
// IMAP plain, and IMAP TLS each get their own GuardListener/counter in main.go), not as
// one counter shared across all four.
func GuardListener(inner net.Listener, database *db.DB, cfg *ini.File, logger *toolbox.Logger) net.Listener {
maxPerIP := 20
maxPerIP, maxTotal := 20, 1000
if cfg != nil {
maxPerIP = cfg.Section("Security").Key("max_connections_per_ip").MustInt(20)
maxTotal = cfg.Section("Security").Key("max_total_connections").MustInt(1000)
}
return &guardedListener{Listener: inner, database: database, logger: logger, maxPerIP: maxPerIP, counts: make(map[string]int)}
return &guardedListener{Listener: inner, database: database, logger: logger, maxPerIP: maxPerIP, maxTotal: maxTotal, counts: make(map[string]int)}
}
func (g *guardedListener) Accept() (net.Conn, error) {
@@ -91,26 +98,13 @@ func (g *guardedListener) Accept() (net.Conn, error) {
if splitErr != nil {
host = conn.RemoteAddr().String()
}
abuseWhitelisted := false
if whitelisted, wErr := g.database.IsIPAbuseWhitelisted(host); wErr == nil && whitelisted {
abuseWhitelisted = true
}
if !abuseWhitelisted {
blocked, bErr := g.database.IsIPBlacklisted(host)
if bErr == nil && blocked {
if g.logger != nil {
g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", host)
}
conn.Close()
continue
}
}
// The concurrent-connection cap applies even to an abuse-whitelisted IP —
// Both concurrent-connection caps apply even to an abuse-whitelisted IP —
// whitelisting exempts an IP from being auto-blacklisted over failed auth, not
// from basic resource-exhaustion protection, a different concern.
if g.maxPerIP > 0 {
// from basic resource-exhaustion protection, a different concern. Stays
// synchronous here: it's an in-memory counter, not a DB call.
if g.maxPerIP > 0 || g.maxTotal > 0 {
g.mu.Lock()
if g.counts[host] >= g.maxPerIP {
if g.maxPerIP > 0 && g.counts[host] >= g.maxPerIP {
g.mu.Unlock()
if g.logger != nil {
g.logger.Warning("abuseguard: rejected connection from %s: at the concurrent-connection limit (%d)", host, g.maxPerIP)
@@ -118,14 +112,75 @@ func (g *guardedListener) Accept() (net.Conn, error) {
conn.Close()
continue
}
if g.maxTotal > 0 && g.total >= g.maxTotal {
g.mu.Unlock()
if g.logger != nil {
g.logger.Warning("abuseguard: rejected connection from %s: at the total concurrent-connection limit (%d)", host, g.maxTotal)
}
conn.Close()
continue
}
g.counts[host]++
g.total++
g.mu.Unlock()
conn = &countedConn{Conn: conn, g: g, host: host}
}
return conn, nil
// The abuse-whitelist/blacklist DB lookups are deferred to first Read/Write
// (see checkedConn) rather than done here: this Accept loop is shared across
// every inbound connection on the listener, so a synchronous DB call here would
// serialize *every new connection* behind however long that query takes —
// worse under this app's deliberate SetMaxOpenConns(1), which serializes all DB
// access. Deferring lets each connection's own per-connection goroutine (spawned
// by the SMTP/IMAP server's Serve loop right after Accept returns) run its own
// check in parallel with every other connection's, while still guaranteeing the
// check completes strictly before any protocol byte — including the greeting
// banner — reaches the wire.
return &checkedConn{Conn: conn, g: g, host: host}, nil
}
}
// checkedConn defers the abuse-whitelist/blacklist DB check to the first Read or Write
// (see Accept's comment for why) and blocks that call — not the shared Accept loop —
// until the check resolves.
type checkedConn struct {
net.Conn
g *guardedListener
host string
once sync.Once
blocked bool
}
func (c *checkedConn) ensureChecked() {
c.once.Do(func() {
if whitelisted, err := c.g.database.IsIPAbuseWhitelisted(c.host); err == nil && whitelisted {
return
}
if blocked, err := c.g.database.IsIPBlacklisted(c.host); err == nil && blocked {
if c.g.logger != nil {
c.g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", c.host)
}
c.blocked = true
c.Conn.Close()
}
})
}
func (c *checkedConn) Read(b []byte) (int, error) {
c.ensureChecked()
if c.blocked {
return 0, net.ErrClosed
}
return c.Conn.Read(b)
}
func (c *checkedConn) Write(b []byte) (int, error) {
c.ensureChecked()
if c.blocked {
return 0, net.ErrClosed
}
return c.Conn.Write(b)
}
// countedConn decrements guardedListener's per-IP counter exactly once, however Close
// ends up getting called (explicitly, via a defer, or both).
type countedConn struct {
@@ -142,6 +197,7 @@ func (c *countedConn) Close() error {
if c.g.counts[c.host] <= 0 {
delete(c.g.counts, c.host)
}
c.g.total--
c.g.mu.Unlock()
})
return c.Conn.Close()
+67 -6
View File
@@ -121,14 +121,22 @@ func TestGuardListenerRejectsBlacklistedIP(t *testing.T) {
inner := newFakeListener(&addrOverrideConn{Conn: blockedConn, remote: hostPortAddr(blockedIP)})
guarded := GuardListener(inner, database, nil, nil)
go func() {
guarded.Accept()
inner.Close()
}()
conn, err := guarded.Accept()
if err != nil {
t.Fatal(err)
}
// The blacklist check is deferred to first Read/Write (not done inside Accept
// itself — see checkedConn), so it only fires once something tries to use the
// connection, the way a real per-connection SMTP/IMAP session handler would.
buf := make([]byte, 1)
if _, err := conn.Read(buf); err == nil {
t.Fatal("expected Read on a blacklisted connection to fail")
}
// 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)
// than any protocol banner, since checkedConn closes it before any bytes reach
// the wire.
if _, err := blockedPeer.Read(buf); err == nil {
t.Fatal("expected blocked connection to be closed by GuardListener, got readable data instead")
}
@@ -209,3 +217,56 @@ func TestGuardListenerCapsConcurrentConnectionsPerIP(t *testing.T) {
}
_ = got2
}
// TestGuardListenerCapsTotalConnections confirms max_total_connections rejects a
// connection once the cap is hit even across different source IPs (unlike
// max_connections_per_ip, which only tracks one IP at a time).
func TestGuardListenerCapsTotalConnections(t *testing.T) {
database := openTestDB(t)
cfg := ini.Empty()
sec, _ := cfg.NewSection("Security")
sec.NewKey("max_total_connections", "2")
newConnFromIP := func(ip string) (*addrOverrideConn, net.Conn) {
local, peer := net.Pipe()
return &addrOverrideConn{Conn: local, remote: hostPortAddr(ip)}, peer
}
c1, peer1 := newConnFromIP("198.51.100.10")
c2, peer2 := newConnFromIP("198.51.100.11")
c3, peer3 := newConnFromIP("198.51.100.12")
defer peer1.Close()
defer peer2.Close()
defer peer3.Close()
inner := newFakeListener(c1, c2, c3)
guarded := GuardListener(inner, database, cfg, nil)
if _, err := guarded.Accept(); err != nil {
t.Fatal(err)
}
if _, err := guarded.Accept(); err != nil {
t.Fatal(err)
}
accepted := make(chan net.Conn, 1)
go func() {
c, err := guarded.Accept()
if err == nil {
accepted <- c
}
}()
// The 3rd connection, from a distinct IP not previously seen, is still over the
// total cap of 2 — its peer should observe a close, proving this is a combined
// total, not per-IP.
buf := make([]byte, 1)
if _, err := peer3.Read(buf); err == nil {
t.Fatal("expected the 3rd connection (over the total cap) to be closed rather than accepted")
}
select {
case <-accepted:
t.Fatal("expected no connection to be accepted once the total cap is hit")
case <-time.After(200 * time.Millisecond):
}
}
+11
View File
@@ -64,6 +64,10 @@ var defaults = []struct {
{"LOG_LEVEL", "INFO", ""},
{"", "", "Hide verbose aiosmtpd-equivalent INFO messages when LOG_LEVEL = INFO"},
{"hide_info_aiosmtpd", "true", ""},
{"", "", "Log line format: text (default, matches the original Python server's"},
{"", "", "\"timestamp - name - level - message\" format) or json (one JSON object per"},
{"", "", "line, for shipping to a log aggregator)"},
{"format", "text", ""},
}},
{"Relay", []defaultKV{
{"", "", "Timeout in seconds for external SMTP connections"},
@@ -146,6 +150,10 @@ var defaults = []struct {
{"", "", "source IP — a resource-exhaustion guard, separate from the failed-auth blacklist"},
{"", "", "above (a connection flood doesn't need to fail auth to hurt)"},
{"max_connections_per_ip", "20", ""},
{"", "", "Reject a new SMTP/IMAP connection once this many are open in total, across every"},
{"", "", "source IP combined — bounds worst-case resource use even from many distinct IPs."},
{"", "", "0 = unlimited (previous behavior)."},
{"max_total_connections", "1000", ""},
}},
{"IMAP", []defaultKV{
{"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"},
@@ -183,6 +191,9 @@ var defaults = []struct {
{"virus_scan_enabled", "false", ""},
{"", "", "Address of the clamd instance to scan through, if enabled above"},
{"clamd_address", "127.0.0.1:3310", ""},
{"", "", "Delete send/receive log history (and any stored message attachments) older than"},
{"", "", "this many days. 0 (default) = keep forever — history only grows if left at 0."},
{"email_log_retention_days", "0", ""},
}},
{"Rspamd", []defaultKV{
{"", "", "Optional rspamd integration for spam scoring (off by default; the built-in"},
+5 -1
View File
@@ -134,8 +134,12 @@ func (d *DB) SendCountsByDomain(hours int) ([]DomainSendCount, error) {
// in this count either way.
func (d *DB) CountRecentSendsForDomain(domain string, since time.Time) (int, error) {
var n int
// mail_from_domain is computed once at insert time (see InsertEmailLog) and
// indexed, rather than recomputing substr/instr per row here on every relay send —
// SQLite can't use an index for a computed-expression WHERE clause, so the old form
// of this query was a full table scan against ever-growing history.
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_email_logs
WHERE substr(mail_from, instr(mail_from, '@') + 1) = ? AND timestamp >= ?`,
WHERE mail_from_domain = ? AND timestamp >= ?`,
domain, since.UTC()).Scan(&n)
return n, err
}
+9
View File
@@ -115,3 +115,12 @@ func (d *DB) CountPendingRelayQueueItemsForEmailLog(emailLogID int64) (int, erro
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_relay_queue WHERE email_log_id = ?`, emailLogID).Scan(&n)
return n, err
}
// CountPendingRelayQueueItems reports the total outbound relay backlog across every
// message, for the /metrics gauge (internal/webui/metrics.go) — a sustained rise here
// means deliveries aren't keeping up with intake.
func (d *DB) CountPendingRelayQueueItems() (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_relay_queue`).Scan(&n)
return n, err
}
+46 -3
View File
@@ -6,9 +6,9 @@ import "time"
// new row's id (needed before recipient/attachment child rows can be inserted).
func (d *DB) InsertEmailLog(l EmailLog) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_email_logs
(message_id, timestamp, peer_ip, mail_from, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.MessageID, l.Timestamp, l.PeerIP, l.MailFrom, l.ToAddress, l.CcAddresses, l.BccAddresses, l.Subject, l.EmailHeaders, l.MessageBody, l.Status, l.DKIMSigned, l.Username)
(message_id, timestamp, peer_ip, mail_from, mail_from_domain, to_address, cc_addresses, bcc_addresses, subject, email_headers, message_body, status, dkim_signed, username)
VALUES (?, ?, ?, ?, substr(?, instr(?, '@') + 1), ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.MessageID, l.Timestamp, l.PeerIP, l.MailFrom, l.MailFrom, l.MailFrom, l.ToAddress, l.CcAddresses, l.BccAddresses, l.Subject, l.EmailHeaders, l.MessageBody, l.Status, l.DKIMSigned, l.Username)
if err != nil {
return 0, err
}
@@ -47,6 +47,49 @@ func (d *DB) UpdateEmailLogStatus(id int64, status string) error {
return err
}
// PruneEmailLogsOlderThan deletes esrv_email_logs rows older than cutoff along with
// their esrv_email_recipient_logs and esrv_email_attachments rows (no FK cascade in
// this DB — see the schema comment on PRAGMA foreign_keys). A log still "queued" is
// never pruned regardless of age: that means the relay queue worker hasn't finished
// with it yet, and esrv_relay_queue's own row still references it. Returns the deleted
// attachments' file paths for the caller to remove from disk — this package does no
// file I/O — and the number of email logs deleted.
func (d *DB) PruneEmailLogsOlderThan(cutoff time.Time) (attachmentPaths []string, deleted int64, err error) {
rows, err := d.Query(`SELECT file_path FROM esrv_email_attachments
WHERE email_log_id IN (SELECT id FROM esrv_email_logs WHERE timestamp < ? AND status != 'queued')`, cutoff.UTC())
if err != nil {
return nil, 0, err
}
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
rows.Close()
return nil, 0, err
}
attachmentPaths = append(attachmentPaths, p)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, 0, err
}
rows.Close()
if _, err := d.Exec(`DELETE FROM esrv_email_attachments
WHERE email_log_id IN (SELECT id FROM esrv_email_logs WHERE timestamp < ? AND status != 'queued')`, cutoff.UTC()); err != nil {
return nil, 0, err
}
if _, err := d.Exec(`DELETE FROM esrv_email_recipient_logs
WHERE email_log_id IN (SELECT id FROM esrv_email_logs WHERE timestamp < ? AND status != 'queued')`, cutoff.UTC()); err != nil {
return nil, 0, err
}
res, err := d.Exec(`DELETE FROM esrv_email_logs WHERE timestamp < ? AND status != 'queued'`, cutoff.UTC())
if err != nil {
return nil, 0, err
}
deleted, err = res.RowsAffected()
return attachmentPaths, deleted, err
}
// InsertEmailAttachment mirrors one EmailAttachment row creation.
func (d *DB) InsertEmailAttachment(a EmailAttachment) error {
_, err := d.Exec(`INSERT INTO esrv_email_attachments
+77
View File
@@ -0,0 +1,77 @@
package db
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestPruneEmailLogsOlderThanDeletesOldRowsAndKeepsRecentAndQueued(t *testing.T) {
database := openMonitoringTestDB(t)
now := time.Now().UTC()
oldID, err := database.InsertEmailLog(EmailLog{MessageID: "old@x", Timestamp: now.Add(-48 * time.Hour), MailFrom: "a@sender.example", EmailHeaders: "h", Status: "relayed"})
if err != nil {
t.Fatal(err)
}
if err := database.InsertEmailRecipientLog(EmailRecipientLog{EmailLogID: oldID, Recipient: "r@x", RecipientType: "to", Status: "success"}); err != nil {
t.Fatal(err)
}
attachPath := filepath.Join(t.TempDir(), "attach.bin")
if err := os.WriteFile(attachPath, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
if err := database.InsertEmailAttachment(EmailAttachment{EmailLogID: oldID, Filename: "attach.bin", FilePath: attachPath, Size: 4}); err != nil {
t.Fatal(err)
}
recentID, err := database.InsertEmailLog(EmailLog{MessageID: "recent@x", Timestamp: now, MailFrom: "a@sender.example", EmailHeaders: "h", Status: "relayed"})
if err != nil {
t.Fatal(err)
}
queuedOldID, err := database.InsertEmailLog(EmailLog{MessageID: "queued@x", Timestamp: now.Add(-48 * time.Hour), MailFrom: "a@sender.example", EmailHeaders: "h", Status: "queued"})
if err != nil {
t.Fatal(err)
}
paths, deleted, err := database.PruneEmailLogsOlderThan(now.Add(-24 * time.Hour))
if err != nil {
t.Fatal(err)
}
if deleted != 1 {
t.Fatalf("expected exactly 1 email log deleted, got %d", deleted)
}
if len(paths) != 1 || paths[0] != attachPath {
t.Fatalf("expected the old attachment's path returned, got %v", paths)
}
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM esrv_email_logs WHERE id = ?`, oldID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("expected the old email log row to be deleted")
}
if err := database.QueryRow(`SELECT COUNT(*) FROM esrv_email_recipient_logs WHERE email_log_id = ?`, oldID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("expected the old email log's recipient log row to be deleted")
}
if err := database.QueryRow(`SELECT COUNT(*) FROM esrv_email_logs WHERE id = ?`, recentID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Error("expected the recent email log to survive pruning")
}
if err := database.QueryRow(`SELECT COUNT(*) FROM esrv_email_logs WHERE id = ?`, queuedOldID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Error("expected an old but still-'queued' email log to survive pruning (the relay worker isn't done with it)")
}
}
+33
View File
@@ -104,6 +104,10 @@ CREATE TABLE IF NOT EXISTS esrv_email_logs (
timestamp DATETIME NOT NULL,
peer_ip TEXT NOT NULL,
mail_from TEXT NOT NULL,
-- Derived from mail_from at insert time (see InsertEmailLog) so domainSendRateLimited
-- can filter with a plain indexed equality instead of a per-row substr()/instr()
-- expression, which SQLite can't use an index for.
mail_from_domain TEXT NOT NULL DEFAULT '',
to_address TEXT NOT NULL DEFAULT '',
cc_addresses TEXT DEFAULT '',
bcc_addresses TEXT DEFAULT '',
@@ -115,6 +119,9 @@ CREATE TABLE IF NOT EXISTS esrv_email_logs (
username TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Not created here: on a pre-existing DB, mail_from_domain doesn't exist yet at this
-- point in Open() (migrateAddedColumns below adds it) — CREATE INDEX would fail with
-- "no such column". See migrateAddedColumns for this index.
CREATE TABLE IF NOT EXISTS esrv_email_recipient_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -126,6 +133,10 @@ CREATE TABLE IF NOT EXISTS esrv_email_recipient_logs (
error_message TEXT,
server_response TEXT
);
-- Resolved by UpdateEmailRecipientLogStatus (email_log_id + recipient, guarded on
-- status='queued') on every relay-queue worker tick — without this index that query
-- was a full table scan against a table that only grows.
CREATE INDEX IF NOT EXISTS idx_email_recipient_logs_email_log_id ON esrv_email_recipient_logs(email_log_id);
-- In-flight outbound relay work — one row per recipient-domain-group (matching how
-- RelayEmailAsync/EnqueueForDelivery already batch same-domain recipients into a
@@ -770,6 +781,7 @@ func migrateAddedColumns(db *sql.DB) {
`ALTER TABLE esrv_mailboxes ADD COLUMN carddav_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_domains ADD COLUMN dkim_dns_automation TEXT NOT NULL DEFAULT 'manual'`,
`ALTER TABLE esrv_domains ADD COLUMN use_global_dkim INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_email_logs ADD COLUMN mail_from_domain TEXT NOT NULL DEFAULT ''`,
}
// The three old columns above were NOT NULL with no default, so simply adding
// key_pem left them behind still blocking every new insert (which only ever sets
@@ -787,6 +799,12 @@ func migrateAddedColumns(db *sql.DB) {
// defaults to 0 for every pre-existing row above, which would otherwise let that
// account skip its username change entirely once it re-hits /first-login next.
db.Exec(`UPDATE esrv_admin_users SET must_change_username = 1 WHERE username = ? AND must_change_password = 1`, DefaultAdminUsername)
// Backfill mail_from_domain for rows written before this column existed, then index
// it — both deferred to here (after the ALTER TABLE above) since neither the column
// nor an index on it can exist yet on a pre-existing DB when the main `schema`
// constant runs.
db.Exec(`UPDATE esrv_email_logs SET mail_from_domain = substr(mail_from, instr(mail_from, '@') + 1) WHERE mail_from_domain = ''`)
db.Exec(`CREATE INDEX IF NOT EXISTS idx_email_logs_mail_from_domain_timestamp ON esrv_email_logs(mail_from_domain, timestamp)`)
migrateSpamRenamedToJunk(db)
migrateFilterRulesMarkAsSpamCheck(db)
migrateFilterRulesAdvancedCheck(db)
@@ -959,6 +977,21 @@ func Open(path string) (*DB, error) {
// serialized through a single physical connection, so no connection can ever
// collide with another's in-progress write.
sqlDB.SetMaxOpenConns(1)
// WAL trades the default rollback journal's per-transaction create/fsync/delete
// cycle for periodic checkpointing — a large fsync-count reduction under the write
// volume this MTA generates (one email_log + N recipient_log rows per message).
// synchronous=NORMAL is the documented safe pairing with WAL (still fsyncs at each
// checkpoint; only risks losing the last few not-yet-checkpointed commits on an OS
// crash/power loss, never DB corruption). SetMaxOpenConns(1) above means WAL's
// concurrent-reader benefit doesn't apply here — this is purely about fsync count.
if _, err := sqlDB.Exec(`PRAGMA journal_mode = WAL`); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("set journal_mode: %w", err)
}
if _, err := sqlDB.Exec(`PRAGMA synchronous = NORMAL`); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("set synchronous: %w", err)
}
if _, err := sqlDB.Exec(`PRAGMA busy_timeout = 5000`); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("set busy_timeout: %w", err)
+48
View File
@@ -0,0 +1,48 @@
package imapserver
import (
"net"
"time"
)
// IdleTimeoutListener wraps a listener so every accepted connection gets a rolling
// deadline extended by d on each read/write. Unlike SMTP (go-smtp's Server.ReadTimeout/
// WriteTimeout), go-imap/v2's imapserver.Options has no timeout knob at all, so an idle
// or slow-drip connection can otherwise hold a goroutine (and a file descriptor) open
// indefinitely. 30 minutes (the caller's chosen d) matches RFC 2177's guidance that an
// IDLE-capable client re-issue IDLE at least that often, so a real IDLE session renews
// its own deadline in time and is never cut off by this.
func IdleTimeoutListener(inner net.Listener, d time.Duration) net.Listener {
return &idleTimeoutListener{Listener: inner, d: d}
}
type idleTimeoutListener struct {
net.Listener
d time.Duration
}
func (l *idleTimeoutListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
return nil, err
}
conn.SetDeadline(time.Now().Add(l.d))
return &idleTimeoutConn{Conn: conn, d: l.d}, nil
}
type idleTimeoutConn struct {
net.Conn
d time.Duration
}
func (c *idleTimeoutConn) Read(b []byte) (int, error) {
n, err := c.Conn.Read(b)
c.Conn.SetDeadline(time.Now().Add(c.d))
return n, err
}
func (c *idleTimeoutConn) Write(b []byte) (int, error) {
n, err := c.Conn.Write(b)
c.Conn.SetDeadline(time.Now().Add(c.d))
return n, err
}
+1 -1
View File
@@ -38,7 +38,7 @@ const previewSnippetLen = 150
// an accepted scope limit, not a bug: most real mail includes a text/plain
// alternative regardless of whether the sender expects it to be shown.
func previewSnippet(raw []byte) string {
parsed, err := mailview.Parse(raw)
parsed, err := mailview.Parse(bytes.NewReader(raw))
if err != nil {
return ""
}
+8 -4
View File
@@ -47,11 +47,15 @@ type Message struct {
Attachments []Attachment
}
// Parse walks raw's MIME structure (recursing into nested multiparts, e.g. a
// Parse walks r'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))
// the text body, the HTML body, or an attachment. Takes an io.Reader rather than
// []byte so a caller already holding a string (e.g. a message body pulled straight out
// of the DB) can pass strings.NewReader(s) directly instead of copying via []byte(s)
// first — callers already holding []byte pass bytes.NewReader(b), same as before, no
// copy either way.
func Parse(r io.Reader) (*Message, error) {
msg, err := mail.ReadMessage(r)
if err != nil {
return nil, err
}
+6 -6
View File
@@ -7,7 +7,7 @@ import (
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))
m, err := Parse(strings.NewReader(raw))
if err != nil {
t.Fatal(err)
}
@@ -29,7 +29,7 @@ func TestParseMultipartAlternativeKeepsBothBodies(t *testing.T) {
"--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))
m, err := Parse(strings.NewReader(raw))
if err != nil {
t.Fatal(err)
}
@@ -49,7 +49,7 @@ func TestParseAttachmentDecodesBase64(t *testing.T) {
"--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))
m, err := Parse(strings.NewReader(raw))
if err != nil {
t.Fatal(err)
}
@@ -75,7 +75,7 @@ func TestParseNestedMultipartMixedWithAlternativeBody(t *testing.T) {
"--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))
m, err := Parse(strings.NewReader(raw))
if err != nil {
t.Fatal(err)
}
@@ -99,7 +99,7 @@ func TestParseInlineImageCapturesContentID(t *testing.T) {
"Content-Id: <img1@example.com>\r\n" +
"Content-Transfer-Encoding: BASE64\r\n\r\nSGVsbG8sIHdvcmxkIQ==\r\n" +
"--B--\r\n"
m, err := Parse([]byte(raw))
m, err := Parse(strings.NewReader(raw))
if err != nil {
t.Fatal(err)
}
@@ -125,7 +125,7 @@ func TestParseInlineImageWithNoFilenameStillCaptured(t *testing.T) {
"--B\r\nContent-Type: image/png\r\n" +
"Content-Id: <noname@example.com>\r\n\r\nrawbytes" +
"\r\n--B--\r\n"
m, err := Parse([]byte(raw))
m, err := Parse(strings.NewReader(raw))
if err != nil {
t.Fatal(err)
}
+28 -1
View File
@@ -72,8 +72,18 @@ type Relay struct {
// at a local stand-in MTA (combined with port above) without a real DNS MX record.
// nil (the normal, non-test case) means "use net.LookupMX".
mxLookup func(domain string) ([]*net.MX, error)
// sideEffectSem bounds RelayEmailAsyncBounded's concurrency — see that method.
sideEffectSem chan struct{}
}
// sideEffectConcurrency caps how many RelayEmailAsyncBounded sends (mailbox
// forwarding, forward-rule actions, auto-reply — see session.go) run at once,
// matching the main relay queue worker's own maxConcurrent (main.go's
// runRelayQueueWorker) so a burst of incoming messages that each trigger one of these
// can't spawn unbounded concurrent outbound SMTP connections.
const sideEffectConcurrency = 10
func (r *Relay) targetPort() int {
if r.port != 0 {
return r.port
@@ -91,7 +101,24 @@ func New(database *db.DB, cfg *ini.File, logger *toolbox.Logger) *Relay {
if hostname == "" {
hostname = cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
}
return &Relay{DB: database, Timeout: time.Duration(timeoutSecs) * time.Second, Hostname: hostname, Logger: logger}
return &Relay{DB: database, Timeout: time.Duration(timeoutSecs) * time.Second, Hostname: hostname, Logger: logger, sideEffectSem: make(chan struct{}, sideEffectConcurrency)}
}
// RelayEmailAsyncBounded runs RelayEmailAsync in a new goroutine, gated by a shared
// semaphore (see sideEffectConcurrency) instead of a bare `go func(){ RelayEmailAsync
// (...) }()` — used for low-volume fire-and-forget side effects (mailbox forwarding,
// forward-rule actions, auto-reply) that each relay to a single recipient and have no
// esrv_email_logs row of their own to route through the main EnqueueForDelivery queue.
// Returns immediately either way; onDone (if non-nil) runs once delivery finishes.
func (r *Relay) RelayEmailAsyncBounded(mailFrom string, rcptTos []string, content string, recipientTypes []string, onDone func([]Result)) {
go func() {
r.sideEffectSem <- struct{}{}
defer func() { <-r.sideEffectSem }()
res := r.RelayEmailAsync(mailFrom, rcptTos, content, recipientTypes)
if onDone != nil {
onDone(res)
}
}()
}
// prepareEmailForRecipient mirrors email_relay._prepare_email_for_recipient: strips any
+50 -39
View File
@@ -32,6 +32,18 @@ func sanitizePathSegment(s string, chars string) string {
return s
}
// sanitizeAttachmentFilename confines an attacker-controlled MIME filename to a bare
// file name — filepath.Base strips any directory components (including "../"
// traversal), and the ".."/"." edge cases it can still return are replaced outright,
// so the caller's filepath.Join can never escape the intended storage directory.
func sanitizeAttachmentFilename(name string) string {
name = filepath.Base(name)
if name == "." || name == ".." || name == string(filepath.Separator) {
return "attachment"
}
return name
}
// cleanMessageIDPrefix strips everything from "@" onward, mirroring the
// clean_message_id computation used to build attachment filenames.
func cleanMessageIDPrefix(messageID string) string {
@@ -49,14 +61,18 @@ type attachmentPart struct {
type parsedMessage struct {
HeaderLines []string // "Name: value" per header, in order
BodyText string // concatenated text/* parts
Attachments []attachmentPart
}
// parseMessage mirrors the repeated BytesParser(policy=policy.default) passes in
// handle_DATA: it extracts header lines for logging, concatenated text body, and any
// attachment parts (Content-Disposition: attachment with a filename).
func parseMessage(raw []byte) (*parsedMessage, error) {
// handle_DATA: it extracts header lines for logging and, when wantAttachments is set,
// decodes every attachment part (Content-Disposition: attachment with a filename) to
// its real bytes. wantAttachments should be false whenever the caller doesn't actually
// need Attachments (Session.Data only does when the sender has attachment storage
// enabled) — decoding every attachment fully into memory just to discard it wastes a
// real allocation up to the size of the message's attachments, on every message,
// which matters on the small-RAM hosts this server targets.
func parseMessage(raw []byte, wantAttachments bool) (*parsedMessage, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, err
@@ -67,50 +83,45 @@ func parseMessage(raw []byte) (*parsedMessage, error) {
out.HeaderLines = append(out.HeaderLines, k+": "+v)
}
}
if !wantAttachments {
return out, nil
}
contentType := msg.Header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = "text/plain"
}
if !strings.HasPrefix(mediaType, "multipart/") {
return out, nil
}
if strings.HasPrefix(mediaType, "multipart/") {
mr := multipart.NewReader(msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
data, _ := io.ReadAll(part)
// multipart.Reader auto-decodes quoted-printable transparently during Read,
// but not base64 (see the mime/multipart docs) — without this, a base64
// attachment/body part is stored/relayed-for-display as raw base64 text
// instead of its actual decoded bytes.
data = decodeContentTransferEncoding(part.Header.Get("Content-Transfer-Encoding"), data)
disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
partCT := part.Header.Get("Content-Type")
partMediaType, _, _ := mime.ParseMediaType(partCT)
if disp == "attachment" && dispParams["filename"] != "" {
out.Attachments = append(out.Attachments, attachmentPart{
Filename: dispParams["filename"],
ContentType: getContentType(partMediaType, dispParams["filename"]),
Data: data,
})
continue
}
if strings.HasPrefix(partMediaType, "text/") && disp != "attachment" {
out.BodyText += string(data) + "\n"
}
mr := multipart.NewReader(msg.Body, params["boundary"])
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
if disp != "attachment" || dispParams["filename"] == "" {
continue
}
} else if strings.HasPrefix(mediaType, "text/") {
data, _ := io.ReadAll(msg.Body)
out.BodyText = string(data)
data, _ := io.ReadAll(part)
// multipart.Reader auto-decodes quoted-printable transparently during Read,
// but not base64 (see the mime/multipart docs) — without this, a base64
// attachment part is stored as raw base64 text instead of its actual decoded
// bytes.
data = decodeContentTransferEncoding(part.Header.Get("Content-Transfer-Encoding"), data)
partMediaType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
out.Attachments = append(out.Attachments, attachmentPart{
Filename: dispParams["filename"],
ContentType: getContentType(partMediaType, dispParams["filename"]),
Data: data,
})
}
out.BodyText = strings.TrimSpace(out.BodyText)
return out, nil
}
+2 -2
View File
@@ -29,7 +29,7 @@ func TestParseMessageDecodesBase64Attachment(t *testing.T) {
"SGVsbG8sIHdvcmxkIQ==\r\n" +
"--BOUND--\r\n"
parsed, err := parseMessage([]byte(raw))
parsed, err := parseMessage([]byte(raw), true)
if err != nil {
t.Fatalf("parseMessage: %v", err)
}
@@ -65,7 +65,7 @@ func TestParseMessageLeavesNonBase64EncodingsAlone(t *testing.T) {
"plain text content\r\n" +
"--BOUND--\r\n"
parsed, err := parseMessage([]byte(raw))
parsed, err := parseMessage([]byte(raw), true)
if err != nil {
t.Fatalf("parseMessage: %v", err)
}
+71
View File
@@ -6,9 +6,11 @@ import (
"net/http/httptest"
"net/smtp"
"strings"
"sync/atomic"
"testing"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
// fakeRspamd stands in for a real rspamd instance, always returning the fixed
@@ -87,6 +89,75 @@ func TestRspamdScoreThresholdQuarantinesInsteadOfRejecting(t *testing.T) {
}
}
// TestRspamdCheckedOnceForMultipleLocalRecipients confirms one message to several
// local recipients hits rspamd exactly once, not once per recipient (see
// checkRspamdOnce in deliverLocally) — both recipients still land in Junk from that
// single check.
func TestRspamdCheckedOnceForMultipleLocalRecipients(t *testing.T) {
backend, mailboxID1 := newTestBackendWithMailbox(t)
dek2 := mailstore.GenerateDEK()
wrapped2, nonce2, err := backend.Mailstore.WrapDEK(dek2)
if err != nil {
t.Fatal(err)
}
hash, err := db.HashPassword("portal-password-unused")
if err != nil {
t.Fatal(err)
}
mailboxID2, err := backend.DB.CreateMailbox("second@example.com", hash, 1, 5*1024*1024*1024, wrapped2, nonce2)
if err != nil {
t.Fatal(err)
}
var calls int32
rspamd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
json.NewEncoder(w).Encode(map[string]any{"score": 20, "action": "add header"})
}))
t.Cleanup(rspamd.Close)
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)
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 1: %v", err)
}
if err := c.Rcpt("second@example.com"); err != nil {
t.Fatalf("RCPT 2: %v", err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
w.Write([]byte("Subject: hi\r\n\r\nhi"))
if err := w.Close(); err != nil {
t.Fatalf("expected delivery accepted (quarantined), got: %v", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected rspamd to be checked exactly once for 2 local recipients, got %d calls", got)
}
for _, mb := range []int64{mailboxID1, mailboxID2} {
spamMsgs, err := backend.DB.ListMessagesInFolder(mb, "Junk")
if err != nil || len(spamMsgs) != 1 {
t.Fatalf("expected 1 quarantined message in Spam for mailbox %d, got %d (err=%v)", mb, len(spamMsgs), err)
}
}
}
// firstMessage fetches the single message expected in folder, failing the test if
// there isn't exactly one — a small shared helper for the tagging tests below, which
// all need to inspect both the cached subject and the raw stored content.
+4
View File
@@ -37,6 +37,10 @@ func applyLimits(s *smtp.Server, cfg *ini.File) {
sec := cfg.Section("Mailstore")
s.MaxMessageBytes = sec.Key("max_message_bytes").MustInt64(25 * 1024 * 1024)
s.MaxRecipients = sec.Key("max_recipients").MustInt(100)
// Go strings are UTF-8 natively, so advertising SMTPUTF8 (RFC 6531) needs no
// backend change — unlike BINARYMIME/CHUNKING, which would require real BDAT
// support this backend doesn't implement, so those stay off.
s.EnableSMTPUTF8 = true
}
// NewPlainServer mirrors server_runner.py's PlainController: no TLS context at all, so
+41 -14
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"time"
"unsafe"
"github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
@@ -243,7 +244,13 @@ func (s *Session) Data(r io.Reader) error {
if err != nil {
return internalError("Internal server error")
}
content := string(raw)
// unsafe.String views content directly over raw's own backing array instead of
// string(raw)'s real copy — on a message near [Mailstore] max_message_bytes (25MB
// default) that's a second full-message-sized allocation for no benefit, which
// matters on the small-RAM hosts this server targets. Safe only because raw is
// never mutated again below (only read, by parseMessage's own bytes.NewReader) —
// if that ever changes, this must go back to a real copy.
content := unsafe.String(unsafe.SliceData(raw), len(raw))
messageID := extractMessageID(content, s.backend.HeloHostname)
senderDomain := domainOfAddr(s.mailFrom)
@@ -305,7 +312,10 @@ func (s *Session) Data(r io.Reader) error {
storeMessage = true
}
parsed, parseErr := parseMessage(raw)
// wantAttachments=storeMessage: decoding every attachment fully into memory is
// only useful when they're about to be written to disk below — see parseMessage's
// own comment.
parsed, parseErr := parseMessage(raw, storeMessage)
type savedAttachment struct {
Filename, ContentType, FilePath string
@@ -323,7 +333,7 @@ func (s *Session) Data(r io.Reader) error {
if err := os.MkdirAll(storagePath, 0o755); err == nil {
prefix := cleanMessageIDPrefix(messageID)
for _, a := range parsed.Attachments {
filename := prefix + "_" + a.Filename
filename := prefix + "_" + sanitizeAttachmentFilename(a.Filename)
fullPath := filepath.Join(storagePath, filename)
if err := os.WriteFile(fullPath, a.Data, 0o644); err == nil {
toSave = append(toSave, savedAttachment{Filename: a.Filename, ContentType: a.ContentType, FilePath: fullPath, Size: int64(len(a.Data))})
@@ -501,7 +511,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
// without every mailbox needing its own parse pass. Best-effort: a message this
// package's own parser can't handle just never matches those two condition types.
bodyText, hasAttachment := "", "no"
if parsedForRules, err := mailview.Parse([]byte(signedContent)); err == nil {
if parsedForRules, err := mailview.Parse(strings.NewReader(signedContent)); err == nil {
bodyText = parsedForRules.TextBody
if bodyText == "" && parsedForRules.HTMLBody != "" {
bodyText = bluemonday.StrictPolicy().Sanitize(parsedForRules.HTMLBody)
@@ -544,6 +554,26 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
}
}
// rspamd is checked at most once per message and reused for every local recipient
// below, rather than once per recipient — confirmed safe for this deployment (no
// reliance on rspamd's per-recipient personalization, e.g. per-user Bayes/
// whitelists); content and mail_from are identical for every recipient regardless,
// so the score/action rspamd would return doesn't actually vary by recipient here.
// Cuts what was N rspamd HTTP round-trips down to 1 for a large local fan-out.
rspamdChecked := false
var rspamdScore float64
var rspamdAction string
var rspamdOK bool
checkRspamdOnce := func() (float64, string, bool) {
if !rspamdChecked {
if score, action, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpts[0]); err == nil {
rspamdScore, rspamdAction, rspamdOK = score, action, true
}
rspamdChecked = true
}
return rspamdScore, rspamdAction, rspamdOK
}
results := make([]relay.Result, 0, len(rcpts))
for i, rcpt := range rcpts {
mbox := s.localMailboxes[strings.ToLower(rcpt)]
@@ -596,7 +626,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
if !suppressSpam {
quarantine := heuristicScore >= rejectScore
if rspamdEnabled {
if score, rAction, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil {
if score, rAction, ok := checkRspamdOnce(); ok {
// 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
@@ -656,12 +686,11 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
// accepted edge case, not engineered around).
if mbox.ForwardTo != nil && *mbox.ForwardTo != "" {
forwardTo, mailboxEmail, keepCopy := *mbox.ForwardTo, mbox.Email, mbox.ForwardKeepCopy
go func() {
res := s.backend.Relay.RelayEmailAsync(mailboxEmail, []string{forwardTo}, signedContent, []string{"to"})
s.backend.Relay.RelayEmailAsyncBounded(mailboxEmail, []string{forwardTo}, signedContent, []string{"to"}, func(res []relay.Result) {
if len(res) > 0 && res[0].Status != "success" {
s.backend.Logger.Error("mailbox forwarding: delivery to %s failed: %s", forwardTo, res[0].ErrorMessage)
}
}()
})
if !keepCopy {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Forwarded to " + forwardTo + ", not kept locally"})
continue
@@ -685,12 +714,11 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
// mail; no SRS rewriting or Resent-* headers, matching every other
// send path in this codebase.
forwardTo, mailboxEmail := action.ForwardTo, mbox.Email
go func() {
res := s.backend.Relay.RelayEmailAsync(mailboxEmail, []string{forwardTo}, signedContent, []string{"to"})
s.backend.Relay.RelayEmailAsyncBounded(mailboxEmail, []string{forwardTo}, signedContent, []string{"to"}, func(res []relay.Result) {
if len(res) > 0 && res[0].Status != "success" {
s.backend.Logger.Error("forward rule: delivery to %s failed: %s", forwardTo, res[0].ErrorMessage)
}
}()
})
if !action.KeepCopy {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Forwarded to " + forwardTo + ", not kept locally"})
continue
@@ -752,12 +780,11 @@ func (s *Session) sendAutoReply(mbox *db.Mailbox, subject, body, inReplyTo strin
}
mailboxEmail, replyTo := mbox.Email, s.mailFrom
raw := buildAutoReplyMessage(s.backend.HeloHostname, mailboxEmail, replyTo, subject, body, inReplyTo)
go func() {
res := s.backend.Relay.RelayEmailAsync(mailboxEmail, []string{replyTo}, raw, []string{"to"})
s.backend.Relay.RelayEmailAsyncBounded(mailboxEmail, []string{replyTo}, raw, []string{"to"}, func(res []relay.Result) {
if len(res) > 0 && res[0].Status != "success" {
s.backend.Logger.Error("auto-reply to %s failed: %s", replyTo, res[0].ErrorMessage)
}
}()
})
if err := s.backend.DB.RecordAutoReply(mbox.ID, replyTo); err != nil {
s.backend.Logger.Error("record auto-reply to %s: %v", replyTo, err)
}
+23 -2
View File
@@ -4,6 +4,7 @@ package toolbox
import (
"crypto/rand"
"encoding/json"
"fmt"
"log"
"math/big"
@@ -62,13 +63,18 @@ func (l Level) String() string {
}
}
var globalLevel = LevelInfo
var (
globalLevel = LevelInfo
globalJSONFormat = false
)
// Configure sets the process-wide log level from settings.ini's [Logging] section,
// mirroring tool_box.setup_logging.
// mirroring tool_box.setup_logging. Also reads the optional format=json opt-in (see
// Logger.log) — default stays the original Python-parity text format.
func Configure(cfg *ini.File) {
section := cfg.Section("Logging")
globalLevel = parseLevel(section.Key("LOG_LEVEL").MustString("INFO"))
globalJSONFormat = strings.EqualFold(section.Key("format").MustString("text"), "json")
}
// GetLogger returns a Logger for the given component name, mirroring tool_box.get_logger.
@@ -83,6 +89,21 @@ func (l *Logger) log(level Level, format string, args ...any) {
return
}
msg := fmt.Sprintf(format, args...)
if globalJSONFormat {
line, err := json.Marshal(struct {
Time string `json:"time"`
Logger string `json:"logger"`
Level string `json:"level"`
Message string `json:"message"`
}{time.Now().UTC().Format(time.RFC3339Nano), l.name, level.String(), msg})
if err == nil {
l.out.Println(string(line))
return
}
// json.Marshal only fails here on invalid UTF-8 in msg (e.g. from binary data
// interpolated into a log line) — fall through to the text format rather than
// silently dropping the line.
}
ts := time.Now().Format("2006-01-02 15:04:05,000")
l.out.Printf("%s - %s - %s - %s", ts, l.name, level, msg)
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (a *App) viewMessageContent(w http.ResponseWriter, r *http.Request) {
var plainBody string
var attachments []viewedAttachment
if log.MessageBody != "" {
if parsed, err := mailview.Parse([]byte(log.MessageBody)); err == nil {
if parsed, err := mailview.Parse(strings.NewReader(log.MessageBody)); err == nil {
if parsed.HTMLBody != "" {
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
}
+1 -1
View File
@@ -337,7 +337,7 @@ func TestWebmailComposeHTMLBodyRoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
parsed, err := mailview.Parse(raw)
parsed, err := mailview.Parse(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -232,7 +232,7 @@ func (a *App) webmailLoadForPrefill(mailboxID int64, folder string, uid int64) *
if err != nil {
return nil
}
parsed, err := mailview.Parse(raw)
parsed, err := mailview.Parse(bytes.NewReader(raw))
if err != nil {
return nil
}
@@ -1,6 +1,7 @@
package webui
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
@@ -168,7 +169,7 @@ func TestWebmailComposeSendForwardAttach(t *testing.T) {
if err != nil {
t.Fatal(err)
}
parsed, err := mailview.Parse(rawReceived)
parsed, err := mailview.Parse(bytes.NewReader(rawReceived))
if err != nil {
t.Fatal(err)
}
@@ -225,7 +226,7 @@ func TestWebmailComposeSendMultipleForwardAttach(t *testing.T) {
if err != nil {
t.Fatal(err)
}
parsed, err := mailview.Parse(rawReceived)
parsed, err := mailview.Parse(bytes.NewReader(rawReceived))
if err != nil {
t.Fatal(err)
}
+12 -11
View File
@@ -2,6 +2,7 @@ package webui
import (
"archive/zip"
"bytes"
"encoding/base64"
"fmt"
"html/template"
@@ -365,13 +366,13 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
"has_next": offset+len(rows) < total, "has_prev": page > 1,
"search_query": query, "unread_counts": unreadCounts, "folder_counts": folderCounts,
"unread_only": unreadOnly, "starred_only": starredOnly, "sort_by": sortBy, "sort_dir": sortDir,
"sort_from_href": sortLink("from", unreadOnly, starredOnly, sortBy, sortDir),
"sort_date_href": sortLink("", unreadOnly, starredOnly, sortBy, sortDir),
"unread_only_href": unreadOnlyHref,
"sort_from_href": sortLink("from", unreadOnly, starredOnly, sortBy, sortDir),
"sort_date_href": sortLink("", unreadOnly, starredOnly, sortBy, sortDir),
"unread_only_href": unreadOnlyHref,
"starred_only_href": starredOnlyHref,
"prev_href": pageHref(page - 1),
"next_href": pageHref(page + 1),
"flashes": popFlashes(w, r),
"prev_href": pageHref(page - 1),
"next_href": pageHref(page + 1),
"flashes": popFlashes(w, r),
})
}
@@ -478,7 +479,7 @@ func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *d
return nil, false
}
unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped)
parsed, err := mailview.Parse(bytes.NewReader(unwrapped))
if err != nil {
a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err)
setFlash(w, "error", "Error reading message")
@@ -632,7 +633,7 @@ func (a *App) webmailAlwaysAllowImages(w http.ResponseWriter, r *http.Request) {
return
}
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped)
parsed, err := mailview.Parse(bytes.NewReader(unwrapped))
senderEmail := ""
if err == nil {
senderEmail = extractAddress(parsed.Header.From)
@@ -762,7 +763,7 @@ func (a *App) webmailMarkAsJunk(w http.ResponseWriter, r *http.Request) {
return
}
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
parsed, parseErr := mailview.Parse(unwrapped)
parsed, parseErr := mailview.Parse(bytes.NewReader(unwrapped))
if err := a.DB.MoveMessage(mbox.ID, uid, "Junk"); err != nil {
setFlash(w, "error", "Error marking as junk")
@@ -905,7 +906,7 @@ func (a *App) webmailAttachmentDownload(w http.ResponseWriter, r *http.Request)
return
}
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped)
parsed, err := mailview.Parse(bytes.NewReader(unwrapped))
if err != nil || idx < 0 || idx >= len(parsed.Attachments) {
http.NotFound(w, r)
return
@@ -933,7 +934,7 @@ func (a *App) webmailDownloadAllAttachments(w http.ResponseWriter, r *http.Reque
return
}
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped)
parsed, err := mailview.Parse(bytes.NewReader(unwrapped))
if err != nil || len(parsed.Attachments) == 0 {
http.NotFound(w, r)
return
+114 -10
View File
@@ -14,6 +14,7 @@ import (
"os"
"os/signal"
"path/filepath"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -320,7 +321,7 @@ func main() {
logger.Error("plain IMAP listen: %v", err)
return
}
if err := plainServer.Serve(abuseguard.GuardListener(l, database, cfg, logger)); err != nil && !errors.Is(err, net.ErrClosed) {
if err := plainServer.Serve(imapserver.IdleTimeoutListener(abuseguard.GuardListener(l, database, cfg, logger), 30*time.Minute)); err != nil && !errors.Is(err, net.ErrClosed) {
logger.Error("plain IMAP server: %v", err)
}
}()
@@ -330,7 +331,7 @@ func main() {
logger.Error("TLS IMAP listen: %v", err)
return
}
if err := tlsServer.Serve(abuseguard.GuardListener(imapTLSListener, database, cfg, logger)); err != nil && !errors.Is(err, net.ErrClosed) {
if err := tlsServer.Serve(imapserver.IdleTimeoutListener(abuseguard.GuardListener(imapTLSListener, database, cfg, logger), 30*time.Minute)); err != nil && !errors.Is(err, net.ErrClosed) {
logger.Error("TLS IMAP server: %v", err)
}
}()
@@ -399,6 +400,45 @@ func main() {
}
go runScheduledBackups()
// Prunes send/receive log history (and any attachments stored alongside it) older
// than [Mailstore] email_log_retention_days — off by default (0), matching the
// "blank/0 means manual/off" convention used by the backup schedule and DKIM
// rotation above, since esrv_email_logs otherwise grows forever with no cleanup at
// all. Checked hourly, same cadence as the backup job, for the same reason (cheap,
// and a live Settings change takes effect without a restart).
runLogRetention := func() {
check := func() {
days := cfg.Section("Mailstore").Key("email_log_retention_days").MustInt(0)
if days <= 0 {
return
}
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
paths, deleted, err := database.PruneEmailLogsOlderThan(cutoff)
if err != nil {
logger.Error("log retention: %v", err)
return
}
for _, p := range paths {
if p == "" {
continue
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
logger.Error("log retention: remove attachment %s: %v", p, err)
}
}
if deleted > 0 {
logger.Info("log retention: pruned %d email log(s) older than %d days", deleted, days)
}
}
check()
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
check()
}
}
go runLogRetention()
// Optional auto-rotation of the shared/global DKIM key (CNAME delegation) —
// manual-only (no ticker work beyond the no-op check) when [DKIM]
// global_dkim_rotation_days is blank/0, same "blank means manual" convention as
@@ -451,15 +491,32 @@ func main() {
}
go runGlobalDKIMRotation()
// Created once, up front, so runRelayQueueWorker (below) and waitForShutdown share
// the same signal — otherwise the worker would have no way to know shutdown had
// started and could keep kicking off fresh batches until the process is killed
// mid-delivery.
sigCtx, stopSignal := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stopSignal()
// Delivers messages accepted onto esrv_relay_queue by Session.Data (see
// internal/relay/queue.go) — a 5s tick keeps delivery prompt without polling too
// aggressively; 10 concurrent deliveries / 50 per tick are fixed, not config, same
// "keeps this simple" precedent as retrySchedule.
// "keeps this simple" precedent as retrySchedule. Stops starting new batches once
// sigCtx fires; relayWorkerWG lets waitForShutdown wait for an already-in-progress
// batch to actually finish instead of killing it mid-delivery.
var relayWorkerWG sync.WaitGroup
runRelayQueueWorker := func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
relayer.ProcessQueueOnce(10, 50)
for {
select {
case <-sigCtx.Done():
return
case <-ticker.C:
relayWorkerWG.Add(1)
relayer.ProcessQueueOnce(10, 50)
relayWorkerWG.Done()
}
}
}
go runRelayQueueWorker()
@@ -472,9 +529,7 @@ func main() {
// go-imap/v2's Server only has Close() (force-close, confirmed no graceful variant
// exists in that library) — still better than no shutdown handling at all.
waitForShutdown := func(smtpPlain, smtpTLS *smtp.Server, imapPlain, imapTLS *goimapserver.Server, httpSrv, httpsSrv *http.Server) {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
<-sigCtx.Done()
logger.Info("shutdown signal received, draining connections...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
@@ -497,6 +552,17 @@ func main() {
if httpsSrv != nil {
httpsSrv.Shutdown(shutdownCtx)
}
relayDone := make(chan struct{})
go func() {
relayWorkerWG.Wait()
close(relayDone)
}()
select {
case <-relayDone:
case <-shutdownCtx.Done():
logger.Warning("shutdown: relay queue worker still delivering after the drain timeout, exiting anyway")
}
logger.Info("shutdown complete")
}
@@ -526,6 +592,9 @@ func main() {
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeHealthJSON(w, database, smtpRunning.Load())
})
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
writeMetricsText(w, database)
})
// Most visitors are mailbox owners, not admins — default the bare root to the
// self-service webmail login, with a "Login as Admin" button there for the admin
// dashboard's login instead of requiring the admin URL to be typed by hand.
@@ -550,7 +619,11 @@ func main() {
httpsPort := cfg.Section("Server").Key("WEB_HTTPS_PORT").MustInt(5001)
httpsAddr := fmt.Sprintf("%s:%d", *host, httpsPort)
httpsServer := &http.Server{Addr: httpsAddr, Handler: handler, TLSConfig: webHTTPSConfig}
// ReadHeaderTimeout is the slowloris fix (bounds only the time to receive headers);
// ReadTimeout/WriteTimeout are deliberately left unset — webmailMux serves an
// SSE stream (webmail_mail.go's event-stream handler) and attachment downloads,
// both legitimately long-lived, and a global deadline would cut them off.
httpsServer := &http.Server{Addr: httpsAddr, Handler: handler, TLSConfig: webHTTPSConfig, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second}
go func() {
logger.Info("Web interface (HTTPS) starting at https://%s", httpsAddr)
// Empty cert/key paths: TLSConfig.GetCertificate (backed by certReloader) supplies
@@ -567,7 +640,7 @@ func main() {
httpPort = cfg.Section("Server").Key("WEB_HTTP_PORT").MustInt(5000)
}
addr := fmt.Sprintf("%s:%d", *host, httpPort)
httpServer := &http.Server{Addr: addr, Handler: handler}
httpServer := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second}
go func() {
logger.Info("Web interface starting at http://%s (debug=%v)", addr, *debug)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -602,3 +675,34 @@ func writeHealthJSON(w http.ResponseWriter, database *db.DB, smtpUp bool) {
fmt.Fprintf(w, `{"status":%q,"timestamp":%q,"services":{"smtp_server":%q,"web_frontend":"running","database":%q},"version":"1.0.0"}`,
overall, time.Now().Format(time.RFC3339), smtpStatus, dbStatus)
}
// writeMetricsText serves a hand-written Prometheus text-exposition response (no
// client_golang dependency needed for this small, fixed set of gauges) reusing the
// same DB queries the admin dashboard already runs, so this doesn't add new query
// load beyond what /health and the dashboard already cause.
func writeMetricsText(w http.ResponseWriter, database *db.DB) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
dbUp := 1
if err := database.Ping(); err != nil {
dbUp = 0
}
fmt.Fprintf(w, "# HELP mailgoserver_db_up Whether the database connection is healthy (1) or not (0).\n")
fmt.Fprintf(w, "# TYPE mailgoserver_db_up gauge\n")
fmt.Fprintf(w, "mailgoserver_db_up %d\n", dbUp)
if pending, err := database.CountPendingRelayQueueItems(); err == nil {
fmt.Fprintf(w, "# HELP mailgoserver_relay_queue_pending Outbound relay domain-groups still awaiting delivery.\n")
fmt.Fprintf(w, "# TYPE mailgoserver_relay_queue_pending gauge\n")
fmt.Fprintf(w, "mailgoserver_relay_queue_pending %d\n", pending)
}
if success, failed, err := database.DeliveryStats(24); err == nil {
fmt.Fprintf(w, "# HELP mailgoserver_delivery_success_24h Successful recipient deliveries in the last 24h.\n")
fmt.Fprintf(w, "# TYPE mailgoserver_delivery_success_24h gauge\n")
fmt.Fprintf(w, "mailgoserver_delivery_success_24h %d\n", success)
fmt.Fprintf(w, "# HELP mailgoserver_delivery_failed_24h Failed recipient deliveries in the last 24h.\n")
fmt.Fprintf(w, "# TYPE mailgoserver_delivery_failed_24h gauge\n")
fmt.Fprintf(w, "mailgoserver_delivery_failed_24h %d\n", failed)
}
}