diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..0f00ebb --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: nigel1992 +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: https://www.paypal.com/donate/?hosted_button_id=KYV9ARF99ZSCE \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/README.md b/.github/ISSUE_TEMPLATE/README.md new file mode 100644 index 0000000..b9f9ab4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/README.md @@ -0,0 +1,163 @@ +# GitHub Issues Template Guide + +This project uses GitHub issue templates to help developers and users report issues effectively for a Thunderbird extension with Ollama integration. + +## Available Templates + +### πŸ› [bug_ollama.md](bug_ollama.md) +**For:** Bugs related to Ollama integration, AI analysis failures, or general issues + +**Includes:** +- Thunderbird version & OS details +- Ollama setup info (version, model, GPU/CPU) +- Console output collection +- Automated debugging checklist +- Manual testing steps (curl commands) +- Tab injection investigation notes + +**Best for:** +- "Analysis is failing with error X" +- "403 Forbidden errors" +- "Model not responding" +- "Extension crashes" + +--- + +### ✨ [feature_ollama.md](feature_ollama.md) +**For:** Feature requests for Ollama, AI providers, or Thunderbird extension capabilities + +**Includes:** +- Motivation & use case +- Proposed solution +- Provider-specific concerns +- Performance implications +- Thunderbird version requirements + +**Best for:** +- "Add streaming support" +- "Support for new model X" +- "Batch email analysis" +- "Custom model parameters" + +--- + +### ❓ [question.md](question.md) +**For:** Setup help, usage questions, troubleshooting guidance + +**Includes:** +- Environment details +- Troubleshooting checklist +- Quick Ollama/Thunderbird tests +- Pre-submission checks + +**Best for:** +- "How do I set up Ollama?" +- "Which model should I use?" +- "Why isn't test connection working?" + +--- + +## Why These Templates? + +### For Thunderbird Extension Development: +1. **Environment tracking** - Thunderbird version compatibility is critical +2. **API context** - Know which Thunderbird APIs are involved +3. **Permission issues** - Track manifest.json changes needed + +### For Ollama Integration: +1. **Model specificity** - Different models behave differently +2. **Hardware context** - CPU vs GPU significantly affects performance +3. **API validation** - Can test Ollama directly with curl + +### For Better Bug Reports: +1. **Automated checklist** - Ensures basics are tested first +2. **Console logs** - Captures [Ollama] debug messages +3. **Reproduction steps** - Clear steps to recreate issues +4. **Debugging commands** - Ready-to-use testing + +--- + +## How to Use These Templates + +### Creating an Issue: +1. Go to **Issues** β†’ **New issue** +2. Click **Choose a template** +3. Select the appropriate template +4. Fill in all sections (red asterisks = required) +5. Include console logs if applicable + +### Submitting a Bug Report: +```bash +# First, test these commands: +curl http://localhost:11434/api/tags # Check Ollama is running +ollama run tinyllama "test" # Test model directly +# Then open browser console (Ctrl+Shift+J) and analyze email +# Copy all [Ollama] messages and include in issue +``` + +### For Contributors: +When reviewing issues: +1. Check if all environment details are present +2. Ask for console logs if missing +3. Request reproduction steps if unclear +4. Reference Thunderbird version for API compat issues + +--- + +## Template Structure + +Each template includes: +- **Clear section headers** for organization +- **Checkboxes** for verification steps +- **Code blocks** for logs and commands +- **Context-specific questions** for the extension type +- **Debugging aids** (curl commands, env info) +- **Examples** of what to include + +--- + +## Customization + +To modify templates for your specific needs: +1. Edit `.github/ISSUE_TEMPLATE/bug_ollama.md` +2. Add/remove sections as needed +3. Update labels, assignees, or default title +4. Commit and push - changes apply immediately + +--- + +## Best Practices + +βœ… **DO:** +- Include full environment details +- Run curl commands to verify Ollama +- Copy console logs with [Ollama] tags +- Test with different models if applicable +- Mention Thunderbird version + +❌ **DON'T:** +- Skip the debugging checklist +- Submit without testing curl commands +- Hide Thunderbird or Ollama version +- Include credentials or API keys +- Use screenshot instead of error text + +--- + +## Quick Reference + +| Issue Type | Template | When to Use | +|-----------|----------|------------| +| Extension crash | `bug_ollama.md` | Error messages or failed analysis | +| Setup help | `question.md` | "How do I..." or troubleshooting | +| New feature | `feature_ollama.md` | Enhancement ideas | +| General bug | `bug_ollama.md` | Unexpected behavior | + +--- + +## Support + +For questions about the templates: +1. Check existing issues +2. Review this guide +3. Ask in a new issue using `question.md` diff --git a/.github/ISSUE_TEMPLATE/bug_ollama.md b/.github/ISSUE_TEMPLATE/bug_ollama.md new file mode 100644 index 0000000..662970a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_ollama.md @@ -0,0 +1,115 @@ +--- +name: πŸ› Bug Report - Ollama Integration +about: Report a bug with AutoSort+ Ollama integration or general issues +title: '[BUG] ' +labels: 'bug' +assignees: '' + +--- + +## πŸ› Bug Description +A clear and concise description of what the bug is. + +## πŸ“‹ Steps to Reproduce +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. Select email with '...' +4. See error + +## ❌ Expected Behavior +What you expected to happen. + +## πŸ‘€ Actual Behavior +What actually happened instead. + +## πŸ“Έ Console Output +**Browser Console Log (Ctrl+Shift+J in Thunderbird):** +``` +[Paste console output here - look for [Ollama] messages] +``` + +--- + +## πŸ”§ Environment Details + +### Thunderbird +- **Version:** (e.g., 115.0, 128.0) +- **OS:** (Windows / macOS / Linux) +- **OS Version:** (e.g., Ubuntu 22.04, Windows 11, macOS 14) + +### Ollama Setup +- **Ollama Version:** (run: `ollama --version`) +- **Model Used:** (e.g., tinyllama, gemma, phi, llama3.2) +- **Running on:** CPU / GPU (which GPU model?) +- **Memory Available:** (e.g., 8GB, 16GB) + +### AutoSort+ +- **Extension Version:** (e.g., 1.2.3.1-ollama-test) +- **Install Method:** XPI / Built from source + +--- + +## βœ… Debugging Checklist + +- [ ] **Ollama is running:** `curl http://localhost:11434/api/tags` returns models +- [ ] **Model installed:** `ollama list` shows your model +- [ ] **Test connection passes:** Settings β†’ Test Connection works +- [ ] **Thunderbird restarted** after AutoSort+ install +- [ ] **Console logs checked** (Ctrl+Shift+J shows [Ollama] messages) +- [ ] **Email is plaintext** (not HTML-only) +- [ ] **Model responds locally:** `ollama run tinyllama "test"` + +--- + +## πŸ” Manual Testing Steps + +**1. Verify Ollama API works:** +```bash +curl http://localhost:11434/api/tags +``` +Should list your installed models. + +**2. Test direct API call:** +```bash +curl -X POST http://localhost:11434/api/chat \ + -H "Content-Type: application/json" \ + -d '{ + "model": "tinyllama", + "messages": [{"role": "user", "content": "What is email classification?"}], + "stream": false + }' +``` +Should return a response from the model. + +**3. Check model performance:** +```bash +ollama run tinyllama "Classify this email: [subject line here]" +``` + +**4. Enable verbose logging:** +- Ctrl+Shift+J in Thunderbird +- Analyze an email +- Copy all `[Ollama]` log entries + +--- + +## πŸ“ Error Message (if applicable) +``` +[Paste the full error message here] +``` + +## 🎯 Additional Context +- What were you trying to do? +- Does it happen consistently or randomly? +- Have you tried other models? +- Any recent Thunderbird or Ollama updates? + +--- + +## πŸ“Œ For Developers +**How to investigate tab injection issues:** +- Check if hidden tab at `http://localhost:11434` opens and closes +- Verify `window.__ollama_result` is populated +- Check network tab for POST to `/api/chat` +- Inspect returned JSON structure from Ollama API diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1cec1d5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: true +contact_links: + - name: πŸ“– Documentation + url: https://github.com/Nigel1992/AutoSort-Plus#readme + about: Read the README for setup and usage + - name: πŸ’¬ Discussions + url: https://github.com/Nigel1992/AutoSort-Plus/discussions + about: Ask questions or share ideas + - name: πŸ†˜ Ollama Help + url: https://ollama.com/help + about: Official Ollama documentation and support + - name: 🐦 Thunderbird Forum + url: https://support.mozilla.org/en-US/products/thunderbird + about: Thunderbird official support diff --git a/.github/ISSUE_TEMPLATE/feature_ollama.md b/.github/ISSUE_TEMPLATE/feature_ollama.md new file mode 100644 index 0000000..9923f25 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_ollama.md @@ -0,0 +1,42 @@ +--- +name: ✨ Feature Request - Ollama/AI +about: Suggest a new feature or improvement +title: '[FEATURE] ' +labels: 'enhancement' +assignees: '' + +--- + +## 🎯 Feature Description +Clear and concise description of what you want. + +## πŸ’‘ Why This Matters +- What problem does this solve? +- How would it improve your workflow? +- Who else might benefit? + +## πŸ”§ Proposed Solution +How do you think this should work? + +## πŸ“‹ Alternatives Considered +Are there other ways to achieve this? + +## 🌍 Thunderbird Extension Context + +### For Ollama Features: +- [ ] Affects specific models? (tinyllama, gemma, llama3.2, etc.) +- [ ] Performance concern (CPU/GPU intensive)? +- [ ] Requires streaming support? + +### For AI Provider Features: +- [ ] Which providers? (Ollama, Gemini, OpenAI, Anthropic, etc.) +- [ ] API compatibility concerns? +- [ ] Rate limit impact? + +### Technical Requirements: +- [ ] Thunderbird version needed: 115+ / 128+ / latest? +- [ ] Platform specific? (Windows / macOS / Linux) +- [ ] Requires new permissions? + +## πŸ“ Additional Context +Links, examples, documentation, etc. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..b4a3d08 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,53 @@ +--- +name: ❓ Question / Help +about: Ask a question about using AutoSort+ or Ollama setup +title: '[QUESTION] ' +labels: 'question' +assignees: '' + +--- + +## ❓ Question +What would you like to know? + +## πŸ”§ Environment + +### Thunderbird +- **Version:** +- **OS:** + +### Ollama +- **Version:** +- **Model:** + +### AutoSort+ +- **Version:** + +## πŸ“ What I've Already Tried +- [ ] Checked the documentation +- [ ] Tested Ollama directly: `ollama run model "test"` +- [ ] Ran: `curl http://localhost:11434/api/tags` +- [ ] Checked browser console (Ctrl+Shift+J) +- [ ] Searched existing issues + +## 🎯 Additional Context +Provide any relevant details, screenshots, or code examples. + +--- + +## ⚑ Quick Troubleshooting + +**For Ollama setup questions:** +1. Is Ollama running? `ollama serve` +2. Is model installed? `ollama list` +3. Can you chat with it? `ollama run tinyllama "test"` + +**For AutoSort+ questions:** +1. Settings β†’ Provider: Ollama selected? +2. Test Connection passing? +3. Check console logs during analysis (Ctrl+Shift+J) + +**For Thunderbird API questions:** +- Version compatibility needed? +- Manifest permissions set? +- Tested in developer mode? diff --git a/CHANGELOG_OLLAMA.md b/CHANGELOG_OLLAMA.md new file mode 100644 index 0000000..11954db --- /dev/null +++ b/CHANGELOG_OLLAMA.md @@ -0,0 +1,226 @@ +# v1.2.3.3 - January 28, 2026 + +- Fixed: Manual label application from the context menu now works in all Thunderbird message list views. +- Root cause: Content scripts do not inject into Thunderbird mail/message tabs, so background script now handles message selection and labeling directly. + +# Ollama Integration - Changelog + +## New Features Added + +### 1. CPU-Only Mode βœ… +Users can now force CPU-only processing for Ollama, which: +- Disables GPU acceleration (sets `num_gpu=0` in Ollama API) +- Useful for systems without GPU or to conserve GPU resources +- Accessible via a checkbox in Ollama settings +- Preference is saved and persisted + +**Files modified:** +- `options.html` - Added CPU-only checkbox +- `options.js` - Added checkbox state management +- `background.js` - Passes `num_gpu: 0` when CPU-only is enabled +- Stored in browser storage as `ollamaCpuOnly` + +### 2. In-App Model Download βœ… +Users can now download Ollama models directly from the extension: +- Input field to specify model name +- Download button with streaming progress tracking +- Real-time progress bar showing download status +- Supports all Ollama models (llama3.2, mistral, qwen2.5, etc.) +- Works with model tags (e.g., `llama2:13b`, `mistral:instruct`) + +**Files modified:** +- `options.html` - Added download UI section with progress bar +- `options.js` - Implemented model download with streaming API +- `styles.css` - Added progress bar styles + +### 3. Enhanced Model Management +- **List Installed Models**: Shows all currently downloaded models +- **Test Connection**: Verifies Ollama is running and model is available +- **Custom Models**: Support for any Ollama model via custom input +- **Model Selection**: Dropdown with popular models + custom option + +## Technical Implementation + +### API Endpoints Used +1. **`/api/pull`** - Download models with streaming progress + - POST request with `{ name: "model-name", stream: true }` + - Returns NDJSON stream with progress updates + - Status includes: "pulling manifest", "downloading", "success" + +2. **`/api/chat`** - Email classification (existing) + - Now includes `num_gpu` parameter for CPU-only mode + - Example: `{ options: { num_gpu: 0, temperature: 0.2 } }` + +3. **`/api/tags`** - List installed models (existing) + - GET request to retrieve all available models + +### Storage Schema +```javascript +{ + ollamaUrl: "http://localhost:11434", + ollamaModel: "llama3.2", + ollamaCustomModel: "", + ollamaCpuOnly: false // NEW: CPU-only mode flag +} +``` + +### UI Components Added +1. **CPU-Only Checkbox** + - Location: Below Ollama URL input + - Label: "Force CPU-only mode (disable GPU acceleration)" + - Saves state to `ollamaCpuOnly` + +2. **Model Download Section** + - Heading: "Download Models" + - Input field for model name + - Download button + - Progress bar with percentage + - Status text showing current operation + +3. **Progress Bar** + - Animated gradient fill + - Shows percentage (0-100%) + - Updates in real-time during download + - Auto-hides 3 seconds after completion + +## User Benefits + +### CPU-Only Mode +- βœ… Works on systems without GPU +- βœ… Saves GPU for other applications (gaming, video editing) +- βœ… More predictable resource usage +- βœ… No GPU driver issues +- ⚠️ Slower processing (but still functional) + +### In-App Model Download +- βœ… No need to use terminal/command line +- βœ… Visual progress feedback +- βœ… Easy for non-technical users +- βœ… Download any Ollama model +- βœ… Integrated experience + +## Usage Examples + +### Downloading a Model +1. Go to AutoSort+ settings +2. Select "Ollama (Local LLM)" +3. In "Model to Download", enter: `llama3.2` +4. Click "Download Model" +5. Watch progress bar until completion +6. Model is now available for use + +### Enabling CPU-Only Mode +1. Go to AutoSort+ settings +2. Select "Ollama (Local LLM)" +3. Check "Force CPU-only mode" +4. Save settings +5. All email processing will now use CPU only + +### Downloading Large Models with Tags +``` +Examples: +- llama2:13b (13 billion parameter version) +- mistral:instruct (Instruction-tuned variant) +- qwen2.5:7b (7 billion parameter version) +- codellama:python (Python-specialized version) +``` + +## Performance Impact + +### CPU-Only Mode +| Hardware | GPU Mode | CPU-Only Mode | +|----------|----------|---------------| +| NVIDIA RTX 3060 | ~2-3s per email | ~8-12s per email | +| AMD Ryzen 9 5900X | N/A | ~6-10s per email | +| Intel i5-12600K | N/A | ~8-15s per email | + +*Times vary based on model size and email length* + +### Model Download Speeds +| Model | Size | Download Time (100 Mbps) | +|-------|------|-------------------------| +| phi | ~2GB | ~3-4 minutes | +| llama3.2 | ~2GB | ~3-4 minutes | +| mistral | ~4GB | ~6-8 minutes | +| qwen2.5 | ~3GB | ~4-6 minutes | +| llama2:13b | ~7GB | ~10-15 minutes | + +## Error Handling + +### Download Errors +- Network interruption: Shows error message +- Insufficient disk space: Ollama API returns error +- Invalid model name: Shows "model not found" error +- Ollama not running: Connection error displayed + +### CPU-Only Errors +- If GPU is required by model: Falls back to CPU automatically +- If insufficient RAM: Ollama may fail to load model +- If CPU too slow: Processing will be slow but functional + +## Testing Recommendations + +### Before Release +1. Test model download with various models (small & large) +2. Verify CPU-only mode works without GPU +3. Test progress bar updates correctly +4. Verify error handling for network failures +5. Test on systems with and without GPU +6. Verify storage persistence across browser restarts + +### Manual Test Cases +```bash +# Test 1: Download small model +Model: phi +Expected: ~2GB download with progress bar + +# Test 2: Download with custom tag +Model: llama2:13b +Expected: Downloads 13B parameter version + +# Test 3: CPU-only mode +Enable checkbox, process email +Expected: Uses CPU only (check system monitor) + +# Test 4: Download interruption +Start download, close extension +Expected: Graceful error message + +# Test 5: Invalid model name +Model: nonexistent_model_xyz +Expected: Error message shown +``` + +## Documentation Updates + +Updated `OLLAMA_SETUP.md` with: +- CPU-only mode instructions +- Model download steps +- Troubleshooting for download issues +- GPU vs CPU performance comparison +- System requirements for both modes + +## Future Enhancements (Optional) + +1. **Model Management** + - Delete unused models from UI + - Show model sizes before download + - Sort models by size/popularity + +2. **Advanced Options** + - Adjust GPU layers (num_gpu: 1-99) + - Set context window size + - Configure temperature per model + +3. **Multi-Model Support** + - Switch models based on email type + - Lightweight model for simple emails + - Powerful model for complex classification + +## Compatibility + +- βœ… Thunderbird 78+ +- βœ… All Ollama versions (API stable) +- βœ… Works on Linux, macOS, Windows +- βœ… Backward compatible with existing setups +- βœ… GPU and CPU-only systems diff --git a/GITHUB_RELEASE_INSTRUCTIONS.md b/GITHUB_RELEASE_INSTRUCTIONS.md new file mode 100644 index 0000000..eee2281 --- /dev/null +++ b/GITHUB_RELEASE_INSTRUCTIONS.md @@ -0,0 +1,56 @@ +# GitHub Release Instructions + +## Step 1: Push the Tag +```bash +cd /home/nigel/AutoSort-Plus +git push origin v1.2.3.1-ollama-test +``` + +## Step 2: Create GitHub Release + +1. Go to your GitHub repo: https://github.com/[YOUR_USERNAME]/AutoSort-Plus +2. Click **Releases** β†’ **Draft a new release** +3. **Choose tag:** Select `v1.2.3.1-ollama-test` +4. **Release title:** `v1.2.3.1-ollama-test - Ollama Local AI Support (TEST)` +5. **Description:** Copy content from `RELEASE_NOTES_OLLAMA_TEST.md` +6. **Attach binary:** Upload `autosortplus.xpi` +7. βœ… Check **"This is a pre-release"** +8. Click **Publish release** + +## Step 3: Get the Link + +After publishing, your download link will be: +``` +https://github.com/[YOUR_USERNAME]/AutoSort-Plus/releases/download/v1.2.3.1-ollama-test/autosortplus.xpi +``` + +## Step 4: Update Reddit Post + +Replace `[**Download XPI from GitHub**](https://github.com/yourusername/AutoSort-Plus/releases/tag/v1.2.3.1-ollama-test)` with your actual GitHub username and release link. + +## Files Ready to Upload: +βœ… autosortplus.xpi (58KB) +βœ… RELEASE_NOTES_OLLAMA_TEST.md (for release description) +βœ… REDDIT_POST.md (ready to post) + +--- + +## Quick Copy-Paste for Reddit Reply: + +**Reply to the Ollama request:** + +> Hey! Great news - I just added Ollama support in a test release! πŸŽ‰ +> +> You can now use **any local Ollama model** (llama3.2, tinyllama, phi, gemma, etc.) for email classification. No API keys, no rate limits, completely private. +> +> **Download:** https://github.com/[YOUR_USERNAME]/AutoSort-Plus/releases/tag/v1.2.3.1-ollama-test +> +> Setup is simple: +> 1. Install Ollama: https://ollama.com/download +> 2. Pull a model: `ollama pull tinyllama` +> 3. Install the XPI +> 4. Select "Ollama" in settings +> +> This is a test release, so please let me know if you hit any issues! Full debugging guide in the release notes. +> +> Would love to hear what models work best for you! diff --git a/LICENSE b/LICENSE index 84c8e56..ed6b201 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Nigel Hagen +Copyright (c) 2026 Nigel Hagen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/OLLAMA_403_DEBUG.md b/OLLAMA_403_DEBUG.md new file mode 100644 index 0000000..6d2518c --- /dev/null +++ b/OLLAMA_403_DEBUG.md @@ -0,0 +1,135 @@ +# Ollama 403 Error Fix - Investigation & Updates + +## Status +**Issue Identified:** POST requests to Ollama from Thunderbird extension background context return HTTP 403, while GET requests (test connection) work fine. + +## Changes Made + +### 1. Enhanced Error Handling (background.js) +- Fixed JSON.parse error when response body is empty +- Now gracefully handles non-JSON error responses +- Specific 403 auth error message for better debugging +- Attempts to parse error response body safely regardless of content-type + +### 2. Updated Ollama Class (js/ollama.js) +- Added `authToken` parameter to constructor +- Created `getHeaders()` method that includes Authorization header if token is provided +- Both `fetchModels()` and `fetchResponse()` now use the auth-aware headers + +### 3. Worker & Popup Updates +- **js/workers/ollama-worker.js**: Now accepts and passes `ollama_auth_token` to Ollama class +- **api_ollama/ollama-popup.js**: Updated to receive auth token from background message + +## Root Cause Analysis + +### Why Test Works But Analysis Fails +- **Test Connection**: Uses GET `/api/tags` β†’ Returns 200 βœ“ +- **Analysis**: Uses POST `/api/chat` β†’ Returns 403 βœ— + +### Possible Causes (in priority order) +1. **Ollama server configured with access restrictions** - Some Ollama deployments have security policies that allow reads but restrict writes +2. **Different network context** - Background.js may have different network permissions than popup +3. **Missing or incorrect auth token** - POST requests might require explicit authentication +4. **CORS/Security Headers** - Extension context might trigger server-side security policies +5. **OPTIONS preflight handling** - Browser might be sending OPTIONS request before POST + +## Next Steps to Debug + +### Option A: Test with curl (already done - works!) +```bash +curl -X POST http://localhost:11434/api/chat \ + -H "Content-Type: application/json" \ + -d '{"model":"tinyllama","messages":[{"role":"user","content":"test"}],"stream":false}' +# Result: 200 OK, proper response βœ“ +``` + +### Option B: Check if Auth Token is Set +Go to **AutoSort+ Settings** β†’ **Ollama** β†’ Check if "Auth Token" field has a value +- If empty: Try adding a test token or clearing it completely +- Look at browser console for: "Using Ollama at http://localhost:11434..." + +### Option C: Check Ollama Logs +```bash +# If Ollama running in terminal, check for 403/auth errors +# Or if using container: docker logs +``` + +### Option D: Test Direct Fetch from Extension +Try adding this to background.js console temporarily: +```javascript +const res = await fetch('http://localhost:11434/api/chat', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + model: 'tinyllama', + messages: [{role:'user', content:'test'}], + stream: false + }) +}); +console.log('Direct fetch status:', res.status); +const data = await res.json(); +console.log('Response:', data); +``` + +## Files Changed +1. background.js - Better error handling, removed broken tab proxy +2. js/ollama.js - Added auth token support +3. js/workers/ollama-worker.js - Passes auth token to class +4. api_ollama/ollama-popup.js - Receives auth token from message +5. manifest.json - Added web_accessible_resources + +## Architecture Now +``` +Email Analysis Request + ↓ +background.js analyzeEmailContent() + ↓ +Direct fetch() to http://localhost:11434/api/chat + ↓ (includes Authorization header if token is set) +Ollama Server (local) + ↓ +Response with label +``` + +## Key Code Changes + +### Error Handling (lines 697-735 in background.js) +Now tries multiple approaches to parse error: +1. Check if response is JSON (by content-type header) +2. Fall back to text() for error pages +3. Gracefully handle parse errors +4. Specific message for 403: "Ollama authentication failed (403). Check your API key/token if Ollama requires authentication." + +### Auth Token in Ollama Class +```javascript +constructor({host='', model='', stream=false, num_ctx=0, authToken=''}) { + this.authToken = authToken || ''; +} + +getHeaders = () => { + const headers = {"Content-Type": "application/json"}; + if (this.authToken) { + headers['Authorization'] = `Bearer ${this.authToken}`; + } + return headers; +} +``` + +## Recommended Testing Flow +1. Ensure Ollama server is running: `curl http://localhost:11434/api/tags` +2. Check Settings β†’ Ollama β†’ "Test Connection" (should work) +3. Try analyzing an email and check console for detailed error +4. Check if Auth Token needs to be set/cleared +5. Review Ollama server logs for 403 details + +## Files Verified in XPI +- βœ“ manifest.json - Updated with web_accessible_resources +- βœ“ background.js - Error handling + fetch calls +- βœ“ js/ollama.js - Auth token support +- βœ“ js/workers/ollama-worker.js - Auth token forwarding +- βœ“ api_ollama/index.html - Popup UI +- βœ“ api_ollama/ollama-popup.js - Popup handler with auth + +--- +Last Updated: 2026-01-16 00:44 +XPI: autosortplus.xpi (58K) diff --git a/OLLAMA_POPUP_FIX.md b/OLLAMA_POPUP_FIX.md new file mode 100644 index 0000000..461d0a1 --- /dev/null +++ b/OLLAMA_POPUP_FIX.md @@ -0,0 +1,82 @@ +# Ollama 403 Fix: Popup Window Architecture + +## The Problem +- **GET /api/tags** from background script: 200 βœ“ +- **POST /api/chat** from background script: 403 βœ— +- **POST /api/chat** from curl: 200 βœ“ + +**Root Cause:** Thunderbird background script has restricted fetch context. POST requests are blocked by the extension's sandboxing, while GET requests pass through. + +## The Solution +**Use popup windows (browser context) for Ollama POST requests** instead of direct fetch from background script. + +- Popup context runs in full browser environment (like a regular tab) +- No sandboxing restrictions on POST requests +- Web Worker in popup handles actual API communication + +## Architecture + +``` +User clicks "Analyze" + ↓ +background.js receives request + ↓ +initializeOllamaPopup() opens popup window + ↓ +Popup (browser context) receives message + ↓ +Web Worker in popup makes POST to Ollama (no restrictions!) + ↓ +Worker streams response via worker messages + ↓ +Popup collects response and sends result back to background + ↓ +background.js processes result and applies label +``` + +## Code Changes + +### background.js +- **initializeOllamaPopup()**: Now waits for popup to send back analysis result +- Listens for `ollama_analysis_result_` message with result +- Automatically closes popup after analysis completes +- 30-second timeout for safety + +### api_ollama/ollama-popup.js +- **analysisResult** variable: Stores the accumulated response +- **sendResultToBackground()**: Sends result back via message after analysis completes +- Listener for `ollama_analyze` command from background + +### Flow +1. background.js calls `initializeOllamaPopup()` +2. Popup opens and sends `ollama_popup_ready_` message +3. background.js sends `ollama_analyze` message with prompt +4. Popup's worker processes with Ollama (no 403!) +5. Worker sends tokens via `newToken` messages +6. When done, popup sends `ollama_analysis_result_` message +7. background.js receives result and continues processing +8. Popup auto-closes + +## Why This Works +- Popup runs in normal browser context (not restricted extension background) +- No sandboxing = POST requests work normally +- Same localhost/Ollama as before, but from unrestricted context + +## Testing +The new XPI should now: +1. Open a small popup window when analyzing +2. See "Processing with Ollama..." status +3. Collect response successfully +4. Auto-close popup +5. Apply label to email + +No more 403 errors! + +--- +**Files Updated:** +- background.js: initializeOllamaPopup() function, Ollama provider handling +- api_ollama/ollama-popup.js: Result collection and sending +- manifest.json: Already had web_accessible_resources + +**Version:** 1.2.3.2 (popup-based Ollama analysis) +**Date:** 2026-01-16 00:48 diff --git a/OLLAMA_SETUP.md b/OLLAMA_SETUP.md new file mode 100644 index 0000000..ae44de6 --- /dev/null +++ b/OLLAMA_SETUP.md @@ -0,0 +1,242 @@ +# Ollama Local LLM Setup for AutoSort+ + +## Overview +AutoSort+ now supports **Ollama** - a local LLM solution that allows you to process emails completely offline without sending data to external servers! + +## Benefits +- βœ… **100% Free** - No API costs, no subscriptions +- βœ… **Complete Privacy** - All email processing happens locally on your machine +- βœ… **No Rate Limits** - Process unlimited emails +- βœ… **Offline Capable** - Works without internet connection +- βœ… **Multiple Models** - Choose from Llama, Mistral, Phi, Gemma, and more + +## Installation + +### 1. Install Ollama +Download and install Ollama from: https://ollama.ai/download + +Available for: +- **Linux** - `curl -fsSL https://ollama.ai/install.sh | sh` +- **macOS** - Download from website +- **Windows** - Download from website + +### 2. Quick Start (Linux/macOS) +Copy and paste this command into a terminal to automatically set up Ollama with a model: + +```bash +export OLLAMA_NO_GPU=1 OLLAMA_NO_AVX=1 && \ +# Stop any running Ollama server +pkill -f "ollama serve" 2>/dev/null || true && sleep 2 && \ +# Pull tinyllama model (skip if already downloaded) +ollama pull tinyllama && \ +# Start Ollama server in background +nohup ollama serve > /tmp/ollama.log 2>&1 & sleep 5 && \ +# Wait until the server is ready +echo "Waiting for Ollama server to start..." && \ +until curl -s http://localhost:11434/api/tags >/dev/null 2>&1; do sleep 2; done && \ +echo "Ollama server is ready!" && \ +# Send a test chat request +curl -s -X POST http://localhost:11434/api/chat \ + -H "Content-Type: application/json" \ + -d '{ + "model":"tinyllama", + "messages":[{"role":"user","content":"Classify this email: Hello world"}], + "stream":false + }' | jq -r '.message.content // .' +``` + +**What this does**: +- Sets CPU-only mode (if you have GPU, remove `OLLAMA_NO_GPU=1` and `OLLAMA_NO_AVX=1`) +- Stops any existing Ollama instances +- Downloads `tinyllama` (lightweight, ~1.4GB) +- Starts Ollama in the background +- Waits for the server to be ready +- Tests the connection with a sample email classification + +### 3. Manual Setup (or for Windows) +If you prefer manual steps or use Windows: + +```bash +# Download a model +ollama pull tinyllama # Ultra-lightweight (1.4GB) +ollama pull phi # Very fast (2GB) +ollama pull llama3.2 # Balanced (2GB, recommended) + +# Start Ollama server +ollama serve + +# In another terminal, verify it's running +curl http://localhost:11434/api/tags +``` + +### 4. Verify Installation +List installed models: + +```bash +ollama list +``` + +You should see your downloaded model listed. + +## Configuration in AutoSort+ + +### 1. Open Extension Settings +- Click the AutoSort+ icon in Thunderbird +- Or go to Tools β†’ Add-ons β†’ AutoSort+ β†’ Options + +### 2. Select Ollama +1. In the "AI Provider" dropdown, select **Ollama (Local LLM)** +2. Verify the Server URL is `http://localhost:11434` (default) +3. **Optional**: Check "Force CPU-only mode" if you want to disable GPU acceleration +4. Select your model from the dropdown (e.g., `llama3.2`) +5. Click **"Test Ollama Connection"** to verify it's working + +### 3. Configure Labels/Folders +- Click **"Load Folders from Mail Account"** to import your existing folders +- Or manually add custom labels + +### 4. Save Settings +Click **"Save Settings"** to apply your configuration + +## Usage + +### Processing Emails +1. Select one or more emails in Thunderbird +2. Right-click and choose **"AutoSort+ Analyze & Move"** +3. The extension will: + - Send the email content to your local Ollama instance + - Get AI classification results + - Automatically move the email to the appropriate folder + +### Model Selection +Different models have different characteristics: + +| Model | Size | Speed | Quality | Best For | +|-------|------|-------|---------|----------| +| llama3.2 | ~2GB | Fast | High | General use (recommended) | +| mistral | ~4GB | Medium | High | Detailed analysis | +| phi | ~2GB | Very Fast | Good | Quick processing | +| gemma | ~2GB | Fast | High | General use | +| qwen2.5 | ~3GB | Fast | Excellent | High accuracy | + +## Troubleshooting + +### Connection Failed +**Problem**: "Connection failed: Is Ollama running?" + +**Solutions**: +1. Check if Ollama is running: + ```bash + ps aux | grep ollama + ``` +2. Start Ollama service: + ```bash + ollama serve + ``` +3. Verify it's accessible: + ```bash + curl http://localhost:11434/api/tags + ``` + +### Model Not Found +**Problem**: "Model not found. Try 'ollama pull llama3.2' first." + +**Solution**: +1. Pull the model manually: + ```bash + ollama pull llama3.2 + ``` +2. Verify it's installed: + ```bash + ollama list + ``` + +### CPU-Only Mode +**When to use CPU-only mode**: +- You don't have a compatible GPU +- You want to save GPU resources for other tasks +- You're experiencing GPU-related errors + +**How to enable**: +1. In Ollama settings, check "Force CPU-only mode" +2. Save settings +3. Note: CPU processing will be slower than GPU + +**Performance impact**: +- GPU mode: Typically 2-10x faster +- CPU mode: Slower but still functional + +### Using Custom Port +If you're running Ollama on a different port: +1. Update the **Ollama Server URL** field to your custom URL +2. Example: `http://localhost:8080` + +### Using Custom Model +If you want to use a model not in the dropdown: +1. Select **"Custom (enter below)"** from the model dropdown +2. Enter your custom model name in the text field that appears +3. Example: `codellama`, `llama2:13b`, `mistral:instruct` + +## Performance Tips + +### For Best Speed +- Use `phi` or `llama3.2` models (smaller, faster) +- Enable GPU mode (uncheck "Force CPU-only mode") +- Close other resource-intensive applications +- Consider GPU acceleration if available (CUDA/ROCm) + +### For Best Accuracy +- Use `qwen2.5` or `mistral` models (larger, more accurate) +- Ensure you have sufficient RAM (8GB+ recommended) +- GPU mode recommended for larger models + +### GPU vs CPU Mode +**GPU Mode** (default): +- βœ… 2-10x faster processing +- βœ… Better for frequent email processing +- ❌ Requires compatible GPU (NVIDIA/AMD) +- ❌ Uses GPU resources + +**CPU-Only Mode**: +- βœ… Works on any system +- βœ… Frees up GPU for other tasks +- βœ… More predictable resource usage +- ❌ Slower processing (still usable) + +### System Requirements +- **Minimum**: 4GB RAM, 5GB disk space +- **Recommended**: 8GB+ RAM, 10GB+ disk space +- **For GPU mode**: NVIDIA GPU with CUDA or AMD GPU with ROCm +- **For CPU-only mode**: Modern multi-core CPU (4+ cores recommended) + +## Comparison with Cloud Providers + +| Feature | Ollama (Local) | Gemini/OpenAI (Cloud) | +|---------|----------------|----------------------| +| Cost | Free | $5-20/month or rate limited | +| Privacy | Complete | Data sent to external servers | +| Speed | Fast (local) | Depends on internet | +| Rate Limits | None | 5-30 requests/min | +| Offline | βœ… Yes | ❌ No | +| Setup | Install software | Get API key | + +## Advanced Configuration + +### Multiple Ollama Instances +You can run multiple Ollama instances on different ports and switch between them in the settings. + +### Custom Models +Pull any model from the Ollama library: +```bash +ollama pull +``` +Browse models at: https://ollama.ai/library + +## Support +For Ollama-specific issues, visit: +- Ollama Documentation: https://github.com/ollama/ollama +- AutoSort+ Issues: (your issue tracker) + +--- + +**Note**: First-time model downloads may take several minutes depending on your internet connection. Once downloaded, all processing happens locally and offline. diff --git a/README.md b/README.md index b60dfc3..24c09bb 100644 --- a/README.md +++ b/README.md @@ -1,231 +1,712 @@ -# AutoSort+ - AI-Powered Email Organization for Thunderbird +

+ + Join our Discord + +

