// ── 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) =>
`${t.label} `
).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 =>
`${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)}
${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 =>
`${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)}
Hierarchy delimiter
${delimOpts}
`
},
{
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 =>
`${o.label} `
).join('');
const enabled = p.enabled ?? true;
return `
${opts}
✕
`;
}
function buildPorts(prefix, ports)
{
const rows = (ports || []).map(p => portRow(p)).join('');
return `
Host / IP Port Security
${rows}
+ Add port
`;
}
// ── 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 `
${esc(label)}
`;
}
function textarea(id, label, value)
{
return `
${esc(label)}
`;
}
function toggle(id, label, sub, checked)
{
return ``;
}
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 += `${esc(r[key])} ${r.count} `;
html += '
';
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 =>
`${esc(t)} `
).join('');
const body = open ? `
${tabBar}
${buildSubTab(d, activeTab)}
` : '';
return `
${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 `
${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 '}
+ Add
`;
}
// ── 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)}
`;
}
// ── 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 datalist = `${userOpts} `;
const targetInput = (id, value) =>
`
${domSuffix}
`;
const rows = (d.userAliases || []).map(ua => `
${esc(ua.alias)}
→
${esc(localPart(ua.target))}${domSuffix}
×
`).join('');
return `
${datalist}
Map an alias address to another mailbox.
Alias Target
${rows || `No aliases yet. `}
→
${targetInput(`ua-new-target-${esc(d.id)}`, '')}
+
`;
}
// ── Sub-tab: Email Lists ───────────────────────────────────
function buildEmailListsTab(d)
{
const lists = (d.emailLists || []).map(list => {
const memberTags = (list.members || []).map(m => `
${esc(m)}
×
`).join('');
return `
`;
}).join('');
return `
Email lists / distribution groups
${lists || 'No lists yet.
'}
@${esc(d.name)}
+ Create list
`;
}
// ── Sub-tab: Limits ────────────────────────────────────────
function buildLimitsTab(d)
{
const lim = d.limits || {};
return `
${field(`lim-mailbox-${d.id}`, 'Max mailbox (KB)', lim.maxMailboxSizeKb ?? 0, 'number')}
${field(`lim-allocated-${d.id}`,'Allocated (KB)', lim.allocatedSizeKb ?? 0, 'number')}
${field(`lim-msgsize-${d.id}`, 'Max message (KB)', lim.maxMessageSizeKb ?? 0, 'number')}
${field(`lim-accounts-${d.id}`, 'All accounts (KB)', lim.maxAccountsSizeKb ?? 0, 'number')}
0 = unlimited.
${tabFooter(d.id, 'save-limits', 'Save')}`;
}
function dkimKeyFilePicked(picker, textId)
{
const file = picker.files[0];
if (!file) return;
const el = $id(textId);
if (el) el.value = file.name;
}
// ── Sub-tab: DKIM ──────────────────────────────────────────
function buildDkimTab(d)
{
const dk = d.dkim || {};
const hOpts = ['Simple', 'Relaxed'].map(v =>
` ${v} `).join('');
const bOpts = ['Simple', 'Relaxed'].map(v =>
`${v} `).join('');
const aOpts = ['SHA256', 'SHA1'].map(v =>
`${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 `
${roleOpts}
${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 `
${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
Flow Allowed Require Auth
${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)}
`;
}
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();
});