diff --git a/.env b/.env index f12cda8..3c4b108 100644 --- a/.env +++ b/.env @@ -3,7 +3,7 @@ DATABASE_URL=postgresql://postgres:password@localhost:5432/email_organizer_dev # OPENAI_API_KEY=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 # OPENAI_BASE_URL=https://openrouter.ai/api/v1 # OPENAI_MODEL=qwen/qwen3-coder -# + OPENAI_API_KEY=aaoeu -OPENAI_BASE_URL=http://localhost:8082/v1 -OPENAI_MODEL= +OPENAI_BASE_URL=http://localhost:5082/v1 +OPENAI_MODEL=Qwen3-Coder-480B-A35B-Instruct-GGUF-roo diff --git a/QWEN.md b/QWEN.md index 9b8d24d..620de8f 100644 --- a/QWEN.md +++ b/QWEN.md @@ -13,6 +13,9 @@ Here are special rules you must follow: 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. + # Conventions 1. modals are rendered server-side, by targeting #modal-holder, and using an hx-trigger response attribute for open-modal. diff --git a/app/routes/imap.py b/app/routes/imap.py index 10dffd2..d2c8a84 100644 --- a/app/routes/imap.py +++ b/app/routes/imap.py @@ -22,6 +22,65 @@ def imap_config_modal(): response.headers['HX-Trigger'] = 'open-modal' return response +@imap_bp.route('/api/imap/folders/modal', methods=['GET']) +@login_required +def imap_folders_modal(): + """Return the folder selection modal after successful IMAP connection.""" + try: + if not current_user.imap_config: + return jsonify({'error': 'No IMAP configuration found. Please configure IMAP first.'}), 400 + + # Test connection first + imap_service = IMAPService(current_user) + + # Get folders from IMAP server + imap_folders = imap_service.get_folders() + + if not imap_folders: + return jsonify({'error': 'No folders found on IMAP server'}), 400 + + # Deduplicate folders by name to prevent creating multiple entries for the same folder + unique_folders = [] + seen_names = set() + for imap_folder in imap_folders: + folder_name = imap_folder['name'] + + # Skip special folders that might not be needed + if folder_name.lower() in ['sent', 'drafts', 'spam', 'trash']: + continue + + # Use case-insensitive comparison for deduplication + folder_name_lower = folder_name.lower() + if folder_name_lower not in seen_names: + # Check if this folder already exists in the database + existing_folder = Folder.query.filter_by( + user_id=current_user.id, + name=folder_name + ).first() + + # Add folder type if it exists + if existing_folder: + imap_folder['folder_type'] = existing_folder.folder_type + imap_folder['selected'] = True + else: + # Set default folder type + imap_folder['folder_type'] = 'tidy' if folder_name.lower().strip() == 'inbox' else 'destination' + imap_folder['selected'] = True + + unique_folders.append(imap_folder) + seen_names.add(folder_name_lower) + + # Return the folder selection modal + response = make_response(render_template('partials/folder_selection_modal.html', folders=unique_folders)) + response.headers['hx-retarget'] = "#modal-holder" + response.headers['HX-Trigger'] = 'open-modal' + return response + + except Exception as e: + logging.exception("Error getting IMAP folders modal: %s", e) + print(e) + return jsonify({'error': 'An unexpected error occurred while fetching folders'}), 500 + @imap_bp.route('/api/imap/test', methods=['POST']) @login_required def test_imap_connection(): @@ -75,10 +134,10 @@ def test_imap_connection(): current_user.imap_config = test_config db.session.commit() - response = make_response(render_template('partials/imap_config_modal.html', - success=True, message=message)) - response.headers['HX-Retarget'] = '#imap-modal' - response.headers['HX-Reswap'] = 'outerHTML' + # Redirect to folder selection modal after successful connection + response = make_response('') + response.headers['HX-Trigger'] = 'open-modal' + response.headers['HX-Location'] = '/api/imap/folders/modal' else: print(message) response = make_response(render_template('partials/imap_config_modal.html', @@ -97,10 +156,168 @@ def test_imap_connection(): response.headers['HX-Reswap'] = 'outerHTML' return response +@imap_bp.route('/api/imap/folders', methods=['GET']) +@login_required +def get_imap_folders(): + """Get folders from IMAP server without creating database records.""" + try: + if not current_user.imap_config: + return jsonify({'error': 'No IMAP configuration found. Please configure IMAP first.'}), 400 + + # Test connection first + imap_service = IMAPService(current_user) + + # Get folders from IMAP server + imap_folders = imap_service.get_folders() + + if not imap_folders: + return jsonify({'error': 'No folders found on IMAP server'}), 400 + + # Deduplicate folders by name to prevent creating multiple entries for the same folder + unique_folders = [] + seen_names = set() + for imap_folder in imap_folders: + folder_name = imap_folder['name'] + + # Skip special folders that might not be needed + if folder_name.lower() in ['sent', 'drafts', 'spam', 'trash']: + continue + + # Use case-insensitive comparison for deduplication + folder_name_lower = folder_name.lower() + if folder_name_lower not in seen_names: + unique_folders.append(imap_folder) + seen_names.add(folder_name_lower) + + return jsonify({'folders': unique_folders}) + + except Exception as e: + logging.exception("Error getting IMAP folders: %s", e) + print(e) + return jsonify({'error': 'An unexpected error occurred while fetching folders'}), 500 + +@imap_bp.route('/api/imap/sync-selected', methods=['POST']) +@login_required +def sync_selected_folders(): + """Sync only the selected folders from IMAP server with processed email tracking.""" + try: + if not current_user.imap_config: + return jsonify({'error': 'No IMAP configuration found. Please configure IMAP first.'}), 400 + + # Get selected folder names and types from form data + selected_folders = {} + for key, value in request.form.items(): + if key.startswith('folder_'): + folder_name = value + # Check if there's a corresponding folder type + type_key = f'folder_type_{key.split("_")[1]}' + folder_type = request.form.get(type_key, 'destination') # Default to 'destination' + selected_folders[folder_name] = folder_type + + if not selected_folders: + return jsonify({'error': 'No folders selected'}), 400 + + # Test connection first + imap_service = IMAPService(current_user) + + # Get folders from IMAP server + imap_folders = imap_service.get_folders() + + if not imap_folders: + return jsonify({'error': 'No folders found on IMAP server'}), 400 + + # Deduplicate folders by name to prevent creating multiple entries for the same folder + unique_folders = [] + seen_names = set() + for imap_folder in imap_folders: + folder_name = imap_folder['name'] + + # Skip special folders that might not be needed + if folder_name.lower() in ['sent', 'drafts', 'spam', 'trash']: + continue + + # Only include folders that were selected by the user + if folder_name in selected_folders: + # Use case-insensitive comparison for deduplication + folder_name_lower = folder_name.lower() + if folder_name_lower not in seen_names: + # Add the selected folder type + imap_folder['selected_type'] = selected_folders[folder_name] + unique_folders.append(imap_folder) + seen_names.add(folder_name_lower) + + # Process each selected unique folder + synced_count = 0 + processed_emails_service = ProcessedEmailsService(current_user) + + # Create a list of folders to process + folders_to_process = [] + + for imap_folder in unique_folders: + folder_name = imap_folder['name'].strip() + folder_type = imap_folder.get('selected_type', 'destination') # Get the selected type + + # Handle nested folder names (convert slashes to underscores or keep as-is) + # According to requirements, nested folders should be created with slashes in the name + display_name = folder_name + + # Check if folder already exists + existing_folder = Folder.query.filter_by( + user_id=current_user.id, + name=display_name + ).first() + + if not existing_folder: + # Create new folder with the selected type + new_folder = Folder( + user_id=current_user.id, + name=display_name, + rule_text=f"Auto-synced from IMAP folder: {folder_name}", + priority=0, # Default priority + folder_type=folder_type + ) + db.session.add(new_folder) + synced_count += 1 + folders_to_process.append(new_folder) + else: + # Update existing folder with the selected type and email counts + existing_folder.folder_type = folder_type + + # Get all email UIDs in this folder + email_uids = imap_service.get_folder_email_uids(folder_name) + + # Sync with processed emails service + new_emails_count = processed_emails_service.sync_folder_emails(display_name, email_uids) + print("NEW", new_emails_count) + + # Update counts + pending_count = processed_emails_service.get_pending_count(display_name) + existing_folder.pending_count = pending_count + existing_folder.total_count = len(email_uids) + + # Get the most recent emails for this folder + recent_emails = imap_service.get_recent_emails(folder_name, 3) + existing_folder.recent_emails = recent_emails + + folders_to_process.append(existing_folder) + + db.session.commit() + + # Just trigger the folder list update and close the modal + response = make_response('') + response.headers['HX-Trigger'] = 'close-modal, folder-list-invalidated' + return response + + except Exception as e: + logging.exception("Error syncing selected IMAP folders: %s", e) + print(e) + db.session.rollback() + return jsonify({'error': 'An unexpected error occurred'}), 500 + @imap_bp.route('/api/imap/sync', methods=['POST']) @login_required def sync_imap_folders(): - """Sync folders from IMAP server with processed email tracking.""" + """Create and sync folders from IMAP server with processed email tracking.""" try: if not current_user.imap_config: return jsonify({'error': 'No IMAP configuration found. Please configure IMAP first.'}), 400 diff --git a/app/static/css/animations.css b/app/static/css/animations.css new file mode 100644 index 0000000..1881c4e --- /dev/null +++ b/app/static/css/animations.css @@ -0,0 +1,98 @@ +/* Modal transition animations using Tailwind classes */ +/* +.slide-left-move { + transform: translateX(0); + opacity: 1; + transition: all 300ms ease-out; +} + +.slide-left-enter-from { + transform: translateX(100%); + opacity: 0; +} + +.slide-left-enter-to { + transform: translateX(0); + opacity: 1; + transition: all 300ms ease-out; +} + +.slide-left-leave-from { + transform: translateX(0); + opacity: 1; +} + +.slide-left-leave-to { + transform: translateX(-100%); + opacity: 0; + transition: all 300ms ease-in; +} + +.slide-right-move { + transform: translateX(0); + opacity: 1; + transition: all 300ms ease-out; +} + +.slide-right-enter-from { + transform: translateX(-100%); + opacity: 0; +} + +.slide-right-enter-to { + transform: translateX(0); + opacity: 1; + transition: all 300ms ease-out; +} + +.slide-right-leave-from { + transform: translateX(0); + opacity: 1; +} + +.slide-right-leave-to { + transform: translateX(100%); + opacity: 0; + transition: all 300ms ease-in; +} + +.translate-up-move { + transform: translateY(0); + opacity: 1; + transition: all 300ms ease-out; +} + +.translate-up-enter-from { + transform: translateY(100%); + opacity: 0; +} + +.translate-up-enter-to { + transform: translateY(0); + opacity: 1; + transition: all 300ms ease-out; +} + +.translate-up-leave-from { + transform: translateY(0); + opacity: 1; +} + +.translate-up-leave-to { + transform: translateY(-100%); + opacity: 0; + transition: all 300ms ease-in; +} + +.step-1 .steps .step:first-child, +.step-2 .steps .step:last-child { + --tw-step-text: hsl(var(--bc)); + --tw-step-primary: hsl(var(--p)); +} + +.step-1 .steps .step:last-child, +.step-2 .steps .step:first-child { + --tw-step-text: hsl(var(--bc)); + color: hsl(var(--bc)); +} +*/ \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index b5eb412..078f792 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -13,6 +13,7 @@ +