PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/emails/email-handler.php +866 -88 0.0.1 → 1.6.1 View file →
@@ -6,9 +6,12 @@
6 6 */
7 7
8 8 namespace SureDonation\Inc\Emails;
9 9
10 +use SureDonation\Inc\Database\Tables\Donations;
11 +use SureDonation\Inc\FormEditor\Assets;
10 12 use SureDonation\Inc\Helper;
13 +use SureDonation\Inc\Payments\Offline\Offline_Helper;
11 14 use SureDonation\Inc\Payments\Payment_Helper;
12 15
13 16 // Exit if accessed directly.
14 17 if ( ! defined( 'ABSPATH' ) ) {
@@ -17,124 +20,732 @@
17 20
18 21 /**
19 22 * Email_Handler class.
20 23 *
24 + * Reads email notification config from per-form post meta
25 + * (_suredonation_form_email_notifications) and sends all enabled
26 + * notifications when a donation event occurs.
27 + *
21 28 * @since 0.0.1
22 29 */
23 30 class Email_Handler {
24 31 /**
25 - * Option key for email notifications within consolidated options.
32 + * Valid trigger event types.
26 33 *
27 - * @since 0.0.1
34 + * @since 1.0.0
28 35 */
29 - public const OPTION_KEY = 'email_notifications';
36 + public const EVENT_DONATION_COMPLETED = 'donation_completed';
37 + public const EVENT_DONATION_PROCESSING = 'donation_processing';
38 + public const EVENT_DONATION_FAILED = 'donation_failed';
39 + public const EVENT_REFUND_PROCESSED = 'refund_processed';
30 40
31 41 /**
32 - * Send donation confirmation email to donor.
42 + * Send email notifications matching a specific event.
33 43 *
44 + * Only notifications whose trigger matches the event (or trigger 'all') are sent.
45 + *
34 46 * @param int $donation_id Donation ID.
35 47 * @param int $campaign_id Campaign ID.
36 48 * @param array<string, mixed> $donation_data Donation data array.
37 - * @return bool True if email was sent successfully.
38 - * @since 0.0.1
49 + * @param int $form_id Form post ID.
50 + * @param string $event The event that triggered this call.
51 + * @return void
52 + * @since 1.0.0
39 53 */
40 - public static function send_donation_confirmation( $donation_id, $campaign_id, $donation_data ) {
41 - $notification = self::get_notification( 'donation_receipt' );
54 + public static function send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id = 0, $event = self::EVENT_DONATION_COMPLETED ) {
55 + // Prevent duplicate emails for the same donation + event (e.g. AJAX and
56 + // webhook racing). Claimed immediately after the check rather than once
57 + // the form and campaign are resolved: those lookups are a DB read, the
58 + // notification merge and a get_post(), and holding the gap open across
59 + // them lets both racers pass the check before either claims — which is
60 + // exactly the pair this lock exists to separate, and they arrive together
61 + // on essentially every Stripe donation.
62 + //
63 + // A claim that resolves nothing is released again at each early return
64 + // below, so a caller that bailed does not swallow the retry that would
65 + // have succeeded.
66 + //
67 + // get/set is still non-atomic (TOCTOU) and the worst case is a duplicate
68 + // email, not data corruption. wp_cache_add() would only be atomic with an
69 + // external object cache; most installs use DB transients.
70 + $lock_key = $donation_id > 0 ? 'suredonation_email_lock_' . $event . '_' . $donation_id : '';
42 71
43 - if ( ! $notification ) {
44 - return false;
72 + if ( '' !== $lock_key && get_transient( $lock_key ) ) {
73 + return;
45 74 }
46 75
47 - // Check if donor email is available.
48 - $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : '';
49 - if ( empty( $donor_email ) || ! is_email( $donor_email ) ) {
50 - return false;
76 + if ( '' !== $lock_key ) {
77 + set_transient( $lock_key, true, 60 );
78 +
79 + // Companion to the 60-second race lock, kept long enough to answer a
80 + // different question: has this donation's email for this event been
81 + // attempted at all? The webhook reconciler needs that to decide
82 + // whether a donation the frontend already completed still owes the
83 + // donor a receipt, and the race lock expires far too soon to say.
84 + set_transient( self::sent_marker_key( $event, $donation_id ), true, WEEK_IN_SECONDS );
51 85 }
52 86
53 - // Get campaign data.
54 - $campaign = get_post( $campaign_id );
55 - if ( ! $campaign ) {
56 - return false;
87 + // One lookup covers both the form and the donation timestamp; callers
88 + // build their own data array and rarely carry created_at.
89 + $needs_form_id = empty( $form_id );
90 + $needs_timestamp = empty( $donation_data['created_at'] );
91 + // The submitted form fields live in the donation_data JSON column, which
92 + // callers building their own array never carry. Resolved here, once, so
93 + // the {form_fields} tag does not re-read the row for every tag pass.
94 + $needs_fields = ! isset( $donation_data['fields'] );
95 +
96 + if ( ( $needs_form_id || $needs_timestamp || $needs_fields ) && ! empty( $donation_id ) ) {
97 + $donation = Donations::get( $donation_id );
98 +
99 + if ( is_array( $donation ) ) {
100 + if ( $needs_form_id && isset( $donation['form_id'] ) && is_scalar( $donation['form_id'] ) ) {
101 + $form_id = absint( $donation['form_id'] );
102 + }
103 + if ( $needs_timestamp && isset( $donation['created_at'] ) && is_string( $donation['created_at'] ) ) {
104 + $donation_data['created_at'] = $donation['created_at'];
105 + }
106 + if ( $needs_fields ) {
107 + $donation_data['fields'] = self::extract_stored_fields( $donation );
108 + }
109 + }
57 110 }
58 111
59 - // Send donor receipt email.
60 - $sent = self::send_email(
61 - $donor_email,
62 - $notification,
63 - $donation_data,
64 - $campaign,
65 - $donation_id
66 - );
112 + $notifications = self::get_form_notifications( $form_id );
67 113
68 - // Also send admin notification if enabled.
69 - self::send_admin_new_donation( $donation_id, $campaign_id, $donation_data );
114 + if ( empty( $notifications ) ) {
115 + self::release_send_lock( $lock_key );
116 + return;
117 + }
70 118
71 - return $sent;
119 + // campaign_id 0 is a supported standalone form, not an error — see the
120 + // note on Donations::add(). Everything downstream already tolerates a
121 + // null campaign, so only a genuinely missing campaign should stop the send.
122 + $campaign = $campaign_id > 0 ? get_post( $campaign_id ) : null;
123 + if ( $campaign_id > 0 && ! $campaign ) {
124 + self::release_send_lock( $lock_key );
125 + return;
126 + }
127 +
128 + foreach ( $notifications as $notification ) {
129 + if ( empty( $notification['status'] ) ) {
130 + continue;
131 + }
132 +
133 + // Only send notifications whose trigger matches the current event.
134 + $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : '';
135 + if ( empty( $trigger ) || ( 'all' !== $trigger && $trigger !== $event ) ) {
136 + continue;
137 + }
138 +
139 + // Resolve email_to using smart tags.
140 + $email_to_raw = isset( $notification['email_to'] ) && is_string( $notification['email_to'] ) ? $notification['email_to'] : '';
141 + $email_to = self::process_smart_tags( $email_to_raw, $donation_data, $campaign );
142 +
143 + // Support comma-separated recipients.
144 + $recipients = array_map( 'trim', explode( ',', $email_to ) );
145 + $recipients = array_filter(
146 + $recipients,
147 + static function ( string $email ): bool {
148 + return (bool) is_email( $email );
149 + }
150 + );
151 +
152 + if ( empty( $recipients ) ) {
153 + continue;
154 + }
155 +
156 + foreach ( $recipients as $recipient ) {
157 + self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id, $event );
158 + }
159 + }
72 160 }
73 161
74 162 /**
75 - * Send new donation notification to admin.
163 + * Send donation confirmation emails.
76 164 *
77 165 * @param int $donation_id Donation ID.
78 166 * @param int $campaign_id Campaign ID.
79 167 * @param array<string, mixed> $donation_data Donation data array.
80 - * @return bool True if email was sent successfully.
168 + * @param int $form_id Form post ID.
169 + * @return void
81 170 * @since 0.0.1
82 171 */
83 - public static function send_admin_new_donation( $donation_id, $campaign_id, $donation_data ) {
84 - $notification = self::get_notification( 'admin_new_donation' );
172 + /**
173 + * Transient key recording that an email was attempted for a donation+event.
174 + *
175 + * @param string $event Email event.
176 + * @param int $donation_id Donation ID.
177 + * @return string
178 + * @since 1.6.0
179 + */
180 + public static function sent_marker_key( $event, $donation_id ) {
181 + return 'suredonation_email_sent_' . (string) $event . '_' . absint( $donation_id );
182 + }
85 183
86 - if ( ! $notification ) {
87 - return false;
184 + /**
185 + * Whether an email for this donation and event has already been attempted.
186 + *
187 + * Answers "does this donation still owe the donor a receipt?", which the
188 + * PayPal webhook reconciler asks about a donation the frontend already
189 + * marked completed. Deliberately not `receipt_sent`: that column is written
190 + * only by Pro's PDF attachment path, so on free-only sites it is never set.
191 + *
192 + * @param int $donation_id Donation ID.
193 + * @param string $event Email event; defaults to the donation receipt.
194 + * @return bool
195 + * @since 1.6.0
196 + */
197 + public static function has_sent( $donation_id, $event = self::EVENT_DONATION_COMPLETED ) {
198 + return (bool) get_transient( self::sent_marker_key( $event, $donation_id ) );
199 + }
200 +
201 + /**
202 + * Send donation confirmation emails.
203 + *
204 + * @param int $donation_id Donation ID.
205 + * @param int $campaign_id Campaign ID.
206 + * @param array<string, mixed> $donation_data Donation data array.
207 + * @param int $form_id Form post ID.
208 + * @return void
209 + * @since 1.0.0
210 + */
211 + public static function send_donation_confirmation( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) {
212 + self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_COMPLETED );
213 + }
214 +
215 + /**
216 + * Send donation processing emails.
217 + *
218 + * @param int $donation_id Donation ID.
219 + * @param int $campaign_id Campaign ID.
220 + * @param array<string, mixed> $donation_data Donation data array.
221 + * @param int $form_id Form post ID.
222 + * @return void
223 + * @since 1.0.0
224 + */
225 + public static function send_donation_processing( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) {
226 + self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_PROCESSING );
227 + }
228 +
229 + /**
230 + * Send donation failed emails.
231 + *
232 + * @param int $donation_id Donation ID.
233 + * @param int $campaign_id Campaign ID.
234 + * @param array<string, mixed> $donation_data Donation data array.
235 + * @param int $form_id Form post ID.
236 + * @return void
237 + * @since 1.0.0
238 + */
239 + public static function send_donation_failed( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) {
240 + self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_FAILED );
241 + }
242 +
243 + /**
244 + * Send refund processed emails.
245 + *
246 + * @param int $donation_id Donation ID.
247 + * @param int $campaign_id Campaign ID.
248 + * @param array<string, mixed> $donation_data Donation data array.
249 + * @param int $form_id Form post ID.
250 + * @return void
251 + * @since 1.0.0
252 + */
253 + public static function send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) {
254 + self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_REFUND_PROCESSED );
255 + }
256 +
257 + /**
258 + * Option recording that stored rows have had their keys back-filled.
259 + *
260 + * @since 1.4.0
261 + */
262 + public const KEY_BACKFILL_OPTION = 'suredonation_notification_keys_backfilled';
263 +
264 + /**
265 + * Write resolved identities back to rows saved before keys existed.
266 + *
267 + * Resolution is otherwise re-derived on every send and never persisted, so a
268 + * form saved before this release stays dependent on guesswork for the rest of
269 + * its life — and stays exposed to whatever breaks the guess, whether that is
270 + * a rename, a locale difference or an edited recipient. Doing it once, here,
271 + * is what makes the fallback a migration rather than a permanent code path.
272 + *
273 + * Deliberately runs in admin context only: it writes, and the read path it
274 + * repairs is reached during donor payment requests.
275 + *
276 + * Only identity is written: keys, and the triggers the old sanitizer
277 + * overwrote. Deliberately not the dedupe — it discards one of two rows that
278 + * resolve to the same key, and where both were customised that is an edit
279 + * the admin cannot get back. In memory that loss lasts one request; on disk
280 + * it is permanent. Collapsing duplicates costs nothing to redo per read, so
281 + * it stays there.
282 + *
283 + * Missing defaults are not appended either. That is a read-time concern
284 + * which depends on whether Pro is active, and baking today's answer into the
285 + * row would strand the form the next time that changes.
286 + *
287 + * @return void
288 + * @since 1.4.0
289 + */
290 + public static function backfill_notification_keys() {
291 + $defaults = Assets::get_instance()->get_default_email_notifications();
292 +
293 + // Keyed to the defaults that were available, not a bare "done" flag.
294 + // Which rows can be identified depends on what is registered at the time:
295 + // with Pro inactive or on an older build, its templates and their former
296 + // names are simply absent, and its rows resolve to nothing. Recording the
297 + // set means the pass runs again once that set changes — when Pro is
298 + // activated or updated — instead of a one-time run deciding forever.
299 + $signature = self::default_keys_signature( $defaults );
300 +
301 + if ( get_option( self::KEY_BACKFILL_OPTION ) === $signature ) {
302 + return;
88 303 }
89 304
90 - // Get campaign data.
91 - $campaign = get_post( $campaign_id );
92 - if ( ! $campaign ) {
93 - return false;
305 + global $wpdb;
306 +
307 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration over a meta key with no API equivalent.
308 + $rows = $wpdb->get_results(
309 + $wpdb->prepare(
310 + "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value != ''",
311 + Assets::EMAIL_NOTIFICATIONS_META_KEY
312 + )
313 + );
314 +
315 + if ( is_array( $rows ) && ! empty( $rows ) ) {
316 + foreach ( $rows as $row ) {
317 + $stored = json_decode( (string) $row->meta_value, true );
318 +
319 + if ( ! is_array( $stored ) || empty( $stored ) ) {
320 + continue;
321 + }
322 +
323 + $resolved = self::add_identity_keys( $stored, $defaults );
324 + $resolved = self::restore_rewritten_triggers( $resolved, $defaults );
325 +
326 + // Only rows that were identified are written. A row that resolved
327 + // to nothing keeps exactly what it had, including its status: the
328 + // read path still parks it so it cannot mis-send, but parking it
329 + // on disk would outlive the reason for it — a row unidentifiable
330 + // today because Pro is a version behind would stay switched off
331 + // after Pro caught up, with nothing to switch it back.
332 + foreach ( $resolved as $index => $row_data ) {
333 + if ( empty( $row_data['key'] ) ) {
334 + $resolved[ $index ] = $stored[ $index ];
335 + }
336 + }
337 +
338 + if ( $resolved === $stored ) {
339 + continue;
340 + }
341 +
342 + update_post_meta(
343 + (int) $row->post_id,
344 + Assets::EMAIL_NOTIFICATIONS_META_KEY,
345 + wp_slash( (string) wp_json_encode( $resolved ) )
346 + );
347 + }
94 348 }
95 349
96 - // Send to admin email.
97 - $admin_email = get_option( 'admin_email' );
98 - $admin_email = is_string( $admin_email ) ? $admin_email : '';
350 + update_option( self::KEY_BACKFILL_OPTION, $signature, false );
351 + }
99 352
100 - return self::send_email(
101 - $admin_email,
102 - $notification,
103 - $donation_data,
104 - $campaign,
105 - $donation_id
106 - );
353 + /**
354 + * Signature of the default keys currently registered.
355 + *
356 + * @param array<int, array<string, mixed>> $defaults Default notifications.
357 + * @return string Signature.
358 + * @since 1.4.0
359 + */
360 + private static function default_keys_signature( $defaults ) {
361 + $keys = [];
362 +
363 + foreach ( $defaults as $default ) {
364 + if ( ! empty( $default['key'] ) && is_string( $default['key'] ) ) {
365 + $keys[] = $default['key'];
366 + }
367 + }
368 +
369 + sort( $keys );
370 +
371 + return md5( (string) wp_json_encode( $keys ) );
107 372 }
108 373
109 374 /**
110 - * Get notification settings by type.
375 + * Give back a send lock claimed by a call that delivered nothing.
111 376 *
112 - * @param string $notification_type Notification type key.
113 - * @return array<string, mixed>|false Notification settings or false if not found/disabled.
114 - * @since 0.0.1
377 + * @param string $lock_key Lock transient name, or '' when unlocked.
378 + * @return void
379 + * @since 1.4.0
115 380 */
116 - private static function get_notification( $notification_type ) {
117 - $notifications = Helper::get_suredonation_option( self::OPTION_KEY, [] );
381 + private static function release_send_lock( $lock_key ) {
382 + if ( '' !== $lock_key ) {
383 + delete_transient( $lock_key );
384 + }
385 + }
118 386
119 - // Ensure notifications is an array before accessing offsets.
120 - if ( ! is_array( $notifications ) || empty( $notifications[ $notification_type ] ) ) {
387 + /**
388 + * Get email notifications for a form.
389 + *
390 + * Stored settings are authoritative, but they are only written when an admin
391 + * opens the form's Email Notifications tab and saves. Until then the meta is
392 + * empty, and reading it literally means a form sends nothing at all. Defaults
393 + * fill the gaps so delivery never depends on having visited the editor.
394 + *
395 + * Resolved per read and not persisted: sending happens mid-payment, and a
396 + * write there would be a side effect on a path that only needs to read. The
397 + * resolution is persisted once by backfill_notification_keys(), which runs
398 + * in admin context, so this stays a fallback rather than the normal path.
399 + *
400 + * @param int $form_id Form post ID.
401 + * @return array<int, array<string, mixed>> Array of notification configs.
402 + * @since 1.0.0
403 + */
404 + private static function get_form_notifications( $form_id ) {
405 + if ( empty( $form_id ) ) {
406 + return [];
407 + }
408 +
409 + $raw = get_post_meta( $form_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, true );
410 + $notifications = is_string( $raw ) && '' !== $raw ? json_decode( $raw, true ) : null;
411 +
412 + if ( ! is_array( $notifications ) ) {
413 + $notifications = [];
414 + }
415 +
416 + $defaults = Assets::get_instance()->get_default_email_notifications();
417 +
418 + if ( empty( $notifications ) ) {
419 + return $defaults;
420 + }
421 +
422 + $notifications = self::add_identity_keys( $notifications, $defaults );
423 + $notifications = self::restore_rewritten_triggers( $notifications, $defaults );
424 + $notifications = self::drop_duplicate_notifications( $notifications );
425 +
426 + return self::add_missing_notifications( $notifications, $defaults );
427 + }
428 +
429 + /**
430 + * Stamp a stable identity onto rows saved before keys existed.
431 + *
432 + * Older rows carry only `id` and `name`, and neither identifies anything on
433 + * its own: `id` is reassigned whenever the editor re-seeds a set, and `name`
434 + * is user-editable and translated — it resolves in the admin's locale when
435 + * saved and the site's locale when an email is sent.
436 + *
437 + * So the fallback is `trigger` + `email_to`. Both are untranslated, neither
438 + * changes when a notification is renamed, and the pair is unique across every
439 + * default (`trigger` alone is not — donor and admin templates share one).
440 + *
441 + * @param array<int, array<string, mixed>> $notifications Stored notifications.
442 + * @param array<int, array<string, mixed>> $defaults Default notifications.
443 + * @return array<int, array<string, mixed>> Notifications with a `key` where one could be resolved.
444 + * @since 1.4.0
445 + */
446 + private static function add_identity_keys( $notifications, $defaults ) {
447 + $by_name = [];
448 + $by_signature = [];
449 +
450 + foreach ( $defaults as $default ) {
451 + $key = isset( $default['key'] ) && is_string( $default['key'] ) ? $default['key'] : '';
452 + if ( '' === $key ) {
453 + continue;
454 + }
455 +
456 + $signature = self::notification_signature( $default );
457 + if ( '' !== $signature && ! isset( $by_signature[ $signature ] ) ) {
458 + $by_signature[ $signature ] = $key;
459 + }
460 +
461 + if ( isset( $default['name'] ) && is_string( $default['name'] ) ) {
462 + $by_name[ $default['name'] ] = $key;
463 + }
464 + // A default that has been renamed carries the names it used to have,
465 + // so rows stored under the old wording still resolve.
466 + if ( isset( $default['legacy_names'] ) && is_array( $default['legacy_names'] ) ) {
467 + foreach ( $default['legacy_names'] as $legacy ) {
468 + if ( is_string( $legacy ) && ! isset( $by_name[ $legacy ] ) ) {
469 + $by_name[ $legacy ] = $key;
470 + }
471 + }
472 + }
473 + }
474 +
475 + foreach ( $notifications as $index => $notification ) {
476 + if ( ! empty( $notification['key'] ) ) {
477 + continue;
478 + }
479 +
480 + $name = isset( $notification['name'] ) && is_string( $notification['name'] ) ? $notification['name'] : '';
481 +
482 + if ( '' !== $name && isset( $by_name[ $name ] ) ) {
483 + $notifications[ $index ]['key'] = $by_name[ $name ];
484 + continue;
485 + }
486 +
487 + // The name did not match, which a rename or a locale difference is
488 + // enough to cause. Fall back to what the admin did not edit and
489 + // gettext does not touch.
490 + //
491 + // Not `id`: the editor used to reassign ids when re-seeding, so a
492 + // stored id lands on whichever default happens to hold it and a donor
493 + // row can inherit an admin row's key — routing it to the wrong trigger
494 + // and suppressing the default it was mistaken for. No key at all is
495 + // recoverable; a confidently wrong one is not.
496 + $signature = self::notification_signature( $notification );
497 + if ( '' !== $signature && isset( $by_signature[ $signature ] ) ) {
498 + $notifications[ $index ]['key'] = $by_signature[ $signature ];
499 + continue;
500 + }
501 +
502 + // Unidentifiable, and 'all' means "fire on every event" — the value
503 + // the old sanitizer fell back to. Leaving it enabled would send a
504 + // recurring template to a one-time donor, so it is parked rather than
505 + // guessed at.
506 + //
507 + // Parking here is in memory only, so the editor still shows the row
508 + // switched on. backfill_notification_keys() is what reconciles the
509 + // two: it persists the resolution, which restores the real trigger
510 + // for rows that resolve and records the off state for those that do
511 + // not, so the toggle stops disagreeing with what actually sends.
512 + if ( 'all' === ( $notification['trigger'] ?? '' ) ) {
513 + $notifications[ $index ]['status'] = false;
514 + }
515 + }
516 +
517 + return $notifications;
518 + }
519 +
520 + /**
521 + * Read the stored submitted form fields off a donation row.
522 + *
523 + * The donation_data column is shared JSON that comes back either decoded or
524 + * still encoded depending on the caller, so both shapes are handled.
525 + *
526 + * @param array<string, mixed> $donation Donation row.
527 + * @return array<mixed> Stored fields, or [] when the donation has none.
528 + * @since 1.5.1
529 + */
530 + private static function extract_stored_fields( $donation ) {
531 + $donation_data = $donation['donation_data'] ?? [];
532 +
533 + if ( is_string( $donation_data ) && '' !== $donation_data ) {
534 + $donation_data = json_decode( $donation_data, true );
535 + }
536 +
537 + if ( ! is_array( $donation_data ) || ! isset( $donation_data['fields'] ) || ! is_array( $donation_data['fields'] ) ) {
538 + return [];
539 + }
540 +
541 + return $donation_data['fields'];
542 + }
543 +
544 + /**
545 + * Identify a notification by the two fields that survive editing.
546 + *
547 + * `trigger` is a sanitized key and `email_to` is a smart tag, so neither is
548 + * translated and neither changes when a notification is renamed. Together
549 + * they are unique across every default; `trigger` on its own is not, because
550 + * the donor and admin templates for an event share it.
551 + *
552 + * @param array<string, mixed> $notification Notification row.
553 + * @return string Signature, or '' when the row cannot supply one.
554 + * @since 1.4.0
555 + */
556 + private static function notification_signature( $notification ) {
557 + $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : '';
558 + $email_to = isset( $notification['email_to'] ) && is_string( $notification['email_to'] ) ? $notification['email_to'] : '';
559 +
560 + if ( '' === $trigger || '' === $email_to ) {
561 + return '';
562 + }
563 +
564 + return $trigger . '|' . $email_to;
565 + }
566 +
567 + /**
568 + * Restore triggers rewritten by an older save.
569 + *
570 + * Saving a form while Pro was inactive rewrote its recurring triggers to
571 + * 'all', so those notifications fire on every donation event. The editor
572 + * repairs this when the tab is opened; doing it here as well means a form
573 + * nobody edits stops mis-sending too.
574 + *
575 + * Only 'all' is corrected, and only against the row's resolved key. 'all' is
576 + * the exact value the old sanitizer fell back to, so any other mismatch is
577 + * treated as a deliberate choice and left alone.
578 + *
579 + * @param array<int, array<string, mixed>> $notifications Stored notifications.
580 + * @param array<int, array<string, mixed>> $defaults Default notifications.
581 + * @return array<int, array<string, mixed>> Notifications with triggers restored.
582 + * @since 1.4.0
583 + */
584 + private static function restore_rewritten_triggers( $notifications, $defaults ) {
585 + $by_key = [];
586 + foreach ( $defaults as $default ) {
587 + if ( ! empty( $default['key'] ) && is_string( $default['key'] ) && isset( $default['trigger'] ) ) {
588 + $by_key[ $default['key'] ] = $default['trigger'];
589 + }
590 + }
591 +
592 + foreach ( $notifications as $index => $notification ) {
593 + $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : '';
594 + $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : '';
595 +
596 + if ( 'all' !== $trigger || '' === $key || ! isset( $by_key[ $key ] ) ) {
597 + continue;
598 + }
599 +
600 + $notifications[ $index ]['trigger'] = $by_key[ $key ];
601 + }
602 +
603 + return $notifications;
604 + }
605 +
606 + /**
607 + * Collapse rows that resolve to the same notification.
608 + *
609 + * A form that went through the deactivate/reactivate cycle holds the admin's
610 + * customised row alongside a pristine copy the editor appended when it failed
611 + * to recognise the original. Restoring the trigger above makes the two exact
612 + * twins, so without this the donor receives both.
613 + *
614 + * The customised row wins: it is the one carrying the admin's edits, and the
615 + * pristine copy only exists because of the recognition bug.
616 + *
617 + * @param array<int, array<string, mixed>> $notifications Stored notifications.
618 + * @return array<int, array<string, mixed>> Notifications with duplicates removed.
619 + * @since 1.4.0
620 + */
621 + private static function drop_duplicate_notifications( $notifications ) {
622 + $seen = [];
623 + $kept = [];
624 +
625 + foreach ( $notifications as $notification ) {
626 + $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : '';
627 +
628 + // Without a resolved key there is nothing safe to compare on, so the
629 + // row is kept as-is rather than guessed at.
630 + if ( '' === $key ) {
631 + $kept[] = $notification;
632 + continue;
633 + }
634 +
635 + if ( ! isset( $seen[ $key ] ) ) {
636 + $seen[ $key ] = count( $kept );
637 + $kept[] = $notification;
638 + continue;
639 + }
640 +
641 + // Prefer whichever copy the admin actually edited.
642 + $existing = $kept[ $seen[ $key ] ];
643 + if ( self::is_customised( $notification ) && ! self::is_customised( $existing ) ) {
644 + $kept[ $seen[ $key ] ] = $notification;
645 + }
646 + }
647 +
648 + return $kept;
649 + }
650 +
651 + /**
652 + * Whether a stored row differs from the default it came from.
653 + *
654 + * @param array<string, mixed> $notification Stored notification.
655 + * @return bool True when the row carries admin edits.
656 + * @since 1.4.0
657 + */
658 + private static function is_customised( $notification ) {
659 + $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : '';
660 + if ( '' === $key ) {
121 661 return false;
122 662 }
123 663
124 - $notification = $notifications[ $notification_type ];
664 + // Cached: this runs inside a payment request, twice per duplicate row, and
665 + // rebuilding the array means ~40 __() calls plus an apply_filters pass
666 + // each time. The defaults are constant for the life of the request.
667 + static $defaults = null;
668 + if ( null === $defaults ) {
669 + $defaults = Assets::get_instance()->get_default_email_notifications();
670 + }
125 671
126 - // Ensure notification is an array before accessing offsets.
127 - if ( ! is_array( $notification ) ) {
672 + foreach ( $defaults as $default ) {
673 + if ( ( $default['key'] ?? '' ) !== $key ) {
674 + continue;
675 + }
676 + foreach ( [ 'subject', 'email_body', 'email_to', 'from_name', 'from_email', 'reply_to', 'name' ] as $field ) {
677 + if ( ( $notification[ $field ] ?? '' ) !== ( $default[ $field ] ?? '' ) ) {
678 + return true;
679 + }
680 + }
128 681 return false;
129 682 }
130 683
131 - // Check if enabled.
132 - if ( empty( $notification['enabled'] ) ) {
133 - return false;
684 + return false;
685 + }
686 +
687 + /**
688 + * Add defaults that the stored set does not already cover.
689 + *
690 + * Notifications cannot be added or deleted in the editor, so a default with
691 + * no stored counterpart was never seeded — most often because Pro was
692 + * activated after the form was last saved. Without this, those forms send
693 + * nothing for the events Pro adds.
694 + *
695 + * Matching is on the stable key only. Falling back to `id` or `name` here
696 + * would reintroduce the very ambiguity the key exists to remove: a locale
697 + * difference used to make a present notification look absent (so it was
698 + * added twice), while a reassigned id could collide with a different
699 + * notification's default and make an absent one look present (so it was
700 + * never sent at all).
701 + *
702 + * @param array<int, array<string, mixed>> $notifications Stored notifications.
703 + * @param array<int, array<string, mixed>> $defaults Default notifications.
704 + * @return array<int, array<string, mixed>> Stored notifications plus any missing defaults.
705 + * @since 1.4.0
706 + */
707 + private static function add_missing_notifications( $notifications, $defaults ) {
708 + $stored_keys = [];
709 + $unresolved_slots = [];
710 +
711 + foreach ( $notifications as $notification ) {
712 + if ( ! empty( $notification['key'] ) && is_string( $notification['key'] ) ) {
713 + $stored_keys[ $notification['key'] ] = true;
714 + continue;
715 + }
716 +
717 + // A row we could not identify still occupies its slot. Appending the
718 + // default that belongs there would leave two enabled rows on one
719 + // trigger and send the donor two of every email — so the slot is
720 + // recorded and the default withheld. Matched on the full signature,
721 + // not just the trigger: the donor and admin templates for an event
722 + // share a trigger, and withholding both because one row is
723 + // unidentified would silence a notification that is working.
724 + if ( ! empty( $notification['status'] ) ) {
725 + $signature = self::notification_signature( $notification );
726 + if ( '' !== $signature ) {
727 + $unresolved_slots[ $signature ] = true;
728 + }
729 + }
134 730 }
135 731
136 - return $notification;
732 + foreach ( $defaults as $default ) {
733 + $key = isset( $default['key'] ) && is_string( $default['key'] ) ? $default['key'] : '';
734 +
735 + if ( '' === $key || isset( $stored_keys[ $key ] ) ) {
736 + continue;
737 + }
738 +
739 + $signature = self::notification_signature( $default );
740 + if ( '' !== $signature && isset( $unresolved_slots[ $signature ] ) ) {
741 + continue;
742 + }
743 +
744 + $notifications[] = $default;
745 + }
746 +
747 + return $notifications;
137 748 }
138 749
139 750 /**
140 751 * Send email using notification settings.
@@ -141,14 +752,15 @@
141 752 *
142 753 * @param string $to_email Recipient email address.
143 754 * @param array<string, mixed> $notification Notification settings.
144 755 * @param array<string, mixed> $donation_data Donation data for smart tags.
145 - * @param \WP_Post $campaign Campaign post object.
756 + * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form.
146 757 * @param int $donation_id Optional donation ID.
758 + * @param string $event The event that triggered this email.
147 759 * @return bool True if email was sent successfully.
148 760 * @since 0.0.1
149 761 */
150 - private static function send_email( $to_email, $notification, $donation_data, $campaign, $donation_id = 0 ) {
762 + private static function send_email( $to_email, $notification, $donation_data, $campaign, $donation_id = 0, $event = '' ) {
151 763 if ( empty( $to_email ) || ! is_email( $to_email ) ) {
152 764 return false;
153 765 }
154 766
@@ -168,18 +780,23 @@
168 780 ? $notification['reply_to']
169 781 : ( is_string( $from_email ) ? $from_email : '' );
170 782
171 783 // Process smart tags in from fields.
172 - $from_name = self::process_smart_tags( is_string( $from_name ) ? $from_name : '', $donation_data, $campaign );
784 + $from_name = self::process_smart_tags( is_string( $from_name ) ? $from_name : '', $donation_data, $campaign );
785 + $from_email = self::process_smart_tags( is_string( $from_email ) ? $from_email : '', $donation_data, $campaign );
786 + $reply_to = self::process_smart_tags( is_string( $reply_to ) ? $reply_to : '', $donation_data, $campaign );
787 + $subject = str_replace( [ "\r", "\n" ], '', $subject );
173 788
174 - // Ensure from_email and reply_to are strings.
175 - $from_email = is_string( $from_email ) ? $from_email : '';
176 - $reply_to = is_string( $reply_to ) ? $reply_to : '';
789 + // Sanitize header values: strip CRLF to prevent header injection, validate emails.
790 + $from_name = str_replace( [ "\r", "\n" ], '', $from_name );
791 + $admin_email = get_option( 'admin_email' );
792 + $from_email = is_email( $from_email ) ? (string) $from_email : ( is_string( $admin_email ) ? $admin_email : '' );
793 + $reply_to = is_email( $reply_to ) ? (string) $reply_to : $from_email;
177 794
178 795 // Set email headers.
179 796 $headers = [
180 797 'Content-Type: text/html; charset=UTF-8',
181 - sprintf( 'From: %s <%s>', $from_name, $from_email ),
798 + sprintf( 'From: %s <%s>', (string) $from_name, $from_email ),
182 799 sprintf( 'Reply-To: %s', $reply_to ),
183 800 ];
184 801
185 802 // Convert plain text to HTML if needed.
@@ -184,14 +801,105 @@
184 801
185 802 // Convert plain text to HTML if needed.
186 803 $email_body = self::format_email_body( $email_body );
187 804
805 + /**
806 + * Filter attachments for outgoing notification emails.
807 + *
808 + * Each entry must be an absolute path to a local, readable file
809 + * (wp_mail() contract) inside the uploads directory. Non-string,
810 + * non-existent and out-of-uploads entries are dropped before sending.
811 + *
812 + * A STRING KEY names the attachment for the recipient: wp_mail() passes
813 + * it to PHPMailer as the display name, so the file can be stored under
814 + * one name and delivered under another. Keys are run through
815 + * sanitize_file_name(), given the real file's extension when they lack
816 + * it, and dropped if nothing usable survives -- in which case the file's
817 + * own name is used.
818 + *
819 + * The display name is best effort. Core has never documented the
820 + * key-as-name behaviour, and a plugin that REPLACES pluggable wp_mail()
821 + * (some API-based mailers do) may iterate values only and drop the key,
822 + * delivering the file under its stored name instead.
823 + *
824 + * @param array<int|string, string> $attachments Attachment file paths, optionally keyed by display name. Default empty.
825 + * @param array<string, mixed> $notification Notification settings.
826 + * @param array<string, mixed> $donation_data Donation data.
827 + * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form.
828 + * @param int $donation_id Donation ID (0 when not available).
829 + * @param string $event The event that triggered this email (e.g. 'donation_completed').
830 + * @since 1.5.0
831 + * @since 1.5.1 A string key names the attachment for the recipient.
832 + */
833 + $attachments = apply_filters( 'suredonation_email_attachments', [], $notification, $donation_data, $campaign, $donation_id, $event );
834 +
835 + $upload_dir = wp_upload_dir();
836 + $base_real = isset( $upload_dir['basedir'] ) && is_string( $upload_dir['basedir'] ) ? realpath( $upload_dir['basedir'] ) : false;
837 + $uploads_dir = is_string( $base_real ) ? trailingslashit( wp_normalize_path( $base_real ) ) : '';
838 +
839 + $attachments = is_array( $attachments ) ? array_filter(
840 + $attachments,
841 + static function ( $path ) use ( $uploads_dir ) {
842 + if ( ! is_string( $path ) || '' === $path || ! file_exists( $path ) ) {
843 + return false;
844 + }
845 +
846 + // Containment check: only files inside the uploads directory
847 + // may be attached — a filtered-in traversal path or symlink
848 + // must not exfiltrate arbitrary server files by email.
849 + $real = realpath( $path );
850 +
851 + if ( ! is_string( $real ) || '' === $uploads_dir ) {
852 + return false;
853 + }
854 +
855 + return 0 === strpos( wp_normalize_path( $real ), $uploads_dir );
856 + }
857 + ) : [];
858 +
859 + // Re-key rather than array_values(): a string key is the name the
860 + // recipient sees, which is how a receipt stored under an unguessable
861 + // filename arrives as something readable. Keys that do not survive
862 + // sanitize_file_name() are dropped so the attachment falls back to the
863 + // file's own name — never to attacker-shaped text in a mail header.
864 + $named_attachments = [];
865 +
866 + foreach ( $attachments as $key => $path ) {
867 + $name = is_string( $key ) ? sanitize_file_name( $key ) : '';
868 +
869 + // sanitize_file_name() neither requires nor preserves an extension, so
870 + // a key like "Receipt for Ada" would be delivered with none at all --
871 + // PHPMailer takes the content type from the PATH, leaving the reader a
872 + // file their OS cannot open by double-clicking. Reconcile the two.
873 + if ( '' !== $name ) {
874 + $real_ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
875 +
876 + if ( '' !== $real_ext && strtolower( (string) pathinfo( $name, PATHINFO_EXTENSION ) ) !== $real_ext ) {
877 + $name .= '.' . $real_ext;
878 + }
879 + }
880 +
881 + // A name already claimed by an earlier attachment would silently
882 + // replace it, losing a file that used to be sent. Keep both: the
883 + // loser falls back to its own filename rather than disappearing.
884 + if ( '' !== $name && ! isset( $named_attachments[ $name ] ) ) {
885 + $named_attachments[ $name ] = $path;
886 + continue;
887 + }
888 +
889 + $named_attachments[] = $path;
890 + }
891 +
892 + $attachments = $named_attachments;
893 +
188 894 // Send email.
189 - $sent = wp_mail( $to_email, $subject, $email_body, $headers );
895 + $sent = wp_mail( $to_email, $subject, $email_body, $headers, $attachments );
190 896
191 897 // Log email send attempt.
192 - $notification_id = isset( $notification['id'] ) && is_string( $notification['id'] ) ? $notification['id'] : '';
193 - do_action( 'suredonation_email_sent', $donation_id, $to_email, $sent, $notification_id );
898 + // 4th param is display name (not machine ID). Pre-release plugin (v0.0.1) with no
899 + // external consumers of this hook, so no backward-compatibility concern.
900 + $notification_name = isset( $notification['name'] ) && is_string( $notification['name'] ) ? $notification['name'] : '';
901 + do_action( 'suredonation_email_sent', $donation_id, $to_email, $sent, $notification_name );
194 902
195 903 return $sent;
196 904 }
197 905
@@ -199,15 +907,16 @@
199 907 * Process smart tags in email content.
200 908 *
201 909 * @param string $content Content with smart tags.
202 910 * @param array<string, mixed> $donation_data Donation data.
203 - * @param \WP_Post $campaign Campaign post object.
911 + * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form.
204 912 * @return string Processed content.
205 913 * @since 0.0.1
206 914 */
207 - private static function process_smart_tags( $content, $donation_data, $campaign ) {
915 + public static function process_smart_tags( $content, $donation_data, $campaign ) {
208 916 // Get currency symbol - ensure string type.
209 - $currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD';
917 + $currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD';
918 + $campaign_title = ( $campaign instanceof \WP_Post ) ? $campaign->post_title : '';
210 919
211 920 // Calculate total amount (base + fees) - ensure numeric types.
212 921 $amount_value = $donation_data['amount'] ?? 0;
213 922 $fees_covered_value = $donation_data['fees_covered'] ?? 0;
@@ -221,8 +930,19 @@
221 930 // Get date format - ensure string type.
222 931 $date_format = get_option( 'date_format' );
223 932 $date_format = is_string( $date_format ) ? $date_format : 'Y-m-d';
224 933
934 + // Prefer the donation's own timestamp. Falling back to "now" dates a
935 + // receipt to when the email happened to be sent, which is wrong whenever
936 + // that is not the moment of the donation — a delayed or redelivered
937 + // gateway webhook, or a recurring charge whose template labels the field
938 + // as the start date.
939 + $created_at = isset( $donation_data['created_at'] ) && is_string( $donation_data['created_at'] ) ? $donation_data['created_at'] : '';
940 + $created_stamp = '' !== $created_at ? strtotime( $created_at ) : false;
941 + $donation_date = false !== $created_stamp
942 + ? wp_date( $date_format, $created_stamp )
943 + : current_time( $date_format );
944 +
225 945 // Smart tags mapping.
226 946 $donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : __( 'Donor', 'suredonation' );
227 947 $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : '';
228 948 $transaction_id = isset( $donation_data['transaction_id'] ) && is_string( $donation_data['transaction_id'] ) ? $donation_data['transaction_id'] : '';
@@ -229,23 +949,81 @@
229 949 if ( empty( $transaction_id ) && isset( $donation_data['id'] ) ) {
230 950 $transaction_id = is_scalar( $donation_data['id'] ) ? (string) $donation_data['id'] : '';
231 951 }
232 952
953 + // Subscription smart tags.
954 + $subscription_id = isset( $donation_data['subscription_id'] ) && is_string( $donation_data['subscription_id'] ) ? $donation_data['subscription_id'] : '';
955 + $admin_email = get_option( 'admin_email', '' );
956 +
957 + // Payment method smart tags.
958 + $gateway = isset( $donation_data['gateway'] ) && is_string( $donation_data['gateway'] ) ? $donation_data['gateway'] : 'stripe';
959 + $payment_method = Helper::get_payment_method_label( $gateway );
960 + $payment_status = isset( $donation_data['payment_status'] ) && is_string( $donation_data['payment_status'] ) ? $donation_data['payment_status'] : '';
961 +
962 + $offline_instructions = '';
963 + if ( 'offline' === $gateway ) {
964 + $offline_instructions = Offline_Helper::get_offline_instructions();
965 + }
966 +
233 967 $tags = [
234 - '{donor_name}' => $donor_name,
235 - '{donor_email}' => $donor_email,
236 - '{amount}' => $formatted_amount,
237 - '{campaign_name}' => $campaign->post_title,
238 - '{donation_date}' => current_time( $date_format ),
239 - '{transaction_id}' => $transaction_id,
240 - '{site_title}' => get_bloginfo( 'name' ),
241 - '{admin_email}' => get_option( 'admin_email' ),
242 - '{site_url}' => home_url(),
243 - '{admin_url}' => admin_url( 'admin.php?page=suredonation' ),
968 + '{donor_name}' => esc_html( $donor_name ),
969 + '{donor_email}' => esc_html( $donor_email ),
970 + '{amount}' => esc_html( $formatted_amount ),
971 + '{campaign_name}' => esc_html( $campaign_title ),
972 + '{donation_date}' => esc_html( (string) $donation_date ),
973 + '{transaction_id}' => esc_html( $transaction_id ),
974 + '{site_title}' => esc_html( get_bloginfo( 'name' ) ),
975 + '{admin_email}' => esc_html( Helper::get_string_value( $admin_email ) ),
976 + '{site_url}' => esc_url( home_url() ),
977 + '{admin_url}' => esc_url( admin_url( 'admin.php?page=suredonation' ) ),
978 + '{subscription_id}' => esc_html( $subscription_id ),
979 + '{subscription_interval}' => isset( $donation_data['subscription_interval'] ) && is_string( $donation_data['subscription_interval'] )
980 + ? esc_html( $donation_data['subscription_interval'] )
981 + : '',
982 + '{payment_method}' => esc_html( $payment_method ),
983 + '{donation_amount}' => esc_html( Payment_Helper::format_amount( $base_amount, $currency ) ),
984 + '{donation_total}' => esc_html( $formatted_amount ),
985 + '{payment_status}' => Helper::render_payment_status_badge( $payment_status ),
986 + '{success_badge}' => Helper::render_success_badge(),
987 + '{donation_receipt}' => Helper::render_donation_receipt( $donation_data, $campaign_title ),
988 + '{refund_amount}' => isset( $donation_data['refund_amount'] ) && is_numeric( $donation_data['refund_amount'] )
989 + ? esc_html( Payment_Helper::format_amount( (float) $donation_data['refund_amount'], $currency ) )
990 + : '',
991 + '{offline_instructions}' => wp_kses_post( $offline_instructions ),
992 + '{form_fields}' => Helper::render_submitted_fields(
993 + isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ? $donation_data['fields'] : []
994 + ),
244 995 ];
245 996
246 997 // Apply filters to allow adding custom smart tags.
247 - $tags = apply_filters( 'suredonation_email_smart_tags', $tags, $donation_data, $campaign );
998 + $core_tags = $tags;
999 + $tags = apply_filters( 'suredonation_email_smart_tags', $tags, $donation_data, $campaign );
1000 +
1001 + // Escape anything the filter introduced. Core tags deliberately carry
1002 + // markup, so they are compared by value rather than by key — checking the
1003 + // key alone let a callback overwrite an existing tag and slip raw HTML
1004 + // into every email untouched.
1005 + //
1006 + // A consequence worth knowing: a callback that *appends* to a core tag
1007 + // changes its value, so the result is escaped and any markup or bare `&`
1008 + // the core tag carried is encoded a second time. Allowlisting the
1009 + // markup-carrying tags would avoid that, but it would also reopen the
1010 + // overwrite path above, so the escaping wins and the filter should
1011 + // replace a tag outright rather than concatenate onto it.
1012 + foreach ( $tags as $tag_key => $tag_value ) {
1013 + if ( isset( $core_tags[ $tag_key ] ) && $core_tags[ $tag_key ] === $tag_value ) {
1014 + continue;
1015 + }
1016 +
1017 + // A non-scalar cannot be rendered; casting one would emit "Array" or
1018 + // fatal on an object, inside a payment webhook.
1019 + if ( ! is_scalar( $tag_value ) ) {
1020 + unset( $tags[ $tag_key ] );
1021 + continue;
1022 + }
1023 +
1024 + $tags[ $tag_key ] = esc_html( (string) $tag_value );
1025 + }
248 1026
249 1027 // Replace smart tags.
250 1028 return str_replace( array_keys( $tags ), array_values( $tags ), $content );
251 1029 }