Add account and folder filters for rules

This commit is contained in:
Jordan Wages 2025-07-15 22:58:27 -05:00
commit cd0a31ed98
3 changed files with 87 additions and 5 deletions

View file

@ -19,6 +19,7 @@ message meets a specified criterion.
- **Light/Dark themes** automatically match Thunderbird's appearance with optional manual override. - **Light/Dark themes** automatically match Thunderbird's appearance with optional manual override.
- **Automatic rules** create rules that tag, move, copy, forward, reply, delete, archive, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages and can ignore messages outside a chosen age range. - **Automatic rules** create rules that tag, move, copy, forward, reply, delete, archive, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages and can ignore messages outside a chosen age range.
- **Rule ordering** drag rules to prioritize them and optionally stop processing after a match. - **Rule ordering** drag rules to prioritize them and optionally stop processing after a match.
- **Account & folder filters** limit rules to specific accounts or folders.
- **Context menu** apply AI rules from the message list or the message display action button. - **Context menu** apply AI rules from the message list or the message display action button.
- **Status icons** toolbar icons show when classification is in progress and briefly display success or error states. - **Status icons** toolbar icons show when classification is in progress and briefly display success or error states.
- **View reasoning** inspect why rules matched via the Details popup. - **View reasoning** inspect why rules matched via the Details popup.
@ -69,11 +70,12 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e
## Usage ## Usage
1. Open the add-on's options and set the URL of your classification service. 1. Open the add-on's options and set the URL of your classification service.
2. Use the **Classification Rules** section to add a criterion and optional 2. Use the **Classification Rules** section to add a criterion and optional
actions such as tagging, moving, copying, forwarding, replying, actions such as tagging, moving, copying, forwarding, replying,
deleting or archiving a message when it matches. Drag rules to deleting or archiving a message when it matches. Drag rules to
reorder them, check *Only apply to unread messages* to skip read mail, reorder them, check *Only apply to unread messages* to skip read mail,
set optional minimum or maximum message age limits, and set optional minimum or maximum message age limits, select the accounts or
folders a rule should apply to, and
check *Stop after match* to halt further processing. Forward and reply actions check *Stop after match* to halt further processing. Forward and reply actions
open a compose window using the account that received the message. open a compose window using the account that received the message.
3. Save your settings. New mail will be evaluated automatically using the 3. Save your settings. New mail will be evaluated automatically using the

View file

