HEX
Server: LiteSpeed
System: Linux s787.bom1.mysecurecloudhost.com 4.18.0-477.13.1.lve.el8.x86_64 #1 SMP Thu Jun 1 16:40:47 EDT 2023 x86_64
User: mobilech (5348)
PHP: 8.1.34
Disabled: NONE
Upload Files
File: //proc/self/cwd/wp-content/plugins/wp-mail-smtp/src/Providers/Sendlayer/QuickConnectUsage.php
<?php

namespace WPMailSMTP\Providers\Sendlayer;

use WPMailSMTP\ConnectionInterface;
use WPMailSMTP\ConnectionsManager;
use WPMailSMTP\Helpers\Data;
use WPMailSMTP\Providers\MailerAbstract;
use WPMailSMTP\WP;

/**
 * Tracks SendLayer Quick Connect usage.
 *
 * @since 4.9.0
 */
class QuickConnectUsage {

	/**
	 * Option key holding all site-level usage-notice state as a structured array:
	 * usage (limit/used), plan, cc, limit_reached. Non-autoloaded.
	 *
	 * @since 4.9.0
	 *
	 * @var string
	 */
	const STATE_OPTION = 'wp_mail_smtp_sendlayer_quick_connect';

	/**
	 * Response header carrying the QC account quota snapshot on the plugin's own sends.
	 * Format: `limit=100000;used=437;plan=free;cc=0`. Read case-insensitively.
	 *
	 * @since 4.9.0
	 *
	 * @var string
	 */
	const QUOTA_HEADER = 'x-sendlayer-quota';

	/**
	 * Header `used` floor that gates the custom-domain notice. Below it a quiet install
	 * shows no nudges.
	 *
	 * @since 4.9.0
	 *
	 * @var int
	 */
	const CUSTOM_DOMAIN_NOTICE_LIMIT = 10;

	/**
	 * SendLayer API error code returned when the account's email quota is reached
	 * (HTTP 429, `Errors[0].Code` == 17, message "Email quota reached"). The
	 * limit-reached notice keys off this exact code from the plugin's own send responses.
	 *
	 * @since 4.9.0
	 *
	 * @var string
	 */
	const QUOTA_REACHED_ERROR_CODE = '17';

	/**
	 * Fallback upgrade URL when the connect-time free_upgrade_url is absent.
	 *
	 * @since 4.9.0
	 *
	 * @var string
	 */
	const PRICING_URL = 'https://sendlayer.com/pricing/';

	/**
	 * Connections manager.
	 *
	 * @since 4.9.0
	 *
	 * @var ConnectionsManager
	 */
	private $connections_manager;

	/**
	 * QuickConnectUsage constructor.
	 *
	 * @since 4.9.0
	 *
	 * @param ConnectionsManager|null $connections_manager Connections manager. Defaults to the global one.
	 */
	public function __construct( $connections_manager = null ) {

		$this->connections_manager = $connections_manager === null
			? wp_mail_smtp()->get_connections_manager()
			: $connections_manager;
	}

	/**
	 * Register hooks.
	 *
	 * @since 4.9.0
	 */
	public function hooks() {

		// Nothing here is relevant unless the primary connection is a QC connection.
		if ( ! $this->is_quick_connect() ) {
			return;
		}

		add_action( 'wp_mail_smtp_mailcatcher_send_after', [ $this, 'capture_usage_from_response' ], 10, 1 );
		add_action( 'wp_mail_smtp_mailcatcher_send_after', [ $this, 'clear_limit_reached_on_success' ], 10, 1 );
		add_action( 'wp_mail_smtp_mailcatcher_send_failed', [ $this, 'maybe_flag_limit_reached' ], 10, 6 );
		add_action( 'admin_init', [ $this, 'maybe_add_notice' ] );
	}

	/**
	 * Whether the primary connection is a SendLayer Quick Connect connection.
	 *
	 * @since 4.9.0
	 *
	 * @return bool
	 */
	public function is_quick_connect() {

		$connection = $this->connections_manager->get_primary_connection();

		return $connection->get_mailer_slug() === 'sendlayer'
			&& (bool) $connection->get_options()->get( 'sendlayer', 'quick_connect' );
	}

