added JMAP (not tested yet)
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLogAuthAttemptCoalescesRepeatedSuccesses reproduces a client that reconnects and
|
||||
// re-authenticates rapidly (e.g. an IMAP client polling instead of holding one IDLE
|
||||
// connection): only the first success in a window should write a row, and refreshing
|
||||
// past the window should write a new one. Failures are never coalesced, since
|
||||
// CountRecentFailedAttempts' lockout logic depends on every one being recorded.
|
||||
func TestLogAuthAttemptCoalescesRepeatedSuccesses(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const authType, identifier, ip = "imap_login", "user@example.com", "203.0.113.7"
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := d.LogAuthAttempt(authType, identifier, ip, true, "ok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
logs, err := d.ListRecentAuthLogs(100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countMatching(logs, authType, identifier, ip); n != 1 {
|
||||
t.Fatalf("expected 1 row after 5 rapid successes within the window, got %d", n)
|
||||
}
|
||||
|
||||
// Simulate the window having elapsed: back-date the cached last-seen time directly.
|
||||
d.authLogDedupMu.Lock()
|
||||
d.authLogDedup[authType+"|"+identifier+"|"+ip+"|ok"] = time.Now().Add(-authLogDedupWindow - time.Second)
|
||||
d.authLogDedupMu.Unlock()
|
||||
|
||||
if err := d.LogAuthAttempt(authType, identifier, ip, true, "ok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logs, err = d.ListRecentAuthLogs(100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countMatching(logs, authType, identifier, ip); n != 2 {
|
||||
t.Fatalf("expected a fresh row once the dedup window elapsed, got %d", n)
|
||||
}
|
||||
|
||||
// Failures are never coalesced.
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := d.LogAuthAttempt(authType, identifier, ip, false, "bad password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
n, err := d.CountRecentFailedAttempts(authType, identifier, time.Now().Add(-time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("expected all 3 failed attempts recorded, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogAuthAttemptNeverCoalescesDifferentMessages guards against dedup swallowing
|
||||
// genuinely distinct events that happen to share authType+identifier+IP — e.g. webui's
|
||||
// "admin_mfa" audit entries ("Passkey added: x" / "Passkey removed") for the same admin
|
||||
// within the same window must all survive.
|
||||
func TestLogAuthAttemptNeverCoalescesDifferentMessages(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
const authType, identifier, ip = "admin_mfa", "admin@example.com", "203.0.113.7"
|
||||
|
||||
messages := []string{"Passkey added: laptop", "Passkey added: phone", "Passkey removed"}
|
||||
for _, m := range messages {
|
||||
if err := d.LogAuthAttempt(authType, identifier, ip, true, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
logs, err := d.ListRecentAuthLogs(100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countMatching(logs, authType, identifier, ip); n != len(messages) {
|
||||
t.Fatalf("expected all %d distinct-message events recorded, got %d", len(messages), n)
|
||||
}
|
||||
}
|
||||
|
||||
func countMatching(logs []AuthLog, authType, identifier, ip string) int {
|
||||
n := 0
|
||||
for _, l := range logs {
|
||||
if l.AuthType == authType && l.Identifier == identifier && l.IPAddress == ip && l.Success {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -134,8 +134,41 @@ func (w WhitelistedIP) CanSendForDomain(d *DB, domainName string) (bool, error)
|
||||
return w.DomainID == dom.ID, nil
|
||||
}
|
||||
|
||||
// authLogDedupWindow bounds how often one identifier+IP's successful logins write a
|
||||
// fresh esrv_auth_logs row (see LogAuthAttempt below) — 15 minutes, matching the
|
||||
// ballpark of this codebase's other fixed session-ish windows (IMAP's own
|
||||
// IdleTimeoutListener is 30 minutes) without needing to be configurable.
|
||||
const authLogDedupWindow = 15 * time.Minute
|
||||
|
||||
// LogAuthAttempt mirrors models.log_auth_attempt.
|
||||
//
|
||||
// Successful attempts are coalesced: a repeated success with the exact same
|
||||
// authType+identifier+IP+message as one already logged within authLogDedupWindow only
|
||||
// refreshes an in-memory last-seen time (d.authLogDedup) instead of writing another
|
||||
// row. An IMAP or SMTP client that reconnects and re-authenticates every few
|
||||
// seconds/minutes instead of holding one long-lived connection (common — plenty of
|
||||
// desktop and mobile mail clients poll this way) would otherwise write one
|
||||
// esrv_auth_logs row per reconnect, burying real signal under thousands of identical
|
||||
// "successful login" entries. message is part of the key (not just
|
||||
// authType+identifier+IP) so this never collapses two events that happen to share a
|
||||
// type/identifier/IP but represent genuinely different things — this function also
|
||||
// backs one-off audit entries like "Passkey added: <name>" / "Passkey removed" /
|
||||
// "MFA reset by admin <x>" for the same admin in webui, which must never be silently
|
||||
// dropped just because they landed in the same 15-minute window. Failed attempts
|
||||
// always write a fresh row regardless: CountRecentFailedAttempts' per-account lockout
|
||||
// counts every one of those.
|
||||
func (d *DB) LogAuthAttempt(authType, identifier, ipAddress string, success bool, message string) error {
|
||||
if success {
|
||||
key := authType + "|" + identifier + "|" + ipAddress + "|" + message
|
||||
now := time.Now()
|
||||
d.authLogDedupMu.Lock()
|
||||
last, seen := d.authLogDedup[key]
|
||||
d.authLogDedup[key] = now
|
||||
d.authLogDedupMu.Unlock()
|
||||
if seen && now.Sub(last) < authLogDedupWindow {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
_, err := d.Exec(`INSERT INTO esrv_auth_logs (auth_type, identifier, ip_address, success, message)
|
||||
VALUES (?, ?, ?, ?, ?)`, authType, identifier, ipAddress, success, message)
|
||||
return err
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -1016,6 +1018,12 @@ func migrateContactUIDs(db *sql.DB) {
|
||||
// DB wraps *sql.DB with the query helpers below.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
|
||||
// authLogDedupMu/authLogDedup back LogAuthAttempt's repeat-login coalescing (see
|
||||
// queries.go) — kept per-DB rather than a package-level global so separate *DB
|
||||
// instances (each test's own openTestDB, in particular) never share dedup state.
|
||||
authLogDedupMu sync.Mutex
|
||||
authLogDedup map[string]time.Time
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite file at path and ensures the schema exists.
|
||||
@@ -1056,5 +1064,5 @@ func Open(path string) (*DB, error) {
|
||||
return nil, fmt.Errorf("create tables: %w", err)
|
||||
}
|
||||
migrateAddedColumns(sqlDB)
|
||||
return &DB{sqlDB}, nil
|
||||
return &DB{DB: sqlDB, authLogDedup: map[string]time.Time{}}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package imapserver_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
|
||||
"mailgoserver/internal/abuseguard"
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/imapserver"
|
||||
"mailgoserver/internal/mailstore"
|
||||
)
|
||||
|
||||
// genTLSTestCert generates a throwaway self-signed cert for "127.0.0.1" — enough to
|
||||
// exercise a real *tls.Conn handshake (the client skips verification instead of trusting
|
||||
// a CA, since this test cares about the server-side connection-wrapping order, not
|
||||
// certificate validation).
|
||||
func genTLSTestCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
}
|
||||
|
||||
// TestIMAPLoginOverTLSThroughAbuseguardListener reproduces main.go's real direct-TLS
|
||||
// IMAP listener wiring: a raw TCP listener wrapped by abuseguard.GuardListener and
|
||||
// imapserver.IdleTimeoutListener, with TLS applied outermost via tls.NewListener. This
|
||||
// order matters: go-imap/v2 decides whether LOGIN is allowed (vs. LOGINDISABLED, "TLS is
|
||||
// required to authenticate") via a c.conn.(*tls.Conn) type assertion on whatever
|
||||
// Accept() returns — wrapping abuseguard's own conn type around an already-established
|
||||
// tls.Conn (main.go's previous order) hides that from the library and makes every LOGIN
|
||||
// fail before ever reaching Session.Login, even with correct credentials. Regression
|
||||
// test for that bug.
|
||||
func TestIMAPLoginOverTLSThroughAbuseguardListener(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
domainID, err := database.CreateDomain("example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := mailstore.New(database, mailstore.GenerateDEK(), t.TempDir())
|
||||
dek := mailstore.GenerateDEK()
|
||||
wrapped, nonce, err := store.WrapDEK(dek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
portalHash, err := db.HashPassword("portal-password-unused")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailboxID, err := database.CreateMailbox("inbox@example.com", portalHash, domainID, 5*1024*1024*1024, wrapped, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appPassword := db.GenerateAppPassword(25)
|
||||
appHash, err := db.HashPassword(appPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cert := genTLSTestCert(t)
|
||||
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
|
||||
backend := &imapserver.Backend{DB: database, Mailstore: store}
|
||||
tlsServer := imapserver.NewTLSServer(backend, tlsConfig)
|
||||
|
||||
rawListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guarded := imapserver.IdleTimeoutListener(abuseguard.GuardListener(rawListener, database, nil, nil), 30*time.Minute)
|
||||
finalListener := tls.NewListener(guarded, tlsConfig)
|
||||
go tlsServer.Serve(finalListener)
|
||||
t.Cleanup(func() { tlsServer.Close() })
|
||||
|
||||
client, err := imapclient.DialTLS(finalListener.Addr().String(), &imapclient.Options{
|
||||
TLSConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DialTLS: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { client.Close() })
|
||||
|
||||
if err := client.Login("inbox@example.com", appPassword).Wait(); err != nil {
|
||||
t.Fatalf("login over TLS through abuseguard.GuardListener: %v (this is exactly the bug: LOGIN rejected with PRIVACYREQUIRED because the wrapped conn no longer type-asserts to *tls.Conn)", err)
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,19 @@ func NewPlainServer(backend *Backend) *goimapserver.Server {
|
||||
// NewTLSServer mirrors smtpserver.NewTLSServer: implicit TLS, the whole connection is
|
||||
// encrypted from the first byte. Call ListenAndServeTLS (not ListenAndServe) to run
|
||||
// it — TLSConfig here only takes effect for that method.
|
||||
//
|
||||
// InsecureAuth is set here too, same as smtpserver.NewTLSServer's AllowInsecureAuth:
|
||||
// this server is only ever Serve()'d behind abuseguard's and idletimeout's listener/conn
|
||||
// wrapper types, so go-imap's own isTLS check (conn.go: c.conn.(*tls.Conn)) sees our
|
||||
// wrapper instead of the real *tls.Conn and always reports false. Without this, go-imap
|
||||
// advertises LOGINDISABLED and refuses LOGIN even though the connection is already
|
||||
// encrypted by construction (it's the dedicated implicit-TLS listener) — clients
|
||||
// configured for SSL/TLS (not STARTTLS), like Thunderbird on port 993, can never
|
||||
// authenticate.
|
||||
func NewTLSServer(backend *Backend, tlsConfig *tls.Config) *goimapserver.Server {
|
||||
return goimapserver.New(&goimapserver.Options{
|
||||
NewSession: newSessionFunc(backend),
|
||||
TLSConfig: tlsConfig,
|
||||
NewSession: newSessionFunc(backend),
|
||||
TLSConfig: tlsConfig,
|
||||
InsecureAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
+10
-9
@@ -52,7 +52,7 @@ func (r *Relay) processQueueItem(item db.RelayQueueItem) {
|
||||
for i, rec := range item.Recipients {
|
||||
rcpts[i] = rec.Recipient
|
||||
}
|
||||
status, serverResp, errCode, errMsg := r.deliverToDomain(item.Domain, item.MailFrom, rcpts, item.Content)
|
||||
status, serverResp, errCode, errMsg, permanent := r.deliverToDomain(item.Domain, item.MailFrom, rcpts, item.Content)
|
||||
|
||||
if status == "success" {
|
||||
for _, rec := range item.Recipients {
|
||||
@@ -67,7 +67,7 @@ func (r *Relay) processQueueItem(item db.RelayQueueItem) {
|
||||
return
|
||||
}
|
||||
|
||||
if item.Attempts < len(retrySchedule) {
|
||||
if !permanent && item.Attempts < len(retrySchedule) {
|
||||
next := time.Now().Add(retrySchedule[item.Attempts])
|
||||
if err := r.DB.RescheduleRelayQueueItem(item.ID, next, errMsg); err != nil {
|
||||
r.Logger.Error("relay queue: reschedule item %d: %v", item.ID, err)
|
||||
@@ -75,13 +75,14 @@ func (r *Relay) processQueueItem(item db.RelayQueueItem) {
|
||||
return
|
||||
}
|
||||
|
||||
// Retries exhausted — final failure. Resolve the recipient rows, drop the queue
|
||||
// row, and bounce back to the original sender (skipped for a null sender, the
|
||||
// same rule Session.Data's own immediate-bounce path already follows — replying
|
||||
// to a bounce is the classic loop bug). Unlike that synchronous path, there's no
|
||||
// live peer connection here to gate the bounce on IsIPBlacklisted — by the time
|
||||
// retries are exhausted (minutes to hours later), this is no longer a live
|
||||
// mailbox-enumeration oracle the way an instant response would be.
|
||||
// Retries exhausted, or the remote server gave a permanent (RFC 5321 5xx) rejection
|
||||
// on the very first attempt — either way this is final. Resolve the recipient rows,
|
||||
// drop the queue row, and bounce back to the original sender (skipped for a null
|
||||
// sender, the same rule Session.Data's own immediate-bounce path already follows —
|
||||
// replying to a bounce is the classic loop bug). Unlike that synchronous path,
|
||||
// there's no live peer connection here to gate the bounce on IsIPBlacklisted — by
|
||||
// the time this runs, this is no longer a live mailbox-enumeration oracle the way an
|
||||
// instant response would be.
|
||||
var failedResults []Result
|
||||
for _, rec := range item.Recipients {
|
||||
if err := r.DB.UpdateEmailRecipientLogStatus(item.EmailLogID, rec.Recipient, "failed", errCode, errMsg, serverResp); err != nil {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
@@ -190,3 +194,78 @@ func TestProcessQueueOnceExhaustsRetriesAndBounces(t *testing.T) {
|
||||
t.Errorf("expected email_log status 'failed', got %q", log.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// rejectingBackend is a stand-in "receiving MTA" that rejects every RCPT with a fixed
|
||||
// SMTP status/message — used to prove a permanent (5xx) rejection bounces on the first
|
||||
// attempt instead of working through retrySchedule.
|
||||
type rejectingBackend struct{ code int }
|
||||
|
||||
func (b *rejectingBackend) NewSession(*smtp.Conn) (smtp.Session, error) {
|
||||
return &rejectingSession{code: b.code}, nil
|
||||
}
|
||||
|
||||
type rejectingSession struct{ code int }
|
||||
|
||||
func (s *rejectingSession) Mail(from string, opts *smtp.MailOptions) error { return nil }
|
||||
func (s *rejectingSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
||||
return &smtp.SMTPError{Code: s.code, EnhancedCode: smtp.NoEnhancedCode, Message: "poor reputation"}
|
||||
}
|
||||
func (s *rejectingSession) Data(r io.Reader) error { _, err := io.ReadAll(r); return err }
|
||||
func (s *rejectingSession) Reset() {}
|
||||
func (s *rejectingSession) Logout() error { return nil }
|
||||
|
||||
func TestProcessQueueOnceBouncesImmediatelyOnPermanentRejection(t *testing.T) {
|
||||
database := testDB(t)
|
||||
s := smtp.NewServer(&rejectingBackend{code: 554})
|
||||
s.Domain = "mx.example.com"
|
||||
s.AllowInsecureAuth = true
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
go s.Serve(l)
|
||||
_, portStr, _ := net.SplitHostPort(l.Addr().String())
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &Relay{
|
||||
DB: database, Hostname: "sender.example.com", Timeout: 5 * time.Second,
|
||||
Logger: toolbox.GetLogger("relay_test"), port: port, mxLookup: fixedMXLookup,
|
||||
}
|
||||
|
||||
logID, err := database.InsertEmailLog(db.EmailLog{MailFrom: "from@example.com", ToAddress: "to@dest.example", Status: "queued"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.InsertEmailRecipientLog(db.EmailRecipientLog{EmailLogID: logID, Recipient: "to@dest.example", RecipientType: "to", Status: "queued"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.EnqueueForDelivery(logID, "from@example.com", []string{"to@dest.example"}, []string{"to"}, "Subject: hi\r\n\r\nbody"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// One tick. If this were misclassified as a transient failure it would still be
|
||||
// pending (rescheduled per retrySchedule), not gone.
|
||||
r.ProcessQueueOnce(5, 10)
|
||||
|
||||
pending, err := database.CountPendingRelayQueueItemsForEmailLog(logID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending != 0 {
|
||||
t.Fatalf("expected the queue row to be gone after a single permanent (5xx) rejection, got %d pending", pending)
|
||||
}
|
||||
recipients, err := database.ListRecipientLogsForEmail(logID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recipients[0].Status != "failed" {
|
||||
t.Errorf("expected recipient status 'failed' immediately on a 554, got %q", recipients[0].Status)
|
||||
}
|
||||
if recipients[0].ErrorMessage == "" {
|
||||
t.Error("expected the recipient log to carry the server's rejection reason")
|
||||
}
|
||||
}
|
||||
|
||||
+28
-5
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -199,7 +200,7 @@ func (r *Relay) RelayEmailAsync(mailFrom string, rcptTos []string, content strin
|
||||
all[i] = e.Recipient
|
||||
}
|
||||
targetDomain := domainOf(entries[0].Recipient)
|
||||
status, serverResp, errCode, errMsg := r.deliverToDomain(targetDomain, mailFrom, all, prepared)
|
||||
status, serverResp, errCode, errMsg, _ := r.deliverToDomain(targetDomain, mailFrom, all, prepared)
|
||||
for _, e := range entries {
|
||||
results = append(results, Result{Recipient: e.Recipient, RecipientType: e.Type, Status: status, ErrorCode: errCode, ErrorMessage: errMsg, ServerResponse: serverResp})
|
||||
}
|
||||
@@ -240,14 +241,14 @@ func domainOf(address string) string {
|
||||
|
||||
// deliverToDomain resolves MX hosts for domain and tries each in preference order once,
|
||||
// mirroring the MX-iteration loop in relay_email_async.
|
||||
func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content string) (status, serverResponse, errorCode, errorMessage string) {
|
||||
func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content string) (status, serverResponse, errorCode, errorMessage string, permanent bool) {
|
||||
lookupMX := r.mxLookup
|
||||
if lookupMX == nil {
|
||||
lookupMX = net.LookupMX
|
||||
}
|
||||
mxRecords, err := lookupMX(domain)
|
||||
if err != nil || len(mxRecords) == 0 {
|
||||
return "failed", "", "MX", fmt.Sprintf("MX lookup failed for %s: %v", domain, err)
|
||||
return "failed", "", "MX", fmt.Sprintf("MX lookup failed for %s: %v", domain, err), isPermanentSendError(err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
@@ -255,12 +256,34 @@ func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content
|
||||
host := strings.TrimSuffix(mx.Host, ".")
|
||||
resp, err := r.trySend(host, domain, mailFrom, rcpts, content)
|
||||
if err == nil {
|
||||
return "success", resp, "", ""
|
||||
return "success", resp, "", "", false
|
||||
}
|
||||
lastErr = err
|
||||
r.Logger.Warning("Relay to %s (%s) failed: %v", host, domain, err)
|
||||
if isPermanentSendError(err) {
|
||||
// RFC 5321 5xx: a definitive rejection from this MX. The other MX hosts for
|
||||
// the same domain almost always share the same mailbox/policy decision (they
|
||||
// front the same organization), so trying them wouldn't change the outcome —
|
||||
// stop here instead of working through the rest of the preference list.
|
||||
break
|
||||
}
|
||||
}
|
||||
return "failed", "", "RELAY", fmt.Sprintf("%v", lastErr)
|
||||
return "failed", "", "RELAY", fmt.Sprintf("%v", lastErr), isPermanentSendError(lastErr)
|
||||
}
|
||||
|
||||
// isPermanentSendError reports whether err is a definitive, retry-proof delivery
|
||||
// failure: an RFC 5321 5xx SMTP response. The remote server has already told us it
|
||||
// won't accept this mail no matter how many times we ask — a policy/reputation
|
||||
// rejection, unknown recipient, etc. Anything else — 4xx, connection refused/timeout,
|
||||
// DNS/MX lookup failure — is worth retrying, since it may resolve on its own.
|
||||
// processQueueItem uses this to bounce immediately on a permanent failure instead of
|
||||
// waiting out the full retry schedule.
|
||||
func isPermanentSendError(err error) bool {
|
||||
var protoErr *textproto.Error
|
||||
if errors.As(err, &protoErr) {
|
||||
return protoErr.Code >= 500 && protoErr.Code < 600
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// errStartTLSVerifyFailed wraps a STARTTLS failure that happened during real
|
||||
|
||||
@@ -294,12 +294,18 @@ func main() {
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
tlsListener, err := tls.Listen("tcp", tlsServer.Addr, smtpTLSConfig)
|
||||
l, err := net.Listen("tcp", tlsServer.Addr)
|
||||
if err != nil {
|
||||
logger.Error("TLS SMTP listen: %v", err)
|
||||
return
|
||||
}
|
||||
if err := tlsServer.Serve(abuseguard.GuardListener(tlsListener, database, cfg, logger)); err != nil && !errors.Is(err, smtp.ErrServerClosed) {
|
||||
// TLS must wrap outermost, after abuseguard — go-smtp decides isTLS via a
|
||||
// c.conn.(*tls.Conn) type assertion on whatever Accept() returns.
|
||||
// abuseguard.GuardListener wraps conns in its own type, so TLS applied
|
||||
// before it (tls.Listen, then wrap the result) hides the real *tls.Conn from
|
||||
// go-smtp and makes it re-offer STARTTLS on an already-encrypted connection.
|
||||
tlsListener := tls.NewListener(abuseguard.GuardListener(l, database, cfg, logger), smtpTLSConfig)
|
||||
if err := tlsServer.Serve(tlsListener); err != nil && !errors.Is(err, smtp.ErrServerClosed) {
|
||||
logger.Error("TLS SMTP server: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -328,12 +334,20 @@ func main() {
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
imapTLSListener, err := tls.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", imapTLSPort), imapTLSConfig)
|
||||
l, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", imapTLSPort))
|
||||
if err != nil {
|
||||
logger.Error("TLS IMAP listen: %v", err)
|
||||
return
|
||||
}
|
||||
if err := tlsServer.Serve(imapserver.IdleTimeoutListener(abuseguard.GuardListener(imapTLSListener, database, cfg, logger), 30*time.Minute)); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
// TLS must wrap outermost, after abuseguard/idletimeout — go-imap decides
|
||||
// LOGINDISABLED/STARTTLS via a c.conn.(*tls.Conn) type assertion on whatever
|
||||
// Accept() returns. Wrapping abuseguard's and idletimeout's own conn types
|
||||
// around an already-established tls.Conn (tls.Listen first) hides that from
|
||||
// the library and makes every LOGIN fail with "TLS is required" even over an
|
||||
// already-encrypted connection. See imapserver_tls_test.go.
|
||||
guarded := imapserver.IdleTimeoutListener(abuseguard.GuardListener(l, database, cfg, logger), 30*time.Minute)
|
||||
imapTLSListener := tls.NewListener(guarded, imapTLSConfig)
|
||||
if err := tlsServer.Serve(imapTLSListener); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
logger.Error("TLS IMAP server: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -353,12 +367,17 @@ func main() {
|
||||
srv := &http.Server{Addr: addr, Handler: jmapBackend.Mux(), TLSConfig: webHTTPSConfig, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second}
|
||||
go func() {
|
||||
logger.Info("JMAP listening on :%d", jmapPort)
|
||||
l, err := tls.Listen("tcp", addr, webHTTPSConfig)
|
||||
rawListener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
logger.Error("JMAP listen: %v", err)
|
||||
return
|
||||
}
|
||||
if err := srv.Serve(abuseguard.GuardListener(l, database, cfg, logger)); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
// Same reordering as SMTP/IMAP's TLS listeners above, for the same reason:
|
||||
// net/http's own c.(*tls.Conn) detection (r.TLS, HTTP/2 ALPN) needs
|
||||
// Accept() to return a genuine *tls.Conn, so abuseguard wraps the raw TCP
|
||||
// listener and TLS wraps outermost, not the other way around.
|
||||
l := tls.NewListener(abuseguard.GuardListener(rawListener, database, cfg, logger), webHTTPSConfig)
|
||||
if err := srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("JMAP server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
Reference in New Issue
Block a user