306 lines
13 KiB
Go
306 lines
13 KiB
Go
package smtpserver
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-smtp"
|
|
"gopkg.in/ini.v1"
|
|
"mailgoserver/internal/abuseguard"
|
|
"mailgoserver/internal/db"
|
|
"mailgoserver/internal/dkim"
|
|
"mailgoserver/internal/mailstore"
|
|
"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")
|
|
}
|
|
|
|
// Split recipients resolved to a local mailbox in Rcpt from everything else —
|
|
// RouteAndDeliver only needs the split, not how it was derived (SMTP already
|
|
// knows this from Rcpt() time; JMAP submission, internal/jmap, resolves it
|
|
// itself since it has no RCPT phase at all).
|
|
var localRcpts, relayRcpts []string
|
|
for _, rcpt := range s.rcptTos {
|
|
if _, ok := s.localMailboxes[strings.ToLower(rcpt)]; ok {
|
|
localRcpts = append(localRcpts, rcpt)
|
|
} else {
|
|
relayRcpts = append(relayRcpts, rcpt)
|
|
}
|
|
}
|
|
|
|
_, _, allSucceeded, anySucceeded, err := s.backend.RouteAndDeliver(s.mailFrom, localRcpts, relayRcpts, raw, s.peerIP, s.username)
|
|
if err != nil {
|
|
if errors.Is(err, ErrVirusDetected) {
|
|
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message rejected: " + err.Error()}
|
|
}
|
|
return internalError("Internal server error")
|
|
}
|
|
|
|
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 RouteAndDeliver's
|
|
// bounce comment 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"}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
func containsStr(list []string, s string) bool {
|
|
for _, v := range list {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|