feat(mail): ING-03 SMTP-Server & Mailer (RFC 5321)
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.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Session) handleHelo(verb, arg string) bool {
|
||||
if strings.TrimSpace(arg) == "" {
|
||||
return s.reply(501, verb+" requires a domain/address") == nil
|
||||
}
|
||||
// HELO/EHLO setzt den Envelope zurück, falls bereits einer im
|
||||
// Aufbau war (RFC 5321 §4.1.1.1).
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
s.state = Ready
|
||||
|
||||
if verb == "EHLO" {
|
||||
return s.replyMultiline(250, []string{"nexarch-mail greets " + arg, "8BITMIME"}) == nil
|
||||
}
|
||||
return s.reply(250, "nexarch-mail greets "+arg) == nil
|
||||
}
|
||||
|
||||
// handleMailFrom ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
|
||||
// die Absenderadresse wird vor der Annahme validiert.
|
||||
func (s *Session) handleMailFrom(arg string) bool {
|
||||
if s.state == Greeting {
|
||||
return s.reply(503, "send HELO/EHLO first") == nil
|
||||
}
|
||||
addr, err := parseMailAddressArg(arg, "FROM")
|
||||
if err != nil {
|
||||
return s.reply(501, "invalid MAIL FROM syntax") == nil
|
||||
}
|
||||
if err := validateAddress(addr); err != nil {
|
||||
// Akzeptanzkriterium 3: ungültige Absenderdaten -> saubere
|
||||
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
|
||||
return s.reply(553, "invalid sender address") == nil
|
||||
}
|
||||
s.from = addr
|
||||
s.to = nil
|
||||
s.state = MailFromSet
|
||||
return s.reply(250, "OK") == nil
|
||||
}
|
||||
|
||||
// handleRcptTo ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
|
||||
// jede Empfängeradresse wird vor der Annahme validiert; mehrere RCPT TO
|
||||
// sind erlaubt.
|
||||
func (s *Session) handleRcptTo(arg string) bool {
|
||||
if s.state != MailFromSet && s.state != RcptToSet {
|
||||
return s.reply(503, "send MAIL FROM first") == nil
|
||||
}
|
||||
addr, err := parseMailAddressArg(arg, "TO")
|
||||
if err != nil {
|
||||
return s.reply(501, "invalid RCPT TO syntax") == nil
|
||||
}
|
||||
if err := validateAddress(addr); err != nil {
|
||||
// Akzeptanzkriterium 3: ungültige Empfängerdaten -> saubere
|
||||
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
|
||||
return s.reply(553, "invalid recipient address") == nil
|
||||
}
|
||||
s.to = append(s.to, addr)
|
||||
s.state = RcptToSet
|
||||
return s.reply(250, "OK") == nil
|
||||
}
|
||||
|
||||
func (s *Session) handleRset() bool {
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
if s.state != Greeting {
|
||||
s.state = Ready
|
||||
}
|
||||
return s.reply(250, "OK") == nil
|
||||
}
|
||||
|
||||
// handleData verlangt einen vollständig aufgebauten und validierten
|
||||
// Envelope (Akzeptanzkriterium 1: Envelope UND Nachrichtengröße werden
|
||||
// vor der Annahme geprüft) und liest die dot-gestuffte Nachricht bis zur
|
||||
// Abschlusszeile ".".
|
||||
func (s *Session) handleData(ctx context.Context) bool {
|
||||
if s.state != RcptToSet {
|
||||
return s.reply(503, "send MAIL FROM/RCPT TO first") == nil
|
||||
}
|
||||
if err := s.reply(354, "Start mail input; end with <CRLF>.<CRLF>"); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
for {
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if line == "." {
|
||||
break
|
||||
}
|
||||
// Byte-Stuffing rückgängig machen (RFC 5321 §4.5.2): eine Zeile,
|
||||
// die mit "." beginnt, verliert genau diesen ersten Punkt.
|
||||
line = strings.TrimPrefix(line, ".")
|
||||
buf.WriteString(line)
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
if int64(buf.Len()) > s.maxMessageBytes {
|
||||
// Akzeptanzkriterium 1: Nachrichtengröße wird VOR der
|
||||
// endgültigen Annahme geprüft — sauberer Fehlercode statt
|
||||
// unbegrenztem Pufferwachstum.
|
||||
_ = s.drainUntilDot()
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
s.state = Ready
|
||||
return s.reply(552, "message size exceeds fixed maximum message size") == nil
|
||||
}
|
||||
}
|
||||
|
||||
envelope := Envelope{From: s.from, To: s.to}
|
||||
raw := buf.Bytes()
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
s.state = Ready
|
||||
|
||||
if s.sink != nil {
|
||||
if err := s.sink.Accept(ctx, envelope, raw); err != nil {
|
||||
return s.reply(451, "unable to accept message, try again later") == nil
|
||||
}
|
||||
}
|
||||
return s.reply(250, "OK: message accepted") == nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package smtp implementiert ING-03s SMTP-Server (RFC 5321) für
|
||||
// eingehende Mails: HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, QUIT.
|
||||
// Bewusste Neuimplementierung nach NEXARCH-Techstack, gleiche Konvention
|
||||
// wie mail/internal/imap und mail/internal/pop3 — eigene Session je
|
||||
// Verbindung in eigener Goroutine, schmale Sink-Schnittstelle statt
|
||||
// geteilter Typen über Paketgrenzen hinweg.
|
||||
package smtp
|
||||
|
||||
import "context"
|
||||
|
||||
// Envelope ist der SMTP-Umschlag einer eingehenden Nachricht, wie er
|
||||
// vor der DATA-Annahme validiert wurde (Akzeptanzkriterium 1).
|
||||
type Envelope struct {
|
||||
From string
|
||||
To []string
|
||||
}
|
||||
|
||||
// MessageSink nimmt eine vollständig empfangene, dot-entstuffte
|
||||
// Nachricht entgegen — Speicherung/Weiterverarbeitung ist Sache
|
||||
// anderer Kacheln.
|
||||
type MessageSink interface {
|
||||
Accept(ctx context.Context, envelope Envelope, raw []byte) error
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseCommand zerlegt eine Kommandozeile in Verb (großgeschrieben) und
|
||||
// restliches Argument.
|
||||
func parseCommand(line string) (verb, arg string) {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), " ", 2)
|
||||
verb = strings.ToUpper(parts[0])
|
||||
if len(parts) == 2 {
|
||||
arg = strings.TrimSpace(parts[1])
|
||||
}
|
||||
return verb, arg
|
||||
}
|
||||
|
||||
// parseMailAddressArg extrahiert die Adresse aus "FROM:<addr>" bzw.
|
||||
// "TO:<addr>" (RFC 5321 §4.1.1.2/4.1.1.3). SMTP-Parameter wie SIZE=...
|
||||
// werden für diese kleinste Lösung ignoriert.
|
||||
func parseMailAddressArg(arg, keyword string) (string, error) {
|
||||
trimmed := strings.TrimSpace(arg)
|
||||
upper := strings.ToUpper(trimmed)
|
||||
prefix := keyword + ":"
|
||||
if !strings.HasPrefix(upper, prefix) {
|
||||
return "", fmt.Errorf("smtp: erwartete %q am anfang von %q", prefix, arg)
|
||||
}
|
||||
rest := strings.TrimSpace(trimmed[len(prefix):])
|
||||
if sp := strings.IndexByte(rest, ' '); sp >= 0 {
|
||||
rest = rest[:sp]
|
||||
}
|
||||
rest = strings.TrimPrefix(rest, "<")
|
||||
rest = strings.TrimSuffix(rest, ">")
|
||||
if rest == "" {
|
||||
return "", fmt.Errorf("smtp: leere adresse")
|
||||
}
|
||||
return rest, nil
|
||||
}
|
||||
|
||||
// validateAddress prüft eine E-Mail-Adresse defensiv gegen
|
||||
// Steuerzeichen und offensichtlich falsche Form (Akzeptanzkriterium 3:
|
||||
// ungültige Empfänger-/Absenderdaten führen zu sauberer Fehlermeldung
|
||||
// statt Absturz).
|
||||
func validateAddress(addr string) error {
|
||||
for _, r := range addr {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("smtp: steuerzeichen in adresse")
|
||||
}
|
||||
}
|
||||
at := strings.IndexByte(addr, '@')
|
||||
if at <= 0 || at == len(addr)-1 {
|
||||
return fmt.Errorf("smtp: ungültige adresse %q", addr)
|
||||
}
|
||||
if strings.IndexByte(addr[at+1:], '@') >= 0 {
|
||||
return fmt.Errorf("smtp: ungültige adresse %q", addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package smtp
|
||||
|
||||
import "fmt"
|
||||
|
||||
// reply schreibt eine einzeilige SMTP-Antwort "code text\r\n".
|
||||
func (s *Session) reply(code int, text string) error {
|
||||
if _, err := fmt.Fprintf(s.writer, "%d %s\r\n", code, text); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writer.Flush()
|
||||
}
|
||||
|
||||
// replyMultiline schreibt eine mehrzeilige SMTP-Antwort (z. B. EHLO-
|
||||
// Capability-Liste): alle Zeilen außer der letzten mit "-" statt " "
|
||||
// nach dem Code (RFC 5321 §4.2.1).
|
||||
func (s *Session) replyMultiline(code int, lines []string) error {
|
||||
for i, line := range lines {
|
||||
sep := "-"
|
||||
if i == len(lines)-1 {
|
||||
sep = " "
|
||||
}
|
||||
if _, err := fmt.Fprintf(s.writer, "%d%s%s\r\n", code, sep, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.writer.Flush()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// defaultMaxMessageBytes ist die Standard-Höchstgröße einer
|
||||
// angenommenen Nachricht (Akzeptanzkriterium 1).
|
||||
const defaultMaxMessageBytes = 25 * 1024 * 1024 // 25 MiB
|
||||
|
||||
// Server nimmt SMTP-Verbindungen an und bedient jede in einer eigenen
|
||||
// Goroutine — gleiches Muster wie mail/internal/imap.Server und
|
||||
// mail/internal/pop3.Server. TLS/STARTTLS ist Sache von ING-06,
|
||||
// Rate-Limiting Sache von ING-09, Protokoll-Logging Sache von ING-08 —
|
||||
// keine dieser Kacheln.
|
||||
type Server struct {
|
||||
sink MessageSink
|
||||
maxMessageBytes int64
|
||||
}
|
||||
|
||||
func NewServer(sink MessageSink) *Server {
|
||||
return NewServerWithMaxMessageBytes(sink, defaultMaxMessageBytes)
|
||||
}
|
||||
|
||||
// NewServerWithMaxMessageBytes erlaubt eine abweichende
|
||||
// Nachrichten-Höchstgröße, z. B. für Tests.
|
||||
func NewServerWithMaxMessageBytes(sink MessageSink, maxMessageBytes int64) *Server {
|
||||
return &Server{sink: sink, maxMessageBytes: maxMessageBytes}
|
||||
}
|
||||
|
||||
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
|
||||
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = listener.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("smtp: verbindung annehmen: %w", err)
|
||||
}
|
||||
session := newSession(conn, srv.sink, srv.maxMessageBytes)
|
||||
go session.Serve(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeSink zeichnet angenommene Nachrichten im Prozessspeicher auf.
|
||||
type fakeSink struct {
|
||||
mu sync.Mutex
|
||||
accepted []acceptedMessage
|
||||
fail bool
|
||||
}
|
||||
|
||||
type acceptedMessage struct {
|
||||
envelope Envelope
|
||||
raw []byte
|
||||
}
|
||||
|
||||
func (f *fakeSink) Accept(_ context.Context, envelope Envelope, raw []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.fail {
|
||||
return errFakeSinkRejects
|
||||
}
|
||||
cp := make([]byte, len(raw))
|
||||
copy(cp, raw)
|
||||
f.accepted = append(f.accepted, acceptedMessage{envelope: envelope, raw: cp})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSink) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.accepted)
|
||||
}
|
||||
|
||||
type sinkError string
|
||||
|
||||
func (e sinkError) Error() string { return string(e) }
|
||||
|
||||
const errFakeSinkRejects sinkError = "fake sink lehnt ab"
|
||||
|
||||
func startTestServer(t *testing.T, sink MessageSink, maxMessageBytes int64) (addr string, stop func()) {
|
||||
t.Helper()
|
||||
srv := NewServerWithMaxMessageBytes(sink, maxMessageBytes)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listener: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = srv.Serve(ctx, listener)
|
||||
close(done)
|
||||
}()
|
||||
return listener.Addr().String(), func() {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
type smtpClient struct {
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
||||
func dial(t *testing.T, addr string) *smtpClient {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
c := &smtpClient{conn: conn, reader: bufio.NewReader(conn)}
|
||||
c.readLine(t) // 220 Begrüßung
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *smtpClient) readLine(t *testing.T) string {
|
||||
t.Helper()
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("antwort lesen: %v", err)
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n")
|
||||
}
|
||||
|
||||
func (c *smtpClient) send(t *testing.T, cmd string) string {
|
||||
t.Helper()
|
||||
if _, err := c.conn.Write([]byte(cmd + "\r\n")); err != nil {
|
||||
t.Fatalf("kommando senden: %v", err)
|
||||
}
|
||||
return c.readLine(t)
|
||||
}
|
||||
|
||||
func (c *smtpClient) close() { _ = c.conn.Close() }
|
||||
|
||||
func code(line string) string {
|
||||
if len(line) < 3 {
|
||||
return line
|
||||
}
|
||||
return line[:3]
|
||||
}
|
||||
|
||||
// TestSession_EnvelopeMustBeBuiltBeforeData ist die geforderte
|
||||
// Zustandsmaschinen-Abdeckung für Akzeptanzkriterium 1: Envelope
|
||||
// (HELO -> MAIL FROM -> RCPT TO) wird SCHRITTWEISE validiert, DATA ist
|
||||
// erst nach vollständigem, gültigem Envelope erlaubt.
|
||||
func TestSession_EnvelopeMustBeBuiltBeforeData(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
|
||||
defer stop()
|
||||
c := dial(t, addr)
|
||||
defer c.close()
|
||||
|
||||
// MAIL FROM vor HELO -> 503.
|
||||
if resp := c.send(t, "MAIL FROM:<a@example.com>"); code(resp) != "503" {
|
||||
t.Fatalf("erwartete 503 für MAIL FROM vor HELO, habe: %s", resp)
|
||||
}
|
||||
|
||||
if resp := c.send(t, "EHLO client.example.com"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 für EHLO, habe: %s", resp)
|
||||
}
|
||||
// Mehrzeilige EHLO-Antwort vollständig lesen.
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// RCPT TO vor MAIL FROM -> 503.
|
||||
if resp := c.send(t, "RCPT TO:<b@example.com>"); code(resp) != "503" {
|
||||
t.Fatalf("erwartete 503 für RCPT TO vor MAIL FROM, habe: %s", resp)
|
||||
}
|
||||
|
||||
// DATA vor RCPT TO -> 503.
|
||||
if resp := c.send(t, "DATA"); code(resp) != "503" {
|
||||
t.Fatalf("erwartete 503 für DATA ohne RCPT TO, habe: %s", resp)
|
||||
}
|
||||
|
||||
if resp := c.send(t, "MAIL FROM:<a@example.com>"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 für MAIL FROM, habe: %s", resp)
|
||||
}
|
||||
if resp := c.send(t, "RCPT TO:<b@example.com>"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 für RCPT TO, habe: %s", resp)
|
||||
}
|
||||
if resp := c.send(t, "DATA"); code(resp) != "354" {
|
||||
t.Fatalf("erwartete 354 für DATA nach vollständigem Envelope, habe: %s", resp)
|
||||
}
|
||||
if resp := c.send(t, "Subject: test\r\n\r\nHallo\r\n."); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 nach abgeschlossener DATA, habe: %s", resp)
|
||||
}
|
||||
|
||||
if sink.count() != 1 {
|
||||
t.Fatalf("erwartete 1 angenommene nachricht, habe %d", sink.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestData_MessageSizeCheckedBeforeAcceptance ist die geforderte
|
||||
// Pflichtprüfung für Akzeptanzkriterium 1 (Größenanteil): eine
|
||||
// Nachricht über der konfigurierten Höchstgröße wird sauber
|
||||
// zurückgewiesen, der Sink bekommt sie NICHT.
|
||||
func TestData_MessageSizeCheckedBeforeAcceptance(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
const tinyLimit = 32 // Bytes
|
||||
addr, stop := startTestServer(t, sink, tinyLimit)
|
||||
defer stop()
|
||||
c := dial(t, addr)
|
||||
defer c.close()
|
||||
|
||||
c.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.send(t, "MAIL FROM:<a@example.com>")
|
||||
c.send(t, "RCPT TO:<b@example.com>")
|
||||
if resp := c.send(t, "DATA"); code(resp) != "354" {
|
||||
t.Fatalf("erwartete 354, habe: %s", resp)
|
||||
}
|
||||
|
||||
// Die überlange Zeile überschreitet das Limit bereits selbst — der
|
||||
// Server antwortet SOFORT mit 552, OHNE auf die Abschlusszeile "."
|
||||
// zu warten (drainUntilDot liest sie erst danach weg, damit das
|
||||
// Protokoll synchron bleibt). Deshalb hier NICHT auf eine Antwort
|
||||
// zur ersten Zeile warten, sondern erst die Abschlusszeile senden
|
||||
// und dann einmal lesen.
|
||||
longBody := strings.Repeat("x", 200)
|
||||
if _, err := c.conn.Write([]byte(longBody + "\r\n")); err != nil {
|
||||
t.Fatalf("kommando senden: %v", err)
|
||||
}
|
||||
resp := c.send(t, ".")
|
||||
if code(resp) != "552" {
|
||||
t.Fatalf("erwartete 552 (nachricht zu groß), habe: %s", resp)
|
||||
}
|
||||
|
||||
if sink.count() != 0 {
|
||||
t.Fatalf("sink hätte die zu große nachricht nicht bekommen dürfen, habe %d", sink.count())
|
||||
}
|
||||
|
||||
// Verbindung muss danach weiter benutzbar sein (kein Absturz/Hänger).
|
||||
if resp := c.send(t, "NOOP"); code(resp) != "250" {
|
||||
t.Fatalf("session nach größenfehler nicht mehr funktionsfähig: %s", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRcptTo_InvalidRecipientCleanError ist die geforderte
|
||||
// Akzeptanzkriterium-3-Prüfung: ungültige Empfängerdaten führen zu
|
||||
// sauberer SMTP-Fehlermeldung statt Absturz.
|
||||
func TestRcptTo_InvalidRecipientCleanError(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
|
||||
defer stop()
|
||||
c := dial(t, addr)
|
||||
defer c.close()
|
||||
|
||||
c.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.send(t, "MAIL FROM:<a@example.com>")
|
||||
|
||||
if resp := c.send(t, "RCPT TO:<keine-gueltige-adresse>"); code(resp) != "553" {
|
||||
t.Fatalf("erwartete 553 für ungültigen empfänger, habe: %s", resp)
|
||||
}
|
||||
// Verbindung bleibt nutzbar — kein Absturz, kein Verbindungsabbruch.
|
||||
if resp := c.send(t, "RCPT TO:<b@example.com>"); code(resp) != "250" {
|
||||
t.Fatalf("erwartete 250 für gültigen empfänger nach vorherigem fehler, habe: %s", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailFrom_InvalidSenderCleanError deckt Akzeptanzkriterium 3 auch
|
||||
// für den Absender ab.
|
||||
func TestMailFrom_InvalidSenderCleanError(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
|
||||
defer stop()
|
||||
c := dial(t, addr)
|
||||
defer c.close()
|
||||
|
||||
c.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if resp := c.send(t, "MAIL FROM:<keine-gueltige-adresse>"); code(resp) != "553" {
|
||||
t.Fatalf("erwartete 553 für ungültigen absender, habe: %s", resp)
|
||||
}
|
||||
if resp := c.send(t, "NOOP"); code(resp) != "250" {
|
||||
t.Fatalf("session nach ungültigem absender nicht mehr funktionsfähig: %s", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_ConcurrentConnectionsNoLeak ist die geforderte
|
||||
// Pflichtprüfung 3: Lasttest mit gleichzeitigen Verbindungen ohne
|
||||
// Verbindungsleck.
|
||||
func TestServer_ConcurrentConnectionsNoLeak(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
|
||||
defer stop()
|
||||
|
||||
runtime.GC()
|
||||
baseline := runtime.NumGoroutine()
|
||||
|
||||
const concurrency = 50
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||
if err != nil {
|
||||
t.Errorf("dial: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
c := &smtpClient{conn: conn, reader: bufio.NewReader(conn)}
|
||||
c.readLine(t)
|
||||
c.send(t, "EHLO client.example.com")
|
||||
for {
|
||||
line := c.readLine(t)
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.send(t, "MAIL FROM:<a@example.com>")
|
||||
c.send(t, "RCPT TO:<b@example.com>")
|
||||
c.send(t, "DATA")
|
||||
c.send(t, "Subject: last\r\n\r\nHallo\r\n.")
|
||||
c.send(t, "QUIT")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if sink.count() != concurrency {
|
||||
t.Fatalf("erwartete %d angenommene nachrichten, habe %d", concurrency, sink.count())
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
runtime.GC()
|
||||
current := runtime.NumGoroutine()
|
||||
if current <= baseline+2 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("verbindungs-/goroutine-leck nach lasttest: baseline=%d, aktuell=%d", baseline, current)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package smtp
|
||||
|
||||
// State ist einer der vier SMTP-Sitzungszustände dieser Implementierung
|
||||
// (RFC 5321 §3.3), Akzeptanzkriterium 1: Envelope wird schrittweise vor
|
||||
// der DATA-Annahme aufgebaut und geprüft.
|
||||
type State int
|
||||
|
||||
const (
|
||||
Greeting State = iota // vor HELO/EHLO
|
||||
Ready // nach HELO/EHLO, bereit für MAIL FROM
|
||||
MailFromSet // nach gültigem MAIL FROM, wartet auf RCPT TO
|
||||
RcptToSet // mind. ein gültiges RCPT TO, DATA erlaubt
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case Greeting:
|
||||
return "GREETING"
|
||||
case Ready:
|
||||
return "READY"
|
||||
case MailFromSet:
|
||||
return "MAIL FROM SET"
|
||||
case RcptToSet:
|
||||
return "RCPT TO SET"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user