Compare commits

..

53 commits

Author SHA1 Message Date
c622c07c66 Supporting v140+
After testing in Betterbird v140, updating manifest.
2025-08-18 19:39:35 -05:00
33003365fb
Merge pull request #111 from wagesj45/codex/capture-unaltered-message-body-in-background.js
Capture raw email text for debug view
2025-07-19 22:51:24 -05:00
9cad2674e3 Capture raw message text for debug 2025-07-19 22:44:04 -05:00
d3ee2f128f
Merge pull request #110 from wagesj45/codex/reorganize-debug-options-and-improve-diff-display
Fix debug tab controls
2025-07-19 21:58:50 -05:00
bcac4ad017 Improve debug tab layout 2025-07-19 21:58:37 -05:00
d1ddfc4ad8
Merge pull request #109 from wagesj45/codex/find-real-time-updates-for-diff-view
Improve debug tab updates
2025-07-19 19:52:14 -05:00
841a697c69 Update debug tab with live refresh 2025-07-19 19:46:44 -05:00
45125f634d
Merge pull request #108 from wagesj45/codex/add-diff-view-to-debug-tab
Add debug diff view
2025-07-19 19:33:13 -05:00
55ccf083c8 Add debug diff view 2025-07-19 19:32:36 -05:00
d2de1818ca Adding diff library 2025-07-19 19:24:47 -05:00
587f070886
Merge pull request #107 from wagesj45/codex/add-debug-tab-to-options-page
Add debug tab to options
2025-07-19 19:06:15 -05:00
db7ce49a5b Add debug tab with payload viewer 2025-07-19 19:03:37 -05:00
3d5050ded0
Merge pull request #106 from wagesj45/codex/remove-context-length-option-and-adjust-truncation
Update context trimming logic
2025-07-19 18:42:57 -05:00
3391905254 Remove context length option 2025-07-19 18:42:39 -05:00
91b8d15573 updated icons 2025-07-19 17:33:59 -05:00
e629cc6518
Merge pull request #105 from wagesj45/codex/implement-token-reduction-strategies
Implement advanced token reduction options
2025-07-19 17:33:34 -05:00
183ca8f355 Add token reduction options and context length cap 2025-07-19 17:31:03 -05:00
f56057c042 updated manifest to new version for bug fixes. 2025-07-16 20:56:55 -05:00
66a1d59971
Merge pull request #104 from wagesj45/codex/fix-api-endpoint-not-updating
Fix runtime update of API endpoint
2025-07-16 19:43:42 -05:00
d3b16640b9 Handle endpoint changes without restart 2025-07-16 19:43:24 -05:00
83a718dcfa
Merge pull request #103 from wagesj45/codex/investigate-browser.notifications-undefined-error
Add notifications permission
2025-07-16 19:19:46 -05:00
598e74f8cb Add notifications permission 2025-07-16 19:18:21 -05:00
32c8a0f415 Update AGENTS.md
Adding direction to skip running the build scripts.
2025-07-16 02:22:44 -05:00
8f5496f457
Merge pull request #102 from wagesj45/codex/fix-bug-in-rule-editor-save-logic
Fix rule toggle persistence and spacing
2025-07-16 00:55:55 -05:00
1432690fa8 Fix rule condition toggles and add divider 2025-07-16 00:55:44 -05:00
fcee9a4e8a
Merge pull request #101 from wagesj45/codex/add-visual-queue-for-button-selection
Improve rule option button clarity
2025-07-16 00:40:32 -05:00
20dfba8406 Add active state icons for rule condition buttons 2025-07-16 00:40:03 -05:00
5786049950 Updated icons 2025-07-16 00:32:25 -05:00
bed1bbfd70
Merge pull request #100 from wagesj45/codex/wrap-controls-into-hidden-sections-with-buttons
Add toggle buttons for rule conditions
2025-07-16 00:18:06 -05:00
a56b79dcea Add condition toggle buttons to rule editor 2025-07-16 00:17:53 -05:00
bd050532ec
Merge pull request #99 from wagesj45/codex/fix-visual-bug-in-rule-editor-button
Fix rule toggle button style
2025-07-16 00:04:24 -05:00
f156a653ef Fix rule toggle button style 2025-07-16 00:04:11 -05:00
11db14545c
Merge pull request #98 from wagesj45/codex/redesign-delete-and-enable/disable-buttons
Update rule header controls with icons
2025-07-15 23:52:28 -05:00
f33c27e5b9
Merge branch 'main' into codex/redesign-delete-and-enable/disable-buttons 2025-07-15 23:52:21 -05:00
0cddb5b204 Use SVG icons for rule controls and remove extra PNGs 2025-07-15 23:50:49 -05:00
8bfd00dc49 Added svg 2025-07-15 23:44:43 -05:00
83b890a419
Merge pull request #97 from wagesj45/codex/update-error-handling-in-background.js
Add persistent error indicator
2025-07-15 23:17:11 -05:00
65ff743676 Persist error icon and add notification 2025-07-15 23:16:43 -05:00
39ffa288fa
Merge pull request #96 from wagesj45/codex/add-enabled-checkbox-to-rules-ui
Add enabled toggle for rules
2025-07-15 23:08:37 -05:00
fa84f37637 Add rule enable toggle 2025-07-15 23:07:55 -05:00
5cc897a668
Merge pull request #95 from wagesj45/codex/implement-multi-select-for-account-and-folder-lists
Add account and folder rule filters
2025-07-15 22:59:03 -05:00
cd0a31ed98 Add account and folder filters for rules 2025-07-15 22:58:27 -05:00
1c3ced5134
Merge pull request #94 from wagesj45/q14u8h-codex/add-min/max-age-inputs-to-rules
Implement message age filters
2025-07-15 22:51:21 -05:00
6fd6da8a12 Add age filters to rules 2025-07-15 22:50:50 -05:00
0bd397560d
Merge pull request #93 from wagesj45/codex/add-forward-and-reply-actions
Add forward and reply actions
2025-07-15 22:45:04 -05:00
9a2ecb0162 Add forward and reply rule actions 2025-07-15 22:44:47 -05:00
52ea120534
Merge pull request #92 from wagesj45/codex/add-delete-and-archive-actions
Add delete and archive actions
2025-07-15 22:39:10 -05:00
fa62afa50e Add delete and archive rule actions 2025-07-15 22:38:53 -05:00
d992ad9c55
Merge pull request #91 from wagesj45/codex/add-copy-option-to-action-selector
Enable copy rule action
2025-07-15 22:34:04 -05:00
5c309c9b69 Add copy action support 2025-07-15 22:33:46 -05:00
23c067e2e7
Merge pull request #90 from wagesj45/codex/add-unread-message-option-to-rules
Add unread-only rule option
2025-07-15 22:12:52 -05:00
85224f2d4d Add unread-only option for rules 2025-07-15 22:12:35 -05:00
3eef24d2dd
Merge pull request #89 from wagesj45/codex/extend-action-selector-with-read-and-flag
Add read and flag rule actions
2025-07-15 21:57:22 -05:00
31 changed files with 2915 additions and 64 deletions

