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