providers = $providers; add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_init', array( $this, 'handle_connect_return' ) ); add_action( 'admin_init', array( $this, 'handle_post' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } /** This plugin's page in wp-admin - also where 404 Emails sends the browser back after connecting. */ public static function page_url() { return admin_url( 'admin.php?page=' . self::MENU_SLUG ); } public function register_menu() { add_menu_page( __( '404 Emails', '404-emails' ), __( '404 Emails', '404-emails' ), self::CAPABILITY, self::MENU_SLUG, array( Forms_Page::class, 'render' ), 'dashicons-email-alt', 58 ); add_submenu_page( self::MENU_SLUG, __( 'Forms', '404-emails' ), __( 'Forms', '404-emails' ), self::CAPABILITY, self::MENU_SLUG, array( Forms_Page::class, 'render' ) ); add_submenu_page( self::MENU_SLUG, __( 'Other forms', '404-emails' ), __( 'Other forms', '404-emails' ), self::CAPABILITY, Other_Forms_Page::SLUG, array( Other_Forms_Page::class, 'render' ) ); add_submenu_page( self::MENU_SLUG, __( 'Settings', '404-emails' ), __( 'Settings', '404-emails' ), self::CAPABILITY, self::SETTINGS_SLUG, array( $this, 'render' ) ); } public function enqueue_assets( $hook_suffix ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- which screen this is. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( ! in_array( $page, array( self::MENU_SLUG, self::SETTINGS_SLUG, Other_Forms_Page::SLUG ), true ) ) { return; } wp_enqueue_style( 'emails404-admin', EMAILS404_PLUGIN_URL . 'assets/admin.css', array(), EMAILS404_VERSION ); if ( self::MENU_SLUG === $page ) { wp_enqueue_script( 'emails404-forms-editor', EMAILS404_PLUGIN_URL . 'assets/forms-editor.js', array(), EMAILS404_VERSION, true ); } } /** * 404 Emails sends the browser back here with `state` and either a * one-time `code` or `error`. Finish connecting (Connect::complete checks * the state belongs to this admin), then reload the page without them. */ public function handle_connect_return() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the PKCE state (checked in Connect::complete) is this redirect's CSRF protection. if ( ! isset( $_GET['page'], $_GET['state'] ) || self::MENU_SLUG !== $_GET['page'] ) { return; } if ( ! isset( $_GET['code'] ) && ! isset( $_GET['error'] ) ) { return; } if ( ! current_user_can( self::CAPABILITY ) ) { return; } $state = sanitize_text_field( wp_unslash( $_GET['state'] ) ); $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : null; // phpcs:enable $result = Connect::complete( $state, $code ); delete_transient( self::FORMS_TRANSIENT ); wp_safe_redirect( add_query_arg( 'emails404_notice', $result, self::page_url() ) ); exit; } /** * Handles both the settings-save form and the Test-Connection button, * on admin_init so redirects can happen before any output starts. * Every branch re-checks capability and nonce independently - never * trusts that reaching this page at all implies authorization (Part U: * "Do not trust request parameters merely because they come from * wp-admin"). */ public function handle_post() { if ( ! isset( $_POST['emails404_action'] ) ) { return; } if ( ! is_admin() || ! current_user_can( self::CAPABILITY ) ) { return; } $action = sanitize_key( wp_unslash( $_POST['emails404_action'] ) ); if ( self::SAVE_ACTION === $action ) { $this->handle_save(); } elseif ( self::TEST_ACTION === $action ) { $this->handle_test_connection(); } elseif ( self::CONNECT_ACTION === $action ) { check_admin_referer( self::CONNECT_ACTION, 'emails404_nonce' ); // An outside address on purpose: 404 Emails' own sign-in and Allow page. wp_redirect( Connect::start_url( self::page_url() ) ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } elseif ( self::DISCONNECT_ACTION === $action ) { check_admin_referer( self::DISCONNECT_ACTION, 'emails404_nonce' ); Connect::disconnect(); delete_transient( self::FORMS_TRANSIENT ); wp_safe_redirect( add_query_arg( 'emails404_notice', 'disconnected', self::page_url() ) ); exit; } } private function handle_save() { check_admin_referer( self::SAVE_ACTION, 'emails404_nonce' ); $platform_url_raw = isset( $_POST['emails404_platform_url'] ) ? wp_unslash( $_POST['emails404_platform_url'] ) : ''; $sanitized_url = Settings::sanitize_platform_url( $platform_url_raw ); if ( '' !== trim( (string) $platform_url_raw ) && '' === $sanitized_url ) { $this->save_error = __( 'That platform URL was not accepted - it must be a valid HTTPS URL (a plain HTTP URL is only allowed for localhost/.test/.local development addresses).', '404-emails' ); return; } Settings::update_platform_url_raw( $sanitized_url ); if ( ! empty( $_POST['emails404_api_key'] ) ) { // A fresh key was pasted in - store it. An empty field is // deliberately treated as "leave the existing key alone," not // "clear it," since the field is never pre-filled with the // real secret (see Admin_Page::render_connection_section()) - // an admin re-saving the form without touching this field must // never accidentally wipe a working key. Settings::set_api_key( wp_unslash( $_POST['emails404_api_key'] ) ); } if ( ! empty( $_POST['emails404_has_site_display'] ) ) { $site = array(); foreach ( array( 'enabled', 'form', 'mode', 'trigger', 'delay', 'scroll', 'frequency', 'label', 'position', 'launcher', 'hide_paths', 'hide_shop' ) as $field ) { $key = 'emails404_site_' . $field; if ( isset( $_POST[ $key ] ) ) { $site[ $field ] = 'hide_paths' === $field ? sanitize_textarea_field( wp_unslash( $_POST[ $key ] ) ) : sanitize_text_field( wp_unslash( $_POST[ $key ] ) ); } } Site_Display::save( $site ); } // The "Also add people from" settings are only on the page once connected - // never switch them off just because the form didn't include them. if ( ! empty( $_POST['emails404_has_sources'] ) ) { if ( isset( $_POST['emails404_selected_form_public_id'] ) ) { $form_id = sanitize_text_field( wp_unslash( $_POST['emails404_selected_form_public_id'] ) ); $form_name = isset( $_POST['emails404_selected_form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['emails404_selected_form_name'] ) ) : ''; Settings::set_selected_form( $form_id, $form_name ); } Settings::set_event_tickets_enabled( ! empty( $_POST['emails404_et_enabled'] ) ); Settings::set_consent_checkbox_visible( ! empty( $_POST['emails404_et_consent_visible'] ) ); if ( isset( $_POST['emails404_et_consent_label'] ) ) { Settings::set_consent_label( wp_unslash( $_POST['emails404_et_consent_label'] ) ); } if ( isset( $_POST['emails404_et_consent_field_slug'] ) ) { Settings::set_consent_field_slug( wp_unslash( $_POST['emails404_et_consent_field_slug'] ) ); } Settings::set_woocommerce_enabled( ! empty( $_POST['emails404_woo_enabled'] ) ); if ( isset( $_POST['emails404_woo_consent_label'] ) ) { Settings::set_woo_consent_label( wp_unslash( $_POST['emails404_woo_consent_label'] ) ); } if ( isset( $_POST['emails404_woo_selected_form_public_id'] ) ) { $woo_form_id = sanitize_text_field( wp_unslash( $_POST['emails404_woo_selected_form_public_id'] ) ); $woo_form_name = isset( $_POST['emails404_woo_selected_form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['emails404_woo_selected_form_name'] ) ) : ''; Settings::set_woo_selected_form( $woo_form_id, $woo_form_name ); } } delete_transient( self::FORMS_TRANSIENT ); $this->save_message = __( 'Settings saved.', '404-emails' ); } private function handle_test_connection() { check_admin_referer( self::TEST_ACTION, 'emails404_nonce' ); $result = Api_Client::from_settings()->list_forms(); if ( $result->success ) { Settings::set_connection_result( Settings::CONNECTION_CONNECTED ); $meta = Api_Client::from_settings()->get_meta(); if ( $meta->success && ! empty( $meta->data['meta']['workspaceName'] ) ) { Settings::set_workspace_name( (string) $meta->data['meta']['workspaceName'] ); } delete_transient( self::FORMS_TRANSIENT ); $this->save_message = __( 'Connection successful.', '404-emails' ); return; } $status = $this->map_error_to_connection_status( $result->error_category ); Settings::set_connection_result( $status, $result->error_category ); $this->save_error = $this->describe_connection_failure( $status ); } private function map_error_to_connection_status( $error_category ) { switch ( $error_category ) { case 'unauthorized': return Settings::CONNECTION_INVALID_KEY; case 'forbidden_scope': return Settings::CONNECTION_MISSING_SCOPE; case 'timeout': case 'unreachable': case 'not_configured': return Settings::CONNECTION_UNREACHABLE; default: return Settings::CONNECTION_UNEXPECTED; } } /** Beginner-friendly, never a stack trace or raw response body - Part D. */ private function describe_connection_failure( $status ) { switch ( $status ) { case Settings::CONNECTION_INVALID_KEY: return __( 'Invalid API key. Double-check the key you pasted, or create a new one in your 404 Emails workspace.', '404-emails' ); case Settings::CONNECTION_UNREACHABLE: return __( 'Could not reach 404 Emails. Check the Platform URL and your site\'s outbound internet access, then try again.', '404-emails' ); case Settings::CONNECTION_MISSING_SCOPE: return __( 'This API key is missing a required permission. Create a new key with the "View and submit signup forms" and "Create and update contacts" permissions.', '404-emails' ); default: return __( 'Unexpected response from 404 Emails. Please try again shortly.', '404-emails' ); } } public function render() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', '404-emails' ) ); } $et_provider = $this->find_event_tickets_provider(); $connected = Settings::is_connected(); ?>

save_message ) : ?>

save_message ); ?>

