- 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>
216 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|