notifications and webmail refresh
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ?`
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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: “{{.search_query}}”{{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}} – {{.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}} – {{.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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user