PluginProbe ʕ •ᴥ•ʔ
WP Mail SMTP by WPForms – The Most Popular SMTP and Email Log Plugin / 4.9.0
WP Mail SMTP by WPForms – The Most Popular SMTP and Email Log Plugin v4.9.0
4.9.0 0.9.6 1.0.0 1.0.1 1.0.2 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.6.0 1.6.2 1.7.0 1.7.1 1.8.0 1.8.1 1.9.0 2.0.0 2.0.1 2.1.1 2.2.1 2.3.1 2.4.0 2.5.0 2.5.1 2.6.0 2.7.0 2.8.0 2.9.0 3.0.1 3.0.2 3.0.3 3.1.0 3.10.0 3.11.0 3.11.1 3.2.0 3.2.1 3.3.0 3.4.0 3.5.0 3.5.1 3.5.2 3.6.1 3.7.0 3.8.0 3.8.2 3.9.0 4.0.1 4.1.0 4.1.1 4.2.0 4.3.0 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.8.0 trunk 0.10.0 0.10.1 0.11.1 0.11.2 0.3.1 0.3.2 0.4 0.4.1 0.4.2 0.5.0 0.5.1 0.5.2 0.6 0.7 0.8 0.8.2 0.8.3 0.8.4 0.8.5 0.8.6 0.8.7 0.9.0 0.9.1 0.9.2 0.9.3 0.9.4 0.9.5
wp-mail-smtp / src / MailCatcherTrait.php
wp-mail-smtp / src Last commit date
Abilities 5 days ago Admin 5 days ago Compatibility 5 days ago Helpers 5 days ago Integrations 5 days ago Providers 5 days ago Queue 5 days ago Reports 5 days ago Tasks 5 days ago TestEmail 5 days ago UsageTracking 5 days ago WPCLI 5 days ago AbstractConnection.php 5 days ago Conflicts.php 5 days ago Connect.php 5 days ago Connection.php 5 days ago ConnectionInterface.php 5 days ago ConnectionsManager.php 5 days ago Core.php 5 days ago DBRepair.php 5 days ago Debug.php 5 days ago EmailSendingDebug.php 5 days ago Geo.php 5 days ago MailCatcher.php 5 days ago MailCatcherInterface.php 5 days ago MailCatcherTrait.php 5 days ago MailCatcherV6.php 5 days ago Migration.php 5 days ago MigrationAbstract.php 5 days ago Migrations.php 5 days ago OptimizedEmailSending.php 5 days ago Options.php 5 days ago Processor.php 5 days ago SiteHealth.php 5 days ago Upgrade.php 5 days ago Uploads.php 5 days ago WP.php 5 days ago WPMailArgs.php 5 days ago WPMailInitiator.php 5 days ago
MailCatcherTrait.php
903 lines
1 <?php
2
3 namespace WPMailSMTP;
4
5 use Exception;
6 use WPMailSMTP\Admin\DebugEvents\DebugEvents;
7 use WPMailSMTP\Providers\MailerAbstract;
8
9 /**
10 * Trait MailCatcherTrait.
11 *
12 * @since 3.7.0
13 */
14 trait MailCatcherTrait {
15
16 /**
17 * Debug output buffer.
18 *
19 * @since 3.3.0
20 *
21 * @var array
22 */
23 private $debug_output_buffer = [];
24
25 /**
26 * Debug event ID.
27 *
28 * @since 3.5.0
29 *
30 * @var int
31 */
32 private $debug_event_id = false;
33
34 /**
35 * Whether the current email is a test email.
36 *
37 * @since 3.5.0
38 *
39 * @var bool
40 */
41 private $is_test_email = false;
42
43 /**
44 * Whether the current email is a Setup Wizard test email.
45 *
46 * @since 3.5.0
47 *
48 * @var bool
49 */
50 private $is_setup_wizard_test_email = false;
51
52 /**
53 * Whether the current email is blocked to be sent.
54 *
55 * @since 3.8.0
56 *
57 * @var bool
58 */
59 private $is_emailing_blocked = false;
60
61 /**
62 * Holds the most recent error message.
63 *
64 * @since 3.7.0
65 *
66 * @var string
67 */
68 protected $latest_error = '';
69
70 /**
71 * Last error code captured before PHPMailer clears it.
72 *
73 * Populated via setError() override with first-write-wins semantics.
74 *
75 * @since 4.8.0
76 *
77 * @var string
78 */
79 private $last_error_code = '';
80
81 /**
82 * Email addresses PHPMailer reported as failed during postSend().
83 *
84 * PHPMailer dispatches doCallback() per recipient in smtpSend(), and per recipient
85 * under SingleTo=true in mailSend()/sendmailSend().
86 *
87 * @since 4.9.0
88 *
89 * @var string[]
90 */
91 private $failed_recipients = [];
92
93 /**
94 * Modify the default send() behaviour.
95 * For those mailers, that relies on PHPMailer class - call it directly.
96 * For others - init the correct provider and process it.
97 *
98 * @since 1.0.0
99 * @since 1.4.0 Process "Do Not Send" option, but always allow test email.
100 * @since 4.5.0 Add support for logging blocked emails.
101 *
102 * @throws Exception When sending via PhpMailer fails for some reason.
103 *
104 * @return bool
105 */
106 public function send() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
107
108 // Reset email related variables.
109 $this->debug_event_id = false;
110 $this->is_test_email = false;
111 $this->is_setup_wizard_test_email = false;
112 $this->is_emailing_blocked = false;
113 $this->latest_error = '';
114 $this->failed_recipients = [];
115
116 if ( wp_mail_smtp()->is_blocked() ) {
117 $this->is_emailing_blocked = true;
118 }
119
120 // Always allow a test email - check for the specific header.
121 foreach ( (array) $this->getCustomHeaders() as $header ) {
122 if (
123 ! empty( $header[0] ) &&
124 ! empty( $header[1] ) &&
125 $header[0] === 'X-Mailer-Type'
126 ) {
127 if ( trim( $header[1] ) === 'WPMailSMTP/Admin/Test' ) {
128 $this->is_emailing_blocked = false;
129 $this->is_test_email = true;
130 } elseif ( trim( $header[1] ) === 'WPMailSMTP/Admin/SetupWizard/Test' ) {
131 $this->is_setup_wizard_test_email = true;
132 }
133 }
134 }
135
136 // Log blocked emails if the option is enabled.
137 if ( $this->is_emailing_blocked ) {
138 /**
139 * Fires when an email is blocked from being sent.
140 *
141 * @since 4.5.0
142 *
143 * @param MailCatcherInterface $mailcatcher The MailCatcher object.
144 */
145 do_action( 'wp_mail_smtp_mail_catcher_send_blocked', $this );
146
147 return false;
148 }
149
150 // If it's not a test email,
151 // check if the email should be enqueued
152 // instead of being sent immediately.
153 if ( ! $this->is_test_email && ! $this->is_setup_wizard_test_email ) {
154
155 /**
156 * Filters whether an email should be enqueued or sent immediately.
157 *
158 * @since 4.0.0
159 *
160 * @param bool $should_enqueue Whether to enqueue an email, or send it.
161 * @param array $wp_mail_args Original arguments of the `wp_mail` call.
162 */
163 $should_enqueue_email = apply_filters(
164 'wp_mail_smtp_mail_catcher_send_enqueue_email',
165 false,
166 wp_mail_smtp()->get_processor()->get_filtered_wp_mail_args()
167 );
168
169 $queue = wp_mail_smtp()->get_queue();
170
171 // If we should enqueue the email,
172 // and the email has been enqueued,
173 // bail.
174 if ( $should_enqueue_email && $queue->enqueue_email() ) {
175 return true;
176 }
177 }
178
179 $connection = wp_mail_smtp()->get_connections_manager()->get_mail_connection();
180 $mailer_slug = $connection->get_mailer_slug();
181
182 // Define a custom header, that will be used to identify the plugin and the mailer.
183 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
184 $this->XMailer = 'WPMailSMTP/Mailer/' . $mailer_slug . ' ' . WPMS_PLUGIN_VER;
185
186 // Clear any prior failure entry for this connection. The smtp_send()/api_send()
187 // catch paths set a fresh entry on failure; on success no further action needed.
188 EmailSendingDebug::clear( $connection->get_id() );
189
190 // Use the default PHPMailer, as we inject our settings there for certain providers.
191 if (
192 $mailer_slug === 'mail' ||
193 $mailer_slug === 'smtp' ||
194 $mailer_slug === 'pepipost'
195 ) {
196 return $this->smtp_send( $connection );
197 } else {
198 return $this->api_send( $connection );
199 }
200 }
201
202 /**
203 * Send email via SMTP.
204 *
205 * @since 4.0.0
206 *
207 * @param ConnectionInterface $connection The connection object.
208 *
209 * @throws Exception When sending via PhpMailer fails for some reason.
210 *
211 * @return bool
212 */
213 private function smtp_send( $connection ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
214
215 // Reset captured SMTP error code from previous send attempts.
216 $this->last_error_code = '';
217
218 $mailer_slug = $connection->get_mailer_slug();
219
220 // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
221 try {
222 // Capture PHPMailer's SMTPDebug output whenever the admin has DebugEvents
223 // enabled, or whenever the current send is an admin test email - test sends
224 // always want diagnostics regardless of the global debug setting.
225 if ( DebugEvents::is_debug_enabled() || $this->is_test_email ) {
226 $this->Debugoutput = [ $this, 'debug_output_callback' ];
227
228 if ( $this->is_test_email ) {
229 /**
230 * Filters the PHPMailer SMTPDebug verbosity for admin test emails.
231 *
232 * Levels: 0 (off), 1 (client commands), 2 (commands + server responses),
233 * 3 (commands + data + connection status), 4 (low-level data).
234 *
235 * @since 1.4.0
236 *
237 * @param int $level Debug level passed to PHPMailer::$SMTPDebug. Default 3.
238 */
239 $this->SMTPDebug = apply_filters( 'wp_mail_smtp_admin_test_email_smtp_debug', 3 );
240 } else {
241 $this->SMTPDebug = 3;
242 }
243 }
244
245 /**
246 * Fires before email pre send via SMTP.
247 *
248 * Allow to hook early to catch any early failed emails.
249 *
250 * @since 2.9.0
251 *
252 * @param MailCatcherInterface $mailcatcher The MailCatcher object.
253 */
254 do_action( 'wp_mail_smtp_mailcatcher_smtp_pre_send_before', $this );
255
256 // Prepare all the headers.
257 if ( ! $this->preSend() ) {
258 $this->throw_exception( $this->ErrorInfo );
259 }
260
261 /**
262 * Fires before email send via SMTP.
263 *
264 * Allow to hook after all the preparation before the actual sending.
265 *
266 * @since 2.9.0
267 *
268 * @param MailCatcherInterface $mailcatcher The MailCatcher object.
269 */
270 do_action( 'wp_mail_smtp_mailcatcher_smtp_send_before', $this );
271
272 if ( ! $this->postSend() ) {
273 $this->throw_exception( $this->ErrorInfo );
274 }
275
276 // PHPMailer's mailSend()/sendmailSend() can return true under SingleTo=true even
277 // when individual recipients failed - the result variable is overwritten each loop.
278 // Route those through the existing catch by throwing here.
279 if ( ! empty( $this->failed_recipients ) ) {
280 $failed = array_unique( $this->failed_recipients );
281
282 $this->throw_exception(
283 sprintf(
284 esc_html(
285 /* translators: 1: count of failed recipients, 2: comma-separated email addresses. */
286 _n(
287 'The mail transport accepted the message but rejected %1$d recipient address: %2$s. Other recipients in the same email have been sent.',
288 'The mail transport accepted the message but rejected %1$d recipient addresses: %2$s. Other recipients in the same email have been sent.',
289 count( $failed ),
290 'wp-mail-smtp'
291 )
292 ),
293 count( $failed ),
294 implode( ', ', $failed )
295 )
296 );
297 }
298
299 DebugEvents::add_debug(
300 esc_html__( 'An email request was sent.', 'wp-mail-smtp' )
301 );
302
303 /**
304 * Fires after a successful SMTP/PHP-mail send.
305 *
306 * @since 1.5.0
307 * @since 4.9.0 Fires exactly once per send and only on success.
308 *
309 * @param bool $is_sent Always true. Kept for backwards-compatible signature.
310 * @param array $to To recipients.
311 * @param array $cc CC recipients.
312 * @param array $bcc BCC recipients.
313 * @param string $subject Email subject.
314 * @param string $body MIME-encoded body.
315 * @param string $from From address.
316 */
317 do_action( 'wp_mail_smtp_mailcatcher_smtp_send_after', true, $this->to, $this->cc, $this->bcc, $this->Subject, $this->MIMEBody, $this->From );
318
319 return true;
320 } catch ( Exception $e ) {
321 $this->mailHeader = '';
322
323 // We need this to append SMTP error to the `PHPMailer::ErrorInfo` property.
324 $this->setError( $e->getMessage() );
325
326 $error_message = 'Mailer: ' . esc_html( wp_mail_smtp()->get_providers()->get_options( $mailer_slug )->get_title() ) . "\r\n" . $this->ErrorInfo;
327 $error_code = $this->get_smtp_error_code();
328 $error_key = $this->build_error_key( '', $error_code, $this->ErrorInfo ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
329
330 $this->latest_error = $error_message;
331
332 $debug_event_message = $error_message;
333 $debug_output = implode( "\r\n", $this->debug_output_buffer );
334
335 if ( DebugEvents::is_debug_enabled() && ! empty( $debug_output ) ) {
336 $debug_event_message .= "\r\n" . esc_html__( 'SMTP Debug:', 'wp-mail-smtp' ) . "\r\n";
337 $debug_event_message .= $debug_output;
338 }
339
340 $this->debug_event_id = DebugEvents::add( $debug_event_message );
341
342 $this->record_send_failure( $connection, $mailer_slug, $error_code, $this->ErrorInfo, $this->debug_event_id, $debug_output, $error_key );
343
344 /**
345 * Fires after email sent failure via SMTP.
346 *
347 * @since 3.5.0
348 *
349 * @param string $error_message Error message.
350 * @param MailCatcherInterface $mailcatcher The MailCatcher object.
351 * @param string $mailer_slug Current mailer name.
352 * @param string $error_code Error code/slug.
353 * @param int $response_code HTTP/SMTP response code (0 for non-API mailers).
354 * @param ConnectionInterface $connection The connection object.
355 * @param string $error_key Composite error key.
356 */
357 do_action( 'wp_mail_smtp_mailcatcher_send_failed', $this->ErrorInfo, $this, $mailer_slug, $error_code, 0, $connection, $error_key );
358
359 if ( $this->exceptions ) {
360 throw $e;
361 }
362
363 return false;
364 } finally {
365
366 // Clear debug output buffer.
367 $this->debug_output_buffer = [];
368 }
369 // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
370 }
371
372 /**
373 * Send email via API integration.
374 *
375 * @since 4.0.0
376 *
377 * @param ConnectionInterface $connection The connection object.
378 *
379 * @throws Exception When sending fails for some reason.
380 *
381 * @return bool
382 */
383 private function api_send( $connection ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
384
385 $mailer_slug = $connection->get_mailer_slug();
386
387 // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
388 try {
389 // We need this so that the \PHPMailer class will correctly prepare all the headers.
390 $this->Mailer = 'mail';
391
392 /**
393 * Fires before email pre send.
394 *
395 * Allow to hook early to catch any early failed emails.
396 *
397 * @since 2.9.0
398 *
399 * @param MailCatcherInterface $mailcatcher The MailCatcher object.
400 */
401 do_action( 'wp_mail_smtp_mailcatcher_pre_send_before', $this );
402
403 // Prepare everything (including the message) for sending.
404 if ( ! $this->preSend() ) {
405 $this->throw_exception( $this->ErrorInfo );
406 }
407
408 $mailer = wp_mail_smtp()->get_providers()->get_mailer( $mailer_slug, $this, $connection );
409
410 if ( ! $mailer ) {
411 $this->throw_exception( 'The selected mailer not found.' );
412 }
413
414 if ( ! $mailer->is_php_compatible() ) {
415 $this->throw_exception( 'The selected mailer is not compatible with your PHP version.' );
416 }
417
418 /**
419 * Fires before email send.
420 *
421 * Allows to hook after all the preparation before the actual sending.
422 *
423 * @since 3.3.0
424 *
425 * @param MailerAbstract $mailer The Mailer object.
426 */
427 do_action( 'wp_mail_smtp_mailcatcher_send_before', $mailer );
428
429 /*
430 * Send the actual email.
431 * We reuse everything, that was preprocessed for usage in \PHPMailer.
432 */
433 $mailer->send();
434
435 $is_sent = $mailer->is_email_sent();
436
437 /**
438 * Fires after email send.
439 *
440 * Allow to perform any actions with the data.
441 *
442 * @since 3.5.0
443 *
444 * @param MailerAbstract $mailer The Mailer object.
445 * @param MailCatcherInterface $mailcatcher The MailCatcher object.
446 */
447 do_action( 'wp_mail_smtp_mailcatcher_send_after', $mailer, $this );
448
449 if ( $is_sent !== true ) {
450 $this->throw_exception( $mailer->get_response_error() );
451 }
452
453 return true;
454 } catch ( Exception $e ) {
455 // Add mailer to the beginning and save to display later.
456 $message = 'Mailer: ' . esc_html( wp_mail_smtp()->get_providers()->get_options( $mailer_slug )->get_title() ) . "\r\n";
457
458 $error_code = ! empty( $mailer ) ? $mailer->get_response_error_code() : '';
459 $response_code = ! empty( $mailer ) ? $mailer->get_response_code() : 0;
460 $error_key = $this->build_error_key( $response_code > 0 ? (string) $response_code : '', $error_code, $e->getMessage() );
461 $error_message = $message . $e->getMessage();
462 $this->debug_event_id = DebugEvents::add( $error_message );
463 $this->latest_error = $error_message;
464
465 $this->record_send_failure( $connection, $mailer_slug, $error_code, $e->getMessage(), $this->debug_event_id, '', $error_key );
466
467 /**
468 * Fires after email sent failure.
469 *
470 * @since 3.5.0
471 *
472 * @param string $error_message Error message.
473 * @param MailCatcherInterface $mailcatcher The MailCatcher object.
474 * @param string $mailer_slug Current mailer name.
475 * @param string $error_code Error code/slug.
476 * @param int $response_code HTTP response code.
477 * @param ConnectionInterface $connection The connection object.
478 * @param string $error_key Composite error key.
479 */
480 do_action( 'wp_mail_smtp_mailcatcher_send_failed', $e->getMessage(), $this, $mailer_slug, $error_code, $response_code, $connection, $error_key );
481
482 if ( $this->exceptions ) {
483 throw $e;
484 }
485
486 return false;
487 }
488 // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
489 }
490
491 /**
492 * Create a unique ID to use for multipart email boundaries.
493 *
494 * @since 2.4.0
495 *
496 * @return string
497 */
498 public function generate_id() {
499
500 return $this->generateId();
501 }
502
503 /**
504 * Get SMTP error code combining error category and numeric SMTP code.
505 *
506 * Reverse-lookups PHPMailer language key from ErrorInfo via static::$language,
507 * making the category language-independent even if PHPMailer is translated (WP 6.8+).
508 *
509 * @since 4.8.0
510 *
511 * @return string Error code like "authenticate_535", "connect_host", or empty.
512 */
513 private function get_smtp_error_code() {
514
515 $smtp_code = $this->last_error_code;
516 $category = '';
517
518 // Reverse-lookup PHPMailer language key from ErrorInfo.
519 // Use getTranslations() which works in both old (instance $language) and new (static $language) PHPMailer.
520 $language = method_exists( $this, 'getTranslations' ) ? $this->getTranslations() : [];
521
522 foreach ( $language as $key => $translated ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
523 if ( ! empty( $translated ) && strpos( $this->ErrorInfo, $translated ) === 0 ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
524 $category = $key;
525
526 break;
527 }
528 }
529
530 $parts = array_filter( [ $category, $smtp_code ] );
531
532 return ! empty( $parts ) ? implode( '_', $parts ) : '';
533 }
534
535 /**
536 * Build the composite error key: {prefix}:{error_code}:{sanitized_message}.
537 *
538 * Mirrors ErrorStats::build_error_key() for valid inputs; guards mbstring and
539 * null-coalesces preg results so it cannot throw on the send-failure path.
540 *
541 * @since 4.9.0
542 *
543 * @param string $prefix HTTP response code or category (e.g. "401", "delivery").
544 * @param string $error_code Provider/SMTP error code.
545 * @param string $error_message Error message text.
546 *
547 * @return string
548 */
549 private function build_error_key( $prefix, $error_code, $error_message ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
550
551 $max_length = 50;
552 $part_response = ! empty( $prefix ) ? $prefix : '-';
553 $part_code = ! empty( $error_code ) && $error_code !== 'unknown' && (string) $error_code !== $part_response ? $error_code : '-';
554 $part_message = ! empty( $error_message ) ? $this->sanitize_error_message( $error_message ) : '-';
555
556 if ( $part_response !== '-' ) {
557 $part_message = str_replace( sanitize_title( $part_response ), '', $part_message );
558 }
559
560 if ( $part_code !== '-' ) {
561 $part_message = str_replace( sanitize_title( $part_code ), '', $part_message );
562 }
563
564 $part_message = (string) preg_replace( '/-{2,}/', '-', $part_message );
565 $part_message = trim( $part_message, '-' );
566
567 if ( empty( $part_message ) ) {
568 $part_message = '-';
569 }
570
571 $key = $part_response . ':' . $part_code . ':' . $part_message;
572
573 // function_exists guard so a missing mbstring extension can never fatal on
574 // the send path; substr is an exact byte fallback for this ASCII key.
575 $key_length = function_exists( 'mb_strlen' ) ? mb_strlen( $key ) : strlen( $key );
576
577 if ( $key_length > $max_length ) {
578 $key = function_exists( 'mb_substr' ) ? mb_substr( $key, 0, $max_length ) : substr( $key, 0, $max_length );
579
580 $last_hyphen = strrpos( $key, '-' );
581
582 if ( $last_hyphen !== false && $last_hyphen > strrpos( $key, ':' ) ) {
583 $key = substr( $key, 0, $last_hyphen );
584 }
585 }
586
587 return $key;
588 }
589
590 /**
591 * Sanitize an error message into a short aggregatable slug.
592 *
593 * Mirrors ErrorStats::sanitize_message() with null-safe casts.
594 *
595 * @since 4.9.0
596 *
597 * @param string $message Error message.
598 *
599 * @return string
600 */
601 private function sanitize_error_message( $message ) {
602
603 $message = html_entity_decode( $message, ENT_QUOTES, 'UTF-8' );
604 $message = (string) preg_replace( '/\S+@\S+\.\S+/', '', $message );
605 $message = (string) preg_replace( '#https?://\S+#i', '', $message );
606 $message = (string) preg_replace( '/\b\S+\.\S+\b/', '', $message );
607 $message = (string) preg_replace( '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i', '', $message );
608 $message = (string) preg_replace( '/["\'][^"\']*["\']/', '', $message );
609 $message = trim( (string) preg_replace( '/\s+/', ' ', $message ) );
610
611 return sanitize_title( $message );
612 }
613
614 /**
615 * Write a failure record into EmailSendingDebug for the given connection.
616 *
617 * Shared between the SMTP and API catch paths - per-path values (error message,
618 * error code, debug event id) are computed locally and passed in.
619 *
620 * Also snapshots the environment data the failure-display layer needs to
621 * reconstruct a full debug bundle later (WP/PHP/plugin versions, multisite flag,
622 * settings-as-constants flag, plugin conflicts, mailer-specific debug info, and
623 * the captured SMTPDebug output). Capturing these at write-time matters because
624 * they reflect server state at the moment of failure (conflicts, constants
625 * enabled) and PHPMailer's transient SMTPDebug buffer is gone after the send.
626 *
627 * @since 4.9.0
628 *
629 * @param ConnectionInterface $connection Mail connection the failure belongs to.
630 * @param string $mailer_slug Current mailer slug.
631 * @param string $error_code Provider/SMTP-specific error code.
632 * @param string $error_message Human-readable error message.
633 * @param int|false $debug_event_id DebugEvents row id for this failure, or false when none.
634 * @param string $smtp_debug Captured PHPMailer SMTPDebug output, or empty string when none.
635 * @param string $error_key Composite error key, or empty string when not yet built.
636 */
637 private function record_send_failure( $connection, $mailer_slug, $error_code, $error_message, $debug_event_id, $smtp_debug = '', $error_key = '' ) {
638
639 $initiator = wp_mail_smtp()->get_wp_mail_initiator();
640 $connection_options = $connection->get_options();
641 $conflicts = new Conflicts();
642 $mailer = wp_mail_smtp()->get_providers()->get_mailer( $mailer_slug, $this, $connection );
643
644 EmailSendingDebug::set(
645 $connection->get_id(),
646 [
647 'occurred_at' => time(),
648 'context' => ( $this->is_test_email() || $this->is_setup_wizard_test_email() ) ? 'test' : 'regular',
649 'status' => 'failed',
650 'mailer' => $mailer_slug,
651 'error_code' => $error_code,
652 'error_key' => $error_key,
653 'error_message' => $error_message,
654 'initiator_name' => $initiator->get_name(),
655 'initiator_file' => $initiator->get_file() . ':' . $initiator->get_line(),
656 'debug_event_id' => $debug_event_id,
657 'wp_version' => get_bloginfo( 'version' ),
658 'is_multisite' => is_multisite(),
659 'php_version' => PHP_VERSION,
660 'plugin_version' => WPMS_PLUGIN_VER,
661 'constants_enabled' => $connection_options->is_const_enabled(),
662 'conflicts' => $conflicts->is_detected() ? $conflicts->get_all_conflict_names() : [],
663 'mailer_debug_info' => $mailer ? $mailer->get_debug_info() : '',
664 'smtp_debug_info' => $smtp_debug,
665 ]
666 );
667 }
668
669 /**
670 * Override setError to capture SMTP error codes before PHPMailer clears them.
671 *
672 * PHPMailer's SMTP quit() clears the error via sendCommand success.
673 * By the time we catch the exception, the SMTP error code is gone.
674 * Uses first-write-wins so subsequent setError calls (RSET, connect_host fallback)
675 * cannot overwrite the original meaningful code.
676 *
677 * @since 4.8.0
678 *
679 * @param string $msg Error message.
680 */
681 protected function setError( $msg ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
682
683 // Capture SMTP error code with first-write-wins before quit() wipes it.
684 if (
685 empty( $this->last_error_code ) &&
686 isset( $this->smtp ) &&
687 $this->smtp instanceof \PHPMailer\PHPMailer\SMTP
688 ) {
689 $error = $this->smtp->getError();
690
691 if ( ! empty( $error['smtp_code'] ) ) {
692 $this->last_error_code = (string) $error['smtp_code'];
693 }
694 }
695
696 parent::setError( $msg );
697 }
698
699 /**
700 * Override PHPMailer's per-send callback fan-out.
701 *
702 * PHPMailer dispatches this once per recipient in smtpSend(), and once per
703 * recipient under SingleTo=true in mailSend()/sendmailSend(). We collect failed
704 * recipient addresses and let smtp_send() decide once, at the end, whether the
705 * overall send succeeded.
706 *
707 * @since 4.9.0
708 *
709 * @param bool $is_sent Whether PHPMailer accepted this recipient.
710 * @param array|string $to Recipients for this callback (single recipient in
711 * the per-recipient paths; full list in default mailSend).
712 * @param array $cc CC addresses (unused).
713 * @param array $bcc BCC addresses (unused).
714 * @param string $subject Subject (unused).
715 * @param string $body Body (unused).
716 * @param string $from From address (unused).
717 * @param array $extra Extra data (unused).
718 */
719 protected function doCallback( $is_sent, $to, $cc, $bcc, $subject, $body, $from, $extra ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid, WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
720
721 if ( $is_sent ) {
722 return;
723 }
724
725 // $to is an array of [email, name] tuples; $recipient[0] is the email field of
726 // the current tuple, not the first recipient. Loop captures every failed address.
727 foreach ( (array) $to as $recipient ) {
728 if ( is_array( $recipient ) && isset( $recipient[0] ) && is_string( $recipient[0] ) && $recipient[0] !== '' ) {
729 $this->failed_recipients[] = $recipient[0];
730 } elseif ( is_string( $recipient ) && $recipient !== '' ) {
731 $this->failed_recipients[] = $recipient;
732 }
733 }
734 }
735
736 /**
737 * Debug output callback.
738 * Save debugging info to buffer array.
739 *
740 * @since 3.3.0
741 *
742 * @param string $str Message.
743 * @param int $level Debug level.
744 */
745 public function debug_output_callback( $str, $level ) {
746
747 /*
748 * Filter out all higher levels than 3.
749 * SMTPDebug level 3 is commands, data and connection status.
750 */
751 if ( $level > 3 ) {
752 return;
753 }
754
755 $this->debug_output_buffer[] = trim( $str, "\r\n" );
756 }
757
758 /**
759 * Get debug event ID.
760 *
761 * @since 3.5.0
762 *
763 * @return bool|int
764 */
765 public function get_debug_event_id() {
766
767 return $this->debug_event_id;
768 }
769
770 /**
771 * Whether the current email is a test email.
772 *
773 * @since 3.5.0
774 *
775 * @return bool
776 */
777 public function is_test_email() {
778
779 return $this->is_test_email;
780 }
781
782 /**
783 * Whether the current email is a Setup Wizard test email.
784 *
785 * @since 3.5.0
786 *
787 * @return bool
788 */
789 public function is_setup_wizard_test_email() {
790
791 return $this->is_setup_wizard_test_email;
792 }
793
794 /**
795 * Whether the current email is blocked to be sent.
796 *
797 * @since 3.8.0
798 *
799 * @return bool
800 */
801 public function is_emailing_blocked() {
802
803 return $this->is_emailing_blocked;
804 }
805
806 /**
807 * Return the list of properties representing
808 * this class' state.
809 *
810 * @since 4.0.0
811 *
812 * @return array State of this class.
813 */
814 private function get_state_properties() {
815
816 return [
817 'CharSet',
818 'ContentType',
819 'Encoding',
820 'CustomHeader',
821 'Subject',
822 'Body',
823 'AltBody',
824 'ReplyTo',
825 'to',
826 'cc',
827 'bcc',
828 'attachment',
829 ];
830 }
831
832 /**
833 * Return an array of relevant properties.
834 *
835 * @since 4.0.0
836 *
837 * @return array State of this class.
838 */
839 public function get_state() {
840
841 $state = [];
842
843 foreach ( $this->get_state_properties() as $property ) {
844 $state[ $property ] = $this->{$property};
845 }
846
847 return $state;
848 }
849
850 /**
851 * Set properties from a provided array of data.
852 *
853 * @since 4.0.0
854 *
855 * @param array $state Array of properties to apply.
856 */
857 public function set_state( $state ) { // phpcs:ignore Generic.Metrics.NestingLevel.MaxExceeded
858
859 // Filter out non-allowed properties.
860 $state = array_intersect_key(
861 $state,
862 array_flip( $this->get_state_properties() )
863 );
864
865 foreach ( $state as $property => $value ) {
866 if ( $property !== 'attachment' ) {
867 $this->{$property} = $value;
868 } else {
869 // Handle potential I/O exceptions
870 // in PHPMailer when attaching files.
871 $this->clearAttachments();
872
873 foreach ( $state['attachment'] as $attachment ) {
874 [ $path, , $name ] = $attachment;
875
876 try {
877 $this->addAttachment( $path, $name );
878 } catch ( Exception $e ) {
879 continue;
880 }
881 }
882 }
883 }
884 }
885
886 /**
887 * Set the From and FromName properties.
888 *
889 * @since 4.7.1
890 *
891 * @param string $address Email address.
892 * @param string $name Name.
893 * @param bool $auto Whether to also set the Sender address, defaults to true.
894 *
895 * @return bool Returns true on success and false on failure.
896 */
897 public function setFrom( $address, $name = '', $auto = true ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
898
899 // Set `$auto` param as false, to control return-path via plugin settings.
900 return parent::setFrom( $address, $name, false );
901 }
902 }
903