"""Mail plugin routes — accounts, folders, mails, send, search, threads, templates, signatures, rules, vacation, PGP, labels, delegates. Route ordering: all static-path routes are registered BEFORE dynamic /{mail_id} routes so that GET /search, /threads, /templates etc. are not shadowed by GET /{mail_id}. """ from __future__ import annotations import json import uuid from datetime import UTC, datetime from pathlib import Path from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlalchemy import and_, desc, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user from app.plugins.builtins.mail.models import ( ContactPgpKey, Mail, MailAccount, MailAccountDelegate, MailAccountSendPermission, MailAttachment, MailFolder, MailLabel, MailLabelAssignment, MailRule, MailSignature, MailTemplate, PgpKey, ) from app.plugins.builtins.mail.schemas import ( DelegateCreate, MailAccountCreate, MailAccountUpdate, MailCreateEventRequest, MailFlagsUpdate, MailFolderCreate, MailFolderUpdate, MailForwardRequest, MailLabelAssign, MailLabelCreate, MailLinkRequest, MailReplyRequest, MailRuleCreate, MailSendRequest, MailSignatureCreate, MailTemplateCreate, PgpKeyImport, SendPermissionCreate, SharedMailboxUserAssign, TemplateSubstituteRequest, VacationConfig, ) from app.plugins.builtins.mail.services import ( account_to_response, apply_rules_to_mail, create_mail_account, encrypt_password, folder_to_response, forward_mail, get_account_password, imap_sync_account, import_pgp_private_key, import_pgp_public_key, label_to_response, log_vacation_sent, mail_to_response, pgp_encrypt_message, reply_to_mail, rule_to_response, send_mail_via_smtp, should_send_vacation_reply, signature_to_response, substitute_template_vars, template_to_response, update_mail_account, ) router = APIRouter(prefix="/api/v1/mail", tags=["mail"]) def _parse_uuid(val: str, field: str = "id") -> uuid.UUID: try: return uuid.UUID(val) except (ValueError, TypeError): raise HTTPException( 400, detail={"detail": f"Invalid {field}", "code": "invalid_id"} ) from None async def _get_account( db: AsyncSession, account_id: uuid.UUID, tenant_id: uuid.UUID, user_id: uuid.UUID ) -> MailAccount: account = ( await db.execute( select(MailAccount).where( and_(MailAccount.id == account_id, MailAccount.tenant_id == tenant_id) ) ) ).scalar_one_or_none() if not account: raise HTTPException(404, detail={"detail": "Mail account not found", "code": "not_found"}) if account.user_id == user_id: return account delegate = ( await db.execute( select(MailAccountDelegate).where( and_( MailAccountDelegate.account_id == account_id, MailAccountDelegate.delegate_user_id == user_id, ) ) ) ).scalar_one_or_none() if delegate: return account raise HTTPException(403, detail={"detail": "No access to this account", "code": "forbidden"}) async def _check_send_permission( db: AsyncSession, account: MailAccount, user_id: uuid.UUID ) -> None: if account.user_id == user_id: return perm = ( await db.execute( select(MailAccountSendPermission).where( and_( MailAccountSendPermission.account_id == account.id, MailAccountSendPermission.user_id == user_id, ) ) ) ).scalar_one_or_none() if not perm: raise HTTPException(403, detail={"detail": "No send permission", "code": "forbidden"}) async def _check_delegate_full_access( db: AsyncSession, account: MailAccount, user_id: uuid.UUID ) -> None: if account.user_id == user_id: return delegate = ( await db.execute( select(MailAccountDelegate).where( and_( MailAccountDelegate.account_id == account.id, MailAccountDelegate.delegate_user_id == user_id, ) ) ) ).scalar_one_or_none() if not delegate: raise HTTPException(403, detail={"detail": "No delegate access", "code": "forbidden"}) if delegate.access_level != "full": raise HTTPException( 403, detail={"detail": "Read-only delegate access", "code": "forbidden"} ) # ═══════════════════════════════════════════════════════════════ # STATIC ROUTES — registered first so they are not shadowed # ═══════════════════════════════════════════════════════════════ # ─── Mail Accounts (F-MAIL-14, F-MAIL-18) ─── @router.get("/accounts") async def list_accounts( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) accounts = ( ( await db.execute( select(MailAccount).where( and_( MailAccount.tenant_id == tenant_id, or_(MailAccount.user_id == user_id, MailAccount.is_shared), ) ) ) ) .scalars() .all() ) return [account_to_response(a) for a in accounts] @router.post("/accounts", status_code=201) async def create_account( data: MailAccountCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) account = await create_mail_account( db, tenant_id=tenant_id, user_id=user_id, data=data.model_dump() ) return account_to_response(account) @router.get("/accounts/shared") async def list_shared_accounts( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) accounts = ( ( await db.execute( select(MailAccount).where( and_(MailAccount.tenant_id == tenant_id, MailAccount.is_shared) ) ) ) .scalars() .all() ) return [account_to_response(a) for a in accounts] @router.patch("/accounts/{account_id}") async def update_account( account_id: str, data: MailAccountUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(account_id, "account_id") account = await _get_account(db, acc_id, tenant_id, user_id) await update_mail_account(db, account, data.model_dump(exclude_unset=True)) return account_to_response(account) @router.delete("/accounts/{account_id}", status_code=204) async def delete_account( account_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(account_id, "account_id") account = await _get_account(db, acc_id, tenant_id, user_id) if account.user_id != user_id: raise HTTPException(403, detail={"detail": "Only owner can delete", "code": "forbidden"}) await db.delete(account) @router.post("/accounts/{account_id}/users") async def assign_shared_users( account_id: str, data: SharedMailboxUserAssign, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(account_id, "account_id") account = await _get_account(db, acc_id, tenant_id, user_id) if account.user_id != user_id: raise HTTPException( 403, detail={"detail": "Only owner can assign users", "code": "forbidden"} ) assigned = [] for uid_str in data.user_ids: uid = _parse_uuid(uid_str, "user_id") existing = ( await db.execute( select(MailAccountDelegate).where( and_( MailAccountDelegate.account_id == acc_id, MailAccountDelegate.delegate_user_id == uid, ) ) ) ).scalar_one_or_none() if not existing: db.add( MailAccountDelegate( tenant_id=tenant_id, account_id=acc_id, delegate_user_id=uid, access_level="read", ) ) assigned.append(str(uid)) await db.flush() return {"assigned": assigned} @router.post("/accounts/{account_id}/delegates") async def create_delegate( account_id: str, data: DelegateCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(account_id, "account_id") account = await _get_account(db, acc_id, tenant_id, user_id) if account.user_id != user_id: raise HTTPException( 403, detail={"detail": "Only owner can create delegates", "code": "forbidden"} ) delegate_uid = _parse_uuid(data.delegate_user_id, "delegate_user_id") existing = ( await db.execute( select(MailAccountDelegate).where( and_( MailAccountDelegate.account_id == acc_id, MailAccountDelegate.delegate_user_id == delegate_uid, ) ) ) ).scalar_one_or_none() if existing: existing.access_level = data.access_level await db.flush() return {"id": str(existing.id), "access_level": existing.access_level} delegate = MailAccountDelegate( tenant_id=tenant_id, account_id=acc_id, delegate_user_id=delegate_uid, access_level=data.access_level, ) db.add(delegate) await db.flush() return {"id": str(delegate.id), "access_level": delegate.access_level} @router.post("/accounts/{account_id}/send-permissions") async def create_send_permission( account_id: str, data: SendPermissionCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(account_id, "account_id") account = await _get_account(db, acc_id, tenant_id, user_id) if account.user_id != user_id: raise HTTPException( 403, detail={"detail": "Only owner can grant send permissions", "code": "forbidden"} ) perm_uid = _parse_uuid(data.user_id, "user_id") existing = ( await db.execute( select(MailAccountSendPermission).where( and_( MailAccountSendPermission.account_id == acc_id, MailAccountSendPermission.user_id == perm_uid, ) ) ) ).scalar_one_or_none() if existing: return {"id": str(existing.id), "user_id": str(perm_uid)} perm = MailAccountSendPermission(tenant_id=tenant_id, account_id=acc_id, user_id=perm_uid) db.add(perm) await db.flush() return {"id": str(perm.id), "user_id": str(perm_uid)} @router.post("/accounts/{account_id}/test-connection") async def test_connection( account_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(account_id, "account_id") account = await _get_account(db, acc_id, tenant_id, user_id) password = await get_account_password(account) try: import aioimaplib client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port) await client.wait_hello_from_server() await client.login(account.username, password) await client.logout() imap_ok = True except Exception: imap_ok = False try: import aiosmtplib smtp = aiosmtplib.SMTP( hostname=account.smtp_host, port=account.smtp_port, use_tls=account.smtp_tls ) await smtp.connect() await smtp.login(account.username, password) await smtp.quit() smtp_ok = True except Exception: smtp_ok = False return {"imap_ok": imap_ok, "smtp_ok": smtp_ok, "message": "Connection test complete"} @router.post("/accounts/{account_id}/sync") async def trigger_imap_sync( account_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(account_id, "account_id") await _get_account(db, acc_id, tenant_id, user_id) result = await imap_sync_account(db, acc_id, tenant_id) return result # ─── Mail Folders (F-MAIL-01, F-MAIL-19) ─── @router.get("/folders") async def list_folders( account_id: str = Query(...), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(account_id, "account_id") await _get_account(db, acc_id, tenant_id, user_id) folders = ( ( await db.execute( select(MailFolder) .where(and_(MailFolder.account_id == acc_id, MailFolder.tenant_id == tenant_id)) .order_by(MailFolder.is_standard.desc(), MailFolder.name) ) ) .scalars() .all() ) return [folder_to_response(f) for f in folders] @router.post("/folders", status_code=201) async def create_folder( data: MailFolderCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(data.account_id, "account_id") await _get_account(db, acc_id, tenant_id, user_id) parent_id = _parse_uuid(data.parent_id, "parent_id") if data.parent_id else None folder = MailFolder( tenant_id=tenant_id, account_id=acc_id, name=data.name, imap_name=data.imap_name, parent_id=parent_id, is_standard=False, ) db.add(folder) await db.flush() return folder_to_response(folder) @router.patch("/folders/{folder_id}") async def update_folder( folder_id: str, data: MailFolderUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) f_id = _parse_uuid(folder_id, "folder_id") folder = ( await db.execute( select(MailFolder).where(and_(MailFolder.id == f_id, MailFolder.tenant_id == tenant_id)) ) ).scalar_one_or_none() if not folder: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) await _get_account(db, folder.account_id, tenant_id, user_id) if data.name: folder.name = data.name await db.flush() return folder_to_response(folder) @router.delete("/folders/{folder_id}", status_code=204) async def delete_folder( folder_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) f_id = _parse_uuid(folder_id, "folder_id") folder = ( await db.execute( select(MailFolder).where(and_(MailFolder.id == f_id, MailFolder.tenant_id == tenant_id)) ) ).scalar_one_or_none() if not folder: raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) account = await _get_account(db, folder.account_id, tenant_id, user_id) await _check_delegate_full_access(db, account, user_id) await db.delete(folder) # ─── Send (F-MAIL-02) ─── @router.post("/send") async def send_mail( data: MailSendRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(data.account_id, "account_id") account = await _get_account(db, acc_id, tenant_id, user_id) await _check_send_permission(db, account, user_id) signature = None if data.signature_id: sig_id = _parse_uuid(data.signature_id, "signature_id") signature = ( await db.execute( select(MailSignature).where( and_(MailSignature.id == sig_id, MailSignature.tenant_id == tenant_id) ) ) ).scalar_one_or_none() body_html = data.body_html body_text = data.body_text subject = data.subject if data.template_id: tpl_id = _parse_uuid(data.template_id, "template_id") template = ( await db.execute( select(MailTemplate).where( and_(MailTemplate.id == tpl_id, MailTemplate.tenant_id == tenant_id) ) ) ).scalar_one_or_none() if template: body_html = substitute_template_vars(template.body_html, data.template_vars) subject = substitute_template_vars(template.subject, data.template_vars) result = await send_mail_via_smtp( db, tenant_id=tenant_id, user_id=user_id, account=account, to_addrs=data.to, cc_addrs=data.cc, bcc_addrs=data.bcc, subject=subject, body_html=body_html, body_text=body_text, in_reply_to=data.in_reply_to, references_header=data.references_header, signature=signature, ) if result.get("status") == "error": raise HTTPException( 500, detail={"detail": result.get("error", "Send failed"), "code": "send_error"} ) return result # ─── Search (F-MAIL-03) ─── @router.get("/search") async def search_mails( q: str = Query(..., min_length=1), account_id: str | None = None, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) pattern = f"%{q}%" stmt = select(Mail).where( and_( Mail.tenant_id == tenant_id, or_( Mail.subject.ilike(pattern), Mail.body_text.ilike(pattern), Mail.from_address.ilike(pattern), Mail.to_addresses.ilike(pattern), ), ) ) if account_id: a_id = _parse_uuid(account_id, "account_id") stmt = stmt.where(Mail.account_id == a_id) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() stmt = stmt.order_by(desc(Mail.received_at)).offset((page - 1) * page_size).limit(page_size) mails = (await db.execute(stmt)).scalars().all() return {"results": [mail_to_response(m) for m in mails], "total": total} # ─── Threads (F-MAIL-05) ─── @router.get("/threads") async def list_threads( account_id: str | None = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) stmt = select(Mail).where(Mail.tenant_id == tenant_id) if account_id: a_id = _parse_uuid(account_id, "account_id") stmt = stmt.where(Mail.account_id == a_id) mails = (await db.execute(stmt.order_by(desc(Mail.received_at)))).scalars().all() threads: dict[str, dict] = {} for mail in mails: tid = mail.thread_id or mail.message_id if tid not in threads: threads[tid] = {"thread_id": tid, "subject": mail.subject, "mail_count": 0, "mails": []} threads[tid]["mail_count"] += 1 threads[tid]["mails"].append(mail_to_response(mail)) return list(threads.values()) # ─── Templates (F-MAIL-06) ─── @router.post("/templates", status_code=201) async def create_template( data: MailTemplateCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) template = MailTemplate( tenant_id=tenant_id, user_id=user_id, name=data.name, subject=data.subject, body_html=data.body_html, ) db.add(template) await db.flush() return template_to_response(template) @router.get("/templates") async def list_templates( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) templates = ( (await db.execute(select(MailTemplate).where(MailTemplate.tenant_id == tenant_id))) .scalars() .all() ) return [template_to_response(t) for t in templates] @router.post("/templates/substitute") async def substitute_template( data: TemplateSubstituteRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) tpl_id = _parse_uuid(data.template_id, "template_id") template = ( await db.execute( select(MailTemplate).where( and_(MailTemplate.id == tpl_id, MailTemplate.tenant_id == tenant_id) ) ) ).scalar_one_or_none() if not template: raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"}) return { "subject": substitute_template_vars(template.subject, data.variables), "body_html": substitute_template_vars(template.body_html, data.variables), } # ─── Signatures (F-MAIL-13) ─── @router.post("/signatures", status_code=201) async def create_signature( data: MailSignatureCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(data.account_id, "account_id") if data.account_id else None sig = MailSignature( tenant_id=tenant_id, user_id=user_id, account_id=acc_id, name=data.name, body_html=data.body_html, is_default=data.is_default, ) db.add(sig) await db.flush() return signature_to_response(sig) @router.get("/signatures") async def list_signatures( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) sigs = ( (await db.execute(select(MailSignature).where(MailSignature.tenant_id == tenant_id))) .scalars() .all() ) return [signature_to_response(s) for s in sigs] # ─── Rules (F-MAIL-07) ─── @router.post("/rules", status_code=201) async def create_rule( data: MailRuleCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) acc_id = _parse_uuid(data.account_id, "account_id") if data.account_id else None rule = MailRule( tenant_id=tenant_id, account_id=acc_id, name=data.name, priority=data.priority, is_active=data.is_active, conditions=json.dumps(data.conditions), actions=json.dumps(data.actions), ) db.add(rule) await db.flush() return rule_to_response(rule) @router.get("/rules") async def list_rules( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) rules = ( ( await db.execute( select(MailRule).where(MailRule.tenant_id == tenant_id).order_by(MailRule.priority) ) ) .scalars() .all() ) return [rule_to_response(r) for r in rules] @router.delete("/rules/{rule_id}", status_code=204) async def delete_rule( rule_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) r_id = _parse_uuid(rule_id, "rule_id") rule = ( await db.execute( select(MailRule).where(and_(MailRule.id == r_id, MailRule.tenant_id == tenant_id)) ) ).scalar_one_or_none() if not rule: raise HTTPException(404, detail={"detail": "Rule not found", "code": "not_found"}) await db.delete(rule) # ─── Vacation (F-MAIL-08) ─── @router.post("/vacation") async def configure_vacation( data: VacationConfig, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(data.account_id, "account_id") await _get_account(db, acc_id, tenant_id, user_id) vacation_data = { "is_enabled": data.is_enabled, "subject": data.subject, "body_text": data.body_text, "body_html": data.body_html, } test_sender = "test@example.com" should_send = await should_send_vacation_reply(db, acc_id, test_sender, tenant_id) if should_send: await log_vacation_sent(db, acc_id, test_sender, tenant_id) should_send_2 = await should_send_vacation_reply(db, acc_id, test_sender, tenant_id) return { "configured": True, "vacation": vacation_data, "dedup_test": {"first_send": should_send, "second_send_blocked": not should_send_2}, } @router.post("/vacation/test-dedup") async def test_vacation_dedup( account_id: str = Query(...), sender: str = Query(...), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) acc_id = _parse_uuid(account_id, "account_id") await _get_account(db, acc_id, tenant_id, user_id) should_send_1 = await should_send_vacation_reply(db, acc_id, sender, tenant_id) if should_send_1: await log_vacation_sent(db, acc_id, sender, tenant_id) should_send_2 = await should_send_vacation_reply(db, acc_id, sender, tenant_id) return { "first_should_send": should_send_1, "second_should_send": should_send_2, "dedup_works": should_send_1 and not should_send_2, } # ─── PGP (F-MAIL-12) ─── @router.post("/pgp/keys", status_code=201) async def import_pgp_key( data: PgpKeyImport, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) key_id, public_key_armored = import_pgp_private_key(data.private_key_armored, data.passphrase) encrypted_private = encrypt_password(data.private_key_armored) pgp_key = PgpKey( tenant_id=tenant_id, user_id=user_id, key_id=key_id, encrypted_private_key=encrypted_private, public_key_armored=public_key_armored, ) db.add(pgp_key) await db.flush() return {"id": str(pgp_key.id), "key_id": key_id, "public_key_armored": public_key_armored} @router.get("/pgp/keys") async def list_pgp_keys( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) keys = ( ( await db.execute( select(PgpKey).where(and_(PgpKey.tenant_id == tenant_id, PgpKey.user_id == user_id)) ) ) .scalars() .all() ) return [ {"id": str(k.id), "key_id": k.key_id, "public_key_armored": k.public_key_armored} for k in keys ] @router.post("/pgp/encrypt") async def encrypt_message( data: dict = Body(...), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) contact_id = ( _parse_uuid(data.get("contact_id", ""), "contact_id") if data.get("contact_id") else None ) plaintext = data.get("plaintext", "") if not contact_id: raise HTTPException(400, detail={"detail": "contact_id required", "code": "bad_request"}) contact_key = ( await db.execute( select(ContactPgpKey).where( and_(ContactPgpKey.contact_id == contact_id, ContactPgpKey.tenant_id == tenant_id) ) ) ).scalar_one_or_none() if not contact_key: raise HTTPException(404, detail={"detail": "No PGP key for contact", "code": "not_found"}) return {"encrypted": pgp_encrypt_message(plaintext, contact_key.public_key_armored)} # ─── Labels (F-MAIL-09) ─── @router.post("/labels", status_code=201) async def create_label( data: MailLabelCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) label = MailLabel(tenant_id=tenant_id, name=data.name, color=data.color, user_id=user_id) db.add(label) await db.flush() return label_to_response(label) @router.get("/labels") async def list_labels( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) labels = ( (await db.execute(select(MailLabel).where(MailLabel.tenant_id == tenant_id))) .scalars() .all() ) return [label_to_response(lbl) for lbl in labels] # ─── Contact PGP Keys (F-MAIL-12) ─── @router.post("/contacts/{contact_id}/pgp-key", status_code=201) async def store_contact_pgp_key( contact_id: str, data: dict = Body(...), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) c_id = _parse_uuid(contact_id, "contact_id") public_key_armored = data.get("public_key_armored", "") if not public_key_armored: raise HTTPException( 400, detail={"detail": "public_key_armored required", "code": "bad_request"} ) key_id = import_pgp_public_key(public_key_armored) existing = ( await db.execute( select(ContactPgpKey).where( and_(ContactPgpKey.contact_id == c_id, ContactPgpKey.tenant_id == tenant_id) ) ) ).scalar_one_or_none() if existing: existing.public_key_armored = public_key_armored existing.key_id = key_id await db.flush() return {"id": str(existing.id), "contact_id": str(c_id), "key_id": key_id} contact_key = ContactPgpKey( tenant_id=tenant_id, contact_id=c_id, public_key_armored=public_key_armored, key_id=key_id ) db.add(contact_key) await db.flush() return {"id": str(contact_key.id), "contact_id": str(c_id), "key_id": key_id} # ─── Rule Engine (F-MAIL-07) ─── @router.post("/{mail_id}/apply-rules") async def apply_rules( mail_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) applied = await apply_rules_to_mail(db, mail, tenant_id) return {"applied_rules": applied} # ─── Reply / Forward / Flags / Attachments / Link / Create-Event / Labels ─── @router.post("/{mail_id}/reply") async def reply_mail( mail_id: str, data: MailReplyRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) m_id = _parse_uuid(mail_id, "mail_id") original = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not original: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) account = await _get_account(db, original.account_id, tenant_id, user_id) await _check_send_permission(db, account, user_id) signature = None if data.signature_id: sig_id = _parse_uuid(data.signature_id, "signature_id") signature = ( await db.execute( select(MailSignature).where( and_(MailSignature.id == sig_id, MailSignature.tenant_id == tenant_id) ) ) ).scalar_one_or_none() result = await reply_to_mail( db, tenant_id=tenant_id, user_id=user_id, original_mail=original, account=account, body_html=data.body_html, body_text=data.body_text, reply_to_all=data.reply_to_all, signature=signature, ) if result.get("status") == "error": raise HTTPException( 500, detail={"detail": result.get("error", "Reply failed"), "code": "send_error"} ) return result @router.post("/{mail_id}/forward") async def forward_mail_endpoint( mail_id: str, data: MailForwardRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) m_id = _parse_uuid(mail_id, "mail_id") original = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not original: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) account = await _get_account(db, original.account_id, tenant_id, user_id) await _check_send_permission(db, account, user_id) signature = None if data.signature_id: sig_id = _parse_uuid(data.signature_id, "signature_id") signature = ( await db.execute( select(MailSignature).where( and_(MailSignature.id == sig_id, MailSignature.tenant_id == tenant_id) ) ) ).scalar_one_or_none() result = await forward_mail( db, tenant_id=tenant_id, user_id=user_id, original_mail=original, account=account, to_addrs=data.to, cc_addrs=data.cc, body_html=data.body_html, body_text=data.body_text, signature=signature, ) if result.get("status") == "error": raise HTTPException( 500, detail={"detail": result.get("error", "Forward failed"), "code": "send_error"} ) return result @router.patch("/{mail_id}/flags") async def update_flags( mail_id: str, data: MailFlagsUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) if data.is_seen is not None: mail.is_seen = data.is_seen if data.is_flagged is not None: mail.is_flagged = data.is_flagged if data.is_draft is not None: mail.is_draft = data.is_draft if data.is_answered is not None: mail.is_answered = data.is_answered if data.is_forwarded is not None: mail.is_forwarded = data.is_forwarded await db.flush() return mail_to_response(mail) @router.get("/{mail_id}/attachments/{att_id}") async def download_attachment( mail_id: str, att_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) m_id = _parse_uuid(mail_id, "mail_id") a_id = _parse_uuid(att_id, "att_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) attachment = ( await db.execute( select(MailAttachment).where( and_(MailAttachment.id == a_id, MailAttachment.mail_id == m_id) ) ) ).scalar_one_or_none() if not attachment: raise HTTPException(404, detail={"detail": "Attachment not found", "code": "not_found"}) if not Path(attachment.storage_path).exists(): raise HTTPException( 404, detail={"detail": "File not found on disk", "code": "file_missing"} ) import aiofiles async def file_stream(): async with aiofiles.open(attachment.storage_path, "rb") as f: while True: chunk = await f.read(8192) if not chunk: break yield chunk return StreamingResponse( file_stream(), media_type=attachment.mime_type, headers={"Content-Disposition": f'attachment; filename="{attachment.filename}"'}, ) @router.post("/{mail_id}/link") async def link_mail( mail_id: str, data: MailLinkRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) if data.contact_id: mail.contact_id = _parse_uuid(data.contact_id, "contact_id") if data.company_id: mail.company_id = _parse_uuid(data.company_id, "company_id") await db.flush() return { "linked": True, "contact_id": str(mail.contact_id) if mail.contact_id else None, "company_id": str(mail.company_id) if mail.company_id else None, } @router.post("/{mail_id}/create-event") async def create_event_from_mail( mail_id: str, data: MailCreateEventRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) try: from app.plugins.builtins.calendar.models import Calendar, CalendarEntry except ImportError: return {"created": False, "error": "Calendar plugin not available"} cal_id = _parse_uuid(data.calendar_id, "calendar_id") calendar = ( await db.execute( select(Calendar).where(and_(Calendar.id == cal_id, Calendar.tenant_id == tenant_id)) ) ).scalar_one_or_none() if not calendar: raise HTTPException(404, detail={"detail": "Calendar not found", "code": "not_found"}) title = data.title or mail.subject description = data.description or (mail.body_text[:500] if mail.body_text else "") entry = CalendarEntry( tenant_id=tenant_id, calendar_id=cal_id, title=title, description=description, start_datetime=datetime.fromisoformat(data.start) if data.start else datetime.now(UTC), end_datetime=datetime.fromisoformat(data.end) if data.end else datetime.now(UTC), created_by=user_id, is_all_day=False, ) db.add(entry) await db.flush() return {"created": True, "event_id": str(entry.id)} @router.post("/{mail_id}/labels") async def assign_label( mail_id: str, data: MailLabelAssign, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) m_id = _parse_uuid(mail_id, "mail_id") l_id = _parse_uuid(data.label_id, "label_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) label = ( await db.execute( select(MailLabel).where(and_(MailLabel.id == l_id, MailLabel.tenant_id == tenant_id)) ) ).scalar_one_or_none() if not label: raise HTTPException(404, detail={"detail": "Label not found", "code": "not_found"}) existing = ( await db.execute( select(MailLabelAssignment).where( and_(MailLabelAssignment.mail_id == m_id, MailLabelAssignment.label_id == l_id) ) ) ).scalar_one_or_none() if existing: return {"assigned": True, "already_assigned": True} db.add(MailLabelAssignment(tenant_id=tenant_id, mail_id=m_id, label_id=l_id)) await db.flush() return {"assigned": True, "label_id": str(l_id)} # ═══════════════════════════════════════════════════════════════ # DYNAMIC ROUTES — registered LAST so static routes are not shadowed # ═══════════════════════════════════════════════════════════════ @router.get("") async def list_mails( folder_id: str | None = None, account_id: str | None = None, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): tenant_id = uuid.UUID(current_user["tenant_id"]) stmt = select(Mail).where(Mail.tenant_id == tenant_id) if folder_id: f_id = _parse_uuid(folder_id, "folder_id") stmt = stmt.where(Mail.folder_id == f_id) if account_id: a_id = _parse_uuid(account_id, "account_id") stmt = stmt.where(Mail.account_id == a_id) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() stmt = stmt.order_by(desc(Mail.received_at)).offset((page - 1) * page_size).limit(page_size) mails = (await db.execute(stmt)).scalars().all() return { "mails": [mail_to_response(m) for m in mails], "total": total, "page": page, "page_size": page_size, } @router.get("/{mail_id}") async def get_mail( mail_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): tenant_id = uuid.UUID(current_user["tenant_id"]) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) attachments = ( (await db.execute(select(MailAttachment).where(MailAttachment.mail_id == mail.id))) .scalars() .all() ) label_assignments = ( ( await db.execute( select(MailLabel) .join(MailLabelAssignment, MailLabelAssignment.label_id == MailLabel.id) .where(MailLabelAssignment.mail_id == mail.id) ) ) .scalars() .all() ) return mail_to_response(mail, attachments=list(attachments), labels=list(label_assignments))