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'] ) ) : '';
?>