save_error ) : ?>

save_error ); ?>

render_connected_card(); } else { self::render_welcome_card(); } ?>
render_site_display_section(); ?>

render_forms_section(); ?> render_event_tickets_section( $et_provider ); ?> render_woocommerce_section( $this->find_woocommerce_provider() ); ?>
>

render_connection_section(); ?>
render_status_section(); ?>

' . esc_html( $workspace ) . '' ); } else { esc_html_e( 'Connected to 404 Emails', '404-emails' ); } ?>

array( 'success', __( 'Connected. This site can now collect subscribers for 404 Emails.', '404-emails' ) ), Connect::RESULT_CANCELLED => array( 'warning', __( 'Connecting was cancelled. Nothing was changed.', '404-emails' ) ), Connect::RESULT_EXPIRED => array( 'error', __( 'That connection attempt expired or didn\'t start on this site. Please click Connect again.', '404-emails' ) ), Connect::RESULT_FAILED => array( 'error', __( 'Connecting didn\'t finish - 404 Emails couldn\'t be reached or the link was already used. Please click Connect again.', '404-emails' ) ), 'disconnected' => array( 'success', __( 'Disconnected. This site no longer sends sign-ups to 404 Emails.', '404-emails' ) ), ); if ( ! isset( $messages[ $notice ] ) ) { return; } list( $type, $message ) = $messages[ $notice ]; printf( '

%2$s

', esc_attr( $type ), esc_html( $message ) ); } /** * 404 Emails only sends a connection back to an HTTPS address (or a * local development one), so the button is offered only then. */ public static function can_use_connect_button() { $url = self::page_url(); $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); $host = (string) wp_parse_url( $url, PHP_URL_HOST ); return 'https' === $scheme || Api_Client::is_local_development_host( $host ); } private function render_connection_section() { $platform_url = Settings::get_platform_url(); $has_key = Settings::has_api_key(); $key_prefix = Settings::get_api_key_prefix(); $status = Settings::get_connection_status(); ?>

status_label( $status ) ); ?>