	/**
	 * Whether the primary connection is a Quick Connect connection on a shared
	 * (SendLayer-provided) domain.
	 *
	 * @since 4.9.0
	 *
	 * @return bool
	 */
	public function is_shared_domain() {

		if ( ! $this->is_quick_connect() ) {
			return false;
		}

		$options = $this->connections_manager->get_primary_connection()->get_options();

		return (bool) $options->get( 'sendlayer', 'is_shared_domain' );
	}

	/**
	 * Store the quota snapshot from the QC send response header.
	 *
	 * Hooked to the after-send action; the header is advisory, so a send that lacks it
	 * leaves stored usage unchanged. Runs only for a primary Quick Connect send. Capture
	 * is never gated on is_bailed() - it is the classifier that arms the suppress gate.
	 *
	 * @since 4.9.0
	 *
	 * @param MailerAbstract $mailer The mailer that performed the send.
	 */
	public function capture_usage_from_response( MailerAbstract $mailer ) {

		if ( ! $this->is_quick_connect() || ! $mailer->get_connection()->is_primary() ) {
			return;
		}

		$parsed = $this->parse_quota_header( $mailer->get_response_header( self::QUOTA_HEADER ) );

		if ( $parsed === null ) {
			return;
		}

		$this->save_state(
			[
				'usage' => [
					'limit' => $parsed['limit'],
					'used'  => $parsed['used'],
				],
				'plan'  => $parsed['plan'],
				'cc'    => $parsed['cc'],
			]
		);
	}

	/**
	 * Parse the X-SendLayer-Quota header value into the usage sub-array.
	 *
	 * @since 4.9.0
	 *
	 * @param string $header Raw header value, e.g. `limit=100000;used=437;plan=free;cc=0`.
	 *
	 * @return array|null { limit, used, plan, cc } when limit+used are present, else null.
	 */
	private function parse_quota_header( $header ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		if ( ! is_string( $header ) || $header === '' ) {
			return null;
		}

		$pairs = [];

		foreach ( explode( ';', $header ) as $segment ) {
			if ( strpos( $segment, '=' ) === false ) {
				continue;
			}

			list( $key, $value ) = explode( '=', $segment, 2 );

			$pairs[ trim( $key ) ] = trim( $value );
		}

		if ( ! isset( $pairs['limit'], $pairs['used'] ) ) {
			return null;
		}

		return [
			'limit' => (int) $pairs['limit'],
			'used'  => (int) $pairs['used'],
			'plan'  => isset( $pairs['plan'] ) ? sanitize_text_field( $pairs['plan'] ) : '',
			'cc'    => isset( $pairs['cc'] ) ? (int) $pairs['cc'] : 0,
		];
	}

	/**
	 * Reset all QC usage-notice state.
	 *
	 * Called on QC connect/disconnect and when the SendLayer API key changes, since each
	 * starts a clean slate against a different (or absent) account. Clears usage + the
	 * reached flag and re-arms every notice so the next account starts un-dismissed.
	 *
	 * @since 4.9.0
	 */
	public function reset() {

		$this->save_state(
			[
				'usage'         => $this->default_usage(),
				'plan'          => '',
				'cc'            => 0,
				'limit_reached' => false,
			]
		);

		$this->rearm_notice( $this->custom_domain_key() );
		$this->rearm_notice( $this->limit_warning_key() );
		$this->rearm_notice( $this->limit_reached_key() );
	}

	/**
	 * Clear the per-user dismissal of a notice for all users, so it shows again.
	 *
	 * @since 4.9.0
	 *
	 * @param string $key Notice key.
	 */
	private function rearm_notice( $key ) {

		delete_metadata( 'user', 0, "wp_mail_smtp_notice_{$key}_dismissed", '', true );
	}