View file

@ -28,7 +28,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje
## Testing ## Testing
There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. Do **not** run the `ps1` build script or the SVG processing script.
## Documentation ## Documentation

View file

@ -16,11 +16,15 @@ message meets a specified criterion.
- **Advanced parameters** tune generation settings like temperature, topp and more from the options page. - **Advanced parameters** tune generation settings like temperature, topp and more from the options page.
- **Markdown conversion** optionally convert HTML bodies to Markdown before sending them to the AI service. - **Markdown conversion** optionally convert HTML bodies to Markdown before sending them to the AI service.
- **Debug logging** optional colorized logs help troubleshoot interactions with the AI service. - **Debug logging** optional colorized logs help troubleshoot interactions with the AI service.
- **Debug tab** view the last request payload and a diff between the unaltered message text and the final prompt.
- **Light/Dark themes** automatically match Thunderbird's appearance with optional manual override. - **Light/Dark themes** automatically match Thunderbird's appearance with optional manual override.
- **Automatic rules** create rules that tag, move, mark read/unread or flag/unflag messages based on AI classification. - **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. - **Rule ordering** drag rules to prioritize them and optionally stop processing after a match.
- **Rule enable/disable** temporarily turn a rule off without removing it.
- **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. - **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. - **Status icons** toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red until you dismiss the notification.
- **Error notification** failed classification displays a notification with a button to clear the error and reset the icon.
- **View reasoning** inspect why rules matched via the Details popup. - **View reasoning** inspect why rules matched via the Details popup.
- **Cache management** clear cached results from the context menu or options page. - **Cache management** clear cached results from the context menu or options page.
- **Queue & timing stats** monitor processing time on the Maintenance tab. - **Queue & timing stats** monitor processing time on the Maintenance tab.
@ -69,11 +73,19 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e
## Usage ## Usage
1. Open the add-on's options and set the URL of your classification service. 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 or moving a message when it matches. Drag rules to actions such as tagging, moving, copying, forwarding, replying,
reorder them and check *Stop after match* to halt further processing. 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, select the accounts or
folders a rule should apply to. Use the
slashed-circle/check button to disable or re-enable a rule. The small
circle buttons for optional conditions show a filled dot when active, 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 3. Save your settings. New mail will be evaluated automatically using the
configured rules. configured rules.
4. If the toolbar icon shows a red X, click the notification's **Dismiss** button to clear the error.
### Example Filters ### Example Filters
@ -101,7 +113,7 @@ Here are some useful and fun example criteria you can use in your filters. Filte
For when you're ready to filter based on vibes. For when you're ready to filter based on vibes.
You can define as many filters as you'd like, each using a different prompt and You can define as many filters as you'd like, each using a different prompt and
triggering tags, moves, read/unread changes or flag updates based on the model's classification. triggering tags, moves, copies, deletes, archives, read/unread changes or flag updates based on the model's classification.
## Required Permissions ## Required Permissions
@ -109,12 +121,14 @@ Sortana requests the following Thunderbird permissions:
- `storage` store configuration and cached classification results. - `storage` store configuration and cached classification results.
- `messagesRead` read message contents for classification. - `messagesRead` read message contents for classification.
- `messagesMove` move messages when a rule specifies a target folder. - `messagesMove` move or copy messages when a rule specifies a target folder.
- `messagesUpdate` change message properties such as tags, junk status, read/unread state and flags. - `messagesUpdate` change message properties such as tags, junk status, read/unread state and flags.
- `messagesTagsList` retrieve existing message tags for rule actions. - `messagesTagsList` retrieve existing message tags for rule actions.
- `accountsRead` list accounts and folders for move actions. - `accountsRead` list accounts and folders for move or copy actions.
- `menus` add context menu commands. - `menus` add context menu commands.
- `tabs` open new tabs and query the active tab. - `tabs` open new tabs and query the active tab.
- `notifications` display error notifications.
- `compose` create reply and forward compose windows for matching rules.
## Thunderbird Add-on Store Disclosures ## Thunderbird Add-on Store Disclosures
@ -127,6 +141,8 @@ uses the following third party libraries:
- MIT License - MIT License
- [turndown v7.2.0](https://github.com/mixmark-io/turndown/tree/v7.2.0) - [turndown v7.2.0](https://github.com/mixmark-io/turndown/tree/v7.2.0)
- MIT License - MIT License
- [diff](https://github.com/google/diff-match-patch/blob/62f2e689f498f9c92dbc588c58750addec9b1654/javascript/diff_match_patch_uncompressed.js)
- Apache-2.0 license
## License ## License

View file

@ -18,11 +18,20 @@
"options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" }, "options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" },
"options.stripUrlParams": { "message": "Remove URL tracking parameters" }, "options.stripUrlParams": { "message": "Remove URL tracking parameters" },
"options.altTextImages": { "message": "Replace images with alt text" }, "options.altTextImages": { "message": "Replace images with alt text" },
"options.collapseWhitespace": { "message": "Collapse long whitespace" } "options.collapseWhitespace": { "message": "Collapse long whitespace" },
"options.tokenReduction": { "message": "Aggressive token reduction" }
,"action.read": { "message": "read" } ,"action.read": { "message": "read" }
,"action.flag": { "message": "flag" } ,"action.flag": { "message": "flag" }
,"action.copy": { "message": "copy" }
,"action.delete": { "message": "delete" }
,"action.archive": { "message": "archive" }
,"action.forward": { "message": "forward" }
,"action.reply": { "message": "reply" }
,"param.markRead": { "message": "mark read" } ,"param.markRead": { "message": "mark read" }
,"param.markUnread": { "message": "mark unread" } ,"param.markUnread": { "message": "mark unread" }
,"param.flag": { "message": "flag" } ,"param.flag": { "message": "flag" }
,"param.unflag": { "message": "unflag" } ,"param.unflag": { "message": "unflag" }
,"param.address": { "message": "address" }
,"param.replyAll": { "message": "reply all" }
,"param.replySender": { "message": "reply sender" }
} }

View file

