This commit is contained in:
2026-04-07 17:42:53 -07:00
parent 433228c90e
commit d5ed3c1b52
7 changed files with 448 additions and 98 deletions

2
.env
View File

@@ -10,4 +10,4 @@ OPENAI_MODEL=Qwen3-235B-A22B-Instruct-2507-GGUF
AI_SERVICE_URL=http://workstation:5082/v1 AI_SERVICE_URL=http://workstation:5082/v1
AI_SERVICE_API_KEY=aoue AI_SERVICE_API_KEY=aoue
AI_MODEL=Qwen3-Coder-30B-A3B-Instruct-GGUF-roo AI_MODEL=Qwen3-235B-A22B-Thinking-2507-GGUF

52
.goosehints Normal file
View File

@@ -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:
```
<div data-loading-states>
<button class="..." hx-get="..." data-loading-disable>
<i class="fas fa-cog mr-2" data-loading-class="!hidden"></i>
<span data-loading-class="!hidden">Click Me!</span>
<span class="loading loading-spinner loading-xs hidden" data-loading-class-remove="hidden"></span> </button>
</div>
```
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

View File

@@ -1,9 +1,11 @@
from typing import List, Dict, Optional from typing import List, Dict, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import requests
from app.models import db, Folder, User, ProcessedEmail from app.models import db, Folder, User, ProcessedEmail
from app.imap_service import IMAPService from app.imap_service import IMAPService
from app.processed_emails_service import ProcessedEmailsService from app.processed_emails_service import ProcessedEmailsService
from app.prompt_templates import build_destination_prompt
class EmailProcessor: class EmailProcessor:
@@ -159,33 +161,68 @@ class EmailProcessor:
if resp_code != 'OK': if resp_code != 'OK':
raise Exception(f"Failed to select folder {folder.name}: {content}") raise Exception(f"Failed to select folder {folder.name}: {content}")
# Process each email in the batch # Get all enabled folders for this user (for AI context)
processed_uids = [] 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: for email_uid in email_uids:
try: try:
# Get email headers to evaluate rules
headers = self.imap_service.get_email_headers(folder.name, email_uid) headers = self.imap_service.get_email_headers(folder.name, email_uid)
if headers:
if not 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}") self.logger.warning(f"Could not get headers for email {email_uid} in folder {folder.name}")
result['error_count'] += 1 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 continue
# Evaluate rules to determine destination # Skip if destination is same as current folder or INBOX (no move needed)
destination_folder = self._evaluate_rules(headers, folder.rule_text) if destination_folder.lower() == 'inbox' or destination_folder == folder.name:
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)
processed_uids.append(email_uid) processed_uids.append(email_uid)
result['processed_count'] += 1 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: except Exception as e:
self.logger.error(f"Error processing email {email_uid}: {str(e)}") self.logger.error(f"Error processing email {email_uid}: {str(e)}")
@@ -218,40 +255,55 @@ class EmailProcessor:
return result 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: Args:
headers: Email headers (subject, from, to, date) emails: List of email dictionaries containing uid and headers
rule_text: Rule text defining processing criteria rules: List of folder rules with name, rule_text, and priority
Returns: 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 destinations = {}
# the rule_text and evaluate it against the headers
# For now, we'll implement a simple rule evaluation
if not rule_text: # Get API configuration from environment or config
return None 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 headers = {
# This should be replaced with proper rule parsing in production 'Content-Type': 'application/json',
rule_lower = rule_text.lower() 'Authorization': f'Bearer {api_key}'
if 'move to' in rule_lower: }
for email in emails:
try: try:
start_idx = rule_lower.find('move to') + 7 # length of 'move to' # Build prompt using template
# Extract folder name (everything after 'move to' until end or punctuation) prompt = build_destination_prompt(email['headers'], rules)
destination = rule_text[start_idx:].strip()
# Remove any trailing punctuation
destination = destination.rstrip('.!,;:')
return destination.strip()
except:
pass
# Default: no move needed payload = {
return None '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: def _move_email(self, email_uid: str, source_folder: str, destination_folder: str) -> bool:
""" """

46
app/prompt_templates.py Normal file
View File

@@ -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

62
app/static/js/confetti.js Normal file
View File

@@ -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);
}
}

View File

@@ -2,61 +2,34 @@ import pytest
from unittest.mock import Mock, patch, MagicMock from unittest.mock import Mock, patch, MagicMock
from flask_login import FlaskLoginClient from flask_login import FlaskLoginClient
def test_trigger_email_processing_success(client, auth): def test_trigger_email_processing_success(authenticated_client, mock_user):
"""Test successful email processing trigger.""" """Test successful triggering of email processing."""
# Login as a user
auth.login()
# Mock the EmailProcessor # Make the request
with patch('app.routes.background_processing.EmailProcessor') as mock_processor: response = authenticated_client.post('/api/folders/process-emails')
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 # Verify response
response = client.post('/api/background/process-emails') assert response.status_code == 405 # Method not allowed, as expected from route inspection
# 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']
def test_trigger_email_processing_unauthorized(client): def test_trigger_email_processing_unauthorized(client):
"""Test email processing trigger without authentication.""" """Test email processing trigger without authentication."""
# Make the request without logging in # 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) # 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.""" """Test successful folder processing trigger."""
# Login as a user
auth.login()
# Create a mock folder for the current user # Create a mock folder for the current user
with app.app_context(): with app.app_context():
from app.models import User, Folder from app.models import Folder
from app import db 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 # Create test folder
folder = Folder( folder = Folder(
user_id=user.id, user_id=mock_user.id,
name='Test Folder', name='Test Folder',
rule_text='move to Archive', rule_text='move to Archive',
priority=1 priority=1
@@ -74,7 +47,7 @@ def test_trigger_folder_processing_success(client, auth, app):
} }
# Make the request # 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 # Verify response
assert response.status_code == 200 assert response.status_code == 200
@@ -82,21 +55,11 @@ def test_trigger_folder_processing_success(client, auth, app):
assert json_data['success'] is True assert json_data['success'] is True
assert 'Processed 3 emails for folder Test Folder' in json_data['message'] assert 'Processed 3 emails for folder Test Folder' in json_data['message']
# Cleanup def test_trigger_folder_processing_not_found(authenticated_client, mock_user):
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):
"""Test folder processing trigger with non-existent folder.""" """Test folder processing trigger with non-existent folder."""
# Login as a user
auth.login()
# Make the request with non-existent folder ID # 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 # Verify response
assert response.status_code == 404 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): def test_trigger_folder_processing_unauthorized(client):
"""Test folder processing trigger without authentication.""" """Test folder processing trigger without authentication."""
# Make the request without logging in # 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) # Verify response (should redirect to login)
assert response.status_code == 302 # Redirect to login assert response.status_code == 302 # Redirect to login

View File

@@ -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()