hook = (string) add_management_page( __( 'Email Log', 'adminkeep' ), __( 'Email Log', 'adminkeep' ), self::CAPABILITY, self::PAGE, array( $this, 'render' ) ); // The preview document is this screen's own URL with ?preview=, answered before the admin // header prints. load-{hook} fires only for this page and only once the user may see it. if ( '' !== $this->hook ) { add_action( 'load-' . $this->hook, array( $this, 'maybe_preview' ) ); add_action( 'load-' . $this->hook, array( $this, 'register_columns' ) ); } } /** * Screen Options: a show/hide checkbox for every column but Subject, with From off until * someone turns it on. WordPress builds the panel before the table exists, from these filters; * it stores and applies each user's choice itself. * * @return void */ public function register_columns() { $screen = get_current_screen(); if ( null === $screen ) { return; } add_filter( 'manage_' . $screen->id . '_columns', static function () { // Subject opens the preview and is what stays on a narrow screen, so it is not // offered for hiding at all. return array_diff_key( EmailLogTable::columns(), array( 'subject' => true ) ); } ); add_filter( 'default_hidden_columns', static function ( $hidden, $for ) use ( $screen ) { return $for->id === $screen->id ? array( 'from_email' ) : $hidden; }, 10, 2 ); } /** * The badge and modal styles and the modal script. Scoped to this screen's hook suffix, the * same way SmtpScreen scopes its own. * * @param string $hook_suffix Current admin page. * @return void */ public function enqueue_assets( $hook_suffix ) { if ( '' === $this->hook || $hook_suffix !== $this->hook ) { return; } wp_enqueue_style( 'adminkeep-email-log', ADMINKEEP_URL . 'assets/css/email-log.css', array( 'dashicons' ), \Adminkeep\Assets::version( 'assets/css/email-log.css' ) ); wp_enqueue_script( 'adminkeep-email-log', ADMINKEEP_URL . 'assets/js/email-log.js', array(), \Adminkeep\Assets::version( 'assets/js/email-log.js' ), true ); } /** * House shape: marker field, capability, nonce, work, redirect. * * @return void */ public function handle_action() { if ( ! isset( $_POST[ self::ACTION_FIELD ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked below, after the cheap bail. return; } if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to manage the email log.', 'adminkeep' ), '', 403 ); } check_admin_referer( self::NONCE, self::NONCE_FIELD ); $action = sanitize_key( wp_unslash( (string) $_POST[ self::ACTION_FIELD ] ) ); $deleted = 0; if ( 'import' === $action ) { $this->handle_import(); } // The list table posts its dropdown as `action` (top) or `action2` (bottom). "Apply" with // nothing chosen posts -1 in both, and must delete nothing. $bulk = array( isset( $_POST['action'] ) ? sanitize_key( wp_unslash( (string) $_POST['action'] ) ) : '', isset( $_POST['action2'] ) ? sanitize_key( wp_unslash( (string) $_POST['action2'] ) ) : '', ); if ( 'delete_all' === $action ) { $deleted = EmailLogStore::delete_all(); } elseif ( 'delete' === $action && in_array( 'delete', $bulk, true ) && isset( $_POST['email_log'] ) && is_array( $_POST['email_log'] ) ) { $deleted = EmailLogStore::delete( array_map( 'absint', wp_unslash( $_POST['email_log'] ) ) ); } wp_safe_redirect( add_query_arg( self::DONE_ARG, $deleted, self::url() ) ); exit; } /** * Run the import the button asks for, then redirect with its counts. Nothing is imported * unless the button would be showing: a stale form posted after the Email Log plugin was * deactivated just comes back to the log. * * @return void */ private function handle_import() { $source = $this->import_source(); if ( null === $source ) { wp_safe_redirect( self::url() ); exit; } $result = ( new EmailLogImporter() )->run( $source, false ); $counts = array( $result['imported'], $result['skipped_old'], $result['left_full'], $result['failed'] ); wp_safe_redirect( add_query_arg( self::IMPORT_ARG, implode( '-', $counts ), self::url() ) ); exit; } /** * Whether the Email Log plugin (wordpress.org/plugins/email-log) is running on this site. Its * main file defines EMAIL_LOG_FILE, which holds whatever folder the plugin was installed in. * Its own method so a test can say yes: a real constant could never be undefined again. * * @return bool */ protected function email_log_plugin_active() { return defined( 'EMAIL_LOG_FILE' ); } /** * The source the button copies from, or null when there should be no button: the Email Log * plugin is not active, or its table is missing or not the shape the importer reads. * * @return Source|null */ private function import_source() { if ( ! $this->email_log_plugin_active() ) { return null; } $sources = EmailLogImporter::sources(); $source = $sources[ self::IMPORT_SOURCE ]; return $source->available() ? $source : null; } /** * Serve one email's preview document, when asked for one. * * A plain page load with a nonce, not an AJAX action or a REST route: the log keeps its single * guarded way in. Capability first, then the nonce, then the row. * * @return void */ public function maybe_preview() { if ( ! isset( $_GET['preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Checked below, after the cheap bail. return; } if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to view the email log.', 'adminkeep' ), '', 403 ); } $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['_wpnonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, EmailLogPreview::NONCE ) ) { wp_die( esc_html__( 'This preview link has expired. Reload the email log and try again.', 'adminkeep' ), '', 403 ); } $row = EmailLogStore::get( absint( $_GET['preview'] ) ); if ( null === $row ) { wp_die( esc_html__( 'That email is no longer in the log.', 'adminkeep' ), '', 404 ); } $view = isset( $_GET['view'] ) && in_array( $_GET['view'], array( 'text', 'source' ), true ) ? ( 'text' === $_GET['view'] ? 'text' : 'source' ) : 'preview'; $this->emit( EmailLogPreview::headers(), EmailLogPreview::document( $row, $view ) ); } /** * Send the document and stop. Its own method so tests can capture it instead of exiting. * * @param string[] $headers Header lines. * @param string $body Document. * @return void */ protected function emit( array $headers, $body ) { nocache_headers(); foreach ( $headers as $header ) { header( $header ); } echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- A standalone document under a CSP sandbox; EmailLogPreview::document() escapes everything that is not the logged HTML itself. exit; } /** * Render the screen. * * @return void */ public function render() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to view the email log.', 'adminkeep' ), '', 403 ); } $table = new EmailLogTable(); $table->prepare_items(); echo '

