// ── Helpers ──────────────────────────────────────────────── function $id(id) { return document.getElementById(id); } function $$(sel) { return Array.from(document.querySelectorAll(sel)); } function esc(str) { return String(str ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } async function apiFetch(url, data, method) { let opts; if (method === 'DELETE') opts = { method: 'DELETE' }; else if (data != null) opts = { method: method ?? 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }; else opts = { method: method ?? 'GET' }; const res = await fetch(url, opts); if (!res.ok) throw new Error(`HTTP ${res.status}`); const ct = res.headers.get('content-type') ?? ''; if (res.status === 204 || !ct.includes('application/json')) return null; return res.json(); } // ── Init ─────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initTheme(); if (!$id('tab-smtp')) return; initTabs(); refreshAll(); setInterval(refreshAll, 5000); initDomains(); }); // ── Theme ────────────────────────────────────────────────── function initTheme() { const dark = localStorage.getItem('theme') === 'dark'; applyTheme(dark); const btn = $id('theme-toggle'); if (btn) btn.addEventListener('click', () => { const isDark = document.body.classList.toggle('dark'); localStorage.setItem('theme', isDark ? 'dark' : 'light'); btn.textContent = isDark ? '☾' : '☀'; }); } function applyTheme(dark) { document.body.classList.toggle('dark', dark); const btn = $id('theme-toggle'); if (btn) btn.textContent = dark ? '☾' : '☀'; } // ── Tabs ─────────────────────────────────────────────────── function initTabs() { $$('.tab-btn').forEach(btn => btn.addEventListener('click', function () { $$('.tab-btn').forEach(b => b.classList.remove('active')); $$('.tab-panel').forEach(p => p.classList.remove('active')); this.classList.add('active'); $id(this.dataset.panel).classList.add('active'); })); $$('.inline-settings__toggle').forEach(btn => btn.addEventListener('click', function () { const body = $id(this.dataset.target); const wrap = this.closest('.inline-settings'); const opening = !wrap.classList.contains('open'); wrap.classList.toggle('open'); if (opening && body && (body.innerHTML.trim() === '' || !_cfg)) loadInlineSettings(this.dataset.target); })); } // ── Status refresh ───────────────────────────────────────── async function refreshAll() { await Promise.all([refreshServices(), refreshSmtp(), refreshImap(), refreshPop3()]); pulse(); } async function refreshServices() { try { const [d, cfg] = await Promise.all([apiFetch('/api/status/all'), fetchConfig()]); applyServiceDot('tab-smtp', d.smtp, cfg.smtp?.enabled ?? true); applyServiceDot('tab-imap', d.imap, cfg.imap?.enabled ?? true); applyServiceDot('tab-pop3', d.pop3, cfg.pop3?.enabled ?? true); const running = [d.smtp, d.imap, d.pop3].filter(Boolean).length; $id('header-status').textContent = `${running}/3 running`; } catch { $id('header-status').textContent = 'unreachable'; } } function applyServiceDot(tabId, running, enabled) { const el = $id(tabId); if (!el) return; el.classList.remove('svc-up', 'svc-down', 'svc-disabled'); if (!enabled) el.classList.add('svc-disabled'); else if (running) el.classList.add('svc-up'); else el.classList.add('svc-down'); } async function refreshSmtp() { try { const d = await apiFetch('/api/status/smtp'); set('smtp-total-conn', d.connections.total); set('smtp-active', d.connections.active); set('smtp-recv', d.messages.received); set('smtp-last-msg', d.messages.lastReceivedAt ? timeSince(d.messages.lastReceivedAt) : 'never'); set('smtp-relayed', d.messages.relayed); set('smtp-bytes', formatBytes(d.messages.bytesReceived)); set('smtp-uptime', formatUptime(d.uptimeSeconds)); set('smtp-started', d.startTime ? 'since ' + fmtDate(d.startTime) : ''); set('smtp-auth-ok', d.auth.success); set('smtp-auth-fail', d.auth.failed); const total = Math.max(d.rejections.total, 1); set('smtp-rej-spf', d.rejections.spf); set('smtp-rej-dkim', d.rejections.dkim); set('smtp-rej-dmarc', d.rejections.dmarc); setBar('bar-spf', d.rejections.spf, total); setBar('bar-dkim', d.rejections.dkim, total); setBar('bar-dmarc', d.rejections.dmarc, total); renderUserTable('smtp-tbl-senders', d.topSenders, 'domain'); renderUserTable('smtp-tbl-recipients', d.topRecipients, 'domain'); } catch (e) { console.error('SMTP refresh failed', e); } } async function refreshImap() { try { const d = await apiFetch('/api/status/imap'); set('imap-total-conn', d.connections.total); set('imap-active', d.connections.active); set('imap-fetched', d.messages.fetched); set('imap-last-activity', d.messages.lastActivityAt ? timeSince(d.messages.lastActivityAt) : 'never'); set('imap-bytes', formatBytes(d.messages.bytesSent)); set('imap-commands', d.commands); set('imap-folder-selects', d.folderSelects); set('imap-uptime', formatUptime(d.uptimeSeconds)); set('imap-started', d.startTime ? 'since ' + fmtDate(d.startTime) : ''); set('imap-auth-ok', d.auth.success); set('imap-auth-fail', d.auth.failed); renderUserTable('imap-tbl-users', d.topUsers, 'user'); } catch (e) { console.error('IMAP refresh failed', e); } } async function refreshPop3() { try { const d = await apiFetch('/api/status/pop3'); set('pop3-total-conn', d.connections.total); set('pop3-active', d.connections.active); set('pop3-retrieved', d.messages.retrieved); set('pop3-last-activity', d.messages.lastActivityAt ? timeSince(d.messages.lastActivityAt) : 'never'); set('pop3-deleted', d.messages.deleted); set('pop3-bytes', formatBytes(d.messages.bytesSent)); set('pop3-uptime', formatUptime(d.uptimeSeconds)); set('pop3-started', d.startTime ? 'since ' + fmtDate(d.startTime) : ''); set('pop3-auth-ok', d.auth.success); set('pop3-auth-fail', d.auth.failed); renderUserTable('pop3-tbl-users', d.topUsers, 'user'); } catch (e) { console.error('POP3 refresh failed', e); } } // ── Settings load ────────────────────────────────────────── let _cfg = null; async function fetchConfig() { if (!_cfg) _cfg = await apiFetch('/api/config'); return _cfg; } function wireSettingsContainer(el) { el.addEventListener('change', function (e) { const t = e.target; if (t.id === 'smtp-enabled') { saveServiceEnabled('smtp', t.checked); return; } if (t.id === 'pop3-enabled') { saveServiceEnabled('pop3', t.checked); return; } if (t.id === 'imap-enabled') { saveServiceEnabled('imap', t.checked); return; } if (t.classList.contains('port-enabled')) { const row = t.closest('tr'); if (row) row.classList.toggle('port-row-disabled', !t.checked); } }); el.addEventListener('click', function (e) { if (e.target.classList.contains('cfg-subtab-btn')) { const nav = e.target.closest('.cfg-subtab-nav'); const panels = nav?.nextElementSibling; nav?.querySelectorAll('.cfg-subtab-btn').forEach(b => b.classList.remove('active')); e.target.classList.add('active'); panels?.querySelectorAll('.cfg-subtab-panel').forEach(p => p.hidden = true); const panel = panels?.querySelector(`[data-cfg-panel="${e.target.dataset.cfgTab}"]`); if (panel) panel.hidden = false; return; } if (e.target.classList.contains('btn-add-port')) { const tbody = $id(e.target.dataset.prefix + '-ports-tbody'); if (tbody) tbody.insertAdjacentHTML('beforeend', portRow({})); } if (e.target.classList.contains('btn-remove-port')) { e.target.closest('tr').remove(); } if (e.target.classList.contains('btn-save')) { const key = e.target.dataset.save; const msg = e.target.nextElementSibling; e.target.disabled = true; saveConfig(key) .then(() => { _cfg = null; showMsg(msg, true, 'Saved'); }) .catch(err => showMsg(msg, false, err.message || 'Error')) .finally(() => { e.target.disabled = false; }); } }); } async function loadInlineSettings(bodyId) { const el = $id(bodyId); if (!el) return; el.innerHTML = '

