diff --git a/internal/db/crud_mailbox_contacts.go b/internal/db/crud_mailbox_contacts.go index 42d3896..7d26d28 100644 --- a/internal/db/crud_mailbox_contacts.go +++ b/internal/db/crud_mailbox_contacts.go @@ -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 diff --git a/internal/db/crud_mailbox_contacts_test.go b/internal/db/crud_mailbox_contacts_test.go index 1fcfb64..00a58d3 100644 --- a/internal/db/crud_mailbox_contacts_test.go +++ b/internal/db/crud_mailbox_contacts_test.go @@ -64,3 +64,28 @@ func TestSuggestRecipientsIncludesContacts(t *testing.T) { t.Fatalf("expected contact suggested as 'Alice Smith ', 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) + } +} diff --git a/internal/db/crud_mailbox_messages.go b/internal/db/crud_mailbox_messages.go index 2fd6fb5..788312e 100644 --- a/internal/db/crud_mailbox_messages.go +++ b/internal/db/crud_mailbox_messages.go @@ -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 = ?` diff --git a/internal/db/list_new_messages_test.go b/internal/db/list_new_messages_test.go new file mode 100644 index 0000000..404d7a4 --- /dev/null +++ b/internal/db/list_new_messages_test.go @@ -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)) + } +} diff --git a/internal/webui/templates/webmail_folder.html b/internal/webui/templates/webmail_folder.html index 5bacc68..a839648 100644 --- a/internal/webui/templates/webmail_folder.html +++ b/internal/webui/templates/webmail_folder.html @@ -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 @@
+
@@ -154,6 +165,8 @@ {{end}} + +
{{if not .search_query}} {{if eq .sort_by "from"}} {{end}} @@ -163,36 +176,14 @@ {{end}}
+
{{if .search_query}}Search: “{{.search_query}}”{{else}}{{.active_folder}}{{end}} - {{.total}} message{{if ne .total 1}}s{{end}} + {{.total}} message{{if ne .total 1}}s{{end}}
{{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}} -
- -
{{initial $displayName}}
-
-
- {{$displayName}} - - {{strftime "%Y-%m-%d %H:%M" .InternalDate}} -
-
- {{if .CachedSubject}}{{.CachedSubject}}{{else}}(no subject){{end}} - {{if gt .GroupExtra 0}}+{{.GroupExtra}} more{{end}} - {{if .CachedPreview}} – {{.CachedPreview}}{{end}} -
-
-
- {{end}} + {{range .messages}}{{template "msgRow" .}}{{end}} {{else}}
@@ -214,6 +205,7 @@

Select a message to read

+
@@ -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 = '
'; @@ -590,53 +611,68 @@ .catch(function() { paneBody.innerHTML = '

Failed to load the message.

'; }); } - 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 = '
'; - 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 = '

Failed to load the message.

'; }); - }); + // 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 = '
'; + 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 = '

Failed to load the message.

'; }); }); 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); + })(); {{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}} +
+ +
{{initial $displayName}}
+
+
+ {{$displayName}} + + {{strftime "%Y-%m-%d %H:%M" .InternalDate}} +
+
+ {{if .CachedSubject}}{{.CachedSubject}}{{else}}(no subject){{end}} + {{if gt .GroupExtra 0}}+{{.GroupExtra}} more{{end}} + {{if .CachedPreview}} – {{.CachedPreview}}{{end}} +
+
+
+{{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, diff --git a/internal/webui/webmail_compose.go b/internal/webui/webmail_compose.go index 7beb354..1882dba 100644 --- a/internal/webui/webmail_compose.go +++ b/internal/webui/webmail_compose.go @@ -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) + } + } + } +} diff --git a/internal/webui/webmail_compose_contacts_test.go b/internal/webui/webmail_compose_contacts_test.go new file mode 100644 index 0000000..6bf3369 --- /dev/null +++ b/internal/webui/webmail_compose_contacts_test.go @@ -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 "}, + "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 ', 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) + } +} diff --git a/internal/webui/webmail_mail.go b/internal/webui/webmail_mail.go index b7e066d..2bc7539 100644 --- a/internal/webui/webmail_mail.go +++ b/internal/webui/webmail_mail.go @@ -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 diff --git a/internal/webui/webmail_mail_poll_test.go b/internal/webui/webmail_mail_poll_test.go new file mode 100644 index 0000000..a1e99ca --- /dev/null +++ b/internal/webui/webmail_mail_poll_test.go @@ -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()) + } +} diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 914a6d7..442eef0 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -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)