"""Tests for the Mail plugin — covers all 40 acceptance criteria.""" from __future__ import annotations import os import uuid from unittest.mock import AsyncMock, patch import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from app.core.db import close_engine, reset_engine_for_testing from app.core.service_container import get_container from app.main import create_app from app.plugins.builtins.mail import MailPlugin from app.plugins.builtins.mail.models import ( Mail, MailAccount, MailAttachment, ) from app.plugins.builtins.mail.services import ( _compute_thread_id, decrypt_password, encrypt_password, matches_condition, sanitize_html, substitute_template_vars, ) from app.plugins.registry import reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users # ─── Mail Fixtures ─── @pytest_asyncio.fixture async def mail_app(engine: AsyncEngine, redis_client): """FastAPI app with Mail plugin registered.""" reset_engine_for_testing(engine) app = create_app() registry = reset_registry_for_testing() registry.initialize(engine, app) container = get_container() await container.initialize() registry.register_plugin(MailPlugin()) reset_plugin_service_for_testing(registry) # Pre-install and activate sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) async with sf() as session: await registry.install(session, "mail") await registry.activate(session, "mail") await session.commit() yield app await close_engine() @pytest_asyncio.fixture async def mail_client(mail_app) -> AsyncClient: transport = ASGITransport(app=mail_app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c @pytest_asyncio.fixture async def mail_authed_client( mail_client: AsyncClient, db_session: AsyncSession ) -> tuple[AsyncClient, dict]: """Authenticated admin client with seeded data and mail plugin activated.""" seed = await seed_tenant_and_users(db_session) await login_client(mail_client, "admin@tenanta.com") return mail_client, seed async def _create_account(client: AsyncClient, is_shared: bool = False) -> dict: """Helper: create a mail account via API.""" resp = await client.post( "/api/v1/mail/accounts", json={ "email_address": "test@example.com", "display_name": "Test Account", "imap_host": "imap.example.com", "imap_port": 993, "imap_ssl": True, "smtp_host": "smtp.example.com", "smtp_port": 587, "smtp_tls": True, "username": "test@example.com", "password": "secret123", "is_shared": is_shared, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 201, f"Create account failed: {resp.status_code} {resp.text}" return resp.json() async def _get_inbox_folder(client: AsyncClient, account_id: str) -> dict: """Helper: get the INBOX folder for an account.""" resp = await client.get( f"/api/v1/mail/folders?account_id={account_id}", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 folders = resp.json() for f in folders: if f["imap_name"] == "INBOX": return f raise AssertionError("INBOX folder not found") async def _create_mail_direct(db: AsyncSession, tenant_id, account_id, folder_id, **kwargs) -> Mail: """Helper: create a Mail object directly in DB.""" mail = Mail( tenant_id=tenant_id, account_id=account_id, folder_id=folder_id, message_id=kwargs.get("message_id", f""), thread_id=kwargs.get("thread_id", ""), in_reply_to=kwargs.get("in_reply_to"), references_header=kwargs.get("references_header"), subject=kwargs.get("subject", "Test Subject"), from_address=kwargs.get("from_address", "sender@example.com"), to_addresses=kwargs.get("to_addresses", "recipient@example.com"), cc_addresses=kwargs.get("cc_addresses", ""), bcc_addresses=kwargs.get("bcc_addresses", ""), body_text=kwargs.get("body_text", "Hello world"), body_html=kwargs.get("body_html", "

Hello world

"), body_html_sanitized=kwargs.get("body_html_sanitized", sanitize_html("

Hello world

