array( 'type' => 'bool', 'default' => true, ), // Off by default, which is the log's own top-level item. A site that never saved it, an // unticked box and a form that does not post the box at all all read as off, so each // means "top level". See EmailLogScreen::register_page() for where each puts the item. 'under_tools' => array( 'type' => 'bool', 'default' => false, ), // 'choices' is the allowed-value list the settings page validates against; the labels // are translated and therefore live in field_choices(), which runs at render time. 'retention_days' => array( 'type' => 'string', 'default' => '30', 'ui' => 'radio', 'choices' => self::RETENTION_CHOICES, ), ); } /** * Field labels, resolved at render time. * * @param string $key Field key. * @return string */ public function field_label( $key ) { switch ( $key ) { case 'store_content': return __( 'Keep the content of each email, with sign-in and password-reset links removed', 'adminkeep' ); case 'retention_days': return __( 'Keep logs for', 'adminkeep' ); case 'under_tools': return __( 'Show Email Log under the Tools menu', 'adminkeep' ); default: return ''; } } /** * Labels for the retention_days choices, resolved at render time. * * @param string $key Field key. * @return array Value => label, in display order. */ public function field_choices( $key ) { if ( 'retention_days' !== $key ) { return array(); } $choices = array(); foreach ( self::RETENTION_CHOICES as $days ) { if ( 0 === (int) $days % 365 ) { $years = (int) $days / 365; /* translators: %d: number of years. */ $choices[ $days ] = sprintf( _n( '%d year', '%d years', $years, 'adminkeep' ), $years ); continue; } /* translators: %s: number of days. */ $choices[ $days ] = sprintf( _n( '%s day', '%s days', (int) $days, 'adminkeep' ), $days ); } return $choices; } /** * What switching this on actually does. * * @return string[] */ public function how_it_works() { return array( sprintf( /* translators: %s: the label of the menu setting's checkbox, "Show Email Log under the Tools menu". */ __( 'Adds Email Log near the top of the admin menu, below Comments. Tick "%s" to move it under Tools instead. Every email WordPress sends is listed there with its sender, recipient, subject and whether it was sent.', 'adminkeep' ), $this->field_label( 'under_tools' ) ), __( '"Sent" means the mail server accepted the email. It does not mean it reached the inbox.', 'adminkeep' ), __( '"Unconfirmed" means WordPress never heard back about the email. That happens when another plugin takes over or queues the sending, and the email may still have gone out.', 'adminkeep' ), __( 'Password-reset, sign-in and activation links are taken out before an email is stored. This catches the common link formats, not every possible one; switch content off if your site sends sign-in links in an unusual format.', 'adminkeep' ), __( 'Attachments are listed by name only. The files themselves are not kept.', 'adminkeep' ), __( 'Only administrators can open the log. Old entries are deleted automatically, and the log is trimmed to the newest 10,000.', 'adminkeep' ), __( 'Works with or without the SMTP feature, and beside other SMTP plugins. Switching this off stops logging. What is already logged stays until you switch it back on or delete it, or delete the plugin with "Delete all Adminkeep data" switched on.', 'adminkeep' ), ); } /** * The configured retention, in days. Used by the purge cron and the CLI. * * @return int */ public function retention_days() { return $this->retention_days; } /** * The Email Log screen, built the first time it is asked for. * * @return EmailLogScreen */ public function screen() { if ( null === $this->screen ) { $this->screen = new EmailLogScreen(); } return $this->screen; } /** * A link into the log, for whoever is allowed to open it. * * @return array{url: string, label: string}|array{} */ public function panel_link() { if ( ! current_user_can( EmailLogScreen::CAPABILITY ) ) { return array(); } return array( 'url' => EmailLogScreen::url(), 'label' => __( 'Open the email log', 'adminkeep' ), ); } /** * What is worth mentioning about the log without opening it: recent failures, or the total * kept. * * @return string */ public function state_label() { $failed = EmailLogStore::count( array( 'status' => 'failed', 'since' => gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) ) ); if ( $failed > 0 ) { /* translators: %d: number of emails. */ return sprintf( _n( '%d failed in the last day', '%d failed in the last day', $failed, 'adminkeep' ), $failed ); } // Once into a variable: this runs on every settings render, and the count is a COUNT(*). $logged = EmailLogStore::count(); /* translators: %d: number of emails. */ return sprintf( _n( '%d email logged', '%d emails logged', $logged, 'adminkeep' ), $logged ); } /** * Stored settings read the way boot() reads them. Public so the importer, which runs without * booting the feature, keeps and drops exactly what live logging would. * * @param array $settings Stored settings for this feature. * @return array{store_content: bool, retention_days: int, under_tools: bool} */ public static function resolve( array $settings ) { return array( 'store_content' => ! isset( $settings['store_content'] ) || (bool) $settings['store_content'], 'retention_days' => isset( $settings['retention_days'] ) && in_array( (string) $settings['retention_days'], self::RETENTION_CHOICES, true ) ? (int) $settings['retention_days'] : 30, 'under_tools' => ! empty( $settings['under_tools'] ), ); } /** * Register hooks. Called only when the feature is enabled. * * @param array $settings Resolved settings for this feature. * @return void */ public function boot( array $settings ) { $resolved = self::resolve( $settings ); $this->store_content = $resolved['store_content']; $this->retention_days = $resolved['retention_days']; add_filter( 'wp_mail', array( $this, 'log_mail' ), self::LOG_PRIORITY ); add_filter( 'pre_wp_mail', array( $this, 'drop_cancelled' ), PHP_INT_MAX, 2 ); add_action( 'phpmailer_init', array( $this, 'record_sender' ), self::SENDER_PRIORITY ); add_action( 'wp_mail_succeeded', array( $this, 'mark_sent' ), 10, 0 ); add_action( 'wp_mail_failed', array( $this, 'mark_failed' ) ); add_action( self::PURGE_HOOK, array( $this, 'run_purge' ), 10, 0 ); add_action( 'admin_init', array( $this, 'schedule_purge' ), 10, 0 ); add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) ); add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ) ); if ( is_admin() ) { $this->screen()->boot(); } } /** * Write the row. A filter used as a listener: the arguments go back exactly as they came. * * @param array $atts wp_mail() arguments. * @return array The same arguments. */ public function log_mail( $atts ) { $id = 0; try { $id = EmailLogStore::insert( MailRow::from_atts( is_array( $atts ) ? $atts : array(), $this->store_content ) ); // The cap exists for bursts — a newsletter or a sale pushing tens of thousands of // emails through wp_mail() in one day — and the daily purge is far too late for that; // a site with DISABLE_WP_CRON and no system cron is never trimmed at all. One row in // TRIM_EVERY pays for a COUNT over a table already near its cap plus one bounded // DELETE. It sits inside the same try/catch as the insert, so it can no more cost the // site an email than the insert can. if ( $id > 0 && 0 === $id % max( 1, (int) $this->trim_interval() ) ) { EmailLogStore::trim_to( $this->max_rows(), EmailLogStore::BATCH ); } } catch ( \Throwable $e ) { // A log that cannot write must never cost the site an email. The row loses its status // update along with its id, which is the cheaper of the two prices. $id = 0; } $this->in_flight[] = $id; $this->awaiting_pre = array( 'id' => $id, 'key' => self::identity( $atts ), ); return $atts; } /** * Who an email is for and what it is about, enough to tell whether the email pre_wp_mail is * deciding on is the one log_mail() just wrote. Not the body: a filter after LOG_PRIORITY that * appends to the message (a tracking pixel, a footer) must not make the same email look new. * * @param mixed $atts wp_mail() arguments. * @return string */ private static function identity( $atts ) { $atts = is_array( $atts ) ? $atts : array(); return (string) wp_json_encode( array( isset( $atts['to'] ) ? $atts['to'] : '', isset( $atts['subject'] ) ? $atts['subject'] : '', ) ); } /** * Take a send cancelled through pre_wp_mail off the in-flight stack. Such a send never fires * wp_mail_succeeded or wp_mail_failed, so left on the stack it would be the row the next * status closes, in place of the send that status belongs to. The row itself stays * unconfirmed, which is the truth: nothing is known about what became of it. * * Only the send log_mail() has just written is taken off. Under Post SMTP's own wp_mail(), * pre_wp_mail comes before the wp_mail filter, so a cancelled email was never logged and the * top of the stack belongs to some other send still in flight. * * @param null|bool $short_circuit What pre_wp_mail has decided so far. * @param array $atts wp_mail() arguments. * @return null|bool The same value. */ public function drop_cancelled( $short_circuit, $atts = array() ) { $logged = $this->awaiting_pre; $this->awaiting_pre = null; if ( null !== $short_circuit && null !== $logged && end( $this->in_flight ) === $logged['id'] && self::identity( $atts ) === $logged['key'] ) { array_pop( $this->in_flight ); } return $short_circuit; } /** * The row cap. * * A method rather than the constant read inline, so a test can shrink it: proving that the cap * bounds a burst must not need ten thousand emails. Not a filter — the cap is this feature's * promise about how much of a site's database it will take, not a site's setting. * * @return int */ protected function max_rows() { return EmailLogStore::MAX_ROWS; } /** * How many rows apart the inline trim runs. A method for the same reason as max_rows(). * * @return int */ protected function trim_interval() { return self::TRIM_EVERY; } /** * The sender PHPMailer will really use, and — when the row's own headers said nothing about * it — the content type PHPMailer will really send as. Read only otherwise: the mailer is not * touched. * * @param \PHPMailer\PHPMailer\PHPMailer $phpmailer Mailer. * @return void */ public function record_sender( $phpmailer ) { $id = end( $this->in_flight ); if ( ! $id || ! is_object( $phpmailer ) ) { return; } $this->mailer_owner = $id; $this->mailer = $phpmailer; $sender = self::sender_on( $phpmailer ); if ( '' !== $sender ) { EmailLogStore::update( $id, array( 'from_email' => $sender ) ); } // wp_mail() applies the wp_mail_content_type filter, then hands the result to PHPMailer, // all inside itself -- after log_mail() already wrote the row from the headers wp_mail() // was given. A filter that switches to HTML without adding a Content-Type header (common // among SMTP plugins) would otherwise leave the row looking like plain text and the // preview would escape markup that really went out as HTML. Only a row with no // content-type header of its own is touched: one that came with a header, even // text/plain, is the site's own word on the matter. if ( isset( $phpmailer->ContentType ) && 'text/html' === $phpmailer->ContentType ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName $row = EmailLogStore::get( $id ); if ( null !== $row && ! isset( $row['headers']['content-type'] ) ) { $charset = isset( $phpmailer->CharSet ) && '' !== (string) $phpmailer->CharSet ? (string) $phpmailer->CharSet : 'UTF-8'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName $headers = $row['headers']; $headers['content-type'] = 'text/html; charset=' . $charset; EmailLogStore::update( $id, array( 'headers' => $headers ) ); } } } /** * Close the in-flight row as sent. * * @return void */ public function mark_sent() { $id = array_pop( $this->in_flight ); if ( $id ) { EmailLogStore::update( $id, array_merge( array( 'status' => 'sent' ), $this->late_sender( $id ) ) ); } } /** * The sender the mailer really used, read again once the send is over. * * The phpmailer_init hook is not the last word: FluentSMTP swaps in its connection's own * address inside send(), after every phpmailer_init listener has run. It does so on the very * mailer phpmailer_init was handed, so reading that object back after the send catches it -- * provided the object still belongs to this row, which it does not once a send fired from a * status hook has reached phpmailer_init. * * @param int $id Row id being closed. * @return array{from_email?: string} A from_email to store, or nothing. */ private function late_sender( $id ) { if ( $id !== $this->mailer_owner ) { return array(); } $sender = self::sender_on( $this->mailer ); $this->mailer = null; return '' === $sender ? array() : array( 'from_email' => $sender ); } /** * The sender a mailer holds, name and address, as the From column keeps it. * * @param object|null $mailer PHPMailer, or anything shaped like it. * @return string '' when the mailer has no address. */ private static function sender_on( $mailer ) { // phpcs:disable WordPress.NamingConventions.ValidVariableName -- PHPMailer's own properties. if ( ! is_object( $mailer ) || ! isset( $mailer->From ) || '' === (string) $mailer->From ) { return ''; } return MailRow::mailbox( isset( $mailer->FromName ) ? (string) $mailer->FromName : '', (string) $mailer->From ); // phpcs:enable WordPress.NamingConventions.ValidVariableName } /** * Close the in-flight row as failed, with a scrubbed error message. * * @param \WP_Error $error What wp_mail() reported. * @return void */ public function mark_failed( $error ) { $id = array_pop( $this->in_flight ); if ( ! $id ) { return; } $message = $error instanceof \WP_Error ? $error->get_error_message() : ''; // A rejection often quotes the account it came from, and the log outlives the request. $config = SmtpConfig::from_environment(); $password = $config->password(); $message = SmtpScrubber::scrub( $message, (string) $config->get( 'username' ), false === $password ? '' : (string) $password ); EmailLogStore::update( $id, array_merge( array( 'status' => 'failed', 'error' => $message ), $this->late_sender( $id ) ) ); } /** * Make sure the daily purge is scheduled. On admin_init rather than in boot(): scheduling * writes the cron option, and a front-end request has no business paying for that. * * @return void */ public function schedule_purge() { if ( ! wp_next_scheduled( self::PURGE_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::PURGE_HOOK ); } } /** * Delete what is past its keep-by date, then whatever exceeds the row cap. * * In batches with a time budget: a site that has logged for a year before anyone looked must * not meet one DELETE covering a million rows on a cron request. What is left goes tomorrow. * * @return int Rows deleted. */ public function run_purge() { $deleted = 0; $deadline = microtime( true ) + self::PURGE_SECONDS; do { $batch = EmailLogStore::purge_older_than( $this->retention_days, EmailLogStore::BATCH ); $deleted += $batch; } while ( $batch > 0 && microtime( true ) < $deadline ); do { $batch = EmailLogStore::trim_to( $this->max_rows(), EmailLogStore::BATCH ); $deleted += $batch; } while ( $batch > 0 && microtime( true ) < $deadline ); return $deleted; } /** * Register the log with WordPress's "Export Personal Data" tool. * * @param array $exporters Registered exporters. * @return array */ public function register_exporter( $exporters ) { $exporters['adminkeep-email-log'] = array( 'exporter_friendly_name' => __( 'Adminkeep Email Log', 'adminkeep' ), 'callback' => array( $this, 'export_personal_data' ), ); return $exporters; } /** * Register the log with WordPress's "Erase Personal Data" tool. * * @param array $erasers Registered erasers. * @return array */ public function register_eraser( $erasers ) { $erasers['adminkeep-email-log'] = array( 'eraser_friendly_name' => __( 'Adminkeep Email Log', 'adminkeep' ), 'callback' => array( $this, 'erase_personal_data' ), ); return $erasers; } /** * The exporter callback: every logged email sent to or from one address. * * @param string $email Address being exported. * @param int $page 1-based page number. * @return array{data: array[], done: bool} */ public function export_personal_data( $email, $page = 1 ) { $found = EmailLogStore::find_by_address( $email, self::PRIVACY_PAGE, ( max( 1, (int) $page ) - 1 ) * self::PRIVACY_PAGE ); $data = array(); foreach ( $found['ids'] as $id ) { $row = EmailLogStore::get( $id ); if ( null === $row ) { continue; } $data[] = array( 'group_id' => 'adminkeep-email-log', 'group_label' => __( 'Email Log', 'adminkeep' ), 'item_id' => 'adminkeep-email-log-' . $id, 'data' => array_merge( array( array( 'name' => __( 'Date', 'adminkeep' ), 'value' => $row['created_at'], ), array( 'name' => __( 'From', 'adminkeep' ), 'value' => $row['from_email'], ), array( 'name' => __( 'To', 'adminkeep' ), 'value' => $row['to_email'], ), array( 'name' => __( 'Subject', 'adminkeep' ), 'value' => $row['subject'], ), ), $this->matched_headers( $row, $email ) ), ); } // On 'examined', not on how many matched: a page holding one near miss ("banjo@..." for // "jo@...") is one short of the page size while more of this person's email waits behind // it, and an export that stops there is an export that is quietly incomplete. return array( 'data' => $data, 'done' => $found['examined'] < self::PRIVACY_PAGE, ); } /** * The Cc, Bcc and Reply-To values of one row that hold this person's own address. * * Only those: a Cc naming somebody else is a third party's data and has no business in this * person's export. Showing the header it matched is what tells them why the row is theirs. * * @param array $row Row as EmailLogStore::get() returns it. * @param string $email Address being exported. * @return array[] name/value pairs, in the exporter's own shape. */ private function matched_headers( array $row, $email ) { $labels = array( 'reply-to' => __( 'Reply-To', 'adminkeep' ), 'cc' => __( 'Cc', 'adminkeep' ), 'bcc' => __( 'Bcc', 'adminkeep' ), ); $email = strtolower( trim( (string) $email ) ); $pairs = array(); foreach ( $labels as $name => $label ) { $value = isset( $row['headers'][ $name ] ) && is_scalar( $row['headers'][ $name ] ) ? (string) $row['headers'][ $name ] : ''; if ( '' !== $value && in_array( $email, (array) preg_split( '/[\s,;<>]+/', strtolower( $value ) ), true ) ) { $pairs[] = array( 'name' => $label, 'value' => $value, ); } } return $pairs; } /** * The eraser callback: delete every logged email that holds one address. * * Walks by keyset within the call. Each pass deletes what it matched, so whatever is left in * front of the cursor is a near miss that would otherwise be re-read at offset 0 on every * pass, pinning the eraser on the same rows for ever. Carrying the id forward steps over them. * The walk is bounded twice: it stops once a page's worth has been removed, and the table * itself is capped at MAX_ROWS. * * @param string $email Address being erased. * @param int $page Ignored: the walk starts from the top of what is left each time. * @return array{items_removed: int, items_retained: bool, messages: array, done: bool} */ public function erase_personal_data( $email, $page = 1 ) { $after = 0; $removed = 0; $more = false; do { $found = EmailLogStore::find_by_address( $email, self::PRIVACY_PAGE, 0, $after ); $removed += EmailLogStore::delete( $found['ids'] ); $after = $found['last_id']; $more = $found['examined'] >= self::PRIVACY_PAGE; } while ( $more && $removed < self::PRIVACY_PAGE ); return array( 'items_removed' => $removed, 'items_retained' => false, 'messages' => array(), 'done' => ! $more, ); } }