Skip to content

Commit 721ecb1

Browse files
authored
feat: Support many recipients, cc and bcc (#11)
* feat: Support many recipients, cc and bcc * Add testcase
1 parent c1830fb commit 721ecb1

File tree

14 files changed

+929
-16
lines changed

14 files changed

+929
-16
lines changed

mcp_email_server/app.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,32 @@ async def page_email(
6161
)
6262

6363

64-
@mcp.tool()
65-
async def send_email(account_name: str, recipient: str, subject: str, body: str) -> None:
64+
@mcp.tool(
65+
description="Send an email using the specified account. Recipient should be a list of email addresses.",
66+
)
67+
async def send_email(
68+
account_name: str,
69+
recipients: list[str],
70+
subject: str,
71+
body: str,
72+
cc: list[str] | None = None,
73+
bcc: list[str] | None = None,
74+
) -> None:
6675
handler = dispatch_handler(account_name)
67-
await handler.send_email(recipient, subject, body)
76+
await handler.send_email(recipients, subject, body, cc, bcc)
6877
return
78+
79+
80+
if __name__ == "__main__":
81+
import asyncio
82+
83+
asyncio.run(
84+
send_email(**{
85+
"account_name": "jizhongsheng957@gmail.com",
86+
"recipients": ["jizhongsheng957@gmail.com"],
87+
"subject": "问候",
88+
"body": "你好",
89+
"cc": ["9573586@qq.com"],
90+
"bcc": ["jzs9573586@qq.com"],
91+
})
92+
)

mcp_email_server/emails/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ async def get_emails(
2525
"""
2626

2727
@abc.abstractmethod
28-
async def send_email(self, recipient: str, subject: str, body: str) -> None:
28+
async def send_email(
29+
self, recipients: list[str], subject: str, body: str, cc: list[str] | None = None, bcc: list[str] | None = None
30+
) -> None:
2931
"""
3032
Send email
3133
"""

mcp_email_server/emails/classic.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,20 @@ async def get_email_count(
222222
except Exception as e:
223223
logger.info(f"Error during logout: {e}")
224224

225-
async def send_email(self, recipient: str, subject: str, body: str):
225+
async def send_email(
226+
self, recipients: list[str], subject: str, body: str, cc: list[str] | None = None, bcc: list[str] | None = None
227+
):
226228
msg = MIMEText(body)
227229
msg["Subject"] = subject
228230
msg["From"] = self.sender
229-
msg["To"] = recipient
231+
msg["To"] = ", ".join(recipients)
232+
233+
# Add CC header if provided (visible to recipients)
234+
if cc:
235+
msg["Cc"] = ", ".join(cc)
236+
237+
# Note: BCC recipients are not added to headers (they remain hidden)
238+
# but will be included in the actual recipients for SMTP delivery
230239

231240
async with aiosmtplib.SMTP(
232241
hostname=self.email_server.host,
@@ -235,7 +244,15 @@ async def send_email(self, recipient: str, subject: str, body: str):
235244
use_tls=self.smtp_use_tls,
236245
) as smtp:
237246
await smtp.login(self.email_server.user_name, self.email_server.password)
238-
await smtp.send_message(msg)
247+
248+
# Create a combined list of all recipients for delivery
249+
all_recipients = recipients.copy()
250+
if cc:
251+
all_recipients.extend(cc)
252+
if bcc:
253+
all_recipients.extend(bcc)
254+
255+
await smtp.send_message(msg, recipients=all_recipients)
239256

240257

241258
class ClassicEmailHandler(EmailHandler):
@@ -277,5 +294,7 @@ async def get_emails(
277294
total=total,
278295
)
279296

280-
async def send_email(self, recipient: str, subject: str, body: str) -> None:
281-
await self.outgoing_client.send_email(recipient, subject, body)
297+
async def send_email(
298+
self, recipients: list[str], subject: str, body: str, cc: list[str] | None = None, bcc: list[str] | None = None
299+
) -> None:
300+
await self.outgoing_client.send_email(recipients, subject, body, cc, bcc)

mcp_email_server/emails/dispatcher.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ def dispatch_handler(account_name: str) -> EmailHandler:
1616
raise NotImplementedError
1717
if isinstance(account, EmailSettings):
1818
return ClassicEmailHandler(account)
19+
20+
raise ValueError(f"Account {account_name} not found, available accounts: {settings.get_accounts()}")

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Documentation = "https://ai-zerolab.github.io/mcp-email-server/"
4040
[dependency-groups]
4141
dev = [
4242
"pytest>=7.2.0",
43+
"pytest-asyncio>=0.25.3",
4344
"pre-commit>=2.20.0",
4445
"tox-uv>=1.11.3",
4546
"deptry>=0.22.0",
@@ -111,7 +112,7 @@ ignore = [
111112
]
112113

113114
[tool.ruff.lint.per-file-ignores]
114-
"tests/*" = ["S101"]
115+
"tests/*" = ["S101", "S106", "SIM117"]
115116

116117
[tool.ruff.format]
117118
preview = true

pytest.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# pytest.ini
2+
[pytest]
3+
asyncio_mode = auto
4+
# asyncio_default_fixture_loop_scope =

tests/conftest.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import os
5+
from datetime import datetime
46
from pathlib import Path
7+
from unittest.mock import AsyncMock
58

69
import pytest
710

11+
from mcp_email_server.config import EmailServer, EmailSettings, ProviderSettings, delete_settings
12+
813
_HERE = Path(__file__).resolve().parent
914

1015
os.environ["MCP_EMAIL_SERVER_CONFIG_PATH"] = (_HERE / "config.toml").as_posix()
@@ -13,7 +18,90 @@
1318

1419
@pytest.fixture(autouse=True)
1520
def patch_env(monkeypatch: pytest.MonkeyPatch, tmp_path: pytest.TempPathFactory):
16-
from mcp_email_server.config import delete_settings
17-
1821
delete_settings()
1922
yield
23+
24+
25+
@pytest.fixture
26+
def email_server():
27+
"""Fixture for a test EmailServer."""
28+
return EmailServer(
29+
user_name="test_user",
30+
password="test_password",
31+
host="test.example.com",
32+
port=993,
33+
use_ssl=True,
34+
)
35+
36+
37+
@pytest.fixture
38+
def email_settings():
39+
"""Fixture for test EmailSettings."""
40+
return EmailSettings(
41+
account_name="test_account",
42+
full_name="Test User",
43+
email_address="test@example.com",
44+
incoming=EmailServer(
45+
user_name="test_user",
46+
password="test_password",
47+
host="imap.example.com",
48+
port=993,
49+
use_ssl=True,
50+
),
51+
outgoing=EmailServer(
52+
user_name="test_user",
53+
password="test_password",
54+
host="smtp.example.com",
55+
port=465,
56+
use_ssl=True,
57+
),
58+
)
59+
60+
61+
@pytest.fixture
62+
def provider_settings():
63+
"""Fixture for test ProviderSettings."""
64+
return ProviderSettings(
65+
account_name="test_provider",
66+
provider_name="test_provider",
67+
api_key="test_api_key",
68+
)
69+
70+
71+
@pytest.fixture
72+
def mock_imap():
73+
"""Fixture for a mocked IMAP client."""
74+
mock_imap = AsyncMock()
75+
mock_imap._client_task = asyncio.Future()
76+
mock_imap._client_task.set_result(None)
77+
mock_imap.wait_hello_from_server = AsyncMock()
78+
mock_imap.login = AsyncMock()
79+
mock_imap.select = AsyncMock()
80+
mock_imap.search = AsyncMock(return_value=(None, [b"1 2 3"]))
81+
mock_imap.fetch = AsyncMock(return_value=(None, [b"HEADER", bytearray(b"EMAIL CONTENT")]))
82+
mock_imap.logout = AsyncMock()
83+
return mock_imap
84+
85+
86+
@pytest.fixture
87+
def mock_smtp():
88+
"""Fixture for a mocked SMTP client."""
89+
mock_smtp = AsyncMock()
90+
mock_smtp.__aenter__.return_value = mock_smtp
91+
mock_smtp.__aexit__.return_value = None
92+
mock_smtp.login = AsyncMock()
93+
mock_smtp.send_message = AsyncMock()
94+
return mock_smtp
95+
96+
97+
@pytest.fixture
98+
def sample_email_data():
99+
"""Fixture for sample email data."""
100+
now = datetime.now()
101+
return {
102+
"subject": "Test Subject",
103+
"from": "sender@example.com",
104+
"body": "Test Body",
105+
"date": now,
106+
"attachments": ["attachment.pdf"],
107+
}

tests/test_classic_handler.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
from datetime import datetime
2+
from unittest.mock import AsyncMock, patch
3+
4+
import pytest
5+
6+
from mcp_email_server.config import EmailServer, EmailSettings
7+
from mcp_email_server.emails.classic import ClassicEmailHandler, EmailClient
8+
from mcp_email_server.emails.models import EmailData, EmailPageResponse
9+
10+
11+
@pytest.fixture
12+
def email_settings():
13+
return EmailSettings(
14+
account_name="test_account",
15+
full_name="Test User",
16+
email_address="test@example.com",
17+
incoming=EmailServer(
18+
user_name="test_user",
19+
password="test_password",
20+
host="imap.example.com",
21+
port=993,
22+
use_ssl=True,
23+
),
24+
outgoing=EmailServer(
25+
user_name="test_user",
26+
password="test_password",
27+
host="smtp.example.com",
28+
port=465,
29+
use_ssl=True,
30+
),
31+
)
32+
33+
34+
@pytest.fixture
35+
def classic_handler(email_settings):
36+
return ClassicEmailHandler(email_settings)
37+
38+
39+
class TestClassicEmailHandler:
40+
def test_init(self, email_settings):
41+
"""Test initialization of ClassicEmailHandler."""
42+
handler = ClassicEmailHandler(email_settings)
43+
44+
assert handler.email_settings == email_settings
45+
assert isinstance(handler.incoming_client, EmailClient)
46+
assert isinstance(handler.outgoing_client, EmailClient)
47+
48+
# Check that clients are initialized correctly
49+
assert handler.incoming_client.email_server == email_settings.incoming
50+
assert handler.outgoing_client.email_server == email_settings.outgoing
51+
assert handler.outgoing_client.sender == f"{email_settings.full_name} <{email_settings.email_address}>"
52+
53+
@pytest.mark.asyncio
54+
async def test_get_emails(self, classic_handler):
55+
"""Test get_emails method."""
56+
# Create test data
57+
now = datetime.now()
58+
email_data = {
59+
"subject": "Test Subject",
60+
"from": "sender@example.com",
61+
"body": "Test Body",
62+
"date": now,
63+
"attachments": [],
64+
}
65+
66+
# Mock the get_emails_stream method to yield our test data
67+
mock_stream = AsyncMock()
68+
mock_stream.__aiter__.return_value = [email_data]
69+
70+
# Mock the get_email_count method
71+
mock_count = AsyncMock(return_value=1)
72+
73+
# Apply the mocks
74+
with patch.object(classic_handler.incoming_client, "get_emails_stream", return_value=mock_stream):
75+
with patch.object(classic_handler.incoming_client, "get_email_count", mock_count):
76+
# Call the method
77+
result = await classic_handler.get_emails(
78+
page=1,
79+
page_size=10,
80+
before=now,
81+
since=None,
82+
subject="Test",
83+
body=None,
84+
text=None,
85+
from_address="sender@example.com",
86+
to_address=None,
87+
)
88+
89+
# Verify the result
90+
assert isinstance(result, EmailPageResponse)
91+
assert result.page == 1
92+
assert result.page_size == 10
93+
assert result.before == now
94+
assert result.since is None
95+
assert result.subject == "Test"
96+
assert result.body is None
97+
assert result.text is None
98+
assert len(result.emails) == 1
99+
assert isinstance(result.emails[0], EmailData)
100+
assert result.emails[0].subject == "Test Subject"
101+
assert result.emails[0].sender == "sender@example.com"
102+
assert result.emails[0].body == "Test Body"
103+
assert result.emails[0].date == now
104+
assert result.emails[0].attachments == []
105+
assert result.total == 1
106+
107+
# Verify the client methods were called correctly
108+
classic_handler.incoming_client.get_emails_stream.assert_called_once_with(
109+
1, 10, now, None, "Test", None, None, "sender@example.com", None
110+
)
111+
mock_count.assert_called_once_with(now, None, "Test", None, None, "sender@example.com", None)
112+
113+
@pytest.mark.asyncio
114+
async def test_send_email(self, classic_handler):
115+
"""Test send_email method."""
116+
# Mock the outgoing_client.send_email method
117+
mock_send = AsyncMock()
118+
119+
# Apply the mock
120+
with patch.object(classic_handler.outgoing_client, "send_email", mock_send):
121+
# Call the method
122+
await classic_handler.send_email(
123+
recipients=["recipient@example.com"],
124+
subject="Test Subject",
125+
body="Test Body",
126+
cc=["cc@example.com"],
127+
bcc=["bcc@example.com"],
128+
)
129+
130+
# Verify the client method was called correctly
131+
mock_send.assert_called_once_with(
132+
["recipient@example.com"],
133+
"Test Subject",
134+
"Test Body",
135+
["cc@example.com"],
136+
["bcc@example.com"],
137+
)

0 commit comments

Comments
 (0)