HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.32
Disabled: pcntl_fork
Upload Files
File: /wordpress/plugins/wp-cloud-client/1.1.2/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(),
				'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 {
		$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 );
			$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,
								'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 ) {
				$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 );
	}

	/**
	 * 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 ];
	}
}