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 / UsageTracking / ErrorStats.php
wp-mail-smtp / src / UsageTracking Last commit date
ErrorStats.php 6 days ago SendUsageTask.php 6 days ago UsageTracking.php 6 days ago
ErrorStats.php
330 lines
1 <?php
2 /**
3 * Error statistics tracking.
4 *
5 * Handles tracking of email sending errors for usage stats.
6 *
7 * @since 4.8.0
8 */
9
10 namespace WPMailSMTP\UsageTracking;
11
12 use WPMailSMTP\Helpers\Helpers;
13 use WPMailSMTP\MailCatcherInterface;
14 use WPMailSMTP\Options;
15
16 /**
17 * Class ErrorStats.
18 *
19 * Tracks email sending errors in memory during a request,
20 * then flushes to the database on shutdown if any errors were recorded.
21 *
22 * @since 4.8.0
23 */
24 class ErrorStats {
25
26 /**
27 * Option name to store error stats.
28 *
29 * @since 4.8.0
30 *
31 * @var string
32 */
33 const OPTION_NAME = 'wp_mail_smtp_email_sending_errors_stat';
34
35 /**
36 * Max length for error code string.
37 *
38 * @since 4.8.0
39 *
40 * @var int
41 */
42 const MAX_ERROR_CODE_LENGTH = 50;
43
44 /**
45 * Error stats accumulated during the current request.
46 *
47 * @since 4.8.0
48 *
49 * @var array
50 */
51 private $pending_stats = [];
52
53 /**
54 * Whether the shutdown flush hook has been registered.
55 *
56 * @since 4.8.0
57 *
58 * @var bool
59 */
60 private $shutdown_registered = false;
61
62 /**
63 * Register hooks.
64 *
65 * @since 4.8.0
66 */
67 public function hooks() {
68
69 // Track email sending errors.
70 add_action( 'wp_mail_smtp_mailcatcher_send_failed', [ $this, 'track_send_failed' ], 10, 7 );
71 // Track email delivery failures (webhooks, delivery verification).
72 add_action( 'wp_mail_smtp_email_delivery_failed', [ $this, 'track_delivery_failed' ], 10, 3 );
73 // Add data to usage tracking.
74 add_filter( 'wp_mail_smtp_usage_tracking_get_data', [ $this, 'add_usage_stats' ] );
75 }
76
77 /**
78 * Track email sending error from MailCatcher.
79 *
80 * @since 4.8.0
81 * @since 4.9.0 Added $response_code, $connection, and $error_key params.
82 *
83 * @param string $error_message Error message.
84 * @param MailCatcherInterface $mailcatcher The MailCatcher object.
85 * @param string $mailer_slug Current mailer name.
86 * @param string $error_code Error code.
87 * @param int $response_code HTTP response code.
88 * @param ConnectionInterface $connection Connection object or null.
89 * @param string $error_key Pre-built composite error key.
90 */
91 public function track_send_failed( $error_message, $mailcatcher, $mailer_slug, $error_code = '', $response_code = 0, $connection = null, $error_key = '' ) {
92
93 $mailer_slug = $this->resolve_mailer_slug( $mailer_slug );
94
95 $this->track_error( $mailer_slug, $error_key );
96 }
97
98 /**
99 * Track email delivery failure from webhooks or delivery verification.
100 *
101 * @since 4.8.0
102 *
103 * @param string $mailer_slug Current mailer name.
104 * @param string $error_code Error code.
105 * @param string $error_message Error message.
106 */
107 public function track_delivery_failed( $mailer_slug, $error_code, $error_message = '' ) {
108
109 $error_key = $this->build_error_key( 'delivery', (string) $error_code, (string) $error_message );
110
111 $this->track_error( $mailer_slug, $error_key );
112 }
113
114 /**
115 * Track email sending error.
116 *
117 * Accumulates error in memory. All accumulated stats are flushed
118 * to the database on shutdown.
119 *
120 * @since 4.8.0
121 *
122 * @param string $mailer_slug Current mailer name.
123 * @param string $error_key Normalized error key.
124 */
125 private function track_error( $mailer_slug, $error_key ) { // phpcs:ignore WPForms.PHP.HooksMethod.InvalidPlaceForAddingHooks
126
127 if ( ! isset( $this->pending_stats[ $mailer_slug ] ) ) {
128 $this->pending_stats[ $mailer_slug ] = [];
129 }
130
131 if ( ! isset( $this->pending_stats[ $mailer_slug ][ $error_key ] ) ) {
132 $this->pending_stats[ $mailer_slug ][ $error_key ] = 0;
133 }
134
135 $this->pending_stats[ $mailer_slug ][ $error_key ]++;
136
137 // Register shutdown flush lazily on first tracked error.
138 if ( ! $this->shutdown_registered ) {
139 add_action( 'shutdown', [ $this, 'flush' ] );
140 $this->shutdown_registered = true;
141 }
142 }
143
144 /**
145 * Build error key in format: {prefix}:{error_code}:{sanitized_message}.
146 *
147 * Uses "-" for missing parts to keep the format parseable.
148 *
149 * @since 4.8.0
150 *
151 * @param string $prefix First segment — HTTP response code or category (e.g. "401", "delivery").
152 * @param string $error_code API-specific error code.
153 * @param string $error_message Error message text.
154 *
155 * @return string Normalized error key.
156 */
157 private function build_error_key( $prefix, $error_code, $error_message ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
158
159 $part_response = ! empty( $prefix ) ? $prefix : '-';
160 $part_code = ! empty( $error_code ) && $error_code !== 'unknown' && (string) $error_code !== $part_response ? $error_code : '-';
161 $part_message = ! empty( $error_message ) ? $this->sanitize_message( $error_message ) : '-';
162
163 // Remove response code and error code from message to save space.
164 if ( $part_response !== '-' ) {
165 $part_message = str_replace( sanitize_title( $part_response ), '', $part_message );
166 }
167
168 if ( $part_code !== '-' ) {
169 $part_message = str_replace( sanitize_title( $part_code ), '', $part_message );
170 }
171
172 $part_message = preg_replace( '/-{2,}/', '-', $part_message );
173 $part_message = trim( $part_message, '-' );
174
175 if ( empty( $part_message ) ) {
176 $part_message = '-';
177 }
178
179 $key = $part_response . ':' . $part_code . ':' . $part_message;
180
181 if ( ! function_exists( 'mb_strlen' ) ) {
182 Helpers::include_mbstring_polyfill();
183 }
184
185 if ( mb_strlen( $key ) > self::MAX_ERROR_CODE_LENGTH ) {
186 $key = mb_substr( $key, 0, self::MAX_ERROR_CODE_LENGTH );
187
188 // Cut at last hyphen to avoid partial words.
189 $last_hyphen = strrpos( $key, '-' );
190
191 if ( $last_hyphen !== false && $last_hyphen > strrpos( $key, ':' ) ) {
192 $key = substr( $key, 0, $last_hyphen );
193 }
194 }
195
196 return $key;
197 }
198
199 /**
200 * Sanitize error message into a short aggregatable slug.
201 *
202 * Strips dynamic content (emails, domains, URLs, UUIDs, quoted strings),
203 * takes first ~5 words, and slugifies.
204 *
205 * @since 4.8.0
206 *
207 * @param string $message Error message.
208 *
209 * @return string Sanitized message slug.
210 */
211 private function sanitize_message( $message ) {
212
213 // Strip HTML entities.
214 $message = html_entity_decode( $message, ENT_QUOTES, 'UTF-8' );
215
216 // Strip emails.
217 $message = preg_replace( '/\S+@\S+\.\S+/', '', $message );
218
219 // Strip URLs.
220 $message = preg_replace( '#https?://\S+#i', '', $message );
221
222 // Strip dot-separated strings (domains, namespaces, etc.).
223 $message = preg_replace( '/\b\S+\.\S+\b/', '', $message );
224
225 // Strip UUIDs.
226 $message = preg_replace( '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i', '', $message );
227
228 // Strip quoted strings.
229 $message = preg_replace( '/["\'][^"\']*["\']/', '', $message );
230
231 // Collapse whitespace and trim.
232 $message = trim( preg_replace( '/\s+/', ' ', $message ) );
233
234 return sanitize_title( $message );
235 }
236
237 /**
238 * Resolve mailer slug, distinguishing one-click setups.
239 *
240 * @since 4.8.0
241 *
242 * @param string $mailer_slug Original mailer slug.
243 *
244 * @return string Resolved mailer slug.
245 */
246 private function resolve_mailer_slug( $mailer_slug ) {
247
248 if ( $mailer_slug === 'gmail' && Options::init()->get( 'gmail', 'one_click_setup_enabled' ) ) {
249 return 'gmail_one_click';
250 }
251
252 if ( $mailer_slug === 'outlook' && Options::init()->get( 'outlook', 'one_click_setup_enabled' ) ) {
253 return 'outlook_one_click';
254 }
255
256 return $mailer_slug;
257 }
258
259 /**
260 * Flush accumulated stats to the database.
261 *
262 * Reads current stored stats, merges with pending stats, and saves.
263 * Bails early if no errors were tracked during this request.
264 *
265 * @since 4.8.0
266 */
267 public function flush() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
268
269 if ( empty( $this->pending_stats ) ) {
270 return;
271 }
272
273 $stored = get_option( self::OPTION_NAME, [] );
274
275 if ( ! is_array( $stored ) ) {
276 $stored = [];
277 }
278
279 foreach ( $this->pending_stats as $mailer_slug => $error_codes ) {
280 if ( ! isset( $stored[ $mailer_slug ] ) ) {
281 $stored[ $mailer_slug ] = [];
282 }
283
284 foreach ( $error_codes as $error_code => $count ) {
285 // Apply overflow limit against the merged state.
286 if (
287 count( $stored[ $mailer_slug ] ) >= 50 &&
288 ! isset( $stored[ $mailer_slug ][ $error_code ] )
289 ) {
290 $error_code = 'overflow';
291 }
292
293 if ( ! isset( $stored[ $mailer_slug ][ $error_code ] ) ) {
294 $stored[ $mailer_slug ][ $error_code ] = 0;
295 }
296
297 $stored[ $mailer_slug ][ $error_code ] += $count;
298 }
299 }
300
301 update_option( self::OPTION_NAME, $stored, false );
302
303 $this->pending_stats = [];
304 }
305
306 /**
307 * Add usage stats data.
308 *
309 * @since 4.8.0
310 *
311 * @param array $data Usage data.
312 *
313 * @return array
314 */
315 public function add_usage_stats( $data ) {
316
317 // Flush any pending stats before reading.
318 $this->flush();
319
320 $stats = get_option( self::OPTION_NAME, [] );
321
322 $data['email_sending_errors_stat'] = is_array( $stats ) ? $stats : [];
323
324 // Reset after collecting.
325 delete_option( self::OPTION_NAME );
326
327 return $data;
328 }
329 }
330