fix received message - read status

This commit is contained in:
2026-08-30 08:44:34 +01:00
parent cc9b987e83
commit 77f4b04af6
6 changed files with 327 additions and 46 deletions
+1
View File
@@ -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")
+61
View File
@@ -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 {
+9 -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)
@@ -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)
+40
View File
@@ -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) {
+107 -8
View File
@@ -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 = '<tr><td colspan="4" style="text-align:center;padding:24px"><span class="spinner-inline"></span></td></tr>';
const r = await api('GET', '/login-history?' + params.toString());
if (!r) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--danger);padding:20px">Failed to load login history.</td></tr>';
return;
}
LH.hasMore = !!r.has_more;
const logs = r.logs || [];
tbody.innerHTML = logs.length ? logs.map(l => `
<tr>
<td style="font-family:monospace;font-size:11px;color:var(--muted)">${new Date(l.created_at).toLocaleString()}</td>
<td><span class="badge ${l.event==='login'?'green':'red'}">${l.event==='login'?'Success':'Failed'}</span></td>
<td style="font-family:monospace;font-size:11px">${esc(l.ip_address||'—')}</td>
<td style="color:var(--muted);font-size:12px">${esc(l.detail||'')}</td>
</tr>`).join('') : '<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:24px">No login attempts found.</td></tr>';
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();
+109 -34
View File
@@ -381,6 +381,59 @@
</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>
<!-- ── 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">
@@ -541,6 +594,8 @@
<div style="display:flex;align-items:stretch;min-height:0;flex:1;border-top:1px solid var(--border)">
<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>
@@ -560,29 +615,7 @@
</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">
<label>Username</label>
<div style="display:flex;gap:8px">
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Email Address</label>
<div style="display:flex;gap:8px">
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
</div>
</div>
<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>
@@ -619,19 +652,17 @@
</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>
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
<button class="btn-primary" onclick="changePassword()">Update Password</button>
</div>
<div class="settings-group">
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
Two-Factor Authentication <span id="mfa-badge"></span>
</div>
<div id="mfa-panel">Loading...</div>
<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">
@@ -652,6 +683,50 @@
</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>
<div class="settings-panel" data-tab="account" role="tabpanel">
<div class="settings-group">
<div class="settings-group-title">Profile</div>
<div class="modal-field">
<label>Username</label>
<div style="display:flex;gap:8px">
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Email Address</label>
<div style="display:flex;gap:8px">
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
</div>
</div>
<div class="modal-field">
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
</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>
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
<button class="btn-primary" onclick="changePassword()">Update Password</button>
</div>
<div class="settings-group">
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
Two-Factor Authentication <span id="mfa-badge"></span>
</div>
<div id="mfa-panel">Loading...</div>
</div>
</div>
<div class="settings-panel" data-tab="rules" role="tabpanel">