From 4a56d2d0876001dd665f1efda7e00cf5b8fc2cae Mon Sep 17 00:00:00 2001 From: AbdoBnHesham Date: Thu, 18 Jul 2024 23:48:45 +0300 Subject: [PATCH] Add Search Filters Plugin with Gmail-like Functionality Introducing a new plugin for enhanced email organization: Search Filters, inspired by similar features in Gmail (Search Filters) and Outlook (Rules). The plugin allows you to define custom search queries that automatically perform one or more actions on matching emails based on their priority level (1-10). Available actions include marking as seen, flagging/starring, and moving to a designated folder. This plugin is designed to work seamlessly with both old and new emails in your inbox, taking into account the specified search query criteria. However, please be aware of an outstanding bug that affects the display of unread email counts beside folders; this issue has yet to be resolved. I have developed this plugin as a freelance project for a client on their custom server setup. Unfortunately, I never received any feedback or response from my client after completion of the work. Nonetheless, I hope that sharing it here will benefit others and contribute to the ongoing advancement of this open-source project. Please note: This plugin is offered as-is with no warranties or guarantees. Make sure to thoroughly test and verify its functionality before using it in a production environment. Thank you to everyone who has contributed to making this project a reality. --- plugins/search-filters/index.php | 211 ++++++++++++ plugins/search-filters/js/SearchFilters.js | 317 ++++++++++++++++++ plugins/search-filters/langs/en.json | 21 ++ .../templates/PopupsSTabAdvancedSearch.html | 82 +++++ .../templates/PopupsSearchFilters.html | 54 +++ .../templates/STabSearchFilters.html | 39 +++ 6 files changed, 724 insertions(+) create mode 100644 plugins/search-filters/index.php create mode 100644 plugins/search-filters/js/SearchFilters.js create mode 100644 plugins/search-filters/langs/en.json create mode 100644 plugins/search-filters/templates/PopupsSTabAdvancedSearch.html create mode 100644 plugins/search-filters/templates/PopupsSearchFilters.html create mode 100644 plugins/search-filters/templates/STabSearchFilters.html diff --git a/plugins/search-filters/index.php b/plugins/search-filters/index.php new file mode 100644 index 000000000..ba4ba2571 --- /dev/null +++ b/plugins/search-filters/index.php @@ -0,0 +1,211 @@ +UseLangs(true); + + $this->addHook('imap.after-login', 'ApplyFilters'); + + $this->addTemplate('templates/STabSearchFilters.html'); + + $this->addTemplate('templates/PopupsSearchFilters.html'); + $this->addTemplate('templates/PopupsSTabAdvancedSearch.html'); + $this->addJs('js/SearchFilters.js'); + + $this->addJsonHook('SGetFilters', 'GetFilters'); + $this->addJsonHook('SAddEditFilter', 'AddEditFilter'); + $this->addJsonHook('SUpdateSearchQ', 'UpdateSearchQ'); + $this->addJsonHook('SDeleteFilter', 'DeleteFilter'); + } + + public function ApplyFilters( + \RainLoop\Model\Account $oAccount, + \MailSo\Imap\ImapClient $oImapClient, + bool $bSuccess, + \MailSo\Imap\Settings $oSettings + ) { + if (!$bSuccess) { + return; + } + + $aSettings = $this->getUserSettings(); + if (empty($aSettings['SFilters'])) { + $aSettings['SFilters'] = []; + $this->saveUserSettings($aSettings); + return; + } + + $Filters = $aSettings['SFilters']; + + foreach ($Filters as $filter) { + $this->Manager()->logWrite(json_encode([ + 'filter' => $filter, + ]), LOG_WARNING); + + $folder = 'INBOX'; + $searchQ = $filter['searchQ']; + $uids = $this->searchMessages($oImapClient, $searchQ, "INBOX"); + + //Mark as read/seen + if ($filter['fSeen']) { + foreach ($uids as $uid) { + $oRange = new MailSo\Imap\SequenceSet([$uid]); + $this->Manager()->Actions()->MailClient()->MessageSetFlag( + $folder, + $oRange, + MailSo\Imap\Enumerations\MessageFlag::SEEN + ); + } + } + + //Flag/Star message + if ($filter['fFlag']) { + foreach ($uids as $uid) { + $oRange = new MailSo\Imap\SequenceSet([$uid]); + $this->Manager()->Actions()->MailClient()->MessageSetFlag( + $folder, + $oRange, + MailSo\Imap\Enumerations\MessageFlag::FLAGGED + ); + } + } + + // Move to folder + if ($filter['fFolder']) { + $folder = $filter['fFolder']; + foreach ($uids as $uid) { + $oRange = new MailSo\Imap\SequenceSet([$uid]); + $oImapClient->MessageMove("INBOX", $folder, $oRange); + } + } + } + } + + private function searchMessages( + \MailSo\Imap\ImapClient $imapClient, + string $search, + string $folder = "INBOX" + ): array { + $oParams = new \MailSo\Mail\MessageListParams(); + $oParams->sSearch = $search; + $oParams->sFolderName = $folder; + + $bUseCache = false; + $oSearchCriterias = \MailSo\Imap\SearchCriterias::fromString( + $imapClient, + $folder, + $search, + true, + $bUseCache + ); + + $imapClient->FolderSelect($folder); + return $imapClient->MessageSearch($oSearchCriterias, true); + } + + public function GetFilters() + { + $aSettings = $this->getUserSettings(); + $Filters = $aSettings['SFilters'] ?? []; + + $Search = $this->jsonParam('SSearchQ'); + if (!$Search) { + return $this->jsonResponse(__FUNCTION__, ['SFilters' => $Filters]); + } + + $Filter = null; + foreach ($aSettings['SFilters'] as $filter) { + if ($filter['searchQ'] == $Search) { + $Filter = $filter; + } + } + + return $this->jsonResponse(__FUNCTION__, ['SFilter' => $Filter]); + } + + public function AddEditFilter() + { + $SFilter = $this->jsonParam('SFilter'); + $newFilter = [ + 'searchQ' => $SFilter['searchQ'], + 'priority' => $SFilter['priority'] ?? 1, + 'fFolder' => $SFilter['fFolder'], + 'fSeen' => $SFilter['fSeen'], + 'fFlag' => $SFilter['fFlag'], + ]; + + $aSettings = $this->getUserSettings(); + $aSettings['SFilters'] = $aSettings['SFilters'] ?? []; + + $foundIndex = null; + foreach ($aSettings['SFilters'] as $index => $filter) { + if ($filter['searchQ'] == $SFilter['searchQ']) { + if ($filter['priority'] != $SFilter['priority']) { + array_splice($aSettings['SFilters'], $index, 1); + } else { + $foundIndex = $index; + } + } + } + + if ($foundIndex === null) { + $insertIndex = 0; + foreach ($aSettings['SFilters'] as $index => $filter) + if ($filter['priority'] >= $newFilter['priority']) + $insertIndex = $index + 1; + else + break; + + array_splice($aSettings['SFilters'], $insertIndex, 0, [$newFilter]); + } else { + $aSettings['SFilters'][$foundIndex] = $newFilter; + } + + return $this->jsonResponse(__FUNCTION__, $this->saveUserSettings($aSettings)); + } + + public function UpdateSearchQ() + { + $SFilter = $this->jsonParam('SFilter'); + + $aSettings = $this->getUserSettings(); + $aSettings['SFilters'] = $aSettings['SFilters'] ?? []; + + foreach ($aSettings['SFilters'] as $index => $filter) { + if ($filter['searchQ'] == $SFilter['oldSearchQ']) { + $filter['searchQ'] = $SFilter['searchQ']; + $aSettings['SFilters'][$index] = $filter; + break; + } + } + + return $this->jsonResponse(__FUNCTION__, $this->saveUserSettings($aSettings)); + } + + public function DeleteFilter() + { + $Search = $this->jsonParam('SSearchQ'); + + $aSettings = $this->getUserSettings(); + $aSettings['SFilters'] = $aSettings['SFilters'] ?? []; + + foreach ($aSettings['SFilters'] as $index => $filter) { + if ($filter['searchQ'] == $Search) { + array_splice($aSettings['SFilters'], $index, 1); + } + } + + return $this->jsonResponse(__FUNCTION__, $this->saveUserSettings($aSettings)); + } +} diff --git a/plugins/search-filters/js/SearchFilters.js b/plugins/search-filters/js/SearchFilters.js new file mode 100644 index 000000000..39c998548 --- /dev/null +++ b/plugins/search-filters/js/SearchFilters.js @@ -0,0 +1,317 @@ +((rl) => { + const EmptyOption = { id: -1, name: '' }; + const Folders = ko.computed(() => { + return [EmptyOption, ...rl.app.folderList().map((f) => ({ name: f.name }))]; + }); + const Priorities = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const searchQ = ko.observable(''), + priority = ko.observable(1), + oldSearchQ = ko.observable(''), + fFolder = ko.observable(''), + fSeen = ko.observable(false), + fFlag = ko.observable(false), + ioFilters = ko.observableArray([]); + + const i18n = (val) => rl.i18n(`SFILTERS/${val}`); + + const ServerActions = { + GetFilters(loading) { + ioFilters([]); + rl.pluginRemoteRequest((iError, oData) => { + if (iError) return console.error(iError); + + oData.Result.SFilters.forEach((f) => ioFilters.push(ko.observable(f))); + + if (loading) loading(false); + }, 'SGetFilters'); + }, + GetFilter() { + rl.pluginRemoteRequest( + (iError, oData) => { + if (iError) return console.error(iError); + + const filter = oData.Result.SFilter || {}; + priority(filter.priority || 1); + fFolder(filter.fFolder); + fSeen(filter.fSeen); + fFlag(filter.fFlag); + }, + 'SGetFilters', + { + SSearchQ: searchQ() + } + ); + }, + AddOrEditFilter() { + rl.pluginRemoteRequest( + (iError, oData) => { + if (!iError) this.GetFilters(); + }, + 'SAddEditFilter', + { + SFilter: { + searchQ: oldSearchQ() || searchQ(), + priority: priority(), + fFolder: fFolder(), + fSeen: fSeen(), + fFlag: fFlag() + } + } + ); + }, + UpdateSearchQ(newSearchQ) { + rl.pluginRemoteRequest( + (iError, oData) => { + if (!iError) this.GetFilters(); + }, + 'SUpdateSearchQ', + { + SFilter: { + oldSearchQ: oldSearchQ(), + searchQ: newSearchQ + } + } + ); + }, + RemoveFilter(searchQToRemove) { + rl.pluginRemoteRequest( + (iError, oData) => { + if (iError) return console.error(iError, oData); + const index = ioFilters().findIndex((f) => f.searchQ() === searchQToRemove); + ioFilters.splice(index, 1); + }, + 'SDeleteFilter', + { + SSearchQ: searchQToRemove + } + ); + } + }; + + addEventListener('rl-view-model', (event) => { + const advS = event.detail; + if (advS.viewModelTemplateID == 'PopupsAdvancedSearch') { + const button = document.createElement('button'); + button.setAttribute('data-i18n', 'SFILTERS/CREATE_FILTER'); + button.addEventListener('click', function () { + searchQ(advS.buildSearchString()); + if (searchQ()) SearchFiltersPopupView.showModal(); + }); + + const footer = advS.querySelector('footer'); + footer.style.display = 'flex'; + footer.style.justifyContent = 'space-between'; + + footer.prepend(button); + } + }); + + class SearchFiltersSettingsTab { + constructor() { + this.folders = Folders; + this.Priorities = Priorities; + this.ioFilters = ioFilters; + + this.loading = ko.observable(false); + this.saving = ko.observable(false); + + this.i18n = i18n; + + this.savingOrLoading = ko.computed(() => { + return this.loading() || this.saving(); + }); + } + + edit(filter) { + oldSearchQ(filter.searchQ); + searchQ(filter.searchQ); + priority(filter.priority || 1); + fFolder(filter.fFolder); + fSeen(filter.fSeen); + fFlag(filter.fFlag); + AdvancedSearchPopupView.showModal(); + } + + remove(filter) { + ServerActions.RemoveFilter(filter.searchQ); + } + + onShow() { + this.clear(); + this.loading(true); + ServerActions.GetFilters(this.loading); + } + + clear() { + this.ioFilters([]); + this.loading(false); + this.saving(false); + } + } + + rl.addSettingsViewModel(SearchFiltersSettingsTab, 'STabSearchFilters', 'Filters', 'filters'); + + class SearchFiltersPopupView extends rl.pluginPopupView { + constructor() { + super('SearchFilters'); + + this.folders = Folders; + this.Priorities = Priorities; + + this.priority = priority; + this.fFolder = fFolder; + this.fSeen = fSeen; + this.fFlag = fFlag; + + this.usefFolder = ko.observable(false); + this.fFolder.subscribe((v) => this.usefFolder(!!v)); + } + + submitForm() { + if (!this.usefFolder()) fFolder(''); + ServerActions.AddOrEditFilter(); + this.close(); + } + + beforeShow() { + if (!oldSearchQ()) { + ServerActions.GetFilter(); + } else { + this.usefFolder(!!fFolder()); + } + } + } + + class AdvancedSearchPopupView extends rl.pluginPopupView { + constructor() { + super('STabAdvancedSearch'); + + this.addObservables({ + from: '', + to: '', + subject: '', + text: '', + repliedValue: -1, + selectedDateValue: -1, + selectedTreeValue: '', + + hasAttachment: false, + starred: false, + unseen: false + }); + + this.addComputables({ + repliedOptions: () => { + return [ + { id: -1, name: '' }, + { id: 1, name: rl.i18n('GLOBAL/YES') }, + { id: 0, name: rl.i18n('GLOBAL/NO') } + ]; + }, + + selectedDates: () => { + let prefix = 'SEARCH/DATE_'; + return [ + { id: -1, name: rl.i18n(prefix + 'ALL') }, + { id: 3, name: rl.i18n(prefix + '3_DAYS') }, + { id: 7, name: rl.i18n(prefix + '7_DAYS') }, + { id: 30, name: rl.i18n(prefix + 'MONTH') }, + { id: 90, name: rl.i18n(prefix + '3_MONTHS') }, + { id: 180, name: rl.i18n(prefix + '6_MONTHS') }, + { id: 365, name: rl.i18n(prefix + 'YEAR') } + ]; + }, + + selectedTree: () => { + let prefix = 'SEARCH/SUBFOLDERS_'; + return [ + { id: '', name: rl.i18n(prefix + 'NONE') }, + { id: 'subtree-one', name: rl.i18n(prefix + 'SUBTREE_ONE') }, + { id: 'subtree', name: rl.i18n(prefix + 'SUBTREE') } + ]; + } + }); + } + + submitForm() { + const newSearchQ = this.buildSearchString(); + if (newSearchQ != oldSearchQ()) ServerActions.UpdateSearchQ(newSearchQ); + this.close(); + } + + editFilters() { + SearchFiltersPopupView.showModal(); + } + + buildSearchString() { + const self = this, + data = new FormData(), + append = (key, value) => value.length && data.append(key, value); + + append('from', self.from().trim()); + append('to', self.to().trim()); + append('subject', self.subject().trim()); + append('text', self.text().trim()); + append('in', self.selectedTreeValue()); + if (-1 < self.selectedDateValue()) { + let d = new Date(); + d.setDate(d.getDate() - self.selectedDateValue()); + append('since', d.toISOString().split('T')[0]); + } + + let result = decodeURIComponent(new URLSearchParams(data).toString()); + + if (self.hasAttachment()) { + result += '&attachment'; + } + if (self.unseen()) { + result += '&unseen'; + } + if (self.starred()) { + result += '&flagged'; + } + if (1 == self.repliedValue()) { + result += '&answered'; + } + if (0 == self.repliedValue()) { + result += '&unanswered'; + } + + return result.replace(/^&+/, ''); + } + + onShow() { + const pString = (value) => (null != value ? '' + value : ''); + + const self = this, + params = new URLSearchParams('?' + searchQ()); + self.from(pString(params.get('from'))); + self.to(pString(params.get('to'))); + self.subject(pString(params.get('subject'))); + self.text(pString(params.get('text'))); + self.selectedTreeValue(pString(params.get('in'))); + self.selectedDateValue(-1); + self.hasAttachment(params.has('attachment')); + self.starred(params.has('flagged')); + self.unseen(params.has('unseen')); + if (params.has('answered')) { + self.repliedValue(1); + } else if (params.has('unanswered')) { + self.repliedValue(0); + } + } + + clear() { + oldSearchQ(''); + searchQ(''); + fFolder(''); + priority(1); + fSeen(false); + fFlag(false); + } + + onHide() { + this.clear(); + } + } +})(window.rl); diff --git a/plugins/search-filters/langs/en.json b/plugins/search-filters/langs/en.json new file mode 100644 index 000000000..6fb25bbf3 --- /dev/null +++ b/plugins/search-filters/langs/en.json @@ -0,0 +1,21 @@ +{ + "SFILTERS": { + "WHEN_MESSAGE_MATCH_SEARCH_CRITERIA": "When a message is an exact match for your search criteria:", + "FILTERS": "Filters", + "FILTER": "Filter", + "MOVE_TO_FOLDER": "Move to folder: ", + "CREATE_FILTER": "Create filter", + "EDIT_FILTERS": "Edit filters", + "MATCHES": "Matches: ", + "DO_THIS": "Do this: ", + "SAVE": "Save", + "EDIT": "Edit", + "REMOVE": "Remove", + "NO_FILTERS": "You haven't added any filters yet.", + "THE_FOLLOWING_FILTERS_ARE_APPLIED": "The following filters are applied to all incoming mail: ", + "MARK_AS_READ": "Mark as read", + "STAR_IT": "Star it", + "PRIORITY_LABLE": "Priority points, Higher means it'll apply first", + "PRIORITY_POINTS": "Priority points" + } +} \ No newline at end of file diff --git a/plugins/search-filters/templates/PopupsSTabAdvancedSearch.html b/plugins/search-filters/templates/PopupsSTabAdvancedSearch.html new file mode 100644 index 000000000..d1a260551 --- /dev/null +++ b/plugins/search-filters/templates/PopupsSTabAdvancedSearch.html @@ -0,0 +1,82 @@ +
+ × +

+
+ + \ No newline at end of file diff --git a/plugins/search-filters/templates/PopupsSearchFilters.html b/plugins/search-filters/templates/PopupsSearchFilters.html new file mode 100644 index 000000000..1203272ab --- /dev/null +++ b/plugins/search-filters/templates/PopupsSearchFilters.html @@ -0,0 +1,54 @@ +
+ × +

+
+ + \ No newline at end of file diff --git a/plugins/search-filters/templates/STabSearchFilters.html b/plugins/search-filters/templates/STabSearchFilters.html new file mode 100644 index 000000000..d006da345 --- /dev/null +++ b/plugins/search-filters/templates/STabSearchFilters.html @@ -0,0 +1,39 @@ +
+
+
+ +     + +
+ +
+ +
+ + + +
+ + +
+
+ + +
+
\ No newline at end of file