added IMAP, LetsEncrypt, update layout

This commit is contained in:
2026-08-12 21:14:19 +01:00
parent 6e103959b0
commit 70fa1a5f2c
222 changed files with 42947 additions and 14038 deletions
+29 -8
View File
@@ -66,6 +66,11 @@ func (s *Session) Auth(mech string) (sasl.Server, error) {
// AuthLog row either way, and on any failure returns a *smtp.SMTPError carrying the
// exact Python response code/message, arming the connection to close right after that
// response is flushed — mirroring CustomSMTP.smtp_AUTH's transport.close() override.
//
// Two independent identity types can authenticate here: a Sender (relay-only, tried
// first — unchanged from the original behavior), or a mailbox's app password (never
// its portal password — see esrv_mailbox_app_passwords), which lets a mailbox owner
// send mail as their own primary address or a send-as-enabled alias.
func (s *Session) authenticate(username, password string) error {
sender, err := s.backend.DB.GetSenderByEmail(username)
if err != nil {
@@ -73,16 +78,32 @@ func (s *Session) authenticate(username, password string) error {
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", err))
return s.failAuth(451, "Internal server error")
}
if sender == nil || !db.CheckPassword(password, sender.PasswordHash) {
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Invalid credentials for %s", username))
return s.failAuth(535, "Authentication failed")
if sender != nil && db.CheckPassword(password, sender.PasswordHash) {
s.authenticatedSender = sender
s.authType = "sender"
s.username = username
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, true, "Successful sender authentication")
return nil
}
s.authenticatedSender = sender
s.authType = "sender"
s.username = username
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, true, "Successful sender authentication")
return nil
if s.backend.Mailstore != nil {
mbox, merr := s.backend.DB.VerifyMailboxAppPassword(username, password)
if merr != nil {
s.backend.Logger.Error("Mailbox authentication error: %v", merr)
_ = s.backend.DB.LogAuthAttempt("mailbox", username, s.peerIP, false, fmt.Sprintf("Authentication error: %v", merr))
return s.failAuth(451, "Internal server error")
}
if mbox != nil {
s.authenticatedMailbox = mbox
s.authType = "mailbox"
s.username = username
_ = s.backend.DB.LogAuthAttempt("mailbox", username, s.peerIP, true, "Successful mailbox app-password authentication")
return nil
}
}
_ = s.backend.DB.LogAuthAttempt("sender", username, s.peerIP, false, fmt.Sprintf("Invalid credentials for %s", username))
return s.failAuth(535, "Authentication failed")
}
// failAuth builds the SMTPError for a failed AUTH attempt and closes the connection
+155
View File
@@ -0,0 +1,155 @@
package smtpserver
import (
"net/smtp"
"strings"
"testing"
"mailgoserver/internal/db"
)
// createAlias mirrors createMailboxFor in webui's multitenant tests, for aliases.
func createAlias(t *testing.T, backend *Backend, mailboxID, domainID int64, email string, canSendAs bool) {
t.Helper()
if _, err := backend.DB.CreateAlias(mailboxID, email, domainID, canSendAs); err != nil {
t.Fatal(err)
}
}
func TestLocalDeliveryToAliasLandsInOwningMailbox(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
createAlias(t, backend, mailboxID, 1, "alias@example.com", false)
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("alias@example.com"); err != nil {
t.Fatalf("expected RCPT to a receive-only alias to succeed, got: %v", err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
w.Write([]byte("Subject: via alias\r\n\r\nhi"))
if err := w.Close(); err != nil {
t.Fatalf("expected DATA to succeed delivering to an alias, got: %v", err)
}
mbox, err := backend.DB.GetMailboxByID(mailboxID)
if err != nil {
t.Fatal(err)
}
if mbox.UsedBytes == 0 {
t.Fatal("expected mail delivered to an alias to land in the owning mailbox")
}
}
func TestMailboxAppPasswordCanSendAsPrimaryButNotArbitraryAddress(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
appPassword := "a-long-enough-app-password-123456"
hash, err := db.HashPassword(appPassword)
if err != nil {
t.Fatal(err)
}
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.Auth(smtp.PlainAuth("", "inbox@example.com", appPassword, "127.0.0.1")); err != nil {
t.Fatalf("expected app-password auth to succeed, got: %v", err)
}
if err := c.Mail("inbox@example.com"); err != nil {
t.Fatalf("expected MAIL FROM as own primary address to succeed, got: %v", err)
}
c2, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c2.Close()
if err := c2.Auth(smtp.PlainAuth("", "inbox@example.com", appPassword, "127.0.0.1")); err != nil {
t.Fatalf("auth: %v", err)
}
err = c2.Mail("someoneelse@example.com")
if err == nil {
t.Fatal("expected MAIL FROM as an address with no alias relationship to be rejected")
}
if !strings.Contains(err.Error(), "550") {
t.Fatalf("expected 550, got: %v", err)
}
}
func TestMailboxAppPasswordCanSendAsEnabledAlias(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
createAlias(t, backend, mailboxID, 1, "sendalias@example.com", true)
appPassword := "another-long-enough-app-password-9"
hash, err := db.HashPassword(appPassword)
if err != nil {
t.Fatal(err)
}
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.Auth(smtp.PlainAuth("", "inbox@example.com", appPassword, "127.0.0.1")); err != nil {
t.Fatalf("auth: %v", err)
}
if err := c.Mail("sendalias@example.com"); err != nil {
t.Fatalf("expected MAIL FROM as a send-as-enabled alias to succeed, got: %v", err)
}
}
func TestMailboxAppPasswordCannotSendAsReceiveOnlyAlias(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
createAlias(t, backend, mailboxID, 1, "receivealias@example.com", false)
appPassword := "yet-another-long-enough-app-pass1"
hash, err := db.HashPassword(appPassword)
if err != nil {
t.Fatal(err)
}
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.Auth(smtp.PlainAuth("", "inbox@example.com", appPassword, "127.0.0.1")); err != nil {
t.Fatalf("auth: %v", err)
}
err = c.Mail("receivealias@example.com")
if err == nil {
t.Fatal("expected MAIL FROM as a receive-only alias to be rejected")
}
if !strings.Contains(err.Error(), "550") {
t.Fatalf("expected 550, got: %v", err)
}
}
@@ -0,0 +1,158 @@
package smtpserver
import (
"net/smtp"
"strings"
"testing"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
// newTestBackendWithMailbox extends newTestBackend with a live Mailstore and one
// mailbox, inbox@example.com, on the same verified example.com domain used by the
// rest of this package's tests.
func newTestBackendWithMailbox(t *testing.T) (*Backend, int64) {
t.Helper()
backend := newTestBackend(t)
store := mailstore.New(backend.DB, mailstore.GenerateDEK(), t.TempDir())
backend.Mailstore = store
// Spam/SPF/DNSBL checks make live DNS calls (see internal/mailstore) — deliberately
// so in production, but that makes their exact score environment-dependent (e.g. a
// resolver that hijacks NXDOMAIN, or a real SPF record on the test domain). These
// tests exercise local-delivery wiring, not spam-scoring accuracy (see
// internal/mailstore's own tests for that), so disable rejection entirely here.
backend.Cfg.Section("Mailstore").Key("spam_reject_score").SetValue("1000000")
dek := mailstore.GenerateDEK()
wrapped, nonce, err := store.WrapDEK(dek)
if err != nil {
t.Fatal(err)
}
hash, err := db.HashPassword("portal-password-unused")
if err != nil {
t.Fatal(err)
}
mailboxID, err := backend.DB.CreateMailbox("inbox@example.com", hash, 1, 5*1024*1024*1024, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
return backend, mailboxID
}
func TestLocalDeliveryToKnownMailbox(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
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("expected RCPT to a real local mailbox to succeed, got: %v", err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("Subject: hello\r\n\r\nhi there")); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatalf("expected DATA to succeed for local delivery, got: %v", err)
}
mbox, err := backend.DB.GetMailboxByID(mailboxID)
if err != nil {
t.Fatal(err)
}
if mbox.UsedBytes == 0 {
t.Fatal("expected mailbox used_bytes to increase after local delivery")
}
}
func TestLocalDeliveryUnknownMailboxRejected(t *testing.T) {
backend, _ := newTestBackendWithMailbox(t)
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)
}
err = c.Rcpt("nobody@example.com")
if err == nil {
t.Fatal("expected RCPT to an unknown address on a locally-configured domain to be rejected")
}
if !strings.Contains(err.Error(), "550") {
t.Fatalf("expected 550 response, got: %v", err)
}
}
func TestExternalSenderCanOnlyDeliverLocally(t *testing.T) {
backend, _ := newTestBackendWithMailbox(t)
addr := startTestServer(t, backend)
c, err := smtp.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
// No AUTH, no whitelist match: MAIL FROM an entirely unconfigured external
// domain must be provisionally accepted (Mailstore is enabled) ...
if err := c.Mail("someone@external.example"); err != nil {
t.Fatalf("expected MAIL FROM from an external domain to be provisionally accepted, got: %v", err)
}
// ... but RCPT to an external address must still be denied (not an open relay).
err = c.Rcpt("other@somewhere-else.example")
if err == nil {
t.Fatal("expected RCPT to an external address to be denied for an unauthorized sender")
}
if !strings.Contains(err.Error(), "550") {
t.Fatalf("expected 550 response, got: %v", err)
}
// RCPT to our local mailbox must still succeed for the same provisionally
// accepted sender — this is the whole point of accepting it.
if err := c.Rcpt("inbox@example.com"); err != nil {
t.Fatalf("expected RCPT to a local mailbox to succeed for an external sender, got: %v", err)
}
}
func TestAuthorizedSenderStillRelaysExternallyWithMailstoreEnabled(t *testing.T) {
backend, _ := newTestBackendWithMailbox(t)
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("someone@elsewhere.example"); err != nil {
t.Fatalf("expected an authorized sender's relay RCPT to still be accepted with Mailstore enabled, got: %v", err)
}
}
+203
View File
@@ -0,0 +1,203 @@
package smtpserver
import (
"net/smtp"
"strings"
"testing"
)
func TestBlockedSenderRejectedAtRcpt(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "block", "test@example.com"); err != nil {
t.Fatal(err)
}
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)
}
err = c.Rcpt("inbox@example.com")
if err == nil {
t.Fatal("expected RCPT to be rejected for a blocked sender")
}
if !strings.Contains(err.Error(), "550") {
t.Fatalf("expected 550, got: %v", err)
}
}
func TestAllowListBypassesSpamRejection(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
// Force every non-allow-listed message to be rejected as spam.
backend.Cfg.Section("Mailstore").Key("spam_reject_score").SetValue("0")
send := func(t *testing.T) error {
t.Helper()
c, err := smtp.Dial(startTestServer(t, backend))
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
t.Fatalf("auth: %v", err)
}
if err := c.Mail("test@example.com"); err != nil {
t.Fatalf("MAIL FROM: %v", err)
}
if err := c.Rcpt("inbox@example.com"); err != nil {
t.Fatalf("RCPT: %v", err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
w.Write([]byte("Subject: hi\r\n\r\nhi"))
return w.Close()
}
if err := send(t); err == nil {
t.Fatal("expected delivery to fail as spam with a zero reject threshold and no allow-list entry")
}
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "allow", "test@example.com"); err != nil {
t.Fatal(err)
}
if err := send(t); err != nil {
t.Fatalf("expected delivery to succeed once the sender is allow-listed, got: %v", err)
}
}
func TestFilterRuleDeleteDropsMessage(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "drop-me", "delete", ""); err != nil {
t.Fatal(err)
}
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: %v", err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
w.Write([]byte("Subject: drop-me please\r\n\r\nhi"))
if err := w.Close(); err != nil {
t.Fatalf("expected DATA to still report success even though the rule drops the message, got: %v", err)
}
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(msgs) != 0 {
t.Fatalf("expected the delete rule to prevent storage, found %d messages", len(msgs))
}
}
func TestFilterRuleMarkReadSetsSeenFlag(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "newsletter", "mark_read", ""); err != nil {
t.Fatal(err)
}
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: %v", err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
w.Write([]byte("Subject: weekly newsletter\r\n\r\nhi"))
if err := w.Close(); err != nil {
t.Fatalf("DATA: %v", err)
}
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if !strings.Contains(msgs[0].Flags, `\Seen`) {
t.Fatalf("expected the mark_read rule to set \\Seen, got flags %q", msgs[0].Flags)
}
}
func TestFilterRuleMoveToFolderStoresInNamedFolder(t *testing.T) {
backend, mailboxID := newTestBackendWithMailbox(t)
if _, err := backend.DB.CreateRule(mailboxID, 0, "subject", "contains", "spam", "move_to_folder", "Spam"); err != nil {
t.Fatal(err)
}
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: %v", err)
}
w, err := c.Data()
if err != nil {
t.Fatal(err)
}
w.Write([]byte("Subject: this looks like spam\r\n\r\nhi"))
if err := w.Close(); err != nil {
t.Fatalf("DATA: %v", err)
}
inbox, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(inbox) != 0 {
t.Fatalf("expected nothing in INBOX, found %d", len(inbox))
}
spam, err := backend.DB.ListMessagesInFolder(mailboxID, "Spam")
if err != nil {
t.Fatal(err)
}
if len(spam) != 1 {
t.Fatalf("expected 1 message in Spam, got %d", len(spam))
}
}
+184 -26
View File
@@ -13,6 +13,7 @@ import (
"gopkg.in/ini.v1"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/mailstore"
"mailgoserver/internal/relay"
"mailgoserver/internal/toolbox"
)
@@ -25,6 +26,7 @@ type Backend struct {
DKIM *dkim.Manager
Relay *relay.Relay
Cfg *ini.File
Mailstore *mailstore.Store
Logger *toolbox.Logger
HeloHostname string
AttachmentsBasePath string
@@ -45,18 +47,23 @@ type Session struct {
conn *smtp.Conn
peerIP string
authenticatedSender *db.Sender
authType string // "sender" | "ip" | ""
authorizedDomain string
username string
authenticatedSender *db.Sender
authenticatedMailbox *db.Mailbox // set instead of authenticatedSender when auth used an app password
authType string // "sender" | "mailbox" | "ip" | ""
authorizedDomain string
username string
mailFrom string
rcptTos []string
mailFrom string
mailFromAuthorized bool // true only via an existing authorized path (sender/IP) on one of our own domains
rcptTos []string
localMailboxes map[string]*db.Mailbox // lowercased rcpt -> resolved local mailbox, set in Rcpt
}
func (s *Session) Reset() {
s.mailFrom = ""
s.mailFromAuthorized = false
s.rcptTos = nil
s.localMailboxes = nil
}
func (s *Session) Logout() error { return nil }
@@ -64,63 +71,89 @@ func (s *Session) Logout() error { return nil }
// Mail mirrors EnhancedCustomSMTPHandler.handle_MAIL, delegating authorization to
// validateSenderAuthorization (== auth.validate_sender_authorization).
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
ok, message := s.validateSenderAuthorization(from)
if !ok {
accept, authorized, message := s.validateSenderAuthorization(from)
if !accept {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: message}
}
s.mailFrom = from
s.mailFromAuthorized = authorized
return nil
}
// validateSenderAuthorization mirrors auth.validate_sender_authorization exactly,
// including its two branches (already-authenticated sender vs. IP whitelist fallback)
// and the AuthLog rows each path writes.
func (s *Session) validateSenderAuthorization(mailFrom string) (bool, string) {
// validateSenderAuthorization mirrors auth.validate_sender_authorization for every
// domain configured on this server — that part is byte-for-byte unchanged: senders
// unauthorized or unverified on OUR OWN domains are still hard-rejected here, exactly
// as before, to prevent spoofing/open-relay for domains we're responsible for.
//
// One addition: when this server has local mailbox storage enabled (Mailstore != nil),
// a MAIL FROM on a domain we don't manage at all is now provisionally accepted
// (accept=true, authorized=false) instead of hard-rejected — otherwise this server
// could never receive genuine inbound mail from the internet, since every external
// sender's domain is by definition "not configured here". Rcpt enforces that a
// provisionally-accepted sender may only deliver to a local mailbox, never relay
// onward, so this cannot be used as an open relay.
func (s *Session) validateSenderAuthorization(mailFrom string) (accept, authorized bool, message string) {
if mailFrom == "" {
return false, "No sender address provided"
return false, false, "No sender address provided"
}
fromDomain := domainOfAddr(mailFrom)
if fromDomain == "" {
return false, "Invalid sender address format"
return false, false, "Invalid sender address format"
}
// A domain must have its DNS ownership TXT record verified before it can send —
// otherwise anyone could add a domain they don't control and relay mail as it.
dom, err := s.backend.DB.GetDomainByName(fromDomain)
if err != nil {
s.backend.Logger.Error("domain lookup failed: %v", err)
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
}
if dom == nil {
return false, fmt.Sprintf("Domain %s is not configured on this server", fromDomain)
if s.backend.Mailstore != nil {
return true, false, fmt.Sprintf("Domain %s not configured here; accepted for possible local delivery only", fromDomain)
}
return false, false, fmt.Sprintf("Domain %s is not configured on this server", fromDomain)
}
if !dom.IsVerified {
return false, fmt.Sprintf("Domain %s has not completed DNS ownership verification yet", fromDomain)
return false, false, fmt.Sprintf("Domain %s has not completed DNS ownership verification yet", fromDomain)
}
if s.authenticatedSender != nil {
sender := s.authenticatedSender
if sender.CanSendAs(mailFrom) {
return true, fmt.Sprintf("Sender authorized to send as %s", mailFrom)
return true, true, fmt.Sprintf("Sender authorized to send as %s", mailFrom)
}
_ = s.backend.DB.LogAuthAttempt("sender_validation", fmt.Sprintf("%s -> %s", sender.Email, mailFrom), s.peerIP, false, "")
return false, fmt.Sprintf("Sender %s not authorized to send as %s", sender.Email, mailFrom)
return false, false, fmt.Sprintf("Sender %s not authorized to send as %s", sender.Email, mailFrom)
}
// A mailbox (authenticated via app password) may send as its own primary address,
// or as any of its active send-as-enabled aliases — never as an arbitrary address,
// even within a domain it happens to own a mailbox on.
if s.authenticatedMailbox != nil {
mbox := s.authenticatedMailbox
if strings.EqualFold(mailFrom, mbox.Email) {
return true, true, fmt.Sprintf("Mailbox authorized to send as %s", mailFrom)
}
if canSendAs, err := s.backend.DB.MailboxCanSendAs(mbox.ID, mailFrom); err == nil && canSendAs {
return true, true, fmt.Sprintf("Mailbox authorized to send as alias %s", mailFrom)
}
_ = s.backend.DB.LogAuthAttempt("mailbox_validation", fmt.Sprintf("%s -> %s", mbox.Email, mailFrom), s.peerIP, false, "")
return false, false, fmt.Sprintf("Mailbox %s not authorized to send as %s", mbox.Email, mailFrom)
}
wl, err := s.backend.DB.GetWhitelistedIP(s.peerIP, fromDomain)
if err != nil {
s.backend.Logger.Error("IP authorization lookup failed: %v", err)
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
}
if wl != nil {
s.authType = "ip"
s.authorizedDomain = fromDomain
s.username = "IP:" + s.peerIP
_ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, true, fmt.Sprintf("IP %s authorized for domain %s", s.peerIP, fromDomain))
return true, fmt.Sprintf("IP authorized for domain %s", fromDomain)
return true, true, fmt.Sprintf("IP authorized for domain %s", fromDomain)
}
_ = s.backend.DB.LogAuthAttempt("ip", fmt.Sprintf("%s -> %s", s.peerIP, fromDomain), s.peerIP, false, fmt.Sprintf("IP %s not authorized for domain %s", s.peerIP, fromDomain))
return false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
}
func domainOfAddr(address string) string {
@@ -131,8 +164,42 @@ func domainOfAddr(address string) string {
return strings.ToLower(address[i+1:])
}
// Rcpt mirrors handle_RCPT: accepts any address, no validation.
// Rcpt mirrors handle_RCPT for the pure-relay case (still accept-all for an authorized
// sender relaying to an external address — unchanged), and adds local-mailbox
// resolution: a recipient on one of our own configured+verified domains must resolve
// to a real mailbox, or is rejected with 550 "No such mailbox" — matching how a real
// MTA rejects unknown local recipients at RCPT time. A recipient that resolves to
// neither a local mailbox nor an authorized-to-relay sender's target is rejected with
// "Relay access denied" — the anti-open-relay invariant for provisionally-accepted
// senders (see validateSenderAuthorization).
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
domain := domainOfAddr(to)
var localDomain *db.Domain
if domain != "" && s.backend.Mailstore != nil {
if dom, err := s.backend.DB.GetDomainByName(domain); err == nil && dom != nil && dom.IsVerified {
localDomain = dom
}
}
if localDomain != nil {
mbox, err := s.backend.Mailstore.ResolveRecipient(to)
if err != nil || mbox == nil {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "No such mailbox"}
}
if blocked, _ := s.backend.DB.IsBlocked(mbox.ID, s.mailFrom); blocked {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message rejected"}
}
if s.localMailboxes == nil {
s.localMailboxes = map[string]*db.Mailbox{}
}
s.localMailboxes[strings.ToLower(to)] = mbox
s.rcptTos = append(s.rcptTos, to)
return nil
}
if !s.mailFromAuthorized {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Relay access denied"}
}
s.rcptTos = append(s.rcptTos, to)
return nil
}
@@ -232,7 +299,26 @@ func (s *Session) Data(r io.Reader) error {
}
}
results := s.backend.Relay.RelayEmailAsync(s.mailFrom, s.rcptTos, signedContent, recipientTypes)
// Split recipients resolved to a local mailbox in Rcpt from everything else
// (still relayed exactly as before — unchanged for every non-local recipient).
var localRcpts, localTypes, relayRcpts, relayTypes []string
for i, rcpt := range s.rcptTos {
if _, ok := s.localMailboxes[strings.ToLower(rcpt)]; ok {
localRcpts = append(localRcpts, rcpt)
localTypes = append(localTypes, recipientTypes[i])
} else {
relayRcpts = append(relayRcpts, rcpt)
relayTypes = append(relayTypes, recipientTypes[i])
}
}
var results []relay.Result
if len(relayRcpts) > 0 {
results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes)
}
if len(localRcpts) > 0 {
results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject)...)
}
allSucceeded := len(results) > 0
for _, res := range results {
@@ -266,6 +352,78 @@ func (s *Session) Data(r io.Reader) error {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"}
}
// deliverLocally runs the inbound DKIM/SPF/spam checks once for the message (they
// don't vary per recipient at this milestone — no per-mailbox allow/block-list yet)
// and stores it into each resolved local mailbox, producing one relay.Result per
// recipient so it can be merged into the same LogEmail/allSucceeded logic as relay
// results.
func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID, subject string) []relay.Result {
senderDomain := domainOfAddr(s.mailFrom)
dkimPass := senderDomain != "" && dkim.VerifyInbound(signedContent, senderDomain)
spfPass := mailstore.CheckSPF(s.mailFrom, s.peerIP)
heuristicScore := mailstore.SpamScore(s.peerIP, map[string]string{"subject": subject}, dkimPass, spfPass)
rejectScore := s.backend.Cfg.Section("Mailstore").Key("spam_reject_score").MustInt(5)
rspamdEnabled := s.backend.Cfg.Section("Rspamd").Key("enabled").MustBool(false)
rspamdURL := s.backend.Cfg.Section("Rspamd").Key("url").MustString("http://127.0.0.1:11333")
rspamdRejectScore := s.backend.Cfg.Section("Rspamd").Key("reject_score").MustInt(15)
results := make([]relay.Result, 0, len(rcpts))
for i, rcpt := range rcpts {
mbox := s.localMailboxes[strings.ToLower(rcpt)]
// An explicit per-mailbox allow-list entry bypasses spam scoring entirely —
// the built-in heuristic and optional rspamd check both run regardless of each
// other (additive, not either/or), but neither runs at all once allow-listed.
if allowed, _ := s.backend.DB.IsAllowed(mbox.ID, s.mailFrom); !allowed {
reject := heuristicScore >= rejectScore
if !reject && rspamdEnabled {
if score, action, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil {
if action == "reject" || score >= float64(rspamdRejectScore) {
reject = true
}
}
// rspamd unreachable/erroring must not block mail — errors are swallowed,
// the built-in heuristic above is still the baseline gate either way.
}
if reject {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "Message rejected as spam"})
continue
}
}
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{"from": s.mailFrom, "to": rcpt, "subject": subject})
if err != nil {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()})
continue
}
if action.Drop {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
continue
}
folder := "INBOX"
if action.Folder != "" {
folder = action.Folder
}
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, s.mailFrom, subject)
if err != nil {
errCode, errMsg := "450", err.Error()
if err == mailstore.ErrQuotaExceeded {
errCode, errMsg = "552", "Mailbox quota exceeded"
}
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: errCode, ErrorMessage: errMsg})
continue
}
if action.MarkRead {
if err := s.backend.DB.SetMessageFlags(mbox.ID, uid, `\Seen`); err != nil {
s.backend.Logger.Error("mark_read rule failed to set flag for message %d: %v", uid, err)
}
}
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Delivered to local mailbox"})
}
return results
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {