'email_log', 'plural' => 'email_log', 'ajax' => false, ) ); } /** * Column headings. * * @return array */ public function get_columns() { return self::columns(); } /** * Column headings, for the table and for Screen Options. Static because Screen Options is * built on load-{hook}, long before the table itself exists. * * @return array */ public static function columns() { return array( 'cb' => '', 'subject' => __( 'Subject', 'adminkeep' ), 'to_email' => __( 'To', 'adminkeep' ), 'from_email' => __( 'From', 'adminkeep' ), 'status' => __( 'Status', 'adminkeep' ), 'created_at' => __( 'Date', 'adminkeep' ), 'actions' => __( 'Actions', 'adminkeep' ), ); } /** * Sortable columns. * * @return array */ protected function get_sortable_columns() { return array( 'created_at' => array( 'created_at', true ) ); } /** * Bulk actions. * * @return array */ protected function get_bulk_actions() { return array( 'delete' => __( 'Delete', 'adminkeep' ) ); } /** * The preset periods and how far back each reaches, in seconds. 'custom' is the From/To range. */ const PERIODS = array( '1h' => HOUR_IN_SECONDS, '12h' => 12 * HOUR_IN_SECONDS, '1d' => DAY_IN_SECONDS, '2d' => 2 * DAY_IN_SECONDS, '7d' => 7 * DAY_IN_SECONDS, '30d' => 30 * DAY_IN_SECONDS, ); /** * Period value => label, in dropdown order. '' is no period at all. * * @return array */ public static function period_labels() { return array( '' => __( 'All time', 'adminkeep' ), '1h' => __( '1 hour', 'adminkeep' ), '12h' => __( '12 hours', 'adminkeep' ), '1d' => __( '1 day', 'adminkeep' ), '2d' => __( '2 days', 'adminkeep' ), '7d' => __( '7 days', 'adminkeep' ), '30d' => __( '30 days', 'adminkeep' ), 'custom' => __( 'Custom', 'adminkeep' ), ); } /** * Filter arguments read from the request. Reading a list filter needs no nonce. * * @return array{status: string, search: string, period: string, since: string, until: string, since_typed: string, until_typed: string, orderby: string, order: string} */ public static function filters() { // phpcs:disable WordPress.Security.NonceVerification.Recommended $read = static function ( $key ) { return isset( $_REQUEST[ $key ] ) ? sanitize_text_field( wp_unslash( (string) $_REQUEST[ $key ] ) ) : ''; }; $since = $read( 'since' ); $until = $read( 'until' ); $period = $read( 'period' ); // A link with dates but no period was made before the Period dropdown existed: it meant a range. if ( '' === $period && ( '' !== $since || '' !== $until ) ) { $period = 'custom'; } if ( 'custom' !== $period && ! isset( self::PERIODS[ $period ] ) ) { $period = ''; } // The dates are typed and displayed in the site's own time zone; created_at is UTC. On a // UTC+6 site "From today" compared unconverted misses the first six hours of the local day. // The typed values are carried alongside so the form can show back what was typed. return array( 'status' => $read( 'status' ), 'search' => $read( 's' ), 'period' => $period, // A preset counts back from now and needs no time zone; only the range is typed in one. 'since' => isset( self::PERIODS[ $period ] ) ? gmdate( 'Y-m-d H:i:s', time() - self::PERIODS[ $period ] ) : ( 'custom' === $period ? self::utc_boundary( $since, '00:00:00' ) : '' ), 'until' => 'custom' === $period ? self::utc_boundary( $until, '23:59:59' ) : '', 'since_typed' => $since, 'until_typed' => $until, 'orderby' => $read( 'orderby' ), 'order' => $read( 'order' ), ); // phpcs:enable } /** * A date typed in the site's time zone, as the UTC datetime the store compares against. * * @param string $date A Y-m-d from a date input, or ''. * @param string $time Start or end of that local day. * @return string UTC 'Y-m-d H:i:s', or '' for no filter. */ private static function utc_boundary( $date, $time ) { if ( '' === (string) $date ) { return ''; } try { $local = new \DateTimeImmutable( $date . ' ' . $time, wp_timezone() ); } catch ( \Exception $e ) { // Something that is not a date at all: no filter, the same as an empty field. return ''; } return $local->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); } /** * Load rows, apply pagination, and set up the column headers. * * @return void */ public function prepare_items() { $per_page = 20; $args = self::filters(); // Subject is the primary column, not Date: on a narrow screen every other column collapses // behind the toggle, and Subject is the one that opens the preview. // Hidden columns are the user's own Screen Options choice. Subject is left out of Screen // Options, but a stored preference could still name it; it opens the preview, so it stays. $this->_column_headers = array( $this->get_columns(), array_values( array_diff( get_hidden_columns( $this->screen ), array( 'subject' ) ) ), $this->get_sortable_columns(), 'subject' ); // The list never shows a body, so it never loads one; the preview fetches its own. $this->items = EmailLogStore::query( array_merge( $args, array( 'per_page' => $per_page, 'page' => $this->get_pagenum(), 'with_body' => false ) ) ); $this->set_pagination_args( array( 'total_items' => EmailLogStore::count( $args ), 'per_page' => $per_page, ) ); } /** * Status, Period and the date range, in the top bar after bulk actions, as the Posts screen * places its filters. The table sits inside the POST form that deletes, so every control here * names the GET form with the form attribute: filtering stays in the URL, where it can be * bookmarked, and the delete nonce never goes there. * * @param string $which top or bottom. * @return void */ protected function extra_tablenav( $which ) { if ( 'top' !== $which ) { return; } $current = self::filters(); $form = EmailLogScreen::FILTER_FORM; echo '
'; printf( '', esc_html__( 'Filter by status', 'adminkeep' ) ); printf( ''; printf( '', esc_html__( 'Filter by period', 'adminkeep' ) ); printf( ''; // Printed always, hidden unless Custom is chosen: the script shows it the moment Custom is // picked, and without script it shows after Filter reloads the page with Custom. // The typed values, not the converted ones: the inputs speak the site's time zone. printf( '', 'custom' === $current['period'] ? '' : ' hidden' ); printf( ' ', esc_html__( 'From', 'adminkeep' ), esc_attr( $current['since_typed'] ), esc_attr( $form ) ); printf( ' ', esc_html__( 'To', 'adminkeep' ), esc_attr( $current['until_typed'] ), esc_attr( $form ) ); echo ''; submit_button( __( 'Filter', 'adminkeep' ), '', '', false, array( 'id' => 'adminkeep-email-log-filter-submit', 'form' => $form ) ); echo '
'; } /** * Message shown for an empty table. * * @return void */ public function no_items() { esc_html_e( 'No email has been logged yet.', 'adminkeep' ); } /** * The bulk-select checkbox. * * @param array $item Row. * @return string */ protected function column_cb( $item ) { return sprintf( '', (int) $item['id'] ); } /** * The Date column, in the site's own format and time zone. * * @param array $item Row. * @return string */ protected function column_created_at( $item ) { return esc_html( self::local_date( $item ) ); } /** * A row's time in the site's own format and time zone. * * @param array $item Row. * @return string */ private static function local_date( array $item ) { /* translators: Date and time format for the email log, see https://www.php.net/manual/datetime.format.php */ return (string) wp_date( __( 'M d, Y g:i a', 'adminkeep' ), strtotime( $item['created_at'] . ' UTC' ) ); } /** * The Status column, a small badge. * * @param array $item Row. * @return string */ protected function column_status( $item ) { $labels = self::status_labels(); $status = isset( $labels[ $item['status'] ] ) ? $item['status'] : 'unconfirmed'; return sprintf( '%s', esc_attr( $status ), esc_html( $labels[ $status ] ) ); } /** * Status value => human label. Never "Delivered" — a mail server accepting a message is not * proof it reached an inbox. * * @return array */ public static function status_labels() { return array( 'sent' => __( 'Sent', 'adminkeep' ), 'failed' => __( 'Failed', 'adminkeep' ), 'unconfirmed' => __( 'Unconfirmed', 'adminkeep' ), ); } /** * The Subject column. A link to the preview: the script opens it in the modal, and without * script it opens the bare preview in a new tab. * * @param array $item Row. * @return string */ protected function column_subject( $item ) { $subject = '' === $item['subject'] ? __( '(no subject)', 'adminkeep' ) : $item['subject']; return sprintf( '%s', esc_url( EmailLogPreview::url( (int) $item['id'] ) ), esc_html( $subject ) ); } /** * The Actions column: the Preview button. * * @param array $item Row. * @return string */ protected function column_actions( $item ) { return sprintf( '%s', esc_url( EmailLogPreview::url( (int) $item['id'] ) ), esc_html__( 'Preview', 'adminkeep' ) ); } /** * Any other column: the raw value, escaped, or an em dash when empty. * * @param array $item Row. * @param string $column_name Column key. * @return string */ protected function column_default( $item, $column_name ) { $value = isset( $item[ $column_name ] ) ? (string) $item[ $column_name ] : ''; return '' === $value ? '—' : esc_html( $value ); } /** * One row, carrying everything the modal shows before the body arrives. * * @param array $item Row. * @return void */ public function single_row( $item ) { $labels = self::status_labels(); $status = isset( $labels[ $item['status'] ] ) ? $item['status'] : 'unconfirmed'; $data = array( 'email-id' => (string) (int) $item['id'], 'subject' => (string) $item['subject'], 'from' => (string) $item['from_email'], 'to' => (string) $item['to_email'], 'date' => self::local_date( $item ), 'status' => $status, 'status-label' => $labels[ $status ], 'headers' => (string) wp_json_encode( (object) $item['headers'] ), 'attachments' => (string) wp_json_encode( array_values( $item['attachments'] ) ), 'error' => (string) $item['error'], 'preview-url' => EmailLogPreview::url( (int) $item['id'] ), 'source-url' => EmailLogPreview::url( (int) $item['id'], 'source' ), 'text-url' => EmailLogPreview::url( (int) $item['id'], 'text' ), // Names the modal's first tab: HTML, Plain text, or HTML beside a Plain text tab. 'format' => EmailLogPreview::is_multipart( $item ) ? 'multipart' : ( EmailLogPreview::is_html( $item ) ? 'html' : 'text' ), ); echo ' $value ) { printf( ' data-%s="%s"', esc_attr( $key ), esc_attr( $value ) ); } echo '>'; $this->single_row_columns( $item ); echo ''; } }