This commit is contained in:
Javier Casares 2025-11-26 08:24:18 +00:00
commit c98dcb7b50
9 changed files with 5952 additions and 0 deletions

22
assets/css/admin.css Normal file
View file

@ -0,0 +1,22 @@
.robotstxt-smtp-tools .card {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.robotstxt-smtp-tools .robotstxt-smtp-tool-empty {
margin-top: 1em;
}
.robotstxt-smtp-tools .robotstxt-smtp-tool-meta {
margin: 1em 0;
font-style: italic;
}
.robotstxt-smtp-tools .robotstxt-smtp-tool-actions {
margin: 1em 0;
}
.robotstxt-smtp-tools .robotstxt-smtp-tool-result {
margin-top: 1.5em;
}

52
assets/js/admin.js Normal file
View file

@ -0,0 +1,52 @@
/**
* Admin UI behavior for Robotstxt SMTP settings.
*
* @package RobotstxtSMTP
*/
(function () {
'use strict';
var initializeAdmin = function () {
var security = document.getElementById( 'robotstxt_smtp_security' );
var port = document.getElementById( 'robotstxt_smtp_port' );
if ( ! security || ! port) {
return;
}
var defaultPorts = {
none: 25,
ssl: 465,
tls: 587
};
var previousValue = security.value || 'none';
security.addEventListener(
'change',
function () {
var newValue = security.value || 'none';
var previousDefault = Object.prototype.hasOwnProperty.call( defaultPorts, previousValue )
? defaultPorts[previousValue]
: null;
var newDefault = Object.prototype.hasOwnProperty.call( defaultPorts, newValue )
? defaultPorts[newValue]
: null;
var currentPort = parseInt( port.value, 10 );
if ( ! Number.isNaN( currentPort ) && null !== previousDefault && currentPort === previousDefault && null !== newDefault) {
port.value = newDefault;
}
previousValue = newValue;
}
);
};
if ('loading' !== document.readyState) {
initializeAdmin();
} else {
document.addEventListener( 'DOMContentLoaded', initializeAdmin );
}
})();

19
changelog.txt Normal file
View file

@ -0,0 +1,19 @@
= 1.2.0 =
* Logged failed email deliveries with status and error details in the log list and detail views.
* Captured the SMTP debug conversation for each email and surfaced it inside the log detail view.
* Fixed fatal errors in the SMTP bootstrap by restoring configuration and log cleanup hooks.
= 1.1.0 =
* Added Amazon SES credential fields, regional selection, and live validation helpers that appear when the Amazon SES add-on is active.
* Introduced the `robotstxt_smtp_sanitized_options` filter so add-ons can adjust sanitized settings before they are stored.
* Routed SMTP test messages and regular WordPress emails through Amazon SES whenever the add-on supplies valid credentials.
= 1.0.0 =
* Contextual help guidance in every SMTP configuration field.
* Automatic port updates when selecting an encryption method with standard values.
* Advanced tools: MX analysis, SPF/DKIM/DMARC validation, extended SMTP diagnostics, and blacklist monitoring.
* Enhanced logging with automatic cleanup by limit or age.
* Updated documentation for the 1.0.0 release with WordPress 6.7 and PHP 8.2 support.

664
includes/class-plugin.php Normal file
View file

@ -0,0 +1,664 @@
<?php
/**
* Main plugin bootstrap file.
*
* @package Robotstxt_SMTP
*/
namespace Robotstxt_SMTP;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use Robotstxt_SMTP\Admin\Settings_Page;
use WP_Error;
/**
* Main plugin class.
*/
class Plugin {
/**
* Custom post type used to store email logs.
*/
private const LOG_POST_TYPE = 'robotstxt_smtp_log';
/**
* Cron hook used to trigger the log cleanup.
*/
private const CRON_HOOK = 'robotstxt_smtp_cleanup_logs';
/**
* Holds the class instance.
*
* @var Plugin|null
*/
private static ?Plugin $instance = null;
/**
* Settings page handler.
*
* @var Settings_Page|null
*/
private ?Settings_Page $settings_page = null;
/**
* Captures the SMTP debug output for the current email.
*
* @var array<int, string>
*/
private array $current_debug_log = array();
/**
* Retrieves the plugin instance.
*
* @return Plugin Plugin instance.
*/
public static function get_instance(): Plugin {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Determines whether the Amazon SES integration plugin is active.
*
* @return bool
*/
public static function is_amazon_ses_integration_active(): bool {
/**
* Filters whether the Amazon SES integration should be considered active.
*
* @since 1.0.1
*
* @param bool $is_active True when the Amazon SES add-on is active.
*/
return (bool) \apply_filters( 'robotstxt_smtp_amazon_ses_active', false );
}
/**
* Sets up the plugin.
*
* @return void
*/
public function run(): void {
add_action( 'phpmailer_init', array( $this, 'configure_phpmailer' ) );
add_filter( 'wp_mail_from', array( $this, 'filter_mail_from' ) );
add_filter( 'wp_mail_from_name', array( $this, 'filter_mail_from_name' ) );
add_action( 'init', array( $this, 'register_log_post_type' ) );
add_action( 'init', array( $this, 'maybe_schedule_cleanup' ) );
add_action( 'wp_mail_succeeded', array( $this, 'handle_wp_mail_succeeded' ) );
add_action( 'wp_mail_failed', array( $this, 'handle_wp_mail_failed' ) );
add_action( self::CRON_HOOK, array( $this, 'cleanup_logs' ) );
if ( is_admin() ) {
$this->register_admin();
}
}
/**
* Registers the admin functionality.
*
* @return void
*/
private function register_admin(): void {
if ( null === $this->settings_page ) {
$this->settings_page = new Settings_Page();
}
$this->settings_page->register_hooks();
}
/**
* Registers the custom post type used to store email logs.
*
* @return void
*/
public function register_log_post_type(): void {
register_post_type(
self::LOG_POST_TYPE,
array(
'labels' => array(
'name' => esc_html__( 'SMTP Logs', 'robotstxt-smtp' ),
'singular_name' => esc_html__( 'SMTP Log', 'robotstxt-smtp' ),
),
'public' => false,
'show_ui' => false,
'show_in_menu' => false,
'show_in_nav_menus' => false,
'exclude_from_search' => true,
'publicly_queryable' => false,
'supports' => array( 'title', 'editor' ),
'capability_type' => 'post',
'map_meta_cap' => true,
)
);
}
/**
* Schedules or clears the automatic log cleanup task based on settings.
*
* @return void
*/
public function maybe_schedule_cleanup(): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
wp_clear_scheduled_hook( self::CRON_HOOK );
return;
}
if ( false === wp_next_scheduled( self::CRON_HOOK ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
}
}
/**
* Filters the "From" email address used by WordPress.
*
* @param string $original_email Original email address provided by WordPress.
*
* @return string
*/
public function filter_mail_from( string $original_email ): string {
$settings = $this->get_mailer_settings();
$from_email = isset( $settings['from_email'] ) ? sanitize_email( $settings['from_email'] ) : '';
if ( empty( $from_email ) ) {
return sanitize_email( $original_email );
}
return $from_email;
}
/**
* Filters the "From" name used by WordPress.
*
* @param string $original_name Original name provided by WordPress.
*
* @return string
*/
public function filter_mail_from_name( string $original_name ): string {
$settings = $this->get_mailer_settings();
$from_name = isset( $settings['from_name'] ) ? sanitize_text_field( $settings['from_name'] ) : '';
if ( '' === $from_name ) {
return sanitize_text_field( $original_name );
}
return $from_name;
}
/**
* Retrieves the stored mailer settings merged with defaults.
*
* @return array<string, mixed>
*/
private function get_mailer_settings(): array {
$defaults = Settings_Page::get_default_settings();
if ( Settings_Page::is_network_mode_enabled() ) {
$settings = get_site_option( Settings_Page::NETWORK_OPTION_NAME, array() );
} else {
$settings = get_option( Settings_Page::OPTION_NAME, array() );
}
if ( ! is_array( $settings ) ) {
$settings = array();
}
$settings = wp_parse_args( $settings, $defaults );
/**
* Filters the SMTP settings before they are consumed by the mailer.
*
* @since 1.1.1
*
* @param array<string, mixed> $settings Sanitized settings merged with defaults.
*/
return (array) \apply_filters( 'robotstxt_smtp_mailer_settings', $settings );
}
/**
* Configures PHPMailer to use the stored SMTP settings.
*
* @param PHPMailer $phpmailer Mailer instance.
*
* @return void
*/
public function configure_phpmailer( PHPMailer $phpmailer ): void {
if ( self::is_amazon_ses_integration_active() ) {
return;
}
$this->current_debug_log = array();
$settings = $this->get_mailer_settings();
if ( empty( $settings['host'] ) ) {
return;
}
$phpmailer->isSMTP();
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->Host = $settings['host'];
$phpmailer->Port = (int) $settings['port'];
$phpmailer->SMTPAuth = ! empty( $settings['username'] ) || ! empty( $settings['password'] );
$phpmailer->Username = $phpmailer->SMTPAuth ? $settings['username'] : '';
$phpmailer->Password = $phpmailer->SMTPAuth ? $settings['password'] : '';
$phpmailer->SMTPAutoTLS = false;
$phpmailer->SMTPSecure = '';
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( 'ssl' === $settings['security'] ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} elseif ( 'tls' === $settings['security'] ) {
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$phpmailer->SMTPAutoTLS = true;
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
}
$this->register_debug_logger( $phpmailer );
}
/**
* Enables SMTP debug logging for the current email.
*
* @param PHPMailer $phpmailer Mailer instance.
*
* @return void
*/
private function register_debug_logger( PHPMailer $phpmailer ): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
return;
}
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
$phpmailer->SMTPDebug = SMTP::DEBUG_SERVER;
$phpmailer->Debugoutput = function ( string $message, int $level ): void {
$this->current_debug_log[] = '[' . $level . '] ' . $message;
};
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
}
/**
* Stores the email data when WordPress reports a successful delivery.
*
* @param array<string, mixed> $mail_data Email data provided by wp_mail.
*
* @return void
*/
public function handle_wp_mail_succeeded( array $mail_data ): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
return;
}
$settings = $this->get_mailer_settings();
$subject = isset( $mail_data['subject'] ) ? sanitize_text_field( (string) $mail_data['subject'] ) : '';
$message = isset( $mail_data['message'] ) ? (string) $mail_data['message'] : '';
$headers = $this->normalize_headers( $mail_data['headers'] ?? array() );
$to = $this->normalize_recipients( $mail_data['to'] ?? array() );
$from = $this->determine_from_header( $headers, $settings );
$post_id = wp_insert_post(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'publish',
'post_title' => $subject,
'post_content' => $message,
'post_date' => current_time( 'mysql' ),
),
true
);
if ( is_wp_error( $post_id ) || 0 === $post_id ) {
return;
}
update_post_meta( $post_id, '_robotstxt_smtp_to', implode( ', ', $to ) );
update_post_meta( $post_id, '_robotstxt_smtp_from', $from );
update_post_meta( $post_id, '_robotstxt_smtp_headers', implode( "\n", $headers ) );
update_post_meta( $post_id, '_robotstxt_smtp_status', 'sent' );
update_post_meta( $post_id, '_robotstxt_smtp_error', '' );
if ( isset( $mail_data['attachments'] ) ) {
update_post_meta( $post_id, '_robotstxt_smtp_attachments', maybe_serialize( $mail_data['attachments'] ) );
}
$debug_log = $this->get_sanitized_debug_log();
if ( $debug_log ) {
update_post_meta( $post_id, '_robotstxt_smtp_debug_log', maybe_serialize( $debug_log ) );
}
if ( 'count' === $logging['mode'] ) {
$this->cleanup_logs_by_count( $logging['count'] );
return;
}
$this->cleanup_logs_by_days( $logging['days'] );
}
/**
* Stores the email data when WordPress reports a failed delivery.
*
* @param WP_Error $error Error object reported by wp_mail.
*
* @return void
*/
public function handle_wp_mail_failed( WP_Error $error ): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
return;
}
$settings = $this->get_mailer_settings();
$data = $error->get_error_data();
if ( ! is_array( $data ) ) {
$data = array();
}
$subject = isset( $data['subject'] ) ? sanitize_text_field( (string) $data['subject'] ) : '';
$message = isset( $data['message'] ) ? (string) $data['message'] : '';
$headers = $this->normalize_headers( $data['headers'] ?? array() );
$to = $this->normalize_recipients( $data['to'] ?? array() );
$from = $this->determine_from_header( $headers, $settings );
$error_msg = sanitize_text_field( $error->get_error_message() );
$post_id = wp_insert_post(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'publish',
'post_title' => $subject,
'post_content' => $message,
'post_date' => current_time( 'mysql' ),
),
true
);
if ( is_wp_error( $post_id ) || 0 === $post_id ) {
return;
}
update_post_meta( $post_id, '_robotstxt_smtp_to', implode( ', ', $to ) );
update_post_meta( $post_id, '_robotstxt_smtp_from', $from );
update_post_meta( $post_id, '_robotstxt_smtp_headers', implode( "\n", $headers ) );
update_post_meta( $post_id, '_robotstxt_smtp_status', 'error' );
update_post_meta( $post_id, '_robotstxt_smtp_error', $error_msg );
if ( isset( $data['attachments'] ) ) {
update_post_meta( $post_id, '_robotstxt_smtp_attachments', maybe_serialize( $data['attachments'] ) );
}
$debug_log = $this->get_sanitized_debug_log();
if ( $debug_log ) {
update_post_meta( $post_id, '_robotstxt_smtp_debug_log', maybe_serialize( $debug_log ) );
}
if ( 'count' === $logging['mode'] ) {
$this->cleanup_logs_by_count( $logging['count'] );
return;
}
$this->cleanup_logs_by_days( $logging['days'] );
}
/**
* Retrieves the sanitized SMTP debug output captured for the current email.
*
* @return array<int, string>
*/
private function get_sanitized_debug_log(): array {
if ( empty( $this->current_debug_log ) ) {
return array();
}
$sanitized = array_map(
static function ( $entry ): string {
return sanitize_textarea_field( (string) $entry );
},
$this->current_debug_log
);
$sanitized = array_filter(
$sanitized,
static function ( string $line ): bool {
return '' !== trim( $line );
}
);
return array_values( $sanitized );
}
/**
* Performs log cleanup when triggered by cron.
*
* @return void
*/
public function cleanup_logs(): void {
$logging = $this->get_logging_settings();
if ( ! $logging['enabled'] ) {
return;
}
if ( 'count' === $logging['mode'] ) {
$this->cleanup_logs_by_count( $logging['count'] );
return;
}
$this->cleanup_logs_by_days( $logging['days'] );
}
/**
* Deletes all stored logs.
*
* @return void
*/
public function clear_all_logs(): void {
do {
$posts = get_posts(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'posts_per_page' => 100,
'orderby' => 'ID',
'order' => 'ASC',
)
);
foreach ( $posts as $post_id ) {
wp_delete_post( (int) $post_id, true );
}
} while ( ! empty( $posts ) );
}
/**
* Cleans up logs by enforcing a maximum number of stored records.
*
* @param int $limit Maximum number of logs to keep.
*
* @return void
*/
private function cleanup_logs_by_count( int $limit ): void {
if ( $limit <= 0 ) {
$this->clear_all_logs();
return;
}
$logs_to_delete = get_posts(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'orderby' => 'date',
'order' => 'DESC',
'posts_per_page' => -1,
'offset' => $limit,
'no_found_rows' => true,
)
);
foreach ( $logs_to_delete as $post_id ) {
wp_delete_post( (int) $post_id, true );
}
}
/**
* Cleans up logs based on the maximum number of days to retain them.
*
* @param int $days Number of days to keep.
*
* @return void
*/
private function cleanup_logs_by_days( int $days ): void {
if ( $days <= 0 ) {
$this->clear_all_logs();
return;
}
$cutoff = time() - ( $days * DAY_IN_SECONDS );
$logs_to_delete = get_posts(
array(
'post_type' => self::LOG_POST_TYPE,
'post_status' => 'any',
'fields' => 'ids',
'date_query' => array(
array(
'column' => 'post_date_gmt',
'before' => gmdate( 'Y-m-d H:i:s', $cutoff ),
),
),
'posts_per_page' => -1,
'no_found_rows' => true,
)
);
foreach ( $logs_to_delete as $post_id ) {
wp_delete_post( (int) $post_id, true );
}
}
/**
* Normalizes the headers array into a list of strings.
*
* @param mixed $headers Headers provided by wp_mail.
*
* @return array<int, string>
*/
private function normalize_headers( $headers ): array {
if ( empty( $headers ) ) {
return array();
}
if ( is_string( $headers ) ) {
$headers = preg_split( "/\r\n|\r|\n/", $headers );
}
if ( ! is_array( $headers ) ) {
return array();
}
$normalized = array();
foreach ( $headers as $header ) {
$line = trim( (string) $header );
if ( '' !== $line ) {
$normalized[] = $line;
}
}
return $normalized;
}
/**
* Normalizes the recipient list into an array of strings.
*
* @param mixed $recipients Recipient information from wp_mail.
*
* @return array<int, string>
*/
private function normalize_recipients( $recipients ): array {
if ( empty( $recipients ) ) {
return array();
}
if ( is_string( $recipients ) ) {
$recipients = explode( ',', $recipients );
}
if ( ! is_array( $recipients ) ) {
return array();
}
$normalized = array();
foreach ( $recipients as $recipient ) {
$normalized[] = sanitize_text_field( trim( (string) $recipient ) );
}
return array_filter( $normalized );
}
/**
* Determines the "From" header for the logged message.
*
* @param array<int, string> $headers Normalized header list.
* @param array<string, mixed> $settings Plugin settings.
*
* @return string
*/
private function determine_from_header( array $headers, array $settings ): string {
foreach ( $headers as $header ) {
if ( 0 === stripos( $header, 'from:' ) ) {
return sanitize_text_field( trim( substr( $header, 5 ) ) );
}
}
$from_email = isset( $settings['from_email'] ) ? sanitize_email( $settings['from_email'] ) : '';
$from_name = isset( $settings['from_name'] ) ? sanitize_text_field( $settings['from_name'] ) : '';
if ( $from_email && $from_name ) {
return $from_name . ' <' . $from_email . '>';
}
if ( $from_email ) {
return $from_email;
}
return get_bloginfo( 'name', 'display' );
}
/**
* Retrieves the logging-related settings from the option store.
*
* @return array<string, mixed>
*/
private function get_logging_settings(): array {
$settings = $this->get_mailer_settings();
return array(
'enabled' => ! empty( $settings['logs_enabled'] ),
'mode' => in_array( $settings['logs_retention_mode'], array( 'count', 'days' ), true )
? $settings['logs_retention_mode']
: 'count',
'count' => isset( $settings['logs_retention_count'] ) ? (int) $settings['logs_retention_count'] : 0,
'days' => isset( $settings['logs_retention_days'] ) ? (int) $settings['logs_retention_days'] : 0,
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,107 @@
<?php
/**
* SMTP diagnostics client.
*
* @package Robotstxt_SMTP
*/
namespace Robotstxt_SMTP\Admin;
use PHPMailer\PHPMailer\SMTP;
/**
* SMTP client wrapper used for diagnostics.
*/
class SMTP_Diagnostics_Client extends SMTP {
/**
* Tracks whether the most recent command timed out.
*
* @var bool
*/
private bool $last_command_timed_out = false;
/**
* Executes an SMTP command and returns the raw reply.
*
* @param string $name Command name.
* @param string $command_string Full command string.
* @param array<int,int> $expected_codes Expected success codes.
*
* @return array<string, mixed>
*/
public function execute_command( string $name, string $command_string, array $expected_codes = array( 250 ) ): array {
$this->last_command_timed_out = false;
$this->reset_error_state();
$expected = ! empty( $expected_codes ) ? array_map( 'intval', $expected_codes ) : array( 250 );
$success = parent::sendCommand( $name, $command_string, $expected );
if ( ! $success ) {
$this->last_command_timed_out = $this->error_indicates_timeout( parent::getError() );
}
return array(
'success' => $success,
'reply' => $this->getLastReply(),
'timed_out' => $this->last_command_timed_out,
);
}
/**
* Indicates whether the last command timed out.
*
* @return bool
*/
public function did_last_command_timeout(): bool {
return $this->last_command_timed_out;
}
/**
* Initiates STARTTLS while tracking timeout state.
*
* @return bool
*/
public function startTLS(): bool {
$this->last_command_timed_out = false;
$this->reset_error_state();
$result = parent::startTLS();
if ( ! $result ) {
$this->last_command_timed_out = $this->error_indicates_timeout( parent::getError() );
}
return $result;
}
/**
* Clears the stored error state.
*/
private function reset_error_state(): void {
parent::setError( '' );
}
/**
* Determines whether an error array represents a timeout.
*
* @param array<string, mixed> $error Error details.
*
* @return bool
*/
private function error_indicates_timeout( array $error ): bool {
foreach ( array( 'error', 'detail', 'smtp_code_ex' ) as $key ) {
if ( empty( $error[ $key ] ) ) {
continue;
}
$value = (string) $error[ $key ];
if ( false !== stripos( $value, 'timed out' ) || false !== stripos( $value, 'timed-out' ) ) {
return true;
}
}
return false;
}
}

84
readme.txt Normal file
View file

@ -0,0 +1,84 @@
=== SMTP (by ROBOTSTXT) ===
Contributors: robotstxt
Tags: smtp, email, mail
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 8.2
Stable tag: 1.2.0
License: GPLv3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html
Send every site email through a fully configurable SMTP server managed from the WordPress dashboard.
== Description ==
SMTP (by ROBOTSTXT) replaces WordPress' native mail delivery with a secure SMTP connection and detailed guidance. The plugin is built for marketing teams, agencies, and technical departments that need a clear, complete solution ready for multisite installations.
= Guided PHPMailer configuration =
* Routes `wp_mail()` through PHPMailer in SMTP mode with support for credentials, custom ports, and the `None`, `SSL`, or `TLS` encryption methods.
* Adds inline help text with practical examples in every field: host, port, username, password, sender email, and sender name.
* Automatically changes the port when it matches the default value for the selected encryption type (25, 465, or 587).
* Lets you set default sender information for the site to keep a consistent identity across outgoing messages.
= Tools for support and marketing teams =
* **Settings → SMTP → Test** screen for sending manual test emails and confirming the connection to the server.
* **Tools** panel packed with diagnostics: automatic MX lookup, SPF/DKIM/DMARC checks, extended SMTP server diagnostics, and DNS reputation monitoring.
* Caches results from each tool for 24 hours (with a manual refresh option) to streamline recurring tasks for your team.
= Comprehensive delivery logs =
* Saves the subject, recipients, headers, content, and attachments for every email sent.
* Browse logs from **Settings → SMTP → Logs** with pagination and access to the details of each delivery.
* Configure automatic cleanup by maximum entries or age in days, plus an instant "Clear all" button to wipe the history.
= Multisite-ready configuration =
* Choose whether the configuration applies to the entire network or individually per site.
* Dedicated forms in both the network dashboard and each site to edit, test, and share credentials securely.
== Installation ==
1. Upload the plugin folder to `wp-content/plugins/`.
2. Activate **SMTP (by ROBOTSTXT)** from the **Plugins** menu in the WordPress dashboard.
3. Open **Settings → SMTP** (or **Network Settings → SMTP** in multisite) to enter your connection details.
== Frequently Asked Questions ==
= Do I need credentials to send email? =
Enter a username and password only if your provider requires them. If the server accepts unauthenticated delivery, leave the fields blank and the plugin will send without credentials.
= Which encryption should I use? =
Select `None`, `SSL`, or `TLS` according to your provider's documentation. When you switch encryption, the plugin will automatically suggest the recommended port if you are using one of the standard values.
= How can I review the emails that were sent? =
Enable logging on the settings page and visit **Settings → SMTP → Logs** to open the paginated table of saved emails. From there you can inspect each entry, download attachments, and delete records.
= Does it work on a multisite network? =
Yes. From the network dashboard you can decide whether the configuration is global or site-specific. You can also run the tools and send test emails from the network or from each individual site.
== Changelog ==
= 1.2.0 =
* Logged failed email deliveries with status and error details in the log list and detail views.
* Captured the SMTP debug conversation for each email and surfaced it inside the log detail view.
= 1.1.0 =
* Added Amazon SES credential fields, regional selection, and live validation helpers that appear when the Amazon SES add-on is active.
* Introduced the `robotstxt_smtp_sanitized_options` filter so add-ons can adjust sanitized settings before they are stored.
* Routed SMTP test messages and regular WordPress emails through Amazon SES whenever the add-on supplies valid credentials.
= 1.0.0 =
* Contextual help guidance in every SMTP configuration field.
* Automatic port updates when selecting an encryption method with standard values.
* Advanced tools: MX analysis, SPF/DKIM/DMARC validation, extended SMTP diagnostics, and blacklist monitoring.
* Enhanced logging with automatic cleanup by limit or age.
* Updated documentation for the 1.0.0 release with WordPress 6.7 and PHP 8.2 support.

120
robotstxt-smtp.php Normal file
View file

@ -0,0 +1,120 @@
<?php
/**
* Plugin Name: SMTP (by ROBOTSTXT)
* Plugin URI: https://www.robotstxt.es/
* Description: Configure WordPress to send emails through SMTP with detailed controls.
* Version: 1.2.0
* Requires at least: 6.0
* Requires PHP: 8.2
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
* Text Domain: robotstxt-smtp
* Domain Path: /languages
* License: GPL-3.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
* Gitea Plugin URI: ROBOTSTXT/robotstxt-smtp
* Gitea Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-smtp
* Plugin ID: did:plc:yodoiqooeu3l3lwuolyha3zs
*
* @package Robotstxt_SMTP
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! defined( 'ROBOTSTXT_SMTP_VERSION' ) ) {
define( 'ROBOTSTXT_SMTP_VERSION', '1.2.0' );
}
if ( ! defined( 'ROBOTSTXT_SMTP_FILE' ) ) {
define( 'ROBOTSTXT_SMTP_FILE', __FILE__ );
}
if ( ! defined( 'ROBOTSTXT_SMTP_URL' ) ) {
define( 'ROBOTSTXT_SMTP_URL', plugin_dir_url( __FILE__ ) );
}
if ( ! defined( 'ROBOTSTXT_SMTP_PATH' ) ) {
define( 'ROBOTSTXT_SMTP_PATH', plugin_dir_path( __FILE__ ) );
}
if ( ! defined( 'ROBOTSTXT_SMTP_SLUG' ) ) {
define( 'ROBOTSTXT_SMTP_SLUG', 'robotstxt-smtp' );
}
if ( ! defined( 'ROBOTSTXT_SMTP_BASENAME' ) ) {
define( 'ROBOTSTXT_SMTP_BASENAME', plugin_basename( ROBOTSTXT_SMTP_FILE ) );
}
if ( ! class_exists( 'PHPMailer\PHPMailer\PHPMailer' ) ) {
require_once ABSPATH . WPINC . '/PHPMailer/PHPMailer.php';
}
if ( ! class_exists( 'PHPMailer\PHPMailer\Exception' ) ) {
require_once ABSPATH . WPINC . '/PHPMailer/Exception.php';
}
if ( ! class_exists( 'PHPMailer\PHPMailer\SMTP' ) ) {
require_once ABSPATH . WPINC . '/PHPMailer/SMTP.php';
}
require_once ROBOTSTXT_SMTP_PATH . 'includes/class-smtp-diagnostics-client.php';
require_once ROBOTSTXT_SMTP_PATH . 'includes/class-settings-page.php';
require_once ROBOTSTXT_SMTP_PATH . 'includes/class-plugin.php';
if ( ! function_exists( 'robotstxt_smtp_dependencies_satisfied' ) ) {
/**
* Determines whether the plugin dependencies are available.
*
* @since 1.1.1
*
* @return bool
*/
function robotstxt_smtp_dependencies_satisfied(): bool {
return class_exists( '\\PHPMailer\\PHPMailer\\PHPMailer' );
}
}
if ( ! function_exists( 'robotstxt_smtp_register_dependency_notice' ) ) {
/**
* Registers an admin notice when dependencies are missing.
*
* @since 1.1.1
*
* @return void
*/
function robotstxt_smtp_register_dependency_notice(): void {
if ( ! is_admin() ) {
return;
}
$callback = static function (): void {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( $screen && 'plugins' !== $screen->id && 'plugins-network' !== $screen->id ) {
return;
}
$message = sprintf(
/* translators: %s: plugin name. */
esc_html__( 'The %s plugin requires the PHPMailer library to be available.', 'robotstxt-smtp' ),
esc_html__( 'ROBOTSTXT SMTP', 'robotstxt-smtp' )
);
?>
<div class="notice notice-error">
<p><?php echo esc_html( $message ); ?></p>
</div>
<?php
};
add_action( 'admin_notices', $callback );
add_action( 'network_admin_notices', $callback );
}
}
if ( robotstxt_smtp_dependencies_satisfied() ) {
\Robotstxt_SMTP\Plugin::get_instance()->run();
} else {
robotstxt_smtp_register_dependency_notice();
}

155
uninstall.php Normal file
View file

@ -0,0 +1,155 @@
<?php
/**
* Uninstall routines for the core SMTP plugin.
*
* @package Robotstxt_SMTP
*/
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
if ( ! function_exists( 'robotstxt_smtp_uninstall_cleanup_site' ) ) {
/**
* Removes all plugin data for the current site.
*
* @since 1.1.1
*
* @return void
*/
function robotstxt_smtp_uninstall_cleanup_site(): void {
delete_option( 'robotstxt_smtp_options' );
robotstxt_smtp_uninstall_delete_logs();
robotstxt_smtp_uninstall_delete_transient_prefix( 'robotstxt_smtp_tool_', false );
robotstxt_smtp_uninstall_delete_transient_prefix( 'robotstxt_smtp_test_result_', false );
wp_clear_scheduled_hook( 'robotstxt_smtp_cleanup_logs' );
}
}
if ( ! function_exists( 'robotstxt_smtp_uninstall_cleanup_network' ) ) {
/**
* Removes network-wide plugin data.
*
* @since 1.1.1
*
* @return void
*/
function robotstxt_smtp_uninstall_cleanup_network(): void {
delete_site_option( 'robotstxt_smtp_network_options' );
delete_site_option( 'robotstxt_smtp_configuration_scope' );
robotstxt_smtp_uninstall_delete_transient_prefix( 'robotstxt_smtp_tool_', true );
robotstxt_smtp_uninstall_delete_transient_prefix( 'robotstxt_smtp_test_result_', true );
}
}
if ( ! function_exists( 'robotstxt_smtp_uninstall_delete_transient_prefix' ) ) {
/**
* Deletes transients that match a prefix for the current context.
*
* @since 1.1.1
*
* @param string $prefix Transient prefix without the internal storage prefix.
* @param bool $is_network Whether to delete network transients.
*
* @return void
*/
function robotstxt_smtp_uninstall_delete_transient_prefix( string $prefix, bool $is_network ): void {
global $wpdb;
if ( $is_network ) {
$stored_prefix = '_site_transient_' . $prefix;
$timeout_prefix = '_site_transient_timeout_' . $prefix;
$delete_callback = 'delete_site_transient';
$timeout_prefix_length = strlen( '_site_transient_timeout_' );
$stored_prefix_length = strlen( '_site_transient_' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$matches = $wpdb->get_col(
$wpdb->prepare(
"SELECT meta_key FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s OR meta_key LIKE %s",
$wpdb->esc_like( $stored_prefix ) . '%',
$wpdb->esc_like( $timeout_prefix ) . '%'
)
);
} else {
$stored_prefix = '_transient_' . $prefix;
$timeout_prefix = '_transient_timeout_' . $prefix;
$delete_callback = 'delete_transient';
$timeout_prefix_length = strlen( '_transient_timeout_' );
$stored_prefix_length = strlen( '_transient_' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$matches = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
$wpdb->esc_like( $stored_prefix ) . '%',
$wpdb->esc_like( $timeout_prefix ) . '%'
)
);
}
if ( empty( $matches ) ) {
return;
}
foreach ( $matches as $option_name ) {
if ( ! is_string( $option_name ) ) {
continue;
}
if ( str_starts_with( $option_name, $is_network ? '_site_transient_timeout_' : '_transient_timeout_' ) ) {
$transient_key = substr( $option_name, $timeout_prefix_length );
} else {
$transient_key = substr( $option_name, $stored_prefix_length );
}
if ( '' === $transient_key ) {
continue;
}
call_user_func( $delete_callback, $transient_key );
}
}
}
if ( ! function_exists( 'robotstxt_smtp_uninstall_delete_logs' ) ) {
/**
* Deletes all stored SMTP logs for the current site.
*
* @since 1.1.1
*
* @return void
*/
function robotstxt_smtp_uninstall_delete_logs(): void {
do {
$logs = get_posts(
array(
'post_type' => 'robotstxt_smtp_log',
'post_status' => 'any',
'fields' => 'ids',
'posts_per_page' => 100,
'orderby' => 'ID',
'order' => 'ASC',
)
);
foreach ( $logs as $log_id ) {
wp_delete_post( (int) $log_id, true );
}
} while ( ! empty( $logs ) );
}
}
robotstxt_smtp_uninstall_cleanup_network();
if ( function_exists( 'is_multisite' ) && is_multisite() ) {
$sites = get_sites( array( 'fields' => 'ids' ) );
foreach ( $sites as $site_id ) {
switch_to_blog( (int) $site_id );
robotstxt_smtp_uninstall_cleanup_site();
restore_current_blog();
}
} else {
robotstxt_smtp_uninstall_cleanup_site();
}