package test import ( "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/useCases" "io" "mime" "mime/quotedprintable" "net/mail" "strings" "testing" "time" ) var smtpMailerClock = time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC) func smtpMailerFor(server *fakeSMTPServer, settings config.SmtpSettings) *mailer.MailRepository { if settings.Host == "" { settings.Host = "127.0.0.1" } settings.Port = server.port() if settings.Sender == "" { settings.Sender = "notify@example.com" } return mailer.NewMailRepository(settings, mailer.WithTLSConfig(server.clientTLS()), mailer.WithClock(func() time.Time { return smtpMailerClock })) } func verificationData() useCases.MailValidationData { return useCases.MailValidationData{Host: "https://cms.example.com", VerificationCode: "abc123"} } func parseDeliveredMail(t *testing.T, raw string) (*mail.Message, string) { t.Helper() message, err := mail.ReadMessage(strings.NewReader(raw)) if err != nil { t.Fatalf("delivered data is not a valid message: %v\n%s", err, raw) } decoded, err := io.ReadAll(quotedprintable.NewReader(message.Body)) if err != nil { t.Fatalf("body is not valid quoted-printable: %v", err) } return message, string(decoded) } func TestSMTPMailerDelivery(t *testing.T) { t.Run("Delivers a well-formed HTML message to a plain local server", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{}) err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData()) if err != nil { t.Fatalf("send failed: %v", err) } sessions := server.received() if len(sessions) != 1 || sessions[0].from != "notify@example.com" || sessions[0].to != "reader@example.org" { t.Fatalf("unexpected envelope %+v", sessions) } message, body := parseDeliveredMail(t, sessions[0].data) if got := message.Header.Get("From"); got != `"RenewCMS" ` { t.Errorf("unexpected From %q", got) } if got := message.Header.Get("To"); got != "" { t.Errorf("unexpected To %q", got) } subject, _ := new(mime.WordDecoder).DecodeHeader(message.Header.Get("Subject")) if subject != "Verify your RenewCMS account" { t.Errorf("unexpected Subject %q", subject) } if got := message.Header.Get("Content-Type"); got != "text/html; charset=UTF-8" { t.Errorf("unexpected Content-Type %q", got) } if got := message.Header.Get("MIME-Version"); got != "1.0" { t.Errorf("unexpected MIME-Version %q", got) } if !strings.Contains(body, "https://cms.example.com/register/validate?c=abc123") { t.Errorf("verification link missing from body %q", body) } }) t.Run("Stamps a Date from the clock and a unique Message-ID on the sender's domain", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{}) repo := smtpMailerFor(server, config.SmtpSettings{}) _ = repo.Send("a@example.org", "verification", verificationData()) _ = repo.Send("a@example.org", "verification", verificationData()) sessions := server.received() first, _ := parseDeliveredMail(t, sessions[0].data) second, _ := parseDeliveredMail(t, sessions[1].data) date, err := first.Header.Date() if err != nil || !date.Equal(smtpMailerClock) { t.Errorf("expected Date %v, got %v (%v)", smtpMailerClock, date, err) } id := first.Header.Get("Message-ID") if !strings.HasPrefix(id, "<") || !strings.HasSuffix(id, "@example.com>") { t.Errorf("unexpected Message-ID %q", id) } if id == second.Header.Get("Message-ID") { t.Error("Message-IDs must be unique") } }) t.Run("Encodes a non-ASCII subject and body without corrupting them", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{}) repo := smtpMailerFor(server, config.SmtpSettings{}) err := repo.Send("reader@example.org", "verification", verificationData()) if err != nil { t.Fatal(err) } raw := server.received()[0].data headerBlock, _, _ := strings.Cut(raw, "\r\n\r\n") for _, r := range headerBlock { if r > 127 { t.Fatalf("header contains a raw non-ASCII rune %q", r) } } }) t.Run("Authenticates with PLAIN over STARTTLS", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "PLAIN LOGIN"}) settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"} err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData()) if err != nil { t.Fatalf("send failed: %v", err) } session := server.received()[0] if !session.usedTLS || session.username != "user@example.com" || session.password != "s3cret" { t.Errorf("unexpected session %+v", session) } }) t.Run("Falls back to LOGIN when PLAIN is not offered", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "LOGIN"}) settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"} err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData()) if err != nil { t.Fatalf("send failed: %v", err) } session := server.received()[0] if session.username != "user@example.com" || session.password != "s3cret" { t.Errorf("unexpected credentials %+v", session) } }) t.Run("Skips authentication when no login is configured", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: "PLAIN"}) err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData()) if err != nil { t.Fatal(err) } for _, command := range server.received()[0].commands { if strings.HasPrefix(strings.ToUpper(command), "AUTH") { t.Errorf("unexpected %q", command) } } }) for _, mechanisms := range []string{"PLAIN", "LOGIN"} { t.Run("Refuses to send "+mechanisms+" credentials in clear text to a non-loopback server", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: mechanisms, listenAddress: "127.0.0.2:0"}) settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret", Host: "127.0.0.2"} err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData()) if err == nil { t.Fatal("expected an error") } if session := server.received()[0]; session.password != "" || session.data != "" { t.Errorf("credentials or data leaked: %+v", session) } }) } t.Run("Refuses to deliver in clear text to a non-loopback server that offers no STARTTLS", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{listenAddress: "127.0.0.2:0"}) err := smtpMailerFor(server, config.SmtpSettings{Host: "127.0.0.2"}).Send("reader@example.org", "verification", verificationData()) if err == nil || !strings.Contains(err.Error(), "SMTP_ALLOW_INSECURE") { t.Fatalf("expected an error naming SMTP_ALLOW_INSECURE, got %v", err) } if session := server.received()[0]; session.data != "" || session.from != "" { t.Errorf("nothing may be sent in clear text: %+v", session) } }) t.Run("Delivers in clear text to a non-loopback server when SMTP_ALLOW_INSECURE is set", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{listenAddress: "127.0.0.2:0"}) err := smtpMailerFor(server, config.SmtpSettings{Host: "127.0.0.2", AllowInsecure: true}).Send("reader@example.org", "verification", verificationData()) if err != nil { t.Fatal(err) } if session := server.received()[0]; session.data == "" || session.usedTLS { t.Errorf("expected a clear-text delivery, got %+v", session) } }) t.Run("Still refuses credentials in clear text when SMTP_ALLOW_INSECURE is set", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: "PLAIN", listenAddress: "127.0.0.2:0"}) settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret", Host: "127.0.0.2", AllowInsecure: true} err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData()) if err == nil { t.Fatal("expected an error") } if session := server.received()[0]; session.password != "" || session.data != "" { t.Errorf("credentials or data leaked: %+v", session) } }) t.Run("Fails when credentials are configured but the server offers no AUTH", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{}) settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"} err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData()) if err == nil { t.Fatal("expected an error") } if len(server.received()[0].data) != 0 { t.Error("no message may be delivered") } }) t.Run("Fails the STARTTLS handshake against an untrusted certificate", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true}) repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: server.port(), Sender: "notify@example.com"}) err := repo.Send("reader@example.org", "verification", verificationData()) if err == nil { t.Fatal("expected a certificate error") } if len(server.received()[0].data) != 0 { t.Error("no message may be delivered") } }) t.Run("Uses implicit TLS on port 465", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{implicitTLS: true, listenAddress: "127.0.0.1:465"}) repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: 465, Sender: "notify@example.com"}, mailer.WithTLSConfig(server.clientTLS())) err := repo.Send("reader@example.org", "verification", verificationData()) if err != nil { t.Fatalf("send failed: %v", err) } if !server.received()[0].usedTLS { t.Error("expected a TLS session") } }) t.Run("Surfaces a rejected recipient", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{rejectRcpt: true}) err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData()) if err == nil { t.Fatal("expected an error") } }) t.Run("Surfaces a rejected DATA command", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{rejectData: true}) err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData()) if err == nil { t.Fatal("expected an error") } }) t.Run("Surfaces an unreachable server", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{}) repo := smtpMailerFor(server, config.SmtpSettings{}) _ = server.listener.Close() if err := repo.Send("reader@example.org", "verification", verificationData()); err == nil { t.Fatal("expected an error") } }) } func TestSMTPMailerRejectsUnsafeInput(t *testing.T) { cases := map[string]struct { sender string receiver string template string }{ "receiver with an injected header": {"notify@example.com", "a@example.org\r\nBcc: evil@example.net", "verification"}, "receiver that is not an address": {"notify@example.com", "not-an-address", "verification"}, "empty receiver": {"notify@example.com", "", "verification"}, "sender that is not an address": {"nope", "a@example.org", "verification"}, "unknown template": {"notify@example.com", "a@example.org", "does-not-exist"}, } for name, tc := range cases { t.Run("Rejects a "+name+" before opening a connection", func(t *testing.T) { server := newFakeSMTPServer(t, fakeSMTPOptions{}) repo := smtpMailerFor(server, config.SmtpSettings{Sender: tc.sender}) err := repo.Send(tc.receiver, tc.template, verificationData()) if err == nil { t.Fatal("expected an error") } if len(server.received()) != 0 { t.Errorf("no connection may be made, got %d", len(server.received())) } }) } }