"""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, File, HTTPException, Query, UploadFile from fastapi.responses import StreamingResponse from sqlalchemy import and_, asc, desc, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import require_permission from app.plugins.builtins.mail.models import ( ContactPgpKey, Mail, MailAccount, MailAccountDelegate, MailAccountSendPermission, MailAttachment, MailFolder, MailLabel, MailLabelAssignment, MailRule, MailSignature, MailSyncQueue, MailTemplate, PgpKey, ) from app.plugins.builtins.mail.schemas import ( DelegateCreate, MailAccountCreate, MailAccountUpdate, MailCreateEventRequest, MailDraftCreate, MailFlagsUpdate, MailFolderCreate, MailFolderUpdate, MailForwardRequest, MailLabelAssign, MailLabelCreate, MailLinkRequest, MailMoveRequest, MailReplyRequest, MailRuleCreate, MailSendRequest, MailSignatureCreate, MailTemplateCreate, PgpKeyImport, SendPermissionCreate, SharedMailboxUserAssign, TemplateSubstituteRequest, VacationConfig, ) import app.plugins.builtins.mail.services as mail_services from app.plugins.builtins.mail.services import ( MAX_ATTACHMENT_SIZE, _attachment_storage_path, _sanitize_filename, _save_attachment_to_storage, account_to_response, apply_rules_to_mail, attachment_to_response, create_mail_account, encrypt_password, folder_to_response, forward_mail, get_account_password, imap_create_folder, imap_delete_folder, imap_delete_mail, imap_move_mail, 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, save_draft, send_mail_via_smtp, should_send_vacation_reply, signature_to_response, substitute_template_vars, template_to_response, update_draft, 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 def _resolve_attachment_paths(attachment_ids: list[str]) -> list[dict]: """Resolve temporary attachment upload IDs to file paths on disk. Each uploaded attachment is stored under ``/mail_uploads//``. We scan the directory for the single file inside and return its metadata. """ import os resolved: list[dict] = [] base_dir = os.environ.get("STORAGE_PATH", "/tmp") for att_id in attachment_ids: upload_dir = os.path.join(base_dir, "mail_uploads", att_id) if not os.path.isdir(upload_dir): continue for fname in os.listdir(upload_dir): fpath = os.path.join(upload_dir, fname) if os.path.isfile(fpath): resolved.append( { "path": fpath, "filename": fname, "mime_type": "application/octet-stream", } ) break return resolved 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"}) _LEVEL_HIERARCHY = {"read": 1, "write": 2, "delete": 3, "full": 4} async def _check_delegate_access( db: AsyncSession, account: MailAccount, user_id: uuid.UUID, required_level: str = "read" ) -> None: """Check if user has delegate access to account with at least the required level.""" if account.user_id == user_id: return # Owner has full access 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 to this mailbox", "code": "forbidden"}) user_level = _LEVEL_HIERARCHY.get(delegate.access_level, 0) required = _LEVEL_HIERARCHY.get(required_level, 1) if user_level < required: raise HTTPException( 403, detail={"detail": f"Insufficient delegate access (need {required_level}, have {delegate.access_level})", "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(require_permission("mail:read")) ): 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(require_permission("mail:config")), ): 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(require_permission("mail:read")) ): 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(require_permission("mail:config")), ): 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(require_permission("mail:config")), ): 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(require_permission("mail:share")), ): 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(require_permission("mail:share")), ): 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(require_permission("mail:share")), ): 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(require_permission("mail:config")), ): 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(require_permission("mail:config")), ): 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(require_permission("mail:read")), ): 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(require_permission("mail:config")), ): 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() try: await imap_create_folder(db, acc_id, data.imap_name, tenant_id) except Exception: import logging as _logging _logging.getLogger(__name__).warning( "create_folder: IMAP CREATE failed (non-critical) for %s", data.imap_name ) 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(require_permission("mail:config")), ): 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(require_permission("mail:config")), ): 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_access(db, account, user_id, "full") try: await imap_delete_folder(db, f_id, tenant_id) except Exception: import logging as _logging _logging.getLogger(__name__).warning( "delete_folder: IMAP DELETE failed (non-critical) for folder %s", f_id ) await db.delete(folder) # ─── Empty Folder (soft-delete all mails in folder) ─── @router.post("/folders/{folder_id}/empty") async def empty_folder( folder_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:delete")), ): """Soft-delete all mails in a folder (sets deleted_at = now()). The mails remain in the database but are hidden from the normal list. Returns the number of mails that were emptied. """ 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_access(db, account, user_id, "delete") # Soft-delete all non-deleted mails in this folder result = await db.execute( select(Mail).where( and_( Mail.folder_id == f_id, Mail.tenant_id == tenant_id, Mail.deleted_at.is_(None), ) ) ) mails = result.scalars().all() now = datetime.now(UTC) for mail in mails: mail.deleted_at = now await db.flush() return {"emptied_count": len(mails)} # ─── Sync single folder (live sync when selecting a folder) ─── @router.post("/folders/{folder_id}/sync") async def sync_folder( folder_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read")), ): """Sync a single folder from IMAP server immediately.""" 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) result = await mail_services.imap_sync_folder(db, f_id, tenant_id) await db.flush() return result # ─── Attachment Upload (F-MAIL-04) ─── @router.post("/upload-attachment") async def upload_attachment( file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:write")), ): """Upload a file to be attached to an outgoing email. Returns a temporary attachment ID that can be passed in the `attachments` list of the send-mail request. """ tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) # Read file content and check size content = await file.read() if len(content) > MAX_ATTACHMENT_SIZE: raise HTTPException( 413, detail={ "detail": f"File exceeds maximum size of {MAX_ATTACHMENT_SIZE // (1024 * 1024)} MB", "code": "file_too_large", }, ) # Generate a temporary attachment ID and store the file temp_id = str(uuid.uuid4()) safe_filename = _sanitize_filename(file.filename or "attachment") mime_type = file.content_type or "application/octet-stream" # Store in a temp directory keyed by the temp_id import os temp_dir = os.path.join( os.environ.get("STORAGE_PATH", "/tmp"), "mail_uploads", temp_id ) os.makedirs(temp_dir, exist_ok=True) file_path = os.path.join(temp_dir, safe_filename) import aiofiles async with aiofiles.open(file_path, "wb") as f: await f.write(content) return { "id": temp_id, "filename": safe_filename, "mime_type": mime_type, "size_bytes": len(content), "path": file_path, } # ─── Send (F-MAIL-02) ─── @router.post("/send") async def send_mail( data: MailSendRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:send")), ): 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, attachment_paths=_resolve_attachment_paths(data.attachments), ) 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(require_permission("mail:read")), ): 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(require_permission("mail:read")), ): 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(require_permission("mail:config")), ): 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(require_permission("mail:read")) ): 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(require_permission("mail:read")), ): 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(require_permission("mail:config")), ): 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(require_permission("mail:read")) ): 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(require_permission("mail:config")), ): 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(require_permission("mail:read")) ): 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(require_permission("mail:config")) ): 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(require_permission("mail:config")), ): 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(require_permission("mail:config")), ): 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(require_permission("mail:config")), ): 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(require_permission("mail:read")) ): 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(require_permission("mail:send")), ): 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(require_permission("mail:config")), ): 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(require_permission("mail:read")) ): 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(require_permission("mail:config")), ): 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(require_permission("mail:config")) ): 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(require_permission("mail:send")), ): 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_delegate_access(db, account, user_id, "read") 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(require_permission("mail:send")), ): 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_delegate_access(db, account, user_id, "read") 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(require_permission("mail:write")), ): 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"}) account = await _get_account(db, mail.account_id, tenant_id, user_id) await _check_delegate_access(db, account, user_id, "write") 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.flag_type is not None: mail.flag_type = data.flag_type if data.flag_type else None 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() # Sync flags to IMAP server (non-critical — failures are logged but don't break the API) try: await mail_services.imap_sync_mail_flags(db, m_id, tenant_id) except Exception as exc: import logging logging.getLogger(__name__).warning("IMAP flag sync failed for mail %s: %s", m_id, exc) 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(require_permission("mail:read")), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_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"}) account = await _get_account(db, mail.account_id, tenant_id, user_id) await _check_delegate_access(db, account, user_id, "read") 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(require_permission("mail:write")), ): 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"}) account = await _get_account(db, mail.account_id, tenant_id, user_id) await _check_delegate_access(db, account, user_id, "write") 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(require_permission("mail:write")), ): 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"}) account = await _get_account(db, mail.account_id, tenant_id, user_id) await _check_delegate_access(db, account, user_id, "write") 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(require_permission("mail:write")), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_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"}) account = await _get_account(db, mail.account_id, tenant_id, user_id) await _check_delegate_access(db, account, user_id, "write") 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)} # ─── Drafts (F-MAIL-15) ─── @router.post("/drafts", status_code=201) async def create_draft( data: MailDraftCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:write")), ): 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_delegate_access(db, account, user_id, "write") mail = await save_draft(db, acc_id, tenant_id, user_id, data.model_dump()) return mail_to_response(mail) @router.put("/drafts/{mail_id}") async def update_draft_route( mail_id: str, data: MailDraftCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:write")), ): 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"}) account = await _get_account(db, mail.account_id, tenant_id, user_id) await _check_delegate_access(db, account, user_id, "write") mail = await update_draft(db, m_id, tenant_id, data.model_dump()) return mail_to_response(mail) # ─── Delete / Move (dynamic /{mail_id} routes) ─── @router.delete("/{mail_id}", status_code=204) async def delete_mail( mail_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:delete")), ): 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"}) account = await _get_account(db, mail.account_id, tenant_id, user_id) await _check_delegate_access(db, account, user_id, "delete") # Soft-delete: set deleted_at = now() mail.deleted_at = datetime.now(UTC) await db.flush() # IMAP delete: queue for retry if it fails try: await mail_services.imap_delete_mail(db, m_id, tenant_id) except Exception as exc: import logging logging.getLogger(__name__).warning("IMAP delete failed for mail %s: %s, queuing for retry", m_id, exc) queue_entry = MailSyncQueue( tenant_id=tenant_id, mail_id=m_id, operation="delete", payload={}, status="pending", last_error=str(exc), ) db.add(queue_entry) await db.flush() @router.post("/{mail_id}/move") async def move_mail( mail_id: str, data: MailMoveRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:write")), ): tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) m_id = _parse_uuid(mail_id, "mail_id") target_f_id = _parse_uuid(data.target_folder_id, "target_folder_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"}) account = await _get_account(db, mail.account_id, tenant_id, user_id) await _check_delegate_access(db, account, user_id, "write") # Verify target folder exists and belongs to same tenant target_folder = ( await db.execute( select(MailFolder).where( and_(MailFolder.id == target_f_id, MailFolder.tenant_id == tenant_id) ) ) ).scalar_one_or_none() if not target_folder: raise HTTPException(404, detail={"detail": "Target folder not found", "code": "not_found"}) # Update mail.folder_id mail.folder_id = target_f_id await db.flush() # IMAP move: queue for retry if it fails try: await mail_services.imap_move_mail(db, m_id, target_f_id, tenant_id) except Exception as exc: import logging logging.getLogger(__name__).warning("IMAP move failed for mail %s: %s, queuing for retry", m_id, exc) queue_entry = MailSyncQueue( tenant_id=tenant_id, mail_id=m_id, operation="move", payload={"target_folder_id": str(target_f_id)}, status="pending", last_error=str(exc), ) db.add(queue_entry) await db.flush() return mail_to_response(mail) # ═══════════════════════════════════════════════════════════════ # 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), sort_by: str = Query("date", pattern="^(date|from|subject)$"), sort_order: str = Query("desc", pattern="^(asc|desc)$"), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read")), ): 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) # Exclude soft-deleted mails stmt = stmt.where(Mail.deleted_at.is_(None)) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() # Dynamic sorting sort_columns = { "date": Mail.received_at, "from": Mail.from_address, "subject": Mail.subject, } sort_col = sort_columns.get(sort_by, Mail.received_at) order_func = desc if sort_order == "desc" else asc stmt = stmt.order_by(order_func(sort_col)).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(require_permission("mail:read")) ): 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"}) account = await _get_account(db, mail.account_id, tenant_id, user_id) await _check_delegate_access(db, account, user_id, "read") 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))