persistant message compose

This commit is contained in:
2026-08-16 14:05:59 +01:00
parent 71758786cb
commit f02b344512
20 changed files with 1418 additions and 85 deletions
+11
View File
@@ -141,6 +141,17 @@ func (d *DB) UpdateMessageCachedFields(id int64, cachedFrom, cachedTo, cachedSub
return err
}
// UpdateMessageContent overwrites an existing message row's size/nonce/message-id/
// cached display fields after its ciphertext has been rewritten in place on disk (see
// mailstore.Store.UpdateMessage) — everything InsertMessage sets except folder, flags,
// and storage_path, which don't change for an in-place content replacement. Scoped to
// mailboxID so a caller can't touch another mailbox's message by guessing a uid.
func (d *DB) UpdateMessageContent(mailboxID, id int64, sizeBytes int64, nonce []byte, messageIDHeader, cachedFrom, cachedTo, cachedSubject, cachedPreview string) error {
_, err := d.Exec(`UPDATE esrv_mailbox_messages SET size_bytes = ?, nonce = ?, message_id_header = ?, cached_from = ?, cached_to = ?, cached_subject = ?, cached_preview = ? WHERE id = ? AND mailbox_id = ?`,
sizeBytes, nonce, messageIDHeader, cachedFrom, cachedTo, cachedSubject, cachedPreview, id, mailboxID)
return err
}
// SetMessageFlags overwrites a message's stored IMAP flags (space-separated), scoped
// to mailboxID so a session can't touch another mailbox's message by guessing a UID.
func (d *DB) SetMessageFlags(mailboxID, uid int64, flags string) error {
+50
View File
@@ -135,6 +135,56 @@ func TestStoreMessagePreviewTruncatesLongBodyRuneSafely(t *testing.T) {
}
}
// TestUpdateMessageKeepsSameUID confirms UpdateMessage overwrites a message's content
// in place — same uid, new bytes decrypt back correctly, cached fields and used_bytes
// reflect the new content — rather than the insert-new+delete-old churn StoreMessage
// alone would require for a "replace" (which webmail's draft autosave used to do,
// silently reassigning the draft a new uid on every tick).
func TestUpdateMessageKeepsSameUID(t *testing.T) {
s, mailboxID := newTestMailbox(t, 1024*1024)
original := []byte("From: a@example.com\r\nTo: b@example.com\r\nSubject: v1\r\n\r\nfirst draft")
uid, err := s.StoreMessage(mailboxID, "Drafts", original, "<v1@example.com>", "a@example.com", "v1")
if err != nil {
t.Fatal(err)
}
mboxBefore, err := s.DB.GetMailboxByID(mailboxID)
if err != nil {
t.Fatal(err)
}
updated := []byte("From: a@example.com\r\nTo: c@example.com\r\nSubject: v2 much longer now\r\n\r\nsecond, longer draft body with more content")
if err := s.UpdateMessage(mailboxID, uid, updated, "<v2@example.com>", "v2 much longer now"); err != nil {
t.Fatal(err)
}
raw, err := s.FetchMessage(mailboxID, uid)
if err != nil {
t.Fatal(err)
}
if string(raw) != string(updated) {
t.Errorf("expected FetchMessage to decrypt back the updated content under the SAME uid %d", uid)
}
msg, err := s.DB.GetMessageByUID(mailboxID, uid)
if err != nil || msg == nil {
t.Fatalf("expected message %d to still exist, got %+v (err=%v)", uid, msg, err)
}
if msg.CachedSubject != "v2 much longer now" || msg.CachedTo != "c@example.com" || msg.MessageIDHeader != "<v2@example.com>" {
t.Errorf("expected cached fields refreshed to the new content, got %+v", msg)
}
if msg.SizeBytes != int64(len(updated)) {
t.Errorf("size_bytes = %d, want %d", msg.SizeBytes, int64(len(updated)))
}
mboxAfter, err := s.DB.GetMailboxByID(mailboxID)
if err != nil {
t.Fatal(err)
}
wantUsed := mboxBefore.UsedBytes + int64(len(updated)) - int64(len(original))
if mboxAfter.UsedBytes != wantUsed {
t.Errorf("used_bytes = %d, want %d (delta accounting)", mboxAfter.UsedBytes, wantUsed)
}
}
// TestRebuildMessageCacheRederivesFromExistingContent simulates a message stored
// before the "cache the From: header's display name" fix existed: cached_from was
// passed as the bare envelope address even though the stored raw content always had
+50
View File
@@ -103,6 +103,56 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
return uid, nil
}
// UpdateMessage re-encrypts and overwrites an existing message's stored content IN
// PLACE — same id, same storage file, same folder — rather than the insert-new-then-
// delete-old churn StoreMessage would otherwise require for a "replace". Repeatedly
// autosaving a draft is the only caller today; that churn would mean every autosave
// tick assigns the draft a brand-new uid, which is at best pointless and at worst
// actively wrong — anything holding a "this is draft #40" reference across a save
// (including this app's own compose-popup-survives-navigation restore, see
// webmail_compose_widget.html) would have that reference silently invalidated out
// from under it. Scoped to mailboxID so a caller can't overwrite another mailbox's
// message by guessing a uid; the caller is still responsible for confirming uid is
// actually the right KIND of message to be overwriting this way (e.g. still in
// Drafts) before calling this — it has no opinion on that itself.
func (s *Store) UpdateMessage(mailboxID, uid int64, raw []byte, messageIDHeader, subject string) error {
mbox, err := s.DB.GetMailboxByID(mailboxID)
if err != nil {
return err
}
if mbox == nil {
return fmt.Errorf("mailstore: mailbox %d not found", mailboxID)
}
existing, err := s.DB.GetMessageByUID(mailboxID, uid)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("mailstore: message %d not found in mailbox %d", uid, mailboxID)
}
delta := int64(len(raw)) - existing.SizeBytes
if mbox.UsedBytes+delta > mbox.QuotaBytes {
return ErrQuotaExceeded
}
dek, err := s.UnwrapDEK(mbox.DEKWrapped, mbox.DEKNonce)
if err != nil {
return err
}
ciphertext, nonce, err := sealAESGCM(dek, raw)
if err != nil {
return err
}
if err := os.WriteFile(existing.StoragePath, ciphertext, 0o600); err != nil {
return err
}
if err := s.DB.UpdateMessageContent(mailboxID, uid, int64(len(raw)), nonce, messageIDHeader, extractHeaderValue(raw, "From"), extractHeaderValue(raw, "To"), subject, previewSnippet(raw)); err != nil {
return err
}
return s.DB.AddMailboxUsedBytes(mailboxID, delta)
}
// RebuildMessageCache re-derives cached_from/cached_to/cached_subject/cached_preview
// for every message already stored in mailboxID, from each message's own decrypted
// content — these fields are otherwise only ever computed once, at delivery time
@@ -22,9 +22,7 @@
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
{{template "webmail_account_menu" .}}
</div>
</div>
</nav>
@@ -22,9 +22,7 @@
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
{{template "webmail_account_menu" .}}
</div>
</div>
</nav>
+1 -3
View File
@@ -22,9 +22,7 @@
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
{{template "webmail_account_menu" .}}
</div>
</div>
</nav>
+321 -31
View File
@@ -28,6 +28,17 @@
.cc-bcc-toggle:hover { text-decoration: underline; }
.subject-row input.bare-input { font-size: .95rem; padding: .4rem 0; }
/* Outlook-style To/Cc/Bcc — each address its own removable "bubble", not one
plain comma-separated text field. flex+wrap so tags naturally spill onto a
second line as the field fills, with the entry input always the trailing
element so typing continues right after the last tag. */
.chip-input { flex: 1 1 auto; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: .25rem; padding: .15rem 0; cursor: text; }
.chip-input-tag { display: inline-flex; align-items: center; gap: .35rem; background: #333; color: var(--cw-text); border-radius: 10px; padding: .05rem .5rem; font-size: .8rem; line-height: 1.6; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-input-tag .chip-remove { cursor: pointer; opacity: .65; font-size: .85rem; line-height: 1; }
.chip-input-tag .chip-remove:hover { opacity: 1; color: #ff8080; }
.chip-input-entry { flex: 1 1 90px; min-width: 90px; border: 0; background: transparent; color: var(--cw-text); outline: none; font-size: .85rem; padding: .2rem 0; }
.chip-input-entry::placeholder { color: var(--cw-muted); }
.crypto-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .4rem 0; font-size: .78rem; color: var(--cw-muted); }
.crypto-row .form-check { margin-bottom: 0; }
.crypto-row .form-check-label { font-size: .8rem; }
@@ -76,6 +87,17 @@
<form id="composeForm" method="POST" action="/webmail/mail/compose" enctype="multipart/form-data" class="compose-shell">
<input type="hidden" name="in_reply_to" value="{{.in_reply_to}}">
<input type="hidden" name="draft_id" value="{{.draft_id}}">
{{/* "Forward as attachment" seed data — see webmailComposeForm/webmailComposeSend.
Deliberately NOT the actual submitted fields (no name= attribute) — the JS
below reads these once at load into its own fwdAttachments array (which is
what actually renders as removable chips and what more get pushed into via
forwardAsAttachment/addForwardAttachments), then builds the real
forward_attach_uid/_folder hidden inputs itself at submit time. One element
per pending message, so this naturally supports more than one — the whole
point, see tests/todo.md. */}}
{{range .forward_attach_items}}
<input type="hidden" class="forward-attach-seed" value="{{.UID}}" data-folder="{{.Folder}}" data-label="{{.Label}}">
{{end}}
<div class="compose-actionbar">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-send me-1"></i>Send</button>
@@ -101,24 +123,35 @@
{{else}}
<span class="from-email">{{.mailbox.Email}}</span>
{{end}}
<a href="/webmail/mail/INBOX" class="btn btn-sm btn-outline-light" title="Close"><i class="bi bi-x-lg"></i></a>
</div>
</div>
<div class="compose-fields">
<div class="field-row">
<label class="field-label">To</label>
<input type="text" class="bare-input recipient-input" name="to" value="{{.to}}" placeholder="recipient@example.com, another@example.com" list="recipientSuggestions" autocomplete="off" required>
<div class="chip-input" id="toChips">
<input type="hidden" name="to" value="{{.to}}">
<div class="chip-input-tags"></div>
<input type="text" class="chip-input-entry" list="recipientSuggestions" autocomplete="off" placeholder="recipient@example.com">
</div>
<span class="cc-bcc-toggle" id="showCc">Cc</span>
<span class="cc-bcc-toggle" id="showBcc">Bcc</span>
</div>
<div class="field-row" id="ccRow" style="display: none;">
<label class="field-label">Cc</label>
<input type="text" class="bare-input recipient-input" name="cc" value="{{.cc}}" list="recipientSuggestions" autocomplete="off">
<div class="chip-input" id="ccChips">
<input type="hidden" name="cc" value="{{.cc}}">
<div class="chip-input-tags"></div>
<input type="text" class="chip-input-entry" list="recipientSuggestions" autocomplete="off">
</div>
</div>
<div class="field-row" id="bccRow" style="display: none;">
<label class="field-label">Bcc</label>
<input type="text" class="bare-input recipient-input" name="bcc" value="{{.bcc}}" list="recipientSuggestions" autocomplete="off">
<div class="chip-input" id="bccChips">
<input type="hidden" name="bcc" value="{{.bcc}}">
<div class="chip-input-tags"></div>
<input type="text" class="chip-input-entry" list="recipientSuggestions" autocomplete="off">
</div>
</div>
<datalist id="recipientSuggestions"></datalist>
<div class="field-row subject-row">
@@ -282,7 +315,8 @@
toggle.addEventListener('click', function() {
row.style.display = 'flex';
toggle.style.display = 'none';
field.focus();
const entry = row.querySelector('.chip-input-entry');
if (entry) entry.focus();
});
}
}
@@ -303,10 +337,39 @@
const input = document.getElementById('attachment_input');
const list = document.getElementById('attachment_list');
let staged = new DataTransfer();
// "Forward as attachment" items — {uid, folder, label}, one per pending
// forwarded message. Plain JS state (not real Files), seeded once from the
// server-rendered .forward-attach-seed elements, then only ever grown via
// addForwardAttachments (repeated right-clicks/drag-ins, see
// webmail_compose_widget.html's forwardAsAttachment — this used to
// overwrite a single hidden field instead of accumulating). Synced into
// real forward_attach_uid/_folder hidden inputs at submit time, not kept
// in the DOM continuously — see syncForwardAttachFields below.
let fwdAttachments = Array.from(document.querySelectorAll('.forward-attach-seed')).map(function(el) {
return { uid: el.value, folder: el.dataset.folder, label: el.dataset.label };
});
function render() {
input.files = staged.files;
list.innerHTML = '';
fwdAttachments.forEach(function(item, i) {
const chip = document.createElement('span');
chip.className = 'badge text-bg-primary d-flex align-items-center gap-1 py-2 px-2';
chip.innerHTML = '<i class="bi bi-envelope-paper me-1"></i>';
// label is always the bare subject (no extension) — the actual
// attachment filename is computed fresh server-side at send time
// (see webmailComposeSend's forwardAttachAttachments), this is
// purely a display hint, so ".eml" is only ever added here.
chip.append((item.label || 'Original message') + '.eml');
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'btn-close btn-close-white ms-1';
remove.style.fontSize = '0.65rem';
remove.setAttribute('aria-label', 'Remove');
remove.addEventListener('click', function() { fwdAttachments.splice(i, 1); render(); });
chip.appendChild(remove);
list.appendChild(chip);
});
Array.from(staged.files).forEach(function(file, i) {
const chip = document.createElement('span');
chip.className = 'badge text-bg-secondary d-flex align-items-center gap-1 py-2 px-2';
@@ -334,6 +397,37 @@
render();
}
// Exposed so a compose window already open (see
// webmail_compose_widget.html's forwardAsAttachment, which calls this
// directly on an already-loaded compose iframe instead of reloading it —
// reloading would both lose whatever the user had already typed and reset
// this list back to one item) can have more messages attached without
// losing what's already pending. Dedups on uid+folder so right-clicking
// the same message twice doesn't attach it twice.
window.addForwardAttachments = function(items) {
(items || []).forEach(function(item) {
if (!fwdAttachments.some(function(x) { return x.uid === item.uid && x.folder === item.folder; })) {
fwdAttachments.push(item);
}
});
render();
};
// Built fresh at submit time (see the form's own submit listener further
// down) rather than kept as live DOM the whole time — fwdAttachments is
// the single source of truth in between.
window.__syncForwardAttachFields = function() {
form.querySelectorAll('.forward-attach-hidden').forEach(function(el) { el.remove(); });
fwdAttachments.forEach(function(item) {
const uidInput = document.createElement('input');
uidInput.type = 'hidden'; uidInput.name = 'forward_attach_uid'; uidInput.className = 'forward-attach-hidden'; uidInput.value = item.uid;
const folderInput = document.createElement('input');
folderInput.type = 'hidden'; folderInput.name = 'forward_attach_folder'; folderInput.className = 'forward-attach-hidden'; folderInput.value = item.folder;
form.appendChild(uidInput);
form.appendChild(folderInput);
});
};
attachBtn.addEventListener('click', function() { input.click(); });
input.addEventListener('change', function() { addFiles(input.files); });
form.addEventListener('dragover', function(e) { e.preventDefault(); dropTarget.classList.add('drag-over'); });
@@ -341,10 +435,23 @@
form.addEventListener('drop', function(e) {
e.preventDefault();
dropTarget.classList.remove('drag-over');
// A message row dragged in from the folder list (see webmail_folder.html's
// dragstart handler) — attach the whole original message as .eml, the same
// action "Forward as attachment" triggers, rather than treating it as a
// regular file drop. Real files (e.dataTransfer.files) take the existing path.
const msgData = e.dataTransfer.getData('application/x-mailgoserver-message');
if (msgData) {
try {
const msg = JSON.parse(msgData);
window.addForwardAttachments([{ uid: msg.uid, folder: msg.folder, label: msg.subject || '' }]);
} catch (e2) { /* ignore malformed payload */ }
return;
}
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
});
window.__composeAttachmentCount = function() { return staged.files.length; };
render(); // shows any "Forward as attachment" chip(s) seeded server-side
window.__composeAttachmentCount = function() { return staged.files.length + fwdAttachments.length; };
})();
// The identity picker (when there's more than one S/MIME certificate) only
@@ -367,29 +474,121 @@
update();
})();
// Outlook-style To/Cc/Bcc — each address becomes its own removable "bubble"
// instead of one plain comma-separated text field. The real submitted value
// (parseComposeAddrs/mail.ParseAddressList server-side, unchanged) still
// travels as a comma-separated string, just in a hidden input the chip list
// is kept in sync with, rather than being what the user directly edits.
function initChipField(container) {
const hidden = container.querySelector('input[type="hidden"]');
const tagsEl = container.querySelector('.chip-input-tags');
const entry = container.querySelector('.chip-input-entry');
let chips = [];
function sync() { hidden.value = chips.join(', '); }
function render() {
tagsEl.innerHTML = '';
chips.forEach(function(value, i) {
const chip = document.createElement('span');
chip.className = 'chip-input-tag';
const text = document.createElement('span');
text.textContent = value;
chip.appendChild(text);
const remove = document.createElement('span');
remove.className = 'chip-remove';
remove.innerHTML = '&times;';
remove.addEventListener('click', function() {
chips.splice(i, 1);
render();
sync();
entry.focus();
});
chip.appendChild(remove);
tagsEl.appendChild(chip);
});
}
// A pasted or typed blob can itself hold several addresses separated by
// comma OR semicolon (Outlook's own separator, which this accepts too —
// see tests/todo.md) — each becomes its own chip rather than one chip
// holding the whole blob.
function addChip(raw) {
raw.split(/[,;]/).map(function(s) { return s.trim(); }).filter(Boolean).forEach(function(v) {
if (!chips.includes(v)) chips.push(v);
});
render();
sync();
}
function commitEntry() {
if (entry.value.trim()) { addChip(entry.value); entry.value = ''; }
}
entry.addEventListener('keydown', function(e) {
if (e.key === ',' || e.key === ';' || e.key === 'Enter') {
e.preventDefault();
commitEntry();
} else if (e.key === ' ') {
// Only commit on space if what's typed so far already looks like a
// complete address ("name@domain.tld" or "...<name@domain.tld>")
// — a display name typed as separate words ("John Smith") must not
// get chopped into two chips before reaching the @ part.
if (/\S+@\S+\.\S+>?$/.test(entry.value.trim())) {
e.preventDefault();
commitEntry();
}
} else if (e.key === 'Backspace' && entry.value === '' && chips.length > 0) {
chips.pop();
render();
sync();
}
});
entry.addEventListener('blur', commitEntry);
entry.addEventListener('paste', function(e) {
const text = (e.clipboardData || window.clipboardData).getData('text');
if (text && /[,;]/.test(text)) {
e.preventDefault();
addChip(text);
}
});
container.addEventListener('click', function(e) {
if (!e.target.closest('.chip-input-tag')) entry.focus();
});
// Seed from the hidden input's server-rendered value — same
// comma-separated format the backend already produces/expects (a reply's
// prefilled To:, a reply-all's Cc:, a reloaded draft's saved recipients).
if (hidden.value.trim()) {
hidden.value.split(',').map(function(s) { return s.trim(); }).filter(Boolean).forEach(function(v) { chips.push(v); });
render();
}
return { commitEntry: commitEntry };
}
const chipFields = Array.from(document.querySelectorAll('.chip-input')).map(initChipField);
// Recipient autocomplete: suggests addresses this mailbox has exchanged mail
// with, matching the fragment being typed after the last comma (a
// To/Cc/Bcc field holds a comma-separated address list, so only the
// in-progress fragment should be matched/replaced, not the whole value).
// with, matching whatever's currently being typed in a chip field's trailing
// entry input — simpler than the old plain-text-field version since the entry
// input only ever holds the one in-progress fragment now, never the whole
// comma-separated list.
(function() {
const datalist = document.getElementById('recipientSuggestions');
let debounceTimer = null;
document.querySelectorAll('.recipient-input').forEach(function(input) {
document.querySelectorAll('.chip-input-entry').forEach(function(input) {
input.addEventListener('input', function() {
clearTimeout(debounceTimer);
const value = input.value;
const splitAt = value.lastIndexOf(',') + 1;
const fragment = value.slice(splitAt).trim();
const fragment = input.value.trim();
if (!fragment) { datalist.innerHTML = ''; return; }
debounceTimer = setTimeout(function() {
fetch('/webmail/mail/recipients?q=' + encodeURIComponent(fragment))
.then(function(r) { return r.json(); })
.then(function(suggestions) {
datalist.innerHTML = '';
const prefix = value.slice(0, splitAt);
(suggestions || []).forEach(function(addr) {
const opt = document.createElement('option');
opt.value = (prefix ? prefix + ' ' : '') + addr;
opt.value = addr;
datalist.appendChild(opt);
});
})
@@ -412,8 +611,26 @@
const form = document.getElementById('composeForm');
const draftIdInput = document.querySelector('[name="draft_id"]');
const status = document.getElementById('autosaveStatus');
// Shared with webmail_compose_widget.html (the parent page hosting this in
// an iframe) — sessionStorage, so it survives navigating this SAME tab to a
// different folder/message/settings page, but never leaks to another tab.
// Written here (not by the parent) because only this page actually knows
// its own current draft_id. See that file's tryRestoreOnLoad for the other
// half of this.
const DRAFT_ID_KEY = 'webmail_compose_draft_id';
let lastSaved = null;
function rememberDraftID(id) {
if (!id) return;
draftIdInput.value = id;
try { sessionStorage.setItem(DRAFT_ID_KEY, id); } catch (e) { /* private browsing etc. */ }
}
// A reloaded draft (?draft=X) or a restore-on-navigation (see the parent's
// tryRestoreOnLoad) already has a real id the moment this page loads —
// record it immediately rather than waiting for the first autosave tick,
// so navigating away again right away still has something to restore.
if (draftIdInput.value) rememberDraftID(draftIdInput.value);
function snapshot() {
return JSON.stringify({
to: document.querySelector('[name="to"]').value,
@@ -424,21 +641,35 @@
});
}
// Building the payload is shared between the normal awaited autosave and
// the best-effort save-on-navigate-away below — the only difference is how
// each one actually sends it (fetch, which can read the response back to
// learn a newly-created draft's id; sendBeacon, which can't, but is the
// only thing reliably allowed to still be in flight once the page is
// already unloading).
function buildPayload() {
chipFields.forEach(function(f) { f.commitEntry(); });
const subject = document.querySelector('[name="subject"]').value.trim();
const to = document.querySelector('[name="to"]').value.trim();
if (!to && !subject && !quill.getText().trim() && !(window.__composeAttachmentCount && window.__composeAttachmentCount() > 0)) {
return null; // nothing worth saving yet
}
document.querySelector('[name="body_html"]').value = quill.root.innerHTML;
if (window.__syncForwardAttachFields) window.__syncForwardAttachFields();
const formData = new FormData(form);
formData.delete('attachments'); // large binary — a real Save/Send still includes it, see the file comment above
return formData;
}
async function autosave() {
const current = snapshot();
if (current === lastSaved) return;
const subject = document.querySelector('[name="subject"]').value.trim();
const to = document.querySelector('[name="to"]').value.trim();
if (!to && !subject && !quill.getText().trim()) return; // nothing worth saving yet
document.querySelector('[name="body_html"]').value = quill.root.innerHTML;
const formData = new FormData(form);
formData.delete('attachments');
const formData = buildPayload();
if (!formData) return;
try {
const resp = await fetch('/webmail/mail/save-draft', { method: 'POST', body: formData });
if (resp.ok) {
const draftId = new URL(resp.url).searchParams.get('draft');
if (draftId) draftIdInput.value = draftId;
rememberDraftID(new URL(resp.url).searchParams.get('draft'));
lastSaved = current;
if (status) {
status.textContent = 'Saved ' + new Date().toLocaleTimeString();
@@ -449,35 +680,94 @@
}
setInterval(autosave, 30000);
// Also autosaves shortly after the user stops typing (not on every
// keystroke) — the 30s interval alone means a draft_id might not exist yet
// if the user navigates away within the first half-minute, which is exactly
// when "preserve my in-progress compose across navigation" needs it most.
let debounceTimer = null;
function scheduleAutosave() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(autosave, 4000);
}
form.addEventListener('input', scheduleAutosave);
quill.on('text-change', scheduleAutosave);
// Last-resort save the instant this document actually starts unloading
// (folder switch, opening a message, closing the tab) — fetch() isn't
// guaranteed to complete once that starts, sendBeacon specifically is.
// Can't read its response, so it never learns a brand-new draft's id this
// way; that's fine, rememberDraftID from a prior interval/debounce tick (or
// the initial one above, for an already-existing draft) already covers the
// common case, and it still overwrites the CONTENT of whatever draft that
// was with whatever was typed in these last few seconds.
window.addEventListener('pagehide', function() {
// A real Send/Save already in flight has its own outcome to reach —
// see the submit listener's own comment on why this must not also fire
// for that (this page unloads as part of a normal successful submit
// too, since both redirect away).
if (window.__composeFormSubmitting) return;
if (!draftIdInput.value) return; // no draft to update — buildPayload's own check isn't enough, sendBeacon has no response to learn a new id from
const formData = buildPayload();
if (!formData) return;
navigator.sendBeacon('/webmail/mail/save-draft', formData);
});
})();
// Enter in a single-line field (To/Cc/Bcc/Subject) implicitly submits the
// form — that's how a stray keypress used to send a blank email.
['to', 'cc', 'bcc', 'subject'].forEach(function(name) {
const el = document.querySelector('[name="' + name + '"]');
// Enter in the Subject field implicitly submits the form — that's how a stray
// keypress used to send a blank email. To/Cc/Bcc's own chip-input entry field
// already preventDefaults Enter itself (it commits a chip instead — see
// initChipField above), so only Subject needs this.
(function() {
const el = document.querySelector('[name="subject"]');
if (el) {
el.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); }
});
}
});
})();
document.getElementById('composeForm').addEventListener('submit', function(e) {
// A real Send/Save is about to navigate this page away on its own terms —
// the pagehide-triggered "last-resort" draft save above must not also fire
// for that (it would otherwise resave — or worse, after a successful Send
// deletes the draft server-side, recreate — a draft nobody asked for,
// racing the real submission). Cleared again below on any path that
// preventDefaults instead of actually submitting.
window.__composeFormSubmitting = true;
document.querySelector('[name="body_html"]').value = quill.root.innerHTML;
// Save (formaction=save-draft) deliberately skips the subject/body-required
// guard below — a draft can be incomplete by definition.
if (window.__syncForwardAttachFields) window.__syncForwardAttachFields();
// Whatever's still sitting in a chip field's trailing entry input (typed
// but not yet committed to a bubble — e.g. the user hit Send instead of
// Enter/comma/space right after typing an address) counts as a real
// recipient too, not something to silently drop.
chipFields.forEach(function(f) { f.commitEntry(); });
// Save (formaction=save-draft) deliberately skips the subject/body/
// recipient-required guards below — a draft can be incomplete by definition.
const isDraftSave = e.submitter && e.submitter.getAttribute('formaction') === '/webmail/mail/save-draft';
if (isDraftSave) return;
// "to" no longer has the browser's own native required-field validation —
// it moved from a visible required <input> to a hidden one (constraint
// validation only applies to focusable/visible fields), so this replaces it.
const to = document.querySelector('[name="to"]').value.trim();
const subject = document.querySelector('[name="subject"]').value.trim();
const body = quill.getText().trim();
const hasAttachments = window.__composeAttachmentCount && window.__composeAttachmentCount() > 0;
if (!to) {
e.preventDefault();
window.__composeFormSubmitting = false;
alert('Please add at least one recipient.');
return;
}
if (!subject) {
e.preventDefault();
window.__composeFormSubmitting = false;
alert('Please add a subject before sending.');
return;
}
if (!body && !hasAttachments) {
e.preventDefault();
window.__composeFormSubmitting = false;
alert('Please write a message or add an attachment before sending.');
return;
}
@@ -3,23 +3,73 @@
<div id="composePopupHandle" class="card-header d-flex justify-content-between align-items-center py-1" style="cursor: move; user-select: none;">
<span class="small"><i class="bi bi-pencil-square me-1"></i>Compose</span>
<div>
<button type="button" id="composePopupMinimize" class="btn btn-sm btn-outline-light border-0 py-0 px-2" title="Minimize"><i class="bi bi-dash-lg"></i></button>
<button type="button" id="composePopupMaximize" class="btn btn-sm btn-outline-light border-0 py-0 px-2" title="Maximize"><i class="bi bi-arrows-angle-expand"></i></button>
<button type="button" id="composePopupClose" class="btn btn-sm btn-outline-light border-0 py-0 px-2" title="Close"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<iframe id="composePopupFrame" style="border: 0; width: 100%; flex: 1 1 auto;"></iframe>
<div id="composePopupResize" style="position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize;"></div>
</div>
{{/* Shown by the X button only when a draft actually exists (DRAFT_ID_KEY set) — a
blank/untouched compose just closes immediately, nothing to ask about. Lives here
(not per-host-page) since compose_widget is the one thing common to every page
that can show this. */}}
{{/* z-index bumped above #composePopup's own 1080 (see the popup div's inline style
above) — without it, Bootstrap's default modal z-index (1055) sits BELOW the
compose popup, so this prompt renders visually underneath/behind it instead of
over it. */}}
<div class="modal fade" id="composeCloseDraftModal" tabindex="-1" aria-hidden="true" style="z-index: 1100;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-save2 me-2"></i>Keep this draft?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">This message was saved to Drafts. Keep it there, or discard it?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-danger" id="composeCloseDiscard">Discard</button>
<button type="button" class="btn btn-primary" id="composeCloseKeep">Keep Draft</button>
</div>
</div>
</div>
</div>
<script>
// A floating window over the existing /webmail/mail/compose page (loaded as-is
// in an iframe) — Outlook-style compose without rewriting compose itself as a
// modal. Position/size persist in localStorage so it reopens where it was left.
(function() {
const POS_KEY = 'webmail_compose_popup_pos';
// Survives navigating to a different folder/message/settings page within this
// SAME tab (sessionStorage, not localStorage — deliberately NOT shared with
// other tabs, so two webmail tabs open at once don't fight over which one's
// compose gets restored into the other). OPEN_STATE_KEY's mere presence means
// "a compose was open when the last page unloaded"; DRAFT_ID_KEY (written by
// the compose iframe itself — see webmail_compose.html) is which draft to
// reload it from. Cleared on an explicit X close or once a send actually
// succeeds — see closePopup and the frame 'load' listener below.
const OPEN_STATE_KEY = 'webmail_compose_open_state';
const DRAFT_ID_KEY = 'webmail_compose_draft_id';
const popup = document.getElementById('composePopup');
const handle = document.getElementById('composePopupHandle');
const frame = document.getElementById('composePopupFrame');
const resizeHandle = document.getElementById('composePopupResize');
const closeBtn = document.getElementById('composePopupClose');
const minimizeBtn = document.getElementById('composePopupMinimize');
const maximizeBtn = document.getElementById('composePopupMaximize');
// 'normal' (draggable/resizable, wherever the user last left it) | 'minimized'
// (small bar pinned bottom-right, iframe hidden but NOT unloaded — its content/
// in-progress draft survives, same DOM element, just display:none) | 'maximized'
// (fills most of the viewport). savedRect is always the 'normal' rect to
// return to, captured the moment either alternate state is entered.
let state = 'normal';
let savedRect = null;
function saveOpenState() {
try { sessionStorage.setItem(OPEN_STATE_KEY, JSON.stringify({ minimized: state === 'minimized', maximized: state === 'maximized' })); } catch (e) { /* private browsing etc. — restoring across navigation just won't work, not fatal */ }
}
function defaultRect() {
const w = Math.min(720, window.innerWidth - 40);
@@ -63,18 +113,203 @@
};
}
function setFrameVisible(visible) {
frame.style.display = visible ? '' : 'none';
resizeHandle.style.display = visible ? '' : 'none';
}
// restorePopup() undoes either alternate state, returning to the plain
// draggable/resizable window at whatever rect it had before minimizing/
// maximizing.
function restorePopup() {
state = 'normal';
popup.style.right = '';
popup.style.bottom = '';
popup.style.width = '';
popup.style.height = '';
popup.style.minWidth = '320px';
popup.style.minHeight = '200px';
applyRect(savedRect || loadRect());
setFrameVisible(true);
minimizeBtn.style.display = '';
maximizeBtn.style.display = '';
maximizeBtn.querySelector('i').className = 'bi bi-arrows-angle-expand';
maximizeBtn.title = 'Maximize';
saveOpenState();
}
// doMinimize/doMaximize are the actual button actions, factored out so
// tryRestoreOnLoad (below) can re-apply whichever state was in effect when the
// previous page unloaded, right after reopening — not just the plain/normal
// window every fresh openCompose call would otherwise give it.
function doMinimize() {
if (state === 'normal') savedRect = currentRect();
state = 'minimized';
popup.style.top = '';
popup.style.left = '';
popup.style.right = '16px';
popup.style.bottom = '16px';
popup.style.width = '220px';
popup.style.height = 'auto';
popup.style.minWidth = '0';
popup.style.minHeight = '0';
setFrameVisible(false);
minimizeBtn.style.display = 'none';
maximizeBtn.style.display = 'none';
saveOpenState();
}
function doMaximize() {
if (state === 'normal') savedRect = currentRect();
state = 'maximized';
popup.style.right = '';
popup.style.bottom = '';
popup.style.minWidth = '320px';
popup.style.minHeight = '200px';
applyRect({ top: 10, left: 10, width: window.innerWidth - 20, height: window.innerHeight - 20 });
setFrameVisible(true);
minimizeBtn.style.display = '';
maximizeBtn.style.display = '';
maximizeBtn.querySelector('i').className = 'bi bi-arrows-angle-contract';
maximizeBtn.title = 'Restore';
saveOpenState();
}
window.openCompose = function(url) {
state = 'normal';
savedRect = null;
popup.style.right = '';
popup.style.bottom = '';
popup.style.width = '';
popup.style.height = '';
popup.style.minWidth = '320px';
popup.style.minHeight = '200px';
applyRect(loadRect());
setFrameVisible(true);
minimizeBtn.style.display = '';
maximizeBtn.style.display = '';
maximizeBtn.querySelector('i').className = 'bi bi-arrows-angle-expand';
maximizeBtn.title = 'Maximize';
popup.style.display = 'flex';
popup.style.flexDirection = 'column';
// Whatever draft the previous document in this iframe was pointing at no
// longer applies — the compose page being loaded now (fresh, or a restore
// via ?draft=X) re-establishes this itself as soon as it runs, see
// webmail_compose.html. Clearing it here first means a stray leftover
// value can never be mistaken for this new session's draft if the tab
// closes before that re-establish happens.
try { sessionStorage.removeItem(DRAFT_ID_KEY); } catch (e) {}
frame.src = url;
saveOpenState();
};
function closePopup() {
popup.style.display = 'none';
frame.src = 'about:blank';
try {
sessionStorage.removeItem(OPEN_STATE_KEY);
sessionStorage.removeItem(DRAFT_ID_KEY);
} catch (e) {}
}
closeBtn.addEventListener('click', closePopup);
// Keep/Discard/Cancel prompt for the X button, resolving to which one was
// picked ('cancel' for the backdrop/Escape/its own Cancel button, matching
// showConfirmation's cancel-on-dismiss convention elsewhere in this app).
function promptKeepOrDiscardDraft() {
return new Promise(function(resolve) {
const modal = document.getElementById('composeCloseDraftModal');
const keepBtn = document.getElementById('composeCloseKeep');
const discardBtn = document.getElementById('composeCloseDiscard');
let choice = 'cancel';
const onKeep = function() { choice = 'keep'; bootstrap.Modal.getInstance(modal).hide(); };
const onDiscard = function() { choice = 'discard'; bootstrap.Modal.getInstance(modal).hide(); };
const onHidden = function() { cleanup(); resolve(choice); };
function cleanup() {
keepBtn.removeEventListener('click', onKeep);
discardBtn.removeEventListener('click', onDiscard);
modal.removeEventListener('hidden.bs.modal', onHidden);
}
keepBtn.addEventListener('click', onKeep);
discardBtn.addEventListener('click', onDiscard);
modal.addEventListener('hidden.bs.modal', onHidden, { once: true });
new bootstrap.Modal(modal).show();
});
}
async function discardDraft(draftId) {
const body = new URLSearchParams();
body.set('csrf_token', window.__csrfToken || '');
try {
await fetch('/webmail/mail/Drafts/' + encodeURIComponent(draftId) + '/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
} catch (e) { /* best effort — worst case the draft just stays in Drafts */ }
}
closeBtn.addEventListener('click', async function() {
let draftId;
try { draftId = sessionStorage.getItem(DRAFT_ID_KEY); } catch (e) { draftId = null; }
if (!draftId) { closePopup(); return; } // nothing was ever saved — nothing to ask about
const choice = await promptKeepOrDiscardDraft();
if (choice === 'cancel') return; // leave the compose window open, untouched
if (choice === 'discard') {
// Stop the iframe's own last-resort pagehide save (see
// webmail_compose.html) from firing once closePopup tears it down
// below — it would otherwise silently recreate the very draft just
// discarded, since its draft_id no longer exists once deleted and that
// save's own fallback is to create a brand-new one rather than update
// a now-gone row.
try { if (frame.contentWindow) frame.contentWindow.__composeFormSubmitting = true; } catch (e) {}
await discardDraft(draftId);
}
closePopup();
});
// Minimize: shrinks to a small bar pinned bottom-right and hides the iframe
// (display:none on the element itself, never touching frame.src) — the
// in-progress draft's DOM/JS state is fully preserved, just not rendered, so
// expanding it again picks up exactly where you left off. Clicking anywhere on
// the minimized bar's handle (not a button) restores it, like a taskbar entry
// — the bar itself only ever shows the title plus Close, matching that.
minimizeBtn.addEventListener('click', doMinimize);
handle.addEventListener('click', function(e) {
if (state === 'minimized' && !e.target.closest('button')) restorePopup();
});
maximizeBtn.addEventListener('click', function() {
if (state === 'maximized') restorePopup();
else doMaximize();
});
// "Forward as attachment" (webmail_folder.html/webmail_message.html/
// webmail_message_pane.html's context-menu action and toolbar button, and the
// message-drag-onto-compose drop handler inside webmail_compose.html itself)
// — items is [{uid, folder, label}, ...]. If compose is already open with a
// loaded document, appends to its existing attachment list instead of
// reopening (which used to silently overwrite whatever was already attached —
// this is what makes attaching more than one message at a time work), popping
// it back into view first if it was minimized. Otherwise opens a fresh compose
// prefilled from the first item, then adds any remaining items once that fresh
// compose has finished loading.
window.forwardAsAttachment = function(items) {
if (!items || items.length === 0) return;
if (popup.style.display !== 'none' && frame.contentWindow && typeof frame.contentWindow.addForwardAttachments === 'function') {
if (state === 'minimized') restorePopup();
frame.contentWindow.addForwardAttachments(items);
return;
}
const first = items[0];
window.openCompose('/webmail/mail/compose?forward_attach=' + encodeURIComponent(first.uid) + '&folder=' + encodeURIComponent(first.folder));
if (items.length > 1) {
frame.addEventListener('load', function onLoad() {
frame.removeEventListener('load', onLoad);
if (typeof frame.contentWindow.addForwardAttachments === 'function') {
frame.contentWindow.addForwardAttachments(items.slice(1));
}
});
}
};
// The compose form navigates the iframe on success (send/save-draft both
// redirect away from /mail/compose) — treat that as "done": close the popup
@@ -83,13 +318,37 @@
let path;
try { path = frame.contentWindow.location.pathname; } catch (e) { return; }
if (frame.src === 'about:blank' || path.indexOf('/webmail/mail/compose') !== -1) return;
closePopup();
closePopup(); // also clears OPEN_STATE_KEY/DRAFT_ID_KEY — a sent message has nothing left to restore
window.location.reload();
});
// Reopens whatever was open when the last page in this tab unloaded (a folder
// switch, opening a message, going into Settings — anything short of the X
// button, which clears OPEN_STATE_KEY via closePopup above) so an in-progress
// draft is never silently lost just from navigating around the mail client.
// Only meaningful once the compose iframe has actually written a real
// DRAFT_ID_KEY of its own (see webmail_compose.html) — if navigation happened
// before that (compose opened and left within a couple of seconds, nothing
// worth saving yet), there's nothing to restore and this is a no-op.
(function tryRestoreOnLoad() {
let openState;
try {
const raw = sessionStorage.getItem(OPEN_STATE_KEY);
if (!raw) return;
openState = JSON.parse(raw);
} catch (e) { return; }
let draftId;
try { draftId = sessionStorage.getItem(DRAFT_ID_KEY); } catch (e) { return; }
if (!draftId) { try { sessionStorage.removeItem(OPEN_STATE_KEY); } catch (e) {} return; }
window.openCompose('/webmail/mail/compose?draft=' + encodeURIComponent(draftId) + '&folder=Drafts');
if (openState && openState.minimized) doMinimize();
else if (openState && openState.maximized) doMaximize();
})();
let dragOffset = null;
handle.addEventListener('mousedown', function(e) {
if (e.target.closest('button')) return;
if (state !== 'normal') return; // minimized bar and maximized window aren't draggable
const r = popup.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
e.preventDefault();
@@ -97,6 +356,7 @@
let resizing = false;
resizeHandle.addEventListener('mousedown', function(e) {
if (state !== 'normal') return;
resizing = true;
e.preventDefault();
});
@@ -120,3 +380,23 @@
})();
</script>
{{end}}
{{/* webmail_account_menu is the "sign out" navbar button, everywhere it appears
(every standalone webmail page — see render.go's pagesWithComposeWidget, which
this piggybacks on since it's already parsed on exactly those pages) — now a
dropdown so there's somewhere for "sign in to admin dashboard" to live alongside
it, rather than a second always-visible button competing for navbar space. */}}
{{define "webmail_account_menu"}}
<div class="btn-group">
<button type="button" class="btn btn-outline-light btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" title="Account"><i class="bi bi-box-arrow-right"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/smtp-server/login" target="_blank"><i class="bi bi-shield-lock me-2"></i>Sign in to admin dashboard</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/webmail/logout">
<button type="submit" class="dropdown-item"><i class="bi bi-box-arrow-right me-2"></i>Sign out</button>
</form>
</li>
</ul>
</div>
{{end}}
@@ -22,9 +22,7 @@
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
{{template "webmail_account_menu" .}}
</div>
</div>
</nav>
+127 -6
View File
@@ -99,9 +99,7 @@
<div class="navbar-nav flex-row gap-2 ms-auto">
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Settings"><i class="bi bi-gear"></i></a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm" title="Sign out"><i class="bi bi-box-arrow-right"></i></button>
</form>
{{template "webmail_account_menu" .}}
</div>
</nav>
@@ -223,6 +221,7 @@
<div id="messageContextMenu" class="dropdown-menu" style="display: none; position: fixed;">
<button type="button" class="dropdown-item" data-msg-action="reply"><i class="bi bi-reply me-2"></i>Reply</button>
<button type="button" class="dropdown-item" data-msg-action="forward"><i class="bi bi-arrow-right me-2"></i>Forward</button>
<button type="button" class="dropdown-item" data-msg-action="forward-attach"><i class="bi bi-paperclip me-2"></i>Forward as attachment</button>
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item" data-msg-action="toggle-read"><i class="bi bi-envelope-open me-2"></i><span data-toggle-read-label>Mark as read</span></button>
<button type="button" class="dropdown-item" data-msg-action="junk"><i class="bi bi-shield-exclamation me-2"></i>Mark as Junk</button>
@@ -231,6 +230,7 @@
<div class="dropdown-menu" id="messageMoveSubmenu"></div>
</div>
<button type="button" class="dropdown-item" data-msg-action="open-tab"><i class="bi bi-box-arrow-up-right me-2"></i>Open in new tab</button>
<button type="button" class="dropdown-item" data-msg-action="download"><i class="bi bi-download me-2"></i>Download email</button>
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item text-danger" data-msg-action="delete"><i class="bi bi-trash me-2"></i><span data-delete-label>Delete</span></button>
</div>
@@ -263,6 +263,30 @@
</div>
</div>
{{/* Delete choice — Trash / Delete Permanently / Cancel — offered whenever the
message(s) being deleted aren't already under Trash (once they are, deleting
only has one real outcome, so that path still just uses the plain
confirmationModal above). */}}
{{/* z-index bumped above the compose popup's own 1080 (webmail_compose_widget.html)
so this still renders on top if it's open — same reasoning as
composeCloseDraftModal there. */}}
<div class="modal fade" id="deleteChoiceModal" tabindex="-1" aria-hidden="true" style="z-index: 1100;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="deleteChoiceModalBody">Move to Trash, or delete permanently?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-danger" id="deleteChoicePermanent">Delete Permanently</button>
<button type="button" class="btn btn-primary" id="deleteChoiceTrash">Move to Trash</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="inputModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@@ -307,6 +331,31 @@
new bootstrap.Modal(modal).show();
});
}
// showDeleteChoice resolves 'trash' | 'permanent' | 'cancel' (Escape/backdrop/
// its own Cancel button, matching showConfirmation's cancel-on-dismiss
// convention above) — see deleteChoiceModal for when this is used instead of
// the plain yes/no showConfirmation.
function showDeleteChoice(message) {
return new Promise((resolve) => {
const modal = document.getElementById('deleteChoiceModal');
document.getElementById('deleteChoiceModalBody').textContent = message;
const trashBtn = document.getElementById('deleteChoiceTrash');
const permBtn = document.getElementById('deleteChoicePermanent');
let choice = 'cancel';
const onTrash = () => { choice = 'trash'; bootstrap.Modal.getInstance(modal).hide(); };
const onPermanent = () => { choice = 'permanent'; bootstrap.Modal.getInstance(modal).hide(); };
const onHidden = () => { cleanup(); resolve(choice); };
const cleanup = () => {
trashBtn.removeEventListener('click', onTrash);
permBtn.removeEventListener('click', onPermanent);
modal.removeEventListener('hidden.bs.modal', onHidden);
};
trashBtn.addEventListener('click', onTrash);
permBtn.addEventListener('click', onPermanent);
modal.addEventListener('hidden.bs.modal', onHidden, { once: true });
new bootstrap.Modal(modal).show();
});
}
// showInputPrompt replaces the browser's native prompt() for folder
// name/rename input — a native prompt() blocks the whole tab (including any
// in-flight automation/extension driving it) until dismissed, and looks/feels
@@ -354,6 +403,26 @@
if (form) form.submit();
}
});
// Same idea as [data-confirm] above, but for a message delete button not
// already under Trash — see deleteChoiceModal for the Trash/Permanent/Cancel
// choice, and messageDelete's own permanent=1 form field this injects when
// "Delete Permanently" is picked. Delegated for the same reason: the reading
// pane's own delete button (webmail_message_pane.html) is fetched in later.
document.addEventListener('click', async function(e) {
const button = e.target.closest('[data-delete-confirm]');
if (!button) return;
e.preventDefault();
const choice = await showDeleteChoice('Move this message to Trash, or delete it permanently?');
if (choice === 'cancel') return;
const form = button.closest('form');
if (!form) return;
if (choice === 'permanent') {
const perm = document.createElement('input');
perm.type = 'hidden'; perm.name = 'permanent'; perm.value = '1';
form.appendChild(perm);
}
form.submit();
});
// Drag a message row onto a folder in the sidebar to move it there — a
// shortcut for the toolbar's "Move to..." control. Each row carries its OWN
@@ -373,6 +442,17 @@
draggedUID = row.dataset.uid;
draggedFolder = row.dataset.folder;
row.classList.add('dragging');
// Lets the compose popup's own drop handler (a same-origin iframe —
// native drag-and-drop works into it same as any other element) attach
// this message as a .eml, matching Outlook's drag-message-onto-a-reply
// behavior. The sidebar-folder drop target above doesn't read this —
// only the compose iframe does.
const subjectEl = row.querySelector('.msg-subject');
e.dataTransfer.setData('application/x-mailgoserver-message', JSON.stringify({
uid: draggedUID, folder: draggedFolder,
subject: subjectEl ? subjectEl.textContent : '',
}));
e.dataTransfer.effectAllowed = 'copy';
});
listScroll.addEventListener('dragend', function(e) {
const row = e.target.closest('.msg-row');
@@ -694,7 +774,18 @@
document.querySelectorAll('.bulk-btn').forEach(function(btn) {
btn.addEventListener('click', async function() {
const action = btn.dataset.action;
if (action === 'delete' && !(await showConfirmation('Move the selected message(s) to Trash?'))) return;
if (action === 'delete') {
const activeFolderUnderTrash = document.querySelector('.folder-row[data-folder="' + CSS.escape({{.active_folder}}) + '"][data-under-trash="true"]') !== null;
if (activeFolderUnderTrash) {
if (!(await showConfirmation('Permanently delete the selected message(s)? This cannot be undone.'))) return;
submitBulk('delete-permanent');
return;
}
const choice = await showDeleteChoice('Move the selected message(s) to Trash, or delete them permanently?');
if (choice === 'cancel') return;
submitBulk(choice === 'permanent' ? 'delete-permanent' : 'delete');
return;
}
submitBulk(action);
});
});
@@ -915,6 +1006,29 @@
case 'forward':
openCompose(`/webmail/mail/compose?forward=${uid}&folder=${folder}`);
break;
case 'forward-attach': {
// Right-clicking one of several checked rows attaches all
// of them at once; otherwise just the row that was
// right-clicked. Either way, goes through
// window.forwardAsAttachment (webmail_compose_widget.html)
// so repeating this — on another message, or with a fresh
// selection — adds to an already-open compose instead of
// replacing what's already attached.
const checkedRows = Array.from(document.querySelectorAll('.msg-item')).filter(function(r) {
const cb = r.querySelector('.msg-check');
return cb && cb.checked;
});
const rowsToForward = (checkedRows.length > 1 && checkedRows.includes(row)) ? checkedRows : [row];
const items = rowsToForward.map(function(r) {
const subjectEl = r.querySelector('.msg-subject');
return { uid: r.dataset.uid, folder: r.dataset.folder, label: subjectEl ? subjectEl.textContent : '' };
});
window.forwardAsAttachment(items);
break;
}
case 'download':
window.open(`/webmail/mail/${folder}/${uid}/download`, '_blank', 'noopener');
break;
case 'toggle-read': {
const isUnread = row.classList.contains('unread');
postMessageAction(`/webmail/mail/${folder}/bulk`, { action: isUnread ? 'read' : 'unread', uid: uid });
@@ -928,8 +1042,15 @@
break;
case 'delete': {
const underTrash = menu.querySelector('[data-delete-label]').textContent === 'Delete Permanently';
const confirmMsg = underTrash ? 'Permanently delete this message? This cannot be undone.' : 'Move this message to Trash?';
if (await showConfirmation(confirmMsg)) postMessageAction(`/webmail/mail/${folder}/${uid}/delete`);
if (underTrash) {
if (await showConfirmation('Permanently delete this message? This cannot be undone.')) {
postMessageAction(`/webmail/mail/${folder}/${uid}/delete`);
}
break;
}
const choice = await showDeleteChoice('Move this message to Trash, or delete it permanently?');
if (choice === 'cancel') break;
postMessageAction(`/webmail/mail/${folder}/${uid}/delete`, choice === 'permanent' ? { permanent: '1' } : {});
break;
}
}
+66 -4
View File
@@ -25,9 +25,7 @@
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Settings</a>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
{{template "webmail_account_menu" .}}
</div>
</div>
</nav>
@@ -53,6 +51,8 @@
<button type="button" id="replyBtn" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-reply me-1"></i>Reply</button>
<button type="button" id="replyAllBtn" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-reply-all me-1"></i>Reply All</button>
<button type="button" id="forwardBtn" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-arrow-right me-1"></i>Forward</button>
<button type="button" onclick="window.forwardAsAttachment([{uid: '{{.uid}}', folder: '{{.active_folder}}', label: '{{.parsed.Header.Subject}}'}])" class="btn btn-outline-primary" title="Forward as attachment"><i class="bi bi-paperclip"></i></button>
<a href="/webmail/mail/{{.active_folder}}/{{.uid}}/download" class="btn btn-outline-primary" title="Download email (.eml)"><i class="bi bi-download"></i></a>
</div>
</div>
@@ -161,7 +161,11 @@
</form>
{{end}}
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="{{if .under_trash}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash me-1"></i>{{if .under_trash}}Delete Permanently{{else}}Move to Trash{{end}}</button>
{{if .under_trash}}
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Permanently delete this message? This cannot be undone."><i class="bi bi-trash me-1"></i>Delete Permanently</button>
{{else}}
<button type="submit" class="btn btn-outline-danger btn-sm" data-delete-confirm="1"><i class="bi bi-trash me-1"></i>Move to Trash</button>
{{end}}
</form>
</div>
</div>
@@ -183,6 +187,23 @@
</div>
</div>
<div class="modal fade" id="deleteChoiceModal" tabindex="-1" aria-hidden="true" style="z-index: 1100;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="deleteChoiceModalBody">Move to Trash, or delete permanently?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-danger" id="deleteChoicePermanent">Delete Permanently</button>
<button type="button" class="btn btn-primary" id="deleteChoiceTrash">Move to Trash</button>
</div>
</div>
</div>
</div>
{{template "compose_widget" .}}
{{template "webmail_shortcuts" .}}
@@ -218,6 +239,47 @@
});
});
});
// Trash / Delete Permanently / Cancel — see deleteChoiceModal above, and
// webmail_folder.html's identical copy of this function for the fuller
// explanation of why this is separate from showConfirmation/[data-confirm].
function showDeleteChoice(message) {
return new Promise((resolve) => {
const modal = document.getElementById('deleteChoiceModal');
document.getElementById('deleteChoiceModalBody').textContent = message;
const trashBtn = document.getElementById('deleteChoiceTrash');
const permBtn = document.getElementById('deleteChoicePermanent');
let choice = 'cancel';
const onTrash = () => { choice = 'trash'; bootstrap.Modal.getInstance(modal).hide(); };
const onPermanent = () => { choice = 'permanent'; bootstrap.Modal.getInstance(modal).hide(); };
const onHidden = () => { cleanup(); resolve(choice); };
const cleanup = () => {
trashBtn.removeEventListener('click', onTrash);
permBtn.removeEventListener('click', onPermanent);
modal.removeEventListener('hidden.bs.modal', onHidden);
};
trashBtn.addEventListener('click', onTrash);
permBtn.addEventListener('click', onPermanent);
modal.addEventListener('hidden.bs.modal', onHidden, { once: true });
new bootstrap.Modal(modal).show();
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-delete-confirm]').forEach(function(button) {
button.addEventListener('click', async function(e) {
e.preventDefault();
const choice = await showDeleteChoice('Move this message to Trash, or delete it permanently?');
if (choice === 'cancel') return;
const form = this.closest('form');
if (!form) return;
if (choice === 'permanent') {
const perm = document.createElement('input');
perm.type = 'hidden'; perm.name = 'permanent'; perm.value = '1';
form.appendChild(perm);
}
form.submit();
});
});
});
</script>
</body>
</html>
@@ -4,7 +4,9 @@
<button type="button" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply"><i class="bi bi-reply"></i></button>
<button type="button" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply All"><i class="bi bi-reply-all"></i></button>
<button type="button" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Forward"><i class="bi bi-arrow-right"></i></button>
<button type="button" onclick="window.forwardAsAttachment([{uid: '{{.uid}}', folder: '{{.active_folder}}', label: '{{.parsed.Header.Subject}}'}])" class="btn btn-outline-primary" title="Forward as attachment"><i class="bi bi-paperclip"></i></button>
<a href="{{.message_url}}" target="_blank" rel="noopener" class="btn btn-outline-primary" title="Open in new tab"><i class="bi bi-box-arrow-up-right"></i></a>
<a href="/webmail/mail/{{.active_folder}}/{{.uid}}/download" class="btn btn-outline-primary" title="Download email (.eml)"><i class="bi bi-download"></i></a>
</div>
<div class="d-flex align-items-center gap-2">
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/move" class="d-flex align-items-center gap-1">
@@ -22,7 +24,7 @@
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Restore"><i class="bi bi-arrow-counterclockwise"></i></button>
</form>
{{end}}
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete" data-confirm="{{if .under_trash}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}">
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete" {{if .under_trash}}data-confirm="Permanently delete this message? This cannot be undone."{{else}}data-delete-confirm="1"{{end}}>
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if .under_trash}}Delete Permanently{{else}}Move to Trash{{end}}"><i class="bi bi-trash"></i></button>
</form>
+1 -3
View File
@@ -30,9 +30,7 @@
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
{{template "webmail_account_menu" .}}
</div>
</div>
</nav>
@@ -22,9 +22,7 @@
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<form method="post" action="/webmail/logout" class="d-inline">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
{{template "webmail_account_menu" .}}
</div>
</div>
</nav>
+65
View File
@@ -593,6 +593,71 @@ func TestWebmailMoveAndDeleteMessage(t *testing.T) {
}
}
// TestWebmailMessageDeletePermanentOverride confirms the permanent=1 form field lets
// a message not already under Trash be deleted outright in one step (see
// webmail_folder.html/webmail_message.html's 3-way Trash/Delete Permanently/Cancel
// prompt) — skipping the usual "goes to Trash first" default, without needing to
// delete it twice.
func TestWebmailMessageDeletePermanentOverride(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "permdel@example.com", domains[0].ID, "permdel-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
uid := storeTestMessage(t, app, mailboxID, "Drafts", "a@example.com", "throwaway draft", "body")
uidStr := strconv.FormatInt(uid, 10)
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/Drafts/"+uidStr+"/delete", strings.NewReader("permanent=1"))
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("delete permanent=1: status=%d", rec.Code)
}
gone, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil || gone != nil {
t.Fatalf("expected the message permanently gone (never routed through Trash), got %+v (err=%v)", gone, err)
}
inTrash, err := app.DB.ListMessagesInFolder(mailboxID, "Trash")
if err != nil || len(inTrash) != 0 {
t.Fatalf("expected nothing in Trash, got %d (err=%v)", len(inTrash), err)
}
}
// TestWebmailBulkDeletePermanent confirms the delete-permanent bulk action removes
// every selected message outright, regardless of its current folder, rather than
// moving it to Trash first.
func TestWebmailBulkDeletePermanent(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "bulkpermdel@example.com", domains[0].ID, "bulkpermdel-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
uidA := storeTestMessage(t, app, mailboxID, "Drafts", "a@example.com", "draft one", "body a")
uidB := storeTestMessage(t, app, mailboxID, "Drafts", "b@example.com", "draft two", "body b")
form := url.Values{"action": {"delete-permanent"}, "uid": {strconv.FormatInt(uidA, 10), strconv.FormatInt(uidB, 10)}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/Drafts/bulk", 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("bulk delete-permanent: status=%d", rec.Code)
}
remaining, err := app.DB.ListMessagesInFolder(mailboxID, "Drafts")
if err != nil || len(remaining) != 0 {
t.Fatalf("expected both drafts permanently gone, got %d (err=%v)", len(remaining), err)
}
inTrash, err := app.DB.ListMessagesInFolder(mailboxID, "Trash")
if err != nil || len(inTrash) != 0 {
t.Fatalf("expected nothing routed through Trash, got %d (err=%v)", len(inTrash), err)
}
}
// TestWebmailMessageAccessControlAcrossMailboxes confirms one mailbox owner can't
// view another mailbox's message by guessing its UID, even in a folder name they
// both happen to have.
+123 -18
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"html"
"html/template"
"io"
@@ -112,6 +113,8 @@ func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
uidStr, mode = q.Get("replyall"), "replyall"
case q.Get("forward") != "":
uidStr, mode = q.Get("forward"), "forward"
case q.Get("forward_attach") != "":
uidStr, mode = q.Get("forward_attach"), "forward_attach"
case q.Get("draft") != "":
uidStr, mode = q.Get("draft"), "draft"
}
@@ -120,7 +123,7 @@ func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
// signature_defaults_json to use when the picked address changes — a draft is
// treated like "new" here (matching the auto-insertion skip below: a draft's
// signature situation, if any, is whatever was already saved in it).
data["compose_for_reply"] = mode == "reply" || mode == "replyall" || mode == "forward"
data["compose_for_reply"] = mode == "reply" || mode == "replyall" || mode == "forward" || mode == "forward_attach"
if mode != "" && folder != "" {
if parsed := a.webmailLoadForPrefill(mbox.ID, folder, int64(atoi(uidStr))); parsed != nil {
@@ -154,6 +157,27 @@ func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
case "forward":
data["subject"] = forwardSubject(parsed.Header.Subject)
data["body_html"] = template.HTML(forwardBodyHTML(parsed))
case "forward_attach":
// Unlike a regular forward, the original isn't quoted into the body at
// all — the whole point is attaching it as its own .eml file (see
// webmailComposeSend, which reads forward_attach_uid/_folder back out
// at submit time and does the actual attaching server-side; a browser
// can't be handed a pre-populated <input type=file>). Body stays blank
// (just the cursor-home paragraph) so there's somewhere to type above
// the attachment, same as a reply/forward's own empty line.
data["subject"] = forwardSubject(parsed.Header.Subject)
data["body_html"] = template.HTML(composeCursorHome)
// A single-item seed list — opening compose fresh only ever starts
// with the one message that triggered it; adding more (repeated
// right-clicks, or a multi-select forward, or dragging another message
// in) happens client-side into an already-open compose without a
// reload, see the compose popup's forwardAsAttachment/
// addForwardAttachments. Label is the bare subject, not a filename —
// see webmail_compose.html's chip render, the only thing that reads
// it; the actual attachment filename is computed fresh at send time
// regardless (see forwardAttachAttachments), so this never needs to
// match it exactly.
data["forward_attach_items"] = []forwardAttachSeed{{UID: uidStr, Folder: folder, Label: parsed.Header.Subject}}
}
}
}
@@ -163,7 +187,7 @@ func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
// is left exactly as saved — it may already contain whatever signature was there
// when it was saved, or none, and re-adding one here would duplicate it.
if mode != "draft" {
forReply := mode == "reply" || mode == "replyall" || mode == "forward"
forReply := mode == "reply" || mode == "replyall" || mode == "forward" || mode == "forward_attach"
if sig, err := a.DB.GetDefaultSignature(mbox.ID, forReply, ""); err == nil && sig != nil {
data["default_signature_id"] = sig.ID
if wrapped := wrapSignatureHTML(sig.ContentHTML); wrapped != "" {
@@ -525,6 +549,23 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
data["body_html"] = template.HTML(htmlBody)
data["in_reply_to"] = inReplyTo
data["draft_id"] = r.FormValue("draft_id")
// Unlike a real file attachment (which browsers won't let a server response
// restore into a native file input), "Forward as attachment" chips are just
// hidden-field text — cheap to carry through a failed-validation retry too,
// one or more of them.
uids, folders, labels := r.Form["forward_attach_uid"], r.Form["forward_attach_folder"], r.Form["forward_attach_label"]
items := make([]forwardAttachSeed, 0, len(uids))
for i, uid := range uids {
seed := forwardAttachSeed{UID: uid}
if i < len(folders) {
seed.Folder = folders[i]
}
if i < len(labels) {
seed.Label = labels[i]
}
items = append(items, seed)
}
data["forward_attach_items"] = items
a.render(w, r, "webmail_compose.html", data)
}
@@ -558,6 +599,12 @@ func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
attachments = append(attachments, composeAttachment{Filename: fh.Filename, ContentType: fh.Header.Get("Content-Type"), Data: data})
}
}
if fwd, err := a.forwardAttachAttachments(mbox.ID, r); err != nil {
fail(err.Error())
return
} else if len(fwd) > 0 {
attachments = append(attachments, fwd...)
}
// Mirrors the client-side check in webmail_compose.html — enforced again here
// since a blank send (e.g. via an accidental Enter-key form submit) must never
@@ -819,6 +866,12 @@ func (a *App) webmailComposeSaveDraft(w http.ResponseWriter, r *http.Request) {
attachments = append(attachments, composeAttachment{Filename: fh.Filename, ContentType: fh.Header.Get("Content-Type"), Data: data})
}
}
// A draft can carry a pending "Forward as attachment" too — silently dropped
// (rather than failing the whole save) if the original message has since become
// inaccessible, since a draft is allowed to be imperfect by definition.
if fwd, err := a.forwardAttachAttachments(mbox.ID, r); err == nil {
attachments = append(attachments, fwd...)
}
heloHostname := a.Cfg.Section("Server").Key("helo_hostname").String()
if heloHostname == "" {
@@ -835,28 +888,44 @@ func (a *App) webmailComposeSaveDraft(w http.ResponseWriter, r *http.Request) {
}
raw := assembleMessage(buildEnvelopeHeaders(from, toAddrs, ccAddrs, subject, messageID, inReplyTo), entity)
newUID, err := a.Mailstore.StoreMessage(mbox.ID, "Drafts", []byte(raw), messageID, from, subject)
if err != nil {
a.Logger.Error("save draft for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save the draft: "+err.Error())
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
if err := a.DB.SetMessageFlags(mbox.ID, newUID, `\Seen`); err != nil {
a.Logger.Error("mark draft %d read for mailbox %d: %v", newUID, mbox.ID, err)
}
// Replace, don't accumulate: re-saving an open draft deletes the previous copy.
// Re-saving an already-open draft updates it IN PLACE (same uid) rather than the
// old insert-new+delete-old churn, which assigned a brand-new uid on every single
// autosave tick — pointless, and actively wrong for anything that holds onto a
// "this is draft #40" reference across a save, including this app's own
// compose-popup-survives-navigation restore (see
// webmail_compose_widget.html/webmail_compose.html's DRAFT_ID_KEY: a fire-and-
// forget sendBeacon save on navigating away can't learn a freshly-assigned uid
// back, so the id it already knows about has to remain valid across that save).
// messageAccessible re-scopes to this mailbox AND requires the existing message
// still be in Drafts — a tampered draft_id can't be used to overwrite the content
// of some other message this mailbox owns.
var uid int64
if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
if oldUID := int64(atoi(draftIDStr)); oldUID != newUID {
if err := a.Mailstore.DeleteMessage(mbox.ID, oldUID); err != nil {
a.Logger.Error("delete superseded draft %d for mailbox %d: %v", oldUID, mbox.ID, err)
existingUID := int64(atoi(draftIDStr))
if _, ok := a.messageAccessible(mbox.ID, "Drafts", existingUID); ok {
if err := a.Mailstore.UpdateMessage(mbox.ID, existingUID, []byte(raw), messageID, subject); err != nil {
a.Logger.Error("update draft %d for mailbox %d, falling back to a new one: %v", existingUID, mbox.ID, err)
} else {
uid = existingUID
}
}
}
if uid == 0 {
newUID, err := a.Mailstore.StoreMessage(mbox.ID, "Drafts", []byte(raw), messageID, from, subject)
if err != nil {
a.Logger.Error("save draft for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save the draft: "+err.Error())
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
uid = newUID
}
if err := a.DB.SetMessageFlags(mbox.ID, uid, `\Seen`); err != nil {
a.Logger.Error("mark draft %d read for mailbox %d: %v", uid, mbox.ID, err)
}
setFlash(w, "success", "Draft saved")
http.Redirect(w, r, MailboxPrefix+"/mail/compose?draft="+strconv.FormatInt(newUID, 10)+"&folder=Drafts", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/mail/compose?draft="+strconv.FormatInt(uid, 10)+"&folder=Drafts", http.StatusFound)
}
// deliverWebmailComposeLocally stores a composed message into another local
@@ -911,6 +980,42 @@ func parseComposeAddrs(raw string) ([]string, error) {
return out, nil
}
// forwardAttachAttachment builds the composeAttachment for "Forward as attachment"
// (see webmailComposeForm/webmail_compose.html) — one or more, added via repeated
// right-clicks/a multi-select forward/dragging messages onto an already-open compose
// (see webmail_compose_widget.html's forwardAsAttachment), each submitted as its own
// forward_attach_uid/forward_attach_folder hidden input pair, paired up here by
// position. Re-checks access to every one here rather than trusting the hidden fields
// (still just user-submitted form data) — messageAccessible scopes each lookup to this
// mailbox, see its own doc comment. Returns (nil, nil) when there are none, the common
// case: this is opt-in, not something every send carries.
type forwardAttachSeed struct{ UID, Folder, Label string }
func (a *App) forwardAttachAttachments(mailboxID int64, r *http.Request) ([]composeAttachment, error) {
uids := r.Form["forward_attach_uid"]
folders := r.Form["forward_attach_folder"]
if len(uids) == 0 {
return nil, nil
}
if len(uids) != len(folders) {
return nil, errors.New("the forwarded message list was malformed")
}
out := make([]composeAttachment, 0, len(uids))
for i, uidStr := range uids {
uid := int64(atoi(uidStr))
msgRow, ok := a.messageAccessible(mailboxID, folders[i], uid)
if !ok {
return nil, errors.New("one of the original messages could no longer be found")
}
raw, err := a.Mailstore.FetchMessage(mailboxID, uid)
if err != nil {
return nil, errors.New("could not read one of the original messages")
}
out = append(out, composeAttachment{Filename: emlFilename(msgRow.CachedSubject, uid), ContentType: "message/rfc822", Data: raw})
}
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
@@ -0,0 +1,245 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/mailview"
)
// TestWebmailDownloadMessageServesRawEML confirms the "Download email" action returns
// the message's exact raw bytes (not the decrypted/re-parsed body — see
// webmailDownloadMessage's own doc comment on why), as an attachment with a filename
// derived from the subject, and that it's scoped to the requesting mailbox.
func TestWebmailDownloadMessageServesRawEML(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
ownerID := createTestMailboxWithPassword(t, app, "owner@example.com", domains[0].ID, "owner-password-1!")
otherID := createTestMailboxWithPassword(t, app, "other@example.com", domains[0].ID, "other-password-1!")
uid := storeTestMessage(t, app, ownerID, "INBOX", "sender@example.com", "Quarterly Report", "the body")
cookie := webmailLoginSession(t, app, ownerID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10)+"/download", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("download: status=%d body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "message/rfc822" {
t.Errorf("Content-Type = %q, want message/rfc822", ct)
}
if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, `filename="Quarterly Report.eml"`) {
t.Errorf("Content-Disposition = %q, want a filename derived from the subject", cd)
}
raw, err := app.Mailstore.FetchMessage(ownerID, uid)
if err != nil {
t.Fatal(err)
}
if rec.Body.String() != string(raw) {
t.Errorf("expected the download to be the exact raw stored message")
}
// Scoped to the owner's own mailbox — another mailbox guessing this uid gets 404.
otherCookie := webmailLoginSession(t, app, otherID)
req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10)+"/download", nil)
req2.AddCookie(otherCookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusNotFound {
t.Fatalf("expected 404 for another mailbox's message, got %d", rec2.Code)
}
}
// TestWebmailComposeFormForwardAttachPrefill confirms opening compose with
// forward_attach set prefills a "Fwd:" subject, leaves the body blank (no quoted
// original — unlike a regular forward, see webmailComposeForm), and exposes the
// hidden fields the compose template renders as a removable chip.
func TestWebmailComposeFormForwardAttachPrefill(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "fwder@example.com", domains[0].ID, "fwder-password-1!")
uid := storeTestMessage(t, app, mailboxID, "INBOX", "sender@example.com", "Original Subject", "body text")
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/compose?forward_attach="+strconv.FormatInt(uid, 10)+"&folder=INBOX", 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, `value="Fwd: Original Subject"`) {
t.Errorf("expected the subject prefilled with Fwd:, got:\n%s", body)
}
if strings.Contains(body, "body text") {
t.Errorf("expected the original body NOT quoted in (forward-as-attachment shouldn't duplicate content), got:\n%s", body)
}
if !strings.Contains(body, `class="forward-attach-seed" value="`+strconv.FormatInt(uid, 10)+`" data-folder="INBOX"`) {
t.Errorf("expected a forward-attach-seed element for the message, got:\n%s", body)
}
}
// TestWebmailComposeFormForwardAttachPreservesMultipleOnRetry confirms a failed send
// (e.g. no recipient) re-renders the compose form with every pending forwarded
// message still seeded, not just the first — the whole point of moving from three
// single hidden fields to a list.
func TestWebmailComposeFormForwardAttachPreservesMultipleOnRetry(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "fwder2@example.com", domains[0].ID, "fwder-password-1!")
uidA := storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Subject A", "body a")
uidB := storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Subject B", "body b")
cookie := webmailLoginSession(t, app, mailboxID)
form := url.Values{
// No "to" — deliberately triggers the "at least one recipient" validation
// failure, which re-renders the form via the fail() closure.
"subject": {"Fwd: multiple"},
"body_html": {"see attached"},
"forward_attach_uid": {strconv.FormatInt(uidA, 10), strconv.FormatInt(uidB, 10)},
"forward_attach_folder": {"INBOX", "INBOX"},
"forward_attach_label": {"Subject A", "Subject B"},
}
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.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, uid := range []int64{uidA, uidB} {
if !strings.Contains(body, `class="forward-attach-seed" value="`+strconv.FormatInt(uid, 10)+`"`) {
t.Errorf("expected both forwarded messages (including uid %d) preserved on retry, got:\n%s", uid, body)
}
}
}
// TestWebmailComposeSendForwardAttach confirms the send handler reads the
// forward_attach_uid/_folder hidden fields back out and attaches the original
// message's exact raw bytes as a message/rfc822 attachment, and that it's re-checked
// against the sender's own mailbox rather than trusted blindly (a malicious or stale
// uid from another mailbox is silently ignored, matching messageAccessible's scoping —
// the send itself still succeeds, just without an attachment).
func TestWebmailComposeSendForwardAttach(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "fwdsender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "fwdrecip@example.com", domainID, "recipient-password-1!")
originalUID := storeTestMessage(t, app, senderID, "INBOX", "someone@example.com", "Attach Me", "original body")
originalRaw, err := app.Mailstore.FetchMessage(senderID, originalUID)
if err != nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"fwdrecip@example.com"}, "subject": {"Fwd: Attach Me"}, "body_html": {"see attached"},
"forward_attach_uid": {strconv.FormatInt(originalUID, 10)}, "forward_attach_folder": {"INBOX"},
"forward_attach_label": {"Attach Me"},
}
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())
}
recipientMsgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(recipientMsgs) != 1 {
t.Fatalf("recipient INBOX: got %d messages, err=%v", len(recipientMsgs), err)
}
rawReceived, err := app.Mailstore.FetchMessage(recipientID, recipientMsgs[0].ID)
if err != nil {
t.Fatal(err)
}
parsed, err := mailview.Parse(rawReceived)
if err != nil {
t.Fatal(err)
}
if len(parsed.Attachments) != 1 {
t.Fatalf("expected 1 attachment (the forwarded .eml), got %d", len(parsed.Attachments))
}
att := parsed.Attachments[0]
if att.Filename != "Attach Me.eml" {
t.Errorf("attachment filename = %q, want %q", att.Filename, "Attach Me.eml")
}
if att.ContentType != "message/rfc822" {
t.Errorf("attachment content-type = %q, want message/rfc822", att.ContentType)
}
if string(att.Data) != string(originalRaw) {
t.Errorf("expected the attachment to be the exact raw original message")
}
}
// TestWebmailComposeSendMultipleForwardAttach confirms more than one forwarded
// message can be attached to the same send — previously only the most recent one
// survived (a single hidden field that got overwritten instead of accumulating).
func TestWebmailComposeSendMultipleForwardAttach(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "multifwdsender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "multifwdrecip@example.com", domainID, "recipient-password-1!")
uidA := storeTestMessage(t, app, senderID, "INBOX", "a@example.com", "First Original", "body a")
uidB := storeTestMessage(t, app, senderID, "INBOX", "b@example.com", "Second Original", "body b")
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"multifwdrecip@example.com"}, "subject": {"Fwd: two messages"}, "body_html": {"see attached"},
"forward_attach_uid": {strconv.FormatInt(uidA, 10), strconv.FormatInt(uidB, 10)},
"forward_attach_folder": {"INBOX", "INBOX"},
"forward_attach_label": {"First Original", "Second Original"},
}
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())
}
recipientMsgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(recipientMsgs) != 1 {
t.Fatalf("recipient INBOX: got %d messages, err=%v", len(recipientMsgs), err)
}
rawReceived, err := app.Mailstore.FetchMessage(recipientID, recipientMsgs[0].ID)
if err != nil {
t.Fatal(err)
}
parsed, err := mailview.Parse(rawReceived)
if err != nil {
t.Fatal(err)
}
if len(parsed.Attachments) != 2 {
t.Fatalf("expected 2 attachments (both forwarded .eml files), got %d", len(parsed.Attachments))
}
names := map[string]bool{}
for _, att := range parsed.Attachments {
names[att.Filename] = true
if att.ContentType != "message/rfc822" {
t.Errorf("attachment %q content-type = %q, want message/rfc822", att.Filename, att.ContentType)
}
}
if !names["First Original.eml"] || !names["Second Original.eml"] {
t.Errorf("expected both First Original.eml and Second Original.eml attached, got %+v", names)
}
}
+7
View File
@@ -72,6 +72,13 @@ func TestWebmailSaveDraftThenSend(t *testing.T) {
t.Fatalf("expected still 1 draft after re-save, got %d (err=%v)", len(drafts), err)
}
newDraftID := drafts[0].ID
// Re-saving updates the SAME message in place now (mailstore.Store.UpdateMessage)
// rather than deleting and recreating under a new id — anything holding onto "my
// draft is uid X" across a save (the webmail popup's compose-survives-navigation
// restore, in particular) depends on this not silently changing underneath it.
if newDraftID != draftID {
t.Errorf("expected the draft's uid to stay %d across a re-save, got %d", draftID, newDraftID)
}
// Finally send it — the message is delivered and the draft is gone.
sendForm := url.Values{
+61 -5
View File
@@ -629,10 +629,13 @@ func (a *App) messageAccessible(mailboxID int64, folder string, uid int64) (*db.
return msg, true
}
// webmailMessageDelete moves a message to Trash — or, if it's already somewhere under
// Trash (literally "Trash", or a folder that was itself deleted into Trash — see
// webmailDeleteFolder), permanently deletes it (ciphertext, index row, and frees the
// quota).
// webmailMessageDelete moves a message to Trash — or permanently deletes it
// (ciphertext, index row, and frees the quota) if it's already somewhere under Trash
// (literally "Trash", or a folder that was itself deleted into Trash — see
// webmailDeleteFolder), OR if permanent=1 was explicitly requested (see
// webmail_folder.html/webmail_message.html's 3-way delete prompt — Trash / Delete
// Permanently / Cancel — offered whenever the message isn't already under Trash,
// where "just delete it" would otherwise mean two separate trips).
func (a *App) webmailMessageDelete(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
@@ -641,13 +644,21 @@ func (a *App) webmailMessageDelete(w http.ResponseWriter, r *http.Request) {
return
}
underTrash := false
if root, err := a.DB.FolderRoot(mbox.ID, folder); err == nil && root == "Trash" {
underTrash = true
}
if underTrash || r.FormValue("permanent") == "1" {
if err := a.Mailstore.DeleteMessage(mbox.ID, uid); err != nil {
setFlash(w, "error", "Error deleting message")
} else {
setFlash(w, "success", "Message permanently deleted")
}
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
redirectFolder := folder
if underTrash {
redirectFolder = "Trash"
}
http.Redirect(w, r, MailboxPrefix+"/mail/"+redirectFolder, http.StatusFound)
return
}
if err := a.DB.MoveMessageToTrash(mbox.ID, uid); err != nil {
@@ -797,6 +808,10 @@ func (a *App) webmailBulkAction(w http.ResponseWriter, r *http.Request) {
} else {
err = a.DB.MoveMessageToTrash(mbox.ID, uid)
}
case "delete-permanent":
// Explicit "skip Trash" choice (see webmail_folder.html's 3-way bulk
// delete prompt) — always permanent, regardless of the current folder.
err = a.Mailstore.DeleteMessage(mbox.ID, uid)
case "restore":
err = a.DB.RestoreMessage(mbox.ID, uid)
case "move":
@@ -899,6 +914,47 @@ func (a *App) webmailDownloadAllAttachments(w http.ResponseWriter, r *http.Reque
zw.Close()
}
// webmailDownloadMessage serves the message's own raw, exactly-as-stored bytes as a
// .eml download — RFC 822/2822 format, so any mail client (including Outlook) can open
// it directly. Deliberately NOT run through unwrapCrypto the way the attachment
// download/view handlers are: a PGP/S-MIME message stays encrypted/signed in the
// downloaded file too, matching what a real IMAP client's "save as" would produce —
// unwrapping it first would silently hand out a decrypted copy under a filename that
// looks like the original, and would strip a signature's own proof of authenticity.
func (a *App) webmailDownloadMessage(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
if !ok {
return
}
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Disposition", `attachment; filename="`+emlFilename(msgRow.CachedSubject, uid)+`"`)
w.Header().Set("Content-Type", "message/rfc822")
w.Write(raw)
}
// emlFilename turns a message's cached subject into a safe attachment filename,
// falling back to its uid when the subject is empty — same minimal sanitization
// (strip characters that could break out of the quoted Content-Disposition value or
// inject a header) as the rest of this file's download handlers use.
func emlFilename(subject string, uid int64) string {
name := strings.NewReplacer(`"`, "", "\r", "", "\n", "", "/", "-", "\\", "-").Replace(strings.TrimSpace(subject))
if name == "" {
name = "message-" + strconv.FormatInt(uid, 10)
}
if runes := []rune(name); len(runes) > 120 {
name = string(runes[:120])
}
return name + ".eml"
}
const maxFolderNameLen = 60
// webmailAddFolder creates a new custom folder as a child of the folder the sidebar's
+1
View File
@@ -182,6 +182,7 @@ func (a *App) Mux() *http.ServeMux {
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/clean-spam", a.webmailCleanSpam)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachments.zip", a.webmailDownloadAllAttachments)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/download", a.webmailDownloadMessage)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/rename", a.webmailRenameFolder)