272 lines
9.1 KiB
Go
272 lines
9.1 KiB
Go
package relay
|
|
|
|
import (
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/emersion/go-smtp"
|
|
|
|
"mailgoserver/internal/db"
|
|
"mailgoserver/internal/toolbox"
|
|
)
|
|
|
|
// testDB opens a fresh temp-file sqlite DB with the full schema applied — same
|
|
// pattern smtpserver's newTestBackend uses (db.Open runs schema.go's CREATE TABLE
|
|
// statements, including esrv_relay_queue).
|
|
func testDB(t *testing.T) *db.DB {
|
|
t.Helper()
|
|
f, err := os.CreateTemp("", "relay-queue-test-*.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.Close()
|
|
t.Cleanup(func() { os.Remove(f.Name()) })
|
|
|
|
database, err := db.Open(f.Name())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { database.Close() })
|
|
return database
|
|
}
|
|
|
|
// fixedMXLookup resolves any domain to 127.0.0.1 — combined with Relay.port
|
|
// (overriding the real MX port 25), this points deliverToDomain at a local
|
|
// stand-in MTA regardless of what domain is being "relayed" to.
|
|
func fixedMXLookup(domain string) ([]*net.MX, error) {
|
|
return []*net.MX{{Host: "127.0.0.1.", Pref: 10}}, nil
|
|
}
|
|
|
|
// failingMXLookup simulates every MX lookup failing (e.g. an unreachable/nonexistent
|
|
// domain) — deliverToDomain fails immediately, no connection ever attempted.
|
|
func failingMXLookup(domain string) ([]*net.MX, error) {
|
|
return nil, &net.DNSError{Err: "no such host", Name: domain, IsNotFound: true}
|
|
}
|
|
|
|
func TestProcessQueueOnceDeliversSuccessfully(t *testing.T) {
|
|
cert, caPool := genTestCert(t)
|
|
port, received, _ := startTestMTA(t, cert)
|
|
database := testDB(t)
|
|
|
|
r := &Relay{
|
|
DB: database, Hostname: "sender.example.com", Timeout: 5 * time.Second,
|
|
Logger: toolbox.GetLogger("relay_test"), port: port, rootCAs: caPool, mxLookup: fixedMXLookup,
|
|
}
|
|
|
|
logID, err := database.InsertEmailLog(db.EmailLog{MailFrom: "from@example.com", ToAddress: "to@dest.example", Status: "queued"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.InsertEmailRecipientLog(db.EmailRecipientLog{EmailLogID: logID, Recipient: "to@dest.example", RecipientType: "to", Status: "queued"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.EnqueueForDelivery(logID, "from@example.com", []string{"to@dest.example"}, []string{"to"}, "Subject: hi\r\n\r\nbody"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r.ProcessQueueOnce(5, 10)
|
|
|
|
select {
|
|
case <-received:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("expected the test MTA to receive the queued message")
|
|
}
|
|
|
|
recipients, err := database.ListRecipientLogsForEmail(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(recipients) != 1 || recipients[0].Status != "success" {
|
|
t.Fatalf("expected recipient status success, got %+v", recipients)
|
|
}
|
|
|
|
pending, err := database.CountPendingRelayQueueItemsForEmailLog(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pending != 0 {
|
|
t.Fatalf("expected the queue row to be gone after delivery, got %d pending", pending)
|
|
}
|
|
|
|
log, err := database.GetEmailLogByID(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if log.Status != "relayed" {
|
|
t.Errorf("expected email_log status 'relayed', got %q", log.Status)
|
|
}
|
|
}
|
|
|
|
func TestProcessQueueOnceReschedulesOnFailure(t *testing.T) {
|
|
database := testDB(t)
|
|
r := &Relay{
|
|
DB: database, Hostname: "sender.example.com", Timeout: 5 * time.Second,
|
|
Logger: toolbox.GetLogger("relay_test"), mxLookup: failingMXLookup,
|
|
}
|
|
|
|
logID, err := database.InsertEmailLog(db.EmailLog{MailFrom: "from@example.com", ToAddress: "to@dest.example", Status: "queued"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.InsertEmailRecipientLog(db.EmailRecipientLog{EmailLogID: logID, Recipient: "to@dest.example", RecipientType: "to", Status: "queued"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.EnqueueForDelivery(logID, "from@example.com", []string{"to@dest.example"}, []string{"to"}, "Subject: hi\r\n\r\nbody"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r.ProcessQueueOnce(5, 10)
|
|
|
|
pending, err := database.CountPendingRelayQueueItemsForEmailLog(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pending != 1 {
|
|
t.Fatalf("expected the queue row to still exist (rescheduled, not exhausted), got %d", pending)
|
|
}
|
|
recipients, err := database.ListRecipientLogsForEmail(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recipients[0].Status != "queued" {
|
|
t.Errorf("expected recipient to remain 'queued' pending retry, got %q", recipients[0].Status)
|
|
}
|
|
}
|
|
|
|
// TestProcessQueueOnceExhaustsRetriesAndBounces short-circuits retrySchedule so the
|
|
// test doesn't wait real hours — after every scheduled attempt fails, the queue row
|
|
// should be dropped, the recipient marked failed, and a bounce delivered back to the
|
|
// original sender's own inbox (via SendBounce's relay-out path, using the same
|
|
// always-fails mxLookup — proving the bounce attempt doesn't crash even when it too
|
|
// can't be delivered, matching real MTA behavior of just logging the failed bounce).
|
|
func TestProcessQueueOnceExhaustsRetriesAndBounces(t *testing.T) {
|
|
orig := retrySchedule
|
|
retrySchedule = []time.Duration{0, 0}
|
|
t.Cleanup(func() { retrySchedule = orig })
|
|
|
|
database := testDB(t)
|
|
r := &Relay{
|
|
DB: database, Hostname: "sender.example.com", Timeout: 5 * time.Second,
|
|
Logger: toolbox.GetLogger("relay_test"), mxLookup: failingMXLookup,
|
|
}
|
|
|
|
logID, err := database.InsertEmailLog(db.EmailLog{MailFrom: "from@example.com", ToAddress: "to@dest.example", Status: "queued"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.InsertEmailRecipientLog(db.EmailRecipientLog{EmailLogID: logID, Recipient: "to@dest.example", RecipientType: "to", Status: "queued"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.EnqueueForDelivery(logID, "from@example.com", []string{"to@dest.example"}, []string{"to"}, "Subject: hi\r\n\r\nbody"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// One tick per scheduled retry, plus one for the initial attempt — each due
|
|
// immediately since the schedule above is all-zero backoff.
|
|
for i := 0; i <= len(retrySchedule); i++ {
|
|
r.ProcessQueueOnce(5, 10)
|
|
}
|
|
|
|
pending, err := database.CountPendingRelayQueueItemsForEmailLog(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pending != 0 {
|
|
t.Fatalf("expected the queue row to be gone once retries are exhausted, got %d pending", pending)
|
|
}
|
|
recipients, err := database.ListRecipientLogsForEmail(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recipients[0].Status != "failed" {
|
|
t.Errorf("expected recipient status 'failed' after exhausting retries, got %q", recipients[0].Status)
|
|
}
|
|
|
|
log, err := database.GetEmailLogByID(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if log.Status != "failed" {
|
|
t.Errorf("expected email_log status 'failed', got %q", log.Status)
|
|
}
|
|
}
|
|
|
|
// rejectingBackend is a stand-in "receiving MTA" that rejects every RCPT with a fixed
|
|
// SMTP status/message — used to prove a permanent (5xx) rejection bounces on the first
|
|
// attempt instead of working through retrySchedule.
|
|
type rejectingBackend struct{ code int }
|
|
|
|
func (b *rejectingBackend) NewSession(*smtp.Conn) (smtp.Session, error) {
|
|
return &rejectingSession{code: b.code}, nil
|
|
}
|
|
|
|
type rejectingSession struct{ code int }
|
|
|
|
func (s *rejectingSession) Mail(from string, opts *smtp.MailOptions) error { return nil }
|
|
func (s *rejectingSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
|
return &smtp.SMTPError{Code: s.code, EnhancedCode: smtp.NoEnhancedCode, Message: "poor reputation"}
|
|
}
|
|
func (s *rejectingSession) Data(r io.Reader) error { _, err := io.ReadAll(r); return err }
|
|
func (s *rejectingSession) Reset() {}
|
|
func (s *rejectingSession) Logout() error { return nil }
|
|
|
|
func TestProcessQueueOnceBouncesImmediatelyOnPermanentRejection(t *testing.T) {
|
|
database := testDB(t)
|
|
s := smtp.NewServer(&rejectingBackend{code: 554})
|
|
s.Domain = "mx.example.com"
|
|
s.AllowInsecureAuth = true
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
go s.Serve(l)
|
|
_, portStr, _ := net.SplitHostPort(l.Addr().String())
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := &Relay{
|
|
DB: database, Hostname: "sender.example.com", Timeout: 5 * time.Second,
|
|
Logger: toolbox.GetLogger("relay_test"), port: port, mxLookup: fixedMXLookup,
|
|
}
|
|
|
|
logID, err := database.InsertEmailLog(db.EmailLog{MailFrom: "from@example.com", ToAddress: "to@dest.example", Status: "queued"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.InsertEmailRecipientLog(db.EmailRecipientLog{EmailLogID: logID, Recipient: "to@dest.example", RecipientType: "to", Status: "queued"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := r.EnqueueForDelivery(logID, "from@example.com", []string{"to@dest.example"}, []string{"to"}, "Subject: hi\r\n\r\nbody"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// One tick. If this were misclassified as a transient failure it would still be
|
|
// pending (rescheduled per retrySchedule), not gone.
|
|
r.ProcessQueueOnce(5, 10)
|
|
|
|
pending, err := database.CountPendingRelayQueueItemsForEmailLog(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pending != 0 {
|
|
t.Fatalf("expected the queue row to be gone after a single permanent (5xx) rejection, got %d pending", pending)
|
|
}
|
|
recipients, err := database.ListRecipientLogsForEmail(logID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recipients[0].Status != "failed" {
|
|
t.Errorf("expected recipient status 'failed' immediately on a 554, got %q", recipients[0].Status)
|
|
}
|
|
if recipients[0].ErrorMessage == "" {
|
|
t.Error("expected the recipient log to carry the server's rejection reason")
|
|
}
|
|
}
|