Compare commits

...
5 Commits
25 changed files with 4092 additions and 471 deletions
+24 -1
View File
@@ -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,22 @@ 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")
// Spam blocklist
api.HandleFunc("/spam-block", h.API.ListSpamBlock).Methods("GET")
api.HandleFunc("/spam-block", h.API.AddSpamBlock).Methods("POST")
api.HandleFunc("/spam-block", h.API.DeleteSpamBlock).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 +265,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")
@@ -266,6 +278,7 @@ func main() {
api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT")
api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET")
api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT")
api.HandleFunc("/login-history", h.API.ListMyLoginHistory).Methods("GET")
// Search
api.HandleFunc("/search", h.API.Search).Methods("GET")
@@ -350,6 +363,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,
+498 -22
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"strings"
@@ -157,6 +158,13 @@ func (d *DB) Migrate() error {
created_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, sender)
)`,
`CREATE TABLE IF NOT EXISTS spam_blocklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
sender TEXT NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
UNIQUE(user_id, sender)
)`,
}
for _, stmt := range stmts {
@@ -194,6 +202,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 +453,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,
@@ -895,6 +932,67 @@ func (d *DB) ListAuditLogs(page, pageSize int, eventFilter string) (*models.Audi
}, rows.Err()
}
// ListLoginHistory returns a user's own login attempts (success + failure) — used by the
// Settings > Security "Login History" viewer. Always scoped to userID so a user can only ever
// see their own attempts, unlike the admin-only ListAuditLogs above. success nil means both;
// true/false filters to just successful/failed attempts. ip is a substring match. dateFrom/
// dateTo are inclusive "YYYY-MM-DD HH:MM:SS" bounds (caller pads a plain date to a full day).
func (d *DB) ListLoginHistory(userID int64, page, pageSize int, dateFrom, dateTo string, success *bool, ip string, sortAsc bool) (*models.AuditPage, error) {
offset := (page - 1) * pageSize
where := " WHERE a.user_id=? AND a.event IN ('login','login_fail')"
args := []interface{}{userID}
if success != nil {
if *success {
where += " AND a.event='login'"
} else {
where += " AND a.event='login_fail'"
}
}
if dateFrom != "" {
where += " AND a.created_at>=?"
args = append(args, dateFrom)
}
if dateTo != "" {
where += " AND a.created_at<=?"
args = append(args, dateTo)
}
if ip != "" {
where += " AND a.ip_address LIKE ?"
args = append(args, "%"+ip+"%")
}
var total int
d.sql.QueryRow(`SELECT COUNT(*) FROM audit_log a`+where, args...).Scan(&total)
order := "DESC"
if sortAsc {
order = "ASC"
}
args = append(args, pageSize, offset)
rows, err := d.sql.Query(`
SELECT a.id, a.event, a.detail, a.ip_address, a.user_agent, a.created_at
FROM audit_log a`+where+`
ORDER BY a.created_at `+order+` LIMIT ? OFFSET ?`, args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []models.AuditLog
for rows.Next() {
l := models.AuditLog{UserID: &userID}
if err := rows.Scan(&l.ID, &l.Event, &l.Detail, &l.IPAddress, &l.UserAgent, &l.CreatedAt); err != nil {
return nil, err
}
logs = append(logs, l)
}
return &models.AuditPage{
Logs: logs, Total: total, Page: page, PageSize: pageSize,
HasMore: offset+len(logs) < total,
}, rows.Err()
}
// ---- Email Accounts ----
func (d *DB) CreateAccount(a *models.EmailAccount) error {
@@ -1628,7 +1726,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)
@@ -1650,8 +1748,8 @@ func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page
args = append(args, pageSize, offset)
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,
SELECT m.id, m.account_id, a.email_address, a.display_name, a.color, m.folder_id, f.name,
m.subject, m.from_name, m.from_email, m.to_list, m.body_text,
m.date, m.is_read, m.is_starred, m.has_attachment
FROM messages m
JOIN email_accounts a ON a.id = m.account_id
@@ -1668,10 +1766,10 @@ func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page
var summaries []models.MessageSummary
for rows.Next() {
s := models.MessageSummary{}
var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string
var subjectEnc, fromNameEnc, fromEmailEnc, toListEnc, bodyTextEnc string
if err := rows.Scan(
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc,
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountName, &s.AccountColor, &s.FolderID, &s.FolderName,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &toListEnc, &bodyTextEnc,
&s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment,
); err != nil {
return nil, err
@@ -1679,6 +1777,7 @@ func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page
s.Subject, _ = d.enc.Decrypt(subjectEnc)
s.FromName, _ = d.enc.Decrypt(fromNameEnc)
s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
s.ToList, _ = d.enc.Decrypt(toListEnc)
bodyText, _ := d.enc.Decrypt(bodyTextEnc)
if len(bodyText) > 120 {
bodyText = bodyText[:120] + "…"
@@ -1783,8 +1882,8 @@ func (d *DB) SearchMessages(userID int64, q string, filters SearchFilters, page,
qArgs := append(append([]interface{}{}, args...), pageSize, offset)
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,
SELECT m.id, m.account_id, a.email_address, a.display_name, a.color, m.folder_id, f.name,
m.subject, m.from_name, m.from_email, m.to_list, m.body_text,
m.date, m.is_read, m.is_starred, m.has_attachment, `+approxSizeExpr+`
FROM messages m
JOIN email_accounts a ON a.id=m.account_id
@@ -1800,10 +1899,10 @@ func (d *DB) SearchMessages(userID int64, q string, filters SearchFilters, page,
var summaries []models.MessageSummary
for rows.Next() {
s := models.MessageSummary{}
var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string
var subjectEnc, fromNameEnc, fromEmailEnc, toListEnc, bodyTextEnc string
if err := rows.Scan(
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc,
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountName, &s.AccountColor, &s.FolderID, &s.FolderName,
&subjectEnc, &fromNameEnc, &fromEmailEnc, &toListEnc, &bodyTextEnc,
&s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment, &s.Size,
); err != nil {
return nil, err
@@ -1811,6 +1910,7 @@ func (d *DB) SearchMessages(userID int64, q string, filters SearchFilters, page,
s.Subject, _ = d.enc.Decrypt(subjectEnc)
s.FromName, _ = d.enc.Decrypt(fromNameEnc)
s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
s.ToList, _ = d.enc.Decrypt(toListEnc)
bodyText, _ := d.enc.Decrypt(bodyTextEnc)
if len(bodyText) > 120 {
bodyText = bodyText[:120] + "…"
@@ -1948,6 +2048,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(
@@ -1957,6 +2065,90 @@ func (d *DB) IsRemoteContentAllowed(userID int64, sender string) (bool, error) {
return count > 0, err
}
// ---- Spam Blocklist (Settings > Security > Spam Block) ----
// A blocked sender is enforced at sync time (see syncer.IsSpamBlocked call sites): any new
// message from a blocked address gets moved to the account's Spam folder automatically,
// the same way the Rules engine's mark_as_spam action does — this is a separate, purpose-
// built list rather than a generic Rule so it gets its own simple add/remove UI.
func (d *DB) ListSpamBlock(userID int64) ([]models.SpamBlockEntry, error) {
rows, err := d.sql.Query(
`SELECT sender, created_at FROM spam_blocklist WHERE user_id=? ORDER BY created_at DESC`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.SpamBlockEntry
for rows.Next() {
var e models.SpamBlockEntry
if err := rows.Scan(&e.Sender, &e.CreatedAt); err == nil {
list = append(list, e)
}
}
return list, rows.Err()
}
func (d *DB) AddSpamBlock(userID int64, sender string) error {
_, err := d.sql.Exec(
`INSERT OR IGNORE INTO spam_blocklist (user_id, sender) VALUES (?, ?)`,
userID, sender,
)
return err
}
func (d *DB) DeleteSpamBlock(userID int64, sender string) error {
_, err := d.sql.Exec(
`DELETE FROM spam_blocklist WHERE user_id=? AND sender=?`,
userID, sender,
)
return err
}
// IsSpamBlocked reports whether sender is on userID's spam blocklist. Errors are treated as
// "not blocked" (fail open) since this gates an automatic mail-moving side effect during
// sync — a transient DB hiccup shouldn't misfile someone's legitimate mail.
// IsSpamBlocked reports whether sender matches userID's spam blocklist — either an exact
// blocked email address, or (for a blocklist entry with no "@", i.e. a bare domain like
// "example.com") the sender's address being @ that domain or any subdomain of it.
// Errors are treated as "not blocked" (fail open) since this gates an automatic mail-moving
// side effect during sync — a transient DB hiccup shouldn't misfile someone's legitimate mail.
func (d *DB) IsSpamBlocked(userID int64, sender string) bool {
if sender == "" {
return false
}
sender = strings.ToLower(strings.TrimSpace(sender))
at := strings.LastIndex(sender, "@")
if at < 0 {
return false
}
senderDomain := sender[at+1:]
rows, err := d.sql.Query(`SELECT sender FROM spam_blocklist WHERE user_id=?`, userID)
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var pattern string
if err := rows.Scan(&pattern); err != nil {
continue
}
pattern = strings.ToLower(pattern)
if strings.Contains(pattern, "@") {
if pattern == sender {
return true
}
continue
}
if senderDomain == pattern || strings.HasSuffix(senderDomain, "."+pattern) {
return true
}
}
return false
}
// SetFolderVisibility sets is_hidden and sync_enabled for a folder owned by the user.
func (d *DB) SetFolderVisibility(folderID, userID int64, isHidden, syncEnabled bool) error {
ih, se := 0, 0
@@ -2050,6 +2242,19 @@ func (d *DB) GetMessageIMAPInfo(messageID, userID int64) (remoteUID uint32, fold
return remoteUID, folder.FullPath, account, err
}
// GetMessageFolderID returns the local folder id a message currently belongs to — used to
// recompute that folder's sidebar count immediately after deleting the message, instead of
// leaving it stale until the next background sync happens to run.
func (d *DB) GetMessageFolderID(messageID, userID int64) (int64, error) {
var folderID int64
err := d.sql.QueryRow(`
SELECT m.folder_id FROM messages m
JOIN email_accounts a ON a.id = m.account_id
WHERE m.id=? AND a.user_id=?`, messageID, userID,
).Scan(&folderID)
return folderID, err
}
// GetMessageGraphInfo returns the Graph message ID (remote_uid as string), folder ID string,
// and account for a Graph-backed message. Used by handlers for outlook_personal accounts.
func (d *DB) GetMessageGraphInfo(messageID, userID int64) (graphMsgID string, folderGraphID string, account *models.EmailAccount, err error) {
@@ -2126,6 +2331,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.
@@ -2174,10 +2577,18 @@ func (d *DB) DeletePendingOp(id int64) error {
return err
}
// IncrementPendingOpAttempts bumps attempt count; ops with >5 attempts are abandoned.
func (d *DB) IncrementPendingOpAttempts(id int64) {
// IncrementPendingOpAttempts bumps attempt count; ops with >5 attempts are abandoned (dropped
// from the queue entirely). Returns true when this call was the one that abandoned it, so the
// caller can surface that as a visible account error instead of silently losing the operation
// (e.g. a delete/move that never actually reaches the server, with no sign anything went wrong).
func (d *DB) IncrementPendingOpAttempts(id int64) (abandoned bool) {
d.sql.Exec(`UPDATE pending_imap_ops SET attempts=attempts+1 WHERE id=?`, id)
d.sql.Exec(`DELETE FROM pending_imap_ops WHERE id=? AND attempts>5`, id)
res, _ := d.sql.Exec(`DELETE FROM pending_imap_ops WHERE id=? AND attempts>5`, id)
if res == nil {
return false
}
n, _ := res.RowsAffected()
return n > 0
}
// CountPendingOps returns number of queued ops for an account (for logging).
@@ -2201,6 +2612,26 @@ func (d *DB) SetFolderSyncState(folderID int64, uidValidity, lastSeenUID uint32)
d.sql.Exec(`UPDATE folders SET uid_validity=?, last_seen_uid=? WHERE id=?`, uidValidity, lastSeenUID, folderID)
}
// GetLocalUIDSet returns the set of remote_uid values already stored locally for a folder —
// used alongside PurgeDeletedMessages to reconcile the other direction: UIDs the server has
// that the local cache is missing (from any past cause of local data loss), so the sync can
// re-fetch exactly those instead of relying solely on the last_seen_uid incremental cursor.
func (d *DB) GetLocalUIDSet(folderID int64) (map[string]bool, error) {
rows, err := d.sql.Query(`SELECT remote_uid FROM messages WHERE folder_id=?`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
set := map[string]bool{}
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err == nil {
set[uid] = true
}
}
return set, rows.Err()
}
// PurgeDeletedMessages removes local messages whose remote_uid is no longer
// in the server's UID list for a folder. Returns count purged.
func (d *DB) PurgeDeletedMessages(folderID int64, serverUIDs []uint32) (int, error) {
@@ -2228,6 +2659,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,20 +2733,56 @@ 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) {
res, err := d.sql.Exec(`
DELETE FROM messages WHERE folder_id=?
AND folder_id IN (SELECT id FROM folders WHERE account_id IN
(SELECT id FROM email_accounts WHERE user_id=?))`,
// ListMessageIDsInFolder returns the ids of every message in folderID owned by userID — used
// by EmptyFolder to delete each one through the same per-message path (deleteMessageEverywhere
// in api.go) that a regular single delete uses, so "Empty Trash/Spam" actually removes mail
// from the provider instead of only clearing the local cache.
func (d *DB) ListMessageIDsInFolder(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=?`,
folderID, userID,
)
if err != nil {
return 0, err
return nil, err
}
n, _ := res.RowsAffected()
return int(n), nil
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err == nil {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
// EnableAllFolderSync enables sync for all currently-disabled folders belonging
+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")
}
}
+28 -4
View File
@@ -463,11 +463,14 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
}
func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
// Fetch FetchRFC822 (full raw message) so we can properly parse MIME
// Full raw message, needed for proper MIME parsing — fetched via BODY.PEEK[] (not the
// plain RFC822/BODY[] item) so reading it during a background sync doesn't implicitly
// mark the message \Seen on the server before the user has actually opened it.
peekBody := &imap.BodySectionName{Peek: true}
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822, // full message including headers needed for proper MIME parsing
peekBody.FetchItem(),
}
ch := make(chan *imap.Message, 64)
@@ -491,10 +494,11 @@ func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, er
// fetchByUIDSet fetches messages by UID set (used when UIDs are returned from UidSearch).
func (c *Client) fetchByUIDSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
peekBody := &imap.BodySectionName{Peek: true} // see fetchBySeqSet — avoids implicitly marking \Seen
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822,
peekBody.FetchItem(),
}
ch := make(chan *imap.Message, 64)
@@ -1589,6 +1593,25 @@ func (c *Client) ListAllUIDs(mailboxName string) ([]uint32, error) {
return uids, nil
}
// FetchByUIDs fetches specific messages by UID, regardless of the incremental last_seen_uid
// cursor — used by the sync reconciliation pass (see syncer.syncFolder) to recover messages
// that exist on the server but are missing from the local cache, so a local-only data loss
// (from any cause) self-heals on the next sync instead of leaving that message permanently
// unreachable (incremental fetch only ever asks for UIDs newer than what it last saw).
func (c *Client) FetchByUIDs(mailboxName string, uids []uint32) ([]*gomailModels.Message, error) {
if len(uids) == 0 {
return nil, nil
}
if _, err := c.imap.Select(mailboxName, true); err != nil {
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
}
seqSet := new(imap.SeqSet)
for _, uid := range uids {
seqSet.AddNum(uid)
}
return c.fetchByUIDSet(seqSet)
}
// FetchNewMessages fetches only messages with UID > afterUID (incremental).
func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomailModels.Message, error) {
mbox, err := c.imap.Select(mailboxName, true)
@@ -1603,10 +1626,11 @@ func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomai
seqSet := new(imap.SeqSet)
seqSet.AddRange(afterUID+1, ^uint32(0)) // afterUID+1 to * (max)
peekBody := &imap.BodySectionName{Peek: true} // see fetchBySeqSet — avoids implicitly marking \Seen
items := []imap.FetchItem{
imap.FetchUid, imap.FetchEnvelope,
imap.FetchFlags, imap.FetchBodyStructure,
imap.FetchRFC822,
peekBody.FetchItem(),
}
ch := make(chan *imap.Message, 64)
+47
View File
@@ -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)
}
})
}
}
+48
View File
@@ -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)
}
+61
View File
@@ -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 {
+583 -81
View File
@@ -1,6 +1,8 @@
package handlers
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
@@ -8,6 +10,7 @@ import (
"log"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@@ -862,17 +865,196 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
// ---- 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)
}
}
// deleteMessageEverywhere deletes messageID from the local cache and, best-effort, from the
// mail provider itself (an immediate Graph/JMAP delete call, or an enqueued IMAP delete op
// applied on the next drain) — shared by the single-message delete handler and EmptyFolder
// (bulk), so emptying Trash/Spam actually removes mail from the server instead of only
// hiding it locally (which made deleted messages come back on the next sync).
func (h *APIHandler) deleteMessageEverywhere(userID, messageID int64) error {
// Get message info before deleting from DB
remoteID, _, remoteAcc, remoteErr := h.db.GetMessageGraphInfo(messageID, userID)
uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID)
folderID, folderErr := h.db.GetMessageFolderID(messageID, userID)
if err := h.db.DeleteMessage(messageID, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "delete failed")
return
return err
}
// Recompute the sidebar's folder-count badge right away from what's actually left in the
// local table, rather than leaving it at the pre-delete count until the next background
// sync happens to run — the eventual real sync (once the server-side delete/move below
// actually lands) will overwrite this with the authoritative count anyway.
if folderErr == nil {
h.db.UpdateFolderCounts(folderID)
}
if remoteErr == nil && remoteAcc != nil && remoteAcc.Provider == models.ProviderOutlookPersonal {
@@ -886,25 +1068,32 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
})
h.syncer.TriggerAccountSync(account.ID)
}
return nil
}
func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
if err := h.deleteMessageEverywhere(userID, messageID); err != nil {
h.writeError(w, http.StatusInternalServerError, "delete failed")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- 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 +1145,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 +1193,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 ----
@@ -1097,6 +1288,46 @@ func (h *APIHandler) Search(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, result)
}
// ---- Login history (per-user, Settings > Security) ----
// ListMyLoginHistory returns the authenticated user's own login attempts — never any other
// user's, unlike the admin-only audit log viewer (AdminHandler.ListAuditLogs).
func (h *APIHandler) ListMyLoginHistory(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
page := queryInt(r, "page", 1)
pageSize := queryInt(r, "page_size", 25)
if pageSize > 100 {
pageSize = 100
}
var success *bool
switch r.URL.Query().Get("success") {
case "true":
b := true
success = &b
case "false":
b := false
success = &b
}
var dateFrom, dateTo string
if v := r.URL.Query().Get("date_from"); v != "" {
dateFrom = v + " 00:00:00"
}
if v := r.URL.Query().Get("date_to"); v != "" {
dateTo = v + " 23:59:59"
}
ip := r.URL.Query().Get("ip")
sortAsc := r.URL.Query().Get("sort") == "asc"
result, err := h.db.ListLoginHistory(userID, page, pageSize, dateFrom, dateTo, success, ip, sortAsc)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to load login history")
return
}
h.writeJSON(w, result)
}
// ---- Sync interval (per-user) ----
func (h *APIHandler) GetSyncInterval(w http.ResponseWriter, r *http.Request) {
@@ -1338,23 +1569,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 "<subject>.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 +1629,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 +1750,82 @@ 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})
}
// ---- Spam Blocklist (Settings > Security > Spam Block) ----
func (h *APIHandler) ListSpamBlock(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
list, err := h.db.ListSpamBlock(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to get spam blocklist")
return
}
if list == nil {
list = []models.SpamBlockEntry{}
}
h.writeJSON(w, map[string]interface{}{"entries": list})
}
// A blocklist entry must be either a real email address or a bare domain (e.g. "example.com",
// which IsSpamBlocked then also matches against subdomains) — never arbitrary text, which
// could never match a sender and would just sit in the list doing nothing.
var (
spamBlockEmailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
spamBlockDomainRe = regexp.MustCompile(`(?i)^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
)
func isValidSpamBlockEntry(s string) bool {
return spamBlockEmailRe.MatchString(s) || spamBlockDomainRe.MatchString(s)
}
func (h *APIHandler) AddSpamBlock(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct {
Sender string `json:"sender"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Sender == "" {
h.writeError(w, http.StatusBadRequest, "sender required")
return
}
sender := strings.ToLower(strings.TrimSpace(req.Sender))
if !isValidSpamBlockEntry(sender) {
h.writeError(w, http.StatusBadRequest, "enter a valid email address or domain (e.g. example.com)")
return
}
if err := h.db.AddSpamBlock(userID, sender); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to add to spam blocklist")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
func (h *APIHandler) DeleteSpamBlock(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
sender := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sender")))
if sender == "" {
h.writeError(w, http.StatusBadRequest, "sender required")
return
}
if err := h.db.DeleteSpamBlock(userID, sender); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to remove from spam blocklist")
return
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Empty folder (Trash/Spam) ----
func (h *APIHandler) EmptyFolder(w http.ResponseWriter, r *http.Request) {
@@ -1461,11 +1843,17 @@ func (h *APIHandler) EmptyFolder(w http.ResponseWriter, r *http.Request) {
return
}
n, err := h.db.EmptyFolder(folderID, userID)
ids, err := h.db.ListMessageIDsInFolder(folderID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to empty folder")
h.writeError(w, http.StatusInternalServerError, "failed to list messages")
return
}
n := 0
for _, id := range ids {
if err := h.deleteMessageEverywhere(userID, id); err == nil {
n++
}
}
h.db.UpdateFolderCounts(folderID)
h.writeJSON(w, map[string]interface{}{"ok": true, "deleted": n})
}
@@ -1658,7 +2046,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 +2120,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 +2211,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})
}
+377
View File
@@ -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
}
+56 -23
View File
@@ -136,6 +136,13 @@ type Label struct {
Color string `json:"color"` // hex, e.g. "#5b8def"
}
// SpamBlockEntry pairs a blocked sender address with when it was added — Settings >
// Security > Spam Block.
type SpamBlockEntry struct {
Sender string `json:"sender"`
CreatedAt time.Time `json:"created_at"`
}
// Folder represents a mailbox folder or Gmail label.
type Folder struct {
ID int64 `json:"id"`
@@ -197,6 +204,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"`
@@ -204,22 +212,43 @@ type Message struct {
// MessageSummary is a lightweight version for list views.
type MessageSummary struct {
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
AccountEmail string `json:"account_email"`
AccountColor string `json:"account_color"`
FolderID int64 `json:"folder_id"`
FolderName string `json:"folder_name"`
Subject string `json:"subject"`
FromName string `json:"from_name"`
FromEmail string `json:"from_email"`
Preview string `json:"preview"` // first ~100 chars of body
Date time.Time `json:"date"`
IsRead bool `json:"is_read"`
IsStarred bool `json:"is_starred"`
HasAttachment bool `json:"has_attachment"`
Size int64 `json:"size,omitempty"` // approximate; only populated by search results
Labels []Label `json:"labels,omitempty"`
ID int64 `json:"id"`
AccountID int64 `json:"account_id"`
AccountEmail string `json:"account_email"`
AccountName string `json:"account_name"` // account's own display_name (may be blank)
AccountColor string `json:"account_color"`
FolderID int64 `json:"folder_id"`
FolderName string `json:"folder_name"`
Subject string `json:"subject"`
FromName string `json:"from_name"`
FromEmail string `json:"from_email"`
ToList string `json:"to_list"` // comma-separated; only shown in the Sent folder view
Preview string `json:"preview"` // first ~100 chars of body
Date time.Time `json:"date"`
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 ----
@@ -234,14 +263,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 ----
+28 -2
View File
@@ -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 {
@@ -101,6 +101,32 @@ func parseUID(s string) uint32 {
return uid
}
// ---- Spam blocklist (Settings > Security > Spam Block) ----
// A user-managed list of blocked senders, separate from the Rules engine so it gets its own
// simple add/remove UI instead of the generic condition/action rule builder — but enforced
// the same way the Rules engine's mark_as_spam action already is: move to the account's Spam
// folder. Applied to every provider's newly-synced messages, mirroring where matchRule runs.
func (s *Scheduler) moveToSpamIMAP(account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message) {
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
return
}
uid := parseUID(msg.RemoteUID)
s.db.EnqueueIMAPOp(&db.PendingIMAPOp{AccountID: account.ID, OpType: "move", RemoteUID: uid, FolderPath: dbFolder.FullPath, Extra: junk.FullPath})
s.TriggerAccountSync(account.ID)
}
func (s *Scheduler) moveToSpamGraph(gc *graph.Client, account *models.EmailAccount, msg *models.Message) {
junk, err := s.db.GetFolderByType(account.ID, "spam")
if err != nil || junk == nil {
return
}
if err := gc.MoveMessage(context.Background(), msg.RemoteUID, junk.FullPath); err != nil {
log.Printf("[spam-block] graph move: %v", err)
}
}
// ---- IMAP path ----
func (s *Scheduler) applyRuleIMAP(c *email.Client, account *models.EmailAccount, dbFolder *models.Folder, msg *models.Message, rule *models.Rule) {
+131
View File
@@ -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)
}
}
+45 -3
View File
@@ -519,7 +519,9 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
if dbFolder.FolderType != "spam" && s.db.IsSpamBlocked(account.UserID, msg.FromEmail) {
s.moveToSpamIMAP(account, dbFolder, msg)
} else if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
s.applyRuleIMAP(c, account, dbFolder, msg, rule)
}
}
@@ -547,6 +549,41 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db
if purged > 0 {
log.Printf("[sync] purged %d server-deleted messages from %s/%s", purged, account.EmailAddress, dbFolder.FullPath)
}
// 4. Reconcile the other direction: any UID the server has that we don't (from any
// past cause of local data loss — a bug, a crash mid-write, manual intervention) is
// re-fetched here, so the local cache always self-heals back to matching the server
// instead of staying permanently drifted — the incremental fetch in step 1 alone can
// never recover these, since it only ever asks for UIDs newer than last_seen_uid.
if localUIDs, lerr := s.db.GetLocalUIDSet(dbFolder.ID); lerr == nil {
var missing []uint32
for _, uid := range serverUIDs {
if !localUIDs[fmt.Sprintf("%d", uid)] {
missing = append(missing, uid)
}
}
if len(missing) > 0 {
recovered, rerr := c.FetchByUIDs(dbFolder.FullPath, missing)
if rerr != nil {
log.Printf("[sync] recover missing %s/%s: %v", account.EmailAddress, dbFolder.FullPath, rerr)
} else {
n := 0
for _, msg := range recovered {
msg.FolderID = dbFolder.ID
if dbErr := s.db.UpsertMessage(msg); dbErr == nil {
n++
if len(msg.Attachments) > 0 && msg.ID > 0 {
_ = s.db.SaveAttachmentMeta(msg.ID, msg.Attachments)
}
}
}
if n > 0 {
log.Printf("[sync] recovered %d message(s) missing from local cache in %s/%s", n, account.EmailAddress, dbFolder.FullPath)
newMessages += n
}
}
}
}
}
// Save sync state
@@ -615,7 +652,10 @@ func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
if applyErr != nil {
log.Printf("[ops:%s] %s uid=%d folder=%s: %v", account.EmailAddress, op.OpType, op.RemoteUID, op.FolderPath, applyErr)
s.db.IncrementPendingOpAttempts(op.ID)
if abandoned := s.db.IncrementPendingOpAttempts(op.ID); abandoned {
log.Printf("[ops:%s] giving up on %s uid=%d folder=%s after repeated failures: %v", account.EmailAddress, op.OpType, op.RemoteUID, op.FolderPath, applyErr)
s.db.SetAccountError(account.ID, fmt.Sprintf("a %s operation failed repeatedly and was abandoned: %v", op.OpType, applyErr))
}
} else {
s.db.DeletePendingOp(op.ID)
}
@@ -912,7 +952,9 @@ func (s *Scheduler) graphDeltaSync(account *models.EmailAccount) {
totalNew++
// NOTE: msg.BodyText is never populated here (body is fetched lazily on open,
// by design, for perf) — a rule's "body" condition never matches on this path.
if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
if dbFolderSaved.FolderType != "spam" && s.db.IsSpamBlocked(account.UserID, msg.FromEmail) {
s.moveToSpamGraph(gc, account, msg)
} else if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil {
s.applyRuleGraph(gc, account, msg, rule)
}
}
+42 -4
View File
@@ -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);
@@ -47,9 +51,9 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM San
z-index:100;display:flex;align-items:center;justify-content:center;
opacity:0;pointer-events:none;transition:opacity .2s}
.modal-overlay.open{opacity:1;pointer-events:all}
/* Account add/edit modals open from inside the Settings modal and must stack above it,
regardless of DOM order, so Settings stays visible (and reachable) underneath. */
#add-account-modal,#edit-account-modal{z-index:110}
/* Modals that open from inside the Settings modal must stack above it, regardless of DOM
order, so Settings stays visible (and reachable) underneath. */
#add-account-modal,#edit-account-modal,#login-history-modal,#spam-block-modal{z-index:110}
.modal{width:480px;max-height:90vh;overflow-y:auto;background:var(--surface2);
border:1px solid var(--border2);border-radius:10px;padding:22px;
transform:scale(.95);transition:transform .2s}
@@ -265,15 +269,18 @@ 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 subjectpreview 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-account-name{font-size:10px;color:var(--muted);flex-shrink:0;max-width:110px;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;background:var(--surface3);padding:1px 6px;border-radius:4px}
.msg-icons{display:flex;align-items:center;gap:4px;flex-shrink:0}
.msg-size{font-size:10px;color:var(--muted)}
.msg-star{color:var(--muted);font-size:15px;cursor:pointer}
@@ -491,6 +498,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 +528,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;
+5 -5
View File
@@ -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() {
</div>
<div id="users-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="user-modal">
<div class="modal-overlay" id="user-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="user-modal-title">
<div class="modal">
<h2 id="user-modal-title">New User</h2>
<input type="hidden" id="user-id">
@@ -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() {
<div id="attempts-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="add-block-modal">
<div class="modal-overlay" id="add-block-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="add-block-modal-title">
<div class="modal" style="max-width:420px">
<h2>Block IP Address</h2>
<h2 id="add-block-modal-title">Block IP Address</h2>
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
+827 -126
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -383,7 +383,7 @@ async function loadCalDAVTokens() {
<div class="caldav-token-url" onclick="copyCalDAVUrl('${url}')" title="Click to copy">${url}</div>
<div style="font-size:11px;color:var(--muted)">Created: ${t.created_at}${t.last_used?' · Last used: '+t.last_used:''}</div>
</div>
<button class="icon-btn" onclick="revokeCalDAVToken(${t.id})" title="Revoke" style="color:var(--danger);flex-shrink:0">
<button class="icon-btn" onclick="revokeCalDAVToken(${t.id})" title="Revoke" aria-label="Revoke this CalDAV token" style="color:var(--danger);flex-shrink:0">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>`;
+24 -8
View File
@@ -24,6 +24,9 @@ function toast(msg, type) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container';
container.setAttribute('role', 'status');
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'true');
document.body.appendChild(container);
}
const el = document.createElement('div');
@@ -111,11 +114,15 @@ function debounce(fn, ms) {
// ---- Modal helpers ----
function openModal(id) {
const el = document.getElementById(id);
if (el) el.classList.add('open');
if (!el) return;
el.classList.add('open');
el.setAttribute('aria-hidden', 'false');
const focusable = el.querySelector('input,button,select,textarea,[tabindex]');
if (focusable) setTimeout(() => focusable.focus(), 50);
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('open');
if (el) { el.classList.remove('open'); el.setAttribute('aria-hidden', 'true'); }
}
// Close modals on overlay click
@@ -137,12 +144,21 @@ document.addEventListener('keydown', e => {
});
// ---- Rich text compose helpers ----
function insertLink() {
const url = prompt('Enter URL:');
if (!url) return;
const text = window.getSelection().toString() || url;
document.getElementById('compose-editor').focus();
document.execCommand('createLink', false, url);
// editorId defaults to the main compose editor; the signature editor passes 'sig-content'.
// Uses inlinePrompt (not window.prompt) so the selection has to be saved/restored across the
// async gap — prompt() blocked synchronously and never lost it.
function insertLink(editorId) {
editorId = editorId || 'compose-editor';
const editor = document.getElementById(editorId);
if (!editor) return;
const sel = window.getSelection();
const range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
inlinePrompt('Enter URL:', url => {
if (!url) return;
editor.focus();
if (range) { sel.removeAllRanges(); sel.addRange(range); }
document.execCommand('createLink', false, url);
});
}
// ── Filter dropdown (stubs — real logic in app.js, but onclick needs global scope) ──
+2 -2
View File
@@ -34,10 +34,10 @@
<div class="spinner" style="margin-top:80px"></div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<div class="toast-container" id="toast-container" role="status" aria-live="polite" aria-atomic="true"></div>
<div class="ctx-menu" id="ctx-menu"></div>
{{end}}
{{define "scripts"}}
<script src="/static/js/admin.js?v=25"></script>
<script src="/static/js/admin.js?v=26"></script>
{{end}}
+279 -105
View File
@@ -6,15 +6,15 @@
<div class="app" id="app-root" data-mob-view="list">
<!-- Mobile top bar (hidden on desktop) -->
<div class="mob-topbar" id="mob-topbar">
<button class="mob-nav-btn" id="mob-nav-btn" onclick="mobShowNav()" title="Menu">
<button class="mob-nav-btn" id="mob-nav-btn" onclick="mobShowNav()" title="Menu" aria-label="Open navigation menu">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
</button>
<button class="mob-back-btn" id="mob-back-btn" onclick="mobBack()" title="Back" style="display:none">
<button class="mob-back-btn" id="mob-back-btn" onclick="mobBack()" title="Back" aria-label="Back" style="display:none">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<span class="mob-title" id="mob-title">GoWebMail</span>
<button class="compose-btn" onclick="openCompose()" style="margin-left:auto;padding:5px 10px;font-size:11px">+ New</button>
<button class="compose-btn" onclick="window.open('/compose','_blank')" style="padding:5px 8px;font-size:11px" title="Compose in new tab"></button>
<button class="compose-btn" onclick="window.open('/compose','_blank')" style="padding:5px 8px;font-size:11px" title="Compose in new tab" aria-label="Compose in new tab"></button>
</div>
<!-- Sidebar -->
@@ -22,7 +22,7 @@
<div class="sidebar-header">
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<button class="icon-btn sidebar-collapse-btn" onclick="toggleSidebarCollapse()" title="Collapse sidebar" style="flex-shrink:0">
<button class="icon-btn sidebar-collapse-btn" onclick="toggleSidebarCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar" style="flex-shrink:0">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<div class="logo">
@@ -33,7 +33,7 @@
</div>
<div style="display:flex;margin-top:8px">
<button class="compose-btn" onclick="openCompose()" style="flex:1;border-radius:6px 0 0 6px">+ New</button>
<button class="compose-btn" onclick="toggleComposeDropdown(event)" style="border-radius:0 6px 6px 0;border-left:1px solid rgba(255,255,255,.25);padding:6px 7px" title="More options">
<button class="compose-btn" onclick="toggleComposeDropdown(event)" style="border-radius:0 6px 6px 0;border-left:1px solid rgba(255,255,255,.25);padding:6px 7px" title="More options" aria-label="More compose options" aria-haspopup="true">
<svg viewBox="0 0 24 24" width="10" height="10" fill="white"><path d="M7 10l5 5 5-5z"/></svg>
</button>
</div>
@@ -49,6 +49,14 @@
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
Starred
</div>
<div class="nav-item" id="nav-snoozed" onclick="selectFolder('snoozed','Snoozed')">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 20c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
Snoozed
</div>
<div class="nav-item" id="nav-scheduled" onclick="showScheduledSends()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13zm-8-9h5v5h-5z"/></svg>
Scheduled
</div>
<div class="nav-item" id="nav-contacts" onclick="showContacts()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 0H4v2h16V0zM0 4v18h24V4H0zm22 16H2V6h20v14zM12 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-6 6c0-2.21 2.69-4 6-4s6 1.79 6 4H6z"/></svg>
Contacts
@@ -66,10 +74,10 @@
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Server Administration</a>
</div>
<div class="footer-actions">
<button class="icon-btn" onclick="openSettings()" title="Settings">
<button class="icon-btn" onclick="openSettings()" title="Settings" aria-label="Settings">
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
</button>
<button class="icon-btn" onclick="doLogout()" title="Sign out">
<button class="icon-btn" onclick="doLogout()" title="Sign out" aria-label="Sign out">
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
</button>
</div>
@@ -83,7 +91,7 @@
<div class="message-list-panel">
<div class="panel-header">
<div style="display:flex;align-items:center;gap:6px;min-width:0">
<button class="icon-btn" id="sidebar-expand-btn" onclick="toggleSidebarCollapse()" title="Show sidebar" style="flex-shrink:0">
<button class="icon-btn" id="sidebar-expand-btn" onclick="toggleSidebarCollapse()" title="Show sidebar" aria-label="Show sidebar" style="flex-shrink:0">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
</button>
<span class="panel-title" id="panel-title">Unified Inbox</span>
@@ -91,7 +99,7 @@
<div style="display:flex;align-items:center;gap:6px">
<span class="panel-count" id="panel-count"></span>
<div class="filter-dropdown" id="view-dropdown">
<button class="filter-dropdown-btn" id="view-dropdown-btn" title="View settings" onclick="toggleViewDropdown(event)">
<button class="filter-dropdown-btn" id="view-dropdown-btn" title="View settings" onclick="toggleViewDropdown(event)" aria-haspopup="true" aria-expanded="false">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
<span>View</span>
</button>
@@ -111,14 +119,14 @@
</div>
</div>
<div class="filter-dropdown" id="labels-dropdown">
<button class="filter-dropdown-btn" id="labels-dropdown-btn" title="Labels" onclick="toggleLabelsDropdown(event)">
<button class="filter-dropdown-btn" id="labels-dropdown-btn" title="Labels" onclick="toggleLabelsDropdown(event)" aria-haspopup="true" aria-expanded="false">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12l-4.37-6.16z"/></svg>
<span>Labels</span>
</button>
<div class="filter-dropdown-menu" id="labels-dropdown-menu" style="display:none;min-width:210px"></div>
</div>
<div class="filter-dropdown" id="filter-dropdown">
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter &amp; sort" onclick="var m=document.getElementById('filter-dropdown-menu');m.style.display=m.style.display==='block'?'none':'block';event.stopPropagation()">
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter &amp; sort" aria-haspopup="true" aria-expanded="false" onclick="var m=document.getElementById('filter-dropdown-menu');var exp=m.style.display==='block';m.style.display=exp?'none':'block';this.setAttribute('aria-expanded',String(!exp));event.stopPropagation()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
<span id="filter-label">Filter</span>
</button>
@@ -139,9 +147,9 @@
<div style="display:flex;gap:6px;align-items:center">
<div class="search-wrap" style="flex:1">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input class="search-input" type="text" id="search-input" placeholder="Search emails..." oninput="handleSearch(this.value)" onkeydown="if(event.key==='Enter')applySearchFilters()">
<input class="search-input" type="text" id="search-input" aria-label="Search emails" placeholder="Search emails..." oninput="handleSearch(this.value)" onkeydown="if(event.key==='Enter')applySearchFilters()">
</div>
<button class="filter-dropdown-btn" id="search-filters-btn" title="Search filters" onclick="toggleSearchFilters(event)" style="flex-shrink:0">
<button class="filter-dropdown-btn" id="search-filters-btn" title="Search filters" aria-label="Search filters" aria-haspopup="true" onclick="toggleSearchFilters(event)" style="flex-shrink:0">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
</button>
</div>
@@ -176,15 +184,15 @@
<!-- ── Calendar panel ──────────────────────────────────────────────────── -->
<div id="calendar-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden;background:var(--bg)">
<div style="padding:12px 18px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0">
<button class="icon-btn" onclick="calNav(-1)" title="Previous">&#8249;</button>
<button class="icon-btn" onclick="calNav(-1)" title="Previous" aria-label="Previous period">&#8249;</button>
<span id="cal-title" style="font-family:'DM Serif Display',serif;font-size:17px;min-width:200px;text-align:center"></span>
<button class="icon-btn" onclick="calNav(1)" title="Next">&#8250;</button>
<button class="icon-btn" onclick="calNav(1)" title="Next" aria-label="Next period">&#8250;</button>
<button class="btn-secondary" onclick="calGoToday()" style="font-size:12px;margin-left:4px">Today</button>
<div style="margin-left:auto;display:flex;gap:4px">
<button class="btn-secondary" id="cal-btn-month" onclick="calSetView('month')" style="font-size:12px">Month</button>
<button class="btn-secondary" id="cal-btn-week" onclick="calSetView('week')" style="font-size:12px">Week</button>
<button class="btn-secondary" onclick="openEventForm()" style="font-size:12px;background:var(--accent);color:white;border-color:var(--accent)">+ Event</button>
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing">
<button class="icon-btn" onclick="showCalDAVSettings()" title="CalDAV / sharing" aria-label="CalDAV and sharing settings">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
</button>
</div>
@@ -195,7 +203,7 @@
</div>
<!-- ── Contact form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="contact-modal">
<div class="modal-overlay" id="contact-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="contact-modal-title">
<div class="modal" style="max-width:480px">
<h2 id="contact-modal-title">New Contact</h2>
<div class="modal-field"><label>Name</label><input id="cf-name" type="text" placeholder="Full name"></div>
@@ -212,7 +220,7 @@
</div>
<!-- ── Event form modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="event-modal">
<div class="modal-overlay" id="event-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="event-modal-title">
<div class="modal" style="max-width:520px">
<h2 id="event-modal-title">New Event</h2>
<div class="modal-field"><label>Title</label><input id="ev-title" type="text" placeholder="Event title"></div>
@@ -245,9 +253,9 @@
</div>
<!-- ── CalDAV settings modal ──────────────────────────────────────────────── -->
<div class="modal-overlay" id="caldav-modal">
<div class="modal-overlay" id="caldav-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="caldav-modal-title">
<div class="modal" style="max-width:560px">
<h2>CalDAV / Calendar Sharing</h2>
<h2 id="caldav-modal-title">CalDAV / Calendar Sharing</h2>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">
Subscribe to your GoWebMail calendar from any CalDAV client (Apple Calendar, Thunderbird, etc.) using a token URL. Tokens give read-only calendar access — no password needed.
</p>
@@ -267,28 +275,28 @@
<div class="compose-dialog-header" id="compose-drag-handle">
<span class="compose-title" id="compose-title">New Message</span>
<div style="display:flex;align-items:center;gap:2px">
<button class="compose-close" onclick="minimizeCompose()" title="Minimise">&#8211;</button>
<button class="compose-close" onclick="closeCompose()" title="Close">&#215;</button>
<button class="compose-close" onclick="minimizeCompose()" title="Minimise" aria-label="Minimise compose window">&#8211;</button>
<button class="compose-close" onclick="closeCompose()" title="Close" aria-label="Close compose window">&#215;</button>
</div>
</div>
<div class="compose-body-wrap" id="compose-body-wrap">
<div class="compose-field"><label>From</label><select id="compose-from" onchange="onComposeFromChange()"></select></div>
<div class="compose-field compose-tag-field"><label>To</label><div id="compose-to" class="tag-container"></div></div>
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label>CC</label><div id="compose-cc-tags" class="tag-container"></div></div>
<div class="compose-field compose-tag-field" id="bcc-row" style="display:none"><label>BCC</label><div id="compose-bcc-tags" class="tag-container"></div></div>
<div class="compose-field"><label>Subject</label><input type="text" id="compose-subject" oninput="S.draftDirty=true"></div>
<div class="compose-toolbar">
<button class="fmt-btn" title="Bold" onclick="execFmt('bold')"><b>B</b></button>
<button class="fmt-btn" title="Italic" onclick="execFmt('italic')"><i>I</i></button>
<button class="fmt-btn" title="Underline" onclick="execFmt('underline')"><u>U</u></button>
<div class="compose-field"><label for="compose-from">From</label><select id="compose-from" onchange="onComposeFromChange()"></select></div>
<div class="compose-field compose-tag-field"><label id="compose-to-label">To</label><div id="compose-to" class="tag-container" role="group" aria-labelledby="compose-to-label"></div></div>
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label id="compose-cc-label">CC</label><div id="compose-cc-tags" class="tag-container" role="group" aria-labelledby="compose-cc-label"></div></div>
<div class="compose-field compose-tag-field" id="bcc-row" style="display:none"><label id="compose-bcc-label">BCC</label><div id="compose-bcc-tags" class="tag-container" role="group" aria-labelledby="compose-bcc-label"></div></div>
<div class="compose-field"><label for="compose-subject">Subject</label><input type="text" id="compose-subject" oninput="S.draftDirty=true"></div>
<div class="compose-toolbar" role="toolbar" aria-label="Formatting">
<button class="fmt-btn" title="Bold" aria-label="Bold" onclick="execFmt('bold')"><b>B</b></button>
<button class="fmt-btn" title="Italic" aria-label="Italic" onclick="execFmt('italic')"><i>I</i></button>
<button class="fmt-btn" title="Underline" aria-label="Underline" onclick="execFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Bullets" onclick="execFmt('insertUnorderedList')">&#8226;&#8212;</button>
<button class="fmt-btn" title="Numbers" onclick="execFmt('insertOrderedList')">1&#8212;</button>
<button class="fmt-btn" title="Bullets" aria-label="Bulleted list" onclick="execFmt('insertUnorderedList')">&#8226;&#8212;</button>
<button class="fmt-btn" title="Numbers" aria-label="Numbered list" onclick="execFmt('insertOrderedList')">1&#8212;</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Link" onclick="insertLink()">&#128279;</button>
<button class="fmt-btn" title="Clear format" onclick="execFmt('removeFormat')">T&#x20D7;</button>
<button class="fmt-btn" title="Link" aria-label="Insert link" onclick="insertLink()">&#128279;</button>
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T&#x20D7;</button>
</div>
<div id="compose-editor" contenteditable="true" class="compose-editor" placeholder="Write your message..."></div>
<div id="compose-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Message body" class="compose-editor" placeholder="Write your message..."></div>
<div id="compose-attach-list" class="compose-attach-list"></div>
<div class="compose-footer">
<button class="send-btn" id="send-btn" onclick="sendMessage()">Send</button>
@@ -297,6 +305,7 @@
<button class="btn-secondary" style="font-size:12px" onclick="showBCCRow()">+BCC</button>
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">&#128206; Attach</button>
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">&#9998; Draft</button>
<button class="btn-secondary" style="font-size:12px" onclick="openSendLater()">&#128339; Send later</button>
</div>
<input type="file" id="compose-attach-input" multiple style="display:none" onchange="handleAttachFiles(this)">
</div>
@@ -317,7 +326,7 @@
</div>
<!-- ── Inline confirm (replaces browser confirm()) ───────────────────────── -->
<div class="inline-confirm" id="inline-confirm">
<div class="inline-confirm" id="inline-confirm" role="alertdialog" aria-modal="true" aria-describedby="inline-confirm-msg">
<p id="inline-confirm-msg" style="margin:0 0 14px;font-size:13px;line-height:1.5"></p>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" id="inline-confirm-cancel">Cancel</button>
@@ -326,10 +335,10 @@
</div>
<!-- ── Inline prompt (replaces browser prompt()) ─────────────────────────── -->
<div class="inline-confirm" id="inline-prompt">
<div class="inline-confirm" id="inline-prompt" role="dialog" aria-modal="true" aria-describedby="inline-prompt-msg">
<p id="inline-prompt-msg" style="margin:0 0 10px;font-size:13px;line-height:1.5"></p>
<div class="modal-field">
<input type="text" id="inline-prompt-input"
<input type="text" id="inline-prompt-input" aria-labelledby="inline-prompt-msg"
onkeydown="if(event.key==='Enter'){document.getElementById('inline-prompt-ok').click();}else if(event.key==='Escape'){document.getElementById('inline-prompt-cancel').click();}">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
@@ -338,9 +347,22 @@
</div>
</div>
<!-- ── Inline date/time prompt (snooze / send later) ──────────────────────── -->
<div class="inline-confirm" id="inline-datetime" role="dialog" aria-modal="true" aria-describedby="inline-datetime-msg">
<p id="inline-datetime-msg" style="margin:0 0 10px;font-size:13px;line-height:1.5"></p>
<div class="datetime-presets" id="inline-datetime-presets" role="group" aria-label="Quick presets"></div>
<div class="modal-field">
<input type="datetime-local" id="inline-datetime-input" aria-labelledby="inline-datetime-msg">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-secondary" style="font-size:12px" id="inline-datetime-cancel">Cancel</button>
<button class="btn-primary" style="font-size:12px" id="inline-datetime-ok">Set</button>
</div>
</div>
<!-- ── Draft close confirm (save / delete / keep editing) ─────────────────── -->
<div class="inline-confirm" id="draft-close-confirm">
<p style="margin:0 0 14px;font-size:13px;line-height:1.5">Save this message as a draft before closing?</p>
<div class="inline-confirm" id="draft-close-confirm" role="alertdialog" aria-modal="true" aria-describedby="draft-close-confirm-msg">
<p id="draft-close-confirm-msg" style="margin:0 0 14px;font-size:13px;line-height:1.5">Save this message as a draft before closing?</p>
<div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn-secondary" style="font-size:12px" id="draft-close-cancel">Keep editing</button>
<button class="btn-danger" style="font-size:12px" id="draft-close-delete">Delete draft</button>
@@ -348,10 +370,95 @@
</div>
</div>
<!-- ── Scheduled Sends Modal ───────────────────────────────────────────────── -->
<div class="modal-overlay" id="scheduled-sends-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="scheduled-sends-modal-title">
<div class="modal" style="max-width:520px">
<h2 id="scheduled-sends-modal-title">Scheduled sends</h2>
<div id="scheduled-sends-list"></div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('scheduled-sends-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Login History modal ────────────────────────────────────────────────── -->
<div class="modal-overlay" id="login-history-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="login-history-modal-title">
<div class="modal" style="width:min(1000px,92vw);max-width:none;max-height:90vh;display:flex;flex-direction:column">
<h2 id="login-history-modal-title">Login History</h2>
<p>Login attempts for your account only.</p>
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-bottom:14px">
<div class="modal-field" style="margin-bottom:0">
<label for="lh-date-from">From</label>
<input type="date" id="lh-date-from" onchange="loadLoginHistory(1)">
</div>
<div class="modal-field" style="margin-bottom:0">
<label for="lh-date-to">To</label>
<input type="date" id="lh-date-to" onchange="loadLoginHistory(1)">
</div>
<div class="modal-field" style="margin-bottom:0">
<label for="lh-status">Status</label>
<select id="lh-status" onchange="loadLoginHistory(1)">
<option value="">All</option>
<option value="true">Success</option>
<option value="false">Failed</option>
</select>
</div>
<div class="modal-field" style="margin-bottom:0;flex:1;min-width:160px">
<label for="lh-ip">IP contains</label>
<input type="text" id="lh-ip" placeholder="e.g. 192.168" oninput="debouncedLoadLoginHistory()">
</div>
<div class="modal-field" style="margin-bottom:0">
<label for="lh-sort">Sort by date</label>
<select id="lh-sort" onchange="loadLoginHistory(1)">
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
</div>
<div style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;min-height:200px">
<table class="data-table">
<thead><tr><th>Time</th><th>Status</th><th>IP Address</th><th>Detail</th></tr></thead>
<tbody id="lh-table-body"></tbody>
</table>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px">
<span id="lh-page-info" style="font-size:12px;color:var(--muted)"></span>
<div style="display:flex;gap:8px">
<button class="btn-secondary" id="lh-prev-btn" onclick="loginHistoryPrevPage()">Previous</button>
<button class="btn-secondary" id="lh-next-btn" onclick="loginHistoryNextPage()">Next</button>
</div>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('login-history-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Spam Block modal ───────────────────────────────────────────────────── -->
<div class="modal-overlay" id="spam-block-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="spam-block-modal-title">
<div class="modal" style="width:min(700px,92vw);max-width:none;max-height:90vh;display:flex;flex-direction:column">
<h2 id="spam-block-modal-title">Spam Block</h2>
<p>Mail from these senders is automatically moved to Spam when it arrives — no notification is shown for it. Enter either a full email address, or just a domain (e.g. "example.com") to block every address at that domain and its subdomains.</p>
<div style="display:flex;gap:8px;margin-bottom:14px">
<input type="text" id="sb-add-input" placeholder="Email address or domain (e.g. example.com)…" style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<button class="btn-primary" onclick="addSpamBlockEntry()">Block</button>
</div>
<div style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;min-height:200px">
<table class="data-table">
<thead><tr><th>Sender</th><th>Blocked since</th><th></th></tr></thead>
<tbody id="sb-table-body"></tbody>
</table>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('spam-block-modal')">Close</button>
</div>
</div>
</div>
<!-- ── Add Account Modal ──────────────────────────────────────────────────── -->
<div class="modal-overlay" id="add-account-modal">
<div class="modal-overlay" id="add-account-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="add-account-modal-title">
<div class="modal">
<h2>Connect an account</h2>
<h2 id="add-account-modal-title">Connect an account</h2>
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
<div class="provider-btns">
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
@@ -416,7 +523,7 @@
</div>
<!-- ── Label Editor Modal (create/rename/recolor) ────────────────────────────── -->
<div class="modal-overlay" id="label-editor-modal">
<div class="modal-overlay" id="label-editor-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="label-editor-title">
<div class="modal" style="max-width:360px">
<h2 id="label-editor-title">New Label</h2>
<input type="hidden" id="label-editor-id">
@@ -434,9 +541,9 @@
</div>
<!-- ── Edit Account Modal ─────────────────────────────────────────────────── -->
<div class="modal-overlay" id="edit-account-modal">
<div class="modal-overlay" id="edit-account-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="edit-account-modal-title">
<div class="modal">
<h2>Account Settings</h2>
<h2 id="edit-account-modal-title">Account Settings</h2>
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
<input type="hidden" id="edit-account-id">
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
@@ -499,23 +606,25 @@
</div>
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="settings-modal">
<div class="modal-overlay" id="settings-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="settings-modal-title">
<div class="modal" style="width:820px;max-width:95vw;height:640px;max-height:90vh;padding:0;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;padding:22px 24px 16px">
<h2 style="margin-bottom:0">Settings</h2>
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
<h2 id="settings-modal-title" style="margin-bottom:0">Settings</h2>
<button onclick="closeModal('settings-modal')" class="icon-btn" aria-label="Close settings"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
</div>
<div style="display:flex;align-items:stretch;min-height:0;flex:1;border-top:1px solid var(--border)">
<div class="settings-nav">
<button data-tab="accounts" class="active" onclick="showSettingsTab('accounts')">Accounts</button>
<button data-tab="account" onclick="showSettingsTab('account')">Profile</button>
<button data-tab="rules" onclick="showSettingsTab('rules')">Rules</button>
<button data-tab="signatures" onclick="showSettingsTab('signatures')">Signatures</button>
<button data-tab="certs" onclick="showSettingsTab('certs')">Certificates</button>
<div class="settings-nav" role="tablist" aria-label="Settings sections">
<button data-tab="accounts" class="active" role="tab" aria-selected="true" onclick="showSettingsTab('accounts')">Accounts</button>
<button data-tab="general" role="tab" aria-selected="false" onclick="showSettingsTab('general')">General</button>
<button data-tab="security" role="tab" aria-selected="false" onclick="showSettingsTab('security')">Security</button>
<button data-tab="account" role="tab" aria-selected="false" onclick="showSettingsTab('account')">Profile</button>
<button data-tab="rules" role="tab" aria-selected="false" onclick="showSettingsTab('rules')">Rules</button>
<button data-tab="signatures" role="tab" aria-selected="false" onclick="showSettingsTab('signatures')">Signatures</button>
<button data-tab="certs" role="tab" aria-selected="false" onclick="showSettingsTab('certs')">Certificates</button>
</div>
<div style="flex:1;min-width:0;overflow-y:auto;padding:20px 24px">
<div class="settings-panel active" data-tab="accounts">
<div class="settings-panel active" data-tab="accounts" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Connected mailboxes</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Manage sync, credentials, CalDAV/CardDAV and per-account settings for each connected mailbox.</div>
@@ -527,7 +636,89 @@
</div>
</div>
<div class="settings-panel" data-tab="account">
<div class="settings-panel" data-tab="general" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Email Sync</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
<div style="display:flex;gap:10px;align-items:center">
<select id="sync-interval-select" style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="0">Manual only</option>
<option value="1">Every 1 minute</option>
<option value="5">Every 5 minutes</option>
<option value="10">Every 10 minutes</option>
<option value="15">Every 15 minutes (default)</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every 60 minutes</option>
</select>
<button class="btn-primary" onclick="saveSyncInterval()">Save</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Remote Images</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Control when images and other remote content in emails load automatically. Blocking prevents senders from using tracking pixels to detect that you've opened a message.</div>
<div class="modal-field">
<label for="remote-image-policy-select">Policy</label>
<select id="remote-image-policy-select" onchange="saveRemoteImagePolicy()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="always">Always render images</option>
<option value="contacts">Only from Contacts</option>
<option value="never">Never</option>
<option value="manual">Manually (needs allowing)</option>
</select>
</div>
<div class="modal-field">
<label>Allowed senders</label>
<div id="remote-whitelist-list" style="font-size:12px;color:var(--muted)">Loading…</div>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Notifications</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Show a browser notification when new mail arrives, even while GoWebMail is in a background tab.</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text)">
<input type="checkbox" id="notifications-toggle" onchange="toggleNotifications(this.checked)" style="width:auto">
Enable desktop notifications
</label>
<div id="notifications-status" style="font-size:12px;color:var(--muted);margin-top:8px"></div>
</div>
</div>
<div class="settings-panel" data-tab="security" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">IP Access Rules</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
</div>
<div class="modal-field">
<label>Mode</label>
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="disabled">Disabled — use global settings</option>
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
<option value="allow_only">Allow only — only listed IPs can log in</option>
</select>
</div>
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
<div class="modal-field" id="ip-rule-list-field">
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
</div>
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
</div>
<div class="settings-group">
<div class="settings-group-title">Login History</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">View login attempts for your account only — successful and failed, with timestamps and source IPs.</div>
<button class="btn-secondary" onclick="openLoginHistory()">View Login History</button>
</div>
<div class="settings-group">
<div class="settings-group-title">Spam Block</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Senders blocked here are automatically moved to Spam as soon as new mail from them arrives, across all your connected accounts — no notification is shown for it.</div>
<button class="btn-secondary" onclick="openSpamBlock()">Manage Spam Block List</button>
</div>
</div>
<div class="settings-panel" data-tab="account" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Profile</div>
<div class="modal-field">
@@ -550,23 +741,6 @@
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Email Sync</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
<div style="display:flex;gap:10px;align-items:center">
<select id="sync-interval-select" style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="0">Manual only</option>
<option value="1">Every 1 minute</option>
<option value="5">Every 5 minutes</option>
<option value="10">Every 10 minutes</option>
<option value="15">Every 15 minutes (default)</option>
<option value="30">Every 30 minutes</option>
<option value="60">Every 60 minutes</option>
</select>
<button class="btn-primary" onclick="saveSyncInterval()">Save</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Change Password</div>
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
@@ -580,30 +754,9 @@
</div>
<div id="mfa-panel">Loading...</div>
</div>
<div class="settings-group">
<div class="settings-group-title">IP Access Rules</div>
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
</div>
<div class="modal-field">
<label>Mode</label>
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
<option value="disabled">Disabled — use global settings</option>
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
<option value="allow_only">Allow only — only listed IPs can log in</option>
</select>
</div>
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
<div class="modal-field" id="ip-rule-list-field">
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
</div>
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
</div>
</div>
<div class="settings-panel" data-tab="rules">
<div class="settings-panel" data-tab="rules" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Rules apply to</div>
<select id="rules-account-select" onchange="loadRules()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none"></select>
@@ -642,12 +795,33 @@
</div>
</div>
<div class="settings-panel" data-tab="signatures">
<div class="settings-panel" data-tab="signatures" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Add Signature</div>
<div class="modal-field"><label>Name</label><input type="text" id="sig-name" placeholder="e.g. Work"></div>
<div class="modal-field"><label>Content (HTML)</label><textarea id="sig-content" rows="4" style="width:100%" placeholder="Best regards,&#10;Your Name"></textarea></div>
<button class="btn-primary" onclick="saveSignature()">Add Signature</button>
<div class="settings-group-title" id="sig-form-title">Add Signature</div>
<div class="modal-field"><label for="sig-name">Name</label><input type="text" id="sig-name" placeholder="e.g. Work"></div>
<div class="modal-field">
<label for="sig-content">Content</label>
<div class="compose-toolbar" role="toolbar" aria-label="Signature formatting" style="margin-bottom:6px">
<button type="button" class="fmt-btn" title="Bold" aria-label="Bold" onclick="execSigFmt('bold')"><b>B</b></button>
<button type="button" class="fmt-btn" title="Italic" aria-label="Italic" onclick="execSigFmt('italic')"><i>I</i></button>
<button type="button" class="fmt-btn" title="Underline" aria-label="Underline" onclick="execSigFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<label class="fmt-btn" title="Text color" aria-label="Text color" style="cursor:pointer;position:relative">
🎨<input type="color" id="sig-color-input" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer" onchange="execSigFmt('foreColor', this.value)">
</label>
<span class="fmt-sep"></span>
<button type="button" class="fmt-btn" title="Link" aria-label="Insert link" onclick="insertLink('sig-content')">&#128279;</button>
<button type="button" class="fmt-btn" title="Image" aria-label="Insert image" onclick="document.getElementById('sig-image-input').click()">&#128247;</button>
<button type="button" class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execSigFmt('removeFormat')">T&#x20D7;</button>
<input type="file" id="sig-image-input" accept="image/*" style="display:none" onchange="insertSigImage(this)">
</div>
<div id="sig-content" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Signature content"
style="width:100%;min-height:110px;padding:10px;background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;line-height:1.5;overflow-y:auto"></div>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" id="sig-save-btn" onclick="saveSignature()">Add Signature</button>
<button class="btn-secondary" id="sig-cancel-btn" onclick="cancelSignatureEdit()" style="display:none">Cancel</button>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Your Signatures</div>
@@ -660,7 +834,7 @@
</div>
</div>
<div class="settings-panel" data-tab="certs">
<div class="settings-panel" data-tab="certs" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Certificates apply to</div>
<select id="certs-account-select" onchange="loadCerts()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none"></select>
@@ -776,10 +950,10 @@
<!-- Context menu -->
<div class="ctx-menu" id="ctx-menu"></div>
<div class="toast-container" id="toast-container"></div>
<div class="toast-container" id="toast-container" role="status" aria-live="polite" aria-atomic="true"></div>
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=70"></script>
<script src="/static/js/contacts_calendar.js?v=70"></script>
<script src="/static/js/app.js?v=91"></script>
<script src="/static/js/contacts_calendar.js?v=79"></script>
{{end}}
+2 -2
View File
@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoWebMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=70">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=79">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=70"></script>
<script src="/static/js/gowebmail.js?v=79"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
+278 -50
View File
@@ -1,56 +1,85 @@
{{template "base" .}}
{{define "title"}}Compose — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body_class"}}{{end}}
{{define "body"}}
<div id="compose-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<div id="compose-page" style="width:100%;box-sizing:border-box;margin:0 auto;padding:20px 32px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border);flex-wrap:wrap">
<a href="/" id="cp-back-link" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<span id="compose-page-title" style="font-size:14px;color:var(--text2)">New Message</span>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">Save Draft</button>
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<button class="btn-secondary" id="discard-draft-btn" style="display:none;font-size:12px;color:var(--danger)" onclick="discardDraftAndReset()">Discard draft</button>
<button type="button" id="cc-toggle" class="btn-secondary" style="font-size:12px" onclick="cpShowCC()">+CC</button>
<button type="button" id="bcc-toggle" class="btn-secondary" style="font-size:12px" onclick="cpShowBCC()">+BCC</button>
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">📎 Attach</button>
<button class="btn-secondary" id="save-draft-btn" onclick="saveDraft()" style="font-size:12px">💾 Save Draft</button>
<button class="btn-secondary" id="sendlater-btn" style="font-size:12px" onclick="toggleSendLaterPanel()">🕐 Send later</button>
<button class="modal-submit" id="send-page-btn" onclick="sendFromPage()" style="font-size:13px;padding:7px 18px">Send</button>
<input type="file" id="cp-file-input" multiple style="display:none" onchange="addPageAttachments(this.files)">
</div>
</div>
<div id="cp-leave-confirm" class="remote-content-banner" style="display:none;margin-bottom:14px">
You have a draft in progress.
<button class="rcb-btn" id="cp-leave-keep">Keep editing</button>
<button class="rcb-btn" id="cp-leave-save">Save &amp; leave</button>
<button class="rcb-btn" id="cp-leave-discard">Discard &amp; leave</button>
</div>
<div id="cp-sendlater-panel" class="remote-content-banner" style="display:none;margin-bottom:14px">
Send at:
<input type="datetime-local" id="cp-sendlater-input" style="background:var(--surface3);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:4px 8px;font-size:13px">
<button class="rcb-btn" onclick="confirmSendLater()">Schedule</button>
<button class="rcb-btn" onclick="document.getElementById('cp-sendlater-panel').style.display='none'">Cancel</button>
</div>
<div id="compose-page-form">
<!-- From -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</span>
<label for="cp-from" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">From</label>
<select id="cp-from" style="flex:1;background:transparent;border:none;color:var(--text);font-size:13px;outline:none;cursor:pointer"></select>
</div>
<!-- To -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
<div id="cp-to-tags" class="tag-field" style="flex:1;min-height:30px"></div>
<span id="cp-to-label" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">To</span>
<div id="cp-to-tags" class="tag-container" role="group" aria-labelledby="cp-to-label" style="flex:1;min-height:30px"></div>
</div>
<!-- CC -->
<div style="display:flex;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
<div id="cp-cc-tags" class="tag-field" style="flex:1;min-height:30px"></div>
<div id="cc-row" style="display:none;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span id="cp-cc-label" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">CC</span>
<div id="cp-cc-tags" class="tag-container" role="group" aria-labelledby="cp-cc-label" style="flex:1;min-height:30px"></div>
</div>
<!-- BCC -->
<div id="bcc-row" style="display:none;align-items:flex-start;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span id="cp-bcc-label" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0;padding-top:6px">BCC</span>
<div id="cp-bcc-tags" class="tag-container" role="group" aria-labelledby="cp-bcc-label" style="flex:1;min-height:30px"></div>
</div>
<!-- Subject -->
<div style="display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 0;gap:8px">
<span style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</span>
<input id="cp-subject" type="text" placeholder="Subject" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
<label for="cp-subject" style="font-size:12px;color:var(--muted);width:48px;flex-shrink:0">Subject</label>
<input id="cp-subject" type="text" placeholder="Subject" oninput="markDirty()" style="flex:1;background:transparent;border:none;color:var(--text);font-size:14px;outline:none;font-family:'DM Sans',sans-serif">
</div>
<!-- Formatting toolbar -->
<div class="compose-toolbar" role="toolbar" aria-label="Formatting">
<button class="fmt-btn" title="Bold" aria-label="Bold" onclick="execFmt('bold')"><b>B</b></button>
<button class="fmt-btn" title="Italic" aria-label="Italic" onclick="execFmt('italic')"><i>I</i></button>
<button class="fmt-btn" title="Underline" aria-label="Underline" onclick="execFmt('underline')"><u>U</u></button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Bullets" aria-label="Bulleted list" onclick="execFmt('insertUnorderedList')">&#8226;&#8212;</button>
<button class="fmt-btn" title="Numbers" aria-label="Numbered list" onclick="execFmt('insertOrderedList')">1&#8212;</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T&#x20D7;</button>
</div>
<!-- Body -->
<div id="cp-editor" contenteditable="true" style="min-height:400px;padding:16px 0;outline:none;font-size:14px;line-height:1.6;color:var(--text)" data-placeholder="Write your message…"></div>
<!-- Attachments -->
<div style="border-top:1px solid var(--border);padding:10px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<label style="cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
Attach file
<input type="file" multiple style="display:none" onchange="addPageAttachments(this.files)">
</label>
<div id="cp-att-list" style="display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
<div id="cp-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Message body" oninput="markDirty()" style="min-height:400px;padding:16px 0;outline:none;font-size:14px;line-height:1.6;color:var(--text)" data-placeholder="Write your message…"></div>
<!-- Attachments (added via the "Attach" button in the header) -->
<div id="cp-att-list" style="border-top:1px solid var(--border);padding:10px 0;display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
<div id="cp-status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
<div id="cp-status" role="status" style="font-size:13px;color:var(--muted);margin-top:8px"></div>
</div>
{{end}}
@@ -60,7 +89,10 @@
const params = new URLSearchParams(location.search);
const replyId = parseInt(params.get('reply_id') || '0');
const forwardId = parseInt(params.get('forward_id') || '0');
const editDraftId = parseInt(params.get('edit_draft_id') || '0');
const cpAttachments = [];
let draftId = '', dirty = false, draftTimer = null;
let remoteWhitelist = new Set(), remoteImagePolicy = 'manual', contactsCache = null;
async function apiCall(method, path, body) {
const opts = { method, headers: {} };
@@ -70,9 +102,44 @@ async function apiCall(method, path, body) {
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function markDirty() { dirty = true; }
function setStatus(msg, isError) {
const el = document.getElementById('cp-status');
el.textContent = msg;
el.style.color = isError ? 'var(--danger)' : 'var(--muted)';
}
// Tag field (simple comma/enter separated)
// ── Remote-image policy — same rules as the reading pane (app.js / message.html) ──
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(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
function stripRemoteImages(h){
return h.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi,'<img$1src="" data-blocked-src="$3"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi,'url()')
.replace(/<link[^>]*>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'');
}
function isContactEmail(fromEmail) {
if (!fromEmail || !contactsCache) return false;
const e = fromEmail.toLowerCase();
return contactsCache.some(c => (c.email||'').toLowerCase() === e);
}
function isRemoteContentAllowed(fromEmail) {
if (remoteImagePolicy === 'always') return true;
if (remoteImagePolicy === 'never') return false;
if (remoteImagePolicy === 'contacts') return isContactEmail(fromEmail) || remoteWhitelist.has(fromEmail);
return remoteWhitelist.has(fromEmail); // manual (default)
}
function quotedBodyHTML(msg) {
if (!msg.body_html) return '<pre>'+esc(msg.body_text||'')+'</pre>';
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
if (!isRemoteContentAllowed(msg.from_email)) html = stripRemoteImages(html);
return html;
}
function restoreBlockedImages(html) {
return html.replace(/src=""\s+data-blocked-src="([^"]*)"/gi, 'src="$1"');
}
// ── Tag fields (To/Cc/Bcc) — same visual style as the main compose modal ──
function initTagField(id) {
const el = document.getElementById(id);
if (!el) return;
@@ -85,8 +152,8 @@ function initTagField(id) {
if (v) addTagTo(id, v);
inp.value = '';
} else if (e.key === 'Backspace' && !inp.value) {
const tags = el.querySelectorAll('.tag-chip');
if (tags.length) tags[tags.length-1].remove();
const tags = el.querySelectorAll('.email-tag');
if (tags.length) { tags[tags.length-1].remove(); markDirty(); }
}
});
inp.addEventListener('blur', () => {
@@ -96,20 +163,32 @@ function initTagField(id) {
}
function addTagTo(fieldId, email) {
if (!email) return;
const el = document.getElementById(fieldId);
const inp = el.querySelector('input');
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.style.cssText = 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:var(--accent-dim);color:var(--accent);border-radius:12px;font-size:12px;margin:2px';
chip.innerHTML = `${esc(email)}<span style="cursor:pointer;margin-left:2px" onclick="this.parentNode.remove()">×</span>`;
el.insertBefore(chip, inp);
const tag = document.createElement('span');
tag.className = 'email-tag';
const label = document.createElement('span');
label.textContent = email;
const remove = document.createElement('button');
remove.innerHTML = '×'; remove.className = 'tag-remove'; remove.type = 'button';
remove.onclick = e => { e.stopPropagation(); tag.remove(); markDirty(); };
tag.appendChild(label); tag.appendChild(remove);
el.insertBefore(tag, inp || null);
markDirty();
}
function getTagValues(fieldId) {
const el = document.getElementById(fieldId);
return Array.from(el.querySelectorAll('.tag-chip')).map(c => c.textContent.replace('×','').trim()).filter(Boolean);
return Array.from(el.querySelectorAll('.email-tag')).map(c => c.firstChild.textContent.trim()).filter(Boolean);
}
function cpShowCC() { document.getElementById('cc-row').style.display = 'flex'; document.getElementById('cc-toggle').style.display = 'none'; }
function cpShowBCC() { document.getElementById('bcc-row').style.display = 'flex'; document.getElementById('bcc-toggle').style.display = 'none'; }
function execFmt(cmd, val) { document.getElementById('cp-editor').focus(); document.execCommand(cmd, false, val || null); }
function triggerAttach() { document.getElementById('cp-file-input').click(); }
function addPageAttachments(files) {
for (const f of files) {
cpAttachments.push(f);
@@ -118,6 +197,7 @@ function addPageAttachments(files) {
chip.textContent = f.name;
document.getElementById('cp-att-list').appendChild(chip);
}
markDirty();
}
async function loadAccounts() {
@@ -131,6 +211,13 @@ async function loadAccounts() {
});
}
async function loadRemoteImagePrefs() {
const [uiPrefs, wl] = await Promise.all([apiCall('GET', '/ui-prefs'), apiCall('GET', '/remote-content-whitelist')]);
remoteImagePolicy = uiPrefs?.remoteImagePolicy || 'manual';
if (wl?.whitelist) remoteWhitelist = new Set(wl.whitelist);
if (remoteImagePolicy === 'contacts') contactsCache = await apiCall('GET', '/contacts') || [];
}
async function prefillReply() {
if (!replyId) return;
document.getElementById('compose-page-title').textContent = 'Reply';
@@ -142,13 +229,14 @@ async function prefillReply() {
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">On ${msg.date ? new Date(msg.date).toLocaleString() : ''}, ${esc(msg.from_email)} wrote:</div>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
<div style="max-width:700px;overflow-x:auto">${quotedBodyHTML(msg)}</div>
</div>`;
// Set from to same account
if (msg.account_id) {
const sel = document.getElementById('cp-from');
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
}
dirty = false;
}
async function prefillForward() {
@@ -161,27 +249,56 @@ async function prefillForward() {
const editor = document.getElementById('cp-editor');
editor.innerHTML = `<br><br><div style="border-left:3px solid #ccc;padding-left:12px;color:#666;margin-top:8px">
<div style="font-size:12px;margin-bottom:4px">---------- Forwarded message ----------<br>From: ${esc(msg.from_email)}<br>Subject: ${esc(msg.subject)}</div>
${msg.body_html || '<pre>' + (msg.body_text||'') + '</pre>'}
<div style="max-width:700px;overflow-x:auto">${quotedBodyHTML(msg)}</div>
</div>`;
if (msg.account_id) {
const sel = document.getElementById('cp-from');
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
}
dirty = false;
}
// Resuming a saved draft: unlike reply/forward, fields are populated directly (no quoting
// wrapper) and draftId is seeded from the draft's own id so the next autosave/send/discard
// replaces this exact draft in place instead of creating a second copy.
async function prefillEditDraft() {
if (!editDraftId) return;
document.getElementById('compose-page-title').textContent = 'Edit Draft';
const msg = await apiCall('GET', '/messages/' + editDraftId);
if (!msg) return;
document.title = 'Edit Draft — GoWebMail';
document.getElementById('cp-subject').value = msg.subject || '';
(msg.to || '').split(',').map(s => s.trim()).filter(Boolean).forEach(a => addTagTo('cp-to-tags', a));
const ccList = (msg.cc || '').split(',').map(s => s.trim()).filter(Boolean);
if (ccList.length) { cpShowCC(); ccList.forEach(a => addTagTo('cp-cc-tags', a)); }
const bccList = (msg.bcc || '').split(',').map(s => s.trim()).filter(Boolean);
if (bccList.length) { cpShowBCC(); bccList.forEach(a => addTagTo('cp-bcc-tags', a)); }
document.getElementById('cp-editor').innerHTML = quotedBodyHTML(msg);
if (msg.account_id) {
const sel = document.getElementById('cp-from');
for (const opt of sel.options) { if (parseInt(opt.value) === msg.account_id) { opt.selected = true; break; } }
}
draftId = msg.remote_uid || '';
updateDraftUI();
dirty = false;
}
async function sendFromPage() {
const btn = document.getElementById('send-page-btn');
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { document.getElementById('cp-status').textContent = 'From account and To address required.'; return; }
if (!accountId || !to.length) { setStatus('From account and To address required.', true); return; }
btn.disabled = true; btn.textContent = 'Sending…';
const meta = {
account_id: accountId,
to,
cc: getTagValues('cp-cc-tags'),
bcc: [],
bcc: getTagValues('cp-bcc-tags'),
subject: document.getElementById('cp-subject').value,
body_html: document.getElementById('cp-editor').innerHTML,
body_html: restoreBlockedImages(document.getElementById('cp-editor').innerHTML.trim()),
body_text: document.getElementById('cp-editor').innerText,
in_reply_to_id: replyId || 0,
forward_from_id: forwardId || 0,
};
let r;
@@ -198,23 +315,134 @@ async function sendFromPage() {
btn.disabled = false; btn.textContent = 'Send';
if (r?.ok) {
document.getElementById('cp-status').innerHTML = '✓ Message sent! <a href="/" style="color:var(--accent)">Back to inbox</a>';
stopAutosave();
dirty = false;
await discardDraftReq(); // the autosaved Drafts-folder copy is now redundant — it's been sent
setStatus('✓ Message sent!');
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
document.getElementById('cp-back-link').innerHTML = '← Back to inbox';
} else {
document.getElementById('cp-status').textContent = r?.error || 'Send failed.';
setStatus(r?.error || 'Send failed.', true);
}
}
async function saveDraft() {
document.getElementById('cp-status').textContent = 'Draft saving not yet supported in standalone view.';
// ── Send later ───────────────────────────────────────────────────────────────
function toLocalInput(d) {
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function toggleSendLaterPanel() {
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { setStatus('From account and To address required.', true); return; }
if (cpAttachments.length) { setStatus("Send later doesn't support file attachments yet — forwarded messages are fine", true); return; }
const panel = document.getElementById('cp-sendlater-panel');
const isOpen = panel.style.display !== 'none';
panel.style.display = isOpen ? 'none' : 'flex';
if (!isOpen) {
const input = document.getElementById('cp-sendlater-input');
input.min = toLocalInput(new Date(Date.now() + 60000));
input.value = toLocalInput(new Date(Date.now() + 3600000));
}
}
async function confirmSendLater() {
const input = document.getElementById('cp-sendlater-input');
const d = new Date(input.value);
if (!input.value || isNaN(d.getTime()) || d <= new Date()) { setStatus('Pick a time in the future.', true); return; }
const accountId = parseInt(document.getElementById('cp-from').value || '0');
const to = getTagValues('cp-to-tags');
if (!accountId || !to.length) { setStatus('From account and To address required.', true); return; }
const meta = {
account_id: accountId, to,
cc: getTagValues('cp-cc-tags'), bcc: getTagValues('cp-bcc-tags'),
subject: document.getElementById('cp-subject').value,
body_html: restoreBlockedImages(document.getElementById('cp-editor').innerHTML.trim()),
body_text: document.getElementById('cp-editor').innerText,
send_at: d.toISOString(),
};
const r = await apiCall('POST', '/send-later', meta);
if (r?.ok) {
stopAutosave();
dirty = false;
await discardDraftReq();
setStatus('✓ Message scheduled!');
document.getElementById('cp-sendlater-panel').style.display = 'none';
document.getElementById('compose-page-form').style.opacity = '0.5';
document.getElementById('compose-page-form').style.pointerEvents = 'none';
document.getElementById('cp-back-link').innerHTML = '← Back to inbox';
} else {
setStatus(r?.error || 'Failed to schedule.', true);
}
}
// ── Draft autosave ──────────────────────────────────────────────────────────
function startAutosave() { stopAutosave(); draftTimer = setInterval(() => { if (dirty) saveDraft(true); }, 60000); }
function stopAutosave() { if (draftTimer) { clearInterval(draftTimer); draftTimer = null; } }
function updateDraftUI() {
document.getElementById('discard-draft-btn').style.display = draftId ? 'inline-block' : 'none';
}
async function saveDraft(silent) {
dirty = false;
const accountId = parseInt(document.getElementById('cp-from')?.value || 0);
if (!accountId) { if (!silent) setStatus('Add a From account first.', true); return; }
const editor = document.getElementById('cp-editor');
const meta = {
account_id: accountId,
to: getTagValues('cp-to-tags'),
cc: getTagValues('cp-cc-tags'),
bcc: getTagValues('cp-bcc-tags'),
subject: document.getElementById('cp-subject').value,
body_html: restoreBlockedImages(editor.innerHTML.trim()),
body_text: editor.innerText.trim(),
draft_id: draftId,
};
const r = await apiCall('POST', '/draft', meta);
if (r?.ok) { draftId = r.draft_id || draftId; updateDraftUI(); setStatus(silent ? 'Draft auto-saved' : 'Draft saved'); }
else if (!silent) setStatus(r?.error || 'Draft save failed', true);
}
// Deletes the draft that autosave already wrote to the server for this compose session.
async function discardDraftReq() {
if (!draftId) return;
const accountId = parseInt(document.getElementById('cp-from')?.value || 0);
if (!accountId) return;
await apiCall('POST', '/draft/discard', { account_id: accountId, draft_id: draftId });
draftId = ''; updateDraftUI();
}
async function discardDraftAndReset() {
await discardDraftReq();
setStatus('Draft discarded');
}
// ── Leaving the page with unsent work ───────────────────────────────────────
document.getElementById('cp-back-link').addEventListener('click', e => {
if (dirty || draftId) {
e.preventDefault();
document.getElementById('cp-leave-confirm').style.display = 'flex';
}
});
document.getElementById('cp-leave-keep').onclick = () => { document.getElementById('cp-leave-confirm').style.display = 'none'; };
document.getElementById('cp-leave-discard').onclick = async () => { await discardDraftReq(); location.href = '/'; };
document.getElementById('cp-leave-save').onclick = async () => { await saveDraft(true); location.href = '/'; };
window.addEventListener('beforeunload', e => {
if (dirty || draftId) { e.preventDefault(); e.returnValue = ''; }
});
// Init
initTagField('cp-to-tags');
initTagField('cp-cc-tags');
loadAccounts();
if (replyId) prefillReply();
else if (forwardId) prefillForward();
async function boot() {
initTagField('cp-to-tags');
initTagField('cp-cc-tags');
initTagField('cp-bcc-tags');
await Promise.all([loadAccounts(), loadRemoteImagePrefs()]);
if (replyId) await prefillReply();
else if (forwardId) await prefillForward();
else if (editDraftId) await prefillEditDraft();
startAutosave();
}
boot();
</script>
{{end}}
+3 -3
View File
@@ -9,10 +9,10 @@
</div>
<h1>Welcome back</h1>
<p class="subtitle">Sign in to your Web Mail Client</p>
<div id="err" class="alert error" style="display:none"></div>
<div id="err" class="alert error" role="alert" style="display:none"></div>
<form method="POST" action="/auth/login">
<div class="field"><label>Username or Email</label><input type="text" name="username" placeholder="admin" required autocomplete="username"></div>
<div class="field"><label>Password</label><input type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div>
<div class="field"><label for="login-username">Username or Email</label><input id="login-username" type="text" name="username" placeholder="admin" required autocomplete="username"></div>
<div class="field"><label for="login-password">Password</label><input id="login-password" type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div>
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Sign In</button>
</form>
</div>
+124 -26
View File
@@ -1,9 +1,9 @@
{{template "base" .}}
{{define "title"}}Message — GoWebMail{{end}}
{{define "body_class"}}app-page{{end}}
{{define "body_class"}}{{end}}
{{define "body"}}
<div id="msg-page" style="max-width:860px;margin:0 auto;padding:20px 16px;min-height:100vh">
<div id="msg-page" style="width:100%;box-sizing:border-box;margin:0 auto;padding:20px 32px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
@@ -25,6 +25,7 @@
{{define "scripts"}}
<script>
const msgId = parseInt(location.pathname.split('/').pop());
let remoteWhitelist = new Set(), remoteImagePolicy = 'manual', contactsCache = null;
async function api(method, path, body) {
const opts = { method, headers: {} };
@@ -35,14 +36,83 @@ async function api(method, path, body) {
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
async function load() {
const msg = await api('GET', '/messages/' + msgId);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// ── Remote-image policy — same rules as the main reading pane (app.js) ──
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(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
function stripRemoteImages(h){
return h.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi,'<img$1src="" data-blocked-src="$3"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi,'url()')
.replace(/<link[^>]*>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'');
}
function isContactEmail(fromEmail) {
if (!fromEmail || !contactsCache) return false;
const e = fromEmail.toLowerCase();
return contactsCache.some(c => (c.email||'').toLowerCase() === e);
}
function isRemoteContentAllowed(fromEmail) {
if (remoteImagePolicy === 'always') return true;
if (remoteImagePolicy === 'never') return false;
if (remoteImagePolicy === 'contacts') return isContactEmail(fromEmail) || remoteWhitelist.has(fromEmail);
return remoteWhitelist.has(fromEmail); // manual (default)
}
async function whitelistSender(sender) {
const r = await api('POST', '/remote-content-whitelist', { sender });
if (r?.ok) { remoteWhitelist.add(sender); render(window._msg, true); }
}
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
const cssReset = `<style>html,body{background:#ffffff!important;color:#1a1a1a!important;` +
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
`img{max-width:100%;height:auto}iframe{display:none!important}</style>`;
// Content-aware height report (leaf elements only — see app.js renderMessageDetail for why
// document.documentElement.scrollHeight is wrong: it counts trailing structural dead space
// some email templates leave behind) + link-click interception.
const heightScript = `<script>
function _reportH(){
try{
var maxBottom=0;
var all=document.body?document.body.getElementsByTagName('*'):[];
for(var i=0;i<all.length;i++){
var el=all[i];
if(el.children.length>0) continue;
var cs=getComputedStyle(el);
if(cs.display==='none'||cs.visibility==='hidden'||parseFloat(cs.opacity||'1')===0) continue;
var hasText=(el.textContent||'').replace(/[\\s\\u00A0]/g,'').length>0;
if(!hasText && el.tagName!=='IMG') continue;
var r=el.getBoundingClientRect();
if(r.bottom>maxBottom) maxBottom=r.bottom;
}
var h=maxBottom>0?maxBottom:document.documentElement.scrollHeight;
parent.postMessage({type:'gomail-frame-h',h:h},'*');
}catch(ex){parent.postMessage({type:'gomail-frame-h',h:0},'*');}
}
document.addEventListener('DOMContentLoaded',_reportH);
window.addEventListener('load',_reportH);
new MutationObserver(_reportH).observe(document.documentElement,{subtree:true,childList:true,attributes:true});
if(window.ResizeObserver) new ResizeObserver(_reportH).observe(document.documentElement);
[50,150,400,900,1800,3000].forEach(function(ms){ setTimeout(_reportH, ms); });
document.addEventListener('click',function(e){
var el=e.target; while(el&&el.tagName!=='A') el=el.parentElement;
if(!el) return;
var href=el.getAttribute('href');
if(!href||href.startsWith('#')||href.startsWith('mailto:')) return;
e.preventDefault(); e.stopPropagation();
parent.postMessage({type:'gomail-open-url',url:href},'*');
},true);
<\/script>`;
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
window.addEventListener('message', e => {
if (e.data?.type === 'gomail-frame-h' && e.data.h > 50) {
const frame = document.getElementById('msg-frame');
if (frame) frame.style.height = (e.data.h + 24) + 'px';
} else if (e.data?.type === 'gomail-open-url' && e.data.url) {
window.open(e.data.url, '_blank', 'noopener,noreferrer');
}
});
function render(msg, showRemoteContent) {
window._msg = msg;
const allowed = showRemoteContent || isRemoteContentAllowed(msg.from_email);
const atts = msg.attachments || [];
const attHtml = atts.length ? `
@@ -53,6 +123,28 @@ async function load() {
📎 ${esc(a.filename)} <span style="color:var(--muted)">(${(a.size/1024).toFixed(0)}KB)</span></a>`).join('')}
</div>` : '';
let bodyHtml = '';
if (msg.body_html) {
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
if (!allowed) {
const alwaysAllowBtn = remoteImagePolicy === 'never' ? '' :
`<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>`;
bodyHtml = `<div class="remote-content-banner">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
Remote images blocked.
<button class="rcb-btn" onclick="render(window._msg,true)">Load images</button>
${alwaysAllowBtn}
</div>`;
html = stripRemoteImages(html);
}
const srcdoc = (cssReset + heightScript + html).replace(/"/g,'&quot;');
bodyHtml += `<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
<iframe id="msg-frame" title="Message content" sandbox="${sandboxAttr}" style="width:100%;border:none;min-height:200px;display:block" srcdoc="${srcdoc}"></iframe>
</div>`;
} else {
bodyHtml = `<div style="border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:12px;white-space:pre-wrap">${esc(msg.body_text||'(empty)')}</div>`;
}
document.getElementById('msg-content').innerHTML = `
<h1 style="font-size:22px;font-weight:600;margin-bottom:16px;line-height:1.3">${esc(msg.subject || '(no subject)')}</h1>
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap;gap:8px">
@@ -63,31 +155,37 @@ async function load() {
</div>
<span style="font-size:12px;color:var(--muted);white-space:nowrap">${esc(msg.date ? new Date(msg.date).toLocaleString() : '')}</span>
</div>
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
<iframe id="msg-iframe" sandbox="allow-same-origin" style="width:100%;border:none;min-height:400px;background:white"></iframe>
</div>
${bodyHtml}
${attHtml}`;
}
// Write body into sandboxed iframe
const iframe = document.getElementById('msg-iframe');
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`<!DOCTYPE html><html><head><style>
body{font-family:sans-serif;font-size:14px;line-height:1.6;padding:16px;margin:0;color:#111;word-break:break-word}
img{max-width:100%;height:auto}a{color:#0078D4}
</style></head><body>${msg.body_html || '<pre style="white-space:pre-wrap">' + (msg.body_text||'') + '</pre>'}</body></html>`);
doc.close();
// Auto-resize iframe
setTimeout(() => {
try { iframe.style.height = (doc.documentElement.scrollHeight + 20) + 'px'; } catch(e) {}
}, 200);
async function load() {
const [msg, folders, uiPrefs, wl] = await Promise.all([
api('GET', '/messages/' + msgId), api('GET', '/folders'),
api('GET', '/ui-prefs'), api('GET', '/remote-content-whitelist'),
]);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// A draft opened here (bookmark, typed URL, old link) should open editable, not read-only.
const folder = (folders||[]).find(f=>f.id===msg.folder_id);
if (folder?.folder_type === 'drafts') { location.replace('/compose?edit_draft_id=' + msgId); return; }
remoteImagePolicy = uiPrefs?.remoteImagePolicy || 'manual';
if (wl?.whitelist) remoteWhitelist = new Set(wl.whitelist);
if (remoteImagePolicy === 'contacts') contactsCache = await api('GET', '/contacts') || [];
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
render(msg, false);
}
function replyFromPage() {
window.location = '/?action=reply&id=' + msgId;
window.location = '/compose?reply_id=' + msgId;
}
function forwardFromPage() {
window.location = '/?action=forward&id=' + msgId;
window.location = '/compose?forward_id=' + msgId;
}
load();
+3 -3
View File
@@ -9,10 +9,10 @@
</div>
<h1>Two-Factor Auth</h1>
<p class="subtitle">Enter the 6-digit code from your authenticator app</p>
<div id="err" class="alert error" style="display:none"></div>
<div id="err" class="alert error" role="alert" style="display:none"></div>
<form method="POST" action="/auth/mfa/verify">
<div class="field"><label>Verification Code</label>
<input type="text" name="code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" autofocus required
<div class="field"><label for="mfa-code">Verification Code</label>
<input id="mfa-code" type="text" name="code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" autofocus required
style="font-size:22px;letter-spacing:.3em;text-align:center">
</div>
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Verify</button>