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

@@ -31,6 +31,9 @@ class WooCow_Account {
'woocow_acct_aliases',
'woocow_acct_alias_create',
'woocow_acct_alias_delete',
'woocow_acct_quarantine',
'woocow_acct_quarantine_delete',
'woocow_acct_spam_score',
];
foreach ( $actions as $action ) {
add_action( 'wp_ajax_' . $action, [ $this, 'ajax_' . $action ] );
@@ -61,6 +64,32 @@ class WooCow_Account {
wp_localize_script( 'woocow-account', 'woocowAcct', [
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'woocow_account' ),
'i18n' => [
'load_mailboxes' => __( 'Load Mailboxes', 'woocow' ),
'refresh' => __( 'Refresh', 'woocow' ),
'loading' => __( 'Loading…', 'woocow' ),
'no_mailboxes' => __( 'No mailboxes yet. Create one below.', 'woocow' ),
'change_password' => __( 'Change Password', 'woocow' ),
'aliases_forwarders' => __( 'Aliases & Forwarders', 'woocow' ),
'spam_filter' => __( 'Spam Filter', 'woocow' ),
'webmail' => __( 'Webmail', 'woocow' ),
'add_alias' => __( 'Add Alias / Forwarder', 'woocow' ),
'no_aliases' => __( 'No aliases for this domain yet.', 'woocow' ),
'add_mailbox' => __( 'Add Mailbox', 'woocow' ),
'create_mailbox' => __( 'Create Mailbox', 'woocow' ),
'cancel' => __( 'Cancel', 'woocow' ),
'save' => __( 'Save', 'woocow' ),
'delete' => __( 'Delete', 'woocow' ),
'quarantine' => __( 'Quarantine', 'woocow' ),
'no_quarantine' => __( 'No quarantined messages for this domain.', 'woocow' ),
'q_release_note' => __( 'To release a message to your inbox, use the link in your quarantine notification email or via Webmail.', 'woocow' ),
'q_delete_confirm' => __( 'Permanently delete this quarantined message?', 'woocow' ),
'spam_threshold' => __( 'Spam Filter Threshold', 'woocow' ),
'spam_help' => __( 'Lower = stricter. Default is 5. Emails above this score go to spam/quarantine.', 'woocow' ),
'update_password' => __( 'Update Password', 'woocow' ),
'passwords_mismatch' => __( 'Passwords do not match or are empty.', 'woocow' ),
'unlimited' => __( 'Unlimited', 'woocow' ),
],
] );
}
@@ -94,7 +123,13 @@ class WooCow_Account {
<a href="<?php echo esc_url( $assignment->webmail_url ); ?>" target="_blank" rel="noopener" class="woocow-btn woocow-btn-sm">
Open Webmail
</a>
<button class="woocow-btn woocow-btn-sm woocow-btn-outline woocow-load-mailboxes">Load Mailboxes</button>
<button class="woocow-btn woocow-btn-sm woocow-btn-outline woocow-load-mailboxes"><?php esc_html_e( 'Load Mailboxes', 'woocow' ); ?></button>
<button class="woocow-btn woocow-btn-sm woocow-btn-ghost woocow-load-quarantine"><?php esc_html_e( 'Quarantine', 'woocow' ); ?></button>
</div>
<!-- Quarantine panel -->
<div class="woocow-quarantine-wrap" style="display:none">
<div class="woocow-quarantine-list"></div>
</div>
<div class="woocow-mailboxes-wrap" style="display:none">
@@ -416,6 +451,98 @@ class WooCow_Account {
wp_send_json_success();
}
// ── AJAX: Quarantine ──────────────────────────────────────────────────────
public function ajax_woocow_acct_quarantine(): void {
$this->account_verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$domain = sanitize_text_field( $_POST['domain'] ?? '' );
if ( ! $this->verify_ownership( $server_id, $domain ) ) {
wp_send_json_error( 'Access denied.', 403 );
}
$server = $this->get_server( $server_id );
if ( ! $server ) {
wp_send_json_error( 'Server unavailable.' );
}
$api = WooCow_API::from_server( $server );
$result = $api->get_quarantine();
if ( ! $result['success'] ) {
wp_send_json_error( $result['error'] ?? 'Could not load quarantine.' );
}
// Filter to messages where the recipient belongs to this domain.
$messages = array_values( array_filter( (array) $result['data'], function ( $m ) use ( $domain ) {
return isset( $m['rcpt'] ) && str_ends_with( $m['rcpt'], '@' . $domain );
} ) );
wp_send_json_success( $messages );
}
public function ajax_woocow_acct_quarantine_delete(): void {
$this->account_verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$domain = sanitize_text_field( $_POST['domain'] ?? '' );
$qid = absint( $_POST['qid'] ?? 0 );
if ( ! $this->verify_ownership( $server_id, $domain ) ) {
wp_send_json_error( 'Access denied.', 403 );
}
$server = $this->get_server( $server_id );
if ( ! $server ) {
wp_send_json_error( 'Server unavailable.' );
}
$api = WooCow_API::from_server( $server );
$result = $api->delete_quarantine( $qid );
if ( ! $result['success'] ) {
wp_send_json_error( $result['error'] ?? 'Failed to delete quarantine message.' );
}
wp_send_json_success();
}
// ── AJAX: Spam score ──────────────────────────────────────────────────────
public function ajax_woocow_acct_spam_score(): void {
$this->account_verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$domain = sanitize_text_field( $_POST['domain'] ?? '' );
$email = sanitize_email( $_POST['email'] ?? '' );
$spam_score = floatval( $_POST['spam_score'] ?? 5 );
if ( ! $this->verify_ownership( $server_id, $domain ) ) {
wp_send_json_error( 'Access denied.', 403 );
}
if ( ! str_ends_with( $email, '@' . $domain ) ) {
wp_send_json_error( 'Email does not belong to your domain.' );
}
$server = $this->get_server( $server_id );
if ( ! $server ) {
wp_send_json_error( 'Server unavailable.' );
}
$api = WooCow_API::from_server( $server );
$result = $api->request( 'POST', '/api/v1/edit/spam-score/' . rawurlencode( $email ), [
'spam_score' => $spam_score,
] );
if ( ! $result['success'] ) {
wp_send_json_error( $result['error'] ?? 'Failed to update spam score.' );
}
wp_send_json_success();
}
// ── WP password change sync ───────────────────────────────────────────────
/**

View File

@@ -24,6 +24,12 @@ class WooCow_Admin {
'woocow_admin_mailbox_create',
'woocow_admin_mailbox_delete',
'woocow_admin_mailbox_edit',
'woocow_admin_domain_add',
'woocow_admin_domain_dns',
'woocow_admin_relayhosts_list',
'woocow_admin_relayhost_save',
'woocow_admin_relayhost_delete',
'woocow_admin_logs',
];
foreach ( $ajax_actions as $action ) {
@@ -43,10 +49,13 @@ class WooCow_Admin {
'dashicons-email-alt2',
56
);
add_submenu_page( 'woocow', 'Dashboard', 'Dashboard', 'manage_woocommerce', 'woocow', [ $this, 'page_dashboard' ] );
add_submenu_page( 'woocow', 'Dashboard', 'Dashboard', 'manage_woocommerce', 'woocow', [ $this, 'page_dashboard' ] );
add_submenu_page( 'woocow', 'Servers', 'Servers', 'manage_woocommerce', 'woocow-servers', [ $this, 'page_servers' ] );
add_submenu_page( 'woocow', 'Assignments','Assignments', 'manage_woocommerce', 'woocow-assignments', [ $this, 'page_assignments' ] );
add_submenu_page( 'woocow', 'Mailboxes', 'Mailboxes', 'manage_woocommerce', 'woocow-mailboxes', [ $this, 'page_mailboxes' ] );
add_submenu_page( 'woocow', 'Domains', 'Domains', 'manage_woocommerce', 'woocow-domains', [ $this, 'page_domains' ] );
add_submenu_page( 'woocow', 'Transports','Transports', 'manage_woocommerce', 'woocow-transports', [ $this, 'page_transports' ] );
add_submenu_page( 'woocow', 'Logs', 'Logs', 'manage_woocommerce', 'woocow-logs', [ $this, 'page_logs' ] );
}
public function enqueue_assets( string $hook ): void {
@@ -301,6 +310,181 @@ class WooCow_Admin {
<?php
}
// ── Page: Domains ────────────────────────────────────────────────────────
public function page_domains(): void {
global $wpdb;
$servers = $wpdb->get_results( "SELECT id, name, url FROM {$wpdb->prefix}woocow_servers WHERE active=1 ORDER BY name" );
?>
<div class="wrap woocow-wrap">
<h1>WooCow Domains</h1>
<p>Add and manage domains on your Mailcow servers. After adding a domain, DNS records (including DKIM) are generated automatically.</p>
<div class="woocow-toolbar woocow-flex">
<select id="wc-dom-server" class="regular-text">
<option value="">— Select server —</option>
<?php foreach ( $servers as $s ) : ?>
<option value="<?php echo esc_attr( $s->id ); ?>"
data-url="<?php echo esc_attr( $s->url ); ?>">
<?php echo esc_html( $s->name ); ?>
</option>
<?php endforeach; ?>
</select>
<button class="button" id="wc-dom-load" disabled>Load Domains</button>
<button class="button button-primary" id="wc-dom-add-btn" style="display:none">+ Add Domain</button>
</div>
<div id="wc-dom-notices"></div>
<!-- Add Domain form -->
<div id="wc-dom-form" class="woocow-card woocow-form" style="display:none">
<h3>Add Domain</h3>
<table class="form-table">
<tr>
<th><label for="wc-dom-name">Domain</label></th>
<td><input type="text" id="wc-dom-name" class="regular-text" placeholder="example.com"></td>
</tr>
<tr>
<th><label for="wc-dom-desc">Description</label></th>
<td><input type="text" id="wc-dom-desc" class="regular-text" placeholder="Optional"></td>
</tr>
<tr>
<th><label for="wc-dom-mailboxes">Max Mailboxes</label></th>
<td><input type="number" id="wc-dom-mailboxes" value="10" min="1"></td>
</tr>
<tr>
<th><label for="wc-dom-aliases">Max Aliases</label></th>
<td><input type="number" id="wc-dom-aliases" value="400" min="0"></td>
</tr>
<tr>
<th><label for="wc-dom-quota">Total Quota (MB)</label></th>
<td><input type="number" id="wc-dom-quota" value="10240" min="1"></td>
</tr>
<tr>
<th><label for="wc-dom-defquota">Default Mailbox Quota (MB)</label></th>
<td><input type="number" id="wc-dom-defquota" value="3072" min="1"></td>
</tr>
<tr>
<th><label for="wc-dom-dkim-size">DKIM Key Size</label></th>
<td>
<select id="wc-dom-dkim-size">
<option value="2048" selected>2048 (recommended)</option>
<option value="1024">1024</option>
<option value="4096">4096</option>
</select>
</td>
</tr>
</table>
<div class="woocow-form-actions">
<button class="button button-primary" id="wc-dom-save">Add Domain &amp; Generate DKIM</button>
<button class="button" id="wc-dom-cancel">Cancel</button>
<span id="wc-dom-form-notice"></span>
</div>
</div>
<!-- Domain list -->
<div id="wc-dom-table-wrap"></div>
<!-- DNS Records panel -->
<div id="wc-dom-dns-panel" class="woocow-card" style="display:none">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<h3 style="margin:0" id="wc-dom-dns-title">DNS Records</h3>
<button class="button" id="wc-dom-dns-close">Close</button>
</div>
<p class="description">Add these records to your DNS provider for <strong id="wc-dom-dns-domain"></strong>.</p>
<div id="wc-dom-dns-content"></div>
</div>
</div>
<?php
}
// ── Page: Transports ─────────────────────────────────────────────────────
public function page_transports(): void {
global $wpdb;
$servers = $wpdb->get_results( "SELECT id, name FROM {$wpdb->prefix}woocow_servers WHERE active=1 ORDER BY name" );
?>
<div class="wrap woocow-wrap">
<h1>WooCow Sender-Dependent Transports</h1>
<p>Configure relay hosts for outbound mail delivery. Each transport can be associated with one or more domains in Mailcow.</p>
<div class="woocow-toolbar woocow-flex">
<select id="wc-tr-server" class="regular-text">
<option value="">— Select server —</option>
<?php foreach ( $servers as $s ) : ?>
<option value="<?php echo esc_attr( $s->id ); ?>"><?php echo esc_html( $s->name ); ?></option>
<?php endforeach; ?>
</select>
<button class="button" id="wc-tr-load" disabled>Load Transports</button>
<button class="button button-primary" id="wc-tr-add-btn" style="display:none">+ Add Transport</button>
</div>
<div id="wc-tr-notices"></div>
<div id="wc-tr-form" class="woocow-card woocow-form" style="display:none">
<h3>Add Transport (Relay Host)</h3>
<table class="form-table">
<tr>
<th><label for="wc-tr-hostname">Hostname:Port</label></th>
<td><input type="text" id="wc-tr-hostname" class="regular-text" placeholder="smtp.relay.com:587"></td>
</tr>
<tr>
<th><label for="wc-tr-user">SMTP Username</label></th>
<td><input type="text" id="wc-tr-user" class="regular-text"></td>
</tr>
<tr>
<th><label for="wc-tr-pass">SMTP Password</label></th>
<td><input type="password" id="wc-tr-pass" class="regular-text"></td>
</tr>
<tr>
<th><label for="wc-tr-active">Active</label></th>
<td><input type="checkbox" id="wc-tr-active" checked></td>
</tr>
</table>
<div class="woocow-form-actions">
<button class="button button-primary" id="wc-tr-save">Add Transport</button>
<button class="button" id="wc-tr-cancel">Cancel</button>
<span id="wc-tr-form-notice"></span>
</div>
</div>
<div id="wc-tr-table-wrap"></div>
</div>
<?php
}
// ── Page: Logs ────────────────────────────────────────────────────────────
public function page_logs(): void {
global $wpdb;
$servers = $wpdb->get_results( "SELECT id, name FROM {$wpdb->prefix}woocow_servers WHERE active=1 ORDER BY name" );
$log_types = [ 'postfix', 'dovecot', 'rspamd', 'ratelimit', 'api', 'acme', 'autodiscover', 'sogo', 'netfilter', 'watchdog' ];
?>
<div class="wrap woocow-wrap">
<h1>WooCow Server Logs</h1>
<div class="woocow-toolbar woocow-flex">
<select id="wc-log-server" class="regular-text">
<option value="">— Select server —</option>
<?php foreach ( $servers as $s ) : ?>
<option value="<?php echo esc_attr( $s->id ); ?>"><?php echo esc_html( $s->name ); ?></option>
<?php endforeach; ?>
</select>
<select id="wc-log-type">
<?php foreach ( $log_types as $t ) : ?>
<option value="<?php echo esc_attr( $t ); ?>"><?php echo esc_html( ucfirst( $t ) ); ?></option>
<?php endforeach; ?>
</select>
<button class="button" id="wc-log-load" disabled>Load Logs</button>
</div>
<div id="wc-log-wrap">
<p class="description">Select a server and log type above.</p>
</div>
</div>
<?php
}
// ── AJAX helpers ─────────────────────────────────────────────────────────
private function verify(): void {
@@ -632,4 +816,189 @@ class WooCow_Admin {
$this->json_ok();
}
// ── AJAX: Domains ─────────────────────────────────────────────────────────
public function ajax_woocow_admin_domain_add(): void {
$this->verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$domain = sanitize_text_field( $_POST['domain'] ?? '' );
$desc = sanitize_text_field( $_POST['description'] ?? '' );
$mailboxes = absint( $_POST['mailboxes'] ?? 10 );
$aliases = absint( $_POST['aliases'] ?? 400 );
$quota = absint( $_POST['quota'] ?? 10240 );
$defquota = absint( $_POST['defquota'] ?? 3072 );
$dkim_size = absint( $_POST['dkim_size'] ?? 2048 );
if ( ! $domain ) {
$this->json_err( 'Domain name is required.' );
}
$server = $this->get_server( $server_id );
if ( ! $server ) {
$this->json_err( 'Server not found.' );
}
$api = WooCow_API::from_server( $server );
// Add domain
$result = $api->create_domain( [
'domain' => $domain,
'description' => $desc,
'mailboxes' => $mailboxes,
'aliases' => $aliases,
'quota' => $quota,
'defquota' => $defquota,
'maxquota' => $quota,
'active' => 1,
'restart_sogo'=> 1,
] );
if ( ! $result['success'] ) {
$this->json_err( $result['error'] ?? 'Failed to add domain.' );
}
// Auto-generate DKIM
$api->generate_dkim( $domain, 'dkim', $dkim_size );
$this->json_ok( [ 'domain' => $domain ] );
}
public function ajax_woocow_admin_domain_dns(): void {
$this->verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$domain = sanitize_text_field( $_POST['domain'] ?? '' );
$server = $this->get_server( $server_id );
if ( ! $server ) {
$this->json_err( 'Server not found.' );
}
$api = WooCow_API::from_server( $server );
$mail_host = parse_url( $server->url, PHP_URL_HOST );
// Fetch DKIM
$dkim_result = $api->get_dkim( $domain );
$dkim_txt = '';
$dkim_sel = 'dkim';
if ( $dkim_result['success'] && ! empty( $dkim_result['data']['dkim_txt'] ) ) {
$dkim_txt = $dkim_result['data']['dkim_txt'];
$dkim_sel = $dkim_result['data']['dkim_selector'] ?? 'dkim';
}
$this->json_ok( [
'domain' => $domain,
'mail_host' => $mail_host,
'dkim_sel' => $dkim_sel,
'dkim_txt' => $dkim_txt,
'records' => [
[ 'type' => 'MX', 'host' => $domain, 'value' => $mail_host . '.', 'prio' => '10', 'ttl' => '3600' ],
[ 'type' => 'TXT', 'host' => $domain, 'value' => 'v=spf1 mx ~all', 'prio' => '', 'ttl' => '3600', 'note' => 'SPF' ],
[ 'type' => 'TXT', 'host' => '_dmarc.' . $domain, 'value' => 'v=DMARC1; p=quarantine; rua=mailto:postmaster@' . $domain, 'prio' => '', 'ttl' => '3600', 'note' => 'DMARC' ],
[ 'type' => 'TXT', 'host' => $dkim_sel . '._domainkey.' . $domain, 'value' => $dkim_txt ?: '(generate DKIM first)', 'prio' => '', 'ttl' => '3600', 'note' => 'DKIM' ],
[ 'type' => 'CNAME','host' => 'autoconfig.' . $domain, 'value' => $mail_host . '.', 'prio' => '', 'ttl' => '3600', 'note' => 'Autoconfig' ],
[ 'type' => 'CNAME','host' => 'autodiscover.' . $domain, 'value' => $mail_host . '.', 'prio' => '', 'ttl' => '3600', 'note' => 'Autodiscover' ],
],
] );
}
// ── AJAX: Transports ──────────────────────────────────────────────────────
public function ajax_woocow_admin_relayhosts_list(): void {
$this->verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$server = $this->get_server( $server_id );
if ( ! $server ) {
$this->json_err( 'Server not found.' );
}
$api = WooCow_API::from_server( $server );
$result = $api->get_relayhosts();
if ( ! $result['success'] ) {
$this->json_err( $result['error'] ?? 'Failed to fetch transports.' );
}
$this->json_ok( $result['data'] ?? [] );
}
public function ajax_woocow_admin_relayhost_save(): void {
$this->verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$hostname = sanitize_text_field( $_POST['hostname'] ?? '' );
$username = sanitize_text_field( $_POST['username'] ?? '' );
$password = $_POST['password'] ?? '';
$active = absint( $_POST['active'] ?? 1 );
if ( ! $hostname || ! $username ) {
$this->json_err( 'Hostname and username are required.' );
}
$server = $this->get_server( $server_id );
if ( ! $server ) {
$this->json_err( 'Server not found.' );
}
$api = WooCow_API::from_server( $server );
$result = $api->create_relayhost( compact( 'hostname', 'username', 'password', 'active' ) );
if ( ! $result['success'] ) {
$this->json_err( $result['error'] ?? 'Failed to add transport.' );
}
$this->json_ok();
}
public function ajax_woocow_admin_relayhost_delete(): void {
$this->verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$id = absint( $_POST['id'] ?? 0 );
$server = $this->get_server( $server_id );
if ( ! $server ) {
$this->json_err( 'Server not found.' );
}
$api = WooCow_API::from_server( $server );
$result = $api->delete_relayhost( $id );
if ( ! $result['success'] ) {
$this->json_err( $result['error'] ?? 'Failed to delete transport.' );
}
$this->json_ok();
}
// ── AJAX: Logs ────────────────────────────────────────────────────────────
public function ajax_woocow_admin_logs(): void {
$this->verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$log_type = sanitize_key( $_POST['log_type'] ?? 'postfix' );
$allowed = [ 'postfix', 'dovecot', 'rspamd', 'ratelimit', 'api', 'acme', 'autodiscover', 'sogo', 'netfilter', 'watchdog' ];
if ( ! in_array( $log_type, $allowed, true ) ) {
$this->json_err( 'Invalid log type.' );
}
$server = $this->get_server( $server_id );
if ( ! $server ) {
$this->json_err( 'Server not found.' );
}
$api = WooCow_API::from_server( $server );
$result = $api->request( 'GET', '/api/v1/get/logs/' . $log_type );
if ( ! $result['success'] ) {
$this->json_err( $result['error'] ?? 'Failed to fetch logs.' );
}
$this->json_ok( $result['data'] ?? [] );
}
}

View File

@@ -18,7 +18,7 @@ class WooCow_API {
// ── Core HTTP ────────────────────────────────────────────────────────────
private function request( string $method, string $endpoint, array $body = [] ): array {
public function request( string $method, string $endpoint, array $body = [] ): array {
$args = [
'method' => strtoupper( $method ),
'timeout' => $this->timeout,
@@ -145,6 +145,44 @@ class WooCow_API {
return $this->request( 'POST', '/api/v1/delete/domain-admin', [ 'items' => [ $username ] ] );
}
// ── DKIM ─────────────────────────────────────────────────────────────────
public function get_dkim( string $domain ): array {
return $this->request( 'GET', '/api/v1/get/dkim/' . rawurlencode( $domain ) );
}
public function generate_dkim( string $domain, string $selector = 'dkim', int $key_size = 2048 ): array {
return $this->request( 'POST', '/api/v1/add/dkim', [
'domains' => $domain,
'dkim_selector' => $selector,
'key_size' => $key_size,
] );
}
// ── Relayhosts ───────────────────────────────────────────────────────────
public function get_relayhosts(): array {
return $this->request( 'GET', '/api/v1/get/relayhost/all' );
}
public function create_relayhost( array $data ): array {
return $this->request( 'POST', '/api/v1/add/relayhost', $data );
}
public function delete_relayhost( int $id ): array {
return $this->request( 'POST', '/api/v1/delete/relayhost', [ 'items' => [ $id ] ] );
}
// ── Quarantine ───────────────────────────────────────────────────────────
public function get_quarantine(): array {
return $this->request( 'GET', '/api/v1/get/quarantine/all' );
}
public function delete_quarantine( int $id ): array {
return $this->request( 'POST', '/api/v1/delete/quarantine', [ 'items' => [ $id ] ] );
}
// ── Helpers ──────────────────────────────────────────────────────────────
public function get_webmail_url(): string {