Files
WooCow/includes/class-woocow-account.php
Malin 1c5b58f238 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>
2026-02-27 08:38:52 +01:00

586 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* WooCow Account WooCommerce My Account integration.
*
* Registers the "email-hosting" endpoint and handles all customer-facing AJAX.
*/
defined( 'ABSPATH' ) || exit;
class WooCow_Account {
const ENDPOINT = 'email-hosting';
public function __construct() {
// Endpoint registration
add_action( 'init', [ $this, 'register_endpoint' ] );
add_filter( 'woocommerce_account_menu_items', [ $this, 'add_menu_item' ] );
add_action( 'woocommerce_account_' . self::ENDPOINT . '_endpoint', [ $this, 'render_page' ] );
// Assets
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] );
// Password reset integration
add_action( 'woocommerce_save_account_details', [ $this, 'maybe_sync_password' ], 20 );
// AJAX
$actions = [
'woocow_acct_domains',
'woocow_acct_mailboxes',
'woocow_acct_mailbox_create',
'woocow_acct_mailbox_password',
'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 ] );
}
}
public function register_endpoint(): void {
add_rewrite_endpoint( self::ENDPOINT, EP_ROOT | EP_PAGES );
}
public function add_menu_item( array $items ): array {
// Insert before logout
$logout = $items['customer-logout'] ?? null;
unset( $items['customer-logout'] );
$items[ self::ENDPOINT ] = __( 'Email Hosting', 'woocow' );
if ( $logout ) {
$items['customer-logout'] = $logout;
}
return $items;
}
public function enqueue_assets(): void {
if ( ! is_account_page() ) {
return;
}
wp_enqueue_style( 'woocow-account', WOOCOW_PLUGIN_URL . 'assets/css/woocow.css', [], WOOCOW_VERSION );
wp_enqueue_script( 'woocow-account', WOOCOW_PLUGIN_URL . 'assets/js/woocow-account.js', [ 'jquery' ], WOOCOW_VERSION, true );
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' ),
],
] );
}
// ── My Account page render ────────────────────────────────────────────────
public function render_page(): void {
if ( ! is_user_logged_in() ) {
echo '<p>' . esc_html__( 'Please log in to manage your email hosting.', 'woocow' ) . '</p>';
return;
}
$customer_id = get_current_user_id();
$assignments = $this->get_customer_assignments( $customer_id );
if ( empty( $assignments ) ) {
echo '<div class="woocommerce-info">' . esc_html__( 'You have no email domains assigned yet. Please contact support.', 'woocow' ) . '</div>';
return;
}
?>
<div class="woocow-account" id="woocow-account">
<div id="woocow-acct-notices"></div>
<?php foreach ( $assignments as $assignment ) : ?>
<div class="woocow-domain-panel" data-assignment-id="<?php echo esc_attr( $assignment->id ); ?>"
data-server-id="<?php echo esc_attr( $assignment->server_id ); ?>"
data-domain="<?php echo esc_attr( $assignment->domain ); ?>">
<div class="woocow-domain-header">
<span class="woocow-domain-name"><?php echo esc_html( $assignment->domain ); ?></span>
<span class="woocow-domain-server"><?php echo esc_html( $assignment->server_name ); ?></span>
<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"><?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">
<div class="woocow-mailboxes-list"></div>
<!-- Create mailbox form -->
<div class="woocow-create-mbox-form" style="display:none">
<h4>Create Mailbox</h4>
<div class="woocow-field-row">
<div class="woocow-flex-inline">
<input type="text" class="wc-mbox-local woocow-input" placeholder="username">
<span class="woocow-at">@</span>
<strong><?php echo esc_html( $assignment->domain ); ?></strong>
</div>
</div>
<div class="woocow-field-row">
<input type="text" class="wc-mbox-name woocow-input" placeholder="Full Name">
</div>
<div class="woocow-field-row">
<input type="password" class="wc-mbox-pass woocow-input" placeholder="Password">
</div>
<div class="woocow-field-row">
<input type="password" class="wc-mbox-pass2 woocow-input" placeholder="Confirm Password">
</div>
<div class="woocow-field-row">
<label>Quota (MB): <input type="number" class="wc-mbox-quota woocow-input-sm" value="1024" min="1"></label>
</div>
<div class="woocow-form-actions">
<button class="woocow-btn woocow-btn-primary wc-mbox-submit">Create Mailbox</button>
<button class="woocow-btn woocow-btn-outline wc-mbox-cancel">Cancel</button>
<span class="wc-mbox-notice"></span>
</div>
</div>
<button class="woocow-btn woocow-btn-outline woocow-create-mbox-btn">+ Add Mailbox</button>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Change Password Modal -->
<div id="woocow-pw-modal" class="woocow-modal" style="display:none">
<div class="woocow-modal-box">
<h3>Change Mailbox Password</h3>
<p class="woocow-modal-subtitle" id="woocow-pw-email"></p>
<input type="hidden" id="woocow-pw-server-id">
<input type="hidden" id="woocow-pw-mailbox">
<div class="woocow-field-row">
<input type="password" id="woocow-pw-new" class="woocow-input" placeholder="New Password">
</div>
<div class="woocow-field-row">
<input type="password" id="woocow-pw-new2" class="woocow-input" placeholder="Confirm New Password">
</div>
<div class="woocow-modal-actions">
<button class="woocow-btn woocow-btn-primary" id="woocow-pw-save">Update Password</button>
<button class="woocow-btn woocow-btn-outline" id="woocow-pw-cancel">Cancel</button>
<span id="woocow-pw-notice"></span>
</div>
</div>
</div>
<?php
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function get_customer_assignments( int $customer_id ): array {
global $wpdb;
$rows = $wpdb->get_results( $wpdb->prepare( "
SELECT a.id, a.server_id, a.domain, s.name AS server_name, s.url AS server_url
FROM {$wpdb->prefix}woocow_assignments a
JOIN {$wpdb->prefix}woocow_servers s ON s.id = a.server_id
WHERE a.customer_id = %d AND s.active = 1
ORDER BY a.domain
", $customer_id ) );
foreach ( $rows as $row ) {
$row->webmail_url = rtrim( $row->server_url, '/' ) . '/SOGo';
}
return $rows;
}
private function get_server( int $id ): ?object {
global $wpdb;
return $wpdb->get_row( $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}woocow_servers WHERE id = %d AND active = 1",
$id
) );
}
/** Verify that the current user owns the given assignment. */
private function verify_ownership( int $server_id, string $domain ): bool {
global $wpdb;
$found = $wpdb->get_var( $wpdb->prepare( "
SELECT id FROM {$wpdb->prefix}woocow_assignments
WHERE customer_id = %d AND server_id = %d AND domain = %s
", get_current_user_id(), $server_id, $domain ) );
return (bool) $found;
}
private function account_verify(): void {
check_ajax_referer( 'woocow_account', 'nonce' );
if ( ! is_user_logged_in() ) {
wp_send_json_error( 'Not logged in.', 401 );
}
}
// ── AJAX: Account ─────────────────────────────────────────────────────────
public function ajax_woocow_acct_domains(): void {
$this->account_verify();
$assignments = $this->get_customer_assignments( get_current_user_id() );
wp_send_json_success( $assignments );
}
public function ajax_woocow_acct_mailboxes(): 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_domain_mailboxes( $domain );
if ( ! $result['success'] ) {
wp_send_json_error( $result['error'] ?? 'Could not load mailboxes.' );
}
wp_send_json_success( [
'mailboxes' => $result['data'] ?? [],
'webmail_url' => $api->get_webmail_url(),
] );
}
public function ajax_woocow_acct_mailbox_create(): void {
$this->account_verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$domain = sanitize_text_field( $_POST['domain'] ?? '' );
$local_part = sanitize_text_field( $_POST['local_part'] ?? '' );
$name = sanitize_text_field( $_POST['name'] ?? '' );
$password = $_POST['password'] ?? '';
$password2 = $_POST['password2'] ?? '';
$quota = absint( $_POST['quota'] ?? 1024 );
if ( ! $this->verify_ownership( $server_id, $domain ) ) {
wp_send_json_error( 'Access denied.', 403 );
}
if ( ! $local_part || ! $password ) {
wp_send_json_error( 'Username and password are required.' );
}
if ( $password !== $password2 ) {
wp_send_json_error( 'Passwords do not match.' );
}
$server = $this->get_server( $server_id );
if ( ! $server ) {
wp_send_json_error( 'Server unavailable.' );
}
$api = WooCow_API::from_server( $server );
$result = $api->create_mailbox( [
'local_part' => $local_part,
'domain' => $domain,
'name' => $name ?: $local_part,
'password' => $password,
'password2' => $password2,
'quota' => $quota,
'active' => 1,
'force_pw_update' => 0,
'tls_enforce_in' => 0,
'tls_enforce_out' => 0,
] );
if ( ! $result['success'] ) {
wp_send_json_error( $result['error'] ?? 'Failed to create mailbox.' );
}
wp_send_json_success( [ 'email' => $local_part . '@' . $domain ] );
}
public function ajax_woocow_acct_mailbox_password(): void {
$this->account_verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$domain = sanitize_text_field( $_POST['domain'] ?? '' );
$email = sanitize_email( $_POST['email'] ?? '' );
$password = $_POST['password'] ?? '';
$password2 = $_POST['password2'] ?? '';
if ( ! $this->verify_ownership( $server_id, $domain ) ) {
wp_send_json_error( 'Access denied.', 403 );
}
if ( ! $password || $password !== $password2 ) {
wp_send_json_error( 'Passwords do not match or are empty.' );
}
// Validate that email belongs to the customer's domain.
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->edit_mailbox( [ $email ], [
'password' => $password,
'password2' => $password2,
] );
if ( ! $result['success'] ) {
wp_send_json_error( $result['error'] ?? 'Failed to update password.' );
}
wp_send_json_success();
}
public function ajax_woocow_acct_aliases(): 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_all_aliases();
if ( ! $result['success'] ) {
wp_send_json_error( $result['error'] ?? 'Could not load aliases.' );
}
// Filter to this domain only.
$aliases = array_filter( (array) $result['data'], function ( $a ) use ( $domain ) {
return isset( $a['address'] ) && str_ends_with( $a['address'], '@' . $domain );
} );
wp_send_json_success( array_values( $aliases ) );
}
public function ajax_woocow_acct_alias_create(): void {
$this->account_verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$domain = sanitize_text_field( $_POST['domain'] ?? '' );
$address = sanitize_email( $_POST['address'] ?? '' );
$goto = sanitize_email( $_POST['goto'] ?? '' );
if ( ! $this->verify_ownership( $server_id, $domain ) ) {
wp_send_json_error( 'Access denied.', 403 );
}
if ( ! $address || ! $goto ) {
wp_send_json_error( 'Alias address and destination are required.' );
}
if ( ! str_ends_with( $address, '@' . $domain ) ) {
wp_send_json_error( 'Alias address must 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->create_alias( [
'address' => $address,
'goto' => $goto,
'active' => 1,
] );
if ( ! $result['success'] ) {
wp_send_json_error( $result['error'] ?? 'Failed to create alias.' );
}
wp_send_json_success();
}
public function ajax_woocow_acct_alias_delete(): void {
$this->account_verify();
$server_id = absint( $_POST['server_id'] ?? 0 );
$domain = sanitize_text_field( $_POST['domain'] ?? '' );
$alias_id = absint( $_POST['alias_id'] ?? 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_alias( $alias_id );
if ( ! $result['success'] ) {
wp_send_json_error( $result['error'] ?? 'Failed to delete alias.' );
}
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 ───────────────────────────────────────────────
/**
* When a customer saves their WooCommerce account details with a new password,
* offer them the option to sync it to all their mailboxes.
*
* NOTE: This hook fires after the WP password has been updated.
* We only sync if the customer explicitly checked the option.
*/
public function maybe_sync_password( int $user_id ): void {
// Only proceed if the "sync to mailcow" checkbox was checked and a new password given.
if ( empty( $_POST['woocow_sync_pw'] ) || empty( $_POST['password_1'] ) ) {
return;
}
$password = $_POST['password_1'];
global $wpdb;
$assignments = $wpdb->get_results( $wpdb->prepare( "
SELECT a.server_id, a.domain, s.url, s.api_key
FROM {$wpdb->prefix}woocow_assignments a
JOIN {$wpdb->prefix}woocow_servers s ON s.id = a.server_id
WHERE a.customer_id = %d AND s.active = 1
", $user_id ) );
foreach ( $assignments as $assignment ) {
$api = new WooCow_API( $assignment->url, $assignment->api_key );
$mboxes = $api->get_domain_mailboxes( $assignment->domain );
if ( ! $mboxes['success'] ) {
continue;
}
foreach ( (array) ( $mboxes['data'] ?? [] ) as $mbox ) {
$api->edit_mailbox( [ $mbox['username'] ], [
'password' => $password,
'password2' => $password,
] );
}
}
}
}