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

389 lines
20 KiB
HTML
Raw Normal View History

2026-08-15 21:49:25 +01:00
{{define "webmail_settings_style"}}
<style>
.settings-card { width: 96vw; max-width: 1600px; height: 92vh; margin: 4vh auto; background-color: #2d2d2d; border: 1px solid #404040; border-radius: 12px; box-shadow: 0 1rem 3rem rgba(0,0,0,.5); overflow: hidden; display: flex; flex-direction: column; }
.settings-card-header { flex: 0 0 auto; padding: 1rem 1.25rem; border-bottom: 1px solid #404040; display: flex; align-items: center; justify-content: space-between; }
.settings-card-body { flex: 1 1 auto; display: flex; align-items: stretch; min-height: 0; }
.settings-nav { width: 210px; flex: 0 0 210px; border-right: 1px solid #404040; padding: .75rem; overflow-y: auto; }
.settings-nav .nav-link { color: #c8c8c8; border-radius: 6px; padding: .5rem .75rem; margin-bottom: .15rem; font-size: .9rem; }
.settings-nav .nav-link:hover { background-color: #383838; color: #fff; }
.settings-nav .nav-link.active { background-color: #0d6efd; color: #fff; }
.settings-body { flex: 1 1 auto; padding: 1.25rem 1.5rem; overflow-y: auto; min-width: 0; }
.settings-body h5, .settings-body h6 { font-size: .95rem; }
.settings-body .card { background-color: #262626; border: 1px solid #404040; }
.settings-body .card-header { padding: .5rem .9rem; }
.settings-body .card-body { padding: .75rem .9rem; }
.settings-body .mb-4 { margin-bottom: .9rem !important; }
.settings-body .mb-3 { margin-bottom: .6rem !important; }
.sig-preview { background-color: #fff; color: #000; border-radius: 6px; padding: .75rem; max-height: 140px; overflow: hidden; }
#sigEditor { height: 160px; background-color: #fff; color: #000; }
.ql-toolbar.ql-snow { background-color: #333; border-color: #404040; border-top-left-radius: .375rem; border-top-right-radius: .375rem; }
.ql-container.ql-snow { border-color: #404040; border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; }
.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-options { background-color: #2d2d2d; border-color: #404040; }
.ql-snow .ql-picker-item { color: #c8c8c8; }
</style>
{{end}}
{{define "webmail_settings_nav"}}
<div class="settings-nav">
<div class="nav flex-column nav-pills" id="settingsNav">
<a class="nav-link{{if eq .active_section "account"}} active{{end}}" href="/webmail/account" data-settings-nav><i class="bi bi-gear me-2"></i>Account</a>
<a class="nav-link{{if eq .active_section "rules"}} active{{end}}" href="/webmail/rules" data-settings-nav><i class="bi bi-funnel me-2"></i>Rules</a>
<a class="nav-link{{if eq .active_section "signatures"}} active{{end}}" href="/webmail/signatures" data-settings-nav><i class="bi bi-pen me-2"></i>Signatures</a>
<a class="nav-link{{if eq .active_section "contacts"}} active{{end}}" href="/webmail/contacts" data-settings-nav><i class="bi bi-person-lines-fill me-2"></i>Contacts</a>
<a class="nav-link{{if eq .active_section "blocklist"}} active{{end}}" href="/webmail/blocklist" data-settings-nav><i class="bi bi-slash-circle me-2"></i>Blocklist</a>
<a class="nav-link{{if eq .active_section "certs"}} active{{end}}" href="/webmail/certs" data-settings-nav><i class="bi bi-shield-lock me-2"></i>Certificates</a>
</div>
</div>
{{end}}
{{define "webmail_settings_script"}}
<script>
function closeSettings() { window.location = '/webmail/mail/INBOX'; }
// --- Section switching -------------------------------------------------
// Each nav link is a real <a href> (direct links / reload / no-JS all still
// work — the destination is a normal server-rendered page), but a click
// instead fetches that page and swaps in just its #settingsPanel (nav+body
// together, so the destination page's own active-state highlighting comes
// along for free) rather than navigating away. Delegated on
// #settingsPanelRoot so it keeps working after the panel itself gets
// replaced.
document.addEventListener('DOMContentLoaded', function() {
var root = document.getElementById('settingsPanelRoot');
if (!root) return;
root.addEventListener('click', function(e) {
var link = e.target.closest('[data-settings-nav]');
if (!link) return;
e.preventDefault();
if (link.classList.contains('active')) return;
fetch(link.href)
.then(function(resp) { return resp.text(); })
.then(function(html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var panel = doc.getElementById('settingsPanel');
if (!panel) { window.location = link.href; return; }
root.innerHTML = panel.outerHTML;
window.history.pushState(null, '', link.href);
window.__applyCsrfToForms && window.__applyCsrfToForms(root);
initSettingsSection();
})
.catch(function() { window.location = link.href; });
});
window.addEventListener('popstate', function() { window.location.reload(); });
});
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeSettings(); });
// --- Toasts + confirm dialogs (delegated — survive section swaps) ------
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
function showConfirmation(message) {
return new Promise((resolve) => {
var modal = document.getElementById('confirmationModal');
document.getElementById('confirmationModalBody').textContent = message;
var confirmButton = document.getElementById('confirmationModalConfirm');
var handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
var handleCancel = () => { resolve(false); cleanup(); };
var cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
modal.removeEventListener('hidden.bs.modal', handleCancel);
};
confirmButton.addEventListener('click', handleConfirm);
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
new bootstrap.Modal(modal).show();
});
}
document.addEventListener('click', function(e) {
var button = e.target.closest('[data-confirm]');
if (!button) return;
e.preventDefault();
showConfirmation(button.getAttribute('data-confirm')).then(function(ok) {
if (ok) { var form = button.closest('form'); if (form) form.submit(); }
});
});
// --- Section-specific init, re-run after every swap ---------------------
function initSettingsSection() {
initRulesSection();
initSignaturesSection();
initAccountSection();
initContactsSection();
}
// Fixed-choice condition fields don't take free text — has_attachment is a
// yes/no question, recipient_type mirrors Outlook's "I'm on the Cc line" (this
// delivery's own to/cc/bcc-ness, computed server-side per recipient — see
// smtpserver's deliverLocally). Both always match via "equals".
var RULE_FIXED_FIELD_OPTIONS = {
has_attachment: [['yes', 'Yes'], ['no', 'No']],
recipient_type: [['to', 'To'], ['cc', 'Cc'], ['bcc', 'Bcc']],
};
// A minimal tag/chip input: type a value, press Enter or comma to commit it as a
// chip. Several chips in one condition mean "matches any of these" (an OR within
// that single condition) — see mailstore.matchCondition, which reads the "\n"-
// joined hidden value this maintains.
function createChipInput(container, hiddenInput, initialValues) {
container.innerHTML = '';
var entry = document.createElement('input');
entry.type = 'text';
entry.style.cssText = 'border:0; outline:none; background:transparent; flex:1 1 80px; min-width:80px; color:inherit;';
entry.placeholder = 'Type a value, press Enter';
function sync() {
var chips = Array.prototype.slice.call(container.querySelectorAll('.chip-item')).map(function(c) { return c.dataset.value; });
hiddenInput.value = chips.join('\n');
}
function addChip(value) {
value = value.trim();
if (!value) return;
var chip = document.createElement('span');
chip.className = 'badge text-bg-secondary d-inline-flex align-items-center gap-1 chip-item';
chip.dataset.value = value;
var label = document.createElement('span');
label.textContent = value;
var x = document.createElement('i');
x.className = 'bi bi-x';
x.addEventListener('click', function() { chip.remove(); sync(); });
chip.appendChild(label);
chip.appendChild(x);
container.insertBefore(chip, entry);
sync();
}
entry.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addChip(entry.value);
entry.value = '';
} else if (e.key === 'Backspace' && entry.value === '') {
var last = container.querySelector('.chip-item:last-of-type');
if (last) { last.remove(); sync(); }
}
});
entry.addEventListener('blur', function() {
if (entry.value.trim()) { addChip(entry.value); entry.value = ''; }
});
container.appendChild(entry);
(initialValues || []).forEach(addChip);
}
// Swaps a condition row's value UI between the chip input (free-text fields) and
// a fixed select (has_attachment/recipient_type), keeping the row's single
// .cond-value-hidden input authoritative either way — the server only ever sees
// one condition_value per row regardless of which widget produced it.
function updateConditionRowUI(row, initialValue) {
var field = row.querySelector('.cond-field').value;
var opWrap = row.querySelector('.cond-op-wrap');
var opSelect = row.querySelector('.cond-op');
var chipWrap = row.querySelector('.chip-input');
var fixedSelect = row.querySelector('.cond-fixed-select');
var hidden = row.querySelector('.cond-value-hidden');
var fixedOptions = RULE_FIXED_FIELD_OPTIONS[field];
if (fixedOptions) {
opSelect.value = 'equals';
opWrap.classList.add('d-none');
chipWrap.classList.add('d-none');
fixedSelect.classList.remove('d-none');
fixedSelect.innerHTML = '';
fixedOptions.forEach(function(pair) {
var o = document.createElement('option');
o.value = pair[0]; o.textContent = pair[1];
fixedSelect.appendChild(o);
});
if (initialValue) fixedSelect.value = initialValue;
hidden.value = fixedSelect.value;
fixedSelect.onchange = function() { hidden.value = fixedSelect.value; };
} else {
opWrap.classList.remove('d-none');
chipWrap.classList.remove('d-none');
fixedSelect.classList.add('d-none');
createChipInput(chipWrap, hidden, initialValue ? initialValue.split('\n') : []);
}
}
function initRulesSection() {
var addBtn = document.getElementById('add_condition');
if (!addBtn || addBtn.dataset.wired) return;
addBtn.dataset.wired = '1';
var container = document.getElementById('conditions_container');
function addConditionRow(condition) {
var tpl = document.getElementById('condition_row_template');
var clone = document.importNode(tpl.content, true);
var row = clone.querySelector('.condition-row');
row.querySelector('.remove-condition').addEventListener('click', function() {
if (container.children.length > 1) row.remove();
});
container.appendChild(clone);
if (condition) {
row.querySelector('.cond-field').value = condition.field;
row.querySelector('.cond-op').value = condition.op;
}
updateConditionRowUI(row, condition ? condition.value : null);
row.querySelector('.cond-field').addEventListener('change', function() { updateConditionRowUI(row, null); });
}
addBtn.addEventListener('click', function() { addConditionRow(null); });
var seedData = [];
var seedEl = document.getElementById('editingConditionsData');
if (seedEl) {
try { seedData = JSON.parse(seedEl.textContent) || []; } catch (e) { seedData = []; }
}
if (seedData.length) {
seedData.forEach(addConditionRow);
} else {
addConditionRow(null);
}
var actionSelect = document.getElementById('rule_action');
var folderInput = document.getElementById('rule_action_value_folder');
var forwardInput = document.getElementById('rule_action_value_forward');
function updateActionFields() {
var action = actionSelect.value;
document.querySelectorAll('.action-extra-fields > div').forEach(function(el) { el.style.display = 'none'; });
var shown = document.querySelector('.action-field-' + action);
if (shown) shown.style.display = 'block';
folderInput.disabled = action !== 'move_to_folder';
forwardInput.disabled = action !== 'forward';
}
if (actionSelect) {
actionSelect.addEventListener('change', updateActionFields);
updateActionFields();
}
}
function initSignaturesSection() {
var editorEl = document.getElementById('sigEditor');
var modalEl = document.getElementById('sigModal');
if (!editorEl || !modalEl) return;
var sigQuill = new Quill('#sigEditor', {
theme: 'snow',
modules: { toolbar: [['bold', 'italic', 'underline'], [{ color: [] }], ['link', 'image'], ['clean']] },
});
document.getElementById('sigForm').addEventListener('submit', function() {
document.querySelector('[name="content_html"]').value = sigQuill.root.innerHTML;
});
function resetAliasDefaultFields() {
var emailSel = modalEl.querySelector('[name="default_for_email"]');
var newChk = document.getElementById('sigDefaultNewAlias');
var replyChk = document.getElementById('sigDefaultReplyAlias');
if (emailSel) emailSel.value = '';
if (newChk) newChk.checked = false;
if (replyChk) replyChk.checked = false;
}
var addBtn = document.getElementById('sigAddBtn');
if (addBtn) {
addBtn.addEventListener('click', function() {
document.getElementById('sigModalTitle').textContent = 'New signature';
document.getElementById('sigFormId').value = '';
document.getElementById('sigFormName').value = '';
sigQuill.setText('');
resetAliasDefaultFields();
new bootstrap.Modal(modalEl).show();
});
}
document.querySelectorAll('.sig-edit-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var card = btn.closest('.sig-card');
document.getElementById('sigModalTitle').textContent = 'Edit signature';
document.getElementById('sigFormId').value = card.dataset.sigId;
document.getElementById('sigFormName').value = card.dataset.sigName;
var content = card.querySelector('.sig-content-data');
sigQuill.root.innerHTML = content ? content.innerHTML : '';
resetAliasDefaultFields();
new bootstrap.Modal(modalEl).show();
});
});
}
function initContactsSection() {
var modalEl = document.getElementById('contactModal');
if (!modalEl) return;
var addBtn = document.getElementById('contactAddBtn');
if (addBtn) {
addBtn.addEventListener('click', function() {
document.getElementById('contactModalTitle').textContent = 'New contact';
document.getElementById('contactFormId').value = '';
document.getElementById('contactFormName').value = '';
document.getElementById('contactFormEmail').value = '';
document.getElementById('contactFormPhone').value = '';
new bootstrap.Modal(modalEl).show();
});
}
document.querySelectorAll('.contact-edit-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = btn.closest('.contact-row');
document.getElementById('contactModalTitle').textContent = 'Edit contact';
document.getElementById('contactFormId').value = row.dataset.contactId;
document.getElementById('contactFormName').value = row.dataset.contactName;
document.getElementById('contactFormEmail').value = row.dataset.contactEmail;
document.getElementById('contactFormPhone').value = row.dataset.contactPhone;
new bootstrap.Modal(modalEl).show();
});
});
}
function b64urlToBuf(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
var bin = atob(s);
var buf = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
function bufToB64url(buf) {
var bytes = new Uint8Array(buf);
var bin = '';
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function initAccountSection() {
var passkeyAddBtn = document.getElementById('passkey-add-btn');
if (!passkeyAddBtn) return;
passkeyAddBtn.addEventListener('click', async function() {
var errEl = document.getElementById('passkey-error');
errEl.classList.add('d-none');
try {
var beginResp = await fetch('/webmail/account/passkey/begin', { method: 'POST' });
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey registration');
var options = await beginResp.json();
var publicKey = options.publicKey;
publicKey.challenge = b64urlToBuf(publicKey.challenge);
publicKey.user.id = b64urlToBuf(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
}
var cred = await navigator.credentials.create({ publicKey });
var body = {
id: cred.id,
rawId: bufToB64url(cred.rawId),
type: cred.type,
response: {
attestationObject: bufToB64url(cred.response.attestationObject),
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
},
};
var finishResp = await fetch('/webmail/account/passkey/finish', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Could not save passkey');
window.location.reload();
} catch (e) {
errEl.textContent = e.message || 'Passkey registration failed';
errEl.classList.remove('d-none');
}
});
}
document.addEventListener('DOMContentLoaded', initSettingsSection);
</script>
{{end}}