diff --git a/_locales/en-US/messages.json b/_locales/en-US/messages.json
new file mode 100644
index 0000000..02b6d40
--- /dev/null
+++ b/_locales/en-US/messages.json
@@ -0,0 +1,8 @@
+{
+ "classification": { "message": "AI classification" },
+ "matches": { "message": "matches" },
+ "doesntMatch": { "message": "doesn't match" },
+ "options.title": { "message": "AI Filter Options" },
+ "options.endpoint": { "message": "Endpoint" },
+ "options.save": { "message": "Save" }
+}
diff --git a/ai-filter.sln b/ai-filter.sln
new file mode 100644
index 0000000..619e643
--- /dev/null
+++ b/ai-filter.sln
@@ -0,0 +1,64 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.12.35707.178
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}"
+ ProjectSection(SolutionItems) = preProject
+ background.js = background.js
+ build-xpi.ps1 = build-xpi.ps1
+ manifest.json = manifest.json
+ EndProjectSection
+ ProjectSection(FolderGlobals) = preProject
+ Q_5_4Users_4Jordan_4Documents_4Gitea_4thunderbird-ai-filter_4src_4manifest_1json__JsonSchema =
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "experiment", "experiment", "{F2C8C786-FA23-4B63-934C-8CAA39D9BF95}"
+ ProjectSection(SolutionItems) = preProject
+ experiment\api.js = experiment\api.js
+ experiment\schema.json = experiment\schema.json
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "options", "options", "{7372FCA6-B0BC-4968-A748-7ABB17A7929A}"
+ ProjectSection(SolutionItems) = preProject
+ options\options.html = options\options.html
+ options\options.js = options\options.js
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{75ED3C1E-D3C7-4546-9F2E-AC85859DDF4B}"
+ ProjectSection(SolutionItems) = preProject
+ modules\ExpressionSearchFilter.jsm = modules\ExpressionSearchFilter.jsm
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_locales", "_locales", "{D446E5C6-BDDE-4091-BD1A-EC57170003CF}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "en-US", "en-US", "{8BEA7793-3336-40ED-AB96-7FFB09FEB0F6}"
+ ProjectSection(SolutionItems) = preProject
+ _locales\en-US\messages.json = _locales\en-US\messages.json
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DomContentScript", "DomContentScript", "{B334FFB0-4BD2-496E-BDC4-786620E019DA}"
+ ProjectSection(SolutionItems) = preProject
+ experiment\DomContentScript\implementation.js = experiment\DomContentScript\implementation.js
+ experiment\DomContentScript\schema.json = experiment\DomContentScript\schema.json
+ EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "content", "content", "{028FDA4B-AC3E-4A0E-9291-978E213F9B78}"
+ ProjectSection(SolutionItems) = preProject
+ content\filterEditor.js = content\filterEditor.js
+ EndProjectSection
+EndProject
+Global
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {F2C8C786-FA23-4B63-934C-8CAA39D9BF95} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
+ {7372FCA6-B0BC-4968-A748-7ABB17A7929A} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
+ {75ED3C1E-D3C7-4546-9F2E-AC85859DDF4B} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
+ {D446E5C6-BDDE-4091-BD1A-EC57170003CF} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
+ {8BEA7793-3336-40ED-AB96-7FFB09FEB0F6} = {D446E5C6-BDDE-4091-BD1A-EC57170003CF}
+ {B334FFB0-4BD2-496E-BDC4-786620E019DA} = {F2C8C786-FA23-4B63-934C-8CAA39D9BF95}
+ {028FDA4B-AC3E-4A0E-9291-978E213F9B78} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
+ EndGlobalSection
+EndGlobal
diff --git a/background.js b/background.js
new file mode 100644
index 0000000..7508a17
--- /dev/null
+++ b/background.js
@@ -0,0 +1,69 @@
+/*
+ * 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 Thunderbird’s 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";
+
+// Startup
+console.log("[ai-filter] background.js loaded – ready to classify");
+(async () => {
+ try {
+ const store = await browser.storage.local.get(["endpoint"]);
+ await browser.aiFilter.initConfig(store);
+ console.log("[ai-filter] configuration loaded", store);
+ try {
+ await browser.DomContentScript.registerWindow(
+ "chrome://messenger/content/FilterEditor.xhtml",
+ "resource://aifilter/content/filterEditor.js"
+ );
+ console.log("[ai-filter] registered FilterEditor content script");
+ } catch (e) {
+ console.error("[ai-filter] failed to register content script", e);
+ }
+ } catch (err) {
+ console.error("[ai-filter] failed to load config:", err);
+ }
+})();
+
+// Listen for messages from UI/devtools
+browser.runtime.onMessage.addListener((msg) => {
+ console.log("[ai-filter] onMessage received:", msg);
+
+ if (msg?.type === "aiFilter:test") {
+ const { text = "", criterion = "" } = msg;
+ console.log("[ai-filter] aiFilter:test – text:", text);
+ console.log("[ai-filter] aiFilter:test – criterion:", criterion);
+
+ try {
+ console.log("[ai-filter] Calling browser.aiFilter.classify()");
+ const result = browser.aiFilter.classify(text, criterion);
+ console.log("[ai-filter] classify() returned:", result);
+ return { match: result };
+ }
+ catch (err) {
+ console.error("[ai-filter] Error in classify():", err);
+ // rethrow so the caller sees the failure
+ throw err;
+ }
+ }
+ else {
+ console.warn("[ai-filter] Unknown message type, ignoring:", msg?.type);
+ }
+});
+
+// Catch any unhandled rejections
+window.addEventListener("unhandledrejection", ev => {
+ console.error("[ai-filter] Unhandled promise rejection:", ev.reason);
+});
+
+browser.runtime.onInstalled.addListener(async ({ reason }) => {
+ if (reason === "install") {
+ await browser.runtime.openOptionsPage();
+ }
+});
diff --git a/build-xpi.ps1 b/build-xpi.ps1
new file mode 100644
index 0000000..92a9474
--- /dev/null
+++ b/build-xpi.ps1
@@ -0,0 +1,75 @@
+<#
+.SYNOPSIS
+ Bullet-proof packager: uses .NET ZipFile to preserve folders.
+
+.DESCRIPTION
+ • Reads version from manifest.json (no comments allowed)
+ • Gathers all files under the project (excludes .sln, .ps1, release/, .vs/, .git/)
+ • Creates a .zip with each entry’s name set to its relative path
+ • Renames .zip → .xpi
+#>
+
+# 1) Locate
+$ScriptDir = Split-Path $MyInvocation.MyCommand.Path
+$ReleaseDir = Join-Path $ScriptDir 'release'
+$Manifest = Join-Path $ScriptDir 'manifest.json'
+
+# 2) Prep release folder
+if (-not (Test-Path $ReleaseDir)) {
+ New-Item -ItemType Directory -Path $ReleaseDir | Out-Null
+}
+
+# 3) Read manifest.json (must be pure JSON)
+$version = (Get-Content $Manifest -Raw | ConvertFrom-Json).version
+if (-not $version) {
+ Write-Error "No version found in manifest.json"; exit 1
+}
+
+# 4) Define output names & clean up
+$xpiName = "ai-filter-$version.xpi"
+$zipPath = Join-Path $ReleaseDir "ai-filter-$version.zip"
+$xpiPath = Join-Path $ReleaseDir $xpiName
+
+Remove-Item -Path $zipPath,$xpiPath -Force -ErrorAction SilentlyContinue
+
+# 5) Collect files to include
+$allFiles = Get-ChildItem -Path $ScriptDir -Recurse -File |
+ Where-Object {
+ $_.Extension -notin '.sln','.ps1' -and
+ $_.FullName -notmatch '\\release\\' -and
+ $_.FullName -notmatch '\\.vs\\' -and
+ $_.FullName -notmatch '\\.git\\'
+ }
+
+foreach ($file in $allFiles) {
+ $size = (Get-Item $file.FullName).Length
+ Write-Host "Zipping: $entryName ← $($file.FullName) ($size bytes)"
+}
+
+if ($allFiles.Count -eq 0) {
+ Write-Warning "No files found to package."; exit 0
+}
+
+# 6) Load .NET ZipFile
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+
+# 7) Create zip and add each file with its relative path
+$zip = [System.IO.Compression.ZipFile]::Open($zipPath, 'Create')
+foreach ($file in $allFiles) {
+ # Compute entry name (relative, forward-slashed)
+ $rel = $file.FullName.Substring($ScriptDir.Length + 1).TrimStart('\')
+ $entryName = $rel.Replace('\', '/')
+
+ [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
+ $zip,
+ $file.FullName,
+ $entryName,
+ [System.IO.Compression.CompressionLevel]::Optimal
+ )
+}
+$zip.Dispose()
+
+# 8) Rename zip → xpi
+Rename-Item -Path $zipPath -NewName $xpiName -Force
+
+Write-Host "✅ Built XPI at: $xpiPath"
diff --git a/content/filterEditor.js b/content/filterEditor.js
new file mode 100644
index 0000000..cbd56bd
--- /dev/null
+++ b/content/filterEditor.js
@@ -0,0 +1,50 @@
+(function() {
+ function patch(container) {
+ if (!container || container.getAttribute("ai-filter-patched") === "true") {
+ return;
+ }
+ while (container.firstChild) {
+ container.firstChild.remove();
+ }
+ let frag = window.MozXULElement.parseXULToFragment(
+ `
+ `
+ );
+ container.appendChild(frag);
+ if (container.hasAttribute("value")) {
+ container.firstChild.value = container.getAttribute("value");
+ }
+ container.classList.add("flexelementcontainer");
+ container.setAttribute("ai-filter-patched", "true");
+ }
+
+ function check(node) {
+ if (!(node instanceof Element)) {
+ return;
+ }
+ if (
+ node.classList.contains("search-value-custom") &&
+ node.getAttribute("searchAttribute") === "aifilter#classification"
+ ) {
+ patch(node);
+ }
+ node
+ .querySelectorAll('.search-value-custom[searchAttribute="aifilter#classification"]')
+ .forEach(patch);
+ }
+
+ const observer = new MutationObserver(mutations => {
+ for (let mutation of mutations) {
+ if (mutation.type === "childList") {
+ mutation.addedNodes.forEach(check);
+ } else if (mutation.type === "attributes") {
+ check(mutation.target);
+ }
+ }
+ });
+
+ const termList = document.getElementById("searchTermList") || document;
+ observer.observe(termList, { childList: true, attributes: true, subtree: true });
+ check(termList);
+})();
diff --git a/experiment/DomContentScript/implementation.js b/experiment/DomContentScript/implementation.js
new file mode 100644
index 0000000..541facd
--- /dev/null
+++ b/experiment/DomContentScript/implementation.js
@@ -0,0 +1,80 @@
+
+
+var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+var DomContent_ESM = parseInt(AppConstants.MOZ_APP_VERSION, 10) >= 128;
+
+var { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+
+var { ExtensionUtils } = DomContent_ESM
+ ? ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs")
+ : ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+
+var { ExtensionError } = ExtensionUtils;
+
+var registeredWindows = new Map();
+
+var DomContentScript = class extends ExtensionCommon.ExtensionAPI {
+ constructor(extension) {
+ super(extension);
+
+ this._windowListener = {
+ // nsIWindowMediatorListener functions
+ onOpenWindow(appWindow) {
+ // A new window has opened.
+ let domWindow = appWindow.docShell.domWindow;
+
+ /**
+ * Set up listeners to run the callbacks on the given window.
+ *
+ * @param aWindow {nsIDOMWindow} The window to set up.
+ * @param aID {String} Optional. ID of the new caller that has registered right now.
+ */
+ domWindow.addEventListener(
+ "DOMContentLoaded",
+ function() {
+ // do stuff
+ let windowChromeURL = domWindow.document.location.href;
+ if (registeredWindows.has(windowChromeURL)) {
+ let jsPath = registeredWindows.get(windowChromeURL);
+ Services.scriptloader.loadSubScript(jsPath, domWindow, "UTF-8");
+ }
+ },
+ { once: true }
+ );
+ },
+
+ onCloseWindow(appWindow) {
+ // One of the windows has closed.
+ let domWindow = appWindow.docShell.domWindow; // we don't need to do anything (script only loads once)
+ },
+ };
+
+ Services.wm.addListener(this._windowListener);
+
+ }
+
+
+
+
+
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return; // the application gets unloaded anyway
+ }
+ Services.wm.removeListener(this._windowListener);
+ }
+
+ getAPI(context) {
+ /** API IMPLEMENTATION **/
+ return {
+ DomContentScript: {
+ // only returns something, if a user pref value is set
+ registerWindow: async function (windowUrl,jsPath) {
+ registeredWindows.set(windowUrl,jsPath);
+ }
+ },
+ };
+ }
+};
diff --git a/experiment/DomContentScript/schema.json b/experiment/DomContentScript/schema.json
new file mode 100644
index 0000000..32d779c
--- /dev/null
+++ b/experiment/DomContentScript/schema.json
@@ -0,0 +1,25 @@
+[
+ {
+ "namespace": "DomContentScript",
+ "functions": [
+ {
+ "name": "registerWindow",
+ "type": "function",
+ "async": true,
+ "description": "Register a script for onDOMContentLoaded",
+ "parameters": [
+ {
+ "name": "windowUrl",
+ "type": "string",
+ "description": "chrome URL of the window "
+ },
+ {
+ "name": "jsPath",
+ "type": "string",
+ "description": "chrome URL of the script"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/experiment/api.js b/experiment/api.js
new file mode 100644
index 0000000..eb056cf
--- /dev/null
+++ b/experiment/api.js
@@ -0,0 +1,89 @@
+var { ExtensionCommon } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs");
+var { Services } = globalThis || ChromeUtils.importESModule("resource://gre/modules/Services.sys.mjs");
+var { MailServices } = ChromeUtils.importESModule("resource:///modules/MailServices.sys.mjs");
+
+console.log("[ai-filter][api] Experiment API module loaded");
+
+var resProto = Cc["@mozilla.org/network/protocol;1?name=resource"]
+ .getService(Ci.nsISubstitutingProtocolHandler);
+
+function registerResourceUrl(extension, namespace) {
+ console.log(`[ai-filter][api] registerResourceUrl called for namespace="${namespace}"`);
+ if (resProto.hasSubstitution(namespace)) {
+ console.log(`[ai-filter][api] namespace="${namespace}" already registered, skipping`);
+ return;
+ }
+ let uri = Services.io.newURI(".", null, extension.rootURI);
+ console.log(`[ai-filter][api] setting substitution for "${namespace}" → ${uri.spec}`);
+ resProto.setSubstitutionWithFlags(namespace, uri, resProto.ALLOW_CONTENT_ACCESS);
+}
+
+var gTerm;
+var AIFilterMod;
+
+var aiFilter = class extends ExtensionCommon.ExtensionAPI {
+ async onStartup() {
+ console.log("[ai-filter][api] onStartup()");
+ let { extension } = this;
+
+ registerResourceUrl(extension, "aifilter");
+
+
+ try {
+ console.log("[ai-filter][api] importing ExpressionSearchFilter.jsm");
+ AIFilterMod = ChromeUtils.import("resource://aifilter/modules/ExpressionSearchFilter.jsm");
+ console.log("[ai-filter][api] ExpressionSearchFilter.jsm import succeeded");
+ }
+ catch (err) {
+ console.error("[ai-filter][api] failed to import ExpressionSearchFilter.jsm:", err);
+ }
+ }
+
+ onShutdown(isAppShutdown) {
+ console.log("[ai-filter][api] onShutdown(), isAppShutdown =", isAppShutdown);
+ if (!isAppShutdown && resProto.hasSubstitution("aifilter")) {
+ console.log("[ai-filter][api] removing substitution for namespace='aifilter'");
+ resProto.setSubstitution("aifilter", null);
+ }
+ }
+
+ getAPI(context) {
+ console.log("[ai-filter][api] getAPI()");
+ return {
+ aiFilter: {
+ initConfig: async (config) => {
+ try {
+ if (AIFilterMod?.AIFilter?.setConfig) {
+ AIFilterMod.AIFilter.setConfig(config);
+ console.log("[ai-filter][api] configuration applied", config);
+ }
+ } catch (err) {
+ console.error("[ai-filter][api] failed to apply config:", err);
+ }
+ },
+ classify: (msg) => {
+ console.log("[ai-filter][api] classify() called with msg:", msg);
+ try {
+ if (!gTerm) {
+ console.log("[ai-filter][api] instantiating new ClassificationTerm");
+ let mod = AIFilterMod || ChromeUtils.import("resource://aifilter/modules/ExpressionSearchFilter.jsm");
+ gTerm = new mod.ClassificationTerm();
+ }
+ console.log("[ai-filter][api] calling gTerm.match()");
+ let matchResult = gTerm.match(
+ msg.msgHdr,
+ msg.value,
+ Ci.nsMsgSearchOp.Contains
+ );
+ console.log("[ai-filter][api] gTerm.match() returned:", matchResult);
+ return matchResult;
+ }
+ catch (err) {
+ console.error("[ai-filter][api] error in classify():", err);
+ throw err;
+ }
+ }
+ }
+ };
+ }
+};
diff --git a/experiment/schema.json b/experiment/schema.json
new file mode 100644
index 0000000..991abc4
--- /dev/null
+++ b/experiment/schema.json
@@ -0,0 +1,25 @@
+[
+ {
+ "namespace": "aiFilter",
+ "functions": [
+ {
+ "name": "initConfig",
+ "type": "function",
+ "async": true,
+ "parameters": [
+ { "name": "config", "type": "any" }
+ ]
+ },
+ {
+ "name": "classify",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "msg",
+ "type": "any"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..c881f06
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,32 @@
+{
+ "manifest_version": 2,
+ "name": "AI Filter",
+ "version": "0.1",
+ "default_locale": "en-US",
+ "applications": { "gecko": { "id": "ai-filter@example" } },
+ "background": { "scripts": [ "background.js" ] },
+ "experiment_apis": {
+ "aiFilter": {
+ "schema": "experiment/schema.json",
+ "parent": {
+ "scopes": [ "addon_parent" ],
+ "paths": [ [ "aiFilter" ] ],
+ "script": "experiment/api.js",
+ "events": [ "startup" ]
+ }
+ },
+ "DomContentScript": {
+ "schema": "experiment/DomContentScript/schema.json",
+ "parent": {
+ "scopes": [ "addon_parent" ],
+ "paths": [ [ "DomContentScript" ] ],
+ "script": "experiment/DomContentScript/implementation.js"
+ }
+ }
+ },
+ "options_ui": {
+ "page": "options/options.html",
+ "open_in_tab": false
+ },
+ "permissions": [ "storage" ]
+}
diff --git a/modules/ExpressionSearchFilter.jsm b/modules/ExpressionSearchFilter.jsm
new file mode 100644
index 0000000..1c04e2d
--- /dev/null
+++ b/modules/ExpressionSearchFilter.jsm
@@ -0,0 +1,175 @@
+"use strict";
+var { ExtensionParent } = ChromeUtils.importESModule("resource://gre/modules/ExtensionParent.sys.mjs");
+var { MailServices } = ChromeUtils.importESModule("resource:///modules/MailServices.sys.mjs");
+var { Services } = globalThis || ChromeUtils.importESModule("resource://gre/modules/Services.sys.mjs");
+var { NetUtil } = ChromeUtils.importESModule("resource://gre/modules/NetUtil.sys.mjs");
+var { MimeParser } = ChromeUtils.importESModule("resource:///modules/mimeParser.sys.mjs");
+
+var EXPORTED_SYMBOLS = ["AIFilter", "ClassificationTerm"];
+
+class CustomerTermBase {
+ constructor(nameId, operators) {
+ this.extension = ExtensionParent.GlobalManager.getExtension("ai-filter@example");
+ this.id = "aifilter#" + nameId;
+ this.name = this.extension.localeData.localizeMessage(nameId);
+ this.operators = operators;
+ this.cache = new Map();
+
+ console.log(`[ai-filter][ExpressionSearchFilter] Initialized term base "${this.id}"`);
+ }
+
+ getEnabled() {
+ console.log(`[ai-filter][ExpressionSearchFilter] getEnabled() called on "${this.id}"`);
+ return true;
+ }
+
+ getAvailable() {
+ console.log(`[ai-filter][ExpressionSearchFilter] getAvailable() called on "${this.id}"`);
+ return true;
+ }
+
+ getAvailableOperators() {
+ console.log(`[ai-filter][ExpressionSearchFilter] getAvailableOperators() called on "${this.id}"`);
+ return this.operators;
+ }
+
+ getAvailableValues() {
+ console.log(`[ai-filter][ExpressionSearchFilter] getAvailableValues() called on "${this.id}"`);
+ return null;
+ }
+
+ get attrib() {
+ console.log(`[ai-filter][ExpressionSearchFilter] attrib getter called for "${this.id}"`);
+
+ //return Ci.nsMsgSearchAttrib.Custom;
+ }
+}
+
+function getPlainText(msgHdr) {
+ console.log(`[ai-filter][ExpressionSearchFilter] Extracting plain text for message ID ${msgHdr.messageId}`);
+ let folder = msgHdr.folder;
+ if (!folder.getMsgInputStream) return "";
+ let reusable = {};
+ let stream = folder.getMsgInputStream(msgHdr, reusable);
+ let data = NetUtil.readInputStreamToString(stream, msgHdr.messageSize);
+ if (!reusable.value) stream.close();
+ let parser = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils);
+ return parser.convertToPlainText(data,
+ Ci.nsIDocumentEncoder.OutputLFLineBreak |
+ Ci.nsIDocumentEncoder.OutputNoScriptContent |
+ Ci.nsIDocumentEncoder.OutputNoFramesContent |
+ Ci.nsIDocumentEncoder.OutputBodyOnly, 0);
+}
+
+let gEndpoint = "http://127.0.0.1:5000/v1/classify";
+function setConfig(config = {}) {
+ if (config.endpoint) {
+ gEndpoint = config.endpoint;
+ }
+ console.log(`[ai-filter][ExpressionSearchFilter] Endpoint set to ${gEndpoint}`);
+}
+
+function buildPrompt(body, criterion) {
+ console.log(`[ai-filter][ExpressionSearchFilter] Building prompt with criterion: "${criterion}"`);
+ return `<|im_start|>system
+You are an email-classification assistant.
+Read the email below and the classification criterion provided by the user.
+
+Return ONLY a JSON object on a single line of the form:
+{"match": true} - if the email satisfies the criterion
+{"match": false} - otherwise
+
+Do not add any other keys, text, or formatting.<|im_end|>
+<|im_start|>user
+**Email Contents**
+\`\`\`
+${body}
+\`\`\`
+Classification Criteria: ${criterion}<|im_end|>
+<|im_start|>assistant`;
+}
+
+class ClassificationTerm extends CustomerTermBase {
+ constructor() {
+ super("classification", [Ci.nsMsgSearchOp.Matches, Ci.nsMsgSearchOp.DoesntMatch]);
+ console.log(`[ai-filter][ExpressionSearchFilter] ClassificationTerm constructed`);
+ }
+
+ needsBody() { return true; }
+
+ match(msgHdr, value, op) {
+ const opName = op === Ci.nsMsgSearchOp.Matches ? "matches" :
+ op === Ci.nsMsgSearchOp.DoesntMatch ? "doesn't match" : `unknown (${op})`;
+ console.log(`[ai-filter][ExpressionSearchFilter] Matching message ${msgHdr.messageId} using op "${opName}" and value "${value}"`);
+
+ let key = msgHdr.messageId + "|" + op + "|" + value;
+ if (this.cache.has(key)) {
+ console.log(`[ai-filter][ExpressionSearchFilter] Cache hit for key: ${key}`);
+ return this.cache.get(key);
+ }
+
+ let body = getPlainText(msgHdr);
+ let payload = JSON.stringify({
+ prompt: buildPrompt(body, value),
+ max_tokens: 4096,
+ temperature: 1.31,
+ top_p: 1,
+ seed: -1,
+ repetition_penalty: 1.0,
+ top_k: 0,
+ min_p: 0.2,
+ presence_penalty: 0,
+ frequency_penalty: 0,
+ typical_p: 1,
+ tfs: 1
+ });
+
+
+ console.log(`[ai-filter][ExpressionSearchFilter] Sending classification request to ${gEndpoint}`);
+
+ let matched = false;
+ try {
+ let xhr = new XMLHttpRequest();
+ xhr.open("POST", gEndpoint, false); // synchronous request
+ xhr.setRequestHeader("Content-Type", "application/json");
+ xhr.send(payload);
+
+ if (xhr.status < 200 || xhr.status >= 300) {
+ console.warn(`[ai-filter][ExpressionSearchFilter] HTTP status ${xhr.status}`);
+ } else {
+ const result = JSON.parse(xhr.responseText);
+ const rawText = result.choices?.[0]?.text || "";
+ const cleanedText = rawText.replace(/[\s\S]*?<\/think>/gi, "").trim();
+ const obj = JSON.parse(cleanedText);
+ matched = obj.matched === true || obj.match === true;
+ console.log(`[ai-filter][ExpressionSearchFilter] Received response:`, result);
+
+ console.log(`[ai-filter][ExpressionSearchFilter] Caching:`, key);
+ this.cache.set(key, matched);
+ }
+ } catch (e) {
+ console.error(`[ai-filter][ExpressionSearchFilter] HTTP request failed:`, e);
+ }
+
+ if (op === Ci.nsMsgSearchOp.DoesntMatch) {
+ matched = !matched;
+ console.log(`[ai-filter][ExpressionSearchFilter] Operator is "doesn't match" → inverting to ${matched}`);
+ }
+
+ console.log(`[ai-filter][ExpressionSearchFilter] Final match result: ${matched}`);
+ return matched;
+ }
+}
+
+(function register() {
+ console.log(`[ai-filter][ExpressionSearchFilter] Registering custom filter term...`);
+ let term = new ClassificationTerm();
+ if (!MailServices.filters.getCustomTerm(term.id)) {
+ MailServices.filters.addCustomTerm(term);
+ console.log(`[ai-filter][ExpressionSearchFilter] Registered term: ${term.id}`);
+ } else {
+ console.log(`[ai-filter][ExpressionSearchFilter] Term already registered: ${term.id}`);
+ }
+})();
+
+var AIFilter = { setConfig };
diff --git a/options/options.html b/options/options.html
new file mode 100644
index 0000000..cddd30a
--- /dev/null
+++ b/options/options.html
@@ -0,0 +1,12 @@
+
+
+
+
+ AI Filter Options
+
+
+
+
+
+
+
diff --git a/options/options.js b/options/options.js
new file mode 100644
index 0000000..8c03ef8
--- /dev/null
+++ b/options/options.js
@@ -0,0 +1,14 @@
+document.addEventListener('DOMContentLoaded', async () => {
+ let { endpoint = 'http://127.0.0.1:5000/v1/classify' } = await browser.storage.local.get(['endpoint']);
+ document.getElementById('endpoint').value = endpoint;
+});
+
+document.getElementById('save').addEventListener('click', async () => {
+ const endpoint = document.getElementById('endpoint').value;
+ await browser.storage.local.set({ endpoint });
+ try {
+ await browser.aiFilter.initConfig({ endpoint });
+ } catch (e) {
+ console.error('[ai-filter][options] failed to apply config', e);
+ }
+});