package smtpserver import ( "net/smtp" "testing" "mailgoserver/internal/db" ) func sendFrom(t *testing.T, addr, from, to, subject string) error { t.Helper() c, err := smtp.Dial(addr) if err != nil { t.Fatal(err) } defer c.Close() if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil { t.Fatalf("auth: %v", err) } if err := c.Mail(from); err != nil { t.Fatalf("MAIL FROM: %v", err) } if err := c.Rcpt(to); err != nil { t.Fatalf("RCPT: %v", err) } w, err := c.Data() if err != nil { t.Fatal(err) } w.Write([]byte("Subject: " + subject + "\r\n\r\nhi")) return w.Close() } // TestAutoReplyRuleFiresOnceThenSuppressedFor24h confirms an 'auto_reply' filter rule // sends a reply on the first matching message from a sender, still stores the // original message normally in INBOX (auto-reply is additive, not a replacement // action), and suppresses a second reply to the same sender within the loop-prevention // window — without needing to wait a real 24h, by directly manipulating the DB layer // HasRecentAutoReply/RecordAutoReply already tested at unit-test speed. func TestAutoReplyRuleFiresOnceThenSuppressedFor24h(t *testing.T) { backend, mailboxID := newTestBackendWithMailbox(t) conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "vacation"}} if _, err := backend.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "auto_reply", "Out of office", `{"body":"I am away."}`); err != nil { t.Fatal(err) } addr := startTestServer(t, backend) if err := sendFrom(t, addr, "sender@example.org", "inbox@example.com", "vacation question"); err != nil { t.Fatalf("first send: %v", err) } // The message itself must still be stored normally — auto_reply doesn't divert it. msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX") if err != nil || len(msgs) != 1 { t.Fatalf("expected the original message still stored in INBOX, got %d (err=%v)", len(msgs), err) } // Loop-prevention must now be recorded for this sender. recent, err := backend.DB.HasRecentAutoReply(mailboxID, "sender@example.org") if err != nil || !recent { t.Fatalf("expected an auto-reply to be recorded, HasRecentAutoReply = %v, err=%v", recent, err) } // A second matching message from the same sender within the window must not // record a second auto-reply (RecordAutoReply only called once). if err := sendFrom(t, addr, "sender@example.org", "inbox@example.com", "vacation again"); err != nil { t.Fatalf("second send: %v", err) } var count int if err := backend.DB.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_autoreply_log WHERE mailbox_id = ? AND sender_addr = ?`, mailboxID, "sender@example.org").Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Fatalf("expected exactly 1 auto-reply log row after two messages within the window, got %d", count) } } // TestAutoReplyNeverFiresForNullSender confirms sendAutoReply itself refuses a // null-sender message — the classic bounce-loop bug this codebase already avoids for // SendBounce. A white-box unit test, not a live SMTP round trip: Session.Mail() // already hard-rejects MAIL FROM:<> for every sender category before DATA is ever // reached (a separate, pre-existing gap — this server currently can't accept a // genuine bounce/DSN from another real MTA at all — out of scope for this feature), // so sendAutoReply's own guard is a defense-in-depth check that isn't reachable // through the live protocol today; this test exercises it directly instead. func TestAutoReplyNeverFiresForNullSender(t *testing.T) { backend, mailboxID := newTestBackendWithMailbox(t) mbox, err := backend.DB.GetMailboxByID(mailboxID) if err != nil || mbox == nil { t.Fatal(err) } s := &Session{backend: backend, mailFrom: ""} s.sendAutoReply(mbox, "Out of office", "away", "") var count int if err := backend.DB.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_autoreply_log WHERE mailbox_id = ?`, mailboxID).Scan(&count); err != nil { t.Fatal(err) } if count != 0 { t.Fatalf("expected no auto-reply recorded for a null-sender session, got %d log row(s)", count) } }