277 lines
9.1 KiB
Go
277 lines
9.1 KiB
Go
package smtpserver
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-smtp"
|
|
"gopkg.in/ini.v1"
|
|
"mailgoserver/internal/db"
|
|
"mailgoserver/internal/dkim"
|
|
"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
|
|
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
|
|
authType string // "sender" | "ip" | ""
|
|
authorizedDomain string
|
|
username string
|
|
|
|
mailFrom string
|
|
rcptTos []string
|
|
}
|
|
|
|
func (s *Session) Reset() {
|
|
s.mailFrom = ""
|
|
s.rcptTos = 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 {
|
|
ok, message := s.validateSenderAuthorization(from)
|
|
if !ok {
|
|
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: message}
|
|
}
|
|
s.mailFrom = from
|
|
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) {
|
|
if mailFrom == "" {
|
|
return false, "No sender address provided"
|
|
}
|
|
fromDomain := domainOfAddr(mailFrom)
|
|
if fromDomain == "" {
|
|
return 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)
|
|
}
|
|
if dom == nil {
|
|
return 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)
|
|
}
|
|
|
|
if s.authenticatedSender != nil {
|
|
sender := s.authenticatedSender
|
|
if sender.CanSendAs(mailFrom) {
|
|
return 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
_ = 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)
|
|
}
|
|
|
|
func domainOfAddr(address string) string {
|
|
i := strings.LastIndex(address, "@")
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(address[i+1:])
|
|
}
|
|
|
|
// Rcpt mirrors handle_RCPT: accepts any address, no validation.
|
|
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
|
|
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"]
|
|
|
|
// 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"
|
|
}
|
|
}
|
|
|
|
results := s.backend.Relay.RelayEmailAsync(s.mailFrom, s.rcptTos, signedContent, recipientTypes)
|
|
|
|
allSucceeded := len(results) > 0
|
|
for _, res := range results {
|
|
if res.Status != "success" {
|
|
allSucceeded = false
|
|
}
|
|
}
|
|
|
|
var emailHeaders, messageBody string
|
|
if parseErr == nil {
|
|
emailHeaders = strings.Join(parsed.HeaderLines, "\n")
|
|
messageBody = parsed.BodyText
|
|
}
|
|
|
|
logID, logErr := s.backend.Relay.LogEmail(s.backend.Cfg, s.peerIP, s.mailFrom, toHeader, ccHeader, "", subject, emailHeaders, messageBody, 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"}
|
|
}
|
|
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message relay failed"}
|
|
}
|
|
|
|
func containsStr(list []string, s string) bool {
|
|
for _, v := range list {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|