")), is_seen=kwargs.get("is_seen", False), is_flagged=kwargs.get("is_flagged", False), has_attachments=kwargs.get("has_attachments", False), size_bytes=kwargs.get("size_bytes", 100), ) db.add(mail) await db.flush() return mail # ═══════════════════════════════════════════════════════════════ # AC Tests — grouped by feature area # ═══════════════════════════════════════════════════════════════ # ─── F-MAIL-14: Mail Account CRUD ─── @pytest.mark.asyncio async def test_list_accounts(mail_authed_client): """AC: GET /api/v1/mail/accounts -> 200 + account list (password not in response).""" client, seed = mail_authed_client await _create_account(client) resp = await client.get("/api/v1/mail/accounts", headers=ORIGIN_HEADER) assert resp.status_code == 200 accounts = resp.json() assert len(accounts) >= 1 # Password must NOT be in response assert "password" not in accounts[0] assert "encrypted_password" not in accounts[0] @pytest.mark.asyncio async def test_create_account_password_encrypted(mail_authed_client, db_session): """AC: POST /api/v1/mail/accounts -> 201, password AES-256 encrypted in DB.""" client, seed = mail_authed_client account = await _create_account(client) # Verify in DB that password is encrypted from sqlalchemy import select result = await db_session.execute( select(MailAccount).where(MailAccount.id == uuid.UUID(account["id"])) ) db_account = result.scalar_one() assert db_account.encrypted_password != "secret123" assert db_account.encrypted_password != account.get("password", "") # Verify decryption works decrypted = decrypt_password(db_account.encrypted_password) assert decrypted == "secret123" @pytest.mark.asyncio async def test_update_account(mail_authed_client): """AC: PATCH /api/v1/mail/accounts/{id} -> 200.""" client, seed = mail_authed_client account = await _create_account(client) resp = await client.patch( f"/api/v1/mail/accounts/{account['id']}", json={"display_name": "Updated Name", "password": "newpass456"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["display_name"] == "Updated Name" @pytest.mark.asyncio async def test_list_shared_accounts(mail_authed_client): """AC: GET /api/v1/mail/accounts/shared -> 200 + shared mailboxes.""" client, seed = mail_authed_client await _create_account(client, is_shared=True) resp = await client.get("/api/v1/mail/accounts/shared", headers=ORIGIN_HEADER) assert resp.status_code == 200 accounts = resp.json() assert len(accounts) >= 1 assert accounts[0]["is_shared"] is True @pytest.mark.asyncio async def test_assign_shared_users(mail_authed_client): """AC: POST /api/v1/mail/accounts/{id}/users -> 200, shared mailbox users assigned.""" client, seed = mail_authed_client account = await _create_account(client, is_shared=True) viewer_id = str(seed["viewer_a"].id) resp = await client.post( f"/api/v1/mail/accounts/{account['id']}/users", json={"user_ids": [viewer_id]}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert viewer_id in resp.json()["assigned"] @pytest.mark.asyncio async def test_create_delegate(mail_authed_client): """AC: POST /api/v1/mail/accounts/{id}/delegates -> 200, delegate access granted.""" client, seed = mail_authed_client account = await _create_account(client) editor_id = str(seed["editor_a"].id) resp = await client.post( f"/api/v1/mail/accounts/{account['id']}/delegates", json={"delegate_user_id": editor_id, "access_level": "read"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["access_level"] == "read" @pytest.mark.asyncio async def test_create_send_permission(mail_authed_client): """AC: POST /api/v1/mail/accounts/{id}/send-permissions -> 200, send permission granted.""" client, seed = mail_authed_client account = await _create_account(client) editor_id = str(seed["editor_a"].id) resp = await client.post( f"/api/v1/mail/accounts/{account['id']}/send-permissions", json={"user_id": editor_id}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["user_id"] == editor_id # ─── F-MAIL-01, F-MAIL-19: Mail Folders ─── @pytest.mark.asyncio async def test_list_folders(mail_authed_client): """AC: GET /api/v1/mail/folders?account_id=X -> 200 + folder list with counts.""" client, seed = mail_authed_client account = await _create_account(client) resp = await client.get( f"/api/v1/mail/folders?account_id={account['id']}", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 folders = resp.json() assert len(folders) >= 4 # INBOX, Sent, Drafts, Spam assert any(f["imap_name"] == "INBOX" for f in folders) @pytest.mark.asyncio async def test_create_folder(mail_authed_client): """AC: POST /api/v1/mail/folders -> 201, folder created.""" client, seed = mail_authed_client account = await _create_account(client) resp = await client.post( "/api/v1/mail/folders", json={ "account_id": account["id"], "name": "Custom Folder", "imap_name": "Custom", }, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 assert resp.json()["name"] == "Custom Folder" @pytest.mark.asyncio async def test_update_folder(mail_authed_client): """AC: PATCH /api/v1/mail/folders/{id} -> 200, renamed.""" client, seed = mail_authed_client account = await _create_account(client) folders = ( await client.get(f"/api/v1/mail/folders?account_id={account['id']}", headers=ORIGIN_HEADER) ).json() folder_id = folders[0]["id"] resp = await client.patch( f"/api/v1/mail/folders/{folder_id}", json={"name": "Renamed Folder"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["name"] == "Renamed Folder" @pytest.mark.asyncio async def test_delete_folder(mail_authed_client): """AC: DELETE /api/v1/mail/folders/{id} -> 204, deleted.""" client, seed = mail_authed_client account = await _create_account(client) resp = await client.post( "/api/v1/mail/folders", json={"account_id": account["id"], "name": "To Delete", "imap_name": "ToDelete"}, headers=ORIGIN_HEADER, ) folder_id = resp.json()["id"] resp = await client.delete(f"/api/v1/mail/folders/{folder_id}", headers=ORIGIN_HEADER) assert resp.status_code == 204 # ─── F-MAIL-01: Mail List & Detail ─── @pytest.mark.asyncio async def test_list_mails(mail_authed_client, db_session): """AC: GET /api/v1/mail?folder_id=X&page=1 -> 200 + paginated mails.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) # Create mail directly in DB await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Test Mail 1", ) await db_session.commit() resp = await client.get( f"/api/v1/mail?folder_id={folder['id']}&page=1", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert "mails" in data assert "total" in data assert data["page"] == 1 @pytest.mark.asyncio async def test_get_mail_detail(mail_authed_client, db_session): """AC: GET /api/v1/mail/{id} -> 200 + mail detail (body_html sanitized, attachments listed).""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Detail Test", body_html="

