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/', 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//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//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/', 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'})