From dceebe48de159e27039f54fb5cff79f488c88023 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Wed, 25 Jun 2025 14:15:09 -0500 Subject: [PATCH] Add context menu for applying AI rules --- README.md | 2 ++ background.js | 95 +++++++++++++++++++++++++++++++++------------------ manifest.json | 3 +- 3 files changed, 65 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 7b23f1c..32eaa67 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ message meets a specified criterion. - **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page. - **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service. - **Automatic rules** – create rules that tag or move new messages based on AI classification. +- **Context menu** – apply AI rules to selected messages from the message list or display. - **Packaging script** – `build-xpi.ps1` builds an XPI ready for installation. ## Architecture Overview @@ -100,6 +101,7 @@ Sortana requests the following Thunderbird permissions: - `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. +- `menus` – add context menu commands. ## License diff --git a/background.js b/background.js index b2182d6..3ffa635 100644 --- a/background.js +++ b/background.js @@ -19,6 +19,47 @@ async function sha256Hex(str) { return Array.from(new Uint8Array(buf), b => b.toString(16).padStart(2, '0')).join(''); } +async function applyAiRules(idsInput) { + const ids = Array.isArray(idsInput) ? idsInput : [idsInput]; + if (!ids.length) return; + + if (!aiRules.length) { + const { aiRules: stored } = await browser.storage.local.get("aiRules"); + 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 ids) { + const id = msg?.id ?? msg; + try { + const full = await messenger.messages.getFull(id); + const text = full?.parts?.[0]?.body || ""; + for (const rule of aiRules) { + const cacheKey = await sha256Hex(`${id}|${rule.criterion}`); + const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey); + if (matched) { + 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 }); + } + } + } + } + } catch (e) { + logger.aiLog("failed to apply AI rules", { level: 'error' }, e); + } + } +} + (async () => { logger = await import(browser.runtime.getURL("logger.js")); try { @@ -47,6 +88,24 @@ async function sha256Hex(str) { logger.aiLog("background.js loaded – ready to classify", {debug: true}); + browser.menus.create({ + id: "apply-ai-rules-list", + title: "Apply AI Rules", + contexts: ["message_list"], + }); + browser.menus.create({ + id: "apply-ai-rules-display", + title: "Apply AI Rules", + contexts: ["message_display"], + }); + + browser.menus.onClicked.addListener(async info => { + if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") { + const ids = info.selectedMessages?.ids || (info.messageId ? [info.messageId] : []); + await applyAiRules(ids); + } + }); + // Listen for messages from UI/devtools browser.runtime.onMessage.addListener(async (msg) => { logger.aiLog("onMessage received", {debug: true}, msg); @@ -77,40 +136,8 @@ async function sha256Hex(str) { if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) { messenger.messages.onNewMailReceived.addListener(async (folder, messages) => { logger.aiLog("onNewMailReceived", {debug: true}, messages); - if (!aiRules.length) { - const { aiRules: stored } = await browser.storage.local.get("aiRules"); - 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; - try { - const full = await messenger.messages.getFull(id); - const text = full?.parts?.[0]?.body || ""; - for (const rule of aiRules) { - const cacheKey = await sha256Hex(`${id}|${rule.criterion}`); - const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey); - if (matched) { - 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}); - } - } - } - } - } catch (e) { - logger.aiLog("failed to classify new mail", {level: 'error'}, e); - } - } + const ids = (messages?.messages || messages || []).map(m => m.id ?? m); + await applyAiRules(ids); }); } else { logger.aiLog("messenger.messages API unavailable, skipping new mail listener", { level: 'warn' }); diff --git a/manifest.json b/manifest.json index ba4f8c4..cb30d19 100644 --- a/manifest.json +++ b/manifest.json @@ -29,6 +29,7 @@ "messagesMove", "messagesUpdate", "messagesTagsList", - "accountsRead" + "accountsRead", + "menus" ] }