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