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() {
-
+
New User
@@ -346,7 +346,7 @@ function eventBadge(evt) {
// Boot: detect current page from URL
(function() {
const path = location.pathname;
- 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();
else renderUsers();
@@ -385,9 +385,9 @@ async function renderSecurity() {
-
+
-
Block IP Address
+
Block IP Address
diff --git a/web/static/js/app.js b/web/static/js/app.js
index 8329715..845731b 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -6,7 +6,7 @@ const S = {
folders: [], messages: [], totalMessages: 0, labels: [],
currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox',
currentMessage: null, selectedMessageId: null,
- searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null,
+ searchQuery: '', composeMode: 'new', composeReplyToId: null,
filterUnread: false, filterAttachment: false,
sortOrder: 'date-desc', // 'date-desc' | 'date-asc' | 'size-desc'
uiPrefs: {}, // server-persisted UI preferences (collapsed accounts/folders etc.)
@@ -114,11 +114,13 @@ function toggleViewDropdown(e) {
if (!menu) return;
const isOpen = menu.style.display !== 'none';
menu.style.display = isOpen ? 'none' : 'block';
+ document.getElementById('view-dropdown-btn')?.setAttribute('aria-expanded', String(!isOpen));
if (!isOpen) setTimeout(() => document.addEventListener('click', closeViewDropdown, { once: true }), 0);
}
function closeViewDropdown() {
const menu = document.getElementById('view-dropdown-menu');
if (menu) menu.style.display = 'none';
+ document.getElementById('view-dropdown-btn')?.setAttribute('aria-expanded', 'false');
}
// ── Boot ───────────────────────────────────────────────────────────────────
@@ -136,6 +138,7 @@ async function init() {
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
if (uiPrefsRaw && typeof uiPrefsRaw === 'object') S.uiPrefs = uiPrefsRaw;
applyViewPrefs();
+ ensureContactsCache(); // warms the cache isRemoteContentAllowed()'s "Only from Contacts" check reads synchronously
await loadAccounts();
await loadFolders();
@@ -155,30 +158,30 @@ async function init() {
}
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
- // Handle actions from full-page message/compose views
- if (p.get('action') === 'reply' && p.get('id')) {
- history.replaceState({},'','/');
- const id = parseInt(p.get('id'));
- // Load the message then open reply
- setTimeout(async () => {
- const msg = await api('GET', '/messages/'+id);
- if (msg) { S.currentMessage = msg; openReplyTo(id); }
- }, 500);
- }
- if (p.get('action') === 'forward' && p.get('id')) {
- history.replaceState({},'','/');
- const id = parseInt(p.get('id'));
- setTimeout(async () => {
- const msg = await api('GET', '/messages/'+id);
- if (msg) { S.currentMessage = msg; openForward(); }
- }, 500);
- }
-
document.addEventListener('keydown', e => {
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
if (e.target.contentEditable === 'true') return;
- if ((e.metaKey||e.ctrlKey) && e.key==='n') { e.preventDefault(); openCompose(); }
- if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); document.getElementById('search-input').focus(); }
+ if ((e.metaKey||e.ctrlKey) && e.key==='n') { e.preventDefault(); openCompose(); return; }
+ if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); document.getElementById('search-input').focus(); return; }
+ if (e.metaKey||e.ctrlKey||e.altKey) return; // don't shadow browser/OS shortcuts below
+
+ if (e.key==='j' || e.key==='k') {
+ e.preventDefault();
+ const msgs = getFilteredSortedMsgs();
+ if (!msgs.length) return;
+ let idx = msgs.findIndex(m=>m.id===S.selectedMessageId);
+ idx = e.key==='j' ? Math.min(idx+1, msgs.length-1) : Math.max(idx-1, 0);
+ openMessage(msgs[idx].id);
+ } else if (e.key==='r' && S.currentMessage) {
+ e.preventDefault(); openReplyTo(S.currentMessage.id);
+ } else if (e.key==='f' && S.currentMessage) {
+ e.preventDefault(); openForward();
+ } else if ((e.key==='#'||e.key==='Delete') && S.currentMessage) {
+ e.preventDefault(); deleteMessage(S.currentMessage.id);
+ } else if (e.key==='Escape') {
+ if (S.composeVisible) { e.preventDefault(); closeCompose(); }
+ else if (S.currentMessage) { e.preventDefault(); resetDetail(); renderMessageList(); mobBack(); }
+ }
});
initComposeDragResize();
@@ -259,7 +262,11 @@ function getSignatureHTML(accountId, forReply) {
// live if the From-account changes mid-compose, without touching the rest of the body.
function signatureBlockHTML(accountId, forReply) {
const html = getSignatureHTML(accountId, forReply);
- return `
${html ? ' ' + html : ''}
`;
+ // A block-level "
" renders as exactly one blank line in every browser;
+ // a bare
doesn't — its rendered height depends on what precedes/follows it
+ // (e.g. it collapses to nothing right before another block element), which is exactly
+ // the inconsistency this was written to avoid.
+ return `
${moveEntry}
${emptyEntry}
@@ -805,6 +882,10 @@ async function syncFolderNow(folderId) {
else toast(r?.error||'Sync failed','error');
}
+function exportFolder(folderId, format) {
+ window.open('/api/folders/'+folderId+'/export?format='+format, '_blank');
+}
+
async function markFolderAllRead(folderId) {
const r=await api('POST','/folders/'+folderId+'/mark-all-read');
if(r?.ok){
@@ -945,11 +1026,26 @@ function toggleLabelsDropdown(e) {
if (!menu) return;
const isOpen = menu.style.display !== 'none';
menu.style.display = isOpen ? 'none' : 'block';
+ document.getElementById('labels-dropdown-btn')?.setAttribute('aria-expanded', String(!isOpen));
if (!isOpen) setTimeout(() => document.addEventListener('click', closeLabelsDropdown, { once: true }), 0);
}
function closeLabelsDropdown() {
const menu = document.getElementById('labels-dropdown-menu');
if (menu) menu.style.display = 'none';
+ document.getElementById('labels-dropdown-btn')?.setAttribute('aria-expanded', 'false');
+}
+
+function toggleThreadPanel(e) {
+ if (e) e.stopPropagation();
+ const panel = document.getElementById('thread-panel');
+ if (!panel) return;
+ const isOpen = panel.style.display !== 'none';
+ panel.style.display = isOpen ? 'none' : 'block';
+ if (!isOpen) setTimeout(() => document.addEventListener('click', closeThreadPanel, { once: true }), 0);
+}
+function closeThreadPanel() {
+ const panel = document.getElementById('thread-panel');
+ if (panel) panel.style.display = 'none';
}
function selectLabel(labelId, name) {
@@ -1049,6 +1145,7 @@ function selectFolder(folderId, folderName) {
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
const navEl=folderId==='unified'?document.getElementById('nav-unified')
:folderId==='starred'?document.getElementById('nav-starred')
+ :folderId==='snoozed'?document.getElementById('nav-snoozed')
:document.getElementById('nav-f'+folderId);
if (navEl) navEl.classList.add('active');
mobCloseNav();
@@ -1160,6 +1257,7 @@ async function loadMessages(append) {
}
else if (S.currentFolder==='unified') result=await api('GET',`/messages/unified?page=${S.currentPage}&page_size=50`);
else if (S.currentFolder==='starred') result=await api('GET',`/messages/starred?page=${S.currentPage}&page_size=50`);
+ else if (S.currentFolder==='snoozed') result=await api('GET',`/messages/snoozed?page=${S.currentPage}&page_size=50`);
else if (String(S.currentFolder).startsWith('label:')) result=await api('GET',`/messages/by-label/${String(S.currentFolder).slice(6)}?page=${S.currentPage}&page_size=50`);
else result=await api('GET',`/messages?folder_id=${S.currentFolder}&page=${S.currentPage}&page_size=50`);
if (!result){list.innerHTML='
Failed to load
';return;}
@@ -1203,18 +1301,44 @@ function setSortOrder(order) { setFilter(order); }
// ── Multi-select state ────────────────────────────────────────
if (!window.SEL) window.SEL = { ids: new Set(), lastIdx: -1 };
+// ── Conversation grouping ─────────────────────────────────────────────────
+// No per-provider thread id is populated anywhere in the sync engine (Graph/JMAP have one
+// natively, plain IMAP doesn't), so this groups by normalized subject within the same
+// account — good enough for "N messages" grouping without touching sync or schema. Only
+// grouped within whatever page(s) are currently loaded, not across the whole mailbox.
+function normalizeSubject(s) {
+ let t = (s||'').trim(), prev;
+ do { prev = t; t = t.replace(/^(re|fwd?|fw)\s*:\s*/i, '').trim(); } while (t !== prev);
+ return t.toLowerCase();
+}
+// messageId -> sibling summaries {id,from,date,is_read} for every message that's part of a
+// multi-message thread — populated by groupThreads(), read by renderMessageDetail().
+let threadSiblingsById = {};
+function groupThreads(msgs) {
+ const groups = new Map(); // key -> messages[], insertion order preserved (already sorted)
+ for (const m of msgs) {
+ const subj = normalizeSubject(m.subject);
+ // Blank/no-subject messages never group together — that'd lump unrelated emails.
+ const key = subj ? (m.account_id||0)+'|'+subj : Symbol(m.id);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(m);
+ }
+ threadSiblingsById = {};
+ const out = [];
+ for (const group of groups.values()) {
+ if (group.length > 1) {
+ const siblings = group.map(g=>({id:g.id, from:g.from_name||g.from_email, date:g.date, is_read:g.is_read}));
+ for (const g of group) threadSiblingsById[g.id] = siblings;
+ }
+ const rep = group[0]; // groups are built from an already date-sorted list
+ out.push(group.length > 1 ? {...rep, _threadCount: group.length} : rep);
+ }
+ return out;
+}
+
function renderMessageList() {
const list=document.getElementById('message-list');
- let msgs = [...S.messages];
-
- // Filter
- if (S.filterUnread) msgs = msgs.filter(m => !m.is_read);
- if (S.filterAttachment) msgs = msgs.filter(m => m.has_attachment);
-
- // Sort
- if (S.sortOrder === 'date-asc') msgs.sort((a,b) => new Date(a.date)-new Date(b.date));
- else if (S.sortOrder === 'size-desc') msgs.sort((a,b) => (b.size||0)-(a.size||0));
- else msgs.sort((a,b) => new Date(b.date)-new Date(a.date));
+ const msgs = getFilteredSortedMsgs();
if (!msgs.length){
const emptyMsg = S.filterUnread ? 'No unread messages' : S.filterAttachment ? 'No messages with attachments' : 'No messages';
@@ -1240,7 +1364,7 @@ function renderMessageList() {
';return;}
+ if (isDraftFolder(msg.folder_id)) { resumeDraft(msg); return; }
S.currentMessage=msg;
renderMessageDetail(msg, false);
const li=S.messages.find(m=>m.id===id);
@@ -1360,6 +1501,29 @@ async function openMessage(id) {
}
}
+// Opening a message that lives in a Drafts folder resumes editing it (in the compose modal,
+// pre-filled) rather than showing it read-only — otherwise a saved draft is a dead end, which
+// defeats the point of "save it for later". S.draftId is seeded from the message's remote_uid
+// (the same IMAP UID / Graph id / JMAP id SaveDraft/DiscardDraft already key off of), so the
+// next autosave replaces this exact draft in place instead of creating a duplicate.
+function isDraftFolder(folderId) {
+ return S.folders?.find(f=>f.id===folderId)?.folder_type==='drafts';
+}
+function resumeDraft(msg) {
+ const toList=(msg.to||'').split(',').map(s=>s.trim()).filter(Boolean);
+ const ccList=(msg.cc||'').split(',').map(s=>s.trim()).filter(Boolean);
+ const bccList=(msg.bcc||'').split(',').map(s=>s.trim()).filter(Boolean);
+ openCompose({
+ mode:'new', title:'Edit Draft', subject:msg.subject||'',
+ accountId:msg.account_id, skipSignature:true, body:quotedBodyHTML(msg),
+ });
+ toList.forEach(a=>addTag('compose-to', a));
+ if (ccList.length) { showCCRow(); ccList.forEach(a=>addTag('compose-cc-tags', a)); }
+ if (bccList.length) { showBCCRow(); bccList.forEach(a=>addTag('compose-bcc-tags', a)); }
+ S.draftId=msg.remote_uid||'';
+ S.draftDirty=false;
+}
+
// ── External link navigation whitelist ───────────────────────────────────────
// Persisted in sessionStorage so it resets on tab close (safety default).
const _extNavOk = new Set(JSON.parse(sessionStorage.getItem('extNavOk')||'[]'));
@@ -1387,20 +1551,101 @@ function confirmExternalNav(url) {
overlay.onclick = e => { if(e.target===overlay) overlay.remove(); };
}
+// ── HTML body sanitizing helpers (shared by the reading pane and reply/forward quoting) ──
+function stripUnresolvedCID(h){ return h.replace(/src\s*=\s*(['"])cid:[^'"]*\1/gi,'src=""').replace(/src\s*=\s*cid:\S+/gi,'src=""'); }
+function stripEmbeddedFrames(h){ return h.replace(/