' . Brand::heading_mark() . esc_html__( 'Email Log', 'adminkeep' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- heading_mark() returns escaped markup. if ( isset( $_GET[ self::DONE_ARG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only flag choosing a static notice. $count = absint( $_GET[ self::DONE_ARG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended /* translators: %d: number of log entries. */ echo '

' . esc_html( sprintf( _n( '%d entry deleted.', '%d entries deleted.', $count, 'adminkeep' ), $count ) ) . '

'; } $source = $this->import_source(); $retention = EmailLog::resolve( Settings::get( 'email_log' ) )['retention_days']; $this->render_import_result( null !== $source, $retention ); if ( null !== $source ) { $this->render_import_box( $retention ); } // Filters and search read, so they travel by GET and can be bookmarked. Deleting writes, so // the table sits in its own POST form. One form for both would put the nonce in the URL. // The filters print inside the table's top bar (EmailLogTable::extra_tablenav()) and join // this form by its id. echo '
'; $table->search_box( __( 'Search email', 'adminkeep' ), 'adminkeep-email-log' ); echo '
'; echo '
'; wp_nonce_field( self::NONCE, self::NONCE_FIELD ); echo ''; $table->display(); echo '
'; echo '
'; wp_nonce_field( self::NONCE, self::NONCE_FIELD ); echo ''; submit_button( __( 'Delete all', 'adminkeep' ), 'delete', 'adminkeep_email_log_delete_all', false ); echo '
'; $this->render_dialog(); echo '
'; } /** * The box offering to copy the Email Log plugin's log into this one. A form of its own, so * pressing it never carries the table's bulk-delete fields along. * * @param int $retention Days the log keeps an email. * @return void */ private function render_import_box( $retention ) { echo '
'; echo '