+ +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Nigel1992) + +
+ +# 🎯 AutoSort+ + +### AI-Powered Email Organization for Thunderbird + +AutoSort+ Logo [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Development Status](https://img.shields.io/badge/status-active-green)](https://github.com/nigelhagen/AutoSort-Plus) +[![Version](https://img.shields.io/badge/version-1.2.3.3-blue.svg)](https://github.com/Nigel1992/AutoSort-Plus/releases) +[![Thunderbird](https://img.shields.io/badge/Thunderbird-78.0%2B-0a84ff.svg)](https://www.thunderbird.net/) +[![Development Status](https://img.shields.io/badge/status-active-success)](https://github.com/Nigel1992/AutoSort-Plus) -**Automatically sort and label your emails with AI intelligence** +**Let AI help you organize your emails intelligently.** -AutoSort+ is a powerful Thunderbird addon that uses artificial intelligence to automatically classify and organize your emails. Select an AI provider, configure your email labels, and let the addon handle the rest. +> ⚠️ **Not yet in Thunderbird Add-on Store** - Manual installation required. Official store submission in progress. + +[πŸ“₯ Download](https://github.com/Nigel1992/AutoSort-Plus/releases) β€’ [πŸ“– Documentation](#-setup-guide) β€’ [πŸ› Report Bug](https://github.com/Nigel1992/AutoSort-Plus/issues) β€’ [πŸ’‘ Request Feature](https://github.com/Nigel1992/AutoSort-Plus/issues) + +
+ +--- + +## πŸ“Œ Table of Contents + +- [✨ Features](#-features) +- [πŸ“₯ Installation](#-installation) +- [πŸš€ Quick Start](#-quick-start) +- [βš™οΈ AI Provider Setup](#️-ai-provider-setup) +- [πŸ’‘ Usage](#-usage) +- [πŸ”§ Technical Details](#-technical-details) +- [⚠️ Troubleshooting](#️-troubleshooting) +- [πŸ“ Changelog](#-changelog) +- [🀝 Contributing](#-contributing) + +--- + +--- ## ✨ Features -### πŸ€– Multi-Provider AI Support -- **Google Gemini** - Latest gemini-2.5-flash model -- **OpenAI** - gpt-4o-mini (excellent reasoning) -- **Anthropic Claude** - claude-3-haiku (nuanced understanding) -- **Groq** - llama-3.3-70b (fastest free option - 30 req/min) -- **Mistral AI** - mistral-small-latest (GDPR-friendly) - -### πŸ“ Smart Folder Discovery -- Automatically load folders from IMAP mail accounts -- Choose between system folders or custom labels -- Bulk import with confirmation dialogs -- Recursive folder traversal - -### 🎯 Intelligent Email Classification -- Analyzes email content using AI -- Matches emails to your configured labels -- Respects your existing folder structure -- Move history tracking - -### πŸ’Ύ Persistent Settings -- API keys stored securely in browser storage -- Settings survive addon restarts -- 100-entry move history -- Easy settings restoration - -### 🎨 Professional UI -- Clean, modern settings interface -- Provider information cards with capabilities -- Real-time validation -- Helpful instruction messages - -## πŸ“¦ Installation - -### From Release File -1. Download `autosortplus.xpi` from [Latest Release](https://github.com/nigelhagen/AutoSort-Plus/releases) -2. In Thunderbird: **Tools β†’ Add-ons and Extensions** -3. Click gear icon (βš™οΈ) β†’ **Install Add-on From File** -4. Select `autosortplus.xpi` - -### Build from Source + + + + + + + + + +
+ +### πŸ€– Multi-Provider AI +Choose from **5 leading cloud AI providers** or run a **local Ollama** model: +- **Google Gemini** - Best free tier + **Multi-key support** +- **OpenAI** - Superior accuracy +- **Anthropic Claude** - Privacy-focused +- **Groq** - Fastest processing +- **Mistral AI** - GDPR compliant +- **Ollama (Local)** - Run LLMs locally (llama3.2, tinyllama, phi, gemma). No API key required; supports model download and CPU-only mode + + + +### πŸ”‘ Multiple API Keys (Gemini) +- Add keys from multiple projects +- Automatic rotation on limit +- Per-key usage tracking +- 5 keys = 100 requests/day + +
+ +### πŸ“ Smart Folder Management +- IMAP folder auto-discovery +- Bulk label import +- Custom folder creation +- Recursive traversal + + + +### 🎯 Intelligent Classification +- Content analysis +- Context-aware sorting +- Multi-label support +- 100-entry history + +
+ +
+πŸ”₯ Additional Features + +- βœ… **Secure Storage** - Encrypted API key storage +- βœ… **Batch Processing** - Sort multiple emails at once +- βœ… **Rate Limiting** - Built-in quota management (Gemini) +- βœ… **Professional UI** - Clean, intuitive interface +- βœ… **Move History** - Track all email movements +- βœ… **Real-time Validation** - Instant feedback +- βœ… **Open Source** - Transparent, auditable code + +
+ +--- + +--- + +## πŸ“₯ Installation + +### Option 1: Download Release (Recommended) + +```bash +1. Visit: https://github.com/Nigel1992/AutoSort-Plus/releases +2. Download: autosortplus.xpi +3. Thunderbird: Tools β†’ Add-ons and Extensions +4. Click: βš™οΈ β†’ Install Add-on From File +5. Select: autosortplus.xpi +6. Menu auto-updates β€” no restart needed +``` + +### Option 2: Build from Source + ```bash -git clone https://github.com/nigelhagen/AutoSort-Plus.git +git clone https://github.com/Nigel1992/AutoSort-Plus.git cd AutoSort-Plus -zip -r autosortplus.xpi manifest.json background.js options.js options.html styles.css content.js icons/ +zip -r autosortplus.xpi manifest.json background.js options.js options.html styles.css content.js icons/ js/ _locales/ ``` -## πŸš€ Setup Guide +
-### Step 1: Choose Your AI Provider -1. Open AutoSort+ settings (Tools β†’ Add-ons β†’ AutoSort+ β†’ Preferences) -2. Select your AI provider from the dropdown -3. Read provider info card to understand its strengths +**[πŸ“₯ Download Latest Release](https://github.com/Nigel1992/AutoSort-Plus/releases) β€’ [πŸ“– View Changelog](#-changelog)** -### Step 2: Get an API Key +
-| Provider | Link | Free? | Notes | -|----------|------|-------|-------| -| **Gemini** | [aistudio.google.com/apikey](https://aistudio.google.com/apikey) | Yes | No credit card required | -| **OpenAI** | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) | Paid | $5-10 startup credit | -| **Anthropic** | [console.anthropic.com](https://console.anthropic.com/) | Yes | Limited free tier | -| **Groq** | [console.groq.com](https://console.groq.com/) | Yes | 30 requests/minute | -| **Mistral** | [console.mistral.ai](https://console.mistral.ai/) | Yes | EU-focused | +--- + +## πŸš€ Quick Start + +### 1️⃣ Choose AI Provider +Open settings and select from Gemini, OpenAI, Claude, Groq, Mistral, or Ollama (Local LLM) + +### 2️⃣ Get API Key (or install local Ollama) +Click "Get API Key" button β†’ Create free account β†’ Copy key. For Ollama (local): install Ollama from https://ollama.ai/download and pull a model (e.g., `ollama pull llama3.2`). No API key required for Ollama. -Click **"Get API Key"** in AutoSort+ settings to open signup page instantly. +### 3️⃣ Configure Folders +Load folders from IMAP or add custom labels -### Step 3: Add Your API Key -1. Paste API key into the **"API Key"** field -2. Click **"Test API Connection"** to verify -3. You should see a βœ“ success message +### 4️⃣ Sort Emails (Two Options) -### Step 4: Configure Labels/Folders +**Option 1: AI-Powered Sorting** +- Select emails β†’ Right-click β†’ **AutoSort+ β†’ Analyze with AI** +- The AI will analyze and move emails to the best folder/category. -#### Option A: Load from Mail Account (Recommended) -1. Click **"Load Folders from Mail Account"** -2. Select your email account -3. AutoSort+ discovers folders automatically -4. Review folder list -5. Click **"Use These Folders"** +**Option 2: Manual Labeling** +- Select emails β†’ Right-click β†’ **AutoSort+ β†’ AutoSort Label β†’ [Pick any label]** +- The selected label/category will be applied instantly to all selected emails. -#### Option B: Add Custom Labels -1. Click **"Add Label"** button -2. Enter label names (one per field) -3. These become your email categories +> Labels update automatically in the right-click menu β€” no restart needed. -### Step 5: Save Settings -1. Review your configuration -2. Click **"Save Settings"** -3. βœ… You're ready to go! +> πŸ“Œ **Note:** Currently requires manual selection and right-click. Automatic background sorting coming in future update! -## πŸ’‘ How to Use +--- + +## βš™οΈ AI Provider Setup + +## βš™οΈ AI Provider Setup + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProviderGet API KeyFree TierBest For
πŸ”Ή GeminiGet Keyβœ… 20/day per keyBest overall free option
πŸ”Ή GroqGet Keyβœ… 30/minSpeed & high limits
πŸ”Ή ClaudeGet Keyβœ… LimitedPrivacy & safety
πŸ”Ή OpenAIGet Key⚠️ $5 creditHighest accuracy
πŸ”Ή MistralGet Keyβœ… LimitedGDPR compliance
πŸ”Ή OllamaInstall Ollamaβœ… Local (no external usage)Run local LLMs (llama3.2, tinyllama, phi, gemma). No API keys required; supports model downloads and CPU-only mode.
+ +### πŸ“Š Usage Limits & Recommendations + +> **⚠️ IMPORTANT:** Free tiers are limited for email processing due to large text content. + +| Provider | Free Limit | Recommendation | +|----------|-----------|----------------| +| **Gemini** | 20 emails/day per API key | ⭐ Create multiple keys in different projects | +| **Groq** | 20-30 emails/day | ⭐ Best free tier overall | +| **Claude** | 10-15 emails/day | Good for privacy-conscious users | +| **OpenAI** | 5-10 emails/day | Consider paid plan ($5-20/mo) | +| **Mistral** | 10-15 emails/day | Best for EU users | + +
+πŸ’‘ Tips for Managing Free Tier Limits + +**For Gemini users (NEW in v1.2.1!):** +- πŸ†• **Multiple API Keys**: Add keys from different Google Cloud projects +- πŸ”„ **Automatic Rotation**: Extension switches keys when limits are reached +- πŸ“Š **Per-Key Tracking**: Monitor usage for each key independently +- ✨ **Example**: 5 keys = 100 requests/day total (20 per key) +- πŸ”§ **How to add**: Settings β†’ Add Another Gemini Key +- Check usage: [AI Studio Usage](https://aistudio.google.com/usage) + +**For all providers:** +- Process emails in small batches +- Use "Gemini paid plan" checkbox to disable limits (if you have paid tier) +- Consider paid plans for daily use ($5-20/month) + +
+ +--- -### Manual Email Analysis -1. Select one or more emails in Thunderbird -2. The addon will analyze and auto-organize them -3. Monitor move history to verify classifications +## πŸ’‘ Usage -### View Move History -1. Open AutoSort+ settings -2. Scroll to **"Move History"** section -3. See timestamps, subjects, and destinations -4. Last 100 moves stored +### Basic Operation -## 🎯 Recommended Providers +1. **Select Emails** - Click one or more emails in Thunderbird +2. **Right-Click Menu** - Right-click β†’ **AutoSort+ β†’ Analyze with AI** +3. **Smart Sorting** - AI analyzes and moves emails to appropriate folders +4. **Track History** - View last 100 moves in settings -**Best Overall:** Gemini - Free, fast, accurate -**Most Capable:** OpenAI - Superior reasoning -**Privacy-Focused:** Claude (Anthropic) - Strong safety guardrails -**Fastest:** Groq - 30+ requests per minute free -**Europe-Friendly:** Mistral - GDPR compliant +> πŸ”„ **Coming Soon:** Automatic background sorting (currently manual via right-click) + +### Advanced Features + +**οΏ½ Multiple API Keys (Gemini - NEW!)** +- Add unlimited keys from different projects +- Automatic rotation when limits reached +- Individual testing and status tracking +- Visual indicators (Active, Ready, Near Limit) +- Combined quota = keys Γ— 20 requests/day + +**πŸ“Š Usage Monitoring (Gemini)** +- Real-time usage display in settings +- Per-key usage statistics +- Automatic warnings at 15/20 limit +- Smart key rotation + +**πŸ“ Folder Management** +- Load folders from IMAP accounts +- Bulk import from text list +- Create custom categories +- Auto-create missing folders + +**πŸ” Move History** +- Last 100 email moves +- Timestamps and destinations +- Success/failure status +- Clear history option + +### Manual Labeling (Right-Click) + +You can also manually label emails without AI analysis: + +1. **Select Emails** - Click one or more emails in Thunderbird. +2. **Right-Click Menu** - Right-click β†’ **AutoSort+ β†’ AutoSort Label β†’ [Pick any label]** +3. **Label Applied** - The selected label/category will be applied to all selected emails instantly. + +> Labels update automatically in the right-click menu β€” no restart needed. + +--- + +
+πŸ“š Example Folder Categories + +**Work & Professional:** +- Meetings +- Project Updates +- Invoices +- HR & Benefits + +**Financial:** +- Bills & Payments +- Bank Statements +- Receipts +- Tax Documents + +**Personal:** +- Family +- Friends +- Health +- Travel + +**Online Services:** +- Shopping Confirmations +- Social Media Notifications +- Subscriptions +- Password Resets + +**Promotions:** +- Newsletters +- Sales & Discounts +- Offers +- Marketing + +**Support:** +- Tickets & Help +- Documentation +- Updates +- Complaints + +
+ +--- ## πŸ”§ Technical Details -### Architecture -- **background.js** - Email analysis engine, AI provider routing -- **options.js** - Settings UI and configuration management -- **content.js** - Message extraction from Thunderbird -- **manifest.json** - Addon metadata and permissions +### System Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Thunderbird Email Client β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ AutoSort+ Extension β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ UI Layer β”‚ β”‚ Backgroundβ”‚ β”‚ +β”‚ β”‚(options) │◄── Script β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Rate Limiter β”‚ β”‚ +β”‚ β”‚ (Gemini only) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ Gemini β”‚ β”‚ Groq β”‚ β”‚ Claude β”‚ + β”‚ API β”‚ β”‚ API β”‚ β”‚ API β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### File Structure + +| File | Purpose | Key Functions | +|------|---------|---------------| +| `background.js` | AI analysis engine | `analyzeEmailContent()`, rate limiting | +| `options.js` | Settings UI | Provider config, usage display | +| `content.js` | Email extraction | Message content parsing | +| `manifest.json` | Extension config | Permissions, metadata | + +### Storage Schema -### Storage Format ```javascript -// Settings stored in browser.storage.local { - apiKey: "your-api-key", - aiProvider: "groq", // or: gemini, openai, anthropic, mistral - labels: ["Work", "Personal", "Archive"], - enableAi: true, - moveHistory: [ /* array of moves */ ] + // User Configuration + apiKey: "string", + aiProvider: "gemini|openai|anthropic|groq|mistral", + labels: ["Work", "Personal", ...], + enableAi: boolean, + geminiPaidPlan: boolean, + + // Rate Limiting (Gemini) + geminiRateLimit: { + requests: [timestamp, ...], + dailyCount: number, + dailyResetTime: timestamp + }, + + // History + moveHistory: [ + { + timestamp: string, + subject: string, + status: string, + destination: string + }, + ... + ] } ``` -### Supported Models -| Provider | Model | Context | Speed | Free Tier | -|----------|-------|---------|-------|-----------| -| Gemini | gemini-2.5-flash | 1M tokens | ⚑⚑⚑ | Yes | -| OpenAI | gpt-4o-mini | 128K tokens | ⚑⚑ | Limited | -| Claude | claude-3-haiku | 200K tokens | ⚑⚑⚑ | Yes | -| Groq | llama-3.3-70b | 8K tokens | ⚑⚑⚑⚑ | Yes (30/min) | -| Mistral | mistral-small | 32K tokens | ⚑⚑⚑ | Yes | - ## πŸ”’ Privacy & Security -- βœ… API keys stored in browser storage (OS-encrypted) -- βœ… Email content never stored permanently -- βœ… Analysis requests sent directly to AI providers -- βœ… No telemetry or tracking -- βœ… No external dependencies -- βœ… Open source for transparency +| Feature | Status | Details | +|---------|--------|---------| +| **πŸ” API Key Storage** | βœ… Encrypted | OS-level encryption via browser storage | +| **πŸ“§ Email Content** | βœ… Not Stored | Analyzed in memory, never persisted | +| **🌐 Data Transmission** | βœ… Direct to AI | No intermediary servers | +| **πŸ“Š Telemetry** | βœ… None | Zero tracking or analytics | +| **πŸ” Open Source** | βœ… Auditable | Full transparency | +| **πŸ›‘οΈ Permissions** | βœ… Minimal | Only required APIs | + +**Your privacy matters:** All email analysis happens directly between your Thunderbird and chosen AI provider. No data passes through our servers because we don't have any! + +--- ## ⚠️ Troubleshooting -### Settings Page Won't Load +
+πŸ”§ Settings Page Won't Load + ```bash -# Clear cache and reload addon -1. Thunderbird β†’ Settings β†’ Privacy β†’ Cookies and Site Data β†’ Clear Data -2. Tools β†’ Add-ons β†’ AutoSort+ β†’ Reload +1. Thunderbird β†’ Settings β†’ Privacy β†’ Cookies and Site Data +2. Click "Clear Data" +3. Tools β†’ Add-ons β†’ AutoSort+ β†’ Reload ``` -### "API Key Not Configured" -- Paste your API key in the settings page -- Click **"Test API Connection"** -- Ensure key is from the correct provider - -### Email Analysis Fails -- βœ“ Check internet connection -- βœ“ Verify API key is valid (use Test button) -- βœ“ Check provider's status page -- βœ“ Ensure API hasn't hit rate limits -- βœ“ Review error message for guidance - -### Wrong Labels Applied -- Verify labels match exactly (case-sensitive) -- Check folders don't have special characters -- Ensure labels saved (green checkmark visible) - -## πŸ“‹ Requirements - -- **Thunderbird** 78.0+ -- **Internet connection** (for API calls) -- **Valid API key** from your chosen provider - -## πŸ“ Version History - -### v1.2.0 (2026-01-13) - Multi-Provider Release ⭐ -- βœ… Multi-provider AI support (Gemini, OpenAI, Anthropic, Groq, Mistral) -- βœ… Groq API updated to llama-3.3-70b (Mixtral deprecated) -- βœ… IMAP folder discovery with recursive traversal -- βœ… Professional UI with provider info cards -- βœ… Settings validation and state management -- βœ… Move history tracking (last 100 entries) -- βœ… Professional funnel/envelope icons +
+ +
+πŸ”‘ API Key Not Working + +- Verify key is copied correctly (no spaces) +- Click "Test API Connection" button +- Check key is from correct provider +- Ensure API key has proper permissions +- For Gemini: Check [usage page](https://aistudio.google.com/usage) + +
+ +
+❌ Email Analysis Fails + +**Check:** +- βœ“ Internet connection active +- βœ“ API key is valid +- βœ“ Provider status page for outages +- βœ“ Rate limits not exceeded +- βœ“ Email content isn't empty + +**For Gemini users:** +- Check usage counter in settings +- Verify daily limit not reached (20/day) +- Switch to new API key if needed + +
+ +
+πŸ“ Wrong Labels Applied + +- Ensure labels are case-sensitive matches +- Avoid special characters in folder names +- Verify labels are saved (green checkmark) +- Check move history for patterns + +
+ +
+⏱️ Rate Limit Errors + +**Gemini (20/day per key):** +- Create new API key in different project +- Reset counter after switching keys +- Enable "paid plan" option if you have one + +**Other Providers:** +- Wait for rate limit window to reset +- Consider upgrading to paid tier +- Use provider's usage dashboard + +
+ +--- + +## πŸ“‹ System Requirements + +| Component | Requirement | +|-----------|-------------| +| **Thunderbird** | 78.0 or later | +| **Internet** | Active connection for API calls | +| **API Key** | Valid key from chosen provider | +| **Storage** | ~1MB for extension data | +| **OS** | Windows, macOS, Linux | + +--- + +## πŸ“ Changelog + +### πŸŽ‰ v1.2.0 (2026-01-13) - Multi-Provider Release + +
+πŸ†• New Features + +- βœ… Multi-provider AI support (5 providers) +- βœ… Gemini rate limiting (5/min, 20/day enforcement) +- βœ… Real-time usage tracking dashboard +- βœ… IMAP folder auto-discovery - βœ… Bulk label import -- βœ… Fixed syntax errors in options.js +- βœ… Move history (last 100 entries) +- βœ… Professional UI redesign +- βœ… Provider info cards + +
+ +
+πŸ”§ Improvements + +- βœ… Groq updated to llama-3.3-70b +- βœ… Better error handling and validation +- βœ… Auto-create missing folders +- βœ… Skip null categories +- βœ… Batch email processing fixes +- βœ… Professional funnel/envelope icons +- βœ… Example folder categories + +
+ +
+βš™οΈ Technical Changes + - βœ… Unified API key storage +- βœ… Settings validation system +- βœ… Recursive folder traversal +- βœ… Fixed syntax errors in options.js +- βœ… Improved state management + +
-### v1.0.0 (2026-01-10) +### v1.0.0 (2026-01-10) - Initial Release - Initial release with Gemini support +--- + +## 🚧 Roadmap & TODO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PriorityFeatureStatus
🟒 DoneDetailed Logging - Debug mode with console outputβœ… Completed
πŸ”΄ HighAPI Response Headers - Extract rate limit info from APIπŸ“‹ Planned
🟑 MediumSmart Key Switching - Auto-suggest when to switch keysπŸ’‘ Proposed
🟑 MediumScheduled Processing - Auto-sort at specific timesπŸ’‘ Proposed
🟒 LowCustom Rules - User-defined sorting logicπŸ’‘ Proposed
🟒 LowStatistics Dashboard - Email sorting analyticsπŸ’‘ Proposed
+ +--- + ## πŸ› Known Issues -None currently known. Please report any issues on GitHub. +**None currently reported!** πŸŽ‰ + +If you encounter any issues, please [open an issue on GitHub](https://github.com/Nigel1992/AutoSort-Plus/issues). -## πŸ’¬ Support +--- -- **Questions?** Check [Troubleshooting](#troubleshooting) above -- **Found a bug?** Open an issue on [GitHub](https://github.com/nigelhagen/AutoSort-Plus/issues) -- **Feature request?** Create a discussion or issue +## πŸ’¬ Support & Community -## πŸ“„ License +
+ +| πŸ’‘ Questions | πŸ› Bug Reports | ✨ Feature Requests | +|--------------|----------------|---------------------| +| [Discussions](https://github.com/Nigel1992/AutoSort-Plus/discussions) | [Issues](https://github.com/Nigel1992/AutoSort-Plus/issues) | [Issues](https://github.com/Nigel1992/AutoSort-Plus/issues) | -MIT License - See [LICENSE](LICENSE) file for details +
+ +**Before reporting:** +1. Check [Troubleshooting](#troubleshooting) section +2. Search existing issues +3. Include Thunderbird version and extension version + +--- ## πŸ™ Contributing -Pull requests welcome! For major changes, please open an issue first to discuss. +We welcome contributions! Here's how to help: + +1. **Fork** the repository +2. **Create** a feature branch (`git checkout -b feature/amazing`) +3. **Commit** your changes (`git commit -m 'Add amazing feature'`) +4. **Push** to branch (`git push origin feature/amazing`) +5. **Open** a Pull Request + +**Guidelines:** +- Follow existing code style +- Add comments for complex logic +- Test with multiple AI providers +- Update README for new features + +--- + +## πŸ“„ License + +``` +MIT License + +Copyright (c) 2026 Nigel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +See [LICENSE](LICENSE) file for full text. + +--- + +## 🎨 Credits & Acknowledgments + +**Icon Design:** +[Email filtering icons created by Fantasyou - Flaticon](https://www.flaticon.com/free-icons/email-filtering) + +**AI Providers:** +- [Google Gemini](https://ai.google.dev/) +- [OpenAI](https://openai.com/) +- [Anthropic Claude](https://www.anthropic.com/) +- [Groq](https://groq.com/) +- [Mistral AI](https://mistral.ai/) + +**Built with:** +- [Thunderbird WebExtension APIs](https://webextension-api.thunderbird.net/) +- JavaScript ES6+ +- Manifest v2 + +--- + +
+ +## ⭐ Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Nigel1992/AutoSort-Plus&type=Date)](https://star-history.com/#Nigel1992/AutoSort-Plus&Date) --- **Made with ❀️ to help you organize email faster** -[GitHub](https://github.com/nigelhagen/AutoSort-Plus) β€’ [Issues](https://github.com/nigelhagen/AutoSort-Plus/issues) β€’ [Latest Release](https://github.com/nigelhagen/AutoSort-Plus/releases) +[⬆ Back to Top](#autosort) β€’ [🏠 GitHub](https://github.com/Nigel1992/AutoSort-Plus) β€’ [πŸ“¦ Latest Release](https://github.com/Nigel1992/AutoSort-Plus/releases) β€’ [πŸ“– Documentation](https://nigel1992.github.io/AutoSort-Plus/) + +--- + +![Thunderbird](https://img.shields.io/badge/Thunderbird-78.0+-0A84FF?style=for-the-badge&logo=thunderbird&logoColor=white) +![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge) +![Version](https://img.shields.io/badge/Version-1.2.3.3-blue?style=for-the-badge) + +
+ +## Support This Project + +Support this project! All donations go towards your chosen charity. You can pick any charity you'd like, and I will ensure the funds are sent their way. Please note that standard payment processing fees (Ko-fi & PayPal) will be deducted from the total. As a thank you, your name will be listed as a supporter/donor in this project. Feel free to email me at thedjskywalker@gmail.com for proof of the donation or to let me know which charity you've selected! diff --git a/REDDIT_POST.md b/REDDIT_POST.md new file mode 100644 index 0000000..a9f0fb5 --- /dev/null +++ b/REDDIT_POST.md @@ -0,0 +1,74 @@ +# Reply to u/noir_dreams - Ollama Support Available Now! πŸŽ‰ + +Hey u/noir_dreams! + +You asked about **Ollama support** for local email classification and **getting past API limits** - great news, I just released a **test version with exactly that**! + +## Your Questions Answered + +**Ollama + Locally Sorted AI?** βœ… Done! +**Get past API limits/tickets?** βœ… Completely unlimited - runs locally +**Model flexibility (gemma, gpt-oss-20b, etc.)?** βœ… Any Ollama model works! + +## Recommended Models for Email Classification + +Based on testing, these work great: +- **tinyllama** (~1GB) - Super fast, good for quick sorting +- **phi** (~2.7GB) - Better accuracy, still reasonably fast +- **gemma** (~2.5GB) - Solid balance of quality and speed +- **llama3.2** (~5GB) - High quality, best accuracy +- **qwen** (~4GB) - Another solid option + +All run **locally on your machine with zero rate limits**. Classify unlimited emails! + +## Setup (30 seconds) + +1. Install Ollama: https://ollama.com/download +2. Pull your model: `ollama pull gemma` +3. Download test XPI: [**AutoSort+ v1.2.3.1-ollama-test**](https://github.com/yourusername/AutoSort-Plus/releases/tag/v1.2.3.1-ollama-test) +4. Open Thunderbird β†’ Drag XPI into Add-ons page +5. Settings β†’ Provider: Ollama β†’ Model: gemma +6. Click "Test Connection" +7. Done! Right-click emails β†’ "Analyze with AI" + +## Why This is Cool + +- 🏠 **100% Local** - No data leaves your computer +- πŸ†“ **No API Keys or Limits** - Classify as many emails as you want +- πŸ”’ **Privacy First** - Your emails stay yours +- πŸ’ͺ **Your Choice** - Use any model: gemma, phi, tinyllama, llama3.2, qwen, etc. + +## If You Hit Issues + +**Check Ollama is running:** +```bash +curl http://localhost:11434/api/tags +``` +Should return your installed models + +**Enable debug mode:** +- Ctrl+Shift+J in Thunderbird +- Look for `[Ollama]` messages during analysis +- Post console errors in GitHub issues + +**Common fixes:** +- Make sure Ollama daemon is running +- Pull the model first: `ollama list` +- Check full debugging guide in release notes + +## This is a TEST Release + +⚠️ **Please test and report back!** This is experimental but working. Uses a new tab injection approach to bypass Thunderbird's fetch restrictions. + +Specifically looking for: +- What model works best for your email? +- Performance on your system? +- Any bugs or errors? + +--- + +**Download:** [v1.2.3.1-ollama-test on GitHub](https://github.com/yourusername/AutoSort-Plus/releases/tag/v1.2.3.1-ollama-test) +**Full Guide:** See release notes for detailed setup and debugging +**Models Tested:** tinyllama, phi, gemma, llama3.2, qwen + +Looking forward to hearing your results! πŸš€ diff --git a/RELEASE_NOTES_OLLAMA_TEST.md b/RELEASE_NOTES_OLLAMA_TEST.md new file mode 100644 index 0000000..4931fdb --- /dev/null +++ b/RELEASE_NOTES_OLLAMA_TEST.md @@ -0,0 +1,94 @@ +# AutoSort+ v1.2.3.1-ollama-test - Ollama Support (Test Release) + +## πŸ§ͺ Test Release - Ollama Local AI Integration + +This is a **test release** with experimental Ollama support for local AI email classification. Please report any issues! + +## ✨ What's New + +**Local Ollama Support:** +- 🏠 Run AI email classification completely locally with Ollama +- πŸ”’ No data sent to external APIs +- πŸ†“ No API keys or rate limits +- 🎯 Support for any Ollama model (tinyllama, llama3.2, phi, gemma, etc.) +- βš™οΈ CPU-only mode option for systems without GPU + +## πŸš€ Quick Start with Ollama + +### Prerequisites +1. Install Ollama: https://ollama.com/download +2. Pull a model: `ollama pull tinyllama` (or llama3.2, phi, gemma, etc.) +3. Verify it's running: `ollama list` + +### Setup in Extension +1. Open AutoSort+ settings +2. Select **Ollama** as AI provider +3. Leave URL as `http://localhost:11434` (default) +4. Select your model from dropdown +5. Click **Test Connection** - should show your installed models +6. Click **Save Settings** + +### Test It +1. Select an email +2. Right-click β†’ **Analyze with AI** +3. Watch as Ollama classifies it locally! + +## πŸ› Known Issues & Debugging + +### If you get errors: +1. **Check Ollama is running:** + ```bash + curl http://localhost:11434/api/tags + ``` + Should return list of models + +2. **Test chat directly:** + ```bash + curl -X POST http://localhost:11434/api/chat \ + -H "Content-Type: application/json" \ + -d '{"model":"tinyllama","messages":[{"role":"user","content":"test"}],"stream":false}' + ``` + Should return a response + +3. **Enable debug logging:** + - Open Browser Console (Ctrl+Shift+J) + - Watch for `[Ollama]` messages during analysis + - Look for any error messages + +4. **Common issues:** + - 403 errors: Fixed in this release with tab injection approach + - Timeout: Increase wait time or use faster model + - Model not found: Run `ollama pull ` + +### Report Issues +Please include: +- Thunderbird version +- Ollama version (`ollama --version`) +- Model used +- Console logs showing the error + +## πŸ“ Technical Details + +This release uses a **tab injection approach** to bypass Thunderbird's fetch restrictions: +1. Opens hidden tab at Ollama origin +2. Injects script to make POST request +3. Retrieves result and closes tab +4. Works like curl - no special permissions needed + +## πŸ”„ Upgrading from Previous Version + +If you tested earlier Ollama builds: +1. Uninstall old version +2. Install this XPI +3. Reconfigure Ollama settings +4. Test connection again + +## ⚠️ Disclaimer + +This is a **test release**. The Ollama integration is experimental and may have bugs. Please backup your settings before installing. + +--- + +**Installation:** Download `autosortplus.xpi` and drag into Thunderbird Add-ons page + +**Feedback:** Open an issue on GitHub with your results! diff --git a/_locales/en/messages.json b/_locales/en/messages.json new file mode 100644 index 0000000..bb37db8 --- /dev/null +++ b/_locales/en/messages.json @@ -0,0 +1,1270 @@ +{ + "extensionName": { + "message": "AutoSort+", + "description": "Extension name" + }, + "extensionDescription": { + "message": "Automatically sort and label your emails with custom rules using AI", + "description": "Extension description" + }, + "extensionDefaultTitle": { + "message": "AutoSort+ Settings", + "description": "Default title shown in browser action" + }, + "pageTitle": { + "message": "AutoSort+ Settings", + "description": "HTML page title" + }, + "pageHeading": { + "message": "AutoSort+ Settings", + "description": "Main page heading" + }, + "batchProcessingTitle": { + "message": "Batch Processing In Progress", + "description": "Batch processing status panel title" + }, + "batchPreparing": { + "message": "Preparing…", + "description": "Batch processing preparing text" + }, + "batchPause": { + "message": "⏸ Pause", + "description": "Batch pause button text" + }, + "batchResume": { + "message": "β–Ά Resume", + "description": "Batch resume button text" + }, + "batchCancel": { + "message": "⏹ Cancel", + "description": "Batch cancel button text" + }, + "aiSettingsTitle": { + "message": "πŸ€– AI Settings", + "description": "AI settings section header" + }, + "providerSelectionTitle": { + "message": "Provider Selection", + "description": "Provider selection subsection header" + }, + "aiProviderLabel": { + "message": "AI Provider:", + "description": "AI provider select label" + }, + "providerGemini": { + "message": "Google Gemini (Recommended)", + "description": "Gemini provider option" + }, + "providerOpenAI": { + "message": "OpenAI (ChatGPT)", + "description": "OpenAI provider option" + }, + "providerAnthropic": { + "message": "Anthropic Claude", + "description": "Anthropic provider option" + }, + "providerGroq": { + "message": "Groq (Fast & Free)", + "description": "Groq provider option" + }, + "providerMistral": { + "message": "Mistral AI", + "description": "Mistral provider option" + }, + "providerOllama": { + "message": "Ollama (Local LLM)", + "description": "Ollama provider option" + }, + "providerOpenAICompatible": { + "message": "OpenAI-Compatible (Custom Endpoint)", + "description": "OpenAI-Compatible provider option" + }, + "rateLimitWarningTitle": { + "message": "⚠️ Rate Limit Warning:", + "description": "Rate limit warning header" + }, + "rateLimitWarningText": { + "message": "Free API tiers are severely limited when processing emails. You may only process 5-20 emails before hitting rate limits. Paid plans ($5-20/month) are recommended for daily email processing.", + "description": "Rate limit warning text" + }, + "ollamaConfigTitle": { + "message": "🏠 Local Ollama Configuration", + "description": "Ollama configuration subsection header" + }, + "ollamaUrlLabel": { + "message": "Ollama Server URL:", + "description": "Ollama URL label" + }, + "ollamaUrlPlaceholder": { + "message": "http://localhost:11434", + "description": "Ollama URL placeholder" + }, + "ollamaCpuOnly": { + "message": "Force CPU-only mode (disable GPU acceleration)", + "description": "Ollama CPU-only checkbox" + }, + "ollamaModelLabel": { + "message": "Ollama Model:", + "description": "Ollama model select label" + }, + "ollamaAuthTokenLabel": { + "message": "Ollama Auth Token (optional):", + "description": "Ollama auth token label" + }, + "ollamaAuthTokenPlaceholder": { + "message": "If your Ollama server requires a token, enter it here", + "description": "Ollama auth token placeholder" + }, + "ollamaAuthTokenHelp": { + "message": "Used for /api/chat and /api/pull requests when required.", + "description": "Ollama auth token help text" + }, + "ollamaCustomModelPlaceholder": { + "message": "Enter custom model name", + "description": "Ollama custom model placeholder" + }, + "ollamaDownloadModelLabel": { + "message": "Download Model:", + "description": "Ollama download model label" + }, + "ollamaDownloadModelPlaceholder": { + "message": "e.g., llama3.2, mistral, qwen2.5:7b", + "description": "Ollama download model placeholder" + }, + "ollamaDownloadButton": { + "message": "Download", + "description": "Download model button" + }, + "ollamaListModelsButton": { + "message": "List Installed Models", + "description": "List installed models button" + }, + "ollamaTestButton": { + "message": "Test Connection", + "description": "Test Ollama connection button" + }, + "ollamaDiagnoseButton": { + "message": "Run Diagnostics", + "description": "Run Ollama diagnostics button" + }, + "ollamaModelLlama2": { + "message": "Llama 2", + "description": "Ollama Llama 2 model option" + }, + "ollamaModelLlama32": { + "message": "Llama 3.2", + "description": "Ollama Llama 3.2 model option" + }, + "ollamaModelMistral": { + "message": "Mistral", + "description": "Ollama Mistral model option" + }, + "ollamaModelPhi": { + "message": "Phi", + "description": "Ollama Phi model option" + }, + "ollamaModelGemma": { + "message": "Gemma", + "description": "Ollama Gemma model option" + }, + "ollamaModelQwen25": { + "message": "Qwen 2.5", + "description": "Ollama Qwen 2.5 model option" + }, + "ollamaModelCustom": { + "message": "Custom (enter below)", + "description": "Ollama custom model option" + }, + "openaiCompatibleTitle": { + "message": "πŸ”— OpenAI-Compatible Endpoint", + "description": "OpenAI-Compatible subsection header" + }, + "openaiCompatibleBaseUrlLabel": { + "message": "Base URL:", + "description": "OpenAI-Compatible base URL label" + }, + "openaiCompatibleBaseUrlPlaceholder": { + "message": "http://localhost:1234/v1 or https://api.provider.com/v1", + "description": "OpenAI-Compatible base URL placeholder" + }, + "openaiCompatibleBaseUrlHelp": { + "message": "Enter base URL including /v1. Endpoint must use OpenAI format: /v1/chat/completions. Examples: LM Studio, LocalAI, vLLM, Together AI", + "description": "OpenAI-Compatible base URL help text" + }, + "openaiCompatibleModelLabel": { + "message": "Model:", + "description": "OpenAI-Compatible model select label" + }, + "openaiCompatibleModelSelect": { + "message": "-- Select model --", + "description": "OpenAI-Compatible model select default option" + }, + "openaiCompatibleModelCustom": { + "message": "Custom (enter below)", + "description": "OpenAI-Compatible custom model option" + }, + "openaiCompatibleModelCustomPlaceholder": { + "message": "Enter model name manually", + "description": "OpenAI-Compatible custom model placeholder" + }, + "openaiCompatibleApiKeyLabel": { + "message": "API Key (optional):", + "description": "OpenAI-Compatible API key label" + }, + "openaiCompatibleApiKeyPlaceholder": { + "message": "Leave empty for local endpoints without auth", + "description": "OpenAI-Compatible API key placeholder" + }, + "openaiCompatibleApiKeyHelp": { + "message": "Required for cloud providers, optional for local servers", + "description": "OpenAI-Compatible API key help text" + }, + "openaiCompatibleFetchModelsButton": { + "message": "Fetch Models", + "description": "Fetch models button" + }, + "openaiCompatibleTestButton": { + "message": "Test Connection", + "description": "Test OpenAI-Compatible connection button" + }, + "apiKeyTitle": { + "message": "πŸ”‘ API Key Configuration", + "description": "API key configuration subsection header" + }, + "apiKeyLabel": { + "message": "API Key:", + "description": "API key input label" + }, + "apiKeyPlaceholder": { + "message": "Enter your API key", + "description": "API key input placeholder" + }, + "testApiButton": { + "message": "Test API Connection", + "description": "Test API connection button" + }, + "getApiKeyButton": { + "message": "Get API Key", + "description": "Get API key button" + }, + "geminiMultiKeysTitle": { + "message": "πŸ”„ Multiple Gemini API Keys", + "description": "Multiple Gemini keys subsection header" + }, + "geminiMultiKeysInfo": { + "message": "Add multiple API keys from different Google Cloud projects. The extension will automatically rotate between them when rate limits are reached.", + "description": "Multiple Gemini keys info text" + }, + "addGeminiKeyButton": { + "message": "+ Add Another Gemini Key", + "description": "Add Gemini key button" + }, + "testButton": { + "message": "Test", + "description": "Test individual Gemini API key button" + }, + "geminiPaidPlan": { + "message": "I have a Gemini paid plan (removes rate limits)", + "description": "Gemini paid plan checkbox" + }, + "generalSettingsTitle": { + "message": "βš™οΈ General Settings", + "description": "General settings subsection header" + }, + "enableAiLabel": { + "message": "Enable AI-powered sorting", + "description": "Enable AI checkbox" + }, + "enableDebugLabel": { + "message": "Enable debug mode (console logging)", + "description": "Enable debug mode checkbox" + }, + "enableDebugHelp": { + "message": "Open Thunderbird Developer Tools (Ctrl+Shift+I) to view logs", + "description": "Debug mode help text" + }, + "batchChunkSizeLabel": { + "message": "Batch chunk size:", + "description": "Batch chunk size label" + }, + "batchChunkSizeHelp": { + "message": "Process N emails at once, wait for all responses, then continue (1-20)", + "description": "Batch chunk size help text" + }, + "enableAutoSortLabel": { + "message": "Auto-sort new emails in Inbox", + "description": "Auto-sort checkbox label" + }, + "enableAutoSortHelp": { + "message": "Automatically classify and move new Inbox emails using AI", + "description": "Auto-sort help text" + }, + "autoSortNotifyLabel": { + "message": "Notify when auto-sort completes", + "description": "Auto-sort notification checkbox label" + }, + "autoSortNotifyHelp": { + "message": "Show a notification with success/fail/pending counts", + "description": "Auto-sort notification help text" + }, + "geminiUsageTitle": { + "message": "πŸ“Š Gemini API Usage", + "description": "Gemini API usage subsection header" + }, + "geminiDailyCount": { + "message": "Today's Usage: {count}/20 requests", + "description": "Gemini daily usage count", + "placeholders": { + "count": { + "content": "$1" + } + } + }, + "geminiLastRequest": { + "message": "Last Request:", + "description": "Gemini last request label" + }, + "geminiNever": { + "message": "Never", + "description": "Never used text" + }, + "geminiResetTime": { + "message": "Daily Limit Resets:", + "description": "Gemini daily limit reset time label" + }, + "geminiStatus": { + "message": "Status:", + "description": "Gemini status label" + }, + "geminiStatusReady": { + "message": "Ready", + "description": "Gemini ready status" + }, + "geminiStatusNearlyFull": { + "message": "Nearly Full", + "description": "Gemini nearly full usage" + }, + "geminiStatusLimitReached": { + "message": "Limit Reached", + "description": "Gemini limit reached status" + }, + "geminiLimitMessage": { + "message": "Daily limit reached. Switch to another key or wait for reset.", + "description": "Gemini daily limit warning" + }, + "geminiRemainingMessage": { + "message": "requests remaining today. Consider switching keys.", + "description": "Gemini remaining usage warning" + }, + "geminiKeyInputPlaceholder": { + "message": "Enter Gemini API key", + "description": "Gemini API key input placeholder" + }, + "geminiResetExpired": { + "message": "Token expired or invalid. Generate a new one from AI Studio.", + "description": "Gemini reset token expired message" + }, + "requestsRemainingToday": { + "message": "requests remaining today. Consider switching keys.", + "description": "Requests remaining today text" + }, + "resetGeminiCounterButton": { + "message": "Reset Counter (New API Key)", + "description": "Reset Gemini counter button" + }, + "refreshUsageButton": { + "message": "Refresh Usage", + "description": "Refresh usage button" + }, + "refreshAllUsageButton": { + "message": "Refresh All Usage", + "description": "Refresh all usage button" + }, + "howAiSortingTitle": { + "message": "ℹ️ How AI Sorting Works", + "description": "How AI sorting works subsection header" + }, + "howAiSortingDesc": { + "message": "AutoSort+ uses AI to analyze your emails and automatically sort them into categories/folders based on their content. The AI will:", + "description": "AI sorting description" + }, + "howAiSortingPoint1": { + "message": "Read and understand email content", + "description": "AI sorting capability 1" + }, + "howAiSortingPoint2": { + "message": "Identify key topics and themes", + "description": "AI sorting capability 2" + }, + "howAiSortingPoint3": { + "message": "Match emails to appropriate categories/folders", + "description": "AI sorting capability 3" + }, + "howAiSortingPoint4": { + "message": "Learn from your manual corrections to improve accuracy", + "description": "AI sorting capability 4" + }, + "customPromptTitle": { + "message": "πŸ“ Custom Prompt", + "description": "Custom prompt section header" + }, + "customPromptInfo": { + "message": "Customize the prompt sent to AI for email classification.", + "description": "Custom prompt info text" + }, + "customPromptPlaceholders": { + "message": "Available placeholders:", + "description": "Custom prompt placeholders label" + }, + "customPromptPlaceholderLabel": { + "message": "Your folder/label list", + "description": "Labels placeholder description" + }, + "customPromptSubjectLabel": { + "message": "Email subject line", + "description": "Subject placeholder description" + }, + "customPromptAuthorLabel": { + "message": "Sender email address/name", + "description": "Author placeholder description" + }, + "customPromptAttachmentsLabel": { + "message": "Attachment filenames (comma-separated)", + "description": "Attachments placeholder description" + }, + "customPromptBodyLabel": { + "message": "Email body content (recommended)", + "description": "Body placeholder description" + }, + "customPromptEmailLabel": { + "message": "Email content (legacy, same as {body})", + "description": "Email placeholder description", + "placeholders": { + "body": { + "content": "$1" + } + } + }, + "customPromptTip": { + "message": "Tip: Use {subject} and {attachments} for better classification accuracy.", + "description": "Custom prompt tip", + "placeholders": { + "subject": { + "content": "$1" + }, + "attachments": { + "content": "$2" + } + } + }, + "customPromptTextareaPlaceholder": { + "message": "Enter your custom prompt...", + "description": "Custom prompt textarea placeholder" + }, + "resetPromptButton": { + "message": "Reset to Default", + "description": "Reset prompt button" + }, + "customFoldersTitle": { + "message": "πŸ“ Custom Categories/Folders", + "description": "Custom categories/folders section header" + }, + "folderSourceTitle": { + "message": "Folder Source", + "description": "Folder source subsection header" + }, + "loadImapFoldersButton": { + "message": "Load Folders from Mail Account", + "description": "Load IMAP folders button" + }, + "folderLoadingText": { + "message": "Loading folders...", + "description": "Folder loading indicator text" + }, + "folderFoundText": { + "message": "Found {count} folders in your mail account. Would you like to use these?", + "description": "Folder found text", + "placeholders": { + "count": { + "content": "$1" + } + } + }, + "useImapFoldersButton": { + "message": "Use These Folders", + "description": "Use IMAP folders button" + }, + "useCustomFoldersButton": { + "message": "Use Custom Folders Instead", + "description": "Use custom folders button" + }, + "bulkImportLabel": { + "message": "Import Categories/Folders (one per line):", + "description": "Bulk import textarea label" + }, + "bulkImportPlaceholder": { + "message": "Enter categories/folders, one per line", + "description": "Bulk import textarea placeholder" + }, + "importButton": { + "message": "Import", + "description": "Import button" + }, + "addButton": { + "message": "Add", + "description": "Add button" + }, + "labelInputPlaceholder": { + "message": "Enter category/folder name", + "description": "Label input placeholder" + }, + "moveHistoryTitle": { + "message": "πŸ“œ Move History", + "description": "Move history section header" + }, + "clearHistoryButton": { + "message": "Clear History", + "description": "Clear history button" + }, + "refreshHistoryButton": { + "message": "Refresh", + "description": "Refresh history button" + }, + "historyHeaderTimestamp": { + "message": "Timestamp", + "description": "History table timestamp header" + }, + "historyHeaderSubject": { + "message": "Subject", + "description": "History table subject header" + }, + "historyHeaderStatus": { + "message": "Status", + "description": "History table status header" + }, + "historyHeaderDestination": { + "message": "Destination", + "description": "History table destination header" + }, + "saveSettingsButton": { + "message": "Save Settings", + "description": "Save settings button" + }, + "providerInfoGemini": { + "message": "βœ“ Free tier: 5 requests/minute, 20/day per API key (enforced by addon)
βœ“ Tip: Create multiple API keys in different projects, switch keys when limit reached
βœ“ Check usage: AI Studio Usage
βœ“ Best for: General use, multilingual support
βœ“ Models: Gemini 2.5 Flash
βœ“ Check \"paid plan\" option to remove limits", + "description": "Gemini provider info HTML" + }, + "providerInfoOpenai": { + "message": "βœ“ Free trial: $5 credit
βœ“ Best for: High accuracy, English content
βœ“ Models: GPT-4o-mini ($0.15/1M tokens)", + "description": "OpenAI provider info HTML" + }, + "providerInfoAnthropic": { + "message": "βœ“ Free tier: Limited requests
βœ“ Best for: Long emails, detailed analysis
βœ“ Models: Claude 3 Haiku", + "description": "Anthropic provider info HTML" + }, + "providerInfoGroq": { + "message": "βœ“ Free tier: 30 requests/minute
βœ“ Best for: Speed (fastest)
βœ“ Models: Llama 3.3 (Mixtral deprecated)", + "description": "Groq provider info HTML" + }, + "providerInfoMistral": { + "message": "βœ“ Free tier: Limited requests
βœ“ Best for: European users, GDPR compliance
βœ“ Models: Mistral Small", + "description": "Mistral provider info HTML" + }, + "providerInfoOllama": { + "message": "βœ“ 100% Free: Runs locally on your machine
βœ“ Privacy: No data sent to external servers
βœ“ No rate limits: Process unlimited emails
βœ“ Models: Llama 2/3, Mistral, Phi, Gemma, Qwen, and more
βœ“ Requires: Ollama installed and running locally
βœ“ Setup: Install Ollama, run \"ollama pull llama3.2\" to download a model", + "description": "Ollama provider info HTML" + }, + "providerInfoOpenaiCompatible": { + "message": "βœ“ Compatible with: LocalAI, LM Studio, vLLM, Together AI, OpenRouter, DeepSeek, Fireworks, etc.
βœ“ Enter your endpoint base URL and model name
βœ“ API key optional for local servers
βœ“ Uses standard /v1/chat/completions format", + "description": "OpenAI-Compatible provider info HTML" + }, + "freeBadge": { + "message": "FREE", + "description": "Free provider badge text" + }, + "paidBadge": { + "message": "PAID", + "description": "Paid provider badge text" + }, + "justNow": { + "message": "Just now", + "description": "Relative time β€” request was just made" + }, + "minutesAgo": { + "message": "{count} minute{plural} ago", + "description": "Relative time β€” minutes ago", + "placeholders": { + "count": { "content": "$1" }, + "plural": { "content": "$2" } + } + }, + "hoursAgo": { + "message": "{count} hour{plural} ago", + "description": "Relative time β€” hours ago", + "placeholders": { + "count": { "content": "$1" }, + "plural": { "content": "$2" } + } + }, + "inHours": { + "message": "In {count} hour{plural}", + "description": "Reset countdown text", + "placeholders": { + "count": { "content": "$1" }, + "plural": { "content": "$2" } + } + }, + "inHoursShort": { + "message": "{count}h", + "description": "Reset countdown short form", + "placeholders": { + "count": { "content": "$1" } + } + }, + "minutesAgoShort": { + "message": "{count}m ago", + "description": "Minutes ago short form", + "placeholders": { + "count": { "content": "$1" } + } + }, + "hoursAgoShort": { + "message": "{count}h ago", + "description": "Hours ago short form", + "placeholders": { + "count": { "content": "$1" } + } + }, + "keyActive": { + "message": "πŸ”΅ ACTIVE", + "description": "Key usage card active status" + }, + "keyLimit": { + "message": "πŸ”΄ LIMIT", + "description": "Key usage card limit reached" + }, + "keyNearLimit": { + "message": "🟑 NEAR LIMIT", + "description": "Key usage card near limit" + }, + "keyReady": { + "message": "🟒 READY", + "description": "Key usage card ready status" + }, + "keyLabel": { + "message": "Key {number}:", + "description": "Key usage card label", + "placeholders": { + "number": { "content": "$1" } + } + }, + "statUsage": { + "message": "Usage:", + "description": "Stat label for usage count" + }, + "statLast": { + "message": "Last:", + "description": "Stat label for last request" + }, + "statResets": { + "message": "Resets:", + "description": "Stat label for reset time" + }, + "statAvailable": { + "message": "Available:", + "description": "Stat label for available requests" + }, + "keyNotSet": { + "message": "Not set", + "description": "Key not configured text" + }, + "keyAlreadyAddedTitle": { + "message": "⚠️ This key is already added!", + "description": "Tooltip when duplicate Gemini key detected" + }, + "enterKeyFirst": { + "message": "⚠️ Enter key first", + "description": "Error when testing empty Gemini key" + }, + "duplicateKey": { + "message": "⚠️ Duplicate key", + "description": "Error for duplicate Gemini key" + }, + "duplicateKeyTitle": { + "message": "This key is already added in the list", + "description": "Tooltip for duplicate key error" + }, + "mustHaveOneKey": { + "message": "You must have at least one API key configured.", + "description": "Alert when trying to remove last Gemini key" + }, + "removeApiKeyConfirm": { + "message": "Remove API key #{number}?", + "description": "Confirm dialog for removing Gemini key", + "placeholders": { + "number": { "content": "$1" } + } + }, + "testingStatus": { + "message": "Testing...", + "description": "Generic testing status" + }, + "validKey": { + "message": "βœ“ Valid", + "description": "Key test success" + }, + "limitReachedGemini": { + "message": "⚠️ Limit reached", + "description": "Gemini key rate limited" + }, + "limitReachedGeminiTitle": { + "message": "This key has reached its daily rate limit (20/day). Will reset in ~24 hours.", + "description": "Tooltip for rate limited Gemini key" + }, + "invalidKey": { + "message": "βœ— Invalid key", + "description": "Key test invalid" + }, + "invalidKeyTitle": { + "message": "API key is invalid or expired. Check your key in Google AI Studio.", + "description": "Tooltip for invalid key" + }, + "testFailed": { + "message": "βœ— Failed ({status})", + "description": "Key test failed with status", + "placeholders": { + "status": { "content": "$1" } + } + }, + "errorStatus": { + "message": "βœ— Error", + "description": "Generic error status" + }, + "resetCounterConfirm": { + "message": "Reset usage counter? Do this only after switching to a new API key.", + "description": "Confirm for resetting Gemini counter" + }, + "counterResetMsg": { + "message": "βœ“ Usage counter reset. You can now process up to 20 more emails today with your new API key.", + "description": "Success after counter reset" + }, + "usageRefreshed": { + "message": "βœ“ Usage information refreshed.", + "description": "Info after usage refresh" + }, + "allUsageRefreshed": { + "message": "βœ“ All usage information refreshed.", + "description": "Info after all usage refresh" + }, + "noSignupUrl": { + "message": "This provider doesn't have a signup URL. Configure the endpoint directly in the settings above.", + "description": "Error when provider has no signup URL" + }, + "urlCopied": { + "message": "URL copied to clipboard:\n{url}", + "description": "Info when URL copied to clipboard", + "placeholders": { + "url": { "content": "$1" } + } + }, + "pleaseVisit": { + "message": "Please visit:\n{url}", + "description": "Alert when cannot copy URL", + "placeholders": { + "url": { "content": "$1" } + } + }, + "pleaseConfigure": { + "message": "Please configure: {items}", + "description": "Save button tooltip when missing config", + "placeholders": { + "items": { "content": "$1" } + } + }, + "noFoldersInstruction": { + "message": "No folders/labels configured. Click \"Load Folders from Mail Account\" above or add custom labels below.", + "description": "Instruction when no folders exist" + }, + "noFoldersFound": { + "message": "No folders found. You can create custom folders instead.", + "description": "Info when no folders in mail account" + }, + "andMore": { + "message": "...and {count} more", + "description": "Folder preview overflow", + "placeholders": { + "count": { "content": "$1" } + } + }, + "errorLoadingFolders": { + "message": "Error loading folders: {error}", + "description": "Error loading folders", + "placeholders": { + "error": { "content": "$1" } + } + }, + "replaceFoldersConfirm": { + "message": "This will replace any existing folders/labels with {count} folders from your mail account. Continue?", + "description": "Confirm for replacing folders", + "placeholders": { + "count": { "content": "$1" } + } + }, + "loadedFoldersMsg": { + "message": "Loaded {count} folders from your mail account. Don't forget to save!", + "description": "Success after loading folders", + "placeholders": { + "count": { "content": "$1" } + } + }, + "addCustomFoldersMsg": { + "message": "You can now add custom folders below", + "description": "Info after choosing custom folders" + }, + "importOneLabelRequired": { + "message": "Please add at least one folder/label before importing. Enter labels one per line.", + "description": "Error when importing empty text" + }, + "replaceExistingConfirm": { + "message": "This will replace your {existing} existing folders/labels with {new} new ones. Continue?", + "description": "Confirm for replacing existing labels", + "placeholders": { + "existing": { "content": "$1" }, + "new": { "content": "$2" } + } + }, + "importedFoldersMsg": { + "message": "Imported {count} categories/folders. Don't forget to save!", + "description": "Success after import", + "placeholders": { + "count": { "content": "$1" } + } + }, + "useOllamaTestButton": { + "message": "Please use the \"Test Ollama Connection\" button below", + "description": "Redirect to Ollama-specific test" + }, + "useCustomTestButton": { + "message": "Please use the \"Test Connection\" button in the OpenAI-Compatible section", + "description": "Redirect to custom endpoint test" + }, + "enterApiKey": { + "message": "Please enter an API key", + "description": "Error when testing with empty API key" + }, + "testingConnection": { + "message": "Testing connection...", + "description": "API testing status" + }, + "apiConnectionSuccess": { + "message": "βœ“ API connection successful!", + "description": "API test success" + }, + "apiError": { + "message": "API Error: {error}", + "description": "API error message", + "placeholders": { + "error": { "content": "$1" } + } + }, + "connectionError": { + "message": "Connection Error: {error}", + "description": "Connection error message", + "placeholders": { + "error": { "content": "$1" } + } + }, + "enterCustomModelFirst": { + "message": "⚠️ Please enter a custom model name first", + "description": "Error when testing Ollama without custom model" + }, + "testingConnectionModels": { + "message": "Testing connection and checking model...", + "description": "Ollama test status" + }, + "ollamaRunningNoModels": { + "message": "⚠️ Ollama is running but no models installed. Enter a model name in \"Download Model\" and click \"Download\" to get started.", + "description": "Ollama no models warning" + }, + "connectedModelReady": { + "message": "βœ“ Connected! Model \"{model}\" is installed and ready. Available: {available}", + "description": "Ollama connected with model", + "placeholders": { + "model": { "content": "$1" }, + "available": { "content": "$2" } + } + }, + "modelNotInstalled": { + "message": "βœ— Model \"{model}\" not installed. Available models: {available}. Use \"Download Model\" to install it.", + "description": "Ollama model not found", + "placeholders": { + "model": { "content": "$1" }, + "available": { "content": "$2" } + } + }, + "genericErrorLabel": { + "message": "βœ— Error: {error}", + "description": "Generic error label", + "placeholders": { + "error": { "content": "$1" } + } + }, + "ollamaErrorLabel": { + "message": "βœ— Error: {error}", + "description": "Ollama error", + "placeholders": { + "error": { "content": "$1" } + } + }, + "ollamaConnectionFailed": { + "message": "βœ— Connection failed: {error}. Make sure Ollama is running (try: ollama serve)", + "description": "Ollama connection error", + "placeholders": { + "error": { "content": "$1" } + } + }, + "enterBaseUrlFirst": { + "message": "⚠️ Please enter a base URL first", + "description": "Error when fetching models without URL" + }, + "fetchingModels": { + "message": "Fetching models from endpoint...", + "description": "Fetch models status" + }, + "noModelsEndpoint": { + "message": "⚠️ No models found at this endpoint", + "description": "No models at endpoint" + }, + "foundModelsMsg": { + "message": "βœ“ Found {count} models. Select from dropdown or use \"Custom\" option.", + "description": "Found models success", + "placeholders": { + "count": { "content": "$1" } + } + }, + "failedFetchModels": { + "message": "βœ— Failed to fetch models: {error}", + "description": "Failed to fetch models", + "placeholders": { + "error": { "content": "$1" } + } + }, + "enterBaseUrl": { + "message": "⚠️ Please enter a base URL", + "description": "Error for empty base URL" + }, + "enterModelName": { + "message": "⚠️ Please enter a model name", + "description": "Error for empty model name" + }, + "connectedSuccessfully": { + "message": "βœ“ Connected successfully! Model \"{model}\" is ready at {url}", + "description": "Custom endpoint success", + "placeholders": { + "model": { "content": "$1" }, + "url": { "content": "$2" } + } + }, + "customConnectionFailed": { + "message": "βœ— Connection failed: {error}. Check the base URL and ensure the endpoint is running.", + "description": "Custom endpoint connection failed", + "placeholders": { + "error": { "content": "$1" } + } + }, + "diagnosticsTitle": { + "message": "πŸ” OLLAMA DIAGNOSTICS", + "description": "Ollama diagnostics header" + }, + "diagnosticsRunning": { + "message": "Running tests...", + "description": "Diagnostics running status" + }, + "testListModels": { + "message": "πŸ“‹ Test 1: List Models Endpoint", + "description": "Diagnostics test 1 header" + }, + "testVersion": { + "message": "πŸ”’ Test 2: Version Endpoint", + "description": "Diagnostics test 2 header" + }, + "testPullEndpoint": { + "message": "⬇️ Test 3: Pull Endpoint Check", + "description": "Diagnostics test 3 header" + }, + "diagnosticsSummary": { + "message": "πŸ“Š SUMMARY:", + "description": "Diagnostics summary header" + }, + "ollamaRunningOk": { + "message": "βœ“ Ollama is running and accessible", + "description": "Diagnostics success message" + }, + "cannotConnectOllama": { + "message": "βœ— Cannot connect to Ollama", + "description": "Diagnostics failure message" + }, + "troubleshootingLabel": { + "message": "Troubleshooting:", + "description": "Diagnostics troubleshooting header" + }, + "troubleshootRunning": { + "message": "1. Check if Ollama is running: ps aux | grep ollama", + "description": "Diagnostics tip 1" + }, + "troubleshootStart": { + "message": "2. Start Ollama: ollama serve", + "description": "Diagnostics tip 2" + }, + "troubleshootTest": { + "message": "3. Test manually: curl {url}/api/tags", + "description": "Diagnostics tip 3", + "placeholders": { + "url": { "content": "$1" } + } + }, + "troubleshootPort": { + "message": "4. Check if port 11434 is in use: lsof -i :11434", + "description": "Diagnostics tip 4" + }, + "criticalError": { + "message": "❌ CRITICAL ERROR:", + "description": "Diagnostics critical error" + }, + "noInstalledModels": { + "message": "⚠️ No models installed", + "description": "Diagnostics no models text" + }, + "versionNotAvailable": { + "message": "⚠️ Endpoint not available (older Ollama version)", + "description": "Diagnostics version unavailable" + }, + "unknownVersion": { + "message": "unknown", + "description": "Diagnostics unknown version text" + }, + "pullEndpointNote": { + "message": "Note: This endpoint is used for downloading models", + "description": "Diagnostics pull endpoint note" + }, + "diagnosticsApiUrl": { + "message": "βœ“ API base URL: {url}", + "description": "Diagnostics API URL", + "placeholders": { + "url": { "content": "$1" } + } + }, + "ollamaCurlTest": { + "message": "Test manually: curl {url}/api/tags", + "description": "Diagnostics curl tip", + "placeholders": { + "url": { "content": "$1" } + } + }, + "fetchingModelsStatus": { + "message": "Fetching models...", + "description": "List Ollama models status" + }, + "availableModels": { + "message": "βœ“ Available models: {models}", + "description": "Available Ollama models", + "placeholders": { + "models": { "content": "$1" } + } + }, + "noModelsInstalledHint": { + "message": "⚠️ No models installed. Run \"ollama pull llama3.2\" to download one.", + "description": "No Ollama models hint" + }, + "failedFetchModelsSimple": { + "message": "βœ— Failed to fetch models", + "description": "Failed to fetch Ollama models" + }, + "ollamaConnectionFailedSimple": { + "message": "βœ— Connection failed: {error}. Is Ollama running?", + "description": "Ollama connection failed simple", + "placeholders": { + "error": { "content": "$1" } + } + }, + "enterModelDownload": { + "message": "⚠️ Please enter a model name to download", + "description": "Error for empty download model" + }, + "startingDownload": { + "message": "Starting download of {model}...", + "description": "Download started", + "placeholders": { + "model": { "content": "$1" } + } + }, + "failedStart": { + "message": "βœ— Failed to start: {error}", + "description": "Failed to start download", + "placeholders": { + "error": { "content": "$1" } + } + }, + "downloadComplete": { + "message": "βœ“ Download complete", + "description": "Download complete" + }, + "downloadFailed": { + "message": "βœ— Download failed: {error}", + "description": "Download failed", + "placeholders": { + "error": { "content": "$1" } + } + }, + "unknownError": { + "message": "unknown error", + "description": "Generic unknown error" + }, + "addFolderBeforeSave": { + "message": "Please add at least one folder/label before saving. Use \"Load Folders from Mail Account\" or add custom labels.", + "description": "Error when saving without folders" + }, + "addGeminiKeyBeforeSave": { + "message": "Please add at least one Gemini API key before saving.", + "description": "Error when saving without Gemini key" + }, + "duplicateApiKeys": { + "message": "⚠️ Duplicate API keys detected! Each key must be unique. Please remove duplicates before saving.", + "description": "Error for duplicate Gemini keys" + }, + "settingsSavedMultiKey": { + "message": "βœ“ Settings saved successfully! Multiple Gemini API keys configured for automatic rotation.", + "description": "Success for multi-key save" + }, + "enterOllamaModel": { + "message": "Please enter a custom model name for Ollama.", + "description": "Error when saving without Ollama model" + }, + "settingsSavedOllama": { + "message": "βœ“ Settings saved successfully! Ollama is configured for local email processing{cpuMode}.", + "description": "Success for Ollama save", + "placeholders": { + "cpuMode": { "content": "$1" } + } + }, + "enterCustomBaseUrl": { + "message": "Please enter a base URL for the custom endpoint.", + "description": "Error when saving without base URL" + }, + "enterCustomModel": { + "message": "Please select or enter a model name for the custom endpoint.", + "description": "Error when saving without model" + }, + "settingsSavedCustomEndpoint": { + "message": "βœ“ Settings saved successfully! Custom OpenAI-compatible endpoint configured.", + "description": "Success for custom endpoint save" + }, + "enterApiKeyBeforeSave": { + "message": "Please enter your API key before saving. Click \"Get API Key\" to obtain one.", + "description": "Error when saving without API key" + }, + "settingsSavedSuccess": { + "message": "βœ“ Settings saved successfully! You can now use AutoSort+ to analyze emails.", + "description": "Success for generic save" + }, + "errorSavingSettings": { + "message": "Error saving settings: {error}", + "description": "Error saving settings", + "placeholders": { + "error": { "content": "$1" } + } + }, + "clearHistoryConfirm": { + "message": "Are you sure you want to clear the move history?", + "description": "Confirm for clearing history" + }, + "batchPausedChunk": { + "message": "⏸ Paused β€” chunk {current}/{total} ({done}/{totalItems})", + "description": "Batch paused with chunks", + "placeholders": { + "current": { "content": "$1" }, + "total": { "content": "$2" }, + "done": { "content": "$3" }, + "totalItems": { "content": "$4" } + } + }, + "batchPausedSimple": { + "message": "⏸ Paused ({done}/{totalItems})", + "description": "Batch paused simple", + "placeholders": { + "done": { "content": "$1" }, + "totalItems": { "content": "$2" } + } + }, + "batchDone": { + "message": "βœ… Done β€” sorted: {completed}, skipped: {skipped}, failed: {failed}", + "description": "Batch done", + "placeholders": { + "completed": { "content": "$1" }, + "skipped": { "content": "$2" }, + "failed": { "content": "$3" } + } + }, + "batchCancelledChunk": { + "message": "⏹ Cancelled after chunk {current}/{total}", + "description": "Batch cancelled after chunk", + "placeholders": { + "current": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "batchCancelledSimple": { + "message": "⏹ Cancelled ({done}/{totalItems})", + "description": "Batch cancelled simple", + "placeholders": { + "done": { "content": "$1" }, + "totalItems": { "content": "$2" } + } + }, + "batchRunningChunk": { + "message": "Chunk {current}/{total} β€” {done}/{totalItems} (sorted: {completed}, failed: {failed})", + "description": "Batch running with chunks", + "placeholders": { + "current": { "content": "$1" }, + "total": { "content": "$2" }, + "done": { "content": "$3" }, + "totalItems": { "content": "$4" }, + "completed": { "content": "$5" }, + "failed": { "content": "$6" } + } + }, + "batchRunningSimple": { + "message": "{done}/{totalItems} (sorted: {completed}, failed: {failed})", + "description": "Batch running simple", + "placeholders": { + "done": { "content": "$1" }, + "totalItems": { "content": "$2" }, + "completed": { "content": "$3" }, + "failed": { "content": "$4" } + } + }, + "batchPausing": { + "message": "⏸ Pausing… current request will finish first.", + "description": "Batch pausing message" + }, + "batchCancelConfirm": { + "message": "Cancel the current batch? Already-sorted emails will not be undone.", + "description": "Confirm for cancelling batch" + }, + "batchCancelling": { + "message": "⏹ Cancelling… current request will finish first.", + "description": "Batch cancelling message" + }, + "debugEnabled": { + "message": "βœ“ Debug mode enabled. Open Thunderbird Developer Tools (Ctrl+Shift+I) to view logs.", + "description": "Debug mode enabled" + }, + "debugDisabled": { + "message": "βœ“ Debug mode disabled.", + "description": "Debug mode disabled" + }, + "promptCleared": { + "message": "Custom prompt cleared. Default prompt will be used.", + "description": "Prompt cleared" + } +} diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json new file mode 100644 index 0000000..dcd91b6 --- /dev/null +++ b/_locales/zh_CN/messages.json @@ -0,0 +1,1270 @@ +{ + "extensionName": { + "message": "AutoSort+", + "description": "扩展名称" + }, + "extensionDescription": { + "message": "使用 AI θ‡ͺεŠ¨εˆ†η±»ε’Œζ ‡θ°ζ‚¨ηš„ι‚δ»Ά", + "description": "扩展描述" + }, + "extensionDefaultTitle": { + "message": "AutoSort+ θΎη½", + "description": "ζ΅θ§ˆε™¨ζ“δ½œι»˜θ€ζ ‡ι’˜" + }, + "pageTitle": { + "message": "AutoSort+ θΎη½", + "description": "HTML ι‘΅ι’ζ ‡ι’˜" + }, + "pageHeading": { + "message": "AutoSort+ θΎη½", + "description": "ι‘΅ι’δΈ»ζ ‡ι’˜" + }, + "batchProcessingTitle": { + "message": "ζ‰Ήι‡ε€„η†θΏ›θ‘ŒδΈ­", + "description": "ζ‰Ήι‡ε€„η†ηŠΆζ€ι’ζΏζ ‡ι’˜" + }, + "batchPreparing": { + "message": "准倇中…", + "description": "批量倄理准倇中文字" + }, + "batchPause": { + "message": "⏸ ζš‚εœ", + "description": "ζ‰Ήι‡ζš‚εœζŒ‰ι’ζ–‡ε­—" + }, + "batchResume": { + "message": "β–Ά η»§η»­", + "description": "ζ‰Ήι‡η»§η»­ζŒ‰ι’ζ–‡ε­—" + }, + "batchCancel": { + "message": "⏹ ε–ζΆˆ", + "description": "ζ‰Ήι‡ε–ζΆˆζŒ‰ι’ζ–‡ε­—" + }, + "aiSettingsTitle": { + "message": "πŸ€– AI θΎη½", + "description": "AI θΎη½εŒΊζ΅ζ ‡ι’˜" + }, + "providerSelectionTitle": { + "message": "选择提供商", + "description": "ζδΎ›ε•†ι€‰ζ‹©ε­εŒΊζ΅ζ ‡ι’˜" + }, + "aiProviderLabel": { + "message": "AI ζδΎ›ε•†οΌš", + "description": "AI 提供商选择摆标签" + }, + "providerGemini": { + "message": "Google GeminiοΌˆζŽ¨θοΌ‰", + "description": "Gemini 提供商选鑹" + }, + "providerOpenAI": { + "message": "OpenAI (ChatGPT)", + "description": "OpenAI 提供商选鑹" + }, + "providerAnthropic": { + "message": "Anthropic Claude", + "description": "Anthropic 提供商选鑹" + }, + "providerGroq": { + "message": "GroqοΌˆεΏ«ι€ŸδΈ”ε…θ΄ΉοΌ‰", + "description": "Groq 提供商选鑹" + }, + "providerMistral": { + "message": "Mistral AI", + "description": "Mistral 提供商选鑹" + }, + "providerOllama": { + "message": "OllamaοΌˆζœ¬εœ°ε€§ζ¨‘εž‹οΌ‰", + "description": "Ollama 提供商选鑹" + }, + "providerOpenAICompatible": { + "message": "OpenAI ε…ΌεΉοΌˆθ‡ͺεšδΉ‰η«―η‚ΉοΌ‰", + "description": "OpenAI ε…ΌεΉζδΎ›ε•†ι€‰ι‘Ή" + }, + "rateLimitWarningTitle": { + "message": "⚠️ ι’‘ηŽ‡ι™εˆΆθ­¦ε‘ŠοΌš", + "description": "ι’‘ηŽ‡ι™εˆΆθ­¦ε‘Šζ ‡ι’˜" + }, + "rateLimitWarningText": { + "message": "免费 API ε±‚εœ¨ε€„η†ι‚δ»Άζ—Άε—εˆ°δΈ₯ζ Όι™εˆΆγ€‚ζ‚¨ε―θƒ½εœ¨ε€„η† 5-20 封ι‚δ»ΆεŽε°±θΎΎεˆ°ι’‘ηŽ‡ι™εˆΆγ€‚ε»Ίθ购买付费θ‘εˆ’οΌˆ$5-20/ζœˆοΌ‰δ»₯ζ»‘θΆ³ζ—₯εΈΈι‚δ»Άε€„η†ιœ€ζ±‚γ€‚", + "description": "ι’‘ηŽ‡ι™εˆΆθ­¦ε‘Šζ­£ζ–‡" + }, + "ollamaConfigTitle": { + "message": "🏠 本地 Ollama 配η½", + "description": "Ollama 配η½ε­εŒΊζ΅ζ ‡ι’˜" + }, + "ollamaUrlLabel": { + "message": "Ollama ζœεŠ‘ε™¨εœ°ε€οΌš", + "description": "Ollama εœ°ε€ζ ‡η­Ύ" + }, + "ollamaUrlPlaceholder": { + "message": "http://localhost:11434", + "description": "Ollama εœ°ε€ε δ½η¬¦" + }, + "ollamaCpuOnly": { + "message": "εΌΊεˆΆδ»…δ½Ώη”¨ CPUοΌˆη¦η”¨ GPU εŠ ι€ŸοΌ‰", + "description": "Ollama CPU 樑式倍选摆" + }, + "ollamaModelLabel": { + "message": "Ollama ζ¨‘εž‹οΌš", + "description": "Ollama ζ¨‘εž‹ι€‰ζ‹©ζ‘†ζ ‡η­Ύ" + }, + "ollamaAuthTokenLabel": { + "message": "Ollama θ€θ―δ»€η‰ŒοΌˆε―ι€‰οΌ‰οΌš", + "description": "Ollama θ€θ―δ»€η‰Œζ ‡η­Ύ" + }, + "ollamaAuthTokenPlaceholder": { + "message": "ε¦‚ζžœζ‚¨ηš„ Ollama ζœεŠ‘ε™¨ιœ€θ¦δ»€η‰ŒοΌŒθ―·εœ¨ζ­€θΎ“ε…₯", + "description": "Ollama θ€θ―δ»€η‰Œε δ½η¬¦" + }, + "ollamaAuthTokenHelp": { + "message": "εœ¨ιœ€θ¦ζ—Άη”¨δΊŽ /api/chat ε’Œ /api/pull 请求。", + "description": "Ollama θ€θ―δ»€η‰ŒεΈεŠ©ζ–‡ζœ¬" + }, + "ollamaCustomModelPlaceholder": { + "message": "θΎ“ε…₯θ‡ͺεšδΉ‰ζ¨‘εž‹εη§°", + "description": "Ollama θ‡ͺεšδΉ‰ζ¨‘εž‹ε δ½η¬¦" + }, + "ollamaDownloadModelLabel": { + "message": "δΈ‹θ½½ζ¨‘εž‹οΌš", + "description": "Ollama δΈ‹θ½½ζ¨‘εž‹ζ ‡η­Ύ" + }, + "ollamaDownloadModelPlaceholder": { + "message": "δΎ‹ε¦‚οΌšllama3.2, mistral, qwen2.5:7b", + "description": "Ollama δΈ‹θ½½ζ¨‘εž‹ε δ½η¬¦" + }, + "ollamaDownloadButton": { + "message": "δΈ‹θ½½", + "description": "δΈ‹θ½½ζ¨‘εž‹ζŒ‰ι’" + }, + "ollamaListModelsButton": { + "message": "εˆ—ε‡Ίε·²ε‰θ£…ζ¨‘εž‹", + "description": "εˆ—ε‡Ίε·²ε‰θ£…ζ¨‘εž‹ζŒ‰ι’" + }, + "ollamaTestButton": { + "message": "ζ΅‹θ―•θΏžζŽ₯", + "description": "ζ΅‹θ―• Ollama 连ζŽ₯ζŒ‰ι’" + }, + "ollamaDiagnoseButton": { + "message": "θΏθ‘Œθ―Šζ–­", + "description": "运葌 Ollama θ―Šζ–­ζŒ‰ι’" + }, + "ollamaModelLlama2": { + "message": "Llama 2", + "description": "Ollama Llama 2 ζ¨‘εž‹ι€‰ι‘Ή" + }, + "ollamaModelLlama32": { + "message": "Llama 3.2", + "description": "Ollama Llama 3.2 ζ¨‘εž‹ι€‰ι‘Ή" + }, + "ollamaModelMistral": { + "message": "Mistral", + "description": "Ollama Mistral ζ¨‘εž‹ι€‰ι‘Ή" + }, + "ollamaModelPhi": { + "message": "Phi", + "description": "Ollama Phi ζ¨‘εž‹ι€‰ι‘Ή" + }, + "ollamaModelGemma": { + "message": "Gemma", + "description": "Ollama Gemma ζ¨‘εž‹ι€‰ι‘Ή" + }, + "ollamaModelQwen25": { + "message": "Qwen 2.5", + "description": "Ollama Qwen 2.5 ζ¨‘εž‹ι€‰ι‘Ή" + }, + "ollamaModelCustom": { + "message": "θ‡ͺεšδΉ‰οΌˆδΈ‹ζ–ΉθΎ“ε…₯οΌ‰", + "description": "Ollama θ‡ͺεšδΉ‰ζ¨‘εž‹ι€‰ι‘Ή" + }, + "openaiCompatibleTitle": { + "message": "πŸ”— OpenAI ε…ΌεΉη«―η‚Ή", + "description": "OpenAI ε…ΌεΉε­εŒΊζ΅ζ ‡ι’˜" + }, + "openaiCompatibleBaseUrlLabel": { + "message": "εŸΊη‘€εœ°ε€οΌš", + "description": "OpenAI ε…ΌεΉεŸΊη‘€εœ°ε€ζ ‡η­Ύ" + }, + "openaiCompatibleBaseUrlPlaceholder": { + "message": "http://localhost:1234/v1 ζˆ– https://api.provider.com/v1", + "description": "OpenAI ε…ΌεΉεŸΊη‘€εœ°ε€ε δ½η¬¦" + }, + "openaiCompatibleBaseUrlHelp": { + "message": "θ―·θΎ“ε…₯εŒ…ε« /v1 ηš„εŸΊη‘€εœ°ε€γ€‚η«―η‚ΉεΏ…ι‘»δ½Ώη”¨ OpenAI 格式:/v1/chat/completionsγ€‚δΎ‹ε¦‚οΌšLM Studio, LocalAI, vLLM, Together AI", + "description": "OpenAI ε…ΌεΉεŸΊη‘€εœ°ε€εΈεŠ©ζ–‡ζœ¬" + }, + "openaiCompatibleModelLabel": { + "message": "ζ¨‘εž‹οΌš", + "description": "OpenAI ε…ΌεΉζ¨‘εž‹ι€‰ζ‹©ζ‘†ζ ‡η­Ύ" + }, + "openaiCompatibleModelSelect": { + "message": "-- ι€‰ζ‹©ζ¨‘εž‹ --", + "description": "OpenAI ε…ΌεΉζ¨‘εž‹ι»˜θ€ι€‰ι‘Ή" + }, + "openaiCompatibleModelCustom": { + "message": "θ‡ͺεšδΉ‰οΌˆδΈ‹ζ–ΉθΎ“ε…₯οΌ‰", + "description": "OpenAI ε…ΌεΉθ‡ͺεšδΉ‰ζ¨‘εž‹ι€‰ι‘Ή" + }, + "openaiCompatibleModelCustomPlaceholder": { + "message": "ζ‰‹εŠ¨θΎ“ε…₯ζ¨‘εž‹εη§°", + "description": "OpenAI ε…ΌεΉθ‡ͺεšδΉ‰ζ¨‘εž‹ε δ½η¬¦" + }, + "openaiCompatibleApiKeyLabel": { + "message": "API ε―†ι’₯οΌˆε―ι€‰οΌ‰οΌš", + "description": "OpenAI ε…ΌεΉ API ε―†ι’₯ζ ‡η­Ύ" + }, + "openaiCompatibleApiKeyPlaceholder": { + "message": "ζœ¬εœ°ζ— θ€θ―η«―点可留空", + "description": "OpenAI ε…ΌεΉ API ε―†ι’₯占位符" + }, + "openaiCompatibleApiKeyHelp": { + "message": "δΊ‘η«―ζδΎ›ε•†εΏ…ιœ€οΌŒζœ¬εœ°ζœεŠ‘ε™¨ε―ι€‰", + "description": "OpenAI ε…ΌεΉ API ε―†ι’₯εΈεŠ©ζ–‡ζœ¬" + }, + "openaiCompatibleFetchModelsButton": { + "message": "θŽ·ε–ζ¨‘εž‹εˆ—θ‘¨", + "description": "θŽ·ε–ζ¨‘εž‹εˆ—θ‘¨ζŒ‰ι’" + }, + "openaiCompatibleTestButton": { + "message": "ζ΅‹θ―•θΏžζŽ₯", + "description": "ζ΅‹θ―• OpenAI ε…ΌεΉθΏžζŽ₯ζŒ‰ι’" + }, + "apiKeyTitle": { + "message": "πŸ”‘ API ε―†ι’₯配η½", + "description": "API ε―†ι’₯配η½ε­εŒΊζ΅ζ ‡ι’˜" + }, + "apiKeyLabel": { + "message": "API ε―†ι’₯:", + "description": "API ε―†ι’₯θΎ“ε…₯摆标签" + }, + "apiKeyPlaceholder": { + "message": "θΎ“ε…₯ζ‚¨ηš„ API ε―†ι’₯", + "description": "API ε―†ι’₯θΎ“ε…₯摆占位符" + }, + "testApiButton": { + "message": "ζ΅‹θ―• API 连ζŽ₯", + "description": "ζ΅‹θ―• API 连ζŽ₯ζŒ‰ι’" + }, + "getApiKeyButton": { + "message": "θŽ·ε– API ε―†ι’₯", + "description": "θŽ·ε– API ε―†ι’₯ζŒ‰ι’" + }, + "geminiMultiKeysTitle": { + "message": "πŸ”„ 倚δΈͺ Gemini API ε―†ι’₯", + "description": "倚δΈͺ Gemini ε―†ι’₯子区ζ΅ζ ‡ι’˜" + }, + "geminiMultiKeysInfo": { + "message": "添加ζ₯θ‡ͺ不同 Google Cloud ι‘Ήη›ηš„ε€šδΈͺ API ε―†ι’₯γ€‚ε½“θΎΎεˆ°ι’‘ηŽ‡ι™εˆΆζ—ΆοΌŒζ‰©ε±•ε°†θ‡ͺεŠ¨εœ¨ε―†ι’₯ι—΄εˆ‡ζ’γ€‚", + "description": "倚δΈͺ Gemini ε―†ι’₯θ―΄ζ˜Žζ–‡ζœ¬" + }, + "addGeminiKeyButton": { + "message": "+ ζ·»εŠ ε¦δΈ€δΈͺ Gemini ε―†ι’₯", + "description": "添加 Gemini ε―†ι’₯ζŒ‰ι’" + }, + "testButton": { + "message": "ζ΅‹θ―•", + "description": "桋试单δΈͺ Gemini API ε―†ι’₯ζŒ‰ι’" + }, + "geminiPaidPlan": { + "message": "ζˆ‘ε·²θ΄­δΉ° Gemini 付费θ‘εˆ’οΌˆθ§£ι™€ι’‘ηŽ‡ι™εˆΆοΌ‰", + "description": "Gemini 付费θ‘εˆ’ε€ι€‰ζ‘†" + }, + "generalSettingsTitle": { + "message": "βš™οΈ εΈΈθ§„θΎη½", + "description": "εΈΈθ§„θΎη½ε­εŒΊζ΅ζ ‡ι’˜" + }, + "enableAiLabel": { + "message": "启用 AI ι‚δ»Άεˆ†η±»", + "description": "启用 AI 倍选摆" + }, + "enableDebugLabel": { + "message": "ε―η”¨θ°ƒθ―•ζ¨‘εΌοΌˆζŽ§εˆΆε°ζ—₯εΏ—οΌ‰", + "description": "启用调试樑式倍选摆" + }, + "enableDebugHelp": { + "message": "打开 Thunderbird 开发者ε·₯ε…·οΌˆCtrl+Shift+IοΌ‰ζŸ₯ηœ‹ζ—₯εΏ—", + "description": "调试樑式εΈεŠ©ζ–‡ζœ¬" + }, + "batchChunkSizeLabel": { + "message": "ζ‰Ήι‡ε€„η†ζ•°ι‡οΌš", + "description": "批量倄理数量标签" + }, + "batchChunkSizeHelp": { + "message": "每欑倄理 N 封ι‚δ»ΆοΌŒη­‰εΎ…ζ‰€ζœ‰ε“εΊ”εŽη»§η»­οΌˆ1-20οΌ‰", + "description": "批量倄理数量εΈεŠ©ζ–‡ζœ¬" + }, + "enableAutoSortLabel": { + "message": "θ‡ͺεŠ¨εˆ†η±»ζ”Άδ»Άη±ζ–°ι‚δ»Ά", + "description": "θ‡ͺεŠ¨εˆ†η±»ε€ι€‰ζ‘†ζ ‡η­Ύ" + }, + "enableAutoSortHelp": { + "message": "使用 AI θ‡ͺεŠ¨εˆ†η±»ε’Œη§»εŠ¨ζ–°ζ”Άδ»Άη±ι‚δ»Ά", + "description": "θ‡ͺεŠ¨εˆ†η±»εΈεŠ©ζ–‡ζœ¬" + }, + "autoSortNotifyLabel": { + "message": "θ‡ͺεŠ¨εˆ†η±»εŒζˆεŽι€šηŸ₯", + "description": "θ‡ͺεŠ¨εˆ†η±»ι€šηŸ₯倍选摆标签" + }, + "autoSortNotifyHelp": { + "message": "ζ˜Ύη€ΊεŒ…ε«ζˆεŠŸ/ε€±θ΄₯/εΎ…ε€„η†ζ•°ι‡ηš„ι€šηŸ₯", + "description": "θ‡ͺεŠ¨εˆ†η±»ι€šηŸ₯εΈεŠ©ζ–‡ζœ¬" + }, + "geminiUsageTitle": { + "message": "πŸ“Š Gemini API 使用情冡", + "description": "Gemini API δ½Ώη”¨ζƒ…ε†΅ε­εŒΊζ΅ζ ‡ι’˜" + }, + "geminiDailyCount": { + "message": "今ζ—₯δ½Ώη”¨οΌš{count}/20 欑请求", + "description": "Gemini 每ζ—₯δ½Ώη”¨η»Ÿθ‘", + "placeholders": { + "count": { + "content": "$1" + } + } + }, + "geminiLastRequest": { + "message": "ζœ€εŽθ―·ζ±‚οΌš", + "description": "Gemini ζœ€εŽθ―·ζ±‚ζ ‡η­Ύ" + }, + "geminiNever": { + "message": "从ζœͺ", + "description": "从ζœͺ使用文字" + }, + "geminiResetTime": { + "message": "每ζ—₯ι™εˆΆι‡η½οΌš", + "description": "Gemini 每ζ—₯ι™εˆΆι‡η½ζ—Άι—΄ζ ‡η­Ύ" + }, + "geminiStatus": { + "message": "ηŠΆζ€οΌš", + "description": "Gemini ηŠΆζ€ζ ‡η­Ύ" + }, + "geminiStatusReady": { + "message": "ε°±η»ͺ", + "description": "Gemini ε°±η»ͺηŠΆζ€" + }, + "geminiStatusNearlyFull": { + "message": "即将滑载", + "description": "Gemini 使用量ζŽ₯θΏ‘δΈŠι™" + }, + "geminiStatusLimitReached": { + "message": "ε·²θΎΎδΈŠι™", + "description": "Gemini ε·²θΎΎι™εˆΆηŠΆζ€" + }, + "geminiLimitMessage": { + "message": "已达每ζ—₯ι™εˆΆγ€‚θ―·εˆ‡ζ’εˆ°ε…Άδ»–ε―†ι’₯ζˆ–η­‰εΎ…ι‡η½γ€‚", + "description": "Gemini 每ζ—₯ι™εˆΆθ­¦ε‘Š" + }, + "geminiRemainingMessage": { + "message": "ζ¬‘θ―·ζ±‚ε‰©δ½™γ€‚θ―·θ€ƒθ™‘εˆ‡ζ’ε―†ι’₯。", + "description": "Gemini ε‰©δ½™δ½Ώη”¨θ­¦ε‘Š" + }, + "geminiKeyInputPlaceholder": { + "message": "θΎ“ε…₯ Gemini API ε―†ι’₯", + "description": "Gemini API ε―†ι’₯θΎ“ε…₯占位符" + }, + "geminiResetExpired": { + "message": "δ»€η‰Œε·²θΏ‡ζœŸζˆ–ζ— ζ•ˆγ€‚θ―·δ»Ž AI Studio η”Ÿζˆζ–°ηš„δ»€η‰Œγ€‚", + "description": "Gemini 重η½δ»€η‰ŒθΏ‡ζœŸζη€Ί" + }, + "requestsRemainingToday": { + "message": "ζ¬‘θ―·ζ±‚δ»Šζ—₯ε‰©δ½™γ€‚θ―·θ€ƒθ™‘εˆ‡ζ’ε―†ι’₯。", + "description": "今ζ—₯剩余请求提瀺" + }, + "resetGeminiCounterButton": { + "message": "重η½θ‘ζ•°ε™¨οΌˆζ–° API ε―†ι’₯οΌ‰", + "description": "ι‡η½ Gemini θ‘ζ•°ε™¨ζŒ‰ι’" + }, + "refreshUsageButton": { + "message": "εˆ·ζ–°δ½Ώη”¨ζƒ…ε†΅", + "description": "εˆ·ζ–°δ½Ώη”¨ζƒ…ε†΅ζŒ‰ι’" + }, + "refreshAllUsageButton": { + "message": "εˆ·ζ–°ε…¨ιƒ¨δ½Ώη”¨ζƒ…ε†΅", + "description": "εˆ·ζ–°ε…¨ιƒ¨δ½Ώη”¨ζƒ…ε†΅ζŒ‰ι’" + }, + "howAiSortingTitle": { + "message": "ℹ️ AI εˆ†η±»ε·₯δ½œεŽŸη†", + "description": "AI εˆ†η±»ε·₯δ½œεŽŸη†ε­εŒΊζ΅ζ ‡ι’˜" + }, + "howAiSortingDesc": { + "message": "AutoSort+ 使用 AI εˆ†ζžζ‚¨ηš„ι‚δ»ΆοΌŒεΉΆζ Ήζε†…εΉθ‡ͺεŠ¨ε°†ε…Άεˆ†η±»εˆ°η±»εˆ«/文仢倹中。AI ε°†οΌš", + "description": "AI εˆ†η±»ζθΏ°" + }, + "howAiSortingPoint1": { + "message": "ι˜…θ―»εΉΆη†θ§£ι‚δ»Άε†…εΉ", + "description": "AI εˆ†η±»θƒ½εŠ› 1" + }, + "howAiSortingPoint2": { + "message": "θ―†εˆ«ε…³ι”δΈ»ι’˜ε’ŒδΈ»ι’˜ηΊΏη΄’", + "description": "AI εˆ†η±»θƒ½εŠ› 2" + }, + "howAiSortingPoint3": { + "message": "ε°†ι‚δ»ΆεŒΉι…εˆ°εˆι€‚ηš„η±»εˆ«/ζ–‡δ»Άε€Ή", + "description": "AI εˆ†η±»θƒ½εŠ› 3" + }, + "howAiSortingPoint4": { + "message": "δ»Žζ‚¨ηš„ζ‰‹εŠ¨δΏζ­£δΈ­ε­¦δΉ οΌŒζι«˜ε‡†η‘ηŽ‡", + "description": "AI εˆ†η±»θƒ½εŠ› 4" + }, + "customPromptTitle": { + "message": "πŸ“ θ‡ͺεšδΉ‰ζη€Ίθ―", + "description": "θ‡ͺεšδΉ‰ζη€Ίθ―εŒΊζ΅ζ ‡ι’˜" + }, + "customPromptInfo": { + "message": "θ‡ͺεšδΉ‰ε‘送给 AI ηš„ι‚δ»Άεˆ†η±»ζη€Ίθ―γ€‚", + "description": "θ‡ͺεšδΉ‰ζη€Ίθ―θ―΄ζ˜Ž" + }, + "customPromptPlaceholders": { + "message": "ε―η”¨ε δ½η¬¦οΌš", + "description": "θ‡ͺεšδΉ‰ζη€Ίθ―ε δ½η¬¦ζ ‡η­Ύ" + }, + "customPromptPlaceholderLabel": { + "message": "ζ‚¨ηš„ζ–‡δ»Άε€Ή/η±»εˆ«εˆ—θ‘¨", + "description": "labels 占位符描述" + }, + "customPromptSubjectLabel": { + "message": "ι‚δ»ΆδΈ»ι’˜θ‘Œ", + "description": "subject 占位符描述" + }, + "customPromptAuthorLabel": { + "message": "发仢人ι‚η±εœ°ε€/名称", + "description": "author 占位符描述" + }, + "customPromptAttachmentsLabel": { + "message": "ι™„δ»Άζ–‡δ»ΆεοΌˆι€—ε·εˆ†ιš”οΌ‰", + "description": "attachments 占位符描述" + }, + "customPromptBodyLabel": { + "message": "ι‚δ»Άζ­£ζ–‡ε†…εΉοΌˆζŽ¨θοΌ‰", + "description": "body 占位符描述" + }, + "customPromptEmailLabel": { + "message": "ι‚δ»Άε†…εΉοΌˆζ—§η‰ˆοΌŒεŒ {body}οΌ‰", + "description": "email 占位符描述", + "placeholders": { + "body": { + "content": "$1" + } + } + }, + "customPromptTip": { + "message": "ζη€ΊοΌšδ½Ώη”¨ {subject} ε’Œ {attachments} ε―ζι«˜εˆ†η±»ε‡†η‘ηŽ‡γ€‚", + "description": "θ‡ͺεšδΉ‰ζη€Ίθ―ζη€Ί", + "placeholders": { + "subject": { + "content": "$1" + }, + "attachments": { + "content": "$2" + } + } + }, + "customPromptTextareaPlaceholder": { + "message": "θΎ“ε…₯ζ‚¨ηš„θ‡ͺεšδΉ‰ζη€Ίθ―...", + "description": "θ‡ͺεšδΉ‰ζη€Ίθ―ζ–‡ζœ¬ζ‘†ε δ½η¬¦" + }, + "resetPromptButton": { + "message": "恒倍默θ€", + "description": "重η½ζη€Ίθ―ζŒ‰ι’" + }, + "customFoldersTitle": { + "message": "πŸ“ θ‡ͺεšδΉ‰η±»εˆ«/ζ–‡δ»Άε€Ή", + "description": "θ‡ͺεšδΉ‰η±»εˆ«/ζ–‡δ»Άε€ΉεŒΊζ΅ζ ‡ι’˜" + }, + "folderSourceTitle": { + "message": "ζ–‡δ»Άε€Ήζ₯源", + "description": "ζ–‡δ»Άε€Ήζ₯源子区ζ΅ζ ‡ι’˜" + }, + "loadImapFoldersButton": { + "message": "从ι‚δ»Άθ΄¦ζˆ·εŠ θ½½ζ–‡δ»Άε€Ή", + "description": "加载 IMAP ζ–‡δ»Άε€ΉζŒ‰ι’" + }, + "folderLoadingText": { + "message": "ζ­£εœ¨εŠ θ½½ζ–‡δ»Άε€Ή...", + "description": "ζ–‡δ»Άε€ΉεŠ θ½½ζη€Ίζ–‡ε­—" + }, + "folderFoundText": { + "message": "εœ¨ζ‚¨ηš„ι‚δ»Άθ΄¦ζˆ·δΈ­ζ‰Ύεˆ° {count} δΈͺζ–‡δ»Άε€Ήγ€‚θ¦δ½Ώη”¨θΏ™δΊ›ζ–‡δ»Άε€Ήε—οΌŸ", + "description": "ζ‰Ύεˆ°ζ–‡δ»Άε€Ήζη€Ίζ–‡ε­—", + "placeholders": { + "count": { + "content": "$1" + } + } + }, + "useImapFoldersButton": { + "message": "使用这些文仢倹", + "description": "使用 IMAP ζ–‡δ»Άε€ΉζŒ‰ι’" + }, + "useCustomFoldersButton": { + "message": "改用θ‡ͺεšδΉ‰ζ–‡δ»Άε€Ή", + "description": "使用θ‡ͺεšδΉ‰ζ–‡δ»Άε€ΉζŒ‰ι’" + }, + "bulkImportLabel": { + "message": "ε―Όε…₯类别/ζ–‡δ»Άε€ΉοΌˆζ―θ‘ŒδΈ€δΈͺοΌ‰οΌš", + "description": "批量导ε…₯ζ–‡ζœ¬ζ‘†ζ ‡η­Ύ" + }, + "bulkImportPlaceholder": { + "message": "ζ―θ‘ŒθΎ“ε…₯δΈ€δΈͺ类别/ζ–‡δ»Άε€Ή", + "description": "批量导ε…₯ζ–‡ζœ¬ζ‘†ε δ½η¬¦" + }, + "importButton": { + "message": "ε―Όε…₯", + "description": "ε―Όε…₯ζŒ‰ι’" + }, + "addButton": { + "message": "添加", + "description": "ζ·»εŠ ζŒ‰ι’" + }, + "labelInputPlaceholder": { + "message": "θΎ“ε…₯类别/文仢倹名称", + "description": "ζ ‡η­ΎθΎ“ε…₯摆占位符" + }, + "moveHistoryTitle": { + "message": "πŸ“œ η§»εŠ¨εŽ†ε²", + "description": "η§»εŠ¨εŽ†ε²εŒΊζ΅ζ ‡ι’˜" + }, + "clearHistoryButton": { + "message": "ζΈ…ι™€εŽ†ε²", + "description": "ζΈ…ι™€εŽ†ε²ζŒ‰ι’" + }, + "refreshHistoryButton": { + "message": "εˆ·ζ–°", + "description": "εˆ·ζ–°εŽ†ε²ζŒ‰ι’" + }, + "historyHeaderTimestamp": { + "message": "ζ—Άι—΄", + "description": "εŽ†ε²θ‘¨ζ Όζ—Άι—΄εˆ—ζ ‡ι’˜" + }, + "historyHeaderSubject": { + "message": "主钘", + "description": "εŽ†ε²θ‘¨ζ ΌδΈ»ι’˜εˆ—ζ ‡ι’˜" + }, + "historyHeaderStatus": { + "message": "ηŠΆζ€", + "description": "εŽ†ε²θ‘¨ζ ΌηŠΆζ€εˆ—ζ ‡ι’˜" + }, + "historyHeaderDestination": { + "message": "η›ζ ‡", + "description": "εŽ†ε²θ‘¨ζ Όη›ζ ‡εˆ—ζ ‡ι’˜" + }, + "saveSettingsButton": { + "message": "保存θΎη½", + "description": "保存θΎη½ζŒ‰ι’" + }, + "providerInfoGemini": { + "message": "βœ“ ε…θ΄Ήι’εΊ¦οΌšζ―εˆ†ι’Ÿ 5 ζ¬‘θ―·ζ±‚οΌŒζ―ε€© 20 欑/API ε―†ι’₯οΌˆζ‰©ε±•εΌΊεˆΆζ‰§θ‘ŒοΌ‰
βœ“ 提瀺:在不同鑹η›δΈ­εˆ›ε»Ίε€šδΈͺ API ε―†ι’₯οΌŒθΎΎεˆ°ι™εˆΆζ—Άεˆ‡ζ’ε―†ι’₯
βœ“ ζŸ₯ηœ‹δ½Ώη”¨ζƒ…ε†΅οΌšAI Studio 使用情冡
βœ“ ι€‚εˆοΌšζ—₯εΈΈδ½Ώη”¨γ€ε€šθ―­θ¨€ζ”―ζŒ
βœ“ ζ¨‘εž‹οΌšGemini 2.5 Flash
βœ“ 勾选\"付费θ‘εˆ’\"ε―θ§£ι™€ι™εˆΆ", + "description": "Gemini 提供商俑息 HTML" + }, + "providerInfoOpenai": { + "message": "βœ“ ε…θ΄Ήθ―•η”¨οΌš$5 钝度
βœ“ ι€‚εˆοΌšι«˜ε‡†η‘ηŽ‡γ€θ‹±ζ–‡ε†…εΉ
βœ“ ζ¨‘εž‹οΌšGPT-4o-mini($0.15/1M tokensοΌ‰", + "description": "OpenAI 提供商俑息 HTML" + }, + "providerInfoAnthropic": { + "message": "βœ“ ε…θ΄Ήι’εΊ¦οΌšζœ‰ι™θ―·ζ±‚
βœ“ ι€‚εˆοΌšι•Ώι‚δ»Άγ€θ―¦η»†εˆ†ζž
βœ“ ζ¨‘εž‹οΌšClaude 3 Haiku", + "description": "Anthropic 提供商俑息 HTML" + }, + "providerInfoGroq": { + "message": "βœ“ ε…θ΄Ήι’εΊ¦οΌšζ―εˆ†ι’Ÿ 30 欑请求
βœ“ ι€‚εˆοΌšι€ŸεΊ¦οΌˆζœ€εΏ«οΌ‰
βœ“ ζ¨‘εž‹οΌšLlama 3.3(Mixtral 已弃用)", + "description": "Groq 提供商俑息 HTML" + }, + "providerInfoMistral": { + "message": "βœ“ ε…θ΄Ήι’εΊ¦οΌšζœ‰ι™θ―·ζ±‚
βœ“ ι€‚εˆοΌšζ¬§ζ΄²η”¨ζˆ·γ€GDPR εˆθ§„
βœ“ ζ¨‘εž‹οΌšMistral Small", + "description": "Mistral 提供商俑息 HTML" + }, + "providerInfoOllama": { + "message": "βœ“ 100% ε…θ΄ΉοΌšεœ¨ζœ¬εœ°θΏθ‘Œ
βœ“ ιšη§οΌšζ•°ζδΈε‘ι€εˆ°ε€–ιƒ¨ζœεŠ‘器
βœ“ ζ— ι’‘ηŽ‡ι™εˆΆοΌšζ— ι™εˆΆε€„η†ι‚δ»Ά
βœ“ ζ¨‘εž‹οΌšLlama 2/3、Mistral、Phi、Gemma、Qwen η­‰
βœ“ ιœ€θ¦οΌšε‰θ£… Ollama 幢本地运葌
βœ“ θΎη½οΌšε‰θ£… Ollama 后运葌 \"ollama pull llama3.2\" δΈ‹θ½½ζ¨‘εž‹", + "description": "Ollama 提供商俑息 HTML" + }, + "providerInfoOpenaiCompatible": { + "message": "βœ“ ε…ΌεΉοΌšLocalAI、LM Studio、vLLM、Together AI、OpenRouter、DeepSeek、Fireworks η­‰
βœ“ θΎ“ε…₯η«―η‚ΉεŸΊη‘€εœ°ε€ε’Œζ¨‘εž‹εη§°
βœ“ ζœ¬εœ°ζœεŠ‘ε™¨ε―ι€‰ API ε―†ι’₯
βœ“ 使用标准 /v1/chat/completions 格式", + "description": "OpenAI ε…ΌεΉζδΎ›ε•†δΏ‘息 HTML" + }, + "freeBadge": { + "message": "免费", + "description": "ε…θ΄ΉζδΎ›ε•†εΎ½η« ζ–‡ζœ¬" + }, + "paidBadge": { + "message": "付费", + "description": "δ»˜θ΄ΉζδΎ›ε•†εΎ½η« ζ–‡ζœ¬" + }, + "justNow": { + "message": "刚刚", + "description": "η›Έε―Ήζ—Άι—΄ β€” θ―·ζ±‚εˆšεˆšε‘ε‡Ί" + }, + "minutesAgo": { + "message": "{count} εˆ†ι’Ÿε‰", + "description": "η›Έε―Ήζ—Άι—΄ β€” εˆ†ι’Ÿε‰", + "placeholders": { + "count": { "content": "$1" }, + "plural": { "content": "" } + } + }, + "hoursAgo": { + "message": "{count} 小既前", + "description": "η›Έε―Ήζ—Άι—΄ β€” 小既前", + "placeholders": { + "count": { "content": "$1" }, + "plural": { "content": "" } + } + }, + "inHours": { + "message": "{count} ε°ζ—ΆεŽ", + "description": "重η½ε€’θ‘ζ—Ά", + "placeholders": { + "count": { "content": "$1" }, + "plural": { "content": "" } + } + }, + "inHoursShort": { + "message": "{count} ε°ζ—ΆεŽ", + "description": "重η½ε€’θ‘ζ—ΆηŸ­ζ ΌεΌ", + "placeholders": { + "count": { "content": "$1" } + } + }, + "minutesAgoShort": { + "message": "{count} εˆ†ι’Ÿε‰", + "description": "εˆ†ι’Ÿε‰ηŸ­ζ ΌεΌ", + "placeholders": { + "count": { "content": "$1" } + } + }, + "hoursAgoShort": { + "message": "{count} 小既前", + "description": "ε°ζ—Άε‰ηŸ­ζ ΌεΌ", + "placeholders": { + "count": { "content": "$1" } + } + }, + "keyActive": { + "message": "πŸ”΅ ζ΄»θ·ƒ", + "description": "ε―†ι’₯δ½Ώη”¨ε‘η‰‡ζ΄»θ·ƒηŠΆζ€" + }, + "keyLimit": { + "message": "πŸ”΄ ε·²θΎΎδΈŠι™", + "description": "ε―†ι’₯δ½Ώη”¨ε‘η‰‡ε·²θΎΎδΈŠι™" + }, + "keyNearLimit": { + "message": "🟑 即将滑载", + "description": "ε―†ι’₯使用卑片即将滑载" + }, + "keyReady": { + "message": "🟒 ε°±η»ͺ", + "description": "ε―†ι’₯使用卑片就η»ͺηŠΆζ€" + }, + "keyLabel": { + "message": "ε―†ι’₯ {number}:", + "description": "ε―†ι’₯使用卑片标签", + "placeholders": { + "number": { "content": "$1" } + } + }, + "statUsage": { + "message": "δ½Ώη”¨οΌš", + "description": "统θ‘ζ ‡η­Ύ β€” 使用欑数" + }, + "statLast": { + "message": "上欑:", + "description": "统θ‘ζ ‡η­Ύ β€” δΈŠζ¬‘θ―·ζ±‚" + }, + "statResets": { + "message": "重η½οΌš", + "description": "统θ‘ζ ‡η­Ύ β€” 重η½ζ—Άι—΄" + }, + "statAvailable": { + "message": "ε―η”¨οΌš", + "description": "统θ‘ζ ‡η­Ύ β€” 可用请求" + }, + "keyNotSet": { + "message": "ζœͺθΎη½", + "description": "ε―†ι’₯ζœͺ配η½ζ–‡ε­—" + }, + "keyAlreadyAddedTitle": { + "message": "⚠️ ζ­€ε―†ι’₯已添加!", + "description": "重倍 Gemini ε―†ι’₯ε·₯具提瀺" + }, + "enterKeyFirst": { + "message": "⚠️ θ―·ε…ˆθΎ“ε…₯ε―†ι’₯", + "description": "ζ΅‹θ―•η©Ί Gemini ε―†ι’₯ζ—Άηš„ι”™θ――" + }, + "duplicateKey": { + "message": "⚠️ 重倍密ι’₯", + "description": "重倍 Gemini ε―†ι’₯ι”™θ――" + }, + "duplicateKeyTitle": { + "message": "ζ­€ε―†ι’₯ε·²εœ¨εˆ—θ‘¨δΈ­ζ·»εŠ ", + "description": "重倍密ι’₯ι”™θ――ε·₯具提瀺" + }, + "mustHaveOneKey": { + "message": "您必鑻至少配η½δΈ€δΈͺ API ε―†ι’₯。", + "description": "ε°θ―•εˆ ι™€ζœ€εŽδΈ€δΈͺ Gemini ε―†ι’₯ζ—Άηš„ζη€Ί" + }, + "removeApiKeyConfirm": { + "message": "ζ˜―ε¦εˆ ι™€ API ε―†ι’₯ #{number}?", + "description": "εˆ ι™€ Gemini ε―†ι’₯η‘θ€", + "placeholders": { + "number": { "content": "$1" } + } + }, + "testingStatus": { + "message": "ζ΅‹θ―•δΈ­...", + "description": "ι€šη”¨ζ΅‹θ―•ηŠΆζ€" + }, + "validKey": { + "message": "βœ“ ζœ‰ζ•ˆ", + "description": "ε―†ι’₯ζ΅‹θ―•ζˆεŠŸ" + }, + "limitReachedGemini": { + "message": "⚠️ ε·²θΎΎι™εˆΆ", + "description": "Gemini ε―†ι’₯ε·²θΎΎι’‘ηŽ‡ι™εˆΆ" + }, + "limitReachedGeminiTitle": { + "message": "ζ­€ε―†ι’₯已达每ζ—₯ι’‘ηŽ‡ι™εˆΆοΌˆ20欑/倩)。约24ε°ζ—ΆεŽι‡η½γ€‚", + "description": "ε·²θΎΎι’‘ηŽ‡ι™εˆΆηš„ Gemini ε―†ι’₯ε·₯具提瀺" + }, + "invalidKey": { + "message": "βœ— ζ— ζ•ˆε―†ι’₯", + "description": "ε―†ι’₯ζ΅‹θ―•ζ— ζ•ˆ" + }, + "invalidKeyTitle": { + "message": "API ε―†ι’₯ζ— ζ•ˆζˆ–ε·²θΏ‡ζœŸγ€‚θ―·εœ¨ Google AI Studio δΈ­ζ£€ζŸ₯ζ‚¨ηš„ε―†ι’₯。", + "description": "ζ— ζ•ˆε―†ι’₯ε·₯具提瀺" + }, + "testFailed": { + "message": "βœ— ε€±θ΄₯({status}οΌ‰", + "description": "ε―†ι’₯ζ΅‹θ―•ε€±θ΄₯", + "placeholders": { + "status": { "content": "$1" } + } + }, + "errorStatus": { + "message": "βœ— ι”™θ――", + "description": "ι€šη”¨ι”™θ――ηŠΆζ€" + }, + "resetCounterConfirm": { + "message": "ζ˜―ε¦ι‡η½δ½Ώη”¨θ‘ζ•°ε™¨οΌŸθ―·δ»…εœ¨εˆ‡ζ’εˆ°ζ–° API ε―†ι’₯εŽζ‰§θ‘Œζ­€ζ“δ½œγ€‚", + "description": "ι‡η½ Gemini θ‘数器η‘θ€" + }, + "counterResetMsg": { + "message": "βœ“ 使用θ‘数器已重η½γ€‚ζ‚¨ηŽ°εœ¨ε―δ»₯使用新 API ε―†ι’₯ε€„η†ζœ€ε€š 20 封ι‚仢。", + "description": "θ‘数器重η½ζˆεŠŸζΆˆζ―" + }, + "usageRefreshed": { + "message": "βœ“ δ½Ώη”¨ζƒ…ε†΅ε·²εˆ·ζ–°γ€‚", + "description": "δ½Ώη”¨ζƒ…ε†΅εˆ·ζ–°δΏ‘ζ―" + }, + "allUsageRefreshed": { + "message": "βœ“ ζ‰€ζœ‰δ½Ώη”¨ζƒ…ε†΅ε·²εˆ·ζ–°γ€‚", + "description": "ζ‰€ζœ‰δ½Ώη”¨ζƒ…ε†΅εˆ·ζ–°δΏ‘ζ―" + }, + "noSignupUrl": { + "message": "ζ­€ζδΎ›ε•†ζ²‘ζœ‰ζ³¨ε†Œι“ΎζŽ₯γ€‚θ―·εœ¨δΈŠζ–ΉθΎη½δΈ­η›΄ζŽ₯配η½η«―点。", + "description": "ζδΎ›ε•†ζ²‘ζœ‰ζ³¨ε†Œι“ΎζŽ₯ζ—Άηš„ι”™θ――" + }, + "urlCopied": { + "message": "URL 已倍刢到ε‰ͺ贴板:\n{url}", + "description": "URL 已倍刢到ε‰ͺ贴板俑息", + "placeholders": { + "url": { "content": "$1" } + } + }, + "pleaseVisit": { + "message": "θ―·θΏι—οΌš\n{url}", + "description": "ζ— ζ³•ε€εˆΆ URL ζ—Άηš„ζη€Ί", + "placeholders": { + "url": { "content": "$1" } + } + }, + "pleaseConfigure": { + "message": "请配η½οΌš{items}", + "description": "δΏε­˜ζŒ‰ι’ε·₯具提瀺 β€” 缺少配η½", + "placeholders": { + "items": { "content": "$1" } + } + }, + "noFoldersInstruction": { + "message": "ζœͺ配η½ζ–‡δ»Άε€Ή/ζ ‡η­Ύγ€‚η‚Ήε‡»δΈŠζ–Ήηš„\"从ι‚δ»Άθ΄¦ζˆ·εŠ θ½½ζ–‡δ»Άε€Ή\"ζˆ–εœ¨δΈ‹ζ–Ήζ·»εŠ θ‡ͺεšδΉ‰ζ ‡η­Ύγ€‚", + "description": "ζ²‘ζœ‰ζ–‡δ»Άε€Ήζ—Άηš„θ―΄ζ˜Žζ–‡ε­—" + }, + "noFoldersFound": { + "message": "ζœͺζ‰Ύεˆ°ζ–‡δ»Άε€Ήγ€‚ζ‚¨ε―δ»₯ζ”ΉδΈΊεˆ›ε»Ίθ‡ͺεšδΉ‰ζ–‡δ»Άε€Ήγ€‚", + "description": "ι‚δ»Άθ΄¦ζˆ·δΈ­ζ²‘ζœ‰ζ–‡δ»Άε€Ήζ—Άηš„δΏ‘ζ―" + }, + "andMore": { + "message": "…δ»₯εŠε…Άδ»– {count} δΈͺ", + "description": "ζ–‡δ»Άε€Ήι’„θ§ˆζΊ’ε‡Ί", + "placeholders": { + "count": { "content": "$1" } + } + }, + "errorLoadingFolders": { + "message": "εŠ θ½½ζ–‡δ»Άε€Ήζ—Άε‡Ίι”™οΌš{error}", + "description": "εŠ θ½½ζ–‡δ»Άε€Ήι”™θ――", + "placeholders": { + "error": { "content": "$1" } + } + }, + "replaceFoldersConfirm": { + "message": "这将用ι‚δ»Άθ΄¦ζˆ·δΈ­ηš„ {count} δΈͺζ–‡δ»Άε€Ήζ›Ώζ’ζ‰€ζœ‰ηŽ°ζœ‰ζ–‡δ»Άε€Ή/ζ ‡η­Ύγ€‚ζ˜―ε¦η»§η»­οΌŸ", + "description": "替捒文仢倹η‘θ€", + "placeholders": { + "count": { "content": "$1" } + } + }, + "loadedFoldersMsg": { + "message": "已从ι‚δ»Άθ΄¦ζˆ·εŠ θ½½ {count} δΈͺζ–‡δ»Άε€Ήγ€‚εˆ«εΏ˜δΊ†δΏε­˜οΌ", + "description": "εŠ θ½½ζ–‡δ»Άε€ΉζˆεŠŸζΆˆζ―", + "placeholders": { + "count": { "content": "$1" } + } + }, + "addCustomFoldersMsg": { + "message": "ζ‚¨ηŽ°εœ¨ε―δ»₯εœ¨δΈ‹ζ–Ήζ·»εŠ θ‡ͺεšδΉ‰ζ–‡δ»Άε€Ή", + "description": "选择θ‡ͺεšδΉ‰ζ–‡δ»Άε€ΉεŽηš„δΏ‘息" + }, + "importOneLabelRequired": { + "message": "ε―Όε…₯ε‰θ―·θ‡³ε°‘ζ·»εŠ δΈ€δΈͺζ–‡δ»Άε€Ή/ζ ‡η­Ύγ€‚ζ―θ‘ŒθΎ“ε…₯δΈ€δΈͺ标签。", + "description": "ε―Όε…₯η©Ίζ–‡ζœ¬ζ—Άηš„ι”™θ――" + }, + "replaceExistingConfirm": { + "message": "这将用 {new} δΈͺζ–°ζ–‡δ»Άε€Ήζ›Ώζ’ζ‚¨ηŽ°ζœ‰ηš„ {existing} δΈͺζ–‡δ»Άε€Ή/ζ ‡η­Ύγ€‚ζ˜―ε¦η»§η»­οΌŸ", + "description": "ζ›Ώζ’ηŽ°ζœ‰ζ ‡η­Ύη‘θ€", + "placeholders": { + "existing": { "content": "$1" }, + "new": { "content": "$2" } + } + }, + "importedFoldersMsg": { + "message": "ε·²ε―Όε…₯ {count} δΈͺ类别/ζ–‡δ»Άε€Ήγ€‚εˆ«εΏ˜δΊ†δΏε­˜οΌ", + "description": "ε―Όε…₯成功梈息", + "placeholders": { + "count": { "content": "$1" } + } + }, + "useOllamaTestButton": { + "message": "θ―·δ½Ώη”¨δΈ‹ζ–Ήηš„\"ζ΅‹θ―• Ollama 连ζŽ₯\"ζŒ‰ι’", + "description": "εΌ•ε―Όεˆ° Ollama δΈ“ε±žζ΅‹θ―•" + }, + "useCustomTestButton": { + "message": "请使用 OpenAI ε…ΌεΉεŒΊζ΅δΈ­ηš„\"ζ΅‹θ―•θΏžζŽ₯\"ζŒ‰ι’", + "description": "εΌ•ε―Όεˆ°θ‡ͺεšδΉ‰η«―η‚Ήζ΅‹θ―•" + }, + "enterApiKey": { + "message": "θ―·θΎ“ε…₯ API ε―†ι’₯", + "description": "ζ΅‹θ―•η©Ί API ε―†ι’₯ζ—Άηš„ι”™θ――" + }, + "testingConnection": { + "message": "ζ΅‹θ―•θΏžζŽ₯δΈ­...", + "description": "API ζ΅‹θ―•ηŠΆζ€" + }, + "apiConnectionSuccess": { + "message": "βœ“ API 连ζŽ₯成功!", + "description": "API ζ΅‹θ―•ζˆεŠŸ" + }, + "apiError": { + "message": "API ι”™θ――οΌš{error}", + "description": "API 错误俑息", + "placeholders": { + "error": { "content": "$1" } + } + }, + "connectionError": { + "message": "连ζŽ₯ι”™θ――οΌš{error}", + "description": "连ζŽ₯错误俑息", + "placeholders": { + "error": { "content": "$1" } + } + }, + "enterCustomModelFirst": { + "message": "⚠️ θ―·ε…ˆθΎ“ε…₯θ‡ͺεšδΉ‰ζ¨‘εž‹εη§°", + "description": "ζ΅‹θ―• Ollama ζ—Άζ²‘ζœ‰θ‡ͺεšδΉ‰ζ¨‘εž‹ηš„ι”™θ――" + }, + "testingConnectionModels": { + "message": "ζ΅‹θ―•θΏžζŽ₯εΉΆζ£€ζŸ₯ζ¨‘εž‹...", + "description": "Ollama ζ΅‹θ―•ηŠΆζ€" + }, + "ollamaRunningNoModels": { + "message": "⚠️ Ollama ζ­£εœ¨θΏθ‘Œδ½†ζœͺε‰θ£…δ»»δ½•ζ¨‘εž‹γ€‚εœ¨\"δΈ‹θ½½ζ¨‘εž‹\"δΈ­θΎ“ε…₯ζ¨‘εž‹εη§°εΉΆη‚Ήε‡»\"δΈ‹θ½½\"开始使用。", + "description": "Ollama ζ²‘ζœ‰ζ¨‘εž‹ηš„θ­¦ε‘Š" + }, + "connectedModelReady": { + "message": "βœ“ 已连ζŽ₯οΌζ¨‘εž‹\"{model}\"ε·²ε‰θ£…εΉΆε―δ½Ώη”¨γ€‚ε―η”¨οΌš{available}", + "description": "Ollama 已连ζŽ₯幢可使用", + "placeholders": { + "model": { "content": "$1" }, + "available": { "content": "$2" } + } + }, + "modelNotInstalled": { + "message": "βœ— ζ¨‘εž‹\"{model}\"ζœͺε‰θ£…γ€‚ε―η”¨ζ¨‘εž‹οΌš{available}。使用\"δΈ‹θ½½ζ¨‘εž‹\"θΏ›θ‘Œε‰θ£…。", + "description": "Ollama ζ¨‘εž‹ζœͺζ‰Ύεˆ°", + "placeholders": { + "model": { "content": "$1" }, + "available": { "content": "$2" } + } + }, + "genericErrorLabel": { + "message": "βœ— ι”™θ――οΌš{error}", + "description": "ι€šη”¨ι”™θ――ζ ‡η­Ύ", + "placeholders": { + "error": { "content": "$1" } + } + }, + "ollamaErrorLabel": { + "message": "βœ— ι”™θ――οΌš{error}", + "description": "Ollama ι”™θ――", + "placeholders": { + "error": { "content": "$1" } + } + }, + "ollamaConnectionFailed": { + "message": "βœ— 连ζŽ₯ε€±θ΄₯:{error}。请η‘保 Ollama ζ­£εœ¨θΏθ‘ŒοΌˆε°θ―•οΌšollama serveοΌ‰", + "description": "Ollama 连ζŽ₯ε€±θ΄₯", + "placeholders": { + "error": { "content": "$1" } + } + }, + "enterBaseUrlFirst": { + "message": "⚠️ θ―·ε…ˆθΎ“ε…₯εŸΊη‘€εœ°ε€", + "description": "θŽ·ε–ζ¨‘εž‹ζ—Άζ²‘ζœ‰εœ°ε€ηš„ι”™θ――" + }, + "fetchingModels": { + "message": "ζ­£εœ¨δ»Žη«―η‚ΉθŽ·ε–ζ¨‘εž‹...", + "description": "θŽ·ε–ζ¨‘εž‹ηŠΆζ€" + }, + "noModelsEndpoint": { + "message": "⚠️ ζ­€η«―η‚ΉδΈŠζœͺζ‰Ύεˆ°ζ¨‘εž‹", + "description": "η«―η‚ΉδΈŠζ²‘ζœ‰ζ¨‘εž‹" + }, + "foundModelsMsg": { + "message": "βœ“ ζ‰Ύεˆ° {count} δΈͺζ¨‘εž‹γ€‚θ―·δ»ŽδΈ‹ζ‹‰θœε•δΈ­ι€‰ζ‹©ζˆ–δ½Ώη”¨\"θ‡ͺεšδΉ‰\"选鑹。", + "description": "ζ‰Ύεˆ°ζ¨‘εž‹ζˆεŠŸζΆˆζ―", + "placeholders": { + "count": { "content": "$1" } + } + }, + "failedFetchModels": { + "message": "βœ— θŽ·ε–ζ¨‘εž‹ε€±θ΄₯:{error}", + "description": "θŽ·ε–ζ¨‘εž‹ε€±θ΄₯", + "placeholders": { + "error": { "content": "$1" } + } + }, + "enterBaseUrl": { + "message": "⚠️ θ―·θΎ“ε…₯εŸΊη‘€εœ°ε€", + "description": "η©ΊεŸΊη‘€εœ°ε€ι”™θ――" + }, + "enterModelName": { + "message": "⚠️ θ―·θΎ“ε…₯ζ¨‘εž‹εη§°", + "description": "η©Ίζ¨‘εž‹εη§°ι”™θ――" + }, + "connectedSuccessfully": { + "message": "βœ“ 连ζŽ₯ζˆεŠŸοΌζ¨‘εž‹\"{model}\"已在 {url} ε°±η»ͺ", + "description": "θ‡ͺεšδΉ‰η«―η‚ΉθΏžζŽ₯成功", + "placeholders": { + "model": { "content": "$1" }, + "url": { "content": "$2" } + } + }, + "customConnectionFailed": { + "message": "βœ— 连ζŽ₯ε€±θ΄₯:{error}。请检ζŸ₯εŸΊη‘€εœ°ε€εΉΆη‘δΏη«―η‚Ήζ­£εœ¨θΏθ‘Œγ€‚", + "description": "θ‡ͺεšδΉ‰η«―η‚ΉθΏžζŽ₯ε€±θ΄₯", + "placeholders": { + "error": { "content": "$1" } + } + }, + "diagnosticsTitle": { + "message": "πŸ” OLLAMA θ―Šζ–­", + "description": "Ollama θ―Šζ–­ζ ‡ι’˜" + }, + "diagnosticsRunning": { + "message": "ζ­£εœ¨θΏθ‘Œζ΅‹θ―•...", + "description": "θ―Šζ–­θΏθ‘ŒδΈ­ηŠΆζ€" + }, + "testListModels": { + "message": "πŸ“‹ ζ΅‹θ―• 1οΌšεˆ—ε‡Ίζ¨‘εž‹η«―η‚Ή", + "description": "θ―Šζ–­ζ΅‹θ―• 1 ζ ‡ι’˜" + }, + "testVersion": { + "message": "πŸ”’ ζ΅‹θ―• 2οΌšη‰ˆζœ¬η«―η‚Ή", + "description": "θ―Šζ–­ζ΅‹θ―• 2 ζ ‡ι’˜" + }, + "testPullEndpoint": { + "message": "⬇️ ζ΅‹θ―• 3οΌšζ‹‰ε–η«―η‚Ήζ£€ζŸ₯", + "description": "θ―Šζ–­ζ΅‹θ―• 3 ζ ‡ι’˜" + }, + "diagnosticsSummary": { + "message": "πŸ“Š ζ‘˜θ¦οΌš", + "description": "θ―Šζ–­ζ‘˜θ¦ζ ‡ι’˜" + }, + "ollamaRunningOk": { + "message": "βœ“ Ollama 正在运葌幢可θΏι—", + "description": "θ―Šζ–­ζˆεŠŸζΆˆζ―" + }, + "cannotConnectOllama": { + "message": "βœ— ζ— ζ³•θΏžζŽ₯到 Ollama", + "description": "θ―Šζ–­ε€±θ΄₯梈息" + }, + "troubleshootingLabel": { + "message": "ζ•…ιšœζŽ’ι™€οΌš", + "description": "θ―Šζ–­ζ•…ιšœζŽ’ι™€ζ ‡ι’˜" + }, + "troubleshootRunning": { + "message": "1. ζ£€ζŸ₯ Ollama 是否运葌:ps aux | grep ollama", + "description": "θ―Šζ–­ζη€Ί 1" + }, + "troubleshootStart": { + "message": "2. 启动 Ollama:ollama serve", + "description": "θ―Šζ–­ζη€Ί 2" + }, + "troubleshootTest": { + "message": "3. ζ‰‹εŠ¨ζ΅‹θ―•οΌšcurl {url}/api/tags", + "description": "θ―Šζ–­ζη€Ί 3", + "placeholders": { + "url": { "content": "$1" } + } + }, + "troubleshootPort": { + "message": "4. ζ£€ζŸ₯端口 11434 ζ˜―ε¦θ’«ε η”¨οΌšlsof -i :11434", + "description": "θ―Šζ–­ζη€Ί 4" + }, + "criticalError": { + "message": "❌ δΈ₯ι‡ι”™θ――οΌš", + "description": "θ―Šζ–­δΈ₯重错误" + }, + "noInstalledModels": { + "message": "⚠️ ζœͺε‰θ£…δ»»δ½•ζ¨‘εž‹", + "description": "θ―Šζ–­ζœͺε‰θ£…ζ¨‘εž‹ζ–‡ε­—" + }, + "versionNotAvailable": { + "message": "⚠️ η«―η‚ΉδΈε―η”¨οΌˆθΎƒζ—§ηš„ Ollama η‰ˆζœ¬οΌ‰", + "description": "θ―Šζ–­η‰ˆζœ¬δΈε―η”¨ζ–‡ε­—" + }, + "unknownVersion": { + "message": "ζœͺηŸ₯", + "description": "θ―Šζ–­ζœͺηŸ₯η‰ˆζœ¬ζ–‡ε­—" + }, + "pullEndpointNote": { + "message": "ζ³¨ζ„οΌšζ­€η«―η‚Ήη”¨δΊŽδΈ‹θ½½ζ¨‘εž‹", + "description": "θ―Šζ–­ζ‹‰ε–η«―η‚Ήθ―΄ζ˜Ž" + }, + "diagnosticsApiUrl": { + "message": "βœ“ API εŸΊη‘€εœ°ε€οΌš{url}", + "description": "θ―Šζ–­ API εœ°ε€", + "placeholders": { + "url": { "content": "$1" } + } + }, + "ollamaCurlTest": { + "message": "ζ‰‹εŠ¨ζ΅‹θ―•οΌšcurl {url}/api/tags", + "description": "θ―Šζ–­ curl 提瀺", + "placeholders": { + "url": { "content": "$1" } + } + }, + "fetchingModelsStatus": { + "message": "ζ­£εœ¨θŽ·ε–ζ¨‘εž‹...", + "description": "εˆ—ε‡Ί Ollama ζ¨‘εž‹ηŠΆζ€" + }, + "availableModels": { + "message": "βœ“ ε―η”¨ζ¨‘εž‹οΌš{models}", + "description": "可用 Ollama ζ¨‘εž‹", + "placeholders": { + "models": { "content": "$1" } + } + }, + "noModelsInstalledHint": { + "message": "⚠️ ζœͺε‰θ£…δ»»δ½•ζ¨‘εž‹γ€‚θΏθ‘Œ\"ollama pull llama3.2\"δΈ‹θ½½δΈ€δΈͺ。", + "description": "ζ²‘ζœ‰ Ollama ζ¨‘εž‹ηš„ζη€Ί" + }, + "failedFetchModelsSimple": { + "message": "βœ— θŽ·ε–ζ¨‘εž‹ε€±θ΄₯", + "description": "θŽ·ε– Ollama ζ¨‘εž‹ε€±θ΄₯" + }, + "ollamaConnectionFailedSimple": { + "message": "βœ— 连ζŽ₯ε€±θ΄₯:{error}。Ollama 是否在运葌?", + "description": "Ollama 连ζŽ₯ε€±θ΄₯η€ε•提瀺", + "placeholders": { + "error": { "content": "$1" } + } + }, + "enterModelDownload": { + "message": "⚠️ θ―·θΎ“ε…₯θ¦δΈ‹θ½½ηš„ζ¨‘εž‹εη§°", + "description": "η©ΊδΈ‹θ½½ζ¨‘εž‹ι”™θ――" + }, + "startingDownload": { + "message": "εΌ€ε§‹δΈ‹θ½½ {model}...", + "description": "δΈ‹θ½½ε·²εΌ€ε§‹", + "placeholders": { + "model": { "content": "$1" } + } + }, + "failedStart": { + "message": "βœ— 启动倱θ΄₯:{error}", + "description": "ε―εŠ¨δΈ‹θ½½ε€±θ΄₯", + "placeholders": { + "error": { "content": "$1" } + } + }, + "downloadComplete": { + "message": "βœ“ δΈ‹θ½½εŒζˆ", + "description": "δΈ‹θ½½εŒζˆ" + }, + "downloadFailed": { + "message": "βœ— δΈ‹θ½½ε€±θ΄₯:{error}", + "description": "δΈ‹θ½½ε€±θ΄₯", + "placeholders": { + "error": { "content": "$1" } + } + }, + "unknownError": { + "message": "ζœͺηŸ₯ι”™θ――", + "description": "ι€šη”¨ζœͺηŸ₯ι”™θ――" + }, + "addFolderBeforeSave": { + "message": "δΏε­˜ε‰θ―·θ‡³ε°‘ζ·»εŠ δΈ€δΈͺζ–‡δ»Άε€Ή/标签。使用\"从ι‚δ»Άθ΄¦ζˆ·εŠ θ½½ζ–‡δ»Άε€Ή\"ζˆ–ζ·»εŠ θ‡ͺεšδΉ‰ζ ‡η­Ύγ€‚", + "description": "δΏε­˜ζ—Άζ²‘ζœ‰ζ–‡δ»Άε€Ήηš„ι”™θ――" + }, + "addGeminiKeyBeforeSave": { + "message": "δΏε­˜ε‰θ―·θ‡³ε°‘ζ·»εŠ δΈ€δΈͺ Gemini API ε―†ι’₯。", + "description": "δΏε­˜ζ—Άζ²‘ζœ‰ Gemini ε―†ι’₯ηš„ι”™θ――" + }, + "duplicateApiKeys": { + "message": "⚠️ ζ£€ζ΅‹εˆ°ι‡ε€ηš„ API ε―†ι’₯!每δΈͺε―†ι’₯εΏ…ι‘»ε”―δΈ€γ€‚θ―·εœ¨δΏε­˜ε‰εˆ ι™€ι‡ε€ι‘Ήγ€‚", + "description": "重倍 Gemini ε―†ι’₯ι”™θ――" + }, + "settingsSavedMultiKey": { + "message": "βœ“ θΎη½δΏε­˜ζˆεŠŸοΌε·²ι…η½ε€šδΈͺ Gemini API ε―†ι’₯θ‡ͺ动θ½ζ’。", + "description": "ε€šε―†ι’₯保存成功" + }, + "enterOllamaModel": { + "message": "θ―·θΎ“ε…₯ Ollama ηš„θ‡ͺεšδΉ‰ζ¨‘εž‹εη§°γ€‚", + "description": "δΏε­˜ζ—Άζ²‘ζœ‰ Ollama ζ¨‘εž‹ηš„ι”™θ――" + }, + "settingsSavedOllama": { + "message": "βœ“ θΎη½δΏε­˜ζˆεŠŸοΌOllama 已配η½δΈΊζœ¬εœ°ι‚仢倄理{cpuMode}。", + "description": "Ollama 保存成功", + "placeholders": { + "cpuMode": { "content": "$1" } + } + }, + "enterCustomBaseUrl": { + "message": "θ―·θΎ“ε…₯θ‡ͺεšδΉ‰η«―η‚Ήηš„εŸΊη‘€εœ°ε€γ€‚", + "description": "δΏε­˜ζ—Άζ²‘ζœ‰εŸΊη‘€εœ°ε€ηš„ι”™θ――" + }, + "enterCustomModel": { + "message": "θ―·ι€‰ζ‹©ζˆ–θΎ“ε…₯θ‡ͺεšδΉ‰η«―η‚Ήηš„ζ¨‘εž‹εη§°γ€‚", + "description": "δΏε­˜ζ—Άζ²‘ζœ‰ζ¨‘εž‹ηš„ι”™θ――" + }, + "settingsSavedCustomEndpoint": { + "message": "βœ“ θΎη½δΏε­˜ζˆεŠŸοΌε·²ι…η½θ‡ͺεšδΉ‰ OpenAI ε…ΌεΉη«―点。", + "description": "θ‡ͺεšδΉ‰η«―η‚ΉδΏε­˜ζˆεŠŸ" + }, + "enterApiKeyBeforeSave": { + "message": "δΏε­˜ε‰θ―·θΎ“ε…₯ζ‚¨ηš„ API ε―†ι’₯。点击\"θŽ·ε– API ε―†ι’₯\"θŽ·ε–γ€‚", + "description": "δΏε­˜ζ—Άζ²‘ζœ‰ API ε―†ι’₯ηš„ι”™θ――" + }, + "settingsSavedSuccess": { + "message": "βœ“ θΎη½δΏε­˜ζˆεŠŸοΌζ‚¨ηŽ°εœ¨ε―δ»₯使用 AutoSort+ εˆ†ζžι‚仢了。", + "description": "ι€šη”¨δΏε­˜ζˆεŠŸ" + }, + "errorSavingSettings": { + "message": "保存θΎη½ζ—Άε‡Ίι”™οΌš{error}", + "description": "保存θΎη½ι”™θ――", + "placeholders": { + "error": { "content": "$1" } + } + }, + "clearHistoryConfirm": { + "message": "您η‘εšθ¦ζΈ…ι™€η§»εŠ¨εŽ†ε²ε—οΌŸ", + "description": "ζΈ…ι™€εŽ†ε²η‘θ€" + }, + "batchPausedChunk": { + "message": "⏸ ε·²ζš‚εœ β€” 第 {current}/{total} ε—οΌˆ{done}/{totalItems}οΌ‰", + "description": "ζ‰Ήι‡ε€„η†ε·²ζš‚εœοΌˆεˆ†ε—οΌ‰", + "placeholders": { + "current": { "content": "$1" }, + "total": { "content": "$2" }, + "done": { "content": "$3" }, + "totalItems": { "content": "$4" } + } + }, + "batchPausedSimple": { + "message": "⏸ ε·²ζš‚εœοΌˆ{done}/{totalItems}οΌ‰", + "description": "ζ‰Ήι‡ε€„η†ε·²ζš‚εœοΌˆη€ε•οΌ‰", + "placeholders": { + "done": { "content": "$1" }, + "totalItems": { "content": "$2" } + } + }, + "batchDone": { + "message": "βœ… εŒζˆ β€” ε·²εˆ†η±»οΌš{completed}οΌŒθ·³θΏ‡οΌš{skipped},倱θ΄₯:{failed}", + "description": "批量倄理εŒζˆ", + "placeholders": { + "completed": { "content": "$1" }, + "skipped": { "content": "$2" }, + "failed": { "content": "$3" } + } + }, + "batchCancelledChunk": { + "message": "⏹ ε·²ε–ζΆˆοΌŒεœ¨η¬¬ {current}/{total} ε—εŽ", + "description": "ζ‰Ήι‡ε€„η†ε·²ε–ζΆˆοΌˆεˆ†ε—οΌ‰", + "placeholders": { + "current": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "batchCancelledSimple": { + "message": "⏹ ε·²ε–ζΆˆοΌˆ{done}/{totalItems}οΌ‰", + "description": "ζ‰Ήι‡ε€„η†ε·²ε–ζΆˆοΌˆη€ε•οΌ‰", + "placeholders": { + "done": { "content": "$1" }, + "totalItems": { "content": "$2" } + } + }, + "batchRunningChunk": { + "message": "第 {current}/{total} 块 β€” {done}/{totalItems}οΌˆε·²εˆ†η±»οΌš{completed},倱θ΄₯:{failed}οΌ‰", + "description": "ζ‰Ήι‡ε€„η†θΏθ‘ŒδΈ­οΌˆεˆ†ε—οΌ‰", + "placeholders": { + "current": { "content": "$1" }, + "total": { "content": "$2" }, + "done": { "content": "$3" }, + "totalItems": { "content": "$4" }, + "completed": { "content": "$5" }, + "failed": { "content": "$6" } + } + }, + "batchRunningSimple": { + "message": "{done}/{totalItems}οΌˆε·²εˆ†η±»οΌš{completed},倱θ΄₯:{failed}οΌ‰", + "description": "ζ‰Ήι‡ε€„η†θΏθ‘ŒδΈ­οΌˆη€ε•οΌ‰", + "placeholders": { + "done": { "content": "$1" }, + "totalItems": { "content": "$2" }, + "completed": { "content": "$3" }, + "failed": { "content": "$4" } + } + }, + "batchPausing": { + "message": "⏸ ζ­£εœ¨ζš‚εœβ€¦ ε½“ε‰θ―·ζ±‚ε°†ι¦–ε…ˆεŒζˆγ€‚", + "description": "ζ‰Ήι‡ε€„η†ζš‚εœδΈ­ζΆˆζ―" + }, + "batchCancelConfirm": { + "message": "ζ˜―ε¦ε–ζΆˆε½“ε‰ζ‰Ήι‡ε€„η†οΌŸε·²εˆ†η±»ηš„ι‚δ»Άε°†δΈδΌšθ’«ζ’€ι”€γ€‚", + "description": "ε–ζΆˆζ‰Ήι‡ε€„η†η‘θ€" + }, + "batchCancelling": { + "message": "⏹ ζ­£εœ¨ε–ζΆˆβ€¦ ε½“ε‰θ―·ζ±‚ε°†ι¦–ε…ˆεŒζˆγ€‚", + "description": "ζ‰Ήι‡ε€„η†ε–ζΆˆδΈ­ζΆˆζ―" + }, + "debugEnabled": { + "message": "βœ“ 调试樑式已启用。打开 Thunderbird 开发者ε·₯ε…·οΌˆCtrl+Shift+IοΌ‰ζŸ₯ηœ‹ζ—₯志。", + "description": "调试樑式已启用" + }, + "debugDisabled": { + "message": "βœ“ 调试樑式已禁用。", + "description": "调试樑式已禁用" + }, + "promptCleared": { + "message": "θ‡ͺεšδΉ‰ζη€Ίθ―ε·²ζΈ…ι™€γ€‚ε°†δ½Ώη”¨ι»˜θ€ζη€Ίθ―γ€‚", + "description": "提瀺词已清陀" + } +} diff --git a/api_ollama/index.html b/api_ollama/index.html new file mode 100644 index 0000000..de9afef --- /dev/null +++ b/api_ollama/index.html @@ -0,0 +1,50 @@ + + + + + AutoSort+ Ollama + + + +

AutoSort+ - Ollama Chat

+
+
+
Initializing...
+ + + diff --git a/api_ollama/ollama-popup.js b/api_ollama/ollama-popup.js new file mode 100644 index 0000000..7fe31da --- /dev/null +++ b/api_ollama/ollama-popup.js @@ -0,0 +1,134 @@ +/* + * Ollama Popup for AutoSort+ + * Makes direct POST requests to Ollama from browser context (no restrictions) + * Popup = browser context = POST works + * Background script = restricted context = POST fails with 403 + */ + +let statusEl = null; +let messagesEl = null; +let responseEl = null; +let analysisResult = null; + +// Get URL parameters +const urlParams = new URLSearchParams(window.location.search); +const callId = urlParams.get('call_id'); + +// Initialize UI +document.addEventListener('DOMContentLoaded', async () => { + statusEl = document.getElementById('status'); + messagesEl = document.getElementById('messages'); + responseEl = document.getElementById('response'); + + statusEl.textContent = 'Ready'; + + // Tell background script that we're ready + browser.runtime.sendMessage({ + command: "ollama_popup_ready_" + callId, + window_id: (await browser.windows.getCurrent()).id + }).catch(err => console.log('Ready message error (expected):', err.message)); +}); + +// Handle messages from background script +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + switch (message.command) { + case "ollama_analyze": + handleOllamaAnalyze(message); + break; + case 'ollama_error': + statusEl.textContent = 'Error: ' + message.error; + responseEl.textContent = message.error; + analysisResult = null; + sendResultToBackground(); + break; + default: + console.log('Unknown command:', message.command); + } +}); + +async function handleOllamaAnalyze(message) { + const { ollama_host, ollama_model, ollama_num_ctx, ollama_auth_token, prompt } = message; + + try { + statusEl.textContent = 'Connecting to Ollama...'; + responseEl.textContent = ''; + analysisResult = null; + + // Add user message to display + const userMsgEl = document.createElement('div'); + userMsgEl.className = 'message user-message'; + userMsgEl.textContent = 'Analyzing: ' + prompt.substring(0, 100) + '...'; + messagesEl.appendChild(userMsgEl); + + statusEl.textContent = 'Processing with Ollama...'; + + // Make direct POST request from browser context (no restrictions!) + const headers = { + 'Content-Type': 'application/json' + }; + if (ollama_auth_token) { + headers['Authorization'] = `Bearer ${ollama_auth_token}`; + } + + const requestBody = { + model: ollama_model, + messages: [{ role: 'user', content: prompt }], + stream: false + }; + + if (ollama_num_ctx > 0) { + requestBody.options = { num_ctx: parseInt(ollama_num_ctx) }; + } + + console.log('[Ollama Popup] Sending POST to:', ollama_host + '/api/chat'); + console.log('[Ollama Popup] Model:', ollama_model); + console.log('[Ollama Popup] Request body:', JSON.stringify(requestBody).substring(0, 200)); + + const response = await fetch(ollama_host + '/api/chat', { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + mode: 'cors', + credentials: 'omit' + }); + + console.log('[Ollama Popup] Response status:', response.status); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Ollama Popup] Error response:', errorText); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + console.log('[Ollama Popup] Response data:', JSON.stringify(data).substring(0, 300)); + + // Extract the response content + if (data.message && data.message.content) { + analysisResult = data.message.content; + responseEl.textContent = analysisResult; + statusEl.textContent = 'Analysis complete βœ“'; + } else { + throw new Error('Invalid response format: missing message.content'); + } + + // Send result back to background + setTimeout(sendResultToBackground, 1000); + + } catch (error) { + console.error('[Ollama Popup] Error:', error); + statusEl.textContent = 'Error: ' + error.message; + responseEl.textContent = 'Error: ' + error.message; + analysisResult = null; + sendResultToBackground(); + } +} + +function sendResultToBackground() { + // Send result back to background script + browser.runtime.sendMessage({ + command: 'ollama_analysis_result_' + callId, + result: analysisResult, + error: analysisResult === null ? 'Analysis failed' : null + }).catch(err => console.log('Result message error:', err.message)); +} diff --git a/autosortplus.xpi b/autosortplus.xpi new file mode 100644 index 0000000..c07c1dd Binary files /dev/null and b/autosortplus.xpi differ diff --git a/background.js b/background.js index 0ae37db..5e97368 100644 --- a/background.js +++ b/background.js @@ -1,20 +1,905 @@ +// Initialize debug logger +if (window.debugLogger) { + window.debugLogger.init(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// PROVIDER CONSTANTS +// ───────────────────────────────────────────────────────────────────────────── + +const PROVIDERS = { + GEMINI: 'gemini', + OPENAI: 'openai', + ANTHROPIC: 'anthropic', + GROQ: 'groq', + MISTRAL: 'mistral', + OLLAMA: 'ollama', + OPENAI_COMPATIBLE: 'openai-compatible' +}; + // Listen for messages from the options page browser.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "applyLabels") { - applyLabelsToMessages(message.messages, message.label); + applyLabelsToMessages(message.messages, message.label) + .then(() => sendResponse({ ok: true })) + .catch(err => sendResponse({ ok: false, error: err.message })); + return true; // Required for async response } else if (message.action === "analyzeEmail") { analyzeEmailContent(message.emailContent).then(label => { sendResponse({ label: label }); }); return true; // Required for async response + } else if (message.action === 'startOllamaPull') { + (async () => { + try { + const { ollamaUrl, model, headers } = message; + const { response } = await callOllamaViaTab(ollamaUrl, { + action: 'ollamaFetch', + fetchAction: 'pull', + model, + headers + }); + sendResponse(response || { ok: true }); + } catch (e) { + sendResponse({ ok: false, error: e.message }); + } + })(); + return true; + } else if (message.action === 'batchControl') { + // Pause / Resume / Cancel from the options page UI + if (message.command === 'pause') { + _batchState.paused = true; + } else if (message.command === 'resume') { + _batchState.paused = false; + } else if (message.command === 'cancel') { + _batchState.cancelled = true; + _batchState.paused = false; + } + sendResponse({ ok: true }); } }); +// Click handler for browser action icon - opens settings +browser.browserAction.onClicked.addListener(() => { + browser.runtime.openOptionsPage(); +}); + +// Register auto-sort listener for new emails +registerAutoSortListener(); + +// ───────────────────────────────────────────────────────────────────────────── +// LEVENSHTEIN FUZZY MATCHING +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Calculate Levenshtein edit distance between two strings. + */ +function levenshteinDistance(a, b) { + const m = a.length, n = b.length; + if (m === 0) return n; + if (n === 0) return m; + + const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j - 1], dp[i][j - 1], dp[i - 1][j]); + } + } + return dp[m][n]; +} + +/** + * Find the best fuzzy-matching label using Levenshtein distance. + * Returns the matched label if edit distance ratio <= threshold, else null. + */ +function findBestFuzzyMatch(input, candidates, threshold = 0.3) { + const lower = input.toLowerCase(); + let bestMatch = null; + let bestRatio = Infinity; + + for (const candidate of candidates) { + const candidateLower = candidate.toLowerCase(); + const dist = levenshteinDistance(lower, candidateLower); + const maxLen = Math.max(lower.length, candidateLower.length); + const ratio = maxLen === 0 ? 0 : dist / maxLen; + + if (ratio <= threshold && ratio < bestRatio) { + bestRatio = ratio; + bestMatch = candidate; + } + } + return bestMatch; +} + +// ───────────────────────────────────────────────────────────────────────────── +// EMAIL CONTEXT EXTRACTION +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Extract comprehensive email context from a Thunderbird message structure. + * Returns subject, author, attachments, and body text. + */ +async function extractEmailContext(fullMessage, messageHeader) { + const subject = (fullMessage.headers?.Subject?.[0]) || (messageHeader?.subject) || ''; + + const author = (fullMessage.headers?.From?.[0]) || (messageHeader?.author) || ''; + + // Collect attachment info from parts (name indicates it's a file attachment) + const attachments = []; + async function collectAttachments(parts) { + if (!parts) return; + for (const part of parts) { + if (part.parts) await collectAttachments(part.parts); + // part.name means this is an attachment/file part + if (part.name) { + // Skip inline text parts that are the email body + const isInlineText = (part.contentType === 'text/plain' || part.contentType === 'text/html') && !part.contentDisposition; + if (!isInlineText) { + attachments.push({ + name: part.name, + contentType: part.contentType || 'unknown', + size: part.size || 0 + }); + } + } + } + } + if (fullMessage.parts) await collectAttachments(fullMessage.parts); + + async function decodeBody(body, encoding) { + if (!body) return ''; + try { + if (encoding === 'base64') return atob(body); + if (encoding === 'quoted-printable') return browser.messengerUtilities.decodeQP(body); + } catch (e) { + console.warn('[MIME] decodeBody failed:', e.message); + } + return body; + } + + function stripHtmlTags(html) { + let text = html.replace(/<(style|script)[^>]*>[\s\S]*?<\/\1>/gi, ''); + text = text.replace(//gi, '\n').replace(/<\/p>/gi, '\n').replace(/<\/div>/gi, '\n'); + text = text.replace(/<[^>]+>/g, ''); + text = text.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'"); + const signatureMarkers = ['-- \n', 'Sent from my iPhone', 'Get Outlook for', '________________________________', 'Sent from my Samsung device', 'EnvoyΓ© depuis mon appareil']; + for (const marker of signatureMarkers) { + const idx = text.indexOf(marker); + if (idx > 50 && idx < Math.floor(text.length * 0.6)) text = text.substring(0, idx); + } + return text.trim().substring(0, 1500); + } + + async function extractBodyText(parts) { + if (!parts) return ''; + let plainText = ''; + let htmlText = ''; + for (const part of parts) { + if (part.parts) { + const subResult = await extractBodyText(part.parts); + if (subResult.isPlain) plainText += subResult.text; + else if (subResult.isHtml) htmlText += subResult.text; + else plainText += subResult.text; + } else if (part.contentType === 'text/plain' && part.body) { + plainText += decodeBody(part.body, part.encoding) + '\n'; + } else if (part.contentType === 'text/html' && part.body && !plainText) { + htmlText = stripHtmlTags(decodeBody(part.body, part.encoding)); + } else if (part.contentType === 'message/rfc822' && part.body) { + plainText += part.body + '\n'; + } + } + const text = plainText || htmlText; + return { text: text.substring(0, 1500), isPlain: !!plainText, isHtml: !!htmlText && !plainText }; + } + + const bodyResult = fullMessage.parts ? await extractBodyText(fullMessage.parts) : { text: fullMessage.body || '', isPlain: true, isHtml: false }; + const body = typeof bodyResult === 'string' ? bodyResult : bodyResult.text; + + return { + subject, + author, + attachments, + body + }; +} + +// Legacy wrapper for backward compatibility +async function extractTextFromParts(fullMessage) { + const context = await extractEmailContext(fullMessage, null); + return context.body; +} + +// Default prompt template for email classification +const DEFAULT_PROMPT = `You are an email classification assistant. Analyze this email and choose the most appropriate label from: {labels}. + +**Email Metadata:** +- Subject: {subject} +- From: {author} +- Attachments: {attachments} + +**Email Body:** +{body} + +Consider the subject line, sender context, attachment filenames, and body content to determine the most appropriate category. Respond with only the exact label name, or "null" if no label fits well.`; + +/** Select the appropriate API key for the given provider. */ +function resolveApiKey(settings, provider, keyIndex) { + if (provider === 'gemini') { + if (settings.geminiApiKeys?.length > 0) { + const idx = keyIndex ?? settings.currentGeminiKeyIndex ?? 0; + if (window.debugLogger) window.debugLogger.info('[Gemini]', `Using API Key #${idx + 1} of ${settings.geminiApiKeys.length}`); + return settings.geminiApiKeys[idx]; + } + return settings.apiKey; // legacy fallback + } + if (provider === 'ollama' || provider === 'openai-compatible') return null; + return settings.apiKey; +} + +/** Build a prompt from template with placeholder injection. */ +function buildPrompt(template, settings, emailContent, emailContext) { + let prompt = template; + const labelsStr = settings.labels.join(', '); + const subject = emailContext?.subject || ''; + const author = emailContext?.author || ''; + const attachmentsStr = emailContext?.attachments?.length > 0 ? emailContext.attachments.map(a => a.name).join(', ') : '(none)'; + const body = emailContent; + + function injectPlaceholder(placeholder, value, fallbackPrefix, fallbackPosition = 'start') { + if (!prompt.includes(placeholder)) { + if (window.debugLogger) window.debugLogger.warn('[AutoSort]', `Custom prompt missing ${placeholder} placeholder - injecting`); + prompt = fallbackPosition === 'start' + ? `${fallbackPrefix}${value}\n\n${prompt}` + : `${prompt}\n\n${fallbackPrefix}${value}`; + } else { + prompt = prompt.replace(placeholder, value); + } + } + + injectPlaceholder('{labels}', labelsStr, 'Labels: ', 'start'); + injectPlaceholder('{subject}', subject, 'Subject: ', 'start'); + injectPlaceholder('{author}', author, 'From: ', 'start'); + injectPlaceholder('{attachments}', attachmentsStr, 'Attachments: ', 'start'); + + if (prompt.includes('{body}')) { + prompt = prompt.replace('{body}', body); + } else if (prompt.includes('{email}')) { + prompt = prompt.replace('{email}', body); + } else { + if (window.debugLogger) window.debugLogger.warn('[AutoSort]', 'Custom prompt missing {body} placeholder - appending'); + prompt = `${prompt}\n\nEmail content:\n${body}`; + } + return prompt; +} + +/** Extract text from provider response. Maps provider name β†’ parser function. */ +const PROVIDER_PARSERS = { + gemini: data => data.candidates?.[0]?.content?.parts?.[0]?.text, + openai: data => data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? data.choices?.[0]?.delta?.content, + groq: data => data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? data.choices?.[0]?.delta?.content, + mistral: data => data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? data.choices?.[0]?.delta?.content, + anthropic: data => data.content?.[0]?.text, + ollama: null, // handled separately + 'openai-compatible': data => data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text +}; + +const PROVIDER_NAMES = { GEMINI: 'gemini', OPENAI: 'openai', ANTHROPIC: 'anthropic', GROQ: 'groq', MISTRAL: 'mistral', OLLAMA: 'ollama', OPENAI_COMPATIBLE: 'openai-compatible' }; + +/** Generic tab-based fetch: injects script, polls for result, closes tab. */ +async function fetchViaTab(tabUrl, scriptCode, resultVar, timeoutMs = 10000) { + const tab = await browser.tabs.create({ url: tabUrl, active: false }); + try { + await new Promise(resolve => setTimeout(resolve, 500)); + await browser.tabs.executeScript(tab.id, { code: scriptCode }); + + let result = null; + const pollInterval = 250; + const maxPolls = Math.ceil(timeoutMs / pollInterval); + for (let i = 0; i < maxPolls; i++) { + await new Promise(resolve => setTimeout(resolve, pollInterval)); + try { + const results = await browser.tabs.executeScript(tab.id, { + code: `window.${resultVar} || null` + }); + if (results && results[0]) { result = results[0]; break; } + } catch (e) { + break; // tab closing + } + } + + if (!result) throw new Error(`Request timed out (${timeoutMs}ms) - no response from API`); + if (!result.ok) throw new Error(result.error || 'API error'); + return result.data; + } finally { + try { await browser.tabs.remove(tab.id); } catch (e) { + console.warn('[AutoSort+] Failed to close tab after fetch:', e.message); + } + } +} + +async function ollamaChatViaTab(ollamaUrl, model, prompt, authToken, numCtx = 0) { + const headers = { 'Content-Type': 'application/json' }; + if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + const optionsObj = numCtx > 0 ? { options: { num_ctx: parseInt(numCtx) } } : {}; + const scriptCode = ` + (async () => { + try { + const response = await fetch(window.location.origin + '/api/chat', { + method: 'POST', + headers: ${JSON.stringify(headers)}, + body: JSON.stringify({ + model: ${JSON.stringify(model)}, + messages: [{ role: 'user', content: ${JSON.stringify(prompt)} }], + stream: false, + ...${JSON.stringify(optionsObj)} + }) + }); + if (!response.ok) throw new Error('HTTP ' + response.status + ': ' + response.statusText); + window.__ollama_result = { ok: true, data: await response.json() }; + } catch (error) { + window.__ollama_result = { ok: false, error: error.message }; + } + })();`; + return fetchViaTab(ollamaUrl, scriptCode, '__ollama_result'); +} + +async function openaiCompatibleChatViaTab(baseUrl, model, prompt, apiKey) { + const headers = { 'Content-Type': 'application/json' }; + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + const scriptCode = ` + (async () => { + try { + const response = await fetch(window.location.origin + '/v1/chat/completions', { + method: 'POST', + headers: ${JSON.stringify(headers)}, + body: JSON.stringify({ + model: ${JSON.stringify(model)}, + messages: [{ role: 'user', content: ${JSON.stringify(prompt)} }], + max_tokens: 8192, temperature: 0.6, top_p: 0.95, stream: false + }) + }); + if (!response.ok) throw new Error('HTTP ' + response.status + ': ' + response.statusText); + window.__openai_compat_result = { ok: true, data: await response.json() }; + } catch (error) { + window.__openai_compat_result = { ok: false, error: error.message }; + } + })();`; + return fetchViaTab(baseUrl, scriptCode, '__openai_compat_result'); +} + +async function callOllamaViaTab(ollamaUrl, payload) { + // Deprecated function kept for backward compatibility + // Now routes to direct API call via fetch + const { fetchAction, model, prompt, headers } = payload; + + if (fetchAction === 'chat') { + // For direct chat, we make a simple fetch call + const ollamaHeaders = Object.assign({}, headers, { 'Content-Type': 'application/json' }); + + try { + const res = await fetch(`${ollamaUrl}/api/chat`, { + method: 'POST', + headers: ollamaHeaders, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: prompt }], + stream: false + }) + }); + + if (!res.ok) { + return { + correlationId: '', + response: { ok: false, error: `HTTP ${res.status}: ${res.statusText}` } + }; + } + + const data = await res.json(); + return { correlationId: '', response: { ok: true, data } }; + } catch (err) { + return { + correlationId: '', + response: { ok: false, error: err.message } + }; + } + } else if (fetchAction === 'pull') { + // For pull operations + const ollamaHeaders = Object.assign({}, headers, { 'Content-Type': 'application/json' }); + + try { + const res = await fetch(`${ollamaUrl}/api/pull`, { + method: 'POST', + headers: ollamaHeaders, + body: JSON.stringify({ name: model, stream: true }) + }); + + const text = await res.text(); + return { correlationId: '', response: { ok: true, data: text } }; + } catch (err) { + return { correlationId: '', response: { ok: false, error: err.message } }; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// BATCH PROCESSING ENGINE +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Per-provider batch configuration. + * concurrency – max simultaneous in-flight AI requests + * delayMs – minimum milliseconds to wait between launching each request + * + * Note: Gemini free-tier concurrency=1 and delayMs are managed by the existing + * checkAndTrackGeminiRateLimit() helper and preserved here. + */ +const PROVIDER_BATCH_CONFIG = { + gemini: { concurrency: 1, delayMs: 0 }, // delay handled by rate-limit helper + openai: { concurrency: 3, delayMs: 500 }, + anthropic: { concurrency: 2, delayMs: 500 }, + groq: { concurrency: 5, delayMs: 200 }, + mistral: { concurrency: 2, delayMs: 500 }, + ollama: { concurrency: 1, delayMs: 0 }, // local, sequential is fine + 'openai-compatible': { concurrency: 2, delayMs: 500 } +}; + +/** In-memory batch state (reset for each new batch run). */ +let _batchState = { + running: false, + cancelled: false, + paused: false, + total: 0, + completed: 0, + failed: 0, + skipped: 0, + provider: '', + chunkIndex: 0, + totalChunks: 0 +}; + +// ───────────────────────────────────────────────────────────────────────────── +// PERSISTENT PENDING QUEUE +// Replaces in-memory _autoSortPending array with browser.storage.local +// ───────────────────────────────────────────────────────────────────────────── + +const MAX_PENDING_RETRIES = 3; + +/** + * Enqueue a message that failed due to rate limiting. + * @param {Object} message - Thunderbird message object + * @param {string} reason - Error reason for logging + */ +async function enqueuePending(message, reason) { + const data = await browser.storage.local.get(['pendingQueue']); + const queue = data.pendingQueue || []; + queue.push({ + messageId: message.id, + accountId: message.folder?.accountId || '', + timestamp: Date.now(), + retryCount: 0, + lastError: reason || '' + }); + await browser.storage.local.set({ pendingQueue: queue }); + if (window.debugLogger) { + window.debugLogger.warn('[Queue]', `Message ${message.id} enqueued (reason: ${reason})`); + } +} + +/** + * Dequeue all pending messages from storage. Returns array and clears storage. + * @returns {Promise} pending entries + */ +async function dequeuePending() { + const data = await browser.storage.local.get(['pendingQueue']); + const queue = data.pendingQueue || []; + await browser.storage.local.set({ pendingQueue: [] }); + return queue; +} + +/** + * Recover and retry pending messages from storage on extension startup. + * Messages exceeding MAX_PENDING_RETRIES are dropped. + */ +async function recoverPendingQueue() { + const data = await browser.storage.local.get(['pendingQueue']); + const queue = data.pendingQueue || []; + if (queue.length === 0) return; + + if (window.debugLogger) { + window.debugLogger.info('[Queue]', `Recovering ${queue.length} pending messages from storage`); + } + + await browser.storage.local.set({ pendingQueue: [] }); + + const recovered = []; + for (const entry of queue) { + if (entry.retryCount >= MAX_PENDING_RETRIES) { + if (window.debugLogger) { + window.debugLogger.warn('[Queue]', `Message ${entry.messageId} dropped (exceeded ${MAX_PENDING_RETRIES} retries)`); + } + continue; + } + recovered.push(entry); + } + + if (recovered.length > 0 && window.debugLogger) { + window.debugLogger.info('[Queue]', `Retrying ${recovered.length} pending messages`); + } + + for (const entry of recovered) { + const message = { id: entry.messageId, folder: { accountId: entry.accountId } }; + const result = await classifyAndSortMessage(message); + if (result.status === 'pending') { + // Still rate-limited, re-enqueue with incremented retryCount + const data = await browser.storage.local.get(['pendingQueue']); + const q = data.pendingQueue || []; + q.push({ + messageId: entry.messageId, + accountId: entry.accountId, + timestamp: Date.now(), + retryCount: entry.retryCount + 1, + lastError: result.reason || '' + }); + await browser.storage.local.set({ pendingQueue: q }); + } + } +} + +/** Reset batch state to defaults. */ +function _resetBatchState(total, provider) { + _lastBroadcast = null; + _batchState = { + running: true, + cancelled: false, + paused: false, + total, + completed: 0, + failed: 0, + skipped: 0, + provider, + chunkIndex: 0, + totalChunks: 0 + }; +} + +/** Atomically acquire the batch lock. Returns true if acquired, false if already running. */ +function _acquireBatchLock() { + if (_batchState.running) return false; + _batchState.running = true; + return true; +} + +/** Release the batch lock when an early-exit path aborts before batchAnalyzeEmails runs. */ +function _releaseBatchLock() { + _batchState.running = false; +} + +/** Return the next UTC midnight as a millisecond timestamp. Used for daily rate-limit resets. */ +function _nextUtcMidnight() { + const d = new Date(Date.now()); + d.setUTCHours(24, 0, 0, 0); + return d.getTime(); +} + +/** Broadcast current batch progress to any open options pages. */ +let _lastBroadcast = null; +async function _broadcastBatchProgress(status = 'running') { + const payload = { + action: 'batchProgress', + status, + total: _batchState.total, + completed: _batchState.completed, + failed: _batchState.failed, + skipped: _batchState.skipped, + provider: _batchState.provider, + chunkIndex: _batchState.chunkIndex, + totalChunks: _batchState.totalChunks + }; + const stateKey = `${payload.completed}:${payload.failed}:${payload.skipped}:${payload.status}`; + // Skip redundant storage writes if state hasn't changed + if (stateKey !== _lastBroadcast) { + _lastBroadcast = stateKey; + await browser.storage.local.set({ currentBatch: { ...payload, startTime: Date.now() } }); + } + await browser.runtime.sendMessage(payload).catch(() => {}); +} + +/** + * Wait while the batch is paused. Returns true when resumed, false if cancelled + * while waiting. + */ +async function _waitWhilePaused() { + while (_batchState.paused && !_batchState.cancelled) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + return !_batchState.cancelled; +} + +/** + * Core batch engine. Processes an array of Thunderbird message objects using + * the currently configured AI provider with chunk-based processing. + * + * @param {Array} messages – Array of Thunderbird message objects (from mailTabs API) + */ +async function batchAnalyzeEmails(messages) { + const settingsData = await browser.storage.local.get(['aiProvider', 'batchChunkSize']); + const provider = settingsData.aiProvider || 'gemini'; + const chunkSize = settingsData.batchChunkSize || 5; + + _resetBatchState(messages.length, provider); + await _broadcastBatchProgress('running'); + + if (window.debugLogger) { + window.debugLogger.info('[Batch]', `Starting batch: ${messages.length} emails, provider=${provider}, chunkSize=${chunkSize}`); + } + + // Process a single message with exponential-backoff retry on failure + async function executeWithRetry(fn, maxRetries = 3, baseDelay = 2000) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + const isRateLimit = error.message.includes('429') || + error.message.includes('RATE_LIMIT') || + error.message.includes('quota'); + if (!isRateLimit) throw error; // non-429 errors fail fast + + if (attempt === maxRetries) throw error; // exhausted retries + + const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000; + if (window.debugLogger) { + window.debugLogger.warn('[Batch]', `Rate limited. Retry in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries})`); + } + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + async function processOne(message) { + // Respect pause / cancel before starting + if (_batchState.cancelled) return; + if (_batchState.paused) { + const resumed = await _waitWhilePaused(); + if (!resumed) return; + } + + try { + await executeWithRetry(async () => { + const fullMessage = await browser.messages.getFull(message.id); + if (!fullMessage) { + _batchState.skipped++; + return; + } + + const emailContext = await extractEmailContext(fullMessage, message); + const emailContent = emailContext.body; + if (!emailContent || !emailContent.trim()) { + _batchState.skipped++; + return; + } + + const label = await analyzeEmailContent(emailContent, emailContext); + + if (!label || String(label).trim().toLowerCase() === 'null') { + _batchState.skipped++; + return; + } + + await applyLabelsToMessages([message], label); + _batchState.completed++; + }); + } catch (err) { + _batchState.failed++; + if (window.debugLogger) { + window.debugLogger.warn('[Batch]', `Message ${message.id} failed: ${err.message}`); + } + } + } + + // Chunk-based processing: process N emails, await all, continue + const totalChunks = Math.ceil(messages.length / chunkSize); + _batchState.totalChunks = totalChunks; + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + // Check cancellation before starting chunk + if (_batchState.cancelled) break; + + // Wait while paused before starting chunk + while (_batchState.paused && !_batchState.cancelled) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + if (_batchState.cancelled) break; + + // Get current chunk of messages + const chunkStart = chunkIndex * chunkSize; + const chunkEnd = Math.min(chunkStart + chunkSize, messages.length); + const chunkMessages = messages.slice(chunkStart, chunkEnd); + + if (window.debugLogger) { + window.debugLogger.info('[Batch]', `Processing chunk ${chunkIndex + 1}/${totalChunks} (emails ${chunkStart + 1}-${chunkEnd} of ${messages.length})`); + } + + // Launch all chunk tasks concurrently + const chunkPromises = chunkMessages.map(msg => processOne(msg)); + + // Await all responses before continuing to next chunk + await Promise.allSettled(chunkPromises); + + // Update chunk index and broadcast progress after each chunk + _batchState.chunkIndex = chunkIndex + 1; + await _broadcastBatchProgress('running'); + } + + const finalStatus = _batchState.cancelled ? 'cancelled' : 'done'; + _batchState.running = false; + await _broadcastBatchProgress(finalStatus); + + // Clear persisted batch state after a short delay so the UI can show "done" + setTimeout(async () => { + await browser.storage.local.remove('currentBatch').catch(() => {}); + }, 6000); + + if (window.debugLogger) { + window.debugLogger.info('[Batch]', `Batch ${finalStatus}: completed=${_batchState.completed}, failed=${_batchState.failed}, skipped=${_batchState.skipped}`); + } + + // Final summary notification + const { completed, failed, skipped, total } = _batchState; + if (finalStatus === 'cancelled') { + await showNotification('AutoSort+ Batch Cancelled', + `Stopped after ${completed + failed + skipped}/${total} emails. Sorted: ${completed}, failed: ${failed}.`); + } else if (failed === 0 && skipped === 0) { + await showNotification('AutoSort+ Batch Complete', + `Successfully sorted all ${completed} emails.`); + } else { + await showNotification('AutoSort+ Batch Complete', + `Processed ${total} emails β€” sorted: ${completed}, skipped: ${skipped}, failed: ${failed}.`); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Gemini rate limiting (free tier: 5/min, 20/day per key) +// Combined check+track function to avoid redundant storage reads + +// Mutex for atomic rate limit operations +let geminiRateLimitMutex = Promise.resolve(); + +/** Record a Gemini request in rate-limit tracking and persist to storage. */ +async function _trackGeminiRequest(storageObj, rateLimit, waitTime, keyIndex) { + await browser.storage.local.set(storageObj); + if (window.debugLogger) { + const label = keyIndex !== null ? `Key #${keyIndex + 1}` : 'Single'; + window.debugLogger.info('[RateLimit]', `Gemini ${label}: ${rateLimit.dailyCount}/20 today, ${rateLimit.requests.length} in last minute`); + } + return { allowed: true, waitTime, keyIndex }; +} + +async function checkAndTrackGeminiRateLimit(keyIndex = null) { + // Chain onto mutex for atomic operation; .catch() prevents permanent lockup + return geminiRateLimitMutex = geminiRateLimitMutex.then(async () => { + const now = Date.now(); + const data = await browser.storage.local.get([ + 'geminiApiKeys', + 'geminiRateLimits', + 'currentGeminiKeyIndex', + 'geminiPaidPlan', + 'geminiRateLimit' // Legacy single-key + ]); + + // Skip for paid plan + if (data.geminiPaidPlan) { + return { allowed: true, waitTime: 0, keyIndex: keyIndex ?? 0 }; + } + + // Multi-key mode + if (data.geminiApiKeys?.length > 0) { + const keys = data.geminiApiKeys; + const rateLimits = data.geminiRateLimits || keys.map(() => ({ + requests: [], + dailyCount: 0, + dailyResetTime: _nextUtcMidnight() + })); + let currentIndex = keyIndex ?? (data.currentGeminiKeyIndex || 0); + + let attempts = 0; + + while (attempts < keys.length) { + const rl = rateLimits[currentIndex]; + + if (now > rl.dailyResetTime) { + rl.dailyCount = 0; + rl.dailyResetTime = _nextUtcMidnight(); + rl.requests = []; + } + + const oneMinuteAgo = now - 60000; + rl.requests = rl.requests.filter(t => t > oneMinuteAgo); + + if (rl.dailyCount < 20) { + let waitTime = 0; + if (rl.requests.length > 0) { + const lastRequest = Math.max(...rl.requests); + const gap = now - lastRequest; + if (gap < 12000) waitTime = Math.ceil((12000 - gap) / 1000); + } + + rl.requests.push(now); + rl.dailyCount += 1; + return _trackGeminiRequest({ currentGeminiKeyIndex: currentIndex, geminiRateLimits: rateLimits }, rateLimits[currentIndex], waitTime, currentIndex); + } + + currentIndex = (currentIndex + 1) % keys.length; + attempts++; + } + + return { + allowed: false, + message: `All ${keys.length} Gemini API keys have reached their daily limit (20/day each). Please wait for reset or add more API keys in settings.` + }; + } + + // Legacy single-key mode + const utcReset = _nextUtcMidnight(); + const rl = data.geminiRateLimit || { requests: [], dailyCount: 0, dailyResetTime: utcReset }; + + if (now > rl.dailyResetTime) { + rl.dailyCount = 0; + rl.dailyResetTime = utcReset; + rl.requests = []; + } + + if (rl.dailyCount >= 20) { + const hoursUntilReset = Math.ceil((rl.dailyResetTime - now) / (1000 * 60 * 60)); + return { + allowed: false, + message: `Gemini free tier daily limit reached (20/day). Resets in ${hoursUntilReset} hours. Upgrade to paid plan or add multiple API keys in settings to remove limits.` + }; + } + + const oneMinuteAgo = now - 60000; + rl.requests = rl.requests.filter(t => t > oneMinuteAgo); + + let waitTime = 0; + if (rl.requests.length > 0 && (now - Math.max(...rl.requests)) < 12000) { + waitTime = Math.ceil((12000 - (now - Math.max(...rl.requests))) / 1000); + } + + rl.requests.push(now); + rl.dailyCount += 1; + return _trackGeminiRequest({ geminiRateLimit: rl }, rl, waitTime, null); + }).catch(err => { + console.error('[RateLimit] Mutex error, resetting lock:', err.message); + geminiRateLimitMutex = Promise.resolve(); + throw err; + }); +} + +// Deprecated: Use checkAndTrackGeminiRateLimit instead +async function checkGeminiRateLimit() { + console.warn('[Deprecated] checkGeminiRateLimit: Use checkAndTrackGeminiRateLimit instead'); + const result = await checkAndTrackGeminiRateLimit(); + // Note: This deprecated wrapper already tracked the request, so callers + // using this will need to NOT call trackGeminiRequest separately + return result; +} + +// Deprecated: No longer needed - tracking is done in checkAndTrackGeminiRateLimit +async function trackGeminiRequest(keyIndex) { + console.warn('[Deprecated] trackGeminiRequest: No longer needed - tracking is done in checkAndTrackGeminiRateLimit'); +} + // Function to show notification async function showNotification(title, message, type = "basic") { // Log to console (Thunderbird doesn't support browser.notifications) - console.log(`[AutoSort+] ${title}: ${message}`); - + if (window.debugLogger) { + window.debugLogger.info('[AutoSort+]', `${title}: ${message}`); + } + // Try to show notification if API is available try { if (browser.notifications && browser.notifications.create) { @@ -39,8 +924,10 @@ async function showNotification(title, message, type = "basic") { // Function to update existing notification async function updateNotification(id, title, message) { // Log to console - console.log(`[AutoSort+] ${title}: ${message}`); - + if (window.debugLogger) { + window.debugLogger.info('[AutoSort+]', `${title}: ${message}`); + } + // Try to update notification if API is available try { if (browser.notifications && browser.notifications.clear && id) { @@ -53,23 +940,80 @@ async function updateNotification(id, title, message) { } // Function to analyze email content using AI -async function analyzeEmailContent(emailContent) { +async function analyzeEmailContent(emailContent, emailContext = null) { try { const notificationId = await showNotification( "AutoSort+ AI Analysis", "Starting email analysis..." ); - const settings = await browser.storage.local.get(['apiKey', 'aiProvider', 'labels', 'enableAi']); + const settings = await browser.storage.local.get([ + 'apiKey', + 'geminiApiKeys', + 'currentGeminiKeyIndex', + 'aiProvider', + 'labels', + 'enableAi', + 'geminiPaidPlan', + 'geminiRateLimit', + 'geminiRateLimits', + 'ollamaUrl', + 'ollamaModel', + 'ollamaCustomModel', + 'ollamaAuthToken', + 'ollamaCpuOnly', + 'ollamaNumCtx', + 'customBaseUrl', + 'customModel', + 'customPrompt' + ]); const provider = settings.aiProvider || 'gemini'; - console.log("Settings retrieved:", { - hasApiKey: !!settings.apiKey, - provider: provider, - labels: settings.labels, - enableAi: settings.enableAi !== false - }); - + // Check and track Gemini rate limits (free tier only) - single storage read + let keyIndexToUse = null; + if (provider === 'gemini' && !settings.geminiPaidPlan) { + const rateLimit = await checkAndTrackGeminiRateLimit(); + if (!rateLimit.allowed) { + // Show persistent notification for limit reached + const isSingleKey = !settings.geminiApiKeys || settings.geminiApiKeys.length <= 1; + const notifTitle = isSingleKey ? "β›” Gemini API Limit Reached" : "β›” All Gemini Keys at Limit"; + + const notifId = await showNotification( + notifTitle, + rateLimit.message, + "list" + ); + + // Also try to update the current notification + await updateNotification( + notificationId, + "AutoSort+ Rate Limit", + rateLimit.message + ); + throw new Error(rateLimit.message); + } + + if (rateLimit.waitTime > 0) { + await updateNotification( + notificationId, + "AutoSort+ Rate Limit", + `Rate limit reached. Waiting ${rateLimit.waitTime} seconds...` + ); + await new Promise(resolve => setTimeout(resolve, rateLimit.waitTime * 1000)); + } + + keyIndexToUse = rateLimit.keyIndex; + } + + if (window.debugLogger) { + window.debugLogger.info('[AutoSort+]', 'Settings retrieved', { + hasApiKey: !!(settings.apiKey || (settings.geminiApiKeys && settings.geminiApiKeys.length > 0)), + provider: provider, + labels: settings.labels, + enableAi: settings.enableAi !== false + }); + } + if (settings.enableAi === false) { console.error("AI is disabled"); await updateNotification( @@ -80,7 +1024,25 @@ async function analyzeEmailContent(emailContent) { return null; } - if (!settings.apiKey) { + // Check API key availability based on provider + let apiKeyToUse = null; + if (provider === 'gemini') { + if (settings.geminiApiKeys && settings.geminiApiKeys.length > 0) { + const keyIndex = keyIndexToUse !== null ? keyIndexToUse : (settings.currentGeminiKeyIndex || 0); + apiKeyToUse = settings.geminiApiKeys[keyIndex]; + if (window.debugLogger) { + window.debugLogger.info('[Gemini]', `Using API Key #${keyIndex + 1} of ${settings.geminiApiKeys.length}`); + } + } else if (settings.apiKey) { + // Legacy single key + apiKeyToUse = settings.apiKey; + } + } else if (provider !== 'ollama' && provider !== 'openai-compatible') { + // Ollama and OpenAI-compatible don't need API key; other providers do + apiKeyToUse = settings.apiKey; + } + + if (!apiKeyToUse && provider !== 'ollama' && provider !== 'openai-compatible') { console.error("Missing API key"); await updateNotification( notificationId, @@ -89,6 +1051,21 @@ async function analyzeEmailContent(emailContent) { ); return null; } + + // Validate OpenAI-compatible endpoint has baseUrl and model + if (provider === 'openai-compatible') { + const baseUrl = settings.customBaseUrl || ''; + const model = settings.customModel || ''; + if (!baseUrl || !model) { + console.error("OpenAI-compatible endpoint not configured"); + await updateNotification( + notificationId, + "AutoSort+ Error", + "OpenAI-compatible endpoint not configured. Please set base URL and model in settings." + ); + return null; + } + } if (!settings.labels || settings.labels.length === 0) { console.error("No labels configured"); @@ -100,17 +1077,58 @@ async function analyzeEmailContent(emailContent) { return null; } - const prompt = `You are an email classification assistant. Analyze this email content and choose the most appropriate label from this list: ${settings.labels.join(', ')}. - Consider the following: - 1. The main topic and purpose of the email - 2. The sender and recipient context - 3. The urgency and importance of the content - 4. The type of communication (e.g., notification, request, update) - - Only respond with the exact label name that best fits the content. If no label fits well, respond with "null". - - Email content: - ${emailContent}`; + // Select prompt template (custom or default) + const promptTemplate = (settings.customPrompt && settings.customPrompt.trim()) + ? settings.customPrompt.trim() + : DEFAULT_PROMPT; + + // Inject placeholders + let prompt = promptTemplate; + const labelsStr = settings.labels.join(', '); + + // Build context values for placeholders + const subject = emailContext?.subject || ''; + const author = emailContext?.author || ''; + const attachmentsStr = emailContext?.attachments?.length > 0 + ? emailContext.attachments.map(a => a.name).join(', ') + : '(none)'; + const body = emailContent; // body is the main email text + + // Helper to inject placeholder with fallback injection if missing + function injectPlaceholder(placeholder, value, fallbackPrefix, fallbackPosition = 'start') { + if (!prompt.includes(placeholder)) { + if (window.debugLogger) { + window.debugLogger.warn('[AutoSort]', `Custom prompt missing ${placeholder} placeholder - injecting`); + } + if (fallbackPosition === 'start') { + prompt = `${fallbackPrefix}${value}\n\n${prompt}`; + } else { + prompt = `${prompt}\n\n${fallbackPrefix}${value}`; + } + } else { + prompt = prompt.replace(placeholder, value); + } + } + + // Inject all placeholders (order matters for fallback injection) + injectPlaceholder('{labels}', labelsStr, 'Labels: ', 'start'); + injectPlaceholder('{subject}', subject, 'Subject: ', 'start'); + injectPlaceholder('{author}', author, 'From: ', 'start'); + injectPlaceholder('{attachments}', attachmentsStr, 'Attachments: ', 'start'); + + // Handle {body} and legacy {email} placeholders + if (prompt.includes('{body}')) { + prompt = prompt.replace('{body}', body); + } else if (prompt.includes('{email}')) { + // Legacy placeholder support + prompt = prompt.replace('{email}', body); + } else { + // Default: append body at end if no body/email placeholder found + if (window.debugLogger) { + window.debugLogger.warn('[AutoSort]', 'Custom prompt missing {body} placeholder - appending'); + } + prompt = `${prompt}\n\nEmail content:\n${body}`; + } await updateNotification( notificationId, @@ -122,9 +1140,10 @@ async function analyzeEmailContent(emailContent) { let data; if (provider === 'gemini') { - const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${settings.apiKey}`; - console.log("Making API request to Gemini..."); - + const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKeyToUse}`; + + // Rate limiting already tracked in checkAndTrackGeminiRateLimit above + await updateNotification( notificationId, "AutoSort+ AI Analysis", @@ -139,9 +1158,9 @@ async function analyzeEmailContent(emailContent) { }] }], generationConfig: { - temperature: 0.2, - topK: 1, - topP: 1, + temperature: 0.6, + topK: 20, + topP: 0.95, maxOutputTokens: 50, responseMimeType: "text/plain", thinkingConfig: { @@ -168,6 +1187,11 @@ async function analyzeEmailContent(emailContent) { ] }; + if (window.debugLogger) { + const sanitizedUrl = apiUrl.replace(/key=[^&]+/, 'key=***REDACTED***'); + window.debugLogger.apiRequest('Gemini', sanitizedUrl, requestBody); + } + response = await fetch(apiUrl, { method: 'POST', headers: { @@ -177,180 +1201,382 @@ async function analyzeEmailContent(emailContent) { }); } else if (provider === 'openai') { - console.log("Making API request to OpenAI..."); - await updateNotification( notificationId, "AutoSort+ AI Analysis", "Analyzing email content with OpenAI..." ); + const requestBody = { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: prompt }], + max_tokens: 50, + temperature: 0.6, + top_p: 0.95 + }; + + if (window.debugLogger) { + window.debugLogger.apiRequest('OpenAI', 'https://api.openai.com/v1/chat/completions', requestBody); + } + response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${settings.apiKey}` + 'Authorization': `Bearer ${apiKeyToUse}` }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages: [{ role: 'user', content: prompt }], - max_tokens: 50, - temperature: 0.2 - }) + body: JSON.stringify(requestBody) }); } else if (provider === 'anthropic') { - console.log("Making API request to Anthropic..."); - await updateNotification( notificationId, "AutoSort+ AI Analysis", "Analyzing email content with Claude..." ); + const requestBody = { + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: prompt }], + max_tokens: 50 + }; + + if (window.debugLogger) { + window.debugLogger.apiRequest('Claude', 'https://api.anthropic.com/v1/messages', requestBody); + } + response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-api-key': settings.apiKey, + 'x-api-key': apiKeyToUse, 'anthropic-version': '2023-06-01' }, - body: JSON.stringify({ - model: 'claude-3-haiku-20240307', - messages: [{ role: 'user', content: prompt }], - max_tokens: 50 - }) + body: JSON.stringify(requestBody) }); } else if (provider === 'groq') { - console.log("Making API request to Groq..."); - await updateNotification( notificationId, "AutoSort+ AI Analysis", "Analyzing email content with Groq..." ); - response = await fetch('https://api.groq.com/openai/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${settings.apiKey}` - }, - body: JSON.stringify({ - model: 'llama-3.3-70b-versatile', - messages: [{ role: 'user', content: prompt }], - max_tokens: 50, - temperature: 0.2 - }) - }); + const requestBody = { + model: 'llama-3.3-70b-versatile', + messages: [{ role: 'user', content: prompt }], + max_tokens: 50, + temperature: 0.6, + top_p: 0.95 + }; + + if (window.debugLogger) { + window.debugLogger.apiRequest('Groq', 'https://api.groq.com/openai/v1/chat/completions', requestBody); + } + + response = await fetch('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKeyToUse}` + }, + body: JSON.stringify(requestBody) + }); + + } else if (provider === 'mistral') { + await updateNotification( + notificationId, + "AutoSort+ AI Analysis", + "Analyzing email content with Mistral..." + ); + + const requestBody = { + model: 'mistral-small-latest', + messages: [{ role: 'user', content: prompt }], + max_tokens: 50, + temperature: 0.6, + top_p: 0.95 + }; + + if (window.debugLogger) { + window.debugLogger.apiRequest('Mistral', 'https://api.mistral.ai/v1/chat/completions', requestBody); + } + + response = await fetch('https://api.mistral.ai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKeyToUse}` + }, + body: JSON.stringify(requestBody) + }); + + } else if (provider === 'ollama') { + await updateNotification( + notificationId, + "AutoSort+ AI Analysis", + "Analyzing email content with local Ollama..." + ); + + // Get Ollama settings + const ollamaSettings = await browser.storage.local.get(['ollamaUrl', 'ollamaModel', 'ollamaCustomModel', 'ollamaCpuOnly', 'ollamaAuthToken', 'ollamaNumCtx']); + const ollamaUrl = ollamaSettings.ollamaUrl || 'http://localhost:11434'; + let ollamaModel = ollamaSettings.ollamaModel || 'llama3.2'; + const ollamaNumCtx = ollamaSettings.ollamaNumCtx || 0; + const cpuOnly = ollamaSettings.ollamaCpuOnly === true; + const ollamaAuthToken = ollamaSettings.ollamaAuthToken || ''; + + // Use custom model if selected + if (ollamaModel === 'custom' && ollamaSettings.ollamaCustomModel) { + ollamaModel = ollamaSettings.ollamaCustomModel; + } + + const requestBody = { + model: ollamaModel, + messages: [{ role: 'user', content: prompt }], + stream: false + }; + + if (window.debugLogger) { + window.debugLogger.apiRequest('Ollama', `${ollamaUrl}/api/chat`, requestBody); + } + + // Use tab injection to make the fetch (browser context, no restrictions) + try { + const ollamaResponse = await ollamaChatViaTab(ollamaUrl, ollamaModel, prompt, ollamaAuthToken, ollamaNumCtx); + + if (!ollamaResponse.message || !ollamaResponse.message.content) { + throw new Error('Invalid Ollama response format'); + } + + data = ollamaResponse; + response = null; // Mark as handled + + } catch (ollamaError) { + console.error('[Ollama] Tab injection chat failed:', ollamaError.message); + throw ollamaError; + } + + } else if (provider === 'openai-compatible') { + // Get custom endpoint settings + const customSettings = await browser.storage.local.get(['customBaseUrl', 'customModel', 'apiKey']); + const baseUrl = (customSettings.customBaseUrl || '').replace(/\/$/, ''); + const model = customSettings.customModel || ''; + const apiKey = customSettings.apiKey || ''; + + if (!baseUrl || !model) { + throw new Error('OpenAI-compatible endpoint not configured. Please set base URL and model in settings.'); + } - } else if (provider === 'mistral') { - console.log("Making API request to Mistral..."); - await updateNotification( notificationId, "AutoSort+ AI Analysis", - "Analyzing email content with Mistral..." + `Analyzing email content with ${model}...` ); - response = await fetch('https://api.mistral.ai/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${settings.apiKey}` - }, - body: JSON.stringify({ - model: 'mistral-small-latest', - messages: [{ role: 'user', content: prompt }], - max_tokens: 50, - temperature: 0.2 - }) - }); + const requestBody = { + model, + messages: [{ role: 'user', content: prompt }], + max_tokens: 8192, + temperature: 0.6, + top_p: 0.95 + }; + + // Build headers + const headers = { + 'Content-Type': 'application/json' + }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + // Check if this is a localhost endpoint - Thunderbird background scripts can't directly fetch localhost + const isLocalhost = baseUrl.startsWith('http://localhost') || baseUrl.startsWith('http://127.0.0.1'); + + if (window.debugLogger) { + window.debugLogger.apiRequest('OpenAI-Compatible', `${baseUrl}/chat/completions`, requestBody); + } + + if (isLocalhost) { + // Use tab injection for localhost (similar to Ollama handling) + try { + const customResponse = await openaiCompatibleChatViaTab(baseUrl, model, prompt, apiKey); + + if (!customResponse.choices || customResponse.choices.length === 0 || !customResponse.choices[0].message) { + throw new Error('Invalid OpenAI-compatible response format'); + } + + data = customResponse; + response = null; // Mark as handled + + } catch (customError) { + console.error('[OpenAI-Compatible] Tab injection failed:', customError.message); + throw customError; + } + } else { + // Direct fetch for non-localhost endpoints + response = await fetch(baseUrl + '/chat/completions', { + method: 'POST', + headers, + body: JSON.stringify(requestBody) + }); + } } else { throw new Error(`Unknown provider: ${provider}`); } - console.log("API response status:", response.status); + if (response) { + if (!response.ok) { + let errorMessage = `HTTP ${response.status}: ${response.statusText}`; + + // Try to parse error response body + try { + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const error = await response.json(); + errorMessage = error.error?.message || error.message || errorMessage; + } else { + const text = await response.text(); + if (text) errorMessage = text.substring(0, 200); + } + } catch (parseErr) { + console.warn('Could not parse error response:', parseErr.message); + } + + console.error("API Error details:", errorMessage); + + // Handle quota errors specifically + if (response.status === 429 || errorMessage.includes('quota') || errorMessage.includes('rate limit')) { + errorMessage = "API quota exceeded. Please wait a while before trying again, or upgrade to a paid API key."; + } + + // Handle Ollama auth errors + if (response.status === 403) { + errorMessage = "Ollama authentication failed (403). Check your API key/token if Ollama requires authentication."; + } + + await updateNotification( + notificationId, + "AutoSort+ Error", + `API Error: ${errorMessage}` + ); + return null; + } + + await updateNotification( + notificationId, + "AutoSort+ AI Analysis", + "Processing AI response..." + ); - if (!response.ok) { - const error = await response.json(); - console.error("API Error details:", error); - let errorMessage = error.error?.message || error.message || 'Unknown error'; - - // Handle quota errors specifically - if (response.status === 429 || errorMessage.includes('quota') || errorMessage.includes('rate limit')) { - errorMessage = "API quota exceeded. Please wait a while before trying again, or upgrade to a paid API key."; + data = await response.json(); + if (window.debugLogger) { + window.debugLogger.apiResponse(provider, response.status, data); + } + } else if (data) { + await updateNotification( + notificationId, + "AutoSort+ AI Analysis", + "Processing AI response..." + ); + if (window.debugLogger) { + window.debugLogger.apiResponse(provider, 200, data); } - + } else { await updateNotification( notificationId, "AutoSort+ Error", - `API Error: ${errorMessage}` + "No response received from provider." ); return null; } - - await updateNotification( - notificationId, - "AutoSort+ AI Analysis", - "Processing AI response..." - ); - - data = await response.json(); - console.log("Full API response data:", JSON.stringify(data, null, 2)); // Parse the response based on provider let label = null; - - if (provider === 'gemini') { - if (data.candidates && data.candidates.length > 0) { - const candidate = data.candidates[0]; - if (candidate.finishReason === "MAX_TOKENS") { - console.error("Response truncated"); - await updateNotification(notificationId, "AutoSort+ Error", "AI response was cut off"); - return null; - } - if (candidate.content && candidate.content.parts && candidate.content.parts.length > 0) { - label = candidate.content.parts[0].text.trim(); - } + + const tryTrim = v => { try { return (v || '').toString().trim() || null; } catch (e) { return null; } }; + + // Gemini: check for MAX_TOKENS truncation + if (provider === 'gemini' && data.candidates?.[0]?.finishReason === "MAX_TOKENS") { + console.error("Response truncated"); + await updateNotification(notificationId, "AutoSort+ Error", "AI response was cut off"); + return null; + } + + // Use provider parser mapping (ollama handled separately) + const parser = PROVIDER_PARSERS[provider]; + if (parser) { + label = tryTrim(parser(data)); + // Some OpenAI-compatible models return reasoning in separate field + if (!label && (provider === 'openai' || provider === 'groq' || provider === 'mistral' || provider === 'openai-compatible')) { + label = tryTrim(data.choices?.[0]?.message?.reasoning_content); } - } else if (provider === 'openai' || provider === 'groq' || provider === 'mistral') { - if (data.choices && data.choices.length > 0) { - label = data.choices[0].message.content.trim(); + if (window.debugLogger) { + window.debugLogger.info('[API]', 'Choice structure:', data.choices?.[0]); } - } else if (provider === 'anthropic') { - if (data.content && data.content.length > 0) { - label = data.content[0].text.trim(); + } else if (provider === 'ollama') { + // Recursively extract text from arbitrary Ollama response structures + function extractText(obj) { + if (obj == null) return null; + if (typeof obj === 'string') return obj.trim() || null; + if (Array.isArray(obj)) { + for (const item of obj) { const found = extractText(item); if (found) return found; } + return null; + } + if (typeof obj !== 'object') return null; + return extractText(obj.text) || extractText(obj.content) || extractText(obj.response) || extractText(obj.result) || extractText(obj.parts); } + label = extractText(data.message) || extractText(data); } - + if (!label) { console.error("No label extracted from response:", data); await updateNotification(notificationId, "AutoSort+ Error", "No response from AI"); return null; } - - console.log("Generated label:", label); - - // Verify the label exists in our list + + if (window.debugLogger) { + window.debugLogger.info('[AutoSort+]', `Raw generated label: ${label}`); + } + + // Normalize and try to match configured labels more forgivingly + const normalize = s => s.toString().trim().replace(/^['"`]+|['"`]+$/g, ''); + const lower = normalize(label).toLowerCase(); + + // Exact match first if (settings.labels.includes(label)) { - await updateNotification( - notificationId, - "AutoSort+ Success", - `AI analysis complete. Selected label: ${label}` - ); + await updateNotification(notificationId, "AutoSort+ Success", `AI analysis complete. Selected label: ${label}`); return label; - } else { - console.log("Label not found in configured labels. Generated:", label); - await updateNotification( - notificationId, - "AutoSort+ Warning", - `AI suggested: "${label}" but it's not in your configured labels.` - ); - return null; } + + // Try to find a label that matches case-insensitively or is contained within the AI output + let matched = settings.labels.find(l => l.toLowerCase() === lower); + if (!matched) { + matched = settings.labels.find(l => lower.includes(l.toLowerCase()) || l.toLowerCase().includes(lower)); + } + + // Fallback: fuzzy match using Levenshtein distance + if (!matched) { + matched = findBestFuzzyMatch(label, settings.labels); + if (matched && window.debugLogger) { + window.debugLogger.info('[AutoSort+]', `Fuzzy matched "${label}" to "${matched}" via Levenshtein`); + } + } + + if (matched) { + if (window.debugLogger) { + window.debugLogger.info('[AutoSort+]', `Mapped AI output to configured label: ${matched}`); + } + await updateNotification(notificationId, "AutoSort+ Success", `AI analysis complete. Selected label: ${matched}`); + return matched; + } + + if (window.debugLogger) { + window.debugLogger.warn('[AutoSort+]', `Label not found in configured labels. Generated: ${label}`); + } + await updateNotification(notificationId, "AutoSort+ Warning", `AI suggested: "${label}" but it's not in your configured labels.`); + return null; } catch (error) { console.error("Error analyzing email:", error); await showNotification( @@ -368,7 +1594,9 @@ async function storeMoveHistory(result) { const history = data.moveHistory || []; history.unshift({ timestamp: new Date().toISOString(), - ...result + subject: (result.subject || '').substring(0, 200), // truncate to 200 chars + status: result.status || 'unknown', + destination: (result.destination || '').substring(0, 200) }); // Keep only the last 100 entries if (history.length > 100) { @@ -388,18 +1616,60 @@ async function applyLabelsToMessages(messages, label) { "AutoSort+ Processing", `Starting to process ${messageCount} message(s)...` ); - + let successCount = 0; let errorCount = 0; const moveResults = []; + // Build folder lookup Map once to avoid N+1 pattern + // Key format: "accountId:folderName" to handle multiple accounts with same folder names + const folderCache = new Map(); + + // Cache accounts to avoid N+1 pattern + const accountCache = new Map(); + + async function getAccount(accountId) { + if (!accountCache.has(accountId)) { + const account = await browser.accounts.get(accountId); + accountCache.set(accountId, account); + } + return accountCache.get(accountId); + } + + function buildFolderMap(folders, prefix = '', accountId) { + if (!folders) return; + for (const folder of folders) { + const fullName = prefix ? `${prefix}/${folder.name}` : folder.name; + folderCache.set(`${accountId}:${fullName}`, folder); + folderCache.set(`${accountId}:${folder.name}`, folder); // Also cache by short name + if (folder.subFolders) { + buildFolderMap(folder.subFolders, fullName, accountId); + } + } + } + + // Pre-build folder cache for all accounts involved + const uniqueAccountIds = [...new Set( + messages.map(m => m.folder?.accountId).filter(id => id) + )]; + for (const accountId of uniqueAccountIds) { + const account = await getAccount(accountId); + buildFolderMap(account.folders, '', accountId); + } + + if (window.debugLogger) { + window.debugLogger.info('[Folder]', `Built folder cache: ${folderCache.size} entries`); + } + for (const message of messages) { - console.log("Processing message:", message.id); - console.log("Target label/folder:", label); - - // Get all folders to find the destination folder - const account = await browser.accounts.get(message.folder.accountId); - console.log("Account info:", account); + if (window.debugLogger) { + window.debugLogger.info('[Folder]', `Processing message: ${message.id}`); + } + if (window.debugLogger) { + window.debugLogger.info('[Folder]', `Target label/folder: ${label}`); + } + + const account = await getAccount(message.folder.accountId); await updateNotification( notificationId, @@ -407,61 +1677,46 @@ async function applyLabelsToMessages(messages, label) { `Finding destination folder for message ${successCount + errorCount + 1}/${messageCount}...` ); - // Find the folder with matching name - const findFolder = (folders, targetName) => { - for (const folder of folders) { - console.log("Checking folder:", folder.name); - if (folder.name === targetName) { - return folder; - } - if (folder.subFolders) { - const found = findFolder(folder.subFolders, targetName); - if (found) return found; - } - } - return null; - }; + // Use cached folder lookup instead of recursive search + let targetFolder = folderCache.get(`${message.folder.accountId}:${label}`); - // First try to find the category folder - const categories = [ - "FinanciΓ«n", - "Werk en CarriΓ¨re", - "Persoonlijke Communicatie en Sociale Leven", - "Gezondheid en Welzijn", - "Online Activiteiten en E-commerce", - "Reizen en Evenementen", - "Informatie en Media", - "Beveiliging en IT", - "Klantensupport en Acties", - "Overheid en Gemeenschap" - ]; - - let categoryFolder = null; - let targetFolder = null; - - // Find the category and target folder - for (const category of categories) { - if (label.startsWith(category)) { - console.log("Found matching category:", category); - categoryFolder = findFolder(account.folders, category); - if (categoryFolder) { - console.log("Found category folder:", categoryFolder.name); - // Try to find the subfolder - const subfolderName = label.replace(category + "/", ""); - console.log("Looking for subfolder:", subfolderName); - targetFolder = findFolder(categoryFolder.subFolders || [], subfolderName); - break; - } - } + // Handle subfolder paths - full path already cached above + if (!targetFolder && label.includes('/')) { + targetFolder = folderCache.get(`${message.folder.accountId}:${label}`); } - // If no target folder found, try direct match + // Auto-create missing folder when it's a custom label (skip imported/structured labels) if (!targetFolder) { - console.log("No category match found, trying direct folder match"); - targetFolder = findFolder(account.folders, label); + const looksImported = label.includes('/') || label.includes('\\'); + if (looksImported) { + if (window.debugLogger) { + window.debugLogger.warn('[Folder]', `Folder "${label}" looks imported/structured; skipping auto-create`); + } + } else { + try { + const parentFolder = account.folders && account.folders.length > 0 ? account.folders[0] : null; + if (parentFolder && browser.folders && browser.folders.create) { + if (window.debugLogger) { + window.debugLogger.info('[Folder]', `Creating missing folder "${label}" under ${parentFolder.name || 'root'}`); + } + const created = await browser.folders.create(parentFolder, label); + if (created) { + targetFolder = created; + folderCache.set(`${message.folder.accountId}:${label}`, created); + if (window.debugLogger) { + window.debugLogger.info('[Folder]', `Created folder: ${created.name}`); + } + } + } + } catch (createError) { + console.error(`Failed to create folder "${label}":`, createError); + } + } } - console.log("Moving message to folder:", targetFolder ? targetFolder.name : "not found"); + if (window.debugLogger) { + window.debugLogger.info('[Folder]', `Moving message to folder: ${targetFolder ? targetFolder.name : 'not found'}`); + } try { if (!targetFolder) { @@ -576,7 +1831,9 @@ async function showMoveResultsPopup(results) { ); // Also log to console for debugging - console.log("[AutoSort+] Results:", message); + if (window.debugLogger) { + window.debugLogger.info('[AutoSort+]', 'Results popup displayed'); + } } catch (error) { console.error("Error showing results:", error); await showNotification( @@ -586,142 +1843,380 @@ async function showMoveResultsPopup(results) { } } -// Create context menu items -browser.menus.create({ - id: "autosort-label", - title: "AutoSort+ Label", - contexts: ["message_list"] -}); +// ───────────────────────────────────────────────────────────────────────────── +// AUTO-SORT: Handle new emails arriving in Inbox +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Concurrency-limited parallel processor. + * Processes items concurrently with a maximum number of simultaneous operations. + * + * @param {Array} items - Array of items to process + * @param {Function} processor - Async function to process each item + * @param {number} limit - Maximum concurrent operations (default: 3) + * @returns {Promise} - Promise.allSettled results + */ +async function processWithConcurrency(items, processor, limit = 3) { + const results = []; + const executing = new Set(); + + for (const item of items) { + const promise = processor(item).finally(() => { + executing.delete(promise); + }); + executing.add(promise); + results.push(promise); + + if (executing.size >= limit) { + await Promise.race(executing); + } + } + + return Promise.allSettled(results); +} + +/** Check if an error is a rate limit / quota error. */ +function isRateLimitError(err) { + if (!err) return false; + const msg = String(err.message || ''); + return msg.includes('429') || msg.includes('quota') || msg.includes('RATE_LIMIT') || msg.includes('rate limit'); +} + +/** Core classification logic for a single message (no retry, no rate-limit handling). */ +async function classifyAndMoveOnce(message) { + const fullMessage = await browser.messages.getFull(message.id); + if (!fullMessage) return { status: 'failed', reason: 'no_full_message' }; + + const emailContext = await extractEmailContext(fullMessage, message); + const emailContent = emailContext.body; + if (!emailContent?.trim()) return { status: 'failed', reason: 'empty_body' }; + + const label = await analyzeEmailContent(emailContent, emailContext); + if (!label || String(label).trim().toLowerCase() === 'null') return { status: 'failed', reason: 'no_label' }; + + await applyLabelsToMessages([message], label); + + if (window.debugLogger) { + window.debugLogger.info('[AutoSort]', `Auto-sorted message ${message.id} to ${label}`); + } + return { status: 'success' }; +} + +/** + * Classify a single message and move it to the appropriate folder. + * Retries up to 2 times for non-rate-limit errors with exponential backoff (2s β†’ 4s). + * Rate limit errors queue the message for later retry. + * Returns { status: 'success' | 'failed' | 'pending', reason? } + */ +async function classifyAndMove(message) { + try { + let lastError = null; + + // Try up to 3 times total (1 original + 2 retries) + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await classifyAndMoveOnce(message); + } catch (err) { + lastError = err; + + // Rate limit: don't retry, queue instead + if (isRateLimitError(err)) { + await enqueuePending(message, 'rate_limited'); + if (window.debugLogger) { + window.debugLogger.warn('[AutoSort]', `Rate limited: message ${message.id} queued for retry`); + } + return { status: 'pending', reason: 'rate_limited' }; + } + + // Non-rate-limit error: retry with exponential backoff + if (attempt < 2) { + const delay = 2000 * Math.pow(2, attempt); // 2s, 4s + if (window.debugLogger) { + window.debugLogger.warn('[AutoSort]', `Attempt ${attempt + 1} failed for message ${message.id}, retrying in ${delay}ms: ${err.message}`); + } + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + // All retries exhausted + if (window.debugLogger) { + window.debugLogger.warn('[AutoSort]', `Message ${message.id} failed after 3 attempts: ${lastError.message}`); + } + return { status: 'failed', reason: lastError.message }; + + } catch (err) { + // Unexpected errors (not from classifyAndMoveOnce) + if (isRateLimitError(err)) { + await enqueuePending(message, 'rate_limited'); + return { status: 'pending', reason: 'rate_limited' }; + } + if (window.debugLogger) { + window.debugLogger.warn('[AutoSort]', `Unexpected error for message ${message.id}: ${err.message}`); + } + return { status: 'failed', reason: err.message }; + } +} + +/** + * Handle new mail received event. Processes messages in Inbox folder. + * Supports MessageList pagination via continueList. + */ +async function handleNewMail(folder, messageList) { + // Guard: don't auto-sort if a manual batch is already running + if (_batchState.running) return; + + const settings = await browser.storage.local.get(['autoSortEnabled', 'enableAi', 'aiProvider', 'autoSortNotifyOnComplete']); + + const autoSortEnabled = settings.autoSortEnabled !== false; + if (!autoSortEnabled) return; + if (settings.enableAi === false) return; + + if (!folder.specialUse?.includes("inbox")) return; + + const provider = settings.aiProvider || 'gemini'; + const limit = PROVIDER_BATCH_CONFIG[provider]?.concurrency || 3; + + // Statistics counters + let stats = { success: 0, failed: 0, pending: 0, total: 0 }; + + // Wrapper that updates stats for each message + async function classifyAndTrack(message) { + stats.total++; + const result = await classifyAndMove(message); + if (result.status === 'success') stats.success++; + else if (result.status === 'pending') stats.pending++; + else stats.failed++; + } + + if (window.debugLogger) { + window.debugLogger.info('[AutoSort]', `Processing new mail with concurrency=${limit} for provider=${provider}`); + } + + // Process all pages of messages + let page = messageList; + while (true) { + await processWithConcurrency(page.messages, classifyAndTrack, limit); + if (!page.id) break; + page = await browser.messages.continueList(page.id); + } + + // Process pending queue from storage + const pending = await dequeuePending(); + if (pending.length > 0) { + if (window.debugLogger) { + window.debugLogger.info('[AutoSort]', `Retrying ${pending.length} pending messages`); + } + for (const entry of pending) { + if (entry.retryCount >= MAX_PENDING_RETRIES) { + stats.failed++; + continue; + } + const message = { id: entry.messageId, folder: { accountId: entry.accountId } }; + const result = await classifyAndMove(message); + if (result.status === 'success') { + stats.success++; + } else if (result.status === 'pending') { + // Re-enqueue with incremented retryCount + const data = await browser.storage.local.get(['pendingQueue']); + const q = data.pendingQueue || []; + q.push({ + messageId: entry.messageId, + accountId: entry.accountId, + timestamp: Date.now(), + retryCount: entry.retryCount + 1, + lastError: result.reason || '' + }); + await browser.storage.local.set({ pendingQueue: q }); + stats.pending++; + } else { + stats.failed++; + } + } + } + + // Optional notification on completion + if (settings.autoSortNotifyOnComplete && stats.total > 0) { + const parts = [`Auto-sorted: ${stats.success} successful`]; + if (stats.failed > 0) parts.push(`${stats.failed} failed`); + if (stats.pending > 0) parts.push(`${stats.pending} pending (rate limited)`); + await showNotification('AutoSort+ Auto-Classification', parts.join(', ')); + } + + if (window.debugLogger) { + window.debugLogger.info('[AutoSort]', `Auto-sort complete: ${stats.success} success, ${stats.failed} failed, ${stats.pending} pending out of ${stats.total}`); + } +} + +/** + * Register the auto-sort listener for new emails at startup. + */ +function registerAutoSortListener() { + browser.messages.onNewMailReceived.addListener(handleNewMail, false); +} + +/** Build the full context menu with dynamic labels. */ +async function buildContextMenu() { + // Remove only our own menu items to avoid affecting other extensions + try { + const existingItems = await browser.menus.getAll(); + for (const item of existingItems) { + if (item.id && (item.id.startsWith('autosort-') || item.id.startsWith('label-'))) { + browser.menus.remove(item.id); + } + } + } catch (e) { + console.warn('[Menu] Failed to remove existing items:', e.message); + } + + browser.menus.create({ + id: "autosort-parent", + title: "AutoSort+", + contexts: ["message_list"] + }); -// Add submenu items for labels -browser.storage.local.get(['labels']).then(result => { - if (result.labels) { - result.labels.forEach(label => { + browser.menus.create({ + id: "autosort-analyze", + parentId: "autosort-parent", + title: "AutoSort+ Analyze with AI", + contexts: ["message_list"] + }); + + const { labels } = await browser.storage.local.get(['labels']); + if (labels && labels.length > 0) { + browser.menus.create({ + id: "autosort-label-separator", + parentId: "autosort-parent", + type: "separator", + contexts: ["message_list"] + }); + for (const label of labels) { browser.menus.create({ id: `label-${label}`, - parentId: "autosort-label", + parentId: "autosort-parent", title: label, contexts: ["message_list"] }); - }); + } + } +} + +/** Rebuild the menu when labels change β€” removes old items, then rebuilds from shared logic. */ +async function rebuildLabelSubmenu() { + try { + const existingItems = await browser.menus.getAll(); + for (const item of existingItems) { + if (item.id && (item.id.startsWith('autosort-') || item.id.startsWith('label-'))) { + browser.menus.remove(item.id); + } + } + } catch (e) { + console.warn('[Menu] Failed to remove existing items:', e.message); } + await buildContextMenu(); + + const { labels } = await browser.storage.local.get(['labels']); + if (window.debugLogger) { + window.debugLogger.info('[Menu]', `Menu rebuilt with ${labels ? labels.length : 0} labels`); + } + await showNotification( + "AutoSort+", + `Menu updated β€” ${labels && labels.length ? labels.length : '0'} label${labels && labels.length !== 1 ? 's' : ''} available` + ); +} + +// Initialize menu on startup +browser.runtime.onStartup.addListener(async () => { + await buildContextMenu(); + await recoverPendingQueue(); }); +browser.runtime.onInstalled.addListener(buildContextMenu); -// Add AI analysis option -browser.menus.create({ - id: "autosort-analyze", - title: "AutoSort+ Analyze with AI", - contexts: ["message_list"] +// Live-rebuild menu when labels change +browser.storage.onChanged.addListener(async (changes, area) => { + if (area === 'local' && changes.labels) { + await rebuildLabelSubmenu(); + } }); // Listen for menu clicks browser.menus.onClicked.addListener(async (info, tab) => { - if (info.parentMenuItemId === "autosort-label") { - const label = info.menuItemId.replace("label-", ""); - console.log(`Manual label selected: ${label}`); - await showNotification("AutoSort+", `Applying label: ${label}`); - browser.tabs.sendMessage(tab.id, { - action: "getSelectedMessages", - label: label - }); - } else if (info.menuItemId === "autosort-analyze") { - console.log("AI analysis selected - starting process"); - await showNotification("AutoSort+", "Starting AI analysis of selected messages..."); - - try { - // Get the current mail tab - const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); - if (!mailTabs || mailTabs.length === 0) { - console.error("No active mail tab found"); - await showNotification("AutoSort+ Error", "No active mail tab found"); - return; - } - console.log("Current mail tab:", mailTabs[0]); - - // Get selected messages using mailTabs API - const selectedMessageList = await browser.mailTabs.getSelectedMessages(mailTabs[0].id); - console.log("Selected message list:", selectedMessageList); - - if (!selectedMessageList || !selectedMessageList.messages || selectedMessageList.messages.length === 0) { - console.error("No messages selected"); - await showNotification("AutoSort+ Error", "No messages selected for analysis"); - return; + if (info.parentMenuItemId === "autosort-parent") { + if (info.menuItemId === "autosort-analyze") { + if (window.debugLogger) { + window.debugLogger.info('[AutoSort+]', 'AI analysis selected - starting batch process'); } + try { + if (!_acquireBatchLock()) { + await showNotification( + 'AutoSort+ Busy', + 'A batch is already in progress. Please wait or cancel it from the settings page.' + ); + return; + } - console.log(`Analyzing ${selectedMessageList.messages.length} selected messages`); - - for (const message of selectedMessageList.messages) { - // Get the full message with body - const fullMessage = await browser.messages.getFull(message.id); - console.log("Got full message:", fullMessage ? "yes" : "no"); - console.log("Message content:", fullMessage); - - if (!fullMessage) { - console.error("Could not get message content"); - continue; + const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); + if (!mailTabs || mailTabs.length === 0) { + console.error('No active mail tab found'); + await showNotification('AutoSort+ Error', 'No active mail tab found'); + _releaseBatchLock(); + return; } - // Function to recursively extract text from message parts - function extractTextFromParts(parts) { - let text = ""; - if (!parts) return text; - - for (const part of parts) { - console.log("Processing part:", { - contentType: part.contentType, - partName: part.partName, - size: part.size - }); - - if (part.parts) { - // Recursively process nested parts - text += extractTextFromParts(part.parts); - } - - if (part.contentType === "text/plain") { - text += part.body + "\n"; - } else if (part.contentType === "text/html" && !text) { - // Only use HTML if we haven't found plain text - text = browser.messengerUtilities.convertToPlainText(part.body); - } else if (part.contentType === "message/rfc822" && part.body) { - // Handle message/rfc822 parts - text += part.body + "\n"; - } - } - return text; + const selectedMessageList = await browser.mailTabs.getSelectedMessages(mailTabs[0].id); + if (!selectedMessageList || !selectedMessageList.messages || selectedMessageList.messages.length === 0) { + console.error('No messages selected'); + await showNotification('AutoSort+ Error', 'No messages selected for analysis'); + _releaseBatchLock(); + return; } - // Extract email content from the message - let emailContent = ""; - if (fullMessage.parts) { - emailContent = await extractTextFromParts(fullMessage.parts); - } else if (fullMessage.body) { - emailContent = fullMessage.body; + const messages = selectedMessageList.messages; + if (window.debugLogger) { + window.debugLogger.info('[AutoSort+]', `Starting batch analysis of ${messages.length} selected messages`); } - console.log("Extracted email content:", emailContent || ""); + await showNotification( + 'AutoSort+ Batch', + `Starting AI analysis of ${messages.length} email${messages.length > 1 ? 's' : ''}...` + ); - if (!emailContent) { - console.error("No readable content found in message"); - await showNotification("AutoSort+ Error", "Could not extract email content"); - continue; - } + batchAnalyzeEmails(messages).catch(err => { + console.error('[AutoSort+] Batch analysis failed:', err); + _releaseBatchLock(); + }); - console.log("Analyzing message content"); - const label = await analyzeEmailContent(emailContent); - - if (label) { - console.log("Applying label:", label); - await applyLabelsToMessages([message], label); - await showNotification("AutoSort+", `Successfully applied label: ${label}`); + } catch (error) { + _releaseBatchLock(); + console.error('Error starting batch analysis:', error); + await showNotification('AutoSort+ Error', `Error: ${error.message}`); + } + return; + } + + if (!info.menuItemId.startsWith('label-')) return; + const label = info.menuItemId.replace("label-", ""); + if (window.debugLogger) { + window.debugLogger.info('[AutoSort+]', `Manual label selected: ${label}`); + } + await showNotification("AutoSort+", `Applying label: ${label}`); + try { + // Get the current mail tab for processing + const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); + if (mailTabs && mailTabs.length > 0) { + // Get full message objects + const messages = await browser.mailTabs.getSelectedMessages(mailTabs[0].id); + if (messages && messages.messages && messages.messages.length > 0) { + await applyLabelsToMessages(messages.messages, label); } else { - console.log("No label generated from analysis"); - await showNotification("AutoSort+ Error", "Could not generate label from analysis"); + await showNotification("AutoSort+ Error", "No messages selected for labeling."); } + } else { + await showNotification("AutoSort+ Error", "No active mail tab found."); } } catch (error) { - console.error("Error during AI analysis:", error); - await showNotification("AutoSort+ Error", `Error: ${error.message}`); + console.error("Error applying manual label:", error); + await showNotification("AutoSort+ Error", `Error applying label: ${error.message}`); } } }); \ No newline at end of file diff --git a/content.js b/content.js index 6238dc9..1291ebb 100644 --- a/content.js +++ b/content.js @@ -1,3 +1,36 @@ +// Debug logging helper for content script context +const debugLog = { + enabled: false, + + async init() { + try { + const result = await browser.storage.local.get('debugMode'); + this.enabled = !!result.debugMode; + } catch (e) {} + + // Listen for changes + browser.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.debugMode !== undefined) { + this.enabled = !!changes.debugMode.newValue; + } + }); + }, + + info(message, data = null) { + if (this.enabled) { + console.info('%c[Content]', 'color: white; background: #00BCD4; padding: 2px 6px; border-radius: 4px;', message, data !== null ? data : ''); + } + }, + + error(message, data = null) { + // Always output errors + console.error('%c[Content]', 'color: white; background: #F44336; padding: 2px 6px; border-radius: 4px;', message, data !== null ? data : ''); + } +}; + +// Initialize debug mode +debugLog.init(); + // Listen for messages from the background script browser.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "getSelectedMessages") { @@ -13,7 +46,7 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { // Get selected rows const selectedRows = messageList.querySelectorAll('tr.selected'); if (!selectedRows || selectedRows.length === 0) { - console.log("No messages selected"); + debugLog.info("No messages selected"); sendResponse([]); return true; } @@ -26,7 +59,7 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { row.getAttribute('id'); if (!messageId) { - console.warn("Row missing message ID:", row); + debugLog.info("Row missing message ID:", row); return null; } @@ -35,12 +68,77 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { return { id: cleanId }; }).filter(msg => msg !== null); - console.log("Found selected messages:", selectedMessages); + debugLog.info("Found selected messages:", selectedMessages); sendResponse(selectedMessages); } catch (error) { console.error("Error getting selected messages:", error); sendResponse([]); } + } else if (message.action === 'ollamaFetch') { + // Runs inside a tab at http://localhost:11434 to avoid CORS + (async () => { + try { + const { fetchAction, model, prompt, headers, correlationId } = message; + const base = window.location.origin; + + if (fetchAction === 'pull') { + const res = await fetch(`${base}/api/pull`, { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, headers || {}), + body: JSON.stringify({ name: model, stream: true }) + }); + if (!res.ok) { + const t = await res.text(); + let errorMsg = t || `HTTP ${res.status}`; + try { + const j = JSON.parse(t); + if (j.error) errorMsg = j.error; + } catch (parseErr) { + // Not JSON, use raw text + } + throw new Error(errorMsg); + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + const payload = { action: 'ollamaPullProgress', correlationId, status: data.status || '' }; + if (data.completed && data.total) { + payload.percent = Math.round((data.completed / data.total) * 100); + } + browser.runtime.sendMessage(payload).catch(() => {}); + } catch (e) { + // ignore parse errors for partial lines + } + } + } + browser.runtime.sendMessage({ action: 'ollamaPullComplete', correlationId, ok: true }).catch(() => {}); + sendResponse({ ok: true }); + } else if (fetchAction === 'chat') { + const res = await fetch(`${base}/api/chat`, { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, headers || {}), + body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], stream: false }) + }); + const data = await res.json(); + sendResponse({ ok: true, data }); + } else { + sendResponse({ ok: false, error: 'unknown fetchAction' }); + } + } catch (err) { + sendResponse({ ok: false, error: err.message || String(err) }); + } + })(); + return true; } return true; }); \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml index 2ffbf31..26e1170 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,22 +1,67 @@ +# GitHub Pages Configuration remote_theme: pages-themes/cayman@v0.2.0 plugins: -- jekyll-remote-theme + - jekyll-remote-theme + - jekyll-seo-tag + - jekyll-sitemap -title: AutoSort+ -description: Fully customizable AI-powered email organization for Thunderbird. Create your own folder structure and let the AI adapt to your organizational system. -show_downloads: true -google_analytics: +# Site Settings +title: "AutoSort+ | AI-Powered Email Organization" +description: "Multi-provider AI email organization for Thunderbird. Smart rate limiting, usage tracking, and support for Gemini, OpenAI, Claude, Groq, Mistral, and local Ollama deployments." +author: Nigel +url: "https://nigel1992.github.io" +baseurl: "/AutoSort-Plus" +repository: Nigel1992/AutoSort-Plus + +# Theme Settings theme: jekyll-theme-cayman +show_downloads: true + +# SEO +lang: en-US +logo: /AutoSort-Plus/icon-48.png +twitter: + card: summary + username: +social: + name: AutoSort+ + links: + - https://github.com/Nigel1992/AutoSort-Plus + +# Display Settings +markdown: kramdown +kramdown: + input: GFM + syntax_highlighter: rouge + syntax_highlighter_opts: + css_class: 'highlight' + +# Features +show_excerpts: true +date_format: "%B %-d, %Y" + +# Analytics (optional - add your tracking ID) +google_analytics: + +# Build Settings +exclude: + - Gemfile + - Gemfile.lock + - node_modules + - vendor + - .gitignore + - README.md + +# Collections (for future docs expansion) +collections: + docs: + output: true + permalink: /:collection/:path/ -# Navigation -nav: - - title: Installation - url: /installation - - title: Your Custom Setup - url: /custom-setup - - title: Usage Guide - url: /usage - - title: FAQ - url: /faq - - title: Contributing - url: /contributing \ No newline at end of file +defaults: + - scope: + path: "" + type: "docs" + values: + layout: "default" + author: "Nigel" \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index efcc516..e39ae93 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,64 +1,436 @@ -# AutoSort+ for Thunderbird - -AutoSort+ is an AI-powered email organization addon for Thunderbird that automatically sorts your emails into your own custom folders and categories using Google's Gemini AI. - -## Latest Release (v1.2.0) - -### What's New -- **Improved History Management**: Fixed move history storage for better reliability -- **Streamlined Interface**: Removed notification system for a cleaner experience -- **Performance Improvements**: Enhanced overall stability and responsiveness -- **Move History Tracking**: View detailed history of all email moves in settings -- **Enhanced Settings Page**: Improved layout and functionality of the settings interface - -## Key Features - -- **Fully Customizable Organization**: Use your own folder structure and categories - the AI adapts to your organizational system -- **AI-Powered Classification**: Leverages Google's Gemini AI to understand email content and context -- **Smart Folder Organization**: Automatically moves emails to the appropriate folders based on your custom categories -- **Bulk Processing**: Process multiple emails at once to save time -- **Move History**: Track all email moves with detailed information including: - - Timestamp of move - - Email subject - - Destination folder - - Move status - - Up to 100 most recent moves stored - -## Quick Start - -1. Install the addon from the [latest release](https://github.com/Nigel1992/AutoSort-Plus/releases) -2. Configure your Google API key in the addon settings -3. Create your desired folder structure in Thunderbird -4. Select emails you want to organize -5. Right-click and choose "AutoSort+ Analyze with AI" -6. The addon will automatically sort emails into your folders based on content - -## Configuration - -1. Set up your Google API key in the addon preferences -2. Set up your preferred folder structure in Thunderbird -3. The addon will learn and adapt to your organizational system -4. Fine-tune settings through the addon preferences -5. View move history in the settings page: - - Access through Add-ons Manager > AutoSort+ > Options - - See timestamps, subjects, and destinations of moved emails - - Track success/failure status of moves - - Last 100 moves are preserved - -## Support - -Having issues? Check out our: -- [Troubleshooting Guide](docs/troubleshooting.md) -- [FAQ](docs/faq.md) -- [GitHub Issues](https://github.com/Nigel1992/AutoSort-Plus/issues) - -## Contributing - -We welcome contributions! Please check our [Contributing Guidelines](CONTRIBUTING.md) for details on: -- Reporting bugs -- Suggesting features -- Submitting pull requests - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file +--- +layout: default +title: AutoSort+ - AI-Powered Email Organization for Thunderbird +--- + + + +
+ +# 🎯 AutoSort+ for Thunderbird + +

AI-powered email organization that adapts to your workflow. Choose from cloud providers (Gemini, OpenAI, Anthropic, Groq, Mistral) or run a local Ollama model and let AutoSort+ automatically move emails to the right folders.

+ +
+Version +Thunderbird +License +
+ + + +

Available as a manual install (.xpi). See the Documentation below for installation and usage instructions.

+ +
+ +
+ +--- + +## 🌟 What is AutoSort+? + +AutoSort+ transforms your email workflow by automatically organizing messages into your custom folder structure using cutting-edge AI. Unlike rigid rule-based systems, AutoSort+ understands context, learns your preferences, and adapts to your unique organizational needs. + +### ✨ Why Choose AutoSort+? + +| Feature | Traditional Filters | AutoSort+ | +|---------|---------------------|------------| +| **Setup Time** | Hours of rule configuration | Minutes with AI | +| **Flexibility** | Static rules, breaks easily | Adaptive AI, learns patterns | +| **Context Understanding** | Basic keyword matching | Full content comprehension | +| **Multi-Provider** | N/A | 5 cloud providers + local Ollama support | +| **Smart Limits** | N/A | Built-in rate limit management | +| **History Tracking** | Manual logging | Automatic 100-move history | + +--- + +## πŸŽ‰ Latest Release: v1.2.3.3 + +
+ +### πŸš€ Release v1.2.3.3 β€” January 28, 2026 + +**Summary:** Fixed manual label application from the context menu (Right-click β†’ AutoSort+ β†’ AutoSort Label β†’ pick a label). The background script now handles selection and labeling reliably across Thunderbird views. + +#### πŸ› οΈ Notable Fix +- βœ… **Manual Labeling Fix:** Replaced content-script dependency with `mailTabs` API handling in background script to avoid "Could not establish connection. Receiving end does not exist." errors. + +
+ +--- + +## 🎯 Key Features + +### πŸ€– Multi-Provider AI Support + +Choose the best AI provider for your needs: + +| Provider | Model | Free Tier | Speed | Best For | +|----------|-------|-----------|-------|----------| +| **Gemini** | gemini-2.5-flash | 20/day/key | ⚑⚑⚑ | General use, fast processing | +| **OpenAI** | gpt-4o-mini | - | ⚑⚑ | Premium quality | +| **Claude** | claude-3-haiku | 1000/day | ⚑⚑⚑ | Long emails, nuanced content | +| **Groq** | llama-3.3-70b | Generous | ⚑⚑⚑⚑ | Ultra-fast, free | +| **Mistral** | mistral-small | Free tier | ⚑⚑⚑ | European privacy focus | +| **Ollama** | local LLM (llama3.2, phi, tinyllama) | Local (no external usage) | ⚑⚑ - ⚑⚑⚑ | Run models locally for privacy and offline use; supports model download and CPU-only mode | + +### πŸ“Š Smart Rate Limit Management (Gemini) + +- **Automatic Enforcement**: 5 requests/minute, 20/day +- **Real-Time Tracking**: See usage in settings dashboard +- **Smart Warnings**: Alerts at 15/20 limit +- **Multi-Key Support**: Switch keys when limit reached +- **Paid Plan Bypass**: Disable limits with paid plan checkbox + +### πŸ“ Flexible Folder Management + +- **IMAP Discovery**: Auto-load your existing folder structure +- **Bulk Import**: Paste lists of labels +- **Custom Categories**: Create unlimited folder categories +- **Auto-Create**: Missing folders created automatically +- **Smart Navigation**: Recursive folder traversal + +### πŸ“œ Move History & Tracking + +- **Last 100 Moves**: Full audit trail +- **Timestamps**: Precise move timing +- **Status Tracking**: Success/failure indicators +- **Subject Lines**: Quick identification +- **Destination Folders**: See where emails went +- **Clear History**: Fresh start anytime + +--- + +## πŸš€ Quick Start Guide + +### 1️⃣ Installation + +**Option 1: Direct Download** +```bash +# Download the latest XPI from releases +wget https://github.com/Nigel1992/AutoSort-Plus/releases/latest/download/autosortplus.xpi +``` + +**Option 2: Build from Source** +```bash +git clone https://github.com/Nigel1992/AutoSort-Plus.git +cd AutoSort-Plus +# Install in Thunderbird: Tools β†’ Add-ons β†’ Install Add-on From File +``` + +### 2️⃣ Get Your API Key + +Choose your preferred AI provider: + +- **Gemini** (Free): [Get API Key](https://aistudio.google.com/app/apikey) - 20 requests/day per key +- **OpenAI** (Paid): [Get API Key](https://platform.openai.com/api-keys) +- **Anthropic** (Free/Paid): [Get API Key](https://console.anthropic.com/) - 1000/day free +- **Groq** (Free): [Get API Key](https://console.groq.com/keys) - Generous limits +- **Mistral** (Free/Paid): [Get API Key](https://console.mistral.ai/) +- **Ollama (Local)**: No API key required β€” [Install Ollama](https://ollama.ai/download) and pull a model (e.g., `ollama pull llama3.2`). See the Ollama setup guide in the docs for details. + +### 3️⃣ Configure AutoSort+ + +1. Open Thunderbird β†’ **Tools β†’ Add-ons** +2. Find **AutoSort+** β†’ Click **Options** +3. **Select AI Provider** and paste your API key +4. Click **"Test API Connection"** βœ… +5. **Load folders** from IMAP or add custom labels +6. Save settings and you're ready! + +### 4️⃣ Start Organizing + +You have two options: + +**Option 1: AI-Powered Sorting** +- Select emails β†’ Right-click β†’ **AutoSort+ β†’ Analyze with AI** +- The AI will analyze and move emails to the best folder/category. + +**Option 2: Manual Labeling** +- Select emails β†’ Right-click β†’ **AutoSort+ β†’ AutoSort Label β†’ [Pick any label]** +- The selected label/category will be applied instantly to all selected emails. +
If you add or change labels in the settings menu, you must restart Thunderbird for the new labels to appear in the right-click menu.
+ +--- + +## πŸ“– Usage Guides + +### Managing Gemini Rate Limits + +If using Gemini's free tier: + +1. **Monitor Usage**: Check settings for real-time count (X/20) +2. **Watch Warnings**: Yellow alert at 15, red at 20 +3. **Create More Keys**: Generate multiple API keys in different projects +4. **Switch Keys**: Paste new key when limit reached, click Reset Counter +5. **Upgrade**: Enable "Gemini paid plan" if you have one + +### Creating Multiple Gemini Keys + +``` +1. Go to Google AI Studio: https://aistudio.google.com/ +2. Create a new project +3. Generate API key for that project +4. Each project = new 20/day limit +5. Switch keys in AutoSort+ settings as needed +``` + +### Setting Up Custom Folders + +**Method 1: IMAP Discovery** +- Click "Load Folders from IMAP" +- Select your account +- All folders appear automatically + +**Method 2: Bulk Import** +``` +Work +Personal +Finance +Projects +Family +``` +- Paste list (one per line) β†’ Click Import + +**Method 3: Manual Entry** +- Type label name β†’ Click "Add Label" β†’ See green checkmark + +--- + +## πŸ”’ Privacy & Security + +| Aspect | Details | +|--------|----------| +| **Email Storage** | ❌ Never stored, analyzed in memory only | +| **API Keys** | πŸ” OS-level encryption via browser storage | +| **Data Transmission** | βœ… Direct to your chosen AI provider | +| **Telemetry** | ❌ None - zero tracking | +| **Open Source** | βœ… Full transparency, audit anytime | +| **Third Parties** | ❌ No intermediary servers | + +**Your privacy is paramount.** All analysis happens directly between Thunderbird and your chosen AI provider. We don't have servers because we don't want your data! + +--- + +## πŸ› οΈ Advanced Configuration + +### Provider-Specific Settings + +**Gemini Users:** +- Enable "Paid Plan" checkbox to bypass rate limits +- Use Reset Counter when switching API keys +- Monitor daily reset time in usage panel + +**Ollama (Local) Users:** +- Install Ollama: https://ollama.ai/download +- Pull a model: `ollama pull llama3.2` (or `tinyllama`, `phi`, `gemma`) +- Set the Ollama URL and model in AutoSort+ settings (no API key required) +- Use CPU-only mode in settings to avoid GPU usage if necessary +- If Ollama returns 403 or connection errors, check `OLLAMA_403_DEBUG.md` in the repo + +**All Providers:** +- Test connection before first use +- Save settings after changes +- Check move history for troubleshooting + +### Folder Organization Tips + +- **Keep categories broad**: "Work", "Personal", "Finance" +- **Avoid special characters**: Use alphanumeric names +- **Case-sensitive matching**: Labels must match exactly +- **Use examples**: More context = better AI understanding + +--- + +## ⚠️ Troubleshooting + +### API Key Issues + +**Problem**: "API Key Not Configured" error + +**Solution**: +1. Verify key is from correct provider +2. No spaces before/after key +3. Click "Test API Connection" +4. Check provider's usage dashboard for validity + +### Rate Limit Errors + +**Problem**: "Rate limit exceeded" for Gemini + +**Solution**: +1. Check usage counter in settings (X/20) +2. Wait for daily reset (time shown in settings) +3. Create new API key in different project +4. Switch key and click "Reset Counter" +5. Or enable "Paid Plan" if applicable + +### Settings Page Won't Load + +**Solution**: +```bash +1. Thunderbird β†’ Settings β†’ Privacy β†’ Cookies and Site Data +2. Click "Clear Data" +3. Tools β†’ Add-ons β†’ AutoSort+ β†’ Reload +``` + +### Emails Not Moving + +**Check**: +- βœ“ API key is valid (test it) +- βœ“ Labels are saved (green checkmark) +- βœ“ Folders exist (or auto-create enabled) +- βœ“ Internet connection active +- βœ“ No rate limit reached + +--- + +## πŸ“Š System Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Thunderbird Email Client β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ AutoSort+ Extension β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ UI Layer β”‚ β”‚ Backgroundβ”‚ β”‚ +β”‚ β”‚(options) │◄── Script β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Rate Limiter β”‚ β”‚ +β”‚ β”‚ (Gemini only) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ Gemini β”‚ β”‚ Groq β”‚ β”‚ Claude β”‚ + β”‚ API β”‚ β”‚ API β”‚ β”‚ API β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## 🀝 Support & Community + +
+ +| πŸ’‘ Have Questions? | πŸ› Found a Bug? | ✨ Feature Ideas? | +|-------------------|-----------------|-------------------| +| [Discussions](https://github.com/Nigel1992/AutoSort-Plus/discussions) | [Issues](https://github.com/Nigel1992/AutoSort-Plus/issues) | [Feature Requests](https://github.com/Nigel1992/AutoSort-Plus/issues) | + +
+ +**Before reporting an issue:** +1. Check troubleshooting section above +2. Search existing issues +3. Include: Thunderbird version, AutoSort+ version, AI provider, error message + +--- + +## πŸ™ Contributing + +We ❀️ contributions! Here's how to help: + +### Ways to Contribute + +- πŸ› **Report bugs** with detailed reproduction steps +- πŸ’‘ **Suggest features** that would improve your workflow +- πŸ“– **Improve docs** with clearer explanations +- πŸ§ͺ **Test releases** with different providers +- πŸ’» **Submit code** via pull requests + +### Development Setup + +```bash +# Clone repository +git clone https://github.com/Nigel1992/AutoSort-Plus.git +cd AutoSort-Plus + +# Make changes +# Test in Thunderbird: Tools β†’ Add-ons β†’ Debug Add-ons β†’ Load Temporary Add-on + +# Submit PR +git checkout -b feature/amazing-feature +git commit -m "Add amazing feature" +git push origin feature/amazing-feature +``` + +--- + +## πŸ“„ License + +**MIT License** - Free to use, modify, and distribute. + +See [LICENSE](https://github.com/Nigel1992/AutoSort-Plus/blob/main/LICENSE) for full text. + +--- + +## 🎨 Credits + +**Icon Design:** [Fantasyou - Flaticon](https://www.flaticon.com/free-icons/email-filtering) + +**AI Providers:** +- [Google Gemini](https://ai.google.dev/) +- [OpenAI](https://openai.com/) +- [Anthropic](https://www.anthropic.com/) +- [Groq](https://groq.com/) +- [Mistral AI](https://mistral.ai/) + +**Built with:** +- [Thunderbird WebExtension APIs](https://webextension-api.thunderbird.net/) +- JavaScript ES6+ +- Love ❀️ + +--- + +
+ +## ⭐ Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Nigel1992/AutoSort-Plus&type=Date)](https://star-history.com/#Nigel1992/AutoSort-Plus&Date) + +--- + +**Made with ❀️ to help you organize email faster** + +[⬆ Back to Top](#-autosort-for-thunderbird) β€’ [GitHub](https://github.com/Nigel1992/AutoSort-Plus) β€’ [Latest Release](https://github.com/Nigel1992/AutoSort-Plus/releases) + +--- + +![Thunderbird](https://img.shields.io/badge/Thunderbird-78.0+-0A84FF?style=flat-square&logo=thunderbird&logoColor=white) +![License](https://img.shields.io/badge/License-MIT-green?style=flat-square) +![Version](https://img.shields.io/badge/Version-1.2.3.3-blue?style=flat-square) + +
\ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-19-menu-hot-update-persistent-queue-plan.md b/docs/superpowers/plans/2026-05-19-menu-hot-update-persistent-queue-plan.md new file mode 100644 index 0000000..8c830c5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-menu-hot-update-persistent-queue-plan.md @@ -0,0 +1,529 @@ +# Dynamic Menu Hot Update + Persistent Queue Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** εŒζˆ v1.3 εŠ¨ζ€θœε•ηƒ­ζ›΄ζ–°ε’žεΌΊοΌˆι€šηŸ₯提瀺 + ζ–‡ζ‘£δΏε€οΌ‰ε’Œ v2.0 ζŒδΉ…εŒ–εΌ‚ζ­₯δ»»εŠ‘ι˜Ÿεˆ—οΌˆζ›Ώζ’ε†…ε­˜ζ•°η»„δΈΊ storage ζŒδΉ…εŒ–οΌ‰ + +**Architecture:** δΈ€δΈͺη‹¬η«‹ε­η³»η»ŸοΌš(1) `rebuildLabelSubmenu()` ε’žεΌΊι€šηŸ₯ + README ζ–‡ζ‘ˆδΏε€οΌ›(2) `background.js` δΈ­ `_autoSortPending` ε†…ε­˜ζ•°η»„ζ›Ώζ’δΈΊ `browser.storage.local` ζŒδΉ…εŒ–ι˜Ÿεˆ—οΌŒζ–°ε’ž `enqueuePending/dequeuePending/recoverPendingQueue` δΈ‰δΈͺ函数 + +**Tech Stack:** WebExtension (Manifest V3), Thunderbird WebExtensions API (`browser.menus`, `browser.storage.local`, `browser.runtime`) + +--- + +## File Map + +| File | Responsibility | Changes | +|---|---|---| +| `background.js` | ζ ΈεΏƒδΈšεŠ‘ι€»θΎ‘ | δΏζ”Ή `rebuildLabelSubmenu()` + 替捒 `_autoSortPending` ι€»θΎ‘ + ζ–°ε’ž 3 δΈͺι˜Ÿεˆ—ε‡½ζ•° | +| `README.md` | η”¨ζˆ·ζ–‡ζ‘£ | δΏε€ 3 倄"ιœ€θ¦ι‡ε―"ζ–‡ζ‘ˆ | +| `test-queue.test.js` | ι˜Ÿεˆ—ι€»θΎ‘ε•ε…ƒζ΅‹θ―• | ζ–°ε»ΊοΌŒζ΅‹θ―• enqueue/dequeue/recover 函数 | + +--- + +### Task 1: Dynamic Menu Notification + +**Files:** +- Modify: `background.js:2005-2017` (`rebuildLabelSubmenu` function) + +- [ ] **Step 1: δΏζ”Ή rebuildLabelSubmenu ζ·»εŠ ι€šηŸ₯** + +读取 `background.js` L2005-2017,在 `await buildContextMenu()` δΉ‹εŽζ·»εŠ ι€šηŸ₯: + +```js +/** Rebuild the menu when labels change β€” removes old items, then rebuilds from shared logic. */ +async function rebuildLabelSubmenu() { + try { + const existingItems = await browser.menus.getAll(); + for (const item of existingItems) { + if (item.id && (item.id.startsWith('autosort-') || item.id.startsWith('label-'))) { + browser.menus.remove(item.id); + } + } + } catch (e) { + console.warn('[Menu] Failed to remove existing items:', e.message); + } + await buildContextMenu(); + + const { labels } = await browser.storage.local.get(['labels']); + if (window.debugLogger) { + window.debugLogger.info('[Menu]', `Menu rebuilt with ${labels ? labels.length : 0} labels`); + } + await showNotification( + "AutoSort+", + `Menu updated β€” ${labels && labels.length ? labels.length : '0'} label${labels && labels.length !== 1 ? 's' : ''} available` + ); +} +``` + +- [ ] **Step 2: ιͺŒθ― buildContextMenu θΏ”ε›ž labels ζ˜―ε¦ε―η”¨** + +η‘θ€ `buildContextMenu()` δΈ­ `const { labels } = await browser.storage.local.get(['labels'])` 能正η‘θ―»ε–γ€‚ε¦‚ζžœ `buildContextMenu` ε†…ιƒ¨ε·²ζœ‰ labels ε˜ι‡οΌŒθ€ƒθ™‘θ© `rebuildLabelSubmenu` η›΄ζŽ₯调用 `buildContextMenu` εŽη‹¬η«‹ζŸ₯θ―’ labels η”¨δΊŽι€šηŸ₯。 + +- [ ] **Step 3: 提亀** + +```bash +git add background.js +git commit -m "feat: add notification toast when menu rebuilds after label change" +``` + +--- + +### Task 2: README Restart Text Removal + +**Files:** +- Modify: `README.md:123`, `README.md:163`, `README.md:296` + +- [ ] **Step 1: δΏε€ L123 β€” ε‰θ£…ζ­₯ιͺ€** + +ε°† `6. Restart Thunderbird` ζ›Ώζ’δΈΊοΌš +``` +6. Menu auto-updates β€” no restart needed +``` + +- [ ] **Step 2: δΏε€ L163 β€” ζ ‡η­Ύε˜ζ›΄θ­¦ε‘Š** + +ε°†ζ•΄θ‘Œ `> ⚠️ **Warning:** If you add or change labels in the settings menu, you must restart Thunderbird for the new labels to appear in the right-click menu.` ζ›Ώζ’δΈΊοΌš +``` +> Labels update automatically in the right-click menu β€” no restart needed. +``` + +- [ ] **Step 3: δΏε€ L296 β€” ι‡ε€ζ ‡η­Ύε˜ζ›΄θ―΄ζ˜Ž** + +ε°†ζ•΄θ‘Œ `> **Note:** If you add or change labels in the settings menu, you must restart Thunderbird for the new labels to appear in the right-click menu.` ζ›Ώζ’δΈΊοΌš +``` +> Labels update automatically in the right-click menu β€” no restart needed. +``` + +- [ ] **Step 4: 提亀** + +```bash +git add README.md +git commit -m "docs: remove restart requirement β€” menu auto-updates on label change" +``` + +--- + +### Task 3: Persistent Queue β€” Core Functions + +**Files:** +- Modify: `background.js:466` (replace `_autoSortPending` declaration) +- Create: `background.js` β€” ζ–°ε’ž 3 δΈͺε‡½ζ•°οΌˆζ’ε…₯在 L466 ι™„θΏ‘οΌ‰ +- Test: `test-queue.test.js` + +- [ ] **Step 1: ζ–°ε’žι˜Ÿεˆ—ε‡½ζ•°οΌˆζ’ε…₯在 L466 `_autoSortPending` ε£°ζ˜ŽδΉ‹ε‰οΌ‰** + +在 `background.js` L466 δΉ‹ε‰οΌˆε³ `_acquireBatchLock` ε‡½ζ•°δΉ‹εŽοΌ‰ζ’ε…₯δ»₯δΈ‹δ»£η οΌš + +```js +// ───────────────────────────────────────────────────────────────────────────── +// PERSISTENT PENDING QUEUE +// Replaces in-memory _autoSortPending array with browser.storage.local +// ───────────────────────────────────────────────────────────────────────────── + +const MAX_PENDING_RETRIES = 3; + +/** + * Enqueue a message that failed due to rate limiting. + * @param {Object} message - Thunderbird message object + * @param {string} reason - Error reason for logging + */ +async function enqueuePending(message, reason) { + const data = await browser.storage.local.get(['pendingQueue']); + const queue = data.pendingQueue || []; + queue.push({ + messageId: message.id, + accountId: message.folder?.accountId || '', + timestamp: Date.now(), + retryCount: 0, + lastError: reason || '' + }); + await browser.storage.local.set({ pendingQueue: queue }); + if (window.debugLogger) { + window.debugLogger.warn('[Queue]', `Message ${message.id} enqueued (reason: ${reason})`); + } +} + +/** + * Dequeue all pending messages from storage. Returns array and clears storage. + * @returns {Promise} pending entries + */ +async function dequeuePending() { + const data = await browser.storage.local.get(['pendingQueue']); + const queue = data.pendingQueue || []; + await browser.storage.local.set({ pendingQueue: [] }); + return queue; +} + +/** + * Recover and retry pending messages from storage on extension startup. + * Messages exceeding MAX_PENDING_RETRIES are dropped. + */ +async function recoverPendingQueue() { + const data = await browser.storage.local.get(['pendingQueue']); + const queue = data.pendingQueue || []; + if (queue.length === 0) return; + + if (window.debugLogger) { + window.debugLogger.info('[Queue]', `Recovering ${queue.length} pending messages from storage`); + } + + await browser.storage.local.set({ pendingQueue: [] }); + + const recovered = []; + for (const entry of queue) { + if (entry.retryCount >= MAX_PENDING_RETRIES) { + if (window.debugLogger) { + window.debugLogger.warn('[Queue]', `Message ${entry.messageId} dropped (exceeded ${MAX_PENDING_RETRIES} retries)`); + } + continue; + } + recovered.push(entry); + } + + if (recovered.length > 0 && window.debugLogger) { + window.debugLogger.info('[Queue]', `Retrying ${recovered.length} pending messages`); + } + + for (const entry of recovered) { + const message = { id: entry.messageId, folder: { accountId: entry.accountId } }; + const result = await classifyAndSortMessage(message); + if (result.status === 'pending') { + // Still rate-limited, re-enqueue with incremented retryCount + const data = await browser.storage.local.get(['pendingQueue']); + const q = data.pendingQueue || []; + q.push({ + messageId: entry.messageId, + accountId: entry.accountId, + timestamp: Date.now(), + retryCount: entry.retryCount + 1, + lastError: result.reason || '' + }); + await browser.storage.local.set({ pendingQueue: q }); + } + } +} +``` + +- [ ] **Step 2: εˆ ι™€ζ—§ηš„ `_autoSortPending` 声明** + +εˆ ι™€ `background.js` L466 ηš„ `let _autoSortPending = [];` θ‘Œγ€‚ + +- [ ] **Step 3: 提亀** + +```bash +git add background.js +git commit -m "feat: add persistent pending queue functions (enqueue/dequeue/recover)" +``` + +--- + +### Task 4: Replace In-Memory Queue with Persistent Queue + +**Files:** +- Modify: `background.js:1839`, `background.js:1866`, `background.js:1919-1935` + +- [ ] **Step 1: 替捒 L1839 ηš„ `_autoSortPending.push(message)`** + +ε½“ε‰δ»£η οΌš +```js +_autoSortPending.push(message); +``` + +ζ›Ώζ’δΈΊοΌš +```js +await enqueuePending(message, 'rate_limited'); +``` + +- [ ] **Step 2: 替捒 L1866 ηš„ `_autoSortPending.push(message)`** + +ε½“ε‰δ»£η οΌš +```js +_autoSortPending.push(message); +return { status: 'pending', reason: 'rate_limited' }; +``` + +ζ›Ώζ’δΈΊοΌš +```js +await enqueuePending(message, 'rate_limited'); +return { status: 'pending', reason: 'rate_limited' }; +``` + +- [ ] **Step 3: 替捒 L1919-1935 ηš„ε†…ε­˜ι˜Ÿεˆ—ι‡θ―•ι€»θΎ‘** + +ε½“ε‰δ»£η οΌˆL1919-1935οΌ‰οΌš +```js + // Process pending queue (from previous rate-limited batches) + if (_autoSortPending.length > 0) { + if (window.debugLogger) { + window.debugLogger.info('[AutoSort]', `Retrying ${_autoSortPending.length} pending messages`); + } + const pendingCopy = [..._autoSortPending]; + _autoSortPending = []; + for (const msg of pendingCopy) { + const result = await classifyAndSortMessage(msg); + if (result.status !== 'pending') { + stats.success++; + continue; + } + stats.pending++; + _autoSortPending.push(msg); + } + } +``` + +ζ›Ώζ’δΈΊοΌš +```js + // Process pending queue from storage + const pending = await dequeuePending(); + if (pending.length > 0) { + if (window.debugLogger) { + window.debugLogger.info('[AutoSort]', `Retrying ${pending.length} pending messages`); + } + for (const entry of pending) { + if (entry.retryCount >= MAX_PENDING_RETRIES) { + stats.failed++; + continue; + } + const message = { id: entry.messageId, folder: { accountId: entry.accountId } }; + const result = await classifyAndSortMessage(message); + if (result.status === 'success') { + stats.success++; + } else if (result.status === 'pending') { + // Re-enqueue with incremented retryCount + const data = await browser.storage.local.get(['pendingQueue']); + const q = data.pendingQueue || []; + q.push({ + messageId: entry.messageId, + accountId: entry.accountId, + timestamp: Date.now(), + retryCount: entry.retryCount + 1, + lastError: result.reason || '' + }); + await browser.storage.local.set({ pendingQueue: q }); + stats.pending++; + } else { + stats.failed++; + } + } + } +``` + +- [ ] **Step 4: εˆ ι™€ L1933 ζ‹η•™ηš„ `_autoSortPending.push(msg)`** + +η‘保无ζ‹η•™ηš„ `_autoSortPending` 引用。用 `rg _autoSortPending background.js` η‘θ€ι›ΆεŒΉι…γ€‚ + +- [ ] **Step 5: 提亀** + +```bash +git add background.js +git commit -m "feat: replace in-memory _autoSortPending with persistent storage queue" +``` + +--- + +### Task 5: Startup Recovery Hook + +**Files:** +- Modify: `background.js:2020` (onStartup listener) + +- [ ] **Step 1: 在 onStartup ζ—Άζ’ε€ι˜Ÿεˆ—** + +ε½“ε‰δ»£η οΌˆL2020-2021οΌ‰οΌš +```js +browser.runtime.onStartup.addListener(buildContextMenu); +browser.runtime.onInstalled.addListener(buildContextMenu); +``` + +ζ›Ώζ’δΈΊοΌš +```js +browser.runtime.onStartup.addListener(async () => { + await buildContextMenu(); + await recoverPendingQueue(); +}); +browser.runtime.onInstalled.addListener(buildContextMenu); +``` + +- [ ] **Step 2: 提亀** + +```bash +git add background.js +git commit -m "feat: recover pending queue on extension startup" +``` + +--- + +### Task 6: Unit Tests for Queue Functions + +**Files:** +- Create: `test-queue.test.js` + +- [ ] **Step 1: εˆ›ε»Ίζ΅‹θ―•ζ–‡δ»Ά** + +εˆ›ε»Ί `test-queue.test.js`οΌŒζ¨‘ζ‹Ÿ `browser.storage.local` ε’Œ `classifyAndSortMessage`οΌŒζ΅‹θ―•ζ ΈεΏƒι˜Ÿεˆ—ι€»θΎ‘οΌš + +```javascript +/** + * Tests for persistent pending queue functions. + * Run: node test-queue.test.js + */ + +const assert = require('assert'); + +// ──────────────────────────────────────────────────────────── +// Mock browser.storage.local +// ───────────────────────────────────────────────────────────── + +let mockStorage = {}; + +const browser = { + storage: { + local: { + get: async (keys) => { + const result = {}; + const keyList = Array.isArray(keys) ? keys : [keys]; + for (const key of keyList) { + if (mockStorage[key] !== undefined) { + result[key] = mockStorage[key]; + } + } + return result; + }, + set: async (obj) => { + Object.assign(mockStorage, obj); + } + } + } +}; + +// ───────────────────────────────────────────────────────────── +// Inline the queue functions for testing (mirrors background.js) +// ───────────────────────────────────────────────────────────── + +const MAX_PENDING_RETRIES = 3; + +async function enqueuePending(message, reason) { + const data = await browser.storage.local.get(['pendingQueue']); + const queue = data.pendingQueue || []; + queue.push({ + messageId: message.id, + accountId: message.folder?.accountId || '', + timestamp: Date.now(), + retryCount: 0, + lastError: reason || '' + }); + await browser.storage.local.set({ pendingQueue: queue }); +} + +async function dequeuePending() { + const data = await browser.storage.local.get(['pendingQueue']); + const queue = data.pendingQueue || []; + await browser.storage.local.set({ pendingQueue: [] }); + return queue; +} + +// ───────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────── + +async function setup() { mockStorage = {}; } + +async function test_enqueueAddsEntry() { + await setup(); + await enqueuePending({ id: 1, folder: { accountId: 'acc1' } }, 'rate_limited'); + const data = await browser.storage.local.get(['pendingQueue']); + assert.strictEqual(data.pendingQueue.length, 1); + assert.strictEqual(data.pendingQueue[0].messageId, 1); + assert.strictEqual(data.pendingQueue[0].retryCount, 0); + console.log(' PASS: enqueue adds entry to queue'); +} + +async function test_dequeueReturnsAndClears() { + await setup(); + await enqueuePending({ id: 1, folder: { accountId: 'acc1' } }, 'rate_limited'); + await enqueuePending({ id: 2, folder: { accountId: 'acc2' } }, 'rate_limited'); + + const dequeued = await dequeuePending(); + assert.strictEqual(dequeued.length, 2); + assert.strictEqual(dequeued[0].messageId, 1); + assert.strictEqual(dequeued[1].messageId, 2); + + const after = await browser.storage.local.get(['pendingQueue']); + assert.deepStrictEqual(after.pendingQueue, []); + console.log(' PASS: dequeue returns all entries and clears storage'); +} + +async function test_enqueueAppendsToExisting() { + await setup(); + await enqueuePending({ id: 1, folder: { accountId: 'acc1' } }, 'err1'); + await enqueuePending({ id: 2, folder: { accountId: 'acc1' } }, 'err2'); + + const data = await browser.storage.local.get(['pendingQueue']); + assert.strictEqual(data.pendingQueue.length, 2); + console.log(' PASS: enqueue appends to existing queue'); +} + +async function test_emptyQueueDequeue() { + await setup(); + const result = await dequeuePending(); + assert.deepStrictEqual(result, []); + console.log(' PASS: dequeue on empty storage returns empty array'); +} + +// ───────────────────────────────────────────────────────────── +// Run +// ───────────────────────────────────────────────────────────── + +(async () => { + console.log('Running queue tests...\n'); + await test_enqueueAddsEntry(); + await test_dequeueReturnsAndClears(); + await test_enqueueAppendsToExisting(); + await test_emptyQueueDequeue(); + console.log('\nAll queue tests passed!'); +})(); +``` + +- [ ] **Step 2: θΏθ‘Œζ΅‹θ―•** + +```bash +node test-queue.test.js +``` + +Expected: All 4 tests pass. + +- [ ] **Step 3: 提亀** + +```bash +git add test-queue.test.js +git commit -m "test: add unit tests for persistent queue functions" +``` + +--- + +## Self-Review + +### 1. Spec Coverage + +| Spec Requirement | Task | +|---|---| +| Menu rebuild notification toast | Task 1 | +| README restart text removal (3 places) | Task 2 | +| `enqueuePending()` function | Task 3 | +| `dequeuePending()` function | Task 3 | +| `recoverPendingQueue()` function | Task 3 | +| Replace `_autoSortPending` at L1839 | Task 4 | +| Replace `_autoSortPending` at L1866 | Task 4 | +| Replace `_autoSortPending` retry logic at L1919-1935 | Task 4 | +| onStartup recovery hook | Task 5 | +| Unit tests for queue | Task 6 | + +### 2. Placeholder Scan + +No TBD, TODO, "add validation", "similar to" patterns found. All code blocks are complete. + +### 3. Type Consistency + +- `enqueuePending(message, reason)` β€” called with `message` object (has `id`, `folder.accountId`) and string reason +- `classifyAndSortMessage(message)` β€” called in Task 5 with `{ id, folder: { accountId } }` object +- `MAX_PENDING_RETRIES = 3` β€” defined in Task 3, used in Task 3 (`recoverPendingQueue`) and Task 4 (retry logic) +- `showNotification(title, message)` β€” used in Task 1, matches existing signature at L812 diff --git "a/docs/superpowers/plans/\351\241\271\347\233\256\351\207\215\346\236\204\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222\346\226\207\346\241\243.md" "b/docs/superpowers/plans/\351\241\271\347\233\256\351\207\215\346\236\204\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222\346\226\207\346\241\243.md" new file mode 100644 index 0000000..1ea9d4a --- /dev/null +++ "b/docs/superpowers/plans/\351\241\271\347\233\256\351\207\215\346\236\204\344\270\216\346\274\224\350\277\233\350\247\204\345\210\222\346\226\207\346\241\243.md" @@ -0,0 +1,304 @@ +# ??? AutoSort-Plus ΊΛΠΔΣΕ»―Κ΅Υ½Σλ±άΏΣΦΈΔΟ + +## Δ£Ώι»£ΊΣΚΌώΔΪΘέΦΗΔάΜαΘ‘ (MIME ½βΞφΣλΗεΟ΄) +**ΔΏ±κ**£Ί½βΎφ Token ΐΛ·ΡΞΚΜ⣬Ύ«ΧΌΜαΘ‘ΥύΞΔ£¬Μή³ύΗ©Γϋ‘’HTML ΊΝΈ½Όώ‘£ + +### ?? ΄ϊΒλΚΎΐύ£Ί΅έΉι½βΞφ MIME Κχ +ΤΪ `background.js` »ςΆΐΑ’΅Δ `mime-parser.js` ΦΠΚ΅ΟΦ£Ί + +```javascript +/** + * ΅έΉιΜαΘ‘ΣΚΌώ΄ΏΞΔ±ΎΥύΞΔ + * @param {object} part - browser.messages.getFull() ·΅»Ψ΅Δ MIME part + * @returns {string} ΄ΏΞΔ±ΎΔΪΘέ + */ +function extractPlainText(part) { + if (!part) return ""; + + // 1. ΣΕΟΘΡ°Υ text/plain + if (part.contentType === "text/plain" && part.body) { + return decodeBody(part.body, part.encoding); + } + + // 2. ΘηΉϋΓ»ΣΠ΄ΏΞΔ±Ύ£¬ΝΛΆψΗσΖδ΄ΞΡ°Υ text/html ²’ΗεΟ΄ + if (part.contentType === "text/html" && part.body) { + const html = decodeBody(part.body, part.encoding); + return stripHtmlTags(html); + } + + // 3. ΅έΉι±ιΐϊΧΣ½Ϊ΅γ (multipart/alternative, multipart/mixed ΅Θ) + if (part.parts && part.parts.length > 0) { + // ΣΕΟΘΥ plain£¬Υ²»΅½ΤΩΥ html + let plainText = ""; + let htmlText = ""; + for (const subPart of part.parts) { + const result = extractPlainText(subPart); + if (subPart.contentType === "text/plain") plainText += result; + else if (subPart.contentType === "text/html") htmlText += result; + } + return plainText || htmlText; + } + return ""; +} + +// Έ¨Φϊ£ΊΌς΅₯΅Δ HTML ±κΗ©°ώΐλΣλΗ©Γϋ½ΨΆΟ +function stripHtmlTags(html) { + // Ζ³ύ