Loading…

'; try { const cfg = await fetchConfig(); if (bodyId === 'cfg-smtp-body') el.innerHTML = buildCfgSmtp(cfg.smtp, cfg.dmarc); else if (bodyId === 'cfg-imap-body') el.innerHTML = buildCfgImap(cfg.imap); else if (bodyId === 'cfg-pop3-body') el.innerHTML = buildCfgPop3(cfg.pop3); else if (bodyId === 'cfg-general-body') el.innerHTML = buildCfgGeneral(cfg.general); wireSettingsContainer(el); } catch (e) { el.innerHTML = `

${esc(e.message)}

`; } } async function saveConfig(key) { switch (key) { case 'smtp-general': return apiFetch('/api/config/smtp/general', collectSmtpGeneral()); case 'smtp-connections': return apiFetch('/api/config/smtp/connections', collectSmtpConnections()); case 'smtp-delivery': return apiFetch('/api/config/smtp/delivery', collectSmtpDelivery()); case 'smtp-relay': return apiFetch('/api/config/smtp/relay', collectSmtpRelay()); case 'smtp-security': return apiFetch('/api/config/smtp/security', collectSmtpSecurity()); case 'smtp-limits': return apiFetch('/api/config/smtp/limits', collectSmtpLimits()); case 'pop3-general': return apiFetch('/api/config/pop3/general', collectPop3General()); case 'pop3-connections': return apiFetch('/api/config/pop3/connections', collectPop3Connections()); case 'imap-general': return apiFetch('/api/config/imap/general', collectImapGeneral()); case 'imap-connections': return apiFetch('/api/config/imap/connections', collectImapConnections()); case 'imap-folders': return apiFetch('/api/config/imap/folders', collectImapFolders()); case 'imap-advanced': return apiFetch('/api/config/imap/advanced', collectImapAdvanced()); case 'dmarc': return apiFetch('/api/config/dmarc', collectDmarc()); case 'general': return apiFetch('/api/config/general', collectGeneral()); } } async function saveServiceEnabled(svc, enabled) { _cfg = null; try { await saveConfig(`${svc}-general`); const el = $id(`tab-${svc}`); if (el) { el.classList.remove('svc-up', 'svc-down', 'svc-disabled'); el.classList.add(enabled ? 'svc-up' : 'svc-disabled'); } setTimeout(refreshServices, 800); } catch (e) { console.error(`Failed to save ${svc} enabled:`, e); } } // ── Settings subtab builder ──────────────────────────────── function buildCfgSubtabs(tabs) { const nav = tabs.map((t, i) => `` ).join(''); const panels = tabs.map((t, i) => `
${t.html}
` ).join(''); return `
${nav}
${panels}
`; } // ── SMTP panel ───────────────────────────────────────────── function buildCfgSmtp(s, dmarc) { const secOpts = ['None', 'StartTls', 'Tls'].map(v => `` ).join(''); const tabs = [ { key: 'general', label: 'General', html: `
${toggle('smtp-enabled', 'Enable SMTP service', 'Start the SMTP server on startup', s.enabled ?? true)}
${field('smtp-localhost', 'Server hostname (greeting)', s.localHostName)}
` }, { key: 'connections', label: 'Connections', html: `
${field('smtp-maxconn', 'Max connections (0 = unlimited)', s.maxConnections, 'number')} ${field('smtp-maxmsg', 'Max message size (KB)', s.maxMessageSizeKb, 'number')} ${field('smtp-certpath', 'Certificate path', s.certificatePath)} ${field('smtp-certpass', 'Certificate password', s.certificatePassword, 'password')}

⚠ restart required after port changes

