Merge pull request #22 from o3LL/refactor/routes-email-subpackage

refactor(routes): move the email modules into routes/email/
This commit is contained in:
Alexandre Teixeira
2026-10-01 02:17:29 +01:00
committed by GitHub
16 changed files with 11127 additions and 10980 deletions
+8
View File
@@ -0,0 +1,8 @@
"""Email route domain package.
Contains email_routes.py, email_helpers.py and email_pollers.py, migrated
from the flat routes/ directory. Backward-compat shims at
routes/email_routes.py, routes/email_helpers.py and routes/email_pollers.py
replace themselves with these modules, so both import paths resolve to one
object.
"""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -2020
View File
File diff suppressed because it is too large Load Diff
+11 -1759
View File
File diff suppressed because it is too large Load Diff
+11 -7189
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
"""odysseus-mail — Unix-style command-line wrapper around the email
backend that powers the web UI.
Calls the same helpers `routes/email_helpers.py` exports, so a request
Calls the same helpers `routes/email/email_helpers.py` exports, so a request
issued from the shell hits IMAP/SMTP through the same connection pool
and the same parsing pipeline as the HTTP routes. State is shared via
`data/app.db` and `data/.app_key` (passwords decrypt automatically).
+2 -1
View File
@@ -8,7 +8,8 @@ This spec covers mail and contacts in:
- app wiring in `app.py`;
- `core.database.EmailAccount`;
- `routes/email_routes.py`, `routes/email_helpers.py`, and `routes/email_pollers.py`;
- canonical `routes/email/email_routes.py`, `routes/email/email_helpers.py`, and
`routes/email/email_pollers.py`, plus their shims at the old flat paths;
- email threading in `src/email_thread_parser.py`;
- email MCP tools in `mcp_servers/email_server.py`;
- canonical contact/CardDAV routes in `routes/contacts/contacts_routes.py`,
+2 -2
View File
@@ -16,7 +16,7 @@ This spec covers durable state in:
- `src/attachment_refs.py`, `src/upload_handler.py`, and
`routes/upload_routes.py` for durable upload references and retention;
- JSON stores managed by `core/auth.py`, `src/settings.py`, `src/api_key_manager.py`, `src/preset_manager.py`, `src/integrations.py`, `src/upload_handler.py`, `src/personal_docs.py`, `src/research_handler.py`, `src/bg_jobs.py`, `routes/prefs_routes.py`, canonical `routes/contacts/contacts_routes.py` and `routes/vault/vault_routes.py` plus their shims, `routes/cookbook_routes.py`, and memory/skills managers;
- `routes/email_helpers.py` scheduled-email storage;
- canonical `routes/email/email_helpers.py` scheduled-email storage;
- `routes/backup_routes.py` and `scripts/odysseus-backup`;
- runtime data under `data/`.
@@ -61,7 +61,7 @@ Email default-account state is serialized per owner. Startup normalizes legacy d
`core/models.py` owns pure dataclasses used by `SessionManager`. It does not own database persistence.
`routes/email_helpers.py` owns a second SQLite database at `data/scheduled_emails.db` for scheduled email, summary, reply, tag, sender-signature, urgency-alert, calendar-extraction, and cache state. Its migrations and owner backfills are local to that module, not `core/database.py`, and those auxiliary tables are owner-scoped.
`routes/email/email_helpers.py` owns a second SQLite database at `data/scheduled_emails.db` for scheduled email, summary, reply, tag, sender-signature, urgency-alert, calendar-extraction, and cache state. Its migrations and owner backfills are local to that module, not `core/database.py`, and those auxiliary tables are owner-scoped.
## Migration Policy
+3 -3
View File
@@ -258,7 +258,7 @@ def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
backend = (ROOT / "routes/email_routes.py").read_text(encoding="utf-8")
backend = (ROOT / "routes/email/email_routes.py").read_text(encoding="utf-8")
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
assert "async function _deleteAfterUnsubscribe" in frontend
assert "action: 'delete'" in frontend[frontend.index("async function _deleteAfterUnsubscribe"):]
@@ -311,7 +311,7 @@ def test_email_mutation_tool_events_include_exact_arguments() -> None:
def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> None:
source = (ROOT / "routes" / "email_routes.py").read_text()
source = (ROOT / "routes" / "email" / "email_routes.py").read_text()
cleanup = source[source.index('@router.post("/unsubscribe/cleanup")'):source.index('@router.get("/contacts")')]
assert 'scope == "sender_unsubscribe"' in cleanup
assert "_unsubscribe_sender_uids_sync" in cleanup
@@ -372,7 +372,7 @@ def test_email_settings_sections_use_static_headers() -> None:
def test_unsubscribe_scan_defaults_to_bounded_page_in_api_and_tool_prompt() -> None:
backend = (ROOT / "routes" / "email_routes.py").read_text()
backend = (ROOT / "routes" / "email" / "email_routes.py").read_text()
schema = (ROOT / "src" / "tool_schemas.py").read_text()
agent = (ROOT / "src" / "agent_loop.py").read_text()
scan_start = backend.index('@router.get("/unsubscribe/scan")')
+1 -1
View File
@@ -50,7 +50,7 @@ def test_direct_upload_routes_use_bounded_reads():
"routes/calendar_routes.py": [
"read_upload_limited(file, ICS_MAX_BYTES",
],
"routes/email_routes.py": [
"routes/email/email_routes.py": [
"read_upload_limited(file, EMAIL_COMPOSE_UPLOAD_MAX_BYTES",
],
}
+1 -1
View File
@@ -4,7 +4,7 @@ from tests.helpers.document_source import document_source
_REPO = Path(__file__).resolve().parents[1]
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
_EMAIL_ROUTES = _REPO / "routes" / "email_routes.py"
_EMAIL_ROUTES = _REPO / "routes" / "email" / "email_routes.py"
_EMAIL_MCP_SERVER = _REPO / "mcp_servers" / "email_server.py"
_EMAIL_FIXTURE_HELPER = _REPO / "scripts" / "ody_eval_email_fixture.py"
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import ast
import os
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
CANONICAL = (
ROOT / "routes" / "email" / "email_helpers.py",
ROOT / "routes" / "email" / "email_pollers.py",
ROOT / "routes" / "email" / "email_routes.py",
)
LEGACY_NAMES = {
"routes.email_helpers",
"routes.email_pollers",
"routes.email_routes",
}
def _imported_module_names(path: Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
names.add(node.module)
return names
def test_canonical_email_package_does_not_import_legacy_shims() -> None:
offenders: list[str] = []
for path in CANONICAL:
imports = _imported_module_names(path)
legacy = sorted(imports & LEGACY_NAMES)
if legacy:
offenders.append(f"{path.relative_to(ROOT)}: {legacy}")
assert not offenders, "\n".join(offenders)
def test_legacy_email_modules_alias_canonical_module_objects() -> None:
code = r'''
import importlib
import sys
pairs = (
("routes.email_helpers", "routes.email.email_helpers"),
("routes.email_pollers", "routes.email.email_pollers"),
("routes.email_routes", "routes.email.email_routes"),
)
for legacy_name, canonical_name in pairs:
legacy = importlib.import_module(legacy_name)
canonical = importlib.import_module(canonical_name)
assert legacy is canonical, (legacy_name, canonical_name)
assert sys.modules[legacy_name] is canonical
assert sys.modules[canonical_name] is canonical
marker = object()
legacy._compat_identity_probe = marker
assert canonical._compat_identity_probe is marker
del canonical._compat_identity_probe
'''
env = os.environ.copy()
env["PYTHONDONTWRITEBYTECODE"] = "1"
env["PYTHON_DOTENV_DISABLED"] = "1"
env["ODYSSEUS_INPROCESS_POLLERS"] = "0"
result = subprocess.run(
[sys.executable, "-c", code],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
timeout=30,
)
assert result.returncode == 0, (
f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}"
)
+1 -1
View File
@@ -86,7 +86,7 @@ def test_known_imap_mailbox_call_sites_are_quoted():
assert 'conn.uid("MOVE", _b(uid), dest_folder)' not in mcp
assert 'conn.uid("COPY", _b(uid), dest_folder)' not in mcp
pollers = Path("routes/email_pollers.py").read_text()
pollers = Path("routes/email/email_pollers.py").read_text()
assert "conn.select(sent_name" not in pollers
assert "imap.append(sent_folder" not in pollers
+2 -2
View File
@@ -86,7 +86,7 @@ def test_routes_import_from_upload_limits_not_local_defs():
],
"routes/memory/memory_routes.py": ['int(os.getenv("ODYSSEUS_MEMORY_IMPORT_MAX_BYTES"'],
"routes/personal_routes.py": ['os.getenv("ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES"'],
"routes/email_routes.py": ["EMAIL_COMPOSE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024"],
"routes/email/email_routes.py": ["EMAIL_COMPOSE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024"],
"routes/stt_routes.py": ["STT_MAX_AUDIO_BYTES = 25 * 1024 * 1024"],
"routes/calendar_routes.py": ["_ICS_MAX_BYTES = 10 * 1024 * 1024"],
}
@@ -100,7 +100,7 @@ def test_routes_import_from_upload_limits_not_local_defs():
"routes/gallery/gallery_routes.py": "GALLERY_UPLOAD_MAX_BYTES",
"routes/memory/memory_routes.py": "MEMORY_IMPORT_MAX_BYTES",
"routes/personal_routes.py": "PERSONAL_UPLOAD_MAX_BYTES",
"routes/email_routes.py": "EMAIL_COMPOSE_UPLOAD_MAX_BYTES",
"routes/email/email_routes.py": "EMAIL_COMPOSE_UPLOAD_MAX_BYTES",
"routes/stt_routes.py": "STT_MAX_AUDIO_BYTES",
"routes/calendar_routes.py": "ICS_MAX_BYTES",
}