0, 'templates' => array( 'earned' => array( 'enabled' => 1, 'subject' => __( 'You earned {points} reward points', 'devoynx-rewards' ), 'body' => __( 'Hi {customer_name}, you earned {points} points at {site_name}. View your wallet: {wallet_url}', 'devoynx-rewards' ), ), 'redeemed' => array( 'enabled' => 1, 'subject' => __( 'You used {points} reward points', 'devoynx-rewards' ), 'body' => __( 'Hi {customer_name}, you used {points} points at {site_name}. View your wallet: {wallet_url}', 'devoynx-rewards' ), ), 'referral' => array( 'enabled' => 1, 'subject' => __( 'Your referral earned {points} points', 'devoynx-rewards' ), 'body' => __( 'Hi {customer_name}, your referral earned {points} points at {site_name}. View your wallet: {wallet_url}', 'devoynx-rewards' ), ), ), ); } /** * Validate fixed email categories and bounded plain-text templates. * * @param mixed $input Submitted settings. * @return array|\WP_Error */ public function sanitize( $input ) { $result = $this->defaults(); if ( ! is_array( $input ) || ( isset( $input['templates'] ) && ! is_array( $input['templates'] ) ) ) { return new \WP_Error( 'invalid_email_settings' ); } $result['enabled'] = empty( $input['enabled'] ) ? 0 : 1; foreach ( $result['templates'] as $kind => $default ) { $row = $input['templates'][ $kind ] ?? $default; if ( ! is_array( $row ) || ! is_scalar( $row['subject'] ?? null ) || ! is_scalar( $row['body'] ?? null ) ) { return new \WP_Error( 'invalid_email_template' ); } $subject = sanitize_text_field( (string) $row['subject'] ); $body = sanitize_textarea_field( (string) $row['body'] ); if ( '' === $subject || '' === $body || mb_strlen( $subject ) > 160 || mb_strlen( $body ) > 2000 ) { return new \WP_Error( 'invalid_email_template' ); } $result['templates'][ $kind ] = array( 'enabled' => empty( $row['enabled'] ) ? 0 : 1, 'subject' => $subject, 'body' => $body, ); } return $result; } /** * Read validated configuration. * * @return array */ public function settings() { $result = $this->sanitize( get_option( self::OPTION, array() ) ); return is_wp_error( $result ) ? $this->defaults() : $result; } /** * Queue either earned or referral mail, never both for the same award. * * @param int $user_id Recipient. * @param int $points Awarded points. * @param int $entry_id Immutable ledger entry. * @param array $context Ledger event context. */ public function awarded( $user_id, $points, $entry_id, $context ) { $kind = in_array( $context['event_type'] ?? '', array( 'referral_award', 'referral_friend_award' ), true ) ? 'referral' : 'earned'; $this->queue( $kind, $user_id, $points, $entry_id ); } /** * Queue a redemption receipt, not a reservation or released selection. * * @param int $user_id Recipient. * @param int $points Redeemed points. * @param int $entry_id Immutable ledger entry. * @param array $context Ledger context. */ public function redeemed( $user_id, $points, $entry_id, $context ) { unset( $context ); $this->queue( 'redeemed', $user_id, $points, $entry_id ); } /** * Append the outbox record inside an existing ledger transaction when present. * * @param string $kind Template category. * @param int $user_id Recipient. * @param int $points Event points. * @param int $entry_id Ledger entry. */ private function queue( $kind, $user_id, $points, $entry_id ) { $settings = $this->settings(); $event = 'redeemed' === $kind ? 'points_redeemed' : 'points_awarded'; if ( ! $settings['enabled'] || ! $settings['templates'][ $kind ]['enabled'] || ! $user_id || ! $entry_id || ! $this->email_allowed( $user_id ) || false === apply_filters( 'devoynx_rewards_notification_allowed', true, $event, $user_id, array( 'points' => $points, 'entry_id' => $entry_id, ) ) ) { return; } try { $store = new ProgramStore(); $store->locked( 'email:' . $kind . ':' . absint( $entry_id ), 'basic_email', static function ( $row ) use ( $store, $kind, $user_id, $points, $entry_id ) { return 'new' === $row['status'] ? $store->save( $row, array( 'category' => $kind, 'points' => absint( $points ), 'entry_id' => absint( $entry_id ), ), 'queued', array( 'user_id' => $user_id ) ) : $row; } ); } catch ( \Throwable $error ) { // Notification infrastructure must not turn a committed payment into a failed checkout. wc_get_logger()->error( 'A reward email could not be queued safely.', array( 'source' => 'devoynx-rewards' ) ); } } /** * Respect the existing shared Free/Pro customer communication preference. * * @param int $user_id Recipient. * @return bool */ private function email_allowed( $user_id ) { $preferences = get_user_meta( $user_id, '_devoynx_rewards_notification_preferences', true ); return ! is_array( $preferences ) || ! array_key_exists( 'email', $preferences ) || ! empty( $preferences['email'] ); } /** Schedule only after Action Scheduler initialization; no external service is used. */ public function schedule() { if ( ! $this->settings()['enabled'] && ! ( new ProgramStore() )->pending( 'basic_email', 'queued', 1 ) ) { return; } if ( function_exists( 'as_has_scheduled_action' ) ) { if ( ! as_has_scheduled_action( self::HOOK, array(), 'devoynx-rewards' ) ) { as_schedule_recurring_action( time() + MINUTE_IN_SECONDS, 5 * MINUTE_IN_SECONDS, self::HOOK, array(), 'devoynx-rewards', true ); } } elseif ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::HOOK ); } } /** Process at most ten pending receipts per invocation. */ public function process() { foreach ( ( new ProgramStore() )->pending( 'basic_email', 'queued', 10 ) as $row ) { $this->deliver( $row['event_key'] ); } } /** * Commit an attempt marker before calling mail; concurrent workers cannot resend. * * A crash after marking may lose delivery. Never automatically retry an uncertain * transport attempt: WordPress mail acceptance cannot prove inbox delivery. * * @param string $key Outbox key. * @return bool Whether the transport accepted this attempt. */ public function deliver( $key ) { $store = new ProgramStore(); $existing = $store->find( $key ); if ( ! $existing || 'basic_email' !== $existing['kind'] ) { return false; } $row = $store->locked( $key, 'basic_email', static function ( $locked ) use ( $store ) { if ( 'queued' !== $locked['status'] ) { return null; } return $store->save( $locked, json_decode( $locked['payload'], true ), 'attempted' ); } ); if ( ! $row ) { return false; } $status = 'cancelled'; $accepted = false; $switched = switch_to_user_locale( $row['user_id'] ); try { $data = json_decode( $row['payload'], true ); $settings = $this->settings(); $template = $settings['templates'][ $data['category'] ] ?? null; $user = get_userdata( $row['user_id'] ); if ( $settings['enabled'] && $template && $template['enabled'] && $user && is_email( $user->user_email ) && $this->email_allowed( $user->ID ) ) { $tokens = array( '{points}' => number_format_i18n( $data['points'] ), '{customer_name}' => sanitize_text_field( $user->display_name ), '{site_name}' => sanitize_text_field( wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), '{wallet_url}' => wc_get_account_endpoint_url( 'rewards' ), ); $subject = sanitize_text_field( strtr( $template['subject'], $tokens ) ); $body = strtr( $template['body'], $tokens ); $status = 'failed'; $accepted = (bool) wp_mail( $user->user_email, $subject, $body, array( 'Content-Type: text/plain; charset=UTF-8' ) ); $status = $accepted ? 'accepted' : 'failed'; } } catch ( \Throwable $error ) { $status = 'failed'; } finally { if ( $switched ) { restore_previous_locale(); } } $store->locked( $key, 'basic_email', static function ( $locked ) use ( $store, $status ) { return 'attempted' === $locked['status'] ? $store->save( $locked, json_decode( $locked['payload'], true ), $status ) : $locked; } ); return $accepted; } /** * Add the email configuration tab. * * @param array $tabs Existing tabs. * @return array */ public function tabs( $tabs ) { $tabs['emails'] = __( 'Emails', 'devoynx-rewards' ); return $tabs; } /** Save merchant-only plain-text template configuration. */ public function save() { if ( 'POST' !== sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) || ! current_user_can( 'manage_devoynx_rewards' ) ) { wp_die( esc_html__( 'Permission denied.', 'devoynx-rewards' ), 403 ); } check_admin_referer( 'devoynx_rewards_basic_emails' ); $settings = $this->sanitize( isset( $_POST['emails'] ) ? wp_unslash( $_POST['emails'] ) : array() ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Every field is strictly validated and sanitized before persistence. if ( ! is_wp_error( $settings ) ) { update_option( self::OPTION, $settings, false ); } wp_safe_redirect( add_query_arg( array( 'page' => 'devoynx-rewards', 'tab' => 'emails', 'email_result' => is_wp_error( $settings ) ? 'invalid' : 'saved', ), admin_url( 'admin.php' ) ) ); exit; } /** Save only the current customer's shared email preference. */ public function save_preference() { if ( 'POST' !== sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) || ! get_current_user_id() ) { wp_die( esc_html__( 'Permission denied.', 'devoynx-rewards' ), 403 ); } check_admin_referer( 'devoynx_rewards_email_preference' ); $preferences = get_user_meta( get_current_user_id(), '_devoynx_rewards_notification_preferences', true ); $preferences = is_array( $preferences ) ? $preferences : array(); $preferences['email'] = ! empty( $_POST['email_enabled'] ); update_user_meta( get_current_user_id(), '_devoynx_rewards_notification_preferences', $preferences ); wp_safe_redirect( add_query_arg( 'email_preference_saved', '1', wc_get_account_endpoint_url( 'rewards' ) ) ); exit; } /** * Show the owner the Free email preference independently of add-ons. * * @param int $user_id Wallet owner. */ public function render_preference( $user_id ) { if ( ! $user_id || get_current_user_id() !== (int) $user_id || ! $this->settings()['enabled'] ) { return; } ?>

get_results( $wpdb->prepare( "SELECT status,COUNT(*) total FROM %i WHERE kind = 'basic_email' GROUP BY status", $wpdb->prefix . 'devoynx_rewards_program_records' ), ARRAY_A ); $settings = $this->settings(); $labels = array( 'earned' => __( 'Earned points', 'devoynx-rewards' ), 'redeemed' => __( 'Redeemed points', 'devoynx-rewards' ), 'referral' => __( 'Referral points', 'devoynx-rewards' ), ); $statuses = array( 'queued' => __( 'Queued', 'devoynx-rewards' ), 'attempted' => __( 'Attempt started (outcome unknown)', 'devoynx-rewards' ), 'accepted' => __( 'Accepted by WordPress', 'devoynx-rewards' ), 'failed' => __( 'Failed', 'devoynx-rewards' ), 'cancelled' => __( 'Cancelled', 'devoynx-rewards' ), ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only result notice on an authorized screen. $result = isset( $_GET['email_result'] ) ? sanitize_key( wp_unslash( $_GET['email_result'] ) ) : ''; ?>

$label ) : ?>