$to Recipients. * @param string $subject Subject line. * @param string $message HTML body. * @param array $cc Carbon copy recipients. * @param array $bcc Blind carbon copy recipients. * @param string $from_name Sender name. * @param string $from_email Sender address. * @param string $reply_to Reply-To address. * @param array $attachments Absolute file paths. * @param array $context Metadata passed to the hooks. * @param string $text Plain text alternative to the HTML body. */ public function __construct( private array $to = array(), private string $subject = '', private string $message = '', private array $cc = array(), private array $bcc = array(), private string $from_name = '', private string $from_email = '', private string $reply_to = '', private array $attachments = array(), private array $context = array(), private string $text = '' ) {} /** * Rebuilds a notification from stored data. * * @param array $data Payload produced by {@see Notification::to_array()}. */ public static function from_array( array $data ): Notification { return new self( to: array_values( array_filter( array_map( 'strval', (array) ( $data['to'] ?? array() ) ) ) ), subject: (string) ( $data['subject'] ?? '' ), message: (string) ( $data['message'] ?? '' ), cc: array_values( array_filter( array_map( 'strval', (array) ( $data['cc'] ?? array() ) ) ) ), bcc: array_values( array_filter( array_map( 'strval', (array) ( $data['bcc'] ?? array() ) ) ) ), from_name: (string) ( $data['from_name'] ?? '' ), from_email: (string) ( $data['from_email'] ?? '' ), reply_to: (string) ( $data['reply_to'] ?? '' ), attachments: array_values( array_filter( array_map( 'strval', (array) ( $data['attachments'] ?? array() ) ) ) ), context: (array) ( $data['context'] ?? array() ), text: (string) ( $data['text'] ?? '' ) ); } /** * Returns the recipients. * * @return array Email addresses. */ public function to(): array { /* * Sanitised like every other header value. It was the one that was not — * safe while the builder was the only source, because that validates each * address, but a notification also comes back through `from_array()` off * the cron queue, where nothing revalidates it. An address is dropped * rather than cleaned: a recipient that needed repairing is not one this * should be guessing at. */ return array_values( array_filter( array_map( 'sanitize_email', array_map( array( self::class, 'sanitize_header' ), $this->to ) ), static fn( string $address ): bool => '' !== $address && is_email( $address ) ) ); } /** * Returns the subject line. */ public function subject(): string { return self::sanitize_header( $this->subject ); } /** * Returns the plain text alternative. * * @return string The text body, or an empty string when none was built. */ public function text(): string { return $this->text; } /** * Returns the HTML body. */ public function message(): string { return $this->message; } /** * Returns the attachment paths. * * @return array Absolute file paths. */ public function attachments(): array { return $this->attachments; } /** * Returns the metadata attached to the notification. * * @return array The context. */ public function context(): array { return $this->context; } /** * Determines whether the notification can be sent. * * @return bool True when at least one recipient and a subject are present. */ public function is_sendable(): bool { // Asked of `to()`, not of the raw property: `to()` drops anything that is not // a valid address, so judging the raw list could call a notification sendable // and then hand `wp_mail()` an empty recipient list — which fails, and is then // logged with the merge-tag-expanded subject. return array() !== $this->to() && '' !== $this->subject; } /** * Builds the mail headers. * * The content type is declared per message rather than through the global * `wp_mail_content_type` filter, so a concurrent plain text mail from another * plugin is never turned into HTML by accident. * * @return array The headers. */ public function headers(): array { $headers = array( 'Content-Type: text/html; charset=UTF-8' ); $from_email = self::sanitize_header( $this->from_email ); if ( '' !== $from_email ) { $from_name = self::quote_display_name( self::sanitize_header( $this->from_name ) ); $headers[] = '' !== $from_name ? sprintf( 'From: %1$s <%2$s>', $from_name, $from_email ) : sprintf( 'From: %s', $from_email ); } $reply_to = self::sanitize_header( $this->reply_to ); if ( '' !== $reply_to ) { $headers[] = sprintf( 'Reply-To: %s', $reply_to ); } foreach ( $this->cc as $address ) { $headers[] = sprintf( 'Cc: %s', self::sanitize_header( $address ) ); } foreach ( $this->bcc as $address ) { $headers[] = sprintf( 'Bcc: %s', self::sanitize_header( $address ) ); } return $headers; } /** * Strips the characters that would end a header early. * * A carriage return or newline inside a header value lets the rest of the * value be read as further headers, which is how a `From` name turns into an * extra `Bcc`. Addresses are already validated with `is_email()`, so this is * the second line of defence rather than the first. * * @param string $value Raw header value. */ public static function sanitize_header( string $value ): string { return trim( (string) preg_replace( '/[\r\n\t\0\x0B]+/', ' ', $value ) ); } /** * Quotes a display name when the address grammar requires it. * * A name containing a comma, angle bracket or colon changes how the header * parses unless it is quoted, so `Doe, John ` does not become two * recipients. * * @param string $name Display name. * * @return string The name, quoted when needed. */ public static function quote_display_name( string $name ): string { if ( '' === $name ) { return ''; } if ( ! preg_match( '/[",;:<>@\[\]\\\\]/', $name ) ) { return $name; } return '"' . str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $name ) . '"'; } /** * Returns the notification as storable data. * * @return array The payload. */ public function to_array(): array { return array( 'to' => $this->to, 'subject' => $this->subject, 'message' => $this->message, 'cc' => $this->cc, 'bcc' => $this->bcc, 'from_name' => $this->from_name, 'from_email' => $this->from_email, 'reply_to' => $this->reply_to, 'attachments' => $this->attachments, 'text' => $this->text, 'context' => $this->context, ); } }