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 '
'; // 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( '
',
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 '';
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 '
';
}
/**
* 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 '';
// 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 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 '