Skip to content

Commit e0f8796

Browse files
PillumzWh1isper
andauthored
feat: Add email reply support with In-Reply-To and References headers (#80)
* feat(models): add message_id field for reply threading * feat(parsing): extract Message-ID header for reply threading * feat(send): add in_reply_to and references parameters for threading * feat(handler): pass reply headers through ClassicEmailHandler * feat(mcp): add in_reply_to and references params to send_email tool * feat(content): include message_id in EmailBodyResponse * docs: add reply/threading usage example * style: apply formatter fixes * chore: remove accidentally committed config.toml * chore: add config.toml to gitignore * drop useless docs --------- Co-authored-by: Wh1isper <9573586@qq.com>
1 parent 5d97637 commit e0f8796

File tree

9 files changed

+376
-5
lines changed

9 files changed

+376
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,4 @@ cython_debug/
141141

142142
tests/config.toml
143143
local/
144+
config.toml

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,34 @@ To install Email Server for Claude Desktop automatically via [Smithery](https://
189189
npx -y @smithery/cli install @ai-zerolab/mcp-email-server --client claude
190190
```
191191

192+
## Usage
193+
194+
### Replying to Emails
195+
196+
To reply to an email with proper threading (so it appears in the same conversation in email clients):
197+
198+
1. First, fetch the original email to get its `message_id`:
199+
200+
```python
201+
emails = await get_emails_content(account_name="work", email_ids=["123"])
202+
original = emails.emails[0]
203+
```
204+
205+
2. Send your reply using `in_reply_to` and `references`:
206+
207+
```python
208+
await send_email(
209+
account_name="work",
210+
recipients=[original.sender],
211+
subject=f"Re: {original.subject}",
212+
body="Thank you for your email...",
213+
in_reply_to=original.message_id,
214+
references=original.message_id,
215+
)
216+
```
217+
218+
The `in_reply_to` parameter sets the `In-Reply-To` header, and `references` sets the `References` header. Both are used by email clients to thread conversations properly.
219+
192220
## Development
193221

194222
This project is managed using [uv](https://github.com/ai-zerolab/uv).

mcp_email_server/app.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ async def get_emails_content(
103103

104104

105105
@mcp.tool(
106-
description="Send an email using the specified account. Recipient should be a list of email addresses. Optionally attach files by providing their absolute paths.",
106+
description="Send an email using the specified account. Supports replying to emails with proper threading when in_reply_to is provided.",
107107
)
108108
async def send_email(
109109
account_name: Annotated[str, Field(description="The name of the email account to send from.")],
@@ -129,9 +129,33 @@ async def send_email(
129129
description="A list of absolute file paths to attach to the email. Supports common file types (documents, images, archives, etc.).",
130130
),
131131
] = None,
132+
in_reply_to: Annotated[
133+
str | None,
134+
Field(
135+
default=None,
136+
description="Message-ID of the email being replied to. Enables proper threading in email clients.",
137+
),
138+
] = None,
139+
references: Annotated[
140+
str | None,
141+
Field(
142+
default=None,
143+
description="Space-separated Message-IDs for the thread chain. Usually includes in_reply_to plus ancestors.",
144+
),
145+
] = None,
132146
) -> str:
133147
handler = dispatch_handler(account_name)
134-
await handler.send_email(recipients, subject, body, cc, bcc, html, attachments)
148+
await handler.send_email(
149+
recipients,
150+
subject,
151+
body,
152+
cc,
153+
bcc,
154+
html,
155+
attachments,
156+
in_reply_to,
157+
references,
158+
)
135159
recipient_str = ", ".join(recipients)
136160
attachment_info = f" with {len(attachments)} attachment(s)" if attachments else ""
137161
return f"Email sent successfully to {recipient_str}{attachment_info}"

mcp_email_server/emails/classic.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def _parse_email_data(self, raw_email: bytes, email_id: str | None = None) -> di
4646
sender = email_message.get("From", "")
4747
date_str = email_message.get("Date", "")
4848

49+
# Extract Message-ID for reply threading
50+
message_id = email_message.get("Message-ID")
51+
4952
# Extract recipients
5053
to_addresses = []
5154
to_header = email_message.get("To", "")
@@ -106,6 +109,7 @@ def _parse_email_data(self, raw_email: bytes, email_id: str | None = None) -> di
106109
body = body[:20000] + "...[TRUNCATED]"
107110
return {
108111
"email_id": email_id or "",
112+
"message_id": message_id,
109113
"subject": subject,
110114
"from": sender,
111115
"to": to_addresses,
@@ -528,6 +532,8 @@ async def send_email(
528532
bcc: list[str] | None = None,
529533
html: bool = False,
530534
attachments: list[str] | None = None,
535+
in_reply_to: str | None = None,
536+
references: str | None = None,
531537
):
532538
# Create message with or without attachments
533539
if attachments:
@@ -554,6 +560,12 @@ async def send_email(
554560
if cc:
555561
msg["Cc"] = ", ".join(cc)
556562

563+
# Set threading headers for replies
564+
if in_reply_to:
565+
msg["In-Reply-To"] = in_reply_to
566+
if references:
567+
msg["References"] = references
568+
557569
# Note: BCC recipients are not added to headers (they remain hidden)
558570
# but will be included in the actual recipients for SMTP delivery
559571

@@ -742,6 +754,7 @@ async def get_emails_content(self, email_ids: list[str], mailbox: str = "INBOX")
742754
emails.append(
743755
EmailBodyResponse(
744756
email_id=email_data["email_id"],
757+
message_id=email_data.get("message_id"),
745758
subject=email_data["subject"],
746759
sender=email_data["from"],
747760
recipients=email_data["to"],
@@ -772,8 +785,12 @@ async def send_email(
772785
bcc: list[str] | None = None,
773786
html: bool = False,
774787
attachments: list[str] | None = None,
788+
in_reply_to: str | None = None,
789+
references: str | None = None,
775790
) -> None:
776-
msg = await self.outgoing_client.send_email(recipients, subject, body, cc, bcc, html, attachments)
791+
msg = await self.outgoing_client.send_email(
792+
recipients, subject, body, cc, bcc, html, attachments, in_reply_to, references
793+
)
777794

778795
# Save to Sent folder if enabled
779796
if self.save_to_sent and msg:

mcp_email_server/emails/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class EmailMetadata(BaseModel):
88
"""Email metadata"""
99

1010
email_id: str
11+
message_id: str | None = None # RFC 5322 Message-ID header for reply threading
1112
subject: str
1213
sender: str
1314
recipients: list[str] # Recipient list
@@ -18,6 +19,7 @@ class EmailMetadata(BaseModel):
1819
def from_email(cls, email: dict[str, Any]):
1920
return cls(
2021
email_id=email["email_id"],
22+
message_id=email.get("message_id"),
2123
subject=email["subject"],
2224
sender=email["from"],
2325
recipients=email.get("to", []),
@@ -42,6 +44,7 @@ class EmailBodyResponse(BaseModel):
4244
"""Single email body response"""
4345

4446
email_id: str # IMAP UID of this email
47+
message_id: str | None = None # RFC 5322 Message-ID header for reply threading
4548
subject: str
4649
sender: str
4750
recipients: list[str]

tests/test_classic_handler.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55

66
from mcp_email_server.config import EmailServer, EmailSettings
77
from mcp_email_server.emails.classic import ClassicEmailHandler, EmailClient
8-
from mcp_email_server.emails.models import AttachmentDownloadResponse, EmailMetadata, EmailMetadataPageResponse
8+
from mcp_email_server.emails.models import (
9+
AttachmentDownloadResponse,
10+
EmailBodyResponse,
11+
EmailContentBatchResponse,
12+
EmailMetadata,
13+
EmailMetadataPageResponse,
14+
)
915

1016

1117
@pytest.fixture
@@ -168,6 +174,8 @@ async def test_send_email(self, classic_handler):
168174
["bcc@example.com"],
169175
False,
170176
None,
177+
None,
178+
None,
171179
)
172180

173181
@pytest.mark.asyncio
@@ -199,6 +207,8 @@ async def test_send_email_with_attachments(self, classic_handler, tmp_path):
199207
None,
200208
False,
201209
[str(test_file)],
210+
None,
211+
None,
202212
)
203213

204214
@pytest.mark.asyncio
@@ -276,3 +286,63 @@ async def test_download_attachment(self, classic_handler, tmp_path):
276286
assert result.saved_path == save_path
277287

278288
mock_download.assert_called_once_with("123", "document.pdf", save_path)
289+
290+
@pytest.mark.asyncio
291+
async def test_send_email_with_reply_headers(self, classic_handler):
292+
"""Test sending email with reply headers."""
293+
mock_smtp = AsyncMock()
294+
mock_smtp.__aenter__.return_value = mock_smtp
295+
mock_smtp.__aexit__.return_value = None
296+
mock_smtp.login = AsyncMock()
297+
mock_smtp.send_message = AsyncMock()
298+
299+
with patch("aiosmtplib.SMTP", return_value=mock_smtp):
300+
await classic_handler.send_email(
301+
recipients=["recipient@example.com"],
302+
subject="Re: Test",
303+
body="Reply body",
304+
in_reply_to="<original@example.com>",
305+
references="<original@example.com>",
306+
)
307+
308+
call_args = mock_smtp.send_message.call_args
309+
msg = call_args[0][0]
310+
assert msg["In-Reply-To"] == "<original@example.com>"
311+
assert msg["References"] == "<original@example.com>"
312+
313+
@pytest.mark.asyncio
314+
async def test_get_emails_content_includes_message_id(self, classic_handler):
315+
"""Test that get_emails_content returns message_id from parsed email data."""
316+
now = datetime.now(timezone.utc)
317+
email_data = {
318+
"email_id": "123",
319+
"message_id": "<test-message-id@example.com>",
320+
"subject": "Test Subject",
321+
"from": "sender@example.com",
322+
"to": ["recipient@example.com"],
323+
"date": now,
324+
"body": "Test email body",
325+
"attachments": [],
326+
}
327+
328+
# Mock the get_email_body_by_id method to return our test data
329+
mock_get_body = AsyncMock(return_value=email_data)
330+
331+
with patch.object(classic_handler.incoming_client, "get_email_body_by_id", mock_get_body):
332+
result = await classic_handler.get_emails_content(
333+
email_ids=["123"],
334+
mailbox="INBOX",
335+
)
336+
337+
# Verify the result
338+
assert isinstance(result, EmailContentBatchResponse)
339+
assert len(result.emails) == 1
340+
assert isinstance(result.emails[0], EmailBodyResponse)
341+
assert result.emails[0].email_id == "123"
342+
assert result.emails[0].message_id == "<test-message-id@example.com>"
343+
assert result.emails[0].subject == "Test Subject"
344+
assert result.emails[0].sender == "sender@example.com"
345+
assert result.emails[0].body == "Test email body"
346+
347+
# Verify the client method was called correctly
348+
mock_get_body.assert_called_once_with("123", "INBOX")

tests/test_email_client.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,95 @@ async def test_send_email(self, email_client):
262262
assert "recipient@example.com" in recipients
263263
assert "cc@example.com" in recipients
264264
assert "bcc@example.com" in recipients
265+
266+
267+
class TestParseEmailData:
268+
def test_parse_email_extracts_message_id(self, email_client):
269+
"""Test that Message-ID header is extracted during parsing."""
270+
raw_email = b"""Message-ID: <test123@example.com>
271+
From: sender@example.com
272+
To: recipient@example.com
273+
Subject: Test Subject
274+
Date: Mon, 1 Jan 2024 12:00:00 +0000
275+
276+
Test body content
277+
"""
278+
result = email_client._parse_email_data(raw_email, email_id="1")
279+
assert result["message_id"] == "<test123@example.com>"
280+
281+
def test_parse_email_handles_missing_message_id(self, email_client):
282+
"""Test graceful handling when Message-ID is missing."""
283+
raw_email = b"""From: sender@example.com
284+
To: recipient@example.com
285+
Subject: Test Subject
286+
Date: Mon, 1 Jan 2024 12:00:00 +0000
287+
288+
Test body content
289+
"""
290+
result = email_client._parse_email_data(raw_email, email_id="1")
291+
assert result["message_id"] is None
292+
293+
294+
class TestSendEmailReplyHeaders:
295+
@pytest.mark.asyncio
296+
async def test_send_email_sets_in_reply_to_header(self, email_client):
297+
"""Test that In-Reply-To header is set when provided."""
298+
mock_smtp = AsyncMock()
299+
mock_smtp.__aenter__.return_value = mock_smtp
300+
mock_smtp.__aexit__.return_value = None
301+
mock_smtp.login = AsyncMock()
302+
mock_smtp.send_message = AsyncMock()
303+
304+
with patch("aiosmtplib.SMTP", return_value=mock_smtp):
305+
await email_client.send_email(
306+
recipients=["recipient@example.com"],
307+
subject="Re: Test",
308+
body="Reply body",
309+
in_reply_to="<original123@example.com>",
310+
)
311+
312+
call_args = mock_smtp.send_message.call_args
313+
msg = call_args[0][0]
314+
assert msg["In-Reply-To"] == "<original123@example.com>"
315+
316+
@pytest.mark.asyncio
317+
async def test_send_email_sets_references_header(self, email_client):
318+
"""Test that References header is set when provided."""
319+
mock_smtp = AsyncMock()
320+
mock_smtp.__aenter__.return_value = mock_smtp
321+
mock_smtp.__aexit__.return_value = None
322+
mock_smtp.login = AsyncMock()
323+
mock_smtp.send_message = AsyncMock()
324+
325+
with patch("aiosmtplib.SMTP", return_value=mock_smtp):
326+
await email_client.send_email(
327+
recipients=["recipient@example.com"],
328+
subject="Re: Test",
329+
body="Reply body",
330+
references="<first@example.com> <second@example.com>",
331+
)
332+
333+
call_args = mock_smtp.send_message.call_args
334+
msg = call_args[0][0]
335+
assert msg["References"] == "<first@example.com> <second@example.com>"
336+
337+
@pytest.mark.asyncio
338+
async def test_send_email_without_reply_headers(self, email_client):
339+
"""Test that send works without reply headers (backward compatibility)."""
340+
mock_smtp = AsyncMock()
341+
mock_smtp.__aenter__.return_value = mock_smtp
342+
mock_smtp.__aexit__.return_value = None
343+
mock_smtp.login = AsyncMock()
344+
mock_smtp.send_message = AsyncMock()
345+
346+
with patch("aiosmtplib.SMTP", return_value=mock_smtp):
347+
await email_client.send_email(
348+
recipients=["recipient@example.com"],
349+
subject="Test",
350+
body="Body",
351+
)
352+
353+
call_args = mock_smtp.send_message.call_args
354+
msg = call_args[0][0]
355+
assert "In-Reply-To" not in msg
356+
assert "References" not in msg

0 commit comments

Comments
 (0)