This commit is contained in:
2025-08-05 14:37:49 -07:00
parent 27fc2e29a1
commit 5d87be1d96
10 changed files with 123 additions and 136 deletions

View File

@@ -263,8 +263,9 @@ class IMAPService:
if not imap_folders:
return False, "No folders found on IMAP server"
# Process each folder
synced_count = 0
# 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']
@@ -272,6 +273,17 @@ class IMAPService:
if folder_name.lower() in ['inbox', '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)
# Process each unique folder
synced_count = 0
for imap_folder in unique_folders:
folder_name = imap_folder['name']
# 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

View File

@@ -234,6 +234,7 @@ def imap_config_modal():
server=current_user.imap_config.get('server') if current_user.imap_config else None,
port=current_user.imap_config.get('port') if current_user.imap_config else None,
username=current_user.imap_config.get('username') if current_user.imap_config else None,
password=current_user.imap_config.get('password') if current_user.imap_config else None,
use_ssl=current_user.imap_config.get('use_ssl', True) if current_user.imap_config else True))
response.headers['HX-Trigger'] = 'open-modal'
return response
@@ -243,8 +244,6 @@ def imap_config_modal():
def test_imap_connection():
"""Test IMAP connection with provided configuration."""
try:
import time
time.sleep(5)
# Get form data
server = request.form.get('server')
port = request.form.get('port')
@@ -337,4 +336,23 @@ def sync_imap_folders():
except Exception as e:
logging.exception("Error syncing IMAP folders: %s", e)
print(e)
return jsonify({'error': 'An unexpected error occurred. Please try again.'}), 500
@main.route('/api/folders', methods=['GET'])
@login_required
def get_folders():
"""Get folders with optional filtering."""
# Get filter parameter from query string
filter_type = request.args.get('filter', 'all')
# Get folders for the current authenticated user
if 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()
return render_template('partials/folders_list.html', folders=folders)

View File

@@ -22,6 +22,19 @@
.shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
.fade-in-htmx.htmx-added {
opacity: 0;
}
.fade-in-htmx {
opacity: 1;
transition: opacity 1s ease-out;
}
/* Fade out transition for HTMX */
.fade-out-htmx.htmx-swapping {
opacity: 0;
transition: opacity 1s ease-out;
}
</style>
{% block head %}{% endblock %}
</head>

View File

@@ -96,9 +96,9 @@
<i class="fas fa-search absolute right-3 top-3 text-base-content/50"></i>
</div>
<div class="flex space-x-2">
<button class="btn btn-sm btn-outline">All</button>
<button class="btn btn-sm btn-outline">High Priority</button>
<button class="btn btn-sm btn-outline">Normal</button>
<button class="btn btn-sm btn-outline" hx-get="/api/folders?filter=all" hx-target="#folders-list" hx-swap="innerHTML swap1:s">All</button>
<button class="btn btn-sm btn-outline" hx-get="/api/folders?filter=high" hx-target="#folders-list" hx-swap="innerHTML swap1:s">High Priority</button>
<button class="btn btn-sm btn-outline" hx-get="/api/folders?filter=normal" hx-target="#folders-list" hx-swap="innerHTML swap1:s">Normal</button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<div id="folder-{{ folder.id }}" class="card bg-base-100 shadow-xl border border-base-300 hover:shadow-lg transition-shadow duration-200">
<div id="folder-{{ folder.id }}" class="card bg-base-100 shadow-xl border border-base-300 hover:shadow-lg">
<div class="card-body" data-loading-states>
<div class="flex justify-between items-start mb-2">
@@ -14,10 +14,10 @@
<i class="fas fa-edit" data-loading-class="!hidden"></i>
<span class="loading loading-spinner loading-xs hidden" data-loading-class-remove="hidden"></span>
</button>
<button class="btn btn-sm btn-outline btn-error"
<button class="btn btn-sm btn-outline btn-error fade-me-out"
hx-delete="/api/folders/{{ folder.id }}"
hx-target="#folders-list"
hx-swap="innerHTML"
hx-swap="innerHTML swap:1s"
hx-confirm="Are you sure you want to delete this folder?"
data-loading-disable
>
@@ -30,8 +30,8 @@
<!-- Email count badges placed below title but in a separate row -->
<div class="flex justify-between items-center mb-2">
<div class="flex space-x-1">
<span class="badge badge-outline">{{ folder.total_count }} emails</span>
<span class="badge badge-secondary" x-tooltip.raw="{% if folder.recent_emails %}<table class='text-xs'><tr><th class='text-left pr-2'>Subject</th><th class='text-left'>Date</th></tr>{% for email in folder.recent_emails %}<tr><td class='text-left pr-2 truncate max-w-[150px]'>{{ email.subject }}</td><td class='text-left'>{{ email.date[:10] if email.date else 'N/A' }}</td></tr>{% endfor %}</table>{% else %}No recent emails{% endif %}">{{ folder.pending_count }} pending</span>
<span class="badge badge-outline cursor-pointer">{{ folder.total_count }} emails</span>
<span class="badge badge-secondary cursor-pointer" x-tooltip.raw.html="{% if folder.recent_emails %}<table class='text-xs'><tr><th class='text-left pr-2'>Subject</th><th class='text-left'>Date</th></tr>{% for email in folder.recent_emails %}<tr><td class='text-left pr-2 truncate max-w-[150px]'>{{ email.subject }}</td><td class='text-left'>{{ email.date[:10] if email.date else 'N/A' }}</td></tr>{% endfor %}</table>{% else %}No recent emails{% endif %}">{{ folder.pending_count }} pending</span>
</div>
{% if folder.priority == 1 %}
<span class="badge badge-error">High Priority</span>

