mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-10 08:52:21 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
"""mcp email server _decode_header must not inject spaces between parts.
|
||||
|
||||
email.header.decode_header returns plain-text runs WITH their surrounding
|
||||
whitespace (e.g. (b"Re: ", None)), so joining parts with " " produced a
|
||||
double space after "Re:" on every non-ASCII subject, a spurious space in
|
||||
"Name <addr>" senders, and violated RFC 2047 6.2 which requires whitespace
|
||||
between two adjacent encoded-words to be dropped.
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mcp")
|
||||
|
||||
import mcp_servers.email_server as es
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_mcp_email_owner_env(monkeypatch):
|
||||
for key in es._OWNER_ENV_KEYS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
es._ACCOUNT_CACHE.clear()
|
||||
yield
|
||||
es._ACCOUNT_CACHE.clear()
|
||||
|
||||
|
||||
def _init_accounts_db(path, rows=None):
|
||||
rows = rows or [
|
||||
("acct-alice", "alice", "Alice Mail", 1, "alice@example.com", "alice@example.com", "alice@example.com", "2026-01-01"),
|
||||
("acct-bob", "bob", "Bob Mail", 1, "bob@example.com", "bob@example.com", "bob@example.com", "2026-01-02"),
|
||||
]
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE email_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT,
|
||||
name TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
imap_host TEXT,
|
||||
imap_port INTEGER,
|
||||
imap_user TEXT,
|
||||
imap_password TEXT,
|
||||
imap_starttls INTEGER,
|
||||
smtp_host TEXT,
|
||||
smtp_port INTEGER,
|
||||
smtp_security TEXT,
|
||||
smtp_user TEXT,
|
||||
smtp_password TEXT,
|
||||
from_address TEXT,
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO email_accounts
|
||||
(id, owner, name, is_default, enabled, imap_host, imap_port, imap_user,
|
||||
imap_password, imap_starttls, smtp_host, smtp_port, smtp_security,
|
||||
smtp_user, smtp_password, from_address, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, 'imap.example.com', 993, ?, '', 1,
|
||||
'smtp.example.com', 465, 'ssl', ?, '', ?, ?)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_prefix_then_encoded_word_single_space():
|
||||
assert es._decode_header("Re: =?utf-8?b?SsOzc2U=?=") == "Re: J\u00f3se"
|
||||
|
||||
|
||||
def test_encoded_word_then_plain_text():
|
||||
assert es._decode_header("=?utf-8?b?SsOzc2U=?= Smith") == "J\u00f3se Smith"
|
||||
|
||||
|
||||
def test_adjacent_encoded_words_join_without_space():
|
||||
out = es._decode_header("=?iso-8859-1?q?Caf=E9?= =?utf-8?b?5pel5pys?=")
|
||||
assert out == "Caf\u00e9\u65e5\u672c"
|
||||
|
||||
|
||||
def test_plain_ascii_header_unchanged():
|
||||
assert es._decode_header("Weekly report") == "Weekly report"
|
||||
|
||||
|
||||
def test_empty_header():
|
||||
assert es._decode_header("") == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_account_listing_explains_default_email_tool_fallback(monkeypatch):
|
||||
monkeypatch.setattr(es, "_read_accounts_from_db", lambda: [])
|
||||
monkeypatch.setattr(es, "_fixture_email_enabled", lambda: False)
|
||||
monkeypatch.setattr(es, "_fixture_account_rows", lambda: [])
|
||||
|
||||
out = await es.call_tool("list_email_accounts", {})
|
||||
|
||||
assert "Default single-account mode is active" in out[0].text
|
||||
assert "omit the `account` field and continue" in out[0].text
|
||||
assert "search_emails" in out[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_email_accounts_are_filtered_by_hidden_owner(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "app.db"
|
||||
_init_accounts_db(db_path)
|
||||
monkeypatch.setattr(es, "APP_DB", str(db_path))
|
||||
es._ACCOUNT_CACHE.clear()
|
||||
|
||||
out = await es.call_tool("list_email_accounts", {"_odysseus_owner": "alice"})
|
||||
text = out[0].text
|
||||
|
||||
assert "Alice Mail" in text
|
||||
assert "Bob Mail" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_email_requires_owner_when_multiple_account_owners_exist(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "app.db"
|
||||
_init_accounts_db(db_path)
|
||||
monkeypatch.setattr(es, "APP_DB", str(db_path))
|
||||
es._ACCOUNT_CACHE.clear()
|
||||
|
||||
out = await es.call_tool("list_email_accounts", {})
|
||||
|
||||
assert "requires an authenticated owner" in out[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_email_requires_owner_when_any_account_owner_exists(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "app.db"
|
||||
_init_accounts_db(
|
||||
db_path,
|
||||
rows=[
|
||||
("acct-alice", "alice", "Alice Mail", 1, "alice@example.com", "alice@example.com", "alice@example.com", "2026-01-01"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(es, "APP_DB", str(db_path))
|
||||
|
||||
out = await es.call_tool("list_email_accounts", {})
|
||||
|
||||
assert "requires an authenticated owner" in out[0].text
|
||||
assert "Alice Mail" not in out[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_email_configured_owner_filters_accounts(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "app.db"
|
||||
_init_accounts_db(db_path)
|
||||
monkeypatch.setattr(es, "APP_DB", str(db_path))
|
||||
monkeypatch.setenv("ODYSSEUS_MCP_EMAIL_OWNER", "alice")
|
||||
|
||||
out = await es.call_tool("list_email_accounts", {})
|
||||
text = out[0].text
|
||||
|
||||
assert "Alice Mail" in text
|
||||
assert "Bob Mail" not in text
|
||||
|
||||
|
||||
def test_mcp_email_scoped_owner_without_visible_account_skips_legacy_fallback(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "app.db"
|
||||
settings_path = tmp_path / "settings.json"
|
||||
_init_accounts_db(db_path)
|
||||
settings_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"imap_host": "legacy-imap.example.com",
|
||||
"imap_user": "legacy@example.com",
|
||||
"imap_password": "legacy-secret",
|
||||
"smtp_host": "legacy-smtp.example.com",
|
||||
"smtp_user": "legacy@example.com",
|
||||
"smtp_password": "legacy-secret",
|
||||
"from_address": "legacy@example.com",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(es, "APP_DB", str(db_path))
|
||||
monkeypatch.setattr(es, "_SETTINGS_FILE", str(settings_path))
|
||||
es._ACCOUNT_CACHE.clear()
|
||||
|
||||
token = es._CURRENT_OWNER.set("charlie")
|
||||
try:
|
||||
with pytest.raises(ValueError, match="No email account is configured"):
|
||||
es._load_config()
|
||||
finally:
|
||||
es._CURRENT_OWNER.reset(token)
|
||||
es._ACCOUNT_CACHE.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_email_owner_cannot_use_other_owner_account_for_list_read_send_or_draft(tmp_path, monkeypatch):
|
||||
import src.constants as constants
|
||||
|
||||
db_path = tmp_path / "app.db"
|
||||
scheduled_path = tmp_path / "scheduled_emails.db"
|
||||
_init_accounts_db(db_path)
|
||||
monkeypatch.setattr(es, "APP_DB", str(db_path))
|
||||
monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(scheduled_path))
|
||||
monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True)
|
||||
|
||||
calls = [
|
||||
("list_emails", {"account": "Bob Mail"}),
|
||||
("read_email", {"uid": "1", "account": "Bob Mail"}),
|
||||
("send_email", {
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Blocked",
|
||||
"body": "Do not stage.",
|
||||
"account": "Bob Mail",
|
||||
}),
|
||||
("draft_email", {
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Blocked",
|
||||
"body": "Do not draft.",
|
||||
"account": "Bob Mail",
|
||||
}),
|
||||
]
|
||||
|
||||
for tool_name, args in calls:
|
||||
out = await es.call_tool(tool_name, {**args, "_odysseus_owner": "alice"})
|
||||
assert "Email account not found for selector" in out[0].text, tool_name
|
||||
assert "Bob Mail" not in out[0].text or "Available accounts" in out[0].text
|
||||
|
||||
assert not scheduled_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_send_email_stages_with_visible_owner_account_id(tmp_path, monkeypatch):
|
||||
import src.constants as constants
|
||||
|
||||
app_db_path = tmp_path / "app.db"
|
||||
scheduled_path = tmp_path / "scheduled_emails.db"
|
||||
_init_accounts_db(app_db_path)
|
||||
monkeypatch.setattr(es, "APP_DB", str(app_db_path))
|
||||
monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(scheduled_path))
|
||||
monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True)
|
||||
|
||||
out = await es.call_tool(
|
||||
"send_email",
|
||||
{
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Review",
|
||||
"body": "Please review.",
|
||||
"account": "Alice Mail",
|
||||
"_odysseus_owner": "alice",
|
||||
},
|
||||
)
|
||||
|
||||
assert "Draft staged for approval" in out[0].text
|
||||
conn = sqlite3.connect(scheduled_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT owner, status, account_id FROM scheduled_emails"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row == ("alice", "agent_draft", "acct-alice")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_send_email_stages_owner_scoped_pending_draft(tmp_path, monkeypatch):
|
||||
import src.constants as constants
|
||||
|
||||
db_path = tmp_path / "scheduled_emails.db"
|
||||
monkeypatch.setattr(es, "APP_DB", str(tmp_path / "missing-app.db"))
|
||||
monkeypatch.setattr(constants, "SCHEDULED_EMAILS_DB", str(db_path))
|
||||
monkeypatch.setattr(es, "_read_agent_email_confirm_setting", lambda: True)
|
||||
|
||||
out = await es.call_tool(
|
||||
"send_email",
|
||||
{
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Review",
|
||||
"body": "Please review.",
|
||||
"_odysseus_owner": "alice",
|
||||
},
|
||||
)
|
||||
|
||||
assert "Draft staged for approval" in out[0].text
|
||||
assert "Nothing has been sent yet" in out[0].text
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT owner, status, to_addr, subject FROM scheduled_emails"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row == ("alice", "agent_draft", "recipient@example.com", "Review")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_draft_email_document_uses_hidden_owner(monkeypatch):
|
||||
import core.database as db_mod
|
||||
|
||||
saved = []
|
||||
|
||||
class FakeDocument:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
class FakeDocumentVersion:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
class FakeDb:
|
||||
def add(self, obj):
|
||||
saved.append(obj)
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(db_mod, "Document", FakeDocument)
|
||||
monkeypatch.setattr(db_mod, "DocumentVersion", FakeDocumentVersion)
|
||||
monkeypatch.setattr(db_mod, "SessionLocal", lambda: FakeDb())
|
||||
monkeypatch.setattr(
|
||||
es,
|
||||
"_load_config",
|
||||
lambda account=None: {"account_name": "Alice Mail", "account_id": "acct-alice"},
|
||||
)
|
||||
|
||||
out = await es.call_tool(
|
||||
"draft_email",
|
||||
{
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Draft subject",
|
||||
"body": "Draft body",
|
||||
"_odysseus_owner": "alice",
|
||||
},
|
||||
)
|
||||
|
||||
assert "Created Odysseus email draft" in out[0].text
|
||||
docs = [obj for obj in saved if isinstance(obj, FakeDocument)]
|
||||
assert len(docs) == 1
|
||||
assert docs[0].owner == "alice"
|
||||
Reference in New Issue
Block a user