Sortana/background.js

343 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Runs in the **WebExtension (addon)** context.
* For this minimal working version we only expose an async helper
* so UI pages / devtools panels can test the classifier without
* needing Thunderbirds filter engine.
*
* Note: the filter-engine itself NEVER calls this file the
* synchronous work is all done in experiment/api.js (chrome side).
*/
"use strict";
const storage = (globalThis.messenger ?? browser).storage;
let logger;
let AiClassifier;
let aiRules = [];
let queue = Promise.resolve();
let queuedCount = 0;
let processing = false;
let iconTimer = null;
function setIcon(path) {
if (browser.browserAction) {
browser.browserAction.setIcon({ path });
}
if (browser.messageDisplayAction) {
browser.messageDisplayAction.setIcon({ path });
}
}
function updateActionIcon() {
let path = "resources/img/logo32.png";
if (processing || queuedCount > 0) {
path = "resources/img/busy.png";
}
setIcon(path);
}
function showTransientIcon(path, delay = 1500) {
clearTimeout(iconTimer);
setIcon(path);
iconTimer = setTimeout(updateActionIcon, delay);
}
async function sha256Hex(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return Array.from(new Uint8Array(buf), b => b.toString(16).padStart(2, '0')).join('');
}
function byteSize(str) {
return new TextEncoder().encode(str || "").length;
}
function replaceInlineBase64(text) {
return text.replace(/[A-Za-z0-9+/]{100,}={0,2}/g,
m => `[base64: ${byteSize(m)} bytes]`);
}
function collectText(part, bodyParts, attachments) {
if (part.parts && part.parts.length) {
for (const p of part.parts) collectText(p, bodyParts, attachments);
return;
}
const ct = (part.contentType || "text/plain").toLowerCase();
const cd = (part.headers?.["content-disposition"]?.[0] || "").toLowerCase();
const body = String(part.body || "");
if (cd.includes("attachment") || !ct.startsWith("text/")) {
const nameMatch = /filename\s*=\s*"?([^";]+)/i.exec(cd) || /name\s*=\s*"?([^";]+)/i.exec(part.headers?.["content-type"]?.[0] || "");
const name = nameMatch ? nameMatch[1] : "";
attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`);
} else if (ct.startsWith("text/html")) {
const doc = new DOMParser().parseFromString(body, 'text/html');
bodyParts.push(replaceInlineBase64(doc.body.textContent || ""));
} else {
bodyParts.push(replaceInlineBase64(body));
}
}
function buildEmailText(full) {
const bodyParts = [];
const attachments = [];
collectText(full, bodyParts, attachments);
const headers = Object.entries(full.headers || {})
.map(([k,v]) => `${k}: ${v.join(' ')}`)
.join('\n');
const attachInfo = `Attachments: ${attachments.length}` + (attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : "");
return `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim();
}
async function applyAiRules(idsInput) {
const ids = Array.isArray(idsInput) ? idsInput : [idsInput];
if (!ids.length) return queue;
if (!aiRules.length) {
const { aiRules: stored } = await 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 });
const rule = { criterion: r.criterion, actions };
if (r.stopProcessing) rule.stopProcessing = true;
return rule;
}) : [];
}
for (const msg of ids) {
const id = msg?.id ?? msg;
queuedCount++;
updateActionIcon();
queue = queue.then(async () => {
processing = true;
queuedCount--;
updateActionIcon();
try {
const full = await messenger.messages.getFull(id);
const text = buildEmailText(full);
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 });
}
}
if (rule.stopProcessing) {
break;
}
}
}
processing = false;
showTransientIcon("resources/img/done.png");
} catch (e) {
processing = false;
logger.aiLog("failed to apply AI rules", { level: 'error' }, e);
showTransientIcon("resources/img/error.png");
}
});
}
return queue;
}
async function clearCacheForMessages(idsInput) {
const ids = Array.isArray(idsInput) ? idsInput : [idsInput];
if (!ids.length) return;
if (!aiRules.length) {
const { aiRules: stored } = await 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 });
const rule = { criterion: r.criterion, actions };
if (r.stopProcessing) rule.stopProcessing = true;
return rule;
}) : [];
}
const keys = [];
for (const msg of ids) {
const id = msg?.id ?? msg;
for (const rule of aiRules) {
const key = await sha256Hex(`${id}|${rule.criterion}`);
keys.push(key);
}
}
if (keys.length) {
await AiClassifier.removeCacheEntries(keys);
showTransientIcon("resources/img/done.png");
}
}
(async () => {
logger = await import(browser.runtime.getURL("logger.js"));
try {
AiClassifier = await import(browser.runtime.getURL("modules/AiClassifier.js"));
logger.aiLog("AiClassifier imported", {debug: true});
} catch (e) {
console.error("failed to import AiClassifier", e);
return;
}
try {
const store = await 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.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 });
const rule = { criterion: r.criterion, actions };
if (r.stopProcessing) rule.stopProcessing = true;
return rule;
}) : [];
logger.aiLog("configuration loaded", {debug: true}, store);
storage.onChanged.addListener(async changes => {
if (changes.aiRules) {
const newRules = changes.aiRules.newValue || [];
aiRules = newRules.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 });
const rule = { criterion: r.criterion, actions };
if (r.stopProcessing) rule.stopProcessing = true;
return rule;
});
logger.aiLog("aiRules updated from storage change", {debug: true}, aiRules);
}
});
} catch (err) {
logger.aiLog("failed to load config", {level: 'error'}, err);
}
logger.aiLog("background.js loaded ready to classify", {debug: true});
if (browser.messageDisplayAction) {
browser.messageDisplayAction.setTitle({ title: "Classify" });
if (browser.messageDisplayAction.setLabel) {
browser.messageDisplayAction.setLabel({ label: "Classify" });
}
}
if (browser.messageDisplayScripts?.registerScripts) {
try {
await browser.messageDisplayScripts.registerScripts([
{ js: [browser.runtime.getURL("resources/clearCacheButton.js")] }
]);
} catch (e) {
logger.aiLog("failed to register message display script", { level: 'warn' }, e);
}
}
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_action"],
});
browser.menus.create({
id: "clear-ai-cache-list",
title: "Clear AI Cache",
contexts: ["message_list"],
});
browser.menus.create({
id: "clear-ai-cache-display",
title: "Clear AI Cache",
contexts: ["message_display_action"],
});
if (browser.messageDisplayAction) {
browser.messageDisplayAction.onClicked.addListener(async (tab) => {
try {
const msgs = await browser.messageDisplay.getDisplayedMessages(tab.id);
const ids = msgs.map(m => m.id);
await applyAiRules(ids);
} catch (e) {
logger.aiLog("failed to apply AI rules from action", { level: 'error' }, e);
}
});
}
browser.menus.onClicked.addListener(async info => {
if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") {
const ids = info.selectedMessages?.messages?.map(m => m.id) ||
(info.messageId ? [info.messageId] : []);
await applyAiRules(ids);
} else if (info.menuItemId === "clear-ai-cache-list" || info.menuItemId === "clear-ai-cache-display") {
const ids = info.selectedMessages?.messages?.map(m => m.id) ||
(info.messageId ? [info.messageId] : []);
await clearCacheForMessages(ids);
}
});
// Listen for messages from UI/devtools
browser.runtime.onMessage.addListener(async (msg) => {
logger.aiLog("onMessage received", {debug: true}, msg);
if (msg?.type === "aiFilter:test") {
const { text = "", criterion = "" } = msg;
logger.aiLog("aiFilter:test text", {debug: true}, text);
logger.aiLog("aiFilter:test criterion", {debug: true}, criterion);
try {
logger.aiLog("Calling AiClassifier.classifyText()", {debug: true});
const result = await AiClassifier.classifyText(text, criterion);
logger.aiLog("classify() returned", {debug: true}, result);
return { match: result };
}
catch (err) {
logger.aiLog("Error in classify()", {level: 'error'}, err);
// rethrow so the caller sees the failure
throw err;
}
} else if (msg?.type === "sortana:clearCacheForDisplayed") {
try {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
const tabId = tabs[0]?.id;
const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : [];
const ids = msgs.map(m => m.id);
await clearCacheForMessages(ids);
} catch (e) {
logger.aiLog("failed to clear cache from message script", { level: 'error' }, e);
}
} else {
logger.aiLog("Unknown message type, ignoring", {level: 'warn'}, msg?.type);
}
});
// Automatically classify new messages
if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
messenger.messages.onNewMailReceived.addListener(async (folder, messages) => {
logger.aiLog("onNewMailReceived", {debug: true}, messages);
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' });
}
// Catch any unhandled rejections
window.addEventListener("unhandledrejection", ev => {
logger.aiLog("Unhandled promise rejection", {level: 'error'}, ev.reason);
});
browser.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === "install") {
await browser.runtime.openOptionsPage();
}
});
})();