Skip to content

Commit 4efa88b

Browse files
committed
wip: import imap label
1 parent e2aaae3 commit 4efa88b

File tree

6 files changed

+296
-66
lines changed

6 files changed

+296
-66
lines changed

src/backend/core/mda/inbound.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ def deliver_inbound_message(
168168
parsed_email: Dict[str, Any],
169169
raw_data: bytes,
170170
is_import: bool = False,
171+
label_name: str = "",
171172
) -> bool: # Return True on success, False on failure
172173
"""Deliver a parsed inbound email message to the correct mailbox and thread.
173174
@@ -207,6 +208,17 @@ def deliver_inbound_message(
207208
snippet=snippet,
208209
count_unread=1,
209210
)
211+
if label_name:
212+
# Create or get label for the thread
213+
label, _ = models.Label.objects.get_or_create(
214+
name=label_name,
215+
mailbox=mailbox,
216+
)
217+
# Add thread to label
218+
label.threads.add(thread)
219+
logger.info(
220+
"Created and linked label %s to thread %s", label_name, thread.id
221+
)
210222
# Create a thread access for the sender mailbox
211223
models.ThreadAccess.objects.create(
212224
thread=thread,

src/backend/core/tasks.py

Lines changed: 223 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
# pylint: disable=unused-argument, broad-exception-raised, broad-exception-caught
44
import imaplib
55
from typing import Any, Dict, List, Tuple
6+
import quopri
7+
from email.header import decode_header, make_header
8+
from urllib.parse import quote, unquote
69

710
from django.conf import settings
811

@@ -393,6 +396,47 @@ def split_mbox_file(content: bytes) -> List[bytes]:
393396
return messages[::-1]
394397

395398

399+
def decode_imap_folder_name(folder_name: str) -> str:
400+
"""Decode IMAP folder name from quoted-printable or URL encoding."""
401+
try:
402+
# Handle Gmail's modified UTF-7 encoding
403+
if '&' in folder_name and folder_name.endswith('-'):
404+
# This is Gmail's modified UTF-7 encoding
405+
# Convert & to + and - to /
406+
modified = folder_name.replace('&', '+').replace('-', '/')
407+
try:
408+
decoded = modified.encode('utf-7').decode('utf-8')
409+
return decoded
410+
except Exception:
411+
pass
412+
413+
# Try URL decoding for other cases
414+
if '%' in folder_name:
415+
return unquote(folder_name)
416+
417+
return folder_name
418+
except Exception:
419+
return folder_name
420+
421+
422+
def encode_imap_folder_name(folder_name: str) -> str:
423+
"""Encode folder name for IMAP commands."""
424+
try:
425+
# For Gmail-style folders, use proper IMAP encoding
426+
if folder_name.startswith('[Gmail]'):
427+
# Gmail folders need to be properly quoted
428+
return f'"{folder_name}"'
429+
430+
# For folders with spaces or special characters, use proper IMAP encoding
431+
if ' ' in folder_name or any(char in folder_name for char in ['(', ')', '{', '}', '"', '\\']):
432+
# Use IMAP's literal string format
433+
return f'"{folder_name}"'
434+
435+
return folder_name
436+
except Exception:
437+
return folder_name
438+
439+
396440
@celery_app.task(bind=True)
397441
def import_imap_messages_task(
398442
self,
@@ -413,8 +457,8 @@ def import_imap_messages_task(
413457
username: Email address for login
414458
password: Password for login
415459
use_ssl: Whether to use SSL
416-
folder: IMAP folder to import from
417-
max_messages: Maximum number of messages to import (0 for all)
460+
folder: IMAP folder to import from. Use "ALL" or "*" to import from all folders
461+
max_messages: Maximum number of messages to import per folder (0 for all)
418462
recipient_id: ID of the recipient mailbox
419463
420464
Returns:
@@ -428,78 +472,203 @@ def import_imap_messages_task(
428472
imap = imaplib.IMAP4(imap_server, imap_port)
429473

430474
# Login
431-
432475
imap.login(username, password)
433476

434-
# Select folder
435-
status, messages = imap.select(folder)
436-
if status != "OK":
437-
raise Exception(f"Failed to select folder {folder}: {messages}")
438-
439-
# Search for all messages
440-
status, message_numbers = imap.search(None, "ALL")
477+
# List all folders
478+
# TODO: check is working for other IMAP servers
479+
status, folder_list = imap.list()
441480
if status != "OK":
442-
raise Exception(f"Failed to search messages: {message_numbers}")
443-
444-
# Get list of message numbers
445-
message_list = message_numbers[0].split()
446-
447-
# Apply max_messages limit if specified
448-
if max_messages > 0:
449-
message_list = message_list[-max_messages:] # Get most recent messages
450-
451-
total_messages = len(message_list)
452-
success_count = 0
453-
failure_count = 0
481+
raise Exception(f"Failed to list folders: {folder_list}")
482+
483+
# Get selectable folders
484+
selectable_folders = []
485+
for folder_info in folder_list:
486+
folder_info = folder_info.decode()
487+
if "\\Noselect" not in folder_info:
488+
# Extract folder name - it's the last part in quotes
489+
folder_name = folder_info.split('"')[-2]
490+
selectable_folders.append(folder_name)
491+
492+
logger.info("Available IMAP folders: %s", selectable_folders)
493+
494+
# Determine which folders to process
495+
folders_to_process = []
496+
if folder.upper() in ["ALL", "*"]:
497+
folders_to_process = selectable_folders
498+
else:
499+
if folder not in selectable_folders:
500+
raise Exception(f"Folder '{folder}' not found or not selectable")
501+
folders_to_process = [folder]
454502

455503
# Get recipient mailbox
456504
recipient = Mailbox.objects.get(id=recipient_id)
457505

458-
# Process each message
459-
for i, msg_num in enumerate(message_list, 1):
506+
total_messages = 0
507+
total_success = 0
508+
total_failure = 0
509+
folder_stats = {}
510+
511+
# Process each folder
512+
for current_folder in folders_to_process:
460513
try:
461-
# Update task state
462-
self.update_state(
463-
state="PROGRESS",
464-
meta={
465-
"current": i,
466-
"total": total_messages,
467-
"status": f"Processing message {i} of {total_messages}",
468-
},
514+
logger.info("Processing folder: %s", current_folder)
515+
516+
# Create or get label for this folder
517+
folder_label, _ = Label.objects.get_or_create(
518+
name=current_folder,
519+
mailbox=recipient,
520+
defaults={'color': '#000000'} # Default color
469521
)
470522

471-
# Fetch message
472-
status, msg_data = imap.fetch(msg_num, "(RFC822)")
523+
# Encode folder name for IMAP command
524+
encoded_folder = encode_imap_folder_name(current_folder)
525+
526+
# Select folder
527+
try:
528+
status, messages = imap.select(encoded_folder)
529+
except imaplib.IMAP4.error as e:
530+
# If first attempt fails, try with the original folder name
531+
try:
532+
status, messages = imap.select(current_folder)
533+
except imaplib.IMAP4.error:
534+
# If both attempts fail, log and continue
535+
logger.error("Failed to select folder %s: %s", current_folder, str(e))
536+
folder_stats[current_folder] = {
537+
"status": "failed",
538+
"error": f"Failed to select folder: {str(e)}",
539+
"success_count": 0,
540+
"failure_count": 0
541+
}
542+
continue
543+
473544
if status != "OK":
474-
logger.error("Failed to fetch message %s: %s", msg_num, msg_data)
475-
failure_count += 1
545+
logger.error("Failed to select folder %s: %s", current_folder, messages)
546+
folder_stats[current_folder] = {
547+
"status": "failed",
548+
"error": f"Failed to select folder: {messages}",
549+
"success_count": 0,
550+
"failure_count": 0
551+
}
476552
continue
477553

478-
# Parse message
479-
raw_email = msg_data[0][1]
480-
parsed_email = parse_email_message(raw_email)
554+
# Search for all messages
555+
status, message_numbers = imap.search(None, "ALL")
556+
if status != "OK":
557+
logger.error("Failed to search messages in %s: %s", current_folder, message_numbers)
558+
folder_stats[current_folder] = {
559+
"status": "failed",
560+
"error": f"Failed to search messages: {message_numbers}",
561+
"success_count": 0,
562+
"failure_count": 0
563+
}
564+
continue
481565

482-
# Deliver message
483-
if deliver_inbound_message(
484-
str(recipient), parsed_email, raw_email, is_import=True
485-
):
486-
success_count += 1
487-
else:
488-
failure_count += 1
566+
# Get list of message numbers
567+
message_list = message_numbers[0].split()
568+
569+
# Apply max_messages limit if specified
570+
if max_messages > 0:
571+
message_list = message_list[-max_messages:] # Get most recent messages
572+
573+
folder_total = len(message_list)
574+
total_messages += folder_total
575+
folder_success = 0
576+
folder_failure = 0
577+
578+
# Process each message
579+
for i, msg_num in enumerate(message_list, 1):
580+
try:
581+
# Update task state
582+
self.update_state(
583+
state="PROGRESS",
584+
meta={
585+
"current_folder": current_folder,
586+
"current": i,
587+
"total": folder_total,
588+
"status": f"Processing message {i} of {folder_total} in {current_folder}",
589+
},
590+
)
489591

490-
except Exception as e:
491-
logger.exception("Error processing message %s: %s", msg_num, e)
492-
failure_count += 1
592+
# Fetch message
593+
status, msg_data = imap.fetch(msg_num, "(RFC822)")
594+
if status != "OK":
595+
logger.error("Failed to fetch message %s: %s", msg_num, msg_data)
596+
folder_failure += 1
597+
continue
598+
599+
# Parse message
600+
raw_email = msg_data[0][1]
601+
602+
# Add debugging for raw email
603+
logger.debug("Raw email size: %d bytes", len(raw_email))
604+
605+
try:
606+
parsed_email = parse_email_message(raw_email)
607+
except Exception as parse_error:
608+
logger.exception("Error parsing message %s in folder %s: %s", msg_num, current_folder, parse_error)
609+
folder_failure += 1
610+
continue
611+
612+
# Add debugging information
613+
logger.debug("Processing message in folder %s: Subject=%s, From=%s",
614+
current_folder,
615+
parsed_email.get('headers', {}).get('Subject', 'No Subject'),
616+
parsed_email.get('headers', {}).get('From', 'No From'))
617+
618+
# Deliver message
619+
try:
620+
delivery_result = deliver_inbound_message(
621+
str(recipient), parsed_email, raw_email, is_import=True, label_name=current_folder
622+
)
623+
624+
if delivery_result:
625+
folder_success += 1
626+
logger.debug("Successfully delivered message in folder %s", current_folder)
627+
else:
628+
folder_failure += 1
629+
logger.warning("Failed to deliver message in folder %s", current_folder)
630+
631+
except Exception as delivery_error:
632+
folder_failure += 1
633+
logger.exception("Error delivering message in folder %s: %s", current_folder, delivery_error)
634+
635+
except Exception as e:
636+
logger.exception("Error processing message %s in %s: %s", msg_num, current_folder, e)
637+
folder_failure += 1
638+
639+
# Update folder statistics
640+
folder_stats[current_folder] = {
641+
"status": "completed",
642+
"total_messages": folder_total,
643+
"success_count": folder_success,
644+
"failure_count": folder_failure
645+
}
646+
total_success += folder_success
647+
total_failure += folder_failure
493648

494-
# Logout
495-
imap.close()
496-
imap.logout()
649+
except Exception as e:
650+
logger.exception("Error processing folder %s: %s", current_folder, e)
651+
folder_stats[current_folder] = {
652+
"status": "failed",
653+
"error": str(e),
654+
"success_count": 0,
655+
"failure_count": 0
656+
}
657+
total_failure += 1
658+
659+
# Logout - only call logout, not close
660+
try:
661+
imap.logout()
662+
except Exception as e:
663+
logger.warning("Error during logout: %s", e)
497664

498665
return {
499666
"status": "completed",
667+
"total_folders": len(folders_to_process),
500668
"total_messages": total_messages,
501-
"success_count": success_count,
502-
"failure_count": failure_count,
669+
"total_success": total_success,
670+
"total_failure": total_failure,
671+
"folder_stats": folder_stats
503672
}
504673

505674
except Exception as e:

src/backend/core/tests/api/test_messages_import.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@ def test_import_imap(api_client, user, mailbox):
209209
# Mock IMAP connection and responses
210210
with patch("imaplib.IMAP4_SSL") as mock_imap:
211211
mock_imap_instance = mock_imap.return_value
212+
# Mock list() to return some folders
213+
mock_imap_instance.list.return_value = ("OK", [
214+
b'(\\HasNoChildren) "/" "INBOX"',
215+
b'(\\HasNoChildren) "/" "[Gmail]/Sent Mail"',
216+
b'(\\HasNoChildren) "/" "[Gmail]/Drafts"'
217+
])
212218
mock_imap_instance.select.return_value = ("OK", [b"1"])
213219
mock_imap_instance.search.return_value = ("OK", [b"1 2"])
214220

@@ -263,6 +269,14 @@ def test_import_imap(api_client, user, mailbox):
263269
2025, 5, 26, 10, 0, 0, tzinfo=datetime.timezone.utc
264270
)
265271

272+
# Verify IMAP calls
273+
mock_imap_instance.login.assert_called_once_with("[email protected]", "password123")
274+
mock_imap_instance.list.assert_called_once()
275+
mock_imap_instance.select.assert_called_once_with('INBOX')
276+
mock_imap_instance.search.assert_called_once_with(None, "ALL")
277+
assert mock_imap_instance.fetch.call_count == 2
278+
mock_imap_instance.logout.assert_called_once()
279+
266280

267281
def test_import_imap_no_access(api_client, domain):
268282
"""Test import of IMAP messages without access to mailbox."""

0 commit comments

Comments
 (0)