lock( 'notification_test', 30 ); if ( ! $lock ) { throw new \RuntimeException( 'A test request is already being prepared.' ); } try { $last = (int) Store::freshOption( 'antivirus_ai_notification_test_at', 0 ); if ( $last > time() - 300 ) { throw new \RuntimeException( 'Wait five minutes before requesting another test message.' ); } // Reserve before creating any deliverable record. A failed reservation // must not leave mail queued; a later queue failure safely retains it. Store::saveOption( 'antivirus_ai_notification_test_at', time() ); $key = 'test_' . bin2hex( random_bytes( 12 ) ); self::queue( $key, array( 'type' => 'test' ) ); if ( ! $store->get( 'n_' . $key ) ) { throw new \RuntimeException( 'The test could not be queued. Review notification consent and storage limits.' ); } } finally { $store->unlock( 'notification_test', $lock ); } } public static function accountEvent( string $event, int $accountId ): void { self::queue( 'account_' . substr( hash( 'sha256', $event . '|' . $accountId . '|' . gmdate( 'YmdHi' ) ), 0, 40 ), array( 'type' => 'account', 'event' => $event, 'account_id' => $accountId ) ); } public static function register(): void { add_action( 'antivirus_ai_finding_recorded', array( self::class, 'critical' ) ); add_action( 'antivirus_ai_scan_completed', array( self::class, 'queueDigest' ) ); add_action( 'antivirus_ai_notify', array( self::class, 'process' ) ); add_action( 'antivirus_ai_worker', array( self::class, 'worker' ) ); } /** Recover a lost mail wake-up only from the authenticated local worker; existing consent/retry gates still apply. */ public static function worker(): void { $due = wp_next_scheduled( 'antivirus_ai_notify' ); if ( $due && $due > time() ) { return; } $store = new Store(); if ( $store->count( 'notification', 'queued' ) + $store->count( 'notification', 'retry' ) < 1 ) { return; } wp_clear_scheduled_hook( 'antivirus_ai_notify' ); self::process(); } public static function critical( array $finding ): void { $id = FindingGuide::id( $finding ); $current = $id ? ( new Store() )->get( $id ) : null; // Scanner hooks can carry the pre-storage body without _status; use the saved decision. if ( ! $current || 'finding' !== ( $current['_kind'] ?? '' ) || 'critical' !== ( $current['severity'] ?? '' ) || ! in_array( $current['_status'] ?? '', array( 'open', 'acknowledged' ), true ) || Finding::exceptionKey( $current ) !== Finding::exceptionKey( $finding ) ) { return; } $finding = $current; $key = Finding::exceptionKey( $finding ); self::queue( 'critical_' . substr( $key, 0, 48 ), array( 'type' => 'critical', 'finding_id' => $finding['id'] ?? $finding['_id'], 'finding_key' => $key, 'severity' => 'critical', ) ); } public static function queueDigest( string $scanId ): void { self::queue( 'digest_' . gmdate( 'Ymd' ), array( 'type' => 'digest', 'scan_id' => $scanId, ) ); } private static function queue( string $key, array $body ): void { $settings = Store::freshOption( 'antivirus_ai_settings', array() ); if ( null === NotificationDelivery::recipient( $settings ) ) { return; } $store = new Store(); $id = 'n_' . $key; if ( $store->get( $id ) ) { return; } if ( $store->count( 'notification' ) >= 2000 ) { $store->prune(); if ( $store->count( 'notification' ) >= 2000 ) { update_option( 'antivirus_ai_notification_warning', array( 'at' => time(), 'message' => 'Notification storage budget reached. A new alert could not be queued; review findings and retained notification history.', ), false ); return; } } $store->put( 'notification', $id, $body + array( 'attempts' => 0, 'next_attempt' => time(), 'queued_at' => gmdate( 'c' ), ), 'queued' ); self::schedule(); } private static function schedule( int $delay = 20 ): void { if ( ! wp_next_scheduled( 'antivirus_ai_notify' ) ) { wp_schedule_single_event( time() + $delay, 'antivirus_ai_notify' ); } } public static function process(): void { $store = new Store(); $token = $store->lock( 'notifications', 120 ); if ( ! $token ) { self::schedule( 120 ); return; } try { // Rotate bounded windows so fresh arrivals cannot starve older retries. $cursor = get_option( 'antivirus_ai_notification_cursor', array( 'queued' => 0, 'retry' => 0, ) ); // This cursor is only a bounded fairness hint, never permission or delivery state. if ( ! is_array( $cursor ) ) { $cursor = array(); } foreach ( array( 'queued', 'retry' ) as $state ) { if ( ! is_int( $cursor[ $state ] ?? null ) || $cursor[ $state ] < 0 || $cursor[ $state ] > 2000 ) { $cursor[ $state ] = 0; } } $pending = array(); foreach ( array( 'retry', 'queued' ) as $state ) { $offset = max( 0, min( 2000, (int) ( $cursor[ $state ] ?? 0 ) ) ); $window = $store->list( 'notification', $state, 50, $offset ); $pending = array_merge( $pending, $window ); $cursor[ $state ] = count( $window ) < 50 ? 0 : $offset + 50; } update_option( 'antivirus_ai_notification_cursor', $cursor, false ); usort( $pending, static fn( $a, $b ) => ( is_int( $a['next_attempt'] ?? null ) ? $a['next_attempt'] : 0 ) <=> ( is_int( $b['next_attempt'] ?? null ) ? $b['next_attempt'] : 0 ) ); $processed = 0; foreach ( $pending as $item ) { if ( $processed >= 10 ) { break; } // Re-read the durable job and permission before reserving one delivery attempt. $current = $store->get( $item['_id'] ); if ( ! $current || ! in_array( $current['_status'] ?? '', array( 'queued', 'retry' ), true ) ) { continue; } $item = $current; $recipient = NotificationDelivery::recipient( Store::freshOption( 'antivirus_ai_settings', array() ) ); if ( null === $recipient ) { $item['last_error'] = 'Notification permission or recipient is absent or invalid. No mail was attempted.'; $store->put( 'notification', $item['_id'], $item, 'suppressed', $item['_revision'] ); continue; } if ( ! self::metadata( $item ) ) { $item['last_error'] = 'Invalid saved notification metadata. No mail was attempted.'; $store->put( 'notification', $item['_id'], $item, 'blocked', $item['_revision'] ); continue; } if ( $item['next_attempt'] > time() ) { continue; } ++$processed; $subject = 'test' === $item['type'] ? __( 'Shieldlume Security: your requested notification test', 'shieldlume-security' ) : ( 'account' === $item['type'] ? __( 'Shieldlume Security: administrator access changed', 'shieldlume-security' ) : ( 'critical' === $item['type'] ? __( 'Shieldlume Security: critical finding needs review', 'shieldlume-security' ) : __( 'Shieldlume Security: scan digest', 'shieldlume-security' ) ) ); $body = self::message( $item, $store ); if ( ! $store->ownsLock( 'notifications', $token ) ) { throw new \RuntimeException( 'Notification worker lease expired before delivery.' ); } ++$item['attempts']; $item['last_attempt'] = gmdate( 'c' ); $item['next_attempt'] = 0; $item['last_error'] = 'Delivery attempt reserved. If processing stops here, its outcome is unconfirmed and it will not be repeated automatically.'; // Persist before wp_mail: an interrupted process must never leave this job retryable. $claim = $store->put( 'notification', $item['_id'], $item, 'attempting', $item['_revision'] ); $result = NotificationDelivery::send( $store, $claim, $recipient, $subject, $body, $token, static fn(): string => self::message( $claim, $store ) ); $claim['last_error'] = $result['message']; $status = $result['accepted'] ? 'accepted' : ( $result['stopped'] ? 'suppressed' : 'unknown' ); $store->put( 'notification', $claim['_id'], $claim, $status, $claim['_revision'] ); $store->activity( 'notification_' . $status, array( 'notification_id' => $claim['_id'], 'attempts' => $claim['attempts'], 'meaning' => $result['accepted'] ? 'Accepted by wp_mail; inbox delivery is unverified.' : $claim['last_error'] ) ); } if ( $store->count( 'notification', 'queued' ) + $store->count( 'notification', 'retry' ) > 0 ) { self::schedule( 120 ); } } finally { $store->unlock( 'notifications', $token ); } } /** Validate retained data before conversion or mail composition; legacy alerts may lack finding_key. */ private static function metadata( array $item ): bool { if ( ! is_int( $item['attempts'] ?? null ) || $item['attempts'] < 0 || $item['attempts'] > 2 || ! is_int( $item['next_attempt'] ?? null ) || $item['next_attempt'] < 0 ) { return false; } switch ( $item['type'] ?? null ) { case 'test': return true; case 'account': return is_string( $item['event'] ?? null ) && (bool) preg_match( '/^[a-z][a-z0-9_]{0,79}$/D', $item['event'] ) && is_int( $item['account_id'] ?? null ) && $item['account_id'] > 0; case 'critical': return is_string( $item['finding_id'] ?? null ) && (bool) preg_match( '/^f_[a-f0-9]{60}$/D', $item['finding_id'] ) && ( ! array_key_exists( 'finding_key', $item ) || ( is_string( $item['finding_key'] ) && (bool) preg_match( '/^[a-f0-9]{64}$/D', $item['finding_key'] ) ) ); case 'digest': return is_string( $item['scan_id'] ?? null ) && (bool) preg_match( '/^s_[a-f0-9]{32}$/D', $item['scan_id'] ); default: return false; } } private static function message( array $item, Store $store ): string { $text = __( 'Shieldlume Security security review', 'shieldlume-security' ) . "\n\n"; $url = Admin::url( 'activity' ); if ( 'test' === $item['type'] ) { $text .= "An administrator requested this notification test from Shieldlume Security Protection. Receiving it confirms delivery of this test only; future delivery can vary.\n\nReview notification preferences and recent attempts in your local WordPress dashboard. No security finding or source content is included.\n"; $url = Admin::url( 'settings' ); } elseif ( 'account' === $item['type'] ) { $text .= sprintf( "Administrator security event: %s\nAccount ID: %d\nReview this change in your local Activity history. Legitimate administrator actions can produce this alert.\n", str_replace( '_', ' ', (string) $item['event'] ), (int) $item['account_id'] ); } elseif ( 'critical' === $item['type'] ) { $id = is_string( $item['finding_id'] ?? null ) ? $item['finding_id'] : ''; $finding = preg_match( '/^f_[a-f0-9]{60}$/D', $id ) ? $store->get( $id ) : null; if ( $finding && 'finding' === ( $finding['_kind'] ?? '' ) && FindingGuide::id( $finding ) === $id ) { $key = $item['finding_key'] ?? ''; $changed = is_string( $key ) && '' !== $key && ! hash_equals( $key, Finding::exceptionKey( $finding ) ); $text .= $changed ? __( 'The evidence changed after this alert was queued. The following details are the current retained observation, not the earlier alert evidence.', 'shieldlume-security' ) . "\n" : __( 'Current retained observation:', 'shieldlume-security' ) . "\n"; $text .= sprintf( "Severity: %s\nConfidence: %s\nStatus: %s\nReason: %s\n", FindingGuide::text( $finding['severity'] ?? '', '', 100 ), FindingGuide::text( $finding['confidence'] ?? '', '', 100 ), FindingGuide::text( $finding['_status'] ?? '', '', 100 ), FindingGuide::text( $finding['reason'] ?? '' ) ); $url = FindingGuide::url( $finding ); } else { $text .= __( 'The finding is no longer available. Open retained findings to review current evidence; this alert does not recreate the missing observation.', 'shieldlume-security' ) . "\n"; $url = Admin::url( 'findings' ); } } else { $id = is_string( $item['scan_id'] ?? null ) ? $item['scan_id'] : ''; $scan = preg_match( '/^s_[a-f0-9]{32}$/D', $id ) ? $store->get( $id ) : null; if ( $scan && 'scan' === ( $scan['_kind'] ?? '' ) && ( $scan['_id'] ?? '' ) === $id ) { $projection = Diagnostics::scan( $scan ); $counts = array(); foreach ( array( 'checked', 'skipped', 'unreadable' ) as $counter ) { $counts[] = null === $projection['counters'][ $counter ] ? __( 'Unknown', 'shieldlume-security' ) : (string) $projection['counters'][ $counter ]; } $text .= sprintf( "Scan state: %s\nChecked: %s\nSkipped: %s\nUnreadable: %s\n", $projection['status'], $counts[0], $counts[1], $counts[2] ); $text .= __( 'The link opens findings last observed in this scan. Findings observed again later may no longer appear in that filtered view; it is not a complete historical snapshot.', 'shieldlume-security' ) . "\n"; $url = Admin::url( 'findings', array( 'scan_id' => $id ) ); } else { $text .= __( 'The scan record is no longer available. Open local activity to review retained records.', 'shieldlume-security' ) . "\n"; } } return $text . "\n" . __( 'Open the relevant local screen (WordPress sign-in and administrator access are required):', 'shieldlume-security' ) . "\n" . $url . "\n\n" . __( 'This alert does not establish that every check completed or every business workflow is intact.', 'shieldlume-security' ); } }