feat: domains, transports, logs, quarantine, spam filter, i18n + UX fixes

Features added:
- Admin > Domains: add domains to Mailcow servers, auto-generate DKIM,
  display full DNS record set (MX, SPF, DMARC, DKIM, autoconfig CNAMEs)
  with one-click copy per record
- Admin > Transports: manage sender-dependent relay hosts (add/delete)
- Admin > Logs: view Postfix, Dovecot, Rspamd, Ratelimit, API and other
  server logs in a dark scrollable panel
- My Account: per-domain Quarantine panel — view score, sender, subject,
  date; permanently delete quarantined messages
- My Account: per-mailbox Spam Filter slider (1–15 threshold) saved via API
- My Account: Aliases & Forwarders (alias creation doubles as forwarder
  to any external address)

UX fixes:
- Quota 0 now displays ∞ (unlimited) in both admin and account views
- Admin mailbox action buttons replaced with Dashicon icon buttons
  (lock, chart-bar, trash) with title tooltips

i18n:
- load_plugin_textdomain registered on init hook
- All user-facing PHP strings wrapped in __() / esc_html__()
- Translated strings array passed to account JS via wp_localize_script
- woocow-es_ES.po/.mo — Spanish translation
- woocow-ro_RO.po/.mo — Romanian translation (with correct plural forms)
- English remains the fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 08:38:52 +01:00
parent 1ea2ed7e74
commit 1c5b58f238
11 changed files with 1252 additions and 14 deletions

View File

