diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go index 43b1085..c71827d 100644 --- a/internal/smtp_server/server.go +++ b/internal/smtp_server/server.go @@ -285,7 +285,14 @@ type parsedSMTPMessage struct { textBody string htmlBody string date time.Time - attachments []*db.Attachment + attachments []*parsedAttachment +} + +// parsedAttachment holds an extracted MIME attachment part. +type parsedAttachment struct { + fileName string + contentType string + data []byte } func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) { @@ -346,10 +353,10 @@ func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) { log.Printf("SMTP: error reading attachment part: %v", readErr) continue } - msg.attachments = append(msg.attachments, &db.Attachment{ - FileName: filename, - ContentType: contentType, - FileSize: int64(len(buf)), + msg.attachments = append(msg.attachments, &parsedAttachment{ + fileName: filename, + contentType: contentType, + data: buf, }) } } @@ -376,7 +383,32 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP IsFlagged: false, Date: parsed.date, } - return s.backend.server.stores.Mails.Create(msg) + if err := s.backend.server.stores.Mails.Create(msg); err != nil { + return err + } + + // Persist attachments to disk and link them to the message so that the + // Web mail UI can list/download them and quota accounting stays correct. + for _, att := range parsed.attachments { + relPath, err := s.backend.server.storage.Save(att.fileName, att.data) + if err != nil { + log.Printf("SMTP: failed to save attachment %s: %v", att.fileName, err) + continue + } + rec := &db.Attachment{ + MessageID: msg.ID, + FileName: att.fileName, + FilePath: relPath, + ContentType: att.contentType, + FileSize: int64(len(att.data)), + } + if err := s.backend.server.stores.Attachments.Create(rec); err != nil { + log.Printf("SMTP: failed to create attachment record: %v", err) + continue + } + _ = s.backend.server.stores.Users.UpdateUsedBytes(userID, rec.FileSize) + } + return nil } // Reset clears the session state for the next message on the same connection. diff --git a/internal/smtp_server/server_test.go b/internal/smtp_server/server_test.go new file mode 100644 index 0000000..37d8b90 --- /dev/null +++ b/internal/smtp_server/server_test.go @@ -0,0 +1,115 @@ +package smtp_server + +import ( + "bytes" + "fmt" + "testing" + + "mail_go/internal/db" + "mail_go/internal/storage" + "mail_go/internal/store" + + "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{}); 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) + } +}