File: /wordpress/plugins/wp-cloud-client/beta/src/Mail/MailManager.php
<?php
declare(strict_types=1);
namespace VPlugins\WPCloudClient\Mail;
use VPlugins\WPCloudClient\Settings\SettingsRepository;
use VPlugins\WPCloudClient\Support\Logger;
/**
* Intercepts wp_mail(), stores email metadata in wpcc_mails, and manages
* attachment copies in the uploads directory.
*
* Ported from wsp-bootstrapper: wsp-mail-manager.php
*/
final class MailManager {
public const MAX_ATTACHMENTS = 10;
public const MAX_ATTACHMENT_SIZE = 16_777_216; // 16 MB total per email.
public const ATTACHMENT_DIR = 'wpcc-mails';
/** Maximum emails to dispatch per flush cycle. */
private const MAX_NOTIFY = 20;
/** Give up notifying after this many consecutive failures for a given email. */
public const MAX_NOTIFY_ATTEMPTS = 5;
private static ?self $instance = null;
/**
* Constructor.
*
* @param MailTable $table Mail table instance.
* @param SettingsRepository $settings Settings repository instance.
* @param Logger $logger Logger instance.
*/
private function __construct(
private readonly MailTable $table,
private readonly SettingsRepository $settings,
private readonly Logger $logger,
) {}
/**
* Return the singleton instance.
*
* @throws \LogicException If init() has not been called first.
*/
public static function getInstance(): self {
if ( null === self::$instance ) {
throw new \LogicException( 'MailManager has not been initialised. Call MailManager::init() first.' );
}
return self::$instance;
}
/**
* Initialise and return the singleton instance.
*
* @param MailTable $table Mail table instance.
* @param SettingsRepository $settings Settings repository instance.
* @param Logger $logger Logger instance.
*/
public static function init( MailTable $table, SettingsRepository $settings, Logger $logger ): self {
if ( null === self::$instance ) {
self::$instance = new self( $table, $settings, $logger );
}
return self::$instance;
}
// -------------------------------------------------------------------------
// WordPress hook registration
// -------------------------------------------------------------------------
/**
* Register hooks for intercepting outgoing mail.
*
* Uses the `pre_wp_mail` filter (WP 5.7+, plugin requires 6.4+) to suppress
* native PHPMailer delivery cleanly — returning true signals success to
* WordPress without touching PHPMailer at all, so no wp_mail_failed action
* is fired on every intercepted email.
*/
public function register(): void {
add_filter( 'pre_wp_mail', [ $this, 'preIntercept' ], PHP_INT_MAX, 2 );
}
// -------------------------------------------------------------------------
// Core interceptor
// -------------------------------------------------------------------------
/**
* Hooked to the `pre_wp_mail` filter (WP 5.7+). Captures outgoing email
* data, persists it to the DB, then suppresses native WordPress delivery so
* the recipient does not receive the same email twice (manager delivers via
* Sendgrid).
*
* Returning true tells WordPress the mail was sent successfully without ever
* invoking PHPMailer, so no wp_mail_failed action is fired. Returning null
* falls back to the native delivery path (used on plugin failure so the
* email is not silently lost).
*
* @param mixed $result Short-circuit value passed by WordPress (null on first call).
* @param array $atts wp_mail arguments: to, subject, message, headers, attachments.
* @return mixed true to suppress native delivery; null to fall through.
*/
public function preIntercept( mixed $result, array $atts ): mixed {
try {
$this->send(
(array) ( $atts['to'] ?? [] ),
(string) ( $atts['subject'] ?? '' ),
(string) ( $atts['message'] ?? '' ),
$atts['headers'] ?? [],
(array) ( $atts['attachments'] ?? [] ),
);
} catch ( \Throwable $e ) {
$this->logger->error( 'MailManager: failed to record email', $e );
// Fall back to native WordPress delivery so the email is not lost.
return null;
}
// Suppress native WordPress delivery — manager handles dispatch via
// Sendgrid. Returning true reports success without invoking PHPMailer.
return true;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* Record an outgoing email in the database.
*
* @param array $to Recipient email addresses.
* @param string $subject Email subject.
* @param string $message Email message body.
* @param array|string $headers Email headers.
* @param array $attachments Filesystem paths to attachments.
* @throws \Throwable On failure to store the email or attachments.
*/
public function send(
array $to,
string $subject,
string $message,
array|string $headers,
array $attachments,
): void {
if ( is_string( $headers ) ) {
$headers = array_filter( array_map( 'trim', explode( "\n", $headers ) ) );
}
// If no explicit From: header was passed, apply wp_mail_from/wp_mail_from_name
// filters so third-party SMTP plugins (e.g. WP Mail SMTP Force From) are respected.
$hasFrom = false;
foreach ( $headers as $h ) {
if ( stripos( (string) $h, 'from:' ) === 0 ) {
$hasFrom = true;
break;
}
}
if ( ! $hasFrom ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- consuming core WP hooks, not registering them.
$fromEmail = (string) apply_filters( 'wp_mail_from', get_option( 'admin_email' ) );
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- consuming core WP hooks, not registering them.
$fromName = trim( (string) apply_filters( 'wp_mail_from_name', get_bloginfo( 'name' ) ) );
$headers[] = '' !== $fromName ? 'From: "' . addslashes( $fromName ) . '" <' . $fromEmail . '>' : "From: {$fromEmail}";
}
$validated = $this->validate( $to, $subject, $message, $headers, $attachments );
if ( null === $validated ) {
return;
}
[$to, $subject, $message, $headers, $attachments] = $validated;
// Reserve a row early so we have an ID for the attachment sub-directory.
$mailId = $this->table->register();
try {
$copiedAttachments = $this->prepareAttachments( $mailId, $attachments );
$envelope = [
'to' => $to,
'subject' => $subject,
'message' => $message,
'headers' => $headers,
'attachments' => $copiedAttachments,
'type' => $this->resolveMailType(),
'sensitive' => $this->isSensitiveMail(),
'recorded_at' => gmdate( 'c' ),
];
$this->table->envelop( $mailId, (string) wp_json_encode( $envelope ) );
} catch ( \Throwable $e ) {
// Roll back the placeholder row on failure.
$this->table->remove( $mailId );
$this->cleanAttachmentDir( $mailId );
throw $e;
}
$this->flush();
}
/**
* Remove all traces of a stored email (attachments + DB row).
*
* Called by the MailCleaner REST endpoint.
*
* @param int $mailId Row eid.
*/
public function clean( int $mailId ): void {
$this->cleanAttachmentDir( $mailId );
$this->table->remove( $mailId );
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Validate and normalise mail parameters.
* Returns null to silently abort recording (e.g. no recipients).
*
* @param array $to Recipient addresses.
* @param string $subject Email subject.
* @param string $message Email body.
* @param array $headers Email headers.
* @param array $attachments File paths.
* @return array{0: list<string>, 1: string, 2: string, 3: list<string>, 4: list<string>}|null
*/
private function validate(
array $to,
string $subject,
string $message,
array $headers,
array $attachments,
): ?array {
// wp_mail() accepts a comma-separated string for $to; (array) cast wraps
// it as a single element, so we must split each entry on commas here.
// splitAddresses() is angle-bracket-aware to preserve "Name <email>" entries.
$expanded = [];
foreach ( $to as $address ) {
foreach ( $this->splitAddresses( (string) $address ) as $single ) {
$expanded[] = $single;
}
}
$to = array_values( array_filter( array_map( 'trim', $expanded ) ) );
if ( empty( $to ) ) {
$this->logger->warning( 'MailManager: discarding email with no recipients' );
return null;
}
$subject = trim( $subject );
$message = trim( $message );
// Normalise headers to plain strings.
$headers = array_values(
array_filter(
array_map( 'trim', $headers ),
static fn ( string $h ) => '' !== $h,
)
);
// Filter to existing, readable files.
$attachments = array_values(
array_filter(
array_map( 'trim', $attachments ),
static fn ( string $p ) => '' !== $p && is_readable( $p ),
)
);
if ( count( $attachments ) > self::MAX_ATTACHMENTS ) {
$attachments = array_slice( $attachments, 0, self::MAX_ATTACHMENTS );
$this->logger->warning( 'MailManager: attachment count truncated to ' . self::MAX_ATTACHMENTS );
}
// Enforce total attachment size limit.
$totalSize = 0;
$safeAttach = [];
foreach ( $attachments as $path ) {
$size = filesize( $path );
if ( false === $size ) {
continue;
}
if ( ( $totalSize + $size ) > self::MAX_ATTACHMENT_SIZE ) {
$this->logger->warning( 'MailManager: attachment skipped — would exceed size limit', [ 'path' => $path ] );
continue;
}
$totalSize += $size;
$safeAttach[] = $path;
}
return [ $to, $subject, $message, $headers, $safeAttach ];
}
/**
* Copy attachments into uploads/wpcc-mails/<mailId>/ and return metadata.
*
* @param int $mailId Row eid for directory naming.
* @param array $attachments Original file paths.
* @return list<array{name: string, url: string, size: int}>
*/
private function prepareAttachments( int $mailId, array $attachments ): array {
if ( empty( $attachments ) ) {
return [];
}
$uploadDir = $this->attachmentDir( $mailId, create: true );
$baseUrl = $this->attachmentUrl( $mailId );
$result = [];
foreach ( $attachments as $sourcePath ) {
$filename = sanitize_file_name( basename( $sourcePath ) );
$destPath = $uploadDir . DIRECTORY_SEPARATOR . $filename;
if ( ! copy( $sourcePath, $destPath ) ) {
$this->logger->warning( 'MailManager: failed to copy attachment', [ 'src' => $sourcePath ] );
continue;
}
$size = filesize( $destPath );
$result[] = [
'name' => $filename,
'url' => trailingslashit( $baseUrl ) . $filename,
'size' => false !== $size ? $size : 0,
];
}
return $result;
}
/**
* Dispatch any unnotified emails through wp-cloud-manager (max MAX_NOTIFY per cycle).
*
* Called after each recorded email and by the WP-Cron retry job. Failures
* are logged but do not bubble up — notification is best-effort.
*/
public function flush(): void {
if ( ( new MailSettings( $this->settings ) )->isDisabled() ) {
return;
}
if ( ! $this->settings->isConfigured() ) {
return;
}
// Prevent concurrent cron invocations from double-sending the same batch.
if ( get_transient( 'wpcc_flush_lock' ) ) {
return;
}
set_transient( 'wpcc_flush_lock', true, 60 );
try {
$this->doFlush();
} finally {
delete_transient( 'wpcc_flush_lock' );
}
}
/**
* Inner flush logic, called only when the lock is held.
*/
private function doFlush(): void {
$unnotified = $this->table->listUnnotified( self::MAX_NOTIFY, self::MAX_NOTIFY_ATTEMPTS );
if ( empty( $unnotified ) ) {
return;
}
$siteId = (string) $this->settings->getSiteId();
$apiKey = (string) $this->settings->getApiKey();
$managerUrl = $this->settings->getManagerUrl();
if ( '' === $managerUrl ) {
$this->logger->error( 'MailManager: manager URL is not configured; notifications cannot be sent' );
return;
}
$endpoint = rtrim( $managerUrl, '/' ) . '/wp_cloud_manager.v1.WPEmailManager/SendSiteEmail';
foreach ( $unnotified as $row ) {
$mailId = (int) $row['eid'];
$envelope = is_array( $row['content'] ) ? $row['content'] : json_decode( (string) $row['content'], true );
if ( ! is_array( $envelope ) ) {
$this->logger->warning( 'MailManager: skipping row with invalid envelope', [ 'eid' => $mailId ] );
$this->table->markNotified( $mailId );
continue;
}
$recipients = (array) ( $envelope['to'] ?? [] );
$subject = (string) ( $envelope['subject'] ?? '' );
$message = (string) ( $envelope['message'] ?? '' );
$headers = (array) ( $envelope['headers'] ?? [] );
[ $replyTo, $replyToName ] = $this->extractReplyTo( $headers );
[ $from, $fromName ] = $this->extractFrom( $headers );
[ $bodyHtml, $bodyText ] = $this->splitBody( $message );
$emailType = $this->emailTypeForEnvelope( (string) ( $envelope['type'] ?? '' ) );
$sensitive = (bool) ( $envelope['sensitive'] ?? false );
$this->logger->info(
'MailManager: resolved email type',
[
'eid' => $mailId,
'email_type' => $emailType,
'sensitive' => $sensitive,
],
);
$anyFailed = false;
foreach ( $recipients as $idx => $recipient ) {
[ $toEmail, $toName ] = $this->parseAddress( (string) $recipient );
$idempotencyKey = count( $recipients ) > 1
? "{$mailId}-{$idx}"
: (string) $mailId;
$response = wp_remote_post(
$endpoint,
[
'timeout' => 10,
'redirection' => 0,
'headers' => [
// Connect protocol (connectrpc.com/connect) unary RPC over HTTP/1.1.
'Content-Type' => 'application/connect+json',
'Connect-Protocol-Version' => '1',
'X-WPCC-Site-ID' => $siteId,
'X-WPCC-API-Key' => $apiKey,
],
'body' => (string) wp_json_encode(
[
'to' => $toEmail,
'to_name' => $toName,
'subject' => $subject,
'body_html' => $bodyHtml,
'body_text' => $bodyText,
'reply_to' => $replyTo,
'reply_to_name' => $replyToName,
'from' => $from,
'from_name' => $fromName,
'idempotency_key' => $idempotencyKey,
'email_type' => $emailType,
'sensitive' => $sensitive,
]
),
],
);
if ( is_wp_error( $response ) ) {
$anyFailed = true;
$this->logger->warning(
'MailManager: SendSiteEmail transport error',
[
'eid' => $mailId,
'to' => $toEmail,
'code' => $response->get_error_code(),
'message' => $response->get_error_message(),
],
);
continue;
}
$statusCode = (int) wp_remote_retrieve_response_code( $response );
$responseBody = (string) wp_remote_retrieve_body( $response );
if ( $statusCode < 200 || $statusCode >= 300 ) {
$anyFailed = true;
$connectError = json_decode( $responseBody, true );
$this->logger->warning(
'MailManager: SendSiteEmail error',
[
'eid' => $mailId,
'to' => $toEmail,
'status' => $statusCode,
'code' => is_array( $connectError ) ? ( $connectError['code'] ?? '' ) : '',
'error' => is_array( $connectError ) ? ( $connectError['message'] ?? $responseBody ) : $responseBody,
],
);
continue;
}
$parsed = json_decode( $responseBody, true );
$emailId = is_array( $parsed ) ? ( $parsed['emailId'] ?? '' ) : '';
$this->logger->info(
'MailManager: SendSiteEmail delivered',
[
'eid' => $mailId,
'to' => $toEmail,
'email_id' => $emailId,
],
);
}
if ( $anyFailed ) {
if ( (int) ( $row['notify_attempts'] ?? 0 ) + 1 >= self::MAX_NOTIFY_ATTEMPTS ) {
$this->logger->error(
'MailManager: giving up on email after max notify attempts',
[
'eid' => $mailId,
'max_attempts' => self::MAX_NOTIFY_ATTEMPTS,
],
);
}
$this->table->incrementAttempts( [ $mailId ] );
} else {
$this->table->markNotified( $mailId );
}
}
}
/**
* Classify the current mail as "transactional" (form-plugin submission) or
* "other" by inspecting the call stack at send time.
*
* WooCommerce emails are explicitly treated as non-transactional and are
* checked first, so a WooCommerce send stays "other" even if a form plugin
* appears deeper in the stack. Mirrors the detection in wsp-bootstrapper's
* MailManager::isTransactionalMail().
*
* @param array<int, array<string, mixed>>|null $trace Optional call stack;
* defaults to debug_backtrace(). Injectable for testing.
*/
private function resolveMailType( ?array $trace = null ): string {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
$trace ??= debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 25 );
foreach ( $trace as $frame ) {
$file = (string) ( $frame['file'] ?? '' );
$class = (string) ( $frame['class'] ?? '' );
// WooCommerce mail → non-transactional (checked first).
// 'WC_Email' also matches the 'WC_Emails' manager class as a substring.
if ( str_contains( $file, 'woocommerce' )
|| str_contains( $class, 'WC_Email' )
) {
return 'other';
}
// Supported form plugins → transactional.
// 'contact-form-7' also matches 'wp-contact-form-7' paths as a substring.
if ( str_contains( $file, 'contact-form-7' )
|| str_contains( $class, 'WPCF7' )
|| str_contains( $file, 'wpforms' )
|| str_contains( $class, 'WPForms' )
|| str_contains( $file, 'gravityforms' )
|| str_contains( $class, 'GFForms' )
|| str_contains( $class, 'GFCommon' )
|| str_contains( $class, 'RGForms' )
|| str_contains( $file, 'ninja-forms' )
|| str_contains( $class, 'NF_' )
) {
return 'transactional';
}
}
return 'other';
}
/**
* Determine whether the current mail is "sensitive" — carries a secret such
* as a password-reset key, a set-password link, or a personal-data export
* link — by inspecting the call stack at send time.
*
* Sensitive mail is flagged so wp-cloud-manager can apply encrypted
* handling for compliance. Detection is intentionally precise (only known
* WordPress core and WooCommerce credential emails) to avoid over-flagging.
* Orthogonal to resolveMailType(): a mail can be non-transactional and
* sensitive at once (e.g. a WooCommerce password-reset email).
*
* @param array<int, array<string, mixed>>|null $trace Optional call stack;
* defaults to debug_backtrace(). Injectable for testing.
*/
private function isSensitiveMail( ?array $trace = null ): bool {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
$trace ??= debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 25 );
foreach ( $trace as $frame ) {
$function = (string) ( $frame['function'] ?? '' );
$class = (string) ( $frame['class'] ?? '' );
$file = (string) ( $frame['file'] ?? '' );
// WordPress core emails that carry a secret (reset key, set-password
// link, or personal-data export download link).
if ( 'retrieve_password' === $function
|| 'wp_new_user_notification' === $function
|| 'wp_privacy_send_personal_data_export_email' === $function
) {
return true;
}
// WooCommerce account-credential emails. The child class overrides
// trigger(), so its class name and file path appear in the frame.
if ( str_contains( $class, 'WC_Email_Customer_Reset_Password' )
|| str_contains( $class, 'WC_Email_Customer_New_Account' )
|| str_contains( $file, 'customer-reset-password' )
|| str_contains( $file, 'customer-new-account' )
) {
return true;
}
}
return false;
}
/**
* Map the stored envelope mail type to the wp-cloud-manager EmailType enum
* name accepted by SendSiteEmail. Unknown/missing types fall back to
* EMAIL_TYPE_NON_TRANSACTIONAL (the manager's documented default).
*
* @param string $type Stored envelope type ("transactional"/"other").
*/
private function emailTypeForEnvelope( string $type ): string {
return 'transactional' === $type
? 'EMAIL_TYPE_TRANSACTIONAL'
: 'EMAIL_TYPE_NON_TRANSACTIONAL';
}
/**
* Absolute filesystem path to uploads/wpcc-mails/<mailId>.
*
* @param int $mailId Row eid.
* @param bool $create Whether to create the directory if it does not exist.
* @return string
*/
private function attachmentDir( int $mailId, bool $create = false ): string {
$upload = wp_upload_dir();
$baseDir = trailingslashit( $upload['basedir'] ) . self::ATTACHMENT_DIR;
$dir = $baseDir . DIRECTORY_SEPARATOR . $mailId;
if ( $create && ! is_dir( $dir ) ) {
wp_mkdir_p( $dir );
}
return $dir;
}
/**
* Public URL to uploads/wpcc-mails/<mailId>.
*
* @param int $mailId Row eid.
* @return string
*/
private function attachmentUrl( int $mailId ): string {
$upload = wp_upload_dir();
return trailingslashit( $upload['baseurl'] ) . self::ATTACHMENT_DIR . '/' . $mailId;
}
/**
* Delete all files inside uploads/wpcc-mails/<mailId>.
*
* @param int $mailId Row eid.
*/
private function cleanAttachmentDir( int $mailId ): void {
$dir = $this->attachmentDir( $mailId );
if ( ! is_dir( $dir ) ) {
return;
}
$files = glob( $dir . DIRECTORY_SEPARATOR . '*' );
if ( is_array( $files ) ) {
foreach ( $files as $file ) {
if ( is_file( $file ) ) {
wp_delete_file( $file );
}
}
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
rmdir( $dir );
}
/**
* Split an RFC 2822 address list on commas, respecting angle-bracket groups
* so that "Display Name <email>" entries are never torn apart.
*
* WordPress documents wp_mail()'s $to parameter as accepting a comma-separated
* list, but (array) cast wraps the whole string as one element. This method
* restores the intended semantics before the address list reaches SendGrid.
*
* @param string $address_list Raw address-list string (may already be a single address).
* @return list<string> Individual address strings, trimmed, non-empty.
*/
private function splitAddresses( string $address_list ): array {
$addresses = [];
$depth = 0;
$in_quote = false;
$current = '';
foreach ( str_split( $address_list ) as $char ) {
if ( '"' === $char ) {
$in_quote = ! $in_quote;
}
if ( '<' === $char && ! $in_quote ) {
++$depth;
} elseif ( '>' === $char && $depth > 0 && ! $in_quote ) {
--$depth;
}
if ( ',' === $char && 0 === $depth && ! $in_quote ) {
$trimmed = trim( $current );
if ( '' !== $trimmed ) {
$addresses[] = $trimmed;
}
$current = '';
} else {
$current .= $char;
}
}
$trimmed = trim( $current );
if ( '' !== $trimmed ) {
$addresses[] = $trimmed;
}
return $addresses;
}
/**
* Parse "Display Name <email>" or bare "email" into [email, name].
*
* @param string $address Raw address string.
* @return array{0: string, 1: string}
*/
private function parseAddress( string $address ): array {
if ( preg_match( '/^(.*?)\s*<([^>]+)>\s*$/', $address, $m ) ) {
return [ trim( $m[2] ), trim( $m[1], " \t\"'" ) ];
}
return [ trim( $address ), '' ];
}
/**
* Find the Reply-To header and return [email, name], or ['', ''] if absent.
*
* @param string[] $headers Raw header strings from the stored envelope.
* @return array{0: string, 1: string}
*/
private function extractReplyTo( array $headers ): array {
foreach ( $headers as $header ) {
if ( stripos( (string) $header, 'reply-to:' ) === 0 ) {
return $this->parseAddress( trim( substr( (string) $header, 9 ) ) );
}
}
return [ '', '' ];
}
/**
* Find the From header and return [email, name], or ['', ''] if absent.
*
* @param string[] $headers Raw header strings from the stored envelope.
* @return array{0: string, 1: string}
*/
private function extractFrom( array $headers ): array {
foreach ( $headers as $header ) {
if ( stripos( (string) $header, 'from:' ) === 0 ) {
return $this->parseAddress( trim( substr( (string) $header, 5 ) ) );
}
}
return [ '', '' ];
}
/**
* Return [body_html, body_text] — exactly one is non-empty, based on
* whether the message contains HTML tags.
*
* @param string $message Raw message body.
* @return array{0: string, 1: string}
*/
private function splitBody( string $message ): array {
if ( preg_match( '/<[a-z][\s\S]*>/i', $message ) ) {
return [ $message, '' ];
}
return [ '', $message ];
}
}