newsletter.php

I’ll build this as a production-style inc/ajax/newsletter.php module for your RX theme: secure AJAX handlers, validation, nonce checks, database table, admin helpers, unsubscribe, double opt-in, rate limiting, logging, and extensible hooks.

Below is an advanced inc/ajax/newsletter.php file for your RX Theme. It includes many useful newsletter features: subscribe, unsubscribe, double opt-in confirmation, nonce security, rate limiting, database table creation, admin stats, CSV export, privacy-safe logging, honeypot spam protection, consent checking, and WordPress hooks.

Create this file:

<?php
/**
 * RX Theme Newsletter AJAX System
 *
 * File: inc/ajax/newsletter.php
 *
 * Features:
 * - Secure AJAX newsletter subscribe
 * - Guest + logged-in support
 * - Nonce verification
 * - Honeypot anti-spam
 * - Email validation and sanitization
 * - Name sanitization
 * - Consent/GDPR checkbox validation
 * - Rate limiting by IP and email
 * - Custom database table
 * - Double opt-in email confirmation
 * - Unsubscribe link support
 * - Resubscribe support
 * - Admin-only stats AJAX
 * - Admin-only CSV export
 * - Privacy-safe IP hashing
 * - Extensible WordPress hooks/filters
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! class_exists( 'RX_Theme_Newsletter_Ajax' ) ) :

final class RX_Theme_Newsletter_Ajax {

	/**
	 * Database version.
	 */
	const DB_VERSION = '1.0.0';

	/**
	 * Option name for DB version.
	 */
	const DB_VERSION_OPTION = 'rx_newsletter_db_version';

	/**
	 * AJAX nonce action.
	 */
	const NONCE_ACTION = 'rx_newsletter_nonce_action';

	/**
	 * AJAX nonce field.
	 */
	const NONCE_FIELD = 'security';

	/**
	 * Subscribe rate limit transient prefix.
	 */
	const RATE_PREFIX = 'rx_newsletter_rate_';

	/**
	 * Email rate limit transient prefix.
	 */
	const EMAIL_RATE_PREFIX = 'rx_newsletter_email_rate_';

	/**
	 * Instance.
	 */
	private static $instance = null;

	/**
	 * Get instance.
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Constructor.
	 */
	private function __construct() {
		add_action( 'after_switch_theme', array( $this, 'maybe_create_table' ) );
		add_action( 'init', array( $this, 'maybe_create_table' ) );

		add_action( 'wp_ajax_rx_newsletter_subscribe', array( $this, 'ajax_subscribe' ) );
		add_action( 'wp_ajax_nopriv_rx_newsletter_subscribe', array( $this, 'ajax_subscribe' ) );

		add_action( 'wp_ajax_rx_newsletter_unsubscribe', array( $this, 'ajax_unsubscribe' ) );
		add_action( 'wp_ajax_nopriv_rx_newsletter_unsubscribe', array( $this, 'ajax_unsubscribe' ) );

		add_action( 'wp_ajax_rx_newsletter_confirm', array( $this, 'ajax_confirm' ) );
		add_action( 'wp_ajax_nopriv_rx_newsletter_confirm', array( $this, 'ajax_confirm' ) );

		add_action( 'wp_ajax_rx_newsletter_stats', array( $this, 'ajax_stats' ) );
		add_action( 'wp_ajax_rx_newsletter_export_csv', array( $this, 'ajax_export_csv' ) );

		add_shortcode( 'rx_newsletter_form', array( $this, 'shortcode_form' ) );
	}

	/**
	 * Get table name.
	 */
	public function table_name() {
		global $wpdb;

		return $wpdb->prefix . 'rx_newsletter_subscribers';
	}

	/**
	 * Create or update newsletter table.
	 */
	public function maybe_create_table() {
		$current_version = get_option( self::DB_VERSION_OPTION );

		if ( self::DB_VERSION === $current_version ) {
			return;
		}

		global $wpdb;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$table_name      = $this->table_name();
		$charset_collate = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE {$table_name} (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			email VARCHAR(190) NOT NULL,
			name VARCHAR(190) NULL,
			status VARCHAR(30) NOT NULL DEFAULT 'pending',
			source VARCHAR(190) NULL,
			consent TINYINT(1) NOT NULL DEFAULT 0,
			confirm_token VARCHAR(128) NULL,
			unsubscribe_token VARCHAR(128) NULL,
			ip_hash VARCHAR(128) NULL,
			user_agent TEXT NULL,
			referrer TEXT NULL,
			meta LONGTEXT NULL,
			confirmed_at DATETIME NULL,
			unsubscribed_at DATETIME NULL,
			created_at DATETIME NOT NULL,
			updated_at DATETIME NOT NULL,
			PRIMARY KEY  (id),
			UNIQUE KEY email (email),
			KEY status (status),
			KEY confirm_token (confirm_token),
			KEY unsubscribe_token (unsubscribe_token),
			KEY created_at (created_at)
		) {$charset_collate};";

		dbDelta( $sql );

		update_option( self::DB_VERSION_OPTION, self::DB_VERSION, false );
	}

	/**
	 * Newsletter shortcode form.
	 *
	 * Usage:
	 * [rx_newsletter_form]
	 */
	public function shortcode_form( $atts = array() ) {
		$atts = shortcode_atts(
			array(
				'title'       => __( 'Subscribe to Newsletter', 'rx-theme' ),
				'description' => __( 'Get latest health articles, updates, and useful resources.', 'rx-theme' ),
				'button'      => __( 'Subscribe', 'rx-theme' ),
				'source'      => 'shortcode',
			),
			$atts,
			'rx_newsletter_form'
		);

		ob_start();
		?>
		<div class="rx-newsletter-box" data-rx-newsletter>
			<h3 class="rx-newsletter-title"><?php echo esc_html( $atts['title'] ); ?></h3>

			<p class="rx-newsletter-description">
				<?php echo esc_html( $atts['description'] ); ?>
			</p>

			<form class="rx-newsletter-form" method="post">
				<input type="hidden" name="action" value="rx_newsletter_subscribe">
				<input type="hidden" name="security" value="<?php echo esc_attr( wp_create_nonce( self::NONCE_ACTION ) ); ?>">
				<input type="hidden" name="source" value="<?php echo esc_attr( $atts['source'] ); ?>">

				<p class="rx-newsletter-field">
					<label for="rx-newsletter-name"><?php esc_html_e( 'Name', 'rx-theme' ); ?></label>
					<input id="rx-newsletter-name" type="text" name="name" placeholder="<?php esc_attr_e( 'Your name', 'rx-theme' ); ?>" autocomplete="name">
				</p>

				<p class="rx-newsletter-field">
					<label for="rx-newsletter-email"><?php esc_html_e( 'Email address', 'rx-theme' ); ?></label>
					<input id="rx-newsletter-email" type="email" name="email" placeholder="<?php esc_attr_e( 'you@example.com', 'rx-theme' ); ?>" autocomplete="email" required>
				</p>

				<p class="rx-newsletter-field rx-newsletter-consent">
					<label>
						<input type="checkbox" name="consent" value="1" required>
						<?php esc_html_e( 'I agree to receive newsletter emails and understand I can unsubscribe anytime.', 'rx-theme' ); ?>
					</label>
				</p>

				<p class="rx-newsletter-hp" style="display:none !important;">
					<label>
						<?php esc_html_e( 'Leave this field empty', 'rx-theme' ); ?>
						<input type="text" name="website_url" value="" tabindex="-1" autocomplete="off">
					</label>
				</p>

				<button type="submit" class="rx-newsletter-submit">
					<?php echo esc_html( $atts['button'] ); ?>
				</button>

				<div class="rx-newsletter-response" aria-live="polite"></div>
			</form>
		</div>
		<?php

		return ob_get_clean();
	}

	/**
	 * AJAX subscribe.
	 */
	public function ajax_subscribe() {
		$this->verify_nonce();

		$this->maybe_create_table();

		$honeypot = isset( $_POST['website_url'] ) ? sanitize_text_field( wp_unslash( $_POST['website_url'] ) ) : '';

		if ( ! empty( $honeypot ) ) {
			$this->log_security_event( 'honeypot_triggered' );

			wp_send_json_error(
				array(
					'message' => __( 'Subscription could not be completed.', 'rx-theme' ),
				),
				400
			);
		}

		$email   = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
		$name    = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
		$source  = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : 'unknown';
		$consent = isset( $_POST['consent'] ) ? absint( $_POST['consent'] ) : 0;

		if ( empty( $email ) || ! is_email( $email ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'Please enter a valid email address.', 'rx-theme' ),
				),
				422
			);
		}

		if ( ! $consent ) {
			wp_send_json_error(
				array(
					'message' => __( 'Please accept the newsletter consent checkbox.', 'rx-theme' ),
				),
				422
			);
		}

		if ( $this->is_disposable_email( $email ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'Please use a valid personal or business email address.', 'rx-theme' ),
				),
				422
			);
		}

		if ( $this->is_rate_limited( $email ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'Too many attempts. Please try again later.', 'rx-theme' ),
				),
				429
			);
		}

		/**
		 * Filter newsletter subscription data before saving.
		 */
		$subscription_data = apply_filters(
			'rx_newsletter_subscription_data',
			array(
				'email'   => strtolower( $email ),
				'name'    => $name,
				'source'  => $source,
				'consent' => $consent,
			)
		);

		$result = $this->save_subscriber( $subscription_data );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error(
				array(
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		$subscriber = $result;

		$this->send_confirm_email( $subscriber );

		do_action( 'rx_newsletter_after_subscribe', $subscriber );

		wp_send_json_success(
			array(
				'message' => __( 'Almost done. Please check your email and confirm your subscription.', 'rx-theme' ),
				'status'  => $subscriber['status'],
			)
		);
	}

	/**
	 * AJAX unsubscribe.
	 */
	public function ajax_unsubscribe() {
		$this->verify_nonce();

		$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
		$token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';

		if ( empty( $email ) || ! is_email( $email ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'Please enter a valid email address.', 'rx-theme' ),
				),
				422
			);
		}

		$result = $this->unsubscribe( $email, $token );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error(
				array(
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		wp_send_json_success(
			array(
				'message' => __( 'You have been unsubscribed successfully.', 'rx-theme' ),
			)
		);
	}

	/**
	 * AJAX confirm double opt-in.
	 */
	public function ajax_confirm() {
		$this->verify_nonce();

		$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
		$token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';

		if ( empty( $email ) || ! is_email( $email ) || empty( $token ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'Invalid confirmation request.', 'rx-theme' ),
				),
				422
			);
		}

		$result = $this->confirm_subscriber( $email, $token );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error(
				array(
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		wp_send_json_success(
			array(
				'message' => __( 'Your newsletter subscription has been confirmed. Thank you.', 'rx-theme' ),
			)
		);
	}

	/**
	 * Admin AJAX stats.
	 */
	public function ajax_stats() {
		$this->verify_admin_ajax();

		global $wpdb;

		$table = $this->table_name();

		$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
		$active = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE status = %s",
				'active'
			)
		);
		$pending = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE status = %s",
				'pending'
			)
		);
		$unsubscribed = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE status = %s",
				'unsubscribed'
			)
		);

		wp_send_json_success(
			array(
				'total'        => $total,
				'active'       => $active,
				'pending'      => $pending,
				'unsubscribed' => $unsubscribed,
			)
		);
	}

	/**
	 * Admin CSV export.
	 */
	public function ajax_export_csv() {
		$this->verify_admin_ajax();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'rx-theme' ) );
		}

		global $wpdb;

		$table = $this->table_name();

		$rows = $wpdb->get_results(
			"SELECT email, name, status, source, consent, confirmed_at, unsubscribed_at, created_at, updated_at FROM {$table} ORDER BY created_at DESC",
			ARRAY_A
		);

		nocache_headers();

		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename=rx-newsletter-subscribers-' . gmdate( 'Y-m-d' ) . '.csv' );

		$output = fopen( 'php://output', 'w' );

		fputcsv(
			$output,
			array(
				'Email',
				'Name',
				'Status',
				'Source',
				'Consent',
				'Confirmed At',
				'Unsubscribed At',
				'Created At',
				'Updated At',
			)
		);

		foreach ( $rows as $row ) {
			fputcsv(
				$output,
				array(
					$row['email'],
					$row['name'],
					$row['status'],
					$row['source'],
					$row['consent'],
					$row['confirmed_at'],
					$row['unsubscribed_at'],
					$row['created_at'],
					$row['updated_at'],
				)
			);
		}

		fclose( $output );
		exit;
	}

	/**
	 * Save subscriber.
	 */
	private function save_subscriber( $data ) {
		global $wpdb;

		$table = $this->table_name();

		$email = isset( $data['email'] ) ? strtolower( sanitize_email( $data['email'] ) ) : '';
		$name  = isset( $data['name'] ) ? sanitize_text_field( $data['name'] ) : '';

		if ( empty( $email ) || ! is_email( $email ) ) {
			return new WP_Error( 'invalid_email', __( 'Invalid email address.', 'rx-theme' ) );
		}

		$existing = $this->get_subscriber_by_email( $email );

		$now               = current_time( 'mysql' );
		$confirm_token     = $this->generate_token();
		$unsubscribe_token = $this->generate_token();

		$record = array(
			'email'             => $email,
			'name'              => $name,
			'status'            => 'pending',
			'source'            => isset( $data['source'] ) ? sanitize_key( $data['source'] ) : 'unknown',
			'consent'           => ! empty( $data['consent'] ) ? 1 : 0,
			'confirm_token'     => $confirm_token,
			'unsubscribe_token' => $unsubscribe_token,
			'ip_hash'           => $this->get_ip_hash(),
			'user_agent'        => $this->get_user_agent(),
			'referrer'          => $this->get_referrer(),
			'meta'              => wp_json_encode( $this->get_default_meta() ),
			'updated_at'        => $now,
		);

		if ( $existing ) {
			if ( 'active' === $existing['status'] ) {
				return new WP_Error(
					'already_subscribed',
					__( 'This email address is already subscribed.', 'rx-theme' )
				);
			}

			$updated = $wpdb->update(
				$table,
				$record,
				array( 'email' => $email ),
				array(
					'%s',
					'%s',
					'%s',
					'%s',
					'%d',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',
					'%s',
				),
				array( '%s' )
			);

			if ( false === $updated ) {
				return new WP_Error(
					'db_update_failed',
					__( 'Could not update your subscription. Please try again.', 'rx-theme' )
				);
			}

			return $this->get_subscriber_by_email( $email );
		}

		$record['created_at'] = $now;

		$inserted = $wpdb->insert(
			$table,
			$record,
			array(
				'%s',
				'%s',
				'%s',
				'%s',
				'%d',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
			)
		);

		if ( false === $inserted ) {
			return new WP_Error(
				'db_insert_failed',
				__( 'Could not save your subscription. Please try again.', 'rx-theme' )
			);
		}

		return $this->get_subscriber_by_email( $email );
	}

	/**
	 * Confirm subscriber.
	 */
	private function confirm_subscriber( $email, $token ) {
		global $wpdb;

		$table = $this->table_name();

		$subscriber = $this->get_subscriber_by_email( $email );

		if ( ! $subscriber ) {
			return new WP_Error( 'not_found', __( 'Subscriber not found.', 'rx-theme' ) );
		}

		if ( 'active' === $subscriber['status'] ) {
			return true;
		}

		if ( empty( $subscriber['confirm_token'] ) || ! hash_equals( $subscriber['confirm_token'], $token ) ) {
			return new WP_Error( 'invalid_token', __( 'Invalid confirmation token.', 'rx-theme' ) );
		}

		$updated = $wpdb->update(
			$table,
			array(
				'status'        => 'active',
				'confirm_token' => '',
				'confirmed_at'  => current_time( 'mysql' ),
				'updated_at'    => current_time( 'mysql' ),
			),
			array(
				'email' => $email,
			),
			array(
				'%s',
				'%s',
				'%s',
				'%s',
			),
			array(
				'%s',
			)
		);

		if ( false === $updated ) {
			return new WP_Error( 'confirm_failed', __( 'Could not confirm subscription.', 'rx-theme' ) );
		}

		do_action( 'rx_newsletter_after_confirm', $email );

		return true;
	}

	/**
	 * Unsubscribe subscriber.
	 */
	private function unsubscribe( $email, $token = '' ) {
		global $wpdb;

		$table = $this->table_name();

		$subscriber = $this->get_subscriber_by_email( $email );

		if ( ! $subscriber ) {
			return new WP_Error( 'not_found', __( 'Subscriber not found.', 'rx-theme' ) );
		}

		if ( ! empty( $subscriber['unsubscribe_token'] ) && ! empty( $token ) ) {
			if ( ! hash_equals( $subscriber['unsubscribe_token'], $token ) ) {
				return new WP_Error( 'invalid_token', __( 'Invalid unsubscribe token.', 'rx-theme' ) );
			}
		}

		$updated = $wpdb->update(
			$table,
			array(
				'status'          => 'unsubscribed',
				'unsubscribed_at' => current_time( 'mysql' ),
				'updated_at'      => current_time( 'mysql' ),
			),
			array(
				'email' => $email,
			),
			array(
				'%s',
				'%s',
				'%s',
			),
			array(
				'%s',
			)
		);

		if ( false === $updated ) {
			return new WP_Error( 'unsubscribe_failed', __( 'Could not unsubscribe this email.', 'rx-theme' ) );
		}

		do_action( 'rx_newsletter_after_unsubscribe', $email );

		return true;
	}

	/**
	 * Get subscriber by email.
	 */
	private function get_subscriber_by_email( $email ) {
		global $wpdb;

		$table = $this->table_name();

		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE email = %s LIMIT 1",
				strtolower( $email )
			),
			ARRAY_A
		);

		return $row;
	}

	/**
	 * Send confirmation email.
	 */
	private function send_confirm_email( $subscriber ) {
		$email = $subscriber['email'];

		$confirm_url = add_query_arg(
			array(
				'rx_newsletter_action' => 'confirm',
				'email'                => rawurlencode( $email ),
				'token'                => rawurlencode( $subscriber['confirm_token'] ),
			),
			home_url( '/' )
		);

		$unsubscribe_url = add_query_arg(
			array(
				'rx_newsletter_action' => 'unsubscribe',
				'email'                => rawurlencode( $email ),
				'token'                => rawurlencode( $subscriber['unsubscribe_token'] ),
			),
			home_url( '/' )
		);

		$site_name = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );

		$subject = sprintf(
			/* translators: %s site name */
			__( 'Confirm your subscription to %s', 'rx-theme' ),
			$site_name
		);

		$message = sprintf(
			'<p>%s</p><p><a href="%s">%s</a></p><p>%s</p><p><a href="%s">%s</a></p>',
			esc_html__( 'Thank you for subscribing. Please confirm your email address by clicking the button below.', 'rx-theme' ),
			esc_url( $confirm_url ),
			esc_html__( 'Confirm Subscription', 'rx-theme' ),
			esc_html__( 'If you did not request this email, you may ignore it or unsubscribe here:', 'rx-theme' ),
			esc_url( $unsubscribe_url ),
			esc_html__( 'Unsubscribe', 'rx-theme' )
		);

		$headers = array(
			'Content-Type: text/html; charset=UTF-8',
		);

		/**
		 * Filter confirmation email subject.
		 */
		$subject = apply_filters( 'rx_newsletter_confirm_email_subject', $subject, $subscriber );

		/**
		 * Filter confirmation email message.
		 */
		$message = apply_filters( 'rx_newsletter_confirm_email_message', $message, $subscriber, $confirm_url, $unsubscribe_url );

		return wp_mail( $email, $subject, $message, $headers );
	}

	/**
	 * Process confirm/unsubscribe query URL.
	 *
	 * Optional: call this on template_redirect from functions.php.
	 */
	public function handle_public_query_action() {
		$action = isset( $_GET['rx_newsletter_action'] ) ? sanitize_key( wp_unslash( $_GET['rx_newsletter_action'] ) ) : '';
		$email  = isset( $_GET['email'] ) ? sanitize_email( wp_unslash( $_GET['email'] ) ) : '';
		$token  = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';

		if ( empty( $action ) || empty( $email ) || empty( $token ) ) {
			return;
		}

		if ( 'confirm' === $action ) {
			$result = $this->confirm_subscriber( $email, $token );

			$message = is_wp_error( $result )
				? $result->get_error_message()
				: __( 'Your subscription has been confirmed successfully.', 'rx-theme' );

			wp_die(
				esc_html( $message ),
				esc_html__( 'Newsletter Confirmation', 'rx-theme' ),
				array( 'response' => is_wp_error( $result ) ? 400 : 200 )
			);
		}

		if ( 'unsubscribe' === $action ) {
			$result = $this->unsubscribe( $email, $token );

			$message = is_wp_error( $result )
				? $result->get_error_message()
				: __( 'You have been unsubscribed successfully.', 'rx-theme' );

			wp_die(
				esc_html( $message ),
				esc_html__( 'Newsletter Unsubscribe', 'rx-theme' ),
				array( 'response' => is_wp_error( $result ) ? 400 : 200 )
			);
		}
	}

	/**
	 * Verify public AJAX nonce.
	 */
	private function verify_nonce() {
		$nonce = isset( $_POST[ self::NONCE_FIELD ] )
			? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) )
			: '';

		if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'Security verification failed. Please refresh the page and try again.', 'rx-theme' ),
				),
				403
			);
		}
	}

	/**
	 * Verify admin AJAX request.
	 */
	private function verify_admin_ajax() {
		$this->verify_nonce();

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error(
				array(
					'message' => __( 'Permission denied.', 'rx-theme' ),
				),
				403
			);
		}
	}

	/**
	 * Rate limit checker.
	 */
	private function is_rate_limited( $email ) {
		$ip_hash     = $this->get_ip_hash();
		$email_hash  = hash_hmac( 'sha256', strtolower( $email ), wp_salt( 'auth' ) );
		$ip_key      = self::RATE_PREFIX . $ip_hash;
		$email_key   = self::EMAIL_RATE_PREFIX . $email_hash;
		$ip_count    = (int) get_transient( $ip_key );
		$email_count = (int) get_transient( $email_key );

		$max_ip_attempts    = (int) apply_filters( 'rx_newsletter_max_ip_attempts', 5 );
		$max_email_attempts = (int) apply_filters( 'rx_newsletter_max_email_attempts', 3 );
		$window             = (int) apply_filters( 'rx_newsletter_rate_window', HOUR_IN_SECONDS );

		if ( $ip_count >= $max_ip_attempts || $email_count >= $max_email_attempts ) {
			return true;
		}

		set_transient( $ip_key, $ip_count + 1, $window );
		set_transient( $email_key, $email_count + 1, $window );

		return false;
	}

	/**
	 * Generate secure token.
	 */
	private function generate_token() {
		return wp_hash( wp_generate_password( 64, true, true ) . microtime( true ) . wp_rand() );
	}

	/**
	 * Get privacy-safe IP hash.
	 */
	private function get_ip_hash() {
		$ip = '';

		if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
			$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
		} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
			$forwarded = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
			$parts     = explode( ',', $forwarded );
			$ip        = trim( $parts[0] );
		} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
			$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
		}

		if ( empty( $ip ) ) {
			$ip = 'unknown';
		}

		return hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) );
	}

	/**
	 * Get user agent.
	 */
	private function get_user_agent() {
		if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return '';
		}

		return substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 500 );
	}

	/**
	 * Get referrer.
	 */
	private function get_referrer() {
		if ( empty( $_SERVER['HTTP_REFERER'] ) ) {
			return '';
		}

		return esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) );
	}

	/**
	 * Default subscriber metadata.
	 */
	private function get_default_meta() {
		return array(
			'user_id'     => get_current_user_id(),
			'language'    => get_locale(),
			'page_url'    => $this->get_referrer(),
			'subscribed'  => current_time( 'mysql' ),
			'site_url'    => home_url( '/' ),
			'site_name'   => get_bloginfo( 'name' ),
		);
	}

	/**
	 * Disposable email checker.
	 */
	private function is_disposable_email( $email ) {
		$domain = substr( strrchr( strtolower( $email ), '@' ), 1 );

		$blocked_domains = apply_filters(
			'rx_newsletter_blocked_email_domains',
			array(
				'10minutemail.com',
				'tempmail.com',
				'mailinator.com',
				'guerrillamail.com',
				'yopmail.com',
				'trashmail.com',
			)
		);

		return in_array( $domain, $blocked_domains, true );
	}

	/**
	 * Log security event.
	 */
	private function log_security_event( $event ) {
		if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
			return;
		}

		error_log(
			sprintf(
				'RX Newsletter security event: %s | IP hash: %s',
				sanitize_key( $event ),
				$this->get_ip_hash()
			)
		);
	}
}