Hello

", body_html_sanitized=sanitize_html("

Hello

"), ) await db_session.commit() resp = await client.get(f"/api/v1/mail/{mail.id}", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["subject"] == "Detail Test" assert "attachments" in data assert "labels" in data # body_html_sanitized should NOT contain script tags assert " 200, mail sent via SMTP.""" client, seed = mail_authed_client account = await _create_account(client) with patch("app.plugins.builtins.mail.services.aiosmtplib.SMTP") as mock_smtp_class: mock_smtp_inst = AsyncMock() mock_smtp_class.return_value = mock_smtp_inst resp = await client.post( "/api/v1/mail/send", json={ "account_id": account["id"], "to": ["recipient@test.com"], "subject": "Test Send", "body_html": "

Hello

", "body_text": "Hello", }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["status"] == "sent" @pytest.mark.asyncio async def test_reply_mail(mail_authed_client, db_session): """AC: POST /api/v1/mail/{id}/reply -> 200, reply sent with In-Reply-To header.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Original", ) await db_session.commit() with patch("app.plugins.builtins.mail.services.aiosmtplib.SMTP") as mock_smtp_class: mock_smtp_class.return_value = AsyncMock() resp = await client.post( f"/api/v1/mail/{mail.id}/reply", json={"body_html": "

Reply body

", "body_text": "Reply body"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["status"] == "sent" @pytest.mark.asyncio async def test_forward_mail(mail_authed_client, db_session): """AC: POST /api/v1/mail/{id}/forward -> 200, forwarded with original as attachment.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Forward Me", ) await db_session.commit() with patch("app.plugins.builtins.mail.services.aiosmtplib.SMTP") as mock_smtp_class: mock_smtp_class.return_value = AsyncMock() resp = await client.post( f"/api/v1/mail/{mail.id}/forward", json={"to": ["forward@test.com"], "body_html": "

