PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | app/Services/Helper.php +234 -35 2.7.02.10.0 View file →
@@ -5,8 +5,9 @@
5 5 use FluentCommunity\App\App;
6 6 use FluentCommunity\App\Functions\Utility;
7 7 use FluentCommunity\App\Hooks\Handlers\ActivationHandler;
8 8 use FluentCommunity\App\Models\BaseSpace;
9 +use FluentCommunity\App\Models\Contact;
9 10 use FluentCommunity\App\Models\Feed;
10 11 use FluentCommunity\App\Models\Space;
11 12 use FluentCommunity\App\Models\Media;
12 13 use FluentCommunity\App\Models\Meta;
@@ -150,8 +151,81 @@
150 151 $status = Utility::isCustomizationEnabled('dark_mode');
151 152 return apply_filters('fluent_community/has_color_scheme', $status);
152 153 }
153 154
155 + /**
156 + * Admin default theme mode: 'light', 'dark', or 'system'.
157 + */
158 + public static function getDefaultThemeMode()
159 + {
160 + $settings = Utility::getCustomizationSettings();
161 + $mode = isset($settings['default_theme_mode']) ? $settings['default_theme_mode'] : 'light';
162 +
163 + if (!in_array($mode, ['light', 'dark', 'system'], true)) {
164 + $mode = 'light';
165 + }
166 +
167 + return apply_filters('fluent_community/default_theme_mode', $mode);
168 + }
169 +
170 + /**
171 + * Pre-paint script that sets the theme before first render (no flash).
172 + * Precedence mirrors runtime: host-theme cookie → user pick → admin default.
173 + * Not persisted. Gate on hasColorScheme().
174 + */
175 + public static function renderColorSchemePrePaintScript()
176 + {
177 + $defaultMode = self::getDefaultThemeMode();
178 + $portalVars = apply_filters('fluent_community/general_portal_vars', ['color_switch_cookie_name' => '']);
179 + $cookieName = isset($portalVars['color_switch_cookie_name']) ? $portalVars['color_switch_cookie_name'] : '';
180 + ?>
181 + <script>
182 + (function () {
183 + var root = document.documentElement;
184 + var cookieName = '<?php echo esc_js($cookieName); ?>';
185 + var mode = null;
186 +
187 + // host-theme integration cookie (Blocksy/Kadence) wins when present
188 + if (cookieName) {
189 + var safeName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
190 + var match = document.cookie.match('(?:^|; )' + safeName + '=([^;]*)');
191 + if (match) {
192 + var cookieMode = decodeURIComponent(match[1]);
193 + if (cookieMode === 'dark' || cookieMode === 'light') {
194 + mode = cookieMode;
195 + }
196 + }
197 + }
198 +
199 + // explicit user pick
200 + if (!mode) {
201 + try {
202 + var stored = JSON.parse(localStorage.getItem('fcom_global_storage') || '{}').fcom_color_mode;
203 + if (stored === 'dark' || stored === 'light') {
204 + mode = stored;
205 + }
206 + } catch (error) {}
207 + }
208 +
209 + // admin default
210 + if (!mode) {
211 + var defaultMode = '<?php echo esc_js($defaultMode); ?>';
212 + if (defaultMode === 'system') {
213 + mode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
214 + } else {
215 + mode = defaultMode;
216 + }
217 + }
218 +
219 + root.setAttribute('data-color-mode', mode === 'dark' ? 'dark' : 'light');
220 + if (mode === 'dark') {
221 + root.classList.add('dark');
222 + }
223 + })();
224 + </script>
225 + <?php
226 + }
227 +
154 228 public static function isSuperAdmin($userId = null)
155 229 {
156 230 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
157 231
@@ -173,8 +247,9 @@
173 247 /**
174 248 * Check if the user is a site admin.
175 249 *
176 250 * @param int|null $userId The user ID to check. If null, checks the current user.
251 + * @param \FluentCommunity\App\Models\User|null $user Resolved user model, to save a lookup.
177 252 * @return bool True if the user is a site admin, false otherwise.
178 253 */
179 254 public static function isSiteAdmin($userId = null, $user = null)
180 255 {
@@ -182,9 +257,11 @@
182 257 return true;
183 258 }
184 259
185 260 if (!$user) {
186 - $user = self::getCurrentUser();
261 + $user = ($userId && (int)$userId !== get_current_user_id())
262 + ? User::find($userId)
263 + : self::getCurrentUser();
187 264 }
188 265
189 266 return $user && Arr::get($user->getPermissions(), 'community_admin');
190 267 }
@@ -557,16 +634,14 @@
557 634 if (!$userId) {
558 635 return false;
559 636 }
560 637
561 - static $user;
562 - if ($user && $cached) {
563 - return $user;
638 + static $users = [];
639 + if ($cached && isset($users[$userId])) {
640 + return $users[$userId];
564 641 }
565 642
566 - $user = User::find($userId);
567 -
568 - return $user;
643 + return $users[$userId] = User::find($userId);
569 644 }
570 645
571 646 /**
572 647 * Get the route paths for the community.
@@ -678,8 +753,31 @@
678 753 return false;
679 754 }
680 755
681 756 /**
757 + * Sanitize embed markup held in a feed/comment meta array on read.
758 + *
759 + * meta.media_preview.html is rendered with v-html in _MediaPreview.vue, so it is
760 + * sanitized on write. Doing it on read as well neutralizes rows that were stored
761 + * before the write-side fix landed, and covers any writer reaching the meta via
762 + * the fluent_community/feed/* filters. The emptiness check keeps this free for the
763 + * vast majority of rows, which carry no embed markup at all.
764 + *
765 + * @param array $meta The unserialized meta array.
766 + * @return array The meta array with any embed markup passed through the allowlist.
767 + */
768 + public static function sanitizeStoredMediaPreview($meta)
769 + {
770 + if (empty($meta['media_preview']['html'])) {
771 + return $meta;
772 + }
773 +
774 + $meta['media_preview']['html'] = RemoteUrlParser::sanitizeOembedHtml($meta['media_preview']['html']);
775 +
776 + return $meta;
777 + }
778 +
779 + /**
682 780 * Get a human-readable excerpt from content.
683 781 *
684 782 * @param string $content The content to extract from.
685 783 * @param int $length The maximum length of the excerpt.
@@ -700,12 +798,12 @@
700 798 // Blockquotes: remove '>' symbol
701 799 '/^\s*>\s?/m' => '',
702 800 // Horizontal rules: replace with empty line
703 801 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
802 + // Images: keep only the alt text (run before links)
803 + '/!\[([^\]]*)\]\([^\)]+\)/' => '$1',
704 804 // Links: keep only the link text
705 - '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
706 - // Images: keep only the alt text
707 - '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
805 + '/\[([^\]]*)\]\([^\)]+\)/' => '$1',
708 806 // Strikethrough: remove '~~' symbols
709 807 '/~~(.*?)~~/' => '$1',
710 808 // Task lists: remove checkbox syntax
711 809 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
@@ -712,8 +810,10 @@
712 810 ];
713 811
714 812 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
715 813
814 + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
815 +
716 816 // remove all tags
717 817 $content = wp_strip_all_tags($content);
718 818 // remove new lines and tabs
719 819 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
@@ -798,9 +898,9 @@
798 898 foreach ($communityGroups as $communityGroup) {
799 899 $validSpaces = [];
800 900 $spaces = $communityGroup->spaces;
801 901 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
802 -
902 +
803 903 if (!$isShowAll && !$isSpaceModerator) {
804 904 $spaceIds = $spaces->pluck('id')->toArray();
805 905 $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
806 906 if ($isNotMemberOfAnySpace) {
@@ -807,8 +907,12 @@
807 907 continue;
808 908 }
809 909 }
810 910
911 + if ($user) {
912 + BaseSpace::preloadMemberships($spaces, $user->ID);
913 + }
914 +
811 915 foreach ($spaces as $space) {
812 916 $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space;
813 917 if (!$validSpace) {
814 918 continue;
@@ -1245,11 +1349,33 @@
1245 1349
1246 1350 return $menuGroups;
1247 1351 }
1248 1352
1353 + /**
1354 + * Drop the links the given user may not see.
1355 + *
1356 + * Space links carry their own privacy, so every place that hands a space's settings
1357 + * to a client has to filter them. Doing that inline is how the feed endpoints came
1358 + * to skip it, so both call sites go through here.
1359 + *
1360 + * @param array $links
1361 + * @param \FluentCommunity\App\Models\User|null $currentUser
1362 + * @return array
1363 + */
1364 + public static function filterAccessibleLinks($links, $currentUser = null)
1365 + {
1366 + if (!$links || !is_array($links)) {
1367 + return [];
1368 + }
1369 +
1370 + return array_values(array_filter($links, function ($link) use ($currentUser) {
1371 + return self::isLinkAccessible($link, $currentUser);
1372 + }));
1373 + }
1374 +
1249 1375 public static function isLinkAccessible($link, $currentUser = null)
1250 1376 {
1251 - $isEnabled = Arr::get($link, 'enabled') === 'yes';
1377 + $isEnabled = Arr::get($link, 'enabled', 'yes') === 'yes';
1252 1378 $isUnavailable = Arr::get($link, 'is_unavailable') === 'yes';
1253 1379
1254 1380 if (!$isEnabled || $isUnavailable) {
1255 1381 return false;
@@ -1277,14 +1403,16 @@
1277 1403 if (!$currentUser) {
1278 1404 return false;
1279 1405 }
1280 1406
1281 - static $userSpacesIds = null;
1282 - if ($userSpacesIds === null) {
1283 - $userSpacesIds = $currentUser->getJoinedSpaceIds();
1407 + static $userSpacesIds = [];
1408 + if (!isset($userSpacesIds[$currentUser->ID])) {
1409 + $userSpacesIds[$currentUser->ID] = $currentUser->getJoinedSpaceIds();
1284 1410 }
1285 1411
1286 - return $userSpacesIds && !!array_intersect($userSpacesIds, $membershipIds);
1412 + $ids = $userSpacesIds[$currentUser->ID];
1413 +
1414 + return $ids && !!array_intersect($ids, $membershipIds);
1287 1415 }
1288 1416
1289 1417 /**
1290 1418 * Get the meta data for a space.
@@ -1486,22 +1614,33 @@
1486 1614 public static function getMobileMenuItems($context = 'headless')
1487 1615 {
1488 1616 $xprofile = Helper::getCurrentProfile();
1489 1617
1490 - $mobileMenuItems = [
1491 - [
1618 + $mainMenuItems = Arr::get(self::getMenuItemsGroup('view'), 'mainMenuItems', []);
1619 +
1620 + $defaultMobileIcons = [
1621 + 'all_feeds' => '<svg width="20" height="18" viewBox="0 0 20 18" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 13.166H10.0075H10Z" fill="currentColor"></path><path d="M10 13.166H10.0075" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M16.6666 6.08301V10.2497C16.6666 13.3924 16.6666 14.9637 15.6903 15.94C14.714 16.9163 13.1426 16.9163 9.99992 16.9163C6.85722 16.9163 5.28587 16.9163 4.30956 15.94C3.33325 14.9637 3.33325 13.3924 3.33325 10.2497V6.08301" stroke="currentColor" stroke-width="1.5"></path><path d="M18.3333 7.74967L14.714 4.27925C12.4918 2.14842 11.3807 1.08301 9.99996 1.08301C8.61925 1.08301 7.50814 2.14842 5.28592 4.27924L1.66663 7.74967" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path></svg>',
1622 + 'spaces' => '<svg version="1.1" viewBox="0 0 128 128" xml:space="preserve"><g><path d="M64,42c-13.2,0-24,10.8-24,24s10.8,24,24,24s24-10.8,24-24S77.2,42,64,42z M64,82c-8.8,0-16-7.2-16-16s7.2-16,16-16 s16,7.2,16,16S72.8,82,64,82z"></path><path d="M64,100.8c-14.9,0-29.2,6.2-39.4,17.1l-2.7,2.9l5.8,5.5l2.7-2.9c8.8-9.4,20.7-14.6,33.6-14.6s24.8,5.2,33.6,14.6l2.7,2.9 l5.8-5.5l-2.7-2.9C93.2,107.1,78.9,100.8,64,100.8z"></path><path d="M97,47.9v8c9.4,0,18.1,3.8,24.6,10.7l5.8-5.5C119.6,52.7,108.5,47.9,97,47.9z"></path><path d="M116.1,20c0-10.5-8.6-19.1-19.1-19.1S77.9,9.5,77.9,20S86.5,39.1,97,39.1S116.1,30.5,116.1,20z M85.9,20 c0-6.1,5-11.1,11.1-11.1s11.1,5,11.1,11.1s-5,11.1-11.1,11.1S85.9,26.1,85.9,20z"></path><path d="M31,47.9c-11.5,0-22.6,4.8-30.4,13.2l5.8,5.5c6.4-6.9,15.2-10.7,24.6-10.7V47.9z"></path><path d="M50.1,20C50.1,9.5,41.5,0.9,31,0.9S11.9,9.5,11.9,20S20.5,39.1,31,39.1S50.1,30.5,50.1,20z M31,31.1 c-6.1,0-11.1-5-11.1-11.1S24.9,8.9,31,8.9s11.1,5,11.1,11.1S37.1,31.1,31,31.1z"></path></g></svg>'
1623 + ];
1624 +
1625 + $mobileMenuItems = [];
1626 +
1627 + foreach ($defaultMobileIcons as $slug => $defaultIcon) {
1628 + $menuItem = Arr::get($mainMenuItems, $slug);
1629 + if (!$menuItem) {
1630 + continue;
1631 + }
1632 +
1633 + $iconSvg = Arr::get($menuItem, 'shape_svg');
1634 +
1635 + $mobileMenuItems[] = [
1492 1636 'route' => [
1493 - 'name' => 'all_feeds'
1637 + 'name' => $slug
1494 1638 ],
1495 - 'icon_svg' => '<svg width="20" height="18" viewBox="0 0 20 18" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 13.166H10.0075H10Z" fill="currentColor"></path><path d="M10 13.166H10.0075" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M16.6666 6.08301V10.2497C16.6666 13.3924 16.6666 14.9637 15.6903 15.94C14.714 16.9163 13.1426 16.9163 9.99992 16.9163C6.85722 16.9163 5.28587 16.9163 4.30956 15.94C3.33325 14.9637 3.33325 13.3924 3.33325 10.2497V6.08301" stroke="currentColor" stroke-width="1.5"></path><path d="M18.3333 7.74967L14.714 4.27925C12.4918 2.14842 11.3807 1.08301 9.99996 1.08301C8.61925 1.08301 7.50814 2.14842 5.28592 4.27924L1.66663 7.74967" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path></svg>'
1496 - ],
1497 - [
1498 - 'route' => [
1499 - 'name' => 'spaces'
1500 - ],
1501 - 'icon_svg' => '<svg version="1.1" viewBox="0 0 128 128" xml:space="preserve"><g><path d="M64,42c-13.2,0-24,10.8-24,24s10.8,24,24,24s24-10.8,24-24S77.2,42,64,42z M64,82c-8.8,0-16-7.2-16-16s7.2-16,16-16 s16,7.2,16,16S72.8,82,64,82z"></path><path d="M64,100.8c-14.9,0-29.2,6.2-39.4,17.1l-2.7,2.9l5.8,5.5l2.7-2.9c8.8-9.4,20.7-14.6,33.6-14.6s24.8,5.2,33.6,14.6l2.7,2.9 l5.8-5.5l-2.7-2.9C93.2,107.1,78.9,100.8,64,100.8z"></path><path d="M97,47.9v8c9.4,0,18.1,3.8,24.6,10.7l5.8-5.5C119.6,52.7,108.5,47.9,97,47.9z"></path><path d="M116.1,20c0-10.5-8.6-19.1-19.1-19.1S77.9,9.5,77.9,20S86.5,39.1,97,39.1S116.1,30.5,116.1,20z M85.9,20 c0-6.1,5-11.1,11.1-11.1s11.1,5,11.1,11.1s-5,11.1-11.1,11.1S85.9,26.1,85.9,20z"></path><path d="M31,47.9c-11.5,0-22.6,4.8-30.4,13.2l5.8,5.5c6.4-6.9,15.2-10.7,24.6-10.7V47.9z"></path><path d="M50.1,20C50.1,9.5,41.5,0.9,31,0.9S11.9,9.5,11.9,20S20.5,39.1,31,39.1S50.1,30.5,50.1,20z M31,31.1 c-6.1,0-11.1-5-11.1-11.1S24.9,8.9,31,8.9s11.1,5,11.1,11.1S37.1,31.1,31,31.1z"></path></g></svg>'
1502 - ]
1503 - ];
1639 + 'title' => Arr::get($menuItem, 'title'),
1640 + 'icon_svg' => $iconSvg ? CustomSanitizer::sanitizeSvg($iconSvg) : $defaultIcon
1641 + ];
1642 + }
1504 1643
1505 1644 if ($xprofile) {
1506 1645 $mobileMenuItems[] = [
1507 1646 'route' => [
@@ -1509,13 +1648,15 @@
1509 1648 'params' => [
1510 1649 'username' => $xprofile->username
1511 1650 ]
1512 1651 ],
1652 + 'title' => __('Profile', 'fluent-community'),
1513 1653 'icon_svg' => '<svg viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 512a192 192 0 1 0 0-384 192 192 0 0 0 0 384m0 64a256 256 0 1 1 0-512 256 256 0 0 1 0 512m320 320v-96a96 96 0 0 0-96-96H288a96 96 0 0 0-96 96v96a32 32 0 1 1-64 0v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 1 1-64 0"></path></svg>'
1514 1654 ];
1515 1655 } else if (!get_current_user_id()) {
1516 1656 $mobileMenuItems[] = [
1517 1657 'name' => 'login',
1658 + 'title' => __('Login', 'fluent-community'),
1518 1659 'permalink' => Helper::getAuthUrl(),
1519 1660 'icon_svg' => '<svg viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 512a192 192 0 1 0 0-384 192 192 0 0 0 0 384m0 64a256 256 0 1 1 0-512 256 256 0 0 1 0 512m320 320v-96a96 96 0 0 0-96-96H288a96 96 0 0 0-96 96v96a32 32 0 1 1-64 0v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 1 1-64 0"></path></svg>'
1520 1661 ];
1521 1662 }
@@ -1593,9 +1734,9 @@
1593 1734
1594 1735 /**
1595 1736 * Add a user to a space.
1596 1737 *
1597 - * @param Space | int $space space to add the user to.
1738 + * @param BaseSpace|int $space space to add the user to.
1598 1739 * @param int $userId The ID of the user to add.
1599 1740 * @param string $role The role of the user in the space.
1600 1741 * @param string $by The source of the action.
1601 1742 * @return bool True if the user was added, false otherwise.
@@ -1743,15 +1884,15 @@
1743 1884 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1744 1885 ]);
1745 1886
1746 1887 ?>
1747 - <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1748 - data-fcom-tip="<?php echo esc_attr(Arr::get($link, 'title')); ?>"
1888 + <a data-fcom-hint="<?php echo esc_attr(Arr::get($link, 'title')); ?>"
1749 1889 href="<?php echo esc_url($link['permalink']); ?>"<?php foreach ($linkAtts as $key => $value) {
1750 - echo esc_attr($key) . '="' . esc_attr($value) . '"';
1890 + echo ' ' . esc_attr($key) . '="' . esc_attr($value) . '"';
1751 1891 } ?>>
1752 1892 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1753 - <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
1893 + <?php // The native title sits on the label span (not the anchor) so it can not duplicate the link's accessible name for screen readers. ?>
1894 + <span class="community_name" title="<?php echo esc_attr(Arr::get($link, 'title')); ?>"><?php echo wp_kses_post((string) Arr::get($link, 'title', '')); ?></span>
1754 1895 <?php if (Arr::get($link, 'show_lock')) : ?>
1755 1896 <span class="fcom_space_lock">
1756 1897 <i class="el-icon">
1757 1898 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
@@ -2232,12 +2373,12 @@
2232 2373 }
2233 2374
2234 2375 // Check if the character is escaped
2235 2376 if ($char === "\\") {
2236 - // Add the next character to the result as is, without mapping
2377 + // Day.js escapes literal text with square brackets, not backslashes
2237 2378 $i++;
2238 2379 if ($i < strlen($phpFormat)) {
2239 - $dayjsFormat .= "\\" . $phpFormat[$i];
2380 + $dayjsFormat .= "[" . $phpFormat[$i] . "]";
2240 2381 }
2241 2382 continue;
2242 2383 }
2243 2384
@@ -2369,6 +2510,64 @@
2369 2510 // else (6) => expanded, do nothing
2370 2511 }
2371 2512
2372 2513 return $collapsed;
2514 + }
2515 +
2516 + public static function getUndeliverableEmails($emails)
2517 + {
2518 + if (!$emails || !defined('FLUENTCRM')) {
2519 + return [];
2520 + }
2521 +
2522 + if (Utility::getPrivacySetting('skip_crm_undeliverable_emails') != 'yes') {
2523 + return [];
2524 + }
2525 +
2526 + /**
2527 + * FluentCRM contact statuses that FluentCommunity treats as undeliverable.
2528 + * Emails to contacts with these statuses are skipped for notification emails.
2529 + *
2530 + * @param array $statuses Contact statuses to skip. Default: bounced, complained, spammed.
2531 + */
2532 + $skippableStatuses = apply_filters('fluent_community/undeliverable_crm_contact_statuses', ['bounced', 'complained', 'spammed']);
2533 +
2534 + if (!$skippableStatuses) {
2535 + return [];
2536 + }
2537 +
2538 + $undeliverableEmails = Contact::whereIn('email', $emails)
2539 + ->whereIn('status', $skippableStatuses)
2540 + ->pluck('email')
2541 + ->toArray();
2542 +
2543 + return array_map('strtolower', $undeliverableEmails);
2544 + }
2545 +
2546 + public static function getCrmUndeliverableStatus($email)
2547 + {
2548 + if (!$email || !defined('FLUENTCRM')) {
2549 + return '';
2550 + }
2551 +
2552 + if (Utility::getPrivacySetting('skip_crm_undeliverable_emails') != 'yes') {
2553 + return '';
2554 + }
2555 +
2556 + $skippableStatuses = apply_filters('fluent_community/undeliverable_crm_contact_statuses', ['bounced', 'complained', 'spammed']);
2557 +
2558 + if (!$skippableStatuses) {
2559 + return '';
2560 + }
2561 +
2562 + $contact = Contact::where('email', $email)
2563 + ->whereIn('status', $skippableStatuses)
2564 + ->first();
2565 +
2566 + return $contact ? $contact->status : '';
2567 + }
2568 +
2569 + public static function isUndeliverableEmail($email)
2570 + {
2571 + return (bool)self::getCrmUndeliverableStatus($email);
2373 2572 }
2374 2573 }