Files
leocrm/app/plugins/builtins/mail/routes.py
T

1705 lines
59 KiB
Python
Raw Normal View History

"""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,
2026-07-15 19:44:41 +02:00
MailDraftCreate,
MailFlagsUpdate,
MailFolderCreate,
MailFolderUpdate,
MailForwardRequest,
MailLabelAssign,
MailLabelCreate,
MailLinkRequest,
2026-07-15 19:44:41 +02:00
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,
2026-07-15 19:44:41 +02:00
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,
2026-07-15 19:44:41 +02:00
save_draft,
send_mail_via_smtp,
should_send_vacation_reply,
signature_to_response,
substitute_template_vars,
template_to_response,
2026-07-15 19:44:41 +02:00
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
``<storage>/mail_uploads/<temp_id>/<filename>``. 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)}
2026-07-15 19:44:41 +02:00
# ─── 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")),
2026-07-15 19:44:41 +02:00
):
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")
2026-07-15 19:44:41 +02:00
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")),
2026-07-15 19:44:41 +02:00
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
2026-07-15 19:44:41 +02:00
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")
2026-07-15 19:44:41 +02:00
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")),
2026-07-15 19:44:41 +02:00
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
2026-07-15 19:44:41 +02:00
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")
2026-07-15 19:44:41 +02:00
# Soft-delete: set deleted_at = now()
mail.deleted_at = datetime.now(UTC)
await db.flush()
# IMAP delete: queue for retry if it fails
2026-07-15 19:44:41 +02:00
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()
2026-07-15 19:44:41 +02:00
@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")),
2026-07-15 19:44:41 +02:00
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
2026-07-15 19:44:41 +02:00
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")
2026-07-15 19:44:41 +02:00
# 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
2026-07-15 19:44:41 +02:00
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()
2026-07-15 19:44:41 +02:00
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))