${buildPorts('smtp', s.ports)} ` }, { key: 'delivery', label: 'Delivery', html: `
${field('smtp-retries', 'Number of retries', s.retryCount, 'number')} ${field('smtp-retryinterval', 'Minutes between every retry', s.retryIntervalMinutes, 'number')}
` }, { key: 'relayer', label: 'Relayer', html: `
${toggle('smtp-relay', 'Enable relay', 'Forward outgoing mail to an external SMTP server', s.relayEnabled)} ${toggle('smtp-deliveredto', 'Add Delivered-To header', 'Insert Delivered-To header in relayed messages', s.addDeliveredToHeader)}
${field('smtp-relayhost', 'Remote host name', s.relayHost)} ${field('smtp-relayport', 'Remote TCP/IP port', s.relayPort, 'number')} ${field('smtp-relayqueue', 'Relay queue path', s.relayQueuePath)}
${toggle('smtp-relayauth', 'Server requires authentication', 'Authenticate when relaying', s.relayRequiresAuth)}
${field('smtp-relayuser', 'User name', s.relayUsername)} ${field('smtp-relaypass', 'Password', s.relayPassword, 'password')}
` }, { key: 'security', label: 'Security', html: `
RFC Compliance
${toggle('smtp-plainauth', 'Allow plain text authentication', 'Allow AUTH PLAIN and AUTH LOGIN', s.allowPlainTextAuth)} ${toggle('smtp-emptysender', 'Allow empty sender address', 'Accept MAIL FROM:<>', s.allowEmptySender)} ${toggle('smtp-badlineends', 'Allow incorrectly formatted line endings', 'Accept bare CR or LF in message data', s.allowBadLineEndings)} ${toggle('smtp-discbadcmds', 'Disconnect after too many invalid commands', 'Close connection on repeated bad commands', s.disconnectOnTooManyInvalidCommands)}
${field('smtp-maxbadcmds', 'Maximum invalid commands', s.maxInvalidCommands, 'number')}
Auth / DKIM
${toggle('smtp-vrfy', 'Enable VRFY', 'Allow address verification', s.enableVrfy)} ${toggle('smtp-expn', 'Enable EXPN', 'Allow mailing list expansion', s.enableExpn)} ${toggle('smtp-dkim', 'Require DKIM', 'Reject mail without valid DKIM',s.requireDkim)}
` }, { key: 'limits', label: 'Limits', html: `
${field('smtp-maxrecipients', 'Max recipients per batch', s.maxRecipientsPerBatch, 'number')} ${field('smtp-ruleloop', 'Rule loop limit', s.ruleLoopLimit, 'number')} ${field('smtp-maxrechosts', 'Max recipient hosts', s.maxRecipientHosts, 'number')}
` }, { key: 'dmarc', label: 'DMARC', html: `
${toggle('dmarc-failopen', 'Fail open', 'Accept mail when DMARC lookup fails', dmarc.failOpen)} ${toggle('dmarc-require', 'Require DMARC', 'Reject mail that fails DMARC policy', dmarc.requireDmarc)}
` } ]; return buildCfgSubtabs(tabs); } // ── POP3 panel ───────────────────────────────────────────── function buildCfgPop3(s) { const tabs = [ { key: 'general', label: 'General', html: `
${toggle('pop3-enabled', 'Enable POP3 service', 'Start the POP3 server on startup', s.enabled ?? true)}
${field('pop3-welcome', 'Welcome message', s.welcomeMessage)}
` }, { key: 'connections', label: 'Connections', html: `
${field('pop3-maxconn', 'Max connections (0 = unlimited)', s.maxConnections, 'number')} ${field('pop3-certpath', 'Certificate path', s.certificatePath)} ${field('pop3-certpass', 'Certificate password', s.certificatePassword, 'password')}

⚠ restart required after port changes

${buildPorts('pop3', s.ports)} ` } ]; return buildCfgSubtabs(tabs); } // ── IMAP panel ───────────────────────────────────────────── function buildCfgImap(s) { const delimOpts = ['.', '/', '\\'].map(d => `` ).join(''); const tabs = [ { key: 'general', label: 'General', html: `
${toggle('imap-enabled', 'Enable IMAP service', 'Start the IMAP server on startup', s.enabled ?? true)}
${field('imap-welcome', 'Welcome message', s.welcomeMessage)}
` }, { key: 'connections', label: 'Connections', html: `
${field('imap-maxconn', 'Max connections (0 = unlimited)', s.maxConnections, 'number')} ${field('imap-certpath', 'Certificate path', s.certificatePath)} ${field('imap-certpass', 'Certificate password', s.certificatePassword, 'password')}

⚠ restart required after port changes

