package mailstore import ( "bytes" "os" "path/filepath" "strings" "testing" "unicode/utf8" "mailgoserver/internal/db" ) func newTestDB(t *testing.T) *db.DB { t.Helper() dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") database, err := db.Open(dbPath) if err != nil { t.Fatal(err) } t.Cleanup(func() { database.Close() }) return database } // newTestMailbox creates a domain + mailbox with a real wrapped DEK, using a Store // built against a random master key, and returns both. func newTestMailbox(t *testing.T, quotaBytes int64) (*Store, int64) { t.Helper() database := newTestDB(t) domainID, err := database.CreateDomain("example.com") if err != nil { t.Fatal(err) } masterKey := GenerateDEK() // 32 random bytes, reused here as a throwaway master key s := New(database, masterKey, t.TempDir()) dek := GenerateDEK() wrapped, nonce, err := s.WrapDEK(dek) if err != nil { t.Fatal(err) } hash, err := db.HashPassword("irrelevant-portal-password") if err != nil { t.Fatal(err) } mailboxID, err := database.CreateMailbox("user@example.com", hash, domainID, quotaBytes, wrapped, nonce) if err != nil { t.Fatal(err) } return s, mailboxID } func TestWrapUnwrapDEK(t *testing.T) { database := newTestDB(t) s := New(database, GenerateDEK(), t.TempDir()) dek := GenerateDEK() wrapped, nonce, err := s.WrapDEK(dek) if err != nil { t.Fatal(err) } got, err := s.UnwrapDEK(wrapped, nonce) if err != nil { t.Fatal(err) } if !bytes.Equal(dek, got) { t.Fatalf("unwrapped DEK does not match original: got %x, want %x", got, dek) } } func TestStoreFetchRoundTrip(t *testing.T) { s, mailboxID := newTestMailbox(t, 1024*1024) raw := []byte("From: a@example.com\r\nSubject: hi\r\n\r\nhello world") uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "", "a@example.com", "hi") if err != nil { t.Fatal(err) } got, err := s.FetchMessage(mailboxID, uid) if err != nil { t.Fatal(err) } if !bytes.Equal(raw, got) { t.Fatalf("fetched message does not match stored: got %q, want %q", got, raw) } msg, err := s.DB.GetMessageByUID(mailboxID, uid) if err != nil { t.Fatal(err) } onDisk, err := os.ReadFile(msg.StoragePath) if err != nil { t.Fatal(err) } if bytes.Equal(onDisk, raw) { t.Fatal("on-disk file matches plaintext — message was not actually encrypted") } mbox, err := s.DB.GetMailboxByID(mailboxID) if err != nil { t.Fatal(err) } if mbox.UsedBytes != int64(len(raw)) { t.Fatalf("used_bytes = %d, want %d", mbox.UsedBytes, len(raw)) } if msg.CachedPreview != "hello world" { t.Errorf("CachedPreview = %q, want %q", msg.CachedPreview, "hello world") } } // TestStoreMessagePreviewTruncatesLongBodyRuneSafely confirms the cached preview is // capped at previewSnippetLen characters (not bytes — a naive byte-slice cap could // split a multi-byte UTF-8 character) and that non-ASCII text survives intact. func TestStoreMessagePreviewTruncatesLongBodyRuneSafely(t *testing.T) { s, mailboxID := newTestMailbox(t, 1024*1024) longBody := strings.Repeat("héllo ", 100) // well over previewSnippetLen once joined raw := []byte("From: a@example.com\r\nSubject: hi\r\n\r\n" + longBody) uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "", "a@example.com", "hi") if err != nil { t.Fatal(err) } msg, err := s.DB.GetMessageByUID(mailboxID, uid) if err != nil { t.Fatal(err) } if n := len([]rune(msg.CachedPreview)); n != previewSnippetLen { t.Errorf("preview length = %d runes, want %d", n, previewSnippetLen) } if !utf8.ValidString(msg.CachedPreview) { t.Error("preview is not valid UTF-8 — truncation split a multi-byte character") } } // TestRebuildMessageCacheRederivesFromExistingContent simulates a message stored // before the "cache the From: header's display name" fix existed: cached_from was // passed as the bare envelope address even though the stored raw content always had // the full header. RebuildMessageCache should bring it up to date without needing the // message re-delivered. func TestRebuildMessageCacheRederivesFromExistingContent(t *testing.T) { s, mailboxID := newTestMailbox(t, 1024*1024) raw := []byte("From: Bob Marley \r\nTo: user@example.com\r\nSubject: One love\r\n\r\nHello there, this is the body.") // "bob@example.com" mimics what the old (pre-fix) code would have cached — the // bare envelope address — despite the header above always having the display name. uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "", "bob@example.com", "One love") if err != nil { t.Fatal(err) } before, err := s.DB.GetMessageByUID(mailboxID, uid) if err != nil { t.Fatal(err) } if before.CachedFrom != "bob@example.com" { t.Fatalf("test setup: expected the stale bare address before rebuild, got %q", before.CachedFrom) } updated, skipped := s.RebuildMessageCache(mailboxID) if len(skipped) != 0 { t.Fatalf("expected no skipped messages, got %v", skipped) } if updated != 1 { t.Fatalf("expected 1 message updated, got %d", updated) } after, err := s.DB.GetMessageByUID(mailboxID, uid) if err != nil { t.Fatal(err) } if after.CachedFrom != "Bob Marley " { t.Errorf("CachedFrom after rebuild = %q, want the header's display name", after.CachedFrom) } if after.CachedPreview != "Hello there, this is the body." { t.Errorf("CachedPreview after rebuild = %q", after.CachedPreview) } // Re-running is a safe no-op once everything's already correct. updated2, _ := s.RebuildMessageCache(mailboxID) if updated2 != 0 { t.Errorf("expected 0 messages updated on a second run, got %d", updated2) } } func TestQuotaExceeded(t *testing.T) { s, mailboxID := newTestMailbox(t, 10) // tiny quota raw := []byte("this message is definitely longer than ten bytes") _, err := s.StoreMessage(mailboxID, "INBOX", raw, "", "a@example.com", "hi") if err != ErrQuotaExceeded { t.Fatalf("err = %v, want ErrQuotaExceeded", err) } mbox, err := s.DB.GetMailboxByID(mailboxID) if err != nil { t.Fatal(err) } if mbox.UsedBytes != 0 { t.Fatalf("used_bytes = %d after a rejected store, want 0", mbox.UsedBytes) } entries, err := os.ReadDir(s.BasePath) if err == nil && len(entries) != 0 { t.Fatalf("expected no files written under %s after a rejected store, found %d entries", s.BasePath, len(entries)) } } func TestDeleteMessageFreesQuota(t *testing.T) { s, mailboxID := newTestMailbox(t, 1024*1024) raw := []byte("From: a@example.com\r\nSubject: bye\r\n\r\ngoodbye") uid, err := s.StoreMessage(mailboxID, "INBOX", raw, "", "a@example.com", "bye") if err != nil { t.Fatal(err) } msg, err := s.DB.GetMessageByUID(mailboxID, uid) if err != nil { t.Fatal(err) } storagePath := msg.StoragePath if err := s.DeleteMessage(mailboxID, uid); err != nil { t.Fatal(err) } mbox, err := s.DB.GetMailboxByID(mailboxID) if err != nil { t.Fatal(err) } if mbox.UsedBytes != 0 { t.Fatalf("used_bytes = %d after delete, want 0", mbox.UsedBytes) } if _, err := os.Stat(storagePath); !os.IsNotExist(err) { t.Fatalf("ciphertext file %s still exists after delete", storagePath) } }