hook; } /** * Register hooks. * * @return void */ public function boot() { add_action( 'admin_menu', array( $this, 'redirect_other_parent' ), 0 ); add_action( 'admin_menu', array( $this, 'register_page' ) ); add_action( 'admin_init', array( $this, 'handle_action' ), 10, 0 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_filter( 'removable_query_args', array( $this, 'removable_query_args' ) ); } /** * Let WordPress strip the "N entries deleted" flag out of the address bar. * * The list table builds its pagination, sort and filter links from the current URL, so without * this the notice follows the administrator around the log long after the delete it reported. * * @param array $args Query arguments WordPress removes after the page has loaded. * @return array */ public function removable_query_args( $args ) { $args[] = self::DONE_ARG; $args[] = self::IMPORT_ARG; return $args; } /** * Add the page: its own menu item, or under Tools. * * @return void */ public function register_page() { if ( self::top_level() ) { $this->hook = (string) add_menu_page( __( 'Email Log', 'adminkeep' ), __( 'Email Log', 'adminkeep' ), self::CAPABILITY, self::PAGE, array( $this, 'render' ), self::MENU_ICON, self::MENU_POSITION ); } else { $this->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' ) ); } } /** * Send the Tools address to the top-level one while the log has its own item, keeping the rest * of the query: bookmarks, the docs and older links carry it, and WordPress would otherwise * answer it with "Sorry, you are not allowed to access this page". On 'admin_menu' at 0, as * SettingsPage::redirect_legacy_url() does, because that fires inside wp-admin/menu.php ahead * of the page-access check. * * The other way round needs nothing: under Tools, core already serves admin.php?page= as the * same screen. * * 307, not 302: a form posted from a log opened before the move (Delete, Delete all, Copy * from Email Log) is then posted again to the new address, body and nonce included, where a * 302 would turn it into a GET and drop it without a word. For a GET the two are the same, so * every method gets 307. * * @return void */ public function redirect_other_parent() { global $pagenow; $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'tools.php' !== $pagenow || self::PAGE !== $page || ! self::top_level() ) { return; } $query = array_diff_key( wp_unslash( $_GET ), array( 'page' => true ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Handed back unchanged: urlencode_deep() encodes each value; the keys are PHP's own parsed names, which add_query_arg() leaves as they are. wp_safe_redirect() keeps the target on this site. wp_safe_redirect( add_query_arg( urlencode_deep( $query ), self::url() ), 307 ); exit; } /** * 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; } $this->carry_hidden_columns( $screen->id ); 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 ); } /** * Bring the user's hidden-column choice along when the screen moves between Tools and its own * menu item. WordPress keeps that choice per screen id, and the id changes with the position, * so without this a move would quietly reset Screen Options. Copied only while the new * position has no choice of its own, so it happens once and never overrides a later one. * * @param string $screen_id Current screen id. * @return void */ private function carry_hidden_columns( $screen_id ) { $user_id = get_current_user_id(); $key = 'manage' . $screen_id . 'columnshidden'; if ( 0 === $user_id || false !== get_user_option( $key, $user_id ) ) { return; } foreach ( array( 'toplevel_page_', 'tools_page_' ) as $prefix ) { $other = 'manage' . $prefix . self::PAGE . 'columnshidden'; $value = $other === $key ? false : get_user_option( $other, $user_id ); if ( is_array( $value ) ) { // Where WordPress's own Screen Options save puts it (wp_ajax_hidden_columns()). update_user_meta( $user_id, $key, $value ); return; } } } /** * 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 '
' . esc_html( sprintf( _n( '%d entry deleted.', '%d entries deleted.', $count, 'adminkeep' ), $count ) ) . '
' . 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 '' . esc_html( implode( ' ', $parts ) ) . '