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.
328 lines
8.9 KiB
Go
328 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|