	/**
	 * Queue the single top-precedence due usage notice (limit_reached > limit_warning >
	 * custom_domain) for WP::display_admin_notices() to render dashboard-wide.
	 *
	 * All three require a QC primary connection, the global-options capability (the QC
	 * connection state is main-site-scoped in network-wide mode), and a not-bailed feature.
	 *
	 * @since 4.9.0
	 */
	public function maybe_add_notice() {

		if (
			$this->is_bailed() ||
			! current_user_can( wp_mail_smtp()->get_capability_manage_global_options() )
		) {
			return;
		}

		if ( $this->should_show_limit_reached() ) {
			WP::add_admin_notice( $this->limit_reached_message(), WP::ADMIN_NOTICE_ERROR, true, $this->limit_reached_key() );

			return;
		}

		if ( $this->should_show_limit_warning() ) {
			WP::add_admin_notice( $this->limit_warning_message(), WP::ADMIN_NOTICE_WARNING, true, $this->limit_warning_key() );

			return;
		}

		if ( $this->should_show_custom_domain() ) {
			WP::add_admin_notice( $this->custom_domain_message(), WP::ADMIN_NOTICE_INFO, true, $this->custom_domain_key() );
		}
	}

	/**
	 * Notice key for the custom-domain notice. Stable; dismissal is re-armed on reset.
	 *
	 * @since 4.9.0
	 *
	 * @return string
	 */
	protected function custom_domain_key() {

		return 'sendlayer_qc_custom_domain';
	}

	/**
	 * Notice key for the limit-warning notice. Stable; dismissal is re-armed on reset.
	 *
	 * @since 4.9.0
	 *
	 * @return string
	 */
	protected function limit_warning_key() {

		return 'sendlayer_qc_limit_warning';
	}

	/**
	 * Notice key for the limit-reached notice. Stable; dismissal is re-armed on reset.
	 *
	 * @since 4.9.0
	 *
	 * @return string
	 */
	protected function limit_reached_key() {

		return 'sendlayer_qc_limit_reached';
	}

	/**
	 * Whether the custom-domain notice is due.
	 *
	 * @since 4.9.0
	 *
	 * @return bool
	 */
	public function should_show_custom_domain() {

		$usage = $this->get_usage();
		$used  = isset( $usage['used'] ) ? (int) $usage['used'] : 0;

		return $this->is_shared_domain() &&
			$used >= self::CUSTOM_DOMAIN_NOTICE_LIMIT;
	}

	/**
	 * Whether the limit-warning notice is due.
	 *
	 * @since 4.9.0
	 *
	 * @return bool
	 */
	public function should_show_limit_warning() {

		$usage = $this->get_usage();

		if ( empty( $usage ) ) {
			return false;
		}

		$limit = isset( $usage['limit'] ) ? (int) $usage['limit'] : 0;
		$used  = isset( $usage['used'] ) ? (int) $usage['used'] : 0;

		// No data or a nonsensical limit: never render (a wrong notice is worse
		// than none). At-or-over the limit is the limit-reached notice's territory.
		if ( $limit <= 0 || $used >= $limit ) {
			return false;
		}

		return ( $used / $limit ) >= 0.5;
	}

	/**
	 * Whether the limit-reached notice is due.
	 *
	 * @since 4.9.0
	 *
	 * @return bool
	 */
	public function should_show_limit_reached() {

		return (bool) $this->get_state( 'limit_reached' );
	}

	/**
	 * Flag the limit-reached state when a primary-QC send fails with the
	 * SendLayer email-quota-reached error.
	 *
	 * @since 4.9.0
	 *
	 * @param string                   $error_message Error message.
	 * @param mixed                    $mailcatcher   The MailCatcher object.
	 * @param string                   $mailer_slug   Current mailer slug.
	 * @param string                   $error_code    Error code/slug from the mailer response.
	 * @param int                      $response_code HTTP/SMTP response code.
	 * @param ConnectionInterface|null $connection    The connection the send used.
	 */
	public function maybe_flag_limit_reached( $error_message, $mailcatcher, $mailer_slug, $error_code, $response_code = 0, ConnectionInterface $connection = null ) {

		if (
			$this->is_bailed() ||
			! $connection instanceof ConnectionInterface ||
			! $connection->is_primary()
		) {
			return;
		}

		// The SendLayer mailer surfaces Errors[0].Code, which is a numeric quota
		// code on a 429; compare as a string so an int or string code both match.
		if ( (string) $error_code !== self::QUOTA_REACHED_ERROR_CODE ) {
			return;
		}

		$this->rearm_notice( $this->limit_reached_key() );

		if ( ! $this->get_state( 'limit_reached' ) ) {
			$this->save_state( [ 'limit_reached' => true ] );
		}
	}

