763 lines
22 KiB
Go
763 lines
22 KiB
Go
package smtp
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"context"
|
||
|
|
"crypto/tls"
|
||
|
|
"encoding/base64"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"log/slog"
|
||
|
|
"net"
|
||
|
|
"net/mail"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"gomail/internal/db"
|
||
|
|
"gomail/internal/pipeline"
|
||
|
|
"gomail/internal/sieve"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
type state int
|
||
|
|
|
||
|
|
const (
|
||
|
|
stateGreeted state = iota
|
||
|
|
stateAuthenticated
|
||
|
|
stateMailFrom
|
||
|
|
stateRcptTo
|
||
|
|
)
|
||
|
|
|
||
|
|
type session struct {
|
||
|
|
conn net.Conn
|
||
|
|
rw *bufio.ReadWriter
|
||
|
|
server *Server
|
||
|
|
kind Kind
|
||
|
|
hostname string
|
||
|
|
senderIP net.IP
|
||
|
|
senderHost string
|
||
|
|
|
||
|
|
state state
|
||
|
|
tlsActive bool
|
||
|
|
authUser *db.User
|
||
|
|
mailFrom string
|
||
|
|
rcptTo []string
|
||
|
|
recipientsValid []recipientTarget
|
||
|
|
}
|
||
|
|
|
||
|
|
type recipientTarget struct {
|
||
|
|
address string
|
||
|
|
user *db.User // nil if only validated as accept-all domain (no specific mailbox yet resolvable)
|
||
|
|
tenantID string
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) isSubmissionKind() bool {
|
||
|
|
return s.kind == KindSubmission || s.kind == KindImplicitTLS
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) run(ctx context.Context) {
|
||
|
|
s.rw = bufio.NewReadWriter(bufio.NewReader(s.conn), bufio.NewWriter(s.conn))
|
||
|
|
|
||
|
|
if s.kind == KindImplicitTLS {
|
||
|
|
s.tlsActive = true // listener already wrapped with tls.NewListener
|
||
|
|
}
|
||
|
|
|
||
|
|
s.writeLine(fmt.Sprintf("220 %s GoMail ESMTP ready", s.hostname))
|
||
|
|
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
s.writeLine("421 4.3.2 Server shutting down")
|
||
|
|
return
|
||
|
|
default:
|
||
|
|
}
|
||
|
|
|
||
|
|
s.conn.SetReadDeadline(time.Now().Add(idleTimeout))
|
||
|
|
line, err := s.readLine()
|
||
|
|
if err != nil {
|
||
|
|
if err != io.EOF {
|
||
|
|
slog.Debug("SMTP read error", "err", err)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if !s.handleCommand(ctx, line) {
|
||
|
|
return // QUIT or fatal error
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleCommand dispatches one command line. Returns false if the session
|
||
|
|
// should close (QUIT or unrecoverable error).
|
||
|
|
func (s *session) handleCommand(ctx context.Context, line string) bool {
|
||
|
|
if len(line) > maxCommandLine {
|
||
|
|
s.writeLine("500 5.5.2 Line too long")
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
verb, rest := splitVerb(line)
|
||
|
|
|
||
|
|
switch strings.ToUpper(verb) {
|
||
|
|
case "HELO":
|
||
|
|
s.handleHelo(rest, false)
|
||
|
|
case "EHLO":
|
||
|
|
s.handleHelo(rest, true)
|
||
|
|
case "STARTTLS":
|
||
|
|
s.handleStartTLS()
|
||
|
|
case "AUTH":
|
||
|
|
s.handleAuth(rest)
|
||
|
|
case "MAIL":
|
||
|
|
s.handleMailFrom(rest)
|
||
|
|
case "RCPT":
|
||
|
|
s.handleRcptTo(rest)
|
||
|
|
case "DATA":
|
||
|
|
s.handleData(ctx)
|
||
|
|
case "RSET":
|
||
|
|
s.reset()
|
||
|
|
s.writeLine("250 2.0.0 OK")
|
||
|
|
case "NOOP":
|
||
|
|
s.writeLine("250 2.0.0 OK")
|
||
|
|
case "QUIT":
|
||
|
|
s.writeLine(fmt.Sprintf("221 2.0.0 %s closing connection", s.hostname))
|
||
|
|
return false
|
||
|
|
case "VRFY", "EXPN":
|
||
|
|
// Information disclosure — always decline, never confirm/deny addresses.
|
||
|
|
s.writeLine("252 2.5.2 Cannot VRFY user, but will accept message and attempt delivery")
|
||
|
|
default:
|
||
|
|
s.writeLine("500 5.5.1 Command not recognized")
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) handleHelo(arg string, extended bool) {
|
||
|
|
if arg == "" {
|
||
|
|
s.writeLine("501 5.5.4 HELO/EHLO requires a hostname argument")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.reset()
|
||
|
|
s.state = stateGreeted
|
||
|
|
|
||
|
|
if !extended {
|
||
|
|
s.writeLine(fmt.Sprintf("250 %s", s.hostname))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
caps := []string{
|
||
|
|
fmt.Sprintf("250-%s", s.hostname),
|
||
|
|
"250-PIPELINING",
|
||
|
|
fmt.Sprintf("250-SIZE %d", s.server.maxMessageBytes),
|
||
|
|
"250-8BITMIME",
|
||
|
|
}
|
||
|
|
if !s.tlsActive {
|
||
|
|
caps = append(caps, "250-STARTTLS")
|
||
|
|
}
|
||
|
|
if s.isSubmissionKind() && s.tlsActive {
|
||
|
|
caps = append(caps, "250-AUTH PLAIN LOGIN")
|
||
|
|
}
|
||
|
|
caps = append(caps, "250 ENHANCEDSTATUSCODES")
|
||
|
|
|
||
|
|
for _, c := range caps {
|
||
|
|
s.writeLine(c)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) handleStartTLS() {
|
||
|
|
if s.tlsActive {
|
||
|
|
s.writeLine("503 5.5.1 TLS already active")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.writeLine("220 2.0.0 Ready to start TLS")
|
||
|
|
|
||
|
|
tlsConn := tls.Server(s.conn, s.server.tlsConf)
|
||
|
|
if err := tlsConn.HandshakeContext(context.Background()); err != nil {
|
||
|
|
slog.Debug("STARTTLS handshake failed", "err", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
s.conn = tlsConn
|
||
|
|
s.rw = bufio.NewReadWriter(bufio.NewReader(tlsConn), bufio.NewWriter(tlsConn))
|
||
|
|
s.tlsActive = true
|
||
|
|
s.reset() // RFC 3207 — discard any prior state after STARTTLS
|
||
|
|
s.state = stateGreeted
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleAuth implements SASL PLAIN and LOGIN. Verifies against either the
|
||
|
|
// user's main password (bcrypt) or an active, non-expired app password
|
||
|
|
// scoped for "smtp". Submission (:587) requires TLS to be active first.
|
||
|
|
func (s *session) handleAuth(arg string) {
|
||
|
|
if s.kind != KindSubmission && s.kind != KindImplicitTLS {
|
||
|
|
s.writeLine("503 5.5.1 AUTH not permitted on this port")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !s.tlsActive {
|
||
|
|
s.writeLine("538 5.7.11 Encryption required for requested authentication mechanism")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Checked before attempting any credential parsing — an IP that has
|
||
|
|
// already exhausted its allowance shouldn't get free password-guessing
|
||
|
|
// attempts just because the failure hasn't been recorded yet.
|
||
|
|
ip := connHost(s.conn.RemoteAddr())
|
||
|
|
if !s.server.authLimiter.Allow(ip) {
|
||
|
|
slog.Warn("SMTP AUTH rate limit exceeded", "remote", ip)
|
||
|
|
s.writeLine("454 4.7.0 Too many authentication attempts, try again later")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
mechanism, initialResponse, _ := strings.Cut(arg, " ")
|
||
|
|
mechanism = strings.ToUpper(mechanism)
|
||
|
|
|
||
|
|
var username, password string
|
||
|
|
var ok bool
|
||
|
|
|
||
|
|
switch mechanism {
|
||
|
|
case "PLAIN":
|
||
|
|
username, password, ok = s.readAuthPlain(initialResponse)
|
||
|
|
case "LOGIN":
|
||
|
|
username, password, ok = s.readAuthLogin()
|
||
|
|
default:
|
||
|
|
s.writeLine("504 5.5.4 Unrecognized authentication mechanism")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !ok {
|
||
|
|
s.writeLine("501 5.5.4 Malformed authentication response")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
user, verified := authenticate(s.server.database, username, password)
|
||
|
|
if !verified {
|
||
|
|
slog.Info("SMTP auth failed", "user", username, "remote", s.senderIP)
|
||
|
|
s.writeLine("535 5.7.8 Authentication credentials invalid")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
s.authUser = user
|
||
|
|
s.state = stateAuthenticated
|
||
|
|
s.writeLine("235 2.7.0 Authentication successful")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) readAuthPlain(initial string) (username, password string, ok bool) {
|
||
|
|
raw := initial
|
||
|
|
if raw == "" {
|
||
|
|
s.writeLine("334 ")
|
||
|
|
line, err := s.readLine()
|
||
|
|
if err != nil {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
raw = line
|
||
|
|
}
|
||
|
|
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||
|
|
if err != nil {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
// SASL PLAIN format: authzid\0authcid\0password
|
||
|
|
parts := strings.SplitN(string(decoded), "\x00", 3)
|
||
|
|
if len(parts) != 3 {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
return parts[1], parts[2], true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) readAuthLogin() (username, password string, ok bool) {
|
||
|
|
s.writeLine("334 VXNlcm5hbWU6") // "Username:"
|
||
|
|
uLine, err := s.readLine()
|
||
|
|
if err != nil {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
uDecoded, err := base64.StdEncoding.DecodeString(uLine)
|
||
|
|
if err != nil {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
|
||
|
|
s.writeLine("334 UGFzc3dvcmQ6") // "Password:"
|
||
|
|
pLine, err := s.readLine()
|
||
|
|
if err != nil {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
pDecoded, err := base64.StdEncoding.DecodeString(pLine)
|
||
|
|
if err != nil {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
|
||
|
|
return string(uDecoded), string(pDecoded), true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) handleMailFrom(arg string) {
|
||
|
|
if s.isSubmissionKind() && s.state != stateAuthenticated {
|
||
|
|
s.writeLine("530 5.7.0 Authentication required")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
addr, ok := parseMailCmdArg(arg, "FROM:")
|
||
|
|
if !ok {
|
||
|
|
s.writeLine("501 5.5.4 Syntax error in MAIL FROM command")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Submission: envelope sender must match the authenticated user (or their alias).
|
||
|
|
if s.isSubmissionKind() && addr != "" {
|
||
|
|
if !strings.EqualFold(addr, s.authUser.Email) {
|
||
|
|
s.writeLine("553 5.7.1 MAIL FROM must match authenticated identity")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
s.mailFrom = strings.ToLower(addr)
|
||
|
|
s.rcptTo = nil
|
||
|
|
s.recipientsValid = nil
|
||
|
|
s.state = stateMailFrom
|
||
|
|
s.writeLine("250 2.1.0 OK")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) handleRcptTo(arg string) {
|
||
|
|
if s.state != stateMailFrom && s.state != stateRcptTo {
|
||
|
|
s.writeLine("503 5.5.1 MAIL FROM required before RCPT TO")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(s.rcptTo) >= maxRecipients {
|
||
|
|
s.writeLine("452 4.5.3 Too many recipients")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
addr, ok := parseMailCmdArg(arg, "TO:")
|
||
|
|
if !ok || addr == "" {
|
||
|
|
s.writeLine("501 5.5.4 Syntax error in RCPT TO command")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
addr = strings.ToLower(addr)
|
||
|
|
|
||
|
|
parts := strings.SplitN(addr, "@", 2)
|
||
|
|
if len(parts) != 2 {
|
||
|
|
s.writeLine("501 5.1.3 Bad recipient address syntax")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
domainPart := parts[1]
|
||
|
|
|
||
|
|
// Outbound relay (submission, authenticated) — recipient is external, no local check.
|
||
|
|
if s.isSubmissionKind() && s.authUser != nil {
|
||
|
|
s.rcptTo = append(s.rcptTo, addr)
|
||
|
|
s.recipientsValid = append(s.recipientsValid, recipientTarget{address: addr, tenantID: s.authUser.TenantID})
|
||
|
|
s.state = stateRcptTo
|
||
|
|
s.writeLine("250 2.1.5 OK")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Inbound — recipient must be a hosted domain, and either accept-all or a known user.
|
||
|
|
domain, tenant, err := s.server.database.LookupDomain(domainPart)
|
||
|
|
if err != nil {
|
||
|
|
slog.Debug("RCPT rejected — unknown domain", "domain", domainPart)
|
||
|
|
s.writeLine("550 5.1.2 Bad destination mailbox address")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sender IP/address block-list check.
|
||
|
|
senderDomain := ""
|
||
|
|
if i := strings.LastIndex(s.mailFrom, "@"); i >= 0 {
|
||
|
|
senderDomain = s.mailFrom[i+1:]
|
||
|
|
}
|
||
|
|
if blocked, action, _ := s.server.database.MatchListRule(tenant.ID, s.mailFrom, senderDomain); blocked && action == db.ListActionBlock {
|
||
|
|
slog.Info("RCPT rejected — sender blocked by list rule", "from", s.mailFrom, "to", addr)
|
||
|
|
s.writeLine("550 5.7.1 Sender rejected")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var user *db.User
|
||
|
|
if u, err := s.server.database.LookupUserByEmail(addr); err == nil {
|
||
|
|
user = u
|
||
|
|
} else if !domain.AcceptAll {
|
||
|
|
slog.Debug("RCPT rejected — unknown user, domain not accept-all", "to", addr)
|
||
|
|
s.writeLine("550 5.1.1 User unknown")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
s.rcptTo = append(s.rcptTo, addr)
|
||
|
|
s.recipientsValid = append(s.recipientsValid, recipientTarget{address: addr, user: user, tenantID: tenant.ID})
|
||
|
|
s.state = stateRcptTo
|
||
|
|
s.writeLine("250 2.1.5 OK")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) handleData(ctx context.Context) {
|
||
|
|
if s.state != stateRcptTo || len(s.rcptTo) == 0 {
|
||
|
|
s.writeLine("503 5.5.1 RCPT TO required before DATA")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
s.writeLine("354 Start mail input; end with <CRLF>.<CRLF>")
|
||
|
|
s.conn.SetReadDeadline(time.Now().Add(dataTimeout))
|
||
|
|
|
||
|
|
raw, err := s.readDotStuffed()
|
||
|
|
if err != nil {
|
||
|
|
s.writeLine("451 4.3.0 Error reading message data")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if int64(len(raw)) > s.server.maxMessageBytes {
|
||
|
|
s.writeLine(fmt.Sprintf("552 5.3.4 Message size exceeds maximum of %d bytes", s.server.maxMessageBytes))
|
||
|
|
s.reset()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
subject := extractSubject(raw)
|
||
|
|
msgIDHdr := extractMessageID(raw)
|
||
|
|
|
||
|
|
deliveredCount := 0
|
||
|
|
for _, target := range s.recipientsValid {
|
||
|
|
msgID := uuid.NewString()
|
||
|
|
|
||
|
|
msg := &db.Message{
|
||
|
|
ID: msgID,
|
||
|
|
TenantID: target.tenantID,
|
||
|
|
FromAddress: s.mailFrom,
|
||
|
|
ToAddress: target.address,
|
||
|
|
Subject: subject,
|
||
|
|
MessageIDHdr: msgIDHdr,
|
||
|
|
SizeBytes: int64(len(raw)),
|
||
|
|
Verdict: db.VerdictClean,
|
||
|
|
SenderIP: senderIPString(s.senderIP),
|
||
|
|
}
|
||
|
|
|
||
|
|
// Insert the audit row immediately — message_checks rows inserted by
|
||
|
|
// the pipeline below FK-reference messages.id, so the parent row
|
||
|
|
// must exist first regardless of how long pipeline evaluation takes.
|
||
|
|
if err := s.server.database.InsertMessage(msg); err != nil {
|
||
|
|
slog.Error("failed to record message audit row", "err", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if target.user != nil {
|
||
|
|
deliverRaw := raw
|
||
|
|
msg.Verdict = db.VerdictClean
|
||
|
|
|
||
|
|
// Run the security pipeline only for true inbound mail from the
|
||
|
|
// internet (KindMTA) — mail submitted by an authenticated local
|
||
|
|
// user to another local user (KindSubmission/KindImplicitTLS) is
|
||
|
|
// treated as trusted internal mail and skips filtering, matching
|
||
|
|
// standard MTA practice.
|
||
|
|
if s.kind == KindMTA && s.server.pipeline != nil {
|
||
|
|
mc := &pipeline.MailContext{
|
||
|
|
SenderIP: s.senderIP,
|
||
|
|
SenderHost: s.senderHost,
|
||
|
|
MailFrom: s.mailFrom,
|
||
|
|
RcptTo: target.address,
|
||
|
|
RawMessage: raw,
|
||
|
|
}
|
||
|
|
s.server.pipeline.Run(ctx, mc)
|
||
|
|
msg.Verdict = mc.Verdict
|
||
|
|
msg.TotalScore = mc.TotalScore
|
||
|
|
|
||
|
|
for _, check := range mc.Checks {
|
||
|
|
mcRow := &db.MessageCheck{
|
||
|
|
ID: uuid.NewString(),
|
||
|
|
MessageID: msgID,
|
||
|
|
Stage: check.Stage,
|
||
|
|
Result: check.Result,
|
||
|
|
Score: check.Score,
|
||
|
|
Detail: check.Detail,
|
||
|
|
DurationMs: check.DurationMs,
|
||
|
|
}
|
||
|
|
if err := s.server.database.InsertMessageCheck(mcRow); err != nil {
|
||
|
|
slog.Error("failed to record pipeline check result", "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if msg.Verdict == db.VerdictFlagged {
|
||
|
|
deliverRaw = injectSpamHeaders(raw, mc.TotalScore, mc.Checks)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
switch msg.Verdict {
|
||
|
|
case db.VerdictQuarantine, db.VerdictBlocked:
|
||
|
|
if err := s.quarantineMessage(msgID, raw, msg.Verdict); err != nil {
|
||
|
|
slog.Error("quarantine failed", "to", target.address, "err", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
slog.Info("message quarantined", "to", target.address, "verdict", msg.Verdict, "score", msg.TotalScore)
|
||
|
|
if err := s.server.database.UpdateMessageVerdict(msgID, msg.Verdict, msg.TotalScore, nil); err != nil {
|
||
|
|
slog.Error("failed to update message verdict", "err", err)
|
||
|
|
}
|
||
|
|
deliveredCount++ // "accepted" from the SMTP client's perspective — held, not bounced
|
||
|
|
default:
|
||
|
|
// Clean or flagged — check for an active Sieve script before
|
||
|
|
// delivering, so fileinto/discard rules apply to the same
|
||
|
|
// mail the security pipeline already cleared.
|
||
|
|
destFolder := "INBOX"
|
||
|
|
discard := false
|
||
|
|
if script, err := s.server.database.GetActiveSieveScript(target.user.ID); err == nil {
|
||
|
|
if result, applyErr := applySieve(script.ScriptText, deliverRaw); applyErr == nil {
|
||
|
|
switch result.Action {
|
||
|
|
case "fileinto":
|
||
|
|
destFolder = result.Folder
|
||
|
|
case "discard":
|
||
|
|
discard = true
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
slog.Warn("sieve script failed to apply, falling back to INBOX delivery", "user", target.user.Email, "err", applyErr)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Now().UTC()
|
||
|
|
if discard {
|
||
|
|
slog.Info("message discarded by sieve rule", "to", target.address)
|
||
|
|
if err := s.server.database.UpdateMessageVerdict(msgID, msg.Verdict, msg.TotalScore, &now); err != nil {
|
||
|
|
slog.Error("failed to update message verdict", "err", err)
|
||
|
|
}
|
||
|
|
deliveredCount++ // accepted from the SMTP client's perspective, then discarded per user's own rule
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := s.server.store.Deliver(target.user.ID, target.user.Email, destFolder, deliverRaw); err != nil {
|
||
|
|
slog.Error("local delivery failed", "to", target.address, "folder", destFolder, "err", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
msg.RelayedAt = &now
|
||
|
|
if err := s.server.database.UpdateMessageVerdict(msgID, msg.Verdict, msg.TotalScore, &now); err != nil {
|
||
|
|
slog.Error("failed to update message verdict", "err", err)
|
||
|
|
}
|
||
|
|
deliveredCount++
|
||
|
|
}
|
||
|
|
} else if s.isSubmissionKind() {
|
||
|
|
// Outbound to external address — stage the message and enqueue it
|
||
|
|
// for the background queue worker (internal/queue) to deliver.
|
||
|
|
_, queuePath, err := s.server.store.WriteQueueFile(raw)
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("failed to stage outbound message", "to", target.address, "err", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
qEntry := &db.OutboundQueueEntry{
|
||
|
|
ID: uuid.NewString(),
|
||
|
|
UserID: s.authUser.ID,
|
||
|
|
FromAddress: s.mailFrom,
|
||
|
|
ToAddress: target.address,
|
||
|
|
EMLPath: queuePath,
|
||
|
|
NextAttemptAt: time.Now().UTC(),
|
||
|
|
}
|
||
|
|
if err := s.server.database.InsertOutboundQueueEntry(qEntry); err != nil {
|
||
|
|
slog.Error("failed to enqueue outbound message", "to", target.address, "err", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
now := time.Now().UTC()
|
||
|
|
s.server.database.UpdateMessageVerdict(msgID, db.VerdictClean, 0, &now)
|
||
|
|
slog.Info("outbound message queued", "to", target.address, "from", s.mailFrom)
|
||
|
|
deliveredCount++
|
||
|
|
} else {
|
||
|
|
slog.Warn("accept-all domain recipient has no mailbox yet — message accepted but not delivered", "to", target.address)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if deliveredCount == 0 {
|
||
|
|
s.writeLine("451 4.3.0 Temporary delivery failure")
|
||
|
|
s.reset()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
s.writeLine("250 2.0.0 OK: message accepted")
|
||
|
|
s.reset()
|
||
|
|
}
|
||
|
|
|
||
|
|
// quarantineMessage stores the raw message encrypted in the quarantine area
|
||
|
|
// and creates the DB entry — called when the pipeline verdict is quarantine
|
||
|
|
// or blocked. The message is NOT delivered to the recipient's mailbox; it's
|
||
|
|
// held for admin/user review (release flow lands with the webmail/admin
|
||
|
|
// portal in a later phase; for now this establishes the storage half).
|
||
|
|
func (s *session) quarantineMessage(msgID string, raw []byte, verdict db.MessageVerdict) error {
|
||
|
|
path, err := s.server.store.WriteQuarantineFile(msgID, raw)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("write quarantine file: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
entry := &db.QuarantineEntry{
|
||
|
|
ID: uuid.NewString(),
|
||
|
|
MessageID: msgID,
|
||
|
|
EMLPath: path,
|
||
|
|
Status: db.QuarantineHeld,
|
||
|
|
Reason: fmt.Sprintf("verdict=%s", verdict),
|
||
|
|
ExpiresAt: time.Now().UTC().AddDate(0, 0, s.server.cfg.Storage.QuarantineDays),
|
||
|
|
}
|
||
|
|
if err := s.server.database.InsertQuarantineEntry(entry); err != nil {
|
||
|
|
return fmt.Errorf("insert quarantine entry: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// injectSpamHeaders prepends X-Spam-* headers to a flagged (but still
|
||
|
|
// delivered) message so the recipient's mail client / webmail can surface
|
||
|
|
// the pipeline's findings without the message needing to be held.
|
||
|
|
// applySieve parses and executes a user's active Sieve script against a
|
||
|
|
// message's headers, returning the routing decision (fileinto/discard/keep).
|
||
|
|
// Headers are extracted fresh from raw rather than reusing any previously
|
||
|
|
// parsed structure, since this runs after the pipeline may have prepended
|
||
|
|
// X-Spam-* headers (injectSpamHeaders) — the script should see exactly what
|
||
|
|
// will be delivered, filters included.
|
||
|
|
func applySieve(scriptText string, raw []byte) (sieve.Result, error) {
|
||
|
|
parsed, err := sieve.Parse(scriptText)
|
||
|
|
if err != nil {
|
||
|
|
return sieve.Result{}, fmt.Errorf("parse: %w", err)
|
||
|
|
}
|
||
|
|
headers := extractHeaderMap(raw)
|
||
|
|
return sieve.Execute(parsed, headers), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// extractHeaderMap does a lightweight single-value-per-header extraction
|
||
|
|
// (last value wins for repeated headers) — sufficient for the header
|
||
|
|
// :contains / :is tests this Sieve subset supports.
|
||
|
|
func extractHeaderMap(raw []byte) map[string]string {
|
||
|
|
headers := map[string]string{}
|
||
|
|
text := string(raw)
|
||
|
|
headerEnd := strings.Index(text, "\r\n\r\n")
|
||
|
|
if headerEnd == -1 {
|
||
|
|
headerEnd = len(text)
|
||
|
|
}
|
||
|
|
for _, line := range strings.Split(text[:headerEnd], "\r\n") {
|
||
|
|
if line == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && len(headers) > 0 {
|
||
|
|
continue // folded continuation — good enough for this subset, not appended
|
||
|
|
}
|
||
|
|
name, value, found := strings.Cut(line, ":")
|
||
|
|
if !found {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
headers[strings.TrimSpace(name)] = strings.TrimSpace(value)
|
||
|
|
}
|
||
|
|
return headers
|
||
|
|
}
|
||
|
|
|
||
|
|
func injectSpamHeaders(raw []byte, score float64, checks []pipeline.StageResult) []byte {
|
||
|
|
var failedStages []string
|
||
|
|
for _, c := range checks {
|
||
|
|
if c.Result == db.CheckFail || c.Result == db.CheckWarn {
|
||
|
|
failedStages = append(failedStages, c.Stage)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
header := fmt.Sprintf("X-Spam-Score: %.1f\r\nX-Spam-Flag: YES\r\n", score)
|
||
|
|
if len(failedStages) > 0 {
|
||
|
|
header += fmt.Sprintf("X-Spam-Checks: %s\r\n", strings.Join(failedStages, ", "))
|
||
|
|
}
|
||
|
|
return append([]byte(header), raw...)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) reset() {
|
||
|
|
s.mailFrom = ""
|
||
|
|
s.rcptTo = nil
|
||
|
|
s.recipientsValid = nil
|
||
|
|
if s.state != stateAuthenticated {
|
||
|
|
s.state = stateGreeted
|
||
|
|
} else {
|
||
|
|
s.state = stateAuthenticated
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── I/O helpers ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func (s *session) writeLine(line string) {
|
||
|
|
s.rw.WriteString(line)
|
||
|
|
s.rw.WriteString("\r\n")
|
||
|
|
s.rw.Flush()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *session) readLine() (string, error) {
|
||
|
|
line, err := s.rw.ReadString('\n')
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return strings.TrimRight(line, "\r\n"), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// readDotStuffed reads the DATA payload until the terminating "\r\n.\r\n",
|
||
|
|
// undoing dot-stuffing (a line starting with ".." becomes ".") per RFC 5321 §4.5.2.
|
||
|
|
func (s *session) readDotStuffed() ([]byte, error) {
|
||
|
|
var buf []byte
|
||
|
|
for {
|
||
|
|
line, err := s.rw.ReadString('\n')
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
trimmed := strings.TrimRight(line, "\r\n")
|
||
|
|
if trimmed == "." {
|
||
|
|
return buf, nil
|
||
|
|
}
|
||
|
|
if strings.HasPrefix(trimmed, "..") {
|
||
|
|
trimmed = trimmed[1:]
|
||
|
|
}
|
||
|
|
buf = append(buf, []byte(trimmed)...)
|
||
|
|
buf = append(buf, '\r', '\n')
|
||
|
|
|
||
|
|
if int64(len(buf)) > s.server.maxMessageBytes+1024 {
|
||
|
|
return nil, fmt.Errorf("message exceeds max size during read")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Parsing helpers ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func splitVerb(line string) (verb, rest string) {
|
||
|
|
line = strings.TrimSpace(line)
|
||
|
|
i := strings.IndexAny(line, " :")
|
||
|
|
if i < 0 {
|
||
|
|
return line, ""
|
||
|
|
}
|
||
|
|
// Keep MAIL FROM: / RCPT TO: colon attached to rest for parseMailCmdArg.
|
||
|
|
if line[i] == ':' {
|
||
|
|
return line[:i], line[i:]
|
||
|
|
}
|
||
|
|
return line[:i], strings.TrimSpace(line[i+1:])
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseMailCmdArg extracts the address from "FROM:<addr>" or "TO:<addr>" —
|
||
|
|
// tolerant of the colon being split into verb or rest depending on spacing.
|
||
|
|
func parseMailCmdArg(arg, prefix string) (string, bool) {
|
||
|
|
arg = strings.TrimSpace(arg)
|
||
|
|
upper := strings.ToUpper(arg)
|
||
|
|
prefixUpper := strings.ToUpper(prefix)
|
||
|
|
if strings.HasPrefix(upper, prefixUpper) {
|
||
|
|
arg = arg[len(prefix):]
|
||
|
|
} else if strings.HasPrefix(upper, ":") {
|
||
|
|
arg = arg[1:]
|
||
|
|
}
|
||
|
|
arg = strings.TrimSpace(arg)
|
||
|
|
|
||
|
|
// Strip angle brackets and any trailing ESMTP parameters (e.g. "SIZE=1234").
|
||
|
|
if i := strings.Index(arg, ">"); i >= 0 {
|
||
|
|
arg = arg[:i+1]
|
||
|
|
}
|
||
|
|
arg = strings.TrimPrefix(arg, "<")
|
||
|
|
arg = strings.TrimSuffix(arg, ">")
|
||
|
|
arg = strings.TrimSpace(arg)
|
||
|
|
|
||
|
|
if arg == "" {
|
||
|
|
return "", true // null sender (bounces) is valid: MAIL FROM:<>
|
||
|
|
}
|
||
|
|
if _, err := mail.ParseAddress(arg); err != nil {
|
||
|
|
return "", false
|
||
|
|
}
|
||
|
|
return arg, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func senderIPString(ip net.IP) string {
|
||
|
|
if ip == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return ip.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
func extractSubject(raw []byte) string {
|
||
|
|
return extractHeader(raw, "Subject:")
|
||
|
|
}
|
||
|
|
|
||
|
|
func extractMessageID(raw []byte) string {
|
||
|
|
return extractHeader(raw, "Message-Id:")
|
||
|
|
}
|
||
|
|
|
||
|
|
func extractHeader(raw []byte, prefix string) string {
|
||
|
|
lines := strings.Split(string(raw), "\r\n")
|
||
|
|
for _, line := range lines {
|
||
|
|
if line == "" {
|
||
|
|
break // end of headers
|
||
|
|
}
|
||
|
|
if strings.HasPrefix(strings.ToLower(line), strings.ToLower(prefix)) {
|
||
|
|
return strings.TrimSpace(line[len(prefix):])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|