package smtpserver import ( "fmt" "io" "net" "os" "path/filepath" "strings" "time" "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/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 } 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 == "" { 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") } content := string(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 } rebuiltHeaders := existingHeaders(rebuilt) toHeader := rebuiltHeaders["to"] ccHeader := rebuiltHeaders["cc"] subject := rebuiltHeaders["subject"] // The message's own From: header (e.g. "Bob Marley "), 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 } parsed, parseErr := parseMessage(raw) 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 + "_" + 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]) } } 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, fromHeader)...) } var failed []relay.Result for _, res := range results { if res.Status != "success" { 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 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([]byte(signedContent)); err == nil { bodyText = parsedForRules.TextBody if bodyText == "" && parsedForRules.HTMLBody != "" { bodyText = bluemonday.StrictPolicy().Sanitize(parsedForRules.HTMLBody) } if len(parsedForRules.Attachments) > 0 { hasAttachment = "yes" } } results := make([]relay.Result, 0, len(rcpts)) for i, rcpt := range rcpts { mbox := s.localMailboxes[strings.ToLower(rcpt)] folder := "INBOX" markRead := false // 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. spamGated := false if allowed, _ := s.backend.DB.IsAllowed(mbox.ID, s.mailFrom); !allowed { // 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. if junked, _ := s.backend.DB.IsJunked(mbox.ID, s.mailFrom); junked { folder = "Junk" spamGated = true } else { quarantine := heuristicScore >= rejectScore hardReject := false if rspamdEnabled { if score, rAction, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil { // 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 hardReject { results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "Message rejected as spam"}) continue } if quarantine { folder = "Junk" spamGated = true } } } // 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 { 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 go func() { res := s.backend.Relay.RelayEmailAsync(mailboxEmail, []string{forwardTo}, signedContent, []string{"to"}) 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.Folder != "" { folder = action.Folder } markRead = action.MarkRead } uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, fromDisplay, 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 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) } } 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 } func containsStr(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false }