// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT //go:build linux || freebsd package email import ( "bufio" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "fmt" "math/big" "net" "net/smtp" "strconv" "strings" "testing" "time" "sourcedock.dev/petrbalvin/nuntius/internal/config" "sourcedock.dev/petrbalvin/nuntius/internal/contactform" ) // mustCompose wraps compose for tests: a composition failure is always a // bug, not a case worth branching on. The bare form carries no policy // keys, so the composition defaults apply. func mustCompose(t *testing.T, from, to string, req contactform.Request, formName, formType string) []byte { t.Helper() b, err := compose(from, to, req, &config.Form{Name: formName, Type: formType}) if err != nil { t.Fatalf("compose: %v", err) } return b } func TestComposeContactWithoutService(t *testing.T) { req := contactform.Request{ Name: "Alice", Email: "alice@example.com", Message: "I have a question about your services.", } b := mustCompose(t, "from@example.com", "to@example.com", req, "MyForm", "contact") s := string(b) if !strings.Contains(s, "Subject: [nuntius/MyForm] Contact form submission") { t.Errorf("expected subject with form name only, got body:\n%s", s) } if !strings.Contains(s, "From: from@example.com") { t.Error("expected From header") } if !strings.Contains(s, "To: to@example.com") { t.Error("expected To header") } if !strings.Contains(s, "Reply-To: alice@example.com") { t.Error("expected Reply-To header") } if !strings.Contains(s, "MIME-Version: 1.0") { t.Error("expected MIME-Version header") } if !strings.Contains(s, "Content-Type: text/plain; charset=UTF-8") { t.Error("expected text/plain part") } if !strings.Contains(s, "Content-Type: text/html; charset=UTF-8") { t.Error("expected text/html part") } if !strings.Contains(s, "Content-Type: multipart/alternative") { t.Error("expected multipart/alternative content type") } if !strings.Contains(s, "I have a question about your services.") { t.Error("expected message content in body") } if !strings.Contains(s, "Contact Form Submission:") { t.Error("expected contact form plain-text header") } } func TestComposeContactWithService(t *testing.T) { req := contactform.Request{ Name: "Bob", Email: "bob@example.com", Service: "architecture", Message: "I would like a consultation.", } b := mustCompose(t, "from@e.com", "to@e.com", req, "ContactForm", "contact") s := string(b) if !strings.Contains(s, "Subject: [nuntius/ContactForm][architecture] Contact form submission") { t.Errorf("expected subject with service tag, got body:\n%s", s) } if !strings.Contains(s, "Service interest: architecture") { t.Error("expected service interest in plain text") } if !strings.Contains(s, "Content-Type: text/plain") { t.Error("expected text/plain part") } if !strings.Contains(s, "Content-Type: text/html") { t.Error("expected text/html part") } if !strings.Contains(s, "bob@example.com") { t.Error("expected submitter email in body") } } func TestComposeFeedback(t *testing.T) { req := contactform.Request{ Name: "Carol", Email: "carol@example.com", Message: "Great platform, but can you add dark mode?", } b := mustCompose(t, "sender@h.com", "recv@h.com", req, "FeedbackForm", "feedback") s := string(b) if !strings.Contains(s, "Subject: [nuntius/FeedbackForm] New feedback") { t.Errorf("expected feedback subject, got body:\n%s", s) } if !strings.Contains(s, "New Feedback:") { t.Error("expected feedback plain-text header") } if !strings.Contains(s, "From: Carol ") { t.Error("expected From line in plain text") } if !strings.Contains(s, "Content-Type: text/plain") { t.Error("expected text/plain part") } if !strings.Contains(s, "Content-Type: text/html") { t.Error("expected text/html part") } } func TestComposeNewsletter(t *testing.T) { req := contactform.Request{ Email: "subscriber@example.com", } b := mustCompose(t, "news@h.com", "owner@h.com", req, "NewsletterSignup", "newsletter") s := string(b) if !strings.Contains(s, "Subject: [nuntius/NewsletterSignup] New newsletter subscriber") { t.Errorf("expected newsletter subject, got body:\n%s", s) } if !strings.Contains(s, "New Newsletter Subscriber:") { t.Error("expected newsletter plain-text header") } if !strings.Contains(s, "Email: subscriber@example.com") { t.Error("expected subscriber email in plain text") } if !strings.Contains(s, "Content-Type: text/plain") { t.Error("expected text/plain part") } if !strings.Contains(s, "Content-Type: text/html") { t.Error("expected text/html part") } } func TestComposeGeneric(t *testing.T) { req := contactform.Request{ Name: "Dave", Email: "dave@example.com", Message: "Generic inquiry.", } b := mustCompose(t, "g@h.com", "g@h.com", req, "GenericForm", "generic") s := string(b) if !strings.Contains(s, "Subject: [nuntius/GenericForm] New submission") { t.Errorf("expected generic subject, got body:\n%s", s) } if !strings.Contains(s, "New Submission:") { t.Error("expected generic plain-text header") } if !strings.Contains(s, "Content-Type: text/plain") { t.Error("expected text/plain part") } if !strings.Contains(s, "Content-Type: text/html") { t.Error("expected text/html part") } } func TestComposeEmptyFormNameAndEmail(t *testing.T) { req := contactform.Request{ Name: "", Email: "", Message: "", } b := mustCompose(t, "", "", req, "", "generic") s := string(b) if !strings.Contains(s, "Subject: [nuntius/] New submission") { t.Errorf("expected subject with empty form name, got body:\n%s", s) } if !strings.Contains(s, "MIME-Version: 1.0") { t.Error("expected MIME-Version header even with empty fields") } if !strings.Contains(s, "Content-Type: text/plain") { t.Error("expected text/plain part even with empty fields") } if !strings.Contains(s, "Content-Type: text/html") { t.Error("expected text/html part even with empty fields") } } func TestComposeLongMessage(t *testing.T) { longMsg := strings.Repeat("Lorem ipsum dolor sit amet. ", 200) req := contactform.Request{ Name: "Eve", Email: "eve@example.com", Message: longMsg, } b := mustCompose(t, "x@y.com", "z@y.com", req, "LongForm", "feedback") s := string(b) if !strings.Contains(s, longMsg) { t.Error("expected long message content in body") } // Verify it's in both text and html parts by checking after the respective // content-type boundaries. textIdx := strings.Index(s, "Content-Type: text/plain") htmlIdx := strings.Index(s, "Content-Type: text/html") if textIdx == -1 || htmlIdx == -1 { t.Fatal("expected both text/plain and text/html parts") } textPart := s[textIdx:htmlIdx] htmlPart := s[htmlIdx:] if !strings.Contains(textPart, longMsg) { t.Error("expected long message in text/plain part") } if !strings.Contains(htmlPart, longMsg) { t.Error("expected long message in text/html part") } } func TestComposeSpecialCharacters(t *testing.T) { specialMsg := "Café résumé, déjà vu\nLine\twith\ttabs\n€uro sign © 2026" req := contactform.Request{ Name: "Renée", Email: "renée@example.com", Message: specialMsg, } b := mustCompose(t, "ñ@c.com", "ö@c.com", req, "SpaForm", "contact") s := string(b) if !strings.Contains(s, "Café résumé, déjà vu") { t.Error("expected accented characters to survive round-trip") } if !strings.Contains(s, "€uro sign © 2026") { t.Error("expected special symbols to survive round-trip") } if !strings.Contains(s, "Renée") { t.Error("expected accented name in body") } if !strings.Contains(s, "MIME-Version: 1.0") { t.Error("expected MIME-Version header") } } func TestComposeUnknownFormTypeFallsBackToContact(t *testing.T) { // Unknown form type should default to the contact template. req := contactform.Request{ Name: "Fallback", Email: "fallback@example.com", Message: "Does this work?", } b := mustCompose(t, "a@b.com", "c@b.com", req, "UnknownForm", "nonexistent") s := string(b) if !strings.Contains(s, "Subject: [nuntius/UnknownForm] Contact form submission") { t.Errorf("expected contact fallback subject, got body:\n%s", s) } if !strings.Contains(s, "Content-Type: text/plain") { t.Error("expected text/plain part in fallback") } if !strings.Contains(s, "Content-Type: text/html") { t.Error("expected text/html part in fallback") } } func TestComposeAllHeadersPresent(t *testing.T) { req := contactform.Request{ Name: "Test", Email: "test@example.com", Message: "Checking headers.", } b := mustCompose(t, "from@x.com", "to@x.com", req, "HeaderForm", "contact") s := string(b) required := []string{ "From: from@x.com", "To: to@x.com", "Subject:", "Date:", "Reply-To: test@example.com", "MIME-Version: 1.0", "Content-Type: multipart/alternative", } for _, h := range required { if !strings.Contains(s, h) { t.Errorf("expected header %q in message", h) } } } func TestComposeMultipartBoundary(t *testing.T) { req := contactform.Request{ Name: "Boundary", Email: "boundary@example.com", Message: "Boundary test.", } b := mustCompose(t, "from@t.com", "to@t.com", req, "BoundForm", "contact") s := string(b) // The boundary is random per message; verify structure, not a fixed value. if !strings.Contains(s, "Content-Type: multipart/alternative; boundary=nuntius-") { t.Error("expected multipart/alternative with nuntius- prefixed boundary") } // Extract the boundary token and verify opening, middle and closing markers. idx := strings.Index(s, "boundary=nuntius-") if idx == -1 { t.Fatal("boundary token not found") } boundary := s[idx+len("boundary="):] if end := strings.IndexByte(boundary, '\r'); end >= 0 { boundary = boundary[:end] } if count := strings.Count(s, "--"+boundary); count < 3 { t.Errorf("expected at least 3 boundary markers for %q, got %d", boundary, count) } if !strings.Contains(s, "--"+boundary+"--") { t.Error("expected closing boundary marker") } } func TestComposeDeliveredByFooter(t *testing.T) { req := contactform.Request{ Name: "Footer", Email: "footer@example.com", Message: "Footer check.", } for _, ft := range []string{"contact", "feedback", "newsletter", "generic"} { b := mustCompose(t, "f@t.com", "t@t.com", req, "FooterForm", ft) s := string(b) if !strings.Contains(s, "Delivered by nuntius") { t.Errorf("form type %q: expected 'Delivered by nuntius' footer in plain text", ft) } } } // A configured subject prefix and brand replace the default nuntius // wording in both the plain-text and the HTML part. func TestComposePrefixAndBrand(t *testing.T) { req := contactform.Request{ Name: "Alice", Email: "alice@example.com", Message: "A custom branding check.", } form := &config.Form{ Name: "MyForm", Type: "feedback", SubjectPrefix: new("web"), EmailBrand: new("Acme Mail"), } b, err := compose("from@example.com", "to@example.com", req, form) if err != nil { t.Fatalf("compose: %v", err) } s := string(b) if !strings.Contains(s, "Subject: [web/MyForm] New feedback") { t.Errorf("configured prefix missing from subject:\n%s", s) } if !strings.Contains(s, "Delivered by Acme Mail") { t.Error("configured brand missing from the plain-text footer") } if !strings.Contains(s, "Delivered by Acme Mail") { t.Error("configured brand missing from the HTML footer") } // An empty prefix and brand drop the segments entirely. form.SubjectPrefix = new("") form.EmailBrand = new("") b, err = compose("from@example.com", "to@example.com", req, form) if err != nil { t.Fatalf("compose: %v", err) } s = string(b) if !strings.Contains(s, "Subject: New feedback\r\n") { t.Errorf("empty prefix must drop the bracket segment:\n%s", s) } if strings.Contains(s, "Delivered by") { t.Errorf("empty brand must drop the footer:\n%s", s) } } // A service value configured onto a non-contact form surfaces in the // subject tag, the plain-text body and the HTML body. func TestComposeServiceOnFeedback(t *testing.T) { req := contactform.Request{ Name: "Alice", Email: "alice@example.com", Service: "bug", Message: "A service-aware feedback.", } form := &config.Form{ Name: "MyForm", Type: "feedback", Services: []string{"bug", "idea"}, } b, err := compose("from@example.com", "to@example.com", req, form) if err != nil { t.Fatalf("compose: %v", err) } s := string(b) if !strings.Contains(s, "Subject: [nuntius/MyForm][bug] New feedback") { t.Errorf("service tag missing from subject:\n%s", s) } if !strings.Contains(s, "Service interest: bug") { t.Error("service line missing from the plain-text body") } if !strings.Contains(s, "Service Interest") { t.Error("service block missing from the HTML body") } } // --------------------------------------------------------------------------- // SMTP delivery tests // --------------------------------------------------------------------------- func selfSignedCert(t *testing.T) tls.Certificate { t.Helper() priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("generate key: %v", err) } tmpl := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, } der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) if err != nil { t.Fatalf("create certificate: %v", err) } return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv} } // startFakeSMTP runs a minimal SMTP server that supports STARTTLS and AUTH. // It returns the listen address. func startFakeSMTP(t *testing.T, cert tls.Certificate) string { 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() }) go func() { conn, err := ln.Accept() if err != nil { return } defer conn.Close() fmt.Fprintf(conn, "220 fake ESMTP\r\n") reader := bufio.NewReader(conn) for { line, err := reader.ReadString('\n') if err != nil { return } line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): fmt.Fprintf(conn, "250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN\r\n") case strings.HasPrefix(line, "STARTTLS"): fmt.Fprintf(conn, "220 Ready to start TLS\r\n") tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}}) if err := tlsConn.Handshake(); err != nil { return } conn = tlsConn reader = bufio.NewReader(conn) case strings.HasPrefix(line, "AUTH"): fmt.Fprintf(conn, "235 Authentication successful\r\n") case strings.HasPrefix(line, "MAIL FROM:"), strings.HasPrefix(line, "RCPT TO:"): fmt.Fprintf(conn, "250 OK\r\n") case strings.HasPrefix(line, "DATA"): fmt.Fprintf(conn, "354 End data with .\r\n") for { dataLine, err := reader.ReadString('\n') if err != nil { return } if strings.TrimSpace(dataLine) == "." { break } } fmt.Fprintf(conn, "250 OK\r\n") case strings.HasPrefix(line, "QUIT"): fmt.Fprintf(conn, "221 Bye\r\n") return default: fmt.Fprintf(conn, "250 OK\r\n") } } }() return ln.Addr().String() } // A validated email may still contain characters that would terminate a // header line; composition must neutralise them defensively in the header // block. Body content is free-form and delimited by the random boundary. func TestComposeSanitisesHeaderInjection(t *testing.T) { req := contactform.Request{ Name: "Eve", Email: "eve@example.com", Message: "Hello there, this is fine.", } b := mustCompose(t, "from@example.com", "to@example.com", req, "Form\r\nBcc: victim@example.com", "contact") s := string(b) headers := s[:strings.Index(s, "\r\n\r\n")] for line := range strings.SplitSeq(headers, "\r\n") { if strings.HasPrefix(line, "Bcc:") { t.Errorf("injected Bcc header survived composition:\n%s", s) } } if n := strings.Count(headers, "Subject:"); n != 1 { t.Errorf("expected exactly one Subject header in block %q, got %d", headers, n) } } func TestRandomBoundary(t *testing.T) { b1, err := randomBoundary() if err != nil { t.Fatalf("randomBoundary: %v", err) } b2, err := randomBoundary() if err != nil { t.Fatalf("randomBoundary: %v", err) } if !strings.HasPrefix(b1, "nuntius-") { t.Errorf("boundary %q missing nuntius- prefix", b1) } if b1 == b2 { t.Error("two consecutive boundaries should differ") } } func TestSendMailDialError(t *testing.T) { // Connect to a closed port to trigger a dial error quickly. err := sendMail(config.SMTPConfig{Host: "127.0.0.1", Port: 1}, nil, "a@b.c", []string{"d@e.f"}, []byte("x"), 100*time.Millisecond, nil) if err == nil { t.Fatal("expected dial error") } } func TestSendMailWithTLS(t *testing.T) { cert := selfSignedCert(t) addr := startFakeSMTP(t, cert) host, portStr, err := net.SplitHostPort(addr) if err != nil { t.Fatalf("split host: %v", err) } port, err := strconv.Atoi(portStr) if err != nil { t.Fatalf("parse port: %v", err) } auth := smtp.PlainAuth("", "user", "pass", host) msg := mustCompose(t, "from@example.com", "to@example.com", contactform.Request{ Name: "Test User", Email: "test@example.com", Message: "Hello, this is a test message.", }, "test", "contact") err = sendMail(config.SMTPConfig{Host: host, Port: port}, auth, "from@example.com", []string{"to@example.com"}, msg, 5*time.Second, &tls.Config{InsecureSkipVerify: true}) if err != nil { t.Fatalf("sendMail: %v", err) } } func TestFormSenderSend(t *testing.T) { cert := selfSignedCert(t) addr := startFakeSMTP(t, cert) host, portStr, err := net.SplitHostPort(addr) if err != nil { t.Fatalf("split host: %v", err) } port := 0 for _, c := range portStr { port = port*10 + int(c-'0') } form := &config.Form{ Name: "test", Type: "contact", From: "from@example.com", To: "to@example.com", SMTP: config.SMTPConfig{ Host: host, Port: port, User: "user", Password: "pass", }, } s := NewFormSender(form) s.TLSConfig = &tls.Config{InsecureSkipVerify: true} err = s.Send(contactform.Request{ Name: "Test User", Email: "test@example.com", Message: "Hello, this is a test message.", }) if err != nil { t.Fatalf("Send: %v", err) } }