@ -108,6 +108,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-175
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{21D2A42C-3F85-465C-9141-C106AFD92B68}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{21D2A42C-3F85-465C-9141-C106AFD92B68}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
resources\js\diff_match_patch_uncompressed.js = resources\js\diff_match_patch_uncompressed.js
resources\js\turndown.js = resources\js\turndown.js resources\js\turndown.js = resources\js\turndown.js
EndProjectSection EndProjectSection
EndProject EndProject

View file

@ -26,19 +26,36 @@ let htmlToMarkdown = false;
let stripUrlParams = false; let stripUrlParams = false;
let altTextImages = false; let altTextImages = false;
let collapseWhitespace = false; let collapseWhitespace = false;
let tokenReduction = false;
let maxTokens = 4096;
let TurndownService = null; let TurndownService = null;
let userTheme = 'auto'; let userTheme = 'auto';
let currentTheme = 'light'; let currentTheme = 'light';
let detectSystemTheme; let detectSystemTheme;
let errorPending = false;
let showDebugTab = false;
const ERROR_NOTIFICATION_ID = 'sortana-error';
function normalizeRules(rules) { function normalizeRules(rules) {
return Array.isArray(rules) ? rules.map(r => { 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 = [];
r.enabled = r.enabled !== false;
return r;
}
const actions = []; const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
if (r.copyTarget || r.copyTo) actions.push({ type: 'copy', copyTarget: r.copyTarget || r.copyTo });
const rule = { criterion: r.criterion, actions }; const rule = { criterion: r.criterion, actions };
if (r.stopProcessing) rule.stopProcessing = true; if (r.stopProcessing) rule.stopProcessing = true;
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;
rule.enabled = r.enabled !== false;
return rule; return rule;
}) : []; }) : [];
} }
@ -56,7 +73,8 @@ const ICONS = {
logo: () => 'resources/img/logo.png', logo: () => 'resources/img/logo.png',
circledots: () => iconPaths('circledots'), circledots: () => iconPaths('circledots'),
circle: () => iconPaths('circle'), circle: () => iconPaths('circle'),
average: () => iconPaths('average') average: () => iconPaths('average'),
error: () => iconPaths('x')
}; };
function setIcon(path) { function setIcon(path) {
@ -70,19 +88,31 @@ function setIcon(path) {
function updateActionIcon() { function updateActionIcon() {
let path = ICONS.logo(); let path = ICONS.logo();
if (processing || queuedCount > 0) { if (errorPending) {
path = ICONS.error();
} else if (processing || queuedCount > 0) {
path = ICONS.circledots(); path = ICONS.circledots();
} }
setIcon(path); setIcon(path);
} }
function showTransientIcon(factory, delay = 1500) { function showTransientIcon(factory, delay = 1500) {
if (errorPending) {
return;
}
clearTimeout(iconTimer); clearTimeout(iconTimer);
const path = typeof factory === 'function' ? factory() : factory; const path = typeof factory === 'function' ? factory() : factory;
setIcon(path); setIcon(path);
iconTimer = setTimeout(updateActionIcon, delay); iconTimer = setTimeout(updateActionIcon, delay);
} }
async function clearError() {
errorPending = false;
await storage.local.set({ errorPending: false });
await browser.notifications.clear(ERROR_NOTIFICATION_ID);
updateActionIcon();
}
function refreshMenuIcons() { function refreshMenuIcons() {
browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') }); browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') });
browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') }); browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') });
@ -98,12 +128,16 @@ function byteSize(str) {
} }
function replaceInlineBase64(text) { function replaceInlineBase64(text) {
return text.replace(/[A-Za-z0-9+/]{100,}={0,2}/g, return text.replace(/(?:data:[^;]+;base64,)?[A-Za-z0-9+/=\r\n]{100,}/g,
m => `[base64: ${byteSize(m)} bytes]`); m => tokenReduction ? '__BASE64__' : `[base64: ${byteSize(m)} bytes]`);
} }
function sanitizeString(text) { function sanitizeString(text) {
let t = String(text); let t = String(text);
if (tokenReduction) {
t = t.replace(/<!--.*?-->/gs, '')
.replace(/url\([^\)]*\)/gi, 'url(__IMG__)');
}
if (stripUrlParams) { if (stripUrlParams) {
t = t.replace(/https?:\/\/[^\s)]+/g, m => { t = t.replace(/https?:\/\/[^\s)]+/g, m => {
const idx = m.indexOf('?'); const idx = m.indexOf('?');
@ -111,7 +145,7 @@ function sanitizeString(text) {
}); });
} }
if (collapseWhitespace) { if (collapseWhitespace) {
t = t.replace(/[ \t\u00A0]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n'); t = t.replace(/[\u200B-\u200D\u2060\s]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n');
} }
return t; return t;
} }
@ -130,12 +164,26 @@ function collectText(part, bodyParts, attachments) {
attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`); attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`);
} else if (ct.startsWith("text/html")) { } else if (ct.startsWith("text/html")) {
const doc = new DOMParser().parseFromString(body, 'text/html'); const doc = new DOMParser().parseFromString(body, 'text/html');
if (altTextImages) { if (tokenReduction) {
doc.querySelectorAll('img').forEach(img => { doc.querySelectorAll('script,style').forEach(el => el.remove());
const alt = img.getAttribute('alt') || ''; const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_COMMENT);
img.replaceWith(doc.createTextNode(alt)); let node;
while ((node = walker.nextNode())) {
node.parentNode.removeChild(node);
}
doc.querySelectorAll('*').forEach(el => {
for (const attr of Array.from(el.attributes)) {
if (!['href','src','alt'].includes(attr.name)) {
el.removeAttribute(attr.name);
}
}
}); });
} }
doc.querySelectorAll('img').forEach(img => {
const alt = img.getAttribute('alt') || '';
const text = altTextImages ? alt : '__IMG__';
img.replaceWith(doc.createTextNode(text));
});
if (stripUrlParams) { if (stripUrlParams) {
doc.querySelectorAll('[href]').forEach(a => { doc.querySelectorAll('[href]').forEach(a => {
const href = a.getAttribute('href'); const href = a.getAttribute('href');
@ -162,17 +210,46 @@ function collectText(part, bodyParts, attachments) {
} }
} }
function buildEmailText(full) { function collectRawText(part, bodyParts, attachments) {
if (part.parts && part.parts.length) {
for (const p of part.parts) collectRawText(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(doc.body.textContent || "");
} else {
bodyParts.push(body);
}
}
function buildEmailText(full, applyTransforms = true) {
const bodyParts = []; const bodyParts = [];
const attachments = []; const attachments = [];
collectText(full, bodyParts, attachments); const collect = applyTransforms ? collectText : collectRawText;
collect(full, bodyParts, attachments);
const headers = Object.entries(full.headers || {}) const headers = Object.entries(full.headers || {})
.map(([k, v]) => `${k}: ${v.join(' ')}`) .map(([k, v]) => `${k}: ${v.join(' ')}`)
.join('\n'); .join('\n');
const attachInfo = `Attachments: ${attachments.length}` + const attachInfo = `Attachments: ${attachments.length}` +
(attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : ""); (attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : "");
const combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim(); let combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim();
return sanitizeString(combined); if (applyTransforms && tokenReduction) {
const seen = new Set();
combined = combined.split('\n').filter(l => {
if (seen.has(l)) return false;
seen.add(l);
return true;
}).join('\n');
}
return applyTransforms ? sanitizeString(combined) : combined;
} }
function updateTimingStats(elapsed) { function updateTimingStats(elapsed) {
@ -206,16 +283,60 @@ async function processMessage(id) {
updateActionIcon(); updateActionIcon();
try { try {
const full = await messenger.messages.getFull(id); const full = await messenger.messages.getFull(id);
const text = buildEmailText(full); const originalText = buildEmailText(full, false);
let text = buildEmailText(full);
if (tokenReduction && maxTokens > 0) {
const limit = Math.floor(maxTokens * 0.9);
if (text.length > limit) {
text = text.slice(0, limit);
}
}
if (showDebugTab) {
await storage.local.set({ lastFullText: originalText, lastPromptText: text });
}
let hdr;
let currentTags = []; let currentTags = [];
let alreadyRead = false;
let identityId = null;
try { try {
const hdr = await messenger.messages.get(id); hdr = await messenger.messages.get(id);
currentTags = Array.isArray(hdr.tags) ? [...hdr.tags] : []; currentTags = Array.isArray(hdr.tags) ? [...hdr.tags] : [];
alreadyRead = hdr.read === true;
const ids = await messenger.identities.list(hdr.folder.accountId);
identityId = ids[0]?.id || null;
} catch (e) { } catch (e) {
currentTags = []; currentTags = [];
alreadyRead = false;
identityId = null;
} }
for (const rule of aiRules) { for (const rule of aiRules) {
if (rule.enabled === false) {
continue;
}
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;
}
if (hdr && (typeof rule.minAgeDays === 'number' || typeof rule.maxAgeDays === 'number')) {
const msgTime = new Date(hdr.date).getTime();
if (!isNaN(msgTime)) {
const ageDays = (Date.now() - msgTime) / 86400000;
if (typeof rule.minAgeDays === 'number' && ageDays < rule.minAgeDays) {
continue;
}
if (typeof rule.maxAgeDays === 'number' && ageDays > rule.maxAgeDays) {
continue;
}
}
}
const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion); const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion);
const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey); const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
if (matched) { if (matched) {
@ -227,12 +348,22 @@ async function processMessage(id) {
} }
} else if (act.type === 'move' && act.folder) { } else if (act.type === 'move' && act.folder) {
await messenger.messages.move([id], act.folder); await messenger.messages.move([id], act.folder);
} else if (act.type === 'copy' && act.copyTarget) {
await messenger.messages.copy([id], act.copyTarget);
} else if (act.type === 'junk') { } else if (act.type === 'junk') {
await messenger.messages.update(id, { junk: !!act.junk }); await messenger.messages.update(id, { junk: !!act.junk });
} else if (act.type === 'read') { } else if (act.type === 'read') {
await messenger.messages.update(id, { read: !!act.read }); await messenger.messages.update(id, { read: !!act.read });
} else if (act.type === 'flag') { } else if (act.type === 'flag') {
await messenger.messages.update(id, { flagged: !!act.flagged }); await messenger.messages.update(id, { flagged: !!act.flagged });
} else if (act.type === 'delete') {
await messenger.messages.delete([id]);
} else if (act.type === 'archive') {
await messenger.messages.archive([id]);
} else if (act.type === 'forward' && act.address && identityId) {
await browser.compose.beginForward(id, { to: [act.address], identityId });
} else if (act.type === 'reply' && act.replyType && identityId) {
await browser.compose.beginReply(id, { replyType: act.replyType, identityId });
} }
} }
if (rule.stopProcessing) { if (rule.stopProcessing) {
@ -251,9 +382,17 @@ async function processMessage(id) {
const elapsed = Date.now() - currentStart; const elapsed = Date.now() - currentStart;
currentStart = 0; currentStart = 0;
updateTimingStats(elapsed); updateTimingStats(elapsed);
await storage.local.set({ classifyStats: timingStats }); await storage.local.set({ classifyStats: timingStats, errorPending: true });
errorPending = true;
logger.aiLog("failed to apply AI rules", { level: 'error' }, e); logger.aiLog("failed to apply AI rules", { level: 'error' }, e);
showTransientIcon(ICONS.average); setIcon(ICONS.error());
browser.notifications.create(ERROR_NOTIFICATION_ID, {
type: 'basic',
iconUrl: browser.runtime.getURL('resources/img/logo.png'),
title: 'Sortana Error',
message: 'Failed to apply AI rules',
buttons: [{ title: 'Dismiss' }]
});
} }
} }
async function applyAiRules(idsInput) { async function applyAiRules(idsInput) {
@ -312,7 +451,7 @@ async function clearCacheForMessages(idsInput) {
} }
try { try {
const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "aiRules", "theme"]); const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "errorPending", "showDebugTab"]);
logger.setDebug(store.debugLogging); logger.setDebug(store.debugLogging);
await AiClassifier.setConfig(store); await AiClassifier.setConfig(store);
userTheme = store.theme || 'auto'; userTheme = store.theme || 'auto';
@ -322,6 +461,12 @@ async function clearCacheForMessages(idsInput) {
stripUrlParams = store.stripUrlParams === true; stripUrlParams = store.stripUrlParams === true;
altTextImages = store.altTextImages === true; altTextImages = store.altTextImages === true;
collapseWhitespace = store.collapseWhitespace === true; collapseWhitespace = store.collapseWhitespace === true;
tokenReduction = store.tokenReduction === true;
if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') {
maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens;
}
errorPending = store.errorPending === true;
showDebugTab = store.showDebugTab === true;
const savedStats = await storage.local.get('classifyStats'); const savedStats = await storage.local.get('classifyStats');
if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') { if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') {
Object.assign(timingStats, savedStats.classifyStats); Object.assign(timingStats, savedStats.classifyStats);
@ -337,6 +482,25 @@ async function clearCacheForMessages(idsInput) {
aiRules = normalizeRules(newRules); aiRules = normalizeRules(newRules);
logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules); logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules);
} }
if (changes.endpoint || changes.templateName || changes.customTemplate || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) {
const config = {};
if (changes.endpoint) config.endpoint = changes.endpoint.newValue;
if (changes.templateName) config.templateName = changes.templateName.newValue;
if (changes.customTemplate) config.customTemplate = changes.customTemplate.newValue;
if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue;
if (changes.aiParams) {
config.aiParams = changes.aiParams.newValue;
if (changes.aiParams.newValue && typeof changes.aiParams.newValue.max_tokens !== 'undefined') {
maxTokens = parseInt(changes.aiParams.newValue.max_tokens) || maxTokens;
}
}
if (changes.debugLogging) {
config.debugLogging = changes.debugLogging.newValue === true;
logger.setDebug(config.debugLogging);
}
await AiClassifier.setConfig(config);
logger.aiLog("AiClassifier config updated from storage change", { debug: true }, config);
}
if (changes.htmlToMarkdown) { if (changes.htmlToMarkdown) {
htmlToMarkdown = changes.htmlToMarkdown.newValue === true; htmlToMarkdown = changes.htmlToMarkdown.newValue === true;
logger.aiLog("htmlToMarkdown updated from storage change", { debug: true }, htmlToMarkdown); logger.aiLog("htmlToMarkdown updated from storage change", { debug: true }, htmlToMarkdown);
@ -353,6 +517,17 @@ async function clearCacheForMessages(idsInput) {
collapseWhitespace = changes.collapseWhitespace.newValue === true; collapseWhitespace = changes.collapseWhitespace.newValue === true;
logger.aiLog("collapseWhitespace updated from storage change", { debug: true }, collapseWhitespace); logger.aiLog("collapseWhitespace updated from storage change", { debug: true }, collapseWhitespace);
} }
if (changes.tokenReduction) {
tokenReduction = changes.tokenReduction.newValue === true;
logger.aiLog("tokenReduction updated from storage change", { debug: true }, tokenReduction);
}
if (changes.showDebugTab) {
showDebugTab = changes.showDebugTab.newValue === true;
}
if (changes.errorPending) {
errorPending = changes.errorPending.newValue === true;
updateActionIcon();
}
if (changes.theme) { if (changes.theme) {
userTheme = changes.theme.newValue || 'auto'; userTheme = changes.theme.newValue || 'auto';
currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme; currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
@ -578,6 +753,18 @@ async function clearCacheForMessages(idsInput) {
logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason); logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason);
}); });
browser.notifications.onClicked.addListener(id => {
if (id === ERROR_NOTIFICATION_ID) {
clearError();
}
});
browser.notifications.onButtonClicked.addListener((id) => {
if (id === ERROR_NOTIFICATION_ID) {
clearError();
}
});
browser.runtime.onInstalled.addListener(async ({ reason }) => { browser.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === "install") { if (reason === "install") {
await browser.runtime.openOptionsPage(); await browser.runtime.openOptionsPage();

View file

@ -1,13 +1,13 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "Sortana", "name": "Sortana",
"version": "2.1.1", "version": "2.2.0",
"default_locale": "en-US", "default_locale": "en-US",
"applications": { "applications": {
"gecko": { "gecko": {
"id": "ai-filter@jordanwages", "id": "ai-filter@jordanwages",
"strict_min_version": "128.0", "strict_min_version": "128.0",
"strict_max_version": "139.*" "strict_max_version": "140.*"
} }
}, },
"icons": { "icons": {
@ -40,8 +40,10 @@
"messagesTagsList", "messagesTagsList",
"accountsRead", "accountsRead",
"menus", "menus",
"notifications",
"scripting", "scripting",
"tabs", "tabs",
"theme" "theme",
"compose"
] ]
} }

View file

@ -308,6 +308,11 @@ async function classifyText(text, criterion, cacheKey = null) {
} }
const payload = buildPayload(text, criterion); const payload = buildPayload(text, criterion);
try {
await storage.local.set({ lastPayload: JSON.parse(payload) });
} catch (e) {
aiLog('failed to save last payload', { level: 'warn' }, e);
}
aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true}); aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true});
aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload); aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload);

