409 lines
23 KiB
HTML
409 lines
23 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 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; }
|
|
|
|
.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" data-bs-autohide="false">
|
|
<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}}">
|
|
|
|
<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;">
|
|
<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}}
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<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'],
|
|
],
|
|
},
|
|
});
|
|
(function() {
|
|
const seed = document.getElementById('body_html_seed');
|
|
if (seed && seed.innerHTML.trim()) {
|
|
quill.root.innerHTML = seed.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();
|
|
}
|
|
})();
|
|
|
|
// 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';
|
|
field.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();
|
|
|
|
function render() {
|
|
input.files = staged.files;
|
|
list.innerHTML = '';
|
|
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();
|
|
}
|
|
|
|
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');
|
|
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
|
|
});
|
|
|
|
window.__composeAttachmentCount = function() { return staged.files.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();
|
|
})();
|
|
|
|
// 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).
|
|
(function() {
|
|
const datalist = document.getElementById('recipientSuggestions');
|
|
let debounceTimer = null;
|
|
document.querySelectorAll('.recipient-input').forEach(function(input) {
|
|
input.addEventListener('input', function() {
|
|
clearTimeout(debounceTimer);
|
|
const value = input.value;
|
|
const splitAt = value.lastIndexOf(',') + 1;
|
|
const fragment = value.slice(splitAt).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;
|
|
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');
|
|
let lastSaved = null;
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
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');
|
|
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;
|
|
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);
|
|
})();
|
|
|
|
// 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 + '"]');
|
|
if (el) {
|
|
el.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') { e.preventDefault(); }
|
|
});
|
|
}
|
|
});
|
|
|
|
document.getElementById('composeForm').addEventListener('submit', function(e) {
|
|
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.
|
|
const isDraftSave = e.submitter && e.submitter.getAttribute('formaction') === '/webmail/mail/save-draft';
|
|
if (isDraftSave) return;
|
|
const subject = document.querySelector('[name="subject"]').value.trim();
|
|
const body = quill.getText().trim();
|
|
const hasAttachments = window.__composeAttachmentCount && window.__composeAttachmentCount() > 0;
|
|
if (!subject) {
|
|
e.preventDefault();
|
|
alert('Please add a subject before sending.');
|
|
return;
|
|
}
|
|
if (!body && !hasAttachments) {
|
|
e.preventDefault();
|
|
alert('Please write a message or add an attachment before sending.');
|
|
return;
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|