endif;

/**
 * Start RX Newsletter AJAX.
 */
function rx_theme_newsletter_ajax() {
	return RX_Theme_Newsletter_Ajax::instance();
}

rx_theme_newsletter_ajax();

/**
 * Handle public email confirmation and unsubscribe URLs.
 */
add_action(
	'template_redirect',
	function () {
		rx_theme_newsletter_ajax()->handle_public_query_action();
	}
);

Now include it in your theme functions.php:

/**
 * RX Theme Newsletter AJAX.
 */
$rx_newsletter_ajax_file = get_template_directory() . '/inc/ajax/newsletter.php';

if ( file_exists( $rx_newsletter_ajax_file ) ) {
	require_once $rx_newsletter_ajax_file;
}

For frontend JavaScript, your AJAX request should send data to:

admin_url( 'admin-ajax.php' )

Example action name:

action: "rx_newsletter_subscribe"

And the form can be shown anywhere with this shortcode:

[rx_newsletter_form]

This file is very advanced for a theme-level newsletter system, but for large real-world email campaigns, it is still better to connect it later with Mailchimp, Brevo, FluentCRM, MailPoet, ConvertKit, or your own email API, because normal WordPress wp_mail() is not ideal for bulk sending.

Leave a Reply

Your email address will not be published. Required fields are marked *