get_col($wpdb->prepare('SELECT id FROM %i WHERE cleanup_locked = 0 ORDER BY id DESC LIMIT %d', Database::table('commentmate_reviews'), self::MAX_REVIEW_SCAN)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. foreach (array_values(array_unique(array_filter(array_map('absint', is_array($ids) ? $ids : array())))) as $review_id) { $state = self::load_state($review_id); foreach (array('daily', 'immediate', 'completion') as $channel) { $entry = self::channel_state($state, $channel); if ($entry['failure_count'] > 0) $rows[] = array_merge($entry, array('review_id' => $review_id, 'channel' => $channel)); } } return array_slice($rows, 0, self::MAX_REVIEW_SCAN * 3); } /** Current owner retries only a failed delivery using its existing lease/watermark. */ public static function retry_failed(int $review_id, string $channel, int $attempt, int $failures): bool|\WP_Error { if (!Owner::can_manage_current_user() || !in_array($channel, array('daily', 'immediate', 'completion'), true)) return Security::error('owner_required', __('Only a site administrator may retry notifications.', 'commentmate'), 403); $entry = self::channel_state(self::load_state($review_id), $channel); if ($entry['failure_count'] < 1 || $entry['last_attempt_at'] !== $attempt || $entry['failure_count'] !== $failures) return Security::error('notification_changed', __('Notification state changed. Reload before retrying.', 'commentmate'), 409); if ('completion' === $channel) { $result = self::complete_review($review_id, 'owner-retry', __('Reviewer', 'commentmate'), get_current_user_id(), array($attempt, $failures)); return is_wp_error($result) ? $result : !empty($result['notified']); } return self::deliver($review_id, $channel, array($attempt, $failures), get_current_user_id()); } public static function completion_enabled(): bool { $settings = self::settings(); return !empty($settings['enabled']) && !empty($settings['recipients']); } /** Explicit reviewer completion. No share secrets or attachment data enter mail. */ public static function complete_review(int $review_id, string $actor, string $name, int $retry_owner = 0, ?array $expected_failure = null): array|\WP_Error { $settings = self::settings(); if (!self::completion_enabled()) return array('notified' => false); $lock = self::acquire_delivery_lock($review_id); if (null === $lock) return Security::error('notification_busy', __('A notification is already being processed. Please try again shortly.', 'commentmate'), 409); try { $state = self::load_state($review_id); $entry = self::channel_state($state, 'completion'); if ($expected_failure !== null && array($entry['last_attempt_at'], $entry['failure_count']) !== $expected_failure) return Security::error('notification_changed', __('Notification state changed. Reload before retrying.', 'commentmate'), 409); global $wpdb; $table = Database::table('commentmate_comments'); $counts = $wpdb->get_row($wpdb->prepare('SELECT COUNT(*) AS total, SUM(status = \'open\') AS active, SUM(status = \'resolved\') AS resolved, MAX(updated_at) AS latest FROM %i WHERE review_id = %d AND deleted_at IS NULL', $table, $review_id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. if (!is_array($counts)) return Security::error('notification_unavailable', __('Feedback could not be checked. Please try again.', 'commentmate'), 503); // Bound duplicate clicks per reviewer and revision without recording names or email bodies. $key = 'commentmate_completion_' . $review_id . '_' . substr(hash('sha256', $actor), 0, 24); $revision = hash('sha256', wp_json_encode($counts)); // A newly recorded failure must not reuse an earlier owner-retry success // for the same revision. Normal reviewer duplicate-click suppression stays. if (($expected_failure === null || $retry_owner < 1) && get_transient($key) === $revision) return array('notified' => true); $site = self::display_text((string) get_bloginfo('name'), 160); /* translators: %s: website name. */ $subject = sanitize_text_field(sprintf(__('CommentMate: commenting complete — %s', 'commentmate'), $site)); $plain = implode("\r\n", array( $subject, '', /* translators: %s: reviewer display name. */ sprintf(__('%s has finished commenting. Please review the feedback.', 'commentmate'), self::display_text($name ?: __('Reviewer', 'commentmate'), 80)), /* translators: %s: website name. */ sprintf(__('Site: %s', 'commentmate'), $site), /* translators: %s: local completion date and time. */ sprintf(__('Completed: %s', 'commentmate'), wp_date('j M Y, g:i a T')), /* translators: 1: review ID, 2: total comments, 3: active comments, 4: resolved comments. */ sprintf(__('Review #%1$d: %2$d comments (%3$d active, %4$d resolved).', 'commentmate'), $review_id, (int) $counts['total'], (int) $counts['active'], (int) $counts['resolved']), __('Open CommentMate (requires a WordPress administrator login):', 'commentmate'), admin_url('admin.php?page=commentmate'), '', __('This finishes this reviewer’s visit. Sharing stays active and comments are retained.', 'commentmate'), )); $html = '
' . nl2br(esc_html($plain)) . '

' . esc_html__('Review comments', 'commentmate') . '

'; $sent = self::send_mail_with_review_lock($review_id, $settings['recipients'], array('subject' => $subject, 'plain' => $plain, 'html' => $html), $settings, $retry_owner); if (null !== $sent && self::review_allows_delivery($review_id)) { $entry['last_attempt_at'] = time(); $entry['failure_count'] = $sent ? 0 : min(20, $entry['failure_count'] + 1); if ($sent) $entry['last_success_at'] = time(); $state['completion'] = $entry; self::save_state($review_id, $state); } if (true !== $sent) return Security::error('notification_failed', __('The admin notification could not be sent. Your comments are saved. Please try again.', 'commentmate'), 503); set_transient($key, $revision, 5 * MINUTE_IN_SECONDS); return array('notified' => true); } finally { self::release_delivery_lock($review_id, $lock); } } /** * @return array{enabled:bool,recipients:list,daily_summary:bool,daily_time:string,immediate_alerts:bool,detail_level:string} */ public static function default_settings(): array { return array( 'enabled' => true, 'recipients' => self::normalize_recipients(get_option('admin_email', '')), 'daily_summary' => false, 'daily_time' => '09:00', 'immediate_alerts' => false, 'detail_level' => 'activity', ); } /** * Returns a normalized view even if an older site stored a malformed value. * Reads intentionally do not write, which keeps ordinary front-end requests * free of configuration mutations. * * @return array{enabled:bool,recipients:list,daily_summary:bool,daily_time:string,immediate_alerts:bool,detail_level:string} */ public static function settings(): array { return self::sanitize_settings(get_option(self::SETTINGS_OPTION, self::default_settings())); } /** * Ensures activation writes a non-autoloaded normalized option exactly once. * * @return array{enabled:bool,recipients:list,daily_summary:bool,daily_time:string,immediate_alerts:bool,detail_level:string} */ public static function ensure_settings_option(): array { $raw = get_option(self::SETTINGS_OPTION, null); $settings = self::sanitize_settings($raw); if (null === $raw) { add_option(self::SETTINGS_OPTION, $settings, '', false); } elseif ($settings !== $raw) { update_option(self::SETTINGS_OPTION, $settings, false); } return $settings; } /** * Persists only the canonical settings shape. The admin request boundary is * responsible for owner authorization and nonce verification; keeping this * service free of request globals also makes it safe for activation and cron. * * @return array{enabled:bool,recipients:list,daily_summary:bool,daily_time:string,immediate_alerts:bool,detail_level:string} */ public static function update_settings(mixed $settings): array { $previous = self::settings(); $normalised = self::sanitize_settings($settings); if (!add_option(self::SETTINGS_OPTION, $normalised, '', false)) { update_option(self::SETTINGS_OPTION, $normalised, false); } $daily_time_changed = (string) $previous['daily_time'] !== (string) $normalised['daily_time']; self::sync_schedules($daily_time_changed); return $normalised; } /** Persist an owner form only after revalidating ownership under lifecycle locks. */ public static function update_settings_for_owner(mixed $settings, int $actor): array|\WP_Error { global $wpdb; if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('notification_settings_failed', __('Notification settings could not be saved. Try again.', 'commentmate'), 503); } $committed = false; try { if (!Owner::lock_actor($actor)) { return Security::error('owner_changed', __('Administrator access could not be verified. Reload CommentMate before saving notification settings.', 'commentmate'), 409); } $previous = self::durable_settings(); if (!is_array($previous)) { return Security::error('notification_settings_failed', __('Notification settings could not be checked. Try again.', 'commentmate'), 503); } $normalised = self::sanitize_settings($settings); if (false === $wpdb->query($wpdb->prepare('INSERT INTO %i (option_name, option_value, autoload) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE option_value = VALUES(option_value), autoload = VALUES(autoload)', $wpdb->options, self::SETTINGS_OPTION, maybe_serialize($normalised), 'off')) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. || false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return Security::error('notification_settings_failed', __('Notification settings could not be saved. Try again.', 'commentmate'), 503); } $committed = true; } finally { if (!$committed) $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. wp_cache_delete(self::SETTINGS_OPTION, 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); } self::sync_schedules($previous['daily_time'] !== $normalised['daily_time']); return $normalised; } /** Bypass request-local and persistent option caches at the final delivery fence. */ private static function durable_settings(): ?array { global $wpdb; $value = $wpdb->get_var($wpdb->prepare('SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, self::SETTINGS_OPTION)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. if ('' !== (string) ($wpdb->last_error ?? '')) return null; return self::sanitize_settings(null === $value ? self::default_settings() : maybe_unserialize($value)); } /** * Alias for owner-facing integrations that describe mutations as saves. * * @return array{enabled:bool,recipients:list,daily_summary:bool,daily_time:string,immediate_alerts:bool,detail_level:string} */ public static function save_settings(mixed $settings): array { return self::update_settings($settings); } /** * Sanitizes raw settings into the only option shape stored by this service. * * @return array{enabled:bool,recipients:list,daily_summary:bool,daily_time:string,immediate_alerts:bool,detail_level:string} */ public static function sanitize_settings(mixed $settings): array { $settings = is_array($settings) ? $settings : array(); $defaults = self::default_settings(); $detail_level = sanitize_key((string) ($settings['detail_level'] ?? $settings['detailLevel'] ?? $defaults['detail_level'])); if (!in_array($detail_level, array('activity', 'detailed'), true)) { $detail_level = 'activity'; } return array( 'enabled' => self::to_bool($settings['enabled'] ?? $defaults['enabled']), 'recipients' => self::normalize_recipients($settings['recipients'] ?? $settings['recipient'] ?? $defaults['recipients']), 'daily_summary' => self::to_bool($settings['daily_summary'] ?? $settings['dailySummary'] ?? false), 'daily_time' => self::sanitize_daily_time($settings['daily_time'] ?? $settings['dailyTime'] ?? $defaults['daily_time']), 'immediate_alerts' => self::to_bool($settings['immediate_alerts'] ?? $settings['immediateAlerts'] ?? false), 'detail_level' => $detail_level, ); } /** * Accepts a comma, semicolon, or newline separated field as well as a list * from an admin form. Addresses are validated, lower-cased for deterministic * deduplication, and capped before they can become a mail fan-out. * * @return list */ public static function normalize_recipients(mixed $recipients): array { $candidates = array(); self::collect_recipient_values($recipients, $candidates); $output = array(); $seen = array(); foreach (array_slice($candidates, 0, self::MAX_RECIPIENT_INPUTS) as $candidate) { if (!is_scalar($candidate)) { continue; } $value = trim(wp_check_invalid_utf8((string) $candidate)); if ('' === $value || strlen($value) > 254) { continue; } $email = sanitize_email($value); if ('' === $email || !is_email($email)) { continue; } $email = strtolower($email); if (isset($seen[$email])) { continue; } $seen[$email] = true; $output[] = $email; if (count($output) >= self::MAX_RECIPIENTS) { break; } } return $output; } /** * British-spelling alias retained for integrations written against planning * documentation; stored values always use normalize_recipients(). * * @return list */ public static function normalise_recipients(mixed $recipients): array { return self::normalize_recipients($recipients); } /** * @param array|null $settings */ public static function daily_summary_enabled(?array $settings = null): bool { $settings = null === $settings ? self::settings() : self::sanitize_settings($settings); return !empty($settings['enabled']) && !empty($settings['daily_summary']) && !empty($settings['recipients']); } /** * @param array|null $settings */ public static function immediate_alerts_enabled(?array $settings = null): bool { $settings = null === $settings ? self::settings() : self::sanitize_settings($settings); return !empty($settings['enabled']) && !empty($settings['immediate_alerts']) && !empty($settings['recipients']); } /** * Schedules a feedback check only after the guest transaction has committed. * No message content, guest identity, or URL is copied into the cron event. */ public static function queue_feedback(int $review_id, int $comment_id = 0, int $message_id = 0): void { if ($review_id < 1 || $comment_id < 1 || $message_id < 1) { return; } $settings = self::settings(); if (self::daily_summary_enabled($settings)) { self::ensure_daily_schedule(); } if (self::immediate_alerts_enabled($settings)) { self::schedule_immediate($review_id, time() + self::IMMEDIATE_DELAY_SECONDS); add_action('shutdown', static function () use ($review_id): void { self::run_immediate($review_id); }); } } /** * Runs a daily activity pass. Each invocation calculates and schedules the * next local-site due time instead of using the UTC `daily` interval. */ public static function run_daily(): void { if (!self::daily_summary_enabled()) { wp_clear_scheduled_hook(self::DAILY_HOOK); return; } foreach (self::review_ids_with_feedback() as $review_id) { self::deliver($review_id, 'daily'); } self::ensure_daily_schedule(); } /** * Runs a coalesced, asynchronous immediate alert for one review. */ public static function run_immediate(mixed $review_id): void { $review_id = absint($review_id); if ($review_id < 1 || !self::immediate_alerts_enabled()) { return; } self::deliver($review_id, 'immediate'); } /** * Returns the next daily due time in Unix UTC seconds while honouring the * WordPress site timezone. This is public for deterministic local tests. */ public static function next_daily_timestamp(?int $now = null, ?string $time = null): int { $settings = self::settings(); $time = self::sanitize_daily_time($time ?? (string) $settings['daily_time']); return self::next_daily_timestamp_in_timezone($time, wp_timezone(), $now ?? time()); } /** * Pure timezone calculation used by next_daily_timestamp() and tests. */ public static function next_daily_timestamp_in_timezone(string $time, \DateTimeZone $timezone, int $now): int { $time = self::sanitize_daily_time($time); list($hour, $minute) = array_map('intval', explode(':', $time)); $now = max(0, $now); $local_now = (new \DateTimeImmutable('@' . $now))->setTimezone($timezone); $due = $local_now->setTime($hour, $minute, 0); if ($due->getTimestamp() <= $now) { $due = $local_now->modify('+1 day')->setTime($hour, $minute, 0); } return $due->getTimestamp(); } /** * Removes every job and bounded state record belonging to one review. The * cleanup engine should call this once it owns the review lifecycle lock, * before deleting review rows. */ public static function clear_review(int $review_id): void { if ($review_id < 1) { return; } wp_clear_scheduled_hook(self::IMMEDIATE_HOOK, array($review_id)); delete_option(self::state_option($review_id)); delete_option(self::lock_option($review_id)); } /** * Lets Cleanup_Service verify that its review-scoped notification cleanup * succeeded without inspecting private option names or delivery contents. */ public static function has_review_state(int $review_id): bool { if ($review_id < 1) { return false; } return false !== get_option(self::state_option($review_id), false) || false !== get_option(self::lock_option($review_id), false) || false !== wp_next_scheduled(self::IMMEDIATE_HOOK, array($review_id)); } /** * Clears all notification schedules and delivery state while deliberately * retaining the reusable settings option. This supports the uninstall * preserve-data choice and is safe on deactivation-adjacent lifecycle paths. */ public static function clear_all_delivery_state(): void { wp_clear_scheduled_hook(self::DAILY_HOOK); wp_clear_scheduled_hook(self::IMMEDIATE_HOOK); self::delete_options_with_prefix(self::STATE_PREFIX); self::delete_options_with_prefix(self::LOCK_PREFIX); } /** * Generates an owner-authenticated endpoint for an exact comment. It carries * only numeric identifiers: no share link, session, CSRF token, attachment * URL, page URL, or private storage detail is ever placed in an email. */ public static function owner_comment_url(int $review_id, int $comment_id): string { return self::owner_comment_action_url(max(0, $review_id), max(0, $comment_id)); } /** * Authenticated handler for email links. It verifies the designated owner * again before reading the page target, then redirects to the existing * owner-context focus URL that contains no capability secret. */ public static function redirect_to_owner_comment(): void { if (!is_user_logged_in()) { self::redirect_to_owner_login(); return; } if (!Owner::can_manage_current_user()) { wp_die(esc_html__('Only a site administrator may open notification feedback links.', 'commentmate'), '', array('response' => 403)); } $review_id = self::requested_positive_id('review_id'); $comment_id = self::requested_positive_id('comment_id'); $context = self::comment_context($review_id, $comment_id); if (!is_array($context)) { wp_die(esc_html__('That CommentMate feedback item is no longer available.', 'commentmate'), '', array('response' => 404)); } Security::send_no_cache_headers(); wp_safe_redirect( Admin_Board::context_url( (string) $context['canonical_url'], (int) $context['id'], (int) $context['comment_number'] ) ); exit; } /** * Sends anonymous recipients through the standard WordPress login flow. The * returned action endpoint still enforces designated-owner authorization. */ public static function redirect_to_owner_login(): void { $review_id = self::requested_positive_id('review_id'); $comment_id = self::requested_positive_id('comment_id'); $target = self::owner_comment_action_url($review_id, $comment_id); wp_safe_redirect(wp_login_url($target)); exit; } /** * @param 'daily'|'immediate' $channel */ private static function deliver(int $review_id, string $channel, ?array $expected_failure = null, int $retry_owner = 0): bool { if ($review_id < 1 || !self::channel_enabled($channel)) { return false; } $lock_token = self::acquire_delivery_lock($review_id); if (!is_string($lock_token)) { return false; } try { // Recheck after taking the lease so a master-off setting takes effect // before any query or wp_mail() call in a previously queued callback. if (!self::channel_enabled($channel) || !self::review_allows_delivery($review_id)) { return false; } $state = self::load_state($review_id); $channel_state = self::channel_state($state, $channel); if ($expected_failure !== null && array($channel_state['last_attempt_at'], $channel_state['failure_count']) !== $expected_failure) return false; $batch = self::feedback_items($review_id, (int) $channel_state['watermark']); if (empty($batch['items'])) { if ((int) $channel_state['failure_count'] > 0 || (int) $channel_state['next_retry_at'] > 0) { $channel_state['failure_count'] = 0; $channel_state['next_retry_at'] = 0; $state[$channel] = $channel_state; self::save_state($review_id, $state); } return false; } $rendered = self::render_mail($batch['items'], self::settings()); // Settings can change while the query/render step is running. A second // read is the final master switch gate immediately before delivery. The // cleanup gate is also repeated so a queued callback cannot mail a review // after Cleanup_Service has committed its lifecycle lock. $settings = self::settings(); if (!self::channel_enabled($channel, $settings) || !self::review_allows_delivery($review_id)) { return false; } $sent = self::send_mail_with_review_lock($review_id, (array) $settings['recipients'], $rendered, $settings, $retry_owner); // A cleanup transaction owns the same review-row lock. If it committed // first, do not turn its removed notification state into a retry record. if (null === $sent) { return false; } // A cleanup lock may have landed while the local mail transport was // running. Do not recreate delivery state after cleanup has cleared it. if (!self::review_allows_delivery($review_id)) { return false; } $channel_state['last_attempt_at'] = time(); if ($sent) { // The watermark moves only after wp_mail() reports success. The // monotonic message id also preserves feedback created mid-delivery // for the next run, including records with the same SQL timestamp. $channel_state['watermark'] = max((int) $channel_state['watermark'], (int) $batch['last_id']); $channel_state['last_success_at'] = time(); $channel_state['failure_count'] = 0; $channel_state['next_retry_at'] = 0; $state[$channel] = $channel_state; self::save_state($review_id, $state); if (!empty($batch['has_more'])) { self::schedule_follow_up($review_id, $channel); } return true; } $channel_state['failure_count'] = min(20, (int) $channel_state['failure_count'] + 1); $retry_at = time() + self::retry_delay((int) $channel_state['failure_count']); $channel_state['next_retry_at'] = $retry_at; $state[$channel] = $channel_state; self::save_state($review_id, $state); self::schedule_retry($review_id, $channel, $retry_at); return false; } finally { self::release_delivery_lock($review_id, $lock_token); } } /** * @param 'daily'|'immediate' $channel * @param array|null $settings */ private static function channel_enabled(string $channel, ?array $settings = null): bool { $settings = null === $settings ? self::settings() : $settings; return 'daily' === $channel ? self::daily_summary_enabled($settings) : self::immediate_alerts_enabled($settings); } private static function sync_schedules(bool $force_daily = false): void { if (!self::daily_summary_enabled()) { wp_clear_scheduled_hook(self::DAILY_HOOK); } else { self::ensure_daily_schedule($force_daily); } if (!self::immediate_alerts_enabled()) { wp_clear_scheduled_hook(self::IMMEDIATE_HOOK); } } private static function ensure_daily_schedule(bool $force = false): void { $settings = self::settings(); if (!self::daily_summary_enabled($settings)) { wp_clear_scheduled_hook(self::DAILY_HOOK); return; } if ($force) { wp_clear_scheduled_hook(self::DAILY_HOOK); } if (false !== wp_next_scheduled(self::DAILY_HOOK)) { return; } wp_schedule_single_event( self::next_daily_timestamp(null, (string) $settings['daily_time']), self::DAILY_HOOK ); } private static function schedule_immediate(int $review_id, int $due_at): void { if ($review_id < 1 || false !== wp_next_scheduled(self::IMMEDIATE_HOOK, array($review_id))) { return; } wp_schedule_single_event(max(time() + 1, $due_at), self::IMMEDIATE_HOOK, array($review_id)); } /** * @param 'daily'|'immediate' $channel */ private static function schedule_retry(int $review_id, string $channel, int $retry_at): void { if ('immediate' === $channel) { self::schedule_immediate($review_id, $retry_at); return; } // A daily retry uses the same global callback. WordPress coalesces close // duplicate single events, and the per-review lease prevents duplicate mail // if more than one failed review requests a retry in the same window. wp_schedule_single_event(max(time() + 1, $retry_at), self::DAILY_HOOK); } /** * @param 'daily'|'immediate' $channel */ private static function schedule_follow_up(int $review_id, string $channel): void { if ('immediate' === $channel) { self::schedule_immediate($review_id, time() + 1); return; } wp_schedule_single_event(time() + 1, self::DAILY_HOOK); } private static function retry_delay(int $failure_count): int { $multiplier = 2 ** max(0, min(6, $failure_count - 1)); return min(self::MAX_RETRY_SECONDS, 300 * $multiplier); } /** * Treat a cleanup lifecycle lock as an absolute delivery stop. This remains * a fast preflight; send_mail_with_review_lock() supplies the definitive * row-level gate immediately around wp_mail() so cleanup and delivery cannot * pass each other between separate reads. */ private static function review_allows_delivery(int $review_id): bool { if ($review_id < 1) { return false; } global $wpdb; $review = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. $wpdb->prepare('SELECT id FROM %i WHERE id = %d AND cleanup_locked = 0 LIMIT 1', Database::table('commentmate_reviews'), $review_id) ); return null !== $review; } /** * @return list */ private static function review_ids_with_feedback(): array { global $wpdb; $reviews = Database::table('commentmate_reviews'); $comments = Database::table('commentmate_comments'); $messages = Database::table('commentmate_comment_messages'); $rows = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. $wpdb->prepare("SELECT DISTINCT c.review_id FROM %i AS c INNER JOIN %i AS m ON m.comment_id = c.id INNER JOIN %i AS r ON r.id = c.review_id WHERE c.deleted_at IS NULL AND m.deleted_at IS NULL AND m.author_session_id IS NOT NULL AND m.author_user_id IS NULL AND r.cleanup_locked = 0 ORDER BY c.review_id DESC LIMIT %d", $comments, $messages, $reviews, self::MAX_REVIEW_SCAN) ); return array_values(array_unique(array_filter(array_map('absint', is_array($rows) ? $rows : array())))); } /** * @return array{items:list>,last_id:int,has_more:bool} */ private static function feedback_items(int $review_id, int $after_message_id): array { global $wpdb; $comments = Database::table('commentmate_comments'); $messages = Database::table('commentmate_comment_messages'); $pages = Database::table('commentmate_pages'); $reviews = Database::table('commentmate_reviews'); $limit = self::MAX_FEEDBACK_ITEMS + 1; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. $wpdb->prepare( "SELECT m.id AS message_id, m.comment_id, m.body_html, m.created_at, c.comment_number, p.page_title, CASE WHEN m.id = ( SELECT MIN(first_message.id) FROM %i AS first_message WHERE first_message.comment_id = c.id ) THEN 'comment' ELSE 'reply' END AS feedback_type FROM %i AS m INNER JOIN %i AS c ON c.id = m.comment_id INNER JOIN %i AS p ON p.id = c.page_id INNER JOIN %i AS r ON r.id = c.review_id WHERE c.review_id = %d AND c.deleted_at IS NULL AND m.deleted_at IS NULL AND m.author_session_id IS NOT NULL AND m.author_user_id IS NULL AND r.cleanup_locked = 0 AND m.id > %d ORDER BY m.id ASC LIMIT %d", $messages, $messages, $comments, $pages, $reviews, $review_id, max(0, $after_message_id), $limit ), ARRAY_A ); $rows = is_array($rows) ? $rows : array(); $has_more = count($rows) > self::MAX_FEEDBACK_ITEMS; if ($has_more) { $rows = array_slice($rows, 0, self::MAX_FEEDBACK_ITEMS); } $items = array(); $last_id = $after_message_id; foreach ($rows as $row) { $message_id = absint($row['message_id'] ?? 0); $comment_id = absint($row['comment_id'] ?? 0); if ($message_id < 1 || $comment_id < 1) { continue; } $last_id = max($last_id, $message_id); $items[] = array( 'review_id' => $review_id, 'message_id' => $message_id, 'comment_id' => $comment_id, 'comment_number' => max(1, absint($row['comment_number'] ?? $comment_id)), 'page_title' => self::display_text((string) ($row['page_title'] ?? ''), 180), 'body_html' => (string) ($row['body_html'] ?? ''), 'created_at' => (string) ($row['created_at'] ?? ''), 'feedback_type' => 'reply' === (string) ($row['feedback_type'] ?? '') ? 'reply' : 'comment', ); } return array( 'items' => $items, 'last_id' => $last_id, 'has_more' => $has_more, ); } /** * @param list> $items * @param array $settings * @return array{subject:string,plain:string,html:string} */ private static function render_mail(array $items, array $settings): array { $count = count($items); $subject = sprintf( /* translators: %d: number of new CommentMate feedback items. */ _n('CommentMate: %d new client feedback item', 'CommentMate: %d new client feedback items', $count, 'commentmate'), $count ); $subject = sanitize_text_field($subject); $detail = 'detailed' === (string) ($settings['detail_level'] ?? 'activity'); $plain = array( $subject, '', __('A client has added feedback in CommentMate.', 'commentmate'), '', ); $html_items = array(); foreach ($items as $item) { $type = 'reply' === (string) ($item['feedback_type'] ?? '') ? __('New reply', 'commentmate') : __('New comment', 'commentmate'); $page_title = (string) ($item['page_title'] ?? ''); if ('' === $page_title) { $page_title = __('Untitled page', 'commentmate'); } $timestamp = self::display_time((string) ($item['created_at'] ?? '')); $url = self::owner_comment_url((int) ($item['review_id'] ?? 0), (int) ($item['comment_id'] ?? 0)); if ('' === $url || (int) ($item['comment_id'] ?? 0) < 1) { continue; } $plain[] = $type . ': ' . $page_title; $plain[] = sprintf( /* translators: %s: local feedback timestamp. */ __('Received: %s', 'commentmate'), $timestamp ); if ($detail) { $plain[] = sprintf( /* translators: %s: a short sanitized feedback excerpt. */ __('Feedback: %s', 'commentmate'), self::excerpt((string) ($item['body_html'] ?? '')) ); } $plain[] = __('Open this feedback (requires your WordPress owner login):', 'commentmate'); $plain[] = $url; $plain[] = ''; $html_item = '
'; $html_item .= '

' . esc_html($type) . '

'; $html_item .= '

' . esc_html__('Page:', 'commentmate') . ' ' . esc_html($page_title) . '

'; $html_item .= '

' . esc_html__('Received:', 'commentmate') . ' ' . esc_html($timestamp) . '

'; if ($detail) { $html_item .= '

' . esc_html__('Feedback:', 'commentmate') . ' ' . esc_html(self::excerpt((string) ($item['body_html'] ?? ''))) . '

'; } $html_item .= '

' . esc_html__('Open feedback in CommentMate', 'commentmate') . '

'; $html_item .= '
'; $html_items[] = $html_item; } $plain[] = __('CommentMate never includes attachments, screenshots, review-link secrets, session data, or private storage details in notification emails.', 'commentmate'); $plain[] = __('A WordPress administrator account is required to open this feedback.', 'commentmate'); $plain_body = implode("\r\n", $plain); $html_body = ''; $html_body .= '
'; $html_body .= '

' . esc_html($subject) . '

'; $html_body .= '

' . esc_html__('A client has added feedback in CommentMate.', 'commentmate') . '

'; $html_body .= implode('', $html_items); $html_body .= '

' . esc_html__('CommentMate never includes attachments, screenshots, review-link secrets, session data, or private storage details in notification emails.', 'commentmate') . '

'; $html_body .= '

' . esc_html__('A WordPress administrator account is required to open this feedback.', 'commentmate') . '

'; $html_body .= '
'; return array( 'subject' => $subject, 'plain' => $plain_body, 'html' => $html_body, ); } /** * Calls wp_mail only at cron delivery time. Tests exercise sanitizers and * time calculations only, never this method. * * @param list $recipients * @param array{subject:string,plain:string,html:string} $rendered */ private static function send_mail(array $recipients, array $rendered): bool { if (empty($recipients) || '' === trim((string) $rendered['subject']) || '' === (string) $rendered['plain'] || '' === (string) $rendered['html']) { return false; } // wp_mail() owns the configured PHPMailer instance. Configure its two // in-memory bodies only for this one send so PHPMailer creates a standards // compliant multipart/alternative message with a complete text fallback. $configure_mailer = static function (object $mailer) use ($rendered): void { if (method_exists($mailer, 'isHTML')) { $mailer->isHTML(true); } $mailer->Body = (string) $rendered['html']; $mailer->AltBody = (string) $rendered['plain']; }; add_action('phpmailer_init', $configure_mailer); try { return (bool) wp_mail($recipients, (string) $rendered['subject'], (string) $rendered['html'], array(), array()); } catch (\Throwable) { return false; } finally { remove_action('phpmailer_init', $configure_mailer); } } /** * Serializes the irreversible mail hand-off with Cleanup_Service's review * lifecycle lock. The delivery lease avoids duplicate cron work, but its * expiry alone cannot prove an older worker will not call wp_mail(). Holding * the review row until the local mail call returns gives cleanup a durable * ordering: either mail completes first, or cleanup commits first and this * method returns null without sending. * * @param list $recipients * @param array{subject:string,plain:string,html:string} $rendered * @return bool|null True/false is the mail result; null means cleanup has * already locked or removed the review. */ private static function send_mail_with_review_lock(int $review_id, array $recipients, array $rendered, array $expected_settings, int $retry_owner = 0): ?bool { if ($review_id < 1) { return null; } global $wpdb; $reviews = Database::table('commentmate_reviews'); $expected_review = $wpdb->get_row($wpdb->prepare('SELECT owner_user_id, token_version FROM %i WHERE id = %d', $reviews, $review_id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. if (!is_array($expected_review)) return null; if (false === $wpdb->query('START TRANSACTION')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return false; } $committed = false; try { $review = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. $wpdb->prepare('SELECT id, cleanup_locked, owner_user_id, token_version FROM %i WHERE id = %d FOR UPDATE', $reviews, $review_id), ARRAY_A ); if (!is_array($review) || (bool) ($review['cleanup_locked'] ?? false)) { return null; } if ($retry_owner > 0 && !Owner::lock_actor($retry_owner)) return null; $settings = self::durable_settings(); if (!is_array($settings) || (int) $review['token_version'] !== (int) $expected_review['token_version'] || $settings !== $expected_settings || empty($settings['enabled']) || $settings['recipients'] !== $recipients) { return null; } $sent = self::send_mail($recipients, $rendered); if (false === $wpdb->query('COMMIT')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. return false; } $committed = true; return $sent; } catch (\Throwable) { return false; } finally { if (!$committed) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit transaction boundary for the surrounding plugin-table operation; caching cannot provide atomicity or row locks. } } } /** * Converts stored HTML to an excerpt only after stripping all markup and * controls. A Unicode whitespace split makes the twenty-word limit literal. */ public static function excerpt(string $html): string { $text = wp_strip_all_tags($html, true); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = wp_check_invalid_utf8($text); $text = preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $text) ?? ''; $text = trim(preg_replace('/\s+/u', ' ', $text) ?? ''); if ('' === $text) { return ''; } $words = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY); if (!is_array($words)) { return ''; } return implode(' ', array_slice($words, 0, self::MAX_EXCERPT_WORDS)); } private static function display_text(string $value, int $limit): string { $value = sanitize_text_field($value); if (function_exists('mb_substr')) { return mb_substr($value, 0, $limit); } return substr($value, 0, $limit); } private static function display_time(string $utc): string { try { $timestamp = (new \DateTimeImmutable($utc, new \DateTimeZone('UTC')))->getTimestamp(); return wp_date(get_option('date_format') . ' ' . get_option('time_format'), $timestamp, wp_timezone()); } catch (\Throwable) { return __('Unknown time', 'commentmate'); } } /** * @return array{version:int,daily:array,immediate:array} */ private static function load_state(int $review_id): array { return self::sanitize_state(get_option(self::state_option($review_id), array())); } /** * @param array $state */ private static function save_state(int $review_id, array $state): void { $state = self::sanitize_state($state); if (!add_option(self::state_option($review_id), $state, '', false)) { update_option(self::state_option($review_id), $state, false); } } /** * @param mixed $state * @return array{version:int,daily:array,immediate:array} */ private static function sanitize_state(mixed $state): array { $state = is_array($state) ? $state : array(); return array( 'version' => self::STATE_VERSION, 'daily' => self::sanitize_channel_state($state['daily'] ?? array()), 'immediate' => self::sanitize_channel_state($state['immediate'] ?? array()), 'completion' => self::sanitize_channel_state($state['completion'] ?? array()), ); } /** * @param mixed $state * @return array{watermark:int,last_success_at:int,last_attempt_at:int,failure_count:int,next_retry_at:int} */ private static function sanitize_channel_state(mixed $state): array { $state = is_array($state) ? $state : array(); return array( 'watermark' => max(0, absint($state['watermark'] ?? 0)), 'last_success_at' => self::timestamp_value($state['last_success_at'] ?? 0), 'last_attempt_at' => self::timestamp_value($state['last_attempt_at'] ?? 0), 'failure_count' => min(20, max(0, absint($state['failure_count'] ?? 0))), 'next_retry_at' => self::timestamp_value($state['next_retry_at'] ?? 0), ); } /** * @param array $state * @param 'daily'|'immediate' $channel * @return array{watermark:int,last_success_at:int,last_attempt_at:int,failure_count:int,next_retry_at:int} */ private static function channel_state(array $state, string $channel): array { return self::sanitize_channel_state($state[$channel] ?? array()); } private static function timestamp_value(mixed $value): int { $value = is_numeric($value) ? (int) $value : 0; return max(0, min(2147483647, $value)); } private static function acquire_delivery_lock(int $review_id): ?string { if ($review_id < 1) { return null; } try { $token = bin2hex(random_bytes(16)); } catch (\Throwable) { return null; } $lock = array('token' => $token, 'expires_at' => time() + self::LOCK_SECONDS); $option = self::lock_option($review_id); if (add_option($option, $lock, '', false)) { return $token; } $existing = get_option($option, array()); $expires_at = is_array($existing) ? self::timestamp_value($existing['expires_at'] ?? 0) : 0; if ($expires_at >= time()) { return null; } // Expired owners cannot be trusted to release their own lease. Delete then // add is intentionally raced: add_option's unique option name lets exactly // one contender acquire the replacement lock. delete_option($option); return add_option($option, $lock, '', false) ? $token : null; } private static function release_delivery_lock(int $review_id, string $token): void { $option = self::lock_option($review_id); $existing = get_option($option, array()); if (!is_array($existing) || !isset($existing['token']) || !is_string($existing['token'])) { return; } if (hash_equals((string) $existing['token'], $token)) { delete_option($option); } } private static function state_option(int $review_id): string { return self::STATE_PREFIX . max(0, $review_id); } private static function lock_option(int $review_id): string { return self::LOCK_PREFIX . max(0, $review_id); } private static function owner_comment_action_url(int $review_id, int $comment_id): string { return add_query_arg( array( 'action' => self::OWNER_COMMENT_ACTION, 'review_id' => max(0, $review_id), 'comment_id' => max(0, $comment_id), ), admin_url('admin-post.php') ); } private static function requested_positive_id(string $key): int { return isset($_GET[$key]) && is_scalar($_GET[$key]) ? absint(wp_unslash($_GET[$key])) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only navigation selects a screen or numeric review; mutations use separately verified requests. } /** * @return array{id:int,comment_number:int,canonical_url:string}|null */ private static function comment_context(int $review_id, int $comment_id): ?array { if ($review_id < 1 || $comment_id < 1) { return null; } global $wpdb; $comments = Database::table('commentmate_comments'); $pages = Database::table('commentmate_pages'); $reviews = Database::table('commentmate_reviews'); $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. $wpdb->prepare('SELECT c.id, c.comment_number, p.canonical_url FROM %i AS c INNER JOIN %i AS p ON p.id = c.page_id INNER JOIN %i AS r ON r.id = c.review_id WHERE c.review_id = %d AND c.id = %d AND c.deleted_at IS NULL LIMIT 1', $comments, $pages, $reviews, $review_id, $comment_id), ARRAY_A ); if (!is_array($row) || '' === trim((string) ($row['canonical_url'] ?? ''))) { return null; } return array( 'id' => absint($row['id'] ?? 0), 'comment_number' => max(1, absint($row['comment_number'] ?? $comment_id)), 'canonical_url' => (string) $row['canonical_url'], ); } /** * @param list $values */ private static function collect_recipient_values(mixed $value, array &$values): void { if (count($values) >= self::MAX_RECIPIENT_INPUTS) { return; } if (is_array($value)) { foreach ($value as $child) { self::collect_recipient_values($child, $values); if (count($values) >= self::MAX_RECIPIENT_INPUTS) { return; } } return; } if (!is_scalar($value)) { return; } foreach (preg_split('/[,;\r\n]+/', (string) $value, -1, PREG_SPLIT_NO_EMPTY) ?: array() as $candidate) { $values[] = $candidate; if (count($values) >= self::MAX_RECIPIENT_INPUTS) { return; } } } private static function to_bool(mixed $value): bool { if (is_bool($value)) { return $value; } if (is_int($value) || is_float($value)) { return 1 === (int) $value; } if (is_string($value)) { return in_array(strtolower(trim($value)), array('1', 'true', 'yes', 'on'), true); } return false; } private static function sanitize_daily_time(mixed $value): string { $value = is_scalar($value) ? trim((string) $value) : ''; return 1 === preg_match('/\A(?:[01][0-9]|2[0-3]):[0-5][0-9]\z/', $value) ? $value : '09:00'; } private static function delete_options_with_prefix(string $prefix): void { global $wpdb; if (!isset($wpdb->options)) { return; } $names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Notification state and private feedback use plugin tables; fresh reads respect cleanup and recipient changes before sending. $wpdb->prepare('SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like($prefix) . '%') ); foreach (is_array($names) ? $names : array() as $name) { delete_option((string) $name); } } }