${buildPorts('imap', s.ports)} ` }, { key: 'folders', label: 'Folders', html: `
${field('imap-publicfolder', 'Public folder name', s.publicFolderName)}
` }, { key: 'advanced', label: 'Advanced', html: `
${toggle('imap-sort', 'IMAP Sort', 'Enable SORT extension', s.enableSort)} ${toggle('imap-quota', 'IMAP Quota', 'Enable QUOTA extension', s.enableQuota)} ${toggle('imap-idle', 'IMAP IDLE', 'Enable IDLE extension', s.enableIdle)} ${toggle('imap-acl', 'IMAP ACL', 'Enable ACL extension', s.enableAcl)}
` } ]; return buildCfgSubtabs(tabs); } // ── Ports editor ─────────────────────────────────────────── const SEC_OPTIONS = [ { value: 'None', label: 'None — plain' }, { value: 'StartTlsOptional', label: 'STARTTLS optional' }, { value: 'StartTls', label: 'STARTTLS required' }, { value: 'Tls', label: 'TLS (implicit)' } ]; function portRow(p) { const opts = SEC_OPTIONS.map(o => `` ).join(''); const enabled = p.enabled ?? true; return ` `; } function buildPorts(prefix, ports) { const rows = (ports || []).map(p => portRow(p)).join(''); return ` ${rows}
Host / IPPortSecurity
`; } // ── Collectors ───────────────────────────────────────────── // ── Per-section collectors ───────────────────────────────── function collectSmtpGeneral() { return { enabled: chk('smtp-enabled'), localHostName: val('smtp-localhost') }; } function collectSmtpConnections() { return { maxConnections: int('smtp-maxconn'), maxMessageSizeKb: int('smtp-maxmsg'), certificatePath: val('smtp-certpath'), certificatePassword: val('smtp-certpass'), ports: collectPorts('smtp') }; } function collectSmtpDelivery() { return { retryCount: int('smtp-retries'), retryIntervalMinutes: int('smtp-retryinterval') }; } function collectSmtpRelay() { return { relayEnabled: chk('smtp-relay'), addDeliveredToHeader: chk('smtp-deliveredto'), relayHost: val('smtp-relayhost'), relayPort: int('smtp-relayport'), relayQueuePath: val('smtp-relayqueue'), relayConnectionSecurity: val('smtp-relaysec'), relayRequiresAuth: chk('smtp-relayauth'), relayUsername: val('smtp-relayuser'), relayPassword: val('smtp-relaypass') }; } function collectSmtpSecurity() { return { allowPlainTextAuth: chk('smtp-plainauth'), allowEmptySender: chk('smtp-emptysender'), allowBadLineEndings: chk('smtp-badlineends'), disconnectOnTooManyInvalidCommands: chk('smtp-discbadcmds'), maxInvalidCommands: int('smtp-maxbadcmds'), enableVrfy: chk('smtp-vrfy'), enableExpn: chk('smtp-expn'), requireDkim: chk('smtp-dkim') }; } function collectSmtpLimits() { return { maxRecipientsPerBatch: int('smtp-maxrecipients'), ruleLoopLimit: int('smtp-ruleloop'), maxRecipientHosts: int('smtp-maxrechosts') }; } function collectPop3General() { return { enabled: chk('pop3-enabled'), welcomeMessage: val('pop3-welcome') }; } function collectPop3Connections() { return { maxConnections: int('pop3-maxconn'), certificatePath: val('pop3-certpath'), certificatePassword: val('pop3-certpass'), ports: collectPorts('pop3') }; } function collectImapGeneral() { return { enabled: chk('imap-enabled'), welcomeMessage: val('imap-welcome') }; } function collectImapConnections() { return { maxConnections: int('imap-maxconn'), certificatePath: val('imap-certpath'), certificatePassword: val('imap-certpass'), ports: collectPorts('imap') }; } function collectImapFolders() { return { publicFolderName: val('imap-publicfolder'), hierarchyDelimiter: val('imap-delim') }; } function collectImapAdvanced() { return { enableSort: chk('imap-sort'), enableQuota: chk('imap-quota'), enableIdle: chk('imap-idle'), enableAcl: chk('imap-acl') }; } function collectDmarc() { return { failOpen: chk('dmarc-failopen'), requireDmarc: chk('dmarc-require') }; } function collectPorts(prefix) { const tbody = $id(`${prefix}-ports-tbody`); if (!tbody) return []; return Array.from(tbody.querySelectorAll('tr')).map(tr => ({ host: tr.querySelector('.port-host')?.value ?? '', port: parseInt(tr.querySelector('.port-port')?.value) || 0, security: tr.querySelector('.port-sec')?.value ?? 'None', enabled: tr.querySelector('.port-enabled')?.checked ?? true })); } // ── Form helpers ─────────────────────────────────────────── function field(id, label, value, type = 'text') { return `
`; } function textarea(id, label, value) { return `
`; } function toggle(id, label, sub, checked) { return `
${esc(label)}
${esc(sub)}
`; } function showMsg(el, ok, text) { if (!el) return; el.textContent = text; el.className = 'save-msg ' + (ok ? 'show-ok' : 'show-err'); setTimeout(() => { el.className = 'save-msg'; }, 3000); } function val(id) { const el = $id(id); return el ? el.value : ''; } function int(id) { return parseInt(val(id)) || 0; } function chk(id) { const el = $id(id); return el ? el.checked : false; } function lines(id) { return val(id).split('\n').map(s => s.trim()).filter(Boolean); } // ── Shared helpers ───────────────────────────────────────── function set(id, value) { const el = $id(id); if (el) el.textContent = (value != null) ? value : '—'; } function setBar(id, n, total) { const el = $id(id); if (el) el.style.width = Math.min(100, Math.round((n / total) * 100)) + '%'; } function renderUserTable(id, rows, key) { const el = $id(id); if (!el) return; if (!rows || rows.length === 0) { el.innerHTML = '
No data yet
'; return; } let html = ''; for (const r of rows) html += ``; html += '
${esc(r[key])}${r.count}
'; el.innerHTML = html; } function pulse() { const dot = $id('refresh-dot'); if (!dot) return; dot.classList.remove('pulse'); void dot.offsetWidth; dot.classList.add('pulse'); $id('refresh-time').textContent = new Date().toLocaleTimeString(); } function formatBytes(b) { if (b == null) return '—'; if (b < 1024) return b + ' B'; if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; return (b / 1048576).toFixed(2) + ' MB'; } function formatUptime(s) { if (s == null) return '—'; const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m`; return `${m}m ${s % 60}s`; } function timeSince(iso) { const diff = Math.floor((Date.now() - new Date(iso)) / 1000); if (diff < 60) return diff + 's ago'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; return Math.floor(diff / 3600) + 'h ago'; } function fmtDate(iso) { return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); } // ── Domain manager ───────────────────────────────────────── let _domains = []; const _openSections = {}; // id → active sub-tab name const DOM_TABS = ['General', 'Users', 'User Aliases', 'Email Lists', 'Limits', 'DKIM', 'Domein-aliases']; function initDomains() { const addBtn = $id('dom-add-btn'); const addInput = $id('dom-new-name'); if (!addBtn) return; addBtn.addEventListener('click', async () => { const name = addInput.value.trim(); if (!name) return; try { await apiFetch('/api/domain', { name }); addInput.value = ''; await loadDomains(); showMsg($id('msg-domains'), true, 'Domain added'); } catch (e) { showMsg($id('msg-domains'), false, e.message || 'Error'); } }); addInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); }); // delegated events on #dom-list const list = $id('dom-list'); if (!list) return; list.addEventListener('click', domListClick); list.addEventListener('change', domListChange); list.addEventListener('keydown', domListKeydown); } function domListClick(e) { const t = e.target; const id = t.dataset.id; if (t.dataset.action === 'delete-domain') { deleteDomain(id); return; } if (t.dataset.action === 'toggle-expand') { toggleExpand(id ?? t.closest('.dom-card')?.id?.replace('dom-card-','')); return; } if (t.dataset.action === 'sub-tab') { setSubTab(id, t.dataset.tab); return; } if (t.dataset.action === 'save-general') { saveGeneral(id); return; } if (t.dataset.action === 'save-limits') { saveLimits(id); return; } if (t.dataset.action === 'save-dkim') { saveDkim(id); return; } if (t.dataset.action === 'add-alias') { apiAddAlias(id); return; } if (t.dataset.action === 'remove-alias') { apiRemoveAlias(id, t.dataset.value); return; } if (t.dataset.action === 'add-ualias') { apiAddUserAlias(id); return; } if (t.dataset.action === 'remove-ualias') { apiRemoveUserAlias(id, t.dataset.alias); return; } if (t.dataset.action === 'add-list') { apiAddList(id); return; } if (t.dataset.action === 'remove-list') { apiRemoveList(id, t.dataset.listid); return; } if (t.dataset.action === 'add-list-member') { apiAddListMember(id, t.dataset.listid); return; } if (t.dataset.action === 'remove-list-member') { apiRemoveListMember(id, t.dataset.listid, t.dataset.value); return; } if (t.dataset.action === 'add-user') { apiAddUser(id); return; } if (t.dataset.action === 'save-user') { apiSaveUser(id, t.dataset.userid); return; } if (t.dataset.action === 'remove-user') { apiRemoveUser(id, t.dataset.userid); return; } } function domListChange(e) { const t = e.target; if (t.dataset.action === 'toggle-enabled') { const d = _domains.find(x => x.id === t.dataset.id); if (!d) return; d.enabled = t.checked; const dot = $id(`dom-dot-${d.id}`); if (dot) { dot.classList.toggle('dom-status--on', d.enabled); dot.classList.toggle('dom-status--off', !d.enabled); } saveGeneral(d.id); } } function domListKeydown(e) { if (e.key !== 'Enter') return; const t = e.target; if (t.dataset.enter === 'add-alias') { apiAddAlias(t.dataset.id); return; } if (t.dataset.enter === 'add-user') { apiAddUser(t.dataset.id); return; } if (t.dataset.enter === 'add-ualias') { apiAddUserAlias(t.dataset.id); return; } if (t.dataset.enter === 'add-list') { apiAddList(t.dataset.id); return; } if (t.dataset.enter === 'add-lmember') { apiAddListMember(t.dataset.id, t.dataset.listid); return; } } // ── Load / render ────────────────────────────────────────── async function loadDomains() { _domains = await apiFetch('/api/domain'); renderDomains(); } function renderDomains() { const el = $id('dom-list'); if (!el) return; if (_domains.length === 0) { el.innerHTML = '

