Skip to content

Commit dd8d346

Browse files
gigqclaude
andauthored
Fix iCloud IMAP integration for large mailboxes (#27) (#32)
This commit fixes an issue where iCloud IMAP accounts would report the correct email count but return empty message lists when fetching emails. The root cause was that iCloud's IMAP server requires UID-based operations for large mailboxes and returns metadata-only responses to certain fetch commands. Changes: - Switch from sequence-based to UID-based IMAP operations (uid_search, uid fetch) - Add multiple fetch format attempts (RFC822, BODY[], BODY.PEEK[], etc.) to handle different IMAP server implementations - Detect and handle metadata-only responses from iCloud that contain only "FETCH (UID XXXXX)" without actual email content - Update tests to use UID-based operations This ensures compatibility with iCloud IMAP accounts while maintaining support for other IMAP providers. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 08a2df7 commit dd8d346

File tree

2 files changed

+70
-17
lines changed

2 files changed

+70
-17
lines changed

mcp_email_server/emails/classic.py

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,18 @@ async def get_emails_stream( # noqa: C901
111111
await imap.select("INBOX")
112112

113113
search_criteria = self._build_search_criteria(before, since, subject, body, text, from_address, to_address)
114-
# Search for messages
115-
_, messages = await imap.search(*search_criteria)
116114
logger.info(f"Get: Search criteria: {search_criteria}")
117-
logger.debug(f"Raw messages: {messages}")
118-
message_ids = messages[0].split()
119-
logger.debug(f"Message IDs: {message_ids}")
115+
116+
# Search for messages - use UID SEARCH for better compatibility
117+
_, messages = await imap.uid_search(*search_criteria)
118+
119+
# Handle empty or None responses
120+
if not messages or not messages[0]:
121+
logger.warning("No messages returned from search")
122+
message_ids = []
123+
else:
124+
message_ids = messages[0].split()
125+
logger.info(f"Found {len(message_ids)} message IDs")
120126
start = (page - 1) * page_size
121127
end = start + page_size
122128

@@ -129,20 +135,55 @@ async def get_emails_stream( # noqa: C901
129135
# Convert message_id from bytes to string
130136
message_id_str = message_id.decode("utf-8")
131137

132-
# Use the string version of the message ID
133-
_, data = await imap.fetch(message_id_str, "RFC822")
138+
# Fetch the email using UID - try different formats for compatibility
139+
data = None
140+
fetch_formats = ["RFC822", "BODY[]", "BODY.PEEK[]", "(BODY.PEEK[])"]
141+
142+
for fetch_format in fetch_formats:
143+
try:
144+
_, data = await imap.uid("fetch", message_id_str, fetch_format)
145+
146+
if data and len(data) > 0:
147+
# Check if we got actual email content or just metadata
148+
has_content = False
149+
for item in data:
150+
if (
151+
isinstance(item, bytes)
152+
and b"FETCH (" in item
153+
and b"RFC822" not in item
154+
and b"BODY" not in item
155+
):
156+
# This is just metadata (like 'FETCH (UID 71998)'), not actual content
157+
continue
158+
elif isinstance(item, bytes | bytearray) and len(item) > 100:
159+
# This looks like email content
160+
has_content = True
161+
break
162+
163+
if has_content:
164+
break
165+
else:
166+
data = None # Try next format
167+
168+
except Exception as e:
169+
logger.debug(f"Fetch format {fetch_format} failed: {e}")
170+
data = None
171+
172+
if not data:
173+
logger.error(f"Failed to fetch UID {message_id_str} with any format")
174+
continue
134175

135176
# Find the email data in the response
136177
raw_email = None
137178

138-
# The actual email content is in the bytearray at index 1
139-
if len(data) > 1 and isinstance(data[1], bytearray) and len(data[1]) > 0:
179+
# The email content is typically at index 1 as a bytearray
180+
if len(data) > 1 and isinstance(data[1], bytearray):
140181
raw_email = bytes(data[1])
141182
else:
142-
# Fallback to searching through all items
143-
for _, item in enumerate(data):
183+
# Search through all items for email content
184+
for item in data:
144185
if isinstance(item, bytes | bytearray) and len(item) > 100:
145-
# Skip header lines that contain FETCH
186+
# Skip IMAP protocol responses
146187
if isinstance(item, bytes) and b"FETCH" in item:
147188
continue
148189
# This is likely the email content
@@ -220,8 +261,8 @@ async def get_email_count(
220261
await imap.select("INBOX")
221262
search_criteria = self._build_search_criteria(before, since, subject, body, text, from_address, to_address)
222263
logger.info(f"Count: Search criteria: {search_criteria}")
223-
# Search for messages and count them
224-
_, messages = await imap.search(*search_criteria)
264+
# Search for messages and count them - use UID SEARCH for consistency
265+
_, messages = await imap.uid_search(*search_criteria)
225266
return len(messages[0].split())
226267
finally:
227268
# Ensure we logout properly

tests/test_email_client.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,18 @@ async def test_get_emails_stream(self, email_client):
150150
mock_imap.login = AsyncMock()
151151
mock_imap.select = AsyncMock()
152152
mock_imap.search = AsyncMock(return_value=(None, [b"1 2 3"]))
153+
mock_imap.uid_search = AsyncMock(return_value=(None, [b"1 2 3"]))
153154
mock_imap.fetch = AsyncMock(return_value=(None, [b"HEADER", bytearray(b"EMAIL CONTENT")]))
155+
# Create a simple email with headers for testing
156+
test_email = b"""From: sender@example.com\r
157+
To: recipient@example.com\r
158+
Subject: Test Subject\r
159+
Date: Mon, 1 Jan 2024 00:00:00 +0000\r
160+
\r
161+
This is the email body."""
162+
mock_imap.uid = AsyncMock(
163+
return_value=(None, [b"1 FETCH (UID 1 RFC822 {%d}" % len(test_email), bytearray(test_email)])
164+
)
154165
mock_imap.logout = AsyncMock()
155166

156167
# Mock IMAP class
@@ -179,8 +190,8 @@ async def test_get_emails_stream(self, email_client):
179190
email_client.email_server.user_name, email_client.email_server.password
180191
)
181192
mock_imap.select.assert_called_once_with("INBOX")
182-
mock_imap.search.assert_called_once_with("ALL")
183-
assert mock_imap.fetch.call_count == 3
193+
mock_imap.uid_search.assert_called_once_with("ALL")
194+
assert mock_imap.uid.call_count == 3
184195
mock_imap.logout.assert_called_once()
185196

186197
@pytest.mark.asyncio
@@ -194,6 +205,7 @@ async def test_get_email_count(self, email_client):
194205
mock_imap.login = AsyncMock()
195206
mock_imap.select = AsyncMock()
196207
mock_imap.search = AsyncMock(return_value=(None, [b"1 2 3 4 5"]))
208+
mock_imap.uid_search = AsyncMock(return_value=(None, [b"1 2 3 4 5"]))
197209
mock_imap.logout = AsyncMock()
198210

199211
# Mock IMAP class
@@ -207,7 +219,7 @@ async def test_get_email_count(self, email_client):
207219
email_client.email_server.user_name, email_client.email_server.password
208220
)
209221
mock_imap.select.assert_called_once_with("INBOX")
210-
mock_imap.search.assert_called_once_with("ALL")
222+
mock_imap.uid_search.assert_called_once_with("ALL")
211223
mock_imap.logout.assert_called_once()
212224

213225
@pytest.mark.asyncio

0 commit comments

Comments
 (0)