is_available(); $plus_active = Event_Tickets_Provider::is_plus_available(); ?>

is_available(); $forms = self::get_forms(); ?>

created_at, get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); if ( $row->error_code ) { /* translators: 1: date/time, 2: safe error code */ return sprintf( __( '%1$s (%2$s)', '404-emails' ), $time, $row->error_code ); } return $time; } private function status_label( $status ) { switch ( $status ) { case Settings::CONNECTION_CONNECTED: return __( '✓ Connected', '404-emails' ); case Settings::CONNECTION_INVALID_KEY: return __( '✗ Invalid API key', '404-emails' ); case Settings::CONNECTION_UNREACHABLE: return __( '✗ Platform unreachable', '404-emails' ); case Settings::CONNECTION_MISSING_SCOPE: return __( '✗ Missing permission on this API key', '404-emails' ); case Settings::CONNECTION_UNEXPECTED: return __( '✗ Unexpected response', '404-emails' ); default: return __( 'Not tested yet', '404-emails' ); } } /** * The workspace's published forms (id = public id), cached for a minute. * Also used by the editor block and the every-page setting. * * @return array */ public static function get_forms() { $cached = get_transient( self::FORMS_TRANSIENT ); if ( is_array( $cached ) ) { return $cached; } if ( ! Settings::has_api_key() || '' === Settings::get_platform_url() ) { return array(); } $result = Api_Client::from_settings()->list_forms(); if ( ! $result->success || empty( $result->data['items'] ) ) { return array(); } $forms = array(); foreach ( $result->data['items'] as $item ) { if ( ! empty( $item['id'] ) && isset( $item['name'] ) ) { $forms[] = array( 'id' => (string) $item['id'], 'name' => (string) $item['name'], 'heading' => isset( $item['heading'] ) ? (string) $item['heading'] : '', ); } } set_transient( self::FORMS_TRANSIENT, $forms, MINUTE_IN_SECONDS ); return $forms; } private function find_event_tickets_provider() { foreach ( $this->providers as $provider ) { if ( $provider instanceof Event_Tickets_Provider ) { return $provider; } } return null; } private function find_woocommerce_provider() { foreach ( $this->providers as $provider ) { if ( $provider instanceof WooCommerce_Provider ) { return $provider; } } return null; } }