imap progress

This commit is contained in:
Bryce
2025-08-04 07:34:34 -07:00
parent 34d2913165
commit 31088cf112
11 changed files with 1405 additions and 5 deletions

167
app/imap_service.py Normal file
View File

@@ -0,0 +1,167 @@
import imaplib
import ssl
import logging
from typing import List, Dict, Optional, Tuple
from email.header import decode_header
from app.models import db, Folder, User
from app import create_app
class IMAPService:
def __init__(self, user: User):
self.user = user
self.config = user.imap_config or {}
self.connection = None
def test_connection(self) -> Tuple[bool, str]:
"""Test IMAP connection with current configuration."""
try:
if not self.config:
return False, "No IMAP configuration found"
# Create SSL context
context = ssl.create_default_context()
# Connect to IMAP server
self.connection = imaplib.IMAP4_SSL(
self.config.get('server', 'imap.gmail.com'),
self.config.get('port', 993)
)
# Login
self.connection.login(
self.config.get('username', ''),
self.config.get('password', '')
)
# Select inbox to verify connection
self.connection.select('INBOX')
# Close connection
self.connection.close()
self.connection.logout()
return True, "Connection successful"
except imaplib.IMAP4.error as e:
return False, f"IMAP connection error: {str(e)}"
except Exception as e:
return False, f"Connection error: {str(e)}"
finally:
if self.connection:
try:
self.connection.logout()
except:
pass
def get_folders(self) -> List[Dict[str, str]]:
"""Get list of folders from IMAP server."""
try:
if not self.config:
return []
# Create SSL context
context = ssl.create_default_context()
# Connect to IMAP server
self.connection = imaplib.IMAP4_SSL(
self.config.get('server', 'imap.gmail.com'),
self.config.get('port', 993)
)
# Login
self.connection.login(
self.config.get('username', ''),
self.config.get('password', '')
)
# List folders
status, folder_data = self.connection.list()
if status != 'OK':
return []
folders = []
for folder_item in folder_data:
if isinstance(folder_item, bytes):
folder_item = folder_item.decode('utf-8')
# Parse folder name (handle different IMAP server formats)
parts = folder_item.split('"')
if len(parts) >= 3:
folder_name = parts[-1] if parts[-1] else parts[-2]
else:
folder_name = folder_item.split()[-1]
# Handle nested folders (convert to slash notation)
if folder_name.startswith('"') and folder_name.endswith('"'):
folder_name = folder_name[1:-1]
folders.append({
'name': folder_name,
'full_path': folder_name
})
# Close connection
self.connection.close()
self.connection.logout()
return folders
except Exception as e:
logging.error(f"Error fetching IMAP folders: {str(e)}")
return []
finally:
if self.connection:
try:
self.connection.logout()
except:
pass
def sync_folders(self) -> Tuple[bool, str]:
"""Sync IMAP folders with local database."""
try:
if not self.config:
return False, "No IMAP configuration found"
# Get folders from IMAP server
imap_folders = self.get_folders()
if not imap_folders:
return False, "No folders found on IMAP server"
# Process each folder
synced_count = 0
for imap_folder in imap_folders:
folder_name = imap_folder['name']
# Skip special folders that might not be needed
if folder_name.lower() in ['inbox', 'sent', 'drafts', 'spam', 'trash']:
continue
# 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=self.user.id,
name=display_name
).first()
if not existing_folder:
# Create new folder
new_folder = Folder(
user_id=self.user.id,
name=display_name,
rule_text=f"Auto-synced from IMAP folder: {folder_name}",
priority=0 # Default priority
)
db.session.add(new_folder)
synced_count += 1
db.session.commit()
return True, f"Successfully synced {synced_count} folders"
except Exception as e:
db.session.rollback()
return False, f"Sync error: {str(e)}"

View File

@@ -2,6 +2,7 @@ from flask import Blueprint, render_template, request, jsonify, make_response, f
from flask_login import login_required, current_user
from app import db
from app.models import Folder, User
from app.imap_service import IMAPService
import uuid
import logging
@@ -174,7 +175,7 @@ def update_folder(folder_id):
response.headers['HX-Retarget'] = '#folder-modal'
response.headers['HX-Reswap'] = 'outerHTML'
return response
# Update folder
folder.name = name.strip()
folder.rule_text = rule_text.strip()
@@ -198,4 +199,107 @@ def update_folder(folder_id):
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
return response
@main.route('/api/imap/config', methods=['GET'])
@login_required
def imap_config_modal():
"""Return the IMAP configuration modal."""
response = make_response(render_template('partials/imap_config_modal.html'))
response.headers['HX-Trigger'] = 'open-modal'
return response
@main.route('/api/imap/test', methods=['POST'])
@login_required
def test_imap_connection():
"""Test IMAP connection with provided configuration."""
try:
# Get form data
server = request.form.get('server')
port = request.form.get('port')
username = request.form.get('username')
password = request.form.get('password')
use_ssl = request.form.get('use_ssl') == 'on'
# Validate required fields
errors = {}
if not server:
errors['server'] = 'Server is required'
if not port:
errors['port'] = 'Port is required'
elif not port.isdigit():
errors['port'] = 'Port must be a number'
if not username:
errors['username'] = 'Username is required'
if not password:
errors['password'] = 'Password is required'
if errors:
response = make_response(render_template('partials/imap_config_modal.html', errors=errors))
response.headers['HX-Retarget'] = '#imap-modal'
response.headers['HX-Reswap'] = 'outerHTML'
return response
# Store configuration temporarily for testing
test_config = {
'server': server,
'port': int(port),
'username': username,
'password': password,
'use_ssl': use_ssl,
'use_tls': False,
'connection_timeout': 30
}
# Test connection
temp_user = type('User', (), {'imap_config': test_config})()
imap_service = IMAPService(temp_user)
success, message = imap_service.test_connection()
if success:
# Save configuration to user's profile
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'
else:
response = make_response(render_template('partials/imap_config_modal.html',
errors={'general': message}))
response.headers['HX-Retarget'] = '#imap-modal'
response.headers['HX-Reswap'] = 'outerHTML'
return response
except Exception as e:
logging.exception("Error testing IMAP connection: %s", e)
errors = {'general': 'An unexpected error occurred. Please try again.'}
response = make_response(render_template('partials/imap_config_modal.html', errors=errors))
response.headers['HX-Retarget'] = '#imap-modal'
response.headers['HX-Reswap'] = 'outerHTML'
return response
@main.route('/api/imap/sync', methods=['POST'])
@login_required
def sync_imap_folders():
"""Sync folders from IMAP server."""
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)
success, message = imap_service.sync_folders()
if success:
# Get updated list of folders
folders = Folder.query.filter_by(user_id=current_user.id).all()
return render_template('partials/folders_list.html', folders=folders)
else:
return jsonify({'error': message}), 400
except Exception as e:
logging.exception("Error syncing IMAP folders: %s", e)
return jsonify({'error': 'An unexpected error occurred. Please try again.'}), 500

