diff --git a/mail/docs/ING-03-PRUEFPROTOKOLL.md b/mail/docs/ING-03-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..2b1de41 --- /dev/null +++ b/mail/docs/ING-03-PRUEFPROTOKOLL.md @@ -0,0 +1,116 @@ +# ING-03 — SMTP-Server & Mailer: Prüfprotokoll + +Datum: 2026-09-01 +Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh +Pakete: `mail/internal/smtp` (SMTP-Server, neu), `mail/internal/mailer` (Mailer-Komponente, neu) + +## Umsetzung + +**`mail/internal/smtp`** — SMTP-Server (RFC 5321) für eingehende Mails, +von Grund auf implementiert, architektonisch analog zu +`mail/internal/imap`/`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-Aufbau ist strikt schrittweise: MAIL FROM ohne HELO, +RCPT TO ohne MAIL FROM und DATA ohne mindestens ein gültiges RCPT TO +werden jeweils mit `503` zurückgewiesen. Absender-/Empfängeradressen +werden vor Annahme validiert (`503`/`553` bei ungültiger Syntax bzw. +Steuerzeichen). Die Nachrichtengröße wird während des DATA-Empfangs +laufend geprüft; eine Überschreitung führt zu `552` und verworfener +Nachricht, ohne den Sink zu erreichen. Dot-(Byte-)Stuffing wird beim +Empfang korrekt rückgängig gemacht (RFC 5321 §4.5.2). + +**`mail/internal/mailer`** — Mailer-Komponente für ausgehende +Nachrichten. `headerWriter` (`header.go`) ist die EINZIGE Stelle, an der +Header geschrieben werden: jeder Feldwert wird vor dem Schreiben hart +gegen CR/LF/Steuerzeichen geprüft, `Message.Build()` nutzt +ausschließlich diese API — keine freie Stringkonkatenation von +From/To/Subject (behebt den bekannten archivmail-Fehler #1, +Header-Injection durch ungeprüfte Konkatenation). `Sender.Send` +überträgt die gebaute Nachricht per echtem `net/smtp`-Client +(Standardbibliothek, reale TCP-Verbindung) über HELO/MAIL FROM/RCPT +TO/DATA. Keine Zugangsdaten im Code — die Zieladresse wird als +Parameter/Umgebungsvariable vom Aufrufer bereitgestellt. + +## Pflichtprüfung 1: Steuerzeichen/CRLF in Betreff und Anzeigenamen — kein Header-Bruch möglich + +`TestHeaderWriter_RejectsControlCharsAndCRLFInSubjectAndDisplayName` +(`mailer/mailer_test.go`), vier Fälle: CRLF im Betreff (versuchte +Bcc-Injection), CRLF im Anzeigenamen des Absenders, nackter LF ohne CR, +Steuerzeichen NUL im Betreff — `Message.Build()` liefert in allen vier +Fällen einen Fehler, KEINE gebaute Nachricht. Ergänzend +`TestHeaderWriter_AcceptsCleanValues`: normale Werte (inkl. Umlaute) +werden nicht fälschlich abgelehnt. + +Ergebnis: **BESTANDEN**. + +## Pflichtprüfung 2: automatisierter Test sendet Testmail über Mailpit/MailHog, prüft Header-Integrität + +**Abweichung von der wörtlichen Ticketvorgabe, dokumentiert:** Mailpit +und MailHog sind auf diesem Rechner NICHT installiert — Projektregel +verbietet das Nachinstallieren zusätzlicher Toolchains/Dienste +(kein Docker verfügbar, keine Systempaketinstallation). Als echter +Ersatz — kein Mock, kein fabriziertes Transkript, dieselbe Konvention +wie die manuellen Client-Tests aus ING-01/ING-02 — läuft +`TestSender_SendRealMessageOverSMTP_HeaderIntegrity` +(`mailer/mailer_test.go`) gegen den in dieser Kachel gebauten, echten +`mail/internal/smtp`-Server: realer TCP-Listener, echter +`net/smtp`-Standardbibliotheks-Client, reale HELO/MAIL FROM/RCPT +TO/DATA-Sequenz über das Netzwerk. Geprüft wird: + +- Envelope (`From`/`To`) kommt beim Server unverändert an. +- From-, To-, Subject- und ein zusätzlicher Header (`X-NEXARCH-Test`) + kommen byte-identisch als eigene Headerzeilen an. +- Genau eine Leerzeile trennt Header von Body (`\r\n\r\n`), Body-Text + vollständig und unverändert. + +Ergebnis: **BESTANDEN** — Header-Integrität über einen echten +Ende-zu-Ende-SMTP-Dialog bestätigt. + +## Pflichtprüfung 3: Lasttest mit gleichzeitigen Verbindungen ohne Verbindungsleck + +`TestServer_ConcurrentConnectionsNoLeak` (`smtp/smtp_test.go`): 50 +parallele reale TCP-Verbindungen, jede vollständige +EHLO/MAIL/RCPT/DATA/QUIT-Sequenz. Alle 50 Nachrichten kommen beim Sink +an. `runtime.NumGoroutine()` vor und nach dem Lasttest verglichen (mit +Toleranz für Laufzeit-Jitter und Aufräumzeit). + +Ergebnis: **BESTANDEN** — Goroutinezahl kehrt auf den Ausgangswert +zurück, kein Verbindungs-/Ressourcenleck. + +## Akzeptanzkriterien + +1. **SMTP-Annahme validiert Envelope und Nachrichtengröße vor der + Annahme**: `TestSession_EnvelopeMustBeBuiltBeforeData` (schrittweise + Envelope-Prüfung, `503` bei übersprungenen Schritten) und + `TestData_MessageSizeCheckedBeforeAcceptance` (Überschreitung der + konfigurierten Höchstgröße führt zu `552`, Sink bekommt die + Nachricht NICHT, Session danach weiter funktionsfähig). +2. **Mailer erzeugt Header ausschließlich über strukturierte + Writer-API, keine freie Stringkonkatenation**: `header.go` + (`headerWriter.WriteField`) ist der einzige Ort, an dem + `Message.Build()` Header schreibt; durch Pflichtprüfung 1 belegt. +3. **Ungültige Empfängerdaten führen zu sauberer SMTP-Fehlermeldung + statt Absturz**: `TestRcptTo_InvalidRecipientCleanError` und + `TestMailFrom_InvalidSenderCleanError` — `553` bei ungültiger + Adresse, Verbindung bleibt danach nutzbar. + +## Build/Vet/Lint/Test — Gesamtmodul + +``` +go build ./... → OK +go vet ./... → OK +golangci-lint run ./... → 0 issues +go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL gesetzt) → alle Pakete ok, inkl. neuen internal/smtp und internal/mailer +``` + +Keine Regression in den bestehenden ~26 Paketen. + +## Ergebnis + +ING-03 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten +Nachweisen. Pflichtprüfung 2 wurde mangels installierbarem +Mailpit/MailHog gegen den eigenen, in dieser Kachel gebauten +SMTP-Server durchgeführt (funktional gleichwertig: echter SMTP-Dialog, +kein Mock) — siehe Abschnitt oben. Freigeschaltet: ING-06, ING-08, +ING-09, ING-10, QA-04, QA-07. diff --git a/mail/internal/mailer/header.go b/mail/internal/mailer/header.go new file mode 100644 index 0000000..66a6181 --- /dev/null +++ b/mail/internal/mailer/header.go @@ -0,0 +1,46 @@ +package mailer + +import "fmt" + +// headerWriter schreibt E-Mail-Header ausschließlich über diese +// strukturierte API (Akzeptanzkriterium 2) — nie über freie +// Stringkonkatenation von Feldname und -wert. Jeder Feldwert wird vor +// dem Schreiben hart gegen CRLF/Steuerzeichen geprüft: bekannter Fehler +// aus archivmail (known-issues-archivmail.md #1) — From/To/Subject +// wurden dort per Konkatenation ohne Prüfung zusammengebaut, was +// Header-Injection über eingeschleuste Zeilenumbrüche erlaubte. +type headerWriter struct { + buf []byte +} + +// WriteField validiert value und hängt bei Erfolg "name: value\r\n" an. +// Ein Fehler lässt buf unverändert. +func (h *headerWriter) WriteField(name, value string) error { + if err := validateHeaderValue(value); err != nil { + return fmt.Errorf("mailer: feld %q: %w", name, err) + } + h.buf = append(h.buf, name...) + h.buf = append(h.buf, ':', ' ') + h.buf = append(h.buf, value...) + h.buf = append(h.buf, '\r', '\n') + return nil +} + +func (h *headerWriter) Bytes() []byte { return h.buf } + +// validateHeaderValue lehnt Steuerzeichen ab, insbesondere CR/LF, mit +// denen sich sonst zusätzliche Header oder ein vorzeitiges Body-Ende +// einschleusen ließen (Header-Injection). +func validateHeaderValue(value string) error { + for _, r := range value { + switch { + case r == '\r' || r == '\n': + return fmt.Errorf("enthält zeilenumbruch (header-injection verhindert)") + case r == '\t': + // Tabs sind in gefalteten Headerwerten zulässig. + case r < 0x20: + return fmt.Errorf("enthält steuerzeichen 0x%02x", r) + } + } + return nil +} diff --git a/mail/internal/mailer/mailer.go b/mail/internal/mailer/mailer.go new file mode 100644 index 0000000..d03e189 --- /dev/null +++ b/mail/internal/mailer/mailer.go @@ -0,0 +1,126 @@ +// Package mailer implementiert ING-03s Mailer-Komponente für ausgehende +// Benachrichtigungen/Berichte: Nachrichtenaufbau ausschließlich über +// eine strukturierte Header-Writer-API (header.go, Akzeptanzkriterium +// 2) sowie Versand per echtem SMTP-Dialog. +package mailer + +import ( + "bytes" + "context" + "fmt" + "net" + "net/smtp" + "strings" + "time" +) + +// Message ist eine ausgehende Nachricht. +type Message struct { + From string + To []string + Subject string + Body string + // ExtraHeaders sind zusätzliche Headerfelder (Name -> Wert), z. B. + // "Reply-To". Werden nach den festen Feldern in Map-Iterationsreihenfolge + // geschrieben (Reihenfolge zwischen ihnen ist nicht garantiert). + ExtraHeaders map[string]string +} + +// Build erzeugt die vollständige RFC-5322-Nachricht (Header + Leerzeile +// + Body) ausschließlich über headerWriter (Akzeptanzkriterium 2: keine +// freie Stringkonkatenation von Header-Feldern). +func (m Message) Build() ([]byte, error) { + hw := &headerWriter{} + if err := hw.WriteField("From", m.From); err != nil { + return nil, err + } + if err := hw.WriteField("To", strings.Join(m.To, ", ")); err != nil { + return nil, err + } + if err := hw.WriteField("Subject", m.Subject); err != nil { + return nil, err + } + for name, value := range m.ExtraHeaders { + if err := hw.WriteField(name, value); err != nil { + return nil, err + } + } + + var buf bytes.Buffer + buf.Write(hw.Bytes()) + buf.WriteString("\r\n") + buf.WriteString(m.Body) + return buf.Bytes(), nil +} + +// Sender versendet fertig gebaute Nachrichten per echtem SMTP-Dialog +// (HELO/MAIL FROM/RCPT TO/DATA). +type Sender struct { + // Addr ist die SMTP-Serveradresse (host:port). Ausschließlich über + // Umgebungsvariable durch den Aufrufer bereitzustellen — keine + // Zugangsdaten/Verbindungszeichenfolgen im Code dieses Pakets. + Addr string + Timeout time.Duration +} + +func NewSender(addr string) *Sender { + return &Sender{Addr: addr, Timeout: 10 * time.Second} +} + +// Send baut die Nachricht (Akzeptanzkriterium 2) und überträgt sie per +// echtem SMTP-Client (stdlib net/smtp, reale TCP-Verbindung) an s.Addr. +// Ungültige Empfängerdaten werden vom SMTP-Server sauber zurückgewiesen +// (Akzeptanzkriterium 3) und hier als Fehler durchgereicht, kein Absturz. +func (s *Sender) Send(ctx context.Context, m Message) error { + if len(m.To) == 0 { + return fmt.Errorf("mailer: kein empfänger") + } + raw, err := m.Build() + if err != nil { + return fmt.Errorf("mailer: nachricht aufbauen: %w", err) + } + + dialer := net.Dialer{Timeout: s.Timeout} + conn, err := dialer.DialContext(ctx, "tcp", s.Addr) + if err != nil { + return fmt.Errorf("mailer: verbindung zu %s: %w", s.Addr, err) + } + defer func() { _ = conn.Close() }() + + client, err := smtp.NewClient(conn, hostOnly(s.Addr)) + if err != nil { + return fmt.Errorf("mailer: smtp-client: %w", err) + } + defer func() { _ = client.Close() }() + + if err := client.Hello("nexarch-mail"); err != nil { + return fmt.Errorf("mailer: HELO: %w", err) + } + if err := client.Mail(m.From); err != nil { + return fmt.Errorf("mailer: MAIL FROM: %w", err) + } + for _, rcpt := range m.To { + if err := client.Rcpt(rcpt); err != nil { + return fmt.Errorf("mailer: RCPT TO %s: %w", rcpt, err) + } + } + wc, err := client.Data() + if err != nil { + return fmt.Errorf("mailer: DATA: %w", err) + } + if _, err := wc.Write(raw); err != nil { + return fmt.Errorf("mailer: nachricht senden: %w", err) + } + if err := wc.Close(); err != nil { + return fmt.Errorf("mailer: nachricht abschließen: %w", err) + } + return client.Quit() +} + +func hostOnly(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + return host +} diff --git a/mail/internal/mailer/mailer_test.go b/mail/internal/mailer/mailer_test.go new file mode 100644 index 0000000..ffc13ea --- /dev/null +++ b/mail/internal/mailer/mailer_test.go @@ -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\" ", + 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) + } +} diff --git a/mail/internal/smtp/commands.go b/mail/internal/smtp/commands.go new file mode 100644 index 0000000..15509fe --- /dev/null +++ b/mail/internal/smtp/commands.go @@ -0,0 +1,127 @@ +package smtp + +import ( + "bytes" + "context" + "strings" +) + +func (s *Session) handleHelo(verb, arg string) bool { + if strings.TrimSpace(arg) == "" { + return s.reply(501, verb+" requires a domain/address") == nil + } + // HELO/EHLO setzt den Envelope zurück, falls bereits einer im + // Aufbau war (RFC 5321 §4.1.1.1). + s.from = "" + s.to = nil + s.state = Ready + + if verb == "EHLO" { + return s.replyMultiline(250, []string{"nexarch-mail greets " + arg, "8BITMIME"}) == nil + } + return s.reply(250, "nexarch-mail greets "+arg) == nil +} + +// handleMailFrom ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1): +// die Absenderadresse wird vor der Annahme validiert. +func (s *Session) handleMailFrom(arg string) bool { + if s.state == Greeting { + return s.reply(503, "send HELO/EHLO first") == nil + } + addr, err := parseMailAddressArg(arg, "FROM") + if err != nil { + return s.reply(501, "invalid MAIL FROM syntax") == nil + } + if err := validateAddress(addr); err != nil { + // Akzeptanzkriterium 3: ungültige Absenderdaten -> saubere + // SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch. + return s.reply(553, "invalid sender address") == nil + } + s.from = addr + s.to = nil + s.state = MailFromSet + return s.reply(250, "OK") == nil +} + +// handleRcptTo ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1): +// jede Empfängeradresse wird vor der Annahme validiert; mehrere RCPT TO +// sind erlaubt. +func (s *Session) handleRcptTo(arg string) bool { + if s.state != MailFromSet && s.state != RcptToSet { + return s.reply(503, "send MAIL FROM first") == nil + } + addr, err := parseMailAddressArg(arg, "TO") + if err != nil { + return s.reply(501, "invalid RCPT TO syntax") == nil + } + if err := validateAddress(addr); err != nil { + // Akzeptanzkriterium 3: ungültige Empfängerdaten -> saubere + // SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch. + return s.reply(553, "invalid recipient address") == nil + } + s.to = append(s.to, addr) + s.state = RcptToSet + return s.reply(250, "OK") == nil +} + +func (s *Session) handleRset() bool { + s.from = "" + s.to = nil + if s.state != Greeting { + s.state = Ready + } + return s.reply(250, "OK") == nil +} + +// handleData verlangt einen vollständig aufgebauten und validierten +// Envelope (Akzeptanzkriterium 1: Envelope UND Nachrichtengröße werden +// vor der Annahme geprüft) und liest die dot-gestuffte Nachricht bis zur +// Abschlusszeile ".". +func (s *Session) handleData(ctx context.Context) bool { + if s.state != RcptToSet { + return s.reply(503, "send MAIL FROM/RCPT TO first") == nil + } + if err := s.reply(354, "Start mail input; end with ."); err != nil { + return false + } + + var buf bytes.Buffer + for { + line, err := s.readLine() + if err != nil { + return false + } + if line == "." { + break + } + // Byte-Stuffing rückgängig machen (RFC 5321 §4.5.2): eine Zeile, + // die mit "." beginnt, verliert genau diesen ersten Punkt. + line = strings.TrimPrefix(line, ".") + buf.WriteString(line) + buf.WriteString("\r\n") + + if int64(buf.Len()) > s.maxMessageBytes { + // Akzeptanzkriterium 1: Nachrichtengröße wird VOR der + // endgültigen Annahme geprüft — sauberer Fehlercode statt + // unbegrenztem Pufferwachstum. + _ = s.drainUntilDot() + s.from = "" + s.to = nil + s.state = Ready + return s.reply(552, "message size exceeds fixed maximum message size") == nil + } + } + + envelope := Envelope{From: s.from, To: s.to} + raw := buf.Bytes() + s.from = "" + s.to = nil + s.state = Ready + + if s.sink != nil { + if err := s.sink.Accept(ctx, envelope, raw); err != nil { + return s.reply(451, "unable to accept message, try again later") == nil + } + } + return s.reply(250, "OK: message accepted") == nil +} diff --git a/mail/internal/smtp/interfaces.go b/mail/internal/smtp/interfaces.go new file mode 100644 index 0000000..aa6bd4e --- /dev/null +++ b/mail/internal/smtp/interfaces.go @@ -0,0 +1,23 @@ +// Package smtp implementiert ING-03s SMTP-Server (RFC 5321) für +// eingehende Mails: HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, QUIT. +// Bewusste Neuimplementierung nach NEXARCH-Techstack, gleiche Konvention +// wie mail/internal/imap und mail/internal/pop3 — eigene Session je +// Verbindung in eigener Goroutine, schmale Sink-Schnittstelle statt +// geteilter Typen über Paketgrenzen hinweg. +package smtp + +import "context" + +// Envelope ist der SMTP-Umschlag einer eingehenden Nachricht, wie er +// vor der DATA-Annahme validiert wurde (Akzeptanzkriterium 1). +type Envelope struct { + From string + To []string +} + +// MessageSink nimmt eine vollständig empfangene, dot-entstuffte +// Nachricht entgegen — Speicherung/Weiterverarbeitung ist Sache +// anderer Kacheln. +type MessageSink interface { + Accept(ctx context.Context, envelope Envelope, raw []byte) error +} diff --git a/mail/internal/smtp/parser.go b/mail/internal/smtp/parser.go new file mode 100644 index 0000000..4f054c6 --- /dev/null +++ b/mail/internal/smtp/parser.go @@ -0,0 +1,59 @@ +package smtp + +import ( + "fmt" + "strings" +) + +// parseCommand zerlegt eine Kommandozeile in Verb (großgeschrieben) und +// restliches Argument. +func parseCommand(line string) (verb, arg string) { + parts := strings.SplitN(strings.TrimSpace(line), " ", 2) + verb = strings.ToUpper(parts[0]) + if len(parts) == 2 { + arg = strings.TrimSpace(parts[1]) + } + return verb, arg +} + +// parseMailAddressArg extrahiert die Adresse aus "FROM:" bzw. +// "TO:" (RFC 5321 §4.1.1.2/4.1.1.3). SMTP-Parameter wie SIZE=... +// werden für diese kleinste Lösung ignoriert. +func parseMailAddressArg(arg, keyword string) (string, error) { + trimmed := strings.TrimSpace(arg) + upper := strings.ToUpper(trimmed) + prefix := keyword + ":" + if !strings.HasPrefix(upper, prefix) { + return "", fmt.Errorf("smtp: erwartete %q am anfang von %q", prefix, arg) + } + rest := strings.TrimSpace(trimmed[len(prefix):]) + if sp := strings.IndexByte(rest, ' '); sp >= 0 { + rest = rest[:sp] + } + rest = strings.TrimPrefix(rest, "<") + rest = strings.TrimSuffix(rest, ">") + if rest == "" { + return "", fmt.Errorf("smtp: leere adresse") + } + return rest, nil +} + +// validateAddress prüft eine E-Mail-Adresse defensiv gegen +// Steuerzeichen und offensichtlich falsche Form (Akzeptanzkriterium 3: +// ungültige Empfänger-/Absenderdaten führen zu sauberer Fehlermeldung +// statt Absturz). +func validateAddress(addr string) error { + for _, r := range addr { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("smtp: steuerzeichen in adresse") + } + } + at := strings.IndexByte(addr, '@') + if at <= 0 || at == len(addr)-1 { + return fmt.Errorf("smtp: ungültige adresse %q", addr) + } + if strings.IndexByte(addr[at+1:], '@') >= 0 { + return fmt.Errorf("smtp: ungültige adresse %q", addr) + } + return nil +} diff --git a/mail/internal/smtp/response.go b/mail/internal/smtp/response.go new file mode 100644 index 0000000..87d083d --- /dev/null +++ b/mail/internal/smtp/response.go @@ -0,0 +1,27 @@ +package smtp + +import "fmt" + +// reply schreibt eine einzeilige SMTP-Antwort "code text\r\n". +func (s *Session) reply(code int, text string) error { + if _, err := fmt.Fprintf(s.writer, "%d %s\r\n", code, text); err != nil { + return err + } + return s.writer.Flush() +} + +// replyMultiline schreibt eine mehrzeilige SMTP-Antwort (z. B. EHLO- +// Capability-Liste): alle Zeilen außer der letzten mit "-" statt " " +// nach dem Code (RFC 5321 §4.2.1). +func (s *Session) replyMultiline(code int, lines []string) error { + for i, line := range lines { + sep := "-" + if i == len(lines)-1 { + sep = " " + } + if _, err := fmt.Fprintf(s.writer, "%d%s%s\r\n", code, sep, line); err != nil { + return err + } + } + return s.writer.Flush() +} diff --git a/mail/internal/smtp/server.go b/mail/internal/smtp/server.go new file mode 100644 index 0000000..1a39557 --- /dev/null +++ b/mail/internal/smtp/server.go @@ -0,0 +1,56 @@ +package smtp + +import ( + "context" + "errors" + "fmt" + "net" +) + +// defaultMaxMessageBytes ist die Standard-Höchstgröße einer +// angenommenen Nachricht (Akzeptanzkriterium 1). +const defaultMaxMessageBytes = 25 * 1024 * 1024 // 25 MiB + +// Server nimmt SMTP-Verbindungen an und bedient jede in einer eigenen +// Goroutine — gleiches Muster wie mail/internal/imap.Server und +// mail/internal/pop3.Server. TLS/STARTTLS ist Sache von ING-06, +// Rate-Limiting Sache von ING-09, Protokoll-Logging Sache von ING-08 — +// keine dieser Kacheln. +type Server struct { + sink MessageSink + maxMessageBytes int64 +} + +func NewServer(sink MessageSink) *Server { + return NewServerWithMaxMessageBytes(sink, defaultMaxMessageBytes) +} + +// NewServerWithMaxMessageBytes erlaubt eine abweichende +// Nachrichten-Höchstgröße, z. B. für Tests. +func NewServerWithMaxMessageBytes(sink MessageSink, maxMessageBytes int64) *Server { + return &Server{sink: sink, maxMessageBytes: maxMessageBytes} +} + +// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird. +func (srv *Server) Serve(ctx context.Context, listener net.Listener) error { + go func() { + <-ctx.Done() + _ = listener.Close() + }() + + for { + conn, err := listener.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + continue + } + return fmt.Errorf("smtp: verbindung annehmen: %w", err) + } + session := newSession(conn, srv.sink, srv.maxMessageBytes) + go session.Serve(ctx) + } +} diff --git a/mail/internal/smtp/session.go b/mail/internal/smtp/session.go new file mode 100644 index 0000000..8028717 --- /dev/null +++ b/mail/internal/smtp/session.go @@ -0,0 +1,120 @@ +package smtp + +import ( + "bufio" + "context" + "errors" + "io" + "net" + "strings" +) + +// maxCommandLineBytes begrenzt eine einzelne Kommando-/DATA-Zeile +// (defensive Fehlerbehandlung bei nicht-konformen Gegenstellen statt +// optimistischem Parsing, gleiche Konvention wie mail/internal/imap und +// mail/internal/pop3). +const maxCommandLineBytes = 8192 + +// Session ist eine einzelne SMTP-Verbindung mit eigener +// Zustandsmaschine (Akzeptanzkriterium 1). +type Session struct { + conn net.Conn + reader *bufio.Reader + writer *bufio.Writer + sink MessageSink + + maxMessageBytes int64 + + state State + from string + to []string +} + +func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64) *Session { + return &Session{ + conn: conn, + reader: bufio.NewReaderSize(conn, maxCommandLineBytes), + writer: bufio.NewWriter(conn), + sink: sink, + maxMessageBytes: maxMessageBytes, + state: Greeting, + } +} + +// State liefert den aktuellen Sitzungszustand (für Tests). +func (s *Session) State() State { return s.state } + +// Serve führt die Sitzung bis QUIT oder Verbindungsende aus. +func (s *Session) Serve(ctx context.Context) { + defer func() { _ = s.conn.Close() }() + + if err := s.reply(220, "nexarch-mail SMTP server ready"); err != nil { + return + } + + for { + line, err := s.readLine() + if err != nil { + return + } + if line == "" { + continue + } + + verb, arg := parseCommand(line) + if !s.dispatch(ctx, verb, arg) { + return + } + } +} + +func (s *Session) readLine() (string, error) { + line, err := s.reader.ReadString('\n') + if err != nil { + if errors.Is(err, io.EOF) && line != "" { + return strings.TrimRight(line, "\r"), nil + } + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} + +// dispatch verarbeitet EIN geparstes Kommando. false bedeutet: Sitzung +// beenden (QUIT abgeschlossen oder nicht behebbarer Schreibfehler). +func (s *Session) dispatch(ctx context.Context, verb, arg string) bool { + switch verb { + case "HELO", "EHLO": + return s.handleHelo(verb, arg) + case "MAIL": + return s.handleMailFrom(arg) + case "RCPT": + return s.handleRcptTo(arg) + case "DATA": + return s.handleData(ctx) + case "RSET": + return s.handleRset() + case "NOOP": + return s.reply(250, "OK") == nil + case "QUIT": + _ = s.reply(221, "Bye") + return false + default: + return s.reply(500, "Command not recognized") == nil + } +} + +// drainUntilDot liest Zeilen, ohne sie zu puffern, bis zur +// DATA-Abschlusszeile "." — hält das Protokoll nach einer wegen +// Größenüberschreitung abgelehnten Nachricht synchron, ohne den +// verworfenen Rest unbegrenzt im Speicher zu halten. +func (s *Session) drainUntilDot() error { + for { + line, err := s.readLine() + if err != nil { + return err + } + if line == "." { + return nil + } + } +} diff --git a/mail/internal/smtp/smtp_test.go b/mail/internal/smtp/smtp_test.go new file mode 100644 index 0000000..f8761d3 --- /dev/null +++ b/mail/internal/smtp/smtp_test.go @@ -0,0 +1,327 @@ +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:"); 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:"); 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:"); code(resp) != "250" { + t.Fatalf("erwartete 250 für MAIL FROM, habe: %s", resp) + } + if resp := c.send(t, "RCPT TO:"); 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:") + c.send(t, "RCPT TO:") + 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:") + + if resp := c.send(t, "RCPT TO:"); 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:"); 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:"); 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:") + c.send(t, "RCPT TO:") + 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) + } +} diff --git a/mail/internal/smtp/state.go b/mail/internal/smtp/state.go new file mode 100644 index 0000000..62e9a26 --- /dev/null +++ b/mail/internal/smtp/state.go @@ -0,0 +1,28 @@ +package smtp + +// State ist einer der vier SMTP-Sitzungszustände dieser Implementierung +// (RFC 5321 §3.3), Akzeptanzkriterium 1: Envelope wird schrittweise vor +// der DATA-Annahme aufgebaut und geprüft. +type State int + +const ( + Greeting State = iota // vor HELO/EHLO + Ready // nach HELO/EHLO, bereit für MAIL FROM + MailFromSet // nach gültigem MAIL FROM, wartet auf RCPT TO + RcptToSet // mind. ein gültiges RCPT TO, DATA erlaubt +) + +func (s State) String() string { + switch s { + case Greeting: + return "GREETING" + case Ready: + return "READY" + case MailFromSet: + return "MAIL FROM SET" + case RcptToSet: + return "RCPT TO SET" + default: + return "unknown" + } +}