added IMAP, LetsEncrypt, update layout
This commit is contained in:
+184
-26
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user