Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.36% covered (warning)
80.36%
225 / 280
41.94% covered (danger)
41.94%
13 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Debug_Log
80.94% covered (warning)
80.94%
225 / 278
41.94% covered (danger)
41.94%
13 / 31
247.11
0.00% covered (danger)
0.00%
0 / 1
 table_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 schema
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 enabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 until
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 expire_if_due
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 turn_on
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 turn_off
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 add
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 write
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
12.08
 hold
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 claim
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 changed
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 secret
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 redact
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
11.09
 service_body
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
13.29
 scrub
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
12
 remove_secrets
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 secrets
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 mask_emails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mask_email
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 fit
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
11.10
 query
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
3.17
 where
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
6.25
 get
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 text
82.86% covered (warning)
82.86%
29 / 35
0.00% covered (danger)
0.00%
0 / 1
7.25
 count
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 clear
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prune
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 state
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 drop
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 row
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * The debug log: each step Send takes, while it's turned on.
4 *
5 * @package automattic/jetpack-send
6 */
7
8namespace Automattic\Jetpack\Send;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A custom table, read only by its own screen.
15
16/**
17 * Off by default, records only while on, and turns itself off. Everything is redacted on the way in:
18 * no keys, tokens, bodies, subjects, full addresses or server paths ever reach the table.
19 */
20class Debug_Log {
21
22    /**
23     * Most entries kept, newest first; the hourly job enforces it.
24     *
25     * @var int
26     */
27    const MAX_ROWS = 20000;
28
29    /**
30     * Largest context, JSON-encoded.
31     *
32     * @var int
33     */
34    const CONTEXT_BYTES = 4096;
35
36    /**
37     * Longest string kept.
38     *
39     * @var int
40     */
41    const STRING_CHARS = 500;
42
43    /**
44     * Longest string kept from a safe key of the service's answer.
45     *
46     * @var int
47     */
48    const SERVICE_CHARS = 300;
49
50    /**
51     * The parts of Send an entry can be about.
52     *
53     * @var string[]
54     */
55    const AREAS = array( 'sending', 'domain', 'account', 'settings', 'cleanup' );
56
57    /**
58     * Levels, most serious first.
59     *
60     * @var string[]
61     */
62    const LEVELS = array( 'error', 'warning', 'info', 'debug' );
63
64    /**
65     * How long it records once turned on, in days; entries are kept as long.
66     *
67     * @var int[]
68     */
69    const DAYS = array( 1, 7, 30 );
70
71    /**
72     * Keys of the service's answers whose values are kept.
73     *
74     * @var string[]
75     */
76    const SERVICE_KEYS = array( 'status', 'reason', 'errors', 'request_id', 'message_id', 'verdict', 'selector', 'domain', 'index', 'message' );
77
78    /**
79     * Values the long-run rule skips: the IDs support asks for, and public DNS values.
80     *
81     * @var string[]
82     */
83    const RAW_KEYS = array( 'request_id', 'message_id', 'selector' );
84
85    /**
86     * The context keys each event may keep; any other key is dropped.
87     *
88     * @var array<string, string[]>
89     */
90    const EVENTS = array(
91        'mail.caught'          => array( 'origin', 'recipients', 'attachments', 'bytes', 'content_type' ),
92        'mail.skipped'         => array( 'why', 'origin' ),
93        'api.call'             => array( 'method', 'path', 'status', 'ms', 'request_id', 'content_type', 'bytes', 'body' ),
94        'api.transport_error'  => array( 'method', 'path', 'wp_code', 'errno', 'phase', 'ms', 'timeout_s', 'message' ),
95        'mail.sent'            => array( 'request_id', 'message_id', 'ms' ),
96        'mail.failed'          => array( 'reason', 'code', 'http_status' ),
97        'fallback.handed_off'  => array( 'reason', 'code' ),
98        'fallback.host_result' => array( 'result', 'reason', 'message', 'exception_code' ),
99        'mail.maybe_duplicate' => array( 'reason', 'host' ),
100        'resend.sent'          => array( 'original', 'recipients', 'changed', 'status', 'reason' ),
101        'resend.refused'       => array( 'reason', 'code' ),
102        'test.sent'            => array( 'kind', 'request_id' ),
103        'test.failed'          => array( 'kind', 'reason', 'code' ),
104        'dns.check'            => array( 'domain', 'lockdown', 'dkim', 'spf', 'sender_domain', 'expected', 'found' ),
105        'dns.watch'            => array( 'run', 'outcome', 'elapsed', 'next' ),
106        'cloudflare.write'     => array( 'purpose', 'name', 'type', 'action', 'reason', 'codes' ),
107        'account.provision'    => array( 'provider', 'domain', 'selector', 'status', 'code' ),
108        'account.usage'        => array( 'used', 'limit', 'period_end' ),
109        'settings.changed'     => array( 'keys' ),
110        'debug.on'             => array( 'days' ),
111        'debug.off'            => array( 'automatic' ),
112        'log.pruned'           => array( 'deleted', 'remaining', 'days' ),
113        'log.deleted'          => array( 'count' ),
114        'sending.reset'        => array(),
115    );
116
117    /**
118     * Set while an entry is being written, so writing can never call itself.
119     *
120     * @var bool
121     */
122    private static $writing = false;
123
124    /**
125     * Values to remove wherever they appear in this request, like a token pasted for one call.
126     *
127     * @var string[]
128     */
129    private static $secrets = array();
130
131    /**
132     * Entries written for the email being sent before its log row exists (picked up, the API call), so
133     * they can be linked to it once it does; null when no email is being sent.
134     *
135     * @var int[]|null
136     */
137    private static $held = null;
138
139    /**
140     * Most entries held for one email.
141     *
142     * @var int
143     */
144    const HELD_MAX = 20;
145
146    /**
147     * Prefix of the day-long transients that remember a value, so a repeat isn't recorded again.
148     *
149     * @var string
150     */
151    const SEEN_PREFIX = 'jetpack_send_debug_seen_';
152
153    /**
154     * Table name.
155     *
156     * @return string
157     */
158    public static function table_name() {
159        global $wpdb;
160        return $wpdb->prefix . 'jetpack_send_debug';
161    }
162
163    /**
164     * The table's definition, for dbDelta().
165     *
166     * @param string $charset Charset and collation clause.
167     * @return string
168     */
169    public static function schema( $charset ) {
170        $table = self::table_name();
171        return "CREATE TABLE {$table} (
172                id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
173                created_at datetime NOT NULL,
174                level varchar(10) NOT NULL,
175                area varchar(20) NOT NULL,
176                event varchar(40) NOT NULL,
177                message varchar(500) NOT NULL DEFAULT '',
178                context text NULL,
179                log_id bigint(20) unsigned NOT NULL DEFAULT 0,
180                user_id bigint(20) unsigned NOT NULL DEFAULT 0,
181                PRIMARY KEY  (id),
182                KEY created_at (created_at),
183                KEY log_id (log_id),
184                KEY level_created (level,created_at)
185            ) {$charset};";
186    }
187
188    /**
189     * Whether it's recording now. Only reads.
190     *
191     * @since $$next-version$$
192     *
193     * @return bool
194     */
195    public static function enabled() {
196        if ( ! Email_Log::has_v4_schema() ) {
197            return false;
198        }
199        $settings = Settings::get();
200        $until    = self::until();
201        return ! empty( $settings['debug_log'] ) && null !== $until && $until > time();
202    }
203
204    /**
205     * When it turns itself off, as a Unix time.
206     *
207     * @return int|null Null when it isn't on.
208     */
209    private static function until() {
210        $until = (string) ( Settings::get()['debug_log_until'] ?? '' );
211        $time  = '' === $until ? false : strtotime( $until );
212        return false === $time ? null : (int) $time;
213    }
214
215    /**
216     * Turn it off once its time is up.
217     *
218     * @since $$next-version$$
219     *
220     * @return void
221     */
222    public static function expire_if_due() {
223        $settings = Settings::get();
224        if ( empty( $settings['debug_log'] ) ) {
225            return;
226        }
227        $until = self::until();
228        if ( null === $until || $until <= time() ) {
229            self::turn_off( true );
230        }
231    }
232
233    /**
234     * Turn it on for a number of days.
235     *
236     * @since $$next-version$$
237     *
238     * @param int $days 1, 7 or 30.
239     * @return void
240     */
241    public static function turn_on( $days ) {
242        $days   = in_array( (int) $days, self::DAYS, true ) ? (int) $days : 7;
243        $was_on = self::enabled();
244        Settings::update(
245            array(
246                'debug_log'       => true,
247                'debug_log_days'  => $days,
248                'debug_log_until' => gmdate( 'Y-m-d\\TH:i:s\\Z', time() + $days * DAY_IN_SECONDS ),
249            )
250        );
251        // As Settings names the choice.
252        $lengths = array(
253            1  => __( '1 day', 'jetpack-send-pkg' ),
254            7  => __( '1 week', 'jetpack-send-pkg' ),
255            30 => __( '1 month', 'jetpack-send-pkg' ),
256        );
257        $message = $was_on
258            /* translators: %s: how long it records for from now, e.g. "1 week". */
259            ? sprintf( __( 'Debug log now records for %s.', 'jetpack-send-pkg' ), $lengths[ $days ] )
260            /* translators: %s: how long it records for, e.g. "1 week". */
261            : sprintf( __( 'Debug log turned on for %s.', 'jetpack-send-pkg' ), $lengths[ $days ] );
262        self::write( 'info', 'settings', 'debug.on', $message, array( 'days' => $days ) );
263    }
264
265    /**
266     * Turn it off. Entries stay until they're older than the days it was set to keep them.
267     *
268     * @since $$next-version$$
269     *
270     * @param bool $automatic Whether its time ran out, rather than someone turning it off.
271     * @return void
272     */
273    public static function turn_off( $automatic = false ) {
274        self::write(
275            'info',
276            'settings',
277            'debug.off',
278            $automatic ? __( 'Debug log turned off automatically.', 'jetpack-send-pkg' ) : __( 'Debug log turned off.', 'jetpack-send-pkg' ),
279            array( 'automatic' => (bool) $automatic )
280        );
281        Settings::update(
282            array(
283                'debug_log'       => false,
284                'debug_log_until' => '',
285            )
286        );
287    }
288
289    /**
290     * Record one step, while it's on. Never throws and never blocks a send.
291     *
292     * @since $$next-version$$
293     *
294     * @param string $level   `error`, `warning`, `info` or `debug`.
295     * @param string $area    `sending`, `domain`, `account`, `settings` or `cleanup`.
296     * @param string $event   A dot key like `mail.failed`; it decides which context keys are kept.
297     * @param string $message One plain line.
298     * @param array  $context Details; unknown keys are dropped and the rest redacted.
299     * @param int    $log_id  The email it's about, or 0.
300     * @return void
301     */
302    public static function add( $level, $area, $event, $message, array $context = array(), $log_id = 0 ) {
303        if ( self::$writing ) {
304            return;
305        }
306        try {
307            if ( self::enabled() ) {
308                self::write( $level, $area, $event, $message, $context, $log_id );
309            }
310        } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- A debug entry must never stop an email.
311        }
312    }
313
314    /**
315     * Write an entry, whether or not it's on: turning it on and off is always recorded.
316     *
317     * @param string $level   Level.
318     * @param string $area    Area.
319     * @param string $event   Event key.
320     * @param string $message Plain line.
321     * @param array  $context Details.
322     * @param int    $log_id  The email it's about, or 0.
323     * @return void
324     */
325    private static function write( $level, $area, $event, $message, array $context = array(), $log_id = 0 ) {
326        global $wpdb;
327        if ( self::$writing || ! Email_Log::has_v4_schema() ) {
328            return;
329        }
330        self::$writing = true;
331        try {
332            $event   = substr( (string) preg_replace( '/[^a-z0-9._]/', '', strtolower( (string) $event ) ), 0, 40 );
333            $allowed = array_flip( self::EVENTS[ $event ] ?? array() );
334            $context = self::fit( self::redact( array_intersect_key( $context, $allowed ), '', $event ) );
335            // Every string is already valid UTF-8 and cut to size, so wpdb's per-column checks aren't needed.
336            $wpdb->query(
337                $wpdb->prepare(
338                    'INSERT INTO %i ( created_at, level, area, event, message, context, log_id, user_id ) VALUES ( %s, %s, %s, %s, %s, %s, %d, %d )',
339                    self::table_name(),
340                    gmdate( 'Y-m-d H:i:s' ),
341                    in_array( $level, self::LEVELS, true ) ? $level : 'info',
342                    in_array( $area, self::AREAS, true ) ? $area : 'sending',
343                    $event,
344                    self::scrub( (string) $message ),
345                    array() === $context ? '' : (string) wp_json_encode( $context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
346                    max( 0, (int) $log_id ),
347                    get_current_user_id()
348                )
349            );
350            if ( is_array( self::$held ) && (int) $log_id < 1 && 'sending' === $area && $wpdb->insert_id && count( self::$held ) < self::HELD_MAX ) {
351                self::$held[] = (int) $wpdb->insert_id;
352            }
353        } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- A debug entry must never stop an email.
354        } finally {
355            self::$writing = false;
356        }
357    }
358
359    /**
360     * Start holding the sending entries for one email until its log row exists. A new email starts over.
361     *
362     * @since $$next-version$$
363     *
364     * @return void
365     */
366    public static function hold() {
367        self::$held = array();
368    }
369
370    /**
371     * Link the held entries to the email's log row, so its "See debug entries" shows the whole send,
372     * the service's answer included. Stops holding either way.
373     *
374     * @since $$next-version$$
375     *
376     * @param int $log_id The email's row, or 0 when it wasn't logged.
377     * @return void
378     */
379    public static function claim( $log_id ) {
380        global $wpdb;
381        $held       = self::$held;
382        self::$held = null;
383        if ( empty( $held ) || (int) $log_id < 1 || ! Email_Log::has_v4_schema() ) {
384            return;
385        }
386        try {
387            $in = implode( ',', array_fill( 0, count( $held ), '%d' ) );
388            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- `$in` is one `%d` per id.
389            $wpdb->query( $wpdb->prepare( "UPDATE %i SET log_id = %d WHERE log_id = 0 AND id IN ($in)", self::table_name(), (int) $log_id, ...$held ) );
390        } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- A debug entry must never stop an email.
391        }
392    }
393
394    /**
395     * Whether a value worth one entry changed since it was last recorded (usage, say), so repeated reads
396     * don't fill the log. Only while it's on, so nothing is stored while it's off.
397     *
398     * @since $$next-version$$
399     *
400     * @param string $what  What it is, e.g. `usage`.
401     * @param string $value Its current value.
402     * @return bool
403     */
404    public static function changed( $what, $value ) {
405        if ( ! self::enabled() ) {
406            return false;
407        }
408        $key = self::SEEN_PREFIX . preg_replace( '/[^a-z_]/', '', (string) $what );
409        if ( get_transient( $key ) === (string) $value ) {
410            return false;
411        }
412        set_transient( $key, (string) $value, DAY_IN_SECONDS );
413        return true;
414    }
415
416    /**
417     * Remove a value wherever it appears in this request's entries, like a token used for one call.
418     *
419     * @since $$next-version$$
420     *
421     * @param string $value Secret.
422     * @return void
423     */
424    public static function secret( $value ) {
425        $value = (string) $value;
426        if ( strlen( $value ) >= 8 && ! in_array( $value, self::$secrets, true ) ) {
427            self::$secrets[] = $value;
428        }
429    }
430
431    /**
432     * Redact a context value: strings are scrubbed, other scalars kept, objects dropped.
433     *
434     * @since $$next-version$$
435     *
436     * @param mixed  $value Value.
437     * @param string $key   Its key, which decides whether long values are kept.
438     * @param string $event The event, for its own exemptions.
439     * @return mixed
440     */
441    public static function redact( $value, $key = '', $event = '' ) {
442        if ( is_array( $value ) ) {
443            $out = array();
444            foreach ( $value as $child => $item ) {
445                $out[ $child ] = self::redact( $item, is_int( $child ) ? $key : $child, $event );
446            }
447            return $out;
448        }
449        if ( is_string( $value ) ) {
450            // Public DNS values for the domain check, and the IDs support asks for, can be long.
451            $raw = in_array( $key, self::RAW_KEYS, true ) || ( 'dns.check' === $event && in_array( $key, array( 'expected', 'found' ), true ) );
452            // A message ID looks like an address but names no one, and support matches it with the service's.
453            return self::scrub( $value, ! $raw, 'message_id' !== $key );
454        }
455        if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) {
456            return $value;
457        }
458        return '<' . gettype( $value ) . '>';
459    }
460
461    /**
462     * The parts of a service's answer that are safe to keep: listed keys keep their words, other strings
463     * become `<string, N chars>`, and lists show three items.
464     *
465     * @since $$next-version$$
466     *
467     * @param mixed  $data   Decoded answer.
468     * @param string $parent The key it sits under.
469     * @param int    $depth  Nesting depth.
470     * @return mixed
471     */
472    public static function service_body( $data, $parent = '', $depth = 0 ) {
473        if ( $depth > 5 ) {
474            return '<deeper>';
475        }
476        if ( is_array( $data ) ) {
477            $out   = array();
478            $count = 0;
479            $list  = array_keys( $data ) === range( 0, count( $data ) - 1 );
480            foreach ( $data as $key => $value ) {
481                if ( $list && ++$count > 3 ) {
482                    $out[] = '<+' . ( count( $data ) - 3 ) . ' more>';
483                    break;
484                }
485                // A sub-account's key comes back in the answer that creates it.
486                $out[ $key ] = 'key' === $key || 'api_key' === $key ? '[removed]' : self::service_body( $value, $list ? $parent : (string) $key, $depth + 1 );
487            }
488            return $out;
489        }
490        if ( is_string( $data ) ) {
491            return in_array( $parent, self::SERVICE_KEYS, true ) ? mb_substr( $data, 0, self::SERVICE_CHARS ) : '<string, ' . strlen( $data ) . ' chars>';
492        }
493        return $data;
494    }
495
496    /**
497     * Scrub one string: bearer tokens, secret-looking query values, long random runs, addresses,
498     * server paths, and this request's secrets; then cut it to length.
499     *
500     * @param string $text      Text.
501     * @param bool   $long_runs Whether to remove long random-looking runs.
502     * @param bool   $mask      Whether to mask addresses.
503     * @return string
504     */
505    private static function scrub( $text, $long_runs = true, $mask = true ) {
506        $text = (string) wp_check_invalid_utf8( (string) $text, true );
507        $text = (string) preg_replace( '/\bBearer\s+\S+/i', 'Bearer [removed]', $text );
508        $text = (string) preg_replace( '/\b(key|token|nonce|_wpnonce|password|pass|code|auth|sig|signature)=[^&\s"]+/i', '$1=[removed]', $text );
509        if ( $long_runs ) {
510            $text = (string) preg_replace_callback(
511                '/[A-Za-z0-9+\/_-]{32,}={0,2}/',
512                // Hex, or a mix of capitals, small letters and digits, like keys and tokens; words and paths stay.
513                fn( $match ) => preg_match( '/^[0-9a-f]+$/i', $match[0] ) || ( preg_match( '/[A-Z]/', $match[0] ) && preg_match( '/[a-z]/', $match[0] ) && preg_match( '/\d/', $match[0] ) ) ? '[removed]' : $match[0],
514                $text
515            );
516        }
517        if ( $mask ) {
518            $text = self::mask_emails( $text );
519        }
520        if ( defined( 'WP_CONTENT_DIR' ) && '' !== WP_CONTENT_DIR ) {
521            $text = str_replace( rtrim( WP_CONTENT_DIR, '/\\' ), 'wp-content', $text );
522        }
523        if ( defined( 'ABSPATH' ) && strlen( ABSPATH ) > 1 ) {
524            $text = str_replace( ABSPATH, '', $text );
525        }
526        $text = self::remove_secrets( $text );
527        return mb_strlen( $text ) > self::STRING_CHARS ? mb_substr( $text, 0, self::STRING_CHARS - 1 ) . '…' : $text;
528    }
529
530    /**
531     * Replace the stored API key and this request's other secrets wherever they appear, e.g. when the
532     * service repeats a key in its error. Used for the email log's error and details too.
533     *
534     * @since $$next-version$$
535     *
536     * @param mixed $value A string, or an array of them.
537     * @return mixed
538     */
539    public static function remove_secrets( $value ) {
540        $secrets = self::secrets();
541        if ( array() === $secrets ) {
542            return $value;
543        }
544        return map_deep( $value, fn( $item ) => is_string( $item ) ? str_replace( $secrets, '[removed]', $item ) : $item );
545    }
546
547    /**
548     * The stored API key and this request's other secrets.
549     *
550     * @return string[]
551     */
552    private static function secrets() {
553        $secrets = self::$secrets;
554        $account = get_option( Account::OPTION );
555        $key     = is_array( $account ) ? (string) ( $account['api_key'] ?? '' ) : '';
556        if ( strlen( $key ) >= 8 ) {
557            $secrets[] = $key;
558        }
559        return $secrets;
560    }
561
562    /**
563     * Mask every email address in a text.
564     *
565     * @since $$next-version$$
566     *
567     * @param string $text Text.
568     * @return string
569     */
570    public static function mask_emails( $text ) {
571        return (string) preg_replace_callback( '/[A-Za-z0-9._%+\'-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}/', fn( $match ) => self::mask_email( $match[0] ), $text );
572    }
573
574    /**
575     * An address with its mailbox hidden: `jane.doe@example.com` becomes `j•••@example.com`.
576     *
577     * @since $$next-version$$
578     *
579     * @param string $email Address.
580     * @return string
581     */
582    public static function mask_email( $email ) {
583        $email = (string) $email;
584        $at    = strrpos( $email, '@' );
585        if ( false === $at || 0 === $at ) {
586            return $email;
587        }
588        return mb_substr( $email, 0, 1 ) . '•••' . substr( $email, $at );
589    }
590
591    /**
592     * Keep a context under CONTEXT_BYTES: shorten strings, then keep only the plain values.
593     *
594     * @param array $context Redacted context.
595     * @return array
596     */
597    private static function fit( array $context ) {
598        $size = fn( $value ) => strlen( (string) wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) );
599        if ( $size( $context ) <= self::CONTEXT_BYTES ) {
600            return $context;
601        }
602        $short = map_deep( $context, fn( $value ) => is_string( $value ) && mb_strlen( $value ) > 100 ? mb_substr( $value, 0, 99 ) . '…' : $value );
603        if ( $size( $short ) <= self::CONTEXT_BYTES ) {
604            return $short;
605        }
606        $plain = array_filter( $short, 'is_scalar' );
607        return $plain + array( 'cut' => true );
608    }
609
610    /**
611     * A page of entries, newest first.
612     *
613     * @since $$next-version$$
614     *
615     * @param array $args `page`, `per_page`, `search`, `level` (string[]), `area` (string[]), `email` (log id).
616     * @return array{items: array[], total: int}
617     */
618    public static function query( array $args ) {
619        global $wpdb;
620        if ( ! Email_Log::has_v4_schema() ) {
621            return array(
622                'items' => array(),
623                'total' => 0,
624            );
625        }
626        $page                   = max( 1, (int) ( $args['page'] ?? 1 ) );
627        $per_page               = min( 100, max( 1, (int) ( $args['per_page'] ?? 20 ) ) );
628        list( $where, $values ) = self::where( $args );
629        $table                  = self::table_name();
630
631        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- `$where` is built from placeholders only, with `$values` to match.
632        $rows  = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d", $table, ...array_merge( $values, array( $per_page, ( $page - 1 ) * $per_page ) ) ), ARRAY_A );
633        $total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", $table, ...$values ) );
634        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
635        return array(
636            'items' => array_map( array( __CLASS__, 'row' ), is_array( $rows ) ? $rows : array() ),
637            'total' => (int) $total,
638        );
639    }
640
641    /**
642     * The WHERE clause for the list's filters.
643     *
644     * @param array $args As for query().
645     * @return array{0: string, 1: array} SQL with placeholders, and their values.
646     */
647    private static function where( array $args ) {
648        global $wpdb;
649        $where  = array( '1=1' );
650        $values = array();
651        foreach ( array(
652            'level' => self::LEVELS,
653            'area'  => self::AREAS,
654        ) as $column => $allowed ) {
655            $list = array_values( array_intersect( array_map( 'strval', (array) ( $args[ $column ] ?? array() ) ), $allowed ) );
656            if ( $list ) {
657                $where[] = "{$column} IN (" . implode( ',', array_fill( 0, count( $list ), '%s' ) ) . ')';
658                $values  = array_merge( $values, $list );
659            }
660        }
661        if ( ! empty( $args['email'] ) ) {
662            $where[]  = 'log_id = %d';
663            $values[] = (int) $args['email'];
664        }
665        $search = Email_Log::clean_search( $args['search'] ?? '' );
666        if ( '' !== $search ) {
667            $like    = '%' . $wpdb->esc_like( $search ) . '%';
668            $where[] = '( message LIKE %s OR event LIKE %s OR context LIKE %s )';
669            array_push( $values, $like, $like, $like );
670        }
671        return array( implode( ' AND ', $where ), $values );
672    }
673
674    /**
675     * One entry.
676     *
677     * @since $$next-version$$
678     *
679     * @param int $id Entry id.
680     * @return array|null
681     */
682    public static function get( $id ) {
683        global $wpdb;
684        if ( ! Email_Log::has_v4_schema() ) {
685            return null;
686        }
687        $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', self::table_name(), $id ), ARRAY_A );
688        return is_array( $row ) ? self::row( $row ) : null;
689    }
690
691    /**
692     * Entries as plain text for support, newest first, in the site's timezone.
693     *
694     * @since $$next-version$$
695     *
696     * @param array $args  Filters, as for query().
697     * @param int   $limit Most entries.
698     * @return array{text: string, count: int}
699     */
700    public static function text( array $args, $limit = 200 ) {
701        $args['per_page'] = 100;
702        $args['page']     = 1;
703        $page             = self::query( $args );
704        $items            = $page['items'];
705        $want             = min( (int) $limit, $page['total'] );
706        $have             = count( $items );
707        while ( $have < $want ) {
708            ++$args['page'];
709            $more = self::query( $args )['items'];
710            if ( ! $more ) {
711                break;
712            }
713            $items = array_merge( $items, $more );
714            $have  = count( $items );
715        }
716        $items = array_slice( $items, 0, $limit );
717
718        $zone    = wp_timezone();
719        $account = Account::get();
720        $site    = $account ? (string) $account['domain'] : (string) wp_parse_url( home_url(), PHP_URL_HOST );
721        // English on purpose: support reads it.
722        $lines = array(
723            'Jetpack Send debug log',
724            sprintf( 'Site: %s Â· Send %s Â· WordPress %s Â· PHP %s', $site, Send::PACKAGE_VERSION, get_bloginfo( 'version' ), PHP_VERSION ),
725            sprintf(
726                'Times are %s. Newest first. %s',
727                Period::zone_label( new \DateTimeImmutable( 'now', $zone ) ),
728                count( $items ) < $page['total'] ? sprintf( 'Newest %d of %d entries.', count( $items ), $page['total'] ) : sprintf( '%d entries.', count( $items ) )
729            ),
730            '',
731        );
732        foreach ( $items as $item ) {
733            $time    = ( new \DateTimeImmutable( $item['created_at'], new \DateTimeZone( 'UTC' ) ) )->setTimezone( $zone )->format( 'Y-m-d H:i:s' );
734            $context = null === $item['context'] ? '' : ' ' . wp_json_encode( $item['context'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
735            $lines[] = sprintf( '%s  %-8s %-9s %-16s %s%s', $time, strtoupper( $item['level'] ), $item['area'], $item['event'], $item['message'], $context );
736        }
737        return array(
738            'text'  => implode( "\n", $lines ) . "\n",
739            'count' => count( $items ),
740        );
741    }
742
743    /**
744     * How many entries there are, or how many are about one email.
745     *
746     * @since $$next-version$$
747     *
748     * @param int $log_id A log row, or 0 for all.
749     * @return int
750     */
751    public static function count( $log_id = 0 ) {
752        global $wpdb;
753        if ( ! Email_Log::has_v4_schema() ) {
754            return 0;
755        }
756        if ( (int) $log_id > 0 ) {
757            return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE log_id = %d', self::table_name(), $log_id ) );
758        }
759        return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', self::table_name() ) );
760    }
761
762    /**
763     * Delete every entry.
764     *
765     * @since $$next-version$$
766     *
767     * @return int How many were deleted.
768     */
769    public static function clear() {
770        global $wpdb;
771        $count = self::count();
772        if ( $count > 0 ) {
773            $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', self::table_name() ) );
774        }
775        return $count;
776    }
777
778    /**
779     * Delete entries older than the days it keeps them, on or off, and past MAX_ROWS.
780     *
781     * @since $$next-version$$
782     *
783     * @return void
784     */
785    public static function prune() {
786        global $wpdb;
787        if ( ! Email_Log::has_v4_schema() ) {
788            return;
789        }
790        $days  = (int) ( Settings::get()['debug_log_days'] ?? 7 );
791        $table = self::table_name();
792        $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s', $table, gmdate( 'Y-m-d H:i:s', time() - max( 1, $days ) * DAY_IN_SECONDS ) ) );
793        $oldest_kept = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i ORDER BY id DESC LIMIT 1 OFFSET %d', $table, self::MAX_ROWS - 1 ) );
794        if ( null !== $oldest_kept ) {
795            $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id < %d', $table, (int) $oldest_kept ) );
796        }
797    }
798
799    /**
800     * What GET /status reports.
801     *
802     * @since $$next-version$$
803     *
804     * @return array{enabled: bool, days: int, until: string|null, entries: int}
805     */
806    public static function state() {
807        $settings = Settings::get();
808        $enabled  = self::enabled();
809        $until    = self::until();
810        return array(
811            'enabled' => $enabled,
812            'days'    => (int) $settings['debug_log_days'],
813            'until'   => $enabled && null !== $until ? gmdate( 'Y-m-d\\TH:i:s\\Z', $until ) : null,
814            'entries' => self::count(),
815        );
816    }
817
818    /**
819     * Drop the table (uninstall).
820     *
821     * @since $$next-version$$
822     *
823     * @return void
824     */
825    public static function drop() {
826        global $wpdb;
827        $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', self::table_name() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Uninstall.
828        delete_transient( self::SEEN_PREFIX . 'usage' );
829    }
830
831    /**
832     * An entry as the REST API returns it.
833     *
834     * @param array $row Database row.
835     * @return array
836     */
837    private static function row( array $row ) {
838        $context = is_string( $row['context'] ?? null ) ? json_decode( $row['context'], true ) : null;
839        return array(
840            'id'         => (string) $row['id'],
841            'created_at' => (string) $row['created_at'],
842            'level'      => (string) $row['level'],
843            'area'       => (string) $row['area'],
844            'event'      => (string) $row['event'],
845            'message'    => (string) $row['message'],
846            'context'    => is_array( $context ) && array() !== $context ? $context : null,
847            'log_id'     => (int) ( $row['log_id'] ?? 0 ) > 0 ? (string) $row['log_id'] : '',
848            'user'       => Email_Log::user_name( (int) ( $row['user_id'] ?? 0 ) ),
849        );
850    }
851}