@ -33,7 +33,11 @@ let detectSystemTheme;
function normalizeRules(rules) { function normalizeRules(rules) {
return Array.isArray(rules) ? rules.map(r => { return Array.isArray(rules) ? rules.map(r => {
if (r.actions) return r; if (r.actions) {
if (!Array.isArray(r.accounts)) r.accounts = [];
if (!Array.isArray(r.folders)) r.folders = [];
return r;
}
const actions = []; const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
@ -43,6 +47,8 @@ function normalizeRules(rules) {
if (r.unreadOnly) rule.unreadOnly = true; if (r.unreadOnly) rule.unreadOnly = true;
if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays; if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays;
if (typeof r.maxAgeDays === 'number') rule.maxAgeDays = r.maxAgeDays; if (typeof r.maxAgeDays === 'number') rule.maxAgeDays = r.maxAgeDays;
if (Array.isArray(r.accounts)) rule.accounts = r.accounts;
if (Array.isArray(r.folders)) rule.folders = r.folders;
return rule; return rule;
}) : []; }) : [];
} }
@ -228,6 +234,14 @@ async function processMessage(id) {
} }
for (const rule of aiRules) { for (const rule of aiRules) {
if (hdr && Array.isArray(rule.accounts) && rule.accounts.length &&
!rule.accounts.includes(hdr.folder.accountId)) {
continue;
}
if (hdr && Array.isArray(rule.folders) && rule.folders.length &&
!rule.folders.includes(hdr.folder.path)) {
continue;
}
if (rule.unreadOnly && alreadyRead) { if (rule.unreadOnly && alreadyRead) {
continue; continue;
} }

View file

@ -123,6 +123,7 @@ document.addEventListener('DOMContentLoaded', async () => {
let tagList = []; let tagList = [];
let folderList = []; let folderList = [];
let accountList = [];
try { try {
tagList = await messenger.messages.tags.list(); tagList = await messenger.messages.tags.list();
} catch (e) { } catch (e) {
@ -130,6 +131,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
try { try {
const accounts = await messenger.accounts.list(true); const accounts = await messenger.accounts.list(true);
accountList = accounts.map(a => ({ id: a.id, name: a.name }));
const collect = (f, prefix='') => { const collect = (f, prefix='') => {
folderList.push({ id: f.id ?? f.path, name: prefix + f.name }); folderList.push({ id: f.id ?? f.path, name: prefix + f.name });
(f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/')); (f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/'));
@ -372,6 +374,54 @@ document.addEventListener('DOMContentLoaded', async () => {
ageBox.appendChild(minInput); ageBox.appendChild(minInput);
ageBox.appendChild(maxInput); ageBox.appendChild(maxInput);
const acctBox = document.createElement('div');
acctBox.className = 'field mt-2';
const acctLabel = document.createElement('label');
acctLabel.className = 'label';
acctLabel.textContent = 'Accounts';
const acctControl = document.createElement('div');
const acctWrap = document.createElement('div');
acctWrap.className = 'select is-multiple is-small';
const acctSel = document.createElement('select');
acctSel.className = 'account-select';
acctSel.multiple = true;
acctSel.size = Math.min(accountList.length, 4) || 1;
for (const a of accountList) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.name;
if ((rule.accounts || []).includes(a.id)) opt.selected = true;
acctSel.appendChild(opt);
}
acctWrap.appendChild(acctSel);
acctControl.appendChild(acctWrap);
acctBox.appendChild(acctLabel);
acctBox.appendChild(acctControl);
const folderBox = document.createElement('div');
folderBox.className = 'field mt-2';
const folderLabel = document.createElement('label');
folderLabel.className = 'label';
folderLabel.textContent = 'Folders';
const folderControl = document.createElement('div');
const folderWrap = document.createElement('div');
folderWrap.className = 'select is-multiple is-small';
const folderSel = document.createElement('select');
folderSel.className = 'folder-filter-select';
folderSel.multiple = true;
folderSel.size = Math.min(folderList.length, 6) || 1;
for (const f of folderList) {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
if ((rule.folders || []).includes(f.id)) opt.selected = true;
folderSel.appendChild(opt);
}
folderWrap.appendChild(folderSel);
folderControl.appendChild(folderWrap);
folderBox.appendChild(folderLabel);
folderBox.appendChild(folderControl);
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'message-body'; body.className = 'message-body';
body.appendChild(actionsContainer); body.appendChild(actionsContainer);
@ -379,6 +429,8 @@ document.addEventListener('DOMContentLoaded', async () => {
body.appendChild(stopLabel); body.appendChild(stopLabel);
body.appendChild(unreadLabel); body.appendChild(unreadLabel);
body.appendChild(ageBox); body.appendChild(ageBox);
body.appendChild(acctBox);
body.appendChild(folderBox);
article.appendChild(header); article.appendChild(header);
article.appendChild(body); article.appendChild(body);
@ -419,17 +471,25 @@ document.addEventListener('DOMContentLoaded', async () => {
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked; const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value); const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value);
const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value); const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value);
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
const rule = { criterion, actions, unreadOnly, stopProcessing }; const rule = { criterion, actions, unreadOnly, stopProcessing };
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays; if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays; if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays;
if (accounts.length) rule.accounts = accounts;
if (folders.length) rule.folders = folders;
return rule; return rule;
}); });
data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false }); data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false, accounts: [], folders: [] });
renderRules(data); renderRules(data);
}); });
renderRules((defaults.aiRules || []).map(r => { renderRules((defaults.aiRules || []).map(r => {
if (r.actions) return r; if (r.actions) {
if (!Array.isArray(r.accounts)) r.accounts = [];
if (!Array.isArray(r.folders)) r.folders = [];
return r;
}
const actions = []; const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
@ -439,6 +499,8 @@ document.addEventListener('DOMContentLoaded', async () => {
if (r.unreadOnly) rule.unreadOnly = true; if (r.unreadOnly) rule.unreadOnly = true;
if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays; if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays;
if (typeof r.maxAgeDays === 'number') rule.maxAgeDays = r.maxAgeDays; if (typeof r.maxAgeDays === 'number') rule.maxAgeDays = r.maxAgeDays;
if (Array.isArray(r.accounts)) rule.accounts = r.accounts;
if (Array.isArray(r.folders)) rule.folders = r.folders;
return rule; return rule;
})); }));
@ -584,9 +646,13 @@ document.addEventListener('DOMContentLoaded', async () => {
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked; const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value); const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value);
const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value); const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value);
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
const rule = { criterion, actions, unreadOnly, stopProcessing }; const rule = { criterion, actions, unreadOnly, stopProcessing };
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays; if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays; if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays;
if (accounts.length) rule.accounts = accounts;
if (folders.length) rule.folders = folders;
return rule; return rule;
}).filter(r => r.criterion); }).filter(r => r.criterion);
const stripUrlParams = stripUrlToggle.checked; const stripUrlParams = stripUrlToggle.checked;