View File

@@ -3,7 +3,7 @@
<div class="card bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
<div class="flex justify-between items-start mb-2">
<h3 class="text-xl font-bold">{{ folder.name }}</h3>
<h3 class="text-xl font-bold truncate">{{ folder.name }}</h3>
<div class="flex space-x-2">
<button class="btn btn-sm btn-outline"
hx-get="/api/folders/{{ folder.id }}/edit"

View File

@@ -0,0 +1,87 @@
<div id="imap-modal" @click.away="$refs.modal.close()" class="modal-box" x-data="{ errors: {{ 'true' if errors else 'false' }} }" x-init="$nextTick(() => { if (errors) { document.querySelector('#submit-btn').classList.add('shake'); } })">
<h3 class="font-bold text-lg mb-4">Configure IMAP Connection</h3>
{% if success %}
<div class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{{ message }}</span>
</div>
{% endif %}
{% if errors and errors.general %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{{ errors.general }}</span>
</div>
{% endif %}
<form id="imap-form" hx-post="/api/imap/test" hx-target="#imap-modal" hx-swap="outerHTML">
<div class="mb-4">
<label for="imap-server" class="block text-sm font-medium mb-1">IMAP Server</label>
<input type="text" id="imap-server" name="server"
class="input input-bordered w-full {% if errors and errors.server %}input-error{% endif %}"
placeholder="e.g., imap.gmail.com" required
value="{% if server is defined %}{{ server }}{% endif %}">
{% if errors and errors.server %}
<div class="text-error text-sm mt-1">{{ errors.server }}</div>
{% endif %}
</div>
<div class="mb-4">
<label for="imap-port" class="block text-sm font-medium mb-1">Port</label>
<input type="number" id="imap-port" name="port"
class="input input-bordered w-full {% if errors and errors.port %}input-error{% endif %}"
placeholder="e.g., 993" required
value="{% if port is defined %}{{ port }}{% else %}993{% endif %}">
{% if errors and errors.port %}
<div class="text-error text-sm mt-1">{{ errors.port }}</div>
{% endif %}
</div>
<div class="mb-4">
<label for="imap-username" class="block text-sm font-medium mb-1">Username</label>
<input type="text" id="imap-username" name="username"
class="input input-bordered w-full {% if errors and errors.username %}input-error{% endif %}"
placeholder="e.g., your-email@gmail.com" required
value="{% if username is defined %}{{ username }}{% endif %}">
{% if errors and errors.username %}
<div class="text-error text-sm mt-1">{{ errors.username }}</div>
{% endif %}
</div>
<div class="mb-4">
<label for="imap-password" class="block text-sm font-medium mb-1">Password</label>
<input type="password" id="imap-password" name="password"
class="input input-bordered w-full {% if errors and errors.password %}input-error{% endif %}"
placeholder="App password or account password" required>
{% if errors and errors.password %}
<div class="text-error text-sm mt-1">{{ errors.password }}</div>
{% endif %}
</div>
<div class="mb-6">
<label class="flex items-center cursor-pointer">
<input type="checkbox" id="imap-use-ssl" name="use_ssl" class="checkbox mr-2" checked>
<span class="text-sm font-medium">Use SSL (Recommended)</span>
</label>
<p class="text-xs text-base-content/70 mt-1">Most IMAP servers use SSL on port 993</p>
</div>
<div class="modal-action">
<button type="button" class="btn btn-outline"
@click="$dispatch('close-modal')">Cancel</button>
<button type="submit" class="btn btn-primary" id="submit-btn" :class="{ 'shake': errors }">
Test Connection
</button>
</div>
</form>
{% if success %}
<div class="mt-4 pt-4 border-t border-base-300">
<button class="btn btn-success w-full" hx-post="/api/imap/sync" hx-target="#folders-list" hx-swap="innerHTML">
<i class="fas fa-sync mr-2"></i>
Sync Folders
</button>
</div>
{% endif %}
</div>

View File

@@ -14,9 +14,9 @@
</a>
</div>
<div>
<a class="btn btn-ghost justify-start">
<a class="btn btn-ghost justify-start" hx-get="/api/imap/config" hx-target="#modal-holder" hx-swap="innerHTML">
<i class="fas fa-cog mr-3 text-accent"></i>
Settings
IMAP Settings
</a>
</div>
<div>