624 lines
18 KiB
Go
624 lines
18 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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 <carol@example.com>") {
|
||
|
|
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 <strong style=\"color:#6b7280\">Acme Mail</strong>") {
|
||
|
|
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 <CR><LF>.<CR><LF>\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)
|
||
|
|
}
|
||
|
|
}
|