View file

@ -31,6 +31,10 @@
.tag { .tag {
--bulma-tag-h: 318; --bulma-tag-h: 318;
} }
#diff-display {
white-space: pre-wrap;
font-family: monospace;
}
</style> </style>
</head> </head>
<body> <body>
@ -47,6 +51,7 @@
<li class="is-active" data-tab="settings"><a><span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span><span>Settings</span></a></li> <li class="is-active" data-tab="settings"><a><span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span><span>Settings</span></a></li>
<li data-tab="rules"><a><span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span><span>Rules</span></a></li> <li data-tab="rules"><a><span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span><span>Rules</span></a></li>
<li data-tab="maintenance"><a><span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span><span>Maintenance</span></a></li> <li data-tab="maintenance"><a><span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span><span>Maintenance</span></a></li>
<li id="debug-tab-button" class="is-hidden" data-tab="debug"><a><span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span><span>Debug</span></a></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -144,6 +149,16 @@
<input type="checkbox" id="collapse-whitespace"> Collapse long whitespace <input type="checkbox" id="collapse-whitespace"> Collapse long whitespace
</label> </label>
</div> </div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="token-reduction"> Aggressive token reduction
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="show-debug-tab"> Show debug information
</label>
</div>
<div class="field"> <div class="field">
<label class="label" for="max_tokens">Max tokens</label> <label class="label" for="max_tokens">Max tokens</label>
<div class="control"> <div class="control">
@ -268,8 +283,21 @@
</p> </p>
</div> </div>
</div> </div>
<div id="debug-tab" class="tab-content is-hidden">
<h2 class="title is-4">
<span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span>
<span>Debug</span>
</h2>
<pre id="payload-display"></pre>
<div id="diff-container" class="mt-4 is-hidden">
<label class="label">Prompt diff</label>
<div id="diff-display" class="box content is-family-monospace"></div>
</div>
</div>
</div> </div>
</section> </section>
<script src="../resources/js/diff_match_patch_uncompressed.js"></script>
<script src="options.js"></script> <script src="options.js"></script>
</body> </body>
</html> </html>

