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 .") 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:" or "TO:" — // 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 "" }