' . esc_html__( 'The Email Log plugin is active.', 'adminkeep' ) . ' '; echo esc_html( sprintf( /* translators: %d: number of days the email log keeps an entry. */ _n( 'Copy its log into this one. Emails older than %d day are skipped, as this log would already have deleted them, and sign-in links are removed. Running it again copies only new emails. The Email Log plugin\'s own log is not changed.', 'Copy its log into this one. Emails older than %d days are skipped, as this log would already have deleted them, and sign-in links are removed. Running it again copies only new emails. The Email Log plugin\'s own log is not changed.', $retention, 'adminkeep' ), $retention ) ); echo '

'; echo '
'; wp_nonce_field( self::NONCE, self::NONCE_FIELD ); echo ''; submit_button( __( 'Copy from Email Log', 'adminkeep' ), 'secondary', 'adminkeep_email_log_import', false ); echo '
'; } /** * What the last import did, read from the redirect's flag. * * @param bool $plugin_active Whether the Email Log plugin is still active. * @param int $retention Days the log keeps an email. * @return void */ private function render_import_result( $plugin_active, $retention ) { $flag = isset( $_GET[ self::IMPORT_ARG ] ) ? sanitize_text_field( wp_unslash( (string) $_GET[ self::IMPORT_ARG ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only flag choosing a static notice. if ( ! preg_match( '/^(\d+)-(\d+)-(\d+)-(\d+)$/', $flag, $counts ) ) { return; } list( , $imported, $skipped, $full, $failed ) = array_map( 'intval', $counts ); /* translators: %d: number of emails. */ $parts = array( sprintf( _n( '%d email copied from Email Log.', '%d emails copied from Email Log.', $imported, 'adminkeep' ), $imported ) ); if ( $skipped > 0 ) { /* translators: 1: number of emails, 2: number of days. */ $parts[] = sprintf( _n( '%1$d older than %2$d days was skipped.', '%1$d older than %2$d days were skipped.', $skipped, 'adminkeep' ), $skipped, $retention ); } if ( $full > 0 ) { /* translators: %d: number of emails. */ $parts[] = sprintf( _n( '%d was left out because the log is full.', '%d were left out because the log is full.', $full, 'adminkeep' ), $full ); } if ( $failed > 0 ) { /* translators: %d: number of emails. */ $parts[] = sprintf( _n( '%d could not be saved.', '%d could not be saved.', $failed, 'adminkeep' ), $failed ); } if ( $plugin_active ) { $parts[] = __( 'While both plugins are active, every email is logged twice, so you can deactivate Email Log now.', 'adminkeep' ); } $class = $failed > 0 || $full > 0 ? 'notice-warning' : 'notice-success'; echo '

' . esc_html( implode( ' ', $parts ) ) . '

'; } /** * The preview dialog, printed once and filled by assets/js/email-log.js from the row clicked. * Every label is here, translated; the script only ever sets textContent. * * @return void */ private function render_dialog() { $rows = array( 'from' => __( 'From', 'adminkeep' ), 'to' => __( 'To', 'adminkeep' ), 'reply-to' => __( 'Reply-To', 'adminkeep' ), 'cc' => __( 'Cc', 'adminkeep' ), 'bcc' => __( 'Bcc', 'adminkeep' ), 'date' => __( 'Date', 'adminkeep' ), 'attachments' => __( 'Attachments', 'adminkeep' ), ); echo ''; echo '
'; echo '
'; echo '

'; echo ''; echo '
'; echo ''; echo ''; echo ''; echo '
'; echo ''; echo '
'; foreach ( $rows as $key => $label ) { printf( '', esc_attr( $key ), esc_html( $label ) ); } echo '
'; echo '
'; echo ''; echo ''; echo ''; echo '
'; echo '
'; echo '
' . esc_html__( 'Loading email…', 'adminkeep' ) . '
'; echo ''; echo ''; echo ''; echo '
'; echo '

' . esc_html__( 'Remote images are blocked in this preview.', 'adminkeep' ) . '

'; echo '
'; } }