No domains configured yet.

'; return; } el.innerHTML = _domains.map(d => domainCard(d)).join(''); } function domainCard(d) { const open = !!_openSections[d.id]; const activeTab = _openSections[d.id] || 'Aliases'; const statusCls = d.enabled ? 'dom-status--on' : 'dom-status--off'; const tabBar = DOM_TABS.map(t => `` ).join(''); const body = open ? `
${tabBar}
${buildSubTab(d, activeTab)}
` : ''; return `
${esc(d.name)}
${body}
`; } function buildSubTab(d, tab) { switch (tab) { case 'General': return buildGeneralTab(d); case 'Users': return buildUsersTab(d); case 'User Aliases': return buildUserAliasesTab(d); case 'Email Lists': return buildEmailListsTab(d); case 'Limits': return buildLimitsTab(d); case 'DKIM': return buildDkimTab(d); case 'Domein-aliases': return buildAliasesTab(d); default: return ''; } } function tabFooter(domainId, action, label) { return ``; } function tabMsg(domainId, action) { return $id(`dom-msg-${domainId}-${action}`); } // ── Sub-tab: General ─────────────────────────────────────── function buildGeneralTab(d) { return `
@${esc(d.name)}
${tabFooter(d.id, 'save-general', 'Save')}`; } // ── Sub-tab: Aliases ─────────────────────────────────────── function buildAliasesTab(d) { const tags = (d.aliases || []).map(a => ` ${esc(a)} `).join(''); return `

Other domain names that resolve to this domain.

