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

1814 lines
64 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
2026-07-23 08:42:26 +02:00
import os
import uuid
from datetime import UTC, datetime
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
2026-07-23 08:42:26 +02:00
from app.core.storage import get_storage_backend
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
2026-07-23 08:42:26 +02:00
async 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.
"""
resolved: list[dict] = []
2026-07-23 08:42:26 +02:00
storage = get_storage_backend()
for att_id in attachment_ids:
2026-07-23 08:42:26 +02:00
prefix = f"mail_uploads/{att_id}/"
files = await storage.list_files(prefix)
if not files:
continue
2026-07-23 08:42:26 +02:00
for fpath in files:
fname = os.path.basename(fpath)
abs_path = await storage.get_url(fpath)
resolved.append(
{
"path": abs_path,
"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 (move to Trash or permanent delete) ───
@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")),
):
"""Empty a folder: move all mails to Trash, or permanent delete if already in Trash.
If the folder IS the Trash folder, mails are permanently deleted (DELETE + IMAP EXPUNGE).
If the folder is NOT Trash, mails are moved to Trash (folder_id changed + IMAP MOVE).
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")
# Check if this folder IS the Trash folder
is_trash = 'trash' in folder.imap_name.lower() or 'papierkorb' in folder.name.lower() or 'trash' in folder.name.lower()
# Find Trash folder for non-trash emptying
trash_folder = None
if not is_trash:
all_folders = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.account_id == folder.account_id, MailFolder.tenant_id == tenant_id)
)
)
).scalars().all()
for f in all_folders:
if 'trash' in f.imap_name.lower() or 'papierkorb' in f.name.lower() or 'trash' in f.name.lower():
trash_folder = f
break
# Get all mails in this folder
result = await db.execute(
select(Mail).where(
and_(
Mail.folder_id == f_id,
Mail.tenant_id == tenant_id,
)
)
)
mails = result.scalars().all()
for mail in mails:
if is_trash or not trash_folder:
# Permanent delete from DB + IMAP EXPUNGE
# IMPORTANT: Call imap_delete_mail BEFORE db.delete, because imap_delete_mail
# reads the mail from DB to find its folder and imap_uid.
try:
await mail_services.imap_delete_mail(db, mail.id, tenant_id, permanent=True)
await db.delete(mail)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"empty_folder: IMAP permanent delete failed for mail %s: %s, queuing for retry", mail.id, exc
)
await db.delete(mail)
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=mail.id,
operation="delete",
payload={"permanent": True},
status="pending",
last_error=str(exc),
)
db.add(queue_entry)
else:
# Move to Trash folder (standard mail program logic)
# IMPORTANT: Call imap_delete_mail BEFORE changing folder_id,
# because imap_delete_mail reads mail.folder_id to find the IMAP folder.
try:
await mail_services.imap_delete_mail(db, mail.id, tenant_id, permanent=False)
mail.folder_id = trash_folder.id
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"empty_folder: IMAP move to Trash failed for mail %s: %s, queuing for retry", mail.id, exc
)
mail.folder_id = trash_folder.id
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=mail.id,
operation="delete",
payload={"permanent": False},
status="pending",
last_error=str(exc),
)
db.add(queue_entry)
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"
2026-07-23 08:42:26 +02:00
# Store via storage backend in a path keyed by the temp_id
storage = get_storage_backend()
file_path = f"mail_uploads/{temp_id}/{safe_filename}"
await storage.save(file_path, 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,
2026-07-23 08:42:26 +02:00
attachment_paths=await _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"})
2026-07-23 08:42:26 +02:00
storage = get_storage_backend()
if not await storage.exists(attachment.storage_path):
raise HTTPException(
404, detail={"detail": "File not found on disk", "code": "file_missing"}
)
2026-07-23 08:42:26 +02:00
content = await storage.read(attachment.storage_path)
async def file_stream():
2026-07-23 08:42:26 +02:00
yield content
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")
await db.flush()
return {
"linked": True,
"contact_id": str(mail.contact_id) if mail.contact_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")
# Find the Trash folder for this account
trash_folder = None
all_folders = (
await db.execute(
select(MailFolder).where(
and_(MailFolder.account_id == mail.account_id, MailFolder.tenant_id == tenant_id)
)
)
).scalars().all()
for f in all_folders:
if 'trash' in f.imap_name.lower() or 'trash' in f.name.lower() or 'papierkorb' in f.name.lower():
trash_folder = f
break
if trash_folder and mail.folder_id == trash_folder.id:
# Mail is already in Trash → permanent delete from DB + IMAP EXPUNGE
# IMPORTANT: Call imap_delete_mail BEFORE db.delete, because imap_delete_mail
# reads the mail from DB to find its folder and imap_uid.
try:
await mail_services.imap_delete_mail(db, m_id, tenant_id, permanent=True)
await db.delete(mail)
await db.flush()
except Exception as exc:
import logging
logging.getLogger(__name__).warning("IMAP permanent delete failed for mail %s: %s, queuing for retry", m_id, exc)
await db.delete(mail)
await db.flush()
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=m_id,
operation="delete",
payload={"permanent": True},
status="pending",
last_error=str(exc),
)
db.add(queue_entry)
await db.flush()
elif trash_folder:
# Mail is NOT in Trash → move to Trash folder (standard mail program logic)
# IMPORTANT: Call imap_delete_mail BEFORE changing folder_id,
# because imap_delete_mail reads mail.folder_id to find the IMAP folder.
try:
await mail_services.imap_delete_mail(db, m_id, tenant_id, permanent=False)
mail.folder_id = trash_folder.id
await db.flush()
except Exception as exc:
import logging
logging.getLogger(__name__).warning("IMAP move to Trash failed for mail %s: %s, queuing for retry", m_id, exc)
mail.folder_id = trash_folder.id
await db.flush()
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=m_id,
operation="delete",
payload={"permanent": False},
status="pending",
last_error=str(exc),
)
db.add(queue_entry)
await db.flush()
else:
# No Trash folder found → permanent delete from DB + IMAP EXPUNGE
# IMPORTANT: Call imap_delete_mail BEFORE db.delete, because imap_delete_mail
# reads the mail from DB to find its folder and imap_uid.
try:
await mail_services.imap_delete_mail(db, m_id, tenant_id, permanent=True)
await db.delete(mail)
await db.flush()
except Exception as exc:
import logging
logging.getLogger(__name__).warning("IMAP delete failed for mail %s: %s, queuing for retry", m_id, exc)
await db.delete(mail)
await db.flush()
queue_entry = MailSyncQueue(
tenant_id=tenant_id,
mail_id=m_id,
operation="delete",
payload={"permanent": True},
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)
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))