	/**
	 * Clear the limit-reached state on the next successful primary-QC send.
	 *
	 * @since 4.9.0
	 *
	 * @param MailerAbstract $mailer The mailer that performed the send.
	 */
	public function clear_limit_reached_on_success( MailerAbstract $mailer ) {

		if ( $this->is_bailed() || ! $mailer->get_connection()->is_primary() ) {
			return;
		}

		if ( $this->get_state( 'limit_reached' ) ) {
			$this->save_state( [ 'limit_reached' => false ] );
		}
	}

	/**
	 * Resolve the upgrade CTA URL: the connect-time free_upgrade_url stored on the
	 * primary connection when present, otherwise the UTM-tagged pricing fallback.
	 *
	 * @since 4.9.0
	 *
	 * @return string
	 */
	public function get_free_upgrade_url() {

		$stored = $this->connections_manager->get_primary_connection()->get_options()->get( 'sendlayer', 'free_upgrade_url' );

		$target = ! empty( $stored ) ? $stored : self::PRICING_URL;

		// phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound
		return wp_mail_smtp()->get_utm_url( $target, [ 'source' => 'wpmailsmtpplugin', 'medium' => 'WordPress', 'content' => 'SendLayer QC Usage Notice - Upgrade' ] );
	}

	/**
	 * Whether the feature is suppressed: notices run only for a primary QC connection the
	 * quota header has classified as free with no card on file. An unclassified account
	 * (empty plan) bails until the header confirms free+no-card; capture still runs, so
	 * classification still happens.
	 *
	 * @since 4.9.0
	 *
	 * @return bool
	 */
	public function is_bailed() {

		return ! $this->is_quick_connect()
			|| ! ( $this->get_state( 'plan' ) === 'free' && (int) $this->get_state( 'cc' ) === 0 );
	}

	/**
	 * Whether the account has a card on file (per the latest quota header).
	 *
	 * @since 4.9.0
	 *
	 * @return bool
	 */
	public function has_card_on_file() {

		return (int) $this->get_state( 'cc' ) === 1;
	}

	/**
	 * Get the stored usage data ({ limit, used }), or an empty array when there is no
	 * usable limit yet.
	 *
	 * @since 4.9.0
	 *
	 * @return array
	 */
	public function get_usage() {

		$usage = $this->get_state( 'usage' );

		if ( $usage['limit'] === null ) {
			return [];
		}

		return [
			'limit' => (int) $usage['limit'],
			'used'  => (int) $usage['used'],
		];
	}

	/**
	 * Custom-domain suggestion notice copy.
	 *
	 * @since 4.9.0
	 *
	 * @return string
	 */
	protected function custom_domain_message() {

		// phpcs:disable WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound
		$app_url = wp_mail_smtp()->get_utm_url( 'https://app.sendlayer.com/', [ 'source' => 'wpmailsmtpplugin', 'medium' => 'WordPress', 'content' => 'QC Usage Notice - Custom Domain App' ] );
		$doc_url = wp_mail_smtp()->get_utm_url( 'https://sendlayer.com/docs/authorizing-your-domain/', [ 'source' => 'wpmailsmtpplugin', 'medium' => 'WordPress', 'content' => 'QC Usage Notice - Custom Domain Docs' ] );
		// phpcs:enable WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound

		$message = sprintf(
			wp_kses(
				/* translators: %1$s - SendLayer dashboard URL; %2$s - custom domain documentation URL. */
				__( 'You\'re sending through a shared SendLayer domain. For better deliverability, add your own domain on the <a href="%1$s" target="_blank" rel="noopener noreferrer">SendLayer dashboard</a> and follow our <a href="%2$s" target="_blank" rel="noopener noreferrer">custom domain guide</a>.', 'wp-mail-smtp' ),
				[
					'a' => [
						'href'   => [],
						'rel'    => [],
						'target' => [],
					],
				]
			),
			esc_url( $app_url ),
			esc_url( $doc_url )
		);

		return $message;
	}

