481 lines
20 KiB
Python
481 lines
20 KiB
Python
from flask import Blueprint, render_template, request, jsonify, make_response
|
|
from flask_login import login_required, current_user
|
|
from app import db
|
|
from app.models import Folder, AIRuleCache
|
|
from app.ai_service import AIService
|
|
from datetime import datetime, timedelta
|
|
import logging
|
|
import json
|
|
|
|
# Initialize the AI service instance
|
|
ai_service = AIService()
|
|
|
|
folders_bp = Blueprint('folders', __name__)
|
|
|
|
@folders_bp.route('/')
|
|
@login_required
|
|
def index():
|
|
"""Main page showing all folders."""
|
|
# Get folders for the current authenticated user
|
|
folders = Folder.query.filter_by(user_id=current_user.id).all()
|
|
|
|
return render_template('index.html',
|
|
folders=folders,
|
|
show_hidden=False)
|
|
|
|
@folders_bp.route('/api/folders/new', methods=['GET'])
|
|
@login_required
|
|
def new_folder_modal():
|
|
"""Return the add folder modal."""
|
|
response = make_response(render_template('partials/folder_modal.html'))
|
|
response.headers['HX-Trigger'] = 'open-modal'
|
|
return response
|
|
|
|
@folders_bp.route('/api/folders', methods=['POST'])
|
|
@login_required
|
|
def add_folder():
|
|
"""Add a new folder."""
|
|
try:
|
|
# Get form data instead of JSON
|
|
name = request.form.get('name')
|
|
rule_text = request.form.get('rule_text')
|
|
priority = request.form.get('priority')
|
|
|
|
# Server-side validation
|
|
errors = {}
|
|
if not name or not name.strip():
|
|
errors['name'] = 'Folder name is required'
|
|
elif len(name.strip()) < 3:
|
|
errors['name'] = 'Folder name must be at least 3 characters'
|
|
elif len(name.strip()) > 50:
|
|
errors['name'] = 'Folder name must be less than 50 characters'
|
|
|
|
if not rule_text or not rule_text.strip():
|
|
errors['rule_text'] = 'Rule text is required'
|
|
elif len(rule_text.strip()) < 10:
|
|
errors['rule_text'] = 'Rule text must be at least 10 characters'
|
|
elif len(rule_text.strip()) > 200:
|
|
errors['rule_text'] = 'Rule text must be less than 200 characters'
|
|
|
|
# If there are validation errors, return the modal with errors
|
|
if errors:
|
|
response = make_response(render_template('partials/folder_modal.html', errors=errors, name=name, rule_text=rule_text, priority=priority))
|
|
response.headers['HX-Retarget'] = '#folder-modal'
|
|
response.headers['HX-Reswap'] = 'outerHTML'
|
|
return response
|
|
|
|
# Create new folder for the current user
|
|
# Default to 'destination' type for manually created folders
|
|
folder_type = 'tidy' if name.strip().lower() == 'inbox' else 'destination'
|
|
|
|
# If folder_type is 'ignore', reset emails_count to 0
|
|
if folder_type == 'ignore':
|
|
emails_count = 0
|
|
|
|
folder = Folder(
|
|
user_id=current_user.id,
|
|
name=name.strip(),
|
|
rule_text=rule_text.strip(),
|
|
priority=int(priority) if priority else 0,
|
|
folder_type=folder_type,
|
|
emails_count=0 if folder_type == 'ignore' else None
|
|
)
|
|
|
|
db.session.add(folder)
|
|
db.session.commit()
|
|
|
|
response = make_response('')
|
|
response.headers['HX-Trigger'] = 'close-modal, folder-list-invalidated'
|
|
response.status_code = 201
|
|
return response
|
|
|
|
except Exception as e:
|
|
# Print unhandled exceptions to the console as required
|
|
logging.exception("Error adding folder: %s", e)
|
|
db.session.rollback()
|
|
# Return error in modal
|
|
errors = {'general': 'An unexpected error occurred. Please try again.'}
|
|
# Get updated lists of folders by type for error fallback
|
|
|
|
response = make_response(render_template('partials/folder_modal.html', errors=errors, name=name, rule_text=rule_text, priority=priority))
|
|
response.headers['HX-Retarget'] = '#folder-modal'
|
|
response.headers['HX-Reswap'] = 'outerHTML'
|
|
return response
|
|
|
|
@folders_bp.route('/api/folders/<folder_id>', methods=['DELETE'])
|
|
@login_required
|
|
def delete_folder(folder_id):
|
|
"""Delete a folder."""
|
|
try:
|
|
# Find the folder by ID and ensure it belongs to the current user
|
|
folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first()
|
|
|
|
if not folder:
|
|
# Folder not found
|
|
response = make_response('')
|
|
response.headers['HX-Trigger'] = 'folder-list-invalidated'
|
|
return response
|
|
|
|
# Delete all associated processed emails first
|
|
from app.models import ProcessedEmail
|
|
ProcessedEmail.query.filter_by(folder_id=folder.id).delete()
|
|
|
|
# Delete the folder
|
|
db.session.delete(folder)
|
|
db.session.commit()
|
|
|
|
response = make_response('')
|
|
response.headers['HX-Trigger'] = 'folder-list-invalidated'
|
|
return response
|
|
|
|
except Exception as e:
|
|
# Print unhandled exceptions to the console as required
|
|
logging.exception("Error deleting folder: %s", e)
|
|
db.session.rollback()
|
|
# Return the folders list unchanged
|
|
response = make_response('')
|
|
response.headers['HX-Trigger'] = 'folder-list-invalidated'
|
|
return response
|
|
|
|
@folders_bp.route('/api/folders/<folder_id>/type', methods=['PUT'])
|
|
@login_required
|
|
def update_folder_type(folder_id):
|
|
"""Update the type of a folder."""
|
|
try:
|
|
# Find the folder by ID and ensure it belongs to the current user
|
|
folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first()
|
|
|
|
if not folder:
|
|
return jsonify({'error': 'Folder not found'}), 404
|
|
|
|
# Get the new folder type from form data
|
|
new_folder_type = request.form.get('folder_type')
|
|
|
|
# Validate the folder type
|
|
if new_folder_type not in ['tidy', 'destination', 'ignore']:
|
|
return jsonify({'error': 'Invalid folder type'}), 400
|
|
|
|
# If changing to 'ignore', reset the emails_count to 0
|
|
if new_folder_type == 'ignore' and folder.folder_type != 'ignore':
|
|
folder.emails_count = 0
|
|
|
|
# Update the folder type
|
|
folder.folder_type = new_folder_type
|
|
|
|
db.session.commit()
|
|
|
|
response = make_response('')
|
|
response.headers['HX-Trigger'] = 'folder-list-invalidated'
|
|
return response
|
|
|
|
except Exception as e:
|
|
# Print unhandled exceptions to the console as required
|
|
logging.exception("Error updating folder type: %s", e)
|
|
db.session.rollback()
|
|
return jsonify({'error': 'An unexpected error occurred'}), 500
|
|
|
|
@folders_bp.route('/api/folders/<folder_id>/edit', methods=['GET'])
|
|
@login_required
|
|
def edit_folder_modal(folder_id):
|
|
"""Return the edit folder modal with folder data."""
|
|
try:
|
|
# Find the folder by ID and ensure it belongs to the current user
|
|
folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first()
|
|
|
|
if not folder:
|
|
return jsonify({'error': 'Folder not found'}), 404
|
|
|
|
# Return the edit folder modal with folder data
|
|
response = make_response(render_template('partials/folder_modal.html', folder=folder,
|
|
folder_data={'rule_text': folder.rule_text,
|
|
'show_ai_rules': True,
|
|
'errors': None }))
|
|
response.headers['HX-Trigger'] = 'open-modal'
|
|
return response
|
|
|
|
except Exception as e:
|
|
# Print unhandled exceptions to the console as required
|
|
logging.exception("Error getting folder for edit: %s", e)
|
|
return jsonify({'error': 'Error retrieving folder'}), 500
|
|
|
|
@folders_bp.route('/api/folders/<folder_id>', methods=['PUT'])
|
|
@login_required
|
|
def update_folder(folder_id):
|
|
"""Update an existing folder."""
|
|
try:
|
|
# Find the folder by ID and ensure it belongs to the current user
|
|
folder = Folder.query.filter_by(id=folder_id, user_id=current_user.id).first()
|
|
|
|
if not folder:
|
|
# Folder not found
|
|
response = make_response('')
|
|
response.headers['HX-Trigger'] = 'close-modal, folder-list-invalidated'
|
|
return response
|
|
|
|
# Get form data
|
|
name = request.form.get('name')
|
|
rule_text = request.form.get('rule_text')
|
|
priority = request.form.get('priority')
|
|
|
|
# Server-side validation
|
|
errors = {}
|
|
if not name or not name.strip():
|
|
errors['name'] = 'Folder name is required'
|
|
elif len(name.strip()) < 3:
|
|
errors['name'] = 'Folder name must be at least 3 characters'
|
|
elif len(name.strip()) > 50:
|
|
errors['name'] = 'Folder name must be less than 50 characters'
|
|
|
|
if not rule_text or not rule_text.strip():
|
|
errors['rule_text'] = 'Rule text is required'
|
|
elif len(rule_text.strip()) < 10:
|
|
errors['rule_text'] = 'Rule text must be at least 10 characters'
|
|
elif len(rule_text.strip()) > 200:
|
|
errors['rule_text'] = 'Rule text must be less than 200 characters'
|
|
|
|
# If there are validation errors, return the modal with errors
|
|
if errors:
|
|
response = make_response(render_template('partials/folder_modal.html', folder=folder, errors=errors, name=name, rule_text=rule_text, priority=priority))
|
|
return response
|
|
|
|
# Update folder
|
|
folder.name = name.strip()
|
|
folder.rule_text = rule_text.strip()
|
|
folder.priority = int(priority) if priority else 0
|
|
|
|
# Check if folder type is being changed to 'ignore'
|
|
old_folder_type = folder.folder_type
|
|
folder.folder_type = 'tidy' if name.strip().lower() == 'inbox' else 'destination'
|
|
|
|
# If changing to 'ignore', reset emails_count to 0
|
|
if folder.folder_type == 'ignore' and old_folder_type != 'ignore':
|
|
folder.emails_count = 0
|
|
|
|
db.session.commit()
|
|
|
|
response = make_response('')
|
|
response.headers['HX-Trigger'] = 'close-modal, folder-list-invalidated'
|
|
return response
|
|
|
|
except Exception as e:
|
|
# Print unhandled exceptions to the console as required
|
|
logging.exception("Error updating folder: %s", e)
|
|
db.session.rollback()
|
|
# Return error in modal
|
|
errors = {'general': 'An unexpected error occurred. Please try again.'}
|
|
response = make_response(render_template('partials/folder_modal.html', folder=folder, errors=errors, name=name, rule_text=rule_text, priority=priority))
|
|
response.headers['HX-Retarget'] = '#folder-modal'
|
|
response.headers['HX-Reswap'] = 'outerHTML'
|
|
return response
|
|
|
|
@folders_bp.route('/api/folders', methods=['GET'])
|
|
@login_required
|
|
def get_folders():
|
|
"""Get folders with optional filtering."""
|
|
# Check if this is an event-triggered request
|
|
is_event_triggered = request.headers.get('HX-Trigger') == 'folder-list-invalidated'
|
|
|
|
# Get filter parameter from query string
|
|
filter_type = request.args.get('filter', 'all')
|
|
folder_type = request.args.get('type', None)
|
|
show_hidden = request.args.get('show_hidden', 'off').lower() == 'on'
|
|
|
|
# For event-triggered requests, maintain current filter state
|
|
if is_event_triggered:
|
|
# If this is an event trigger, we need to preserve the current filter state
|
|
# The event listener will have the current state in the DOM
|
|
# So we just return the full list with current show_hidden state
|
|
folders = Folder.query.filter_by(user_id=current_user.id).all()
|
|
return render_template('partials/folders_list.html', folders=folders, show_hidden=show_hidden)
|
|
|
|
# Get folders for the current authenticated user
|
|
if folder_type:
|
|
# Filter by folder type
|
|
folders = Folder.query.filter_by(user_id=current_user.id, folder_type=folder_type).all()
|
|
elif filter_type == 'high':
|
|
# Get high priority folders (priority = 1)
|
|
folders = Folder.query.filter_by(user_id=current_user.id, priority=1).all()
|
|
elif filter_type == 'normal':
|
|
# Get normal priority folders (priority = 0 or not set)
|
|
folders = Folder.query.filter_by(user_id=current_user.id).filter(Folder.priority != 1).all()
|
|
else:
|
|
# Get all folders
|
|
folders = Folder.query.filter_by(user_id=current_user.id).all()
|
|
|
|
if folder_type == 'tidy':
|
|
response = make_response(render_template('partials/folders_to_tidy_section.html', folders=folders, show_hidden=show_hidden))
|
|
response.headers["HX-Retarget"] = "#folders-to-tidy"
|
|
return response
|
|
elif folder_type == 'destination':
|
|
response = make_response(render_template('partials/destination_folders_section.html', folders=folders, show_hidden=show_hidden))
|
|
response.headers["HX-Retarget"] = "#destination-folders"
|
|
return response
|
|
elif folder_type == 'ignore':
|
|
response = make_response(render_template('partials/hidden_folders_section.html', folders=folders, show_hidden=show_hidden))
|
|
response.headers["HX-Retarget"] = "#hidden-folders"
|
|
|
|
# Show or hide the hidden folders section based on the show_hidden parameter
|
|
if show_hidden:
|
|
response.headers['HX-Trigger'] = 'show-hidden'
|
|
else:
|
|
response.headers['HX-Trigger'] = 'hide-hidden'
|
|
|
|
return response
|
|
else:
|
|
return render_template('partials/folders_list.html', folders=folders, show_hidden=show_hidden)
|
|
|
|
@folders_bp.route('/api/folders/generate-rule', methods=['POST'])
|
|
@login_required
|
|
def generate_rule():
|
|
"""Generate an email organization rule using AI."""
|
|
try:
|
|
# Get form data
|
|
folder_name = request.form.get('name', '').strip()
|
|
folder_type = request.form.get('folder_type', 'destination')
|
|
rule_type = request.form.get('rule_type', 'single') # 'single' or 'multiple'
|
|
rule_text = request.form.get('rule_text', '')
|
|
|
|
# Validate inputs
|
|
if not folder_name:
|
|
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Folder name is required'})
|
|
|
|
if folder_type not in ['destination', 'tidy', 'ignore']:
|
|
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Invalid folder type'})
|
|
|
|
if rule_type not in ['single', 'multiple']:
|
|
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Invalid rule type'})
|
|
|
|
# Check cache first
|
|
cache_key = AIRuleCache.generate_cache_key(folder_name, folder_type, rule_type, rule_text)
|
|
cached_rule = AIRuleCache.query.filter_by(
|
|
cache_key=cache_key,
|
|
user_id=current_user.id,
|
|
is_active=True
|
|
).first()
|
|
|
|
if cached_rule and not cached_rule.is_expired():
|
|
# Return cached result
|
|
result = {
|
|
'success': True,
|
|
'cached': True,
|
|
'rule': cached_rule.rule_text,
|
|
'metadata': cached_rule.rule_metadata,
|
|
'quality_score': cached_rule.rule_metadata.get('quality_score', 0) if cached_rule.rule_metadata else 0
|
|
}
|
|
return render_template('partials/ai_rule_result.html', result=result)
|
|
|
|
# Generate new rule using AI service
|
|
if rule_type == 'single':
|
|
rule_text, metadata = ai_service.generate_single_rule(folder_name, folder_type, rule_text)
|
|
|
|
if rule_text is None:
|
|
# AI service failed, return fallback
|
|
fallback_rule = ai_service.get_fallback_rule(folder_name, folder_type)
|
|
result = {
|
|
'success': True,
|
|
'fallback': True,
|
|
'rule': fallback_rule,
|
|
'quality_score': 50,
|
|
'message': 'AI service unavailable, using fallback rule'
|
|
}
|
|
return render_template('partials/ai_rule_result.html', result=result)
|
|
|
|
# Cache the result
|
|
expires_at = datetime.utcnow() + timedelta(hours=1) # Cache for 1 hour
|
|
cache_entry = AIRuleCache(
|
|
user_id=current_user.id,
|
|
folder_name=folder_name,
|
|
folder_type=folder_type,
|
|
rule_text=rule_text,
|
|
rule_metadata=metadata,
|
|
cache_key=cache_key,
|
|
expires_at=expires_at
|
|
)
|
|
db.session.add(cache_entry)
|
|
|
|
db.session.commit()
|
|
|
|
result = {
|
|
'success': True,
|
|
'rule': rule_text,
|
|
'metadata': metadata,
|
|
'quality_score': metadata.get('quality_score', 0)
|
|
}
|
|
return render_template('partials/ai_rule_result.html', result=result)
|
|
|
|
else: # multiple rules
|
|
rules, metadata = ai_service.generate_multiple_rules(folder_name, folder_type, rule_text)
|
|
|
|
if rules is None:
|
|
# AI service failed, return fallback
|
|
fallback_rule = ai_service.get_fallback_rule(folder_name, folder_type)
|
|
result = {
|
|
'success': True,
|
|
'fallback': True,
|
|
'rules': [{'text': fallback_rule, 'quality_score': 50}],
|
|
'message': 'AI service unavailable, using fallback rule'
|
|
}
|
|
return render_template('partials/ai_rule_result.html', result=result)
|
|
|
|
# Cache the first rule as representative
|
|
expires_at = datetime.utcnow() + timedelta(hours=1)
|
|
cache_entry = AIRuleCache(
|
|
user_id=current_user.id,
|
|
folder_name=folder_name,
|
|
folder_type=folder_type,
|
|
rule_text=rules[0]['text'] if rules else '',
|
|
rule_metadata=metadata,
|
|
cache_key=cache_key,
|
|
expires_at=expires_at
|
|
)
|
|
db.session.add(cache_entry)
|
|
|
|
db.session.commit()
|
|
|
|
result = {
|
|
'success': True,
|
|
'rules': rules,
|
|
'metadata': metadata
|
|
}
|
|
return render_template('partials/ai_rule_result.html', result=result)
|
|
|
|
except Exception as e:
|
|
# Print unhandled exceptions to the console as required
|
|
logging.exception("Error generating rule: %s", e)
|
|
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'An unexpected error occurred'})
|
|
|
|
@folders_bp.route('/api/folders/assess-rule', methods=['POST'])
|
|
@login_required
|
|
def assess_rule():
|
|
"""Assess the quality of an email organization rule."""
|
|
try:
|
|
# Get form data
|
|
rule_text = request.form.get('rule_text', '').strip()
|
|
folder_name = request.form.get('folder_name', '').strip()
|
|
folder_type = request.form.get('folder_type', 'destination')
|
|
|
|
# Validate inputs
|
|
if not rule_text:
|
|
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Rule text is required'})
|
|
|
|
if not folder_name:
|
|
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Folder name is required'})
|
|
|
|
if folder_type not in ['destination', 'tidy', 'ignore']:
|
|
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Invalid folder type'})
|
|
|
|
# Assess rule quality
|
|
quality_assessment = ai_service.assess_rule_quality(rule_text, folder_name, folder_type)
|
|
|
|
result = {
|
|
'success': True,
|
|
'assessment': quality_assessment,
|
|
'rule': rule_text,
|
|
'quality_score': quality_assessment['score']
|
|
}
|
|
return render_template('partials/ai_rule_result.html', result=result)
|
|
|
|
except Exception as e:
|
|
# Print unhandled exceptions to the console as required
|
|
logging.exception("Error assessing rule: %s", e)
|
|
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'An unexpected error occurred'}) |