// 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 }