@@ -336,6 +336,93 @@
.woocow-muted { color: #aaa; font-size: 13px; }
.woocow-error { color: #c0392b; }
/* ── Icon buttons (admin) ────────────────────────────────────── */
.button.woocow-icon-btn {
width: 30px !important;
min-width: 0 !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.button.woocow-icon-btn .dashicons {
font-size: 15px;
width: 15px;
height: 28px;
line-height: 28px;
}
/* ── DNS records table ───────────────────────────────────────── */
.woocow-dns-table { margin-top: 8px; }
.woocow-dns-host code { font-size: 11px; word-break: break-all; }
.woocow-dns-val { max-width: 340px; }
.woocow-dns-value {
display: block;
font-size: 11px;
word-break: break-all;
white-space: pre-wrap;
background: #f6f8fa;
padding: 4px 6px;
border-radius: 3px;
max-height: 60px;
overflow-y: auto;
}
/* ── Log viewer ──────────────────────────────────────────────── */
.woocow-log-toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 8px;
}
.woocow-log-pre {
background: #1e1e2e;
color: #cdd6f4;
padding: 16px;
border-radius: 6px;
font-size: 12px;
line-height: 1.6;
max-height: 500px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
/* ── Quarantine (account) ────────────────────────────────────── */
.woocow-quarantine-wrap {
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
background: #fffbf0;
}
.woocow-quarantine-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-bottom: 10px;
}
.woocow-quarantine-table th,
.woocow-quarantine-table td {
padding: 7px 10px;
border-bottom: 1px solid #eee;
text-align: left;
vertical-align: middle;
}
.woocow-quarantine-table th { font-weight: 700; background: #f5f5f5; }
.woocow-quarantine-table tr:hover td { background: #fafafa; }
.woocow-score-badge {
display: inline-block;
padding: 2px 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
color: #fff;
}
/* ── Spam filter panel ───────────────────────────────────────── */
.woocow-spam-panel { font-size: 13px; }
.woocow-spam-panel input[type=range] { vertical-align: middle; }
.wc-spam-val { font-weight: 700; min-width: 30px; display: inline-block; }
@media (max-width: 600px) {
.woocow-domain-header,
.woocow-mbox-main,
@@ -343,4 +430,6 @@
.woocow-quota-bar-outer { width: 80px; }
.woocow-alias-fields { flex-direction: column; }
.woocow-input { max-width: 100%; }
.woocow-quarantine-table { font-size: 11px; }
.woocow-quarantine-table th, .woocow-quarantine-table td { padding: 5px 6px; }
}

View File

@@ -57,9 +57,10 @@
let html = '';
boxes.forEach(m => {
const pct = parseFloat(m.percent_in_use || 0);
const unlimited = (m.quota === 0 || m.quota === '0');
const pct = unlimited ? 0 : parseFloat(m.percent_in_use || 0);
const used = formatMB(m.quota_used);
const max = formatMB(m.quota);
const max = unlimited ? '∞' : formatMB(m.quota);
const col = pct > 85 ? '#e74c3c' : pct > 60 ? '#f39c12' : '#27ae60';
html += `<div class="woocow-mailbox-row" data-email="${esc(m.username)}">
@@ -79,14 +80,19 @@
<div class="woocow-mbox-actions">
<button class="woocow-btn woocow-btn-sm wc-change-pw"
data-server="${esc(sid)}" data-domain="${esc(domain)}" data-email="${esc(m.username)}">
Change Password
🔑 Change Password
</button>
<button class="woocow-btn woocow-btn-sm woocow-btn-outline wc-toggle-aliases"
data-server="${esc(sid)}" data-domain="${esc(domain)}">
Aliases
Aliases &amp; Forwarders
</button>
<button class="woocow-btn woocow-btn-sm woocow-btn-outline wc-spam-score-btn"
data-server="${esc(sid)}" data-domain="${esc(domain)}"
data-email="${esc(m.username)}" data-score="${esc(m.spam_score || 5)}">
🛡 Spam Filter
</button>
<a href="${esc(webmail)}" target="_blank" rel="noopener" class="woocow-btn woocow-btn-sm woocow-btn-ghost">
Webmail
Webmail
</a>
</div>
<div class="woocow-aliases-wrap" style="display:none">
@@ -278,4 +284,106 @@
});
});
// ── Quarantine ────────────────────────────────────────────────────────────
$(document).on('click', '.woocow-load-quarantine', function () {
const $panel = $(this).closest('.woocow-domain-panel');
const $wrap = $panel.find('.woocow-quarantine-wrap');
const $list = $panel.find('.woocow-quarantine-list');
const sid = $panel.data('server-id');
const domain = $panel.data('domain');
if ($wrap.is(':visible')) { $wrap.slideUp(); return; }
$list.html('<p class="woocow-loading">Loading quarantine…</p>');
$wrap.slideDown();
ajax('woocow_acct_quarantine', { server_id: sid, domain }).done(res => {
if (!res.success) { $list.html(`<p class="woocow-error">${esc(res.data)}</p>`); return; }
const msgs = res.data;
if (!msgs.length) { $list.html('<p class="woocow-muted">No quarantined messages for this domain.</p>'); return; }
let html = `<table class="woocow-quarantine-table">
<thead><tr><th>From</th><th>To</th><th>Subject</th><th>Score</th><th>Date</th><th></th></tr></thead><tbody>`;
msgs.forEach(m => {
const date = new Date(m.created * 1000).toLocaleString();
const score = parseFloat(m.score).toFixed(1);
const virus = m.virus_flag == 1 ? ' 🦠' : '';
html += `<tr>
<td>${esc(m.sender)}</td>
<td>${esc(m.rcpt)}</td>
<td>${esc(m.subject)}${virus}</td>
<td><span class="woocow-score-badge" style="background:${score > 10 ? '#e74c3c' : score > 5 ? '#f39c12' : '#95a5a6'}">${score}</span></td>
<td style="white-space:nowrap;font-size:12px">${esc(date)}</td>
<td><button class="woocow-btn woocow-btn-danger woocow-btn-xs wc-q-del"
data-id="${esc(m.id)}" data-server="${esc(sid)}" data-domain="${esc(domain)}">Delete</button></td>
</tr>`;
});
html += '</tbody></table>';
html += '<p class="woocow-muted" style="margin-top:8px">To release a message to your inbox, use the link in your quarantine notification email or via Webmail.</p>';
$list.html(html);
});
});
$(document).on('click', '.wc-q-del', function () {
if (!confirm('Permanently delete this quarantined message?')) return;
const $row = $(this).closest('tr');
ajax('woocow_acct_quarantine_delete', {
server_id: $(this).data('server'),
domain: $(this).data('domain'),
qid: $(this).data('id'),
}).done(res => {
if (res.success) $row.fadeOut(300, function () { $(this).remove(); });
else alert('Delete failed: ' + res.data);
});
});
// ── Spam Score ────────────────────────────────────────────────────────────
$(document).on('click', '.wc-spam-score-btn', function () {
const $row = $(this).closest('.woocow-mailbox-row');
const sid = $(this).data('server');
const domain = $(this).data('domain');
const email = $(this).data('email');
const score = $(this).data('score');
const $existing = $row.find('.woocow-spam-panel');
if ($existing.length) { $existing.slideToggle(); return; }
$row.append(`
<div class="woocow-spam-panel" style="margin-top:12px;padding-top:12px;border-top:1px dashed #e0e0e0">
<strong>Spam Filter Threshold</strong>
<p class="woocow-muted">Lower = stricter. Default is 5. Emails above this score go to spam/quarantine.</p>
<div class="woocow-flex-inline" style="gap:10px;margin-top:8px">
<input type="range" class="wc-spam-slider" min="1" max="15" step="0.5" value="${esc(score)}"
style="width:200px">
<span class="wc-spam-val">${parseFloat(score).toFixed(1)}</span>
<button class="woocow-btn woocow-btn-primary woocow-btn-sm wc-spam-save"
data-server="${esc(sid)}" data-domain="${esc(domain)}" data-email="${esc(email)}">Save</button>
<span class="wc-spam-notice"></span>
</div>
</div>
`);
$row.find('.wc-spam-slider').on('input', function () {
$row.find('.wc-spam-val').text(parseFloat($(this).val()).toFixed(1));
});
});
$(document).on('click', '.wc-spam-save', function () {
const $panel = $(this).closest('.woocow-spam-panel');
const $note = $panel.find('.wc-spam-notice');
const score = $panel.find('.wc-spam-slider').val();
$note.text('Saving…');
ajax('woocow_acct_spam_score', {
server_id: $(this).data('server'),
domain: $(this).data('domain'),
email: $(this).data('email'),
spam_score: score,
}).done(res => {
if (res.success) $note.html('<span style="color:green">✓ Saved</span>');
else $note.html(`<span class="woocow-error">${esc(res.data)}</span>`);
});
});
})(jQuery);

View File

@@ -324,12 +324,18 @@
<td>${max}</td>
<td>${m.active == 1 ? '✓' : ''}</td>
<td class="woocow-actions">
<button class="button button-small wc-mb-reset-pw"
data-email="${esc(m.username)}">Reset PW</button>
<button class="button button-small wc-mb-set-quota"
data-email="${esc(m.username)}" data-quota="${quotaMB}">Set Quota</button>
<button class="button button-small wc-mb-del"
data-email="${esc(m.username)}" style="color:#a00">Delete</button>
<button class="button button-small woocow-icon-btn wc-mb-reset-pw"
title="Reset Password" data-email="${esc(m.username)}">
<span class="dashicons dashicons-lock"></span>
</button>
<button class="button button-small woocow-icon-btn wc-mb-set-quota"
title="Set Quota" data-email="${esc(m.username)}" data-quota="${quotaMB}">
<span class="dashicons dashicons-chart-bar"></span>
</button>
<button class="button button-small woocow-icon-btn wc-mb-del"
title="Delete" data-email="${esc(m.username)}" style="color:#a00">
<span class="dashicons dashicons-trash"></span>
</button>
</td>
</tr>`;
});
@@ -459,9 +465,236 @@
}
function formatMB(bytes) {
if (bytes === 0 || bytes === '0') return '∞';
if (!bytes) return '0 MB';
const mb = bytes / 1024 / 1024;
return mb >= 1024 ? (mb / 1024).toFixed(1) + ' GB' : mb.toFixed(0) + ' MB';
}
// ── Domains Page ──────────────────────────────────────────────────────────
if ($('#wc-dom-server').length) {
let domServerId = null;
let domServerUrl = null;
$('#wc-dom-server').on('change', function () {
domServerId = $(this).val();
domServerUrl = $(this).find('option:selected').data('url');
$('#wc-dom-load').prop('disabled', !domServerId);
$('#wc-dom-add-btn, #wc-dom-table-wrap').hide();
});
const loadDomains = () => {
ajax('woocow_server_domains', { server_id: domServerId }).done(res => {
if (!res.success) {
notice($('#wc-dom-notices'), 'error', res.data);
return;
}
const domains = res.data;
if (!domains.length) {
$('#wc-dom-table-wrap').html('<p>No domains on this server yet.</p>').show();
} else {
let html = `<table class="wp-list-table widefat fixed striped woocow-table">
<thead><tr><th>Domain</th><th>Active</th><th>Actions</th></tr></thead><tbody>`;
domains.forEach(d => {
html += `<tr>
<td><strong>${esc(d.domain)}</strong></td>
<td>${d.active == 1 ? '<span class="woocow-badge woocow-badge-green">Active</span>' : '<span class="woocow-badge woocow-badge-grey">Inactive</span>'}</td>
<td>
<button class="button button-small wc-dom-dns" data-domain="${esc(d.domain)}">
<span class="dashicons dashicons-admin-site"></span> DNS Records
</button>
</td>
</tr>`;
});
html += '</tbody></table>';
$('#wc-dom-table-wrap').html(html).show();
}
$('#wc-dom-add-btn').show();
});
};
$('#wc-dom-load').on('click', loadDomains);
$('#wc-dom-add-btn').on('click', () => {
$('#wc-dom-name, #wc-dom-desc').val('');
$('#wc-dom-form-notice').text('');
$('#wc-dom-form').slideDown();
});
$('#wc-dom-cancel').on('click', () => $('#wc-dom-form').slideUp());
$('#wc-dom-save').on('click', () => {
const $note = $('#wc-dom-form-notice').text('Adding domain…');
const data = {
server_id: domServerId,
domain: $('#wc-dom-name').val().trim(),
description: $('#wc-dom-desc').val().trim(),
mailboxes: $('#wc-dom-mailboxes').val(),
aliases: $('#wc-dom-aliases').val(),
quota: $('#wc-dom-quota').val(),
defquota: $('#wc-dom-defquota').val(),
dkim_size: $('#wc-dom-dkim-size').val(),
};
if (!data.domain) { $note.html('<span style="color:red">Domain name required.</span>'); return; }
ajax('woocow_admin_domain_add', data).done(res => {
if (res.success) {
$note.html('<span style="color:green">✓ Domain added with DKIM generated!</span>');
$('#wc-dom-form').slideUp();
loadDomains();
} else {
$note.html(`<span style="color:red">${esc(res.data)}</span>`);
}
});
});
// DNS Records panel
$(document).on('click', '.wc-dom-dns', function () {
const domain = $(this).data('domain');
$('#wc-dom-dns-domain').text(domain);
$('#wc-dom-dns-content').html('<p>Loading…</p>');
$('#wc-dom-dns-panel').show();
$('html,body').animate({ scrollTop: $('#wc-dom-dns-panel').offset().top - 40 }, 300);
ajax('woocow_admin_domain_dns', { server_id: domServerId, domain }).done(res => {
if (!res.success) {
$('#wc-dom-dns-content').html(`<p style="color:red">${esc(res.data)}</p>`);
return;
}
const d = res.data;
let html = `<table class="wp-list-table widefat fixed striped woocow-table woocow-dns-table">
<thead><tr><th>Type</th><th>Host / Name</th><th>Value</th><th>Priority</th><th>TTL</th><th>Note</th><th></th></tr></thead><tbody>`;
d.records.forEach(r => {
html += `<tr>
<td><code>${esc(r.type)}</code></td>
<td class="woocow-dns-host"><code>${esc(r.host)}</code></td>
<td class="woocow-dns-val"><code class="woocow-dns-value">${esc(r.value)}</code></td>
<td>${esc(r.prio)}</td>
<td>${esc(r.ttl)}</td>
<td><em>${esc(r.note || '')}</em></td>
<td><button class="button button-small wc-copy-dns" data-val="${esc(r.value)}">Copy</button></td>
</tr>`;
});
html += '</tbody></table>';
if (!d.dkim_txt) {
html += `<p class="description" style="color:orange">⚠ DKIM key not yet generated for this domain. Add the domain first, then view DNS records again.</p>`;
}
$('#wc-dom-dns-content').html(html);
});
});
$(document).on('click', '.wc-copy-dns', function () {
const val = $(this).data('val');
navigator.clipboard.writeText(val).then(() => {
$(this).text('Copied!');
setTimeout(() => $(this).text('Copy'), 1500);
});
});
$('#wc-dom-dns-close').on('click', () => $('#wc-dom-dns-panel').hide());
}
// ── Transports Page ───────────────────────────────────────────────────────
if ($('#wc-tr-server').length) {
let trServerId = null;
$('#wc-tr-server').on('change', function () {
trServerId = $(this).val();
$('#wc-tr-load').prop('disabled', !trServerId);
});
const loadTransports = () => {
ajax('woocow_admin_relayhosts_list', { server_id: trServerId }).done(res => {
if (!res.success) { notice($('#wc-tr-notices'), 'error', res.data); return; }
const rows = res.data;
if (!Array.isArray(rows) || !rows.length) {
$('#wc-tr-table-wrap').html('<p>No transports configured on this server.</p>');
} else {
let html = `<table class="wp-list-table widefat fixed striped woocow-table">
<thead><tr><th>Hostname:Port</th><th>Username</th><th>Used by Domains</th><th>Active</th><th>Actions</th></tr></thead><tbody>`;
rows.forEach(r => {
html += `<tr>
<td><code>${esc(r.hostname)}</code></td>
<td>${esc(r.username)}</td>
<td>${esc(r.used_by_domains || '—')}</td>
<td>${r.active == 1 ? '✓' : ''}</td>
<td><button class="button button-small woocow-icon-btn wc-tr-del" title="Delete" data-id="${r.id}" style="color:#a00">
<span class="dashicons dashicons-trash"></span>
</button></td>
</tr>`;
});
html += '</tbody></table>';
$('#wc-tr-table-wrap').html(html);
}
$('#wc-tr-add-btn').show();
});
};
$('#wc-tr-load').on('click', loadTransports);
$('#wc-tr-add-btn').on('click', () => { $('#wc-tr-hostname,#wc-tr-user,#wc-tr-pass').val(''); $('#wc-tr-form').slideDown(); });
$('#wc-tr-cancel').on('click', () => $('#wc-tr-form').slideUp());
$('#wc-tr-save').on('click', () => {
const $note = $('#wc-tr-form-notice').text('Saving…');
ajax('woocow_admin_relayhost_save', {
server_id: trServerId,
hostname: $('#wc-tr-hostname').val().trim(),
username: $('#wc-tr-user').val().trim(),
password: $('#wc-tr-pass').val(),
active: $('#wc-tr-active').is(':checked') ? 1 : 0,
}).done(res => {
if (res.success) { $note.html('<span style="color:green">✓ Transport added.</span>'); $('#wc-tr-form').slideUp(); loadTransports(); }
else $note.html(`<span style="color:red">${esc(res.data)}</span>`);
});
});
$(document).on('click', '.wc-tr-del', function () {
if (!confirm('Delete this transport?')) return;
ajax('woocow_admin_relayhost_delete', { server_id: trServerId, id: $(this).data('id') }).done(res => {
if (res.success) loadTransports();
else notice($('#wc-tr-notices'), 'error', res.data);
});
});
}
// ── Logs Page ─────────────────────────────────────────────────────────────
if ($('#wc-log-server').length) {
$('#wc-log-server').on('change', function () {
$('#wc-log-load').prop('disabled', !$(this).val());
});
$('#wc-log-load').on('click', () => {
const sid = $('#wc-log-server').val();
const type = $('#wc-log-type').val();
$('#wc-log-wrap').html('<p>Loading…</p>');
ajax('woocow_admin_logs', { server_id: sid, log_type: type }).done(res => {
if (!res.success) {
$('#wc-log-wrap').html(`<p style="color:red">${esc(res.data)}</p>`);
return;
}
const entries = Array.isArray(res.data) ? res.data : Object.values(res.data);
if (!entries.length) {
$('#wc-log-wrap').html('<p>No log entries.</p>');
return;
}
// Render as a scrollable pre block
const text = entries.map(e => {
if (typeof e === 'string') return e;
if (e.time && e.message) return `[${e.time}] ${e.message}`;
return JSON.stringify(e);
}).join('\n');
$('#wc-log-wrap').html(`
<div class="woocow-log-toolbar">
<strong>${esc(type.charAt(0).toUpperCase() + type.slice(1))} log</strong>
<span style="color:#666;font-size:12px">${entries.length} entries</span>
</div>
<pre class="woocow-log-pre">${esc(text)}</pre>
`);
});
});
}
})(jQuery);