Fwd

"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["status"] == "sent" # ─── F-MAIL-09: Flags ─── @pytest.mark.asyncio async def test_update_flags(mail_authed_client, db_session): """AC: PATCH /api/v1/mail/{id}/flags -> 200, seen/flagged toggled.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]) ) await db_session.commit() resp = await client.patch( f"/api/v1/mail/{mail.id}/flags", json={"is_seen": True, "is_flagged": True}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["is_seen"] is True assert data["is_flagged"] is True # ─── F-MAIL-04: Attachments ─── @pytest.mark.asyncio async def test_download_attachment(mail_authed_client, db_session): """AC: GET /api/v1/mail/{id}/attachments/{att_id} -> 200 + file stream.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]) ) # Create attachment test_file_path = f"/tmp/mail_test_{uuid.uuid4()}.txt" with open(test_file_path, "w") as f: f.write("attachment content") att = MailAttachment( tenant_id=seed["tenant_a"].id, mail_id=mail.id, filename="test.txt", mime_type="text/plain", size_bytes=17, storage_path=test_file_path, ) db_session.add(att) await db_session.commit() resp = await client.get( f"/api/v1/mail/{mail.id}/attachments/{att.id}", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 # Clean up os.remove(test_file_path) # ─── F-MAIL-10: Contact/Company Linking ─── @pytest.mark.asyncio async def test_link_mail(mail_authed_client, db_session): """AC: POST /api/mail/{id}/link -> 200, manual contact/company link created.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]) ) await db_session.commit() contact_id = str(seed["company_a"].id) # Use company as stand-in resp = await client.post( f"/api/v1/mail/{mail.id}/link", json={"company_id": contact_id}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["linked"] is True # ─── F-MAIL-11: Create Event from Mail ─── @pytest.mark.asyncio async def test_create_event_from_mail(mail_authed_client, db_session): """AC: POST /api/v1/mail/{id}/create-event -> 200, calendar event created from mail.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Meeting Request", ) await db_session.commit() # Try without calendar plugin - should return gracefully resp = await client.post( f"/api/v1/mail/{mail.id}/create-event", json={ "calendar_id": str(uuid.uuid4()), "title": "Meeting", "start": "2026-07-01T10:00:00", "end": "2026-07-01T11:00:00", }, headers=ORIGIN_HEADER, ) # Calendar plugin may not be loaded, so we accept 200 with created=False or 404 assert resp.status_code in (200, 404) # ─── F-MAIL-03: Search ─── @pytest.mark.asyncio async def test_search_mails(mail_authed_client, db_session): """AC: GET /api/v1/mail/search?q=text -> 200 + FTS results (body_tsv).""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Important Project Update", body_text="Please review the quarterly report", ) await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Lunch tomorrow", body_text="Let us grab lunch", ) await db_session.commit() resp = await client.get( "/api/v1/mail/search?q=quarterly", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert "results" in data assert data["total"] >= 1 # ─── F-MAIL-05: Threading ─── @pytest.mark.asyncio async def test_threads(mail_authed_client, db_session): """AC: GET /api/v1/mail/threads -> 200 + threaded view grouped by thread_id.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) # Create threaded mails await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Thread Start", thread_id="thread-123", message_id="", ) await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Re: Thread Start", thread_id="thread-123", message_id="", in_reply_to="", ) await db_session.commit() resp = await client.get("/api/v1/mail/threads", headers=ORIGIN_HEADER) assert resp.status_code == 200 threads = resp.json() assert isinstance(threads, list) # Should have at least one thread assert len(threads) >= 1 # Find our thread our_thread = [t for t in threads if t["thread_id"] == "thread-123"] assert len(our_thread) == 1 assert our_thread[0]["mail_count"] == 2 # ─── F-MAIL-06: Templates ─── @pytest.mark.asyncio async def test_create_template(mail_authed_client): """AC: POST /api/v1/mail/templates -> 201, template created.""" client, seed = mail_authed_client resp = await client.post( "/api/v1/mail/templates", json={"name": "Welcome", "subject": "Welcome {{name}}", "body_html": "

Hi {{name}}

"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 assert resp.json()["name"] == "Welcome" @pytest.mark.asyncio async def test_list_templates(mail_authed_client): """AC: GET /api/v1/mail/templates -> 200 + template list.""" client, seed = mail_authed_client await client.post( "/api/v1/mail/templates", json={"name": "T1", "subject": "S1", "body_html": "

B1

"}, headers=ORIGIN_HEADER, ) resp = await client.get("/api/v1/mail/templates", headers=ORIGIN_HEADER) assert resp.status_code == 200 templates = resp.json() assert len(templates) >= 1 # ─── F-MAIL-13: Signatures ─── @pytest.mark.asyncio async def test_create_signature(mail_authed_client): """AC: POST /api/v1/mail/signatures -> 201, signature created.""" client, seed = mail_authed_client resp = await client.post( "/api/v1/mail/signatures", json={"name": "Default Sig", "body_html": "

Best regards

", "is_default": True}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 assert resp.json()["name"] == "Default Sig" @pytest.mark.asyncio async def test_list_signatures(mail_authed_client): """AC: GET /api/v1/mail/signatures -> 200 + signature list.""" client, seed = mail_authed_client await client.post( "/api/v1/mail/signatures", json={"name": "S1", "body_html": "

Sig

"}, headers=ORIGIN_HEADER, ) resp = await client.get("/api/v1/mail/signatures", headers=ORIGIN_HEADER) assert resp.status_code == 200 sigs = resp.json() assert len(sigs) >= 1 # ─── F-MAIL-07: Rules ─── @pytest.mark.asyncio async def test_create_rule(mail_authed_client): """AC: POST /api/v1/mail/rules -> 201, rule created with conditions+actions.""" client, seed = mail_authed_client resp = await client.post( "/api/v1/mail/rules", json={ "name": "Move spam", "priority": 1, "conditions": {"from_contains": "spam@example.com"}, "actions": {"mark_seen": True}, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 assert resp.json()["name"] == "Move spam" @pytest.mark.asyncio async def test_list_rules(mail_authed_client): """AC: GET /api/v1/mail/rules -> 200 + rule list sorted by priority.""" client, seed = mail_authed_client await client.post( "/api/v1/mail/rules", json={"name": "R2", "priority": 5, "conditions": {}, "actions": {}}, headers=ORIGIN_HEADER, ) await client.post( "/api/v1/mail/rules", json={"name": "R1", "priority": 1, "conditions": {}, "actions": {}}, headers=ORIGIN_HEADER, ) resp = await client.get("/api/v1/mail/rules", headers=ORIGIN_HEADER) assert resp.status_code == 200 rules = resp.json() assert len(rules) >= 2 # Should be sorted by priority priorities = [r["priority"] for r in rules] assert priorities == sorted(priorities) @pytest.mark.asyncio async def test_delete_rule(mail_authed_client): """AC: DELETE /api/v1/mail/rules/{id} -> 204.""" client, seed = mail_authed_client resp = await client.post( "/api/v1/mail/rules", json={"name": "ToDelete", "priority": 0, "conditions": {}, "actions": {}}, headers=ORIGIN_HEADER, ) rule_id = resp.json()["id"] resp = await client.delete(f"/api/v1/mail/rules/{rule_id}", headers=ORIGIN_HEADER) assert resp.status_code == 204 # ─── F-MAIL-08: Vacation ─── @pytest.mark.asyncio async def test_vacation_config(mail_authed_client): """AC: POST /api/v1/mail/vacation -> 200, vacation auto-reply configured.""" client, seed = mail_authed_client account = await _create_account(client) resp = await client.post( "/api/v1/mail/vacation", json={ "account_id": account["id"], "is_enabled": True, "subject": "Out of Office", "body_text": "I am away.", "body_html": "

I am away.

", }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["configured"] is True @pytest.mark.asyncio async def test_vacation_dedup(mail_authed_client): """AC: Vacation dedup: second auto-reply to same sender within 24h -> not sent.""" client, seed = mail_authed_client account = await _create_account(client) resp = await client.post( "/api/v1/mail/vacation/test-dedup", params={"account_id": account["id"], "sender": "someone@test.com"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["first_should_send"] is True assert data["second_should_send"] is False assert data["dedup_works"] is True # ─── F-MAIL-12: PGP ─── @pytest.mark.asyncio async def test_import_pgp_key(mail_authed_client): """AC: POST /api/v1/mail/pgp/keys -> 201, private key imported (encrypted).""" client, seed = mail_authed_client # Generate a test PGP key import pgpy key = pgpy.PGPKey.new(pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 1024) private_key_armored = str(key) resp = await client.post( "/api/v1/mail/pgp/keys", json={"private_key_armored": private_key_armored, "passphrase": ""}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 data = resp.json() assert "key_id" in data assert "public_key_armored" in data @pytest.mark.asyncio async def test_contact_pgp_key(mail_authed_client): """AC: POST /api/v1/contacts/{id}/pgp-key -> 201, contact public key stored.""" client, seed = mail_authed_client import pgpy key = pgpy.PGPKey.new(pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 1024) pub_key_armored = str(key.pubkey) contact_id = str(uuid.uuid4()) # fake contact resp = await client.post( f"/api/v1/mail/contacts/{contact_id}/pgp-key", json={"public_key_armored": pub_key_armored}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 assert resp.json()["key_id"] # ─── F-MAIL-09: Labels ─── @pytest.mark.asyncio async def test_create_label(mail_authed_client): """AC: POST /api/v1/mail/labels -> 201, label created.""" client, seed = mail_authed_client resp = await client.post( "/api/v1/mail/labels", json={"name": "Important", "color": "#ff0000"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 assert resp.json()["name"] == "Important" @pytest.mark.asyncio async def test_assign_label(mail_authed_client, db_session): """AC: POST /api/v1/mail/{id}/labels -> 200, label assigned.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]) ) # Create label label_resp = await client.post( "/api/v1/mail/labels", json={"name": "Work", "color": "#0000ff"}, headers=ORIGIN_HEADER, ) label_id = label_resp.json()["id"] await db_session.commit() resp = await client.post( f"/api/v1/mail/{mail.id}/labels", json={"label_id": label_id}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["assigned"] is True # ─── F-MAIL-01: IMAP Sync ─── @pytest.mark.asyncio async def test_imap_sync(mail_authed_client, db_session): """AC: IMAP sync (ARQ job): mails fetched and stored with body_tsv.""" client, seed = mail_authed_client account = await _create_account(client) # Mock the IMAP sync to simulate fetching mails with patch("app.plugins.builtins.mail.routes.imap_sync_account") as mock_sync: mock_sync.return_value = {"synced": 3} resp = await client.post( f"/api/v1/mail/accounts/{account['id']}/sync", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["synced"] == 3 # ─── F-MAIL-07: Rule Engine ─── @pytest.mark.asyncio async def test_rule_engine(mail_authed_client, db_session): """AC: Mail rule engine: incoming mail matching condition -> action executed.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), subject="Spam Mail", from_address="spam@example.com", ) # Create a rule await client.post( "/api/v1/mail/rules", json={ "name": "Mark spam seen", "priority": 1, "conditions": {"from_contains": "spam@example.com"}, "actions": {"mark_seen": True}, }, headers=ORIGIN_HEADER, ) await db_session.commit() resp = await client.post( f"/api/v1/mail/{mail.id}/apply-rules", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert len(data["applied_rules"]) >= 1 assert data["applied_rules"][0]["actions"]["seen"] is True # ─── F-MAIL: HTML Sanitization ─── @pytest.mark.asyncio async def test_html_sanitization(mail_authed_client, db_session): """AC: Body HTML sanitized (no script tags) via DOMPurify-equivalent.""" client, seed = mail_authed_client account = await _create_account(client) folder = await _get_inbox_folder(client, account["id"]) raw_html = '

