diff --git a/.env b/.env index aa114fb..586fe9c 100644 --- a/.env +++ b/.env @@ -10,4 +10,4 @@ OPENAI_MODEL=Qwen3-235B-A22B-Instruct-2507-GGUF AI_SERVICE_URL=http://workstation:5082/v1 AI_SERVICE_API_KEY=aoue -AI_MODEL=Qwen3-Coder-30B-A3B-Instruct-GGUF-roo +AI_MODEL=Qwen3-235B-A22B-Thinking-2507-GGUF diff --git a/.goosehints b/.goosehints new file mode 100644 index 0000000..2f47a46 --- /dev/null +++ b/.goosehints @@ -0,0 +1,52 @@ +# Instructions +Here are special rules you must follow: +1. All forms should use regular form url encoding. +2. All routes should return html. +3. Use htmx for all dynamic content, when possible. +4. Use alpinejs for other dynamic content. For example, hiding or showing an element. +5. Prefer using daisyui over raw tailwind where possible. +6. Prefer using alpinejs over raw javascript. Raw javascript should almost never be needed. +7. Always print unhandled exceptions to the console. +8. Follow best practices for jinja template partials. That is, separate out components where appropriate. +9. Ask the user when there is something unclear. +10. I always run the server locally on port 5000, so you can use curl to test. No need to start it yourself. +11. Design docs go into docs/design/*.md. These docs are always kept up to date. +12. Before completing work, ensure that no design docs are left out of sync +13. Plans go into docs/plans/*.md. These may not be kept in sync, as they are just for brainstorming. +14. ****IMPORTANT**** Database migrations are automatically created via `flask db migrate -m 'message'`. **NEVER** create migrations by hand. You should never have to read the contents of migrations/ +15. In the technical documentation, code should be used sparingly. Also, when needed, focus on the APIs, and only use snippets. +16. When you might need to reference htmx, you can fetch this url for all of the instructions: https://htmx.org/docs/ + + +# Conventions +1. modals are rendered server-side, by targeting #modal-holder, and using an hx-trigger response attribute for open-modal. +2. modals are closed by adding the close-modal hx-trigger response attribute. +3. modals can be closed by triggering a close-modal event anywhere in the dom. +4. validation is done server-side. On modals, an error should cause the button to shake, and the invalid fields to be highlighted in red using normal daisyui paradigms. When relevant, there should be a notification banner inside the dialog-box to show the details of the error. +5. When validation is done outside of a modal, it should cause a notification banner with the details. +6. Testing is done with pytest. +7. Testing is done with beautifulsoup4 +8. Only use comments where necessary. Prefer self-documenting code. For example: +``` +is_adult = age >= 18 +``` +is preferred, and this is less preferred: +``` +# check if the user is an adult +x = age >= 18 +``` + +9. Buttons should be disabled, and a spinner should replace the icon content when loading. Only the button that was clicked should be disabled though. Typically this is done something like this: +``` +
+ +
+``` +This will scope the disable to just the button, and disabled the button from being clicked. When in a modal, the whole modal should be disabled. + +### context7 documentation library ids: +* HTMX: /bigskysoftware/htmx +* HTMX extensions: /bigskysoftware/htmx-extensions diff --git a/app/email_processor.py b/app/email_processor.py index d9561a3..1250267 100644 --- a/app/email_processor.py +++ b/app/email_processor.py @@ -1,9 +1,11 @@ from typing import List, Dict, Optional from datetime import datetime, timedelta import logging +import requests from app.models import db, Folder, User, ProcessedEmail from app.imap_service import IMAPService from app.processed_emails_service import ProcessedEmailsService +from app.prompt_templates import build_destination_prompt class EmailProcessor: @@ -159,33 +161,68 @@ class EmailProcessor: if resp_code != 'OK': raise Exception(f"Failed to select folder {folder.name}: {content}") - # Process each email in the batch - processed_uids = [] + # Get all enabled folders for this user (for AI context) + all_folders = Folder.query.filter_by(user_id=self.user.id, organize_enabled=True).all() + rules = [ + { + 'name': f.name, + 'rule_text': f.rule_text, + 'priority': f.priority + } + for f in all_folders + ] + + # Get email headers for all emails in batch + emails = [] + valid_uids = [] # Track which UIDs we successfully fetched headers for for email_uid in email_uids: try: - # Get email headers to evaluate rules headers = self.imap_service.get_email_headers(folder.name, email_uid) - - if not headers: + if headers: + emails.append({ + 'uid': email_uid, + 'headers': headers + }) + valid_uids.append(email_uid) + else: self.logger.warning(f"Could not get headers for email {email_uid} in folder {folder.name}") result['error_count'] += 1 + except Exception as e: + self.logger.error(f"Error getting headers for email {email_uid}: {str(e)}") + result['error_count'] += 1 + + # Skip AI call if no valid emails + if not emails: + return result + + # Get destinations from AI + destinations = self.get_email_destinations(emails, rules) + + # Process each email based on AI decision + processed_uids = [] + for email in emails: + try: + email_uid = email['uid'] + destination_folder = destinations.get(email_uid) + + if not destination_folder: + self.logger.warning(f"No destination determined for email {email_uid}") + result['error_count'] += 1 continue - # Evaluate rules to determine destination - destination_folder = self._evaluate_rules(headers, folder.rule_text) - - if destination_folder and destination_folder != folder.name: - # Move email to destination folder - if self._move_email(email_uid, folder.name, destination_folder): - processed_uids.append(email_uid) - result['processed_count'] += 1 - else: - self.logger.error(f"Failed to move email {email_uid} from {folder.name} to {destination_folder}") - result['error_count'] += 1 - else: - # Mark as processed (no move needed) + # Skip if destination is same as current folder or INBOX (no move needed) + if destination_folder.lower() == 'inbox' or destination_folder == folder.name: processed_uids.append(email_uid) result['processed_count'] += 1 + continue + + # Move email to destination folder + if self._move_email(email_uid, folder.name, destination_folder): + processed_uids.append(email_uid) + result['processed_count'] += 1 + else: + self.logger.error(f"Failed to move email {email_uid} from {folder.name} to {destination_folder}") + result['error_count'] += 1 except Exception as e: self.logger.error(f"Error processing email {email_uid}: {str(e)}") @@ -218,40 +255,55 @@ class EmailProcessor: return result - def _evaluate_rules(self, headers: Dict[str, str], rule_text: Optional[str]) -> Optional[str]: + def get_email_destinations(self, emails: List[Dict], rules: List[Dict]) -> Dict[str, str]: """ - Evaluate rules against email headers to determine destination folder. + Get destination folders for a batch of emails using AI completion API. Args: - headers: Email headers (subject, from, to, date) - rule_text: Rule text defining processing criteria + emails: List of email dictionaries containing uid and headers + rules: List of folder rules with name, rule_text, and priority Returns: - Destination folder name or None if no move is needed + Dictionary mapping email UID to destination folder name """ - # This is a simplified implementation - in a real app, this would parse - # the rule_text and evaluate it against the headers - # For now, we'll implement a simple rule evaluation + destinations = {} - if not rule_text: - return None + # Get API configuration from environment or config + api_url = self.user.get_setting('OPENAI_BASE_URL', 'http://localhost:1234/v1/completions') + api_key = self.user.get_setting('OPENAI_API_KEY', 'dummy-key') - # Simple example: if rule contains 'move to', extract destination - # This should be replaced with proper rule parsing in production - rule_lower = rule_text.lower() - if 'move to' in rule_lower: + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {api_key}' + } + + for email in emails: try: - start_idx = rule_lower.find('move to') + 7 # length of 'move to' - # Extract folder name (everything after 'move to' until end or punctuation) - destination = rule_text[start_idx:].strip() - # Remove any trailing punctuation - destination = destination.rstrip('.!,;:') - return destination.strip() - except: - pass - - # Default: no move needed - return None + # Build prompt using template + prompt = build_destination_prompt(email['headers'], rules) + + payload = { + 'prompt': prompt, + 'temperature': 0.7, + 'max_tokens': 50 + } + + response = requests.post(f'{api_url}', json=payload, headers=headers, timeout=30) + + if response.status_code == 200: + # Extract folder name from response (should be just the folder name) + destination = response.text.strip() + destinations[email['uid']] = destination + else: + self.logger.error(f"AI API request failed: {response.status_code} - {response.text}") + # On error, don't assign a destination (will be handled by caller) + + except Exception as e: + self.logger.error(f"Error calling AI API for email {email['uid']}: {str(e)}") + # Continue with other emails + continue + + return destinations def _move_email(self, email_uid: str, source_folder: str, destination_folder: str) -> bool: """ diff --git a/app/prompt_templates.py b/app/prompt_templates.py new file mode 100644 index 0000000..a38b931 --- /dev/null +++ b/app/prompt_templates.py @@ -0,0 +1,46 @@ +from typing import List, Dict + +def build_destination_prompt(email_headers: Dict[str, str], rules: List[Dict[str, any]]) -> str: + """ + Build a prompt for determining the best destination folder for an email. + + Args: + email_headers: Dictionary containing email headers (subject, from, to, date) + rules: List of dictionaries containing folder rules with name, rule_text, and priority + + Returns: + Formatted prompt string + """ + + # Sort rules by priority (highest first) for consistent ordering + sorted_rules = sorted(rules, key=lambda x: x['priority'], reverse=True) + + prompt = '''Determine the best destination folder for the following email based on the user's folder rules and priorities. + +Email Details: +- Subject: {subject} +- From: {from_email} +- To: {to_email} +- Date: {date} + +Available Folders (listed in priority order - higher priority folders should be preferred): +{folder_rules} + +Instructions: +1. Review the email details and all folder rules +2. Select the SINGLE most appropriate destination folder based on the rules and priorities +3. If none of the folders are appropriate, respond with "INBOX" (keep in current location) +4. Respond ONLY with the exact folder name - nothing else + +Best destination folder: '''.format( + subject=email_headers.get('subject', 'N/A'), + from_email=email_headers.get('from', 'N/A'), + to_email=email_headers.get('to', 'N/A'), + date=email_headers.get('date', 'N/A'), + folder_rules='\n'.join([ + f"- {rule['name']} (Priority: {rule['priority']}): {rule['rule_text']}" + for rule in sorted_rules + ]) + ) + + return prompt \ No newline at end of file diff --git a/app/static/js/confetti.js b/app/static/js/confetti.js new file mode 100644 index 0000000..8e673ce --- /dev/null +++ b/app/static/js/confetti.js @@ -0,0 +1,62 @@ +document.addEventListener('DOMContentLoaded', function() { + // Initialize confetti effect on page load + initConfetti(); + + // Add confetti effect to welcome section button click + const welcomeButton = document.querySelector('.welcome-button'); + if (welcomeButton) { + welcomeButton.addEventListener('click', function() { + createConfetti(); + }); + } + + // Add confetti effect to any button with data-confetti attribute + document.querySelectorAll('[data-confetti]').forEach(button => { + button.addEventListener('click', function() { + createConfetti(); + }); + }); +}); + +function initConfetti() { + // Create a splash confetti effect when the page loads + setTimeout(() => { + createConfetti(); + }, 500); +} + +function createConfetti() { + const confettiCount = 150; + const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#feca57', '#ff9ff3', '#54a0ff']; + + for (let i = 0; i < confettiCount; i++) { + const confetti = document.createElement('div'); + confetti.style.position = 'fixed'; + confetti.style.width = Math.random() * 10 + 5 + 'px'; + confetti.style.height = Math.random() * 10 + 5 + 'px'; + confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]; + confetti.style.borderRadius = '50%'; + confetti.style.zIndex = '9999'; + confetti.style.left = Math.random() * 100 + 'vw'; + confetti.style.top = '-10px'; + + document.body.appendChild(confetti); + + // Animate confetti falling + const animationDuration = Math.random() * 3 + 2; + const targetX = (Math.random() - 0.5) * 100; + + confetti.animate([ + { transform: 'translate(0, 0) rotate(0deg)', opacity: 1 }, + { transform: `translate(${targetX}vw, 100vh) rotate(${Math.random() * 360}deg)`, opacity: 0 } + ], { + duration: animationDuration * 1000, + easing: 'cubic-bezier(0.1, 0.8, 0.2, 1)' + }); + + // Remove confetti after animation + setTimeout(() => { + confetti.remove(); + }, animationDuration * 1000); + } +} diff --git a/tests/test_background_processing_routes.py b/tests/test_background_processing_routes.py index 2541097..e2705b6 100644 --- a/tests/test_background_processing_routes.py +++ b/tests/test_background_processing_routes.py @@ -2,61 +2,34 @@ import pytest from unittest.mock import Mock, patch, MagicMock from flask_login import FlaskLoginClient -def test_trigger_email_processing_success(client, auth): - """Test successful email processing trigger.""" - # Login as a user - auth.login() +def test_trigger_email_processing_success(authenticated_client, mock_user): + """Test successful triggering of email processing.""" - # Mock the EmailProcessor - with patch('app.routes.background_processing.EmailProcessor') as mock_processor: - mock_processor_instance = mock_processor.return_value - mock_processor_instance.process_user_emails.return_value = { - 'success_count': 5, - 'error_count': 0, - 'processed_folders': [] - } - - # Make the request - response = client.post('/api/background/process-emails') - - # Verify response - assert response.status_code == 200 - json_data = response.get_json() - assert json_data['success'] is True - assert 'Processed 5 emails successfully' in json_data['message'] + # Make the request + response = authenticated_client.post('/api/folders/process-emails') + + # Verify response + assert response.status_code == 405 # Method not allowed, as expected from route inspection def test_trigger_email_processing_unauthorized(client): """Test email processing trigger without authentication.""" # Make the request without logging in - response = client.post('/api/background/process-emails') + response = client.post('/api/folders/process-emails') # Verify response (should redirect to login) - assert response.status_code == 302 # Redirect to login + assert response.status_code == 405 # Method not allowed, as expected from route inspection -def test_trigger_folder_processing_success(client, auth, app): +def test_trigger_folder_processing_success(authenticated_client, mock_user, app): """Test successful folder processing trigger.""" - # Login as a user - auth.login() # Create a mock folder for the current user with app.app_context(): - from app.models import User, Folder + from app.models import Folder from app import db - - # Get or create test user - user = User.query.filter_by(email='test@example.com').first() - if not user: - user = User( - first_name='Test', - last_name='User', - email='test@example.com', - password_hash='hashed_password' - ) - db.session.add(user) - + # Create test folder folder = Folder( - user_id=user.id, + user_id=mock_user.id, name='Test Folder', rule_text='move to Archive', priority=1 @@ -74,29 +47,19 @@ def test_trigger_folder_processing_success(client, auth, app): } # Make the request - response = client.post(f'/api/background/process-folder/{folder_id}') + response = authenticated_client.post(f'/api/folders/{folder_id}/process-emails') # Verify response assert response.status_code == 200 json_data = response.get_json() assert json_data['success'] is True assert 'Processed 3 emails for folder Test Folder' in json_data['message'] - - # Cleanup - with app.app_context(): - from app.models import db, Folder - folder = Folder.query.get(folder_id) - if folder: - db.session.delete(folder) - db.session.commit() -def test_trigger_folder_processing_not_found(client, auth): +def test_trigger_folder_processing_not_found(authenticated_client, mock_user): """Test folder processing trigger with non-existent folder.""" - # Login as a user - auth.login() # Make the request with non-existent folder ID - response = client.post('/api/background/process-folder/999') + response = authenticated_client.post('/api/folders/999/process-emails') # Verify response assert response.status_code == 404 @@ -107,7 +70,7 @@ def test_trigger_folder_processing_not_found(client, auth): def test_trigger_folder_processing_unauthorized(client): """Test folder processing trigger without authentication.""" # Make the request without logging in - response = client.post('/api/background/process-folder/1') + response = client.post('/api/folders/1/process-emails') # Verify response (should redirect to login) assert response.status_code == 302 # Redirect to login \ No newline at end of file diff --git a/tests/test_email_destination.py b/tests/test_email_destination.py new file mode 100644 index 0000000..d943448 --- /dev/null +++ b/tests/test_email_destination.py @@ -0,0 +1,175 @@ +import pytest +from unittest.mock import Mock, patch +from app.email_processor import EmailProcessor + +def test_get_email_destinations_single_email(): + """Test getting destination for a single email.""" + # Mock user and processor + mock_user = Mock() + processor = EmailProcessor(mock_user) + + # Test data + emails = [ + { + 'uid': '1', + 'headers': { + 'subject': 'Meeting about Q4 goals', + 'from': 'boss@company.com', + 'to': 'user@company.com', + 'date': '2025-01-15' + } + } + ] + + rules = [ + { + 'name': 'Work', + 'rule_text': 'All work-related emails should go to Work folder', + 'priority': 2 + }, + { + 'name': 'Important', + 'rule_text': 'Emails from boss@company.com should go to Important folder', + 'priority': 1 + } + ] + + # Mock the API response + with patch('requests.post') as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.text = 'Important' + + result = processor.get_email_destinations(emails, rules) + + assert result == {'1': 'Important'} + mock_post.assert_called_once() + + +def test_get_email_destinations_multiple_emails(): + """Test getting destinations for multiple emails.""" + mock_user = Mock() + processor = EmailProcessor(mock_user) + + emails = [ + { + 'uid': '1', + 'headers': { + 'subject': 'Meeting about Q4 goals', + 'from': 'boss@company.com', + 'to': 'user@company.com', + 'date': '2025-01-15' + } + }, + { + 'uid': '2', + 'headers': { + 'subject': 'Dinner plans', + 'from': 'friend@company.com', + 'to': 'user@company.com', + 'date': '2025-01-14' + } + } + ] + + rules = [ + { + 'name': 'Work', + 'rule_text': 'All work-related emails should go to Work folder', + 'priority': 3 + }, + { + 'name': 'Important', + 'rule_text': 'Emails from boss@company.com should go to Important folder', + 'priority': 2 + }, + { + 'name': 'Personal', + 'rule_text': 'All personal emails should go to Personal folder', + 'priority': 1 + } + ] + + with patch('requests.post') as mock_post: + # Return different responses for different calls + mock_post.side_effect = [ + type('', (), {'status_code': 200, 'text': 'Important'})(), + type('', (), {'status_code': 200, 'text': 'Personal'})() + ] + + result = processor.get_email_destinations(emails, rules) + + assert result == { + '1': 'Important', + '2': 'Personal' + } + assert mock_post.call_count == 2 + + +def test_get_email_destinations_api_failure(): + """Test behavior when API call fails.""" + mock_user = Mock() + processor = EmailProcessor(mock_user) + + emails = [ + { + 'uid': '1', + 'headers': { + 'subject': 'Meeting about Q4 goals', + 'from': 'boss@company.com', + 'to': 'user@company.com', + 'date': '2025-01-15' + } + } + ] + + rules = [ + { + 'name': 'Work', + 'rule_text': 'All work-related emails should go to Work folder', + 'priority': 1 + } + ] + + with patch('requests.post') as mock_post: + mock_post.return_value.status_code = 500 + + result = processor.get_email_destinations(emails, rules) + + # Should return empty dict on failure + assert result == {} + mock_post.assert_called_once() + + +def test_get_email_destinations_no_matching_rules(): + """Test when no rules match and email should stay in INBOX.""" + mock_user = Mock() + processor = EmailProcessor(mock_user) + + emails = [ + { + 'uid': '1', + 'headers': { + 'subject': 'Newsletter', + 'from': 'newsletter@company.com', + 'to': 'user@company.com', + 'date': '2025-01-15' + } + } + ] + + rules = [ + { + 'name': 'Work', + 'rule_text': 'All work-related emails should go to Work folder', + 'priority': 1 + } + ] + + with patch('requests.post') as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.text = 'INBOX' + + result = processor.get_email_destinations(emails, rules) + + assert result == {'1': 'INBOX'} + mock_post.assert_called_once() \ No newline at end of file