diff --git a/README.md b/README.md index de61261..adebc99 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ message meets a specified criterion. - **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. - **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. - **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. @@ -69,11 +70,12 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e ## Usage 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, deleting or archiving a message when it matches. Drag rules to 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 open a compose window using the account that received the message. 3. Save your settings. New mail will be evaluated automatically using the diff --git a/background.js b/background.js index 6636f60..fb820bb 100644 --- a/background.js +++ b/background.js @@ -33,7 +33,11 @@ let detectSystemTheme; function normalizeRules(rules) { 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 = []; if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); @@ -43,6 +47,8 @@ function normalizeRules(rules) { if (r.unreadOnly) rule.unreadOnly = true; if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays; 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; }) : []; } @@ -228,6 +234,14 @@ async function processMessage(id) { } 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) { continue; } diff --git a/options/options.js b/options/options.js index b089568..0042004 100644 --- a/options/options.js +++ b/options/options.js @@ -123,6 +123,7 @@ document.addEventListener('DOMContentLoaded', async () => { let tagList = []; let folderList = []; + let accountList = []; try { tagList = await messenger.messages.tags.list(); } catch (e) { @@ -130,6 +131,7 @@ document.addEventListener('DOMContentLoaded', async () => { } try { const accounts = await messenger.accounts.list(true); + accountList = accounts.map(a => ({ id: a.id, name: a.name })); const collect = (f, prefix='') => { folderList.push({ id: f.id ?? f.path, name: 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(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'); body.className = 'message-body'; body.appendChild(actionsContainer); @@ -379,6 +429,8 @@ document.addEventListener('DOMContentLoaded', async () => { body.appendChild(stopLabel); body.appendChild(unreadLabel); body.appendChild(ageBox); + body.appendChild(acctBox); + body.appendChild(folderBox); article.appendChild(header); article.appendChild(body); @@ -419,17 +471,25 @@ document.addEventListener('DOMContentLoaded', async () => { const unreadOnly = ruleEl.querySelector('.unread-only')?.checked; const minAgeDays = parseFloat(ruleEl.querySelector('.min-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 }; if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays; if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays; + if (accounts.length) rule.accounts = accounts; + if (folders.length) rule.folders = folders; return rule; }); - data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false }); + data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false, accounts: [], folders: [] }); renderRules(data); }); 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 = []; if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); 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 (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays; 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; })); @@ -584,9 +646,13 @@ document.addEventListener('DOMContentLoaded', async () => { const unreadOnly = ruleEl.querySelector('.unread-only')?.checked; const minAgeDays = parseFloat(ruleEl.querySelector('.min-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 }; if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays; if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays; + if (accounts.length) rule.accounts = accounts; + if (folders.length) rule.folders = folders; return rule; }).filter(r => r.criterion); const stripUrlParams = stripUrlToggle.checked;