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
← All changes | backend/src/Services/Mailer/MailerService.php +210 -14 0.1.2 → 1.5 View file →
@@ -1,18 +1,14 @@
1 1 <?php
2 2
3 3 /**
4 - * @copyright © TMS-Plugins. All rights reserved.
4 + * @copyright © Melograno Venture Studio. All rights reserved.
5 5 * @licence See COPYING.md for license details.
6 6 */
7 7
8 8 namespace IvyForms\Services\Mailer;
9 9
10 -// phpcs:disable PSR1.Files.SideEffects
11 -if (!defined('ABSPATH')) {
12 - exit; // Exit if accessed directly
13 -}
14 -
10 +use IvyForms\Common\Sanitizer\Sanitizer;
15 11 use IvyForms\Entity\Notification\Notification;
16 12
17 13 class MailerService
18 14 {
@@ -20,30 +16,162 @@
20 16 * Send an email using WordPress wp_mail function
21 17 *
22 18 * @param Notification $notification The notification object with email data
23 19 * @param mixed[] $formData The submitted form data for template variables
20 + * @param string[] $attachments Absolute local file paths to attach
24 21 *
25 22 * @return bool Whether the email was sent successfully
26 23 */
27 - public function sendEmail(Notification $notification, array $formData): bool
24 + public function sendEmail(Notification $notification, array $formData, array $attachments = []): bool
28 25 {
29 26 // Extract notification data
30 - $to = $notification->getReceiver();
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 +
31 45 $subject = $notification->getSubject();
32 46 $message = $this->replacePlaceholders($notification->getMessage(), $formData);
33 47
34 - // Set up headers
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 + {
35 86 $headers = [
36 87 'Content-Type: text/html; charset=UTF-8',
37 - 'From: ' . $notification->getSender(),
38 - 'Reply-To: ' . $notification->getSender(),
39 88 ];
40 89
41 - // Send email using WordPress function
42 - return wp_mail($to, $subject, $message, $headers);
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;
43 100 }
44 101
45 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 + /**
46 174 * Replace template variables in content with actual form data
47 175 * Supports format like {{field_name}}
48 176 *
49 177 * @param string $content Content with template variables
@@ -53,9 +181,9 @@
53 181 */
54 182 private function replacePlaceholders(string $content, array $formData): string
55 183 {
56 184 // Replace field variables like {{field_name}}
57 - preg_match_all('/\{\{([^}]+)\}\}/', $content, $matches);
185 + preg_match_all('/{{(.+?)}}/s', $content, $matches);
58 186
59 187 if (!empty($matches[1])) {
60 188 foreach ($matches[1] as $key => $fieldName) {
61 189 $fieldValue = $formData[$fieldName] ?? '';
@@ -112,6 +240,74 @@
112 240
113 241 $html .= '</table>';
114 242
115 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 + ];
116 312 }
117 313 }