mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-15 00:00:36 +01:00
image reder and drafts
This commit is contained in:
+270
-1
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user