PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.7
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.7
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 4 days ago class-form-captcha-handler.php 4 days ago class-form-controller.php 4 days ago class-form-email-config-check.php 4 days ago class-form-email-handler.php 4 days ago class-form-encryption.php 4 days ago class-form-exporter.php 4 days ago class-form-field-validator.php 4 days ago class-form-file-handler.php 4 days ago class-form-google-auth.php 4 days ago class-form-integration-handler.php 4 days ago class-form-math-parser.php 4 days ago class-form-permissions.php 4 days ago class-form-registry.php 4 days ago class-form-settings.php 4 days ago class-form-submission-cpt.php 4 days ago class-form-submission-handler.php 4 days ago
class-form-email-handler.php
524 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 $body = self::WrapInTemplate($message);
140 $headers = array('Content-Type: text/html; charset=UTF-8');
141
142 // From name/email: form-level -> global default -> WordPress default
143 $from_name = self::ResolveFromName($form_data);
144 $from_email = self::ResolveFromEmail($form_data);
145 if (!empty($from_email) && is_email($from_email)) {
146 $from_header = !empty($from_name) ? ($from_name . ' <' . $from_email . '>') : $from_email;
147 $headers[] = 'From: ' . $from_header;
148 }
149
150 self::$last_mail_error = null;
151 add_action('wp_mail_failed', array(__CLASS__, 'CaptureMailError'));
152 $result = wp_mail(sanitize_email($user_email), $subject, $body, $headers);
153 remove_action('wp_mail_failed', array(__CLASS__, 'CaptureMailError'));
154
155 if ($post_id > 0) {
156 self::UpdateEmailStatus($post_id, 'user', $result, self::$last_mail_error);
157 }
158
159 return $result;
160 }
161
162 /**
163 * Capture wp_mail_failed error for status tracking.
164 *
165 * @param \WP_Error $error
166 */
167 public static function CaptureMailError($error)
168 {
169 if (is_wp_error($error)) {
170 self::$last_mail_error = $error->get_error_message();
171 }
172 }
173
174 /**
175 * Update email delivery status meta on a submission.
176 *
177 * @param int $post_id
178 * @param string $type 'admin' or 'user'
179 * @param bool $sent
180 * @param string|null $error
181 */
182 public static function UpdateEmailStatus($post_id, $type, $sent, $error = null)
183 {
184 $status = get_post_meta($post_id, '_spb_form_email_status', true);
185 if (!is_array($status)) {
186 $status = array();
187 }
188 $status[$type] = array(
189 'sent' => (bool) $sent,
190 'time' => time(),
191 'error' => $error,
192 );
193 update_post_meta($post_id, '_spb_form_email_status', $status);
194 }
195
196 private static function BuildEmailBody($form_data, $fields)
197 {
198 $form_name = !empty($form_data['form_name']) ? esc_html($form_data['form_name']) : __('Form Submission', 'superb-blocks');
199
200 // Build fieldId => deduplicated label lookup
201 $form_fields = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
202 $label_map = array();
203 foreach (self::BuildFieldTagMap($form_fields) as $entry) {
204 $label_map[$entry['fieldId']] = $entry['label'];
205 }
206
207 // Build sensitive field lookup to skip in email body. The sensitive
208 // toggle is unavailable for file and hidden fields, so a flag left over
209 // from a prior field type is treated as off. Those fields are shown,
210 // and files attached, like any other field.
211 $sensitive_ids = array();
212 foreach ($form_fields as $field_def) {
213 $field_type = isset($field_def['fieldType']) ? $field_def['fieldType'] : '';
214 if ($field_type === 'file' || $field_type === 'hidden') {
215 continue;
216 }
217 if (!empty($field_def['sensitive']) && !empty($field_def['fieldId'])) {
218 $sensitive_ids[$field_def['fieldId']] = true;
219 }
220 }
221
222 // Build signature field lookup to skip in email body
223 $signature_ids = array();
224 foreach ($form_fields as $field_def) {
225 if (isset($field_def['fieldType']) && $field_def['fieldType'] === 'signature' && !empty($field_def['fieldId'])) {
226 $signature_ids[$field_def['fieldId']] = true;
227 }
228 }
229
230 $rows = '';
231 foreach ($fields as $field_id => $value) {
232 // Skip sensitive fields
233 if (isset($sensitive_ids[$field_id])) {
234 continue;
235 }
236 // Skip signature fields (PNG data URLs are too large for email)
237 if (isset($signature_ids[$field_id])) {
238 continue;
239 }
240 $label = esc_html(isset($label_map[$field_id]) ? $label_map[$field_id] : $field_id);
241
242 // File fields store an array of file metadata. Attached files are
243 // listed by name; files that exceeded the attachment budget get a
244 // plain-text note (no admin URL, since it may be quoted in a reply
245 // to the submitter when Reply-To is mapped to their email).
246 if (is_array($value)) {
247 // Only point the admin to the dashboard when submissions are
248 // stored. With storage off there is no submission to open, so an
249 // oversized file is gone once it misses the email; say no more.
250 $store_enabled = !empty($form_data['store_enabled']);
251 $parts = array();
252 foreach ($value as $file) {
253 if (!is_array($file) || !isset($file['name'])) {
254 continue;
255 }
256 $name = esc_html($file['name']);
257 if (!empty($file['too_large'])) {
258 $size_str = isset($file['size']) ? esc_html(self::FormatBytes($file['size'])) : '';
259 $file_label = $size_str !== '' ? $name . ' (' . $size_str . ')' : $name;
260 $parts[] = $store_enabled
261 /* translators: %s: file name, optionally followed by its size in parentheses */
262 ? sprintf(__('%s is too large to email. It is available in this form\'s submissions in your dashboard.', 'superb-blocks'), $file_label)
263 /* translators: %s: file name, optionally followed by its size in parentheses */
264 : sprintf(__('%s is too large to email.', 'superb-blocks'), $file_label);
265 } else {
266 $parts[] = $name;
267 }
268 }
269 $val = implode('<br>', array_filter($parts));
270 if (empty($val)) {
271 continue;
272 }
273 } else {
274 $val = nl2br(esc_html($value));
275 }
276
277 $rows .= '<tr><td style="padding:8px 12px;border-bottom:1px solid #eee;font-weight:500;vertical-align:top;width:30%;">' . $label . '</td>';
278 $rows .= '<td style="padding:8px 12px;border-bottom:1px solid #eee;">' . $val . '</td></tr>';
279 }
280
281 $content = '<h2 style="margin:0 0 16px;font-size:18px;">' . $form_name . '</h2>';
282 $content .= '<table style="width:100%;border-collapse:collapse;font-size:14px;">' . $rows . '</table>';
283
284 return self::WrapInTemplate($content);
285 }
286
287 /**
288 * Decide which uploaded files can ride along as email attachments.
289 *
290 * Files are attached greedily in field order while the running total stays
291 * within the budget returned by GetMaxAttachmentBytes(). Each file's
292 * metadata array is annotated with 'attached' (bool) and, when a present
293 * file did not fit, 'too_large' (bool) so BuildEmailBody can render it.
294 * The per-field upload cap (fileSettings.maxFileSize) is intentionally NOT
295 * reused: it is per-file, while this budget bounds the whole message.
296 *
297 * @param array $fields Submitted field data (field_id => value).
298 * @param array $form_data Form configuration (passed through to the filter).
299 * @return array list($fields, $attachment_paths)
300 */
301 private static function PrepareAttachments($fields, $form_data)
302 {
303 $max_total = self::GetMaxAttachmentBytes($form_data);
304 $attachments = array();
305 $used = 0;
306
307 // The only array-valued fields reaching here are file fields (multi-value
308 // fields are stringified during sanitization), and the sensitive toggle
309 // is never available for file fields, so no sensitive check is needed.
310 foreach ($fields as $field_id => $value) {
311 if (!is_array($value)) {
312 continue;
313 }
314 foreach ($value as $i => $file) {
315 if (!is_array($file) || empty($file['path'])) {
316 continue;
317 }
318 $fields[$field_id][$i]['attached'] = false;
319 if (!file_exists($file['path'])) {
320 continue;
321 }
322 $size = isset($file['size']) ? intval($file['size']) : 0;
323 if ($size <= 0) {
324 $size = (int) filesize($file['path']);
325 }
326 if ($max_total > 0 && $size > 0 && ($used + $size) <= $max_total) {
327 $attachments[] = $file['path'];
328 $used += $size;
329 $fields[$field_id][$i]['attached'] = true;
330 } else {
331 $fields[$field_id][$i]['too_large'] = true;
332 }
333 }
334 }
335
336 return array($fields, $attachments);
337 }
338
339 /**
340 * Total byte budget for files attached to a single notification email.
341 *
342 * Defaults to 10MB, which clears the strict end of common mail-server
343 * limits after base64 inflation (~33%). Not a user-facing setting because
344 * the real ceiling is a property of the recipient's mail server; devs on a
345 * generous SMTP relay can raise it (or return 0 to disable attachments).
346 *
347 * @param array $form_data Form configuration, for per-form overrides.
348 * @return int Maximum total attachment size in bytes (0 disables).
349 */
350 private static function GetMaxAttachmentBytes($form_data)
351 {
352 $default = 10 * 1024 * 1024;
353 /**
354 * Filters the total attachment byte budget for form notification emails.
355 *
356 * @param int $default Default budget in bytes (10MB).
357 * @param array $form_data The form configuration for the submission.
358 */
359 $bytes = apply_filters('superbaddons_form_email_max_attachment_bytes', $default, $form_data);
360 return max(0, intval($bytes));
361 }
362
363 /**
364 * Format a byte count as a short human-readable size (e.g. "14MB").
365 *
366 * @param int|float $bytes
367 * @return string
368 */
369 private static function FormatBytes($bytes)
370 {
371 $bytes = floatval($bytes);
372 if ($bytes >= 1048576) {
373 return round($bytes / 1048576, 1) . 'MB';
374 }
375 if ($bytes >= 1024) {
376 return round($bytes / 1024) . 'KB';
377 }
378 return intval($bytes) . 'B';
379 }
380
381 private static function WrapInTemplate($content)
382 {
383 $site_name = esc_html(get_bloginfo('name'));
384 /* translators: %s: site name */
385 $footer_text = sprintf(__('Sent from %s', 'superb-blocks'), $site_name);
386 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;">'
387 . '<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:4px;padding:24px;border:1px solid #e0e0e0;">'
388 . $content
389 . '<p style="margin:24px 0 0;font-size:12px;color:#999;">' . $footer_text . '</p>'
390 . '</div></body></html>';
391 }
392
393 /**
394 * Build a deduplicated tag map from form field definitions.
395 * Mirrors the JS buildFieldTagMap — duplicate labels get numeric suffixes.
396 *
397 * @param array $form_fields Array of field attribute arrays.
398 * @return array Array of [ 'fieldId' => string, 'label' => string ]
399 */
400 private static function BuildFieldTagMap($form_fields)
401 {
402 $counts = array();
403 $result = array();
404 foreach ($form_fields as $field_def) {
405 $fid = isset($field_def['fieldId']) ? $field_def['fieldId'] : '';
406 if ($fid === '') {
407 continue;
408 }
409 $field_type = isset($field_def['fieldType']) ? $field_def['fieldType'] : '';
410 if ($field_type === 'hidden' || $field_type === 'signature') {
411 continue;
412 }
413 if (!empty($field_def['sensitive'])) {
414 continue;
415 }
416 $base_label = isset($field_def['label']) && $field_def['label'] !== ''
417 ? $field_def['label']
418 : ($field_type !== '' ? 'Unlabeled ' . ucfirst($field_type) . ' Field' : 'Unlabeled Field');
419 $counts[$base_label] = isset($counts[$base_label]) ? $counts[$base_label] + 1 : 1;
420 $n = $counts[$base_label];
421 $label = $n > 1 ? $base_label . ' ' . $n : $base_label;
422 $result[] = array('fieldId' => $fid, 'label' => $label);
423 }
424 return $result;
425 }
426
427 /**
428 * @param string $text Text with merge tags
429 * @param array $form_data Form configuration
430 * @param array $fields Submitted field data
431 * @param bool $escape_html Whether to escape values for HTML context
432 */
433 private static function ProcessMergeTags($text, $form_data, $fields, $escape_html = false)
434 {
435 $form_name = isset($form_data['form_name']) ? $form_data['form_name'] : '';
436 $site_name = get_bloginfo('name');
437 if ($escape_html) {
438 $form_name = esc_html($form_name);
439 $site_name = esc_html($site_name);
440 }
441 $text = str_replace('{form_name}', $form_name, $text);
442 $text = str_replace('{site_name}', $site_name, $text);
443
444 $form_fields = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
445 $tag_map = self::BuildFieldTagMap($form_fields);
446 foreach ($tag_map as $entry) {
447 if (isset($fields[$entry['fieldId']])) {
448 $val = $fields[$entry['fieldId']];
449 // Skip non-string values (e.g. file metadata arrays)
450 if (!is_string($val)) {
451 continue;
452 }
453 if ($escape_html) {
454 $val = esc_html($val);
455 }
456 $text = str_replace('{' . $entry['label'] . '}', $val, $text);
457 }
458 }
459
460 return $text;
461 }
462
463 /**
464 * Resolve the "From" name: form-level -> global default -> site name.
465 *
466 * @param array $form_data
467 * @return string
468 */
469 private static function ResolveFromName($form_data)
470 {
471 // 1. Form-level setting
472 if (!empty($form_data['email_from_name'])) {
473 return sanitize_text_field($form_data['email_from_name']);
474 }
475 // 2. Global default setting
476 $defaults = get_option('superbaddons_form_default_email', array());
477 if (is_array($defaults) && !empty($defaults['from_name'])) {
478 return sanitize_text_field($defaults['from_name']);
479 }
480 // 3. WordPress default
481 return get_bloginfo('name');
482 }
483
484 /**
485 * Resolve the "From" email: form-level -> global default -> admin email.
486 *
487 * @param array $form_data
488 * @return string
489 */
490 private static function ResolveFromEmail($form_data)
491 {
492 // 1. Form-level setting
493 if (!empty($form_data['email_from_address'])) {
494 return sanitize_email($form_data['email_from_address']);
495 }
496 // 2. Global default setting
497 $defaults = get_option('superbaddons_form_default_email', array());
498 if (is_array($defaults) && !empty($defaults['from_email'])) {
499 return sanitize_email($defaults['from_email']);
500 }
501 // 3. WordPress default
502 return get_option('admin_email');
503 }
504
505 /**
506 * Resolve a deduplicated label to its submitted value.
507 */
508 private static function FindFieldValue($fields, $field_name, $form_fields = array())
509 {
510 // First try direct match (fieldId as key)
511 if (isset($fields[$field_name])) {
512 return $fields[$field_name];
513 }
514 // Resolve deduplicated label to fieldId via tag map
515 $tag_map = self::BuildFieldTagMap($form_fields);
516 foreach ($tag_map as $entry) {
517 if ($entry['label'] === $field_name && isset($fields[$entry['fieldId']])) {
518 return $fields[$entry['fieldId']];
519 }
520 }
521 return '';
522 }
523 }
524