added JMAP (not tested yet)

This commit is contained in:
2026-08-22 15:03:32 +01:00
parent 123e58a6b0
commit c34c876c99
9 changed files with 408 additions and 23 deletions
+92
View File
@@ -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
}
+33
View File
@@ -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
+9 -1
View File
@@ -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
}
+120
View File
@@ -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)
}
}
+12 -2
View File
@@ -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
View File
@@ -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 {
+79
View File
@@ -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
View File
@@ -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
+25 -6
View File
@@ -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)
}
}()