View File

@@ -1,4 +1,4 @@
<div id="folders-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div id="folders-list" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 fade-in-htmx">
{% for folder in folders %}
{% include 'partials/folder_card.html' %}
{% else %}

View File

@@ -53,7 +53,8 @@
<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>
placeholder="App password or account password" required
value="{% if password is defined %}{{ password }}{% endif %}">
{% if errors and errors.password %}
<div class="text-error text-sm mt-1">{{ errors.password }}</div>
{% endif %}

View File

@@ -3,61 +3,33 @@ from app import create_app
from app.models import User, db, Folder
class TestIMAPRoutes:
def test_imap_config_modal(self, client):
# Create a test user and log in
user = User(email='test@example.com', first_name='Test', last_name='User')
user.set_password('password')
db.session.add(user)
db.session.commit()
client.post('/auth/login', data={
'email': 'test@example.com',
'password': 'password'
})
response = client.get('/api/imap/config')
def test_imap_config_modal(self, authenticated_client):
response = authenticated_client.get('/api/imap/config')
assert response.status_code == 200
assert b'Configure IMAP Connection' in response.data
def test_imap_connection_test_success(self, client, app):
with app.app_context():
# Create a test user and log in
user = User(email='test2@example.com', first_name='Test', last_name='User')
user.set_password('password')
db.session.add(user)
db.session.commit()
def test_imap_connection_test_success(self, authenticated_client, app):
response = authenticated_client.post('/api/imap/test', data={
'server': 'localhost',
'port': '5143',
'username': 'user1@example.com',
'password': 'password1',
'use_ssl': False
})
print(response.data)
client.post('/auth/login', data={
'email': 'test2@example.com',
'password': 'password'
})
assert response.status_code == 200
# Should show either success or error message
assert b'Test Connection' in response.data
response = client.post('/api/imap/test', data={
'server': 'test.com',
'port': '5153',
'username': 'user1@example.com',
'password': 'password1',
'use_ssl': 'off'
})
assert response.status_code == 200
# Should show either success or error message
assert b'Test Connection' in response.data
def test_imap_sync_folders(self, client, app):
with app.app_context():
# Create a test user and log in
user = User(email='test3@example.com', first_name='Test', last_name='User')
user.set_password('password')
user.imap_config = {'server': 'test.com', 'port': 993, 'username': 'test', 'password': 'pass'}
db.session.add(user)
db.session.commit()
client.post('/auth/login', data={
'email': 'test3@example.com',
'password': 'password'
})
response = client.post('/api/imap/sync')
# Should fail without real IMAP server but return proper response
assert response.status_code in [200, 400]
def test_imap_sync_folders(self, authenticated_client, app, mock_user):
# Create a test user and log in
mock_user.imap_config = {'server': 'localhost', 'port': 5143, 'username': 'user1@example.com', 'password': 'password1', 'use_ssl': False}
db.session.commit()
folders = Folder.query.filter_by(user_id=mock_user.id).all()
response = authenticated_client.post('/api/imap/sync')
print('respo', response.data, response)
new_folders = Folder.query.filter_by(user_id=mock_user.id).all()
assert len(new_folders) > len(folders)
# Should fail without real IMAP server but return proper response
assert response.status_code in [200, 400]

View File

