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