From 77f4b04af68775de600311cb78e111e2d3785471 Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Sun, 30 Aug 2026 08:44:34 +0100 Subject: [PATCH] fix received message - read status --- cmd/server/main.go | 1 + internal/db/db.go | 61 +++++++++++++++++ internal/email/imap.go | 13 ++-- internal/handlers/api.go | 40 +++++++++++ web/static/js/app.js | 115 ++++++++++++++++++++++++++++--- web/templates/app.html | 143 +++++++++++++++++++++++++++++---------- 6 files changed, 327 insertions(+), 46 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 54d5dac..ab667b5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -273,6 +273,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") diff --git a/internal/db/db.go b/internal/db/db.go index b6fc71f..ce49922 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -925,6 +925,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 { diff --git a/internal/email/imap.go b/internal/email/imap.go index e1c6434..b529204 100644 --- a/internal/email/imap.go +++ b/internal/email/imap.go @@ -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) @@ -1603,10 +1607,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) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 561b5a9..a09cf2a 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1268,6 +1268,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) { diff --git a/web/static/js/app.js b/web/static/js/app.js index 845731b..764c647 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -2524,6 +2524,7 @@ async function openSettings() { loadSyncInterval(); loadRemoteImagePolicy(); loadRemoteWhitelist(); + loadNotificationSetting(); renderMFAPanel(); loadIPRules(); populateSettingsAccountSelects(); @@ -2962,6 +2963,56 @@ function saveRemoteImagePolicy() { if (S.currentMessage) renderMessageDetail(S.currentMessage, false); } +// ── Notifications (Settings > General) ────────────────────────────────────── +function loadNotificationSetting() { + const cb = document.getElementById('notifications-toggle'); + if (cb) cb.checked = uiPrefsGet('notificationsEnabled', false); + updateNotificationStatus(); +} + +function updateNotificationStatus() { + const el = document.getElementById('notifications-status'); + if (!el) return; + if (!('Notification' in window)) { el.textContent = 'Not supported in this browser.'; return; } + if (Notification.permission === 'denied') { el.textContent = 'Blocked for this site in your browser settings — allow it there, then try again.'; return; } + el.textContent = ''; +} + +// Turning this on is the ONLY place the browser's notification-permission prompt gets +// triggered (see startPoller) — never on page load unasked. Turning it off can't revoke a +// permission the browser already granted, so it just stops the poller from using it. +async function toggleNotifications(enabled) { + const cb = document.getElementById('notifications-toggle'); + if (!enabled) { + uiPrefsSet('notificationsEnabled', false); + POLLER.notifGranted = false; + updateNotificationStatus(); + return; + } + if (!('Notification' in window)) { + toast('Notifications are not supported in this browser', 'error'); + if (cb) cb.checked = false; + return; + } + if (Notification.permission === 'denied') { + toast('Notifications are blocked for this site in your browser settings', 'error'); + if (cb) cb.checked = false; + updateNotificationStatus(); + return; + } + const perm = Notification.permission === 'default' ? await Notification.requestPermission() : Notification.permission; + if (perm === 'granted') { + uiPrefsSet('notificationsEnabled', true); + POLLER.notifGranted = true; + toast('Notifications enabled', 'success'); + } else { + if (cb) cb.checked = false; + uiPrefsSet('notificationsEnabled', false); + toast('Notification permission was not granted', 'error'); + } + updateNotificationStatus(); +} + async function loadRemoteWhitelist() { const el = document.getElementById('remote-whitelist-list'); const r = await api('GET', '/remote-content-whitelist'); @@ -3060,6 +3111,57 @@ async function saveIPRules() { else toast(r?.error || 'Save failed', 'error'); } +// ── Login History (Settings > Security) ───────────────────────────────────── +// Server-side filtered/sorted/paginated — always scoped to the logged-in user by the +// GET /api/login-history handler itself (never a param this code could tamper with). +const LH = { page: 1, hasMore: false }; + +function openLoginHistory() { + openModal('login-history-modal'); + loadLoginHistory(1); +} + +async function loadLoginHistory(page) { + LH.page = page; + const params = new URLSearchParams({ page, page_size: 25 }); + const dateFrom = document.getElementById('lh-date-from').value; + const dateTo = document.getElementById('lh-date-to').value; + const status = document.getElementById('lh-status').value; + const ip = document.getElementById('lh-ip').value.trim(); + const sort = document.getElementById('lh-sort').value; + if (dateFrom) params.set('date_from', dateFrom); + if (dateTo) params.set('date_to', dateTo); + if (status) params.set('success', status); + if (ip) params.set('ip', ip); + if (sort) params.set('sort', sort); + + const tbody = document.getElementById('lh-table-body'); + tbody.innerHTML = ''; + const r = await api('GET', '/login-history?' + params.toString()); + if (!r) { + tbody.innerHTML = 'Failed to load login history.'; + return; + } + + LH.hasMore = !!r.has_more; + const logs = r.logs || []; + tbody.innerHTML = logs.length ? logs.map(l => ` + + ${new Date(l.created_at).toLocaleString()} + ${l.event==='login'?'Success':'Failed'} + ${esc(l.ip_address||'—')} + ${esc(l.detail||'')} + `).join('') : 'No login attempts found.'; + + document.getElementById('lh-page-info').textContent = `Page ${page} · ${r.total||0} total`; + document.getElementById('lh-prev-btn').disabled = page <= 1; + document.getElementById('lh-next-btn').disabled = !LH.hasMore; +} + +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); + async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; } // ── Context menu helper ──────────────────────────────────────────────────── @@ -3134,14 +3236,11 @@ const POLLER = { }; async function startPoller() { - // Request browser notification permission (non-blocking) - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission().then(p => { - POLLER.notifGranted = p === 'granted'; - }); - } else if ('Notification' in window) { - POLLER.notifGranted = Notification.permission === 'granted'; - } + // Notification permission is only ever requested from the explicit Settings > General + // toggle (toggleNotifications) — never on load — so this just reflects whatever the user + // already opted into and the browser already granted. + POLLER.notifGranted = uiPrefsGet('notificationsEnabled', false) + && 'Notification' in window && Notification.permission === 'granted'; POLLER.active = true; schedulePoll(); diff --git a/web/templates/app.html b/web/templates/app.html index 2991493..8cb3753 100644 --- a/web/templates/app.html +++ b/web/templates/app.html @@ -381,6 +381,59 @@ + + +