PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.12.3
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.12.3
1.8.13.1 1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 All 212 releases
charitable / includes / abstracts / abstract-class-charitable-email.php

abstract-class-charitable-email.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.12.3, at includes/abstracts/abstract-class-charitable-email.php

1,369 lines 36.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email model.
4 *
5 * @package Charitable/Classes/Charitable_Email
6 * @author David Bisset
7 * @copyright Copyright (c) 2023, WP Charitable LLC
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.0.0
10 * @version 1.6.60
11 */
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 if ( ! class_exists( 'Charitable_Email' ) ) :
19
20 /**
21 * Charitable_Email
22 *
23 * @since 1.0.0
24 */
25 abstract class Charitable_Email implements Charitable_Email_Interface {
26
27 /** Email ID */
28 const ID = '';
29
30 /**
31 * Descriptive name of the email.
32 *
33 * @since 1.0.0
34 *
35 * @var string
36 */
37 protected $name;
38
39 /**
40 * Array of supported object types (campaigns, donations, donors, etc).
41 *
42 * @since 1.0.0
43 *
44 * @var string[]
45 */
46 protected $object_types = array();
47
48 /**
49 * Whether the email allows you to define the email recipients.
50 *
51 * @since 1.0.0
52 *
53 * @var boolean
54 */
55 protected $has_recipient_field = false;
56
57 /**
58 * Whether the email is required.
59 *
60 * @since 1.4.0
61 *
62 * @var boolean
63 */
64 protected $required = false;
65
66 /**
67 * The Donation object, if relevant.
68 *
69 * @since 1.0.0
70 *
71 * @var Charitable_Donation
72 */
73 protected $donation;
74
75 /**
76 * The Campaign object, if relevant.
77 *
78 * @since 1.0.0
79 *
80 * @var Charitable_Campaign
81 */
82 protected $campaign;
83
84 /**
85 * Email recipient.
86 *
87 * @since 1.0.0
88 *
89 * @var string
90 */
91 protected $recipients;
92
93 /**
94 * Email headers.
95 *
96 * @since 1.0.0
97 *
98 * @var string
99 */
100 protected $headers;
101
102 /**
103 * Email attachments.
104 *
105 * @since 1.6.59
106 *
107 * @var string
108 */
109 protected $attachments;
110
111 /**
112 * Create a class instance.
113 *
114 * @since 1.0.0
115 *
116 * @param mixed[] $objects Objects for the email.
117 */
118 public function __construct( $objects = array() ) {
119 $this->donation = isset( $objects['donation'] ) ? $objects['donation'] : null;
120 $this->campaign = isset( $objects['campaign'] ) ? $objects['campaign'] : null;
121 }
122
123 /**
124 * Return an instance property.
125 *
126 * @since 1.5.0
127 *
128 * @param key $property The property to return.
129 * @return mixed
130 */
131 public function get( $property ) {
132 if ( property_exists( $this, $property ) ) {
133 return $this->$property;
134 }
135
136 return '';
137 }
138
139 /**
140 * Return the email name.
141 *
142 * @since 1.0.0
143 *
144 * @return string
145 */
146 public function get_name() {
147 return $this->name;
148 }
149
150 /**
151 * Return whether the email is required.
152 *
153 * If an email is required, it cannot be disabled/enabled, but it can still be edited.
154 *
155 * @since 1.4.0
156 *
157 * @return boolean
158 */
159 public function is_required() {
160 return $this->required;
161 }
162
163 /**
164 * Return the types of objects.
165 *
166 * @since 1.3.0
167 *
168 * @return string[]
169 */
170 public function get_object_types() {
171 return $this->object_types;
172 }
173
174 /**
175 * Return the donation object.
176 *
177 * @since 1.3.0
178 *
179 * @return null|Charitable_Donation
180 */
181 public function get_donation() {
182 return $this->donation;
183 }
184
185 /**
186 * Return the campaign object.
187 *
188 * @since 1.3.0
189 *
190 * @return null|Charitable_Campaign
191 */
192 public function get_campaign() {
193 return $this->campaign;
194 }
195
196 /**
197 * Get from name for email.
198 *
199 * @since 1.0.0
200 *
201 * @return string
202 */
203 public function get_from_name() {
204 return wp_specialchars_decode( charitable_get_option( 'email_from_name', get_option( 'blogname' ) ) );
205 }
206
207 /**
208 * Get from address for email.
209 *
210 * @since 1.0.0
211 *
212 * @return string
213 */
214 public function get_from_address() {
215 return charitable_get_option( 'email_from_email', get_option( 'admin_email' ) );
216 }
217
218 /**
219 * Return the email recipients.
220 *
221 * @since 1.0.0
222 * @since 1.8.9.8 Normalize array values to comma-separated string to prevent downstream TypeErrors.
223 *
224 * @return string
225 */
226 public function get_recipient() {
227 $recipient = $this->get_option( 'recipient', $this->get_default_recipient() );
228 return is_array( $recipient ) ? implode( ', ', $recipient ) : $recipient;
229 }
230
231 /**
232 * Return the email subject line.
233 *
234 * @since 1.0.0
235 *
236 * @return string
237 */
238 public function get_subject() {
239 /**
240 * Allow the email subject to be filtered.
241 *
242 * @since 1.6.60
243 *
244 * @param string $subject The email subject.
245 * @param \Charitable_Email $email The email object.
246 */
247 return apply_filters(
248 'charitable_email_subject',
249 $this->get_option( 'subject', $this->get_default_subject() ),
250 $this
251 );
252 }
253
254 /**
255 * Get the email content type
256 *
257 * @since 1.0.0
258 *
259 * @return string
260 */
261 public function get_content_type() {
262 /**
263 * Filter the content type for the email.
264 *
265 * @since 1.0.0
266 *
267 * @param string $content_type The content type. Defaults to 'text/html'.
268 * @param \Charitable_Email $email This instance of `Charitable_Email`.
269 */
270 return apply_filters( 'charitable_email_content_type', 'text/html', $this );
271 }
272
273 /**
274 * Get the email headers.
275 *
276 * @since 1.0.0
277 *
278 * @return string
279 */
280 public function get_headers() {
281 if ( ! isset( $this->headers ) ) {
282 $this->headers = "From: {$this->get_from_name()} <{$this->get_from_address()}>\r\n";
283 $this->headers .= "Reply-To: {$this->get_from_address()}\r\n";
284 $this->headers .= "Content-Type: {$this->get_content_type()}; charset=utf-8\r\n";
285 }
286
287 /**
288 * Filter the email headers.
289 *
290 * @since 1.0.0
291 *
292 * @param string $headers The default email headers.
293 * @param \Charitable_Email $email The email object.
294 */
295 return apply_filters( 'charitable_email_headers', $this->headers, $this );
296 }
297
298 /**
299 * Get the email attachments.
300 *
301 * @since 1.6.59
302 *
303 * @return array
304 */
305 public function get_attachments() {
306 if ( ! isset( $this->attachments ) ) {
307 $this->attachments = array();
308 }
309
310 /**
311 * Filter the email attachments.
312 *
313 * @since 1.6.59
314 *
315 * @param array $attachments The default email attachments.
316 * @param \Charitable_Email $email The email object.
317 */
318 return apply_filters( 'charitable_email_attachments', $this->attachments, $this );
319 }
320
321 /**
322 * Checks whether we are currently previewing the email.
323 *
324 * @since 1.3.5
325 *
326 * @return boolean
327 */
328 public function is_preview() {
329 return isset( $_GET['charitable_action'] ) && 'preview_email' === $_GET['charitable_action']; // phpcs:ignore WordPress.Security.NonceVerification
330 }
331
332 /**
333 * Register email settings.
334 *
335 * @since 1.0.0
336 * @since 1.5.0 $settings argument is now deprecated.
337 * @since 1.5.7 $settings argument removed from function definition.
338 *
339 * @return array
340 */
341 public function email_settings() {
342 if ( func_num_args() ) {
343 charitable_get_deprecated()->deprecated_argument(
344 __METHOD__,
345 '1.5.0',
346 __( 'The `$settings` parameter is no longer used.', 'charitable' )
347 );
348 }
349
350 $email_settings = array(
351 'section_email' => array(
352 'type' => 'heading',
353 'title' => $this->get_name(),
354 'priority' => 2,
355 ),
356 'subject' => array(
357 'type' => 'text',
358 'title' => __( 'Email Subject Line', 'charitable' ),
359 'help' => __( 'The email subject line when it is delivered to recipients.', 'charitable' ),
360 'priority' => 6,
361 'class' => 'wide',
362 'default' => $this->get_default_subject(),
363 ),
364 'headline' => array(
365 'type' => 'text',
366 'title' => __( 'Email Headline', 'charitable' ),
367 'help' => __( 'The headline displayed at the top of the email.', 'charitable' ),
368 'priority' => 10,
369 'class' => 'wide',
370 'default' => $this->get_default_headline(),
371 ),
372 'body' => array(
373 'type' => 'editor',
374 'title' => __( 'Email Body', 'charitable' ),
375 'help' => sprintf(
376 '%s <div class="charitable-shortcode-options">%s</div>',
377 __( 'The content of the email that will be delivered to recipients. HTML is accepted.', 'charitable' ),
378 $this->get_shortcode_options()
379 ),
380 'priority' => 14,
381 'default' => $this->get_default_body(),
382 ),
383 'preview' => array(
384 'type' => 'content',
385 'title' => __( 'Preview', 'charitable' ),
386 'content' => sprintf(
387 '<a href="%s" target="_blank" class="button">%s</a>',
388 esc_url(
389 add_query_arg(
390 array(
391 'charitable_action' => 'preview_email',
392 'email_id' => $this->get_email_id(),
393 ),
394 home_url()
395 )
396 ),
397 __( 'Preview email', 'charitable' )
398 ),
399 'priority' => 18,
400 'save' => false,
401 ),
402 );
403
404 // We need to add 'reply_to_donor' to the email settings if it's not already there but only for the new donation email.
405 if ( 'new_donation' === $this->get_email_id() || 'offline_donation_notification' === $this->get_email_id() ) {
406 $email_settings['reply_to_donor'] = array(
407 'type' => 'checkbox',
408 'title' => __( 'Reply To Donor', 'charitable' ),
409 'help' => __( 'If active the recipient will be able to thank the donor by replying to the email.', 'charitable' ),
410 'priority' => 8,
411 'class' => 'wide',
412 'default' => 1,
413 );
414 }
415
416 /* Add the recipients field if applicable to this email. */
417 $email_settings = $this->add_recipients_field( $email_settings );
418
419 /**
420 * Filter the settings available for this email.
421 *
422 * This filter is primarily useful for adding settings to specific email types.
423 * If you only want to add fields to all email types, use this hook instead:
424 *
425 * charitable_settings_fields_emails_email
426 *
427 * @see Charitable_Email_Settings::add_individual_email_fields
428 *
429 * @since 1.0.0
430 *
431 * @param array $email_settings Email settings.
432 */
433 return apply_filters( 'charitable_settings_fields_emails_email_' . $this->get_email_id(), $email_settings );
434 }
435
436 /**
437 * Add recipient field
438 *
439 * @since 1.0.0
440 *
441 * @param array $settings Existing array of email settings.
442 * @return array
443 */
444 public function add_recipients_field( $settings = array() ) {
445 if ( ! $this->has_recipient_field ) {
446 return $settings;
447 }
448
449 $settings['recipient'] = array(
450 'type' => 'text',
451 'title' => __( 'Recipients', 'charitable' ),
452 'help' => __( 'A comma-separated list of email address that will receive this email.', 'charitable' ),
453 'priority' => 4,
454 'class' => 'wide',
455 'default' => $this->get_default_recipient(),
456 );
457
458 return $settings;
459 }
460
461 /**
462 * Sends the email.
463 *
464 * @since 1.0.0
465 * @since 1.8.9.2 Added comprehensive error handling for email processing failures.
466 *
467 * @return boolean
468 */
469 public function send() {
470 // AJAX DEFERRAL: Prevent email HTML from contaminating AJAX JSON response
471 // when Oxygen page builder + Braintree + Recurring donations are active.
472 if ( $this->should_defer_ajax_send() ) {
473 return $this->defer_ajax_send();
474 }
475
476 /**
477 * Do something before sending the email.
478 *
479 * @since 1.0.0
480 *
481 * @param \Charitable_Email $email The email object.
482 */
483 do_action( 'charitable_before_send_email', $this );
484
485 try {
486 // Safely get each email component with error handling
487 $recipient = $this->safe_get_recipient();
488 if ( false === $recipient ) {
489 return false;
490 }
491
492 $subject = $this->safe_get_subject();
493 if ( false === $subject ) {
494 return false;
495 }
496
497 $body = $this->safe_build_email();
498 if ( false === $body ) {
499 return false;
500 }
501
502 $headers = $this->safe_get_headers();
503 if ( false === $headers ) {
504 return false;
505 }
506
507 $attachments = $this->safe_get_attachments();
508 if ( false === $attachments ) {
509 return false;
510 }
511
512 // Capture the actual error from wp_mail() failures.
513 // WordPress fires 'wp_mail_failed' action with a WP_Error
514 // containing the real SMTP/mailer error message.
515 $wp_mail_error = null;
516 $capture_error = function( $error ) use ( &$wp_mail_error ) {
517 $wp_mail_error = $error;
518 };
519 add_action( 'wp_mail_failed', $capture_error );
520
521 $sent = wp_mail( $recipient, $subject, $body, $headers, $attachments );
522
523 remove_action( 'wp_mail_failed', $capture_error );
524
525 if ( ! $sent ) {
526 $error_detail = 'wp_mail() returned false';
527 if ( $wp_mail_error instanceof \WP_Error ) {
528 $error_detail .= ' — ' . $wp_mail_error->get_error_code() . ': ' . $wp_mail_error->get_error_message();
529 $error_data = $wp_mail_error->get_error_data();
530 if ( ! empty( $error_data ) ) {
531 $error_detail .= ' | Data: ' . ( is_string( $error_data ) ? $error_data : wp_json_encode( $error_data ) );
532 }
533 }
534 $this->log_email_error( 'wp_mail_failed', $error_detail );
535 }
536
537 } catch ( Throwable $e ) {
538 $this->log_email_error( 'exception_during_send', $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
539 $sent = false;
540 }
541
542 /**
543 * Do something after sending the email.
544 *
545 * @since 1.0.0
546 *
547 * @param \Charitable_Email $email The email object.
548 */
549 do_action( 'charitable_after_send_email', $this, $sent ?? false );
550
551 return $sent ?? false;
552 }
553
554 /**
555 * Resend an email.
556 *
557 * @since 1.5.0
558 *
559 * @param int $object_id An object ID.
560 * @param array $args Mixed set of arguments.
561 * @return boolean
562 */
563 public static function resend( $object_id, $args = array() ) {
564 charitable_get_deprecated()->doing_it_wrong(
565 __METHOD__,
566 __( 'A `resend` method has not been defined for this class.', 'charitable' ),
567 '1.5.0'
568 );
569
570 return false;
571 }
572
573 /**
574 * Checks whether an email can be resent.
575 *
576 * @since 1.5.0
577 *
578 * @param int $object_id An object ID.
579 * @param array $args Mixed set of arguments.
580 * @return boolean
581 */
582 public static function can_be_resent( $object_id, $args = array() ) {
583 charitable_get_deprecated()->doing_it_wrong(
584 __METHOD__,
585 __( 'A `can_be_resent` method has not been defined for this class.', 'charitable' ),
586 '1.5.0'
587 );
588
589 return false;
590 }
591
592 /**
593 * Checks whether the email has already been sent.
594 *
595 * @since 1.3.2
596 * @since 1.5.2 Added the $data_type parameter.
597 *
598 * @param int $object_id The ID of the object related to this email. May be a campaign ID, a donation ID or a user ID.
599 * @param string $data_type Optional. The type of meta we are saving. Defaults to 'post'.
600 * @return boolean
601 */
602 public function is_sent_already( $object_id, $data_type = 'post' ) {
603 $log = get_metadata( $data_type, $object_id, $this->get_log_key(), true );
604
605 if ( is_array( $log ) ) {
606 foreach ( $log as $time => $sent ) {
607 if ( $sent ) {
608 return true;
609 }
610 }
611 }
612
613 return false;
614 }
615
616 /**
617 * Log that the email was sent.
618 *
619 * @since 1.3.2
620 * @since 1.5.2 Added the $data_type parameter.
621 *
622 * @param int $object_id The ID of the object related to this email. May be a campaign ID, a donation ID, or a user ID.
623 * @param boolean $sent Whether the email was sent.
624 * @param string $data_type Optional. The type of meta we are saving. Defaults to 'post'.
625 * @return void
626 */
627 public function log( $object_id, $sent, $data_type = 'post' ) {
628 $log = get_metadata( $data_type, $object_id, $this->get_log_key(), true );
629
630 if ( ! $log ) {
631 $log = array();
632 }
633
634 $log[ time() ] = $sent;
635
636 update_metadata( $data_type, $object_id, $this->get_log_key(), $log );
637 }
638
639 /**
640 * Preview the email. This will display a sample email within the browser.
641 *
642 * @since 1.0.0
643 *
644 * @return string
645 */
646 public function preview() {
647 /**
648 * Do something before building the preview output.
649 *
650 * @since 1.0.0
651 *
652 * @param \Charitable_Email $this Email object.
653 */
654 do_action( 'charitable_before_preview_email', $this );
655
656 $output = $this->build_email();
657
658 /**
659 * Do something after building the preview output.
660 *
661 * @since 1.5.0
662 *
663 * @param \Charitable_Email $this Email object.
664 */
665 do_action( 'charitable_after_preview_email', $this );
666
667 return $output;
668 }
669
670 /**
671 * Returns the body content of the email, formatted as HTML.
672 *
673 * @since 1.0.0
674 * @version 1.8.4.3 add wp_kses_post and html_entity_decode.
675 *
676 * @return string
677 */
678 public function get_body() {
679 $body = $this->get_option( 'body', $this->get_default_body() );
680 $body = do_shortcode( $body );
681 $body = wpautop( $body );
682
683 // Because this addition is applying to all emails potentially, let's add a temp global for any troubleshooting.
684 if ( ! defined( 'CHARITABLE_EMAILS_DISABLE_HTML' ) || ! CHARITABLE_EMAILS_DISABLE_HTML ) {
685 $body = wp_kses_post( $body );
686 $body = html_entity_decode( $body );
687 }
688
689 /**
690 * Filter the email body before it is sent.
691 *
692 * @since 1.0.0
693 *
694 * @param string $body Body content.
695 * @param \Charitable_Email $email Instance of `Charitable_Email`.
696 */
697 return apply_filters( 'charitable_email_body', $body, $this );
698 }
699
700 /**
701 * Returns the email headline.
702 *
703 * @since 1.0.0
704 *
705 * @return string
706 */
707 public function get_headline() {
708 $headline = $this->get_option( 'headline', $this->get_default_headline() );
709 $headline = do_shortcode( $headline );
710
711 /**
712 * Filter the email headline.
713 *
714 * @since 1.0.0
715 *
716 * @param string $headline Headline.
717 * @param \Charitable_Email $email Instance of `Charitable_Email`.
718 */
719 return apply_filters( 'charitable_email_headline', $headline, $this );
720 }
721
722 /**
723 * Return the value of an option specific to this email.
724 *
725 * @since 1.0.0
726 * @since 1.6.59 Access changed to public.
727 *
728 * @param string $key Settings option key.
729 * @param mixed $default Default value to return in case setting is not set.
730 * @return mixed
731 */
732 public function get_option( $key, $default ) {
733 return charitable_get_option( array( 'emails_' . $this->get_email_id(), $key ), $default );
734 }
735
736 /**
737 * Return an array of email fields that are specifically available
738 * for this email.
739 *
740 * @since 1.5.0
741 *
742 * @return array
743 */
744 public function email_fields() {
745 return array();
746 }
747
748 /**
749 * Checks whether the email has a valid donation object set.
750 *
751 * @since 1.0.0
752 *
753 * @return boolean
754 */
755 public function has_valid_donation() {
756 if ( is_null( $this->donation ) || ! is_a( $this->donation, 'Charitable_Donation' ) ) {
757 charitable_get_deprecated()->doing_it_wrong(
758 __METHOD__,
759 __( 'You cannot send this email without a donation!', 'charitable' ),
760 '1.0.0'
761 );
762
763 return false;
764 }
765
766 return true;
767 }
768 /**
769 * Checks whether the email has a valid campaign object set.
770 *
771 * @since 1.0.0
772 *
773 * @return boolean
774 */
775 public function has_valid_campaign() {
776 if ( is_null( $this->campaign ) || ! is_a( $this->campaign, 'Charitable_Campaign' ) ) {
777 charitable_get_deprecated()->doing_it_wrong(
778 __METHOD__,
779 __( 'You cannot send this email without a campaign!', 'charitable' ),
780 '1.0.0'
781 );
782
783 return false;
784 }
785
786 return true;
787 }
788
789 /**
790 * Build the email.
791 *
792 * @since 1.0.0
793 *
794 * @return string
795 */
796 protected function build_email() {
797 ob_start();
798
799 charitable_template( 'emails/header.php', array( 'email' => $this ) );
800
801 charitable_template( 'emails/body.php', array( 'email' => $this ) );
802
803 charitable_template( 'emails/footer.php', array( 'email' => $this ) );
804
805 $message = ob_get_clean();
806
807 /**
808 * Filter the email message before it is sent.
809 *
810 * @since 1.0.0
811 *
812 * @param string $message The full email message output (header, body and footer).
813 * @param \Charitable_Email $email Instance of `Charitable_Email`.
814 */
815 return apply_filters( 'charitable_email_message', $message, $this );
816 }
817
818 /**
819 * Return the meta key used for the log.
820 *
821 * @since 1.3.2
822 *
823 * @return string
824 */
825 protected function get_log_key() {
826 return '_email_' . $this->get_email_id() . '_log';
827 }
828
829 /**
830 * Return the default recipient for the email.
831 *
832 * @since 1.0.0
833 *
834 * @return string
835 */
836 protected function get_default_recipient() {
837 return '';
838 }
839
840 /**
841 * Return the default subject line for the email.
842 *
843 * @since 1.0.0
844 *
845 * @return string
846 */
847 protected function get_default_subject() {
848 return '';
849 }
850
851 /**
852 * Return the default headline for the email.
853 *
854 * @since 1.0.0
855 *
856 * @return string
857 */
858 protected function get_default_headline() {
859 return '';
860 }
861
862 /**
863 * Return the default body for the email.
864 *
865 * @since 1.0.0
866 *
867 * @return string
868 */
869 protected function get_default_body() {
870 return '';
871 }
872
873 /**
874 * Return HTML formatted list of shortcode options that can be used within the body, headline and subject line.
875 *
876 * @since version
877 * @version 1.8.9.1
878 *
879 * @return string
880 */
881 protected function get_shortcode_options() {
882 $fields = new Charitable_Email_Fields( $this );
883 ob_start();
884 ?>
885 <p><?php esc_html_e( 'The following options are available with the <code>[charitable_email]</code> shortcode:', 'charitable' ); ?></p>
886 <ul>
887 <?php foreach ( $fields->get_fields() as $key => $field ) : ?>
888 <li><strong><?php echo wp_kses_post( $field['description'] ); ?></strong>: [charitable_email show=<?php echo esc_html( $key ); ?>]</li>
889 <?php endforeach; ?>
890 </ul>
891
892 <?php
893 $html = ob_get_clean();
894
895 /**
896 * Filter the shortcode options block.
897 *
898 * @since 1.0.0
899 *
900 * @param string $html The content.
901 * @param \Charitable_Email $email This instance of `Charitable_Email`.
902 */
903 return apply_filters( 'charitable_email_shortcode_options_text', $html, $this );
904 }
905
906 /**
907 * This function is deprecated as of 1.5.0. Checks whether the passed email is the
908 * same as the current email object.
909 *
910 * @deprecated 1.8.0
911 *
912 * @since 1.3.2
913 * @since 1.5.0 Deprecated. No notice added to allow extensions to be updated first.
914 *
915 * @param \Charitable_Email $email Email object.
916 * @return boolean
917 */
918 protected function is_current_email( Charitable_Email $email ) {
919 return $email->get_email_id() == $this->get_email_id();
920 }
921
922 /**
923 * Add donation content fields.
924 *
925 * @deprecated 1.8.0
926 *
927 * @since 1.0.0
928 * @since 1.5.0 Deprecated.
929 *
930 * @param array $fields Shortcode fields.
931 * @param Charitable_Email $email Instance of `Charitable_Email`.
932 * @return array[]
933 */
934 public function add_donation_content_fields( $fields, Charitable_Email $email ) {
935 return $fields;
936 }
937
938 /**
939 * Add donation preview fields.
940 *
941 * @deprecated 1.8.0
942 *
943 * @since 1.0.0
944 * @since 1.5.0 Deprecated.
945 *
946 * @param array $fields Shortcode fields.
947 * @param Charitable_Email $email Instance of `Charitable_Email`.
948 * @return array[]
949 */
950 public function add_preview_donation_content_fields( $fields, Charitable_Email $email ) {
951 return $fields;
952 }
953
954 /**
955 * Add campaign content fields.
956 *
957 * @deprecated 1.8.0
958 *
959 * @since 1.0.0
960 * @since 1.5.0 Deprecated.
961 *
962 * @param array $fields Shortcode fields.
963 * @param Charitable_Email $email Instance of `Charitable_Email`.
964 * @return array[]
965 */
966 public function add_campaign_content_fields( $fields, Charitable_Email $email ) {
967 return $fields;
968 }
969
970 /**
971 * Add campaign preview fields.
972 *
973 * @deprecated 1.8.0
974 *
975 * @since 1.0.0
976 * @since 1.5.0 Deprecated.
977 *
978 * @param array $fields Shortcode fields.
979 * @param Charitable_Email $email Instance of `Charitable_Email`.
980 * @return array[]
981 */
982 public function add_preview_campaign_content_fields( $fields, Charitable_Email $email ) {
983 return $fields;
984 }
985
986 /**
987 * Safely get email recipient with error handling.
988 *
989 * @since 1.8.9.2
990 * @since 1.8.9.5 Enhanced with detailed diagnostic context for recipient extraction failures.
991 * @since 1.8.9.8 Normalize array recipients from child class overrides before validation.
992 *
993 * @return string|false Email recipient or false on error.
994 */
995 private function safe_get_recipient() {
996 try {
997 $recipient = $this->get_recipient();
998
999 // Normalize array recipients (e.g. from child class overrides or filters) to comma-separated string.
1000 if ( is_array( $recipient ) ) {
1001 $recipient = implode( ', ', array_filter( array_map( 'trim', $recipient ) ) );
1002 }
1003
1004 if ( empty( $recipient ) || ! $this->is_valid_recipient( $recipient ) ) {
1005 // Phase 1: Enhanced diagnostics - minimal overhead, only on errors
1006 $diagnostic_context = array(
1007 'recipient_value' => $recipient,
1008 'recipient_type' => gettype( $recipient ),
1009 'recipient_length' => is_string( $recipient ) ? strlen( $recipient ) : 0,
1010 );
1011
1012 // Add donation context if available (safe checks)
1013 if ( is_object( $this->donation ) && method_exists( $this->donation, 'get_donor_id' ) ) {
1014 $donor_id = $this->donation->get_donor_id();
1015 $diagnostic_context['donation_id'] = method_exists( $this->donation, 'get_donation_id' ) ? $this->donation->get_donation_id() : 'unknown';
1016 $diagnostic_context['donor_id'] = $donor_id;
1017 $diagnostic_context['donor_id_type'] = gettype( $donor_id );
1018
1019 // Check campaign donations count (common failure point)
1020 if ( method_exists( $this->donation, 'get_campaign_donations' ) ) {
1021 $campaign_donations = $this->donation->get_campaign_donations();
1022 $diagnostic_context['campaign_donations_count'] = is_array( $campaign_donations ) ? count( $campaign_donations ) : 'not_array';
1023 }
1024
1025 // Check donor object validity if donor_id exists
1026 if ( ! empty( $donor_id ) && $donor_id !== false && method_exists( $this->donation, 'get_donor' ) ) {
1027 $donor = $this->donation->get_donor();
1028 $diagnostic_context['donor_object_type'] = gettype( $donor );
1029 $diagnostic_context['donor_object_class'] = is_object( $donor ) ? get_class( $donor ) : 'not_object';
1030 }
1031 } else {
1032 $diagnostic_context['donation_object_type'] = gettype( $this->donation );
1033 }
1034
1035 $this->log_email_error( 'invalid_recipient',
1036 'Recipient email is empty or invalid - Enhanced diagnosis: ' . wp_json_encode( $diagnostic_context ) );
1037 return false;
1038 }
1039
1040 return $recipient;
1041
1042 } catch ( Throwable $e ) {
1043 $this->log_email_error( 'recipient_error', $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
1044 return false;
1045 }
1046 }
1047
1048 /**
1049 * Safely get email subject with error handling.
1050 *
1051 * @since 1.8.9.2
1052 *
1053 * @return string|false Email subject or false on error.
1054 */
1055 private function safe_get_subject() {
1056 try {
1057 // Capture any errors during shortcode processing
1058 $original_handler = null;
1059 if ( function_exists( 'set_error_handler' ) ) {
1060 $original_handler = set_error_handler( function( $severity, $message, $file, $line ) {
1061 // Skip deprecation warnings - they are not real errors (PHP 8.2+)
1062 if ( $severity === E_DEPRECATED || $severity === E_USER_DEPRECATED ) {
1063 return false;
1064 }
1065 // Skip warnings and notices - common in WordPress plugins (WPML, etc.)
1066 // and should not prevent email delivery.
1067 if ( $severity === E_WARNING || $severity === E_NOTICE || $severity === E_USER_WARNING || $severity === E_USER_NOTICE || $severity === E_STRICT ) {
1068 return false;
1069 }
1070 // Only convert actual user errors to exceptions
1071 throw new ErrorException( $message, 0, $severity, $file, $line );
1072 } );
1073 }
1074
1075 $subject = do_shortcode( $this->get_subject() );
1076
1077 if ( $original_handler !== null ) {
1078 restore_error_handler();
1079 }
1080
1081 return $subject;
1082
1083 } catch ( Throwable $e ) {
1084 if ( isset( $original_handler ) && $original_handler !== null ) {
1085 restore_error_handler();
1086 }
1087 $this->log_email_error( 'subject_error', $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
1088 return false;
1089 }
1090 }
1091
1092 /**
1093 * Safely build email body with error handling.
1094 *
1095 * @since 1.8.9.2
1096 * @since 1.8.9.5 Enhanced with detailed diagnostic context for email build failures.
1097 *
1098 * @return string|false Email body or false on error.
1099 */
1100 private function safe_build_email() {
1101 try {
1102 // Set up error handler to catch fatal errors during email building
1103 $original_handler = null;
1104 if ( function_exists( 'set_error_handler' ) ) {
1105 $original_handler = set_error_handler( function( $severity, $message, $file, $line ) {
1106 // Skip deprecation warnings - they are not real errors (PHP 8.2+)
1107 if ( $severity === E_DEPRECATED || $severity === E_USER_DEPRECATED ) {
1108 return false;
1109 }
1110 // Skip warnings and notices - common in WordPress plugins (WPML, etc.)
1111 // and should not prevent email delivery.
1112 if ( $severity === E_WARNING || $severity === E_NOTICE || $severity === E_USER_WARNING || $severity === E_USER_NOTICE || $severity === E_STRICT ) {
1113 return false;
1114 }
1115 // Only convert actual user errors to exceptions
1116 throw new ErrorException( $message, 0, $severity, $file, $line );
1117 } );
1118 }
1119
1120 $body = $this->build_email();
1121
1122 if ( $original_handler !== null ) {
1123 restore_error_handler();
1124 }
1125
1126 return $body;
1127
1128 } catch ( Throwable $e ) {
1129 if ( isset( $original_handler ) && $original_handler !== null ) {
1130 restore_error_handler();
1131 }
1132
1133 // Phase 1: Enhanced email build diagnostics
1134 $build_context = array(
1135 'email_class' => get_class( $this ),
1136 'email_id' => method_exists( $this, 'get_email_id' ) ? $this->get_email_id() : 'unknown',
1137 'error_message' => $e->getMessage(),
1138 'error_file' => basename( $e->getFile() ),
1139 'error_line' => $e->getLine(),
1140 'ob_level' => ob_get_level()
1141 );
1142
1143 // Add donation context for template building
1144 if ( is_object( $this->donation ) ) {
1145 $build_context['has_donation_data'] = true;
1146 $build_context['donation_id'] = method_exists( $this->donation, 'get_donation_id' ) ? $this->donation->get_donation_id() : 'unknown';
1147 } else {
1148 $build_context['has_donation_data'] = false;
1149 }
1150
1151 // Check template availability
1152 $template_dir = CHARITABLE_DIRECTORY_PATH . 'templates/emails/';
1153 $build_context['templates_exist'] = array(
1154 'header' => file_exists( $template_dir . 'header.php' ),
1155 'body' => file_exists( $template_dir . 'body.php' ),
1156 'footer' => file_exists( $template_dir . 'footer.php' )
1157 );
1158
1159 $this->log_email_error( 'email_build_failed',
1160 'Email template build failed - Enhanced diagnosis: ' . wp_json_encode( $build_context ) );
1161 return false;
1162 }
1163 }
1164
1165 /**
1166 * Safely get email headers with error handling.
1167 *
1168 * @since 1.8.9.2
1169 *
1170 * @return string|array|false Email headers or false on error.
1171 */
1172 private function safe_get_headers() {
1173 try {
1174 return $this->get_headers();
1175 } catch ( Throwable $e ) {
1176 $this->log_email_error( 'headers_error', $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
1177 return false;
1178 }
1179 }
1180
1181 /**
1182 * Safely get email attachments with error handling.
1183 *
1184 * @since 1.8.9.2
1185 *
1186 * @return array|false Email attachments or false on error.
1187 */
1188 private function safe_get_attachments() {
1189 try {
1190 $attachments = $this->get_attachments();
1191 return is_array( $attachments ) ? $attachments : array();
1192 } catch ( Throwable $e ) {
1193 $this->log_email_error( 'attachments_error', $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
1194 return false;
1195 }
1196 }
1197
1198 /**
1199 * Log email processing errors using the existing donation form error logging system.
1200 *
1201 * @since 1.8.9.2
1202 *
1203 * @param string $error_details Specific error details or code.
1204 * @param string $error_message The error message with context.
1205 * @return int|false The activity ID on success, false on failure.
1206 */
1207 private function log_email_error( $error_details, $error_message ) {
1208 // Only log if the function exists (defensive coding)
1209 if ( ! function_exists( 'charitable_log_form_error' ) ) {
1210 return false;
1211 }
1212
1213 // Prepare context with email and donation details
1214 $context = array(
1215 'error_message' => $error_message,
1216 'email_class' => get_class( $this ),
1217 );
1218
1219 // Add donation context if available
1220 if ( isset( $this->donation ) && is_object( $this->donation ) ) {
1221 $context['donation_id'] = method_exists( $this->donation, 'get_donation_id' ) ? $this->donation->get_donation_id() : null;
1222 $context['campaign_id'] = method_exists( $this->donation, 'get_campaign_id' ) ? $this->donation->get_campaign_id() : null;
1223 $context['donor_id'] = method_exists( $this->donation, 'get_donor_id' ) ? $this->donation->get_donor_id() : null;
1224 $context['amount'] = method_exists( $this->donation, 'get_total_donation_amount' ) ? $this->donation->get_total_donation_amount() : null;
1225 }
1226
1227 // Try to get recipient for context (safely) — normalize to string in case a subclass or filter returns an array.
1228 try {
1229 if ( method_exists( $this, 'get_recipient' ) ) {
1230 $raw_recipient = $this->get_recipient();
1231 $context['recipient'] = is_array( $raw_recipient ) ? implode( ', ', $raw_recipient ) : $raw_recipient;
1232 }
1233 } catch ( Throwable $e ) {
1234 // Ignore if we can't get recipient
1235 }
1236
1237 // Log using the existing error logging system
1238 return charitable_log_form_error( 'email_failure', $error_details, $context );
1239 }
1240
1241 /**
1242 * Check if recipient string is valid (handles single emails, comma-separated lists, and arrays).
1243 *
1244 * @since 1.8.9.5
1245 * @since 1.8.9.8 Handle array recipients explicitly to prevent TypeError on PHP 8+.
1246 *
1247 * @param string|array $recipient The recipient string or array to validate.
1248 * @return boolean True if valid, false otherwise.
1249 */
1250 private function is_valid_recipient( $recipient ) {
1251 if ( empty( $recipient ) ) {
1252 return false;
1253 }
1254
1255 // Handle array recipients gracefully.
1256 if ( is_array( $recipient ) ) {
1257 foreach ( $recipient as $email ) {
1258 if ( empty( $email ) || ! is_email( trim( $email ) ) ) {
1259 return false;
1260 }
1261 }
1262 return true;
1263 }
1264
1265 // If it's a single email, use is_email()
1266 if ( strpos( $recipient, ',' ) === false ) {
1267 return is_email( trim( $recipient ) );
1268 }
1269
1270 // Handle comma-separated emails
1271 $emails = explode( ',', $recipient );
1272 foreach ( $emails as $email ) {
1273 $email = trim( $email );
1274 if ( empty( $email ) || ! is_email( $email ) ) {
1275 return false;
1276 }
1277 }
1278
1279 return true;
1280 }
1281 /**
1282 * Check if the current email should be deferred to avoid AJAX response contamination.
1283 *
1284 * Only defers when ALL of these conditions are true:
1285 * - Running in AJAX context
1286 * - Processing a donation form submission
1287 * - Using Braintree gateway
1288 * - Processing a recurring donation
1289 * - Oxygen page builder is active (CT_VERSION defined)
1290 *
1291 * @since 1.8.9.6
1292 *
1293 * @return boolean
1294 */
1295 private function should_defer_ajax_send() {
1296 if ( ! ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) {
1297 return false;
1298 }
1299
1300 if ( ! isset( $_POST['action'] ) || 'make_donation' !== $_POST['action'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
1301 return false;
1302 }
1303
1304 if ( ! isset( $_POST['gateway'] ) || 'braintree' !== $_POST['gateway'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
1305 return false;
1306 }
1307
1308 if ( ! isset( $_POST['recurring_donation'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
1309 return false;
1310 }
1311
1312 if ( ! defined( 'CT_VERSION' ) ) {
1313 return false;
1314 }
1315
1316 return true;
1317 }
1318
1319 /**
1320 * Defer email sending via WordPress cron to avoid AJAX contamination.
1321 *
1322 * @since 1.8.9.6
1323 *
1324 * @return boolean True if successfully scheduled.
1325 */
1326 private function defer_ajax_send() {
1327 $email_data = array(
1328 'class' => get_class( $this ),
1329 'args' => $this->get_email_args_for_deferral(),
1330 'timestamp' => time(),
1331 );
1332
1333 wp_schedule_single_event( time() + 5, 'charitable_send_deferred_email', array( $email_data ) );
1334
1335 return true;
1336 }
1337
1338 /**
1339 * Get email constructor args for deferred sending.
1340 *
1341 * @since 1.8.9.6
1342 *
1343 * @return array
1344 */
1345 private function get_email_args_for_deferral() {
1346 $args = array();
1347
1348 if ( isset( $this->donation ) && is_object( $this->donation ) ) {
1349 if ( method_exists( $this->donation, 'get_donation_id' ) ) {
1350 $args['donation_id'] = $this->donation->get_donation_id();
1351 } elseif ( isset( $this->donation->ID ) ) {
1352 $args['donation_id'] = $this->donation->ID;
1353 }
1354 }
1355
1356 if ( isset( $this->campaign ) && is_object( $this->campaign ) ) {
1357 if ( method_exists( $this->campaign, 'get_campaign_id' ) ) {
1358 $args['campaign_id'] = $this->campaign->get_campaign_id();
1359 } elseif ( isset( $this->campaign->ID ) ) {
1360 $args['campaign_id'] = $this->campaign->ID;
1361 }
1362 }
1363
1364 return $args;
1365 }
1366 }
1367
1368 endif;
1369