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