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 SendLayer dashboard and follow our custom domain guide.', '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. Upgrade your plan 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! Upgrade your plan 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(); } }