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\" ", 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 ", 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) } }