From cc9b987e83c587d32cedc9d4379fd70d39a8a8e3 Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Sun, 30 Aug 2026 08:17:03 +0100 Subject: [PATCH] image reder and drafts --- cmd/server/main.go | 19 +- internal/db/db.go | 271 ++++++++++- internal/db/db_test.go | 576 ++++++++++++++++++++++ internal/email/imap_test.go | 47 ++ internal/email/jmap_draft.go | 48 ++ internal/graph/graph.go | 61 +++ internal/handlers/api.go | 526 +++++++++++++++++--- internal/handlers/api_test.go | 377 ++++++++++++++ internal/models/models.go | 38 +- internal/syncer/rules.go | 4 +- internal/syncer/rules_test.go | 131 +++++ web/static/css/gowebmail.css | 38 +- web/static/js/admin.js | 10 +- web/static/js/app.js | 755 ++++++++++++++++++++++++----- web/static/js/contacts_calendar.js | 2 +- web/static/js/gowebmail.js | 32 +- web/templates/admin.html | 4 +- web/templates/app.html | 206 +++++--- web/templates/base.html | 4 +- web/templates/compose.html | 328 +++++++++++-- web/templates/login.html | 6 +- web/templates/message.html | 150 +++++- web/templates/mfa.html | 6 +- 23 files changed, 3273 insertions(+), 366 deletions(-) create mode 100644 internal/db/db_test.go create mode 100644 internal/email/imap_test.go create mode 100644 internal/email/jmap_draft.go create mode 100644 internal/handlers/api_test.go create mode 100644 internal/syncer/rules_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 7027c85..54d5dac 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -219,12 +219,15 @@ func main() { api.HandleFunc("/messages/{id:[0-9]+}/read", h.API.MarkRead).Methods("PUT") api.HandleFunc("/messages/{id:[0-9]+}/star", h.API.ToggleStar).Methods("PUT") api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT") + api.HandleFunc("/messages/{id:[0-9]+}/snooze", h.API.SnoozeMessage).Methods("PUT") + api.HandleFunc("/messages/{id:[0-9]+}/snooze", h.API.UnsnoozeMessage).Methods("DELETE") api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}/attachments", h.API.ListAttachments).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}/attachments/{att_id:[0-9]+}", h.API.DownloadAttachment).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE") api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET") + api.HandleFunc("/messages/snoozed", h.API.SnoozedMessages).Methods("GET") api.HandleFunc("/messages/by-label/{id:[0-9]+}", h.API.MessagesByLabel).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.AssignLabel).Methods("POST") api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.UnassignLabel).Methods("DELETE") @@ -236,14 +239,17 @@ func main() { // Remote content whitelist api.HandleFunc("/remote-content-whitelist", h.API.GetRemoteContentWhitelist).Methods("GET") api.HandleFunc("/remote-content-whitelist", h.API.AddRemoteContentWhitelist).Methods("POST") + api.HandleFunc("/remote-content-whitelist", h.API.DeleteRemoteContentWhitelist).Methods("DELETE") // Send api.HandleFunc("/send", h.API.SendMessage).Methods("POST") api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST") api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST") - api.HandleFunc("/forward-attachment", h.API.ForwardAsAttachment).Methods("POST") api.HandleFunc("/draft", h.API.SaveDraft).Methods("POST") api.HandleFunc("/draft/discard", h.API.DiscardDraft).Methods("POST") + api.HandleFunc("/send-later", h.API.CreateScheduledSend).Methods("POST") + api.HandleFunc("/scheduled-sends", h.API.ListScheduledSends).Methods("GET") + api.HandleFunc("/scheduled-sends/{id:[0-9]+}", h.API.CancelScheduledSend).Methods("DELETE") // Folders api.HandleFunc("/folders", h.API.ListFolders).Methods("GET") @@ -254,6 +260,7 @@ func main() { api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST") api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST") api.HandleFunc("/folders/{id:[0-9]+}/mark-all-read", h.API.MarkFolderAllRead).Methods("POST") + api.HandleFunc("/folders/{id:[0-9]+}/export", h.API.ExportFolder).Methods("GET") api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE") api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST") api.HandleFunc("/accounts/{account_id:[0-9]+}/folders", h.API.CreateFolder).Methods("POST") @@ -350,6 +357,16 @@ func main() { } }() + // Deliver due scheduled sends and wake expired message snoozes + go func() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + for range ticker.C { + h.API.ProcessDueScheduledSends() + h.API.WakeExpiredSnoozes() + } + }() + srv := &http.Server{ Addr: cfg.ListenAddr, Handler: r, diff --git a/internal/db/db.go b/internal/db/db.go index 4cc2d4e..b6fc71f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "database/sql" "encoding/base64" + "encoding/json" "fmt" "log" "strings" @@ -194,6 +195,9 @@ func (d *DB) Migrate() error { // instead of always matching the combined search_text blob. `ALTER TABLE messages ADD COLUMN search_subject TEXT NOT NULL DEFAULT ''`, `ALTER TABLE messages ADD COLUMN search_body TEXT NOT NULL DEFAULT ''`, + // Snooze: NULL = not snoozed; a future timestamp hides the message from normal + // folder views until it passes, at which point the background sweep clears it. + `ALTER TABLE messages ADD COLUMN snoozed_until DATETIME`, } for _, stmt := range alterStmts { d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally @@ -442,6 +446,32 @@ func (d *DB) Migrate() error { return fmt.Errorf("create message_labels: %w", err) } + // Send-later: a fully-composed message held until send_at, delivered by the background + // sweep in main.go via the same send path as an immediate send. to/cc/bcc/forward_from_ids + // are JSON arrays in plain TEXT (matching messages.to_list's existing convention); + // subject/body are AES-encrypted like messages.subject already is. No raw file attachments + // in v1 — only forwarded-message .eml attachments (forward_from_ids), since those need no + // blob storage between scheduling and send time. + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS scheduled_sends ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + to_list TEXT NOT NULL DEFAULT '', + cc_list TEXT NOT NULL DEFAULT '', + bcc_list TEXT NOT NULL DEFAULT '', + subject TEXT NOT NULL DEFAULT '', + body_html TEXT NOT NULL DEFAULT '', + body_text TEXT NOT NULL DEFAULT '', + forward_from_ids TEXT NOT NULL DEFAULT '', + send_at DATETIME NOT NULL, + created_at DATETIME DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create scheduled_sends: %w", err) + } + if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(send_at)`); err != nil { + return fmt.Errorf("create idx_scheduled_sends_due: %w", err) + } + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS trusted_certs ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, @@ -1628,7 +1658,7 @@ func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page offset := (page - 1) * pageSize args := []interface{}{userID} - where := "a.user_id=?" + where := "a.user_id=? AND (m.snoozed_until IS NULL OR m.snoozed_until <= datetime('now'))" if accountID > 0 { where += " AND m.account_id=?" args = append(args, accountID) @@ -1948,6 +1978,14 @@ func (d *DB) AddRemoteContentWhitelist(userID int64, sender string) error { return err } +func (d *DB) DeleteRemoteContentWhitelist(userID int64, sender string) error { + _, err := d.sql.Exec( + `DELETE FROM remote_content_whitelist WHERE user_id=? AND sender=?`, + userID, sender, + ) + return err +} + func (d *DB) IsRemoteContentAllowed(userID int64, sender string) (bool, error) { var count int err := d.sql.QueryRow( @@ -2126,6 +2164,204 @@ func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.Page }, nil } +// ---- Snooze ---- + +// SnoozeMessage hides a message from normal folder views until `until`, scoped to accounts +// owned by userID. +func (d *DB) SnoozeMessage(id, userID int64, until time.Time) error { + _, err := d.sql.Exec(` + UPDATE messages SET snoozed_until=? + WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`, + until.UTC().Format("2006-01-02 15:04:05"), id, userID) + return err +} + +// UnsnoozeMessage clears a message's snooze early, scoped to accounts owned by userID. +func (d *DB) UnsnoozeMessage(id, userID int64) error { + _, err := d.sql.Exec(` + UPDATE messages SET snoozed_until=NULL + WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`, + id, userID) + return err +} + +// ListSnoozedMessages returns messages currently snoozed (snoozed_until in the future), +// soonest-to-wake first. +func (d *DB) ListSnoozedMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) { + offset := (page - 1) * pageSize + const where = "a.user_id=? AND m.snoozed_until IS NOT NULL AND m.snoozed_until > datetime('now')" + var total int + d.sql.QueryRow(`SELECT COUNT(*) FROM messages m JOIN email_accounts a ON a.id=m.account_id WHERE `+where, userID).Scan(&total) + + rows, err := d.sql.Query(` + SELECT m.id, m.account_id, a.email_address, a.color, m.folder_id, f.name, + m.subject, m.from_name, m.from_email, m.body_text, + m.date, m.is_read, m.is_starred, m.has_attachment, m.snoozed_until + FROM messages m + JOIN email_accounts a ON a.id = m.account_id + JOIN folders f ON f.id = m.folder_id + WHERE `+where+` + ORDER BY m.snoozed_until ASC + LIMIT ? OFFSET ?`, userID, pageSize, offset) + if err != nil { + return nil, err + } + defer rows.Close() + var summaries []models.MessageSummary + for rows.Next() { + s := models.MessageSummary{} + var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string + var snoozedUntil sql.NullTime + if err := rows.Scan( + &s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName, + &subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc, + &s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment, &snoozedUntil, + ); err != nil { + return nil, err + } + s.Subject, _ = d.enc.Decrypt(subjectEnc) + s.FromName, _ = d.enc.Decrypt(fromNameEnc) + s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc) + bodyText, _ := d.enc.Decrypt(bodyTextEnc) + if len(bodyText) > 120 { + bodyText = bodyText[:120] + "…" + } + s.Preview = bodyText + if snoozedUntil.Valid { + s.SnoozedUntil = &snoozedUntil.Time + } + summaries = append(summaries, s) + } + if err := rows.Err(); err != nil { + return nil, err + } + d.attachLabels(summaries) + return &models.PagedMessages{ + Messages: summaries, + Total: total, + Page: page, + PageSize: pageSize, + HasMore: offset+len(summaries) < total, + }, nil +} + +// WakeExpiredSnoozes clears snoozed_until on every message whose snooze has passed, marks +// them unread (the conventional "snooze brought it back" signal), and returns the set of +// folder IDs that need their unread counts recomputed. Called by the background sweep. +func (d *DB) WakeExpiredSnoozes() ([]int64, error) { + rows, err := d.sql.Query(`SELECT DISTINCT folder_id FROM messages WHERE snoozed_until IS NOT NULL AND snoozed_until <= datetime('now')`) + if err != nil { + return nil, err + } + var folderIDs []int64 + for rows.Next() { + var fid int64 + if err := rows.Scan(&fid); err == nil { + folderIDs = append(folderIDs, fid) + } + } + rows.Close() + if len(folderIDs) == 0 { + return nil, nil + } + if _, err := d.sql.Exec(`UPDATE messages SET snoozed_until=NULL, is_read=0 WHERE snoozed_until IS NOT NULL AND snoozed_until <= datetime('now')`); err != nil { + return nil, err + } + return folderIDs, nil +} + +// ---- Send-later (scheduled sends) ---- + +// CreateScheduledSend stores a fully-composed message to be sent at s.SendAt by the +// background sweep. Returns the new row's ID. +func (d *DB) CreateScheduledSend(s *models.ScheduledSend) (int64, error) { + toJSON, _ := json.Marshal(s.To) + ccJSON, _ := json.Marshal(s.CC) + bccJSON, _ := json.Marshal(s.BCC) + fwdJSON, _ := json.Marshal(s.ForwardFromIDs) + subjectEnc, err := d.enc.Encrypt(s.Subject) + if err != nil { + return 0, err + } + bodyHTMLEnc, err := d.enc.Encrypt(s.BodyHTML) + if err != nil { + return 0, err + } + bodyTextEnc, err := d.enc.Encrypt(s.BodyText) + if err != nil { + return 0, err + } + res, err := d.sql.Exec(` + INSERT INTO scheduled_sends (user_id, account_id, to_list, cc_list, bcc_list, subject, body_html, body_text, forward_from_ids, send_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + s.UserID, s.AccountID, string(toJSON), string(ccJSON), string(bccJSON), + subjectEnc, bodyHTMLEnc, bodyTextEnc, string(fwdJSON), + s.SendAt.UTC().Format("2006-01-02 15:04:05"), + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// ListScheduledSends returns userID's pending scheduled sends, soonest first. +func (d *DB) ListScheduledSends(userID int64) ([]*models.ScheduledSend, error) { + rows, err := d.sql.Query(` + SELECT id, user_id, account_id, to_list, cc_list, bcc_list, subject, body_html, body_text, forward_from_ids, send_at, created_at + FROM scheduled_sends WHERE user_id=? ORDER BY send_at ASC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return d.scanScheduledSends(rows) +} + +// ListDueScheduledSends returns every scheduled send (across all users) whose send_at has +// passed. Called by the background sweep. +func (d *DB) ListDueScheduledSends() ([]*models.ScheduledSend, error) { + rows, err := d.sql.Query(` + SELECT id, user_id, account_id, to_list, cc_list, bcc_list, subject, body_html, body_text, forward_from_ids, send_at, created_at + FROM scheduled_sends WHERE send_at <= datetime('now') ORDER BY send_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + return d.scanScheduledSends(rows) +} + +func (d *DB) scanScheduledSends(rows *sql.Rows) ([]*models.ScheduledSend, error) { + var out []*models.ScheduledSend + for rows.Next() { + s := &models.ScheduledSend{} + var toJSON, ccJSON, bccJSON, fwdJSON, subjectEnc, bodyHTMLEnc, bodyTextEnc string + if err := rows.Scan(&s.ID, &s.UserID, &s.AccountID, &toJSON, &ccJSON, &bccJSON, + &subjectEnc, &bodyHTMLEnc, &bodyTextEnc, &fwdJSON, &s.SendAt, &s.CreatedAt); err != nil { + return nil, err + } + json.Unmarshal([]byte(toJSON), &s.To) + json.Unmarshal([]byte(ccJSON), &s.CC) + json.Unmarshal([]byte(bccJSON), &s.BCC) + json.Unmarshal([]byte(fwdJSON), &s.ForwardFromIDs) + s.Subject, _ = d.enc.Decrypt(subjectEnc) + s.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc) + s.BodyText, _ = d.enc.Decrypt(bodyTextEnc) + out = append(out, s) + } + return out, rows.Err() +} + +// DeleteScheduledSend cancels a pending scheduled send, scoped to userID. Also used by the +// background sweep (without a user check) to remove a row once it's been sent. +func (d *DB) DeleteScheduledSend(id, userID int64) error { + var err error + if userID > 0 { + _, err = d.sql.Exec(`DELETE FROM scheduled_sends WHERE id=? AND user_id=?`, id, userID) + } else { + _, err = d.sql.Exec(`DELETE FROM scheduled_sends WHERE id=?`, id) + } + return err +} + // ---- Pending IMAP ops queue ---- // PendingIMAPOp represents an IMAP write operation that needs to be applied to the server. @@ -2228,6 +2464,15 @@ func (d *DB) PurgeDeletedMessages(folderID int64, serverUIDs []uint32) (int, err return int(n), nil } +// DeleteMessageByRemoteUID removes a single locally-cached message by its provider id — +// used when a draft autosave replaces the server-side copy under a new id/UID (IMAP/JMAP +// delete-then-recreate) so the stale local row doesn't linger until the next full sync's +// purge step runs. +func (d *DB) DeleteMessageByRemoteUID(folderID int64, remoteUID string) error { + _, err := d.sql.Exec(`DELETE FROM messages WHERE folder_id=? AND remote_uid=?`, folderID, remoteUID) + return err +} + // DeleteAllFolderMessages removes all messages from a folder (used on UIDVALIDITY change). func (d *DB) DeleteAllFolderMessages(folderID int64) { d.sql.Exec(`DELETE FROM messages WHERE folder_id=?`, folderID) @@ -2293,6 +2538,30 @@ func boolToInt(b bool) int { return 0 } +// ListMessageIDsByFolder returns every message ID in a folder, newest first, scoped to +// folders owned by userID. Used by folder export (bulk mbox/zip download). +func (d *DB) ListMessageIDsByFolder(folderID, userID int64) ([]int64, error) { + rows, err := d.sql.Query(` + SELECT m.id FROM messages m + JOIN folders f ON f.id = m.folder_id + JOIN email_accounts a ON a.id = f.account_id + WHERE m.folder_id=? AND a.user_id=? + ORDER BY m.date DESC`, folderID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + // EmptyFolder deletes all messages in a folder (Trash/Spam). // Returns count deleted. func (d *DB) EmptyFolder(folderID, userID int64) (int, error) { diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..8676f3d --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,576 @@ +package db + +import ( + "path/filepath" + "testing" + "time" + + "github.com/ghostersk/gowebmail/internal/models" +) + +// newTestDB creates a fresh, migrated DB backed by a temp file (WAL mode needs a real file, +// not :memory:) and returns it along with the bootstrap admin user's ID (always 1 — Migrate +// creates it when no users exist). +func newTestDB(t *testing.T) (*DB, int64) { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + d, err := New(path, key) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { d.Close() }) + if err := d.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + return d, 1 // bootstrap admin +} + +// seedAccountAndFolder creates a minimal IMAP account + INBOX folder for userID, returning +// their IDs. +func seedAccountAndFolder(t *testing.T, d *DB, userID int64) (accountID, folderID int64) { + t.Helper() + acc := &models.EmailAccount{ + UserID: userID, Provider: models.ProviderIMAPSMTP, + EmailAddress: "user@example.com", DisplayName: "Test User", + IMAPHost: "imap.example.com", IMAPPort: 993, + SMTPHost: "smtp.example.com", SMTPPort: 587, + Color: "#4A90D9", + } + if err := d.CreateAccount(acc); err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if err := d.UpsertFolder(&models.Folder{AccountID: acc.ID, Name: "INBOX", FullPath: "INBOX", FolderType: "inbox"}); err != nil { + t.Fatalf("UpsertFolder: %v", err) + } + f, err := d.GetFolderByPath(acc.ID, "INBOX") + if err != nil || f == nil { + t.Fatalf("GetFolderByPath: %v", err) + } + return acc.ID, f.ID +} + +func seedMessage(t *testing.T, d *DB, accountID, folderID int64, remoteUID, subject string) int64 { + t.Helper() + m := &models.Message{ + AccountID: accountID, FolderID: folderID, RemoteUID: remoteUID, + Subject: subject, FromName: "Sender Name", FromEmail: "sender@example.com", + ToList: "user@example.com", BodyText: "hello world", Date: time.Now(), + } + if err := d.UpsertMessage(m); err != nil { + t.Fatalf("UpsertMessage: %v", err) + } + if m.ID == 0 { + t.Fatalf("UpsertMessage did not populate ID") + } + return m.ID +} + +// ---- Encryption round-trip ---- + +func TestMessageEncryptionRoundTrip(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + const subject = `Subject with "quotes", unicode ✉️ and a semicolon; and a % sign` + msgID := seedMessage(t, d, accountID, folderID, "100", subject) + + got, err := d.GetMessage(msgID, userID) + if err != nil || got == nil { + t.Fatalf("GetMessage: %v", err) + } + if got.Subject != subject { + t.Errorf("Subject = %q, want %q", got.Subject, subject) + } + if got.FromEmail != "sender@example.com" { + t.Errorf("FromEmail = %q", got.FromEmail) + } +} + +func TestGetMessage_WrongUserScoped(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + msgID := seedMessage(t, d, accountID, folderID, "100", "secret") + + other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + got, err := d.GetMessage(msgID, other.ID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if got != nil { + t.Errorf("expected nil for another user's message, got %+v", got) + } +} + +// ---- ListMessages / snooze filtering ---- + +func TestListMessages_ExcludesFutureSnoozed(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + visibleID := seedMessage(t, d, accountID, folderID, "1", "visible") + snoozedID := seedMessage(t, d, accountID, folderID, "2", "snoozed") + + if err := d.SnoozeMessage(snoozedID, userID, time.Now().Add(24*time.Hour)); err != nil { + t.Fatalf("SnoozeMessage: %v", err) + } + + page, err := d.ListMessages(userID, []int64{folderID}, 0, 1, 50) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if page.Total != 1 { + t.Fatalf("Total = %d, want 1 (snoozed message should be excluded)", page.Total) + } + if len(page.Messages) != 1 || page.Messages[0].ID != visibleID { + t.Fatalf("Messages = %+v, want only %d", page.Messages, visibleID) + } +} + +func TestListMessages_IncludesPastSnoozed(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + msgID := seedMessage(t, d, accountID, folderID, "1", "was snoozed") + + if err := d.SnoozeMessage(msgID, userID, time.Now().Add(24*time.Hour)); err != nil { + t.Fatalf("SnoozeMessage: %v", err) + } + // Simulate the snooze having already expired (SnoozeMessage validates nothing server-side + // about "future", so write an already-past timestamp directly). + if _, err := d.sql.Exec(`UPDATE messages SET snoozed_until=? WHERE id=?`, + time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05"), msgID); err != nil { + t.Fatalf("backdate snooze: %v", err) + } + + page, err := d.ListMessages(userID, []int64{folderID}, 0, 1, 50) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if page.Total != 1 { + t.Fatalf("Total = %d, want 1 (past-snooze message should be visible again)", page.Total) + } +} + +// ---- Snooze / unsnooze / wake ---- + +func TestSnoozeUnsnoozeRoundTrip(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + msgID := seedMessage(t, d, accountID, folderID, "1", "snooze me") + + until := time.Now().Add(2 * time.Hour) + if err := d.SnoozeMessage(msgID, userID, until); err != nil { + t.Fatalf("SnoozeMessage: %v", err) + } + snoozed, err := d.ListSnoozedMessages(userID, 1, 50) + if err != nil { + t.Fatalf("ListSnoozedMessages: %v", err) + } + if snoozed.Total != 1 || snoozed.Messages[0].ID != msgID { + t.Fatalf("ListSnoozedMessages = %+v, want [%d]", snoozed.Messages, msgID) + } + if snoozed.Messages[0].SnoozedUntil == nil { + t.Fatalf("SnoozedUntil not populated") + } + + if err := d.UnsnoozeMessage(msgID, userID); err != nil { + t.Fatalf("UnsnoozeMessage: %v", err) + } + snoozed, err = d.ListSnoozedMessages(userID, 1, 50) + if err != nil { + t.Fatalf("ListSnoozedMessages after unsnooze: %v", err) + } + if snoozed.Total != 0 { + t.Fatalf("Total = %d after unsnooze, want 0", snoozed.Total) + } +} + +func TestSnoozeMessage_WrongUserScoped(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + msgID := seedMessage(t, d, accountID, folderID, "1", "not yours") + + other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + // Attempting to snooze someone else's message must be a silent no-op (0 rows affected), + // not an error and not a mutation. + if err := d.SnoozeMessage(msgID, other.ID, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("SnoozeMessage (other user): %v", err) + } + msg, err := d.GetMessage(msgID, userID) + if err != nil || msg == nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.SnoozedUntil != nil { + t.Errorf("message got snoozed by a non-owning user: %+v", msg.SnoozedUntil) + } +} + +func TestWakeExpiredSnoozes(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + expiredID := seedMessage(t, d, accountID, folderID, "1", "expired") + futureID := seedMessage(t, d, accountID, folderID, "2", "future") + + if err := d.SnoozeMessage(expiredID, userID, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("SnoozeMessage: %v", err) + } + if _, err := d.sql.Exec(`UPDATE messages SET snoozed_until=? WHERE id=?`, + time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05"), expiredID); err != nil { + t.Fatalf("backdate: %v", err) + } + if err := d.SnoozeMessage(futureID, userID, time.Now().Add(24*time.Hour)); err != nil { + t.Fatalf("SnoozeMessage: %v", err) + } + // Mark both read=0 initially is already the UpsertMessage default; flip expired one to + // read=1 so we can prove WakeExpiredSnoozes resets it to unread. + if _, err := d.sql.Exec(`UPDATE messages SET is_read=1 WHERE id=?`, expiredID); err != nil { + t.Fatalf("mark read: %v", err) + } + + folderIDs, err := d.WakeExpiredSnoozes() + if err != nil { + t.Fatalf("WakeExpiredSnoozes: %v", err) + } + if len(folderIDs) != 1 || folderIDs[0] != folderID { + t.Fatalf("folderIDs = %v, want [%d]", folderIDs, folderID) + } + + expired, err := d.GetMessage(expiredID, userID) + if err != nil || expired == nil { + t.Fatalf("GetMessage(expired): %v", err) + } + if expired.SnoozedUntil != nil { + t.Errorf("expired message still snoozed: %+v", expired.SnoozedUntil) + } + if expired.IsRead { + t.Errorf("expired message should be marked unread on wake") + } + + // GetMessage doesn't project snoozed_until (only the Snoozed-view listing does), so check + // the future message is still excluded from the normal folder listing instead. + page, err := d.ListMessages(userID, []int64{folderID}, 0, 1, 50) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + for _, m := range page.Messages { + if m.ID == futureID { + t.Errorf("future-snoozed message reappeared in folder listing after wake sweep") + } + } +} + +// ---- Scheduled sends ---- + +func TestScheduledSendRoundTrip(t *testing.T) { + d, userID := newTestDB(t) + accountID, _ := seedAccountAndFolder(t, d, userID) + + s := &models.ScheduledSend{ + UserID: userID, AccountID: accountID, + To: []string{"a@example.com", "b@example.com"}, + CC: []string{"c@example.com"}, + Subject: `Meeting notes — "Q3 review"`, BodyHTML: "

hi

", BodyText: "hi", + ForwardFromIDs: []int64{42}, + SendAt: time.Now().Add(time.Hour), + } + id, err := d.CreateScheduledSend(s) + if err != nil { + t.Fatalf("CreateScheduledSend: %v", err) + } + if id == 0 { + t.Fatalf("CreateScheduledSend returned id=0") + } + + list, err := d.ListScheduledSends(userID) + if err != nil { + t.Fatalf("ListScheduledSends: %v", err) + } + if len(list) != 1 { + t.Fatalf("ListScheduledSends returned %d items, want 1", len(list)) + } + got := list[0] + if got.Subject != s.Subject { + t.Errorf("Subject = %q, want %q", got.Subject, s.Subject) + } + if len(got.To) != 2 || got.To[0] != "a@example.com" || got.To[1] != "b@example.com" { + t.Errorf("To = %v", got.To) + } + if len(got.CC) != 1 || got.CC[0] != "c@example.com" { + t.Errorf("CC = %v", got.CC) + } + if len(got.ForwardFromIDs) != 1 || got.ForwardFromIDs[0] != 42 { + t.Errorf("ForwardFromIDs = %v", got.ForwardFromIDs) + } + + // Not due yet (send_at is an hour out). + due, err := d.ListDueScheduledSends() + if err != nil { + t.Fatalf("ListDueScheduledSends: %v", err) + } + if len(due) != 0 { + t.Fatalf("ListDueScheduledSends = %d items, want 0 (not due yet)", len(due)) + } + + if err := d.DeleteScheduledSend(id, userID); err != nil { + t.Fatalf("DeleteScheduledSend: %v", err) + } + list, err = d.ListScheduledSends(userID) + if err != nil { + t.Fatalf("ListScheduledSends after delete: %v", err) + } + if len(list) != 0 { + t.Fatalf("ListScheduledSends after delete = %d, want 0", len(list)) + } +} + +func TestListDueScheduledSends(t *testing.T) { + d, userID := newTestDB(t) + accountID, _ := seedAccountAndFolder(t, d, userID) + + dueID, err := d.CreateScheduledSend(&models.ScheduledSend{ + UserID: userID, AccountID: accountID, To: []string{"a@example.com"}, + Subject: "due", SendAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatalf("CreateScheduledSend: %v", err) + } + // Backdate it into the past so it's due. + if _, err := d.sql.Exec(`UPDATE scheduled_sends SET send_at=? WHERE id=?`, + time.Now().Add(-time.Minute).UTC().Format("2006-01-02 15:04:05"), dueID); err != nil { + t.Fatalf("backdate: %v", err) + } + if _, err := d.CreateScheduledSend(&models.ScheduledSend{ + UserID: userID, AccountID: accountID, To: []string{"a@example.com"}, + Subject: "not due", SendAt: time.Now().Add(24 * time.Hour), + }); err != nil { + t.Fatalf("CreateScheduledSend: %v", err) + } + + due, err := d.ListDueScheduledSends() + if err != nil { + t.Fatalf("ListDueScheduledSends: %v", err) + } + if len(due) != 1 || due[0].ID != dueID { + t.Fatalf("ListDueScheduledSends = %+v, want only id=%d", due, dueID) + } +} + +func TestDeleteScheduledSend_WrongUserScoped(t *testing.T) { + d, userID := newTestDB(t) + accountID, _ := seedAccountAndFolder(t, d, userID) + id, err := d.CreateScheduledSend(&models.ScheduledSend{ + UserID: userID, AccountID: accountID, To: []string{"a@example.com"}, + Subject: "mine", SendAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatalf("CreateScheduledSend: %v", err) + } + other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := d.DeleteScheduledSend(id, other.ID); err != nil { + t.Fatalf("DeleteScheduledSend: %v", err) + } + list, err := d.ListScheduledSends(userID) + if err != nil { + t.Fatalf("ListScheduledSends: %v", err) + } + if len(list) != 1 { + t.Fatalf("scheduled send was deleted by a non-owning user; list = %+v", list) + } +} + +// ---- Labels ---- + +func TestLabelCRUDAndAssignment(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + msgID := seedMessage(t, d, accountID, folderID, "1", "label me") + + // userID (the bootstrap admin) already has the 4 seeded default labels — use a name that + // doesn't collide with those ("Important", "Personal", "Work", "ToDo"). + baseline, err := d.ListLabels(userID) + if err != nil { + t.Fatalf("ListLabels (baseline): %v", err) + } + label, err := d.CreateLabel(userID, "Project Zeta", "#e74c3c") + if err != nil { + t.Fatalf("CreateLabel: %v", err) + } + if label.ID == 0 { + t.Fatalf("CreateLabel returned id=0") + } + + if _, err := d.CreateLabel(userID, "Project Zeta", "#000000"); err == nil { + t.Errorf("expected duplicate label name to fail") + } + + if err := d.AssignLabel(msgID, label.ID, userID); err != nil { + t.Fatalf("AssignLabel: %v", err) + } + msg, err := d.GetMessage(msgID, userID) + if err != nil || msg == nil { + t.Fatalf("GetMessage: %v", err) + } + if len(msg.Labels) != 1 || msg.Labels[0].ID != label.ID { + t.Fatalf("Labels = %+v, want [%d]", msg.Labels, label.ID) + } + + if err := d.UpdateLabel(label.ID, userID, "Project Zeta Renamed", "#ff0000"); err != nil { + t.Fatalf("UpdateLabel: %v", err) + } + labels, err := d.ListLabels(userID) + if err != nil { + t.Fatalf("ListLabels: %v", err) + } + if len(labels) != len(baseline)+1 { + t.Fatalf("ListLabels = %+v, want %d entries", labels, len(baseline)+1) + } + found := false + for _, l := range labels { + if l.ID == label.ID { + found = true + if l.Name != "Project Zeta Renamed" { + t.Errorf("renamed label Name = %q", l.Name) + } + } + } + if !found { + t.Fatalf("renamed label not found in ListLabels: %+v", labels) + } + + if err := d.UnassignLabel(msgID, label.ID, userID); err != nil { + t.Fatalf("UnassignLabel: %v", err) + } + msg, err = d.GetMessage(msgID, userID) + if err != nil || msg == nil { + t.Fatalf("GetMessage: %v", err) + } + if len(msg.Labels) != 0 { + t.Fatalf("Labels after unassign = %+v, want none", msg.Labels) + } + + if err := d.DeleteLabel(label.ID, userID); err != nil { + t.Fatalf("DeleteLabel: %v", err) + } + labels, err = d.ListLabels(userID) + if err != nil { + t.Fatalf("ListLabels after delete: %v", err) + } + if len(labels) != len(baseline) { + t.Fatalf("ListLabels after delete = %+v, want back to baseline %+v", labels, baseline) + } +} + +func TestAssignLabel_CannotCrossUserBoundary(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + msgID := seedMessage(t, d, accountID, folderID, "1", "protected") + + other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + label, err := d.CreateLabel(other.ID, "Bob's label", "#123456") + if err != nil { + t.Fatalf("CreateLabel: %v", err) + } + // Bob tries to label userID's message with his own label — must be a no-op. + if err := d.AssignLabel(msgID, label.ID, other.ID); err != nil { + t.Fatalf("AssignLabel: %v", err) + } + msg, err := d.GetMessage(msgID, userID) + if err != nil || msg == nil { + t.Fatalf("GetMessage: %v", err) + } + if len(msg.Labels) != 0 { + t.Errorf("cross-user label assignment succeeded: %+v", msg.Labels) + } +} + +// ---- Folder export support ---- + +func TestListMessageIDsByFolder(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + id1 := seedMessage(t, d, accountID, folderID, "1", "one") + id2 := seedMessage(t, d, accountID, folderID, "2", "two") + + ids, err := d.ListMessageIDsByFolder(folderID, userID) + if err != nil { + t.Fatalf("ListMessageIDsByFolder: %v", err) + } + if len(ids) != 2 { + t.Fatalf("ids = %v, want 2 entries", ids) + } + got := map[int64]bool{ids[0]: true, ids[1]: true} + if !got[id1] || !got[id2] { + t.Errorf("ids = %v, want %d and %d", ids, id1, id2) + } +} + +func TestListMessageIDsByFolder_WrongUserScoped(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + seedMessage(t, d, accountID, folderID, "1", "not yours") + + other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + ids, err := d.ListMessageIDsByFolder(folderID, other.ID) + if err != nil { + t.Fatalf("ListMessageIDsByFolder: %v", err) + } + if len(ids) != 0 { + t.Errorf("non-owning user got message IDs from another user's folder: %v", ids) + } +} + +// ---- Delete / star (existing behavior, previously untested) ---- + +func TestDeleteMessage(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + msgID := seedMessage(t, d, accountID, folderID, "1", "delete me") + + if err := d.DeleteMessage(msgID, userID); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + msg, err := d.GetMessage(msgID, userID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg != nil { + t.Errorf("message still present after delete: %+v", msg) + } +} + +func TestToggleMessageStar(t *testing.T) { + d, userID := newTestDB(t) + accountID, folderID := seedAccountAndFolder(t, d, userID) + msgID := seedMessage(t, d, accountID, folderID, "1", "star me") + + starred, err := d.ToggleMessageStar(msgID, userID) + if err != nil { + t.Fatalf("ToggleMessageStar: %v", err) + } + if !starred { + t.Errorf("expected starred=true after first toggle") + } + starred, err = d.ToggleMessageStar(msgID, userID) + if err != nil { + t.Fatalf("ToggleMessageStar: %v", err) + } + if starred { + t.Errorf("expected starred=false after second toggle") + } +} diff --git a/internal/email/imap_test.go b/internal/email/imap_test.go new file mode 100644 index 0000000..76ab587 --- /dev/null +++ b/internal/email/imap_test.go @@ -0,0 +1,47 @@ +package email + +import "testing" + +// InferFolderType drives how the sync engine classifies each IMAP folder (inbox/sent/drafts/ +// trash/spam/archive/custom) — used for default folder discovery, DeleteByUID's trash-move +// target lookup, and rule actions like "mark_as_spam". Covers both the IMAP SPECIAL-USE +// attribute path (authoritative when the server sends it) and the name-guessing fallback. +func TestInferFolderType(t *testing.T) { + cases := []struct { + name string + folderName string + attrs []string + want string + }{ + {"special-use inbox", "Whatever", []string{`\Inbox`}, "inbox"}, + {"special-use sent", "Whatever", []string{`\Sent`}, "sent"}, + {"special-use drafts", "Whatever", []string{`\Drafts`}, "drafts"}, + {"special-use trash", "Whatever", []string{`\Trash`}, "trash"}, + {"special-use deleted alias", "Whatever", []string{`\Deleted`}, "trash"}, + {"special-use junk", "Whatever", []string{`\Junk`}, "spam"}, + {"special-use spam alias", "Whatever", []string{`\Spam`}, "spam"}, + {"special-use archive", "Whatever", []string{`\Archive`}, "archive"}, + {"special-use case-insensitive", "Whatever", []string{`\SENT`}, "sent"}, + {"special-use wins over misleading name", "Trash Talk", []string{`\Sent`}, "sent"}, + + {"name INBOX exact", "INBOX", nil, "inbox"}, + {"name lowercase inbox", "inbox", nil, "inbox"}, + {"name contains sent", "Sent Items", nil, "sent"}, + {"name contains draft", "Drafts", nil, "drafts"}, + {"name contains trash", "Trash", nil, "trash"}, + {"name contains deleted", "Deleted Items", nil, "trash"}, + {"name contains spam", "Spam", nil, "spam"}, + {"name contains junk", "Junk E-mail", nil, "spam"}, + {"name contains archive", "Archive", nil, "archive"}, + {"unrecognized name is custom", "Projects", nil, "custom"}, + {"gmail-style path", "[Gmail]/Sent Mail", nil, "sent"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := InferFolderType(tc.folderName, tc.attrs) + if got != tc.want { + t.Errorf("InferFolderType(%q, %v) = %q, want %q", tc.folderName, tc.attrs, got, tc.want) + } + }) + } +} diff --git a/internal/email/jmap_draft.go b/internal/email/jmap_draft.go new file mode 100644 index 0000000..afa29f1 --- /dev/null +++ b/internal/email/jmap_draft.go @@ -0,0 +1,48 @@ +package email + +import ( + "context" + "fmt" + + "github.com/ghostersk/gowebmail/internal/jmap" + gomailModels "github.com/ghostersk/gowebmail/internal/models" +) + +// SaveDraftJMAP saves req as a draft on the account's JMAP server, mirroring +// AppendToDrafts' replace-in-place behavior: if prevID is non-empty that earlier draft +// copy is deleted first (best-effort — a failure there shouldn't block saving the new +// one), then the new message is uploaded + imported into the Drafts mailbox and flagged +// $draft. Returns the new draft's email id. +func SaveDraftJMAP(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest, prevID string) (string, error) { + rawMsg, err := BuildRawMessage(account, req, nil) + if err != nil { + return "", err + } + jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken) + draftsID, err := jc.FindMailboxByRole(ctx, "drafts") + if err != nil { + return "", fmt.Errorf("jmap find Drafts folder: %w", err) + } + if prevID != "" { + _ = jc.DeleteEmail(ctx, prevID) + } + blobID, err := jc.UploadBlob(ctx, rawMsg) + if err != nil { + return "", fmt.Errorf("jmap upload draft: %w", err) + } + newID, err := jc.ImportEmail(ctx, blobID, draftsID) + if err != nil { + return "", fmt.Errorf("jmap import draft: %w", err) + } + _ = jc.SetKeyword(ctx, newID, "$draft", true) + return newID, nil +} + +// DeleteDraftJMAP deletes a previously-autosaved draft by id. +func DeleteDraftJMAP(ctx context.Context, account *gomailModels.EmailAccount, id string) error { + if id == "" { + return nil + } + jc := jmap.New(account.IMAPHost, account.EmailAddress, account.AccessToken) + return jc.DeleteEmail(ctx, id) +} diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 6c1a93a..50c6a85 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -412,6 +412,67 @@ func (c *Client) SendMail(ctx context.Context, req *models.ComposeRequest) error return nil } +func (c *Client) post(ctx context.Context, path string, body map[string]interface{}, out interface{}) error { + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, strings.NewReader(string(b))) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + errBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("graph POST %s returned %d: %s", path, resp.StatusCode, string(errBody)) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func draftBody(req *models.ComposeRequest) map[string]interface{} { + body := map[string]string{"contentType": "HTML", "content": req.BodyHTML} + if req.BodyHTML == "" { + body["contentType"] = "Text" + body["content"] = req.BodyText + } + return map[string]interface{}{ + "subject": req.Subject, + "body": body, + "toRecipients": graphRecipients(req.To), + "ccRecipients": graphRecipients(req.CC), + "bccRecipients": graphRecipients(req.BCC), + } +} + +// CreateDraft creates a new draft message (POST /me/messages, which — unlike /sendMail — +// files into Drafts instead of sending) and returns its Graph message id. +func (c *Client) CreateDraft(ctx context.Context, req *models.ComposeRequest) (string, error) { + var out struct { + ID string `json:"id"` + } + if err := c.post(ctx, "/messages", draftBody(req), &out); err != nil { + return "", err + } + return out.ID, nil +} + +// UpdateDraft overwrites an existing draft's subject/body/recipients in place. +func (c *Client) UpdateDraft(ctx context.Context, draftID string, req *models.ComposeRequest) error { + return c.patch(ctx, "/messages/"+draftID, draftBody(req)) +} + +// DeleteDraft deletes a draft message by id — used when the user closes a compose panel and +// chooses not to keep the draft that autosave already wrote to the server. +func (c *Client) DeleteDraft(ctx context.Context, draftID string) error { + return c.deleteReq(ctx, "/messages/"+draftID) +} + func graphRecipients(addrs []string) []map[string]interface{} { result := []map[string]interface{}{} for _, a := range addrs { diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 66632e7..561b5a9 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1,6 +1,8 @@ package handlers import ( + "archive/zip" + "bytes" "context" "encoding/json" "fmt" @@ -862,6 +864,176 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, map[string]bool{"ok": true}) } +// ---- Snooze ---- +// Local-only, like Labels — the message stays wherever it is on the server; snoozing just +// hides it from normal folder views client-side-equivalent (via the DB query filter) until it +// wakes. + +func (h *APIHandler) SnoozeMessage(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + var req struct { + Until string `json:"until"` // RFC3339 + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Until == "" { + h.writeError(w, http.StatusBadRequest, "until required") + return + } + until, err := time.Parse(time.RFC3339, req.Until) + if err != nil { + h.writeError(w, http.StatusBadRequest, "invalid until timestamp") + return + } + if err := h.db.SnoozeMessage(messageID, userID, until); err != nil { + h.writeError(w, http.StatusInternalServerError, "snooze failed") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) UnsnoozeMessage(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + if err := h.db.UnsnoozeMessage(messageID, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "unsnooze failed") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) SnoozedMessages(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size")) + if pageSize < 1 || pageSize > 200 { + pageSize = 50 + } + result, err := h.db.ListSnoozedMessages(userID, page, pageSize) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list snoozed") + return + } + h.writeJSON(w, result) +} + +// ---- Send-later (scheduled sends) ---- + +func (h *APIHandler) CreateScheduledSend(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct { + models.ComposeRequest + SendAt string `json:"send_at"` // RFC3339 + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + sendAt, err := time.Parse(time.RFC3339, req.SendAt) + if err != nil { + h.writeError(w, http.StatusBadRequest, "invalid send_at timestamp") + return + } + if !sendAt.After(time.Now()) { + h.writeError(w, http.StatusBadRequest, "send_at must be in the future") + return + } + if len(req.Attachments) > 0 { + h.writeError(w, http.StatusBadRequest, "scheduled sends don't support file attachments yet — forwarded messages (.eml) are fine") + return + } + account, err := h.db.GetAccount(req.AccountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusBadRequest, "account not found") + return + } + id, err := h.db.CreateScheduledSend(&models.ScheduledSend{ + UserID: userID, AccountID: req.AccountID, + To: req.To, CC: req.CC, BCC: req.BCC, + Subject: req.Subject, BodyHTML: req.BodyHTML, BodyText: req.BodyText, + ForwardFromIDs: req.ForwardFromIDs, SendAt: sendAt, + }) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to schedule send") + return + } + h.writeJSON(w, map[string]interface{}{"ok": true, "id": id}) +} + +func (h *APIHandler) ListScheduledSends(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + list, err := h.db.ListScheduledSends(userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list scheduled sends") + return + } + if list == nil { + list = []*models.ScheduledSend{} + } + h.writeJSON(w, list) +} + +func (h *APIHandler) CancelScheduledSend(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + id := pathInt64(r, "id") + if err := h.db.DeleteScheduledSend(id, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "cancel failed") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// ProcessDueScheduledSends sends every scheduled message whose time has come. Called by a +// ticker in main.go (not the syncer package, to avoid an import cycle: this needs +// sendComposedMessage, which lives here in handlers). +func (h *APIHandler) ProcessDueScheduledSends() { + due, err := h.db.ListDueScheduledSends() + if err != nil || len(due) == 0 { + return + } + for _, s := range due { + account, err := h.db.GetAccount(s.AccountID) + if err != nil || account == nil { + log.Printf("[scheduled-send] account %d not found for scheduled send %d, dropping", s.AccountID, s.ID) + h.db.DeleteScheduledSend(s.ID, 0) + continue + } + req := &models.ComposeRequest{ + AccountID: s.AccountID, To: s.To, CC: s.CC, BCC: s.BCC, + Subject: s.Subject, BodyHTML: s.BodyHTML, BodyText: s.BodyText, + ForwardFromIDs: s.ForwardFromIDs, + } + for _, fid := range req.ForwardFromIDs { + raw, filename, ferr := h.fetchMessageRawEML(s.UserID, fid) + if ferr != nil { + continue + } + req.Attachments = append(req.Attachments, models.Attachment{ + Filename: filename, ContentType: "message/rfc822", Data: raw, + }) + } + if err := h.sendComposedMessage(s.UserID, account, req, nil); err != nil { + log.Printf("[scheduled-send] send failed for scheduled send %d: %v — will retry next sweep", s.ID, err) + continue + } + h.db.DeleteScheduledSend(s.ID, 0) + } +} + +// WakeExpiredSnoozes clears snoozed_until on messages whose snooze has passed and refreshes +// folder unread counts. Called by a ticker in main.go. +func (h *APIHandler) WakeExpiredSnoozes() { + folderIDs, err := h.db.WakeExpiredSnoozes() + if err != nil || len(folderIDs) == 0 { + return + } + for _, fid := range folderIDs { + h.db.UpdateFolderCounts(fid) + } +} + func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r) messageID := pathInt64(r, "id") @@ -892,19 +1064,16 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) { // ---- Send / Reply / Forward ---- func (h *APIHandler) SendMessage(w http.ResponseWriter, r *http.Request) { - h.handleSend(w, r, "new") + h.handleSend(w, r) } func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) { - h.handleSend(w, r, "reply") + h.handleSend(w, r) } func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) { - h.handleSend(w, r, "forward") -} -func (h *APIHandler) ForwardAsAttachment(w http.ResponseWriter, r *http.Request) { - h.handleSend(w, r, "forward-attachment") + h.handleSend(w, r) } -func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) { +func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r) var req models.ComposeRequest @@ -956,46 +1125,46 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str return } - // Forward-as-attachment: fetch original message as EML and attach it - if mode == "forward-attachment" && req.ForwardFromID > 0 { - origMsg, _ := h.db.GetMessage(req.ForwardFromID, userID) - if origMsg != nil { - uid, folderPath, origAccount, iErr := h.db.GetMessageIMAPInfo(req.ForwardFromID, userID) - if iErr == nil && uid != 0 && origAccount != nil { - if c, cErr := email.Connect(context.Background(), origAccount); cErr == nil { - if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil { - safe := sanitizeFilename(origMsg.Subject) - if safe == "" { - safe = "message" - } - req.Attachments = append(req.Attachments, models.Attachment{ - Filename: safe + ".eml", - ContentType: "message/rfc822", - Data: raw, - }) - } - c.Close() - } - } + // Attach one or more original messages as .eml — independent of mode, so this works + // whether the user is composing new, replying, or forwarding. + for _, fid := range req.ForwardFromIDs { + raw, filename, ferr := h.fetchMessageRawEML(userID, fid) + if ferr != nil { + continue } + req.Attachments = append(req.Attachments, models.Attachment{ + Filename: filename, + ContentType: "message/rfc822", + Data: raw, + }) } + if err := h.sendComposedMessage(userID, account, &req, r); err != nil { + h.writeError(w, http.StatusBadGateway, err.Error()) + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// sendComposedMessage sends req on behalf of userID via account's provider (Graph API, JMAP, +// or SMTP), triggering a post-send sync so the message appears in Sent Items. Shared by the +// immediate send path (handleSend, r non-nil) and the scheduled-send background sweep +// (processDueScheduledSends, r nil — there's no HTTP request to pull audit IP/UA from). +func (h *APIHandler) sendComposedMessage(userID int64, account *models.EmailAccount, req *models.ComposeRequest, r *http.Request) error { account = h.ensureAccountTokenFresh(account) // Graph accounts (personal outlook.com) send via Graph API, not SMTP if account.Provider == models.ProviderOutlookPersonal { - if err := graphpkg.New(account).SendMail(context.Background(), &req); err != nil { + if err := graphpkg.New(account).SendMail(context.Background(), req); err != nil { log.Printf("Graph send failed account=%d user=%d: %v", req.AccountID, userID, err) - h.writeError(w, http.StatusBadGateway, err.Error()) - return + return err } // Delay slightly so Microsoft has time to save to Sent Items before we sync go func() { time.Sleep(3 * time.Second) h.syncer.TriggerAccountSync(account.ID) }() - h.writeJSON(w, map[string]bool{"ok": true}) - return + return nil } sendCtx, sendCancel := context.WithTimeout(context.Background(), 2*time.Minute) @@ -1004,17 +1173,19 @@ func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode str if account.Provider == models.ProviderJMAP { sendFn = email.SendMessageJMAP } - if err := sendFn(sendCtx, account, &req, h.newSigner(userID)); err != nil { + if err := sendFn(sendCtx, account, req, h.newSigner(userID)); err != nil { log.Printf("send failed account=%d user=%d: %v", req.AccountID, userID, err) + var ip, ua string + if r != nil { + ip, ua = middleware.ClientIP(r), r.UserAgent() + } h.db.WriteAudit(&userID, models.AuditAppError, - fmt.Sprintf("send failed account:%d – %v", req.AccountID, err), - middleware.ClientIP(r), r.UserAgent()) - h.writeError(w, http.StatusBadGateway, err.Error()) - return + fmt.Sprintf("send failed account:%d – %v", req.AccountID, err), ip, ua) + return err } // Trigger immediate sync so the sent message appears in Sent Items h.syncer.TriggerAccountSync(account.ID) - h.writeJSON(w, map[string]bool{"ok": true}) + return nil } // ---- Folders ---- @@ -1338,23 +1509,33 @@ func (h *APIHandler) StarredMessages(w http.ResponseWriter, r *http.Request) { func (h *APIHandler) DownloadEML(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r) messageID := pathInt64(r, "id") - msg, err := h.db.GetMessage(messageID, userID) - if err != nil || msg == nil { + raw, filename, err := h.fetchMessageRawEML(userID, messageID) + if err != nil { h.writeError(w, http.StatusNotFound, "message not found") return } + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("Content-Type", "message/rfc822") + w.Write(raw) +} + +// fetchMessageRawEML returns a message as raw RFC 822 bytes plus a safe ".eml" +// filename — fetched live from IMAP when possible (exact original bytes), falling back to +// reconstructing a MIME message from the stored fields for messages with no live IMAP +// backing (e.g. synced via Graph/JMAP). Shared by DownloadEML and forward-as-attachment. +func (h *APIHandler) fetchMessageRawEML(userID, messageID int64) ([]byte, string, error) { + msg, err := h.db.GetMessage(messageID, userID) + if err != nil || msg == nil { + return nil, "", fmt.Errorf("message not found") + } + filename := sanitizeFilename(msg.Subject) + ".eml" - // Try to fetch raw from IMAP first uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID) if iErr == nil && uid != 0 && account != nil { if c, cErr := email.Connect(context.Background(), account); cErr == nil { defer c.Close() if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil { - safe := sanitizeFilename(msg.Subject) + ".eml" - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe)) - w.Header().Set("Content-Type", "message/rfc822") - w.Write(raw) - return + return raw, filename, nil } } } @@ -1388,10 +1569,75 @@ func (h *APIHandler) DownloadEML(w http.ResponseWriter, r *http.Request) { buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n") buf.WriteString(msg.BodyText) } - safe := sanitizeFilename(msg.Subject) + ".eml" - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe)) - w.Header().Set("Content-Type", "message/rfc822") - w.Write([]byte(buf.String())) + return []byte(buf.String()), filename, nil +} + +// ExportFolder streams every message in a folder as a single download — either a zip of +// .eml files (default) or an mbox file — via ?format=zip|mbox. +func (h *APIHandler) ExportFolder(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + folderID := pathInt64(r, "id") + format := r.URL.Query().Get("format") + + folder, err := h.db.GetFolderByID(folderID) + if err != nil || folder == nil { + h.writeError(w, http.StatusNotFound, "folder not found") + return + } + ids, err := h.db.ListMessageIDsByFolder(folderID, userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list messages") + return + } + if len(ids) == 0 { + h.writeError(w, http.StatusBadRequest, "folder is empty") + return + } + base := sanitizeFilename(folder.Name) + + if format == "mbox" { + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.mbox"`, base)) + w.Header().Set("Content-Type", "application/mbox") + for _, id := range ids { + raw, _, err := h.fetchMessageRawEML(userID, id) + if err != nil { + continue + } + writeMboxEntry(w, raw) + } + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, base)) + w.Header().Set("Content-Type", "application/zip") + zw := zip.NewWriter(w) + defer zw.Close() + for _, id := range ids { + raw, filename, err := h.fetchMessageRawEML(userID, id) + if err != nil { + continue + } + fw, err := zw.Create(fmt.Sprintf("%d-%s", id, filename)) + if err != nil { + continue + } + fw.Write(raw) + } +} + +// writeMboxEntry appends one message to an mboxrd-format stream: a synthetic "From " envelope +// line, the raw message with any body line starting with "From " escaped by a leading '>' +// (the standard mboxrd quoting rule so mail clients don't mistake it for the next envelope). +func writeMboxEntry(w io.Writer, raw []byte) { + fmt.Fprintf(w, "From MAILER-DAEMON %s\n", time.Now().UTC().Format("Mon Jan _2 15:04:05 2006")) + for _, line := range bytes.Split(raw, []byte("\n")) { + if bytes.HasPrefix(line, []byte("From ")) { + w.Write([]byte(">")) + } + w.Write(line) + w.Write([]byte("\n")) + } + w.Write([]byte("\n")) } func sanitizeFilename(s string) string { @@ -1444,6 +1690,20 @@ func (h *APIHandler) AddRemoteContentWhitelist(w http.ResponseWriter, r *http.Re h.writeJSON(w, map[string]bool{"ok": true}) } +func (h *APIHandler) DeleteRemoteContentWhitelist(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + sender := r.URL.Query().Get("sender") + if sender == "" { + h.writeError(w, http.StatusBadRequest, "sender required") + return + } + if err := h.db.DeleteRemoteContentWhitelist(userID, sender); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to remove from whitelist") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + // ---- Empty folder (Trash/Spam) ---- func (h *APIHandler) EmptyFolder(w http.ResponseWriter, r *http.Request) { @@ -1658,7 +1918,59 @@ func (h *APIHandler) MarkFolderAllRead(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, map[string]interface{}{"ok": true, "marked": len(ops)}) } -// ---- Save draft (IMAP APPEND to Drafts) ---- +// ---- Save draft ---- + +// draftUIDFromID parses the IMAP-path DraftID string (a decimal UID) back to a uint32. +// A blank or invalid id just means "no previous draft" (uid 0 — AppendToDrafts appends +// fresh rather than trying to replace something that doesn't exist). +func draftUIDFromID(id string) uint32 { + n, _ := strconv.ParseUint(id, 10, 32) + return uint32(n) +} + +// upsertLocalDraft writes the just-saved draft into the local message cache immediately, +// instead of leaving the UI's Drafts folder showing nothing until the async background sync +// (TriggerAccountSync, below) completes its round trip to the provider — which is what made +// a successful save look like it silently did nothing. oldID is the previous draft id this +// save replaced (its stale local row, if any, is removed — for IMAP/JMAP a resave gets a new +// id, so the old row would otherwise sit as a duplicate until the next full sync's purge +// step). Best-effort: any failure here just means the real sync fills it in a bit later. +func (h *APIHandler) upsertLocalDraft(account *models.EmailAccount, req *models.ComposeRequest, oldID, newID string) { + folder, err := h.db.GetFolderByType(account.ID, "drafts") + if err != nil || folder == nil { + return + } + if oldID != "" && oldID != newID { + _ = h.db.DeleteMessageByRemoteUID(folder.ID, oldID) + } + if newID != "" { + _ = h.db.UpsertMessage(&models.Message{ + AccountID: account.ID, FolderID: folder.ID, RemoteUID: newID, + Subject: req.Subject, FromName: account.DisplayName, FromEmail: account.EmailAddress, + ToList: strings.Join(req.To, ", "), CCList: strings.Join(req.CC, ", "), BCCList: strings.Join(req.BCC, ", "), + BodyText: req.BodyText, BodyHTML: req.BodyHTML, + Date: time.Now(), IsRead: true, IsDraft: true, + }) + } + // Recompute the sidebar's folder-count badge from what's actually in the local table now, + // rather than leaving it at whatever the last full background sync happened to see — the + // real sync will overwrite this with the server's authoritative count shortly after anyway. + h.db.UpdateFolderCounts(folder.ID) +} + +// deleteLocalDraft removes the local cached copy of a discarded draft immediately, mirroring +// upsertLocalDraft — otherwise "Discard draft" looks like it did nothing until the next sync. +func (h *APIHandler) deleteLocalDraft(account *models.EmailAccount, id string) { + if id == "" { + return + } + folder, err := h.db.GetFolderByType(account.ID, "drafts") + if err != nil || folder == nil { + return + } + _ = h.db.DeleteMessageByRemoteUID(folder.ID, id) + h.db.UpdateFolderCounts(folder.ID) +} func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r) @@ -1680,50 +1992,89 @@ func (h *APIHandler) SaveDraft(w http.ResponseWriter, r *http.Request) { h.writeError(w, http.StatusBadRequest, "account not found") return } + account = h.ensureAccountTokenFresh(account) + ctx := context.Background() - // Build the MIME message bytes - var buf strings.Builder - buf.WriteString("From: " + account.EmailAddress + "\r\n") - if len(req.To) > 0 { - buf.WriteString("To: " + strings.Join(req.To, ", ") + "\r\n") + // Graph (personal outlook.com) and JMAP accounts have no real IMAP Drafts folder to + // APPEND to — email.Connect used to be called unconditionally here and simply errored + // out for both, so autosave silently failed for every non-IMAP account. Dispatch by + // provider instead, same as sendComposedMessage. + switch account.Provider { + case models.ProviderOutlookPersonal: + gc := graphpkg.New(account) + var draftID string + if req.DraftID != "" { + draftID = req.DraftID + if err := gc.UpdateDraft(ctx, draftID, &req); err != nil { + h.writeError(w, http.StatusBadGateway, "save failed: "+err.Error()) + return + } + } else { + draftID, err = gc.CreateDraft(ctx, &req) + if err != nil { + h.writeError(w, http.StatusBadGateway, "save failed: "+err.Error()) + return + } + } + h.upsertLocalDraft(account, &req, req.DraftID, draftID) + h.syncer.TriggerAccountSync(account.ID) + h.writeJSON(w, map[string]interface{}{"ok": true, "draft_id": draftID}) + return + case models.ProviderJMAP: + newID, err := email.SaveDraftJMAP(ctx, account, &req, req.DraftID) + if err != nil { + h.writeError(w, http.StatusBadGateway, "save failed: "+err.Error()) + return + } + h.upsertLocalDraft(account, &req, req.DraftID, newID) + h.syncer.TriggerAccountSync(account.ID) + h.writeJSON(w, map[string]interface{}{"ok": true, "draft_id": newID}) + return } - buf.WriteString("Subject: " + req.Subject + "\r\n") - buf.WriteString("MIME-Version: 1.0\r\n") - buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n") - buf.WriteString(req.BodyHTML) - raw := []byte(buf.String()) + raw, err := email.BuildRawMessage(account, &req, nil) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "build message failed: "+err.Error()) + return + } - c, err := email.Connect(context.Background(), account) + c, err := email.Connect(ctx, account) if err != nil { h.writeError(w, http.StatusBadGateway, "could not connect to mailbox") return } defer c.Close() - draftsFolder, newUID, err := c.AppendToDrafts(raw, req.DraftUID) + draftsFolder, newUID, err := c.AppendToDrafts(raw, draftUIDFromID(req.DraftID)) if err != nil { h.writeError(w, http.StatusBadGateway, "save failed: "+err.Error()) return } - if draftsFolder != "" { - // Trigger a sync of the drafts folder to pick up the saved draft - h.syncer.TriggerAccountSync(account.ID) + if draftsFolder == "" { + h.writeError(w, http.StatusBadGateway, "no Drafts folder found for this account") + return } - h.writeJSON(w, map[string]interface{}{"ok": true, "draft_uid": newUID}) + draftID := "" + if newUID != 0 { + draftID = strconv.FormatUint(uint64(newUID), 10) + } + h.upsertLocalDraft(account, &req, req.DraftID, draftID) + // Trigger a sync of the drafts folder to reconcile with the server's real state + h.syncer.TriggerAccountSync(account.ID) + + h.writeJSON(w, map[string]interface{}{"ok": true, "draft_id": draftID}) } -// DiscardDraft deletes a previously-autosaved draft (identified by its IMAP UID, returned -// from an earlier SaveDraft call) from the account's Drafts folder — used when the user -// closes a compose panel and chooses not to keep the draft that autosave already wrote to -// the server. +// DiscardDraft deletes a previously-autosaved draft (identified by the id returned from an +// earlier SaveDraft call) — used when the user closes a compose panel and chooses not to +// keep the draft that autosave already wrote to the server. func (h *APIHandler) DiscardDraft(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r) var req struct { AccountID int64 `json:"account_id"` - DraftUID uint32 `json:"draft_uid"` + DraftID string `json:"draft_id"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DraftUID == 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.DraftID == "" { h.writeError(w, http.StatusBadRequest, "invalid request") return } @@ -1732,16 +2083,39 @@ func (h *APIHandler) DiscardDraft(w http.ResponseWriter, r *http.Request) { h.writeError(w, http.StatusBadRequest, "account not found") return } - c, err := email.Connect(context.Background(), account) + account = h.ensureAccountTokenFresh(account) + ctx := context.Background() + + switch account.Provider { + case models.ProviderOutlookPersonal: + if err := graphpkg.New(account).DeleteDraft(ctx, req.DraftID); err != nil { + h.writeError(w, http.StatusBadGateway, "delete failed: "+err.Error()) + return + } + h.deleteLocalDraft(account, req.DraftID) + h.writeJSON(w, map[string]bool{"ok": true}) + return + case models.ProviderJMAP: + if err := email.DeleteDraftJMAP(ctx, account, req.DraftID); err != nil { + h.writeError(w, http.StatusBadGateway, "delete failed: "+err.Error()) + return + } + h.deleteLocalDraft(account, req.DraftID) + h.writeJSON(w, map[string]bool{"ok": true}) + return + } + + c, err := email.Connect(ctx, account) if err != nil { h.writeError(w, http.StatusBadGateway, "could not connect to mailbox") return } defer c.Close() - if err := c.DiscardDraftUID(req.DraftUID); err != nil { + if err := c.DiscardDraftUID(draftUIDFromID(req.DraftID)); err != nil { h.writeError(w, http.StatusBadGateway, "delete failed: "+err.Error()) return } + h.deleteLocalDraft(account, req.DraftID) h.syncer.TriggerAccountSync(account.ID) h.writeJSON(w, map[string]bool{"ok": true}) } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go new file mode 100644 index 0000000..86ea4b1 --- /dev/null +++ b/internal/handlers/api_test.go @@ -0,0 +1,377 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/gorilla/mux" + + "github.com/ghostersk/gowebmail/internal/db" + "github.com/ghostersk/gowebmail/internal/middleware" + "github.com/ghostersk/gowebmail/internal/models" +) + +// newTestHandler builds an APIHandler backed by a fresh, migrated temp-file DB, with no +// syncer/cfg — sufficient for the local-only handlers under test here (Labels, Snooze, +// Send-later, Folder export), none of which touch IMAP/Graph/JMAP or config. +func newTestHandler(t *testing.T) (*APIHandler, *db.DB, int64) { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + d, err := db.New(path, key) + if err != nil { + t.Fatalf("db.New: %v", err) + } + t.Cleanup(func() { d.Close() }) + if err := d.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + return &APIHandler{db: d}, d, 1 // bootstrap admin +} + +func seedTestAccountAndFolder(t *testing.T, d *db.DB, userID int64) (accountID, folderID int64) { + t.Helper() + acc := &models.EmailAccount{ + UserID: userID, Provider: models.ProviderIMAPSMTP, + EmailAddress: "user@example.com", DisplayName: "Test User", Color: "#4A90D9", + } + if err := d.CreateAccount(acc); err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if err := d.UpsertFolder(&models.Folder{AccountID: acc.ID, Name: "INBOX", FullPath: "INBOX", FolderType: "inbox"}); err != nil { + t.Fatalf("UpsertFolder: %v", err) + } + f, err := d.GetFolderByPath(acc.ID, "INBOX") + if err != nil || f == nil { + t.Fatalf("GetFolderByPath: %v", err) + } + return acc.ID, f.ID +} + +func seedTestMessage(t *testing.T, d *db.DB, accountID, folderID int64, remoteUID, subject string) int64 { + t.Helper() + m := &models.Message{ + AccountID: accountID, FolderID: folderID, RemoteUID: remoteUID, + Subject: subject, FromName: "Sender", FromEmail: "sender@example.com", + ToList: "user@example.com", BodyText: "hello", Date: time.Now(), + } + if err := d.UpsertMessage(m); err != nil { + t.Fatalf("UpsertMessage: %v", err) + } + return m.ID +} + +// authedRequest builds a request carrying userID the way RequireAuth middleware would (via +// context), with mux path vars set directly (bypassing the router) and an optional JSON body. +func authedRequest(t *testing.T, method, target string, userID int64, vars map[string]string, body interface{}) *http.Request { + t.Helper() + var r *http.Request + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + r = httptest.NewRequest(method, target, bytes.NewReader(b)) + } else { + r = httptest.NewRequest(method, target, nil) + } + ctx := context.WithValue(r.Context(), middleware.UserIDKey, userID) + r = r.WithContext(ctx) + if vars != nil { + r = mux.SetURLVars(r, vars) + } + return r +} + +func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder, v interface{}) { + t.Helper() + if err := json.NewDecoder(rec.Body).Decode(v); err != nil { + t.Fatalf("decode response %q: %v", rec.Body.String(), err) + } +} + +// ---- Labels ---- + +func TestCreateAndListLabels(t *testing.T) { + h, d, userID := newTestHandler(t) + baseline, err := d.ListLabels(userID) + if err != nil { + t.Fatalf("ListLabels (baseline): %v", err) + } + + rec := httptest.NewRecorder() + h.CreateLabel(rec, authedRequest(t, "POST", "/api/labels", userID, nil, map[string]string{"Name": "Project Zeta", "Color": "#abcdef"})) + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Fatalf("CreateLabel status = %d, body = %s", rec.Code, rec.Body.String()) + } + var created models.Label + decodeJSON(t, rec, &created) + if created.ID == 0 || created.Name != "Project Zeta" { + t.Fatalf("created label = %+v", created) + } + + rec = httptest.NewRecorder() + h.ListLabels(rec, authedRequest(t, "GET", "/api/labels", userID, nil, nil)) + var labels []models.Label + decodeJSON(t, rec, &labels) + if len(labels) != len(baseline)+1 { + t.Fatalf("ListLabels = %+v, want %d entries", labels, len(baseline)+1) + } +} + +func TestCreateLabel_MissingFields(t *testing.T) { + h, _, userID := newTestHandler(t) + rec := httptest.NewRecorder() + h.CreateLabel(rec, authedRequest(t, "POST", "/api/labels", userID, nil, map[string]string{"Name": "", "Color": "#fff"})) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +// ---- Snooze ---- + +func TestSnoozeMessage_Handler(t *testing.T) { + h, d, userID := newTestHandler(t) + accountID, folderID := seedTestAccountAndFolder(t, d, userID) + msgID := seedTestMessage(t, d, accountID, folderID, "1", "snooze via handler") + + until := time.Now().Add(time.Hour).Format(time.RFC3339) + rec := httptest.NewRecorder() + vars := map[string]string{"id": itoa(msgID)} + h.SnoozeMessage(rec, authedRequest(t, "PUT", "/api/messages/"+itoa(msgID)+"/snooze", userID, vars, map[string]string{"until": until})) + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Fatalf("SnoozeMessage status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + h.SnoozedMessages(rec, authedRequest(t, "GET", "/api/messages/snoozed", userID, nil, nil)) + var page models.PagedMessages + decodeJSON(t, rec, &page) + if page.Total != 1 || len(page.Messages) != 1 || page.Messages[0].ID != msgID { + t.Fatalf("SnoozedMessages = %+v", page) + } + + rec = httptest.NewRecorder() + h.UnsnoozeMessage(rec, authedRequest(t, "DELETE", "/api/messages/"+itoa(msgID)+"/snooze", userID, vars, nil)) + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Fatalf("UnsnoozeMessage status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + h.SnoozedMessages(rec, authedRequest(t, "GET", "/api/messages/snoozed", userID, nil, nil)) + decodeJSON(t, rec, &page) + if page.Total != 0 { + t.Fatalf("SnoozedMessages after unsnooze = %+v, want empty", page) + } +} + +func TestSnoozeMessage_RejectsMissingUntil(t *testing.T) { + h, d, userID := newTestHandler(t) + accountID, folderID := seedTestAccountAndFolder(t, d, userID) + msgID := seedTestMessage(t, d, accountID, folderID, "1", "no until") + + rec := httptest.NewRecorder() + vars := map[string]string{"id": itoa(msgID)} + h.SnoozeMessage(rec, authedRequest(t, "PUT", "/api/messages/"+itoa(msgID)+"/snooze", userID, vars, map[string]string{})) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +// ---- Send-later ---- + +func TestCreateScheduledSend_RejectsPastDate(t *testing.T) { + h, d, userID := newTestHandler(t) + accountID, _ := seedTestAccountAndFolder(t, d, userID) + + body := map[string]interface{}{ + "account_id": accountID, "to": []string{"a@example.com"}, + "subject": "hi", "send_at": time.Now().Add(-time.Hour).Format(time.RFC3339), + } + rec := httptest.NewRecorder() + h.CreateScheduledSend(rec, authedRequest(t, "POST", "/api/send-later", userID, nil, body)) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestCreateScheduledSend_RejectsFileAttachments(t *testing.T) { + h, d, userID := newTestHandler(t) + accountID, _ := seedTestAccountAndFolder(t, d, userID) + + body := map[string]interface{}{ + "account_id": accountID, "to": []string{"a@example.com"}, + "subject": "hi", "send_at": time.Now().Add(time.Hour).Format(time.RFC3339), + "attachments": []map[string]string{{"filename": "x.pdf", "content_type": "application/pdf"}}, + } + rec := httptest.NewRecorder() + h.CreateScheduledSend(rec, authedRequest(t, "POST", "/api/send-later", userID, nil, body)) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestScheduledSend_CreateListCancel(t *testing.T) { + h, d, userID := newTestHandler(t) + accountID, _ := seedTestAccountAndFolder(t, d, userID) + + body := map[string]interface{}{ + "account_id": accountID, "to": []string{"a@example.com"}, + "subject": "Scheduled", "send_at": time.Now().Add(time.Hour).Format(time.RFC3339), + } + rec := httptest.NewRecorder() + h.CreateScheduledSend(rec, authedRequest(t, "POST", "/api/send-later", userID, nil, body)) + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Fatalf("CreateScheduledSend status = %d, body = %s", rec.Code, rec.Body.String()) + } + var created struct { + OK bool `json:"ok"` + ID int64 `json:"id"` + } + decodeJSON(t, rec, &created) + if !created.OK || created.ID == 0 { + t.Fatalf("CreateScheduledSend result = %+v", created) + } + + rec = httptest.NewRecorder() + h.ListScheduledSends(rec, authedRequest(t, "GET", "/api/scheduled-sends", userID, nil, nil)) + var list []models.ScheduledSend + decodeJSON(t, rec, &list) + if len(list) != 1 || list[0].ID != created.ID { + t.Fatalf("ListScheduledSends = %+v", list) + } + + rec = httptest.NewRecorder() + vars := map[string]string{"id": itoa(created.ID)} + h.CancelScheduledSend(rec, authedRequest(t, "DELETE", "/api/scheduled-sends/"+itoa(created.ID), userID, vars, nil)) + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Fatalf("CancelScheduledSend status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + h.ListScheduledSends(rec, authedRequest(t, "GET", "/api/scheduled-sends", userID, nil, nil)) + decodeJSON(t, rec, &list) + if len(list) != 0 { + t.Fatalf("ListScheduledSends after cancel = %+v, want empty", list) + } +} + +// ---- Folder export ---- + +func TestExportFolder_Zip(t *testing.T) { + h, d, userID := newTestHandler(t) + accountID, folderID := seedTestAccountAndFolder(t, d, userID) + seedTestMessage(t, d, accountID, folderID, "1", "one") + seedTestMessage(t, d, accountID, folderID, "2", "two") + + rec := httptest.NewRecorder() + vars := map[string]string{"id": itoa(folderID)} + target := "/api/folders/" + itoa(folderID) + "/export?format=zip" + h.ExportFolder(rec, authedRequest(t, "GET", target, userID, vars, nil)) + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Fatalf("ExportFolder status = %d, body = %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/zip" { + t.Errorf("Content-Type = %q", ct) + } + body := rec.Body.Bytes() + if len(body) < 2 || string(body[:2]) != "PK" { + t.Errorf("body doesn't look like a zip (got %d bytes, prefix %q)", len(body), body[:min(4, len(body))]) + } +} + +func TestExportFolder_Mbox(t *testing.T) { + h, d, userID := newTestHandler(t) + accountID, folderID := seedTestAccountAndFolder(t, d, userID) + seedTestMessage(t, d, accountID, folderID, "1", "one") + seedTestMessage(t, d, accountID, folderID, "2", "two") + + rec := httptest.NewRecorder() + vars := map[string]string{"id": itoa(folderID)} + target := "/api/folders/" + itoa(folderID) + "/export?format=mbox" + h.ExportFolder(rec, authedRequest(t, "GET", target, userID, vars, nil)) + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Fatalf("ExportFolder status = %d, body = %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/mbox" { + t.Errorf("Content-Type = %q", ct) + } + body := rec.Body.String() + count := bytesCount(body, "From MAILER-DAEMON") + if count != 2 { + t.Errorf("mbox has %d envelope lines, want 2; body:\n%s", count, body) + } +} + +func TestExportFolder_EmptyFolderRejected(t *testing.T) { + h, d, userID := newTestHandler(t) + _, folderID := seedTestAccountAndFolder(t, d, userID) + + rec := httptest.NewRecorder() + vars := map[string]string{"id": itoa(folderID)} + h.ExportFolder(rec, authedRequest(t, "GET", "/api/folders/"+itoa(folderID)+"/export", userID, vars, nil)) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestExportFolder_WrongUserScoped(t *testing.T) { + h, d, userID := newTestHandler(t) + accountID, folderID := seedTestAccountAndFolder(t, d, userID) + seedTestMessage(t, d, accountID, folderID, "1", "not yours") + + other, err := d.CreateUser("bob", "bob@example.com", "password123", models.RoleUser) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + rec := httptest.NewRecorder() + vars := map[string]string{"id": itoa(folderID)} + h.ExportFolder(rec, authedRequest(t, "GET", "/api/folders/"+itoa(folderID)+"/export", other.ID, vars, nil)) + if rec.Code != http.StatusBadRequest { + t.Errorf("non-owning user's export status = %d, want %d (folder empty for them); body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +// ---- small local helpers ---- + +func itoa(id int64) string { + if id == 0 { + return "0" + } + neg := id < 0 + if neg { + id = -id + } + var buf [20]byte + i := len(buf) + for id > 0 { + i-- + buf[i] = byte('0' + id%10) + id /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +func bytesCount(s, substr string) int { + count := 0 + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + count++ + i += len(substr) - 1 + } + } + return count +} diff --git a/internal/models/models.go b/internal/models/models.go index 20eda16..b8e9e3f 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -197,6 +197,7 @@ type Message struct { IsStarred bool `json:"is_starred"` IsDraft bool `json:"is_draft"` HasAttachment bool `json:"has_attachment"` + SnoozedUntil *time.Time `json:"snoozed_until,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` Labels []Label `json:"labels,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -218,10 +219,29 @@ type MessageSummary struct { IsRead bool `json:"is_read"` IsStarred bool `json:"is_starred"` HasAttachment bool `json:"has_attachment"` + SnoozedUntil *time.Time `json:"snoozed_until,omitempty"` Size int64 `json:"size,omitempty"` // approximate; only populated by search results Labels []Label `json:"labels,omitempty"` } +// ScheduledSend is a fully-composed message held until SendAt, delivered by the background +// sweep via the same send path as an immediate send. No raw file attachments in v1 — only +// forwarded-message .eml attachments (ForwardFromIDs). +type ScheduledSend struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + AccountID int64 `json:"account_id"` + To []string `json:"to"` + CC []string `json:"cc,omitempty"` + BCC []string `json:"bcc,omitempty"` + Subject string `json:"subject"` + BodyHTML string `json:"body_html"` + BodyText string `json:"body_text"` + ForwardFromIDs []int64 `json:"forward_from_ids,omitempty"` + SendAt time.Time `json:"send_at"` + CreatedAt time.Time `json:"created_at"` +} + // ---- Compose ---- // ComposeRequest is the payload for sending/replying/forwarding. @@ -234,14 +254,18 @@ type ComposeRequest struct { BodyHTML string `json:"body_html"` BodyText string `json:"body_text"` // For reply/forward - InReplyToID int64 `json:"in_reply_to_id,omitempty"` - ForwardFromID int64 `json:"forward_from_id,omitempty"` + InReplyToID int64 `json:"in_reply_to_id,omitempty"` + // ForwardFromIDs: each message here is fetched as a raw .eml and attached to the outgoing + // message — independent of mode (new/reply/forward), so a user can attach one or more + // original emails to any compose session, not just a dedicated "forward as attachment" one. + ForwardFromIDs []int64 `json:"forward_from_ids,omitempty"` // Attachments: populated from multipart/form-data or inline base64 - Attachments []Attachment `json:"attachments,omitempty"` - // DraftUID is the IMAP UID of this compose session's previously-autosaved draft (0 if - // never saved). A resave deletes that copy before appending the new one, so repeated - // autosaves replace the draft in place instead of piling up duplicates. - DraftUID uint32 `json:"draft_uid,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` + // DraftID identifies this compose session's previously-autosaved draft ("" if never + // saved) — an IMAP UID, Graph message id, or JMAP email id depending on the account's + // provider, opaque to the caller. A resave replaces that copy in place (delete-then- + // recreate for IMAP/JMAP, PATCH for Graph) instead of piling up duplicates. + DraftID string `json:"draft_id,omitempty"` } // ---- Search ---- diff --git a/internal/syncer/rules.go b/internal/syncer/rules.go index 7461b21..7410280 100644 --- a/internal/syncer/rules.go +++ b/internal/syncer/rules.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "strings" "github.com/ghostersk/gowebmail/internal/db" "github.com/ghostersk/gowebmail/internal/email" @@ -84,7 +85,6 @@ func splitAndTrim(s string) []string { } func trimLower(s string) string { - // minimal trim, avoids pulling in strings just for this start, end := 0, len(s) for start < end && (s[start] == ' ' || s[start] == '\t') { start++ @@ -92,7 +92,7 @@ func trimLower(s string) string { for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { end-- } - return s[start:end] + return strings.ToLower(s[start:end]) } func parseUID(s string) uint32 { diff --git a/internal/syncer/rules_test.go b/internal/syncer/rules_test.go new file mode 100644 index 0000000..34853cc --- /dev/null +++ b/internal/syncer/rules_test.go @@ -0,0 +1,131 @@ +package syncer + +import ( + "testing" + + "github.com/ghostersk/gowebmail/internal/models" +) + +// ---- matchRule ---- + +func TestMatchRule_NoActiveRules(t *testing.T) { + msg := &models.Message{Subject: "hello"} + if got := matchRule(msg, "me@example.com", nil); got != nil { + t.Errorf("matchRule with no rules = %+v, want nil", got) + } +} + +func TestMatchRule_SubjectContains(t *testing.T) { + msg := &models.Message{FromEmail: "boss@work.com", Subject: "Re: Invoice #42", BodyText: "please pay"} + active := []models.Rule{ + {ID: 1, Priority: 1, MatchType: "all", Action: "move_to_folder", ActionValue: "Finance", + Conditions: []models.RuleCondition{{Field: "subject", Op: "contains", Value: "invoice"}}}, + } + got := matchRule(msg, "me@example.com", active) + if got == nil { + t.Fatalf("matchRule = nil, want rule 1 to match") + } + if got.ID != 1 || got.Action != "move_to_folder" || got.ActionValue != "Finance" { + t.Errorf("matchRule = %+v", got) + } +} + +func TestMatchRule_ReturnsFirstMatchByPriority(t *testing.T) { + msg := &models.Message{FromEmail: "newsletter@shop.com", Subject: "50% off everything"} + active := []models.Rule{ + {ID: 2, Priority: 2, MatchType: "all", Action: "delete", + Conditions: []models.RuleCondition{{Field: "subject", Op: "contains", Value: "off"}}}, + {ID: 1, Priority: 1, MatchType: "all", Action: "mark_as_spam", + Conditions: []models.RuleCondition{{Field: "from", Op: "contains", Value: "shop.com"}}}, + } + // Match() iterates in the slice's given order and returns the first hit — callers + // (ListActiveRules) are documented to pre-sort by priority ascending, so put rule 1 + // (priority 1) first here to prove matchRule returns it, not rule 2. + active[0], active[1] = active[1], active[0] + got := matchRule(msg, "me@example.com", active) + if got == nil || got.ID != 1 { + t.Fatalf("matchRule = %+v, want rule with ID=1 (lower priority number, listed first)", got) + } +} + +func TestMatchRule_NoConditionsMatch(t *testing.T) { + msg := &models.Message{FromEmail: "friend@example.com", Subject: "hi"} + active := []models.Rule{ + {ID: 1, Priority: 1, MatchType: "all", Action: "delete", + Conditions: []models.RuleCondition{{Field: "subject", Op: "contains", Value: "invoice"}}}, + } + if got := matchRule(msg, "me@example.com", active); got != nil { + t.Errorf("matchRule = %+v, want nil (no condition matches)", got) + } +} + +// ---- recipientType / containsAddress ---- + +func TestRecipientType(t *testing.T) { + cases := []struct { + name string + msg *models.Message + want string + }{ + {"plain to", &models.Message{ToList: "me@example.com"}, "to"}, + {"in cc", &models.Message{CCList: "me@example.com, other@example.com"}, "cc"}, + {"in bcc", &models.Message{BCCList: "me@example.com"}, "bcc"}, + {"cc checked before bcc", &models.Message{CCList: "me@example.com", BCCList: "me@example.com"}, "cc"}, + {"not in cc/bcc falls back to to", &models.Message{CCList: "someoneelse@example.com"}, "to"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := recipientType(tc.msg, "me@example.com") + if got != tc.want { + t.Errorf("recipientType = %q, want %q", got, tc.want) + } + }) + } +} + +func TestContainsAddress(t *testing.T) { + cases := []struct { + list, addr string + want bool + }{ + {"a@x.com, b@x.com", "b@x.com", true}, + {"a@x.com,b@x.com", "b@x.com", true}, // no space after comma + {" a@x.com , b@x.com ", "b@x.com", true}, + {"a@x.com, b@x.com", "c@x.com", false}, + {"", "a@x.com", false}, + {"User@Example.com", "user@example.com", true}, // case-insensitive match + } + for _, tc := range cases { + if got := containsAddress(tc.list, tc.addr); got != tc.want { + t.Errorf("containsAddress(%q, %q) = %v, want %v", tc.list, tc.addr, got, tc.want) + } + } +} + +func TestSplitAndTrim(t *testing.T) { + got := splitAndTrim(" A@x.com , B@x.com,C@x.com ") + want := []string{"a@x.com", "b@x.com", "c@x.com"} + if len(got) != len(want) { + t.Fatalf("splitAndTrim = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("splitAndTrim[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestTrimLower(t *testing.T) { + if got := trimLower(" MiXeD Case\t"); got != "mixed case" { + t.Errorf("trimLower = %q", got) + } +} + +func TestParseUID(t *testing.T) { + if got := parseUID("12345"); got != 12345 { + t.Errorf("parseUID(\"12345\") = %d, want 12345", got) + } + if got := parseUID("not-a-number"); got != 0 { + t.Errorf("parseUID(garbage) = %d, want 0", got) + } +} diff --git a/web/static/css/gowebmail.css b/web/static/css/gowebmail.css index 7855df9..9d383d4 100644 --- a/web/static/css/gowebmail.css +++ b/web/static/css/gowebmail.css @@ -30,6 +30,10 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM San .toast.error{border-color:rgba(239,68,68,.4);background:rgba(239,68,68,.08);color:#fca5a5} .toast.warn{border-color:rgba(245,158,11,.4);background:rgba(245,158,11,.08);color:#fde68a} @keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}} +.toast-undo{display:flex;align-items:center;gap:14px;max-width:none} +.toast-undo-btn{background:none;border:none;color:var(--accent);font-weight:700;font-size:13px; + cursor:pointer;flex-shrink:0;padding:0} +.toast-undo-btn:hover{text-decoration:underline} /* ---- Context menu ---- */ .ctx-menu{position:fixed;z-index:200;background:var(--surface2);border:1px solid var(--border2); @@ -265,13 +269,14 @@ body.app-page{overflow:hidden} .message-item.unread.active{background:var(--accent-dim)} .message-item.unread.active::before{display:none} .msg-unread-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:transparent} -.message-item.unread .msg-unread-dot{background:var(--accent);box-shadow:0 0 0 2px var(--accent-glow)} +.message-item.unread .msg-unread-dot,.thread-sibling-row.unread .msg-unread-dot{background:var(--accent);box-shadow:0 0 0 2px var(--accent-glow)} /* Compact 2-line row (default): sender+date, then subject–preview with trailing icons */ .msg-top{display:flex;align-items:center;gap:6px;margin-bottom:1px} .msg-date{font-size:11px;color:var(--muted);flex-shrink:0} .msg-line2{display:flex;align-items:center;gap:6px} .msg-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:1.4} .msg-subject{color:var(--text2)} +.msg-thread-count{color:var(--muted);font-size:11px;font-weight:600} .msg-preview{color:var(--muted)} .msg-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0} .msg-icons{display:flex;align-items:center;gap:4px;flex-shrink:0} @@ -491,6 +496,19 @@ body.admin-page{overflow:auto;background:var(--bg)} .attachment-chip:hover{background:var(--border2)} .attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px; padding:8px 14px;border-bottom:1px solid var(--border)} +.thread-btn-wrap{position:relative;display:inline-flex;align-items:center} +.thread-dropdown{position:absolute;top:calc(100% + 6px);left:0;z-index:250; + background:var(--surface2);border:1px solid var(--border2);border-radius:8px; + box-shadow:0 8px 28px rgba(0,0,0,.5);min-width:260px;max-width:360px; + max-height:320px;overflow-y:auto;padding:8px} +.thread-siblings-title{font-size:10px;text-transform:uppercase;letter-spacing:.6px; + color:var(--muted);margin-bottom:6px} +.thread-sibling-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px; + cursor:pointer;font-size:12px;color:var(--text2)} +.thread-sibling-row:hover{background:var(--surface3)} +.thread-sibling-row.active{background:var(--accent-dim);color:var(--accent)} +.thread-sibling-from{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.thread-sibling-date{color:var(--muted);font-size:11px;flex-shrink:0} /* Drag-and-drop compose overlay */ .compose-dialog.drag-over{outline:3px dashed var(--accent);outline-offset:-4px;} @@ -508,6 +526,24 @@ body.admin-page{overflow:auto;background:var(--bg)} .tag-remove:hover{color:var(--text)} .tag-input{background:none;border:none;outline:none;color:var(--text);font-size:13px; font-family:inherit;min-width:80px;flex:1;padding:1px 0;pointer-events:all;cursor:text} +.compose-tag-field{position:relative} +.contact-suggest{position:absolute;top:100%;left:12px;right:12px;z-index:50; + background:var(--surface2);border:1px solid var(--border2);border-radius:8px; + box-shadow:0 8px 24px rgba(0,0,0,.25);overflow:hidden;margin-top:2px} +.contact-suggest-row{display:flex;align-items:center;gap:8px;padding:7px 10px; + cursor:pointer;font-size:12px} +.contact-suggest-row:hover,.contact-suggest-row.active{background:var(--surface3)} +.contact-suggest-name{color:var(--text);flex-shrink:0;max-width:45%;overflow:hidden; + text-overflow:ellipsis;white-space:nowrap} +.contact-suggest-email{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* ── Date/time presets (snooze / send later) ─────────────────────── */ +.datetime-presets{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap} +.datetime-preset-btn{padding:5px 10px;background:var(--surface3);border:1px solid var(--border2); + border-radius:14px;color:var(--text2);font-size:12px;cursor:pointer;font-family:inherit} +.datetime-preset-btn:hover{background:var(--surface2);color:var(--text)} +#inline-datetime-input{width:100%;padding:7px 9px;background:var(--surface3);border:1px solid var(--border2); + border-radius:6px;color:var(--text);font-family:inherit;font-size:13px} /* ── Settings: connected-accounts list (Accounts tab) ──────────── */ .acct-row{display:flex;align-items:center;gap:8px;padding:9px 8px;border-radius:6px; diff --git a/web/static/js/admin.js b/web/static/js/admin.js index f9aad9b..26ff791 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -9,7 +9,7 @@ const adminRoutes = { function navigate(path) { history.pushState({}, '', path); - document.querySelectorAll('.admin-nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === path)); + document.querySelectorAll('.admin-nav a').forEach(a => { const on = a.getAttribute('href') === path; a.classList.toggle('active', on); if (on) a.setAttribute('aria-current','page'); else a.removeAttribute('aria-current'); }); const fn = adminRoutes[path]; if (fn) fn(); } @@ -35,7 +35,7 @@ async function renderUsers() {
-