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.
192 lines
5.6 KiB
Go
192 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|