prefix . 'chargeforms_email_log'; } public static function boot() { add_action( 'wp_mail_succeeded', [ __CLASS__, 'on_success' ] ); add_action( 'wp_mail_failed', [ __CLASS__, 'on_failure' ] ); } /** * Request-scoped, in-memory context for the NEXT wp_mail() call only — * lets a caller (e.g. Email_Test::send()) tag its own send as * `email_type => 'test'` rather than the generic 'other' every * unattributed wp_mail() call falls back to. Cleared by the caller * right after, same lifetime pattern as Smtp_Connection's test * override. */ private static $next_context = []; public static function set_next_context( array $context ) { self::$next_context = $context; } /** * @param array $mail_data Same shape wp_mail_failed's WP_Error->get_error_data() already carries — {to, subject, message, headers, attachments} plus (WP 6.4+) 'phpmailer_exception_code'. * * Gated on an active connection existing (see should_log()) — a site * that has never opened Email Delivery settings must see ZERO new * behavior from this feature, including silent logging. Without this * gate, every wp_mail() call site-wide (password resets, other * plugins' mail) would start being written to this table the moment * the plugin activates, regardless of whether the admin ever touched * SMTP settings — a real, unintended overreach found during the * production audit (docs/SMTP_PRODUCTION_AUDIT.md). */ public static function on_success( $mail_data ) { if ( self::should_log() ) { self::record( $mail_data['to'] ?? '', $mail_data['subject'] ?? '', 'sent', null, null, self::$next_context ); } self::$next_context = []; } public static function on_failure( \WP_Error $error ) { if ( self::should_log() ) { $mail_data = $error->get_error_data( $error->get_error_code() ); if ( ! is_array( $mail_data ) ) { $mail_data = []; } $category = Email_Test::classify_message( $error->get_error_message() ); self::record( $mail_data['to'] ?? '', $mail_data['subject'] ?? '', 'failed', $category, self::redact( $error->get_error_message() ), self::$next_context ); } self::$next_context = []; } /** * Logging is opt-in by construction: a test send (which always has an * explicit context, even before any connection is saved — see * Email_Test::send()) is always logged; any OTHER wp_mail() call is * only logged once the site has an actual active (enabled + default) * SMTP connection — the same condition Smtp_Transport::configure() * itself uses to decide whether it does anything at all. This keeps * "logging happens" and "SMTP transport is actually active" the same * condition, rather than two conditions that could drift apart. */ private static function should_log() { if ( isset( self::$next_context['email_type'] ) && 'test' === self::$next_context['email_type'] ) { return true; } return null !== Smtp_Connection::get_default(); } /** * Writes one log row. `$error_message` must already be redacted by * the caller (see redact() below) — this method doesn't re-check, * since Email_Test::send() constructs its own already-sanitized * message for the "test" email_type case rather than routing through * on_failure() (a test send's failure is reported synchronously to * the REST caller, not just logged). */ public static function record( $to, $subject, $status, $error_category = null, $error_message = null, array $context = [] ) { global $wpdb; $connection = Smtp_Connection::get_default(); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom plugin table, no core API exists for it. self::table(), [ 'connection_id' => $connection['id'] ?? null, 'connection_name' => $connection['name'] ?? null, 'email_type' => $context['email_type'] ?? 'other', 'form_id' => $context['form_id'] ?? null, 'entry_id' => $context['entry_id'] ?? null, 'recipient' => is_array( $to ) ? implode( ', ', array_slice( $to, 0, 5 ) ) : (string) $to, 'subject' => (string) $subject, 'status' => $status, 'error_category' => $error_category, 'error_message' => $error_message ? substr( $error_message, 0, 500 ) : null, 'duration_ms' => $context['duration_ms'] ?? null, 'created_at' => current_time( 'mysql', true ), ], [ '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s' ] ); $total = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', self::table() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. if ( 0 === $total % self::PRUNE_FREQUENCY ) { self::prune(); } } /** * Strips any substring that looks like it could be a credential * before a raw PHPMailer/exception message is ever stored or shown — * defense in depth on top of PHPMailer generally not echoing * passwords back in its own error strings. Matches the "user:pass@" * shape a malformed DSN-style string could carry, and any long * base64-ish run that could be an API-key-style secret. */ public static function redact( $message ) { $message = preg_replace( '/[^\s\/@]+:[^\s\/@]+@/', '[redacted]@', (string) $message ); $message = preg_replace( '/\b[A-Za-z0-9+\/]{32,}={0,2}\b/', '[redacted]', $message ); return $message; } /** Deletes rows older than the configured retention window — bounded storage, one indexed query, no separate cron job needed (called from record()'s own prune-on-Nth-insert gate). */ public static function prune() { global $wpdb; $days = max( 1, (int) Settings::get( 'email_log_retention_days', 30 ) ); $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. 'DELETE FROM %i WHERE created_at < %s', self::table(), gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ) ) ); } /** Paginated log for the admin screen — mirrors Notification_Log::query()'s shape. */ public static function query( array $args = [] ) { global $wpdb; $page = max( 1, (int) ( $args['page'] ?? 1 ) ); $per_page = max( 1, min( 100, (int) ( $args['per_page'] ?? 20 ) ) ); $offset = ( $page - 1 ) * $per_page; $table = self::table(); $where = '1=1'; $params = []; if ( ! empty( $args['status'] ) && in_array( $args['status'], [ 'sent', 'failed' ], true ) ) { $where .= ' AND status = %s'; $params[] = $args['status']; } if ( ! empty( $args['search'] ) ) { $where .= ' AND ( recipient LIKE %s OR subject LIKE %s )'; $like = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $params[] = $like; $params[] = $like; } // $where's placeholder count always matches $params' length at runtime (built together above); // the sniff can't trace that through the dynamic {$where} interpolation. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", array_merge( [ $table ], $params ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE {$where} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $where's placeholder count always matches $params' length at runtime. array_merge( [ $table ], $params, [ $per_page, $offset ] ) ), ARRAY_A ); return [ 'items' => $rows ?: [], 'total' => $total, 'page' => $page, 'per_page' => $per_page, ]; } }