Compare commits

...
Sign in to create a new pull request.

170 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
34ed955a5b Add read and flag rule actions 2025-07-15 21:56:58 -05:00
0d543e5567 Update manifest.json 2025-07-15 21:45:28 -05:00
959482c3d0
Merge pull request #88 from wagesj45/codex/fix-variable-initialization-error
Fix rule count initialization
2025-07-15 21:43:49 -05:00
2d22404198 Fix rule count initialization 2025-07-15 21:42:32 -05:00
64d5c99ba5 Update manifest.json
140 testing failing, so reverting support back a version.
2025-07-15 21:04:58 -05:00
3db1c851fa
Merge pull request #87 from wagesj45/codex/add-async-helper-to-accumulate-message-ids
Handle multi-page message selections
2025-07-15 20:59:00 -05:00
0c105cb6e4 Handle multi-page selected message lists 2025-07-15 20:58:42 -05:00
766afc9079
Merge pull request #86 from wagesj45/codex/update-cache-count-and-add-stats
Update maintenance stats
2025-07-15 20:58:15 -05:00
3c87950dfb Update maintenance stats 2025-07-15 20:57:57 -05:00
5d87ec658d Merge branch 'main' of https://github.com/wagesj45/Sortana 2025-07-09 03:24:35 -05:00
ea8888f057 Update ai-filter.sln 2025-07-09 03:24:33 -05:00
52583cebc1
Update README.md 2025-07-09 00:38:56 -05:00
2a31b02039
Merge pull request #85 from wagesj45/codex/update-readme.md-and-agents.md
Update README and AGENTS docs
2025-07-08 21:59:51 -05:00
044e7df07d Update documentation 2025-07-08 21:59:25 -05:00
f6b67b3407
Merge pull request #84 from wagesj45/codex/add-processmessage-function-and-updates
Refactor message classification
2025-07-08 19:46:25 -05:00
1b7fa3d5ee Refactor message processing 2025-07-08 19:45:57 -05:00
3c292096eb
Merge pull request #83 from wagesj45/codex/confirm-and-clean-up-sync-api-usage
Remove unused sync classifier API
2025-07-08 18:22:46 -05:00
f3e20f8941 Remove unused sync classifier API 2025-07-08 17:53:35 -05:00
e979869601
Merge pull request #82 from wagesj45/codex/create-default-ai-parameters-module
Refactor default AI params
2025-07-08 17:34:41 -05:00
32e79a13d5 Add shared defaults for AI parameters 2025-07-08 17:34:26 -05:00
0c7d40a56d
Merge pull request #81 from wagesj45/codex/add-themeutils-module-and-refactor-code
Refactor theme detection
2025-07-08 16:54:32 -05:00
a62a882791 Refactor theme detection 2025-07-08 16:54:04 -05:00
c420699e51
Merge pull request #80 from wagesj45/codex/modify-details.js-to-request-message-ids
Update details popup to load message IDs
2025-07-08 16:44:22 -05:00
0695272b21 Update details page to use message id list 2025-07-08 16:44:13 -05:00
ea6952655b
Merge pull request #79 from wagesj45/codex/add-normalizerules-function-and-refactor
Refactor rule normalization
2025-07-08 14:37:44 -05:00
0313a03fa4 Add normalizeRules helper 2025-07-08 12:58:42 -05:00
0490b67e0a Fixing default URI 2025-07-08 02:29:29 -05:00
e1beb5fa35
Merge pull request #78 from wagesj45/codex/fix-context-menu-icon-color-in-dark-theme
Fix menu icons for dark theme
2025-07-08 01:47:05 -05:00
179c81a156 Fix menu icons for dark theme 2025-07-08 01:46:54 -05:00
0041f339e3
Merge pull request #77 from wagesj45/codex/add-light-and-dark-mode-support
Add theme option and dynamic icon support
2025-07-08 01:30:46 -05:00
7e64a428ac Add theme support with dynamic icons 2025-07-08 01:30:37 -05:00
b29d5c702e correcting/theming icons 2025-07-08 01:06:09 -05:00
cd5ef92e0e
Merge pull request #76 from wagesj45/codex/rework-project-iconography-and-update-readme
Update icons and options UI
2025-07-08 00:41:00 -05:00
9a4b1ce239 Update icons and options UI 2025-07-08 00:40:40 -05:00
6c352e904e more images 2025-07-08 00:24:43 -05:00
58a2891847 Update ai-filter.sln 2025-07-07 23:55:38 -05:00
97628c693b Changing up imagery 2025-07-07 23:54:52 -05:00
ff17bd9c1f
Merge pull request #75 from wagesj45/codex/add-export/import-feature-for-settings
Add export/import controls
2025-07-07 22:07:39 -05:00
c7333482ce Allow selective data export and import 2025-07-07 22:07:00 -05:00
6a85dbb2eb Going back to what works.
Manifest v3 and its consequences have been a disaster for the human race.
2025-07-07 21:46:21 -05:00
f9a31415a0
Merge pull request #74 from wagesj45/codex/update-add-on-popup-to-use-sortana-getdisplayedmessages
Fix details popup id retrieval
2025-07-07 00:24:03 -05:00
51816d8a19 Use getDisplayedMessages to load message 2025-07-07 00:23:54 -05:00
46c101f101
Merge pull request #73 from wagesj45/codex/implement-sortana--message-in-background.js
Add displayed message retrieval and update details page
2025-07-07 00:15:37 -05:00
caf18ed5ab Add message for displayed messages and convert details to module 2025-07-07 00:14:47 -05:00
13751b3ab2 Trying new things 2025-07-06 18:05:19 -05:00
bd56cbf19a
Merge pull request #72 from wagesj45/codex/backport-manifest.json-to-v2
Backport manifest to version 2
2025-07-06 04:35:57 -05:00
aec56aac33 Revert manifest to version 2 2025-07-06 04:35:46 -05:00
254f0c5ffc just catching up 2025-07-06 04:31:51 -05:00
713a75eaae
Merge pull request #71 from wagesj45/codex/update-thunderbird-panel-and-background.js
Restore automatic panel popup
2025-07-06 02:36:52 -05:00
34cf8e234e Restore popup defaults and update message lookups 2025-07-06 02:36:42 -05:00
58543b9ccd
Merge pull request #70 from wagesj45/codex/update-popup-and-message-handling-for-thunderbird-≥128
Update UI entry points for Thunderbird 128
2025-07-06 02:11:33 -05:00
d60725eb4b Handle message display in TB 128 2025-07-06 02:10:46 -05:00
0c07479fa9 Added CSP 2025-07-06 01:22:44 -05:00
12e8c940b5
Merge pull request #69 from wagesj45/codex/fix-cors-errors-after-manifest-v3-upgrade
Add host permissions for Sortana
2025-07-06 00:31:00 -05:00
8f5165dcec Add host permissions for endpoint access 2025-07-06 00:29:46 -05:00
40835bd9f6
Merge pull request #68 from wagesj45/codex/new-task
Update to manifest v3
2025-07-06 00:06:00 -05:00
eb91474f5a Migrate manifest to version 3 2025-07-06 00:05:31 -05:00
11cc89dbc1
Merge pull request #67 from wagesj45/codex/add-new-message-type-for-details-fetching
Add active message retrieval fallback
2025-07-05 22:55:10 -05:00
97bfabfbea Add fallback message to fetch active message id 2025-07-05 22:53:40 -05:00
846d1270c5 Updating version number since we're making significant changes 2025-07-05 20:02:51 -05:00
af8218f6c3 Merge branch 'main' of https://github.com/wagesj45/Sortana 2025-07-05 20:02:21 -05:00
4fd5e259c6
Merge pull request #66 from wagesj45/codex/analyze-exception-in-details.js-file
Fix details popup tab lookup
2025-07-05 19:59:27 -05:00
79f49fd502 Simplify message lookup 2025-07-05 19:58:11 -05:00
9724c19b7d Debug attempt 2025-07-05 19:33:05 -05:00
f40715add1
Merge pull request #65 from wagesj45/codex/investigate-details-page-loading-issue
Fix message id detection for details popup
2025-07-05 05:07:58 -05:00
6b741595cc Fix message lookup in details popup 2025-07-05 05:07:48 -05:00
ac2c017082
Merge pull request #64 from wagesj45/codex/add-logging-to-details.js-script
Add logging to details page
2025-07-05 04:50:28 -05:00
c0ba2d1fdd Add logging to details page 2025-07-05 04:50:18 -05:00
3a10609399
Merge pull request #63 from wagesj45/codex/fix-issues-with--reasoning--page-loading
Fix reasoning popup
2025-07-05 04:33:16 -05:00
41769c0e96 Handle preview pane when loading details 2025-07-05 04:33:01 -05:00
a358bc5703
Merge pull request #62 from wagesj45/codex/implement-email-message-optimizations
Implement HTML sanitization options
2025-07-05 03:47:48 -05:00
149ff03cf9 Add HTML sanitization options 2025-07-05 03:45:49 -05:00
b160f2221e
Merge pull request #61 from wagesj45/codex/add-option-to-convert-html-to-markdown
Add Markdown conversion option
2025-07-05 02:31:53 -05:00
200c03c875 Add Markdown conversion option 2025-07-05 02:26:58 -05:00
5e0356086a Update README.md 2025-07-05 01:54:38 -05:00
1057d8c7fd Adding turndown for possible use
Looking at adding html to markdown conversion for html email bodies.
2025-07-05 01:51:32 -05:00
8ae32fe752 Housekeeping for add-on store 2025-07-03 03:07:59 -05:00
86db83bb66 Cleanup 2025-06-30 01:56:22 -05:00
d7416c16ce
Update README.md 2025-06-29 23:37:34 -05:00
74b64ce7db
Merge pull request #60 from wagesj45/codex/update-documentation-and-public-material
Update Sortana docs
2025-06-29 23:35:07 -05:00
f9c1f0f048 Update documentation for current code 2025-06-29 23:34:51 -05:00
5ab4459af0
Merge pull request #59 from wagesj45/codex/update-maintenance-tab-in-options-page
Improve maintenance timing display
2025-06-29 21:23:21 -05:00
c2e114266e Update maintenance stats display 2025-06-29 21:22:31 -05:00
15de566068 Delete logger.jsm 2025-06-29 20:10:38 -05:00
9944d78ed7
Merge pull request #58 from wagesj45/h43lwv-codex/add-timing-stats-to-maintenance-tab
Add run time stats in options
2025-06-29 20:10:21 -05:00
d5aecc6e8a Add timing stats to Maintenance tab 2025-06-29 20:10:06 -05:00
0110f25152
Merge pull request #57 from wagesj45/codex/add-live-updating-queue-stat
Add queue count stat to options page
2025-06-29 03:16:25 -05:00
8c03ad008e Add queue count stat to maintenance page 2025-06-29 03:16:12 -05:00
82c6d86046
Merge pull request #56 from wagesj45/codex/add-maintenance-tab-to-options-page
Add maintenance tab
2025-06-29 02:47:01 -05:00
db767fae48 Add maintenance tab and cache clearing 2025-06-29 02:46:49 -05:00
a77e5e68fb clean up 2025-06-29 02:42:58 -05:00
9f701565f3
Merge pull request #55 from wagesj45/codex/analyze-message-id-uniqueness-and-queue-handling
Use Message-ID for cache keys
2025-06-29 02:37:36 -05:00
8ba2a931b9 Use Message-ID for cache keys 2025-06-29 02:37:05 -05:00
41a0f4f8f2 Added additional logging 2025-06-29 00:26:26 -05:00
0e707f7b5e
Merge pull request #54 from wagesj45/codex/investigate-tag-assignment-issue
Fix tagging with multiple matching rules
2025-06-29 00:07:53 -05:00
3e1df7be3f Preserve existing tags when applying multiple rules 2025-06-29 00:07:37 -05:00
f7bcb88a14
Merge pull request #53 from wagesj45/codex/refactor-caching-strategy-and-implementation
Unify AI caches and add cache key helper
2025-06-28 15:46:46 -05:00
d69d0cae66 Merge AI caches and add cache key helper 2025-06-28 15:46:30 -05:00
b470fcd600
Merge pull request #52 from wagesj45/codex/fix-details-popup-error
Fix missing getCachedResult export
2025-06-27 22:21:21 -05:00
bced7447b2 Export getCachedResult from classifier 2025-06-27 22:20:38 -05:00
e14587476e
Merge pull request #51 from wagesj45/codex/fix-bug-in-details.js-with-missing-params
Fix message ID handling in details popup
2025-06-27 21:59:22 -05:00
ef864651d4 Update ai-filter.sln 2025-06-28 02:56:29 -05:00
9b91abb338 Fix details popup when no mid parameter 2025-06-27 21:59:11 -05:00
05176ab30e
Merge pull request #50 from wagesj45/codex/refactor-messagedisplayaction-button-to-popup
Add popup details view
2025-06-27 04:08:17 -05:00
1d7f0d5344 Add message details popup 2025-06-27 04:08:04 -05:00
4376169001 Update AGENTS.md 2025-06-27 03:55:47 -05:00
7be3c46e62 Update manifest.json 2025-06-27 03:14:57 -05:00
2e8a505166
Merge pull request #49 from wagesj45/codex/fix-cache-clearing-and-reasoning-view-button-registration-bu
Fix message display script paths
2025-06-27 03:13:49 -05:00
a7009d644c Fix script registration with relative URLs 2025-06-27 03:12:32 -05:00
101f8d0cd7
Merge pull request #48 from wagesj45/codex/update-code-for-messagedisplay-api
Fix message pane buttons
2025-06-27 02:53:19 -05:00
9ae22ad5ed Update README.md 2025-06-27 02:52:49 -05:00
a29a6299ef Use new scripting API for message display scripts 2025-06-27 02:52:49 -05:00
b2341b1382
Merge pull request #47 from wagesj45/codex/fix-missing--view-reasoning--button
Add View Reasoning UI
2025-06-27 02:14:01 -05:00
22ab445dea Add View Reasoning menu and fix button 2025-06-27 02:12:34 -05:00
03844a5ab3 Update ai-filter.sln 2025-06-27 02:02:55 -05:00
0e6478269e
Merge pull request #46 from wagesj45/codex/implement-ai-metadata-storage-and-ui
Add reasoning cache and viewer
2025-06-27 01:39:50 -05:00
db7113b03d Create brain.png 2025-06-27 01:38:11 -05:00
146 changed files with 5115 additions and 741 deletions

