diff --git a/cmd/server/main.go b/cmd/server/main.go index ab667b5..4b4e91b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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") diff --git a/internal/db/db.go b/internal/db/db.go index 7908088..d37a11f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 diff --git a/internal/email/imap.go b/internal/email/imap.go index b529204..1dff4ea 100644 --- a/internal/email/imap.go +++ b/internal/email/imap.go @@ -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) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index a09cf2a..77323bb 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -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}) } diff --git a/internal/models/models.go b/internal/models/models.go index a006d59..7d2a56b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/syncer/rules.go b/internal/syncer/rules.go index 7410280..b388912 100644 --- a/internal/syncer/rules.go +++ b/internal/syncer/rules.go @@ -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) { diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index ee3eee2..a552e6b 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -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) } } diff --git a/web/static/css/gowebmail.css b/web/static/css/gowebmail.css index 6090b93..858358f 100644 --- a/web/static/css/gowebmail.css +++ b/web/static/css/gowebmail.css @@ -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} diff --git a/web/static/js/app.js b/web/static/js/app.js index d0b4afb..0d3a86b 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -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) { + ${(isSentFolderView()||isSpamFolderView())?'':``} ${attachHtml} @@ -1906,6 +1923,7 @@ function showMessageMenu(e, id) {