File: /wordpress/plugins/wp-cloud-client/1.1.0/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 the wp_mail filter hook.
*/
public function register(): void {
add_filter( 'wp_mail', [ $this, 'intercept' ], PHP_INT_MAX );
}
// -------------------------------------------------------------------------
// Core interceptor
// -------------------------------------------------------------------------
/**
* Hooked to the `wp_mail` filter. Captures outgoing email data, persists it
* to the DB, and returns the args unchanged so the mail is still delivered.
*
* @param array $args wp_mail arguments.
* @return array
*/
public function intercept( array $args ): array {
try {
$this->send(
(array) ( $args['to'] ?? [] ),
(string) ( $args['subject'] ?? '' ),
(string) ( $args['message'] ?? '' ),
(array) ( $args['headers'] ?? [] ),
(array) ( $args['attachments'] ?? [] ),
);
} catch ( \Throwable $e ) {
$this->logger->error( 'MailManager: failed to record email', $e );
}
return $args;
}
// -------------------------------------------------------------------------
// 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 ) ) );
}
$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(),
'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 {
$to = array_values( array_filter( array_map( 'trim', $to ) ) );
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 );
[ $bodyHtml, $bodyText ] = $this->splitBody( $message );
$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,
'idempotency_key' => $idempotencyKey,
]
),
],
);
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 ) {
$this->table->incrementAttempts( [ $mailId ] );
} else {
$this->table->markNotified( $mailId );
}
}
}
/**
* Determine whether the current mail is "transactional" (form submission)
* vs. other (WooCommerce order email, etc.) by inspecting the call stack.
*/
private function resolveMailType(): string {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 );
$transactionalClasses = [
'WPCF7_', // Contact Form 7.
'WPForms',
'GFCommon', // Gravity Forms.
'NF_', // Ninja Forms.
];
foreach ( $trace as $frame ) {
$class = $frame['class'] ?? '';
foreach ( $transactionalClasses as $prefix ) {
if ( str_starts_with( $class, $prefix ) ) {
return 'transactional';
}
}
}
return 'other';
}
/**
* 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 );
}
/**
* 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( $header, 'reply-to:' ) === 0 ) {
return $this->parseAddress( trim( substr( $header, 9 ) ) );
}
}
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 ];
}
}