first commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"gomail/internal/auth"
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
// authenticate is a thin wrapper over the shared auth package, scoped to SMTP.
|
||||
func authenticate(database *db.DB, username, password string) (*db.User, bool) {
|
||||
return auth.Authenticate(database, username, password, auth.ScopeSMTP)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package smtp implements the inbound SMTP MTA (port 25), submission
|
||||
// (port 587, STARTTLS + AUTH), and implicit-TLS SMTPS (port 465) — all as a
|
||||
// single hand-rolled state machine over net.Listener, per the project's
|
||||
// stdlib-first principle. No third-party SMTP library.
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gomail/internal/config"
|
||||
"gomail/internal/db"
|
||||
"gomail/internal/mailstore"
|
||||
"gomail/internal/pipeline"
|
||||
"gomail/internal/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCommandLine = 1000 // RFC 5321 command line limit
|
||||
maxRecipients = 100
|
||||
idleTimeout = 5 * time.Minute
|
||||
dataTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Kind distinguishes the three listener roles — they share the same session
|
||||
// state machine but differ in whether TLS is implicit, STARTTLS-capable, or
|
||||
// plain (inbound MTA still offers STARTTLS, just doesn't require it for the
|
||||
// initial MAIL FROM the way submission does).
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
KindMTA Kind = iota // :25 — inbound from the internet, STARTTLS optional
|
||||
KindSubmission // :587 — STARTTLS + AUTH required before MAIL FROM
|
||||
KindImplicitTLS // :465 — TLS from the first byte
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
database *db.DB
|
||||
store *mailstore.Store
|
||||
tlsConf *tls.Config
|
||||
pipeline *pipeline.Orchestrator // nil = pipeline disabled, all mail treated as clean
|
||||
|
||||
listeners []net.Listener
|
||||
wg sync.WaitGroup
|
||||
sessionWG sync.WaitGroup // tracks in-flight sessions for graceful drain
|
||||
|
||||
maxMessageBytes int64
|
||||
|
||||
connLimiter *ratelimit.Limiter // per-IP connections/min, cfg.RateLimits.SMTPConnPerMin
|
||||
authLimiter *ratelimit.Limiter // per-IP AUTH failures, cfg.RateLimits.SMTPAuthFailures (per minute)
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, database *db.DB, store *mailstore.Store, tlsConf *tls.Config, orch *pipeline.Orchestrator) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
database: database,
|
||||
store: store,
|
||||
tlsConf: tlsConf,
|
||||
pipeline: orch,
|
||||
maxMessageBytes: int64(cfg.Storage.MaxMessageSizeMB) * 1024 * 1024,
|
||||
connLimiter: ratelimit.New(cfg.RateLimits.SMTPConnPerMin),
|
||||
authLimiter: ratelimit.New(cfg.RateLimits.SMTPAuthFailures),
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe starts all three listeners and blocks until one fails or
|
||||
// ctx is cancelled. Each listener's accept loop runs in its own goroutine.
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
specs := []struct {
|
||||
addr string
|
||||
kind Kind
|
||||
}{
|
||||
{s.cfg.Server.SMTPAddr, KindMTA},
|
||||
{s.cfg.Server.SubmissionAddr, KindSubmission},
|
||||
{s.cfg.Server.SMTPSAddr, KindImplicitTLS},
|
||||
}
|
||||
|
||||
errCh := make(chan error, len(specs))
|
||||
|
||||
for _, spec := range specs {
|
||||
ln, err := net.Listen("tcp", spec.addr)
|
||||
if err != nil {
|
||||
s.closeAll()
|
||||
return fmt.Errorf("listen %s: %w", spec.addr, err)
|
||||
}
|
||||
|
||||
if spec.kind == KindImplicitTLS {
|
||||
ln = tls.NewListener(ln, s.tlsConf)
|
||||
}
|
||||
|
||||
s.listeners = append(s.listeners, ln)
|
||||
slog.Info("SMTP listener started", "addr", spec.addr, "kind", kindName(spec.kind))
|
||||
|
||||
s.wg.Add(1)
|
||||
go func(ln net.Listener, kind Kind) {
|
||||
defer s.wg.Done()
|
||||
s.acceptLoop(ctx, ln, kind)
|
||||
}(ln, spec.kind)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) acceptLoop(ctx context.Context, ln net.Listener, kind Kind) {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return // expected — listener closed during shutdown
|
||||
default:
|
||||
slog.Error("accept error", "err", err, "kind", kindName(kind))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ip := connHost(conn.RemoteAddr())
|
||||
if !s.connLimiter.Allow(ip) {
|
||||
slog.Warn("SMTP connection rate limit exceeded, rejecting", "ip", ip, "kind", kindName(kind))
|
||||
conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
s.sessionWG.Add(1)
|
||||
go func() {
|
||||
defer s.sessionWG.Done()
|
||||
s.handleConn(ctx, conn, kind)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(ctx context.Context, conn net.Conn, kind Kind) {
|
||||
defer conn.Close()
|
||||
|
||||
sess := &session{
|
||||
conn: conn,
|
||||
server: s,
|
||||
kind: kind,
|
||||
hostname: s.cfg.Server.Hostname,
|
||||
}
|
||||
|
||||
remoteAddr := conn.RemoteAddr()
|
||||
if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok {
|
||||
sess.senderIP = tcpAddr.IP
|
||||
}
|
||||
|
||||
slog.Debug("SMTP connection accepted", "remote", remoteAddr, "kind", kindName(kind))
|
||||
sess.run(ctx)
|
||||
}
|
||||
|
||||
// Shutdown closes all listeners immediately (stops accepting new
|
||||
// connections) then waits up to gracePeriod for in-flight sessions to finish
|
||||
// naturally (they'll see ctx.Done() and wind down at their next command read).
|
||||
func (s *Server) Shutdown(gracePeriod time.Duration) {
|
||||
s.closeAll()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.sessionWG.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
slog.Info("all SMTP sessions drained cleanly")
|
||||
case <-time.After(gracePeriod):
|
||||
slog.Warn("SMTP shutdown grace period expired — some sessions forcibly terminated", "grace_period", gracePeriod)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) closeAll() {
|
||||
for _, ln := range s.listeners {
|
||||
ln.Close()
|
||||
}
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func kindName(k Kind) string {
|
||||
switch k {
|
||||
case KindMTA:
|
||||
return "mta"
|
||||
case KindSubmission:
|
||||
return "submission"
|
||||
case KindImplicitTLS:
|
||||
return "smtps"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// connHost extracts just the IP (no port) from a net.Addr, for use as a
|
||||
// rate-limiter key — falls back to the full address string if it isn't
|
||||
// host:port shaped (shouldn't happen for real TCP connections, but a
|
||||
// fallback beats a panic).
|
||||
func connHost(addr net.Addr) string {
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return addr.String()
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user