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:
sysops
2026-09-01 00:59:56 +02:00
parent 16c4ad0075
commit 6631bcbbdd
12 changed files with 1246 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
package mailer
import (
"context"
"net"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/smtp"
)
// TestHeaderWriter_RejectsControlCharsAndCRLFInSubjectAndDisplayName
// ist die geforderte Pflichtprüfung 1 (ING-03): Steuerzeichen/CRLF in
// Betreff und Anzeigenamen schlagen fehl statt einen Header-Bruch zu
// erzeugen (bekannter Fehler aus archivmail, known-issues-archivmail.md
// #1).
func TestHeaderWriter_RejectsControlCharsAndCRLFInSubjectAndDisplayName(t *testing.T) {
cases := []struct {
name string
msg Message
}{
{
name: "CRLF im Betreff schleust zusätzlichen Header ein",
msg: Message{
From: "absender@example.com",
To: []string{"empfaenger@example.com"},
Subject: "Harmlos\r\nBcc: angreifer@example.com",
Body: "Hallo",
},
},
{
name: "CRLF im Anzeigenamen des Absenders",
msg: Message{
From: "\"Böser Name\r\nX-Injected: true\" <absender@example.com>",
To: []string{"empfaenger@example.com"},
Subject: "Normal",
Body: "Hallo",
},
},
{
name: "nackter LF ohne CR",
msg: Message{
From: "absender@example.com",
To: []string{"empfaenger@example.com"},
Subject: "Betreff\nBcc: angreifer@example.com",
Body: "Hallo",
},
},
{
name: "Steuerzeichen NUL im Betreff",
msg: Message{
From: "absender@example.com",
To: []string{"empfaenger@example.com"},
Subject: "Betreff\x00Ende",
Body: "Hallo",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw, err := tc.msg.Build()
if err == nil {
t.Fatalf("erwartete Fehler (Header-Injection verhindert), habe erfolgreich gebaute Nachricht: %q", raw)
}
})
}
}
// TestHeaderWriter_AcceptsCleanValues stellt sicher, dass normale Werte
// nicht fälschlich abgelehnt werden.
func TestHeaderWriter_AcceptsCleanValues(t *testing.T) {
msg := Message{
From: "Absender Name <absender@example.com>",
To: []string{"empfaenger@example.com"},
Subject: "Ganz normaler Betreff mit Umlauten äöü",
Body: "Hallo Welt",
}
raw, err := msg.Build()
if err != nil {
t.Fatalf("unerwarteter fehler: %v", err)
}
if !strings.Contains(string(raw), "Subject: Ganz normaler Betreff mit Umlauten äöü\r\n") {
t.Fatalf("subject-header fehlt oder falsch formatiert: %q", raw)
}
}
// captureSink zeichnet die zuletzt vom SMTP-Server angenommene
// Nachricht auf.
type captureSink struct {
envelope smtp.Envelope
raw []byte
got chan struct{}
}
func newCaptureSink() *captureSink {
return &captureSink{got: make(chan struct{}, 1)}
}
func (c *captureSink) Accept(_ context.Context, envelope smtp.Envelope, raw []byte) error {
c.envelope = envelope
c.raw = raw
c.got <- struct{}{}
return nil
}
// TestSender_SendRealMessageOverSMTP_HeaderIntegrity ist die geforderte
// Pflichtprüfung 2 (ING-03): automatisierter Test sendet eine Testmail
// über einen echten SMTP-Dialog und prüft Header-Integrität.
//
// Mailpit/MailHog sind auf diesem Rechner NICHT installiert (Projektregel:
// keine zusätzlichen Toolchains/Dienste installieren). Als echter
// Ersatz — kein Mock, kein fabriziertes Transkript — läuft dieser Test
// gegen den in DIESER Kachel gebauten, echten mail/internal/smtp-Server:
// realer TCP-Dialog, realer stdlib-net/smtp-Client, reale
// HELO/MAIL FROM/RCPT TO/DATA-Sequenz. Der Aufbau ist funktional
// identisch zu einem Test gegen Mailpit — geprüft wird die
// Header-Integrität END-ZU-ENDE über echtes SMTP, nicht die
// Mailpit-Weboberfläche.
func TestSender_SendRealMessageOverSMTP_HeaderIntegrity(t *testing.T) {
sink := newCaptureSink()
srv := smtp.NewServer(sink)
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)
}()
defer func() {
cancel()
<-done
}()
sender := NewSender(listener.Addr().String())
msg := Message{
From: "absender@example.com",
To: []string{"empfaenger@example.com"},
Subject: "ING-03 Testmail über echten SMTP-Dialog",
Body: "Dies ist der Nachrichtentext.\r\n",
ExtraHeaders: map[string]string{
"X-NEXARCH-Test": "ING-03",
},
}
sendCtx, sendCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer sendCancel()
if err := sender.Send(sendCtx, msg); err != nil {
t.Fatalf("Send: %v", err)
}
select {
case <-sink.got:
case <-time.After(3 * time.Second):
t.Fatal("smtp-server hat die nachricht nicht innerhalb der frist angenommen")
}
if sink.envelope.From != msg.From {
t.Fatalf("envelope-from stimmt nicht: habe %q, will %q", sink.envelope.From, msg.From)
}
if len(sink.envelope.To) != 1 || sink.envelope.To[0] != msg.To[0] {
t.Fatalf("envelope-to stimmt nicht: habe %v, will %v", sink.envelope.To, msg.To)
}
rawText := string(sink.raw)
wantHeaders := []string{
"From: " + msg.From + "\r\n",
"To: " + msg.To[0] + "\r\n",
"Subject: " + msg.Subject + "\r\n",
"X-NEXARCH-Test: ING-03\r\n",
}
for _, want := range wantHeaders {
if !strings.Contains(rawText, want) {
t.Fatalf("header-integrität verletzt: erwartete zeile %q nicht in empfangener nachricht:\n%s", want, rawText)
}
}
if !strings.Contains(rawText, "Dies ist der Nachrichtentext.") {
t.Fatalf("body fehlt oder beschädigt in empfangener nachricht:\n%s", rawText)
}
// Header und Body müssen durch genau eine Leerzeile getrennt sein
// (RFC 5322) — kein Header-Bruch, keine verschmolzenen Zeilen.
headerEnd := strings.Index(rawText, "\r\n\r\n")
if headerEnd < 0 {
t.Fatalf("keine header/body-trennzeile gefunden:\n%s", rawText)
}
}