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 = '
';
$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);
}
}
}