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

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}}