${tags || 'None'}
`; } // ── Sub-tab: Users ───────────────────────────────────────── function buildUsersTab(d) { const users = d.users || []; const stats = d._stats || {}; const rows = users.map(u => { const s = stats[u.id] || {}; const sizeMb = s.sizeMb != null ? s.sizeMb.toFixed(2) + ' MB' : '—'; const lastActive = s.lastActivity ? timeSince(s.lastActivity) : '—'; const usedPct = (u.maxSizeMb > 0 && s.sizeMb != null) ? Math.min(100, Math.round((s.sizeMb / u.maxSizeMb) * 100)) : null; const bar = usedPct != null ? `
` : ''; return `
@${esc(d.name)}
${bar}
${lastActive} ${sizeMb} `; }).join(''); return `
Accounts on ${esc(d.name)}
${rows}
Address Password Max size (MB) Last logon Current size
@${esc(d.name)}
`; } // ── Sub-tab: User Aliases ────────────────────────────────── function buildUserAliasesTab(d) { const domSuffix = `@${esc(d.name)}`; const localPart = v => v ? v.replace(/@.*$/, '') : v; const listId = `ua-users-${esc(d.id)}`; const userOpts = (d.users || []).map(u => ``).join(''); const bOpts = ['Simple', 'Relaxed'].map(v => ``).join(''); const aOpts = ['SHA256', 'SHA1'].map(v => ``).join(''); return `
${toggle(`dkim-enabled-${d.id}`, 'Enable DKIM signing', 'Sign outgoing messages with DKIM', dk.enabled ?? false)}
${tabFooter(d.id, 'save-dkim', 'Save')}`; } function collectLimits(d) { return { maxMailboxSizeKb: parseInt($id(`lim-mailbox-${d.id}`)?.value) || 0, allocatedSizeKb: parseInt($id(`lim-allocated-${d.id}`)?.value) || 0, maxMessageSizeKb: parseInt($id(`lim-msgsize-${d.id}`)?.value) || 0, maxAccountsSizeKb: parseInt($id(`lim-accounts-${d.id}`)?.value) || 0 }; } function collectDkim(d) { return { enabled: $id(`dkim-enabled-${d.id}`)?.checked ?? false, privateKeyFile: $id(`dkim-keyfile-${d.id}`)?.value.trim() ?? '', selector: $id(`dkim-selector-${d.id}`)?.value.trim() ?? '', headerMethod: $id(`dkim-hmethod-${d.id}`)?.value ?? 'Relaxed', bodyMethod: $id(`dkim-bmethod-${d.id}`)?.value ?? 'Relaxed', algorithm: $id(`dkim-algo-${d.id}`)?.value ?? 'SHA256' }; } function domNameInput(input) { const id = input.dataset.labelfor; const label = $id(`dom-label-${id}`); const suffix = $id(`gen-catchall-suffix-${id}`); if (label) label.textContent = input.value || input.placeholder; if (suffix) suffix.textContent = `@${input.value || input.placeholder}`; } // ── Actions ──────────────────────────────────────────────── function toggleExpand(id) { if (_openSections[id]) { delete _openSections[id]; renderDomains(); return; } _openSections[id] = 'General'; renderDomains(); } function setSubTab(id, tab) { _openSections[id] = tab; renderDomains(); const d = _domains.find(x => x.id === id); if (tab === 'Users' && d) loadUserStats(d); } async function loadUserStats(d) { try { const stats = await apiFetch(`/api/domain/${d.id}/userstats`); d._stats = {}; for (const s of stats) d._stats[s.userId] = s; // only re-render if still on Users tab if (_openSections[d.id] === 'Users') renderDomains(); } catch { /* stats are optional */ } } async function saveGeneral(id) { const d = _domains.find(x => x.id === id); if (!d) return; const name = $id(`gen-name-${id}`)?.value.trim() || d.name; const catchAll = $id(`gen-catchall-${id}`)?.value.trim() ?? d.catchAll; const msg = tabMsg(id, 'save-general'); try { await apiFetch(`/api/domain/${id}/general`, { name, enabled: d.enabled, catchAll }, 'PUT'); d.name = name; d.catchAll = catchAll; const label = $id(`dom-label-${id}`); if (label) label.textContent = name; showMsg(msg, true, 'Saved'); } catch (e) { showMsg(msg, false, e.message || 'Error'); } } async function saveLimits(id) { const d = _domains.find(x => x.id === id); if (!d) return; const limits = collectLimits(d); const msg = tabMsg(id, 'save-limits'); try { await apiFetch(`/api/domain/${id}/limits`, limits, 'PUT'); d.limits = limits; showMsg(msg, true, 'Saved'); } catch (e) { showMsg(msg, false, e.message || 'Error'); } } async function saveDkim(id) { const d = _domains.find(x => x.id === id); if (!d) return; const dkim = collectDkim(d); const msg = tabMsg(id, 'save-dkim'); try { await apiFetch(`/api/domain/${id}/dkim`, dkim, 'PUT'); d.dkim = dkim; showMsg(msg, true, 'Saved'); } catch (e) { showMsg(msg, false, e.message || 'Error'); } } async function deleteDomain(id) { if (!confirm('Delete this domain and all its settings?')) return; try { const res = await fetch(`/api/domain/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); delete _openSections[id]; await loadDomains(); showMsg($id('msg-domains'), true, 'Deleted'); } catch (e) { showMsg($id('msg-domains'), false, e.message || 'Error'); } } // ── Per-item API helpers ─────────────────────────────────── async function apiAddAlias(domainId) { const d = _domains.find(x => x.id === domainId); const input = $id(`dom-alias-input-${domainId}`); const val = input?.value.trim().toLowerCase(); const msg = $id(`dom-msg-${domainId}-alias`); if (!d || !val) return; try { await apiFetch(`/api/domain/${domainId}/aliases`, { value: val }, 'POST'); if (!d.aliases.includes(val)) d.aliases.push(val); input.value = ''; renderDomains(); } catch (e) { showMsg(msg, false, e.message || 'Error'); } } async function apiRemoveAlias(domainId, alias) { const d = _domains.find(x => x.id === domainId); if (!d) return; try { await apiFetch(`/api/domain/${domainId}/aliases/${encodeURIComponent(alias)}`, null, 'DELETE'); d.aliases = d.aliases.filter(a => a !== alias); renderDomains(); } catch (e) { console.error(e); } } async function apiAddUser(domainId) { const d = _domains.find(x => x.id === domainId); const username = $id(`usr-new-name-${domainId}`)?.value.trim().toLowerCase(); const password = $id(`usr-new-pass-${domainId}`)?.value; const maxSizeMb = parseInt($id(`usr-new-max-${domainId}`)?.value) || 0; if (!d || !username || !password) return; try { const created = await apiFetch(`/api/domain/${domainId}/users`, { username, password, maxSizeMb }, 'POST'); d.users.push(created); $id(`usr-new-name-${domainId}`).value = ''; $id(`usr-new-pass-${domainId}`).value = ''; $id(`usr-new-max-${domainId}`).value = ''; renderDomains(); } catch (e) { console.error(e); } } async function apiSaveUser(domainId, userId) { const d = _domains.find(x => x.id === domainId); const u = d?.users?.find(x => x.id === userId); if (!u) return; const username = $id(`usr-name-${userId}`)?.value.trim().toLowerCase() || u.username; const password = $id(`usr-pass-${userId}`)?.value || u.password; const maxSizeMb = parseInt($id(`usr-max-${userId}`)?.value) || 0; const msg = $id(`usr-msg-${userId}`); try { await apiFetch(`/api/domain/${domainId}/users/${userId}`, { id: userId, username, password, maxSizeMb }, 'PUT'); u.username = username; u.password = password; u.maxSizeMb = maxSizeMb; showMsg(msg, true, 'Saved'); } catch (e) { showMsg(msg, false, e.message || 'Error'); } } async function apiRemoveUser(domainId, userId) { if (!confirm('Remove this user?')) return; const d = _domains.find(x => x.id === domainId); if (!d) return; try { await apiFetch(`/api/domain/${domainId}/users/${userId}`, null, 'DELETE'); d.users = d.users.filter(u => u.id !== userId); renderDomains(); } catch (e) { console.error(e); } } async function apiAddUserAlias(domainId) { const d = _domains.find(x => x.id === domainId); const alias = $id(`ua-new-alias-${domainId}`)?.value.trim(); const local = $id(`ua-new-target-${domainId}`)?.value.trim(); const target = local ? `${local}@${d?.name}` : ''; const msg = $id(`dom-msg-${domainId}-ualias`); if (!d || !alias || !target) return; try { await apiFetch(`/api/domain/${domainId}/useraliases`, { alias, target }, 'POST'); d.userAliases = d.userAliases.filter(a => a.alias !== alias); d.userAliases.push({ alias, target }); $id(`ua-new-alias-${domainId}`).value = ''; $id(`ua-new-target-${domainId}`).value = ''; renderDomains(); } catch (e) { showMsg(msg, false, e.message || 'Error'); } } async function apiRemoveUserAlias(domainId, alias) { const d = _domains.find(x => x.id === domainId); if (!d) return; try { await apiFetch(`/api/domain/${domainId}/useraliases/${encodeURIComponent(alias)}`, null, 'DELETE'); d.userAliases = d.userAliases.filter(a => a.alias !== alias); renderDomains(); } catch (e) { console.error(e); } } async function apiAddList(domainId) { const d = _domains.find(x => x.id === domainId); const name = $id(`list-new-${domainId}`)?.value.trim().toLowerCase(); if (!d || !name) return; try { const created = await apiFetch(`/api/domain/${domainId}/lists`, { name }, 'POST'); d.emailLists = d.emailLists || []; d.emailLists.push(created); $id(`list-new-${domainId}`).value = ''; renderDomains(); } catch (e) { console.error(e); } } async function apiRemoveList(domainId, listId) { const d = _domains.find(x => x.id === domainId); if (!d) return; try { await apiFetch(`/api/domain/${domainId}/lists/${listId}`, null, 'DELETE'); d.emailLists = d.emailLists.filter(l => l.id !== listId); renderDomains(); } catch (e) { console.error(e); } } async function apiAddListMember(domainId, listId) { const d = _domains.find(x => x.id === domainId); const list = d?.emailLists?.find(l => l.id === listId); const val = $id(`lm-input-${listId}`)?.value.trim().toLowerCase(); if (!list || !val) return; try { await apiFetch(`/api/domain/${domainId}/lists/${listId}/members`, { value: val }, 'POST'); if (!list.members.includes(val)) list.members.push(val); $id(`lm-input-${listId}`).value = ''; renderDomains(); } catch (e) { console.error(e); } } async function apiRemoveListMember(domainId, listId, value) { const d = _domains.find(x => x.id === domainId); const list = d?.emailLists?.find(l => l.id === listId); if (!list) return; try { await apiFetch(`/api/domain/${domainId}/lists/${listId}/members/${encodeURIComponent(value)}`, null, 'DELETE'); list.members = list.members.filter(m => m !== value); renderDomains(); } catch (e) { console.error(e); } } // load domains when Domains tab becomes active document.addEventListener('DOMContentLoaded', () => { const domainsBtn = $id('tab-domains'); if (domainsBtn) domainsBtn.addEventListener('click', () => { if (_domains.length === 0) loadDomains(); }); }); // load General Settings when tab becomes active document.addEventListener('DOMContentLoaded', () => { const btn = $id('tab-general'); if (btn) btn.addEventListener('click', async () => { const el = $id('cfg-general-body'); if (!el || el.innerHTML.trim()) return; el.innerHTML = '

