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.
This commit is contained in:
AbdoBnHesham 2024-07-18 23:48:45 +03:00
parent e4316e6665
commit 4a56d2d087
6 changed files with 724 additions and 0 deletions

View file

@ -0,0 +1,211 @@
<?php
class SearchFiltersPlugin extends \RainLoop\Plugins\AbstractPlugin
{
public const
NAME = 'Search Filters',
VERSION = '0.0.2',
RELEASE = '2024-06-28',
REQUIRED = '2.36.3',
CATEGORY = 'General',
LICENSE = 'MIT',
DESCRIPTION = 'Add filters to search queries';
public function Init(): void
{
$this->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));
}
}

View file

@ -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);

View file

@ -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"
}
}

View file

@ -0,0 +1,82 @@
<header>
<a href="#" class="close" data-bind="click: close">×</a>
<h3 data-i18n="SEARCH/TITLE_ADV"></h3>
</header>
<form id="advsearchform" class="modal-body form-horizontal" action="#/" autocomplete="off" spellcheck="false"
data-bind="submit: submitForm">
<div>
<div class="control-group">
<label data-i18n="GLOBAL/FROM"></label>
<input type="text" autofocus="" autocomplete="off" autocorrect="off" autocapitalize="off"
data-bind="value: from">
</div>
<div class="control-group">
<label data-i18n="GLOBAL/TO"></label>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" data-bind="value: to">
</div>
<div class="control-group">
<label data-i18n="GLOBAL/SUBJECT"></label>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" data-bind="value: subject">
</div>
<div class="control-group">
<label data-i18n="SEARCH/TEXT"></label>
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" data-bind="value: text">
</div>
</div>
<div>
<div class="control-group">
<label data-i18n="SEARCH/DATE"></label>
<div data-bind="component: {
name: 'Select',
params: {
options: selectedDates,
value: selectedDateValue,
optionsText: 'name',
optionsValue: 'id'
}
}"></div>
</div>
<div class="control-group">
<label data-i18n="SEARCH/REPLIED"></label>
<div data-bind="component: {
name: 'Select',
params: {
options: repliedOptions,
value: repliedValue,
optionsText: 'name',
optionsValue: 'id'
}
}"></div>
</div>
<div class="control-group">
<label></label>
<div>
<div data-bind="component: {
name: 'Checkbox',
params: {
label: 'SEARCH/UNSEEN',
value: unseen
}
}"></div>
<div data-bind="component: {
name: 'Checkbox',
params: {
label: 'SEARCH/FLAGGED',
value: starred
}
}"></div>
<div data-bind="component: {
name: 'Checkbox',
params: {
label: 'SEARCH/HAS_ATTACHMENT',
value: hasAttachment
}
}"></div>
</div>
</div>
</div>
</form>
<footer style="display: flex; justify-content: space-between;">
<button class="btn" data-i18n="SFILTERS/EDIT_FILTERS" data-bind="click: editFilters"></button>
<button form="advsearchform" class="btn" data-icon="✔" data-i18n="SFILTERS/SAVE"></button>
</footer>

View file

@ -0,0 +1,54 @@
<header>
<a href="#" class="close" data-bind="click: close">×</a>
<h3 data-i18n="SFILTERS/WHEN_MESSAGE_MATCH_SEARCH_CRITERIA"></h3>
</header>
<form id="search-filters-form" class="modal-body form-horizontal" action="#/" autocomplete="off" spellcheck="false"
data-bind="submit: submitForm">
<div>
<div class="control-group">
<div data-bind="component: {
name: 'Checkbox',
params: {
label: 'SFILTERS/MOVE_TO_FOLDER',
value: usefFolder
}
}"></div>
<select data-bind="
options: folders,
value: fFolder,
optionsText: 'name',
optionsValue: 'name',
enable: usefFolder
"></select>
</div>
<div class="control-group">
<div data-bind="component: {
name: 'Checkbox',
params: {
label: 'SFILTERS/MARK_AS_READ',
value: fSeen
}
}"></div>
</div>
<div class="control-group">
<div data-bind="component: {
name: 'Checkbox',
params: {
label: 'SFILTERS/STAR_IT',
value: fFlag
}
}"></div>
</div>
<div class="control-group">
<label for="priority" data-i18n="SFILTERS/PRIORITY_LABLE"></label>
<select data-bind="
options: Priorities,
value: priority,
"></select>
</div>
</div>
</form>
<footer>
<button form="search-filters-form" class="btn buttonSearchFilters" data-icon="✔"
data-i18n="SFILTERS/CREATE_FILTER"></button>
</footer>

View file

@ -0,0 +1,39 @@
<div>
<div class="form-horizontal">
<div class="legend">
<span data-i18n="SFILTERS/THE_FOLLOWING_FILTERS_ARE_APPLIED"></span>
&nbsp;&nbsp;&nbsp;
<i class="icon-spinner animated" style="margin-top: 5px" data-bind="visible: loading"></i>
</div>
<div data-bind="visible: ioFilters().length == 0">
<span data-i18n="SFILTERS/NO_FILTERS"> </span>
</div>
<!-- ko foreach: ioFilters -->
<label class="control-label">
<span data-bind="text: $root.i18n('MATCHES')"> </span>
<span data-bind="text: searchQ"></span>
&nbsp;
<span data-bind="text: $root.i18n('PRIORITY_POINTS')"></span>
:
&nbsp;
<span data-bind="text: priority"></span>
<br>
<span data-bind="text: $root.i18n('DO_THIS')"></span>
<span data-bind="visible: fFolder, text: $root.i18n('MOVE_TO_FOLDER')"></span>
<span data-bind="visible: fFolder, text: fFolder"></span>
&nbsp;
<span data-bind="visible: fSeen, text: $root.i18n('MARK_AS_READ')"></span>
&nbsp;
<span data-bind="visible: fFlag, text: $root.i18n('STAR_IT')"></span>
</label>
<div class="controls">
<button class="btn" data-bind="click: $root.edit, text: $root.i18n('EDIT')"></button>
<button class="btn" data-bind="click: $root.remove, text: $root.i18n('REMOVE')"></button>
</div>
<br>
<!-- /ko -->
</div>
</div>