hook = (string) add_options_page( __( 'SMTP', 'adminkeep' ), __( 'SMTP', 'adminkeep' ), self::CAPABILITY, self::PAGE, array( $this, 'render' ) ); } /** * The few lines that copy a preset into the three fields it describes. * * @param string $hook_suffix Current admin page. * @return void */ public function enqueue_preset_script( $hook_suffix ) { if ( '' === $this->hook || $hook_suffix !== $this->hook ) { return; } wp_register_script( 'adminkeep-smtp', ADMINKEEP_URL . 'assets/js/smtp.js', array(), \Adminkeep\Assets::version( 'assets/js/smtp.js' ), true ); wp_enqueue_script( 'adminkeep-smtp' ); wp_localize_script( 'adminkeep-smtp', 'adminkeepSmtp', array( 'url' => admin_url( 'admin-ajax.php' ), 'action' => self::TEST_ACTION, 'sending' => __( 'Sending…', 'adminkeep' ), 'failed' => __( 'The test could not be run. Reload the page and try again.', 'adminkeep' ), ) ); wp_add_inline_script( 'adminkeep-smtp', 'document.addEventListener("DOMContentLoaded",function(){var s=document.getElementById("adminkeep-smtp-preset");if(!s){return;}s.addEventListener("change",function(){var o=s.options[s.selectedIndex],n=document.getElementById("adminkeep-smtp-preset-note");if(n){n.textContent=o.getAttribute("data-note")||"";}if(!o.getAttribute("data-host")){return;}[["host","data-host"],["port","data-port"],["encryption","data-encryption"]].forEach(function(p){var f=document.getElementById("adminkeep-smtp-"+p[0]);if(f&&!f.disabled){f.value=o.getAttribute(p[1]);}});});});' ); } /** * Two columns: the settings, and the test email beside them. Below 960px the test drops under * the settings, which is also where it sits when the stylesheet is missing. * * @param string $hook_suffix Current admin page. * @return void */ public function enqueue_layout_style( $hook_suffix ) { if ( '' === $this->hook || $hook_suffix !== $this->hook ) { return; } wp_register_style( 'adminkeep-smtp', false, array(), ADMINKEEP_VERSION ); wp_enqueue_style( 'adminkeep-smtp' ); wp_add_inline_style( 'adminkeep-smtp', '.adminkeep-smtp-columns{display:flex;align-items:flex-start;gap:24px}' . '.adminkeep-smtp-main{flex:1 1 auto;min-width:0}' . '.adminkeep-smtp-test{flex:0 0 340px;box-sizing:border-box;margin-top:16px;padding:4px 20px 20px;background:#fff;border:1px solid #c3c4c7}' . '.adminkeep-smtp-test p{overflow-wrap:anywhere}' . '.adminkeep-smtp-test input[type=email]{width:100%;margin-bottom:8px}' . '#adminkeep-smtp-test-result .notice{margin:16px 0 8px}' . '.adminkeep-smtp-test pre{white-space:pre-wrap;max-height:24em;overflow:auto;background:#f6f7f7;border:1px solid #c3c4c7;padding:12px}' . '.adminkeep-smtp-secret{display:flex;gap:8px;align-items:center;max-width:36em}' . '.adminkeep-smtp-secret input{flex:0 1 25em;min-width:0;margin:0}' . '.adminkeep-smtp-secret input:disabled::placeholder{letter-spacing:2px;color:#50575e}' . '.adminkeep-smtp-secret .button{flex:none;white-space:nowrap}' . '.adminkeep-smtp-saved{display:inline-block;margin:8px 0 0;padding:4px 10px;border-left:4px solid #00a32a;background:#edfaef;font-weight:600;color:#1d2327}' . '@media (max-width:960px){.adminkeep-smtp-columns{display:block}.adminkeep-smtp-test{margin-top:24px}}' ); } /** * Save the settings form. * * @return void */ public function handle_save() { if ( empty( $_POST[ self::SAVE_FIELD ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked below, once this is known to be our form. return; } if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to change email settings on this site.', 'adminkeep' ), '', 403 ); } check_admin_referer( self::NONCE ); // Each field is sanitized for its own type inside SmtpConfig::sanitize(); the password is // deliberately not text-sanitized, because it is sealed and never printed. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $input = isset( $_POST[ self::FIELD ] ) && is_array( $_POST[ self::FIELD ] ) ? wp_unslash( $_POST[ self::FIELD ] ) : array(); update_option( SmtpConfig::OPTION, SmtpConfig::sanitize( $input, SmtpConfig::stored(), SmtpConfig::defined_constants(), SecretBox::from_salts() ), false ); wp_safe_redirect( add_query_arg( self::SAVED_ARG, '1', self::url() ) ); exit; } /** * The configuration this screen shows. Its own method so a test can hand the screen a * configuration with constants in it; PHP would never let a test undefine a real one. * * @return SmtpConfig */ protected function config() { return SmtpConfig::from_environment(); } /** * Copy another plugin's SMTP connection, when the box offering it would be showing, then * redirect with what became of the password. A stale or forged post while the box would be * hidden copies nothing and just comes back to the screen. * * @return void */ public function handle_import() { if ( empty( $_POST[ self::IMPORT_FIELD ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked below, once this is known to be our form. return; } if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to change email settings on this site.', 'adminkeep' ), '', 403 ); } check_admin_referer( self::IMPORT_NONCE ); $offer = $this->import_offer(); $result = null === $offer ? null : $this->importer()->run( $offer['source'] ); if ( ! is_array( $result ) ) { wp_safe_redirect( self::url() ); exit; } wp_safe_redirect( add_query_arg( self::IMPORTED_ARG, $result['password'], self::url() ) ); exit; } /** * The importer. Its own method so a test can hand the screen one with constants in it. * * @return SmtpImporter */ protected function importer() { return new SmtpImporter(); } /** * The source the copy box offers and what copying it would do, or null when there should be * no box. * * The box shows only when the source holds an SMTP connection with a host, and either that * plugin is active or Adminkeep's own SMTP is not set up yet. So once the owner has copied, * tested and deactivated it, the box goes away by itself, and settings left behind by a * plugin removed years ago do not nag a site that was set up by hand. * * @return array{source: \Adminkeep\Service\SmtpImport\Source, preview: array}|null */ private function import_offer() { // The cheap test first: reading the source opens its password. if ( self::IMPORT_SOURCE !== $this->other_mailer() && $this->config()->is_active() ) { return null; } $sources = SmtpImporter::sources(); $source = $sources[ self::IMPORT_SOURCE ]; $preview = $this->importer()->preview( $source ); return is_wp_error( $preview ) ? null : array( 'source' => $source, 'preview' => $preview, ); } /** * Another active plugin that also configures the mailer, if any. Its own method so a test * can name one without activating a real plugin. * * @return string Its folder name, or empty. */ protected function other_mailer() { foreach ( (array) get_option( 'active_plugins', array() ) as $plugin ) { $folder = strtok( (string) $plugin, '/' ); if ( in_array( $folder, self::OTHER_MAILERS, true ) ) { return $folder; } } return ''; } /** * Render the screen. * * @return void */ public function render() { if ( ! current_user_can( self::CAPABILITY ) ) { return; } $config = $this->config(); $error = Smtp::last_error(); echo '
'; echo '

' . Brand::heading_mark( 'smtp' ) . esc_html__( 'SMTP', 'adminkeep' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The mark is escaped where it is built. if ( isset( $_GET[ self::SAVED_ARG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only flag choosing a static notice. printf( '

%s

', esc_html__( 'Settings saved.', 'adminkeep' ) ); } $other = $this->other_mailer(); $this->render_import_result( $other ); if ( array() !== $error ) { printf( '

%1$s %2$s

%3$s

', esc_html__( 'The last email failed to send.', 'adminkeep' ), esc_html( wp_date( 'j M Y, H:i', $error['time'] ) ), esc_html( $error['message'] ) ); } if ( '' !== $other ) { printf( '

%s

', /* translators: %s: folder name of another plugin. */ esc_html( sprintf( __( 'The plugin "%s" is active and also configures email. Whichever runs last decides how mail is sent; use one or the other.', 'adminkeep' ), $other ) ) ); } if ( ! $config->is_active() ) { printf( '

%s

', esc_html__( 'No server is set yet, so WordPress is still sending email the way it always has.', 'adminkeep' ) ); } $offer = $this->import_offer(); if ( null !== $offer ) { $this->render_import_box( $offer['source']->label(), $offer['preview'] ); } echo '
'; echo '
'; wp_nonce_field( self::NONCE ); printf( '', esc_attr( self::SAVE_FIELD ) ); echo ''; $this->preset_row(); $this->text_row( $config, 'host', __( 'Server (host)', 'adminkeep' ), 'text' ); $this->text_row( $config, 'port', __( 'Port', 'adminkeep' ), 'number' ); $this->encryption_row( $config ); $this->checkbox_row( $config, 'auth', __( 'Authentication', 'adminkeep' ), __( 'The server needs a username and password', 'adminkeep' ) ); $this->text_row( $config, 'username', __( 'Username', 'adminkeep' ), 'text' ); $this->password_row( $config ); $this->text_row( $config, 'from_email', __( 'From address', 'adminkeep' ), 'email' ); $this->checkbox_row( $config, 'force_from', '', __( 'Use this address for every email, even when a plugin sets its own', 'adminkeep' ) ); $this->text_row( $config, 'from_name', __( 'From name', 'adminkeep' ), 'text' ); $this->checkbox_row( $config, 'force_name', '', __( 'Use this name for every email, even when a plugin sets its own', 'adminkeep' ) ); echo ''; submit_button( __( 'Save settings', 'adminkeep' ) ); echo '
'; echo '
'; $this->render_test_panel( $config ); echo '
'; } /** * The box offering to copy another plugin's SMTP connection. It says what is copied in words, * never the values: the host, the username and the password stay out of the markup. A form of * its own, so pressing it never posts the settings form's fields. * * @param string $label The source plugin's name. * @param array $preview What copying would do, from SmtpImporter::preview(). * @return void */ private function render_import_box( $label, array $preview ) { $text = array( __( 'Copy its SMTP connection settings here: server, port, encryption, username, password, and the From address and name. Your current Adminkeep SMTP settings are replaced. Values held in wp-config.php are kept.', 'adminkeep' ), ); if ( SmtpImporter::PASSWORD_UNREADABLE === $preview['password'] ) { $text[] = __( 'Its password could not be read, so enter the password after copying.', 'adminkeep' ); } echo '
'; printf( '

%1$s %2$s

', /* translators: %s: name of another SMTP plugin, such as WP Mail SMTP. */ esc_html( sprintf( __( '%s has an SMTP server set up.', 'adminkeep' ), $label ) ), esc_html( implode( ' ', $text ) ) ); echo '
'; wp_nonce_field( self::IMPORT_NONCE ); printf( '', esc_attr( self::IMPORT_FIELD ) ); /* translators: %s: name of another SMTP plugin, such as WP Mail SMTP. */ submit_button( sprintf( __( 'Copy from %s', 'adminkeep' ), $label ), 'secondary', 'adminkeep_smtp_import_submit', false ); echo '
'; } /** * What the last copy did, read from the redirect's flag. * * @param string $other Folder of another active mailer plugin, or ''. * @return void */ private function render_import_result( $other ) { $flag = isset( $_GET[ self::IMPORTED_ARG ] ) ? sanitize_key( wp_unslash( (string) $_GET[ self::IMPORTED_ARG ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only flag choosing a static notice. $outcomes = array( SmtpImporter::PASSWORD_COPIED, SmtpImporter::PASSWORD_KEPT, SmtpImporter::PASSWORD_UNREADABLE, SmtpImporter::PASSWORD_LOCKED ); if ( ! in_array( $flag, $outcomes, true ) ) { return; } $sources = SmtpImporter::sources(); $label = $sources[ self::IMPORT_SOURCE ]->label(); /* translators: %s: name of another SMTP plugin, such as WP Mail SMTP. */ $parts = array( sprintf( __( 'SMTP settings copied from %s.', 'adminkeep' ), $label ) ); if ( SmtpImporter::PASSWORD_UNREADABLE === $flag ) { $parts[] = __( 'Its password could not be read, so enter the password below and save.', 'adminkeep' ); } if ( self::IMPORT_SOURCE === $other ) { /* translators: %s: name of another SMTP plugin, such as WP Mail SMTP. */ $parts[] = sprintf( __( 'Send a test email from this screen, then deactivate %s so the two plugins do not both configure the mailer.', 'adminkeep' ), $label ); } else { $parts[] = __( 'Send a test email from this screen to check them.', 'adminkeep' ); } $class = SmtpImporter::PASSWORD_UNREADABLE === $flag ? 'notice-warning' : 'notice-success'; echo '

' . esc_html( implode( ' ', $parts ) ) . '

'; } /** * Outcome of a test email sent in this request. * * @return array|null */ public function test_result() { return $this->test_result; } /** * Send a test email and keep what happened, including the SMTP conversation. * * This is the form posted the ordinary way, for when the screen's script is not running. No * redirect: the result is shown by the same request, so nothing about the conversation is * ever stored. * * @return void */ public function handle_test() { // admin-ajax.php fires admin_init too, and the screen's script posts this same form // there. That request belongs to ajax_test(); answering it here as well sends two emails. if ( wp_doing_ajax() ) { return; } if ( empty( $_POST[ self::TEST_FIELD ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked below, once this is known to be our form. return; } if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to send a test email on this site.', 'adminkeep' ), '', 403 ); } check_admin_referer( self::TEST_NONCE ); $this->run_test(); } /** * The same test, asked for by the screen's script: same gates, and the answer is the markup * render_test_result() would have printed, so escaping stays on this side. * * @return void */ public function ajax_test() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_send_json_error( null, 403 ); } check_ajax_referer( self::TEST_NONCE ); $this->run_test(); ob_start(); $this->render_test_result(); wp_send_json_success( array( 'html' => (string) ob_get_clean() ) ); } /** * Send the test email to the posted address. Callers have already checked who is asking. * * @return void */ private function run_test() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Both callers verify the nonce before calling. // is_string() first: a recipient posted as an array would reach sanitize_email() and raise // a TypeError. Anything that is not a string is simply not an address. $to = isset( $_POST[ self::TEST_TO_FIELD ] ) && is_string( $_POST[ self::TEST_TO_FIELD ] ) ? sanitize_email( wp_unslash( $_POST[ self::TEST_TO_FIELD ] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing $this->test_result = \Adminkeep\Service\SmtpTester::send( $to, $this->config() ); } /** * The test-email form and, after a test, what happened. * * @param SmtpConfig $config Resolved configuration. * @return void */ protected function render_test_panel( SmtpConfig $config ) { echo '
'; echo '

' . esc_html__( 'Send a test email', 'adminkeep' ) . '

'; if ( ! $config->is_active() ) { printf( '

%s

', esc_html__( 'No server is set, so this test goes out the way WordPress sends email by default.', 'adminkeep' ) ); } elseif ( $config->is_locked( 'host' ) ) { // The host is a wp-config.php constant: never echoed, same as everywhere else on this screen. printf( '

%s

', esc_html__( 'Sends through the server set in wp-config.php, using the saved settings. Save first if you changed anything.', 'adminkeep' ) ); } else { printf( '

%s

', /* translators: %s: SMTP host name. */ esc_html( sprintf( __( 'Sends through %s, using the saved settings. Save first if you changed anything.', 'adminkeep' ), $config->get( 'host' ) ) ) ); } echo '
'; wp_nonce_field( self::TEST_NONCE ); printf( '', esc_attr( self::TEST_FIELD ) ); printf( ' ', esc_html__( 'Send to', 'adminkeep' ), esc_attr( self::TEST_TO_FIELD ), esc_attr( wp_get_current_user()->user_email ) ); submit_button( __( 'Send test email', 'adminkeep' ), 'secondary', 'submit', false ); echo '
'; // Under the form, so an answer arriving from the script does not push the button away. echo '
'; $this->render_test_result(); echo '
'; echo '
'; } /** * What the last test in this request did, or nothing when there was none. Its own method * because the script asks for exactly this much and drops it into the panel. * * @return void */ private function render_test_result() { if ( null !== $this->test_result ) { if ( $this->test_result['sent'] ) { printf( '

%s

', /* translators: %s: email address. */ esc_html( sprintf( __( 'The server accepted a test email for %s. Check that inbox, and its spam folder.', 'adminkeep' ), $this->test_result['to'] ) ) ); } else { printf( '

%1$s %2$s

', esc_html__( 'The test email was not sent.', 'adminkeep' ), esc_html( $this->test_result['error'] ) ); } if ( '' !== $this->test_result['log'] ) { printf( '%2$s
%3$s
', $this->test_result['sent'] ? '' : ' open', esc_html__( 'What the server said', 'adminkeep' ), esc_html( $this->test_result['log'] ) ); } } } /** * The line shown instead of help text when wp-config.php holds a field. * * @param SmtpConfig $config Resolved configuration. * @param string $key Field key. * @return void */ private function locked_note( SmtpConfig $config, $key ) { if ( ! $config->is_locked( $key ) ) { return; } printf( '

%s

', /* translators: %s: name of a PHP constant. */ esc_html( sprintf( __( 'Set in wp-config.php (%s).', 'adminkeep' ), SmtpConfig::CONSTANTS[ $key ] ) ) ); } /** * The preset dropdown. Not a setting: it only fills the three fields below it. * * @return void */ private function preset_row() { echo ''; echo ''; printf( '

%s

', esc_html__( 'Picking a provider fills in the server, port and encryption. Nothing else is sent anywhere.', 'adminkeep' ) ); echo '

'; echo ''; } /** * A one-line input. * * @param SmtpConfig $config Resolved configuration. * @param string $key Field key. * @param string $label Label. * @param string $type Input type. * @return void */ private function text_row( SmtpConfig $config, $key, $label, $type ) { $locked = $config->is_locked( $key ); printf( '', esc_attr( $key ), esc_html( $label ), esc_attr( $type ), esc_attr( self::FIELD ), // A constant's value is not echoed: wp-config.php is where it is kept private. esc_attr( $locked ? '' : (string) $config->get( $key ) ), 'number' === $type ? 'small-text' : 'regular-text', $locked ? ' disabled="disabled"' : '' ); $this->locked_note( $config, $key ); echo ''; } /** * The encryption dropdown. * * @param SmtpConfig $config Resolved configuration. * @return void */ private function encryption_row( SmtpConfig $config ) { $locked = $config->is_locked( 'encryption' ); // A constant's value is not echoed: wp-config.php is where it is kept private. $current = $locked ? '' : $config->get( 'encryption' ); $choices = array( 'tls' => __( 'TLS (STARTTLS) — usual for port 587', 'adminkeep' ), 'ssl' => __( 'SSL — usual for port 465', 'adminkeep' ), 'none' => __( 'None', 'adminkeep' ), ); echo ''; printf( ''; $this->locked_note( $config, 'encryption' ); echo ''; } /** * A checkbox. * * @param SmtpConfig $config Resolved configuration. * @param string $key Field key. * @param string $label Row heading; empty to continue the row above. * @param string $text Text beside the box. * @return void */ private function checkbox_row( SmtpConfig $config, $key, $label, $text ) { printf( '%1$s', esc_html( $label ), esc_attr( self::FIELD ), esc_attr( $key ), checked( (bool) $config->get( $key ), true, false ), esc_html( $text ) ); } /** * The password. Always rendered empty: a saved password is kept by leaving the field blank. * * @param SmtpConfig $config Resolved configuration. * @return void */ private function password_row( SmtpConfig $config ) { $locked = $config->is_locked( 'password' ); $saved = ! $locked && $config->has_password(); // A saved password that still opens keeps the field disabled, so saving the form leaves it // alone; Remove enables the field, and what is saved from then on is what it holds, empty // included. One that no longer opens has to be typed again, so its field starts enabled. $kept = $saved && false !== $config->password(); echo ''; echo '
'; printf( '', esc_attr( self::FIELD ), // Dots as a placeholder, not a value: they show the field is filled without saying how // long the password is, and a placeholder can never be posted as the password. esc_attr( $kept ? str_repeat( '•', 16 ) : '' ), $locked || $kept ? ' disabled="disabled"' : '' ); if ( $kept ) { printf( '', esc_attr__( 'Type a new password, or leave empty to remove it', 'adminkeep' ), esc_html__( 'Remove password', 'adminkeep' ) ); } echo '
'; if ( $kept ) { printf( '

%s

', esc_html__( 'Saved. Use Remove password to enter a different one.', 'adminkeep' ) ); } $this->locked_note( $config, 'password' ); if ( $saved && ! $kept ) { printf( '

%s

', esc_html__( 'The saved password can no longer be read because this site\'s security keys changed. Enter it again.', 'adminkeep' ) ); } if ( ! $locked ) { printf( '

%s

', esc_html__( 'Stored encrypted, and never shown again once saved.', 'adminkeep' ) ); } echo ''; } }