Loading…

'; try { const cfg = await fetchConfig(); el.innerHTML = buildCfgGeneral(cfg.general); wireSettingsContainer(el); } catch (e) { el.innerHTML = `

${esc(e.message)}

`; } }); }); // ── General Settings panel ───────────────────────────────── function buildCfgGeneral(g) { g = g || {}; return `
${field('gen-emlpath', 'Email storage path', g.emlStoragePath)} ${field('gen-timeout', 'Command timeout (sec)', g.commandTimeoutSeconds, 'number')} ${field('gen-backlog', 'Backlog', g.backLog, 'number')}
${textarea('gen-dns', 'DNS Resolvers (one per line)', (g.dnsResolvers || []).join('\n'))}
`; } function collectGeneral() { return { emlStoragePath: val('gen-emlpath'), commandTimeoutSeconds: int('gen-timeout'), backLog: int('gen-backlog'), dnsResolvers: lines('gen-dns') }; } // ── Manager Users ────────────────────────────────────────── let _maintUsers = null; function initMaintUsers() { const addBtn = $id('mu-add-btn'); const saveBtn = $id('mu-save-btn'); if (!addBtn) return; addBtn.addEventListener('click', () => { if (!_maintUsers) return; _maintUsers.push({ userName: '', password: '', role: 'Unknown', expireDays: 365, enabled: true }); renderMaintUsers(); // focus new row username field const rows = $id('mu-tbody')?.querySelectorAll('tr'); rows?.[rows.length - 1]?.querySelector('.mu-username')?.focus(); }); saveBtn.addEventListener('click', async () => { const msg = $id('mu-save-msg'); saveBtn.disabled = true; try { _maintUsers = collectMaintUsers(); await apiFetch('/api/config/maintenance-users', _maintUsers); showMsg(msg, true, 'Saved'); } catch (e) { showMsg(msg, false, e.message || 'Error'); } finally { saveBtn.disabled = false; } }); $id('mu-tbody')?.addEventListener('click', e => { if (e.target.classList.contains('mu-remove')) { e.target.closest('tr').remove(); } }); } async function loadMaintUsers() { try { _maintUsers = await apiFetch('/api/config/maintenance-users'); renderMaintUsers(); } catch (e) { showMsg($id('msg-maint-users'), false, e.message || 'Error'); } } function renderMaintUsers() { const tbody = $id('mu-tbody'); if (!tbody) return; tbody.innerHTML = (_maintUsers || []).map(u => muRow(u)).join(''); } function muRow(u) { const isAdmin = u.userName.toLowerCase() === 'admin'; const roleOpts = ['Unknown', 'Administrator', 'User'].map(r => `${r}`).join(''); const enabled = u.enabled ?? true; const toggleEl = isAdmin ? `` : ``; const deleteEl = isAdmin ? `` : ``; return ` ${toggleEl} ${deleteEl} `; } function collectMaintUsers() { return Array.from($id('mu-tbody')?.querySelectorAll('tr') ?? []).map(tr => ({ userName: tr.querySelector('.mu-username')?.value.trim() ?? '', password: tr.querySelector('.mu-password')?.value ?? '', role: tr.querySelector('.mu-role')?.value || 'Administrator', expireDays: parseInt(tr.querySelector('.mu-expire')?.value) || 365, enabled: tr.querySelector('.mu-enabled')?.checked ?? true })); } document.addEventListener('DOMContentLoaded', () => { const btn = $id('tab-maint-users'); if (btn) btn.addEventListener('click', () => { if (!_maintUsers) loadMaintUsers(); }); initMaintUsers(); }); // ── IP Groups ────────────────────────────────────────────── let _ipGroups = null; const _ipgOpen = {}; // idx → bool function initIpGroups() { const addBtn = $id('ipg-add-btn'); if (!addBtn) return; addBtn.addEventListener('click', () => { if (!_ipGroups) return; _ipGroups.push({ name: 'New Group', priority: _ipGroups.length + 1, cidr: '0.0.0.0/0', expires: null, access: { smtp: true, pop3: true, imap: true, antiSpam: false, antiVirus: false, requireSslTlsForAuth: false }, emailFlows: { localToLocal: { allowed: false, requireAuth: false }, localToExternal: { allowed: false, requireAuth: false }, externalToLocal: { allowed: true, requireAuth: false }, externalToExternal: { allowed: false, requireAuth: false } } }); const newIdx = _ipGroups.length - 1; _ipgOpen[newIdx] = true; renderIpGroups(); }); const list = $id('ipg-list'); if (list) { list.addEventListener('click', ipgListClick); list.addEventListener('change', ipgListChange); } } function ipgListClick(e) { const t = e.target; const idx = t.dataset.idx !== undefined ? parseInt(t.dataset.idx) : NaN; if (t.dataset.action === 'ipg-toggle-expand') { _ipgOpen[idx] = !_ipgOpen[idx]; renderIpGroups(); return; } if (t.dataset.action === 'ipg-delete') { ipgDelete(idx); return; } if (t.dataset.action === 'ipg-save') { ipgSave(idx, t); return; } } function ipgListChange(e) { /* reserved */ } async function loadIpGroups() { try { _ipGroups = await apiFetch('/api/config/ipgroups'); renderIpGroups(); } catch (e) { showMsg($id('msg-ipgroups'), false, e.message || 'Error'); } } function renderIpGroups() { const el = $id('ipg-list'); if (!el) return; if (!_ipGroups || _ipGroups.length === 0) { el.innerHTML = '

No IP groups configured yet.

'; return; } el.innerHTML = _ipGroups.map((g, i) => ipgCard(g, i)).join(''); } function ipgCard(g, idx) { const open = !!_ipgOpen[idx]; const body = open ? ipgCardBody(g, idx) : ''; return `
${esc(g.name)} ${esc(g.cidr)} Priority ${g.priority}
${body}
`; } function ipgCardBody(g, idx) { const a = g.access || {}; const ef = g.emailFlows || {}; return `
${field(`ipg-name-${idx}`, 'Name', g.name, 'text')} ${field(`ipg-cidr-${idx}`, 'CIDR', g.cidr, 'text')} ${field(`ipg-priority-${idx}`, 'Priority', g.priority, 'number')} ${field(`ipg-expires-${idx}`, 'Expires (ISO date, empty = never)', g.expires ?? '', 'text')}
Access
${toggle(`ipg-smtp-${idx}`, 'SMTP', 'Allow SMTP connections', a.smtp ?? false)} ${toggle(`ipg-pop3-${idx}`, 'POP3', 'Allow POP3 connections', a.pop3 ?? false)} ${toggle(`ipg-imap-${idx}`, 'IMAP', 'Allow IMAP connections', a.imap ?? false)} ${toggle(`ipg-antispam-${idx}`, 'Anti-Spam', 'Apply anti-spam checks', a.antiSpam ?? false)} ${toggle(`ipg-antivirus-${idx}`,'Anti-Virus', 'Apply anti-virus checks', a.antiVirus ?? false)} ${toggle(`ipg-reqtls-${idx}`, 'Require TLS for Auth', 'Enforce TLS before authentication', a.requireSslTlsForAuth ?? false)}
Email Flows
${ipgFlowRow(idx, 'localToLocal', 'Local → Local', ef.localToLocal)} ${ipgFlowRow(idx, 'localToExternal', 'Local → External', ef.localToExternal)} ${ipgFlowRow(idx, 'externalToLocal', 'External → Local', ef.externalToLocal)} ${ipgFlowRow(idx, 'externalToExternal', 'External → External', ef.externalToExternal)}
FlowAllowedRequire Auth
`; } function ipgFlowRow(idx, key, label, flow) { const f = flow || {}; return ` ${esc(label)} `; } function collectIpGroup(idx) { const flowKeys = ['localToLocal', 'localToExternal', 'externalToLocal', 'externalToExternal']; const emailFlows = {}; for (const k of flowKeys) emailFlows[k] = { allowed: $id(`ipg-${k}-allowed-${idx}`)?.checked ?? false, requireAuth: $id(`ipg-${k}-reqauth-${idx}`)?.checked ?? false }; return { name: val(`ipg-name-${idx}`), cidr: val(`ipg-cidr-${idx}`), priority: int(`ipg-priority-${idx}`), expires: val(`ipg-expires-${idx}`) || null, access: { smtp: chk(`ipg-smtp-${idx}`), pop3: chk(`ipg-pop3-${idx}`), imap: chk(`ipg-imap-${idx}`), antiSpam: chk(`ipg-antispam-${idx}`), antiVirus: chk(`ipg-antivirus-${idx}`), requireSslTlsForAuth: chk(`ipg-reqtls-${idx}`) }, emailFlows }; } async function ipgSave(idx, btn) { if (!_ipGroups) return; _ipGroups[idx] = collectIpGroup(idx); const msg = $id(`ipg-msg-${idx}`); btn.disabled = true; try { await apiFetch('/api/config/ipgroups', _ipGroups); showMsg(msg, true, 'Saved'); renderIpGroups(); _ipgOpen[idx] = true; } catch (e) { showMsg(msg, false, e.message || 'Error'); } finally { btn.disabled = false; } } function ipgDelete(idx) { if (!_ipGroups || !confirm('Remove this IP group?')) return; _ipGroups.splice(idx, 1); delete _ipgOpen[idx]; // re-index open state const newOpen = {}; Object.keys(_ipgOpen).forEach(k => { const ki = parseInt(k); if (ki < idx) newOpen[ki] = _ipgOpen[k]; else if (ki > idx) newOpen[ki - 1] = _ipgOpen[k]; }); Object.keys(_ipgOpen).forEach(k => delete _ipgOpen[k]); Object.assign(_ipgOpen, newOpen); apiFetch('/api/config/ipgroups', _ipGroups) .then(() => { showMsg($id('msg-ipgroups'), true, 'Deleted'); }) .catch(e => { showMsg($id('msg-ipgroups'), false, e.message || 'Error'); }); renderIpGroups(); } // load IP groups when tab becomes active document.addEventListener('DOMContentLoaded', () => { const ipgBtn = $id('tab-ipgroups'); if (ipgBtn) ipgBtn.addEventListener('click', () => { if (!_ipGroups) loadIpGroups(); }); initIpGroups(); });