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.
127 lines
3.6 KiB
Go
127 lines
3.6 KiB
Go
// 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
|
|
}
|