PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.8
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.8
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 1 week ago class-form-captcha-handler.php 1 week ago class-form-controller.php 1 week ago class-form-email-config-check.php 1 week ago class-form-email-handler.php 1 week ago class-form-encryption.php 1 week ago class-form-exporter.php 1 week ago class-form-field-validator.php 1 week ago class-form-file-handler.php 1 week ago class-form-google-auth.php 1 week ago class-form-integration-handler.php 1 week ago class-form-math-parser.php 1 week ago class-form-permissions.php 1 week ago class-form-registry.php 1 week ago class-form-settings.php 1 week ago class-form-submission-cpt.php 1 week ago class-form-submission-handler.php 1 week ago
class-form-email-handler.php
527 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 = 0;
309
310 // The only array-valued fields reaching here are file fields (multi-value
311 // fields are stringified during sanitization), and the sensitive toggle
312 // is never available for file fields, so no sensitive check is needed.
313 foreach ($fields as $field_id => $value) {
314 if (!is_array($value)) {
315 continue;
316 }
317 foreach ($value as $i => $file) {
318 if (!is_array($file) || empty($file['path'])) {
319 continue;
320 }
321 $fields[$field_id][$i]['attached'] = false;
322 if (!file_exists($file['path'])) {
323 continue;
324 }
325 $size = isset($file['size']) ? intval($file['size']) : 0;
326 if ($size <= 0) {
327 $size = (int) filesize($file['path']);
328 }
329 if ($max_total > 0 && $size > 0 && ($used + $size) <= $max_total) {
330 $attachments[] = $file['path'];
331 $used += $size;
332 $fields[$field_id][$i]['attached'] = true;
333 } else {
334 $fields[$field_id][$i]['too_large'] = true;
335 }
336 }
337 }
338
339 return array($fields, $attachments);
340 }
341
342 /**
343 * Total byte budget for files attached to a single notification email.
344 *
345 * Defaults to 10MB, which clears the strict end of common mail-server
346 * limits after base64 inflation (~33%). Not a user-facing setting because
347 * the real ceiling is a property of the recipient's mail server; devs on a
348 * generous SMTP relay can raise it (or return 0 to disable attachments).
349 *
350 * @param array $form_data Form configuration, for per-form overrides.
351 * @return int Maximum total attachment size in bytes (0 disables).
352 */
353 private static function GetMaxAttachmentBytes($form_data)
354 {
355 $default = 10 * 1024 * 1024;
356 /**
357 * Filters the total attachment byte budget for form notification emails.
358 *
359 * @param int $default Default budget in bytes (10MB).
360 * @param array $form_data The form configuration for the submission.
361 */
362 $bytes = apply_filters('superbaddons_form_email_max_attachment_bytes', $default, $form_data);
363 return max(0, intval($bytes));
364 }
365
366 /**
367 * Format a byte count as a short human-readable size (e.g. "14MB").
368 *
369 * @param int|float $bytes
370 * @return string
371 */
372 private static function FormatBytes($bytes)
373 {
374 $bytes = floatval($bytes);
375 if ($bytes >= 1048576) {
376 return round($bytes / 1048576, 1) . 'MB';
377 }
378 if ($bytes >= 1024) {
379 return round($bytes / 1024) . 'KB';
380 }
381 return intval($bytes) . 'B';
382 }
383
384 private static function WrapInTemplate($content)
385 {
386 $site_name = esc_html(get_bloginfo('name'));
387 /* translators: %s: site name */
388 $footer_text = sprintf(__('Sent from %s', 'superb-blocks'), $site_name);
389 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;">'
390 . '<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:4px;padding:24px;border:1px solid #e0e0e0;">'
391 . $content
392 . '<p style="margin:24px 0 0;font-size:12px;color:#999;">' . $footer_text . '</p>'
393 . '</div></body></html>';
394 }
395
396 /**
397 * Build a deduplicated tag map from form field definitions.
398 * Mirrors the JS buildFieldTagMap — duplicate labels get numeric suffixes.
399 *
400 * @param array $form_fields Array of field attribute arrays.
401 * @return array Array of [ 'fieldId' => string, 'label' => string ]
402 */
403 private static function BuildFieldTagMap($form_fields)
404 {
405 $counts = array();
406 $result = array();
407 foreach ($form_fields as $field_def) {
408 $fid = isset($field_def['fieldId']) ? $field_def['fieldId'] : '';
409 if ($fid === '') {
410 continue;
411 }
412 $field_type = isset($field_def['fieldType']) ? $field_def['fieldType'] : '';
413 if ($field_type === 'hidden' || $field_type === 'signature') {
414 continue;
415 }
416 if (!empty($field_def['sensitive'])) {
417 continue;
418 }
419 $base_label = isset($field_def['label']) && $field_def['label'] !== ''
420 ? $field_def['label']
421 : ($field_type !== '' ? 'Unlabeled ' . ucfirst($field_type) . ' Field' : 'Unlabeled Field');
422 $counts[$base_label] = isset($counts[$base_label]) ? $counts[$base_label] + 1 : 1;
423 $n = $counts[$base_label];
424 $label = $n > 1 ? $base_label . ' ' . $n : $base_label;
425 $result[] = array('fieldId' => $fid, 'label' => $label);
426 }
427 return $result;
428 }
429
430 /**
431 * @param string $text Text with merge tags
432 * @param array $form_data Form configuration
433 * @param array $fields Submitted field data
434 * @param bool $escape_html Whether to escape values for HTML context
435 */
436 private static function ProcessMergeTags($text, $form_data, $fields, $escape_html = false)
437 {
438 $form_name = isset($form_data['form_name']) ? $form_data['form_name'] : '';
439 $site_name = get_bloginfo('name');
440 if ($escape_html) {
441 $form_name = esc_html($form_name);
442 $site_name = esc_html($site_name);
443 }
444 $text = str_replace('{form_name}', $form_name, $text);
445 $text = str_replace('{site_name}', $site_name, $text);
446
447 $form_fields = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
448 $tag_map = self::BuildFieldTagMap($form_fields);
449 foreach ($tag_map as $entry) {
450 if (isset($fields[$entry['fieldId']])) {
451 $val = $fields[$entry['fieldId']];
452 // Skip non-string values (e.g. file metadata arrays)
453 if (!is_string($val)) {
454 continue;
455 }
456 if ($escape_html) {
457 $val = esc_html($val);
458 }
459 $text = str_replace('{' . $entry['label'] . '}', $val, $text);
460 }
461 }
462
463 return $text;
464 }
465
466 /**
467 * Resolve the "From" name: form-level -> global default -> site name.
468 *
469 * @param array $form_data
470 * @return string
471 */
472 private static function ResolveFromName($form_data)
473 {
474 // 1. Form-level setting
475 if (!empty($form_data['email_from_name'])) {
476 return sanitize_text_field($form_data['email_from_name']);
477 }
478 // 2. Global default setting
479 $defaults = get_option('superbaddons_form_default_email', array());
480 if (is_array($defaults) && !empty($defaults['from_name'])) {
481 return sanitize_text_field($defaults['from_name']);
482 }
483 // 3. WordPress default
484 return get_bloginfo('name');
485 }
486
487 /**
488 * Resolve the "From" email: form-level -> global default -> admin email.
489 *
490 * @param array $form_data
491 * @return string
492 */
493 private static function ResolveFromEmail($form_data)
494 {
495 // 1. Form-level setting
496 if (!empty($form_data['email_from_address'])) {
497 return sanitize_email($form_data['email_from_address']);
498 }
499 // 2. Global default setting
500 $defaults = get_option('superbaddons_form_default_email', array());
501 if (is_array($defaults) && !empty($defaults['from_email'])) {
502 return sanitize_email($defaults['from_email']);
503 }
504 // 3. WordPress default
505 return get_option('admin_email');
506 }
507
508 /**
509 * Resolve a deduplicated label to its submitted value.
510 */
511 private static function FindFieldValue($fields, $field_name, $form_fields = array())
512 {
513 // First try direct match (fieldId as key)
514 if (isset($fields[$field_name])) {
515 return $fields[$field_name];
516 }
517 // Resolve deduplicated label to fieldId via tag map
518 $tag_map = self::BuildFieldTagMap($form_fields);
519 foreach ($tag_map as $entry) {
520 if ($entry['label'] === $field_name && isset($fields[$entry['fieldId']])) {
521 return $fields[$entry['fieldId']];
522 }
523 }
524 return '';
525 }
526 }
527