View file

@ -16,9 +16,14 @@ document.addEventListener('DOMContentLoaded', async () => {
'stripUrlParams', 'stripUrlParams',
'altTextImages', 'altTextImages',
'collapseWhitespace', 'collapseWhitespace',
'tokenReduction',
'aiRules', 'aiRules',
'aiCache', 'aiCache',
'theme' 'theme',
'showDebugTab',
'lastPayload',
'lastFullText',
'lastPromptText'
]); ]);
const tabButtons = document.querySelectorAll('#main-tabs li'); const tabButtons = document.querySelectorAll('#main-tabs li');
const tabs = document.querySelectorAll('.tab-content'); const tabs = document.querySelectorAll('.tab-content');
@ -63,6 +68,33 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
await applyTheme(themeSelect.value); await applyTheme(themeSelect.value);
const payloadDisplay = document.getElementById('payload-display');
const diffDisplay = document.getElementById('diff-display');
const diffContainer = document.getElementById('diff-container');
let lastFullText = defaults.lastFullText || '';
let lastPromptText = defaults.lastPromptText || '';
let lastPayload = defaults.lastPayload ? JSON.stringify(defaults.lastPayload, null, 2) : '';
if (lastPayload) {
payloadDisplay.textContent = lastPayload;
}
if (lastFullText && lastPromptText && diff_match_patch) {
const dmp = new diff_match_patch();
dmp.Diff_EditCost = 4;
const diffs = dmp.diff_main(lastFullText, lastPromptText);
dmp.diff_cleanupEfficiency(diffs);
const hasDiff = diffs.some(d => d[0] !== 0);
if (hasDiff) {
diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs);
diffContainer.classList.remove('is-hidden');
} else {
diffDisplay.innerHTML = '';
diffContainer.classList.add('is-hidden');
}
} else {
diffContainer.classList.add('is-hidden');
}
themeSelect.addEventListener('change', async () => { themeSelect.addEventListener('change', async () => {
markDirty(); markDirty();
await applyTheme(themeSelect.value); await applyTheme(themeSelect.value);
@ -115,6 +147,20 @@ document.addEventListener('DOMContentLoaded', async () => {
const collapseWhitespaceToggle = document.getElementById('collapse-whitespace'); const collapseWhitespaceToggle = document.getElementById('collapse-whitespace');
collapseWhitespaceToggle.checked = defaults.collapseWhitespace === true; collapseWhitespaceToggle.checked = defaults.collapseWhitespace === true;
const tokenReductionToggle = document.getElementById('token-reduction');
tokenReductionToggle.checked = defaults.tokenReduction === true;
const debugTabToggle = document.getElementById('show-debug-tab');
const debugTabBtn = document.getElementById('debug-tab-button');
function updateDebugTab() {
const visible = debugTabToggle.checked;
debugTabBtn.classList.toggle('is-hidden', !visible);
}
debugTabToggle.checked = defaults.showDebugTab === true;
debugTabToggle.addEventListener('change', () => { updateDebugTab(); markDirty(); });
updateDebugTab();
const aiParams = Object.assign({}, DEFAULT_AI_PARAMS, defaults.aiParams || {}); const aiParams = Object.assign({}, DEFAULT_AI_PARAMS, defaults.aiParams || {});
for (const [key, val] of Object.entries(aiParams)) { for (const [key, val] of Object.entries(aiParams)) {
const el = document.getElementById(key); const el = document.getElementById(key);
@ -123,6 +169,7 @@ document.addEventListener('DOMContentLoaded', async () => {
let tagList = []; let tagList = [];
let folderList = []; let folderList = [];
let accountList = [];
try { try {
tagList = await messenger.messages.tags.list(); tagList = await messenger.messages.tags.list();
} catch (e) { } catch (e) {
@ -130,6 +177,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
try { try {
const accounts = await messenger.accounts.list(true); const accounts = await messenger.accounts.list(true);
accountList = accounts.map(a => ({ id: a.id, name: a.name }));
const collect = (f, prefix='') => { const collect = (f, prefix='') => {
folderList.push({ id: f.id ?? f.path, name: prefix + f.name }); folderList.push({ id: f.id ?? f.path, name: prefix + f.name });
(f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/')); (f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/'));
@ -171,7 +219,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const typeWrapper = document.createElement('div'); const typeWrapper = document.createElement('div');
typeWrapper.className = 'select is-small mr-2'; typeWrapper.className = 'select is-small mr-2';
const typeSelect = document.createElement('select'); const typeSelect = document.createElement('select');
['tag','move','junk','read','flag'].forEach(t => { ['tag','move','copy','junk','read','flag','delete','archive','forward','reply'].forEach(t => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = t; opt.value = t;
opt.textContent = t; opt.textContent = t;
@ -198,7 +246,7 @@ document.addEventListener('DOMContentLoaded', async () => {
sel.value = action.tagKey || ''; sel.value = action.tagKey || '';
wrap.appendChild(sel); wrap.appendChild(sel);
paramSpan.appendChild(wrap); paramSpan.appendChild(wrap);
} else if (typeSelect.value === 'move') { } else if (typeSelect.value === 'move' || typeSelect.value === 'copy') {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'select is-small'; wrap.className = 'select is-small';
const sel = document.createElement('select'); const sel = document.createElement('select');
@ -209,7 +257,7 @@ document.addEventListener('DOMContentLoaded', async () => {
opt.textContent = f.name; opt.textContent = f.name;
sel.appendChild(opt); sel.appendChild(opt);
} }
sel.value = action.folder || ''; sel.value = action.folder || action.copyTarget || '';
wrap.appendChild(sel); wrap.appendChild(sel);
paramSpan.appendChild(wrap); paramSpan.appendChild(wrap);
} else if (typeSelect.value === 'junk') { } else if (typeSelect.value === 'junk') {
@ -242,6 +290,25 @@ document.addEventListener('DOMContentLoaded', async () => {
sel.value = String(action.flagged ?? true); sel.value = String(action.flagged ?? true);
wrap.appendChild(sel); wrap.appendChild(sel);
paramSpan.appendChild(wrap); paramSpan.appendChild(wrap);
} else if (typeSelect.value === 'forward') {
const input = document.createElement('input');
input.type = 'text';
input.className = 'input is-small forward-input';
input.placeholder = 'address@example.com';
input.value = action.address || '';
paramSpan.appendChild(input);
} else if (typeSelect.value === 'reply') {
const wrap = document.createElement('div');
wrap.className = 'select is-small';
const sel = document.createElement('select');
sel.className = 'reply-select';
sel.appendChild(new Option('all','all'));
sel.appendChild(new Option('sender','sender'));
sel.value = action.replyType || 'all';
wrap.appendChild(sel);
paramSpan.appendChild(wrap);
} else if (typeSelect.value === 'delete' || typeSelect.value === 'archive') {
paramSpan.appendChild(document.createElement('span'));
} }
} }
@ -261,6 +328,39 @@ document.addEventListener('DOMContentLoaded', async () => {
return row; return row;
} }
function createConditionButton(label, sectionEl, checkbox, clearFn) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'button is-small is-light';
const icon = document.createElement('img');
icon.width = 16;
icon.height = 16;
icon.className = 'mr-1';
btn.appendChild(icon);
btn.append(label);
let active = checkbox ? checkbox.checked : sectionEl && !sectionEl.classList.contains('is-hidden');
function update() {
btn.classList.toggle('is-active', active);
icon.src = browser.runtime.getURL(`resources/svg/${active ? 'circledot' : 'circle'}.svg`);
if (sectionEl) sectionEl.classList.toggle('is-hidden', !active);
if (checkbox) checkbox.checked = active;
if (!active && typeof clearFn === 'function') {
clearFn();
}
}
btn.addEventListener('click', () => {
active = !active;
markDirty();
update();
});
update();
return btn;
}
function renderRules(rules = []) { function renderRules(rules = []) {
ruleCountEl.textContent = rules.length; ruleCountEl.textContent = rules.length;
rulesContainer.innerHTML = ''; rulesContainer.innerHTML = '';
@ -294,16 +394,67 @@ document.addEventListener('DOMContentLoaded', async () => {
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'message-header'; header.className = 'message-header';
header.appendChild(critInput);
const leftWrap = document.createElement('div');
leftWrap.style.display = 'flex';
leftWrap.style.alignItems = 'center';
leftWrap.style.flexGrow = '1';
const statusSpan = document.createElement('span');
statusSpan.className = 'rule-status has-text-weight-semibold mr-2';
leftWrap.appendChild(statusSpan);
leftWrap.appendChild(critInput);
header.appendChild(leftWrap);
const btnWrap = document.createElement('div');
btnWrap.style.display = 'flex';
btnWrap.style.gap = '0.25em';
let enabled = rule.enabled !== false;
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'button is-small is-light rule-toggle';
const toggleIcon = document.createElement('img');
toggleIcon.width = 16;
toggleIcon.height = 16;
toggleBtn.appendChild(toggleIcon);
const delBtn = document.createElement('button'); const delBtn = document.createElement('button');
delBtn.className = 'delete'; delBtn.type = 'button';
delBtn.setAttribute('aria-label', 'delete'); delBtn.className = 'button is-small is-danger is-light rule-delete';
const delIcon = document.createElement('img');
delIcon.src = browser.runtime.getURL('resources/svg/trash.svg');
delIcon.width = 16;
delIcon.height = 16;
delBtn.appendChild(delIcon);
function updateToggle() {
toggleIcon.src = browser.runtime.getURL(
`resources/svg/${enabled ? 'circleslash' : 'check'}.svg`
);
statusSpan.textContent = enabled ? '' : '(Disabled)';
article.dataset.enabled = String(enabled);
}
toggleBtn.addEventListener('click', () => {
enabled = !enabled;
markDirty();
updateToggle();
});
delBtn.addEventListener('click', () => { delBtn.addEventListener('click', () => {
article.remove(); article.remove();
ruleCountEl.textContent = rulesContainer.querySelectorAll('.rule').length; ruleCountEl.textContent = rulesContainer.querySelectorAll('.rule').length;
markDirty();
}); });
header.appendChild(delBtn);
btnWrap.appendChild(toggleBtn);
btnWrap.appendChild(delBtn);
header.appendChild(btnWrap);
updateToggle();
const actionsContainer = document.createElement('div'); const actionsContainer = document.createElement('div');
actionsContainer.className = 'rule-actions mb-2'; actionsContainer.className = 'rule-actions mb-2';
@ -319,7 +470,7 @@ document.addEventListener('DOMContentLoaded', async () => {
addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow())); addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow()));
const stopLabel = document.createElement('label'); const stopLabel = document.createElement('label');
stopLabel.className = 'checkbox mt-2'; stopLabel.className = 'checkbox mt-2 is-hidden';
const stopCheck = document.createElement('input'); const stopCheck = document.createElement('input');
stopCheck.type = 'checkbox'; stopCheck.type = 'checkbox';
stopCheck.className = 'stop-processing'; stopCheck.className = 'stop-processing';
@ -327,11 +478,130 @@ document.addEventListener('DOMContentLoaded', async () => {
stopLabel.appendChild(stopCheck); stopLabel.appendChild(stopCheck);
stopLabel.append(' Stop after match'); stopLabel.append(' Stop after match');
const unreadLabel = document.createElement('label');
unreadLabel.className = 'checkbox mt-2 ml-4 is-hidden';
const unreadCheck = document.createElement('input');
unreadCheck.type = 'checkbox';
unreadCheck.className = 'unread-only';
unreadCheck.checked = rule.unreadOnly === true;
unreadLabel.appendChild(unreadCheck);
unreadLabel.append(' Only apply to unread messages');
const ageBox = document.createElement('div');
ageBox.className = 'field is-grouped mt-2 is-hidden';
const minInput = document.createElement('input');
minInput.type = 'number';
minInput.placeholder = 'Min days';
minInput.className = 'input is-small min-age mr-2';
minInput.style.width = '6em';
if (typeof rule.minAgeDays === 'number') minInput.value = rule.minAgeDays;
const maxInput = document.createElement('input');
maxInput.type = 'number';
maxInput.placeholder = 'Max days';
maxInput.className = 'input is-small max-age';
maxInput.style.width = '6em';
if (typeof rule.maxAgeDays === 'number') maxInput.value = rule.maxAgeDays;
ageBox.appendChild(minInput);
ageBox.appendChild(maxInput);
const acctBox = document.createElement('div');
acctBox.className = 'field mt-2 is-hidden';
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 is-hidden';
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);
if (typeof rule.minAgeDays === 'number' || typeof rule.maxAgeDays === 'number') {
ageBox.classList.remove('is-hidden');
}
if ((rule.accounts || []).length) {
acctBox.classList.remove('is-hidden');
}
if ((rule.folders || []).length) {
folderBox.classList.remove('is-hidden');
}
const condButtons = document.createElement('div');
condButtons.className = 'field is-grouped is-grouped-multiline mb-2';
function addCond(btn) {
const p = document.createElement('p');
p.className = 'control';
p.appendChild(btn);
condButtons.appendChild(p);
}
addCond(createConditionButton('Stop', null, stopCheck, () => {
stopCheck.checked = false;
}));
addCond(createConditionButton('Unread', null, unreadCheck, () => {
unreadCheck.checked = false;
}));
addCond(createConditionButton('Age', ageBox, null, () => {
minInput.value = '';
maxInput.value = '';
}));
addCond(createConditionButton('Accounts', acctBox, null, () => {
for (const opt of acctSel.options) opt.selected = false;
}));
addCond(createConditionButton('Folders', folderBox, null, () => {
for (const opt of folderSel.options) opt.selected = false;
}));
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'message-body'; body.className = 'message-body';
body.appendChild(actionsContainer); body.appendChild(actionsContainer);
body.appendChild(addAction); body.appendChild(addAction);
const condDivider = document.createElement('hr');
condDivider.className = 'mt-3 mb-2';
body.appendChild(condDivider);
body.appendChild(condButtons);
body.appendChild(stopLabel); body.appendChild(stopLabel);
body.appendChild(unreadLabel);
body.appendChild(ageBox);
body.appendChild(acctBox);
body.appendChild(folderBox);
article.appendChild(header); article.appendChild(header);
article.appendChild(body); article.appendChild(body);
@ -351,6 +621,9 @@ document.addEventListener('DOMContentLoaded', async () => {
if (type === 'move') { if (type === 'move') {
return { type, folder: row.querySelector('.folder-select').value }; return { type, folder: row.querySelector('.folder-select').value };
} }
if (type === 'copy') {
return { type, copyTarget: row.querySelector('.folder-select').value };
}
if (type === 'junk') { if (type === 'junk') {
return { type, junk: row.querySelector('.junk-select').value === 'true' }; return { type, junk: row.querySelector('.junk-select').value === 'true' };
} }
@ -360,22 +633,50 @@ document.addEventListener('DOMContentLoaded', async () => {
if (type === 'flag') { if (type === 'flag') {
return { type, flagged: row.querySelector('.flag-select').value === 'true' }; return { type, flagged: row.querySelector('.flag-select').value === 'true' };
} }
if (type === 'delete' || type === 'archive') {
return { type };
}
return { type }; return { type };
}); });
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked; const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
return { criterion, actions, stopProcessing }; const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
const enabled = ruleEl.dataset.enabled !== 'false';
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, enabled };
if (unreadOnly) rule.unreadOnly = true;
if (stopProcessing) rule.stopProcessing = true;
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: [], stopProcessing: false }); data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false, enabled: true, accounts: [], folders: [] });
renderRules(data); renderRules(data);
}); });
renderRules((defaults.aiRules || []).map(r => { 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 = [];
if (r.enabled !== false) r.enabled = true; else r.enabled = false;
return r;
}
const actions = []; const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
if (r.copyTarget || r.copyTo) actions.push({ type: 'copy', copyTarget: r.copyTarget || r.copyTo });
const rule = { criterion: r.criterion, actions }; const rule = { criterion: r.criterion, actions };
if (r.stopProcessing) rule.stopProcessing = true; if (r.stopProcessing) rule.stopProcessing = true;
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;
rule.enabled = r.enabled !== false;
return rule; return rule;
})); }));
@ -443,6 +744,38 @@ document.addEventListener('DOMContentLoaded', async () => {
} catch { } catch {
cacheCountEl.textContent = '?'; cacheCountEl.textContent = '?';
} }
try {
if (debugTabToggle.checked) {
const latest = await storage.local.get(['lastPayload', 'lastFullText', 'lastPromptText']);
const payloadStr = latest.lastPayload ? JSON.stringify(latest.lastPayload, null, 2) : '';
if (payloadStr !== lastPayload) {
lastPayload = payloadStr;
payloadDisplay.textContent = payloadStr;
}
if (latest.lastFullText !== lastFullText || latest.lastPromptText !== lastPromptText) {
lastFullText = latest.lastFullText || '';
lastPromptText = latest.lastPromptText || '';
if (lastFullText && lastPromptText && diff_match_patch) {
const dmp = new diff_match_patch();
dmp.Diff_EditCost = 4;
const diffs = dmp.diff_main(lastFullText, lastPromptText);
dmp.diff_cleanupEfficiency(diffs);
const hasDiff = diffs.some(d => d[0] !== 0);
if (hasDiff) {
diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs);
diffContainer.classList.remove('is-hidden');
} else {
diffDisplay.innerHTML = '';
diffContainer.classList.add('is-hidden');
}
} else {
diffDisplay.innerHTML = '';
diffContainer.classList.add('is-hidden');
}
}
}
} catch {}
} }
refreshMaintenance(); refreshMaintenance();
@ -497,6 +830,9 @@ document.addEventListener('DOMContentLoaded', async () => {
if (type === 'move') { if (type === 'move') {
return { type, folder: row.querySelector('.folder-select').value }; return { type, folder: row.querySelector('.folder-select').value };
} }
if (type === 'copy') {
return { type, copyTarget: row.querySelector('.folder-select').value };
}
if (type === 'junk') { if (type === 'junk') {
return { type, junk: row.querySelector('.junk-select').value === 'true' }; return { type, junk: row.querySelector('.junk-select').value === 'true' };
} }
@ -506,16 +842,37 @@ document.addEventListener('DOMContentLoaded', async () => {
if (type === 'flag') { if (type === 'flag') {
return { type, flagged: row.querySelector('.flag-select').value === 'true' }; return { type, flagged: row.querySelector('.flag-select').value === 'true' };
} }
if (type === 'forward') {
return { type, address: row.querySelector('.forward-input').value.trim() };
}
if (type === 'reply') {
return { type, replyType: row.querySelector('.reply-select').value };
}
return { type }; return { type };
}); });
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked; const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
return { criterion, actions, stopProcessing }; const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
const enabled = ruleEl.dataset.enabled !== 'false';
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, enabled };
if (unreadOnly) rule.unreadOnly = true;
if (stopProcessing) rule.stopProcessing = true;
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); }).filter(r => r.criterion);
const stripUrlParams = stripUrlToggle.checked; const stripUrlParams = stripUrlToggle.checked;
const altTextImages = altTextToggle.checked; const altTextImages = altTextToggle.checked;
const collapseWhitespace = collapseWhitespaceToggle.checked; const collapseWhitespace = collapseWhitespaceToggle.checked;
const tokenReduction = tokenReductionToggle.checked;
const showDebugTab = debugTabToggle.checked;
const theme = themeSelect.value; const theme = themeSelect.value;
await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, aiRules: rules, theme }); await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab });
await applyTheme(theme); await applyTheme(theme);
try { try {
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/>
<path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 357 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm-7-3L19 5"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

3
resources/svg/plus.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24">
<path d="M21 12H3m9-9v18"/>
</svg>

After

Width:  |  Height:  |  Size: 250 B