changes
This commit is contained in:
2
.env
2
.env
@@ -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
52
.goosehints
Normal 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
|
||||||
@@ -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
46
app/prompt_templates.py
Normal 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
62
app/static/js/confetti.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
175
tests/test_email_destination.py
Normal file
175
tests/test_email_destination.py
Normal 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()
|
||||||
Reference in New Issue
Block a user