Neues Paket mail/internal/smtp: SMTP-Server für eingehende Mails, von Grund auf implementiert, analog zu mail/internal/imap und mail/internal/pop3 — TCP-Listener mit einer Goroutine pro Verbindung, Session-Zustandsmaschine (Greeting/Ready/MailFromSet/RcptToSet), Kommandos HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT. Envelope wird schrittweise aufgebaut und validiert (503 bei übersprungenen Schritten, 553 bei ungültiger Absender-/Empfängeradresse), Nachrichtengröße wird während DATA laufend gegen eine konfigurierbare Höchstgröße geprüft (552 bei Überschreitung, Sink bekommt die Nachricht nicht). Dot-Stuffing beim Empfang korrekt rückgängig gemacht. Neues Paket mail/internal/mailer: Mailer-Komponente für ausgehende Nachrichten. headerWriter ist die einzige Stelle, an der Header geschrieben werden — jeder Feldwert wird hart gegen CR/LF/Steuerzeichen geprüft, bevor er in die Nachricht geschrieben wird. Behebt den bekannten archivmail-Fehler (Header-Injection durch Stringkonkatenation ohne CRLF-Prüfung, siehe known-issues-archivmail.md #1). Sender.Send überträgt per echtem net/smtp-Client (Standardbibliothek) — keine Zugangsdaten im Code, Zieladresse kommt vom Aufrufer. Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt: CRLF-/Steuerzeichen-Injection in Betreff und Anzeigenamen schlägt fehl (vier Testfälle), Ende-zu-Ende-Header-Integritätstest über echten SMTP-Dialog (Mailpit/MailHog nicht installierbar auf diesem Rechner — Ersatz durch den in dieser Kachel gebauten echten SMTP-Server, kein Mock, im Prüfprotokoll begründet), Lasttest mit 50 gleichzeitigen Verbindungen ohne Goroutine-/Verbindungsleck. go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~26 Pakete) regressionsfrei getestet.
121 lines
2.7 KiB
Go
121 lines
2.7 KiB
Go
package smtp
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
)
|
|
|
|
// maxCommandLineBytes begrenzt eine einzelne Kommando-/DATA-Zeile
|
|
// (defensive Fehlerbehandlung bei nicht-konformen Gegenstellen statt
|
|
// optimistischem Parsing, gleiche Konvention wie mail/internal/imap und
|
|
// mail/internal/pop3).
|
|
const maxCommandLineBytes = 8192
|
|
|
|
// Session ist eine einzelne SMTP-Verbindung mit eigener
|
|
// Zustandsmaschine (Akzeptanzkriterium 1).
|
|
type Session struct {
|
|
conn net.Conn
|
|
reader *bufio.Reader
|
|
writer *bufio.Writer
|
|
sink MessageSink
|
|
|
|
maxMessageBytes int64
|
|
|
|
state State
|
|
from string
|
|
to []string
|
|
}
|
|
|
|
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64) *Session {
|
|
return &Session{
|
|
conn: conn,
|
|
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
|
writer: bufio.NewWriter(conn),
|
|
sink: sink,
|
|
maxMessageBytes: maxMessageBytes,
|
|
state: Greeting,
|
|
}
|
|
}
|
|
|
|
// State liefert den aktuellen Sitzungszustand (für Tests).
|
|
func (s *Session) State() State { return s.state }
|
|
|
|
// Serve führt die Sitzung bis QUIT oder Verbindungsende aus.
|
|
func (s *Session) Serve(ctx context.Context) {
|
|
defer func() { _ = s.conn.Close() }()
|
|
|
|
if err := s.reply(220, "nexarch-mail SMTP server ready"); err != nil {
|
|
return
|
|
}
|
|
|
|
for {
|
|
line, err := s.readLine()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
verb, arg := parseCommand(line)
|
|
if !s.dispatch(ctx, verb, arg) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) readLine() (string, error) {
|
|
line, err := s.reader.ReadString('\n')
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) && line != "" {
|
|
return strings.TrimRight(line, "\r"), nil
|
|
}
|
|
return "", err
|
|
}
|
|
return strings.TrimRight(line, "\r\n"), nil
|
|
}
|
|
|
|
// dispatch verarbeitet EIN geparstes Kommando. false bedeutet: Sitzung
|
|
// beenden (QUIT abgeschlossen oder nicht behebbarer Schreibfehler).
|
|
func (s *Session) dispatch(ctx context.Context, verb, arg string) bool {
|
|
switch verb {
|
|
case "HELO", "EHLO":
|
|
return s.handleHelo(verb, arg)
|
|
case "MAIL":
|
|
return s.handleMailFrom(arg)
|
|
case "RCPT":
|
|
return s.handleRcptTo(arg)
|
|
case "DATA":
|
|
return s.handleData(ctx)
|
|
case "RSET":
|
|
return s.handleRset()
|
|
case "NOOP":
|
|
return s.reply(250, "OK") == nil
|
|
case "QUIT":
|
|
_ = s.reply(221, "Bye")
|
|
return false
|
|
default:
|
|
return s.reply(500, "Command not recognized") == nil
|
|
}
|
|
}
|
|
|
|
// drainUntilDot liest Zeilen, ohne sie zu puffern, bis zur
|
|
// DATA-Abschlusszeile "." — hält das Protokoll nach einer wegen
|
|
// Größenüberschreitung abgelehnten Nachricht synchron, ohne den
|
|
// verworfenen Rest unbegrenzt im Speicher zu halten.
|
|
func (s *Session) drainUntilDot() error {
|
|
for {
|
|
line, err := s.readLine()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if line == "." {
|
|
return nil
|
|
}
|
|
}
|
|
}
|