notifications and webmail refresh

This commit is contained in:
2026-08-16 07:46:41 +01:00
parent ca0ad630d2
commit 5dce9f4bcb
10 changed files with 546 additions and 124 deletions
+10
View File
@@ -64,6 +64,16 @@ func (d *DB) UpdateContact(mailboxID, id int64, email, name, phone string) error
return err
}
// UpsertContactFromSend adds a contact for an address the mailbox owner just emailed,
// if one doesn't already exist there — called after a successful compose send (see
// webmailComposeSend), not a general-purpose upsert: an existing contact's name/phone
// is left untouched (INSERT OR IGNORE against the mailbox+email UNIQUE constraint), so
// this can never clobber a manual edit.
func (d *DB) UpsertContactFromSend(mailboxID int64, email, name string) error {
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_contacts (mailbox_id, email, name, phone) VALUES (?, ?, ?, '')`, mailboxID, email, name)
return err
}
func (d *DB) DeleteContact(mailboxID, id int64) error {
_, err := d.Exec(`DELETE FROM esrv_mailbox_contacts WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
return err
+25
View File
@@ -64,3 +64,28 @@ func TestSuggestRecipientsIncludesContacts(t *testing.T) {
t.Fatalf("expected contact suggested as 'Alice Smith <alice@example.com>', got %+v", suggestions)
}
}
// TestUpsertContactFromSend confirms the auto-add-on-send helper creates a new
// contact but never overwrites an existing one's name (INSERT OR IGNORE semantics).
func TestUpsertContactFromSend(t *testing.T) {
d := openTestDB(t)
const mailboxID = int64(1)
if err := d.UpsertContactFromSend(mailboxID, "bob@example.com", "Bob Marley"); err != nil {
t.Fatal(err)
}
contacts, err := d.ListContacts(mailboxID)
if err != nil || len(contacts) != 1 || contacts[0].Name != "Bob Marley" {
t.Fatalf("expected 1 auto-created contact named Bob Marley, got %+v (err=%v)", contacts, err)
}
// A second send to the same address, even with a different display name, must not
// clobber the first-seen (or since manually edited) name.
if err := d.UpsertContactFromSend(mailboxID, "bob@example.com", "Bob M."); err != nil {
t.Fatal(err)
}
contacts, err = d.ListContacts(mailboxID)
if err != nil || len(contacts) != 1 || contacts[0].Name != "Bob Marley" {
t.Fatalf("expected the existing contact's name left untouched, got %+v (err=%v)", contacts, err)
}
}
+23
View File
@@ -200,6 +200,29 @@ func (d *DB) ListMessagesInFolderPage(mailboxID int64, folder string, unreadOnly
return scanMailboxMessages(rows)
}
// ListNewMessagesInFolder returns messages in folder with id > afterID, respecting the
// same unread/starred filters the folder view itself applies — backs the webmail
// list's polling endpoint (webmailMailPoll) so newly arrived mail can be patched into
// an open folder view without a full page reload. Ordered oldest-first (ascending id)
// so the caller can prepend each row in turn and end up with the newest at the very
// top, matching the folder view's own default newest-first order.
func (d *DB) ListNewMessagesInFolder(mailboxID int64, folder string, afterID int64, unreadOnly, starredOnly bool) ([]MailboxMessage, error) {
query := `SELECT ` + mailboxMessageColumns + ` FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ? AND id > ?`
args := []any{mailboxID, folder, afterID}
if unreadOnly {
query += ` AND flags NOT LIKE '%\Seen%' ESCAPE '\'`
}
if starredOnly {
query += ` AND flags LIKE '%\Flagged%' ESCAPE '\'`
}
query += ` ORDER BY id ASC LIMIT 50`
rows, err := d.Query(query, args...)
if err != nil {
return nil, err
}
return scanMailboxMessages(rows)
}
// CountMessagesInFolder backs ListMessagesInFolderPage's pagination controls.
func (d *DB) CountMessagesInFolder(mailboxID int64, folder string, unreadOnly, starredOnly bool) (int, error) {
query := `SELECT COUNT(*) FROM esrv_mailbox_messages WHERE mailbox_id = ? AND folder = ?`
+51
View File
@@ -0,0 +1,51 @@
package db
import (
"testing"
"time"
)
// TestListNewMessagesInFolder confirms the webmail poll endpoint's backing query only
// returns messages newer than afterID, oldest-first, and honors the same
// unread/starred filters the folder view itself applies.
func TestListNewMessagesInFolder(t *testing.T) {
d := openTestDB(t)
const mailboxID = int64(1)
insert := func(flags string) int64 {
t.Helper()
id, err := d.InsertMessage(mailboxID, "INBOX", "", flags, time.Now(), 10, "/dev/null", []byte("nonce"), "a@example.com", "b@example.com", "subj", "")
if err != nil {
t.Fatal(err)
}
return id
}
first := insert("")
insert(`\Seen`)
third := insert("")
msgs, err := d.ListNewMessagesInFolder(mailboxID, "INBOX", first, false, false)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 || msgs[0].ID <= first || msgs[1].ID != third {
t.Fatalf("expected 2 messages after id %d in ascending order ending at %d, got %+v", first, third, msgs)
}
unreadOnly, err := d.ListNewMessagesInFolder(mailboxID, "INBOX", 0, true, false)
if err != nil {
t.Fatal(err)
}
if len(unreadOnly) != 2 {
t.Fatalf("expected 2 unread messages (the \\Seen one excluded), got %d", len(unreadOnly))
}
none, err := d.ListNewMessagesInFolder(mailboxID, "INBOX", third, false, false)
if err != nil {
t.Fatal(err)
}
if len(none) != 0 {
t.Fatalf("expected no messages newer than the newest one, got %d", len(none))
}
}
+258 -124
View File
@@ -29,10 +29,20 @@
mobile-first one. */
.mail-shell { display: flex; align-items: stretch; height: calc(100vh - 56px); overflow: hidden; }
.mail-sidebar { width: 230px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; padding: .75rem; }
.mail-content { display: flex; flex: 1 1 auto; min-width: 0; overflow: hidden; }
.mail-list-pane { width: 380px; flex: 0 0 auto; overflow-y: auto; border-right: 1px solid #404040; display: flex; flex-direction: column; }
.mail-reading-pane { flex: 1 1 auto; overflow-y: auto; padding: 1.5rem; min-width: 0; }
.mail-toolbar { flex: 0 0 auto; padding: .5rem .75rem; border-bottom: 1px solid #404040; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; }
.mail-list-scroll { flex: 1 1 auto; overflow-y: auto; }
/* "Reading pane below the list" alternate layout (Outlook's View > Reading
Pane > Bottom) — a client-side-only preference (localStorage, see the JS
below), same pattern as the sidebar-hide/folder-collapse toggles already on
this page. Default (nothing stored yet) is the side-by-side layout. */
.mail-content.layout-below { flex-direction: column; }
.mail-content.layout-below .mail-list-pane { width: auto; height: 42%; flex: 0 0 auto; border-right: none; border-bottom: 1px solid #404040; }
.mail-content.layout-below .mail-reading-pane { flex: 1 1 auto; }
.new-mail-banner { display: none; padding: .4rem .75rem; background-color: #0d3860; color: #fff; font-size: .85rem; cursor: pointer; text-align: center; }
.new-mail-banner.show { display: block; }
.mail-list-header { padding: .35rem .75rem; font-size: .75rem; text-transform: uppercase; color: #8a8a8a; display: flex; justify-content: space-between; }
.msg-item { display: flex; align-items: flex-start; gap: .6rem; padding: .55rem .75rem; border-bottom: 1px solid #333; cursor: pointer; }
@@ -137,6 +147,7 @@
<form method="post" id="folderActionForm" class="d-none"></form>
<div class="mail-content" id="mailContent">
<div class="mail-list-pane{{if .search_query}} search-mode{{end}}" id="messageListCol">
<div class="mail-toolbar">
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 d-none" id="sidebarShowBtn" title="Show folder list"><i class="bi bi-chevron-bar-right"></i></button>
@@ -154,6 +165,8 @@
</select>
{{end}}
<button type="button" class="btn btn-sm btn-outline-secondary border-0" onclick="location.reload()" title="Refresh"><i class="bi bi-arrow-clockwise"></i></button>
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0" id="layoutToggleBtn" title="Move reading pane below the list"><i class="bi bi-layout-split"></i></button>
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 d-none" id="notifyPermBtn" title="Enable new-mail notifications"><i class="bi bi-bell"></i></button>
<div class="ms-auto d-flex align-items-center gap-1">
{{if not .search_query}}
<a href="{{.sort_from_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by sender"><i class="bi bi-person{{if eq .sort_by "from"}}-fill{{end}}"></i>{{if eq .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
@@ -163,36 +176,14 @@
{{end}}
</div>
</div>
<div class="new-mail-banner" id="newMailBanner"></div>
<div class="mail-list-header">
<span>{{if .search_query}}Search: &ldquo;{{.search_query}}&rdquo;{{else}}{{.active_folder}}{{end}}</span>
<span>{{.total}} message{{if ne .total 1}}s{{end}}</span>
<span id="mailListTotal">{{.total}} message{{if ne .total 1}}s{{end}}</span>
</div>
<div class="mail-list-scroll" id="mailListScroll">
{{if .messages}}
{{range .messages}}
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
{{$paneHref := printf "/webmail/mail/%s/%d/pane" .Folder .ID}}
{{$isDraft := eq .Folder "Drafts"}}
{{if $isDraft}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
{{$displayName := senderName .CachedFrom}}
{{if eq .Folder "Sent"}}{{if .CachedTo}}{{$displayName = senderName .CachedTo}}{{else}}{{$displayName = "(no recipient)"}}{{end}}{{end}}
<div class="msg-item {{if .Unread}}unread{{end}}{{if .Collapsed}} msg-row-older{{end}} msg-row" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}" data-href="{{$rowHref}}" data-pane-href="{{$paneHref}}" data-is-draft="{{$isDraft}}">
<input type="checkbox" class="form-check-input msg-check" value="{{.ID}}" onclick="event.stopPropagation()">
<div class="msg-avatar">{{initial $displayName}}</div>
<div class="msg-main">
<div class="msg-row1">
<span class="msg-from" title="{{if eq .Folder "Sent"}}{{.CachedTo}}{{else}}{{.CachedFrom}}{{end}}">{{$displayName}}</span>
<i class="bi msg-star {{if .Starred}}bi-star-fill{{else}}bi-star{{end}}" data-uid="{{.ID}}" data-folder="{{.Folder}}" title="{{if .Starred}}Remove from favourites{{else}}Add to favourites{{end}}"></i>
<span class="msg-date">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</span>
</div>
<div class="msg-row2">
<span class="msg-subject">{{if .CachedSubject}}{{.CachedSubject}}{{else}}(no subject){{end}}</span>
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
{{if .CachedPreview}} &ndash; {{.CachedPreview}}{{end}}
</div>
</div>
</div>
{{end}}
{{range .messages}}{{template "msgRow" .}}{{end}}
{{else}}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
@@ -214,6 +205,7 @@
<p class="mt-3">Select a message to read</p>
</div>
</div>
</div>
</div>
<form method="post" id="bulkActionForm" class="d-none">
@@ -370,16 +362,21 @@
(function() {
let draggedUID = null;
let draggedFolder = null;
const listScroll = document.getElementById('mailListScroll');
document.querySelectorAll('.msg-row').forEach(function(row) {
row.addEventListener('dragstart', function() {
draggedUID = row.dataset.uid;
draggedFolder = row.dataset.folder;
row.classList.add('dragging');
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
});
// Delegated (dragstart/dragend both bubble) rather than bound per-row, so
// a message row the new-mail poll inserts later (see the poll IIFE below)
// is draggable immediately with no separate rebind step.
listScroll.addEventListener('dragstart', function(e) {
const row = e.target.closest('.msg-row');
if (!row) return;
draggedUID = row.dataset.uid;
draggedFolder = row.dataset.folder;
row.classList.add('dragging');
});
listScroll.addEventListener('dragend', function(e) {
const row = e.target.closest('.msg-row');
if (row) row.classList.remove('dragging');
});
document.querySelectorAll('.folder-link').forEach(function(link) {
@@ -555,23 +552,47 @@
});
})();
// Reading pane + selection share one block because Shift/Ctrl-click needs to
// work when clicking anywhere on a row, not just its small checkbox — a plain
// click opens the message in the reading pane; Shift-click range-selects from
// the last row you interacted with (open or select) to this one; Ctrl/Cmd-click
// toggles just this row. Any of those needs the full row list + index, not just
// the checkbox's own click event, so it can't be two independent IIFEs the way
// "load the pane" and "manage checkboxes" would otherwise naturally split.
// "Reading pane below the list" layout toggle — same client-side-only
// (localStorage) pattern as the sidebar collapse above. Default (nothing
// stored yet) is the side-by-side layout matching the original reference.
(function() {
const KEY = 'webmail_layout_below';
const content = document.getElementById('mailContent');
const btn = document.getElementById('layoutToggleBtn');
const icon = btn.querySelector('i');
function apply(below) {
content.classList.toggle('layout-below', below);
icon.className = below ? 'bi bi-layout-text-window' : 'bi bi-layout-split';
btn.title = below ? 'Move reading pane back to the side' : 'Move reading pane below the list';
}
apply(localStorage.getItem(KEY) === '1');
btn.addEventListener('click', function() {
const below = !content.classList.contains('layout-below');
localStorage.setItem(KEY, below ? '1' : '0');
apply(below);
});
})();
// Reading pane, selection, and the star toggle share one delegated listener on
// the list container (not one per row/checkbox/star) for two reasons: (1)
// Shift/Ctrl-click needs to work when clicking anywhere on a row, not just its
// small checkbox, which needs the full row list + index either way; (2)
// delegation means a message row the new-mail poll inserts later (see the poll
// IIFE below) works immediately — star, select, open — with no separate rebind
// step, since there's nothing per-row left to bind.
(function() {
const paneBody = document.getElementById('readingPaneBody');
const rows = Array.from(document.querySelectorAll('.msg-item'));
const checks = rows.map(function(row) { return row.querySelector('.msg-check'); });
const listScroll = document.getElementById('mailListScroll');
const selectAll = document.getElementById('selectAllCheck');
const bulkBtns = document.querySelectorAll('.bulk-btn');
const moveSelect = document.getElementById('bulkMoveSelect');
let lastIndex = null;
let lastRow = null;
function getRows() { return Array.from(listScroll.querySelectorAll('.msg-item')); }
function getChecks() { return getRows().map(function(row) { return row.querySelector('.msg-check'); }); }
function updateToolbar() {
const checks = getChecks();
const any = checks.some(function(c) { return c.checked; });
bulkBtns.forEach(function(b) { b.disabled = !any; });
if (moveSelect) moveSelect.disabled = !any;
@@ -580,7 +601,7 @@
function openInPane(row) {
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
rows.forEach(function(r) { r.classList.remove('active'); });
getRows().forEach(function(r) { r.classList.remove('active'); });
row.classList.add('active');
row.classList.remove('unread');
paneBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
@@ -590,53 +611,68 @@
.catch(function() { paneBody.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
}
rows.forEach(function(row, i) {
row.addEventListener('click', function(e) {
if (e.target.closest('.msg-group-toggle') || e.target.classList.contains('msg-check')) return;
if (e.shiftKey && lastIndex !== null) {
e.preventDefault();
const [from, to] = [lastIndex, i].sort(function(a, b) { return a - b; });
for (let j = from; j <= to; j++) checks[j].checked = true;
updateToolbar();
return;
}
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
checks[i].checked = !checks[i].checked;
lastIndex = i;
updateToolbar();
return;
}
lastIndex = i;
openInPane(row);
async function toggleStar(star) {
const uid = star.dataset.uid, folder = star.dataset.folder;
const body = new URLSearchParams();
body.set('csrf_token', window.__csrfToken || '');
const resp = await fetch(`/webmail/mail/${folder}/${uid}/star`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
});
if (!resp.ok) return;
const nowStarred = star.classList.toggle('bi-star-fill');
star.classList.toggle('bi-star', !nowStarred);
star.title = nowStarred ? 'Remove from favourites' : 'Add to favourites';
}
checks.forEach(function(cb, i) {
cb.addEventListener('click', function(e) {
if (e.shiftKey && lastIndex !== null) {
const [from, to] = [lastIndex, i].sort(function(a, b) { return a - b; });
for (let j = from; j <= to; j++) checks[j].checked = cb.checked;
}
lastIndex = i;
listScroll.addEventListener('click', function(e) {
const star = e.target.closest('.msg-star');
if (star) { toggleStar(star); return; }
if (e.target.closest('.msg-group-toggle')) return;
const row = e.target.closest('.msg-item');
if (!row) return;
const rows = getRows();
const checks = getChecks();
const i = rows.indexOf(row);
const isCheck = e.target.classList.contains('msg-check');
if (e.shiftKey && lastRow && rows.includes(lastRow)) {
e.preventDefault();
const [from, to] = [rows.indexOf(lastRow), i].sort(function(a, b) { return a - b; });
const state = isCheck ? e.target.checked : true;
for (let j = from; j <= to; j++) checks[j].checked = state;
lastRow = row;
updateToolbar();
});
return;
}
if (isCheck) { lastRow = row; updateToolbar(); return; }
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
checks[i].checked = !checks[i].checked;
lastRow = row;
updateToolbar();
return;
}
lastRow = row;
openInPane(row);
});
selectAll.addEventListener('change', function() {
checks.forEach(function(c) { c.checked = selectAll.checked; });
getChecks().forEach(function(c) { c.checked = selectAll.checked; });
updateToolbar();
});
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
selectAll.checked = true;
checks.forEach(function(c) { c.checked = true; });
getChecks().forEach(function(c) { c.checked = true; });
updateToolbar();
}
});
function selectedUIDs() { return checks.filter(function(c) { return c.checked; }).map(function(c) { return c.value; }); }
function selectedUIDs() { return getChecks().filter(function(c) { return c.checked; }).map(function(c) { return c.value; }); }
function submitBulk(action, targetFolder) {
const uids = selectedUIDs();
if (uids.length === 0) return;
@@ -667,29 +703,9 @@
if (moveSelect.value) submitBulk('move', moveSelect.value);
});
}
})();
// Star toggle — click the star icon to flip \Flagged (see
// db.ToggleMessageStarred) without a full page reload. stopPropagation keeps
// this from also triggering the row's own click (which would open the reading
// pane / range-select, see the combined IIFE above).
document.querySelectorAll('.msg-star').forEach(function(star) {
star.addEventListener('click', async function(e) {
e.stopPropagation();
const uid = star.dataset.uid, folder = star.dataset.folder;
const body = new URLSearchParams();
body.set('csrf_token', window.__csrfToken || '');
const resp = await fetch(`/webmail/mail/${folder}/${uid}/star`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!resp.ok) return;
const nowStarred = star.classList.toggle('bi-star-fill');
star.classList.toggle('bi-star', !nowStarred);
star.title = nowStarred ? 'Remove from favourites' : 'Add to favourites';
});
});
window.__mailUpdateToolbar = updateToolbar; // used by the new-mail poll IIFE below
})();
// Folder sidebar context menu — right-click any folder for New folder / Mark
// all as read / Rename / Empty Trash, replacing the old always-visible "+ New
@@ -851,31 +867,37 @@
submenu.appendChild(newItem);
}
document.querySelectorAll('.msg-row').forEach(function(row) {
row.addEventListener('contextmenu', function(e) {
e.preventDefault();
if (row.dataset.isDraft === 'true') return; // drafts only open in compose
menuRow = row;
const isUnread = row.classList.contains('unread');
menu.querySelector('[data-toggle-read-label]').textContent = isUnread ? 'Mark as read' : 'Mark as unread';
const underTrash = row.dataset.folder && document.querySelector('.folder-row[data-folder="' + CSS.escape(row.dataset.folder) + '"][data-under-trash="true"]') !== null;
menu.querySelector('[data-delete-label]').textContent = underTrash ? 'Delete Permanently' : 'Delete';
buildMoveSubmenu(row);
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.style.display = 'block';
});
row.addEventListener('dblclick', function(e) {
if (e.target.closest('.msg-check, .msg-star, .msg-group-toggle')) return;
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
const modalBody = document.getElementById('messageModalBody');
modalBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
new bootstrap.Modal(document.getElementById('messageModal')).show();
fetch(row.dataset.paneHref)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) { modalBody.innerHTML = html; })
.catch(function() { modalBody.innerHTML = '<p class="text-danger p-3">Failed to load the message.</p>'; });
});
// Delegated on the list container (contextmenu/dblclick both bubble) so a
// message row the new-mail poll inserts later (see the poll IIFE below)
// gets the right-click menu and double-click modal immediately too.
const listScrollForMenu = document.getElementById('mailListScroll');
listScrollForMenu.addEventListener('contextmenu', function(e) {
const row = e.target.closest('.msg-row');
if (!row) return;
e.preventDefault();
if (row.dataset.isDraft === 'true') return; // drafts only open in compose
menuRow = row;
const isUnread = row.classList.contains('unread');
menu.querySelector('[data-toggle-read-label]').textContent = isUnread ? 'Mark as read' : 'Mark as unread';
const underTrash = row.dataset.folder && document.querySelector('.folder-row[data-folder="' + CSS.escape(row.dataset.folder) + '"][data-under-trash="true"]') !== null;
menu.querySelector('[data-delete-label]').textContent = underTrash ? 'Delete Permanently' : 'Delete';
buildMoveSubmenu(row);
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.style.display = 'block';
});
listScrollForMenu.addEventListener('dblclick', function(e) {
const row = e.target.closest('.msg-row');
if (!row) return;
if (e.target.closest('.msg-check, .msg-star, .msg-group-toggle')) return;
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
const modalBody = document.getElementById('messageModalBody');
modalBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
new bootstrap.Modal(document.getElementById('messageModal')).show();
fetch(row.dataset.paneHref)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) { modalBody.innerHTML = html; })
.catch(function() { modalBody.innerHTML = '<p class="text-danger p-3">Failed to load the message.</p>'; });
});
menu.querySelectorAll('[data-msg-action]').forEach(function(item) {
@@ -922,11 +944,123 @@
});
window.addEventListener('scroll', hideMenu, true);
})();
// New-mail polling — periodically checks this folder for messages newer than
// the highest one currently shown and patches them straight into the list
// (see msgRow/webmailMailPoll on the Go side — every listener above is
// delegated on #mailListScroll specifically so a row inserted here works
// immediately, with no separate rebind step) instead of reloading the page,
// so reading or composing isn't interrupted. Only runs on the plain,
// unpaginated, default-sorted view — a custom sort or a later page would make
// "insert new rows at the top" the wrong place to put them, and it's not worth
// solving for a background poll (see tests/todo.md's scope note).
(function() {
{{if or .search_query (gt .page 1)}}return;{{end}}
{{if eq .sort_by "from"}}return;{{end}}
const folder = {{.active_folder}};
const unreadOnly = {{.unread_only}};
const starredOnly = {{.starred_only}};
const listScroll = document.getElementById('mailListScroll');
const totalEl = document.getElementById('mailListTotal');
const banner = document.getElementById('newMailBanner');
const notifyBtn = document.getElementById('notifyPermBtn');
banner.addEventListener('click', function() { banner.classList.remove('show'); });
if (notifyBtn && 'Notification' in window && Notification.permission === 'default') {
notifyBtn.classList.remove('d-none');
notifyBtn.addEventListener('click', function() {
Notification.requestPermission().then(function() { notifyBtn.classList.add('d-none'); });
});
}
function notify(count) {
if (!('Notification' in window) || Notification.permission !== 'granted') return;
const title = count === 1 ? 'New message' : count + ' new messages';
try { new Notification(title, { body: 'In ' + folder }); } catch (e) { /* ignore — some browsers need a service worker for this */ }
}
function highestUID() {
let max = 0;
listScroll.querySelectorAll('.msg-item').forEach(function(el) {
const id = parseInt(el.dataset.uid, 10);
if (id > max) max = id;
});
return max;
}
async function poll() {
const params = new URLSearchParams({ since_id: highestUID(), unread: unreadOnly ? '1' : '0', starred: starredOnly ? '1' : '0' });
let html;
try {
const resp = await fetch(`/webmail/mail/${folder}/poll?` + params.toString());
if (!resp.ok) return;
html = await resp.text();
} catch (e) { return; }
if (!html.trim()) return;
const frag = document.createElement('div');
frag.innerHTML = html;
const newRows = Array.from(frag.querySelectorAll('.msg-item'));
if (newRows.length === 0) return;
const emptyState = listScroll.querySelector('.text-center.py-5');
if (emptyState) emptyState.remove();
// Server returns oldest-of-the-new-batch first — inserting each in that
// order at the very top leaves the newest of the batch on top, matching
// the folder's own default newest-first order.
newRows.forEach(function(row) { listScroll.insertBefore(row, listScroll.firstChild); });
if (totalEl) {
const n = (parseInt(totalEl.textContent, 10) || 0) + newRows.length;
totalEl.textContent = n + ' message' + (n === 1 ? '' : 's');
}
if (window.__mailUpdateToolbar) window.__mailUpdateToolbar();
banner.textContent = newRows.length + (newRows.length === 1 ? ' new message' : ' new messages');
banner.classList.add('show');
clearTimeout(poll._bannerTimer);
poll._bannerTimer = setTimeout(function() { banner.classList.remove('show'); }, 6000);
notify(newRows.length);
}
setInterval(poll, 25000);
})();
</script>
</body>
</html>
{{end}}
{{/* msgRow renders one message-list row — . is a folderRow (webmail_mail.go). Shared
by the main list render above and webmailMailPoll's fragment response (the
periodic new-mail check, see the poll IIFE below) so a message polled in later
looks byte-identical to one rendered on initial page load. */}}
{{define "msgRow"}}
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
{{$paneHref := printf "/webmail/mail/%s/%d/pane" .Folder .ID}}
{{$isDraft := eq .Folder "Drafts"}}
{{if $isDraft}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
{{$displayName := senderName .CachedFrom}}
{{if eq .Folder "Sent"}}{{if .CachedTo}}{{$displayName = senderName .CachedTo}}{{else}}{{$displayName = "(no recipient)"}}{{end}}{{end}}
<div class="msg-item {{if .Unread}}unread{{end}}{{if .Collapsed}} msg-row-older{{end}} msg-row" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}" data-href="{{$rowHref}}" data-pane-href="{{$paneHref}}" data-is-draft="{{$isDraft}}">
<input type="checkbox" class="form-check-input msg-check" value="{{.ID}}">
<div class="msg-avatar">{{initial $displayName}}</div>
<div class="msg-main">
<div class="msg-row1">
<span class="msg-from" title="{{if eq .Folder "Sent"}}{{.CachedTo}}{{else}}{{.CachedFrom}}{{end}}">{{$displayName}}</span>
<i class="bi msg-star {{if .Starred}}bi-star-fill{{else}}bi-star{{end}}" data-uid="{{.ID}}" data-folder="{{.Folder}}" title="{{if .Starred}}Remove from favourites{{else}}Add to favourites{{end}}"></i>
<span class="msg-date">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</span>
</div>
<div class="msg-row2">
<span class="msg-subject">{{if .CachedSubject}}{{.CachedSubject}}{{else}}(no subject){{end}}</span>
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
{{if .CachedPreview}} &ndash; {{.CachedPreview}}{{end}}
</div>
</div>
</div>
{{end}}
{{/* folderTreeNode renders one sidebar folder row plus (if any) its children,
recursing into itself for each child — . is a *folderViewNode (webmail_mail.go's
buildFolderView), which carries everything needed (icon, counts, active state,
+30
View File
@@ -678,6 +678,7 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
}
raw := assembleMessage(buildEnvelopeHeaders(from, toAddrs, ccAddrs, subject, messageID, inReplyTo), entity)
a.upsertContactsFromRecipients(mbox.ID, mbox.Email, r.FormValue("to"), r.FormValue("cc"), r.FormValue("bcc"))
signed := raw
dkimSigned := false
@@ -909,3 +910,32 @@ func parseComposeAddrs(raw string) ([]string, error) {
}
return out, nil
}
// upsertContactsFromRecipients auto-adds every to/cc/bcc recipient of a just-sent
// message to the mailbox's Contacts address book (tests/todo.md's Claude suggestion
// #5) — parses the raw fields (not parseComposeAddrs' bare-address output) so a
// display name typed or picked in compose is preserved as the new contact's name.
// Skips the sender's own address (mailing yourself isn't "a contact"); an address
// already saved is left untouched (db.UpsertContactFromSend uses INSERT OR IGNORE)
// so this never overwrites a manual edit.
func (a *App) upsertContactsFromRecipients(mailboxID int64, ownEmail string, fields ...string) {
for _, raw := range fields {
addrs, err := mail.ParseAddressList(strings.TrimSpace(raw))
if err != nil {
continue
}
for _, addr := range addrs {
email := strings.ToLower(strings.TrimSpace(addr.Address))
if email == "" || strings.EqualFold(email, ownEmail) {
continue
}
name := addr.Name
if name == "" {
name = email
}
if err := a.DB.UpsertContactFromSend(mailboxID, email, name); err != nil {
a.Logger.Error("auto-save contact %s for mailbox %d: %v", email, mailboxID, err)
}
}
}
}
@@ -0,0 +1,66 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestWebmailComposeSendAutoSavesContacts confirms a successful send auto-adds its
// To/Cc recipients to the sender's own Contacts address book (tests/todo.md's Claude
// suggestion #5), preserving a typed display name, while never adding the sender's
// own address and never overwriting a contact that already exists.
func TestWebmailComposeSendAutoSavesContacts(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "sender2@example.com", domainID, "sender-password-1!")
createTestMailboxWithPassword(t, app, "recipient2@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
// A contact already on file should keep its own name, not get overwritten by
// whatever display name this send happens to use.
if _, err := app.DB.CreateContact(senderID, "existing@example.com", "Already Saved", "555-0000"); err != nil {
t.Fatal(err)
}
form := url.Values{
"to": {"Recipient Two <recipient2@example.com>"},
"cc": {"existing@example.com, sender2@example.com"},
"subject": {"Hello there"},
"body_html": {"This is the message body."},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
contacts, err := app.DB.ListContacts(senderID)
if err != nil {
t.Fatal(err)
}
byEmail := map[string]string{}
for _, c := range contacts {
byEmail[c.Email] = c.Name
}
if name, ok := byEmail["recipient2@example.com"]; !ok || name != "Recipient Two" {
t.Errorf("expected an auto-saved contact 'Recipient Two <recipient2@example.com>', got %+v", byEmail)
}
if name, ok := byEmail["existing@example.com"]; !ok || name != "Already Saved" {
t.Errorf("expected the existing contact's name left untouched, got %+v", byEmail)
}
if _, ok := byEmail["sender2@example.com"]; ok {
t.Errorf("did not expect the sender's own address auto-saved as a contact, got %+v", byEmail)
}
if len(contacts) != 2 {
t.Errorf("expected exactly 2 contacts (existing + newly auto-saved), got %d: %+v", len(contacts), contacts)
}
}
+35
View File
@@ -374,6 +374,41 @@ func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folde
})
}
// webmailMailPoll backs the folder view's periodic new-mail check (see
// webmail_folder.html's poll IIFE): given the highest message id the browser already
// has rendered (since_id) plus the same unread/starred filters that view is applying,
// returns an HTML fragment of any newer messages for the client to insert into the
// list without a full page reload. Renders via the same "msgRow" template the main
// list uses, so a polled-in row is identical to one rendered on initial page load.
func (a *App) webmailMailPoll(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
afterID := int64(atoi(r.URL.Query().Get("since_id")))
unreadOnly := r.URL.Query().Get("unread") == "1"
starredOnly := r.URL.Query().Get("starred") == "1"
rows, err := a.DB.ListNewMessagesInFolder(mbox.ID, folder, afterID, unreadOnly, starredOnly)
if err != nil {
a.Logger.Error("poll new messages for mailbox %d folder %s: %v", mbox.ID, folder, err)
http.Error(w, "error", http.StatusInternalServerError)
return
}
t, ok := a.templates["webmail_folder.html"]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
for _, m := range rows {
row := folderRow{MailboxMessage: m, Unread: isUnread(m.Flags), Starred: isStarred(m.Flags)}
if err := t.ExecuteTemplate(w, "msgRow", row); err != nil {
a.Logger.Error("render polled message row: %v", err)
return
}
}
}
// loadMessageForView decrypts, parses, and marks one message read — the shared core
// behind both webmailMessageView (the full standalone page, for direct links/
// bookmarks) and webmailMessagePane (a bare fragment, AJAX-loaded into the folder
+47
View File
@@ -0,0 +1,47 @@
package webui
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
// TestWebmailMailPollReturnsOnlyNewerRows confirms the poll fragment endpoint (backing
// the folder view's new-mail check) only returns messages newer than since_id, and
// that each row it renders carries the same data-uid the main list uses.
func TestWebmailMailPollReturnsOnlyNewerRows(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "poller@example.com", domains[0].ID, "poller-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
first := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "one", "body")
second := storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "two", "body")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/poll?since_id="+strconv.FormatInt(first, 10), nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `data-uid="`+strconv.FormatInt(second, 10)+`"`) {
t.Fatalf("expected the newer message %d in the poll fragment, got:\n%s", second, body)
}
if strings.Contains(body, `data-uid="`+strconv.FormatInt(first, 10)+`"`) {
t.Fatalf("did not expect the already-seen message %d in the poll fragment, got:\n%s", first, body)
}
// Polling again with since_id at the newest id returns nothing new.
req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/poll?since_id="+strconv.FormatInt(second, 10), nil)
req2.AddCookie(cookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK || strings.TrimSpace(rec2.Body.String()) != "" {
t.Fatalf("expected an empty fragment once caught up, status=%d body=%q", rec2.Code, rec2.Body.String())
}
}
+1
View File
@@ -163,6 +163,7 @@ func (a *App) Mux() *http.ServeMux {
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}", a.webmailFolderView)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}", a.webmailMessageView)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/pane", a.webmailMessagePane)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/poll", a.webmailMailPoll)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/delete", a.webmailMessageDelete)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/star", a.webmailToggleStar)