	/**
	 * Limit-warning notice copy.
	 *
	 * @since 4.9.0
	 *
	 * @return string
	 */
	protected function limit_warning_message() {

		$usage = $this->get_usage();

		$message = sprintf(
			wp_kses(
				/* translators: %1$d - emails used; %2$d - free sending limit; %3$s - upgrade URL. */
				__( 'You\'ve used %1$d of your %2$d free SendLayer emails. <a href="%3$s" target="_blank" rel="noopener noreferrer">Upgrade your plan</a> to keep your emails flowing without interruption.', 'wp-mail-smtp' ),
				[
					'a' => [
						'href'   => [],
						'rel'    => [],
						'target' => [],
					],
				]
			),
			isset( $usage['used'] ) ? (int) $usage['used'] : 0,
			isset( $usage['limit'] ) ? (int) $usage['limit'] : 0,
			esc_url( $this->get_free_upgrade_url() )
		);

		return $message;
	}

	/**
	 * Limit-reached notice copy.
	 *
	 * @since 4.9.0
	 *
	 * @return string
	 */
	protected function limit_reached_message() {

		$message = sprintf(
			wp_kses(
				/* translators: %s - upgrade URL. */
				__( 'You\'ve reached your free SendLayer limit. Emails are failing and won\'t be delivered! <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade your plan</a> to start sending again.', 'wp-mail-smtp' ),
				[
					'a' => [
						'href'   => [],
						'rel'    => [],
						'target' => [],
					],
				]
			),
			esc_url( $this->get_free_upgrade_url() )
		);

		return $message;
	}

	/*
	 * --- Consolidated state ---
	 *
	 * All site-level usage-notice state lives in one non-autoloaded option. The
	 * accessors below merge the stored value over defaults on read and persist the
	 * whole array on write.
	 */

	/**
	 * Default state shape, used both as the read-time merge base and the reset target.
	 *
	 * @since 4.9.0
	 *
	 * @return array
	 */
	private function default_state() {

		return [
			'usage'         => $this->default_usage(),
			'plan'          => '',
			'cc'            => 0,
			'limit_reached' => false,
		];
	}

	/**
	 * Default usage sub-array. A null limit marks "no usable data yet" (no quota header
	 * received).
	 *
	 * @since 4.9.0
	 *
	 * @return array
	 */
	private function default_usage() {

		return [
			'limit' => null,
			'used'  => null,
		];
	}

	/**
	 * Read the consolidated state, merged over defaults so callers always get the full
	 * shape (including the usage sub-array). Network-aware.
	 *
	 * @since 4.9.0
	 *
	 * @param string|null $key           Dot-notation key into the state, or null for the full array.
	 * @param mixed       $default_value Value to return when a keyed read misses.
	 *
	 * @return mixed
	 */
	private function get_state( $key = null, $default_value = null ) {

		$stored = $this->is_network_scoped()
			? get_blog_option( get_main_site_id(), self::STATE_OPTION, [] )
			: get_option( self::STATE_OPTION, [] );

		if ( ! is_array( $stored ) ) {
			$stored = [];
		}

		$state          = array_merge( $this->default_state(), $stored );
		$state['usage'] = array_merge( $this->default_usage(), is_array( $state['usage'] ) ? $state['usage'] : [] );

		if ( $key === null ) {
			return $state;
		}

		return Data::get( $state, $key, $default_value );
	}

	/**
	 * Merge the changed keys over the current state and persist. Callers pass only the
	 * keys they are changing. Non-autoloaded, network-aware.
	 *
	 * @since 4.9.0
	 *
	 * @param array $changes Top-level state keys to overwrite.
	 */
	private function save_state( array $changes ) {

		$state = array_merge( $this->get_state(), $changes );

		if ( $this->is_network_scoped() ) {
			update_blog_option( get_main_site_id(), self::STATE_OPTION, $state );

			return;
		}

		update_option( self::STATE_OPTION, $state, false );
	}

	/**
	 * Whether plugin state should be scoped to the network's main site.
	 *
	 * In network-wide mode the SendLayer connection lives on the main site, so every
	 * sub-site must agree on one per-account view of the usage-notice state.
	 *
	 * @since 4.9.0
	 *
	 * @return bool
	 */
	private function is_network_scoped() {

		// Short-circuit on single-site before consulting the network setting, so the
		// common path never touches main-site option reads.
		if ( ! is_multisite() ) {
			return false;
		}

		return WP::use_global_plugin_settings();
	}
}