persistant message compose
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = '×';
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user