fix deleting messages was not updating server

This commit is contained in:
2026-08-30 09:41:46 +01:00
parent 69d82f1c96
commit de7f124d60
10 changed files with 414 additions and 25 deletions
+5
View File
@@ -241,6 +241,11 @@ func main() {
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")
+142 -11
View File
@@ -158,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 {
@@ -2058,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
@@ -2473,10 +2564,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).
@@ -2500,6 +2599,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) {
@@ -2627,18 +2746,30 @@ func (d *DB) ListMessageIDsByFolder(folderID, userID int64) ([]int64, error) {
// 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
+19
View File
@@ -1593,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)
+88 -8
View File
@@ -10,6 +10,7 @@ import (
"log"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@@ -1034,17 +1035,18 @@ func (h *APIHandler) WakeExpiredSnoozes() {
}
}
func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
// 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)
if err := h.db.DeleteMessage(messageID, userID); err != nil {
h.writeError(w, http.StatusInternalServerError, "delete failed")
return
return err
}
if remoteErr == nil && remoteAcc != nil && remoteAcc.Provider == models.ProviderOutlookPersonal {
@@ -1058,6 +1060,16 @@ 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})
}
@@ -1744,6 +1756,68 @@ func (h *APIHandler) DeleteRemoteContentWhitelist(w http.ResponseWriter, r *http
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) {
@@ -1761,11 +1835,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})
}
+7
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"`
+26
View File
@@ -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) {
+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)
}
}
+3 -3
View File
@@ -51,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}
+52
View File
@@ -1524,6 +1524,22 @@ function isDraftFolder(folderId) {
function isSentFolderView() {
return S.folders?.find(f=>f.id===S.currentFolder)?.folder_type==='sent';
}
function isSpamFolderView() {
return S.folders?.find(f=>f.id===S.currentFolder)?.folder_type==='spam';
}
// Moves the message to its account's Spam folder and adds the sender to the Settings >
// Security > Spam Block list, so future mail from them is auto-filed to Spam at sync time
// too (see IsSpamBlocked call sites in the syncer) — not just this one message.
async function markAsSpam(msgId) {
const msg = (S.currentMessage?.id===msgId) ? S.currentMessage : S.messages.find(m=>m.id===msgId);
if (!msg) return;
const spamFolder = S.folders.find(f=>f.account_id===msg.account_id && f.folder_type==='spam');
if (!spamFolder) { toast('No Spam folder found for this account','error'); return; }
if (msg.from_email) await api('POST','/spam-block',{sender:msg.from_email});
await moveMessage(msgId, spamFolder.id, true);
toast('Marked as spam — future mail from '+(msg.from_email||'this sender')+' will be blocked too','success');
}
function resumeDraft(msg) {
const toList=(msg.to||'').split(',').map(s=>s.trim()).filter(Boolean);
const ccList=(msg.cc||'').split(',').map(s=>s.trim()).filter(Boolean);
@@ -1772,6 +1788,7 @@ function renderMessageDetail(msg, showRemoteContent) {
<button class="action-btn" onclick="${S.currentFolder==='snoozed'?'unsnoozeMessage':'snoozeMessage'}(${msg.id})"> ${S.currentFolder==='snoozed'?'Unsnooze':'Snooze'}</button>
<button class="action-btn" onclick="showMessageHeaders(${msg.id})"> Headers</button>
<button class="action-btn" onclick="downloadEML(${msg.id})"> Download</button>
${(isSentFolderView()||isSpamFolderView())?'':`<button class="action-btn" onclick="markAsSpam(${msg.id})" title="Move to Spam and block this sender">🚫 Mark as Spam</button>`}
<button class="action-btn danger" onclick="deleteMessage(${msg.id})">🗑 Delete</button>
</div>
${attachHtml}
@@ -1906,6 +1923,7 @@ function showMessageMenu(e, id) {
<div class="ctx-item" onclick="showMessageHeaders(${id});closeMenu()"> View headers</div>
<div class="ctx-item" onclick="downloadEML(${id});closeMenu()"> Download .eml</div>
<div class="ctx-sep"></div>
${(isSentFolderView()||isSpamFolderView())?'':`<div class="ctx-item" onclick="markAsSpam(${id});closeMenu()">🚫 Mark as spam</div>`}
<div class="ctx-item danger" onclick="deleteMessage(${id});closeMenu()">🗑 Delete</div>`);
}
@@ -3177,6 +3195,40 @@ function loginHistoryPrevPage() { if (LH.page > 1) loadLoginHistory(LH.page - 1)
function loginHistoryNextPage() { if (LH.hasMore) loadLoginHistory(LH.page + 1); }
const debouncedLoadLoginHistory = debounce(() => loadLoginHistory(1), 400);
// ── Spam Block (Settings > Security) ────────────────────────────────────────
function openSpamBlock() {
openModal('spam-block-modal');
loadSpamBlock();
}
async function loadSpamBlock() {
const tbody = document.getElementById('sb-table-body');
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;padding:24px"><span class="spinner-inline"></span></td></tr>';
const r = await api('GET', '/spam-block');
const entries = r?.entries || [];
tbody.innerHTML = entries.length ? entries.map(e => `
<tr>
<td style="font-family:monospace;font-size:12px">${esc(e.sender)}</td>
<td style="font-family:monospace;font-size:11px;color:var(--muted)">${new Date(e.created_at).toLocaleString()}</td>
<td><button class="btn-secondary" style="font-size:11px;padding:3px 8px" onclick="removeSpamBlockEntry('${esc(e.sender)}')">Remove</button></td>
</tr>`).join('') : '<tr><td colspan="3" style="text-align:center;color:var(--muted);padding:24px">No blocked senders yet.</td></tr>';
}
async function addSpamBlockEntry() {
const input = document.getElementById('sb-add-input');
const sender = input.value.trim();
if (!sender) return;
const r = await api('POST', '/spam-block', { sender });
if (r?.ok) { input.value = ''; toast('Sender blocked', 'success'); loadSpamBlock(); }
else toast(r?.error || 'Failed to block sender', 'error');
}
async function removeSpamBlockEntry(sender) {
const r = await api('DELETE', '/spam-block?sender=' + encodeURIComponent(sender));
if (r?.ok) { toast('Sender unblocked', 'success'); loadSpamBlock(); }
else toast('Failed to remove', 'error');
}
async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; }
// ── Context menu helper ────────────────────────────────────────────────────
+27
View File
@@ -434,6 +434,27 @@
</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" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="add-account-modal-title">
<div class="modal">
@@ -689,6 +710,12 @@
<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">