780 lines
45 KiB
HTML
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 = '×';
|
|
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}}
|