Files
MindSift/internal/api/mail_test.go
T
sandrodachandClaude Opus 5.5 ce321ce579 fix(smtp): proper SMTP dialogue, encoded mails, optional skip-verify
- Explicit SMTP flow (dial -> EHLO -> STARTTLS -> AUTH -> MAIL/RCPT/DATA)
  with errors naming the refusing step (also logged as smtp_step)
- EHLO with the sender's domain instead of "localhost"
- Envelope gets the bare address; "Name <addr>" stays in the From header
  (was rejected as invalid MAIL FROM)
- Date and Message-ID headers, RFC 2047 subject, quoted-printable body
- SMTP_SKIP_VERIFY / admin switch for self-signed certificates;
  SMTP checkboxes are switches now
- Tests against a fake SMTP server (STARTTLS, 451, display names)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:28:26 +01:00

216 lines
6.0 KiB
Go

package api
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"math/big"
"net"
"strings"
"sync"
"testing"
"time"
)
// fakeSMTP is a minimal ESMTP server with STARTTLS (self-signed) and AUTH PLAIN.
type fakeSMTP struct {
addr string
rcptCode string // e.g. "451 4.3.2 Internal server error" to refuse RCPT TO
mu sync.Mutex
ehlo string
mailFrom string
rcptTo string
data string
}
func selfSignedCert(t *testing.T) tls.Certificate {
t.Helper()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "mail.intern"},
DNSNames: []string{"mail.intern"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("cert: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
func startFakeSMTP(t *testing.T, rcptCode string) *fakeSMTP {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode}
tlsCfg := &tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go f.serve(conn, tlsCfg)
}
}()
return f
}
func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
defer conn.Close()
r, w := bufio.NewReader(conn), bufio.NewWriter(conn)
reply := func(s string) { _, _ = w.WriteString(s + "\r\n"); _ = w.Flush() }
reply("220 fake ESMTP")
secure := false
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.TrimRight(line, "\r\n")
upper := strings.ToUpper(cmd)
switch {
case strings.HasPrefix(upper, "EHLO "):
f.mu.Lock()
f.ehlo = cmd[5:]
f.mu.Unlock()
if secure {
reply("250-fake\r\n250 AUTH PLAIN")
} else {
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN")
}
case upper == "STARTTLS":
reply("220 go ahead")
tlsConn := tls.Server(conn, tlsCfg)
if err := tlsConn.Handshake(); err != nil {
return
}
conn, secure = tlsConn, true
r, w = bufio.NewReader(conn), bufio.NewWriter(conn)
case strings.HasPrefix(upper, "AUTH "):
reply("235 ok")
case strings.HasPrefix(upper, "MAIL FROM"):
f.mu.Lock()
f.mailFrom = strings.TrimSpace(cmd[len("MAIL FROM:"):])
f.mu.Unlock()
reply("250 ok")
case strings.HasPrefix(upper, "RCPT TO"):
f.mu.Lock()
f.rcptTo = strings.TrimSpace(cmd[len("RCPT TO:"):])
f.mu.Unlock()
if f.rcptCode != "" {
reply(f.rcptCode)
} else {
reply("250 ok")
}
case upper == "DATA":
reply("354 go")
var sb strings.Builder
for {
l, err := r.ReadString('\n')
if err != nil {
return
}
if l == ".\r\n" {
break
}
sb.WriteString(l)
}
f.mu.Lock()
f.data = sb.String()
f.mu.Unlock()
reply("250 queued")
case upper == "QUIT":
reply("221 bye")
return
default:
reply("250 ok")
}
}
}
func (f *fakeSMTP) settings(skipVerify bool) smtpSettings {
host, port, _ := net.SplitHostPort(f.addr)
return smtpSettings{Host: host, Port: port, User: "alarm@firma.de", Pass: "geheim", From: "alarm@firma.de", TLS: true, SkipVerify: skipVerify}
}
func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) {
f := startFakeSMTP(t, "")
// The certificate is self-signed: refused unless verification is switched off
err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo")
var stepErr *smtpStepError
if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" {
t.Fatalf("expected a STARTTLS error with verification on, got %v", err)
}
if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut"); err != nil {
t.Fatalf("send with SkipVerify failed: %v", err)
}
f.mu.Lock()
ehlo, data := f.ehlo, f.data
f.mu.Unlock()
if ehlo != "firma.de" {
t.Errorf("EHLO should name the sender domain, got %q", ehlo)
}
for _, header := range []string{"Date: ", "Message-ID: <", "Content-Transfer-Encoding: quoted-printable"} {
if !strings.Contains(data, header) {
t.Errorf("message lacks %q:\n%s", header, data)
}
}
if !strings.Contains(data, "Subject: =?utf-8?q?") {
t.Errorf("subject with emoji/umlauts must be RFC 2047 encoded:\n%s", data)
}
if strings.Contains(data, "fällig") {
t.Errorf("raw non-ASCII must not appear in the message:\n%s", data)
}
}
func TestSendMailReportsTheRefusingStep(t *testing.T) {
f := startFakeSMTP(t, "451 4.3.2 Internal server error")
err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo")
var stepErr *smtpStepError
if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") {
t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err)
}
}
// The admin UI suggests "MindSift <alerts@domain>" as sender; the envelope must carry the bare address
func TestSendMailWithDisplayNameSender(t *testing.T) {
f := startFakeSMTP(t, "")
cfg := f.settings(true)
cfg.From = "MindSift Alarme <alarm@firma.de>"
if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo"); err != nil {
t.Fatalf("send failed: %v", err)
}
f.mu.Lock()
defer f.mu.Unlock()
if f.mailFrom != "<alarm@firma.de>" || f.rcptTo != "<anna@example.com>" {
t.Errorf("envelope must hold bare addresses, got MAIL FROM %q, RCPT TO %q", f.mailFrom, f.rcptTo)
}
if !strings.Contains(f.data, `From: "MindSift Alarme" <alarm@firma.de>`) {
t.Errorf("header From should keep the display name:\n%s", f.data)
}
}
func TestEHLONameNeverLocalhost(t *testing.T) {
if got := ehloName("Alarm <alarm@mail.example.org>"); got != "mail.example.org" {
t.Errorf("got %q", got)
}
if got := ehloName("noreply"); got == "localhost" || got == "" {
t.Errorf("fallback must not be localhost, got %q", got)
}
}