diff --git a/README.md b/README.md index b6fdcf2..7b23f1c 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ Sortana requests the following Thunderbird permissions: - `storage` – store configuration and cached classification results. - `messagesRead` – read message contents for classification. - `messagesMove` – move messages when a rule specifies a target folder. +- `messagesUpdate` – change message properties such as tags and junk status. +- `messagesTagsList` – retrieve existing message tags for rule actions. +- `accountsRead` – list accounts and folders for move actions. ## License diff --git a/background.js b/background.js index 20a2573..b2182d6 100644 --- a/background.js +++ b/background.js @@ -33,7 +33,13 @@ async function sha256Hex(str) { const store = await browser.storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "aiRules"]); logger.setDebug(store.debugLogging); await AiClassifier.setConfig(store); - aiRules = Array.isArray(store.aiRules) ? store.aiRules : []; + aiRules = Array.isArray(store.aiRules) ? store.aiRules.map(r => { + if (r.actions) return r; + const actions = []; + if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); + if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); + return { criterion: r.criterion, actions }; + }) : []; logger.aiLog("configuration loaded", {debug: true}, store); } catch (err) { logger.aiLog("failed to load config", {level: 'error'}, err); @@ -73,7 +79,13 @@ if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) { logger.aiLog("onNewMailReceived", {debug: true}, messages); if (!aiRules.length) { const { aiRules: stored } = await browser.storage.local.get("aiRules"); - aiRules = Array.isArray(stored) ? stored : []; + aiRules = Array.isArray(stored) ? stored.map(r => { + if (r.actions) return r; + const actions = []; + if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); + if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); + return { criterion: r.criterion, actions }; + }) : []; } for (const msg of (messages?.messages || messages || [])) { const id = msg.id ?? msg; @@ -84,11 +96,14 @@ if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) { const cacheKey = await sha256Hex(`${id}|${rule.criterion}`); const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey); if (matched) { - if (rule.tag) { - await messenger.messages.update(id, {tags: [rule.tag]}); - } - if (rule.moveTo) { - await messenger.messages.move([id], rule.moveTo); + for (const act of (rule.actions || [])) { + if (act.type === 'tag' && act.tagKey) { + await messenger.messages.update(id, {tags: [act.tagKey]}); + } else if (act.type === 'move' && act.folder) { + await messenger.messages.move([id], act.folder); + } else if (act.type === 'junk') { + await messenger.messages.update(id, {junk: !!act.junk}); + } } } } diff --git a/manifest.json b/manifest.json index bf9dda7..ba4f8c4 100644 --- a/manifest.json +++ b/manifest.json @@ -23,5 +23,12 @@ "page": "options/options.html", "open_in_tab": true }, - "permissions": [ "storage", "messagesRead", "accountsRead" ] + "permissions": [ + "storage", + "messagesRead", + "messagesMove", + "messagesUpdate", + "messagesTagsList", + "accountsRead" + ] } diff --git a/options/options.html b/options/options.html index ed239cd..17af47a 100644 --- a/options/options.html +++ b/options/options.html @@ -78,6 +78,19 @@ flex-wrap: wrap; } + .tab-button { + background: #e0e0e0; + } + + .tab-button.active { + background: #007acc; + color: #fff; + } + + .tab { + margin-top: 20px; + } + #rules-container { margin-top: 10px; } @@ -93,6 +106,10 @@ margin-top: 10px; } + .action-row { + margin-bottom: 5px; + } + button { padding: 10px 20px; border: none; @@ -132,7 +149,13 @@ AI Filter Logo + +
+
@@ -213,10 +236,14 @@
-
+ + + +
diff --git a/options/options.js b/options/options.js index ecca4a5..9c3ff00 100644 --- a/options/options.js +++ b/options/options.js @@ -10,6 +10,16 @@ document.addEventListener('DOMContentLoaded', async () => { 'debugLogging', 'aiRules' ]); + const tabButtons = document.querySelectorAll('.tab-button'); + const tabs = document.querySelectorAll('.tab'); + tabButtons.forEach(btn => btn.addEventListener('click', () => { + tabButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + tabs.forEach(tab => { + tab.style.display = tab.id === `${btn.dataset.tab}-tab` ? 'block' : 'none'; + }); + })); + tabButtons[0]?.click(); logger.setDebug(defaults.debugLogging === true); const DEFAULT_AI_PARAMS = { max_tokens: 4096, @@ -66,6 +76,26 @@ document.addEventListener('DOMContentLoaded', async () => { if (el) el.value = val; } + let tagList = []; + let folderList = []; + try { + tagList = await messenger.messages.tags.list(); + } catch (e) { + logger.aiLog('failed to list tags', {level:'error'}, e); + } + try { + const accounts = await messenger.accounts.list(true); + const collect = (f, prefix='') => { + folderList.push({ id: f.id ?? f.path, name: prefix + f.name }); + (f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/')); + }; + for (const acct of accounts) { + (acct.folders || []).forEach(f => collect(f, `${acct.name}/`)); + } + } catch (e) { + logger.aiLog('failed to list folders', {level:'error'}, e); + } + const DEFAULT_SYSTEM = 'Determine whether the email satisfies the user\'s criterion.'; const systemBox = document.getElementById('system-instructions'); systemBox.value = defaults.customSystemPrompt || DEFAULT_SYSTEM; @@ -76,6 +106,70 @@ document.addEventListener('DOMContentLoaded', async () => { const rulesContainer = document.getElementById('rules-container'); const addRuleBtn = document.getElementById('add-rule'); + function createActionRow(action = {type: 'tag'}) { + const row = document.createElement('div'); + row.className = 'action-row'; + + const typeSelect = document.createElement('select'); + ['tag','move','junk'].forEach(t => { + const opt = document.createElement('option'); + opt.value = t; + opt.textContent = t; + typeSelect.appendChild(opt); + }); + typeSelect.value = action.type; + + const paramSpan = document.createElement('span'); + + function updateParams() { + paramSpan.innerHTML = ''; + if (typeSelect.value === 'tag') { + const sel = document.createElement('select'); + sel.className = 'tag-select'; + for (const t of tagList) { + const opt = document.createElement('option'); + opt.value = t.key; + opt.textContent = t.tag; + sel.appendChild(opt); + } + sel.value = action.tagKey || ''; + paramSpan.appendChild(sel); + } else if (typeSelect.value === 'move') { + const sel = document.createElement('select'); + sel.className = 'folder-select'; + for (const f of folderList) { + const opt = document.createElement('option'); + opt.value = f.id; + opt.textContent = f.name; + sel.appendChild(opt); + } + sel.value = action.folder || ''; + paramSpan.appendChild(sel); + } else if (typeSelect.value === 'junk') { + const sel = document.createElement('select'); + sel.className = 'junk-select'; + sel.appendChild(new Option('mark junk','true')); + sel.appendChild(new Option('mark not junk','false')); + sel.value = String(action.junk ?? true); + paramSpan.appendChild(sel); + } + } + + typeSelect.addEventListener('change', updateParams); + updateParams(); + + const removeBtn = document.createElement('button'); + removeBtn.textContent = 'Remove'; + removeBtn.type = 'button'; + removeBtn.addEventListener('click', () => row.remove()); + + row.appendChild(typeSelect); + row.appendChild(paramSpan); + row.appendChild(removeBtn); + + return row; + } + function renderRules(rules = []) { rulesContainer.innerHTML = ''; for (const rule of rules) { @@ -86,45 +180,63 @@ document.addEventListener('DOMContentLoaded', async () => { critInput.type = 'text'; critInput.placeholder = 'Criterion'; critInput.value = rule.criterion || ''; + critInput.className = 'criterion'; - const tagInput = document.createElement('input'); - tagInput.type = 'text'; - tagInput.placeholder = 'Tag (e.g. $label1)'; - tagInput.value = rule.tag || ''; + const actionsContainer = document.createElement('div'); + actionsContainer.className = 'rule-actions'; - const moveInput = document.createElement('input'); - moveInput.type = 'text'; - moveInput.placeholder = 'Folder URL'; - moveInput.value = rule.moveTo || ''; + for (const act of (rule.actions || [])) { + actionsContainer.appendChild(createActionRow(act)); + } - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'rule-actions'; + const addAction = document.createElement('button'); + addAction.textContent = 'Add Action'; + addAction.type = 'button'; + addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow())); const delBtn = document.createElement('button'); - delBtn.textContent = 'Delete'; + delBtn.textContent = 'Delete Rule'; delBtn.type = 'button'; delBtn.addEventListener('click', () => div.remove()); - actionsDiv.appendChild(delBtn); - div.appendChild(critInput); - div.appendChild(tagInput); - div.appendChild(moveInput); - div.appendChild(actionsDiv); + div.appendChild(actionsContainer); + div.appendChild(addAction); + div.appendChild(delBtn); rulesContainer.appendChild(div); } } addRuleBtn.addEventListener('click', () => { - renderRules([...rulesContainer.querySelectorAll('.rule')].map(el => ({ - criterion: el.children[0].value, - tag: el.children[1].value, - moveTo: el.children[2].value - })).concat([{ criterion: '', tag: '', moveTo: '' }])); + const data = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => { + const criterion = ruleEl.querySelector('.criterion').value; + const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => { + const type = row.querySelector('select').value; + if (type === 'tag') { + return { type, tagKey: row.querySelector('.tag-select').value }; + } + if (type === 'move') { + return { type, folder: row.querySelector('.folder-select').value }; + } + if (type === 'junk') { + return { type, junk: row.querySelector('.junk-select').value === 'true' }; + } + return { type }; + }); + return { criterion, actions }; + }); + data.push({ criterion: '', actions: [] }); + renderRules(data); }); - renderRules(defaults.aiRules || []); + renderRules((defaults.aiRules || []).map(r => { + if (r.actions) return r; + const actions = []; + if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); + if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); + return { criterion: r.criterion, actions }; + })); document.getElementById('save').addEventListener('click', async () => { const endpoint = document.getElementById('endpoint').value; @@ -140,11 +252,23 @@ document.addEventListener('DOMContentLoaded', async () => { } } const debugLogging = debugToggle.checked; - const rules = [...rulesContainer.querySelectorAll('.rule')].map(el => ({ - criterion: el.children[0].value, - tag: el.children[1].value, - moveTo: el.children[2].value - })).filter(r => r.criterion); + const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => { + const criterion = ruleEl.querySelector('.criterion').value; + const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => { + const type = row.querySelector('select').value; + if (type === 'tag') { + return { type, tagKey: row.querySelector('.tag-select').value }; + } + if (type === 'move') { + return { type, folder: row.querySelector('.folder-select').value }; + } + if (type === 'junk') { + return { type, junk: row.querySelector('.junk-select').value === 'true' }; + } + return { type }; + }); + return { criterion, actions }; + }).filter(r => r.criterion); await browser.storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules }); try { await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });