package smtp_server import ( "bytes" "fmt" "testing" "time" "mail_go/config" "mail_go/internal/db" "mail_go/internal/storage" "mail_go/internal/store" "github.com/emersion/go-sasl" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // testMultipartMessage builds an RFC 5322 message with one text part and one // base64 attachment. func testMultipartMessage() []byte { const boundary = "X" return []byte(fmt.Sprintf( "From: sender@example.com\r\n"+ "To: rcpt@lmve.net\r\n"+ "Subject: with attachment\r\n"+ "MIME-Version: 1.0\r\n"+ "Content-Type: multipart/mixed; boundary=\"%s\"\r\n"+ "\r\n"+ "--%s\r\n"+ "Content-Type: text/plain; charset=utf-8\r\n"+ "\r\n"+ "hello body\r\n"+ "--%s\r\n"+ "Content-Type: text/plain; name=\"test.txt\"\r\n"+ "Content-Transfer-Encoding: base64\r\n"+ "Content-Disposition: attachment; filename=\"test.txt\"\r\n"+ "\r\n"+ "aGVsbG8gd29ybGQ=\r\n"+ "--%s--\r\n", boundary, boundary, boundary, boundary)) } func TestParseSMTPMessageExtractsAttachmentData(t *testing.T) { parsed, err := parseSMTPMessage(testMultipartMessage()) if err != nil { t.Fatalf("parseSMTPMessage: %v", err) } if parsed.textBody != "hello body" { t.Fatalf("unexpected text body: %q", parsed.textBody) } if len(parsed.attachments) != 1 { t.Fatalf("expected 1 attachment, got %d", len(parsed.attachments)) } att := parsed.attachments[0] if att.fileName != "test.txt" { t.Fatalf("unexpected filename: %q", att.fileName) } if string(att.data) != "hello world" { t.Fatalf("unexpected attachment data: %q", att.data) } } func TestSaveMessagePersistsAttachments(t *testing.T) { gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil { t.Fatalf("migrate: %v", err) } stores := store.NewStores(gdb) attStorage := storage.NewAttachmentStorage(t.TempDir()) srv := &SMTPServer{stores: stores, storage: attStorage} sess := &smtpSession{backend: &smtpBackend{server: srv}} data := testMultipartMessage() parsed, err := parseSMTPMessage(data) if err != nil { t.Fatalf("parseSMTPMessage: %v", err) } user := &db.User{Username: "rcpt", PasswordHash: "x", DomainID: 0, IsActive: true} if err := stores.Users.Create(user); err != nil { t.Fatalf("create user: %v", err) } if _, err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil { t.Fatalf("saveMessage: %v", err) } msgs, err := stores.Mails.ListAllByUserAndFolder(user.ID, "INBOX") if err != nil || len(msgs) != 1 { t.Fatalf("expected 1 inbox message, got %d (err=%v)", len(msgs), err) } atts, err := stores.Attachments.ListByMessage(msgs[0].ID) if err != nil { t.Fatalf("ListByMessage: %v", err) } if len(atts) != 1 { t.Fatalf("expected 1 attachment record, got %d", len(atts)) } att := atts[0] if att.FileName != "test.txt" || att.FileSize != int64(len("hello world")) { t.Fatalf("unexpected attachment record: %+v", att) } // The file must exist on disk with the original content. content, err := attStorage.Read(att.FilePath) if err != nil { t.Fatalf("read attachment from disk: %v", err) } if !bytes.Equal(content, []byte("hello world")) { t.Fatalf("attachment content mismatch: %q", content) } } // TestSessionLoggingRecordsAuthFailure 验证认证失败的会话在 Logout 时写入协议日志。 func TestSessionLoggingRecordsAuthFailure(t *testing.T) { gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil { t.Fatalf("migrate: %v", err) } stores := store.NewStores(gdb) srv := &SMTPServer{stores: stores, banCfg: config.BanConfig{MaxFailAttempts: 5, BanDurationMin: 30}} sess := &smtpSession{ backend: &smtpBackend{server: srv, mode: smtpModeSubmission}, clientIP: "203.0.113.7", startedAt: time.Now(), port: 587, } // 触发一次认证(用户名不存在 → 失败) mech, err := sess.Auth(sasl.Plain) if err != nil { t.Fatalf("Auth: %v", err) } // SASL PLAIN 凭据格式: authzid\0authcid\0passwd if _, _, err := mech.Next([]byte("\x00no-such-user\x00wrong-pass")); err == nil { t.Fatal("expected auth failure for unknown user") } // 直接调用 Logout 模拟连接结束 if err := sess.Logout(); err != nil { t.Fatalf("Logout: %v", err) } logs, total, err := stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{}) if err != nil { t.Fatalf("list: %v", err) } if total != 1 { t.Fatalf("expected 1 log, got %d", total) } log := logs[0] if log.Protocol != db.ProtocolSMTP || log.Port != 587 || log.ClientIP != "203.0.113.7" { t.Fatalf("unexpected log: %+v", log) } if log.Success { t.Fatalf("expected failure, got %+v", log) } if log.FailReason == "" { t.Fatal("expected fail reason") } if log.Username != "no-such-user" { t.Fatalf("username = %q", log.Username) } } // TestSessionLoggingRecordsDelivery 验证投递成功的会话写入成功日志。 func TestSessionLoggingRecordsDelivery(t *testing.T) { gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil { t.Fatalf("migrate: %v", err) } stores := store.NewStores(gdb) attStorage := storage.NewAttachmentStorage(t.TempDir()) srv := &SMTPServer{stores: stores, storage: attStorage} sess := &smtpSession{ backend: &smtpBackend{server: srv, mode: smtpModeInbound}, clientIP: "203.0.113.8", startedAt: time.Now(), port: 25, rcpts: make([]string, 0), } if err := sess.Mail("sender@example.com", nil); err != nil { t.Fatalf("Mail: %v", err) } if err := sess.Logout(); err != nil { t.Fatalf("Logout: %v", err) } logs, total, err := stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{}) if err != nil { t.Fatalf("list: %v", err) } if total != 1 { t.Fatalf("expected 1 log, got %d", total) } if !logs[0].Success { t.Fatalf("expected success, got %+v", logs[0]) } }