View file

@ -5,9 +5,10 @@ This file provides guidelines for codex agents contributing to the Sortana proje
## Repository Overview ## Repository Overview
- `background.js`: Handles startup tasks and coordinates message passing within the extension. - `background.js`: Handles startup tasks and coordinates message passing within the extension.
- `modules/`: Holds reusable JavaScript modules for the extension. - `modules/`: Contains reusable JavaScript modules such as `AiClassifier.js`,
- `content/`: Scripts for modifying Thunderbird windows (e.g., the filter editor). `defaultParams.js` and `themeUtils.js`.
- `options/`: The options page HTML and JavaScript. - `options/`: The options page HTML, JavaScript and bundled Bulma CSS (v1.0.3).
- `details.html` and `details.js`: View AI reasoning and clear cache for a message.
- `resources/`: Images and other static files. - `resources/`: Images and other static files.
- `prompt_templates/`: Prompt template files for the AI service. - `prompt_templates/`: Prompt template files for the AI service.
- `build-xpi.ps1`: PowerShell script to package the extension. - `build-xpi.ps1`: PowerShell script to package the extension.
@ -27,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
@ -36,11 +37,12 @@ Additional documentation exists outside this repository.
- Development guide: [Webextention-API for Thunderbird](https://webextension-api.thunderbird.net/en/stable/) - Development guide: [Webextention-API for Thunderbird](https://webextension-api.thunderbird.net/en/stable/)
- [Messages API](https://webextension-api.thunderbird.net/en/stable/messages.html) - [Messages API](https://webextension-api.thunderbird.net/en/stable/messages.html)
- [Message Tags API](https://webextension-api.thunderbird.net/en/stable/messages.tags.html) - [Message Tags API](https://webextension-api.thunderbird.net/en/stable/messages.tags.html)
- [messageDisplayAction API](https://webextension-api.thunderbird.net/en/stable/messageDisplayAction.html)
- [Storage API](https://webextension-api.thunderbird.net/en/stable/storage.html) - [Storage API](https://webextension-api.thunderbird.net/en/stable/storage.html)
- Thunderbird Add-on Store Policies - Thunderbird Add-on Store Policies
- [Third Party Library Usage](https://extensionworkshop.com/documentation/publish/third-party-library-usage/) - [Third Party Library Usage](https://extensionworkshop.com/documentation/publish/third-party-library-usage/)
- Third Party Libraries - Third Party Libraries
- [Bulma.css](https://github.com/jgthms/bulma) - [Bulma.css v1.0.3](https://github.com/jgthms/bulma/blob/1.0.3/css/bulma.css)
- Issue tracker: [Thunderbird tracker on Bugzilla](https://bugzilla.mozilla.org/describecomponents.cgi?product=Thunderbird) - Issue tracker: [Thunderbird tracker on Bugzilla](https://bugzilla.mozilla.org/describecomponents.cgi?product=Thunderbird)
@ -58,3 +60,17 @@ base64 data should be replaced with placeholders showing the byte size. The
final string should have the headers, a brief attachment section, then the plain final string should have the headers, a brief attachment section, then the plain
text extracted from all text parts. text extracted from all text parts.
### Cache Strategy
`aiCache` persists classification results. Each key is the SHA256 hex of
`"<message Message-ID>|<criterion>"` and maps to an object with `matched` and `reason`
properties. Any legacy `aiReasonCache` data is merged into `aiCache` the first
time the add-on loads after an update.
### Icon Set Usage
Toolbar and menu icons reside under `resources/img` and are provided in 16, 32
and 64 pixel variants. When changing these icons, pass a dictionary mapping the
sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`.
Use `resources/svg2img.ps1` to regenerate PNGs from the SVG sources.

View file

@ -9,62 +9,83 @@ message meets a specified criterion.
## Features ## Features
- **AI classification rule** adds the "AI classification" term with
`matches` and `doesn't match` operators.
- **Configurable endpoint** set the classification service URL on the options page. - **Configurable endpoint** set the classification service URL on the options page.
- **Prompt templates** choose between several model formats or provide your own custom template. - **Prompt templates** choose between several model formats or provide your own custom template.
- **Custom system prompts** tailor the instructions sent to the model for more precise results. - **Custom system prompts** tailor the instructions sent to the model for more precise results.
- **Filter editor integration** patches Thunderbird's filter editor to accept - **Persistent result caching** classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts.
text criteria for AI classification.
- **Persistent result caching** classification results are saved to disk so messages aren't re-evaluated across restarts.
- **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.
- **Debug logging** optional colorized logs help troubleshoot interactions with the AI service. - **Debug logging** optional colorized logs help troubleshoot interactions with the AI service.
- **Automatic rules** create rules that tag or move new messages based on AI classification. - **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.
- **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.
- **Cache management** clear cached results from the context menu or options page.
- **Queue & timing stats** monitor processing time on the Maintenance tab.
- **Packaging script** `build-xpi.ps1` builds an XPI ready for installation. - **Packaging script** `build-xpi.ps1` builds an XPI ready for installation.
- **Maintenance tab** view rule counts, cache entries and clear cached results from the options page.
### Cache Storage
Classification results are stored under the `aiCache` key in extension storage.
Each entry maps a SHA256 hash of `"<message Message-ID>|<criterion>"` to an object
containing `matched` and `reason` fields. Older installations with a separate
`aiReasonCache` will be migrated automatically on startup.
## Architecture Overview ## Architecture Overview
Sortana is implemented entirely with standard WebExtension scripts—no custom experiment code is required: Sortana is implemented entirely with standard WebExtension scripts—no custom experiment code is required:
- `background.js` loads saved settings and listens for new messages. - `background.js` loads saved settings, manages the classification queue and listens for new messages.
- `modules/ExpressionSearchFilter.jsm` implements the AI filter and performs the - `modules/AiClassifier.js` implements the classification logic and cache handling.
HTTP request. - `options/` contains the HTML and JavaScript for configuring the endpoint and rules.
- `options/` contains the HTML and JavaScript for configuring the endpoint and - `details.html` / `details.js` present cached reasoning for a message.
rules.
- `_locales/` holds localized strings used throughout the UI. - `_locales/` holds localized strings used throughout the UI.
### Key Files ### Key Files
| Path | Purpose | | Path | Purpose |
| --------------------------------------- | ---------------------------------------------- | | --------------------------------------- | ---------------------------------------------- |
| `manifest.json` | Extension manifest and entry points. | | `manifest.json` | Extension manifest and entry points. |
| `background.js` | Startup tasks and message handling. | | `background.js` | Startup tasks and classification queue management. |
| `modules/ExpressionSearchFilter.jsm` | Custom filter term and AI request logic. | | `modules/AiClassifier.js` | Core classification logic and cache handling. |
| `options/options.html` and `options.js` | Endpoint and rule configuration UI. | | `options/options.html` and `options.js` | Endpoint and rule configuration UI. |
| `logger.js` and `modules/logger.jsm` | Colorized logging with optional debug mode. | | `details.html` and `details.js` | View stored reasoning for a message. |
| `logger.js` | Colorized logging with optional debug mode. |
## Building ## Building
1. Ensure PowerShell is available (for Windows) or adapt the script for other 1. Ensure PowerShell is available (for Windows) or adapt the script for other
environments. environments.
2. Ensure the Bulma stylesheet (v1.0.4) is saved as `options/bulma.css`. You can 2. The Bulma stylesheet (v1.0.3) is already included as `options/bulma.css`.
download it from <https://github.com/jgthms/bulma/blob/1.0.4/css/bulma.css>.
3. Run `powershell ./build-xpi.ps1` from the repository root. The script reads 3. Run `powershell ./build-xpi.ps1` from the repository root. The script reads
the version from `manifest.json` and creates an XPI in the `release` folder. the version from `manifest.json` and creates an XPI in the `release` folder.
4. Install the generated XPI in Thunderbird via the Add-ons Manager. During 4. Install the generated XPI in Thunderbird via the Add-ons Manager. During
development you can also load the directory as a temporary add-on. development you can also load the directory as a temporary add-on.
5. To regenerate PNG icons from the SVG sources, run `resources/svg2img.ps1`.
## 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
@ -92,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, or actions 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
@ -100,16 +121,33 @@ 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 and junk status. - `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.
- `notifications` display error notifications.
- `compose` create reply and forward compose windows for matching rules.
## Thunderbird Add-on Store Disclosures
The [Third Party Library Usage](https://extensionworkshop.com/documentation/publish/third-party-library-usage/) policy
requires disclosure of third party libraries that are included in the add-on. Even though
the disclosure is only required for add-on review, they'll be listed here as well. Sortana
uses the following third party libraries:
- [Bulma.css v1.0.3](https://github.com/jgthms/bulma/blob/1.0.3/css/bulma.css)
- MIT License
- [turndown v7.2.0](https://github.com/mixmark-io/turndown/tree/v7.2.0)
- MIT License
- [diff](https://github.com/google/diff-match-patch/blob/62f2e689f498f9c92dbc588c58750addec9b1654/javascript/diff_match_patch_uncompressed.js)
- Apache-2.0 license
## License ## License
This project is licensed under the terms of the GNU General Public License This project is licensed under the terms of the GNU General Public License
version 3. See `LICENSE` for the full text. version 3. See `LICENSE` for the full text. Third party libraries are licensed seperately.
## Acknowledgments ## Acknowledgments
@ -119,3 +157,5 @@ Sortana builds upon knowledge gained from open-source projects. In particular,
how Thunderbird's WebExtension and experiment APIs can be extended. Their code how Thunderbird's WebExtension and experiment APIs can be extended. Their code
provided invaluable guidance during development. provided invaluable guidance during development.
- Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license.

View file

@ -14,5 +14,24 @@
"template.mistral": { "message": "Mistral" }, "template.mistral": { "message": "Mistral" },
"template.custom": { "message": "Custom" }, "template.custom": { "message": "Custom" },
"options.save": { "message": "Save" }, "options.save": { "message": "Save" },
"options.debugLogging": { "message": "Enable debug logging" } "options.debugLogging": { "message": "Enable debug logging" },
"options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" },
"options.stripUrlParams": { "message": "Remove URL tracking parameters" },
"options.altTextImages": { "message": "Replace images with alt text" },
"options.collapseWhitespace": { "message": "Collapse long whitespace" },
"options.tokenReduction": { "message": "Aggressive token reduction" }
,"action.read": { "message": "read" }
,"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.markUnread": { "message": "mark unread" }
,"param.flag": { "message": "flag" }
,"param.unflag": { "message": "unflag" }
,"param.address": { "message": "address" }
,"param.replyAll": { "message": "reply all" }
,"param.replySender": { "message": "reply sender" }
} }

View file

@ -9,6 +9,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
AGENTS.md = AGENTS.md AGENTS.md = AGENTS.md
background.js = background.js background.js = background.js
build-xpi.ps1 = build-xpi.ps1 build-xpi.ps1 = build-xpi.ps1
details.html = details.html
details.js = details.js
LICENSE = LICENSE LICENSE = LICENSE
logger.js = logger.js logger.js = logger.js
manifest.json = manifest.json manifest.json = manifest.json
@ -27,8 +29,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "options", "options", "{7372
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{75ED3C1E-D3C7-4546-9F2E-AC85859DDF4B}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{75ED3C1E-D3C7-4546-9F2E-AC85859DDF4B}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
modules\ExpressionSearchFilter.jsm = modules\ExpressionSearchFilter.jsm modules\AiClassifier.js = modules\AiClassifier.js
modules\logger.jsm = modules\logger.jsm
EndProjectSection EndProjectSection
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_locales", "_locales", "{D446E5C6-BDDE-4091-BD1A-EC57170003CF}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_locales", "_locales", "{D446E5C6-BDDE-4091-BD1A-EC57170003CF}"
@ -38,11 +39,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "en-US", "en-US", "{8BEA7793
_locales\en-US\messages.json = _locales\en-US\messages.json _locales\en-US\messages.json = _locales\en-US\messages.json
EndProjectSection EndProjectSection
EndProject 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
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prompt_templates", "prompt_templates", "{86516D53-50D4-4FE2-9D8A-977A8F5EBDBD}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prompt_templates", "prompt_templates", "{86516D53-50D4-4FE2-9D8A-977A8F5EBDBD}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
prompt_templates\mistral.txt = prompt_templates\mistral.txt prompt_templates\mistral.txt = prompt_templates\mistral.txt
@ -51,10 +47,41 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prompt_templates", "prompt_
EndProjectSection EndProjectSection
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}"
ProjectSection(SolutionItems) = preProject
resources\svg2img.ps1 = resources\svg2img.ps1
EndProjectSection
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-1755-4A95-A11B-6C90C701C5BF}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-1755-4A95-A11B-6C90C701C5BF}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
resources\img\average-16.png = resources\img\average-16.png
resources\img\average-32.png = resources\img\average-32.png
resources\img\average-64.png = resources\img\average-64.png
resources\img\check-16.png = resources\img\check-16.png
resources\img\check-32.png = resources\img\check-32.png
resources\img\check-64.png = resources\img\check-64.png
resources\img\circle-16.png = resources\img\circle-16.png
resources\img\circle-32.png = resources\img\circle-32.png
resources\img\circle-64.png = resources\img\circle-64.png
resources\img\circledots-16.png = resources\img\circledots-16.png
resources\img\circledots-32.png = resources\img\circledots-32.png
resources\img\circledots-64.png = resources\img\circledots-64.png
resources\img\clipboarddata-16.png = resources\img\clipboarddata-16.png
resources\img\clipboarddata-32.png = resources\img\clipboarddata-32.png
resources\img\clipboarddata-64.png = resources\img\clipboarddata-64.png
resources\img\download-16.png = resources\img\download-16.png
resources\img\download-32.png = resources\img\download-32.png
resources\img\download-64.png = resources\img\download-64.png
resources\img\eye-16.png = resources\img\eye-16.png
resources\img\eye-32.png = resources\img\eye-32.png
resources\img\eye-64.png = resources\img\eye-64.png
resources\img\flag-16.png = resources\img\flag-16.png
resources\img\flag-32.png = resources\img\flag-32.png
resources\img\flag-64.png = resources\img\flag-64.png
resources\img\full-logo-white.png = resources\img\full-logo-white.png
resources\img\full-logo.png = resources\img\full-logo.png resources\img\full-logo.png = resources\img\full-logo.png
resources\img\gear-16.png = resources\img\gear-16.png
resources\img\gear-32.png = resources\img\gear-32.png
resources\img\gear-64.png = resources\img\gear-64.png
resources\img\logo.png = resources\img\logo.png resources\img\logo.png = resources\img\logo.png
resources\img\logo128.png = resources\img\logo128.png resources\img\logo128.png = resources\img\logo128.png
resources\img\logo16.png = resources\img\logo16.png resources\img\logo16.png = resources\img\logo16.png
@ -62,6 +89,45 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-175
resources\img\logo48.png = resources\img\logo48.png resources\img\logo48.png = resources\img\logo48.png
resources\img\logo64.png = resources\img\logo64.png resources\img\logo64.png = resources\img\logo64.png
resources\img\logo96.png = resources\img\logo96.png resources\img\logo96.png = resources\img\logo96.png
resources\img\reply-16.png = resources\img\reply-16.png
resources\img\reply-32.png = resources\img\reply-32.png
resources\img\reply-64.png = resources\img\reply-64.png
resources\img\settings-16.png = resources\img\settings-16.png
resources\img\settings-32.png = resources\img\settings-32.png
resources\img\settings-64.png = resources\img\settings-64.png
resources\img\trash-16.png = resources\img\trash-16.png
resources\img\trash-32.png = resources\img\trash-32.png
resources\img\trash-64.png = resources\img\trash-64.png
resources\img\upload-16.png = resources\img\upload-16.png
resources\img\upload-32.png = resources\img\upload-32.png
resources\img\upload-64.png = resources\img\upload-64.png
resources\img\x-16.png = resources\img\x-16.png
resources\img\x-32.png = resources\img\x-32.png
resources\img\x-64.png = resources\img\x-64.png
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{21D2A42C-3F85-465C-9141-C106AFD92B68}"
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
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "svg", "svg", "{D4E9C905-4884-488E-B763-5BD39049C1B1}"
ProjectSection(SolutionItems) = preProject
resources\svg\average.svg = resources\svg\average.svg
resources\svg\check.svg = resources\svg\check.svg
resources\svg\circle.svg = resources\svg\circle.svg
resources\svg\circledots.svg = resources\svg\circledots.svg
resources\svg\clipboarddata.svg = resources\svg\clipboarddata.svg
resources\svg\download.svg = resources\svg\download.svg
resources\svg\eye.svg = resources\svg\eye.svg
resources\svg\flag.svg = resources\svg\flag.svg
resources\svg\gear.svg = resources\svg\gear.svg
resources\svg\reply.svg = resources\svg\reply.svg
resources\svg\settings.svg = resources\svg\settings.svg
resources\svg\trash.svg = resources\svg\trash.svg
resources\svg\upload.svg = resources\svg\upload.svg
resources\svg\x.svg = resources\svg\x.svg
EndProjectSection EndProjectSection
EndProject EndProject
Global Global
@ -73,9 +139,10 @@ Global
{75ED3C1E-D3C7-4546-9F2E-AC85859DDF4B} = {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} {D446E5C6-BDDE-4091-BD1A-EC57170003CF} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
{8BEA7793-3336-40ED-AB96-7FFB09FEB0F6} = {D446E5C6-BDDE-4091-BD1A-EC57170003CF} {8BEA7793-3336-40ED-AB96-7FFB09FEB0F6} = {D446E5C6-BDDE-4091-BD1A-EC57170003CF}
{028FDA4B-AC3E-4A0E-9291-978E213F9B78} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
{86516D53-50D4-4FE2-9D8A-977A8F5EBDBD} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70} {86516D53-50D4-4FE2-9D8A-977A8F5EBDBD} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
{68A87938-5C2B-49F5-8AAA-8A34FBBFD854} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70} {68A87938-5C2B-49F5-8AAA-8A34FBBFD854} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
{F266602F-1755-4A95-A11B-6C90C701C5BF} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854} {F266602F-1755-4A95-A11B-6C90C701C5BF} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
{21D2A42C-3F85-465C-9141-C106AFD92B68} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
{D4E9C905-4884-488E-B763-5BD39049C1B1} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View file

@ -19,6 +19,63 @@ let queue = Promise.resolve();
let queuedCount = 0; let queuedCount = 0;
let processing = false; let processing = false;
let iconTimer = null; let iconTimer = null;
let timingStats = { count: 0, mean: 0, m2: 0, total: 0, last: -1 };
let currentStart = 0;
let logGetTiming = true;
let htmlToMarkdown = false;
let stripUrlParams = false;
let altTextImages = false;
let collapseWhitespace = false;
let tokenReduction = false;
let maxTokens = 4096;
let TurndownService = null;
let userTheme = 'auto';
let currentTheme = 'light';
let detectSystemTheme;
let errorPending = false;
let showDebugTab = false;
const ERROR_NOTIFICATION_ID = 'sortana-error';
function normalizeRules(rules) {
return Array.isArray(rules) ? rules.map(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 = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
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 };
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;
}) : [];
}
function iconPaths(name) {
return {
16: `resources/img/${name}-${currentTheme}-16.png`,
32: `resources/img/${name}-${currentTheme}-32.png`,
64: `resources/img/${name}-${currentTheme}-64.png`
};
}
const ICONS = {
logo: () => 'resources/img/logo.png',
circledots: () => iconPaths('circledots'),
circle: () => iconPaths('circle'),
average: () => iconPaths('average'),
error: () => iconPaths('x')
};
function setIcon(path) { function setIcon(path) {
if (browser.browserAction) { if (browser.browserAction) {
@ -30,31 +87,67 @@ function setIcon(path) {
} }
function updateActionIcon() { function updateActionIcon() {
let path = "resources/img/logo32.png"; let path = ICONS.logo();
if (processing || queuedCount > 0) { if (errorPending) {
path = "resources/img/busy.png"; path = ICONS.error();
} else if (processing || queuedCount > 0) {
path = ICONS.circledots();
} }
setIcon(path); setIcon(path);
} }
function showTransientIcon(path, delay = 1500) { function showTransientIcon(factory, delay = 1500) {
if (errorPending) {
return;
}
clearTimeout(iconTimer); clearTimeout(iconTimer);
const path = typeof factory === 'function' ? factory() : factory;
setIcon(path); setIcon(path);
iconTimer = setTimeout(updateActionIcon, delay); iconTimer = setTimeout(updateActionIcon, delay);
} }
async function sha256Hex(str) { async function clearError() {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); errorPending = false;
return Array.from(new Uint8Array(buf), b => b.toString(16).padStart(2, '0')).join(''); await storage.local.set({ errorPending: false });
await browser.notifications.clear(ERROR_NOTIFICATION_ID);
updateActionIcon();
} }
function refreshMenuIcons() {
browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') });
browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') });
browser.menus.update('clear-ai-cache-list', { icons: iconPaths('trash') });
browser.menus.update('clear-ai-cache-display', { icons: iconPaths('trash') });
browser.menus.update('view-ai-reason-list', { icons: iconPaths('clipboarddata') });
browser.menus.update('view-ai-reason-display', { icons: iconPaths('clipboarddata') });
}
function byteSize(str) { function byteSize(str) {
return new TextEncoder().encode(str || "").length; return new TextEncoder().encode(str || "").length;
} }
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) {
let t = String(text);
if (tokenReduction) {
t = t.replace(/<!--.*?-->/gs, '')
.replace(/url\([^\)]*\)/gi, 'url(__IMG__)');
}
if (stripUrlParams) {
t = t.replace(/https?:\/\/[^\s)]+/g, m => {
const idx = m.indexOf('?');
return idx >= 0 ? m.slice(0, idx) : m;
});
}
if (collapseWhitespace) {
t = t.replace(/[\u200B-\u200D\u2060\s]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n');
}
return t;
} }
function collectText(part, bodyParts, attachments) { function collectText(part, bodyParts, attachments) {
@ -71,21 +164,236 @@ 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');
bodyParts.push(replaceInlineBase64(doc.body.textContent || "")); if (tokenReduction) {
doc.querySelectorAll('script,style').forEach(el => el.remove());
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_COMMENT);
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) {
doc.querySelectorAll('[href]').forEach(a => {
const href = a.getAttribute('href');
if (href) a.setAttribute('href', href.split('?')[0]);
});
doc.querySelectorAll('[src]').forEach(e => {
const src = e.getAttribute('src');
if (src) e.setAttribute('src', src.split('?')[0]);
});
}
if (htmlToMarkdown && TurndownService) {
try {
const td = new TurndownService();
const md = sanitizeString(td.turndown(doc.body.innerHTML || body));
bodyParts.push(replaceInlineBase64(`[HTML Body converted to Markdown]\n${md}`));
} catch (e) {
bodyParts.push(replaceInlineBase64(sanitizeString(doc.body.textContent || "")));
}
} else {
bodyParts.push(replaceInlineBase64(sanitizeString(doc.body.textContent || "")));
}
} else { } else {
bodyParts.push(replaceInlineBase64(body)); bodyParts.push(replaceInlineBase64(sanitizeString(body)));
} }
} }
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}` + (attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : ""); const attachInfo = `Attachments: ${attachments.length}` +
return `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim(); (attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : "");
let combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim();
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) {
const t = timingStats;
t.count += 1;
t.total += elapsed;
t.last = elapsed;
const delta = elapsed - t.mean;
t.mean += delta / t.count;
t.m2 += delta * (elapsed - t.mean);
}
async function getAllMessageIds(list) {
const ids = [];
if (!list) {
return ids;
}
let page = list;
ids.push(...(page.messages || []).map(m => m.id));
while (page.id) {
page = await messenger.messages.continueList(page.id);
ids.push(...(page.messages || []).map(m => m.id));
}
return ids;
}
async function processMessage(id) {
processing = true;
currentStart = Date.now();
queuedCount--;
updateActionIcon();
try {
const full = await messenger.messages.getFull(id);
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 alreadyRead = false;
let identityId = null;
try {
hdr = await messenger.messages.get(id);
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) {
currentTags = [];
alreadyRead = false;
identityId = null;
}
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 matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
if (matched) {
for (const act of (rule.actions || [])) {
if (act.type === 'tag' && act.tagKey) {
if (!currentTags.includes(act.tagKey)) {
currentTags.push(act.tagKey);
await messenger.messages.update(id, { tags: currentTags });
}
} else if (act.type === 'move' && 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') {
await messenger.messages.update(id, { junk: !!act.junk });
} else if (act.type === 'read') {
await messenger.messages.update(id, { read: !!act.read });
} else if (act.type === 'flag') {
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) {
break;
}
}
}
processing = false;
const elapsed = Date.now() - currentStart;
currentStart = 0;
updateTimingStats(elapsed);
await storage.local.set({ classifyStats: timingStats });
showTransientIcon(ICONS.circle);
} catch (e) {
processing = false;
const elapsed = Date.now() - currentStart;
currentStart = 0;
updateTimingStats(elapsed);
await storage.local.set({ classifyStats: timingStats, errorPending: true });
errorPending = true;
logger.aiLog("failed to apply AI rules", { level: 'error' }, e);
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) {
const ids = Array.isArray(idsInput) ? idsInput : [idsInput]; const ids = Array.isArray(idsInput) ? idsInput : [idsInput];
@ -93,55 +401,14 @@ async function applyAiRules(idsInput) {
if (!aiRules.length) { if (!aiRules.length) {
const { aiRules: stored } = await storage.local.get("aiRules"); const { aiRules: stored } = await storage.local.get("aiRules");
aiRules = Array.isArray(stored) ? stored.map(r => { aiRules = normalizeRules(stored);
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) { for (const msg of ids) {
const id = msg?.id ?? msg; const id = msg?.id ?? msg;
queuedCount++; queuedCount++;
updateActionIcon(); updateActionIcon();
queue = queue.then(async () => { queue = queue.then(() => processMessage(id));
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; return queue;
@ -153,217 +420,350 @@ async function clearCacheForMessages(idsInput) {
if (!aiRules.length) { if (!aiRules.length) {
const { aiRules: stored } = await storage.local.get("aiRules"); const { aiRules: stored } = await storage.local.get("aiRules");
aiRules = Array.isArray(stored) ? stored.map(r => { aiRules = normalizeRules(stored);
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 = []; const keys = [];
for (const msg of ids) { for (const msg of ids) {
const id = msg?.id ?? msg; const id = msg?.id ?? msg;
for (const rule of aiRules) { for (const rule of aiRules) {
const key = await sha256Hex(`${id}|${rule.criterion}`); const key = await AiClassifier.buildCacheKey(id, rule.criterion);
keys.push(key); keys.push(key);
} }
} }
if (keys.length) { if (keys.length) {
await AiClassifier.removeCacheEntries(keys); await AiClassifier.removeCacheEntries(keys);
showTransientIcon("resources/img/done.png"); showTransientIcon(ICONS.circle);
} }
} }
(async () => { (async () => {
logger = await import(browser.runtime.getURL("logger.js")); logger = await import(browser.runtime.getURL("logger.js"));
({ detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js')));
try { try {
AiClassifier = await import(browser.runtime.getURL("modules/AiClassifier.js")); AiClassifier = await import(browser.runtime.getURL("modules/AiClassifier.js"));
logger.aiLog("AiClassifier imported", {debug: true}); logger.aiLog("AiClassifier imported", { debug: true });
const td = await import(browser.runtime.getURL("resources/js/turndown.js"));
TurndownService = td.default || td.TurndownService;
} catch (e) { } catch (e) {
console.error("failed to import AiClassifier", e); console.error("failed to import AiClassifier", e);
return; return;
} }
try { try {
const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "aiRules"]); 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);
aiRules = Array.isArray(store.aiRules) ? store.aiRules.map(r => { userTheme = store.theme || 'auto';
if (r.actions) return r; currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
const actions = []; await AiClassifier.init();
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); htmlToMarkdown = store.htmlToMarkdown === true;
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); stripUrlParams = store.stripUrlParams === true;
const rule = { criterion: r.criterion, actions }; altTextImages = store.altTextImages === true;
if (r.stopProcessing) rule.stopProcessing = true; collapseWhitespace = store.collapseWhitespace === true;
return rule; tokenReduction = store.tokenReduction === true;
}) : []; if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') {
logger.aiLog("configuration loaded", {debug: true}, store); maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens;
}
errorPending = store.errorPending === true;
showDebugTab = store.showDebugTab === true;
const savedStats = await storage.local.get('classifyStats');
if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') {
Object.assign(timingStats, savedStats.classifyStats);
}
if (typeof timingStats.last !== 'number') {
timingStats.last = -1;
}
aiRules = normalizeRules(store.aiRules);
logger.aiLog("configuration loaded", { debug: true }, store);
storage.onChanged.addListener(async changes => { storage.onChanged.addListener(async changes => {
if (changes.aiRules) { if (changes.aiRules) {
const newRules = changes.aiRules.newValue || []; const newRules = changes.aiRules.newValue || [];
aiRules = newRules.map(r => { aiRules = normalizeRules(newRules);
if (r.actions) return r; logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules);
const actions = []; }
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (changes.endpoint || changes.templateName || changes.customTemplate || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) {
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); const config = {};
const rule = { criterion: r.criterion, actions }; if (changes.endpoint) config.endpoint = changes.endpoint.newValue;
if (r.stopProcessing) rule.stopProcessing = true; if (changes.templateName) config.templateName = changes.templateName.newValue;
return rule; if (changes.customTemplate) config.customTemplate = changes.customTemplate.newValue;
}); if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue;
logger.aiLog("aiRules updated from storage change", {debug: true}, aiRules); 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) {
htmlToMarkdown = changes.htmlToMarkdown.newValue === true;
logger.aiLog("htmlToMarkdown updated from storage change", { debug: true }, htmlToMarkdown);
}
if (changes.stripUrlParams) {
stripUrlParams = changes.stripUrlParams.newValue === true;
logger.aiLog("stripUrlParams updated from storage change", { debug: true }, stripUrlParams);
}
if (changes.altTextImages) {
altTextImages = changes.altTextImages.newValue === true;
logger.aiLog("altTextImages updated from storage change", { debug: true }, altTextImages);
}
if (changes.collapseWhitespace) {
collapseWhitespace = changes.collapseWhitespace.newValue === true;
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) {
userTheme = changes.theme.newValue || 'auto';
currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
updateActionIcon();
refreshMenuIcons();
} }
}); });
if (browser.theme?.onUpdated) {
browser.theme.onUpdated.addListener(async () => {
if (userTheme === 'auto') {
const theme = await detectSystemTheme();
if (theme !== currentTheme) {
currentTheme = theme;
updateActionIcon();
refreshMenuIcons();
}
}
});
}
} catch (err) { } catch (err) {
logger.aiLog("failed to load config", {level: 'error'}, err); logger.aiLog("failed to load config", { level: 'error' }, err);
} }
logger.aiLog("background.js loaded ready to classify", {debug: true}); logger.aiLog("background.js loaded ready to classify", { debug: true });
updateActionIcon();
if (browser.messageDisplayAction) { if (browser.messageDisplayAction) {
browser.messageDisplayAction.setTitle({ title: "Classify" }); browser.messageDisplayAction.setTitle({ title: "Details" });
if (browser.messageDisplayAction.setLabel) { if (browser.messageDisplayAction.setLabel) {
browser.messageDisplayAction.setLabel({ label: "Classify" }); browser.messageDisplayAction.setLabel({ label: "Details" });
}
}
if (browser.messageDisplayScripts?.registerScripts) {
try {
await browser.messageDisplayScripts.registerScripts([
{ js: [browser.runtime.getURL("resources/clearCacheButton.js")] },
{ js: [browser.runtime.getURL("resources/reasonButton.js")] }
]);
} catch (e) {
logger.aiLog("failed to register message display script", { level: 'warn' }, e);
} }
} }
browser.menus.create({ browser.menus.create({
id: "apply-ai-rules-list", id: "apply-ai-rules-list",
title: "Apply AI Rules", title: "Apply AI Rules",
contexts: ["message_list"], contexts: ["message_list"],
icons: iconPaths('eye')
}); });
browser.menus.create({ browser.menus.create({
id: "apply-ai-rules-display", id: "apply-ai-rules-display",
title: "Apply AI Rules", title: "Apply AI Rules",
contexts: ["message_display_action"], contexts: ["message_display_action"],
icons: iconPaths('eye')
}); });
browser.menus.create({ browser.menus.create({
id: "clear-ai-cache-list", id: "clear-ai-cache-list",
title: "Clear AI Cache", title: "Clear AI Cache",
contexts: ["message_list"], contexts: ["message_list"],
icons: iconPaths('trash')
}); });
browser.menus.create({ browser.menus.create({
id: "clear-ai-cache-display", id: "clear-ai-cache-display",
title: "Clear AI Cache", title: "Clear AI Cache",
contexts: ["message_display_action"], contexts: ["message_display_action"],
icons: iconPaths('trash')
}); });
browser.menus.create({
id: "view-ai-reason-list",
title: "View Reasoning",
contexts: ["message_list"],
icons: iconPaths('clipboarddata')
});
browser.menus.create({
id: "view-ai-reason-display",
title: "View Reasoning",
contexts: ["message_display_action"],
icons: iconPaths('clipboarddata')
});
refreshMenuIcons();
if (browser.messageDisplayAction) { browser.menus.onClicked.addListener(async (info, tab) => {
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") { if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") {
const ids = info.selectedMessages?.messages?.map(m => m.id) || let ids = info.messageId ? [info.messageId] : [];
(info.messageId ? [info.messageId] : []); if (info.selectedMessages) {
ids = await getAllMessageIds(info.selectedMessages);
}
await applyAiRules(ids); await applyAiRules(ids);
} else if (info.menuItemId === "clear-ai-cache-list" || info.menuItemId === "clear-ai-cache-display") { } else if (info.menuItemId === "clear-ai-cache-list" || info.menuItemId === "clear-ai-cache-display") {
const ids = info.selectedMessages?.messages?.map(m => m.id) || let ids = info.messageId ? [info.messageId] : [];
(info.messageId ? [info.messageId] : []); if (info.selectedMessages) {
ids = await getAllMessageIds(info.selectedMessages);
}
await clearCacheForMessages(ids); await clearCacheForMessages(ids);
} else if (info.menuItemId === "view-ai-reason-list" || info.menuItemId === "view-ai-reason-display") {
const [header] = await browser.messageDisplay.getDisplayedMessages(tab.id);
if (!header) { return; }
const url = `${browser.runtime.getURL("details.html")}?mid=${header.id}`;
await browser.tabs.create({ url });
} }
}); });
// Listen for messages from UI/devtools // Listen for messages from UI/devtools
browser.runtime.onMessage.addListener(async (msg) => { browser.runtime.onMessage.addListener(async (msg) => {
logger.aiLog("onMessage received", {debug: true}, msg); if ((msg?.type === "sortana:getTiming" && logGetTiming) || (msg?.type !== "sortana:getTiming")) {
logGetTiming = false;
logger.aiLog("onMessage received", { debug: true }, msg);
}
if (msg?.type === "aiFilter:test") { if (msg?.type === "sortana:test") {
const { text = "", criterion = "" } = msg; const { text = "", criterion = "" } = msg;
logger.aiLog("aiFilter:test text", {debug: true}, text); logger.aiLog("sortana:test text", { debug: true }, text);
logger.aiLog("aiFilter:test criterion", {debug: true}, criterion); logger.aiLog("sortana:test criterion", { debug: true }, criterion);
try { try {
logger.aiLog("Calling AiClassifier.classifyText()", {debug: true}); logger.aiLog("Calling AiClassifier.classifyText()", { debug: true });
const result = await AiClassifier.classifyText(text, criterion); const result = await AiClassifier.classifyText(text, criterion);
logger.aiLog("classify() returned", {debug: true}, result); logger.aiLog("classify() returned", { debug: true }, result);
return { match: 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 if (msg?.type === "sortana:getReasons") {
try {
const id = msg.id;
const hdr = await messenger.messages.get(id);
const subject = hdr?.subject || "";
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 reasons = []; catch (err) {
for (const rule of aiRules) { logger.aiLog("Error in classify()", { level: 'error' }, err);
const key = await sha256Hex(`${id}|${rule.criterion}`); // rethrow so the caller sees the failure
const reason = AiClassifier.getReason(key); throw err;
if (reason) { }
reasons.push({ criterion: rule.criterion, reason }); } else if (msg?.type === "sortana:clearCacheForDisplayed") {
try {
const msgs = await browser.messageDisplay.getDisplayedMessages();
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 if (msg?.type === "sortana:getReasons") {
try {
const id = msg.id;
const hdr = await messenger.messages.get(id);
const subject = hdr?.subject || "";
if (!aiRules.length) {
const { aiRules: stored } = await storage.local.get("aiRules");
aiRules = normalizeRules(stored);
} }
const reasons = [];
for (const rule of aiRules) {
const key = await AiClassifier.buildCacheKey(id, rule.criterion);
const reason = AiClassifier.getReason(key);
if (reason) {
reasons.push({ criterion: rule.criterion, reason });
}
}
return { subject, reasons };
} catch (e) {
logger.aiLog("failed to collect reasons", { level: 'error' }, e);
return { subject: '', reasons: [] };
} }
return { subject, reasons }; } else if (msg?.type === "sortana:getDetails") {
} catch (e) { try {
logger.aiLog("failed to collect reasons", { level: 'error' }, e); const id = msg.id;
return { subject: '', reasons: [] }; const hdr = await messenger.messages.get(id);
const subject = hdr?.subject || "";
if (!aiRules.length) {
const { aiRules: stored } = await storage.local.get("aiRules");
aiRules = normalizeRules(stored);
}
const results = [];
for (const rule of aiRules) {
const key = await AiClassifier.buildCacheKey(id, rule.criterion);
const matched = AiClassifier.getCachedResult(key);
const reason = AiClassifier.getReason(key);
if (matched !== null || reason) {
results.push({ criterion: rule.criterion, matched, reason });
}
}
return { subject, results };
} catch (e) {
logger.aiLog("failed to collect details", { level: 'error' }, e);
return { subject: '', results: [] };
}
} else if (msg?.type === "sortana:getDisplayedMessages") {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const messages = await browser.messageDisplay.getDisplayedMessages(tab?.id);
const ids = messages.map(hdr => hdr.id);
return { ids };
} catch (e) {
logger.aiLog("failed to get displayed messages", { level: 'error' }, e);
return { messages: [] };
}
} else if (msg?.type === "sortana:clearCacheForMessage") {
try {
await clearCacheForMessages([msg.id]);
return { ok: true };
} catch (e) {
logger.aiLog("failed to clear cache for message", { level: 'error' }, e);
return { ok: false };
}
} else if (msg?.type === "sortana:getQueueCount") {
return { count: queuedCount + (processing ? 1 : 0) };
} else if (msg?.type === "sortana:getTiming") {
const t = timingStats;
const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0;
return {
count: queuedCount + (processing ? 1 : 0),
current: currentStart ? Date.now() - currentStart : -1,
last: t.last,
runs: t.count,
average: t.mean,
total: t.total,
stddev: std
};
} else {
logger.aiLog("Unknown message type, ignoring", { level: 'warn' }, msg?.type);
} }
} 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 // Automatically classify new messages
window.addEventListener("unhandledrejection", ev => { if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
logger.aiLog("Unhandled promise rejection", {level: 'error'}, ev.reason); 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.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") {

View file

@ -26,7 +26,7 @@ if (-not $version) {
} }
# 4) Define output names & clean up # 4) Define output names & clean up
$xpiName = "ai-filter-$version.xpi" $xpiName = "sortana-$version.xpi"
$zipPath = Join-Path $ReleaseDir "ai-filter-$version.zip" $zipPath = Join-Path $ReleaseDir "ai-filter-$version.zip"
$xpiPath = Join-Path $ReleaseDir $xpiName $xpiPath = Join-Path $ReleaseDir $xpiName

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>AI Reasoning</title> <title>AI Details</title>
<link rel="stylesheet" href="options/bulma.css"> <link rel="stylesheet" href="options/bulma.css">
</head> </head>
<body> <body>
@ -10,8 +10,11 @@
<div class="container"> <div class="container">
<h1 class="title" id="subject"></h1> <h1 class="title" id="subject"></h1>
<div id="rules"></div> <div id="rules"></div>
<div class="buttons mt-4">
<button class="button is-danger" id="clear">Clear Cache</button>
</div>
</div> </div>
</section> </section>
<script src="reasoning.js"></script> <script type="module" src="details.js"></script>
</body> </body>
</html> </html>

75
details.js Normal file
View file

@ -0,0 +1,75 @@
const aiLog = (await import(browser.runtime.getURL("logger.js"))).aiLog;
const storage = (globalThis.messenger ?? browser).storage;
const { detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js'));
const { theme } = await storage.local.get('theme');
const mode = (theme || 'auto') === 'auto'
? await detectSystemTheme()
: theme;
document.documentElement.dataset.theme = mode;
const qMid = parseInt(new URLSearchParams(location.search).get("mid"), 10);
if (!isNaN(qMid)) {
loadMessage(qMid);
} else {
const { ids } = await browser.runtime.sendMessage({
type: "sortana:getDisplayedMessages",
});
if (ids && ids[0]) {
loadMessage(ids[0]);
} else {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
const tabId = tabs[0]?.id;
const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : [];
let id = msgs[0]?.id;
if (id) {
loadMessage(id);
}
else {
aiLog("Details popup: no displayed message found");
}
}
}
async function loadMessage(id) {
const storage = (globalThis.messenger ?? browser).storage;
const logMod = await import(browser.runtime.getURL('logger.js'));
const { debugLogging } = await storage.local.get('debugLogging');
logMod.setDebug(debugLogging === true);
const log = logMod.aiLog;
log('details page loaded', { debug: true });
try {
log('requesting message details', {}, id);
const { subject, results } = await browser.runtime.sendMessage({ type: 'sortana:getDetails', id });
log('received details', { debug: true }, { subject, results });
document.getElementById('subject').textContent = subject;
const container = document.getElementById('rules');
for (const r of results) {
log('rendering rule result', { debug: true }, r);
const article = document.createElement('article');
const color = r.matched === true ? 'is-success' : 'is-danger';
article.className = `message ${color} mb-4`;
const header = document.createElement('div');
header.className = 'message-header';
header.innerHTML = `<p>${r.criterion}</p>`;
const body = document.createElement('div');
body.className = 'message-body';
const status = document.createElement('p');
status.textContent = r.matched ? 'Matched' : 'Did not match';
const pre = document.createElement('pre');
pre.textContent = r.reason || '';
body.appendChild(status);
body.appendChild(pre);
article.appendChild(header);
article.appendChild(body);
container.appendChild(article);
}
document.getElementById('clear').addEventListener('click', async () => {
log('clearing cache for message', {}, id);
await browser.runtime.sendMessage({ type: 'sortana:clearCacheForMessage', id });
window.close();
});
} catch (e) {
log('failed to load details', { level: 'error' }, e);
}
}

View file

@ -1,13 +1,13 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "Sortana", "name": "Sortana",
"version": "2.0.0", "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": "*" "strict_max_version": "140.*"
} }
}, },
"icons": { "icons": {
@ -22,22 +22,28 @@
"default_icon": "resources/img/logo32.png" "default_icon": "resources/img/logo32.png"
}, },
"message_display_action": { "message_display_action": {
"default_icon": "resources/img/logo32.png", "default_icon": "resources/img/logo.png",
"default_title": "Classify", "default_title": "Details",
"default_label": "Classify" "default_label": "Details",
"default_popup": "details.html"
}, },
"background": { "scripts": [ "background.js" ] }, "background": { "scripts": [ "background.js" ] },
"options_ui": { "options_ui": {
"page": "options/options.html", "page": "options/options.html",
"open_in_tab": true "open_in_tab": true
}, },
"permissions": [ "permissions": [
"storage", "storage",
"messagesRead", "messagesRead",
"messagesMove", "messagesMove",
"messagesUpdate", "messagesUpdate",
"messagesTagsList", "messagesTagsList",
"accountsRead", "accountsRead",
"menus" "menus",
] "notifications",
"scripting",
"tabs",
"theme",
"compose"
]
} }

View file

@ -1,5 +1,6 @@
"use strict"; "use strict";
import { aiLog, setDebug } from "../logger.js"; import { aiLog, setDebug } from "../logger.js";
import { DEFAULT_AI_PARAMS } from "./defaultParams.js";
const storage = (globalThis.messenger ?? globalThis.browser).storage; const storage = (globalThis.messenger ?? globalThis.browser).storage;
@ -33,24 +34,54 @@ let gCustomTemplate = "";
let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT; let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT;
let gTemplateText = ""; let gTemplateText = "";
let gAiParams = { let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS);
max_tokens: 4096,
temperature: 0.6,
top_p: 0.95,
seed: -1,
repetition_penalty: 1.0,
top_k: 20,
min_p: 0,
presence_penalty: 0,
frequency_penalty: 0,
typical_p: 1,
tfs: 1,
};
let gCache = new Map(); let gCache = new Map();
let gCacheLoaded = false; let gCacheLoaded = false;
let gReasonCache = new Map();
let gReasonCacheLoaded = false; function sha256HexSync(str) {
try {
const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
hasher.init(Ci.nsICryptoHash.SHA256);
const data = new TextEncoder().encode(str);
hasher.update(data, data.length);
const binary = hasher.finish(false);
return Array.from(binary, c => ("0" + c.charCodeAt(0).toString(16)).slice(-2)).join("");
} catch (e) {
aiLog(`sha256HexSync failed`, { level: 'error' }, e);
return "";
}
}
async function sha256Hex(str) {
if (typeof crypto?.subtle?.digest === "function") {
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("");
}
return sha256HexSync(str);
}
async function resolveHeaderId(id) {
if (typeof id === "number" && typeof messenger?.messages?.get === "function") {
try {
const hdr = await messenger.messages.get(id);
if (hdr?.headerMessageId) {
return hdr.headerMessageId;
}
} catch (e) {
aiLog(`Failed to resolve headerMessageId for ${id}`, { level: 'warn' }, e);
}
}
return String(id);
}
async function buildCacheKey(id, criterion) {
const resolvedId = await resolveHeaderId(id);
if (Services) {
return sha256HexSync(`${resolvedId}|${criterion}`);
}
return sha256Hex(`${resolvedId}|${criterion}`);
}
async function loadCache() { async function loadCache() {
if (gCacheLoaded) { if (gCacheLoaded) {
@ -58,32 +89,35 @@ async function loadCache() {
} }
aiLog(`[AiClassifier] Loading cache`, {debug: true}); aiLog(`[AiClassifier] Loading cache`, {debug: true});
try { try {
const { aiCache } = await storage.local.get("aiCache"); const { aiCache, aiReasonCache } = await storage.local.get(["aiCache", "aiReasonCache"]);
if (aiCache) { if (aiCache) {
for (let [k, v] of Object.entries(aiCache)) { for (let [k, v] of Object.entries(aiCache)) {
aiLog(`[AiClassifier] ⮡ Loaded entry '${k}' → ${v}`, {debug: true}); if (v && typeof v === "object") {
gCache.set(k, v); gCache.set(k, { matched: v.matched ?? null, reason: v.reason || "" });
} else {
gCache.set(k, { matched: v, reason: "" });
}
} }
aiLog(`[AiClassifier] Loaded ${gCache.size} cache entries`, {debug: true}); aiLog(`[AiClassifier] Loaded ${gCache.size} cache entries`, {debug: true});
} else { } else {
aiLog(`[AiClassifier] Cache is empty`, {debug: true}); aiLog(`[AiClassifier] Cache is empty`, {debug: true});
} }
if (aiReasonCache) {
aiLog(`[AiClassifier] Migrating ${Object.keys(aiReasonCache).length} reason entries`, {debug: true});
for (let [k, reason] of Object.entries(aiReasonCache)) {
let entry = gCache.get(k) || { matched: null, reason: "" };
entry.reason = reason;
gCache.set(k, entry);
}
await storage.local.remove("aiReasonCache");
await storage.local.set({ aiCache: Object.fromEntries(gCache) });
}
} catch (e) { } catch (e) {
aiLog(`Failed to load cache`, {level: 'error'}, e); aiLog(`Failed to load cache`, {level: 'error'}, e);
} }
gCacheLoaded = true; gCacheLoaded = true;
} }
function loadCacheSync() {
if (!gCacheLoaded) {
if (!Services?.tm?.spinEventLoopUntil) {
throw new Error("loadCacheSync requires Services");
}
let done = false;
loadCache().finally(() => { done = true; });
Services.tm.spinEventLoopUntil(() => done);
}
}
async function saveCache(updatedKey, updatedValue) { async function saveCache(updatedKey, updatedValue) {
if (typeof updatedKey !== "undefined") { if (typeof updatedKey !== "undefined") {
@ -96,49 +130,6 @@ async function saveCache(updatedKey, updatedValue) {
} }
} }
async function loadReasonCache() {
if (gReasonCacheLoaded) {
return;
}
aiLog(`[AiClassifier] Loading reason cache`, {debug: true});
try {
const { aiReasonCache } = await storage.local.get("aiReasonCache");
if (aiReasonCache) {
for (let [k, v] of Object.entries(aiReasonCache)) {
aiLog(`[AiClassifier] ⮡ Loaded reason '${k}'`, {debug: true});
gReasonCache.set(k, v);
}
aiLog(`[AiClassifier] Loaded ${gReasonCache.size} reason entries`, {debug: true});
} else {
aiLog(`[AiClassifier] Reason cache is empty`, {debug: true});
}
} catch (e) {
aiLog(`Failed to load reason cache`, {level: 'error'}, e);
}
gReasonCacheLoaded = true;
}
function loadReasonCacheSync() {
if (!gReasonCacheLoaded) {
if (!Services?.tm?.spinEventLoopUntil) {
throw new Error("loadReasonCacheSync requires Services");
}
let done = false;
loadReasonCache().finally(() => { done = true; });
Services.tm.spinEventLoopUntil(() => done);
}
}
async function saveReasonCache(updatedKey, updatedValue) {
if (typeof updatedKey !== "undefined") {
aiLog(`[AiClassifier] ⮡ Persisting reason '${updatedKey}'`, {debug: true});
}
try {
await storage.local.set({ aiReasonCache: Object.fromEntries(gReasonCache) });
} catch (e) {
aiLog(`Failed to save reason cache`, {level: 'error'}, e);
}
}
async function loadTemplate(name) { async function loadTemplate(name) {
try { try {
@ -217,29 +208,22 @@ function buildPrompt(body, criterion) {
function getCachedResult(cacheKey) { function getCachedResult(cacheKey) {
if (!gCacheLoaded) { if (!gCacheLoaded) {
if (Services?.tm?.spinEventLoopUntil) { return null;
loadCacheSync();
} else {
// In non-privileged contexts we can't block, so bail out early.
return null;
}
} }
if (cacheKey && gCache.has(cacheKey)) { if (cacheKey && gCache.has(cacheKey)) {
aiLog(`[AiClassifier] Cache hit for key: ${cacheKey}`, {debug: true}); aiLog(`[AiClassifier] Cache hit for key: ${cacheKey}`, {debug: true});
return gCache.get(cacheKey); const entry = gCache.get(cacheKey);
return entry?.matched ?? null;
} }
return null; return null;
} }
function getReason(cacheKey) { function getReason(cacheKey) {
if (!gReasonCacheLoaded) { if (!gCacheLoaded) {
if (Services?.tm?.spinEventLoopUntil) { return null;
loadReasonCacheSync();
} else {
return null;
}
} }
return cacheKey ? gReasonCache.get(cacheKey) || null : null; const entry = gCache.get(cacheKey);
return cacheKey && entry ? entry.reason || null : null;
} }
function buildPayload(text, criterion) { function buildPayload(text, criterion) {
@ -260,20 +244,20 @@ function parseMatch(result) {
return { matched, reason: thinkText }; return { matched, reason: thinkText };
} }
function cacheResult(cacheKey, matched) { function cacheEntry(cacheKey, matched, reason) {
if (cacheKey) { if (!cacheKey) {
aiLog(`[AiClassifier] Caching entry '${cacheKey}' → ${matched}`, {debug: true}); return;
gCache.set(cacheKey, matched);
saveCache(cacheKey, matched);
} }
} aiLog(`[AiClassifier] Caching entry '${cacheKey}'`, {debug: true});
const entry = gCache.get(cacheKey) || { matched: null, reason: "" };
function cacheReason(cacheKey, reason) { if (typeof matched === "boolean") {
if (cacheKey) { entry.matched = matched;
aiLog(`[AiClassifier] Caching reason '${cacheKey}'`, {debug: true});
gReasonCache.set(cacheKey, reason);
saveReasonCache(cacheKey, reason);
} }
if (typeof reason === "string") {
entry.reason = reason;
}
gCache.set(cacheKey, entry);
saveCache(cacheKey, entry);
} }
async function removeCacheEntries(keys = []) { async function removeCacheEntries(keys = []) {
@ -289,79 +273,49 @@ async function removeCacheEntries(keys = []) {
removed = true; removed = true;
aiLog(`[AiClassifier] Removed cache entry '${key}'`, {debug: true}); aiLog(`[AiClassifier] Removed cache entry '${key}'`, {debug: true});
} }
if (gReasonCache.delete(key)) {
removed = true;
aiLog(`[AiClassifier] Removed reason entry '${key}'`, {debug: true});
}
} }
if (removed) { if (removed) {
await saveCache(); await saveCache();
await saveReasonCache();
} }
} }
function classifyTextSync(text, criterion, cacheKey = null) { async function clearCache() {
if (!Services?.tm?.spinEventLoopUntil) { if (!gCacheLoaded) {
throw new Error("classifyTextSync requires Services"); await loadCache();
} }
if (!gReasonCacheLoaded) { if (gCache.size > 0) {
loadReasonCacheSync(); gCache.clear();
await saveCache();
aiLog(`[AiClassifier] Cleared cache`, {debug: true});
} }
const cached = getCachedResult(cacheKey);
if (cached !== null) {
return cached;
}
const payload = buildPayload(text, criterion);
aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true});
let result;
let done = false;
(async () => {
try {
const response = await fetch(gEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
});
if (response.ok) {
const json = await response.json();
aiLog(`[AiClassifier] Received response:`, {debug: true}, json);
result = parseMatch(json);
cacheResult(cacheKey, result.matched);
cacheReason(cacheKey, result.reason);
result = result.matched;
} else {
aiLog(`HTTP status ${response.status}`, {level: 'warn'});
result = false;
}
} catch (e) {
aiLog(`HTTP request failed`, {level: 'error'}, e);
result = false;
} finally {
done = true;
}
})();
Services.tm.spinEventLoopUntil(() => done);
return result;
} }
async function getCacheSize() {
if (!gCacheLoaded) {
await loadCache();
}
return gCache.size;
}
async function classifyText(text, criterion, cacheKey = null) { async function classifyText(text, criterion, cacheKey = null) {
if (!gCacheLoaded) { if (!gCacheLoaded) {
await loadCache(); await loadCache();
} }
if (!gReasonCacheLoaded) {
await loadReasonCache();
}
const cached = getCachedResult(cacheKey); const cached = getCachedResult(cacheKey);
if (cached !== null) { if (cached !== null) {
return cached; return cached;
} }
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);
try { try {
const response = await fetch(gEndpoint, { const response = await fetch(gEndpoint, {
@ -378,8 +332,7 @@ async function classifyText(text, criterion, cacheKey = null) {
const result = await response.json(); const result = await response.json();
aiLog(`[AiClassifier] Received response:`, {debug: true}, result); aiLog(`[AiClassifier] Received response:`, {debug: true}, result);
const parsed = parseMatch(result); const parsed = parseMatch(result);
cacheResult(cacheKey, parsed.matched); cacheEntry(cacheKey, parsed.matched, parsed.reason);
cacheReason(cacheKey, parsed.reason);
return parsed.matched; return parsed.matched;
} catch (e) { } catch (e) {
aiLog(`HTTP request failed`, {level: 'error'}, e); aiLog(`HTTP request failed`, {level: 'error'}, e);
@ -387,4 +340,8 @@ async function classifyText(text, criterion, cacheKey = null) {
} }
} }
export { classifyText, classifyTextSync, setConfig, removeCacheEntries, getReason }; async function init() {
await loadCache();
}
export { classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init };

View file

@ -1,99 +0,0 @@
"use strict";
var { ExtensionParent } = ChromeUtils.importESModule("resource://gre/modules/ExtensionParent.sys.mjs");
var { MailServices } = ChromeUtils.importESModule("resource:///modules/MailServices.sys.mjs");
var { aiLog } = ChromeUtils.import("resource://aifilter/modules/logger.jsm");
var AiClassifier = ChromeUtils.importESModule("resource://aifilter/modules/AiClassifier.js");
var { getPlainText } = ChromeUtils.import("resource://aifilter/modules/messageUtils.jsm");
function sha256Hex(str) {
const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
hasher.init(Ci.nsICryptoHash.SHA256);
const data = new TextEncoder().encode(str);
hasher.update(data, data.length);
const binary = hasher.finish(false);
return Array.from(binary, c => ("0" + c.charCodeAt(0).toString(16)).slice(-2)).join("");
}
var EXPORTED_SYMBOLS = ["AIFilter", "ClassificationTerm"];
class CustomerTermBase {
constructor(nameId, operators) {
// Lookup our extension instance using the ID from manifest.json
// so locale strings are resolved correctly.
this.extension = ExtensionParent.GlobalManager.getExtension("ai-filter@jordanwages");
this.id = "aifilter#" + nameId;
this.name = this.extension.localeData.localizeMessage(nameId);
this.operators = operators;
aiLog(`[ExpressionSearchFilter] Initialized term base "${this.id}"`, {debug: true});
}
getEnabled() {
aiLog(`[ExpressionSearchFilter] getEnabled() called on "${this.id}"`, {debug: true});
return true;
}
getAvailable() {
aiLog(`[ExpressionSearchFilter] getAvailable() called on "${this.id}"`, {debug: true});
return true;
}
getAvailableOperators() {
aiLog(`[ExpressionSearchFilter] getAvailableOperators() called on "${this.id}"`, {debug: true});
return this.operators;
}
getAvailableValues() {
aiLog(`[ExpressionSearchFilter] getAvailableValues() called on "${this.id}"`, {debug: true});
return null;
}
get attrib() {
aiLog(`[ExpressionSearchFilter] attrib getter called for "${this.id}"`, {debug: true});
//return Ci.nsMsgSearchAttrib.Custom;
}
}
class ClassificationTerm extends CustomerTermBase {
constructor() {
super("classification", [Ci.nsMsgSearchOp.Matches, Ci.nsMsgSearchOp.DoesntMatch]);
aiLog(`[ExpressionSearchFilter] ClassificationTerm constructed`, {debug: true});
}
needsBody() { return true; }
match(msgHdr, value, op) {
const opName = op === Ci.nsMsgSearchOp.Matches ? "matches" :
op === Ci.nsMsgSearchOp.DoesntMatch ? "doesn't match" : `unknown (${op})`;
aiLog(`[ExpressionSearchFilter] Matching message ${msgHdr.messageId} using op "${opName}" and value "${value}"`, {debug: true});
let key = [msgHdr.messageId, op, value].map(sha256Hex).join("|");
let body = getPlainText(msgHdr);
let matched = AiClassifier.classifyTextSync(body, value, key);
if (op === Ci.nsMsgSearchOp.DoesntMatch) {
matched = !matched;
aiLog(`[ExpressionSearchFilter] Operator is "doesn't match" → inverting to ${matched}`, {debug: true});
}
aiLog(`[ExpressionSearchFilter] Final match result: ${matched}`, {debug: true});
return matched;
}
}
(function register() {
aiLog(`[ExpressionSearchFilter] Registering custom filter term...`, {debug: true});
let term = new ClassificationTerm();
if (!MailServices.filters.getCustomTerm(term.id)) {
MailServices.filters.addCustomTerm(term);
aiLog(`[ExpressionSearchFilter] Registered term: ${term.id}`, {debug: true});
} else {
aiLog(`[ExpressionSearchFilter] Term already registered: ${term.id}`, {debug: true});
}
})();
var AIFilter = { setConfig: AiClassifier.setConfig };

16
modules/defaultParams.js Normal file
View file

@ -0,0 +1,16 @@
"use strict";
export const DEFAULT_AI_PARAMS = {
max_tokens: 4096,
temperature: 0.6,
top_p: 0.95,
seed: -1,
repetition_penalty: 1.0,
top_k: 20,
min_p: 0,
presence_penalty: 0,
frequency_penalty: 0,
typical_p: 1,
tfs: 1,
};

View file

@ -1,26 +0,0 @@
var EXPORTED_SYMBOLS = ['aiLog', 'setDebug'];
let debugEnabled = false;
function setDebug(value) {
debugEnabled = !!value;
}
function getCaller() {
try {
let stack = new Error().stack.split('\n');
if (stack.length >= 3) {
return stack[2].trim().replace(/^@?\s*\(?/,'').replace(/^at\s+/, '');
}
} catch (e) {}
return '';
}
function aiLog(message, opts = {}, ...args) {
const { level = 'log', debug = false } = opts;
if (debug && !debugEnabled) {
return;
}
const caller = getCaller();
const prefix = caller ? `[ai-filter][${caller}]` : '[ai-filter]';
console[level](`%c${prefix}`, 'color:#1c92d2;font-weight:bold', message, ...args);
}

View file

@ -1,89 +0,0 @@
"use strict";
var { NetUtil } = ChromeUtils.importESModule("resource://gre/modules/NetUtil.sys.mjs");
var { MimeParser } = ChromeUtils.importESModule("resource:///modules/mimeParser.sys.mjs");
var { aiLog } = ChromeUtils.import("resource://aifilter/modules/logger.jsm");
var EXPORTED_SYMBOLS = ["getPlainText"];
function getPlainText(msgHdr) {
aiLog(`[ExpressionSearchFilter] Extracting plain text for message ID ${msgHdr.messageId}`, {debug: true});
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);
try {
let root = MimeParser.parseSync(data, {strformat: "unicode"});
let parts = [];
function pushPlaceholder(type, info, bytes) {
bytes = bytes || 0;
let prettyType = type.split("/")[1] || type;
parts.push(`[${info}: ${prettyType}, ${bytes} bytes]`);
}
function byteSizeFromBase64(str) {
let clean = str.replace(/[^A-Za-z0-9+/=]/g, "");
return Math.floor(clean.length * 3 / 4);
}
function replaceInlineBase64(text) {
return text.replace(/[A-Za-z0-9+/]{100,}={0,2}/g,
m => `[base64: ${byteSizeFromBase64(m)} bytes]`);
}
function walk(node) {
if (node.parts && node.parts.length) {
for (let child of node.parts) {
walk(child);
}
return;
}
let ct = (node.contentType || "text/plain").toLowerCase();
let cd = (node.headers?.["content-disposition"]?.[0] || "").toLowerCase();
let enc = (node.headers?.["content-transfer-encoding"]?.[0] || "").toLowerCase();
let bodyText = String(node.body || "");
if (cd.includes("attachment")) {
pushPlaceholder(ct, "binary attachment", byteSizeFromBase64(bodyText));
} else if (ct.startsWith("text/plain")) {
if (enc === "base64") {
parts.push(`[base64: ${byteSizeFromBase64(bodyText)} bytes]`);
} else {
parts.push(replaceInlineBase64(bodyText));
}
} else if (ct.startsWith("text/html")) {
if (enc === "base64") {
parts.push(`[base64: ${byteSizeFromBase64(bodyText)} bytes]`);
} else {
let txt = parser.convertToPlainText(bodyText,
Ci.nsIDocumentEncoder.OutputLFLineBreak |
Ci.nsIDocumentEncoder.OutputNoScriptContent |
Ci.nsIDocumentEncoder.OutputNoFramesContent |
Ci.nsIDocumentEncoder.OutputBodyOnly, 0);
parts.push(replaceInlineBase64(txt));
}
} else {
// Other single part types treated as attachments
pushPlaceholder(ct, "binary attachment", byteSizeFromBase64(bodyText));
}
}
walk(root);
return parts.join("\n");
} catch (e) {
// Fallback: convert entire raw message to text
aiLog(`Failed to parse MIME, falling back to raw conversion`, {level: 'warn'}, e);
return parser.convertToPlainText(data,
Ci.nsIDocumentEncoder.OutputLFLineBreak |
Ci.nsIDocumentEncoder.OutputNoScriptContent |
Ci.nsIDocumentEncoder.OutputNoFramesContent |
Ci.nsIDocumentEncoder.OutputBodyOnly, 0);
}
}

20
modules/themeUtils.js Normal file
View file

@ -0,0 +1,20 @@
"use strict";
export async function detectSystemTheme() {
try {
const t = await browser.theme.getCurrent();
const scheme = t?.properties?.color_scheme;
if (scheme === 'dark' || scheme === 'light') {
return scheme;
}
const color = t?.colors?.frame || t?.colors?.toolbar;
if (color && /^#/.test(color)) {
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum < 0.5 ? 'dark' : 'light';
}
} catch {}
return 'light';
}

45
options/dataTransfer.js Normal file
View file

@ -0,0 +1,45 @@
"use strict";
const storage = (globalThis.messenger ?? browser).storage;
const KEY_GROUPS = {
settings: [
'endpoint',
'templateName',
'customTemplate',
'customSystemPrompt',
'aiParams',
'debugLogging',
'htmlToMarkdown',
'stripUrlParams',
'altTextImages',
'collapseWhitespace'
],
rules: ['aiRules'],
cache: ['aiCache']
};
function collectKeys(categories = Object.keys(KEY_GROUPS)) {
return categories.flatMap(cat => KEY_GROUPS[cat] || []);
}
export async function exportData(categories) {
const data = await storage.local.get(collectKeys(categories));
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sortana-export.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export async function importData(file, categories) {
const text = await file.text();
const parsed = JSON.parse(text);
const data = {};
for (const key of collectKeys(categories)) {
if (key in parsed) data[key] = parsed[key];
}
await storage.local.set(data);
}

View file

@ -31,30 +31,43 @@
.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>
<section class="section"> <section class="section">
<div class="container" id="options-container"> <div class="container" id="options-container">
<figure class="has-text-centered mb-4"> <figure class="has-text-centered mb-4">
<img src="../resources/img/full-logo.png" alt="AI Filter Logo" style="max-height:40px;"> <img data-icon="full-logo" src="../resources/img/full-logo.png" alt="AI Filter Logo" style="max-height:40px;">
</figure> </figure>
<div class="level mb-4"> <div class="level mb-4">
<div class="level-left"> <div class="level-left">
<div class="tabs" id="main-tabs"> <div class="tabs" id="main-tabs">
<ul> <ul>
<li class="is-active" data-tab="settings"><a>Settings</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>Rules</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 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>
<div class="level-right"> <div class="level-right">
<button class="button is-primary" id="save" disabled>Save</button> <button class="button is-primary" id="save" disabled>
<span class="icon is-small"><img data-icon="flag" data-size="16" src="../resources/img/flag-light-16.png" alt=""></span>
<span>Save</span>
</button>
</div> </div>
</div> </div>
<div id="settings-tab" class="tab-content"> <div id="settings-tab" class="tab-content">
<h2 class="title is-4">
<span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span>
<span>Settings</span>
</h2>
<div class="field"> <div class="field">
<label class="label" for="endpoint">Endpoint</label> <label class="label" for="endpoint">Endpoint</label>
<div class="control"> <div class="control">
@ -86,9 +99,28 @@
</div> </div>
</div> </div>
<div class="field">
<label class="label" for="theme-select">Theme</label>
<div class="control">
<div class="select">
<select id="theme-select">
<option value="auto">Match Thunderbird</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
</div>
<div class="buttons"> <div class="buttons">
<button class="button is-danger" id="reset-system">Reset to default</button> <button class="button is-danger" id="reset-system">
<button class="button" id="toggle-advanced" type="button">Advanced</button> <span class="icon is-small"><img data-icon="reply" data-size="16" src="../resources/img/reply-light-16.png" alt=""></span>
<span>Reset to default</span>
</button>
<button class="button" id="toggle-advanced" type="button">
<span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span>
<span>Advanced</span>
</button>
</div> </div>
<div id="advanced-options" class="mt-4 is-hidden"> <div id="advanced-options" class="mt-4 is-hidden">
@ -97,6 +129,36 @@
<input type="checkbox" id="debug-logging"> Enable debug logging <input type="checkbox" id="debug-logging"> Enable debug logging
</label> </label>
</div> </div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="html-to-markdown"> Convert HTML body to Markdown
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="strip-url-params"> Remove URL tracking parameters
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="alt-text-images"> Replace images with alt text
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="collapse-whitespace"> Collapse long whitespace
</label>
</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">
@ -167,12 +229,75 @@
</div> </div>
<div id="rules-tab" class="tab-content is-hidden"> <div id="rules-tab" class="tab-content is-hidden">
<h2 class="title is-4">Classification Rules</h2> <h2 class="title is-4">
<span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span>
<span>Classification Rules</span>
</h2>
<div id="rules-container"></div> <div id="rules-container"></div>
<button class="button is-link" id="add-rule" type="button">Add Rule</button> <button class="button is-link" id="add-rule" type="button">Add Rule</button>
</div> </div>
<div id="maintenance-tab" class="tab-content is-hidden">
<h2 class="title is-4">
<span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span>
<span>Maintenance</span>
</h2>
<table class="table is-fullwidth">
<tbody>
<tr><th>Rule count</th><td id="rule-count"></td></tr>
<tr><th>Cache entries</th><td id="cache-count"></td></tr>
<tr><th>Queue items</th><td id="queue-count"></td></tr>
<tr><th>Current run time</th><td id="current-time">--:--:--</td></tr>
<tr><th>Last run time</th><td id="last-time">--:--:--</td></tr>
<tr><th>Average run time</th><td id="average-time">--:--:--</td></tr>
<tr><th>Total run time</th><td id="total-time">--:--:--</td></tr>
<tr><th>Messages per hour</th><td id="per-hour">0</td></tr>
<tr><th>Messages per day</th><td id="per-day">0</td></tr>
</tbody>
</table>
<button class="button is-danger" id="clear-cache" type="button">
<span class="icon is-small"><img data-icon="trash" data-size="16" src="../resources/img/trash-light-16.png" alt=""></span>
<span>Clear Cache</span>
</button>
<div class="field mt-4">
<label class="label">Data categories</label>
<div class="control">
<label class="checkbox mr-3"><input class="transfer-category" type="checkbox" value="settings" checked> Settings</label>
<label class="checkbox mr-3"><input class="transfer-category" type="checkbox" value="rules" checked> Rules</label>
<label class="checkbox"><input class="transfer-category" type="checkbox" value="cache" checked> Cache</label>
</div>
</div>
<div class="field is-grouped mt-4">
<p class="control">
<button class="button" id="export-data" type="button">
<span class="icon is-small"><img data-icon="download" data-size="16" src="../resources/img/download-light-16.png" alt=""></span>
<span>Export Data</span>
</button>
</p>
<p class="control">
<button class="button" id="import-data" type="button">
<span class="icon is-small"><img data-icon="upload" data-size="16" src="../resources/img/upload-light-16.png" alt=""></span>
<span>Import Data</span>
</button>
<input class="is-hidden" type="file" id="import-file" accept="application/json">
</p>
</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

@ -2,6 +2,9 @@ document.addEventListener('DOMContentLoaded', async () => {
const storage = (globalThis.messenger ?? browser).storage; const storage = (globalThis.messenger ?? browser).storage;
const logger = await import(browser.runtime.getURL('logger.js')); const logger = await import(browser.runtime.getURL('logger.js'));
const AiClassifier = await import(browser.runtime.getURL('modules/AiClassifier.js')); const AiClassifier = await import(browser.runtime.getURL('modules/AiClassifier.js'));
const dataTransfer = await import(browser.runtime.getURL('options/dataTransfer.js'));
const { detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js'));
const { DEFAULT_AI_PARAMS } = await import(browser.runtime.getURL('modules/defaultParams.js'));
const defaults = await storage.local.get([ const defaults = await storage.local.get([
'endpoint', 'endpoint',
'templateName', 'templateName',
@ -9,7 +12,18 @@ document.addEventListener('DOMContentLoaded', async () => {
'customSystemPrompt', 'customSystemPrompt',
'aiParams', 'aiParams',
'debugLogging', 'debugLogging',
'aiRules' 'htmlToMarkdown',
'stripUrlParams',
'altTextImages',
'collapseWhitespace',
'tokenReduction',
'aiRules',
'aiCache',
'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');
@ -31,20 +45,61 @@ document.addEventListener('DOMContentLoaded', async () => {
document.addEventListener('input', markDirty, true); document.addEventListener('input', markDirty, true);
document.addEventListener('change', markDirty, true); document.addEventListener('change', markDirty, true);
logger.setDebug(defaults.debugLogging === true); logger.setDebug(defaults.debugLogging === true);
const DEFAULT_AI_PARAMS = {
max_tokens: 4096, const themeSelect = document.getElementById('theme-select');
temperature: 0.6, themeSelect.value = defaults.theme || 'auto';
top_p: 0.95,
seed: -1, function updateIcons(theme) {
repetition_penalty: 1.0, document.querySelectorAll('img[data-icon]').forEach(img => {
top_k: 20, const name = img.dataset.icon;
min_p: 0, const size = img.dataset.size || 16;
presence_penalty: 0, if (name === 'full-logo') {
frequency_penalty: 0, img.src = `../resources/img/full-logo${theme === 'dark' ? '-white' : ''}.png`;
typical_p: 1, } else {
tfs: 1 img.src = `../resources/img/${name}-${theme}-${size}.png`;
}; }
document.getElementById('endpoint').value = defaults.endpoint || 'http://127.0.0.1:5000/v1/classify'; });
}
async function applyTheme(setting) {
const mode = setting === 'auto' ? await detectSystemTheme() : setting;
document.documentElement.dataset.theme = mode;
updateIcons(mode);
}
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 () => {
markDirty();
await applyTheme(themeSelect.value);
});
document.getElementById('endpoint').value = defaults.endpoint || 'http://127.0.0.1:5000/v1/completions';
const templates = { const templates = {
openai: browser.i18n.getMessage('template.openai'), openai: browser.i18n.getMessage('template.openai'),
@ -80,6 +135,32 @@ document.addEventListener('DOMContentLoaded', async () => {
const debugToggle = document.getElementById('debug-logging'); const debugToggle = document.getElementById('debug-logging');
debugToggle.checked = defaults.debugLogging === true; debugToggle.checked = defaults.debugLogging === true;
const htmlToggle = document.getElementById('html-to-markdown');
htmlToggle.checked = defaults.htmlToMarkdown === true;
const stripUrlToggle = document.getElementById('strip-url-params');
stripUrlToggle.checked = defaults.stripUrlParams === true;
const altTextToggle = document.getElementById('alt-text-images');
altTextToggle.checked = defaults.altTextImages === true;
const collapseWhitespaceToggle = document.getElementById('collapse-whitespace');
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);
@ -88,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) {
@ -95,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 + '/'));
@ -116,6 +199,19 @@ document.addEventListener('DOMContentLoaded', async () => {
const rulesContainer = document.getElementById('rules-container'); const rulesContainer = document.getElementById('rules-container');
const addRuleBtn = document.getElementById('add-rule'); const addRuleBtn = document.getElementById('add-rule');
const ruleCountEl = document.getElementById('rule-count');
const cacheCountEl = document.getElementById('cache-count');
const queueCountEl = document.getElementById('queue-count');
const currentTimeEl = document.getElementById('current-time');
const lastTimeEl = document.getElementById('last-time');
const averageTimeEl = document.getElementById('average-time');
const totalTimeEl = document.getElementById('total-time');
const perHourEl = document.getElementById('per-hour');
const perDayEl = document.getElementById('per-day');
let timingLogged = false;
ruleCountEl.textContent = (defaults.aiRules || []).length;
cacheCountEl.textContent = defaults.aiCache ? Object.keys(defaults.aiCache).length : 0;
function createActionRow(action = {type: 'tag'}) { function createActionRow(action = {type: 'tag'}) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'action-row field is-grouped mb-2'; row.className = 'action-row field is-grouped mb-2';
@ -123,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'].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;
@ -150,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');
@ -161,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') {
@ -174,6 +270,45 @@ document.addEventListener('DOMContentLoaded', async () => {
sel.value = String(action.junk ?? true); sel.value = String(action.junk ?? true);
wrap.appendChild(sel); wrap.appendChild(sel);
paramSpan.appendChild(wrap); paramSpan.appendChild(wrap);
} else if (typeSelect.value === 'read') {
const wrap = document.createElement('div');
wrap.className = 'select is-small';
const sel = document.createElement('select');
sel.className = 'read-select';
sel.appendChild(new Option('mark read','true'));
sel.appendChild(new Option('mark unread','false'));
sel.value = String(action.read ?? true);
wrap.appendChild(sel);
paramSpan.appendChild(wrap);
} else if (typeSelect.value === 'flag') {
const wrap = document.createElement('div');
wrap.className = 'select is-small';
const sel = document.createElement('select');
sel.className = 'flag-select';
sel.appendChild(new Option('flag','true'));
sel.appendChild(new Option('unflag','false'));
sel.value = String(action.flagged ?? true);
wrap.appendChild(sel);
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'));
} }
} }
@ -193,7 +328,41 @@ 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;
rulesContainer.innerHTML = ''; rulesContainer.innerHTML = '';
for (const rule of rules) { for (const rule of rules) {
const article = document.createElement('article'); const article = document.createElement('article');
@ -225,13 +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';
delBtn.addEventListener('click', () => article.remove()); const delIcon = document.createElement('img');
header.appendChild(delBtn); 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', () => {
article.remove();
ruleCountEl.textContent = rulesContainer.querySelectorAll('.rule').length;
markDirty();
});
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';
@ -246,20 +469,139 @@ document.addEventListener('DOMContentLoaded', async () => {
addAction.className = 'button is-small mb-2'; addAction.className = 'button is-small mb-2';
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';
stopCheck.checked = rule.stopProcessing === true; stopCheck.checked = rule.stopProcessing === true;
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);
@ -279,27 +621,188 @@ 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' };
} }
if (type === 'read') {
return { type, read: row.querySelector('.read-select').value === 'true' };
}
if (type === 'flag') {
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;
})); }));
function format(ms) {
if (ms < 0) return '--:--:--';
let totalSec = Math.floor(ms / 1000);
const sec = totalSec % 60;
totalSec = (totalSec - sec) / 60;
const min = totalSec % 60;
const hr = (totalSec - min) / 60;
return `${String(hr).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
async function refreshMaintenance() {
try {
const stats = await browser.runtime.sendMessage({ type: 'sortana:getTiming' });
queueCountEl.textContent = stats.count;
currentTimeEl.classList.remove('has-text-danger');
lastTimeEl.classList.remove('has-text-success','has-text-danger');
let arrow = '';
if (stats.last >= 0) {
if (stats.stddev > 0 && stats.last - stats.average > stats.stddev) {
lastTimeEl.classList.add('has-text-danger');
arrow = ' ▲';
} else if (stats.stddev > 0 && stats.average - stats.last > stats.stddev) {
lastTimeEl.classList.add('has-text-success');
arrow = ' ▼';
}
lastTimeEl.textContent = format(stats.last) + arrow;
} else {
lastTimeEl.textContent = '--:--:--';
}
if (stats.current >= 0) {
if (stats.stddev > 0 && stats.current - stats.average > stats.stddev) {
currentTimeEl.classList.add('has-text-danger');
}
currentTimeEl.textContent = format(stats.current);
} else {
currentTimeEl.textContent = '--:--:--';
}
averageTimeEl.textContent = stats.runs > 0 ? format(stats.average) : '--:--:--';
totalTimeEl.textContent = format(stats.total);
const perHour = stats.average > 0 ? Math.round(3600000 / stats.average) : 0;
const perDay = stats.average > 0 ? Math.round(86400000 / stats.average) : 0;
perHourEl.textContent = perHour;
perDayEl.textContent = perDay;
if (!timingLogged) {
logger.aiLog('retrieved timing stats', {debug: true});
timingLogged = true;
}
} catch (e) {
queueCountEl.textContent = '?';
currentTimeEl.textContent = '--:--:--';
lastTimeEl.textContent = '--:--:--';
averageTimeEl.textContent = '--:--:--';
totalTimeEl.textContent = '--:--:--';
perHourEl.textContent = '0';
perDayEl.textContent = '0';
}
try {
const { aiCache } = await storage.local.get('aiCache');
cacheCountEl.textContent = aiCache ? Object.keys(aiCache).length : 0;
} catch {
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();
setInterval(refreshMaintenance, 1000);
document.getElementById('clear-cache').addEventListener('click', async () => {
await AiClassifier.clearCache();
cacheCountEl.textContent = '0';
});
function selectedCategories() {
return [...document.querySelectorAll('.transfer-category:checked')].map(el => el.value);
}
document.getElementById('export-data').addEventListener('click', () => {
dataTransfer.exportData(selectedCategories());
});
const importInput = document.getElementById('import-file');
document.getElementById('import-data').addEventListener('click', () => importInput.click());
importInput.addEventListener('change', async () => {
if (importInput.files.length) {
await dataTransfer.importData(importInput.files[0], selectedCategories());
location.reload();
}
});
initialized = true; initialized = true;
document.getElementById('save').addEventListener('click', async () => { document.getElementById('save').addEventListener('click', async () => {
@ -316,6 +819,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
} }
const debugLogging = debugToggle.checked; const debugLogging = debugToggle.checked;
const htmlToMarkdown = htmlToggle.checked;
const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => { const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
const criterion = ruleEl.querySelector('.criterion').value; const criterion = ruleEl.querySelector('.criterion').value;
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => { const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
@ -326,15 +830,50 @@ 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' };
} }
if (type === 'read') {
return { type, read: row.querySelector('.read-select').value === 'true' };
}
if (type === 'flag') {
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);
await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules }); const stripUrlParams = stripUrlToggle.checked;
const altTextImages = altTextToggle.checked;
const collapseWhitespace = collapseWhitespaceToggle.checked;
const tokenReduction = tokenReductionToggle.checked;
const showDebugTab = debugTabToggle.checked;
const theme = themeSelect.value;
await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab });
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 });
logger.setDebug(debugLogging); logger.setDebug(debugLogging);

View file

@ -1,27 +0,0 @@
document.addEventListener('DOMContentLoaded', async () => {
const params = new URLSearchParams(location.search);
const id = parseInt(params.get('mid'), 10);
if (!id) return;
try {
const { subject, reasons } = await browser.runtime.sendMessage({ type: 'sortana:getReasons', id });
document.getElementById('subject').textContent = subject;
const container = document.getElementById('rules');
for (const r of reasons) {
const article = document.createElement('article');
article.className = 'message mb-4';
const header = document.createElement('div');
header.className = 'message-header';
header.innerHTML = `<p>${r.criterion}</p>`;
const body = document.createElement('div');
body.className = 'message-body';
const pre = document.createElement('pre');
pre.textContent = r.reason;
body.appendChild(pre);
article.appendChild(header);
article.appendChild(body);
container.appendChild(article);
}
} catch (e) {
console.error('failed to load reasons', e);
}
});

View file

@ -1,22 +0,0 @@
(function() {
function addButton() {
const toolbar = document.querySelector("#header-view-toolbar") ||
document.querySelector("#mail-toolbox toolbar");
if (!toolbar || document.getElementById('sortana-clear-cache-button')) return;
const button = document.createXULElement ?
document.createXULElement('toolbarbutton') :
document.createElement('button');
button.id = 'sortana-clear-cache-button';
button.setAttribute('label', 'Clear Cache');
button.className = 'toolbarbutton-1';
button.addEventListener('command', () => {
browser.runtime.sendMessage({ type: 'sortana:clearCacheForDisplayed' });
});
toolbar.appendChild(button);
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
addButton();
} else {
document.addEventListener('DOMContentLoaded', addButton, { once: true });
}
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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: 396 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 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: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,006 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Some files were not shown because too many files have changed in this diff Show more