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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user