@@ -21,7 +21,6 @@ class TestIMAPService:
assert imap_service.user == user
assert imap_service.config == {}
@patch('app.imap_service.imaplib.IMAP4_SSL')
def test_test_connection_success(self, mock_imap, app):
with app.app_context():
# Mock successful connection
@@ -33,17 +32,14 @@ class TestIMAPService:
mock_connection.logout.return_value = None
user = User(email='test@example.com', first_name='Test', last_name='User')
user.imap_config = {'server': 'test.com', 'port': 993, 'username': 'user', 'password': 'pass'}
user.imap_config = {'server': 'localhost', 'port': 5143, 'username': 'user1@example.com', 'password': 'password1', 'use_ssl': False}
imap_service = IMAPService(user)
success, message = imap_service.test_connection()
assert success is True
assert message == "Connection successful"
mock_imap.assert_called_once_with('test.com', 993)
mock_connection.login.assert_called_once_with('user', 'pass')
assert success is True
@patch('app.imap_service.imaplib.IMAP4_SSL')
def test_test_connection_failure(self, mock_imap, app):
with app.app_context():
# Mock failed connection
@@ -153,68 +149,43 @@ class TestIMAPService:
assert len(folders) == 0
@patch('app.imap_service.imaplib.IMAP4_SSL')
def test_sync_folders_success(self, mock_imap, app):
with app.app_context():
# Mock successful folder listing and database operations
mock_connection = Mock()
mock_imap.return_value = mock_connection
mock_connection.login.return_value = None
mock_connection.list.return_value = ('OK', [b'(\\HasNoChildren) "INBOX"', b'(\\HasNoChildren) "CustomFolder"', b'(\\HasNoChildren) "AnotherFolder"'])
mock_connection.close.return_value = None
mock_connection.logout.return_value = None
def test_sync_folders_success(self, app):
user = User(email='test@example.com', first_name='Test', last_name='User')
user.imap_config = {'server': 'localhost', 'port': 5143, 'username': 'user1@example.com', 'password': 'password1'}
imap_service = IMAPService(user)
user = User(email='test@example.com', first_name='Test', last_name='User')
user.imap_config = {'server': 'test.com', 'port': 993, 'username': 'user', 'password': 'pass'}
imap_service = IMAPService(user)
success, message = imap_service.sync_folders()
success, message = imap_service.sync_folders()
assert success is True
assert "Successfully synced 2 folders" in message
assert success is True
assert "Successfully synced 2 folders" in message
@patch('app.imap_service.imaplib.IMAP4_SSL')
def test_sync_folders_no_config(self, mock_imap, app):
with app.app_context():
user = User(email='test@example.com', first_name='Test', last_name='User')
imap_service = IMAPService(user)
user = User(email='test@example.com', first_name='Test', last_name='User')
imap_service = IMAPService(user)
success, message = imap_service.sync_folders()
success, message = imap_service.sync_folders()
assert success is False
assert message == "No IMAP configuration found"
assert success is False
assert message == "No IMAP configuration found"
@patch('app.imap_service.imaplib.IMAP4_SSL')
def test_sync_folders_no_folders(self, mock_imap, app):
with app.app_context():
# Mock empty folder listing
mock_connection = Mock()
mock_imap.return_value = mock_connection
mock_connection.login.return_value = None
mock_connection.list.return_value = ('OK', [])
mock_connection.close.return_value = None
mock_connection.logout.return_value = None
user = User(email='test@example.com', first_name='Test', last_name='User')
user.imap_config = {'server': 'test.com', 'port': 993, 'username': 'user', 'password': 'pass'}
imap_service = IMAPService(user)
user = User(email='test@example.com', first_name='Test', last_name='User')
user.imap_config = {'server': 'localhost', 'port': 5143, 'username': 'user1@example.com', 'password': 'password1', 'use_ssl': False}
imap_service = IMAPService(user)
success, message = imap_service.sync_folders()
success, message = imap_service.sync_folders()
assert success is False
assert message == "No folders found on IMAP server"
assert success is False
assert message == "No folders found on IMAP server"
@patch('app.imap_service.imaplib.IMAP4_SSL')
def test_sync_folders_exception(self, mock_imap, app):
with app.app_context():
# Mock exception during sync
mock_imap.side_effect = Exception("Sync error")
def test_sync_folders_exception(self, app):
user = User(email='test@example.com', first_name='Test', last_name='User')
user.imap_config = {'server': 'test.com', 'port': 993, 'username': 'user', 'password': 'pass'}
imap_service = IMAPService(user)
user = User(email='test@example.com', first_name='Test', last_name='User')
user.imap_config = {'server': 'localhost', 'port': 5143, 'username': 'user1@example.com', 'password': 'password1', 'use_ssl': False }
imap_service = IMAPService(user)
success, message = imap_service.sync_folders()
success, message = imap_service.sync_folders()
assert success is False
assert "Sync error" in message
assert success is False
assert "Sync error" in message

0
tmp/.hold Normal file → Executable file
View File