PluginProbe
FluentSMTP – WP Mail SMTP Plugin with Amazon SES, SendGrid, Mailgun, Postmark, Cloudflare, toSend, Gmail and Any SMTP / trunk
FluentSMTP – WP Mail SMTP Plugin with Amazon SES, SendGrid, Mailgun, Postmark, Cloudflare, toSend, Gmail and Any SMTP vtrunk
2.4.0 trunk 1.0.1 1.1.0 1.1.1 1.2.0 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.1.2 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.71 2.2.72 2.2.73 2.2.80 2.2.81 All 32 releases
fluent-smtp / app / Http / Controllers / SettingsController.php

SettingsController.php in FluentSMTP – WP Mail SMTP Plugin with Amazon SES, SendGrid, Mailgun, Postmark, Cloudflare, toSend, Gmail and Any SMTP trunk, at app/Http/Controllers/SettingsController.php

1,009 lines 37.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentMail\App\Http\Controllers;
4
5 use Exception;
6 use FluentMail\App\Models\Settings;
7 use FluentMail\App\Services\Notification\Manager as NotificationManager;
8 use FluentMail\Includes\Request\Request;
9 use FluentMail\Includes\Support\Arr;
10 use FluentMail\Includes\Support\ValidationException;
11 use FluentMail\App\Services\Mailer\Providers\Factory;
12 use FluentMail\App\Services\ConnectionHealth;
13 use FluentMail\App\Services\Converter;
14 use FluentMail\App\Services\SecretMasker;
15
16 class SettingsController extends Controller
17 {
18 public function index(Settings $settings)
19 {
20 $this->verify();
21
22 try {
23 $setting = $settings->get();
24
25 /*
26 * The stored report, not a fresh one. getReport() reads the option the
27 * scheduled check writes; probing every connection here would put an OAuth
28 * token renewal on the critical path of opening the Connections screen.
29 * A row whose key is absent from it has simply not been checked yet, which
30 * the screen shows as unknown rather than as healthy.
31 */
32 return $this->sendSuccess([
33 'settings' => SecretMasker::mask($setting),
34 'health' => (new ConnectionHealth())->getReport()
35 ]);
36 } catch (Exception $e) {
37 return $this->sendError([
38 'message' => $e->getMessage()
39 ], $e->getCode());
40 }
41 }
42
43 public function validate(Request $request, Settings $settings, Factory $factory)
44 {
45 $this->verify();
46
47 try {
48 $data = $request->except(['action', 'nonce']);
49
50 $provider = $factory->make($data['provider']['key']);
51
52 $provider->validateBasicInformation($data);
53
54 $this->sendSuccess();
55 } catch (ValidationException $e) {
56 $this->sendError($e->errors(), $e->getCode());
57 }
58 }
59
60 public function store(Request $request, Settings $settings, Factory $factory)
61 {
62 $this->verify();
63
64 $passWordKeys = ['password', 'access_key', 'secret_key', 'api_key', 'client_id', 'client_secret', 'auth_token', 'access_token', 'refresh_token'];
65
66 try {
67 $data = $request->except(['action', 'nonce']);
68
69 $data = wp_unslash($data);
70
71 /*
72 * The credentials come back masked unless the admin typed over them, so
73 * the stored ones are put back here - before validateConnection() and
74 * checkConnection() below, which both have to test the real key rather
75 * than the sentinel standing in for it.
76 *
77 * An empty value is not a mask and is not restored: clearing a field is
78 * how the admin removes a credential, and how the provider forms hand
79 * the key over to wp-config when `key_store` is switched.
80 *
81 * A connection being added has nothing stored. Its one legitimate source
82 * of a mask is the dashboard's offer to import another SMTP plugin's
83 * settings, which arrive masked too; those resolve from the Converter.
84 */
85 $data['connection'] = SecretMasker::resolve(
86 $data['connection'],
87 $this->getStoredConnection(
88 Arr::get($data, 'connection_key'),
89 Arr::get($data, 'connection.provider')
90 ) ?: $this->getSuggestedConnection(Arr::get($data, 'connection.provider'))
91 );
92
93 $provider = $factory->make($data['connection']['provider']);
94
95 $connection = $data['connection'];
96
97 foreach ($connection as $index => $value) {
98 if ($index == 'sender_email') {
99 $connection['sender_email'] = sanitize_email($connection['sender_email']);
100 }
101
102 if (in_array($index, $passWordKeys)) {
103 if ($value) {
104 $connection[$index] = trim($value);
105 }
106 continue;
107 }
108
109 if (is_string($value) && $value) {
110 $connection[$index] = sanitize_text_field($value);
111
112 // Store the name the admin typed. A sender name copied from
113 // the site title arrives HTML-escaped, and it is plain text
114 // everywhere it is used. fluentMailGetSettings() decodes on
115 // read as well, so installs that already hold an escaped
116 // name are fixed whether or not they ever save again.
117 if ($index === 'sender_name') {
118 $connection[$index] = wp_specialchars_decode($connection[$index], ENT_QUOTES);
119 }
120 }
121 }
122
123 $data['connection'] = $connection;
124
125 $this->validateConnection($provider, $connection);
126
127 $provider->checkConnection($connection);
128
129 $data['valid_senders'] = $provider->getValidSenders($connection);
130
131 $data = apply_filters('fluentmail_saving_connection_data', $data, $data['connection']['provider']);
132
133 $settings->store($data);
134
135 return $this->sendSuccess([
136 'message' => __('Settings saved.', 'fluent-smtp'),
137 'connections' => SecretMasker::maskConnections($settings->getConnections()),
138 'mappings' => $settings->getMappings(),
139 'misc' => $settings->getMisc()
140 ]);
141 } catch (ValidationException $e) {
142 return $this->sendError($e->errors(), 422);
143 } catch (Exception $e) {
144 return $this->sendError([
145 'message' => $e->getMessage()
146 ], 422);
147 }
148 }
149
150 /**
151 * The credentials currently saved under a connection key, decrypted.
152 *
153 * The source the masked fields of an incoming payload are restored from. An
154 * unknown or absent key - a connection being added rather than edited - gives an
155 * empty array, which SecretMasker::resolve() turns into empty fields
156 * rather than into the sentinel.
157 *
158 * '0' is the connection form's own way of saying "new", so it is not a key.
159 *
160 * @param string|null $connectionKey
161 * @return array
162 */
163 protected function getStoredConnection($connectionKey, $provider = null)
164 {
165 if (!$connectionKey || $connectionKey === '0') {
166 return [];
167 }
168
169 $connections = (new Settings())->getConnections();
170
171 $stored = Arr::get($connections, $connectionKey . '.provider_settings', []);
172
173 /*
174 * A connection switched to a different provider is a new set of credentials,
175 * not the old ones under a new name. Without this, editing a Gmail connection
176 * onto Outlook restored Google's tokens into it: the form still said
177 * "authenticated", the Outlook validator saw an access token and skipped its
178 * own authorization, and an unusable connection was saved over a working one.
179 */
180 if ($provider && Arr::get($stored, 'provider') !== $provider) {
181 return [];
182 }
183
184 return $stored;
185 }
186
187 /**
188 * The import suggestion's settings for a provider, while there is nothing to
189 * import into yet.
190 *
191 * Only offered on the dashboard when no connection exists, so it is only a
192 * resolve source under the same condition - once a connection exists, a mask
193 * without a stored value behind it is an error, not an import.
194 *
195 * @param string|null $provider
196 * @return array
197 */
198 protected function getSuggestedConnection($provider)
199 {
200 if (!empty((new Settings())->getConnections())) {
201 return [];
202 }
203
204 return (new Converter())->suggestedSettingsFor($provider);
205 }
206
207 public function storeMiscSettings(Request $request, Settings $settings)
208 {
209 $this->verify();
210
211 $misc = $request->get('settings');
212 $settings->updateMiscSettings($misc);
213 $this->sendSuccess([
214 'message' => __('General settings saved.', 'fluent-smtp')
215 ]);
216 }
217
218 public function delete(Request $request, Settings $settings)
219 {
220 $this->verify();
221
222 $settings = $settings->delete($request->get('key'));
223
224 /*
225 * The same contract as every other response carrying connections: the screen
226 * installs these straight into its shared state, so the ones that remain
227 * have to arrive masked, with `has_access_token` derived, exactly as the
228 * initial page load handed them over.
229 */
230 return $this->sendSuccess(SecretMasker::mask($settings));
231 }
232
233 public function sendTestEmail(Request $request, Settings $settings)
234 {
235 $this->verify();
236
237 try {
238 $this->app->addAction('wp_mail_failed', [$this, 'onFail']);
239
240 $data = $request->except(['action', 'nonce']);
241
242 if (!isset($data['email'])) {
243 return $this->sendError([
244 'email_error' => __('The email field is required.', 'fluent-smtp')
245 ], 422);
246 }
247
248 if (!defined('FLUENTMAIL_EMAIL_TESTING')) {
249 define('FLUENTMAIL_EMAIL_TESTING', true);
250 }
251
252 $startedAt = microtime(true);
253
254 $settings->sendTestEmail($data, $settings->get());
255
256 /*
257 * The handover to the provider is synchronous, so this covers the whole
258 * round trip: connection/handshake, the API call or SMTP conversation and
259 * the provider's response. It is not the time until the mail lands in the
260 * inbox - that part is out of our hands.
261 */
262 $timeTaken = microtime(true) - $startedAt;
263
264 return $this->sendSuccess([
265 'message' => __('Email delivered successfully.', 'fluent-smtp'),
266 'time_taken' => round($timeTaken, 3),
267 'time_taken_human' => $this->formatDuration($timeTaken),
268 'throughput' => $this->throughputFromDuration($timeTaken)
269 ]);
270 } catch (\Throwable $e) {
271 /*
272 * Throwable, not Exception. A missing PHP extension, a type error or
273 * any other engine-level failure raised while sending is an \Error,
274 * which catch(Exception) lets through — the AJAX request then died
275 * with no JSON body and the UI span forever with no message shown.
276 *
277 * getCode() is meaningless on an \Error (almost always 0) and an HTTP
278 * status of 0 is not valid, so only a sane positive code is honoured.
279 */
280 $code = (int)$e->getCode();
281 if ($code < 400 || $code > 599) {
282 $code = 422;
283 }
284
285 return $this->sendError([
286 'message' => $e->getMessage()
287 ], $code);
288 }
289 }
290
291 protected function formatDuration($seconds)
292 {
293 if ($seconds < 1) {
294 return sprintf(
295 __('Delivered in %s milliseconds', 'fluent-smtp'),
296 number_format_i18n($seconds * 1000)
297 );
298 }
299
300 return sprintf(
301 __('Delivered in %s seconds', 'fluent-smtp'),
302 number_format_i18n($seconds, 2)
303 );
304 }
305
306 /**
307 * The sending-speed ceiling one round trip implies.
308 *
309 * A campaign sender (FluentCRM is the usual one) hands emails to the provider
310 * one after another from a single PHP process, so it can never send faster than
311 * 1 / round-trip. Showing that number next to the test result lets a user see
312 * whether a "slow" campaign is actually running at the pace their server's
313 * connection to the provider allows, or well below it - in which case the
314 * bottleneck is somewhere else (cron, the sending engine, a rate limit).
315 *
316 * The figures are a ceiling, not a forecast: they ignore provider rate limits
317 * and the time the sender spends building each email.
318 *
319 * @param float $seconds Round trip of the test send.
320 * @return array{per_second: string, per_minute: string, per_hour: string}
321 */
322 protected function throughputFromDuration($seconds)
323 {
324 // A clock that reads zero (or negative, after an NTP step) would divide by
325 // zero; nothing hands an email over in under a millisecond anyway.
326 $seconds = max((float)$seconds, 0.001);
327
328 $perSecond = 1 / $seconds;
329
330 return [
331 // Below ten a second the first decimal is the whole story ("0.8" vs
332 // "1"); above it the decimal is noise.
333 'per_second' => number_format_i18n($perSecond, $perSecond < 10 ? 1 : 0),
334 'per_minute' => number_format_i18n(floor($perSecond * 60)),
335 'per_hour' => number_format_i18n(floor($perSecond * 3600)),
336 ];
337 }
338
339 public function onFail($response)
340 {
341 return $this->sendError([
342 'message' => $response->get_error_message(),
343 'errors' => $response->get_error_data()
344 ], 422);
345 }
346
347 public function validateConnection($provider, $connection)
348 {
349 $errors = [];
350
351 try {
352 $provider->validateBasicInformation($connection);
353 } catch (ValidationException $e) {
354 $errors = $e->errors();
355 }
356
357 try {
358 $provider->validateProviderInformation($connection);
359 } catch (ValidationException $e) {
360 $errors = array_merge($errors, $e->errors());
361 }
362
363 if ($errors) {
364 throw new ValidationException(esc_html__('Unprocessable Entity', 'fluent-smtp'), 422, null, $errors); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
365 }
366 }
367
368 public function getConnectionInfo(Request $request, Settings $settings, Factory $factory)
369 {
370 $this->verify();
371
372 $connectionId = $request->get('connection_id');
373 $connections = $settings->getConnections();
374
375 if (!isset($connections[$connectionId]['provider_settings'])) {
376 return $this->sendSuccess([
377 'info' => __('No connection found. Please reload the page and try again.', 'fluent-smtp')
378 ]);
379 }
380
381 $connection = $connections[$connectionId]['provider_settings'];
382
383 $provider = $factory->make($connection['provider']);
384
385 return $this->sendSuccess($provider->getConnectionInfo($connection));
386 }
387
388 public function addNewSenderEmail(Request $request, Settings $settings, Factory $factory)
389 {
390 $this->verify();
391
392 $connectionId = $request->get('connection_id');
393 $connections = $settings->getConnections();
394
395 if (!isset($connections[$connectionId]['provider_settings'])) {
396 return $this->sendSuccess([
397 'info' => __('No connection found. Please reload the page and try again.', 'fluent-smtp')
398 ]);
399 }
400
401 $connection = $connections[$connectionId]['provider_settings'];
402
403 $provider = $factory->make($connection['provider']);
404 $email = sanitize_email($request->get('new_sender'));
405
406 if (!is_email($email)) {
407 return $this->sendError([
408 'message' => __('Please provide a valid email address.', 'fluent-smtp')
409 ]);
410 }
411
412 $result = $provider->addNewSenderEmail($connection, $email);
413
414 if (is_wp_error($result)) {
415 return $this->sendError([
416 'message' => $result->get_error_message()
417 ]);
418 }
419
420 return $this->sendSuccess([
421 'message' => __('Email address added.', 'fluent-smtp')
422 ]);
423 }
424
425 public function removeSenderEmail(Request $request, Settings $settings, Factory $factory)
426 {
427 $this->verify();
428
429 $connectionId = $request->get('connection_id');
430 $connections = $settings->getConnections();
431
432 if (!isset($connections[$connectionId]['provider_settings'])) {
433 return $this->sendSuccess([
434 'info' => __('No connection found. Please reload the page and try again.', 'fluent-smtp')
435 ]);
436 }
437
438 $connection = $connections[$connectionId]['provider_settings'];
439
440 $provider = $factory->make($connection['provider']);
441 $email = sanitize_email($request->get('email'));
442
443 if (!is_email($email)) {
444 return $this->sendError([
445 'message' => __('Please provide a valid email address.', 'fluent-smtp')
446 ]);
447 }
448
449 $result = $provider->removeSenderEmail($connection, $email);
450
451 if (is_wp_error($result)) {
452 return $this->sendError([
453 'message' => $result->get_error_message()
454 ]);
455 }
456
457 return $this->sendSuccess([
458 'message' => __('Email address removed.', 'fluent-smtp')
459 ]);
460 }
461
462 public function installPlugin(Request $request)
463 {
464 $this->verify();
465
466 // Sanitize plugin slug input
467 $pluginSlug = sanitize_key($request->get('plugin_slug'));
468
469 // Define whitelist of allowed plugins
470 $allowedPlugins = ['fluentform', 'fluent-crm', 'ninja-tables'];
471
472 // Validate plugin slug against whitelist with strict comparison
473 if (!in_array($pluginSlug, $allowedPlugins, true)) {
474 return $this->sendError([
475 'message' => __('Invalid plugin specified. Only approved plugins can be installed.', 'fluent-smtp')
476 ]);
477 }
478
479 // Verify user has permission to install plugins
480 if (!current_user_can('install_plugins')) {
481 return $this->sendError([
482 'message' => __('Sorry, you do not have permission to install plugins.', 'fluent-smtp')
483 ]);
484 }
485
486 // Verify file modifications are allowed
487 if (!wp_is_file_mod_allowed('install_plugins')) {
488 return $this->sendError([
489 'message' => __('Plugin installation is disabled on this site.', 'fluent-smtp')
490 ]);
491 }
492
493 $plugin = [
494 'name' => $pluginSlug,
495 'repo-slug' => $pluginSlug,
496 'file' => $pluginSlug . '.php'
497 ];
498
499 $UrlMaps = [
500 'fluentform' => [
501 'admin_url' => admin_url('admin.php?page=fluent_forms'),
502 'title' => __('Go to Fluent Forms Dashboard', 'fluent-smtp')
503 ],
504 'fluent-crm' => [
505 'admin_url' => admin_url('admin.php?page=fluentcrm-admin'),
506 'title' => __('Go to FluentCRM Dashboard', 'fluent-smtp')
507 ],
508 'ninja-tables' => [
509 'admin_url' => admin_url('admin.php?page=ninja_tables#/'),
510 'title' => __('Go to Ninja Tables Dashboard', 'fluent-smtp')
511 ]
512 ];
513
514 try {
515 $this->backgroundInstaller($plugin);
516 return $this->send([
517 'message' => __('Plugin has been successfully installed.', 'fluent-smtp'),
518 'info' => $UrlMaps[$pluginSlug]
519 ]);
520 } catch (\Exception $exception) {
521 return $this->sendError([
522 'message' => $exception->getMessage()
523 ]);
524 }
525 }
526
527 private function backgroundInstaller($plugin_to_install)
528 {
529 if (!empty($plugin_to_install['repo-slug'])) {
530 require_once ABSPATH . 'wp-admin/includes/file.php';
531 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
532 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
533 require_once ABSPATH . 'wp-admin/includes/plugin.php';
534
535 WP_Filesystem();
536
537 $skin = new \Automatic_Upgrader_Skin();
538 $upgrader = new \WP_Upgrader($skin);
539 $installed_plugins = array_keys(\get_plugins());
540 $plugin_slug = $plugin_to_install['repo-slug'];
541 $plugin_file = isset($plugin_to_install['file']) ? $plugin_to_install['file'] : $plugin_slug . '.php';
542 $installed = false;
543 $activate = false;
544
545 // See if the plugin is installed already.
546 if (isset($installed_plugins[$plugin_file])) {
547 $installed = true;
548 $activate = !is_plugin_active($installed_plugins[$plugin_file]);
549 }
550
551 // Install this thing!
552 if (!$installed) {
553 // Suppress feedback.
554 ob_start();
555
556 try {
557 $plugin_information = plugins_api(
558 'plugin_information',
559 array(
560 'slug' => $plugin_slug,
561 'fields' => array(
562 'short_description' => false,
563 'sections' => false,
564 'requires' => false,
565 'rating' => false,
566 'ratings' => false,
567 'downloaded' => false,
568 'last_updated' => false,
569 'added' => false,
570 'tags' => false,
571 'homepage' => false,
572 'donate_link' => false,
573 'author_profile' => false,
574 'author' => false,
575 ),
576 )
577 );
578
579 if (is_wp_error($plugin_information)) {
580 throw new \Exception(wp_kses_post($plugin_information->get_error_message()));
581 }
582
583 $package = $plugin_information->download_link;
584 $download = $upgrader->download_package($package);
585
586 if (is_wp_error($download)) {
587 throw new \Exception(wp_kses_post($download->get_error_message()));
588 }
589
590 $working_dir = $upgrader->unpack_package($download, true);
591
592 if (is_wp_error($working_dir)) {
593 throw new \Exception(wp_kses_post($working_dir->get_error_message()));
594 }
595
596 $result = $upgrader->install_package(
597 array(
598 'source' => $working_dir,
599 'destination' => WP_PLUGIN_DIR,
600 'clear_destination' => false,
601 'abort_if_destination_exists' => false,
602 'clear_working' => true,
603 'hook_extra' => array(
604 'type' => 'plugin',
605 'action' => 'install',
606 ),
607 )
608 );
609
610 if (is_wp_error($result)) {
611 throw new \Exception(wp_kses_post($result->get_error_message()));
612 }
613
614 $activate = true;
615 } catch (\Exception $e) {
616 throw new \Exception(esc_html($e->getMessage()));
617 }
618
619 // Discard feedback.
620 ob_end_clean();
621 }
622
623 wp_clean_plugins_cache();
624
625 // Activate this thing.
626 if ($activate) {
627 try {
628 $result = activate_plugin($installed ? $installed_plugins[$plugin_file] : $plugin_slug . '/' . $plugin_file);
629
630 if (is_wp_error($result)) {
631 throw new \Exception(esc_html($result->get_error_message()));
632 }
633 } catch (\Exception $e) {
634 throw new \Exception(esc_html($e->getMessage()));
635 }
636 }
637 }
638 }
639
640 public function subscribe()
641 {
642 $this->verify();
643
644 // Properly sanitize email input with sanitize_email() instead of sanitize_text_field()
645 $email = isset($_REQUEST['email']) ? sanitize_email($_REQUEST['email']) : '';
646
647 // Sanitize display name
648 $displayName = isset($_REQUEST['display_name']) ? sanitize_text_field($_REQUEST['display_name']) : '';
649
650 // Validate email format
651 if (!is_email($email)) {
652 return $this->sendError([
653 'message' => __('That email address is not valid.', 'fluent-smtp')
654 ], 422);
655 }
656
657 // Properly validate share_essentials with isset() check and strict comparison
658 $shareEssentials = 'no';
659 if (isset($_REQUEST['share_essentials']) && $_REQUEST['share_essentials'] === 'yes') {
660 update_option('_fluentsmtp_sub_update', 'shared', 'no');
661 $shareEssentials = 'yes';
662 } else {
663 update_option('_fluentsmtp_sub_update', 'yes', 'no');
664 }
665
666 $this->pushData($email, $shareEssentials, $displayName);
667
668 return $this->sendSuccess([
669 'message' => __('You are subscribed to release notes and monthly tips.', 'fluent-smtp')
670 ]);
671 }
672
673 public function subscribeDismiss()
674 {
675 $this->verify();
676 update_option('_fluentsmtp_dismissed_timestamp', time(), 'no');
677
678 return $this->sendSuccess([
679 'message' => 'success'
680 ]);
681 }
682
683 private function pushData($optinEmail, $shareEssentials, $displayName = '')
684 {
685 $user = get_user_by('ID', get_current_user_id());
686
687 $url = 'https://fluentsmtp.com/wp-admin/?fluentcrm=1&route=contact&hash=6012116c-90d8-42a5-a65b-3649aa34b356';
688
689
690 if (!$displayName) {
691 $displayName = trim($user->first_name . ' ' . $user->last_name);
692 if (!$displayName) {
693 $displayName = $user->display_name;
694 }
695 }
696
697 wp_remote_post($url, [
698 'body' => json_encode([ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
699 'full_name' => $displayName,
700 'email' => $optinEmail,
701 'source' => 'smtp',
702 'optin_website' => site_url(),
703 'share_essential' => $shareEssentials
704 ])
705 ]);
706 }
707
708 public function getGmailAuthUrl(Request $request)
709 {
710 $this->verify();
711 $connection = wp_unslash($request->get('connection'));
712
713 /*
714 * Re-authenticating an existing connection sends back the masked secret,
715 * since that is what the form was given. Restore it before it is read below.
716 */
717 $connection = SecretMasker::resolve(
718 $connection,
719 $this->getStoredConnection(
720 $request->get('connection_key'),
721 Arr::get($connection, 'provider')
722 )
723 );
724
725 $clientId = Arr::get($connection, 'client_id');
726 $clientSecret = Arr::get($connection, 'client_secret');
727
728 if (Arr::get($connection, 'key_store') == 'wp_config') {
729 if (defined('FLUENTMAIL_GMAIL_CLIENT_ID')) {
730 $clientId = FLUENTMAIL_GMAIL_CLIENT_ID;
731 } else {
732 return $this->sendError([
733 'client_id' => [
734 'required' => __('Please define FLUENTMAIL_GMAIL_CLIENT_ID in your wp-config.php file', 'fluent-smtp')
735 ]
736 ]);
737 }
738 if (defined('FLUENTMAIL_GMAIL_CLIENT_SECRET')) {
739 $clientSecret = FLUENTMAIL_GMAIL_CLIENT_SECRET;
740 } else {
741 return $this->sendError([
742 'client_secret' => [
743 'required' => __('Please define FLUENTMAIL_GMAIL_CLIENT_SECRET in your wp-config.php file', 'fluent-smtp')
744 ]
745 ]);
746 }
747 }
748
749 if (!$clientId) {
750 return $this->sendError([
751 'client_id' => [
752 'required' => __('Please provide the application client ID.', 'fluent-smtp')
753 ]
754 ]);
755 }
756
757 if (!$clientSecret) {
758 return $this->sendError([
759 'client_secret' => [
760 'required' => __('Please provide the application client secret.', 'fluent-smtp')
761 ]
762 ]);
763 }
764
765 $authUrl = add_query_arg([
766 'response_type' => 'code',
767 'access_type' => 'offline',
768 'client_id' => $clientId,
769 'redirect_uri' => apply_filters('fluentsmtp_gapi_callback', 'https://fluentsmtp.com/gapi/'),
770 'state' => admin_url('options-general.php?page=fluent-mail&gapi=1'),
771 /*
772 * Send-only. The plugin's one Gmail API call is
773 * users.messages.send, which gmail.send covers, attachments
774 * included. The full https://mail.google.com/ grant this used to
775 * ask for lets a leaked refresh token read and delete the mailbox.
776 * include_granted_scopes is gone with it, so a re-authentication
777 * does not fold an old wide grant back into the new token.
778 */
779 'scope' => 'https://www.googleapis.com/auth/gmail.send',
780 'approval_prompt' => 'force'
781 ], 'https://accounts.google.com/o/oauth2/auth');
782
783 return $this->sendSuccess([
784 'auth_url' => filter_var($authUrl, FILTER_SANITIZE_URL)
785 ]);
786 }
787
788 public function getOutlookAuthUrl(Request $request)
789 {
790 $this->verify();
791 $connection = wp_unslash($request->get('connection'));
792
793 /* As above - the form holds a mask, the API call needs the real secret. */
794 $connection = SecretMasker::resolve(
795 $connection,
796 $this->getStoredConnection(
797 $request->get('connection_key'),
798 Arr::get($connection, 'provider')
799 )
800 );
801
802 $clientId = Arr::get($connection, 'client_id');
803 $clientSecret = Arr::get($connection, 'client_secret');
804 $tenantId = Arr::get($connection, 'tenant_id');
805
806 /*
807 * The tenant is part of the authority the browser is about to be sent
808 * to, so it is checked here as well as on save — this endpoint is
809 * reached before the connection has been stored, and refusing a bad
810 * value is better than quietly signing in against the wrong directory.
811 */
812 if (!\FluentMail\App\Services\Mailer\Providers\Outlook\API::isValidTenant($tenantId)) {
813 return $this->sendError([
814 'tenant_id' => [
815 'invalid' => __('Directory (tenant) ID must be the tenant GUID, a verified domain such as contoso.onmicrosoft.com, or one of common, organizations, consumers.', 'fluent-smtp')
816 ]
817 ]);
818 }
819
820 if (Arr::get($connection, 'key_store') == 'wp_config') {
821 if (defined('FLUENTMAIL_OUTLOOK_CLIENT_ID')) {
822 $clientId = FLUENTMAIL_OUTLOOK_CLIENT_ID;
823 } else {
824 return $this->sendError([
825 'client_id' => [
826 'required' => __('Please define FLUENTMAIL_OUTLOOK_CLIENT_ID in your wp-config.php file', 'fluent-smtp')
827 ]
828 ]);
829 }
830 if (defined('FLUENTMAIL_OUTLOOK_CLIENT_SECRET')) {
831 $clientSecret = FLUENTMAIL_OUTLOOK_CLIENT_SECRET;
832 } else {
833 return $this->sendError([
834 'client_secret' => [
835 'required' => __('Please define FLUENTMAIL_OUTLOOK_CLIENT_SECRET in your wp-config.php file', 'fluent-smtp')
836 ]
837 ]);
838 }
839 }
840
841 if (!$clientId) {
842 return $this->sendError([
843 'client_id' => [
844 'required' => __('Please provide the application client ID.', 'fluent-smtp')
845 ]
846 ]);
847 }
848
849 if (!$clientSecret) {
850 return $this->sendError([
851 'client_secret' => [
852 'required' => __('Please provide the application client secret.', 'fluent-smtp')
853 ]
854 ]);
855 }
856
857 return $this->sendSuccess([
858 'auth_url' => (new \FluentMail\App\Services\Mailer\Providers\Outlook\API($clientId, $clientSecret, $tenantId))->getAuthUrl()
859 ]);
860 }
861
862 /**
863 * Mask the credentials held by every alert channel in a notification settings array.
864 *
865 * @param array $settings
866 * @return array
867 */
868 protected function maskNotificationSecrets($settings)
869 {
870 foreach ((new NotificationManager())->getAllChannelKeys() as $channelKey) {
871 if (empty($settings[$channelKey]) || !is_array($settings[$channelKey])) {
872 continue;
873 }
874
875 $settings[$channelKey] = SecretMasker::maskFields(
876 $settings[$channelKey],
877 SecretMasker::NOTIFICATION_SECRET_FIELDS
878 );
879 }
880
881 return $settings;
882 }
883
884 public function getNotificationSettings()
885 {
886 $settings = (new Settings())->notificationSettings();
887 $this->verify();
888
889 $settings['telegram_notify_token'] = '';
890
891 return $this->sendSuccess([
892 'settings' => $this->maskNotificationSecrets($settings)
893 ]);
894 }
895
896 public function saveNotificationSettings(Request $request)
897 {
898 $this->verify();
899
900 $settings = $request->get('settings', []);
901
902 $settings = Arr::only($settings, ['enabled', 'notify_email', 'notify_days']);
903
904 /*
905 * A payload carrying none of these keys is a malformed request, not an
906 * instruction to clear the schedule, and it must not reach the write below.
907 *
908 * The screen used to be able to send one: `notification_settings` starts empty,
909 * and if the GET that fills it failed, the form still rendered with a working
910 * Save button over that empty object. The unconditional sanitize_text_field()
911 * calls that used to sit here then turned two missing keys into two empty
912 * strings - which wp_parse_args() treats as values, not absences - so the write
913 * disabled a working summary and blanked its recipient, and reported success.
914 * The form is now gated on a successful read as well; this is the half that
915 * does not depend on the client behaving.
916 */
917 if (!$settings) {
918 return $this->sendError([
919 'message' => __('No settings were submitted. Please reload the page and try again.', 'fluent-smtp')
920 ], 422);
921 }
922
923 /*
924 * Sanitize only what was actually sent. A key that is absent has to stay absent
925 * so that wp_parse_args() below can fall back to the stored value for it.
926 */
927 foreach (['notify_email', 'enabled'] as $key) {
928 if (isset($settings[$key])) {
929 $settings[$key] = sanitize_text_field($settings[$key]);
930 }
931 }
932
933 $defaults = [
934 'enabled' => 'no',
935 'notify_email' => '{site_admin}',
936 'notify_days' => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
937 ];
938
939 $oldSettings = (new Settings())->notificationSettings();
940 $defaults = wp_parse_args($defaults, $oldSettings);
941
942 $settings = wp_parse_args($settings, $defaults);
943
944 update_option('_fluent_smtp_notify_settings', $settings, false);
945
946 return $this->sendSuccess([
947 'message' => __('Settings saved.', 'fluent-smtp')
948 ]);
949 }
950
951 public function getNotificationChannels()
952 {
953 $this->verify();
954
955 $notificationManager = new NotificationManager();
956 $channels = $notificationManager->getAllChannels();
957 $settings = (new Settings())->notificationSettings();
958 $activeChannel = Arr::get($settings, 'active_channel', []);
959
960 // Add status and active state to each channel
961 $channelsWithStatus = [];
962 foreach ($channels as $key => $channel) {
963 $channelSettings = Arr::get($settings, $key, []);
964 $channelsWithStatus[$key] = array_merge($channel, [
965 'status' => Arr::get($channelSettings, 'status', 'no'),
966 'is_active' => in_array($key, $activeChannel),
967 /*
968 * Masked, not omitted. The screen reads these to decide whether a
969 * channel is configured - `!!settings.webhook_url` and the like - and
970 * the mask is truthy, so a connected channel still reads as connected
971 * without the bot token or webhook URL travelling with it.
972 */
973 'settings' => SecretMasker::maskFields(
974 $channelSettings,
975 SecretMasker::NOTIFICATION_SECRET_FIELDS
976 )
977 ]);
978 }
979
980 return $this->sendSuccess([
981 'channels' => $channelsWithStatus,
982 'active_channel' => $activeChannel
983 ]);
984 }
985
986 public function toggleNotificationChannel(Request $request)
987 {
988 $this->verify();
989
990 $channelKeys = $request->get('channel_keys', []);
991 $channelKeys = array_map('sanitize_text_field', $channelKeys);
992 $allChannelKeys = (new NotificationManager())->getAllChannelKeys();
993 $channelKeys = array_filter($channelKeys, function ($key) use ($allChannelKeys) {
994 return in_array($key, $allChannelKeys);
995 });
996
997 $settings = (new Settings())->notificationSettings();
998
999 $settings['active_channel'] = $channelKeys;
1000
1001 update_option('_fluent_smtp_notify_settings', $settings, false);
1002
1003 return $this->sendSuccess([
1004 'message' => __('Notification channel updated.', 'fluent-smtp'),
1005 'active_channels' => $channelKeys
1006 ]);
1007 }
1008 }
1009