PluginProbe
Property Hive / 2.3.0
Property Hive v2.3.0
2.3.1 2.3.0 2.2.6 2.2.5 2.2.4 2.2.3 2.2.2 1.4.46 1.4.47 1.4.48 1.4.49 1.4.5 1.4.50 1.4.51 1.4.52 1.4.53 1.4.54 1.4.55 1.4.56 1.4.57 1.4.58 1.4.59 1.4.6 1.4.60 1.4.61 All 261 releases
← All changes | includes/class-ph-emails.php +449 -179 1.4.572.3.0 View file →
@@ -1,6 +1,9 @@
1 1 <?php
2 +// phpcs:set WordPress.Security.ValidatedSanitizedInput customSanitizingFunctions[] ph_clean
3 +// ph_clean() recursively sanitizes text; presence, shape and unslashing checks remain separate.
2 4
5 +
3 6 if ( ! defined( 'ABSPATH' ) ) {
4 7 exit; // Exit if accessed directly
5 8 }
6 9
@@ -41,9 +44,9 @@
41 44 *
42 45 * @since 1.0.0
43 46 */
44 47 public function __clone() {
45 - _doing_it_wrong( __FUNCTION__, __( 'Cheatin&#8217; huh?', 'propertyhive' ), '1.0.0' );
48 + _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'propertyhive' ), '1.0.0' );
46 49 }
47 50
48 51 /**
49 52 * Unserializing instances of this class is forbidden.
@@ -50,9 +53,9 @@
50 53 *
51 54 * @since 1.0.0
52 55 */
53 56 public function __wakeup() {
54 - _doing_it_wrong( __FUNCTION__, __( 'Cheatin&#8217; huh?', 'propertyhive' ), '1.0.0' );
57 + _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'propertyhive' ), '1.0.0' );
55 58 }
56 59
57 60 /**
58 61 * Constructor for the email class hooks in all emails that can be sent.
@@ -71,36 +74,65 @@
71 74 // Send applicant registration email
72 75 add_action( 'propertyhive_applicant_registered', array( $this, 'send_applicant_registration_alert' ), 10, 2 );
73 76
74 77 add_action( 'admin_init', array( $this, 'run_custom_email_cron' ), 10, 1 );
78 +
79 + add_action( 'init', array( $this, 'check_email_queue_is_scheduled'), 99 );
75 80 }
76 81
82 + public function check_email_queue_is_scheduled()
83 + {
84 + $schedule = wp_get_schedule( 'propertyhive_process_email_log' );
85 +
86 + if ( $schedule === FALSE )
87 + {
88 + // Hmm... cron job not found. Let's set it up
89 + $timestamp = wp_next_scheduled( 'propertyhive_process_email_log' );
90 + wp_unschedule_event($timestamp, 'propertyhive_process_email_log' );
91 + wp_clear_scheduled_hook('propertyhive_process_email_log');
92 +
93 + wp_schedule_event( time(), 'every_fifteen_minutes', 'propertyhive_process_email_log' );
94 + }
95 + }
96 +
77 97 public function run_custom_email_cron()
78 98 {
79 - if (isset($_GET['custom_email_log_cron']) && in_array($_GET['custom_email_log_cron'], array('propertyhive_process_email_log', 'propertyhive_auto_email_match')) )
99 + if ( isset( $_GET['custom_email_log_cron'] ) )
80 100 {
81 - do_action($_GET['custom_email_log_cron']);
101 + if ( ! current_user_can( 'manage_propertyhive' ) ) {
102 + wp_die( esc_html__( 'Insufficient permissions', 'propertyhive' ), '', array( 'response' => 403 ) );
103 + }
104 + check_admin_referer( 'propertyhive-run-email-job' );
105 + $job = is_string( $_GET['custom_email_log_cron'] ) ? sanitize_key( wp_unslash( $_GET['custom_email_log_cron'] ) ) : '';
106 + if ( ! in_array( $job, array( 'propertyhive_process_email_log', 'propertyhive_auto_email_match' ), true ) ) {
107 + wp_die( esc_html__( 'Invalid email job.', 'propertyhive' ), '', array( 'response' => 400 ) );
108 + }
109 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Dynamic email cron dispatch is a bounded internal false positive: the value is sanitized, restricted to two propertyhive_* jobs, and reached only after capability and nonce checks.
110 + do_action( $job );
82 111 }
83 112 }
84 113
85 - public function send_applicant_registration_alert( $contact_post_id, $user_id )
86 - {
87 - if (
88 - get_option( 'propertyhive_new_registration_alert', '' ) == 'yes' &&
89 - isset($_POST['office_id']) && // in the future we should have office stored against contact and use that
90 - $_POST['office_id'] != '' &&
91 - isset($_POST['department']) && // Should really take department from contacts requirements
92 - $_POST['department'] != ''
93 - )
114 + public function send_applicant_registration_alert( $contact_post_id, $user_id )
94 115 {
95 - $to = get_post_meta( (int)$_POST['office_id'], '_office_email_address_' . str_replace("residential-", "", ph_clean($_POST['department'])), TRUE );
116 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This internal action callback is invoked by the nonce-checked applicant registration AJAX handler; it only formats that request's notification email.
117 + $request_post = wp_unslash( $_POST );
118 + $office_id = isset( $request_post['office_id'] ) && is_scalar( $request_post['office_id'] ) ? absint( $request_post['office_id'] ) : 0;
119 + $department = isset( $request_post['department'] ) && is_string( $request_post['department'] ) ? sanitize_key( $request_post['department'] ) : '';
96 120
121 + if ( get_option( 'propertyhive_new_registration_alert', '' ) == 'yes' && $office_id > 0 && '' !== $department )
122 + {
123 + $to = get_post_meta( $office_id, '_office_email_address_' . str_replace( 'residential-', '', $department ), TRUE );
124 +
97 125 if ( $to == '' )
98 126 {
99 127 $to = get_option( 'admin_email', '' );
100 128 }
101 129
102 - $subject = sprintf( __( 'A new applicant, %s, has registered through your website', 'propertyhive' ), get_the_title($contact_post_id) );
130 + $subject = sprintf(
131 + /* translators: %s: person name */
132 + __( 'A new applicant, %s, has registered through your website', 'propertyhive' ),
133 + get_the_title($contact_post_id)
134 + );
103 135
104 136 $message = __( "A new applicant has registered through your website. Please find details of the applicant below:", 'propertyhive' ) . "\n\n";
105 137
106 138 $message = apply_filters( 'propertyhive_applicant_registration_email_pre_body', $message, $contact_post_id );
@@ -110,9 +142,9 @@
110 142 $form_controls = apply_filters( 'propertyhive_user_details_form_fields', $form_controls );
111 143
112 144 $form_controls_2 = ph_get_applicant_requirements_form_fields();
113 145
114 - $form_controls_2 = apply_filters( 'propertyhive_applicant_requirements_form_fields', $form_controls_2 );
146 + $form_controls_2 = apply_filters( 'propertyhive_applicant_requirements_form_fields', $form_controls_2, get_post_meta( $contact_post_id, '_applicant_profile_0', true ) );
115 147
116 148 $form_controls = array_merge( $form_controls, $form_controls_2 );
117 149
118 150 $form_controls = apply_filters( 'propertyhive_applicant_registration_form_fields', $form_controls );
@@ -125,17 +157,26 @@
125 157 {
126 158 continue;
127 159 }
128 160
129 - $value = ph_clean($_POST[$key]);
130 - if ( taxonomy_exists($key) )
161 + $raw_value = isset( $request_post[ $key ] ) && ( is_string( $request_post[ $key ] ) || is_array( $request_post[ $key ] ) ) ? $request_post[ $key ] : '';
162 + $value = ph_clean( $raw_value );
163 + $values = array();
164 + if ( !empty($value) && taxonomy_exists($key) )
131 165 {
132 - $term = get_term( ph_clean($_POST[$key]), $key );
166 + if ( !is_array($value) ) { $value = array($value); }
133 167
134 - if ( isset($term->name) )
168 + foreach ( $value as $value_term )
135 169 {
136 - $value = $term->name;
137 - }
170 + $term = get_term( ph_clean($value_term), $key );
171 +
172 + if ( isset($term->name) )
173 + {
174 + $values[] = $term->name;
175 + }
176 + }
177 +
178 + $value = implode(", ", $values);
138 179 }
139 180
140 181 if ( ph_clean($value) == '' )
141 182 {
@@ -158,67 +199,58 @@
158 199 global $wpdb;
159 200
160 201 $lock_id = uniqid( "", true );
161 202
162 - $wpdb->query("
163 - UPDATE " . $wpdb->prefix . "ph_email_log
164 - SET
165 - status = 'fail2',
166 - lock_id = ''
167 - WHERE
168 - status = 'fail1'
169 - AND
170 - lock_id <> ''
171 - AND
172 - locked_at <= '" . date("Y-m-d H:i:s", strtotime('24 hours ago')) . "'
173 - ");
203 + $expired_lock = gmdate( 'Y-m-d H:i:s', strtotime( '24 hours ago' ) );
204 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The email queue is a custom plugin table and these bounded maintenance updates have no WordPress API equivalent.
205 + $wpdb->query( $wpdb->prepare(
206 + "UPDATE {$wpdb->prefix}ph_email_log SET status = 'fail2', lock_id = '' WHERE status = 'fail1' AND lock_id <> '' AND locked_at <= %s",
207 + $expired_lock
208 + ) );
209 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The email queue is a custom plugin table and these bounded maintenance updates have no WordPress API equivalent.
210 + $wpdb->query( $wpdb->prepare(
211 + "UPDATE {$wpdb->prefix}ph_email_log SET status = 'fail1', lock_id = '' WHERE status = '' AND lock_id <> '' AND locked_at <= %s",
212 + $expired_lock
213 + ) );
174 214
175 - $wpdb->query("
176 - UPDATE " . $wpdb->prefix . "ph_email_log
177 - SET
178 - status = 'fail1',
179 - lock_id = ''
180 - WHERE
181 - status = ''
182 - AND
183 - lock_id <> ''
184 - AND
185 - locked_at <= '" . date("Y-m-d H:i:s", strtotime('24 hours ago')) . "'
186 - ");
187 -
188 - // Lock/reserve all emails in log that are status blank or 'fail1' and lock_id blank and send_at in the past
189 - // Only grab 25 at a time to prevent hanging/being seen as spamming
190 - $wpdb->query("
191 - UPDATE " . $wpdb->prefix . "ph_email_log
192 - SET
193 - lock_id = '" . $lock_id . "',
194 - locked_at = '" . date("Y-m-d H:i:s") . "'
195 - WHERE
196 - (status = '' OR status = 'fail1')
197 - AND
198 - lock_id = ''
199 - AND
200 - send_at <= '" . date("Y-m-d H:i:s") . "'
201 - LIMIT 25
202 - ");
215 + // Reserve a bounded batch before sending, so concurrent workers cannot send it twice.
216 + $process_limit = max( 1, min( 1000, (int) apply_filters( 'propertyhive_email_process_limit', 25 ) ) );
217 + $now = gmdate( 'Y-m-d H:i:s' );
218 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The email queue is a custom plugin table and this bounded reservation update has no WordPress API equivalent.
219 + $wpdb->query( $wpdb->prepare(
220 + "UPDATE {$wpdb->prefix}ph_email_log SET lock_id = %s, locked_at = %s WHERE (status = '' OR status = 'fail1') AND lock_id = '' AND send_at <= %s LIMIT %d",
221 + $lock_id, $now, $now, $process_limit
222 + ) );
223 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The email queue is a custom plugin table and this lock-scoped read has no WordPress API equivalent.
224 + $emails_to_send = $wpdb->get_results( $wpdb->prepare(
225 + "SELECT * FROM {$wpdb->prefix}ph_email_log WHERE lock_id = %s",
226 + $lock_id
227 + ) );
203 228
204 - // We now have up to 25 emails locked. Get this 25 and attempt to send
205 - $emails_to_send = $wpdb->get_results("
206 - SELECT *
207 - FROM " . $wpdb->prefix . "ph_email_log
208 - WHERE
209 - lock_id = '" . $lock_id . "'
210 - ");
211 -
212 229 foreach ( $emails_to_send as $email_to_send )
213 230 {
214 231 $email_id = $email_to_send->email_id;
215 232
216 233 $headers = array();
217 - $headers[] = 'From: ' . $email_to_send->from_name . ' <' . $email_to_send->from_email_address . '>';
234 + $headers[] = 'From: ' . html_entity_decode($email_to_send->from_name) . ' <' . $email_to_send->from_email_address . '>';
235 + if ( $email_to_send->cc_email_address != '' )
236 + {
237 + $headers[] = 'Cc: ' . $email_to_send->cc_email_address;
238 + }
239 + if ( $email_to_send->bcc_email_address != '' )
240 + {
241 + $headers[] = 'Bcc: ' . $email_to_send->bcc_email_address;
242 + }
218 243 $headers[] = 'Content-Type: text/html; charset=UTF-8';
219 244
220 - $body = apply_filters( 'propertyhive_mail_content', $this->style_inline( $this->wrap_message( $email_to_send->body, $email_to_send->contact_id ) ) );
245 + $body = $email_to_send->body;
246 +
247 + if ( extension_loaded('zlib') && @gzuncompress($body) !== false )
248 + {
249 + $body = gzuncompress($body);
250 + }
251 +
252 + $body = apply_filters( 'propertyhive_mail_content', $this->style_inline( $this->wrap_message( $body, $email_to_send->contact_id ) ) );
221 253
222 254 $sent = wp_mail(
223 255 $email_to_send->to_email_address,
224 256 $email_to_send->subject,
@@ -244,17 +276,33 @@
244 276 {
245 277 $new_status = 'fail2';
246 278 }
247 279 }
248 - $wpdb->query("
249 - UPDATE " . $wpdb->prefix . "ph_email_log
250 - SET
251 - status = '" . $new_status . "',
252 - lock_id = ''
253 - WHERE
254 - email_id = '" . $email_id . "'
255 - ");
280 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The email queue is a custom plugin table; wpdb is the required API for updating its status.
281 + $wpdb->update(
282 + $wpdb->prefix . 'ph_email_log',
283 + array( 'status' => $new_status, 'lock_id' => '' ),
284 + array( 'email_id' => (int) $email_id ),
285 + array( '%s', '%s' ),
286 + array( '%d' )
287 + );
256 288 }
289 +
290 + // Delete old logs
291 + $keep_logs_days = (string)apply_filters( 'propertyhive_keep_email_logs_days', '3650' ); // 10 years
292 +
293 + // Revert back to 3650 days if anything other than numbers has been passed
294 + // This prevent SQL injection and errors
295 + if ( !preg_match("/^\d+$/", $keep_logs_days) )
296 + {
297 + $keep_logs_days = '3650';
298 + }
299 +
300 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The email queue is a custom plugin table and this retention cleanup has no WordPress API equivalent.
301 + $wpdb->query( $wpdb->prepare(
302 + "DELETE FROM {$wpdb->prefix}ph_email_log WHERE send_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
303 + (int) $keep_logs_days
304 + ) );
257 305 }
258 306
259 307 /*
260 308 * Automatically send new properties to registered applicants
@@ -262,8 +310,14 @@
262 310 public function ph_auto_email_match()
263 311 {
264 312 global $post;
265 313
314 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- dry_run only selects diagnostic output and never changes persisted data; the real email action is capability and nonce protected in run_custom_email_cron().
315 + $request_get = wp_unslash( $_GET );
316 + $dry_run = isset( $request_get['dry_run'] );
317 +
318 + if ( $dry_run === true ) { echo 'Running auto-match in dry run mode. Logging will be output and no emails will be sent.' . "<br>\n"; }
319 +
266 320 // Auto emails enabled in settings
267 321 // Auto emails not disabled in applicant record
268 322 // Property added more recently that setting enabled in settings
269 323 // Property not already previously sent
@@ -271,8 +325,10 @@
271 325 // 'Do not email' not selected
272 326
273 327 $auto_property_match_enabled = get_option( 'propertyhive_auto_property_match', '' );
274 328
329 + if ( $dry_run === true ) { echo 'Auto-match setting enabled: ' . esc_html( $auto_property_match_enabled ) . "<br>\n"; }
330 +
275 331 if ( $auto_property_match_enabled == '' )
276 332 {
277 333 return false;
278 334 }
@@ -278,20 +334,22 @@
278 334 }
279 335
280 336 $auto_property_match_enabled_date = get_option( 'propertyhive_auto_property_match_enabled_date', '' );
281 337
338 + if ( $dry_run === true ) { echo 'Auto-match setting enabled date: ' . esc_html( $auto_property_match_enabled_date ) . "<br>\n"; }
339 +
282 340 if ( $auto_property_match_enabled_date == '' )
283 341 {
284 342 return false;
285 343 }
286 344
287 - // Get all of the office email addresses and store them in an array to save having to query them for every email
288 - // Get all contacts that have a type of applicant
345 + // Get all of the offices and store them in an array to save having to query them for every email
289 346 $args = array(
290 347 'post_type' => 'office',
291 348 'nopaging' => true
292 349 );
293 350
351 + $office_names = array();
294 352 $office_email_addresses = array();
295 353 $office_query = new WP_Query( $args );
296 354
297 355 if ( $office_query->have_posts() )
@@ -299,8 +357,10 @@
299 357 while ( $office_query->have_posts() )
300 358 {
301 359 $office_query->the_post();
302 360
361 + $office_names[get_the_ID()] = get_the_title( get_the_ID() );
362 +
303 363 $office_email_addresses['residential-sales'][get_the_ID()] = get_post_meta( get_the_ID(), '_office_email_address_sales', TRUE );
304 364 $office_email_addresses['residential-lettings'][get_the_ID()] = get_post_meta( get_the_ID(), '_office_email_address_lettings', TRUE );
305 365 $office_email_addresses['commercial'][get_the_ID()] = get_post_meta( get_the_ID(), '_office_email_address_commercial', TRUE );
306 366 }
@@ -307,32 +367,99 @@
307 367 }
308 368
309 369 wp_reset_postdata();
310 370
371 + // Get all of the negotiators and store them in an array to save having to query them for every email
372 + $negotiator_names = array();
373 + $negotiator_email_addresses = array();
374 +
375 + $args = array(
376 + 'number' => 9999,
377 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy Property Negotiator compatibility filter; existing role filters depend on this exact public hook name.
378 + 'role__not_in' => apply_filters( 'property_negotiator_exclude_roles', array('property_hive_contact', 'subscriber') ),
379 + 'fields' => array( 'ID', 'display_name', 'user_email' )
380 + );
381 +
382 + $args = apply_filters( 'propertyhive_negotiators_query', $args );
383 +
384 + $user_query = new WP_User_Query( $args );
385 +
386 + $negotiators = array();
387 +
388 + if ( ! empty( $user_query->results ) )
389 + {
390 + foreach ( $user_query->results as $user )
391 + {
392 + $negotiator_names[$user->ID] = $user->display_name;
393 +
394 + $negotiator_email_addresses[$user->ID] = $user->user_email;
395 + }
396 + }
397 +
311 398 include_once( dirname(__FILE__) . '/admin/class-ph-admin-matching-properties.php' );
312 399 $ph_admin_matching_properties = new PH_Admin_Matching_Properties();
313 400
401 + $meta_query = array(
402 + array(
403 + 'key' => '_contact_types',
404 + 'value' => 'applicant',
405 + 'compare' => 'LIKE'
406 + )
407 + );
408 +
409 + if ( version_compare( get_bloginfo( 'version' ), '5.1', '>=' ) )
410 + {
411 + // If WP version contains compare_key, check contact has at least one applicant profile with Send Matching Properties checked
412 + $meta_query[] = array(
413 + 'key' => '_applicant_profile_',
414 + 'compare_key' => 'LIKE',
415 + 'value' => 'send_matching_properties";s:3:"yes"',
416 + 'compare' => 'LIKE',
417 + );
418 + }
419 +
314 420 // Get all contacts that have a type of applicant
315 421 $args = array(
316 422 'post_type' => 'contact',
317 423 'nopaging' => true,
318 - 'meta_query' => array(
319 - array(
320 - 'key' => '_contact_types',
321 - 'value' => 'applicant',
322 - 'compare' => 'LIKE'
323 - )
324 - ),
424 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Matching mail must include all eligible applicant profiles; membership and opt-in eligibility are stored in contact metadata.
425 + 'meta_query' => $meta_query,
325 426 'fields' => 'ids'
326 427 );
327 428
429 + if ( $dry_run === true ) { echo 'Running query to get contacts with args: ' . esc_html( wp_json_encode( $args, JSON_PRETTY_PRINT ) ) . "<br>\n"; }
430 +
328 431 $contact_query = new WP_Query( $args );
329 432
433 + if ( $dry_run === true ) { echo 'Found ' . esc_html( $contact_query->found_posts ) . ' contacts' . "<br>\n"; }
434 +
330 435 if ( $contact_query->have_posts() )
331 436 {
332 437 $default_subject = get_option( 'propertyhive_property_match_default_email_subject', '' );
333 438 $default_body = get_option( 'propertyhive_property_match_default_email_body', '' );
334 439
440 + $allowed_tags = array(
441 + 'strong' => array(),
442 + 'span' => array(),
443 + 'em' => array(),
444 + 'h1' => array(),
445 + 'h2' => array(),
446 + 'h3' => array(),
447 + 'h4' => array(),
448 + 'h5' => array(),
449 + 'h6' => array(),
450 + 'i' => array(),
451 + 'u' => array(),
452 + 'b' => array(),
453 + 'a' => array(
454 + 'href' => array(),
455 + 'target' => array(),
456 + ),
457 + );
458 + $allowed_tags = apply_filters( 'propertyhive_match_email_allowed_tags', $allowed_tags );
459 +
460 + $default_body = wp_kses( $default_body, $allowed_tags );
461 +
335 462 while ( $contact_query->have_posts() )
336 463 {
337 464 $contact_query->the_post();
338 465
@@ -337,11 +464,15 @@
337 464 $contact_query->the_post();
338 465
339 466 $contact_id = get_the_ID();
340 467
468 + if ( $dry_run === true ) { echo 'Doing contact: ' . esc_html( get_the_title() ) . "<br>\n"; }
469 +
341 470 // invalid email address
342 471 if ( strpos( get_post_meta( $contact_id, '_email_address', TRUE ), '@' ) === FALSE )
343 472 {
473 + if ( $dry_run === true ) { echo esc_html('Invalid email address. Skipping') . "<br>\n"; }
474 +
344 475 continue;
345 476 }
346 477
347 478 // email in the list of forbidden contact methods
@@ -347,8 +478,10 @@
347 478 // email in the list of forbidden contact methods
348 479 $forbidden_contact_methods = get_post_meta( $contact_id, '_forbidden_contact_methods', TRUE );
349 480 if ( is_array($forbidden_contact_methods) && in_array('email', $forbidden_contact_methods) )
350 481 {
482 + if ( $dry_run === true ) { echo esc_html('Email communication forbidden in contact preferences. Skipping') . "<br>\n"; }
483 +
351 484 continue;
352 485 }
353 486
354 487 $applicant_profiles = get_post_meta( $contact_id, '_applicant_profiles', TRUE );
@@ -360,8 +493,10 @@
360 493 {
361 494 $dismissed_properties = array();
362 495 }
363 496
497 + if ( $dry_run === true ) { if ( !empty($dismissed_properties) ) { echo 'Dismissed properties: ' . esc_html( wp_json_encode( $dismissed_properties, JSON_PRETTY_PRINT ) ) . "<br>\n"; } }
498 +
364 499 for ( $i = 0; $i < $applicant_profiles; ++$i )
365 500 {
366 501 $applicant_profile = get_post_meta( $contact_id, '_applicant_profile_' . $i, TRUE );
367 502
@@ -366,23 +501,30 @@
366 501 $applicant_profile = get_post_meta( $contact_id, '_applicant_profile_' . $i, TRUE );
367 502
368 503 if ( $applicant_profile == '' || !is_array($applicant_profile) || !isset($applicant_profile['department']) )
369 504 {
505 + if ( $dry_run === true ) { echo esc_html('Applicant relationship empty or no department set') . "<br>\n"; }
370 506 continue;
371 507 }
372 508
373 509 if ( !isset($applicant_profile['send_matching_properties']) || ( isset($applicant_profile['send_matching_properties']) && $applicant_profile['send_matching_properties'] != 'yes' ) )
374 510 {
511 + if ( $dry_run === true ) { echo esc_html('Send matching properties disabled') . "<br>\n"; }
375 512 continue;
376 513 }
377 514
378 515 if ( isset($applicant_profile['auto_match_disabled']) && $applicant_profile['auto_match_disabled'] == 'yes' )
379 516 {
517 + if ( $dry_run === true ) { echo esc_html('Auto match disabled') . "<br>\n"; }
380 518 continue;
381 519 }
382 520
521 + if ( $dry_run === true ) { echo 'Getting matching properties' . "<br>\n"; }
522 +
383 523 $matching_properties = $ph_admin_matching_properties->get_matching_properties( $contact_id, $i, $auto_property_match_enabled_date );
384 524
525 + if ( $dry_run === true ) { echo esc_html('Found ' . count($matching_properties) . ' matching properties') . "<br>\n"; }
526 +
385 527 if ( !empty($matching_properties) )
386 528 {
387 529 $already_sent_properties = get_post_meta( $contact_id, '_applicant_profile_' . $i . '_match_history', TRUE );
388 530
@@ -388,8 +530,10 @@
388 530
389 531 // Remove from this array if on market changed or price changed
390 532 if ( is_array($already_sent_properties) )
391 533 {
534 + if ( $dry_run === true ) { echo 'Already sent properties before: ' . esc_html( wp_json_encode( $already_sent_properties, JSON_PRETTY_PRINT ) ) . "<br>\n"; }
535 +
392 536 foreach ( $already_sent_properties as $already_sent_property_id => $sends )
393 537 {
394 538 $highest_send = $sends[count($sends) - 1]['date'];
395 539
@@ -394,20 +538,40 @@
394 538 $highest_send = $sends[count($sends) - 1]['date'];
395 539
396 540 if ( $highest_send != '' )
397 541 {
542 + if ( $dry_run === true ) { echo 'Property: ' . esc_html( $already_sent_property_id ) . ' last sent: ' . esc_html( $highest_send ) . "<br>\n"; }
543 +
398 544 $on_market_change_date = get_post_meta( $already_sent_property_id, '_on_market_change_date', TRUE );
545 +
546 + if ( $dry_run === true ) { echo 'Property: ' . esc_html( $already_sent_property_id ) . ' last on market change: ' . esc_html( $on_market_change_date ) . "<br>\n"; }
547 +
399 548 $price_change_date = get_post_meta( $already_sent_property_id, '_price_change_date', TRUE );
400 549
401 - if ( $on_market_change_date > $highest_send || $price_change_date > $highest_send )
550 + if ( $dry_run === true ) { echo 'Property: ' . esc_html( $already_sent_property_id ) . ' last price change: ' . esc_html( $price_change_date ) . "<br>\n"; }
551 +
552 + if ( $on_market_change_date > $highest_send )
402 553 {
554 + if ( $dry_run === true ) { echo 'Property: ' . esc_html( $already_sent_property_id ) . ' has changed on market since last sent' . "<br>\n"; }
555 +
403 556 // This property has changed since it was last sent. Remove from already sent list so it gets sent again
404 557 unset($already_sent_properties[$already_sent_property_id]);
405 558 }
559 + elseif ( $price_change_date > $highest_send )
560 + {
561 + if ( $dry_run === true ) { echo 'Property: ' . esc_html( $already_sent_property_id ) . ' has changed price since last sent' . "<br>\n"; }
562 +
563 + // This property has changed since it was last sent. Remove from already sent list so it gets sent again
564 + unset($already_sent_properties[$already_sent_property_id]);
565 + }
406 566 }
407 567 }
568 +
569 + if ( $dry_run === true ) { echo 'Already sent properties after: ' . esc_html( wp_json_encode( $already_sent_properties, JSON_PRETTY_PRINT ) ) . "<br>\n"; }
408 570 }
409 571
572 + if ( $dry_run === true ) { echo 'Matching properties before removing already sent: ' . esc_html( wp_json_encode( $matching_properties, JSON_PRETTY_PRINT ) ) . "<br>\n"; }
573 +
410 574 // Check properties haven't already been sent and not marked as 'not interested'
411 575 $new_matching_properties = array();
412 576 foreach ($matching_properties as $matching_property)
413 577 {
@@ -416,20 +580,34 @@
416 580 $new_matching_properties[] = $matching_property->id;
417 581 }
418 582 }
419 583
584 + if ( $dry_run === true ) { echo 'Matching properties after removing already sent: ' . esc_html( wp_json_encode( $new_matching_properties, JSON_PRETTY_PRINT ) ) . "<br>\n"; }
585 +
586 + $max_results = apply_filters( 'propertyhive_auto_match_maximum_results', FALSE);
587 + if ( $max_results !== FALSE )
588 + {
589 + $new_matching_properties = array_slice($new_matching_properties, 0, (int)$max_results);
590 + }
591 +
592 + if ( $dry_run === true ) { echo esc_html('Found ' . count($new_matching_properties) . ' matching properties after removing already sent and dismissed') . "<br>\n"; }
593 +
420 594 if ( !empty($new_matching_properties) )
421 595 {
422 596 $subject = str_replace("[property_count]", count($new_matching_properties) . ' propert' . ( ( count($new_matching_properties) != 1 ) ? 'ies' : 'y' ), $default_subject);
423 597
424 - $body = str_replace("[contact_name]", get_the_title($contact_id), $default_body);
598 + $contact = new PH_Contact($contact_id);
599 + $body = str_replace( '[contact_name]', esc_html( $contact->post_title ), $default_body );
600 + $body = str_replace( '[contact_dear]', esc_html( $contact->dear() ), $body );
425 601 $body = str_replace("[property_count]", count($new_matching_properties) . ' propert' . ( ( count($new_matching_properties) != 1 ) ? 'ies' : 'y' ), $body);
426 602
603 + $office_counts = array();
604 + $negotiator_counts = array();
605 +
427 606 if ( strpos($body, '[properties]') !== FALSE )
428 607 {
429 608 ob_start();
430 609
431 - $office_counts = array();
432 610 if ( !empty($new_matching_properties) )
433 611 {
434 612 foreach ( $new_matching_properties as $email_property_id )
435 613 {
@@ -440,8 +618,14 @@
440 618 if ( !isset($office_counts[$property->office_id]) ) { $office_counts[$property->office_id] = 0; }
441 619 ++$office_counts[$property->office_id];
442 620 }
443 621
622 + if ( $property->negotiator_id != '' && $property->negotiator_id != 0 )
623 + {
624 + if ( !isset($negotiator_counts[$property->negotiator_id]) ) { $negotiator_counts[$property->negotiator_id] = 0; }
625 + ++$negotiator_counts[$property->negotiator_id];
626 + }
627 +
444 628 ph_get_template( 'emails/applicant-match-property.php', array( 'property' => $property ) );
445 629 }
446 630 }
447 631 $body = str_replace("[properties]", ob_get_clean(), $body);
@@ -447,8 +631,9 @@
447 631 $body = str_replace("[properties]", ob_get_clean(), $body);
448 632 }
449 633
450 634 // Get email address of office with most properties
635 + $highest_office_name = '';
451 636 $highest_office_email_address = '';
452 637 if ( !empty($office_counts) )
453 638 {
454 639 arsort($office_counts);
@@ -453,8 +638,9 @@
453 638 {
454 639 arsort($office_counts);
455 640 reset($office_counts);
456 641 $highest_office_id = key($office_counts);
642 + $highest_office_name = ( isset($office_names[$highest_office_id]) ? $office_names[$highest_office_id] : '' );
457 643 $highest_office_email_address = ( isset($office_email_addresses[$applicant_profile['department']][$highest_office_id]) ? $office_email_addresses[$applicant_profile['department']][$highest_office_id] : '' );
458 644 }
459 645 if ( $highest_office_email_address == '' )
460 646 {
@@ -461,24 +647,56 @@
461 647 // fallback to admin email address
462 648 $highest_office_email_address = get_option('admin_email');
463 649 }
464 650
465 - $ph_admin_matching_properties->send_emails(
466 - $contact_id,
467 - $i,
468 - $new_matching_properties,
469 - get_bloginfo('name'),
470 - $highest_office_email_address,
471 - $subject,
472 - $body
473 - );
651 + $body = str_replace( '[office_name]', esc_html( $highest_office_name ), $body );
652 + $body = str_replace( '[office_email_address]', esc_html( $highest_office_email_address ), $body );
653 +
654 + $highest_negotiator_name = '';
655 + $highest_negotiator_email_address = '';
656 + if ( !empty($negotiator_counts) )
657 + {
658 + arsort($negotiator_counts);
659 + reset($negotiator_counts);
660 + $highest_negotiator_id = key($negotiator_counts);
661 + $highest_negotiator_name = ( isset($negotiator_names[$highest_negotiator_id]) ? $negotiator_names[$highest_negotiator_id] : '' );
662 + $highest_negotiator_email_address = ( isset($negotiator_email_addresses[$highest_negotiator_id]) ? $negotiator_email_addresses[$highest_negotiator_id] : '' );
663 + }
664 +
665 + $body = str_replace( '[negotiator_name]', esc_html( $highest_negotiator_name ), $body );
666 + $body = str_replace( '[negotiator_email_address]', esc_html( $highest_negotiator_email_address ), $body );
667 +
668 + $highest_office_email_address = apply_filters( 'propertyhive_auto_match_from_email_address', $highest_office_email_address );
669 +
670 + if ( !$dry_run )
671 + {
672 + $ph_admin_matching_properties->send_emails(
673 + $contact_id,
674 + $i,
675 + $new_matching_properties,
676 + get_bloginfo('name'),
677 + $highest_office_email_address,
678 + $subject,
679 + $body
680 + );
681 + }
682 + else
683 + {
684 + echo esc_html('Would\'ve sent email. Not sending due to being ran in dry run mode') . "<br>\n";
685 + }
474 686 }
475 687 }
476 688 }
477 689 }
690 + else
691 + {
692 + if ( $dry_run === true ) { echo esc_html('No applicant profiles found. Skipping') . "<br>\n"; }
693 + }
478 694 }
479 695 }
480 696
697 + if ( $dry_run === true ) { echo esc_html('Finished auto-match process') . "<br>\n"; die(); }
698 +
481 699 wp_reset_postdata();
482 700 }
483 701
484 702 /**
@@ -501,18 +719,18 @@
501 719 ph_get_template( 'emails/email-styles.php' );
502 720 $css = apply_filters( 'propertyhive_email_styles', ob_get_clean() );
503 721
504 722 // include css inliner
505 - if ( ! class_exists( 'Emogrifier' ) && class_exists( 'DOMDocument' ) ) {
723 + if ( ! class_exists( 'PropertyHive_Emogrifier' ) && class_exists( 'DOMDocument' ) ) {
506 724 include_once( dirname( __FILE__ ) . '/libraries/class-emogrifier.php' );
507 725 }
508 726
509 727 // apply CSS styles inline for picky email clients
510 728 try {
511 - $emogrifier = new Emogrifier( $content, $css );
729 + $emogrifier = new PropertyHive_Emogrifier( $content, $css );
512 730 $content = $emogrifier->emogrify();
513 731 } catch ( Exception $e ) {
514 - die("Error converting CSS styles to be inline. Error as follows: " . $e->getMessage());
732 + die(esc_html("Error converting CSS styles to be inline. Error as follows: " . $e->getMessage()));
515 733 }
516 734 }
517 735 return $content;
518 736 }
@@ -530,9 +748,9 @@
530 748 public function email_footer( $contact_id = '' ) {
531 749 $unsubscribe_link = '';
532 750 if ($contact_id != '')
533 751 {
534 - $unsubscribe_link = site_url() .'?ph_unsubscribe=' . base64_encode($contact_id . '|' . md5( get_post_meta( $contact_id, '_email_address', TRUE ) ) );
752 + $unsubscribe_link = PH()->get_contact_unsubscribe_url( $contact_id );
535 753 }
536 754
537 755 ph_get_template( 'emails/email-footer.php', array( 'unsubscribe_link' => $unsubscribe_link ) );
538 756 }
@@ -549,8 +767,9 @@
549 767 ob_start();
550 768
551 769 do_action( 'propertyhive_email_header', $contact_id );
552 770
771 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Email bodies are deliberately HTML. The callers sanitize stored/request template content before this shared wrapper, and email header/footer plus propertyhive_mail_content are trusted extension points; escaping here would break supported markup.
553 772 echo wpautop( wptexturize( $message ) );
554 773
555 774 do_action( 'propertyhive_email_footer', $contact_id );
556 775
@@ -561,12 +780,26 @@
561 780 }
562 781
563 782 public function send_enquiry_auto_responder( $data = array() )
564 783 {
565 - if ( isset($data['property_id']) && sanitize_text_field($data['property_id']) != '' )
784 + if ( ! is_array( $data ) ) {
785 + return;
786 + }
787 +
788 + $request_data = wp_unslash( $data );
789 + if ( isset( $request_data['property_id'] ) && is_string( $request_data['property_id'] ) && '' !== $request_data['property_id'] )
566 790 {
567 - $property_id = $data['property_id'];
568 - $to = sanitize_email( $_POST['email_address'] );
791 + $property_ids = array_filter( array_map( 'absint', explode( '|', $request_data['property_id'] ) ) );
792 + if ( empty( $property_ids ) || count( $property_ids ) > 100 ) {
793 + return;
794 + }
795 + foreach ( $property_ids as $property_id ) {
796 + if ( 'property' !== get_post_type( $property_id ) || ! propertyhive_is_post_publicly_viewable( $property_id ) ) {
797 + return;
798 + }
799 + }
800 +
801 + $to = isset( $request_data['email_address'] ) && is_string( $request_data['email_address'] ) ? sanitize_email( $request_data['email_address'] ) : '';
569 802 $subject = get_option( 'propertyhive_enquiry_auto_responder_email_subject', '' );
570 803 $body = get_option( 'propertyhive_enquiry_auto_responder_email_body', '' );
571 804
572 805 if ( $to != '' && $subject != '' && $body != '' )
@@ -571,107 +804,144 @@
571 804
572 805 if ( $to != '' && $subject != '' && $body != '' )
573 806 {
574 807 $headers = array();
575 - $headers[] = 'From: ' . get_bloginfo('name') . ' <' . get_option( 'propertyhive_email_from_address', get_option( 'admin_email' ) ) . '>';
808 + $headers[] = 'From: ' . html_entity_decode(get_bloginfo('name')) . ' <' . get_option( 'propertyhive_email_from_address', get_option( 'admin_email' ) ) . '>';
576 809 $headers[] = 'Content-Type: text/html; charset=UTF-8';
577 810
578 - $body = str_replace( "[name]", ( isset($_POST['name']) ? ph_clean($_POST['name']) : '' ), $body );
811 + $name = isset( $request_data['name'] ) && is_string( $request_data['name'] ) ? ph_clean( $request_data['name'] ) : '';
812 + $body = str_replace( '[name]', esc_html( $name ), $body );
579 813
580 - $body = str_replace( "[property_address_hyperlinked]", '<a href="' . get_permalink($property_id) . '">' . get_the_title($property_id) . '</a>', $body );
814 + $property_address_hyperlinked = array();
815 + foreach ( $property_ids as $property_id )
816 + {
817 + $property_address_hyperlinked[] = '<a href="' . esc_url( get_permalink( $property_id ) ) . '">' . esc_html( get_the_title( $property_id ) ) . '</a>';
818 + }
819 + $body = str_replace( "[property_address_hyperlinked]", implode(' and ', array_filter(array_merge(array(join(', ', array_slice($property_address_hyperlinked, 0, -1))), array_slice($property_address_hyperlinked, -1)), 'strlen')), $body );
581 820
582 821 if ( strpos( $body, '[similar_properties]' ) !== FALSE )
583 822 {
584 823 $similar_html = '';
585 824
586 - $department = get_post_meta( $property_id, '_department', TRUE );
587 - $bedrooms = get_post_meta( $property_id, '_bedrooms', TRUE );
588 - $price = get_post_meta( $property_id, '_price_actual', true );
589 - $lower_price = $price - ($price / 10);
590 - $higher_price = $price + ($price / 10);
825 + foreach ( $property_ids as $property_id )
826 + {
827 + $department = get_post_meta( $property_id, '_department', TRUE );
591 828
592 - // Get two similar properties
593 - $args = array(
594 - 'post_type' => 'property',
595 - 'post_status' => 'publish',
596 - 'posts_per_page' => 3,
597 - 'orderby' => 'rand',
598 - 'post__not_in' => array($property_id),
599 - );
829 + // Get three similar properties
830 + $args = array(
831 + 'post_type' => 'property',
832 + 'post_status' => 'publish',
833 + 'posts_per_page' => 3,
834 + 'orderby' => 'rand',
835 + // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- Each email’s similar-property query returns three properties and excludes the current property. posts_per_page=3; post__not_in is one property ID; metadata/taxonomy match the recommendation rules.
836 + 'post__not_in' => array($property_id),
837 + );
600 838
601 - $meta_query = array();
839 + $meta_query = array();
602 840
603 - $meta_query[] = array(
604 - 'key' => '_department',
605 - 'value' => $department,
606 - );
841 + $meta_query[] = array(
842 + 'key' => '_department',
843 + 'value' => $department,
844 + );
607 845
608 - $meta_query[] = array(
609 - 'key' => '_on_market',
610 - 'value' => 'yes',
611 - );
846 + $meta_query[] = array(
847 + 'key' => '_on_market',
848 + 'value' => 'yes',
849 + );
612 850
613 - $meta_query[] = array(
614 - 'key' => '_bedrooms',
615 - 'value' => $bedrooms,
616 - 'type' => 'NUMERIC'
617 - );
851 + if ( $department != 'commercial' && ph_get_custom_department_based_on( $department ) != 'commercial' )
852 + {
853 + $bedrooms = get_post_meta( $property_id, '_bedrooms', TRUE );
618 854
619 - $meta_query[] = array(
620 - 'key' => '_price_actual',
621 - 'value' => $lower_price,
622 - 'compare' => '>=',
623 - 'type' => 'NUMERIC'
624 - );
855 + $meta_query[] = array(
856 + 'key' => '_bedrooms',
857 + 'value' => $bedrooms,
858 + 'type' => 'NUMERIC'
859 + );
625 860
626 - $meta_query[] = array(
627 - 'key' => '_price_actual',
628 - 'value' => $higher_price,
629 - 'compare' => '<=',
630 - 'type' => 'NUMERIC'
631 - );
632 861
633 - $args['meta_query'] = $meta_query;
862 + $price = get_post_meta( $property_id, '_price_actual', true );
863 + $lower_price = $price - ($price / 10);
864 + $higher_price = $price + ($price / 10);
634 865
635 - $properties_query = new WP_Query( apply_filters( 'propertyhive_auto_responder_similar_properties_query', $args, $property_id ) );
866 + $meta_query[] = array(
867 + 'key' => '_price_actual',
868 + 'value' => $lower_price,
869 + 'compare' => '>=',
870 + 'type' => 'NUMERIC'
871 + );
636 872
637 - if ( $properties_query->have_posts() )
638 - {
639 - $similar_html = '<br><hr><br><h3>Similar Properties You Might Like:</h3>';
873 + $meta_query[] = array(
874 + 'key' => '_price_actual',
875 + 'value' => $higher_price,
876 + 'compare' => '<=',
877 + 'type' => 'NUMERIC'
878 + );
879 + }
880 + else
881 + {
882 + $for_sale = get_post_meta( $property_id, '_for_sale', true );
883 + $to_rent = get_post_meta( $property_id, '_to_rent', true );
640 884
641 - while ( $properties_query->have_posts() )
885 + if ( $for_sale == 'yes' )
886 + {
887 + $meta_query[] = array(
888 + 'key' => '_for_sale',
889 + 'value' => 'yes',
890 + );
891 + }
892 + elseif ( $to_rent == 'yes' )
893 + {
894 + $meta_query[] = array(
895 + 'key' => '_to_rent',
896 + 'value' => 'yes',
897 + );
898 + }
899 + }
900 +
901 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Each email’s similar-property query returns three properties and excludes the current property. posts_per_page=3; post__not_in is one property ID; metadata/taxonomy match the recommendation rules.
902 + $args['meta_query'] = $meta_query;
903 +
904 + $property_match_statuses = get_option( 'propertyhive_property_match_statuses', '' );
905 + if ( $property_match_statuses != '' && is_array($property_match_statuses) && !empty($property_match_statuses) )
642 906 {
643 - $properties_query->the_post();
907 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Each email’s similar-property query returns three properties and excludes the current property. posts_per_page=3; post__not_in is one property ID; metadata/taxonomy match the recommendation rules.
908 + $args['tax_query'] = array(
909 + array(
910 + 'taxonomy' => 'availability',
911 + 'field' => 'term_id',
912 + 'terms' => $property_match_statuses,
913 + 'operator' => 'IN',
914 + )
915 + );
916 + }
644 917
645 - $property = new PH_Property( get_the_ID() );
918 + $properties_query = new WP_Query( apply_filters( 'propertyhive_auto_responder_similar_properties_query', $args, $property_id ) );
646 919
647 - $similar_html .= '<table width="100%" cellpadding="5" cellspacing="0">';
648 - $similar_html .= '<tr>';
649 - $similar_html .= '<td width="20%" valign="top">';
650 - $image = $property->get_main_photo_src();
651 - if ( $image !== false )
920 + if ( $properties_query->have_posts() )
921 + {
922 + while ( $properties_query->have_posts() )
652 923 {
653 - $similar_html .= '<a href="' . get_permalink() . '"><img src="' . $image . '" alt="' . get_the_title() . '"></a>';
924 + $properties_query->the_post();
925 +
926 + $property = new PH_Property( get_the_ID() );
927 +
928 + ob_start();
929 +
930 + ph_get_template( 'emails/enquiry-autoresponder-similar-property.php', array( 'property' => $property ) );
931 +
932 + $similar_html .= ob_get_clean();
654 933 }
655 - $similar_html .= '</td>';
656 - $similar_html .= '<td valign="top" class="text">';
657 - $similar_html .= '<p style="margin-bottom:8px !important;"><strong><a href="' . get_permalink() . '">' . get_the_title() . '</a></strong></p>';
658 - $similar_html .= '<p style="margin-bottom:8px !important; font-size:14px;"><strong>' . $property->get_formatted_price() . '</strong></p>';
659 - $similar_html .= '<p style="margin-bottom:8px !important; font-size:14px;">' . $property->bedrooms . ' bed ' . $property->property_type . ' ' . $property->availability . '</p>';
660 - if ( strip_tags($property->post_excerpt) != '' )
661 - {
662 - $similar_html .= '<p style="margin-bottom:0 !important; font-size:14px;">' . substr(strip_tags($property->post_excerpt), 0, 300);
663 - if ( strlen(strip_tags($property->post_excerpt)) > 300 ) { $similar_html .= '...'; }
664 - $similar_html .= '</p>';
665 - }
666 - $similar_html .= '</td>';
667 - $similar_html .= '</tr>';
668 - $similar_html .= '</table><br>';
669 934 }
670 935 }
936 + if ( !empty($similar_html) )
937 + {
938 + $similar_html = '<br><hr><br><h3>Similar Properties You Might Like:</h3>' . $similar_html;
939 + }
671 940 $body = str_replace( "[similar_properties]", $similar_html, $body );
672 941 }
673 942
943 + $body = apply_filters( 'propertyhive_enquiry_auto_responder_body', $body, $property_ids[0] );
674 944 $body = apply_filters( 'propertyhive_mail_content', $this->style_inline( $this->wrap_message( $body ) ) );
675 945
676 946 wp_mail( $to, $subject, $body, $headers );
677 947 }
@@ -676,5 +946,5 @@
676 946 wp_mail( $to, $subject, $body, $headers );
677 947 }
678 948 }
679 949 }
680 -}
950 +}