Files
leocrm/tests/test_mail.py
T

1110 lines
38 KiB
Python
Raw Normal View History

"""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"<msg-{uuid.uuid4()}@test>"),
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", "<p>Hello world</p>"),
body_html_sanitized=kwargs.get("body_html_sanitized", sanitize_html("<p>Hello world</p>")),
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="<p>Hello</p><script>alert('xss')</script>",
body_html_sanitized=sanitize_html("<p>Hello</p><script>alert('xss')</script>"),
)
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 "<script" not in data["body_html_sanitized"]
# ─── F-MAIL-02: Send, Reply, Forward ───
@pytest.mark.asyncio
async def test_send_mail(mail_authed_client):
"""AC: POST /api/v1/mail/send -> 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": "<p>Hello</p>",
"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": "<p>Reply body</p>", "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": "<p>Fwd</p>"},
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="<msg1@test>",
)
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="<msg2@test>",
in_reply_to="<msg1@test>",
)
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": "<p>Hi {{name}}</p>"},
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": "<p>B1</p>"},
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": "<p>Best regards</p>", "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": "<p>Sig</p>"},
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": "<p>I am away.</p>",
},
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 = '<p>Hello</p><script>alert("xss")</script><img src=x onerror=alert(1)>'
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 "<script" not in sanitized
assert "onerror" not in sanitized
# ─── F-MAIL-14: Password never returned ───
@pytest.mark.asyncio
async def test_password_never_in_response(mail_authed_client):
"""AC: Mail account password never returned in any API response."""
client, seed = mail_authed_client
account = await _create_account(client)
# GET list
resp = await client.get("/api/v1/mail/accounts", headers=ORIGIN_HEADER)
for a in resp.json():
assert "password" not in a
assert "encrypted_password" not in a
# GET shared
resp = await client.get("/api/v1/mail/accounts/shared", headers=ORIGIN_HEADER)
for a in resp.json():
assert "password" not in a
assert "encrypted_password" not in a
# PATCH
resp = await client.patch(
f"/api/v1/mail/accounts/{account['id']}",
json={"display_name": "X"},
headers=ORIGIN_HEADER,
)
assert "password" not in resp.json()
assert "encrypted_password" not in resp.json()
# ─── F-MAIL-15, F-MAIL-16: Shared Mailbox Permissions ───
@pytest.mark.asyncio
async def test_shared_mailbox_read_only(mail_authed_client, db_session):
"""AC: Shared mailbox: delegated user can read but not delete
(permission=read -> 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 = "<p>Hello</p><script>alert(1)</script>"
cleaned = sanitize_html(raw)
assert "<script" not in cleaned
assert "alert" not in cleaned
assert "<p>Hello</p>" in cleaned
@pytest.mark.asyncio
async def test_sanitize_html_removes_onerror():
"""Verify nh3 removes onerror attributes."""
raw = "<img src=x onerror=alert(1)>"
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("<msg1@test>", "", None)
assert tid == "<msg1@test>"
# With references -> use first reference
tid = _compute_thread_id("<msg2@test>", "<msg1@test> <msg0@test>", None)
assert tid == "<msg1@test>"
# With in_reply_to -> use it
tid = _compute_thread_id("<msg2@test>", "", "<msg1@test>")
assert tid == "<msg1@test>"
@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 = "<p>Buy our product</p>"
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"}
)