io = $io; } /** * Lists logged email, newest first. * * The content of an email is never listed; use `get` for one email. * * ## OPTIONS * * [--status=] * : Only email with this status: sent, failed or unconfirmed. * * [--search=] * : Only email whose sender, recipient or subject contains this. * * [--since=] * : Only email logged on or after this date, e.g. 2026-09-01. Read * as UTC, not your site's time zone. * * [--limit=] * : How many to list. Default 50, at most 500. * * [--format=] * : table, json, csv, yaml, ids or count. Default table. * * ## EXAMPLES * * $ wp adminkeep email-log list --status=failed --format=json * * @subcommand list * * @param array $args Positional arguments. * @param array $assoc_args Flags. * @return void */ public function list_( $args, $assoc_args ) { $rows = EmailLogStore::query( array( 'status' => $this->status( $assoc_args ), 'search' => isset( $assoc_args['search'] ) ? (string) $assoc_args['search'] : '', 'since' => $this->date( $assoc_args, 'since' ), 'per_page' => isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 50, ) ); $items = array(); foreach ( $rows as $row ) { $items[] = array_intersect_key( $row, array_flip( self::FIELDS ) ); } $this->io->items( isset( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'table', $items, self::FIELDS ); } /** * Prints one logged email, content included. * * ## OPTIONS * * * : The email's id, from `list`. * * @param array $args Positional arguments. * @param array $assoc_args Flags. * @return void */ public function get( $args, $assoc_args ) { $row = EmailLogStore::get( isset( $args[0] ) ? (int) $args[0] : 0 ); if ( null === $row ) { $this->io->error( 'No logged email has that id.' ); return; } foreach ( self::FIELDS as $field ) { $this->io->line( $field . ': ' . $this->printable( $row[ $field ] ) ); } foreach ( $row['headers'] as $name => $value ) { $this->io->line( $this->printable( $name ) . ': ' . ( is_scalar( $value ) ? $this->printable( $value ) : '' ) ); } $this->io->line( 'attachments: ' . $this->printable( implode( ', ', array_filter( $row['attachments'], 'is_scalar' ) ) ) ); $this->io->line( '' ); $this->io->line( $this->printable( $row['body'] ) ); } /** * Drop the control characters a terminal acts on rather than prints. * * Everything `get` prints was typed by a stranger into a form somewhere and lands * straight in an administrator's shell, where an ANSI escape sequence can * rewrite the line, hide what follows it or retitle the window. Newline and tab * are what a message legitimately contains, so they stay. * * @param mixed $value Logged value. * @return string */ private function printable( $value ) { return (string) preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', (string) $value ); } /** * Prints how many emails are logged, as a bare number. * * ## OPTIONS * * [--status=] * : Only count this status: sent, failed or unconfirmed. * * @param array $args Positional arguments. * @param array $assoc_args Flags. * @return void */ public function count( $args, $assoc_args ) { $this->io->line( (string) EmailLogStore::count( array( 'status' => $this->status( $assoc_args ) ) ) ); } /** * Permanently deletes logged email. * * ## OPTIONS * * [--all] * : Delete every logged email. * * [--older-than=] * : Delete email logged more than this many days ago. * * [--yes] * : Do not ask for confirmation. * * @param array $args Positional arguments. * @param array $assoc_args Flags. * @return void */ public function purge( $args, $assoc_args ) { $all = ! empty( $assoc_args['all'] ); $days = isset( $assoc_args['older-than'] ) ? (string) $assoc_args['older-than'] : ''; if ( $all === ( '' !== $days ) ) { $this->io->error( 'Say what to delete: --all, or --older-than=. Not both.' ); return; } if ( ! $all && ( ! ctype_digit( $days ) || (int) $days < 1 ) ) { $this->io->error( '--older-than takes a whole number of days, 1 or more.' ); return; } if ( empty( $assoc_args['yes'] ) ) { $this->io->confirm( $all ? 'Delete every logged email? This cannot be undone.' : sprintf( 'Delete email logged more than %d days ago? This cannot be undone.', (int) $days ) ); } $deleted = 0; if ( $all ) { $deleted = EmailLogStore::delete_all(); } else { do { $batch = EmailLogStore::purge_older_than( (int) $days, EmailLogStore::BATCH ); $deleted += $batch; } while ( $batch > 0 ); } $this->io->success( sprintf( 'Deleted %d logged emails.', $deleted ) ); } /** * Imports email from another plugin's log. * * Each email is stored the way this log would have stored it: sign-in * and password-reset links removed, content dropped if content storage * is off. Email older than the "Keep logs for" setting is skipped, and * the import stops at the 10,000-row cap. Running it again brings only * what is new. The other plugin's own log is only read, never changed. * * ## OPTIONS * * * : Where to import from. email-log: the Email Log plugin. * * [--dry-run] * : Show what would be imported without importing it. * * ## EXAMPLES * * $ wp adminkeep email-log import email-log --dry-run * * @param array $args Positional arguments. * @param array $assoc_args Flags. * @return void */ public function import( $args, $assoc_args ) { $sources = EmailLogImporter::sources(); $name = isset( $args[0] ) ? (string) $args[0] : ''; if ( ! isset( $sources[ $name ] ) ) { $this->io->error( 'Unknown source. Use one of: ' . implode( ', ', array_keys( $sources ) ) . '.' ); return; } if ( ! Settings::is_enabled( 'email_log' ) ) { $this->io->error( 'Email Log is off, so imported email would sit in a log nothing keeps. Switch it on first: wp adminkeep feature enable email_log' ); return; } $source = $sources[ $name ]; if ( ! $source->available() ) { $this->io->error( sprintf( 'No Email Log table (%s) on this site, so there is nothing to import.', $source->location() ) ); return; } $dry_run = ! empty( $assoc_args['dry-run'] ); $result = ( new EmailLogImporter() )->run( $source, $dry_run ); $parts = array( sprintf( '%s %d', $dry_run ? 'Would import' : 'Imported', $result['imported'] ), sprintf( 'skipped %d older than %d days', $result['skipped_old'], $result['retention_days'] ), ); if ( $result['left_full'] > 0 ) { $parts[] = sprintf( '%d left out because the log is full (%d rows); delete some and run it again', $result['left_full'], EmailLogStore::MAX_ROWS ); } if ( $result['failed'] > 0 ) { $parts[] = sprintf( '%d could not be written', $result['failed'] ); } $this->io->success( implode( ' ยท ', $parts ) . '.' ); if ( ! $dry_run ) { $this->io->line( sprintf( '%s was only read. While both plugins are on, every email is logged twice.', $source->location() ) ); } } /** * The `--status` flag, validated against the known statuses. An * empty flag means no filter; an unknown one halts the command. * * @param array $assoc_args Flags. * @return string */ private function status( array $assoc_args ) { $status = isset( $assoc_args['status'] ) ? (string) $assoc_args['status'] : ''; if ( '' !== $status && ! in_array( $status, EmailLogStore::STATUSES, true ) ) { $this->io->error( 'Unknown status. Use one of: ' . implode( ', ', EmailLogStore::STATUSES ) . '.' ); } return $status; } /** * A date-shaped flag, validated with strtotime(). An empty flag * means no filter; an unreadable one halts the command. * * @param array $assoc_args Flags. * @param string $key Flag name. * @return string */ private function date( array $assoc_args, $key ) { $value = isset( $assoc_args[ $key ] ) ? (string) $assoc_args[ $key ] : ''; if ( '' !== $value && false === strtotime( $value ) ) { $this->io->error( sprintf( '--%s is not a date this can read. Try 2026-09-01.', $key ) ); } return $value; } }