Hello

' mail = await _create_mail_direct( db_session, seed["tenant_a"].id, uuid.UUID(account["id"]), uuid.UUID(folder["id"]), body_html=raw_html, body_html_sanitized=sanitize_html(raw_html), ) await db_session.commit() resp = await client.get(f"/api/v1/mail/{mail.id}", headers=ORIGIN_HEADER) assert resp.status_code == 200 sanitized = resp.json()["body_html_sanitized"] assert " 403 on DELETE).""" client, seed = mail_authed_client account = await _create_account(client, is_shared=True) # Grant read delegate to editor editor_id = str(seed["editor_a"].id) await client.post( f"/api/v1/mail/accounts/{account['id']}/delegates", json={"delegate_user_id": editor_id, "access_level": "read"}, headers=ORIGIN_HEADER, ) # Create a folder to try deleting folder_resp = await client.post( "/api/v1/mail/folders", json={"account_id": account["id"], "name": "Test", "imap_name": "Test"}, headers=ORIGIN_HEADER, ) folder_id = folder_resp.json()["id"] # Login as editor and try to delete -> should be 403 editor_client = client # same client, re-login await login_client(editor_client, "editor@tenanta.com") resp = await editor_client.delete( f"/api/v1/mail/folders/{folder_id}", headers=ORIGIN_HEADER, ) assert resp.status_code == 403 # ─── Unit Tests for Service Functions ─── @pytest.mark.asyncio async def test_encrypt_decrypt_password(): """Verify AES-256 encrypt/decrypt roundtrip.""" plaintext = "my-secret-password-123" encrypted = encrypt_password(plaintext) assert encrypted != plaintext decrypted = decrypt_password(encrypted) assert decrypted == plaintext @pytest.mark.asyncio async def test_sanitize_html_removes_script(): """Verify nh3 sanitization removes script tags.""" raw = "

