389 lines
20 KiB
HTML
389 lines
20 KiB
HTML
{{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}}
|