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