PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Modules / MCP / Tools / NotificationTools.php

NotificationTools.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at app/Modules/MCP/Tools/NotificationTools.php

370 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\MCP\Tools;
4
5 defined('ABSPATH') || exit;
6
7 use FluentForm\App\Models\FormMeta;
8 use FluentForm\App\Modules\MCP\Support\ErrorCodes;
9 use FluentForm\App\Modules\MCP\Support\FormAccess;
10 use FluentForm\App\Modules\MCP\Support\MCPHelper;
11 use FluentForm\App\Modules\MCP\Support\Mutation;
12 use FluentForm\App\Modules\MCP\Support\WriteGuard;
13 use FluentForm\App\Services\Settings\SettingsService;
14 use FluentForm\Framework\Support\Arr;
15
16 /**
17 * Email-notification tools (read + write).
18 *
19 * A form's email notifications live as individual fluentform_form_meta rows under
20 * meta_key "notifications"; each row's id IS the notification id. Reads and writes
21 * reuse SettingsService::store — the same path the admin Email Notifications screen
22 * uses — so the agent inherits its validation (sendTo/subject/message required) and
23 * sanitization (the HTML body runs through fluentform_sanitize_html). Writes are
24 * form-scoped and a supplied notification_id is verified to belong to the form
25 * before update (IDOR-safe), never silently forked onto a new row.
26 *
27 * upsert is the highest-leverage write in the module: repointing sendTo
28 * redirects every future submission of the form to a new mailbox, persistently
29 * and with no signal in the admin UI. It is therefore annotated destructive and
30 * routed through WriteGuard, with the old and new recipient spelled out in the
31 * dry-run preview so an operator can catch a redirect before it happens.
32 */
33 class NotificationTools
34 {
35 const META_KEY = 'notifications';
36
37 public static function definitions()
38 {
39 return [
40 'fluentform/list-email-notifications' => [
41 'label' => __('List Email Notifications', 'fluentform'),
42 'group' => __('Notifications', 'fluentform'),
43 'description' => __('List a form\'s email notifications with the id you need to edit one: id, name, enabled state, recipient (sendTo), subject, and a short body preview. Requires form_id.', 'fluentform'),
44 'input_schema' => [
45 'type' => 'object',
46 'properties' => [
47 'form_id' => ['type' => 'integer', 'description' => 'Required. The form to read notifications for.'],
48 ],
49 'required' => ['form_id'],
50 ],
51 'execute_callback' => [self::class, 'listNotifications'],
52 'capability' => ['fluentform_forms_manager', 'fluentform_settings_manager', 'fluentform_dashboard_access'],
53 'annotations' => ['readonly' => true],
54 ],
55
56 'fluentform/upsert-email-notification' => [
57 'label' => __('Create or Update Email Notification', 'fluentform'),
58 'group' => __('Notifications', 'fluentform'),
59 'description' => __('Create a new email notification (omit notification_id) or update an existing one (pass its notification_id from list-email-notifications). On update, only the keys you pass change; the rest are kept. subject and message are required to create; message is HTML and is sanitized on save. Recipient: send_to_type "email" with send_to_email (a fixed address or a {smartcode}), or "field" with send_to_field (a form field key). CHANGING THE RECIPIENT REDIRECTS EVERY FUTURE SUBMISSION OF THIS FORM — call once with dry_run:true to preview the old and new recipient and get a confirm_token, then call again with the same values plus confirm_token to execute. Requires form_id.', 'fluentform'),
60 'input_schema' => [
61 'type' => 'object',
62 'properties' => array_merge([
63 'form_id' => ['type' => 'integer', 'description' => 'Required. The form the notification belongs to.'],
64 'notification_id' => ['type' => 'integer', 'description' => 'Omit to create; pass an id from list-email-notifications to update.'],
65 'name' => ['type' => 'string', 'description' => 'Admin label for the notification.'],
66 'subject' => ['type' => 'string', 'description' => 'Email subject (required to create).'],
67 'message' => ['type' => 'string', 'description' => 'Email body, HTML allowed (required to create). Sanitized on save.'],
68 'send_to_type' => ['type' => 'string', 'enum' => ['email', 'field'], 'description' => 'How to resolve the recipient. Default email.'],
69 'send_to_email' => ['type' => 'string', 'description' => 'Recipient when send_to_type=email; a fixed address or a {smartcode}.'],
70 'send_to_field' => ['type' => 'string', 'description' => 'Recipient field key when send_to_type=field.'],
71 'from_name' => ['type' => 'string'],
72 'from_email' => ['type' => 'string'],
73 'reply_to' => ['type' => 'string'],
74 'enabled' => ['type' => 'boolean', 'description' => 'Whether this notification fires. Default true on create.'],
75 ], WriteGuard::schemaProps()),
76 'required' => ['form_id'],
77 ],
78 'execute_callback' => [self::class, 'upsertNotification'],
79 'capability' => 'fluentform_forms_manager',
80 // Not reversible in any meaningful sense: silently repointing a
81 // recipient exfiltrates every future submission of the form,
82 // and nothing in the UI signals that it happened.
83 'annotations' => ['destructive' => true],
84 ],
85 ];
86 }
87
88 public static function listNotifications($params = [])
89 {
90 $form = FormAccess::resolveForm($params);
91 if (is_wp_error($form)) {
92 return $form;
93 }
94 $formId = (int) $form->id;
95
96 $rows = self::rowsFor($formId);
97
98 $out = [];
99 foreach ($rows as $row) {
100 $n = json_decode($row->value, true);
101 if (!is_array($n)) {
102 continue;
103 }
104 $out[] = [
105 'id' => (int) $row->id,
106 'name' => Arr::get($n, 'name'),
107 'enabled' => !empty($n['enabled']),
108 'sendTo' => Arr::get($n, 'sendTo'),
109 'subject' => Arr::get($n, 'subject'),
110 'message_preview' => MCPHelper::preview(MCPHelper::htmlToText((string) Arr::get($n, 'message', '')), 120),
111 ];
112 }
113
114 return MCPHelper::envelope(
115 sprintf(
116 /* translators: %d: number of email notifications */
117 _n('%d email notification configured.', '%d email notifications configured.', count($out), 'fluentform'),
118 count($out)
119 ),
120 ['form_id' => $formId, 'notifications' => $out]
121 );
122 }
123
124 public static function upsertNotification($params = [])
125 {
126 $form = FormAccess::resolveForm($params);
127 if (is_wp_error($form)) {
128 return $form;
129 }
130 $formId = (int) $form->id;
131
132 $metaId = isset($params['notification_id']) ? (int) $params['notification_id'] : 0;
133
134 // Advisory read, for the dry-run preview and the confirm fingerprint
135 // only. The authoritative read happens under FOR UPDATE inside the
136 // mutation below, so the two layers cover different windows: the
137 // fingerprint catches "changed since you previewed it", the row lock
138 // catches "changed while we were writing".
139 $current = [];
140 if ($metaId) {
141 // IDOR: the row must belong to THIS form, else refuse — never let a
142 // foreign id silently fork a new notification onto this form.
143 $match = null;
144 foreach (self::rowsFor($formId) as $r) {
145 if ((int) $r->id === $metaId) {
146 $match = $r;
147 break;
148 }
149 }
150 if (!$match) {
151 return MCPHelper::error(ErrorCodes::NOT_FOUND, __('No email notification with that id exists on this form.', 'fluentform'), ['fields' => ['notification_id']]);
152 }
153 $decoded = json_decode($match->value, true);
154 $current = is_array($decoded) ? $decoded : [];
155 }
156
157 $notification = self::merge($current, $params, 0 === $metaId);
158
159 return Mutation::runGuarded(
160 'fluentform/upsert-email-notification',
161 $params,
162 // The resulting notification is part of the key, so a token minted
163 // for an innocuous subject edit cannot be replayed to swap the
164 // recipient on the same row.
165 'notification:' . $formId . ':' . ($metaId ? $metaId : 'new') . ':' . md5((string) wp_json_encode($notification)),
166 'notification:' . ($metaId ? md5((string) wp_json_encode($current)) : 'new'),
167 function () use ($formId, $metaId, $current, $notification) {
168 return [
169 'form_id' => $formId,
170 'notification_id' => $metaId ? $metaId : null,
171 'action' => $metaId ? 'update' : 'create',
172 'recipient' => [
173 'from' => $metaId ? self::describeRecipient(Arr::get($current, 'sendTo', [])) : null,
174 'to' => self::describeRecipient(Arr::get($notification, 'sendTo', [])),
175 ],
176 'subject' => [
177 'from' => $metaId ? Arr::get($current, 'subject') : null,
178 'to' => Arr::get($notification, 'subject'),
179 ],
180 'enabled' => !empty($notification['enabled']),
181 // Only a genuine redirect warrants the warning. On create
182 // there is no previous recipient, so comparing against an
183 // empty one would cry wolf on every new notification.
184 'warning' => self::redirectWarning($metaId, $current, $notification),
185 ];
186 },
187 function () use ($params, $formId, $metaId) {
188 // Update is a read-modify-MERGE-write (merge() preserves keys the
189 // agent didn't send). Lock + re-read the row inside one transaction
190 // so a concurrent admin edit to subject/sendTo/conditionals isn't
191 // lost. Create is a plain insert — no read-modify-write.
192 if ($metaId) {
193 global $wpdb;
194 $wpdb->query('START TRANSACTION');
195 try {
196 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from $wpdb->prefix; id/form_id %d-prepared, meta_key %s-prepared
197 $wpdb->query($wpdb->prepare("SELECT id FROM {$wpdb->prefix}fluentform_form_meta WHERE id = %d AND form_id = %d AND meta_key = %s FOR UPDATE", $metaId, $formId, self::META_KEY));
198
199 // IDOR + freshness: the row must still belong to this form.
200 $row = FormMeta::where('form_id', $formId)
201 ->where('meta_key', self::META_KEY)
202 ->where('id', $metaId)
203 ->first();
204 if (!$row) {
205 $wpdb->query('ROLLBACK');
206 return MCPHelper::error(ErrorCodes::NOT_FOUND, __('No email notification with that id exists on this form.', 'fluentform'), ['fields' => ['notification_id']]);
207 }
208
209 $decoded = json_decode($row->value, true);
210 $notification = self::merge(is_array($decoded) ? $decoded : [], $params, false);
211
212 try {
213 list($savedId, $saved) = (new SettingsService())->store([
214 'form_id' => $formId,
215 'meta_key' => self::META_KEY,
216 'value' => wp_json_encode($notification),
217 'meta_id' => $metaId,
218 ]);
219 } catch (\FluentForm\Framework\Validator\ValidationException $e) {
220 $wpdb->query('ROLLBACK');
221 return MCPHelper::error(ErrorCodes::INVALID_PARAM, self::flattenErrors($e), ['fields' => ['subject', 'message', 'send_to_email']]);
222 }
223
224 $wpdb->query('COMMIT');
225
226 return MCPHelper::envelope(
227 __('Email notification updated.', 'fluentform'),
228 ['form_id' => $formId, 'notification_id' => (int) $savedId, 'enabled' => !empty($saved['enabled'])]
229 );
230 } catch (\Throwable $e) {
231 $wpdb->query('ROLLBACK');
232 throw $e;
233 }
234 }
235
236 $notification = self::merge([], $params, true);
237 try {
238 list($savedId, $saved) = (new SettingsService())->store([
239 'form_id' => $formId,
240 'meta_key' => self::META_KEY,
241 'value' => wp_json_encode($notification),
242 ]);
243 } catch (\FluentForm\Framework\Validator\ValidationException $e) {
244 return MCPHelper::error(ErrorCodes::INVALID_PARAM, self::flattenErrors($e), ['fields' => ['subject', 'message', 'send_to_email']]);
245 }
246
247 return MCPHelper::envelope(
248 __('Email notification created.', 'fluentform'),
249 ['form_id' => $formId, 'notification_id' => (int) $savedId, 'enabled' => !empty($saved['enabled'])]
250 );
251 },
252 ['form_id' => $formId]
253 );
254 }
255
256 /**
257 * The preview's headline: is this call about to redirect an existing form's
258 * mail somewhere else? That is the one thing an operator must not miss.
259 */
260 private static function redirectWarning($metaId, array $current, array $notification)
261 {
262 if (!$metaId) {
263 return __('New notification; nothing existing is being redirected.', 'fluentform');
264 }
265
266 $from = self::describeRecipient(Arr::get($current, 'sendTo', []));
267 $to = self::describeRecipient(Arr::get($notification, 'sendTo', []));
268
269 if ($from === $to) {
270 return __('The recipient is unchanged.', 'fluentform');
271 }
272
273 return __('This changes where submissions of this form are emailed. Every future submission will go to the new recipient. Confirm the address with the site owner before executing.', 'fluentform');
274 }
275
276 /**
277 * One-line recipient description for the dry-run preview, so an operator can
278 * eyeball where mail will go without decoding the sendTo structure.
279 */
280 private static function describeRecipient($sendTo)
281 {
282 if (!is_array($sendTo)) {
283 return null;
284 }
285
286 if ('field' === Arr::get($sendTo, 'type')) {
287 return 'field:' . (string) Arr::get($sendTo, 'field', '');
288 }
289
290 return 'email:' . (string) Arr::get($sendTo, 'email', '');
291 }
292
293 /**
294 * Build the notification array to persist: start from the existing one (empty
295 * for create, seeded with defaults), then overlay only the params supplied.
296 * Sanitization/validation are left to SettingsService::store (the shared path).
297 */
298 private static function merge(array $current, array $params, $isCreate)
299 {
300 $n = $current;
301
302 if ($isCreate) {
303 $n = array_merge([
304 'name' => __('Email Notification', 'fluentform'),
305 'sendTo' => ['type' => 'email', 'email' => '{wp.admin_email}', 'field' => '', 'routing' => []],
306 'fromName' => '',
307 'fromEmail' => '',
308 'replyTo' => '',
309 'bcc' => '',
310 'subject' => '',
311 'message' => '',
312 'conditionals' => ['status' => false, 'type' => 'all', 'conditions' => [['field' => '', 'operator' => '=', 'value' => '']]],
313 'enabled' => true,
314 'email_template' => '',
315 ], $n);
316 }
317
318 foreach (['name' => 'name', 'subject' => 'subject', 'message' => 'message', 'from_name' => 'fromName', 'from_email' => 'fromEmail', 'reply_to' => 'replyTo'] as $in => $key) {
319 if (array_key_exists($in, $params)) {
320 $n[$key] = (string) $params[$in];
321 }
322 }
323
324 if (array_key_exists('enabled', $params)) {
325 $n['enabled'] = (bool) $params['enabled'];
326 }
327
328 $sendTo = isset($n['sendTo']) && is_array($n['sendTo']) ? $n['sendTo'] : ['type' => 'email', 'email' => '', 'field' => '', 'routing' => []];
329 if (array_key_exists('send_to_type', $params)) {
330 $sendTo['type'] = in_array($params['send_to_type'], ['email', 'field'], true) ? $params['send_to_type'] : 'email';
331 }
332 if (array_key_exists('send_to_email', $params)) {
333 $sendTo['email'] = (string) $params['send_to_email'];
334 }
335 if (array_key_exists('send_to_field', $params)) {
336 $sendTo['field'] = (string) $params['send_to_field'];
337 }
338 $n['sendTo'] = $sendTo;
339
340 return $n;
341 }
342
343 /**
344 * The stored notification rows for a form (one row per notification).
345 *
346 * @return \FluentForm\Framework\Support\Collection
347 */
348 private static function rowsFor($formId)
349 {
350 return FormMeta::where('form_id', $formId)
351 ->where('meta_key', self::META_KEY)
352 ->get();
353 }
354
355 private static function flattenErrors(\FluentForm\Framework\Validator\ValidationException $e)
356 {
357 $errors = $e->errors();
358 $flat = [];
359 if (is_array($errors)) {
360 array_walk_recursive($errors, function ($m) use (&$flat) {
361 if (is_string($m) && '' !== $m) {
362 $flat[] = $m;
363 }
364 });
365 }
366
367 return $flat ? implode(' ', array_unique($flat)) : $e->getMessage();
368 }
369 }
370