image reder and drafts

This commit is contained in:
2026-08-30 08:17:03 +01:00
parent d005cbb931
commit cc9b987e83
23 changed files with 3273 additions and 366 deletions
+270 -1
View File
@@ -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) {
+576
View File
@@ -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: "<p>hi</p>", 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")
}
}