updated layout for webmail and added http dns letsencrypt
This commit is contained in:
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
@@ -104,6 +106,82 @@ func TestStoreFetchRoundTrip(t *testing.T) {
|
||||
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, "<abc@example.com>", "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 <bob@example.com>\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, "<abc@example.com>", "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 <bob@example.com>" {
|
||||
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) {
|
||||
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mailgoserver/internal/mailview"
|
||||
)
|
||||
|
||||
// extractHeaderValue reads a single header out of raw without parsing the body — used
|
||||
@@ -25,6 +28,29 @@ func extractHeaderValue(raw []byte, name string) string {
|
||||
return msg.Header.Get(name)
|
||||
}
|
||||
|
||||
const previewSnippetLen = 150
|
||||
|
||||
// previewSnippet extracts up to previewSnippetLen characters of the plain-text body
|
||||
// for the folder list's preview line — cached in plain text alongside cached_from/
|
||||
// cached_subject (see schema.go's comment on cached_preview for why that's consistent
|
||||
// with the existing cached_* columns, not a new exposure). HTML-only mail (no
|
||||
// text/plain part) gets no preview rather than a crude tag-stripped approximation —
|
||||
// an accepted scope limit, not a bug: most real mail includes a text/plain
|
||||
// alternative regardless of whether the sender expects it to be shown.
|
||||
func previewSnippet(raw []byte) string {
|
||||
parsed, err := mailview.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.Join(strings.Fields(parsed.TextBody), " ")
|
||||
// Rune-safe truncation — a plain byte slice could split a multi-byte UTF-8
|
||||
// character in half and produce invalid text.
|
||||
if runes := []rune(text); len(runes) > previewSnippetLen {
|
||||
text = string(runes[:previewSnippetLen])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// ErrQuotaExceeded is returned by StoreMessage when storing raw would push the
|
||||
// mailbox over its quota. No row, file, or used_bytes change occurs in that case.
|
||||
var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded")
|
||||
@@ -66,7 +92,7 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
|
||||
return 0, err
|
||||
}
|
||||
|
||||
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject)
|
||||
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject, previewSnippet(raw))
|
||||
if err != nil {
|
||||
os.Remove(storagePath)
|
||||
return 0, err
|
||||
@@ -77,6 +103,52 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// RebuildMessageCache re-derives cached_from/cached_to/cached_subject/cached_preview
|
||||
// for every message already stored in mailboxID, from each message's own decrypted
|
||||
// content — these fields are otherwise only ever computed once, at delivery time
|
||||
// (see StoreMessage), so mail stored before a caching fix or addition landed (e.g.
|
||||
// caching the From: header's display name instead of the bare envelope address, or
|
||||
// the cached_preview column itself) keeps showing the old/blank value forever unless
|
||||
// something re-derives it. Returns how many rows actually changed; a message that
|
||||
// fails to decrypt/parse is skipped (counted in the error map, not fatal to the rest).
|
||||
func (s *Store) RebuildMessageCache(mailboxID int64) (updated int, skipped map[int64]error) {
|
||||
skipped = map[int64]error{}
|
||||
msgs, err := s.DB.ListMessagesForMailbox(mailboxID)
|
||||
if err != nil {
|
||||
skipped[0] = err
|
||||
return 0, skipped
|
||||
}
|
||||
for _, m := range msgs {
|
||||
raw, err := s.FetchMessage(mailboxID, m.ID)
|
||||
if err != nil {
|
||||
skipped[m.ID] = err
|
||||
continue
|
||||
}
|
||||
from := extractHeaderValue(raw, "From")
|
||||
if from == "" {
|
||||
from = m.CachedFrom
|
||||
}
|
||||
to := extractHeaderValue(raw, "To")
|
||||
if to == "" {
|
||||
to = m.CachedTo
|
||||
}
|
||||
subject := extractHeaderValue(raw, "Subject")
|
||||
if subject == "" {
|
||||
subject = m.CachedSubject
|
||||
}
|
||||
preview := previewSnippet(raw)
|
||||
if from == m.CachedFrom && to == m.CachedTo && subject == m.CachedSubject && preview == m.CachedPreview {
|
||||
continue // already correct — don't churn a write for nothing
|
||||
}
|
||||
if err := s.DB.UpdateMessageCachedFields(m.ID, from, to, subject, preview); err != nil {
|
||||
skipped[m.ID] = err
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return updated, skipped
|
||||
}
|
||||
|
||||
// FetchMessage decrypts a stored message on demand. Plaintext is never written to disk
|
||||
// or cached — only returned to the caller.
|
||||
func (s *Store) FetchMessage(mailboxID, uid int64) ([]byte, error) {
|
||||
|
||||
Reference in New Issue
Block a user