PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.2.0
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.2.0
4.2.0 4.1.0 4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / form / class-form-email-handler.php
superb-blocks / src / gutenberg / form Last commit date
class-form-access-control.php 3 weeks ago class-form-captcha-handler.php 2 weeks ago class-form-controller.php 2 days ago class-form-email-config-check.php 3 weeks ago class-form-email-handler.php 2 days ago class-form-encryption.php 3 weeks ago class-form-exporter.php 2 days ago class-form-field-validator.php 3 weeks ago class-form-file-handler.php 2 days ago class-form-google-auth.php 3 weeks ago class-form-integration-handler.php 3 weeks ago class-form-math-parser.php 3 weeks ago class-form-permissions.php 3 weeks ago class-form-registry.php 3 weeks ago class-form-settings.php 3 weeks ago class-form-submission-cpt.php 3 weeks ago class-form-submission-handler.php 2 days ago class-form-zip-exporter.php 2 days ago
class-form-email-handler.php
554 lines
1 <?php
2
3 namespace SuperbAddons\Gutenberg\Form;
4
5 defined('ABSPATH') || exit();
6
7 class FormEmailHandler
8 {
9 /**
10 * Track the last wp_mail_failed error for status capture.
11 */
12 private static $last_mail_error = null;
13
14 /**
15 * Send admin notification email.
16 *
17 * @param array $form_data Form configuration
18 * @param array $fields Submitted field data (field_id => value)
19 * @param int $post_id Optional submission post ID for status tracking
20 * @return bool
21 */
22 public static function SendAdminNotification($form_data, $fields, $post_id = 0)
23 {
24 $to_raw = !empty($form_data['email_to']) ? $form_data['email_to'] : get_option('admin_email');
25 $to = array_filter(array_map(function ($email) {
26 return sanitize_email(trim($email));
27 }, explode(',', $to_raw)), 'is_email');
28 if (empty($to)) {
29 return false;
30 }
31 $subject = self::ProcessMergeTags(
32 !empty($form_data['email_subject']) ? $form_data['email_subject'] : __('New form submission', 'superb-blocks'),
33 $form_data,
34 $fields
35 );
36
37 // Older submissions were stored with file fields after the text fields;
38 // present them in form order regardless of how they were stored.
39 $fields = FormSubmissionHandler::OrderFieldsByConfig($fields, isset($form_data['form_fields']) ? $form_data['form_fields'] : array());
40
41 // Annotate file fields with attach/too-large flags and collect the file
42 // paths that fit within the total email-attachment budget. BuildEmailBody
43 // reads the flags, so this must run before the body is built.
44 list($fields, $attachments) = self::PrepareAttachments($fields, $form_data);
45
46 $body = self::BuildEmailBody($form_data, $fields);
47 $headers = array('Content-Type: text/html; charset=UTF-8');
48
49 // From name/email: form-level -> global default -> WordPress default
50 $from_name = self::ResolveFromName($form_data);
51 $from_email = self::ResolveFromEmail($form_data);
52 if (!empty($from_email) && is_email($from_email)) {
53 $from_header = !empty($from_name) ? ($from_name . ' <' . $from_email . '>') : $from_email;
54 $headers[] = 'From: ' . $from_header;
55 }
56
57 $form_fields = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
58
59 if (!empty($form_data['email_reply_to'])) {
60 $reply_to_value = self::FindFieldValue($fields, $form_data['email_reply_to'], $form_fields);
61 if (!empty($reply_to_value) && is_email($reply_to_value)) {
62 $headers[] = 'Reply-To: ' . sanitize_email($reply_to_value);
63 }
64 }
65
66 if (!empty($form_data['email_cc'])) {
67 $cc_emails = array_map('trim', explode(',', $form_data['email_cc']));
68 foreach ($cc_emails as $cc) {
69 if (is_email($cc)) {
70 $headers[] = 'Cc: ' . sanitize_email($cc);
71 }
72 }
73 }
74
75 if (!empty($form_data['email_bcc'])) {
76 $bcc_emails = array_map('trim', explode(',', $form_data['email_bcc']));
77 foreach ($bcc_emails as $bcc) {
78 if (is_email($bcc)) {
79 $headers[] = 'Bcc: ' . sanitize_email($bcc);
80 }
81 }
82 }
83
84 self::$last_mail_error = null;
85 add_action('wp_mail_failed', array(__CLASS__, 'CaptureMailError'));
86 $result = wp_mail($to, $subject, $body, $headers, $attachments);
87 remove_action('wp_mail_failed', array(__CLASS__, 'CaptureMailError'));
88
89 if ($post_id > 0) {
90 self::UpdateEmailStatus($post_id, 'admin', $result, self::$last_mail_error);
91 }
92
93 return $result;
94 }
95
96 /**
97 * Send confirmation email to the form submitter.
98 *
99 * @param array $form_data Form configuration
100 * @param array $fields Submitted field data
101 * @param int $post_id Optional submission post ID for status tracking
102 * @return bool
103 */
104 public static function SendConfirmation($form_data, $fields, $post_id = 0)
105 {
106 $form_fields = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
107 $fields = FormSubmissionHandler::OrderFieldsByConfig($fields, $form_fields);
108
109 // Find the email field value — use configured field if set, otherwise auto-detect
110 $user_email = '';
111 if (!empty($form_data['confirmation_email_field'])) {
112 $found = self::FindFieldValue($fields, $form_data['confirmation_email_field'], $form_fields);
113 if (!empty($found) && is_email($found)) {
114 $user_email = $found;
115 }
116 }
117 // Fallback: auto-detect first email value
118 if (empty($user_email)) {
119 foreach ($fields as $field_id => $value) {
120 if (is_email($value)) {
121 $user_email = $value;
122 break;
123 }
124 }
125 }
126
127 if (empty($user_email)) {
128 return false;
129 }
130
131 $subject = self::ProcessMergeTags(
132 !empty($form_data['confirmation_subject']) ? $form_data['confirmation_subject'] : __('We received your submission', 'superb-blocks'),
133 $form_data,
134 $fields
135 );
136
137 $message = self::ProcessMergeTags(
138 !empty($form_data['confirmation_message']) ? $form_data['confirmation_message'] : __('Thank you for your submission.', 'superb-blocks'),
139 $form_data,
140 $fields,
141 true // Escape for HTML email body
142 );
143
144 // The message is authored in a plain textarea, so its newlines carry the
145 // intended spacing. The body is sent as text/html, where newlines collapse
146 // to spaces, so convert them to <br> the way the editor preview does.
147 $body = self::WrapInTemplate(nl2br($message));
148 $headers = array('Content-Type: text/html; charset=UTF-8');
149
150 // From name/email: form-level -> global default -> WordPress default
151 $from_name = self::ResolveFromName($form_data);
152 $from_email = self::ResolveFromEmail($form_data);
153 if (!empty($from_email) && is_email($from_email)) {
154 $from_header = !empty($from_name) ? ($from_name . ' <' . $from_email . '>') : $from_email;
155 $headers[] = 'From: ' . $from_header;
156 }
157
158 self::$last_mail_error = null;
159 add_action('wp_mail_failed', array(__CLASS__, 'CaptureMailError'));
160 $result = wp_mail(sanitize_email($user_email), $subject, $body, $headers);
161 remove_action('wp_mail_failed', array(__CLASS__, 'CaptureMailError'));
162
163 if ($post_id > 0) {
164 self::UpdateEmailStatus($post_id, 'user', $result, self::$last_mail_error);
165 }
166
167 return $result;
168 }
169
170 /**
171 * Capture wp_mail_failed error for status tracking.
172 *
173 * @param \WP_Error $error
174 */
175 public static function CaptureMailError($error)
176 {
177 if (is_wp_error($error)) {
178 self::$last_mail_error = $error->get_error_message();
179 }
180 }
181
182 /**
183 * Update email delivery status meta on a submission.
184 *
185 * @param int $post_id
186 * @param string $type 'admin' or 'user'
187 * @param bool $sent
188 * @param string|null $error
189 */
190 public static function UpdateEmailStatus($post_id, $type, $sent, $error = null)
191 {
192 $status = get_post_meta($post_id, '_spb_form_email_status', true);
193 if (!is_array($status)) {
194 $status = array();
195 }
196 $status[$type] = array(
197 'sent' => (bool) $sent,
198 'time' => time(),
199 'error' => $error,
200 );
201 update_post_meta($post_id, '_spb_form_email_status', $status);
202 }
203
204 private static function BuildEmailBody($form_data, $fields)
205 {
206 $form_name = !empty($form_data['form_name']) ? esc_html($form_data['form_name']) : __('Form Submission', 'superb-blocks');
207
208 // Build fieldId => deduplicated label lookup
209 $form_fields = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
210 $label_map = array();
211 foreach (self::BuildFieldTagMap($form_fields) as $entry) {
212 $label_map[$entry['fieldId']] = $entry['label'];
213 }
214
215 // Build sensitive field lookup to skip in email body. The sensitive
216 // toggle is unavailable for file and hidden fields, so a flag left over
217 // from a prior field type is treated as off. Those fields are shown,
218 // and files attached, like any other field.
219 $sensitive_ids = array();
220 foreach ($form_fields as $field_def) {
221 $field_type = isset($field_def['fieldType']) ? $field_def['fieldType'] : '';
222 if ($field_type === 'file' || $field_type === 'hidden') {
223 continue;
224 }
225 if (!empty($field_def['sensitive']) && !empty($field_def['fieldId'])) {
226 $sensitive_ids[$field_def['fieldId']] = true;
227 }
228 }
229
230 // Build signature field lookup to skip in email body
231 $signature_ids = array();
232 foreach ($form_fields as $field_def) {
233 if (isset($field_def['fieldType']) && $field_def['fieldType'] === 'signature' && !empty($field_def['fieldId'])) {
234 $signature_ids[$field_def['fieldId']] = true;
235 }
236 }
237
238 $rows = '';
239 foreach ($fields as $field_id => $value) {
240 // Skip sensitive fields
241 if (isset($sensitive_ids[$field_id])) {
242 continue;
243 }
244 // Skip signature fields (PNG data URLs are too large for email)
245 if (isset($signature_ids[$field_id])) {
246 continue;
247 }
248 $label = esc_html(isset($label_map[$field_id]) ? $label_map[$field_id] : $field_id);
249
250 // File fields store an array of file metadata. Attached files are
251 // listed by name; files that exceeded the attachment budget get a
252 // plain-text note (no admin URL, since it may be quoted in a reply
253 // to the submitter when Reply-To is mapped to their email).
254 if (is_array($value)) {
255 // Only point the admin to the dashboard when submissions are
256 // stored. With storage off there is no submission to open, so an
257 // oversized file is gone once it misses the email; say no more.
258 $store_enabled = !empty($form_data['store_enabled']);
259 $parts = array();
260 foreach ($value as $file) {
261 if (!is_array($file) || !isset($file['name'])) {
262 continue;
263 }
264 $name = esc_html($file['name']);
265 if (!empty($file['too_large'])) {
266 $size_str = isset($file['size']) ? esc_html(self::FormatBytes($file['size'])) : '';
267 $file_label = $size_str !== '' ? $name . ' (' . $size_str . ')' : $name;
268 $parts[] = $store_enabled
269 /* translators: %s: file name, optionally followed by its size in parentheses */
270 ? sprintf(__('%s is too large to email. It is available in this form\'s submissions in your dashboard.', 'superb-blocks'), $file_label)
271 /* translators: %s: file name, optionally followed by its size in parentheses */
272 : sprintf(__('%s is too large to email.', 'superb-blocks'), $file_label);
273 } else {
274 $parts[] = $name;
275 }
276 }
277 $val = implode('<br>', array_filter($parts));
278 if (empty($val)) {
279 continue;
280 }
281 } else {
282 $val = nl2br(esc_html($value));
283 }
284
285 $rows .= '<tr><td style="padding:8px 12px;border-bottom:1px solid #eee;font-weight:500;vertical-align:top;width:30%;">' . $label . '</td>';
286 $rows .= '<td style="padding:8px 12px;border-bottom:1px solid #eee;">' . $val . '</td></tr>';
287 }
288
289 $content = '<h2 style="margin:0 0 16px;font-size:18px;">' . $form_name . '</h2>';
290 $content .= '<table style="width:100%;border-collapse:collapse;font-size:14px;">' . $rows . '</table>';
291
292 return self::WrapInTemplate($content);
293 }
294
295 /**
296 * Decide which uploaded files can ride along as email attachments.
297 *
298 * Files are attached greedily in field order while the running total stays
299 * within the budget returned by GetMaxAttachmentBytes(). Each file's
300 * metadata array is annotated with 'attached' (bool) and, when a present
301 * file did not fit, 'too_large' (bool) so BuildEmailBody can render it.
302 * The per-field upload cap (fileSettings.maxFileSize) is intentionally NOT
303 * reused: it is per-file, while this budget bounds the whole message.
304 *
305 * @param array $fields Submitted field data (field_id => value).
306 * @param array $form_data Form configuration (passed through to the filter).
307 * @return array list($fields, $attachment_paths)
308 */
309 private static function PrepareAttachments($fields, $form_data)
310 {
311 $max_total = self::GetMaxAttachmentBytes($form_data);
312 $attachments = array();
313 $used_names = array();
314 $used = 0;
315
316 // The only array-valued fields reaching here are file fields (multi-value
317 // fields are stringified during sanitization), and the sensitive toggle
318 // is never available for file fields, so no sensitive check is needed.
319 foreach ($fields as $field_id => $value) {
320 if (!is_array($value)) {
321 continue;
322 }
323 foreach ($value as $i => $file) {
324 if (!is_array($file) || empty($file['path'])) {
325 continue;
326 }
327 $fields[$field_id][$i]['attached'] = false;
328 // Stored paths may predate the randomized upload directory
329 $file_path = FormFileHandler::ResolveStoredPath($file['path']);
330 if ($file_path === '' || !file_exists($file_path)) {
331 continue;
332 }
333 $size = isset($file['size']) ? intval($file['size']) : 0;
334 if ($size <= 0) {
335 $size = (int) filesize($file_path);
336 }
337 if ($max_total > 0 && $size > 0 && ($used + $size) <= $max_total) {
338 // Attach under the original upload name (stored files carry
339 // a random suffix). wp_mail supports name => path pairs on
340 // WP 6.2+, which is this code path's version floor.
341 $display = isset($file['name']) && $file['name'] !== '' ? $file['name'] : basename($file_path);
342 if (isset($used_names[$display])) {
343 // Suffix until unique. Generated names are registered
344 // below like any other, so "name (2).ext" can collide
345 // neither with a later duplicate nor with a file
346 // genuinely named "name (2).ext".
347 $base = $display;
348 $dot = strrpos($base, '.');
349 do {
350 $used_names[$base]++;
351 $display = $dot !== false
352 ? substr($base, 0, $dot) . ' (' . $used_names[$base] . ')' . substr($base, $dot)
353 : $base . ' (' . $used_names[$base] . ')';
354 } while (isset($used_names[$display]));
355 }
356 $used_names[$display] = 1;
357 $attachments[$display] = $file_path;
358 $used += $size;
359 $fields[$field_id][$i]['attached'] = true;
360 } else {
361 $fields[$field_id][$i]['too_large'] = true;
362 }
363 }
364 }
365
366 return array($fields, $attachments);
367 }
368
369 /**
370 * Total byte budget for files attached to a single notification email.
371 *
372 * Defaults to 10MB, which clears the strict end of common mail-server
373 * limits after base64 inflation (~33%). Not a user-facing setting because
374 * the real ceiling is a property of the recipient's mail server; devs on a
375 * generous SMTP relay can raise it (or return 0 to disable attachments).
376 *
377 * @param array $form_data Form configuration, for per-form overrides.
378 * @return int Maximum total attachment size in bytes (0 disables).
379 */
380 private static function GetMaxAttachmentBytes($form_data)
381 {
382 $default = 10 * 1024 * 1024;
383 /**
384 * Filters the total attachment byte budget for form notification emails.
385 *
386 * @param int $default Default budget in bytes (10MB).
387 * @param array $form_data The form configuration for the submission.
388 */
389 $bytes = apply_filters('superbaddons_form_email_max_attachment_bytes', $default, $form_data);
390 return max(0, intval($bytes));
391 }
392
393 /**
394 * Format a byte count as a short human-readable size (e.g. "14MB").
395 *
396 * @param int|float $bytes
397 * @return string
398 */
399 private static function FormatBytes($bytes)
400 {
401 $bytes = floatval($bytes);
402 if ($bytes >= 1048576) {
403 return round($bytes / 1048576, 1) . 'MB';
404 }
405 if ($bytes >= 1024) {
406 return round($bytes / 1024) . 'KB';
407 }
408 return intval($bytes) . 'B';
409 }
410
411 private static function WrapInTemplate($content)
412 {
413 $site_name = esc_html(get_bloginfo('name'));
414 /* translators: %s: site name */
415 $footer_text = sprintf(__('Sent from %s', 'superb-blocks'), $site_name);
416 return '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="margin:0;padding:20px;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,sans-serif;">'
417 . '<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:4px;padding:24px;border:1px solid #e0e0e0;">'
418 . $content
419 . '<p style="margin:24px 0 0;font-size:12px;color:#999;">' . $footer_text . '</p>'
420 . '</div></body></html>';
421 }
422
423 /**
424 * Build a deduplicated tag map from form field definitions.
425 * Mirrors the JS buildFieldTagMap — duplicate labels get numeric suffixes.
426 *
427 * @param array $form_fields Array of field attribute arrays.
428 * @return array Array of [ 'fieldId' => string, 'label' => string ]
429 */
430 private static function BuildFieldTagMap($form_fields)
431 {
432 $counts = array();
433 $result = array();
434 foreach ($form_fields as $field_def) {
435 $fid = isset($field_def['fieldId']) ? $field_def['fieldId'] : '';
436 if ($fid === '') {
437 continue;
438 }
439 $field_type = isset($field_def['fieldType']) ? $field_def['fieldType'] : '';
440 if ($field_type === 'hidden' || $field_type === 'signature') {
441 continue;
442 }
443 if (!empty($field_def['sensitive'])) {
444 continue;
445 }
446 $base_label = isset($field_def['label']) && $field_def['label'] !== ''
447 ? $field_def['label']
448 : ($field_type !== '' ? 'Unlabeled ' . ucfirst($field_type) . ' Field' : 'Unlabeled Field');
449 $counts[$base_label] = isset($counts[$base_label]) ? $counts[$base_label] + 1 : 1;
450 $n = $counts[$base_label];
451 $label = $n > 1 ? $base_label . ' ' . $n : $base_label;
452 $result[] = array('fieldId' => $fid, 'label' => $label);
453 }
454 return $result;
455 }
456
457 /**
458 * @param string $text Text with merge tags
459 * @param array $form_data Form configuration
460 * @param array $fields Submitted field data
461 * @param bool $escape_html Whether to escape values for HTML context
462 */
463 private static function ProcessMergeTags($text, $form_data, $fields, $escape_html = false)
464 {
465 $form_name = isset($form_data['form_name']) ? $form_data['form_name'] : '';
466 $site_name = get_bloginfo('name');
467 if ($escape_html) {
468 $form_name = esc_html($form_name);
469 $site_name = esc_html($site_name);
470 }
471 $text = str_replace('{form_name}', $form_name, $text);
472 $text = str_replace('{site_name}', $site_name, $text);
473
474 $form_fields = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
475 $tag_map = self::BuildFieldTagMap($form_fields);
476 foreach ($tag_map as $entry) {
477 if (isset($fields[$entry['fieldId']])) {
478 $val = $fields[$entry['fieldId']];
479 // Skip non-string values (e.g. file metadata arrays)
480 if (!is_string($val)) {
481 continue;
482 }
483 if ($escape_html) {
484 $val = esc_html($val);
485 }
486 $text = str_replace('{' . $entry['label'] . '}', $val, $text);
487 }
488 }
489
490 return $text;
491 }
492
493 /**
494 * Resolve the "From" name: form-level -> global default -> site name.
495 *
496 * @param array $form_data
497 * @return string
498 */
499 private static function ResolveFromName($form_data)
500 {
501 // 1. Form-level setting
502 if (!empty($form_data['email_from_name'])) {
503 return sanitize_text_field($form_data['email_from_name']);
504 }
505 // 2. Global default setting
506 $defaults = get_option('superbaddons_form_default_email', array());
507 if (is_array($defaults) && !empty($defaults['from_name'])) {
508 return sanitize_text_field($defaults['from_name']);
509 }
510 // 3. WordPress default
511 return get_bloginfo('name');
512 }
513
514 /**
515 * Resolve the "From" email: form-level -> global default -> admin email.
516 *
517 * @param array $form_data
518 * @return string
519 */
520 private static function ResolveFromEmail($form_data)
521 {
522 // 1. Form-level setting
523 if (!empty($form_data['email_from_address'])) {
524 return sanitize_email($form_data['email_from_address']);
525 }
526 // 2. Global default setting
527 $defaults = get_option('superbaddons_form_default_email', array());
528 if (is_array($defaults) && !empty($defaults['from_email'])) {
529 return sanitize_email($defaults['from_email']);
530 }
531 // 3. WordPress default
532 return get_option('admin_email');
533 }
534
535 /**
536 * Resolve a deduplicated label to its submitted value.
537 */
538 private static function FindFieldValue($fields, $field_name, $form_fields = array())
539 {
540 // First try direct match (fieldId as key)
541 if (isset($fields[$field_name])) {
542 return $fields[$field_name];
543 }
544 // Resolve deduplicated label to fieldId via tag map
545 $tag_map = self::BuildFieldTagMap($form_fields);
546 foreach ($tag_map as $entry) {
547 if ($entry['label'] === $field_name && isset($fields[$entry['fieldId']])) {
548 return $fields[$entry['fieldId']];
549 }
550 }
551 return '';
552 }
553 }
554