Skip to content

Commit 4f8b616

Browse files
KonstiDollKonstiDoll
andauthored
Fix: Quote mailbox names and add IMAP flag detection for Sent folder (#93)
* Fix: Quote mailbox names and add IMAP flag detection for Sent folder Fixes emails not being saved to Sent folder for providers like IONOS that use folder names with spaces (e.g., "Gesendete Objekte"). Changes: - Use _quote_mailbox() in IMAP APPEND command (RFC 3501 compliance) - Add _find_sent_folder_by_flag() to detect Sent folder via IMAP \Sent flag - Add error handling around append_to_sent() Tested with: - IONOS: Now works correctly - Gmail: Backward compatible, still works * Add comprehensive tests for IMAP flag detection and error handling - Test _find_sent_folder_by_flag() with various folder names (IONOS, Gmail, etc.) - Test integration with append_to_sent() - Test error handling when append_to_sent fails - All 9 new tests passing, total 27/27 tests in test_save_to_sent.py --------- Co-authored-by: KonstiDoll <konstantin.doll@urban-codesign.com>
1 parent 6d852dc commit 4f8b616

File tree

2 files changed

+299
-6
lines changed

2 files changed

+299
-6
lines changed

mcp_email_server/emails/classic.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,37 @@ async def send_email(
638638
# Return the message for potential saving to Sent folder
639639
return msg
640640

641+
async def _find_sent_folder_by_flag(self, imap) -> str | None:
642+
"""Find the Sent folder by searching for the \\Sent IMAP flag.
643+
644+
Args:
645+
imap: Connected IMAP client
646+
647+
Returns:
648+
The folder name with the \\Sent flag, or None if not found
649+
"""
650+
try:
651+
# List all folders - aioimaplib requires reference_name and mailbox_pattern
652+
_, folders = await imap.list('""', "*")
653+
654+
# Search for folder with \Sent flag
655+
for folder in folders:
656+
folder_str = folder.decode("utf-8") if isinstance(folder, bytes) else str(folder)
657+
# IMAP LIST response format: (flags) "delimiter" "name"
658+
# Example: (\Sent \HasNoChildren) "/" "Gesendete Objekte"
659+
if r"\Sent" in folder_str or "\\Sent" in folder_str:
660+
# Extract folder name from the response
661+
# Split by quotes and get the last quoted part
662+
parts = folder_str.split('"')
663+
if len(parts) >= 3:
664+
folder_name = parts[-2] # The folder name is the second-to-last quoted part
665+
logger.info(f"Found Sent folder by \\Sent flag: '{folder_name}'")
666+
return folder_name
667+
except Exception as e:
668+
logger.debug(f"Error finding Sent folder by flag: {e}")
669+
670+
return None
671+
641672
async def append_to_sent(
642673
self,
643674
msg: MIMEText | MIMEMultipart,
@@ -676,6 +707,12 @@ async def append_to_sent(
676707
await imap.login(incoming_server.user_name, incoming_server.password)
677708
await _send_imap_id(imap)
678709

710+
# Try to find Sent folder by IMAP \Sent flag first
711+
flag_folder = await self._find_sent_folder_by_flag(imap)
712+
if flag_folder and flag_folder not in sent_folder_candidates:
713+
# Add it at the beginning (high priority)
714+
sent_folder_candidates.insert(0, flag_folder)
715+
679716
# Try to find and use the Sent folder
680717
for folder in sent_folder_candidates:
681718
try:
@@ -693,7 +730,7 @@ async def append_to_sent(
693730
# aioimaplib.append signature: (message_bytes, mailbox, flags, date)
694731
append_result = await imap.append(
695732
msg_bytes,
696-
mailbox=folder,
733+
mailbox=_quote_mailbox(folder),
697734
flags=r"(\Seen)",
698735
)
699736
logger.debug(f"Append result: {append_result}")
@@ -845,11 +882,14 @@ async def send_email(
845882

846883
# Save to Sent folder if enabled
847884
if self.save_to_sent and msg:
848-
await self.outgoing_client.append_to_sent(
849-
msg,
850-
self.email_settings.incoming,
851-
self.sent_folder_name,
852-
)
885+
try:
886+
await self.outgoing_client.append_to_sent(
887+
msg,
888+
self.email_settings.incoming,
889+
self.sent_folder_name,
890+
)
891+
except Exception as e:
892+
logger.error(f"Failed to save email to Sent folder: {e}", exc_info=True)
853893

854894
async def delete_emails(self, email_ids: list[str], mailbox: str = "INBOX") -> tuple[list[str], list[str]]:
855895
"""Delete emails by their UIDs. Returns (deleted_ids, failed_ids)."""

tests/test_save_to_sent.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,256 @@ async def test_send_email_returns_message(self, email_client):
414414
assert result is not None
415415
assert result["Subject"] == "Test Subject"
416416
assert "recipient@example.com" in result["To"]
417+
418+
419+
class TestFindSentFolderByFlag:
420+
"""Tests for _find_sent_folder_by_flag method."""
421+
422+
@pytest.fixture
423+
def email_client(self):
424+
"""Create an EmailClient for testing."""
425+
server = EmailServer(
426+
user_name="test_user",
427+
password="test_password",
428+
host="smtp.example.com",
429+
port=465,
430+
use_ssl=True,
431+
)
432+
return EmailClient(server)
433+
434+
@pytest.fixture
435+
def mock_imap_with_sent_flag(self):
436+
"""Create a mock IMAP client with Sent folder."""
437+
mock = AsyncMock()
438+
# Simulate IMAP LIST response with \Sent flag
439+
mock.list = AsyncMock(
440+
return_value=(
441+
"OK",
442+
[
443+
b'(\\HasNoChildren) "/" "INBOX"',
444+
b'(\\Sent \\HasNoChildren) "/" "Gesendete Objekte"',
445+
b'(\\Drafts \\HasNoChildren) "/" "Entwuerfe"',
446+
],
447+
)
448+
)
449+
return mock
450+
451+
@pytest.fixture
452+
def mock_imap_without_sent_flag(self):
453+
"""Create a mock IMAP client without Sent folder."""
454+
mock = AsyncMock()
455+
mock.list = AsyncMock(
456+
return_value=(
457+
"OK",
458+
[
459+
b'(\\HasNoChildren) "/" "INBOX"',
460+
b'(\\Drafts \\HasNoChildren) "/" "Drafts"',
461+
],
462+
)
463+
)
464+
return mock
465+
466+
@pytest.fixture
467+
def mock_imap_list_error(self):
468+
"""Create a mock IMAP client that raises error on list."""
469+
mock = AsyncMock()
470+
mock.list = AsyncMock(side_effect=Exception("IMAP list failed"))
471+
return mock
472+
473+
@pytest.mark.asyncio
474+
async def test_find_sent_folder_by_flag_success(self, email_client, mock_imap_with_sent_flag):
475+
"""Test successful detection of Sent folder via IMAP flag."""
476+
result = await email_client._find_sent_folder_by_flag(mock_imap_with_sent_flag)
477+
478+
assert result == "Gesendete Objekte"
479+
mock_imap_with_sent_flag.list.assert_called_once_with('""', "*")
480+
481+
@pytest.mark.asyncio
482+
async def test_find_sent_folder_by_flag_with_spaces(self, email_client):
483+
"""Test detection with folder name containing spaces (IONOS case)."""
484+
mock = AsyncMock()
485+
mock.list = AsyncMock(
486+
return_value=(
487+
"OK",
488+
[b'(\\Sent \\HasNoChildren) "/" "Gesendete Objekte"'],
489+
)
490+
)
491+
492+
result = await email_client._find_sent_folder_by_flag(mock)
493+
assert result == "Gesendete Objekte"
494+
495+
@pytest.mark.asyncio
496+
async def test_find_sent_folder_by_flag_gmail_style(self, email_client):
497+
"""Test detection with Gmail-style folder structure."""
498+
mock = AsyncMock()
499+
mock.list = AsyncMock(
500+
return_value=(
501+
"OK",
502+
[b'(\\Sent \\HasNoChildren) "/" "[Gmail]/Gesendet"'],
503+
)
504+
)
505+
506+
result = await email_client._find_sent_folder_by_flag(mock)
507+
assert result == "[Gmail]/Gesendet"
508+
509+
@pytest.mark.asyncio
510+
async def test_find_sent_folder_by_flag_not_found(self, email_client, mock_imap_without_sent_flag):
511+
"""Test when no folder with \\Sent flag exists."""
512+
result = await email_client._find_sent_folder_by_flag(mock_imap_without_sent_flag)
513+
514+
assert result is None
515+
516+
@pytest.mark.asyncio
517+
async def test_find_sent_folder_by_flag_handles_error(self, email_client, mock_imap_list_error):
518+
"""Test error handling when IMAP list fails."""
519+
result = await email_client._find_sent_folder_by_flag(mock_imap_list_error)
520+
521+
assert result is None
522+
523+
@pytest.mark.asyncio
524+
async def test_find_sent_folder_by_flag_with_string_response(self, email_client):
525+
"""Test handling of string (non-bytes) folder responses."""
526+
mock = AsyncMock()
527+
mock.list = AsyncMock(
528+
return_value=(
529+
"OK",
530+
['(\\Sent \\HasNoChildren) "/" "Sent Items"'],
531+
)
532+
)
533+
534+
result = await email_client._find_sent_folder_by_flag(mock)
535+
assert result == "Sent Items"
536+
537+
538+
class TestAppendToSentWithFlagDetection:
539+
"""Tests for append_to_sent integration with flag detection."""
540+
541+
@pytest.fixture
542+
def email_client(self):
543+
"""Create an EmailClient for testing."""
544+
server = EmailServer(
545+
user_name="test_user",
546+
password="test_password",
547+
host="smtp.example.com",
548+
port=465,
549+
use_ssl=True,
550+
)
551+
return EmailClient(server)
552+
553+
@pytest.fixture
554+
def incoming_server(self):
555+
"""Create an incoming EmailServer for testing."""
556+
return EmailServer(
557+
user_name="test_user",
558+
password="test_password",
559+
host="imap.example.com",
560+
port=993,
561+
use_ssl=True,
562+
)
563+
564+
@pytest.mark.asyncio
565+
async def test_append_uses_flag_detected_folder(self, email_client, incoming_server):
566+
"""Test that append_to_sent uses flag-detected folder when auto-detecting."""
567+
msg = MIMEText("Test body")
568+
569+
mock_imap = AsyncMock()
570+
mock_imap._client_task = asyncio.Future()
571+
mock_imap._client_task.set_result(None)
572+
mock_imap.wait_hello_from_server = AsyncMock()
573+
mock_imap.login = AsyncMock()
574+
mock_imap.list = AsyncMock(
575+
return_value=(
576+
"OK",
577+
[b'(\\Sent \\HasNoChildren) "/" "Gesendete Objekte"'],
578+
)
579+
)
580+
mock_imap.select = AsyncMock(return_value=("OK", []))
581+
mock_imap.append = AsyncMock(return_value=("OK", []))
582+
mock_imap.logout = AsyncMock()
583+
584+
with patch("mcp_email_server.emails.classic.aioimaplib") as mock_aioimaplib:
585+
mock_aioimaplib.IMAP4_SSL.return_value = mock_imap
586+
587+
result = await email_client.append_to_sent(msg, incoming_server, None)
588+
589+
assert result is True
590+
# Verify it used the flag-detected folder with proper quoting
591+
mock_imap.select.assert_called_with('"Gesendete Objekte"')
592+
593+
@pytest.mark.asyncio
594+
async def test_append_prefers_flag_over_explicit(self, email_client, incoming_server):
595+
"""Test that IMAP flag detection has highest priority, even with explicit folder."""
596+
msg = MIMEText("Test body")
597+
598+
mock_imap = AsyncMock()
599+
mock_imap._client_task = asyncio.Future()
600+
mock_imap._client_task.set_result(None)
601+
mock_imap.wait_hello_from_server = AsyncMock()
602+
mock_imap.login = AsyncMock()
603+
mock_imap.list = AsyncMock(
604+
return_value=(
605+
"OK",
606+
[b'(\\Sent \\HasNoChildren) "/" "Flag Detected"'],
607+
)
608+
)
609+
mock_imap.select = AsyncMock(return_value=("OK", []))
610+
mock_imap.append = AsyncMock(return_value=("OK", []))
611+
mock_imap.logout = AsyncMock()
612+
613+
with patch("mcp_email_server.emails.classic.aioimaplib") as mock_aioimaplib:
614+
mock_aioimaplib.IMAP4_SSL.return_value = mock_imap
615+
616+
# Even with explicit folder, flag-detected should be preferred (most reliable)
617+
result = await email_client.append_to_sent(msg, incoming_server, "INBOX.Sent")
618+
619+
assert result is True
620+
# Should use flag-detected folder (highest priority)
621+
mock_imap.select.assert_called_with('"Flag Detected"')
622+
623+
624+
class TestHandlerErrorHandling:
625+
"""Tests for error handling in ClassicEmailHandler.send_email."""
626+
627+
@pytest.mark.asyncio
628+
async def test_send_email_continues_on_append_error(self):
629+
"""Test that send_email continues even if append_to_sent fails."""
630+
settings = EmailSettings(
631+
account_name="test",
632+
full_name="Test User",
633+
email_address="test@example.com",
634+
incoming=EmailServer(
635+
user_name="test_user",
636+
password="test_password",
637+
host="imap.example.com",
638+
port=993,
639+
use_ssl=True,
640+
),
641+
outgoing=EmailServer(
642+
user_name="test_user",
643+
password="test_password",
644+
host="smtp.example.com",
645+
port=465,
646+
use_ssl=True,
647+
),
648+
save_to_sent=True,
649+
sent_folder_name="Sent",
650+
)
651+
handler = ClassicEmailHandler(settings)
652+
653+
mock_msg = MIMEText("Test body")
654+
mock_send = AsyncMock(return_value=mock_msg)
655+
mock_append = AsyncMock(side_effect=Exception("IMAP connection failed"))
656+
657+
with patch.object(handler.outgoing_client, "send_email", mock_send):
658+
with patch.object(handler.outgoing_client, "append_to_sent", mock_append):
659+
# Should not raise exception even though append fails
660+
await handler.send_email(
661+
recipients=["recipient@example.com"],
662+
subject="Test",
663+
body="Test body",
664+
)
665+
666+
# Email should still be sent
667+
mock_send.assert_called_once()
668+
# Append was attempted
669+
mock_append.assert_called_once()

0 commit comments

Comments
 (0)