Files
mailgoserver/internal/webui/templates/webmail_compose.html
T

780 lines
45 KiB
HTML

{{define "webmail_compose.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Compose - Webmail</title>
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<link rel="icon" type="image/x-icon" href="/webmail/static/img/favicon-webmail.ico">
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
<style>
:root { --cw-bg: #1e1e1e; --cw-panel: #262626; --cw-border: #404040; --cw-text: #e0e0e0; --cw-muted: #9a9a9a; }
html, body { height: 100%; margin: 0; overflow: hidden; }
body { background-color: var(--cw-bg); color: var(--cw-text); font-size: .875rem; }
.compose-shell { display: flex; flex-direction: column; height: 100vh; }
.compose-actionbar { flex: 0 0 auto; display: flex; align-items: center; gap: .5rem; padding: .5rem .75rem; border-bottom: 1px solid var(--cw-border); background-color: var(--cw-panel); flex-wrap: wrap; }
.compose-actionbar .from-email { font-size: .8rem; color: var(--cw-muted); }
.compose-actionbar select.from-select { max-width: 220px; }
.compose-fields { flex: 0 0 auto; padding: 0 .75rem; }
.field-row { display: flex; align-items: center; border-bottom: 1px solid var(--cw-border); padding: .3rem 0; }
.field-row .field-label { width: 42px; flex: 0 0 auto; color: var(--cw-muted); font-size: .8rem; }
.field-row input.bare-input { flex: 1 1 auto; min-width: 0; border: 0; background: transparent; color: var(--cw-text); outline: none; font-size: .85rem; padding: .2rem 0; }
.field-row input.bare-input::placeholder { color: var(--cw-muted); }
.cc-bcc-toggle { flex: 0 0 auto; font-size: .75rem; color: #6ea8fe; cursor: pointer; margin-left: .5rem; white-space: nowrap; }
.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; }
.editor-wrap { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; padding: 0 .75rem .5rem; }
.editor-wrap.drag-over { outline: 2px dashed #6ea8fe; outline-offset: -4px; }
#editor { flex: 1 1 auto; min-height: 0; background-color: #fff; color: #000; }
.ql-toolbar.ql-snow { flex: 0 0 auto; background-color: #333; border-color: var(--cw-border); border-top-left-radius: .375rem; border-top-right-radius: .375rem; }
.ql-container.ql-snow { border-color: var(--cw-border); border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; }
/* Quill's default snow-theme icons are near-black (#444) — invisible on a dark
toolbar. Light stroke/fill + light picker text restores contrast. */
.ql-snow .ql-stroke { stroke: #c8c8c8; }
.ql-snow .ql-fill, .ql-snow .ql-stroke.ql-fill { fill: #c8c8c8; }
.ql-snow .ql-picker { color: #c8c8c8; }
.ql-snow .ql-picker-label { color: #c8c8c8; border-color: transparent; }
.ql-snow .ql-picker-options { background-color: var(--cw-panel); border-color: var(--cw-border); }
.ql-snow .ql-picker-item { color: #c8c8c8; }
.ql-toolbar.ql-snow button:hover, .ql-toolbar.ql-snow button.ql-active,
.ql-toolbar.ql-snow .ql-picker-label:hover, .ql-toolbar.ql-snow .ql-picker-label.ql-active,
.ql-toolbar.ql-snow .ql-picker-item:hover { color: #fff; }
.ql-toolbar.ql-snow button:hover .ql-stroke, .ql-toolbar.ql-snow button.ql-active .ql-stroke,
.ql-toolbar.ql-snow button:hover .ql-fill, .ql-toolbar.ql-snow button.ql-active .ql-fill { stroke: #fff; }
.ql-snow .ql-tooltip { background-color: var(--cw-panel); color: var(--cw-text); border-color: var(--cw-border); box-shadow: 0 2px 8px rgba(0,0,0,.4); }
.ql-snow .ql-tooltip input[type="text"] { background-color: var(--cw-bg); color: var(--cw-text); border-color: var(--cw-border); }
.attachment-list:not(:empty) { padding: .4rem 0; display: flex; flex-wrap: wrap; gap: .4rem; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
{{.Message}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
</div>
<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>
<button type="submit" formaction="/webmail/mail/save-draft" formnovalidate class="btn btn-outline-light btn-sm"><i class="bi bi-save2 me-1"></i>Save</button>
<small class="from-email" id="autosaveStatus" style="display: none;"></small>
<button type="button" id="attachBtn" class="btn btn-outline-light btn-sm" title="Attach files"><i class="bi bi-paperclip"></i></button>
<input type="file" id="attachment_input" name="attachments" multiple style="display: none;">
{{if .signatures}}
<select class="form-select form-select-sm" id="signatureSelect" style="width: auto;" title="Signature">
<option value="">No signature</option>
{{range .signatures}}<option value="{{.ID}}" {{if eq $.default_signature_id .ID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
{{range .signatures}}<div class="sig-data" data-sig-id="{{.ID}}" style="display: none;">{{.ContentHTML | safe}}</div>{{end}}
{{end}}
<script type="application/json" id="signatureDefaultsData">{{.signature_defaults_json}}</script>
<script>var composeForReply = {{.compose_for_reply}};</script>
<div class="ms-auto d-flex align-items-center gap-2">
{{if .send_as_options}}
<select class="form-select form-select-sm from-select" name="from">
<option value="{{.mailbox.Email}}">{{.mailbox.Email}}</option>
{{range .send_as_options}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
{{else}}
<span class="from-email">{{.mailbox.Email}}</span>
{{end}}
</div>
</div>
<div class="compose-fields">
<div class="field-row">
<label class="field-label">To</label>
<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>
<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>
<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">
<input type="text" class="bare-input" name="subject" value="{{.subject}}" placeholder="Add a subject">
</div>
<div class="crypto-row">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="smime_sign" value="1" id="smime_sign">
<label class="form-check-label" for="smime_sign"><i class="bi bi-pen me-1"></i>Sign (S/MIME)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="pgp_encrypt" value="1" id="pgp_encrypt">
<label class="form-check-label" for="pgp_encrypt"><i class="bi bi-lock me-1"></i>Encrypt (PGP)</label>
</div>
{{if gt (len .smime_identities) 1}}
<div id="smime_identity_row" style="display: none;">
<select class="form-select form-select-sm" name="smime_identity_id">
{{range .smime_identities}}<option value="{{.ID}}">Certificate expiring {{.NotAfter.Format "2006-01-02"}}</option>{{end}}
</select>
</div>
{{end}}
<div id="pgp_recipient_row" style="display: none;">
{{if .pgp_contacts}}
<select class="form-select form-select-sm" name="pgp_recipient_id" multiple size="3" style="min-width: 220px;">
{{range .pgp_contacts}}<option value="{{.ID}}">{{if .Label}}{{.Label}}{{else}}{{.Email}}{{end}} — {{.Fingerprint}}</option>{{end}}
</select>
{{else}}
<span>No PGP contacts yet — add one on the Certs page.</span>
{{end}}
</div>
<a href="/webmail/certs" class="ms-auto text-muted">Manage certificates</a>
</div>
<div id="attachment_list" class="attachment-list"></div>
</div>
<div class="editor-wrap" id="editorWrap">
<div id="editor"></div>
<textarea name="body_html" style="display: none;"></textarea>
</div>
<div id="body_html_seed" style="display: none;">{{.body_html}}</div>
</form>
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/webmail/static/vendor/quill/quill.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
// Quill's default clipboard module already turns a pasted screenshot into an
// embedded base64 <img> — that's what makes "paste a screenshot like Outlook"
// work with no extra code here.
var quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }, { background: [] }],
[{ size: ['small', false, 'large', 'huge'] }],
['link', 'image'],
['clean'],
],
},
});
// Signature tracking: the signature is kept as a plain-paragraph Delta range
// (index/length) rather than a DOM marker div. A wrapping <div> set directly on
// quill.root gets silently discarded by Quill's own MutationObserver within one
// update cycle (it isn't a recognized block type) — confirmed live, so signature
// content is inserted via Quill's own clipboard API instead, which turns it into
// real paragraph blots that survive.
var sigRange = null; // {index, length} of the signature currently in the editor, or null
function insertSignature(index, html) {
if (!html) return null;
const before = quill.getLength();
quill.clipboard.dangerouslyPasteHTML(index, html);
return { index: index, length: quill.getLength() - before };
}
function removeSignature() {
if (sigRange && sigRange.length > 0) quill.deleteText(sigRange.index, sigRange.length);
sigRange = null;
}
(function() {
const seed = document.getElementById('body_html_seed');
if (!seed || !seed.innerHTML.trim()) return;
const parsed = document.createElement('div');
parsed.innerHTML = seed.innerHTML;
const sigEl = parsed.querySelector('#mailSignatureBlock');
let sigHTML = '';
if (sigEl) {
sigHTML = sigEl.innerHTML;
sigEl.remove();
}
quill.root.innerHTML = parsed.innerHTML;
// Reply/forward seeds start with an empty line above the quoted
// original (see composeCursorHome, webmail_compose.go) — put the
// cursor there so typing starts above the quote, not inside or after it.
quill.setSelection(0, 0);
quill.focus();
if (sigHTML) {
// The backend always inserts the signature immediately after that
// leading empty paragraph (index 1), before any quoted/forwarded
// text — see webmailComposeForm.
sigRange = insertSignature(1, sigHTML);
quill.setSelection(0, 0);
}
})();
// Signature picker: swaps the currently-inserted signature (tracked via
// sigRange above) for a different one, or removes it. Picking one when none
// exists yet appends it at the end of the body.
(function() {
const select = document.getElementById('signatureSelect');
if (!select) return;
const sigHTML = {};
document.querySelectorAll('.sig-data').forEach(function(el) { sigHTML[el.dataset.sigId] = el.innerHTML; });
select.addEventListener('change', function() {
const insertAt = sigRange ? sigRange.index : Math.max(0, quill.getLength() - 1);
removeSignature();
if (!select.value) return;
sigRange = insertSignature(insertAt, sigHTML[select.value] || '');
});
// Switching "From" (a send-as alias) re-picks whichever signature is that
// address's own default — see composeFormData's signature_defaults_json.
// Only ever auto-*switches* to a different default; it never removes a
// signature the user picked by hand if that address has none of its own
// (falls back to "leave whatever's already inserted" instead of clearing it).
const fromSelect = document.querySelector('.from-select');
const defaultsDataEl = document.getElementById('signatureDefaultsData');
if (fromSelect && defaultsDataEl) {
let signatureDefaults = {};
try { signatureDefaults = JSON.parse(defaultsDataEl.textContent) || {}; } catch (e) { signatureDefaults = {}; }
fromSelect.addEventListener('change', function() {
const entry = signatureDefaults[fromSelect.value];
if (!entry) return;
const newId = composeForReply ? entry.reply : entry.new;
if (!newId) return;
select.value = String(newId);
select.dispatchEvent(new Event('change'));
});
}
})();
// Cc/Bcc start hidden (Outlook-style) unless prefilled (e.g. reply-all sets
// Cc) — clicking a toggle reveals its row and focuses the field, same as
// clicking "Cc"/"Bcc" in a real mail client.
(function() {
function wireToggle(toggleId, rowId, fieldName) {
const toggle = document.getElementById(toggleId);
const row = document.getElementById(rowId);
const field = row.querySelector('[name="' + fieldName + '"]');
if (field.value.trim()) {
row.style.display = 'flex';
toggle.style.display = 'none';
} else {
toggle.addEventListener('click', function() {
row.style.display = 'flex';
toggle.style.display = 'none';
const entry = row.querySelector('.chip-input-entry');
if (entry) entry.focus();
});
}
}
wireToggle('showCc', 'ccRow', 'cc');
wireToggle('showBcc', 'bccRow', 'bcc');
})();
// Attachments accumulate across multiple picks/drops instead of the native
// file input's default "replace the whole selection" behavior — a DataTransfer
// is the only way to build an editable FileList, so it's kept as the source of
// truth and re-assigned onto the real (hidden) input before every render.
// Drag-and-drop works anywhere over the message area, matching a real client —
// there's no dedicated dropzone box taking up space when empty.
(function() {
const form = document.getElementById('composeForm');
const dropTarget = document.getElementById('editorWrap');
const attachBtn = document.getElementById('attachBtn');
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';
chip.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
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() { removeFile(i); });
chip.appendChild(remove);
list.appendChild(chip);
});
}
function addFiles(fileList) {
Array.from(fileList).forEach(function(file) { staged.items.add(file); });
render();
}
function removeFile(index) {
const next = new DataTransfer();
Array.from(staged.files).forEach(function(file, i) { if (i !== index) next.items.add(file); });
staged = next;
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'); });
form.addEventListener('dragleave', function(e) { if (e.target === form) dropTarget.classList.remove('drag-over'); });
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);
});
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
// matters for signing — encrypting alone never touches it.
(function() {
const signCheckbox = document.getElementById('smime_sign');
const identityRow = document.getElementById('smime_identity_row');
if (!identityRow) return;
function update() { identityRow.style.display = signCheckbox.checked ? '' : 'none'; }
signCheckbox.addEventListener('change', update);
update();
})();
// The recipient-key picker only matters for encrypting.
(function() {
const encryptCheckbox = document.getElementById('pgp_encrypt');
const recipientRow = document.getElementById('pgp_recipient_row');
function update() { recipientRow.style.display = encryptCheckbox.checked ? '' : 'none'; }
encryptCheckbox.addEventListener('change', update);
update();
})();
// Outlook-style To/Cc/Bcc — each address becomes its own removable "bubble"
// instead of one plain comma-separated text field. The real submitted value
// (parseComposeAddrs/mail.ParseAddressList server-side, unchanged) still
// travels as a comma-separated string, just in a hidden input the chip list
// is kept in sync with, rather than being what the user directly edits.
function initChipField(container) {
const hidden = container.querySelector('input[type="hidden"]');
const tagsEl = container.querySelector('.chip-input-tags');
const entry = container.querySelector('.chip-input-entry');
let chips = [];
function sync() { hidden.value = chips.join(', '); }
function render() {
tagsEl.innerHTML = '';
chips.forEach(function(value, i) {
const chip = document.createElement('span');
chip.className = 'chip-input-tag';
const text = document.createElement('span');
text.textContent = value;
chip.appendChild(text);
const remove = document.createElement('span');
remove.className = 'chip-remove';
remove.innerHTML = '&times;';
remove.addEventListener('click', function() {
chips.splice(i, 1);
render();
sync();
entry.focus();
});
chip.appendChild(remove);
tagsEl.appendChild(chip);
});
}
// A pasted or typed blob can itself hold several addresses separated by
// comma OR semicolon (Outlook's own separator, which this accepts too —
// see tests/todo.md) — each becomes its own chip rather than one chip
// holding the whole blob.
function addChip(raw) {
raw.split(/[,;]/).map(function(s) { return s.trim(); }).filter(Boolean).forEach(function(v) {
if (!chips.includes(v)) chips.push(v);
});
render();
sync();
}
function commitEntry() {
if (entry.value.trim()) { addChip(entry.value); entry.value = ''; }
}
entry.addEventListener('keydown', function(e) {
if (e.key === ',' || e.key === ';' || e.key === 'Enter') {
e.preventDefault();
commitEntry();
} else if (e.key === ' ') {
// Only commit on space if what's typed so far already looks like a
// complete address ("name@domain.tld" or "...<name@domain.tld>")
// — a display name typed as separate words ("John Smith") must not
// get chopped into two chips before reaching the @ part.
if (/\S+@\S+\.\S+>?$/.test(entry.value.trim())) {
e.preventDefault();
commitEntry();
}
} else if (e.key === 'Backspace' && entry.value === '' && chips.length > 0) {
chips.pop();
render();
sync();
}
});
entry.addEventListener('blur', commitEntry);
entry.addEventListener('paste', function(e) {
const text = (e.clipboardData || window.clipboardData).getData('text');
if (text && /[,;]/.test(text)) {
e.preventDefault();
addChip(text);
}
});
container.addEventListener('click', function(e) {
if (!e.target.closest('.chip-input-tag')) entry.focus();
});
// Seed from the hidden input's server-rendered value — same
// comma-separated format the backend already produces/expects (a reply's
// prefilled To:, a reply-all's Cc:, a reloaded draft's saved recipients).
if (hidden.value.trim()) {
hidden.value.split(',').map(function(s) { return s.trim(); }).filter(Boolean).forEach(function(v) { chips.push(v); });
render();
}
return { commitEntry: commitEntry };
}
const chipFields = Array.from(document.querySelectorAll('.chip-input')).map(initChipField);
// Recipient autocomplete: suggests addresses this mailbox has exchanged mail
// with, matching 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('.chip-input-entry').forEach(function(input) {
input.addEventListener('input', function() {
clearTimeout(debounceTimer);
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 = '';
(suggestions || []).forEach(function(addr) {
const opt = document.createElement('option');
opt.value = addr;
datalist.appendChild(opt);
});
})
.catch(function() {});
}, 200);
});
});
})();
// Draft autosave: periodically saves in the background if anything's
// changed since the last autosave, reusing the existing Save Draft endpoint
// exactly as a manual click would submit it — no backend change needed. The
// browser follows the redirect to /webmail/mail/compose?draft=ID&folder=Drafts,
// and that URL's draft param becomes the new draft_id so the next autosave (or
// manual Send) updates the same draft instead of creating a new one.
// Deliberately text-only: attachments aren't re-uploaded on every tick (a
// real Save or Send still includes them) to avoid repeatedly re-sending
// large file data in the background.
(function() {
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,
cc: document.querySelector('[name="cc"]').value,
bcc: document.querySelector('[name="bcc"]').value,
subject: document.querySelector('[name="subject"]').value,
body: quill.root.innerHTML,
});
}
// 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 formData = buildPayload();
if (!formData) return;
try {
const resp = await fetch('/webmail/mail/save-draft', { method: 'POST', body: formData });
if (resp.ok) {
rememberDraftID(new URL(resp.url).searchParams.get('draft'));
lastSaved = current;
if (status) {
status.textContent = 'Saved ' + new Date().toLocaleTimeString();
status.style.display = '';
}
}
} catch (e) { /* offline or a transient error — retried next interval */ }
}
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 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;
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;
}
});
</script>
</body>
</html>
{{end}}