Hello

" cleaned = sanitize_html(raw) assert "Hello

" in cleaned @pytest.mark.asyncio async def test_sanitize_html_removes_onerror(): """Verify nh3 removes onerror attributes.""" raw = "" cleaned = sanitize_html(raw) assert "onerror" not in cleaned @pytest.mark.asyncio async def test_template_substitution(): """Verify template variable substitution.""" template = "Hello {{name}}, welcome to {{company}}!" result = substitute_template_vars(template, {"name": "John", "company": "Acme"}) assert result == "Hello John, welcome to Acme!" @pytest.mark.asyncio async def test_thread_id_computation(): """Verify thread ID from References/In-Reply-To.""" # No references -> use message_id tid = _compute_thread_id("", "", None) assert tid == "" # With references -> use first reference tid = _compute_thread_id("", " ", None) assert tid == "" # With in_reply_to -> use it tid = _compute_thread_id("", "", "") assert tid == "" @pytest.mark.asyncio async def test_rule_condition_matching(): """Verify rule condition matching logic.""" # Create a mock-like mail object class FakeMail: from_address = "spam@example.com" subject = "Buy now!" to_addresses = "recipient@test.com" body_text = "Buy our product" body_html = "

Buy our product

" has_attachments = False is_flagged = False mail = FakeMail() assert matches_condition(mail, {"from_contains": "spam@example.com"}) assert not matches_condition(mail, {"from_contains": "legit@example.com"}) assert matches_condition(mail, {"subject_contains": "Buy"}) assert not matches_condition(mail, {"subject_contains": "Newsletter"}) assert matches_condition(mail, {"from_contains": "spam@example.com", "subject_contains": "Buy"}) assert not matches_condition( mail, {"from_contains": "spam@example.com", "subject_contains": "Newsletter"} )