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