updating rspamd and adding RAM reduction fixes
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user