Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.20% covered (warning)
74.20%
325 / 438
48.98% covered (danger)
48.98%
24 / 49
CRAP
0.00% covered (danger)
0.00%
0 / 1
Email_Log
74.54% covered (warning)
74.54%
325 / 436
48.98% covered (danger)
48.98%
24 / 49
937.69
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 ensure_scheduled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 table_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_installed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_v4_schema
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_install
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 schema_ready
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 install
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
5
 insert
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 update
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 formats
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 columns
87.88% covered (warning)
87.88%
29 / 33
0.00% covered (danger)
0.00%
0 / 1
17.51
 search_fields
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 cut_bytes
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 encode_detail
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
56
 strip_sign_in_links
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 redact_links
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 redact_body
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
10.00
 redact_link
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 query
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
11
 filters
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 clean_search
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 get
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 brief_of
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 resends_of
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 origins_in_log
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 summary
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
6.04
 oldest_created_at
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 attention
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 delete
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 retention_days
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 retention_setting
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 retention_locked
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prune
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
9.02
 run_hourly
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 backfill_batch
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
19.21
 sent_counts
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
2.26
 stats
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 test_count
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 origins
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 max_id
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 max_id_raw
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 last_email_at
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 has_history
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 user_name
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 iso
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stats_gen
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear_stats_cache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 drop_table
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Email log table.
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 log table; nothing to cache.
15
16/**
17 * Stores one row per email Send handled, with its content for previewing unless filtered out.
18 *
19 * Nothing a GET request reaches deletes rows: only the hourly job, a lowered retention and the Log's
20 * own delete routes do.
21 */
22class Email_Log {
23
24    const DB_VERSION        = '4';
25    const DB_VERSION_OPTION = 'jetpack_send_db_version';
26    const PRUNE_HOOK        = 'jetpack_send_prune_log';
27    const STATUS_SENT       = 'sent';
28    const STATUS_FALLBACK   = 'fallback';
29    const STATUS_FAILED     = 'failed';
30
31    /**
32     * Transient prefix for the cached stats of one preset range; the range in days is appended.
33     *
34     * @var string
35     */
36    const STATS_TRANSIENT = 'jetpack_send_stats_';
37
38    /**
39     * The ranges GET /stats accepted before presets, in days. Their caches are cleared too.
40     *
41     * @var int[]
42     */
43    const STATS_RANGES = array( 7, 30 );
44
45    /**
46     * Bumped whenever cached stats can be stale without a new row: deletes, the prune, a moved count.
47     *
48     * @var string
49     */
50    const STATS_GEN_OPTION = 'jetpack_send_stats_gen';
51
52    /**
53     * Cursor (the last row id done) while older rows get their search columns filled; deleted when done.
54     *
55     * @var string
56     */
57    const FIELDS_BACKFILL_OPTION = 'jetpack_send_fields_backfill';
58
59    /**
60     * Larger bodies are logged without content, keeping the table small.
61     *
62     * @var int
63     */
64    const MAX_BODY_BYTES = 524288;
65
66    /**
67     * The searchable plain-text copy of a body is cut to this many bytes.
68     *
69     * @var int
70     */
71    const BODY_TEXT_BYTES = 16384;
72
73    /**
74     * Largest `detail` kept, JSON-encoded.
75     *
76     * @var int
77     */
78    const DETAIL_BYTES = 8192;
79
80    /**
81     * Search looks inside email text only while the other filters match this many rows or fewer.
82     *
83     * @var int
84     */
85    const TEXT_SEARCH_MAX_ROWS = 20000;
86
87    /**
88     * A search's total stops counting here.
89     *
90     * @var int
91     */
92    const SEARCH_TOTAL_CAP = 1000;
93
94    /**
95     * Shorter searches match too much to be useful, so they search nothing.
96     *
97     * @var int
98     */
99    const SEARCH_MIN_CHARS = 2;
100
101    /**
102     * Longest search.
103     *
104     * @var int
105     */
106    const SEARCH_MAX_CHARS = 100;
107
108    /**
109     * Rows deleted per query by the prune.
110     *
111     * @var int
112     */
113    const PRUNE_BATCH = 1000;
114
115    /**
116     * Older rows given their search columns per run.
117     *
118     * @var int
119     */
120    const FIELDS_BATCH = 200;
121
122    /**
123     * Most message bytes one backfill run reads, so a run of large bodies can't exhaust memory on an
124     * admin page. At least one row always moves on.
125     *
126     * @var int
127     */
128    const FIELDS_BATCH_BYTES = 8388608;
129
130    /**
131     * Set when the host refused the table changes, so the upgrade is tried again an hour later, not on
132     * every request.
133     *
134     * @var string
135     */
136    const UPGRADE_RETRY_TRANSIENT = 'jetpack_send_upgrade_retry';
137
138    /**
139     * Wire the hooks.
140     *
141     * @return void
142     */
143    public static function init() {
144        // Every request, not just admin ones, so front-end emails are never dropped after an upgrade.
145        // The version option is autoloaded, so the check costs no query.
146        add_action( 'init', array( __CLASS__, 'maybe_install' ) );
147        add_action( self::PRUNE_HOOK, array( __CLASS__, 'run_hourly' ) );
148        add_action( 'admin_init', array( __CLASS__, 'ensure_scheduled' ), 10, 0 );
149    }
150
151    /**
152     * Put the hourly job back if it went missing (a cron clean-up, a site move), since it's the only
153     * thing that keeps the log within its retention after the day it was set.
154     *
155     * @since $$next-version$$
156     *
157     * @return void
158     */
159    public static function ensure_scheduled() {
160        if ( self::has_v4_schema() && ! wp_next_scheduled( self::PRUNE_HOOK ) ) {
161            wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::PRUNE_HOOK );
162        }
163    }
164
165    /**
166     * Table name.
167     *
168     * @return string
169     */
170    public static function table_name() {
171        global $wpdb;
172        return $wpdb->prefix . 'jetpack_send_log';
173    }
174
175    /**
176     * Whether the table exists with the columns every log read and write needs (schema 3 or later).
177     * Stays true while another request upgrades it.
178     *
179     * @return bool
180     */
181    public static function is_installed() {
182        return version_compare( (string) get_option( self::DB_VERSION_OPTION, '' ), '3', '>=' );
183    }
184
185    /**
186     * Whether the search and failure columns, the counts table and the debug table exist (schema 4).
187     * Every read or write of them checks this first, so newer code runs safely on an older table.
188     *
189     * @since $$next-version$$
190     *
191     * @return bool
192     */
193    public static function has_v4_schema() {
194        return version_compare( (string) get_option( self::DB_VERSION_OPTION, '' ), '4', '>=' );
195    }
196
197    /**
198     * Install or upgrade the table when the schema version changed.
199     *
200     * @return void
201     */
202    public static function maybe_install() {
203        if ( version_compare( (string) get_option( self::DB_VERSION_OPTION, '' ), self::DB_VERSION, '<' ) && ! get_transient( self::UPGRADE_RETRY_TRANSIENT ) ) {
204            self::install();
205        }
206    }
207
208    /**
209     * Whether the newest columns and both new tables are really there after `dbDelta()`, which doesn't
210     * report a change the host refused.
211     *
212     * @return bool
213     */
214    private static function schema_ready() {
215        global $wpdb;
216        $suppress = $wpdb->suppress_errors();
217        $columns  = (array) $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', self::table_name() ) );
218        $ready    = in_array( 'resend_of', $columns, true )
219            && array() !== (array) $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', Stats::table_name() ) )
220            && array() !== (array) $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', Debug_Log::table_name() ) );
221        $wpdb->suppress_errors( $suppress );
222        return $ready;
223    }
224
225    /**
226     * Create or upgrade the tables, under a lock. Every step adds; nothing is dropped or deleted.
227     *
228     * @return void
229     */
230    public static function install() {
231        global $wpdb;
232        // Another request is upgrading; this one carries on with the stored version.
233        if ( ! Lock::acquire( 'upgrade', 5 * MINUTE_IN_SECONDS ) ) {
234            return;
235        }
236
237        try {
238            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
239            $previous = (string) get_option( self::DB_VERSION_OPTION, '' );
240            $table    = self::table_name();
241            $charset  = $wpdb->get_charset_collate();
242            dbDelta(
243                "CREATE TABLE {$table} (
244                    id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
245                    created_at datetime NOT NULL,
246                    recipients varchar(1000) NOT NULL DEFAULT '',
247                    subject varchar(255) NOT NULL DEFAULT '',
248                    status varchar(20) NOT NULL,
249                    error text NULL,
250                    request_id varchar(64) NOT NULL DEFAULT '',
251                    source varchar(20) NOT NULL DEFAULT 'wp_mail',
252                    origin varchar(100) NOT NULL DEFAULT '',
253                    message longtext NULL,
254                    from_email varchar(255) NOT NULL DEFAULT '',
255                    other_addresses varchar(1000) NOT NULL DEFAULT '',
256                    body_text text NULL,
257                    reason varchar(32) NOT NULL DEFAULT '',
258                    detail text NULL,
259                    resend_of bigint(20) unsigned NOT NULL DEFAULT 0,
260                    user_id bigint(20) unsigned NOT NULL DEFAULT 0,
261                    PRIMARY KEY  (id),
262                    KEY created_at (created_at),
263                    KEY created_status (created_at,status),
264                    KEY status_created (status,created_at),
265                    KEY origin_created (origin,created_at),
266                    KEY reason_created (reason,created_at),
267                    KEY resend_of (resend_of)
268                ) {$charset};"
269            );
270            dbDelta( Stats::schema( $charset ) );
271            dbDelta( Debug_Log::schema( $charset ) );
272
273            // A host that refused a change keeps the version it had, so newer code stays off the
274            // missing columns and every email is still logged; it's tried again in an hour.
275            if ( ! self::schema_ready() ) {
276                set_transient( self::UPGRADE_RETRY_TRANSIENT, 1, HOUR_IN_SECONDS );
277                return;
278            }
279
280            // Sites that already had a log keep the 30 days it kept before the setting existed. Read the raw
281            // option: Settings::get() merges in the new default, which would hide a missing key.
282            if ( in_array( $previous, array( '1', '2', '3' ), true ) && ! array_key_exists( 'log_retention_days', Settings::stored() ) ) {
283                Settings::update( array( 'log_retention_days' => 30 ) );
284            }
285            Stats::start_backfill( self::max_id_raw() );
286            update_option( self::FIELDS_BACKFILL_OPTION, 0, false );
287
288            wp_clear_scheduled_hook( self::PRUNE_HOOK );
289            wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::PRUNE_HOOK );
290            self::clear_stats_cache();
291            update_option( self::DB_VERSION_OPTION, self::DB_VERSION, true );
292        } finally {
293            Lock::release( 'upgrade' );
294        }
295
296        // One batch now, so a log of up to 5,000 emails is counted in this request.
297        Stats::backfill_batch( 0 );
298    }
299
300    /**
301     * Insert a row; a no-op until the table is installed.
302     *
303     * @param array $row status, recipients, subject, error, request_id, source, origin, message (array|null),
304     *                   and from schema 4 reason, detail (array), resend_of, user_id. `created_at` (Unix time) defaults to now.
305     * @return int|false The new row's id, or false when nothing was stored.
306     */
307    public static function insert( array $row ) {
308        global $wpdb;
309        if ( ! self::is_installed() ) {
310            return false;
311        }
312
313        $columns               = self::columns( $row + array( 'source' => 'wp_mail' ) );
314        $columns['created_at'] = gmdate( 'Y-m-d H:i:s', isset( $row['created_at'] ) && is_int( $row['created_at'] ) ? $row['created_at'] : time() );
315        if ( ! $wpdb->insert( self::table_name(), $columns, self::formats( $columns ) ) ) {
316            return false;
317        }
318        $id = (int) $wpdb->insert_id;
319        return $id > 0 ? $id : false;
320    }
321
322    /**
323     * Change the given columns of a row, e.g. once a reserved row's email has been sent.
324     *
325     * @param int   $id  Row ID.
326     * @param array $row Columns to change, as for insert().
327     * @return bool Whether the update ran.
328     */
329    public static function update( $id, array $row ) {
330        global $wpdb;
331        if ( ! self::is_installed() || $id < 1 ) {
332            return false;
333        }
334
335        $columns = self::columns( $row );
336        if ( array() === $columns ) {
337            return false;
338        }
339        return false !== $wpdb->update( self::table_name(), $columns, array( 'id' => (int) $id ), self::formats( $columns ), array( '%d' ) );
340    }
341
342    /**
343     * Formats for the columns: numbers for ids, text for the rest.
344     *
345     * @param array $columns Column values.
346     * @return string[]
347     */
348    private static function formats( array $columns ) {
349        return array_map( fn( $key ) => in_array( $key, array( 'resend_of', 'user_id' ), true ) ? '%d' : '%s', array_keys( $columns ) );
350    }
351
352    /**
353     * Clean and truncate the given row keys into column values.
354     *
355     * @param array $row Keys from insert().
356     * @return array<string, string|int|null>
357     */
358    private static function columns( array $row ) {
359        // Invalid UTF-8 makes wpdb refuse the whole row, losing the log entry.
360        $text    = fn( $key ) => (string) wp_check_invalid_utf8( (string) $row[ $key ], true );
361        $limits  = array(
362            'recipients' => 1000,
363            'subject'    => 255,
364            'status'     => 20,
365            'error'      => 0,
366            'request_id' => 64,
367            'source'     => 20,
368            'origin'     => 100,
369        );
370        $columns = array();
371        foreach ( $limits as $key => $limit ) {
372            if ( array_key_exists( $key, $row ) ) {
373                $columns[ $key ] = $limit ? mb_substr( $text( $key ), 0, $limit ) : $text( $key );
374            }
375        }
376        // The service sometimes repeats what it was sent, a key included.
377        if ( isset( $columns['error'] ) ) {
378            $columns['error'] = Debug_Log::remove_secrets( $columns['error'] );
379        }
380        $message = null;
381        if ( array_key_exists( 'message', $row ) ) {
382            $message            = empty( $row['message'] ) || ! is_array( $row['message'] ) ? null : self::strip_sign_in_links( $row['message'] );
383            $encoded            = null === $message ? false : wp_json_encode( $message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
384            $columns['message'] = false === $encoded ? null : $encoded;
385        }
386        if ( ! self::has_v4_schema() ) {
387            return $columns;
388        }
389
390        if ( array_key_exists( 'message', $row ) ) {
391            $columns = array_merge( $columns, self::search_fields( $message ) );
392        }
393        if ( array_key_exists( 'reason', $row ) ) {
394            $columns['reason'] = substr( (string) preg_replace( '/[^a-z_]/', '', (string) $row['reason'] ), 0, 32 );
395        }
396        if ( array_key_exists( 'detail', $row ) ) {
397            $columns['detail'] = is_array( $row['detail'] ) ? self::encode_detail( Debug_Log::remove_secrets( $row['detail'] ) ) : null;
398        }
399        foreach ( array( 'resend_of', 'user_id' ) as $key ) {
400            if ( array_key_exists( $key, $row ) ) {
401                $columns[ $key ] = max( 0, (int) $row[ $key ] );
402            }
403        }
404        return $columns;
405    }
406
407    /**
408     * The search columns for a stored message: the From address, the other addresses, and the text.
409     *
410     * @param array|null $message Decoded message, sign-in links already stripped.
411     * @return array{from_email: string, other_addresses: string, body_text: string|null}
412     */
413    private static function search_fields( $message ) {
414        $message = is_array( $message ) ? $message : array();
415        $others  = array();
416        foreach ( array( 'cc', 'bcc' ) as $key ) {
417            foreach ( (array) ( $message[ $key ] ?? array() ) as $address ) {
418                $others[] = is_array( $address ) ? (string) ( $address['email'] ?? '' ) : '';
419            }
420        }
421        $others[] = is_array( $message['reply_to'] ?? null ) ? (string) ( $message['reply_to']['email'] ?? '' ) : '';
422        $body     = $message['body'] ?? null;
423        return array(
424            'from_email'      => is_array( $message['from'] ?? null ) ? substr( (string) ( $message['from']['email'] ?? '' ), 0, 255 ) : '',
425            'other_addresses' => mb_substr( implode( ',', array_filter( $others ) ), 0, 1000 ),
426            'body_text'       => is_string( $body ) ? self::cut_bytes( (string) wp_check_invalid_utf8( Message_Builder::plain_text( $body, (string) ( $message['content_type'] ?? 'text/plain' ) ), true ), self::BODY_TEXT_BYTES ) : null,
427        );
428    }
429
430    /**
431     * Cut text to a number of bytes without splitting a character, which would make wpdb drop the row.
432     *
433     * @param string $text  Text.
434     * @param int    $bytes Most bytes.
435     * @return string
436     */
437    private static function cut_bytes( $text, $bytes ) {
438        if ( strlen( $text ) <= $bytes ) {
439            return $text;
440        }
441        if ( function_exists( 'mb_strcut' ) ) {
442            return mb_strcut( $text, 0, $bytes, 'UTF-8' );
443        }
444        return (string) preg_replace( '/[\xC0-\xFF][\x80-\xBF]*$/', '', substr( $text, 0, $bytes ) );
445    }
446
447    /**
448     * Encode a detail, shortening it to fit DETAIL_BYTES.
449     *
450     * @param array $detail Detail.
451     * @return string|null
452     */
453    private static function encode_detail( array $detail ) {
454        $encode  = fn( $value ) => wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
455        $encoded = $encode( Failure_Reason::cap( $detail ) );
456        if ( false !== $encoded && strlen( $encoded ) > self::DETAIL_BYTES ) {
457            unset( $detail['service_errors'] );
458            $encoded = $encode( map_deep( $detail, fn( $value ) => is_string( $value ) && mb_strlen( $value ) > 100 ? mb_substr( $value, 0, 99 ) . '…' : $value ) );
459        }
460        return false === $encoded || strlen( $encoded ) > self::DETAIL_BYTES ? null : $encoded;
461    }
462
463    /**
464     * The stored copy of a message with sign-in links stripped, marked `redacted` when any were.
465     *
466     * @param array $message Message.
467     * @return array
468     */
469    private static function strip_sign_in_links( array $message ) {
470        if ( ! is_string( $message['body'] ?? null ) ) {
471            return $message;
472        }
473        $body = self::redact_body( $message['body'] );
474        if ( null === $body ) {
475            // Couldn't check it: keep the body, and let it go only to the same people or the sender.
476            $message['locked'] = true;
477        } elseif ( $body !== $message['body'] ) {
478            $message['body']     = $body;
479            $message['redacted'] = true;
480        }
481        return $message;
482    }
483
484    /**
485     * Remove the secret part of sign-in links from a stored body; the email that goes out is untouched.
486     * Kept narrow on purpose, so links like a store's order page still work in a resend.
487     *
488     * @since $$next-version$$
489     *
490     * @param string $body Body.
491     * @return string
492     */
493    public static function redact_links( $body ) {
494        $redacted = self::redact_body( (string) $body );
495        return null === $redacted ? (string) $body : $redacted;
496    }
497
498    /**
499     * The body with sign-in links stripped, or null when a pattern couldn't run on it (a backtrack or
500     * memory limit), so the caller can keep the original rather than store an empty body.
501     *
502     * @param string $body Body.
503     * @return string|null
504     */
505    private static function redact_body( $body ) {
506        // A link is a run of URL characters with a `?` in it. Matches start only where a run starts and
507        // the part before the `?` never gives characters back, so a long run with no `?` (an inline
508        // image, say) is read once, not once per character.
509        $body = preg_replace_callback( '#(?<![^\s"\'<>()])[^\s"\'<>()?]*+\?[^\s"\'<>()]+#', array( __CLASS__, 'redact_link' ), (string) $body );
510        if ( null === $body ) {
511            return null;
512        }
513        // Personal data export files are reachable by anyone with the link.
514        $body = preg_replace( '#(wp-content/uploads/wp-personal-data-exports/)[^\s"\'<>()?]++#i', '$1[removed]', $body );
515        if ( null === $body ) {
516            return null;
517        }
518
519        /**
520         * More sign-in link patterns to strip from stored emails. A pattern's first capture group is removed,
521         * or the whole match when it has none.
522         *
523         * @since $$next-version$$
524         *
525         * @param string[] $patterns Regular expressions. Default none.
526         */
527        foreach ( (array) apply_filters( 'jetpack_send_log_redact_patterns', array() ) as $pattern ) {
528            if ( ! is_string( $pattern ) || '' === $pattern ) {
529                continue;
530            }
531            $result = @preg_replace_callback( $pattern, fn( $match ) => isset( $match[1] ) && '' !== $match[1] ? str_replace( $match[1], '[removed]', $match[0] ) : '[removed]', $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A bad pattern from a filter must not stop logging.
532            if ( is_string( $result ) ) {
533                $body = $result;
534            }
535        }
536        return $body;
537    }
538
539    /**
540     * Strip the secrets from one link with a query string.
541     *
542     * @param array $match The link, as `[0]`.
543     * @return string
544     */
545    private static function redact_link( array $match ) {
546        $url    = $match[0];
547        $params = array( 'adminhash', 'newuseremail' );
548        if ( preg_match( '/wp-login\.php/i', $url ) && preg_match( '/\baction=(?:rp|resetpass)\b/i', $url ) ) {
549            $params[] = 'key';
550        }
551        if ( preg_match( '/\baction=enter_recovery_mode\b/i', $url ) ) {
552            array_push( $params, 'rm_token', 'rm_key' );
553        }
554        if ( preg_match( '/\baction=confirmaction\b/i', $url ) ) {
555            $params[] = 'confirm_key';
556        }
557        if ( preg_match( '/wp-activate\.php/i', $url ) ) {
558            $params[] = 'key';
559        }
560        // Store account set-password links: a lost-password page with a key, and a new account's link.
561        if ( preg_match( '#/lost-password/?(?:\?|&)#i', $url ) || preg_match( '/\baction=newaccount\b/i', $url ) ) {
562            $params[] = 'key';
563        }
564        foreach ( array_unique( $params ) as $param ) {
565            $url = (string) preg_replace( '/((?:\?|&(?:amp;|#0?38;)?)' . preg_quote( $param, '/' ) . '=)[^&#\s"\'<>]+/i', '$1[removed]', $url );
566        }
567        return $url;
568    }
569
570    /**
571     * A page of rows without their message, newest first.
572     *
573     * Takes an args array: `page`, `per_page`, `search`, `status`, `origin` and `source` (lists; any of),
574     * `start_utc` and `end_utc` (UTC `Y-m-d H:i:s`, or Unix times). The older positional form still works.
575     *
576     * @param array|int $args     Args, or the 1-based page in the older form.
577     * @param int       $per_page Older form: rows per page.
578     * @param string    $status   Older form: status filter, or '' for all.
579     * @param string    $source   Older form: source filter, or '' for all.
580     * @param string    $since    Older form: UTC `Y-m-d H:i:s`; only rows at or after it. '' for any date.
581     * @return array{items: array[], total: int, searched_text: bool, total_capped: bool}
582     */
583    public static function query( $args, $per_page = 20, $status = '', $source = '', $since = '' ) {
584        global $wpdb;
585        if ( ! is_array( $args ) ) {
586            $args = array(
587                'page'      => (int) $args,
588                'per_page'  => (int) $per_page,
589                'status'    => '' === (string) $status ? array() : array( (string) $status ),
590                'source'    => '' === (string) $source ? array() : array( (string) $source ),
591                'start_utc' => (string) $since,
592            );
593        }
594        $page = array(
595            'items'         => array(),
596            'total'         => 0,
597            'searched_text' => true,
598            'total_capped'  => false,
599        );
600        if ( ! self::is_installed() ) {
601            return $page;
602        }
603
604        $table                  = self::table_name();
605        $per_page               = max( 1, (int) ( $args['per_page'] ?? 20 ) );
606        $offset                 = max( 0, ( (int) ( $args['page'] ?? 1 ) - 1 ) * $per_page );
607        $v4                     = self::has_v4_schema();
608        $columns                = 'id, created_at, recipients, subject, status, error, request_id, source, origin' . ( $v4 ? ', from_email, reason, resend_of' : '' );
609        list( $where, $values ) = self::filters( $args );
610        $search                 = self::clean_search( $args['search'] ?? '' );
611
612        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- `$where` and `$match` hold placeholders only, with values to match.
613        if ( '' === $search ) {
614            $page['items'] = $wpdb->get_results( $wpdb->prepare( "SELECT {$columns} FROM %i WHERE {$where} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", $table, ...array_merge( $values, array( $per_page, $offset ) ) ), ARRAY_A );
615            $page['total'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$where}", $table, ...$values ) );
616        } else {
617            $fields = $v4 ? array( 'subject', 'recipients', 'from_email', 'other_addresses' ) : array( 'subject', 'recipients' );
618            // Text is searched only while the other filters leave few enough rows to scan; the count stops early.
619            $page['searched_text'] = $v4 && (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM ( SELECT 1 FROM %i WHERE {$where} LIMIT %d ) counted", $table, ...array_merge( $values, array( self::TEXT_SEARCH_MAX_ROWS + 1 ) ) ) ) <= self::TEXT_SEARCH_MAX_ROWS;
620            if ( $page['searched_text'] ) {
621                $fields[] = 'body_text';
622            }
623            $match  = '( ' . implode( ' OR ', array_map( fn( $field ) => "{$field} LIKE %s", $fields ) ) . ' )';
624            $values = array_merge( $values, array_fill( 0, count( $fields ), '%' . $wpdb->esc_like( $search ) . '%' ) );
625
626            $page['items']        = $wpdb->get_results( $wpdb->prepare( "SELECT {$columns} FROM %i WHERE {$where} AND {$match} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", $table, ...array_merge( $values, array( $per_page, $offset ) ) ), ARRAY_A );
627            $total                = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM ( SELECT 1 FROM %i WHERE {$where} AND {$match} LIMIT %d ) counted", $table, ...array_merge( $values, array( self::SEARCH_TOTAL_CAP + 1 ) ) ) );
628            $page['total_capped'] = $total > self::SEARCH_TOTAL_CAP;
629            $page['total']        = min( $total, self::SEARCH_TOTAL_CAP );
630        }
631        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
632
633        $page['items'] = is_array( $page['items'] ) ? $page['items'] : array();
634        return $page;
635    }
636
637    /**
638     * The WHERE clause for the filters, which the indexes cover.
639     *
640     * @param array $args As for query().
641     * @return array{0: string, 1: array} SQL with placeholders, and their values.
642     */
643    private static function filters( array $args ) {
644        $where  = array( '1=1' );
645        $values = array();
646        foreach ( array( 'status', 'origin', 'source' ) as $column ) {
647            $list = array_values( array_unique( array_filter( array_map( 'strval', (array) ( $args[ $column ] ?? array() ) ), 'strlen' ) ) );
648            if ( $list ) {
649                $where[] = "{$column} IN (" . implode( ',', array_fill( 0, count( $list ), '%s' ) ) . ')';
650                $values  = array_merge( $values, $list );
651            }
652        }
653        foreach ( array(
654            'start_utc' => '>=',
655            'end_utc'   => '<',
656        ) as $key => $operator ) {
657            $time = $args[ $key ] ?? '';
658            if ( is_int( $time ) ) {
659                $time = gmdate( 'Y-m-d H:i:s', $time );
660            }
661            if ( '' !== (string) $time ) {
662                $where[]  = "created_at {$operator} %s";
663                $values[] = (string) $time;
664            }
665        }
666        return array( implode( ' AND ', $where ), $values );
667    }
668
669    /**
670     * Search text as the Log sends it: trimmed, control characters dropped, cut to SEARCH_MAX_CHARS
671     * characters, and '' when shorter than SEARCH_MIN_CHARS. Not sanitize_text_field(): it would change "50%" or "<b>".
672     *
673     * @since $$next-version$$
674     *
675     * @param mixed $value Typed text.
676     * @return string
677     */
678    public static function clean_search( $value ) {
679        if ( ! is_scalar( $value ) ) {
680            return '';
681        }
682        $text = trim( (string) preg_replace( '/[\x00-\x1F\x7F]/u', '', (string) wp_check_invalid_utf8( (string) $value, true ) ) );
683        return mb_strlen( $text ) < self::SEARCH_MIN_CHARS ? '' : mb_substr( $text, 0, self::SEARCH_MAX_CHARS );
684    }
685
686    /**
687     * One row with its decoded message, which is null when none was kept.
688     *
689     * @param int $id Row ID.
690     * @return array|null Null when the row doesn't exist.
691     */
692    public static function get( $id ) {
693        global $wpdb;
694        if ( ! self::is_installed() ) {
695            return null;
696        }
697
698        $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', self::table_name(), $id ), ARRAY_A );
699        if ( ! is_array( $row ) ) {
700            return null;
701        }
702
703        $message        = is_string( $row['message'] ) ? json_decode( $row['message'], true ) : null;
704        $row['message'] = is_array( $message ) ? $message : null;
705        return $row;
706    }
707
708    /**
709     * When an email was logged and how it went, or null when it's no longer in the log.
710     *
711     * @since $$next-version$$
712     *
713     * @param int $id Row id.
714     * @return array{created_at: string, status: string}|null UTC `Y-m-d H:i:s` and the status.
715     */
716    public static function brief_of( $id ) {
717        global $wpdb;
718        if ( ! self::is_installed() || (int) $id < 1 ) {
719            return null;
720        }
721        $row = $wpdb->get_row( $wpdb->prepare( 'SELECT created_at, status FROM %i WHERE id = %d', self::table_name(), $id ), ARRAY_A );
722        return is_array( $row ) && is_string( $row['created_at'] ?? null ) && '' !== $row['created_at']
723            ? array(
724                'created_at' => $row['created_at'],
725                'status'     => (string) ( $row['status'] ?? '' ),
726            )
727            : null;
728    }
729
730    /**
731     * The copies of an email sent again from the Log, newest first.
732     *
733     * @since $$next-version$$
734     *
735     * @param int $id    The original's row id.
736     * @param int $limit Most copies listed.
737     * @return array{items: array[], total: int} `items` hold id, status and created_at.
738     */
739    public static function resends_of( $id, $limit = 20 ) {
740        global $wpdb;
741        if ( ! self::has_v4_schema() || (int) $id < 1 ) {
742            return array(
743                'items' => array(),
744                'total' => 0,
745            );
746        }
747        $table = self::table_name();
748        $items = $wpdb->get_results( $wpdb->prepare( 'SELECT id, status, created_at FROM %i WHERE resend_of = %d ORDER BY id DESC LIMIT %d', $table, $id, $limit ), ARRAY_A );
749        $total = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE resend_of = %d', $table, $id ) );
750        return array(
751            'items' => is_array( $items ) ? $items : array(),
752            'total' => (int) $total,
753        );
754    }
755
756    /**
757     * Origins still in the log, most first.
758     *
759     * @since $$next-version$$
760     *
761     * @return array<int, array{origin: string, n: string}>
762     */
763    public static function origins_in_log() {
764        global $wpdb;
765        if ( ! self::is_installed() ) {
766            return array();
767        }
768        $rows = $wpdb->get_results( $wpdb->prepare( "SELECT origin, COUNT(*) AS n FROM %i WHERE origin <> '' GROUP BY origin ORDER BY n DESC", self::table_name() ), ARRAY_A );
769        return is_array( $rows ) ? $rows : array();
770    }
771
772    /**
773     * How many emails the log holds, the oldest one, and how many each retention choice would delete,
774     * with the same rolling cutoff the prune uses.
775     *
776     * @since $$next-version$$
777     *
778     * @param int|null $now Unix time; defaults to now.
779     * @return array{total: int, oldest_at: string|null, older_than: array<string, int>}
780     */
781    public static function summary( $now = null ) {
782        global $wpdb;
783        $now     = null === $now ? time() : (int) $now;
784        $choices = array_values( array_filter( Settings::RETENTION_DAYS ) );
785        $summary = array(
786            'total'      => 0,
787            'oldest_at'  => null,
788            'older_than' => array_fill_keys( array_map( 'strval', $choices ), 0 ),
789        );
790        if ( ! self::is_installed() ) {
791            return $summary;
792        }
793
794        $sums   = implode( ', ', array_map( fn( $days ) => "SUM( CASE WHEN created_at < %s THEN 1 ELSE 0 END ) AS d{$days}", $choices ) );
795        $values = array_map( fn( $days ) => gmdate( 'Y-m-d H:i:s', $now - $days * DAY_IN_SECONDS ), $choices );
796        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- `$sums` holds one placeholder per choice.
797        $row = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS total, MIN(created_at) AS oldest, {$sums} FROM %i", ...array_merge( $values, array( self::table_name() ) ) ), ARRAY_A );
798        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
799        if ( ! is_array( $row ) ) {
800            return $summary;
801        }
802        $summary['total']     = (int) ( $row['total'] ?? 0 );
803        $summary['oldest_at'] = empty( $row['oldest'] ) ? null : self::iso( (string) $row['oldest'] );
804        foreach ( $choices as $days ) {
805            $summary['older_than'][ (string) $days ] = (int) ( $row[ 'd' . $days ] ?? 0 );
806        }
807        return $summary;
808    }
809
810    /**
811     * When the oldest row was logged.
812     *
813     * @since $$next-version$$
814     *
815     * @return string|null UTC `Y-m-d H:i:s`, or null when the log is empty.
816     */
817    public static function oldest_created_at() {
818        global $wpdb;
819        if ( ! self::is_installed() ) {
820            return null;
821        }
822        $value = $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(created_at) FROM %i', self::table_name() ) );
823        return is_string( $value ) && '' !== $value ? $value : null;
824    }
825
826    /**
827     * Failed and host-sent site emails and resends still in the log in a window.
828     *
829     * @since $$next-version$$
830     *
831     * @param int $start_utc First second, Unix time.
832     * @param int $end_utc   Second after the last one.
833     * @return array{failed_in_log: int, fallback_in_log: int}
834     */
835    public static function attention( $start_utc, $end_utc ) {
836        global $wpdb;
837        $counts = array(
838            'failed_in_log'   => 0,
839            'fallback_in_log' => 0,
840        );
841        if ( ! self::is_installed() ) {
842            return $counts;
843        }
844        $row = $wpdb->get_row( $wpdb->prepare( "SELECT SUM( CASE WHEN status = 'failed' THEN 1 ELSE 0 END ) AS failed, SUM( CASE WHEN status = 'fallback' THEN 1 ELSE 0 END ) AS fallback FROM %i WHERE created_at >= %s AND created_at < %s AND source IN ( 'wp_mail', 'resend' ) AND status IN ( 'failed', 'fallback' )", self::table_name(), gmdate( 'Y-m-d H:i:s', (int) $start_utc ), gmdate( 'Y-m-d H:i:s', (int) $end_utc ) ), ARRAY_A );
845        if ( is_array( $row ) ) {
846            $counts['failed_in_log']   = (int) ( $row['failed'] ?? 0 );
847            $counts['fallback_in_log'] = (int) ( $row['fallback'] ?? 0 );
848        }
849        return $counts;
850    }
851
852    /**
853     * Delete rows by id, for good. Nothing else changes: the onboarding state, the hourly test limit and
854     * the dashboard's counts live outside the log, so deleting a row reopens nothing and lowers no number.
855     *
856     * @param int[] $ids Row IDs; duplicates and anything below 1 are ignored.
857     * @return array{deleted: int[], missing: int[]} The ids that were removed, and the ones that weren't there.
858     */
859    public static function delete( array $ids ) {
860        global $wpdb;
861        $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ), fn( $id ) => $id > 0 ) ) );
862        if ( ! self::is_installed() || array() === $ids ) {
863            return array(
864                'deleted' => array(),
865                'missing' => $ids,
866            );
867        }
868
869        $in    = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
870        $table = self::table_name();
871        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- `$in` is one `%d` per id.
872        $found = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE id IN ($in)", $table, ...$ids ) ) );
873        if ( array() !== $found ) {
874            $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE id IN ($in)", $table, ...$ids ) );
875        }
876        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
877        // The cached stats only notice a new newest row, and the attention notice counts rows still in the log.
878        self::clear_stats_cache();
879
880        $deleted = array_values( array_intersect( $ids, $found ) );
881        if ( $deleted ) {
882            /* translators: %d: how many emails. */
883            Debug_Log::add( 'info', 'cleanup', 'log.deleted', sprintf( _n( 'Deleted %d email from the log.', 'Deleted %d emails from the log.', count( $deleted ), 'jetpack-send-pkg' ), count( $deleted ) ), array( 'count' => count( $deleted ) ) );
884        }
885        return array(
886            'deleted' => $deleted,
887            'missing' => array_values( array_diff( $ids, $deleted ) ),
888        );
889    }
890
891    /**
892     * How many days the log keeps rows: the setting, unless the site's code sets it.
893     *
894     * @return int Days; 0 when rows are kept for good.
895     */
896    public static function retention_days() {
897        $days = self::retention_setting();
898
899        /**
900         * Days to keep Send log rows. Overrides the Email log setting, which then can't be changed.
901         *
902         * @since $$next-version$$
903         *
904         * @param int $days Retention in days from the setting; 0 keeps rows for good. Default 7.
905         */
906        return max( 0, (int) apply_filters( 'jetpack_send_log_retention_days', $days ) );
907    }
908
909    /**
910     * The Email log setting's days, before the site's code has its say.
911     *
912     * @since $$next-version$$
913     *
914     * @return int Days; 0 keeps rows for good.
915     */
916    public static function retention_setting() {
917        $stored = Settings::stored();
918        if ( array_key_exists( 'log_retention_days', $stored ) ) {
919            return (int) $stored['log_retention_days'];
920        }
921        // A log from before the setting kept 30 days, until the upgrade writes that down.
922        return self::is_installed() && ! self::has_v4_schema() ? 30 : (int) Settings::get()['log_retention_days'];
923    }
924
925    /**
926     * Whether the site's code sets the retention, so the setting can't change it.
927     *
928     * @since $$next-version$$
929     *
930     * @return bool
931     */
932    public static function retention_locked() {
933        return false !== has_filter( 'jetpack_send_log_retention_days' );
934    }
935
936    /**
937     * Delete rows older than the retention window, in batches, until done or the time runs out.
938     * Rows the counts backfill hasn't reached stay, so no email drops out of the dashboard.
939     *
940     * @param int $budget_seconds Time to spend.
941     * @return array{deleted: int, remaining: int}
942     */
943    public static function prune( $budget_seconds = 10 ) {
944        global $wpdb;
945        $result = array(
946            'deleted'   => 0,
947            'remaining' => 0,
948        );
949        $days   = self::retention_days();
950        if ( ! self::is_installed() || $days < 1 ) {
951            return $result;
952        }
953
954        $table   = self::table_name();
955        $cutoff  = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS );
956        $state   = Stats::backfill_state();
957        $counted = null === $state || $state['done'] ? '' : $wpdb->prepare( ' AND ( id <= %d OR id > %d )', $state['cursor'], $state['until_id'] );
958        $started = microtime( true );
959        $full    = false;
960        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- `$counted` is already prepared; `$in` is one `%d` per id.
961        do {
962            $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE created_at < %s{$counted} ORDER BY id LIMIT %d", $table, $cutoff, self::PRUNE_BATCH ) ) );
963            if ( array() === $ids ) {
964                break;
965            }
966            $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
967            $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE id IN ($in)", $table, ...$ids ) );
968            $result['deleted'] += count( $ids );
969            $full               = count( $ids ) === self::PRUNE_BATCH;
970        } while ( $full && microtime( true ) - $started < $budget_seconds );
971        if ( $full ) {
972            $result['remaining'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE created_at < %s{$counted}", $table, $cutoff ) );
973        }
974        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
975
976        if ( $result['deleted'] > 0 ) {
977            self::clear_stats_cache();
978            /* translators: %d: a number of days. */
979            $length = sprintf( _n( '%d day', '%d days', $days, 'jetpack-send-pkg' ), $days );
980            Debug_Log::add(
981                'info',
982                'cleanup',
983                'log.pruned',
984                /* translators: 1: how many emails, 2: how long the log keeps them, e.g. "7 days". */
985                sprintf( _n( 'Deleted %1$d email older than %2$s.', 'Deleted %1$d emails older than %2$s.', $result['deleted'], 'jetpack-send-pkg' ), $result['deleted'], $length ),
986                $result + array( 'days' => $days )
987            );
988        }
989        return $result;
990    }
991
992    /**
993     * The hourly job: count older emails, then prune the log, the counts and the debug log.
994     *
995     * @since $$next-version$$
996     *
997     * @return void
998     */
999    public static function run_hourly() {
1000        Stats::backfill_batch();
1001        self::backfill_batch();
1002        self::prune( 10 );
1003        Stats::prune();
1004        Debug_Log::expire_if_due();
1005        Debug_Log::prune();
1006    }
1007
1008    /**
1009     * Fill the search columns of rows logged before they existed, a batch at a time. Only fills;
1010     * restarting it is harmless. Runs on admin pages and the hourly job, never on the front end.
1011     *
1012     * @since $$next-version$$
1013     *
1014     * @param float $budget_seconds Time to spend.
1015     * @return bool Whether every older row is done.
1016     */
1017    public static function backfill_batch( $budget_seconds = 2.0 ) {
1018        global $wpdb;
1019        $cursor = get_option( self::FIELDS_BACKFILL_OPTION, false );
1020        if ( false === $cursor || ! self::has_v4_schema() ) {
1021            return true;
1022        }
1023
1024        $state   = Stats::backfill_state();
1025        $until   = null === $state ? self::max_id() : $state['until_id'];
1026        $table   = self::table_name();
1027        $cursor  = (int) $cursor;
1028        $started = microtime( true );
1029        // Sizes first, so one run reads at most FIELDS_BATCH_BYTES of bodies, and always at least one row.
1030        $sizes = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id, LENGTH(message) AS bytes FROM %i WHERE id > %d AND id <= %d ORDER BY id LIMIT %d', $table, $cursor, $until, self::FIELDS_BATCH ), ARRAY_A );
1031        $take  = 0;
1032        $bytes = 0;
1033        foreach ( $sizes as $size ) {
1034            $bytes += (int) ( $size['bytes'] ?? 0 );
1035            if ( $take > 0 && $bytes > self::FIELDS_BATCH_BYTES ) {
1036                break;
1037            }
1038            ++$take;
1039        }
1040        $rows = $take > 0 ? (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id, message FROM %i WHERE id > %d AND id <= %d ORDER BY id LIMIT %d', $table, $cursor, $until, $take ), ARRAY_A ) : array();
1041        foreach ( $rows as $row ) {
1042            $message  = is_string( $row['message'] ) ? json_decode( $row['message'], true ) : null;
1043            $stripped = is_array( $message ) ? self::strip_sign_in_links( $message ) : null;
1044            $fields   = self::search_fields( $stripped );
1045            $formats  = array( '%s', '%s', '%s' );
1046            // Older rows kept sign-in links in their stored copy; the copy loses them too, as new rows do.
1047            if ( is_array( $stripped ) && $stripped !== $message ) {
1048                $encoded = wp_json_encode( $stripped, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
1049                if ( false !== $encoded ) {
1050                    $fields['message'] = $encoded;
1051                    $formats[]         = '%s';
1052                }
1053            }
1054            $wpdb->update( $table, $fields, array( 'id' => (int) $row['id'] ), $formats, array( '%d' ) );
1055            $cursor = (int) $row['id'];
1056            if ( microtime( true ) - $started > $budget_seconds ) {
1057                break;
1058            }
1059        }
1060
1061        $done = count( $sizes ) < self::FIELDS_BATCH && count( $rows ) === count( $sizes ) && ( array() === $rows || (int) end( $rows )['id'] === $cursor );
1062        if ( $done ) {
1063            delete_option( self::FIELDS_BACKFILL_OPTION );
1064        } else {
1065            update_option( self::FIELDS_BACKFILL_OPTION, $cursor, false );
1066        }
1067        return $done;
1068    }
1069
1070    /**
1071     * Emails handed to the mail service since a UTC time: every sent row, tests included, since
1072     * tests use the plan's sends too.
1073     *
1074     * @param string $since_utc UTC `Y-m-d H:i:s`.
1075     * @return array{emails: int, tests: int}
1076     */
1077    public static function sent_counts( $since_utc ) {
1078        global $wpdb;
1079        if ( ! self::is_installed() ) {
1080            return array(
1081                'emails' => 0,
1082                'tests'  => 0,
1083            );
1084        }
1085        $row = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS emails, SUM( CASE WHEN source = 'test' THEN 1 ELSE 0 END ) AS tests FROM %i WHERE created_at >= %s AND status = %s", self::table_name(), $since_utc, self::STATUS_SENT ), ARRAY_A );
1086        return array(
1087            'emails' => (int) ( $row['emails'] ?? 0 ),
1088            'tests'  => (int) ( $row['tests'] ?? 0 ),
1089        );
1090    }
1091
1092    /**
1093     * Non-test emails per site-local day and status since a UTC time.
1094     *
1095     * @param string $since_utc UTC `Y-m-d H:i:s`; rows at or after it count.
1096     * @param int    $offset    Site timezone offset from UTC, in seconds.
1097     * @return array<int, array{d: string, status: string, n: string}>
1098     */
1099    public static function stats( $since_utc, $offset ) {
1100        global $wpdb;
1101        if ( ! self::is_installed() ) {
1102            return array();
1103        }
1104        // A negative INTERVAL comes back NULL under the SQLite integration, so shift west with DATE_SUB.
1105        $sql  = (int) $offset >= 0
1106            ? "SELECT DATE(DATE_ADD(created_at, INTERVAL %d SECOND)) AS d, status, COUNT(*) AS n FROM %i WHERE created_at >= %s AND source <> 'test' GROUP BY d, status"
1107            : "SELECT DATE(DATE_SUB(created_at, INTERVAL %d SECOND)) AS d, status, COUNT(*) AS n FROM %i WHERE created_at >= %s AND source <> 'test' GROUP BY d, status";
1108        $rows = $wpdb->get_results( $wpdb->prepare( $sql, abs( (int) $offset ), self::table_name(), $since_utc ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is one of the two literals above.
1109        return is_array( $rows ) ? $rows : array();
1110    }
1111
1112    /**
1113     * Test emails since a UTC time.
1114     *
1115     * @param string $since_utc UTC `Y-m-d H:i:s`.
1116     * @return int
1117     */
1118    public static function test_count( $since_utc ) {
1119        global $wpdb;
1120        if ( ! self::is_installed() ) {
1121            return 0;
1122        }
1123        return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE created_at >= %s AND source = 'test'", self::table_name(), $since_utc ) );
1124    }
1125
1126    /**
1127     * Non-test emails per origin since a UTC time, most first. Rows logged before origins existed are left out.
1128     *
1129     * @param string $since_utc UTC `Y-m-d H:i:s`.
1130     * @return array<int, array{origin: string, n: string}>
1131     */
1132    public static function origins( $since_utc ) {
1133        global $wpdb;
1134        if ( ! self::is_installed() ) {
1135            return array();
1136        }
1137        $rows = $wpdb->get_results( $wpdb->prepare( "SELECT origin, COUNT(*) AS n FROM %i WHERE created_at >= %s AND source <> 'test' AND origin <> '' GROUP BY origin ORDER BY n DESC", self::table_name(), $since_utc ), ARRAY_A );
1138        return is_array( $rows ) ? $rows : array();
1139    }
1140
1141    /**
1142     * The newest row's id, 0 when the table is empty: a cheap way to tell whether anything was logged.
1143     *
1144     * @return int
1145     */
1146    public static function max_id() {
1147        if ( ! self::is_installed() ) {
1148            return 0;
1149        }
1150        return self::max_id_raw();
1151    }
1152
1153    /**
1154     * The newest row's id, whatever the stored version: the upgrade reads it before storing its own.
1155     *
1156     * @return int
1157     */
1158    private static function max_id_raw() {
1159        global $wpdb;
1160        return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(id) FROM %i', self::table_name() ) );
1161    }
1162
1163    /**
1164     * When the newest non-test email was logged.
1165     *
1166     * @return string|null UTC `Y-m-d H:i:s`, or null when there is none.
1167     */
1168    public static function last_email_at() {
1169        global $wpdb;
1170        if ( ! self::is_installed() ) {
1171            return null;
1172        }
1173        $value = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(created_at) FROM %i WHERE source <> 'test'", self::table_name() ) );
1174        return is_string( $value ) && '' !== $value ? $value : null;
1175    }
1176
1177    /**
1178     * Whether the log holds any email the site sent (tests don't count). Stops at the first one.
1179     *
1180     * @return bool
1181     */
1182    public static function has_history() {
1183        global $wpdb;
1184        if ( ! self::is_installed() ) {
1185            return false;
1186        }
1187        return null !== $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i WHERE source <> 'test' LIMIT 1", self::table_name() ) );
1188    }
1189
1190    /**
1191     * A user's display name for the Log and the debug log.
1192     *
1193     * @since $$next-version$$
1194     *
1195     * @param int $user_id User id; 0 for automatic work.
1196     * @return string '' for 0, and "a former user" once the account is gone.
1197     */
1198    public static function user_name( $user_id ) {
1199        if ( (int) $user_id < 1 ) {
1200            return '';
1201        }
1202        $user = get_userdata( (int) $user_id );
1203        if ( ! $user ) {
1204            return __( 'a former user', 'jetpack-send-pkg' );
1205        }
1206        return '' !== (string) $user->display_name ? (string) $user->display_name : (string) $user->user_login;
1207    }
1208
1209    /**
1210     * A stored UTC time as ISO 8601.
1211     *
1212     * @param string $utc UTC `Y-m-d H:i:s`.
1213     * @return string
1214     */
1215    public static function iso( $utc ) {
1216        return gmdate( 'Y-m-d\\TH:i:s\\Z', (int) strtotime( $utc . ' UTC' ) );
1217    }
1218
1219    /**
1220     * The cached stats' generation; cached stats from another one aren't used.
1221     *
1222     * @since $$next-version$$
1223     *
1224     * @return int
1225     */
1226    public static function stats_gen() {
1227        return (int) get_option( self::STATS_GEN_OPTION, 0 );
1228    }
1229
1230    /**
1231     * Forget the cached stats for every range.
1232     *
1233     * @return void
1234     */
1235    public static function clear_stats_cache() {
1236        update_option( self::STATS_GEN_OPTION, self::stats_gen() + 1, false );
1237        foreach ( array_unique( array_merge( self::STATS_RANGES, Period::PRESETS ) ) as $range ) {
1238            delete_transient( self::STATS_TRANSIENT . $range );
1239        }
1240    }
1241
1242    /**
1243     * Drop the log, counts and debug tables and every option they use (uninstall).
1244     *
1245     * @return void
1246     */
1247    public static function drop_table() {
1248        global $wpdb;
1249        $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', self::table_name() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Uninstall.
1250        Stats::drop();
1251        Debug_Log::drop();
1252        wp_clear_scheduled_hook( self::PRUNE_HOOK );
1253        self::clear_stats_cache();
1254        foreach ( array( self::DB_VERSION_OPTION, self::FIELDS_BACKFILL_OPTION, self::STATS_GEN_OPTION, Resend::TIMES_OPTION ) as $option ) {
1255            delete_option( $option );
1256        }
1257        delete_transient( self::UPGRADE_RETRY_TRANSIENT );
1258        Lock::delete_all();
1259    }
1260}