PluginProbe
The Innovative Form Builder – IvyForms / 1.5
The Innovative Form Builder – IvyForms v1.5
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / backend / src / Services / Mailer / MailerService.php

MailerService.php in The Innovative Form Builder – IvyForms 1.5, at backend/src/Services/Mailer/MailerService.php

314 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See COPYING.md for license details.
6 */
7
8 namespace IvyForms\Services\Mailer;
9
10 use IvyForms\Common\Sanitizer\Sanitizer;
11 use IvyForms\Entity\Notification\Notification;
12
13 class MailerService
14 {
15 /**
16 * Send an email using WordPress wp_mail function
17 *
18 * @param Notification $notification The notification object with email data
19 * @param mixed[] $formData The submitted form data for template variables
20 * @param string[] $attachments Absolute local file paths to attach
21 *
22 * @return bool Whether the email was sent successfully
23 */
24 public function sendEmail(Notification $notification, array $formData, array $attachments = []): bool
25 {
26 // Extract notification data
27 $contact = $notification->getNotificationContact();
28 $toAddresses = $this->validatedAddresses($contact->getReceiver());
29 $ccAddresses = $this->validatedAddresses($contact->getCc());
30 $bccAddresses = $this->validatedAddresses($contact->getBcc());
31 $to = implode(', ', $toAddresses);
32 $cc = implode(', ', $ccAddresses);
33 $bcc = implode(', ', $bccAddresses);
34 $from = $contact->getSender();
35 $replyTo = $contact->getReplyTo();
36
37 if ($to === '') {
38 return false;
39 }
40
41 if ($this->hasUnsafeHeaderValues($from, $replyTo, $cc, $bcc)) {
42 return false;
43 }
44
45 $subject = $notification->getSubject();
46 $message = $this->replacePlaceholders($notification->getMessage(), $formData);
47
48 $headers = $this->buildHeaders($from, $replyTo);
49 $visibleHeaders = $headers;
50 if ($cc !== '') {
51 $visibleHeaders[] = 'Cc: ' . $cc;
52 }
53
54 $attachments = array_values(array_filter(
55 $attachments,
56 static function ($path): bool {
57 return is_string($path) && $path !== '' && is_readable($path);
58 }
59 ));
60
61 $deliveredWithBcc = false;
62 $sent = $this->sendPrimaryEmail(
63 $to,
64 $subject,
65 $message,
66 $visibleHeaders,
67 $bccAddresses,
68 $deliveredWithBcc,
69 $attachments
70 );
71
72 if ($sent && !$deliveredWithBcc && $bccAddresses !== []) {
73 $sent = $this->sendBccCopies($bccAddresses, $subject, $message, $headers, $attachments);
74 }
75
76 return $sent;
77 }
78
79 /**
80 * Build the headers shared by every copy of the notification.
81 *
82 * @return string[]
83 */
84 private function buildHeaders(string $from, string $replyTo): array
85 {
86 $headers = [
87 'Content-Type: text/html; charset=UTF-8',
88 ];
89
90 if (!empty($from)) {
91 $headers[] = 'From: ' . $from;
92 }
93
94 // If replyTo is set and not identical to From, add Reply-To header
95 if (!empty($replyTo) && $replyTo !== $from) {
96 $headers[] = 'Reply-To: ' . $replyTo;
97 }
98
99 return $headers;
100 }
101
102 /**
103 * SMTP can hide Bcc from To/Cc while still showing To/Cc to the Bcc
104 * recipient. mail()/sendmail cannot, so those transports send copies later.
105 *
106 * @param string[] $headers
107 * @param string[] $bccAddresses
108 * @param string[] $attachments
109 */
110 private function sendPrimaryEmail(
111 string $to,
112 string $subject,
113 string $message,
114 array $headers,
115 array $bccAddresses,
116 bool &$deliveredWithBcc,
117 array $attachments = []
118 ): bool {
119 $parsedBcc = [];
120 foreach ($bccAddresses as $bccAddress) {
121 $parsedBcc[] = $this->parseMailbox($bccAddress);
122 }
123
124 $attachBcc = static function (\PHPMailer\PHPMailer\PHPMailer $phpmailer) use (
125 $parsedBcc,
126 &$deliveredWithBcc
127 ): void {
128 if ($phpmailer->Mailer !== 'smtp' || $parsedBcc === []) {
129 return;
130 }
131
132 foreach ($parsedBcc as $mailbox) {
133 $phpmailer->addBCC($mailbox['email'], $mailbox['name']);
134 }
135
136 $deliveredWithBcc = true;
137 };
138
139 add_action('phpmailer_init', $attachBcc, 1000);
140 try {
141 return wp_mail($to, $subject, $message, $headers, $attachments);
142 } finally {
143 remove_action('phpmailer_init', $attachBcc, 1000);
144 }
145 }
146
147 /**
148 * PHPMailer keeps the Bcc header in the message on the mail()/sendmail/qmail
149 * transports, so a shared send would expose Bcc addresses to the To and Cc
150 * recipients. Each Bcc recipient therefore gets its own copy, without the
151 * original To/Cc replayed as a Cc header (mail() would deliver to those too).
152 *
153 * @param string[] $bccAddresses
154 * @param string[] $headers
155 * @param string[] $attachments
156 */
157 private function sendBccCopies(
158 array $bccAddresses,
159 string $subject,
160 string $message,
161 array $headers,
162 array $attachments = []
163 ): bool {
164 $sent = true;
165
166 foreach ($bccAddresses as $bccAddress) {
167 $sent = wp_mail($bccAddress, $subject, $message, $headers, $attachments) && $sent;
168 }
169
170 return $sent;
171 }
172
173 /**
174 * Replace template variables in content with actual form data
175 * Supports format like {{field_name}}
176 *
177 * @param string $content Content with template variables
178 * @param mixed[] $formData Form submission data
179 *
180 * @return string Content with replaced variables
181 */
182 private function replacePlaceholders(string $content, array $formData): string
183 {
184 // Replace field variables like {{field_name}}
185 preg_match_all('/{{(.+?)}}/s', $content, $matches);
186
187 if (!empty($matches[1])) {
188 foreach ($matches[1] as $key => $fieldName) {
189 $fieldValue = $formData[$fieldName] ?? '';
190 if (is_array($fieldValue)) {
191 $fieldValue = implode(', ', $fieldValue);
192 }
193
194 $content = str_replace($matches[0][$key], $fieldValue, $content);
195 }
196 }
197
198 // Replace special variables
199 $content = str_replace('{{all_fields}}', $this->getAllFieldsHtml($formData), $content);
200
201 return $content;
202 }
203
204 /**
205 * Generate HTML table with all form fields for email template
206 *
207 * @param mixed[] $formData Form submission data
208 *
209 * @return string HTML representation of all fields
210 */
211 private function getAllFieldsHtml(array $formData): string
212 {
213 // Generate HTML table for all fields
214 $html = '<h2>Form Submission Details</h2>';
215 $html .= '<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">';
216
217 foreach ($formData as $fieldName => $fieldValue) {
218 // Skip system fields
219 if (in_array($fieldName, ['formId', 'nonce'])) {
220 continue;
221 }
222
223 // Format array values
224 if (is_array($fieldValue)) {
225 $fieldValue = implode(', ', $fieldValue);
226 }
227
228 // Format label by converting snake_case or camelCase to Title Case
229 $label = ucwords(str_replace(['_', '-'], ' ', $fieldName));
230
231 $html .= '<tr>';
232 $html .= '<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">';
233 $html .= esc_html($label) . '</th>';
234 $html .= '</th>';
235 $html .= '<td style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">';
236 $html .= esc_html($fieldValue) . '</td>';
237 $html .= '</td>';
238 $html .= '</tr>';
239 }
240
241 $html .= '</table>';
242
243 return $html;
244 }
245
246 /**
247 * @param string ...$values
248 */
249 private function hasUnsafeHeaderValues(string ...$values): bool
250 {
251 foreach ($values as $value) {
252 if (Sanitizer::containsEmailHeaderInjection($value)) {
253 return true;
254 }
255 }
256
257 return false;
258 }
259
260 /**
261 * Keep only validated email addresses for To:/Cc:/Bcc: recipients.
262 *
263 * @param string $addresses Comma-separated addresses
264 * @return string[]
265 */
266 private function validatedAddresses(string $addresses): array
267 {
268 $addresses = trim($addresses);
269 if ($addresses === '') {
270 return [];
271 }
272
273 $validated = [];
274
275 foreach (array_map('trim', explode(',', $addresses)) as $address) {
276 if ($address === '' || Sanitizer::containsEmailHeaderInjection($address)) {
277 continue;
278 }
279
280 $emailCandidate = $address;
281 if (preg_match('/<([^>]+)>/', $address, $match)) {
282 $emailCandidate = trim($match[1]);
283 }
284
285 if (Sanitizer::isValidEmail($emailCandidate)) {
286 $validated[] = $address;
287 }
288 }
289
290 return $validated;
291 }
292
293 /**
294 * Split a mailbox into the address PHPMailer expects and an optional name.
295 *
296 * @return array{email: string, name: string}
297 */
298 private function parseMailbox(string $address): array
299 {
300 $address = trim($address);
301 if (preg_match('/^(.*)<([^>]+)>\s*$/', $address, $match)) {
302 return [
303 'email' => trim($match[2]),
304 'name' => trim($match[1], " \t\"'"),
305 ];
306 }
307
308 return [
309 'email' => $address,
310 'name' => '',
311 ];
312 }
313 }
314