Files
gomail/internal/smtp/session.go
T

763 lines
22 KiB
Go
Raw Normal View History

2026-08-09 18:03:09 +01:00
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 ""
}