Files
mailgoserver/internal/smtpserver/session.go
T

843 lines
37 KiB
Go

package smtpserver
import (
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"time"
"unsafe"
"github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
"gopkg.in/ini.v1"
"mailgoserver/internal/abuseguard"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/mailstore"
"mailgoserver/internal/mailview"
"mailgoserver/internal/notify"
"mailgoserver/internal/relay"
"mailgoserver/internal/toolbox"
)
// Backend holds the shared dependencies every connection's Session uses, mirroring the
// constructor args threaded through smtp_handler.EnhancedCustomSMTPHandler /
// email_server/server_runner.py.
type Backend struct {
DB *db.DB
DKIM *dkim.Manager
Relay *relay.Relay
Cfg *ini.File
Mailstore *mailstore.Store
Logger *toolbox.Logger
HeloHostname string
AttachmentsBasePath string
// Notify publishes "a message landed" events for IMAP IDLE push
// (internal/imapserver) and webmail SSE push (internal/webui) — nil-safe
// (notify.Bus.Publish no-ops on a nil receiver), so it's optional wiring, not a
// required dependency for tests that don't care about push.
Notify *notify.Bus
}
func (b *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
host, _, _ := net.SplitHostPort(c.Conn().RemoteAddr().String())
if host == "" {
host = c.Conn().RemoteAddr().String()
}
return &Session{backend: b, conn: c, peerIP: host}, nil
}
// Session implements smtp.Session + smtp.AuthSession for one SMTP connection, mirroring
// EnhancedCustomSMTPHandler's per-connection behavior in smtp_handler.py.
type Session struct {
backend *Backend
conn *smtp.Conn
peerIP 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
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 }
// Mail mirrors EnhancedCustomSMTPHandler.handle_MAIL, delegating authorization to
// validateSenderAuthorization (== auth.validate_sender_authorization).
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
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 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 == "" {
// A null sender (MAIL FROM:<>) is how every real MTA sends bounce/DSN
// messages (RFC 5321 §4.5.5 requires accepting it) — this server previously
// hard-rejected it unconditionally, meaning it could never receive a genuine
// bounce from anywhere. There's no sender identity to authorize (nothing can
// be "authorized to send as no one"), so this is provisionally accepted the
// same way a not-configured-here domain already is just below
// (accept=true, authorized=false) — Rcpt's mailFromAuthorized check still
// forbids using it to relay onward, so a null sender can only ever deliver to
// a local mailbox here, never open-relay. Checked before any
// authenticated-sender/mailbox/IP branch below, on purpose: authentication
// grants "authorized to send as <this address>", which is meaningless for an
// address that doesn't exist, so it must never inherit relay authorization.
if s.backend.Mailstore != nil {
return true, false, "Null sender (bounce/DSN) accepted for possible local delivery only"
}
return false, false, "No sender address provided"
}
fromDomain := domainOfAddr(mailFrom)
if fromDomain == "" {
return false, false, "Invalid sender address format"
}
dom, err := s.backend.DB.GetDomainByName(fromDomain)
if err != nil {
s.backend.Logger.Error("domain lookup failed: %v", err)
return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
}
if dom == nil {
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, 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, 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, "")
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
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, "")
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
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, 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, 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))
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return false, false, fmt.Sprintf("Not authorized to send for domain %s", fromDomain)
}
func domainOfAddr(address string) string {
i := strings.LastIndex(address, "@")
if i < 0 {
return ""
}
return strings.ToLower(address[i+1:])
}
// 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
}
func internalError(msg string) error {
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: msg}
}
// Data mirrors EnhancedCustomSMTPHandler.handle_DATA end to end: Message-ID
// extraction/rehost, full header rebuild, DKIM signing, attachment extraction/storage,
// relay delivery, and EmailLog/EmailRecipientLog/EmailAttachment persistence.
func (s *Session) Data(r io.Reader) error {
raw, err := io.ReadAll(r)
if err != nil {
return internalError("Internal server error")
}
// 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)
var customHeaders [][2]string
if senderDomain != "" {
customHeaders, _ = s.backend.DKIM.GetActiveCustomHeaders(senderDomain)
}
customHeaders = append(customHeaders,
[2]string{"X-Originating-IP", "[" + s.peerIP + "]"},
[2]string{"X-Mailer", "NetBro Mail Server 1.0"},
[2]string{"X-Priority", "3"},
)
rebuilt := ensureRequiredHeaders(content, messageID, s.rcptTos, s.mailFrom, customHeaders)
signedContent := rebuilt
dkimSigned := false
if senderDomain != "" {
signedContent = s.backend.DKIM.Sign(rebuilt, senderDomain)
dkimSigned = signedContent != rebuilt
}
// Virus scanning runs once per message (unlike rspamd's per-recipient check
// below in deliverLocally) — a virus is present or not regardless of who it's
// addressed to, so one scan covers both the relay and local-delivery paths that
// split further down. Hard-rejects the whole transaction on a positive match,
// mirroring rspamd's own "reject" action precedent; fails OPEN on a scanner
// error/unreachable clamd (never blocks mail on a scanner outage, same posture
// CheckRspamd already has) and is off entirely unless explicitly enabled.
if s.backend.Cfg.Section("Mailstore").Key("virus_scan_enabled").MustBool(false) {
addr := s.backend.Cfg.Section("Mailstore").Key("clamd_address").MustString("127.0.0.1:3310")
if infected, signature, err := mailstore.ScanVirus(addr, []byte(signedContent)); err != nil {
s.backend.Logger.Error("virus scan unreachable/errored, delivering normally: %v", err)
} else if infected {
s.backend.Logger.Warning("rejected infected message from %s (%s)", s.mailFrom, signature)
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message rejected: virus detected (" + signature + ")"}
}
}
rebuiltHeaders := existingHeaders(rebuilt)
toHeader := rebuiltHeaders["to"]
ccHeader := rebuiltHeaders["cc"]
subject := rebuiltHeaders["subject"]
// The message's own From: header (e.g. "Bob Marley <bob@example.com>"), not the
// bare SMTP envelope address — used only for what's cached/displayed (webmail's
// folder list), never for delivery/auth decisions, which stay on s.mailFrom
// throughout. Falls back to the envelope address if the header's missing/empty.
fromHeader := rebuiltHeaders["from"]
if fromHeader == "" {
fromHeader = s.mailFrom
}
// Attachment storage: only if the authenticated sender or whitelisted IP opted in.
storeMessage := false
if sender, _ := s.backend.DB.GetSenderByEmail(s.mailFrom); sender != nil && sender.StoreMessageContent {
storeMessage = true
} else if wl, _ := s.backend.DB.GetWhitelistedIP(s.peerIP, senderDomain); wl != nil && wl.StoreMessageContent {
storeMessage = true
}
// 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
Size int64
}
var toSave []savedAttachment
if storeMessage && parseErr == nil && len(parsed.Attachments) > 0 {
usernameOrIP := s.username
if usernameOrIP == "" && s.peerIP != "" {
usernameOrIP = sanitizePathSegment(s.peerIP, ":")
} else {
usernameOrIP = sanitizePathSegment(usernameOrIP, "/\\")
}
storagePath := attachmentStoragePath(s.backend.AttachmentsBasePath, senderDomain, usernameOrIP, time.Now())
if err := os.MkdirAll(storagePath, 0o755); err == nil {
prefix := cleanMessageIDPrefix(messageID)
for _, a := range parsed.Attachments {
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))})
} else {
s.backend.Logger.Error("Failed to write attachment %s: %v", filename, err)
}
}
}
}
// Classify each envelope recipient as to/cc/bcc by presence in the To/Cc headers —
// anything not literally present in either is inferred BCC.
toList := parseAddressList(toHeader)
ccList := parseAddressList(ccHeader)
recipientTypes := make([]string, len(s.rcptTos))
for i, rcpt := range s.rcptTos {
lower := strings.ToLower(rcpt)
switch {
case containsStr(toList, lower):
recipientTypes[i] = "to"
case containsStr(ccList, lower):
recipientTypes[i] = "cc"
default:
recipientTypes[i] = "bcc"
}
}
// 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])
}
}
// Relay recipients are never delivered inline here — that would block this client's
// DATA response on however long the recipient domain's MX takes to answer (the
// concurrency/load issue this queue exists to fix). Instead a "queued" placeholder
// Result is recorded now and the real attempt happens later via EnqueueForDelivery
// (below, once logID exists) + the background worker in internal/relay/queue.go.
var results []relay.Result
if len(relayRcpts) > 0 {
if limited, err := s.domainSendRateLimited(senderDomain); err != nil {
s.backend.Logger.Error("send-rate-limit check for domain %s: %v", senderDomain, err)
for i, rcpt := range relayRcpts {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: relayTypes[i], Status: "queued"})
}
} else if limited {
for i, rcpt := range relayRcpts {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: relayTypes[i], Status: "failed", ErrorCode: "450", ErrorMessage: "Sending rate limit exceeded for domain " + senderDomain + ", try again later"})
}
relayRcpts, relayTypes = nil, nil
} else {
for i, rcpt := range relayRcpts {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: relayTypes[i], Status: "queued"})
}
}
}
if len(localRcpts) > 0 {
results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject, fromHeader)...)
}
// "queued" is neither a known success nor a known failure yet — the worker resolves
// it later (and bounces then, on genuine final failure). Only count actual failures
// here so the immediate bounce-on-partial-failure block below doesn't fire for a
// message that's simply still in flight.
var failed []relay.Result
for _, res := range results {
if res.Status != "success" && res.Status != "queued" {
failed = append(failed, res)
}
}
allSucceeded := len(results) > 0 && len(failed) == 0
anySucceeded := len(results) > len(failed)
// A single SMTP response to DATA can't express "delivered to some recipients, not
// others" — rejecting the whole transaction here would make the connecting
// server's own retry logic re-deliver to the recipients that already succeeded.
// So: accept (below) and bounce the failed subset back to our own sender instead,
// exactly like a real MTA splitting a multi-recipient transaction's outcome. A
// bounce is never sent for a *total* failure — that gets rejected outright (550)
// below instead, letting the connecting server's own MTA generate the bounce to
// its user, avoiding a double notification. Skipped entirely for a null-sender
// message (s.mailFrom == "", already itself a bounce/DSN — replying to one is the
// classic bounce-loop bug) and for a currently-blacklisted peer, so a delivery
// failure never becomes a free "yes, that mailbox doesn't exist" oracle for abuse.
if len(failed) > 0 && anySucceeded && s.mailFrom != "" {
if blacklisted, _ := s.backend.DB.IsIPBlacklisted(s.peerIP); !blacklisted {
if err := s.backend.Relay.SendBounce(s.mailFrom, subject, messageID, failed); err != nil {
s.backend.Logger.Error("send bounce to %s: %v", s.mailFrom, err)
}
}
}
var emailHeaders string
if parseErr == nil {
emailHeaders = strings.Join(parsed.HeaderLines, "\n")
}
// Privacy default: only headers (and the Subject field, logged separately below
// regardless) go into the admin-visible log, never the message itself — unless this
// sender/IP explicitly opted in via "Store Full Message Content" (storeMessage
// above), or the message was quarantined to Junk for at least one recipient, in
// which case an admin genuinely needs to see it to judge a spam/abuse report. When
// stored, it's the *entire* raw message (not a plain-text extraction) so the log
// viewer can render the real HTML body, inline images, and attachments — re-parsed
// on demand via internal/mailview, the same parser webmail's own message view uses
// — rather than a degraded text-only approximation.
storeContent := storeMessage
if !storeContent {
for _, res := range results {
if res.Quarantined {
storeContent = true
break
}
}
}
loggedBody := ""
if storeContent {
loggedBody = signedContent
}
logID, logErr := s.backend.Relay.LogEmail(s.backend.Cfg, s.peerIP, s.mailFrom, toHeader, ccHeader, "", subject, emailHeaders, loggedBody, messageID, s.username, dkimSigned, results)
if logErr != nil {
s.backend.Logger.Error("Failed to log email: %v", logErr)
} else {
for _, a := range toSave {
if err := s.backend.DB.InsertEmailAttachment(db.EmailAttachment{
EmailLogID: logID, Filename: a.Filename, ContentType: a.ContentType, FilePath: a.FilePath, Size: a.Size,
}); err != nil {
s.backend.Logger.Error("Failed to record attachment %s: %v", a.Filename, err)
}
}
if len(relayRcpts) > 0 {
if err := s.backend.Relay.EnqueueForDelivery(logID, s.mailFrom, relayRcpts, relayTypes, signedContent); err != nil {
s.backend.Logger.Error("Failed to enqueue relay delivery: %v", err)
}
}
}
if allSucceeded {
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery"}
}
if anySucceeded {
// Some recipients already have the message — 250 it (see the bounce comment
// above for why), not 550, which would tell the connecting server to retry
// the whole thing and re-deliver to those recipients a second time.
return &smtp.SMTPError{Code: 250, EnhancedCode: smtp.NoEnhancedCode, Message: "Message accepted for delivery to some recipients"}
}
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, fromDisplay 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)
// Parsed once for every local recipient (not per-recipient — same message body for
// all of them) so filter rules can match on body text / attachment presence
// 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(strings.NewReader(signedContent)); err == nil {
bodyText = parsedForRules.TextBody
if bodyText == "" && parsedForRules.HTMLBody != "" {
bodyText = bluemonday.StrictPolicy().Sanitize(parsedForRules.HTMLBody)
}
if len(parsedForRules.Attachments) > 0 {
hasAttachment = "yes"
}
}
enforceDKIM := s.backend.Cfg.Section("Mailstore").Key("enforce_dkim").MustBool(true)
enforceSPF := s.backend.Cfg.Section("Mailstore").Key("enforce_spf").MustBool(true)
enforceDMARC := s.backend.Cfg.Section("Mailstore").Key("enforce_dmarc").MustBool(true)
// DMARC ties DKIM/SPF together via alignment to the visible From: header's domain
// — a materially different check from dkimPass/spfPass above (which align to the
// SMTP envelope's MAIL FROM domain, senderDomain — the two commonly match but
// DMARC specifically cares about the header, since that's what the recipient
// actually sees and what phishing spoofs). Computed once here, applied per
// recipient below (same shape as dkimPass/spfPass/heuristicScore) so the
// per-mailbox whitelist scope can still suppress it independently.
fromHeaderAddrs := parseAddressList(fromDisplay)
fromHeaderDomain := ""
if len(fromHeaderAddrs) > 0 {
fromHeaderDomain = domainOfAddr(fromHeaderAddrs[0])
}
dmarcFailPolicy := "" // "", "quarantine", or "reject" — "" means DMARC didn't fail (or wasn't evaluated)
if enforceDMARC && fromHeaderDomain != "" {
if pol := mailstore.LookupDMARCPolicy(fromHeaderDomain); pol != nil {
orgDomain := mailstore.OrganizationalDomain(fromHeaderDomain)
dkimAligned := dkim.VerifyInbound(signedContent, fromHeaderDomain)
spfAligned := spfPass && mailstore.OrganizationalDomain(senderDomain) == orgDomain
if !dkimAligned && !spfAligned {
// ponytail: pct= sampling (gradual DMARC rollout) isn't applied — the
// full effective policy always enforces regardless of pct, which is
// strictly more cautious than what a pct<100 domain owner asked for,
// never less. Add real sampling if a pct<100 domain's mail needs to
// land in INBOX during a deliberate rollout.
dmarcFailPolicy = pol.EffectivePolicy(fromHeaderDomain, orgDomain)
}
}
}
// 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)]
folder := "INBOX"
markRead := false
spamGated := false
var tags []string
// An explicit per-mailbox "allow" entry scoped to "all" bypasses every check
// below entirely — the pre-existing full-bypass behavior, still available via
// the scope picker (webmail_blocklist.html). A narrower scope (spf/dkim/spam)
// only suppresses that one check; the others below still apply independently.
scope, hasAllow, _ := s.backend.DB.AllowScope(mbox.ID, s.mailFrom)
if hasAllow && scope == "all" {
// unchanged existing behavior: full bypass, no scoring, no tagging
} else if junked, _ := s.backend.DB.IsJunked(mbox.ID, s.mailFrom); junked {
// The mailbox owner's own Blocklist (webmail Settings, or the message-view
// "Mark as Junk" action — internal/webui's webmailMarkAsJunk) also bypasses
// scoring entirely, straight to Junk: the user already told us how to
// treat this sender, so there's nothing left to compute (and no rspamd
// round-trip to make). Deliberately a *soft* quarantine (still delivered,
// just hidden), unlike admin's separate hard-reject block list
// (IsBlocked, checked at RCPT time — see Rcpt()) — those are different
// tools for different jobs, not two ways to do the same thing.
folder = "Junk"
spamGated = true
} else {
suppressDKIM := hasAllow && scope == "dkim"
suppressSPF := hasAllow && scope == "spf"
suppressSpam := hasAllow && scope == "spam"
suppressDMARC := hasAllow && scope == "dmarc"
if enforceDKIM && !dkimPass && !suppressDKIM {
tags = append(tags, "Failed DKIM")
}
if enforceSPF && !spfPass && !suppressSPF {
tags = append(tags, "Failed SPF")
}
hardReject := false
if !suppressDMARC {
switch dmarcFailPolicy {
case "reject":
tags = append(tags, "Failed DMARC")
hardReject = true
case "quarantine":
tags = append(tags, "Failed DMARC")
}
}
if !suppressSpam {
quarantine := heuristicScore >= rejectScore
if rspamdEnabled {
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
// (from either scorer) is quarantined instead of rejected, so a
// false positive is recoverable from the Junk folder rather than
// silently bounced with no trace.
if rAction == "reject" {
hardReject = true
} else if score >= float64(rspamdRejectScore) {
quarantine = true
}
}
// rspamd unreachable/erroring must not block mail — errors are swallowed,
// the built-in heuristic above is still the baseline gate either way.
}
if quarantine {
tags = append(tags, "SPAM")
}
}
if hardReject {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "Message rejected as spam"})
continue
}
if len(tags) > 0 {
folder = "Junk"
spamGated = true
}
}
// A tagged message's per-recipient copy gets its Subject prepended with why it
// was flagged (e.g. "***Failed SPF, Failed DKIM***") — both in the stored raw
// content (so any IMAP client sees it too, not just this webmail UI) and in the
// cached subject used for list views. This does invalidate that copy's own DKIM
// signature (rewritten after signing) — harmless here since spamGated is always
// true whenever tags are non-empty, and that already skips ApplyRules below
// (including its ForwardTo action), so a tagged copy is never relayed/forwarded
// anywhere; it's only ever locally stored and read via IMAP/webmail, neither of
// which re-verifies DKIM on read.
recipientContent := signedContent
recipientSubject := subject
if len(tags) > 0 {
tag := strings.Join(tags, ", ")
recipientContent = prependSubjectTag(signedContent, tag)
recipientSubject = "***" + tag + "***"
if subject != "" {
recipientSubject += " " + subject
}
}
// Filter rules organize legitimate mail the recipient already trusts arriving
// in their INBOX — a quarantined message skips them entirely and always lands
// in Junk, rather than a rule accidentally routing spam back into view.
if !spamGated {
// Persistent mailbox-level forwarding (webmail account settings) — distinct
// from and independent of a filter rule's own "forward" action below; both
// can fire on the same message if a mailbox has both configured (a real but
// accepted edge case, not engineered around).
if mbox.ForwardTo != nil && *mbox.ForwardTo != "" {
forwardTo, mailboxEmail, keepCopy := *mbox.ForwardTo, mbox.Email, mbox.ForwardKeepCopy
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
}
}
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{
"from": s.mailFrom, "to": rcpt, "subject": subject,
"body": bodyText, "has_attachment": hasAttachment, "recipient_type": types[i],
})
if err != nil {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "450", ErrorMessage: err.Error()})
continue
}
if action.ForwardTo != "" {
// Fire-and-forget: forwarding is a side effect layered on top of this
// recipient's own local delivery, not a substitute for it — a slow or
// unreachable forward target must never delay the SMTP response.
// Envelope-from is the mailbox's own address (not the original
// sender's) so this doesn't masquerade as a relay of someone else's
// mail; no SRS rewriting or Resent-* headers, matching every other
// send path in this codebase.
forwardTo, mailboxEmail := action.ForwardTo, mbox.Email
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
}
}
if action.Drop {
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
continue
}
if action.AutoReply {
s.sendAutoReply(mbox, action.AutoReplySubject, action.AutoReplyBody, messageID)
}
if action.Folder != "" {
folder = action.Folder
}
markRead = action.MarkRead
}
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(recipientContent), messageID, fromDisplay, recipientSubject)
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 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)
}
}
s.backend.Notify.Publish(mbox.ID, folder)
serverResponse := "Delivered to local mailbox"
if spamGated {
serverResponse = "Quarantined to Junk folder"
}
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: serverResponse, Quarantined: spamGated})
}
return results
}
// sendAutoReply fires a vacation-responder reply to the current message's sender,
// fire-and-forget (same reasoning as the forward action: a slow/unreachable target
// must never delay the SMTP response), after two loop/storm-prevention checks: never
// reply to a null-sender message (a bounce/DSN — replying to one is the classic
// bounce-loop bug, same rule SendBounce itself already follows), and never reply to
// the same sender more than once per rolling 24h (esrv_mailbox_autoreply_log) — two
// auto-responders emailing each other would otherwise loop forever.
func (s *Session) sendAutoReply(mbox *db.Mailbox, subject, body, inReplyTo string) {
if s.mailFrom == "" {
return
}
if recent, err := s.backend.DB.HasRecentAutoReply(mbox.ID, s.mailFrom); err != nil || recent {
return
}
if subject == "" {
subject = "Automatic reply"
}
mailboxEmail, replyTo := mbox.Email, s.mailFrom
raw := buildAutoReplyMessage(s.backend.HeloHostname, mailboxEmail, replyTo, subject, body, inReplyTo)
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)
}
}
// buildAutoReplyMessage renders a simple vacation-responder reply — plain text/plain,
// marked Auto-Submitted (RFC 3834) so it isn't itself replied to by another
// auto-responder on the receiving end, mirroring relay.buildBounceMessage's shape.
func buildAutoReplyMessage(hostname, from, to, subject, body, inReplyTo string) string {
headers := []string{
"Message-ID: <" + toolbox.GenerateMessageID(hostname) + ">",
"Date: " + time.Now().Format(time.RFC1123Z),
"From: " + from,
"To: " + to,
"Subject: " + subject,
"Auto-Submitted: auto-replied",
`Content-Type: text/plain; charset="UTF-8"`,
"Content-Transfer-Encoding: 8bit",
"MIME-Version: 1.0",
}
if inReplyTo != "" {
headers = append(headers, "In-Reply-To: <"+inReplyTo+">", "References: <"+inReplyTo+">")
}
return strings.Join(headers, "\r\n") + "\r\n\r\n" + body + "\r\n"
}
// domainSendRateLimited reports whether domain has hit its own admin-configured
// outbound send-rate cap (esrv_domains.send_rate_limit_per_hour) within the last
// rolling hour. Unconfigured (nil limit) or an unrecognized/empty domain never limits.
func (s *Session) domainSendRateLimited(domain string) (bool, error) {
if domain == "" {
return false, nil
}
dom, err := s.backend.DB.GetDomainByName(domain)
if err != nil {
return false, err
}
if dom == nil || dom.SendRateLimitPerHour == nil {
return false, nil
}
count, err := s.backend.DB.CountRecentSendsForDomain(domain, time.Now().Add(-time.Hour))
if err != nil {
return false, err
}
return count >= *dom.SendRateLimitPerHour, nil
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}