304 lines
11 KiB
Go
304 lines
11 KiB
Go
package mail
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestMessageShape pins the one mail Felis sends: CRLF line endings throughout
|
|
// (RFC 5322 — a bare LF is how relays mangle or reject a message), the code in
|
|
// the body, a Q-encoded subject (it carries non-ASCII), and a blank line
|
|
// separating headers from body.
|
|
func TestMessageShape(t *testing.T) {
|
|
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
|
msg := string(message("[email protected]", "[email protected]", "042137", now))
|
|
|
|
if strings.Contains(strings.ReplaceAll(msg, "\r\n", ""), "\n") {
|
|
t.Error("message contains a bare LF; every line must end CRLF")
|
|
}
|
|
headers, body, ok := strings.Cut(msg, "\r\n\r\n")
|
|
if !ok {
|
|
t.Fatal("message has no blank line between headers and body")
|
|
}
|
|
for _, want := range []string{
|
|
"From: [email protected]",
|
|
"To: [email protected]",
|
|
"Date: Mon, 20 Jul 2026 12:00:00 +0000",
|
|
"Content-Type: text/plain; charset=utf-8",
|
|
} {
|
|
if !strings.Contains(headers, want) {
|
|
t.Errorf("headers missing %q:\n%s", want, headers)
|
|
}
|
|
}
|
|
// The subject carries 验证码, so it must be MIME-encoded, never raw UTF-8.
|
|
if !strings.Contains(headers, "Subject: =?utf-8?") {
|
|
t.Errorf("subject must be Q-encoded, got headers:\n%s", headers)
|
|
}
|
|
if !strings.Contains(body, "042137") {
|
|
t.Errorf("body missing the code:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// TestSelfTestCarriesNoCode keeps Ping's message distinguishable from a real
|
|
// OTP: an operator finding it in the inbox must read it as the wizard's proof
|
|
// of delivery, not as a code someone requested on their account.
|
|
func TestSelfTestCarriesNoCode(t *testing.T) {
|
|
msg := string(selfTest("[email protected]", time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)))
|
|
if strings.Contains(msg, "verification code") {
|
|
t.Errorf("self-test must not read as an OTP mail:\n%s", msg)
|
|
}
|
|
if !strings.Contains(msg, "To: [email protected]") {
|
|
t.Errorf("self-test must be addressed to the sender itself:\n%s", msg)
|
|
}
|
|
}
|
|
|
|
// TestNoticeShape pins the second message shape — the reaper's pre-deletion
|
|
// warning: CRLF throughout even when the caller's body used bare LFs, a
|
|
// Q-encoded subject when it carries non-ASCII, and the caller's text rendered
|
|
// verbatim between the header block and the wire.
|
|
func TestNoticeShape(t *testing.T) {
|
|
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
|
msg := string(notice("[email protected]", "[email protected]",
|
|
"Felis: 服务器 survival 将回收", "line one\nline two\n", now))
|
|
|
|
if strings.Contains(strings.ReplaceAll(msg, "\r\n", ""), "\n") {
|
|
t.Error("notice contains a bare LF; every line must end CRLF")
|
|
}
|
|
headers, body, ok := strings.Cut(msg, "\r\n\r\n")
|
|
if !ok {
|
|
t.Fatal("notice has no blank line between headers and body")
|
|
}
|
|
if !strings.Contains(headers, "To: [email protected]") {
|
|
t.Errorf("headers missing To:\n%s", headers)
|
|
}
|
|
if !strings.Contains(headers, "Subject: =?utf-8?") {
|
|
t.Errorf("non-ASCII subject must be Q-encoded:\n%s", headers)
|
|
}
|
|
if !strings.Contains(body, "line one\r\nline two\r\n") {
|
|
t.Errorf("body must be CRLF-normalized verbatim text:\n%q", body)
|
|
}
|
|
}
|
|
|
|
// fakeRelay speaks just enough SMTP for net/smtp, answering 250 to MAIL FROM
|
|
// and RCPT TO but dataVerdict at end-of-DATA. That split is the entire point:
|
|
// relays which validate sender identity (Fastmail among them) accept MAIL FROM
|
|
// unconditionally and only render their verdict after the message body, so a
|
|
// probe stopping short of end-of-DATA reports a green relay that cannot send.
|
|
func fakeRelay(t *testing.T, dataVerdict string) (string, int) {
|
|
t.Helper()
|
|
return startRelay(t, &relayOpts{dataVerdict: dataVerdict})
|
|
}
|
|
|
|
// relayOpts shapes a fake relay: its end-of-DATA verdict, whether its EHLO
|
|
// offers STARTTLS (it cannot complete one: it answers 220 and hangs up), and
|
|
// the commands it received, in order.
|
|
type relayOpts struct {
|
|
dataVerdict string
|
|
starttls bool
|
|
|
|
mu sync.Mutex
|
|
seen []string
|
|
}
|
|
|
|
func (o *relayOpts) record(cmd string) {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
o.seen = append(o.seen, cmd)
|
|
}
|
|
|
|
func (o *relayOpts) commands() []string {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
return append([]string(nil), o.seen...)
|
|
}
|
|
|
|
func startRelay(t *testing.T, opts *relayOpts) (string, int) {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { ln.Close() })
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go serveFakeRelay(conn, opts)
|
|
}
|
|
}()
|
|
host, portStr, err := net.SplitHostPort(ln.Addr().String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return host, port
|
|
}
|
|
|
|
func serveFakeRelay(conn net.Conn, opts *relayOpts) {
|
|
defer conn.Close()
|
|
br := bufio.NewReader(conn)
|
|
io.WriteString(conn, "220 fake ESMTP\r\n")
|
|
for {
|
|
line, err := br.ReadString('\n')
|
|
if err != nil {
|
|
opts.record("<closed>")
|
|
return
|
|
}
|
|
cmd := strings.ToUpper(strings.TrimSpace(line))
|
|
if f := strings.Fields(cmd); len(f) > 0 {
|
|
opts.record(f[0])
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(cmd, "EHLO") && opts.starttls:
|
|
io.WriteString(conn, "250-fake\r\n250 STARTTLS\r\n")
|
|
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
|
|
io.WriteString(conn, "250 fake\r\n")
|
|
case strings.HasPrefix(cmd, "STARTTLS"):
|
|
io.WriteString(conn, "220 ready\r\n")
|
|
return
|
|
case strings.HasPrefix(cmd, "MAIL FROM"), strings.HasPrefix(cmd, "RCPT TO"):
|
|
io.WriteString(conn, "250 2.1.0 Ok\r\n")
|
|
case strings.HasPrefix(cmd, "DATA"):
|
|
io.WriteString(conn, "354 go ahead\r\n")
|
|
for {
|
|
l, err := br.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
if strings.TrimRight(l, "\r\n") == "." {
|
|
break
|
|
}
|
|
}
|
|
io.WriteString(conn, opts.dataVerdict+"\r\n")
|
|
case strings.HasPrefix(cmd, "QUIT"):
|
|
io.WriteString(conn, "221 bye\r\n")
|
|
return
|
|
default:
|
|
io.WriteString(conn, "250 ok\r\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPingRejectsRelayThatRefusesAtEndOfData is the regression this whole
|
|
// change exists for. A live install passed the wizard with a From on a domain
|
|
// the relay would not send as, then failed every OTP: the old Ping stopped at
|
|
// NOOP, and even a MAIL FROM probe would have been waved through with a 250.
|
|
// Only a full transaction sees the refusal.
|
|
func TestPingRejectsRelayThatRefusesAtEndOfData(t *testing.T) {
|
|
host, port := fakeRelay(t, "550 5.7.1 sender identity not allowed")
|
|
s := &SMTP{Host: host, Port: port, From: "[email protected]"}
|
|
|
|
err := s.Ping(context.Background())
|
|
if err == nil {
|
|
t.Fatal("Ping accepted a relay that refuses this sender at end-of-DATA")
|
|
}
|
|
// The operator has to be able to act on this, which means seeing both the
|
|
// relay's own refusal and which address it refused.
|
|
if !strings.Contains(err.Error(), "550") {
|
|
t.Errorf("want the relay's refusal surfaced, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "[email protected]") {
|
|
t.Errorf("want the rejected sender named, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// The counterpart: a relay that accepts the message must leave the wizard happy.
|
|
func TestPingAcceptsDeliverableRelay(t *testing.T) {
|
|
host, port := fakeRelay(t, "250 2.0.0 Ok")
|
|
s := &SMTP{Host: host, Port: port, From: "[email protected]"}
|
|
|
|
if err := s.Ping(context.Background()); err != nil {
|
|
t.Fatalf("Ping rejected a relay that accepted the message: %v", err)
|
|
}
|
|
}
|
|
|
|
// SendOTP must fail on the same relay Ping rejects — they share deliver(), and
|
|
// that shared path is what makes the wizard's check meaningful.
|
|
func TestSendOTPSurfacesEndOfDataRefusal(t *testing.T) {
|
|
host, port := fakeRelay(t, "550 5.7.1 sender identity not allowed")
|
|
s := &SMTP{Host: host, Port: port, From: "[email protected]"}
|
|
|
|
err := s.SendOTP(context.Background(), "[email protected]", "042137")
|
|
if err == nil {
|
|
t.Fatal("SendOTP reported success against a relay that refused the message")
|
|
}
|
|
if !strings.Contains(err.Error(), "550") {
|
|
t.Errorf("want the relay's refusal surfaced, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRequireTLSRefusesPlaintextRelay: with RequireTLS a relay that offers no
|
|
// STARTTLS gets nothing past EHLO — no sender, no recipient, no code — and the
|
|
// operator is told which relay and which knob.
|
|
func TestRequireTLSRefusesPlaintextRelay(t *testing.T) {
|
|
relay := &relayOpts{dataVerdict: "250 2.0.0 Ok"}
|
|
host, port := startRelay(t, relay)
|
|
s := &SMTP{Host: host, Port: port, From: "[email protected]", RequireTLS: true}
|
|
|
|
err := s.SendOTP(context.Background(), "[email protected]", "042137")
|
|
if err == nil {
|
|
t.Fatal("SendOTP sent a code through a relay without STARTTLS")
|
|
}
|
|
for _, want := range []string{"does not offer STARTTLS", host + ":" + strconv.Itoa(port), "require_tls = false"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("error %q does not mention %q", err, want)
|
|
}
|
|
}
|
|
// The client hangs up instead of leaving the connection to the garbage
|
|
// collector; the relay sees that shortly after SendOTP returns.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for len(relay.commands()) < 2 && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if got := strings.Join(relay.commands(), " "); got != "EHLO <closed>" {
|
|
t.Errorf("relay received %q, want EHLO and then the connection closed", got)
|
|
}
|
|
if err := s.Ping(context.Background()); err == nil || !strings.Contains(err.Error(), "does not offer STARTTLS") {
|
|
t.Errorf("Ping = %v, want the STARTTLS refusal", err)
|
|
}
|
|
}
|
|
|
|
// TestPlaintextRelayAllowedWithoutRequireTLS: require_tls = false (or a relay
|
|
// on this host) keeps the old opportunistic posture and the code is delivered.
|
|
func TestPlaintextRelayAllowedWithoutRequireTLS(t *testing.T) {
|
|
relay := &relayOpts{dataVerdict: "250 2.0.0 Ok"}
|
|
host, port := startRelay(t, relay)
|
|
s := &SMTP{Host: host, Port: port, From: "[email protected]"}
|
|
|
|
if err := s.SendOTP(context.Background(), "[email protected]", "042137"); err != nil {
|
|
t.Fatalf("SendOTP: %v", err)
|
|
}
|
|
if got := strings.Join(relay.commands(), " "); got != "EHLO MAIL RCPT DATA QUIT" {
|
|
t.Errorf("relay received %q, want EHLO MAIL RCPT DATA QUIT", got)
|
|
}
|
|
}
|
|
|
|
// TestRequireTLSTakesAnOfferedStartTLS: when the relay does offer STARTTLS the
|
|
// client goes for it rather than refusing; this fake then hangs up mid-upgrade,
|
|
// so the failure is the upgrade's own.
|
|
func TestRequireTLSTakesAnOfferedStartTLS(t *testing.T) {
|
|
relay := &relayOpts{dataVerdict: "250 2.0.0 Ok", starttls: true}
|
|
host, port := startRelay(t, relay)
|
|
s := &SMTP{Host: host, Port: port, From: "[email protected]", RequireTLS: true}
|
|
|
|
err := s.SendOTP(context.Background(), "[email protected]", "042137")
|
|
if err == nil || !strings.HasPrefix(err.Error(), "smtp: starttls:") {
|
|
t.Fatalf("SendOTP = %v, want the STARTTLS upgrade failure", err)
|
|
}
|
|
if got := strings.Join(relay.commands(), " "); got != "EHLO STARTTLS" {
|
|
t.Errorf("relay received %q, want EHLO STARTTLS", got)
|
|
}
|
|
}
|