*/ public static function sources() { return array( 'email-log' => new EmailLogPluginSource(), ); } /** * The row cap. A method so a test can shrink it, the way EmailLog::max_rows() is. * * @return int */ protected function max_rows() { return EmailLogStore::MAX_ROWS; } /** * Import everything new from one source. * * @param Source $source Where from. * @param bool $dry_run Count only; write nothing, remember nothing. * @return array{imported: int, skipped_old: int, left_full: int, failed: int, retention_days: int} */ public function run( Source $source, $dry_run ) { $name = $this->name_of( $source ); $settings = EmailLog::resolve( Settings::get( 'email_log' ) ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - $settings['retention_days'] * DAY_IN_SECONDS ); $room = max( 0, $this->max_rows() - EmailLogStore::count() ); $done = $this->last_imported( $name ); $read = $done; $full = false; $result = array( 'imported' => 0, 'skipped_old' => 0, 'left_full' => 0, 'failed' => 0, 'retention_days' => $settings['retention_days'], ); do { $rows = $source->rows( $read, self::BATCH ); foreach ( $rows as $row ) { $read = $row['id']; if ( '' === $row['sent_at'] || $row['sent_at'] < $cutoff ) { ++$result['skipped_old']; // Past the cut-off now means past it for good, so the cursor may step over it — // unless the log filled up earlier in this run, when the cursor has to stay put // in front of the first row that was left behind. $done = $full ? $done : $read; continue; } if ( $room <= 0 ) { $full = true; ++$result['left_full']; continue; } if ( ! $dry_run && 0 === EmailLogStore::insert( $this->row_from( $row, $settings['store_content'] ) ) ) { ++$result['failed']; } else { ++$result['imported']; --$room; } $done = $read; } // Per batch, so an import cut short (a timeout, a fatal) resumes rather than duplicates. if ( ! $dry_run ) { $this->remember( $name, $done ); } } while ( count( $rows ) === self::BATCH ); return $result; } /** * One source row as the row EmailLogStore::insert() expects. * * @param array $row Source row. * @param bool $store_content Whether bodies are kept. * @return array */ private function row_from( array $row, $store_content ) { $built = MailRow::from_atts( $row['atts'], $store_content ); // Live logging learns an HTML content type set by the wp_mail_content_type filter from // PHPMailer itself (EmailLog::record_sender()); an imported row has only its headers, and // a site that switched to HTML by filter left none. Without this its preview would show // escaped markup. A real tag is required, not a bare '<': "fails if x < y" is plain text. $message = isset( $row['atts']['message'] ) ? (string) $row['atts']['message'] : ''; if ( ! isset( $built['headers']['content-type'] ) && preg_match( '#]*>#i', $message ) ) { $built['headers']['content-type'] = 'text/html; charset=UTF-8'; } $built['created_at'] = $row['sent_at']; $built['status'] = $row['status']; $built['error'] = $this->scrub_error( (string) $row['error'] ); return $built; } /** * An SMTP error, with the account it may quote taken out, as EmailLog::mark_failed() does. * * @param string $message Error text. * @return string */ private function scrub_error( $message ) { if ( '' === $message ) { return ''; } $config = SmtpConfig::from_environment(); $password = $config->password(); return SmtpScrubber::scrub( $message, (string) $config->get( 'username' ), false === $password ? '' : (string) $password ); } /** * The name a source is registered under. * * @param Source $source Source. * @return string */ private function name_of( Source $source ) { foreach ( self::sources() as $name => $known ) { if ( get_class( $known ) === get_class( $source ) ) { return $name; } } return get_class( $source ); } /** * The last source id already imported. * * @param string $name Source name. * @return int */ private function last_imported( $name ) { $stored = get_option( self::OPTION, array() ); return is_array( $stored ) && isset( $stored[ $name ] ) ? (int) $stored[ $name ] : 0; } /** * Remember how far an import got. * * @param string $name Source name. * @param int $id Last source id handled. * @return void */ private function remember( $name, $id ) { $stored = get_option( self::OPTION, array() ); $stored = is_array( $stored ) ? $stored : array(); $stored[ $name ] = (int) $id; update_option( self::OPTION, $stored, false ); } }