feat: phase 5 - mail plugin RBAC integration with delegate access levels (read/write/delete/full)

This commit is contained in:
Agent Zero
2026-07-16 00:34:36 +02:00
parent bb6466b53d
commit a4560344c9
4 changed files with 111 additions and 59 deletions
+4
View File
@@ -77,3 +77,7 @@ class Settings(BaseSettings):
def get_settings() -> Settings: def get_settings() -> Settings:
"""Get cached settings instance.""" """Get cached settings instance."""
return Settings() return Settings()
# Module-level singleton for backward-compatible imports
settings = get_settings()
+8 -1
View File
@@ -329,7 +329,14 @@ class MailSeenBy(Base, TenantMixin):
class MailAccountDelegate(Base, TenantMixin): class MailAccountDelegate(Base, TenantMixin):
"""Delegate access to a mail account (read or full).""" """Delegate access to a mail account.
access_level values:
- "read" → Mails lesen, suchen, Threads sehen
- "write" → read + Flags setzen, verschieben, Entwürfe speichern
- "delete" → write + Mails löschen
- "full" → delete + Ordner verwalten + ACLs vergeben
"""
__tablename__ = "mail_account_delegates" __tablename__ = "mail_account_delegates"
__table_args__ = ( __table_args__ = (
+1 -1
View File
@@ -47,7 +47,7 @@ class MailPlugin(BasePlugin):
], ],
events=[], events=[],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql"],
permissions=[], permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
) )
async def on_activate( async def on_activate(
+98 -57
View File
@@ -18,7 +18,7 @@ from sqlalchemy import and_, asc, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import get_current_user from app.deps import require_permission
from app.plugins.builtins.mail.models import ( from app.plugins.builtins.mail.models import (
ContactPgpKey, ContactPgpKey,
Mail, Mail,
@@ -185,11 +185,15 @@ async def _check_send_permission(
raise HTTPException(403, detail={"detail": "No send permission", "code": "forbidden"}) raise HTTPException(403, detail={"detail": "No send permission", "code": "forbidden"})
async def _check_delegate_full_access( _LEVEL_HIERARCHY = {"read": 1, "write": 2, "delete": 3, "full": 4}
db: AsyncSession, account: MailAccount, user_id: uuid.UUID
async def _check_delegate_access(
db: AsyncSession, account: MailAccount, user_id: uuid.UUID, required_level: str = "read"
) -> None: ) -> None:
"""Check if user has delegate access to account with at least the required level."""
if account.user_id == user_id: if account.user_id == user_id:
return return # Owner has full access
delegate = ( delegate = (
await db.execute( await db.execute(
select(MailAccountDelegate).where( select(MailAccountDelegate).where(
@@ -201,10 +205,13 @@ async def _check_delegate_full_access(
) )
).scalar_one_or_none() ).scalar_one_or_none()
if not delegate: if not delegate:
raise HTTPException(403, detail={"detail": "No delegate access", "code": "forbidden"}) raise HTTPException(403, detail={"detail": "No delegate access to this mailbox", "code": "forbidden"})
if delegate.access_level != "full": user_level = _LEVEL_HIERARCHY.get(delegate.access_level, 0)
required = _LEVEL_HIERARCHY.get(required_level, 1)
if user_level < required:
raise HTTPException( raise HTTPException(
403, detail={"detail": "Read-only delegate access", "code": "forbidden"} 403,
detail={"detail": f"Insufficient delegate access (need {required_level}, have {delegate.access_level})", "code": "forbidden"}
) )
@@ -217,7 +224,7 @@ async def _check_delegate_full_access(
@router.get("/accounts") @router.get("/accounts")
async def list_accounts( async def list_accounts(
db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -242,7 +249,7 @@ async def list_accounts(
async def create_account( async def create_account(
data: MailAccountCreate, data: MailAccountCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -254,7 +261,7 @@ async def create_account(
@router.get("/accounts/shared") @router.get("/accounts/shared")
async def list_shared_accounts( async def list_shared_accounts(
db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
accounts = ( accounts = (
@@ -276,7 +283,7 @@ async def update_account(
account_id: str, account_id: str,
data: MailAccountUpdate, data: MailAccountUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -290,7 +297,7 @@ async def update_account(
async def delete_account( async def delete_account(
account_id: str, account_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -306,7 +313,7 @@ async def assign_shared_users(
account_id: str, account_id: str,
data: SharedMailboxUserAssign, data: SharedMailboxUserAssign,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:share")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -348,7 +355,7 @@ async def create_delegate(
account_id: str, account_id: str,
data: DelegateCreate, data: DelegateCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:share")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -389,7 +396,7 @@ async def create_send_permission(
account_id: str, account_id: str,
data: SendPermissionCreate, data: SendPermissionCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:share")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -422,7 +429,7 @@ async def create_send_permission(
async def test_connection( async def test_connection(
account_id: str, account_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -458,7 +465,7 @@ async def test_connection(
async def trigger_imap_sync( async def trigger_imap_sync(
account_id: str, account_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -475,7 +482,7 @@ async def trigger_imap_sync(
async def list_folders( async def list_folders(
account_id: str = Query(...), account_id: str = Query(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:read")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -499,7 +506,7 @@ async def list_folders(
async def create_folder( async def create_folder(
data: MailFolderCreate, data: MailFolderCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -532,7 +539,7 @@ async def update_folder(
folder_id: str, folder_id: str,
data: MailFolderUpdate, data: MailFolderUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -555,7 +562,7 @@ async def update_folder(
async def delete_folder( async def delete_folder(
folder_id: str, folder_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -568,7 +575,7 @@ async def delete_folder(
if not folder: if not folder:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
account = await _get_account(db, folder.account_id, tenant_id, user_id) account = await _get_account(db, folder.account_id, tenant_id, user_id)
await _check_delegate_full_access(db, account, user_id) await _check_delegate_access(db, account, user_id, "full")
try: try:
await imap_delete_folder(db, f_id, tenant_id) await imap_delete_folder(db, f_id, tenant_id)
except Exception: except Exception:
@@ -587,7 +594,7 @@ async def delete_folder(
async def upload_attachment( async def upload_attachment(
file: UploadFile = File(...), file: UploadFile = File(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:write")),
): ):
"""Upload a file to be attached to an outgoing email. """Upload a file to be attached to an outgoing email.
@@ -643,7 +650,7 @@ async def upload_attachment(
async def send_mail( async def send_mail(
data: MailSendRequest, data: MailSendRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:send")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -708,7 +715,7 @@ async def search_mails(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:read")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
pattern = f"%{q}%" pattern = f"%{q}%"
@@ -739,7 +746,7 @@ async def search_mails(
async def list_threads( async def list_threads(
account_id: str | None = None, account_id: str | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:read")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
stmt = select(Mail).where(Mail.tenant_id == tenant_id) stmt = select(Mail).where(Mail.tenant_id == tenant_id)
@@ -764,7 +771,7 @@ async def list_threads(
async def create_template( async def create_template(
data: MailTemplateCreate, data: MailTemplateCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -782,7 +789,7 @@ async def create_template(
@router.get("/templates") @router.get("/templates")
async def list_templates( async def list_templates(
db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
templates = ( templates = (
@@ -797,7 +804,7 @@ async def list_templates(
async def substitute_template( async def substitute_template(
data: TemplateSubstituteRequest, data: TemplateSubstituteRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:read")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
tpl_id = _parse_uuid(data.template_id, "template_id") tpl_id = _parse_uuid(data.template_id, "template_id")
@@ -823,7 +830,7 @@ async def substitute_template(
async def create_signature( async def create_signature(
data: MailSignatureCreate, data: MailSignatureCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -843,7 +850,7 @@ async def create_signature(
@router.get("/signatures") @router.get("/signatures")
async def list_signatures( async def list_signatures(
db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
sigs = ( sigs = (
@@ -861,7 +868,7 @@ async def list_signatures(
async def create_rule( async def create_rule(
data: MailRuleCreate, data: MailRuleCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
acc_id = _parse_uuid(data.account_id, "account_id") if data.account_id else None acc_id = _parse_uuid(data.account_id, "account_id") if data.account_id else None
@@ -881,7 +888,7 @@ async def create_rule(
@router.get("/rules") @router.get("/rules")
async def list_rules( async def list_rules(
db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
rules = ( rules = (
@@ -898,7 +905,7 @@ async def list_rules(
@router.delete("/rules/{rule_id}", status_code=204) @router.delete("/rules/{rule_id}", status_code=204)
async def delete_rule( async def delete_rule(
rule_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) rule_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:config"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
r_id = _parse_uuid(rule_id, "rule_id") r_id = _parse_uuid(rule_id, "rule_id")
@@ -919,7 +926,7 @@ async def delete_rule(
async def configure_vacation( async def configure_vacation(
data: VacationConfig, data: VacationConfig,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -948,7 +955,7 @@ async def test_vacation_dedup(
account_id: str = Query(...), account_id: str = Query(...),
sender: str = Query(...), sender: str = Query(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -972,7 +979,7 @@ async def test_vacation_dedup(
async def import_pgp_key( async def import_pgp_key(
data: PgpKeyImport, data: PgpKeyImport,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -992,7 +999,7 @@ async def import_pgp_key(
@router.get("/pgp/keys") @router.get("/pgp/keys")
async def list_pgp_keys( async def list_pgp_keys(
db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -1015,7 +1022,7 @@ async def list_pgp_keys(
async def encrypt_message( async def encrypt_message(
data: dict = Body(...), data: dict = Body(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:send")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
contact_id = ( contact_id = (
@@ -1043,7 +1050,7 @@ async def encrypt_message(
async def create_label( async def create_label(
data: MailLabelCreate, data: MailLabelCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -1055,7 +1062,7 @@ async def create_label(
@router.get("/labels") @router.get("/labels")
async def list_labels( async def list_labels(
db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
labels = ( labels = (
@@ -1074,7 +1081,7 @@ async def store_contact_pgp_key(
contact_id: str, contact_id: str,
data: dict = Body(...), data: dict = Body(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:config")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
c_id = _parse_uuid(contact_id, "contact_id") c_id = _parse_uuid(contact_id, "contact_id")
@@ -1109,7 +1116,7 @@ async def store_contact_pgp_key(
@router.post("/{mail_id}/apply-rules") @router.post("/{mail_id}/apply-rules")
async def apply_rules( async def apply_rules(
mail_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) mail_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:config"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
m_id = _parse_uuid(mail_id, "mail_id") m_id = _parse_uuid(mail_id, "mail_id")
@@ -1130,7 +1137,7 @@ async def reply_mail(
mail_id: str, mail_id: str,
data: MailReplyRequest, data: MailReplyRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:send")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -1141,6 +1148,7 @@ async def reply_mail(
if not original: if not original:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
account = await _get_account(db, original.account_id, tenant_id, user_id) 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) await _check_send_permission(db, account, user_id)
signature = None signature = None
if data.signature_id: if data.signature_id:
@@ -1175,7 +1183,7 @@ async def forward_mail_endpoint(
mail_id: str, mail_id: str,
data: MailForwardRequest, data: MailForwardRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:send")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -1186,6 +1194,7 @@ async def forward_mail_endpoint(
if not original: if not original:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
account = await _get_account(db, original.account_id, tenant_id, user_id) 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) await _check_send_permission(db, account, user_id)
signature = None signature = None
if data.signature_id: if data.signature_id:
@@ -1221,15 +1230,18 @@ async def update_flags(
mail_id: str, mail_id: str,
data: MailFlagsUpdate, data: MailFlagsUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:write")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
m_id = _parse_uuid(mail_id, "mail_id") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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: if data.is_seen is not None:
mail.is_seen = data.is_seen mail.is_seen = data.is_seen
if data.is_flagged is not None: if data.is_flagged is not None:
@@ -1255,9 +1267,10 @@ async def download_attachment(
mail_id: str, mail_id: str,
att_id: str, att_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:read")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
m_id = _parse_uuid(mail_id, "mail_id") m_id = _parse_uuid(mail_id, "mail_id")
a_id = _parse_uuid(att_id, "att_id") a_id = _parse_uuid(att_id, "att_id")
mail = ( mail = (
@@ -1265,6 +1278,8 @@ async def download_attachment(
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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 = ( attachment = (
await db.execute( await db.execute(
select(MailAttachment).where( select(MailAttachment).where(
@@ -1300,15 +1315,18 @@ async def link_mail(
mail_id: str, mail_id: str,
data: MailLinkRequest, data: MailLinkRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:write")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
m_id = _parse_uuid(mail_id, "mail_id") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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: if data.contact_id:
mail.contact_id = _parse_uuid(data.contact_id, "contact_id") mail.contact_id = _parse_uuid(data.contact_id, "contact_id")
if data.company_id: if data.company_id:
@@ -1326,7 +1344,7 @@ async def create_event_from_mail(
mail_id: str, mail_id: str,
data: MailCreateEventRequest, data: MailCreateEventRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:write")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
@@ -1336,6 +1354,8 @@ async def create_event_from_mail(
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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: try:
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
except ImportError: except ImportError:
@@ -1370,9 +1390,10 @@ async def assign_label(
mail_id: str, mail_id: str,
data: MailLabelAssign, data: MailLabelAssign,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:write")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
m_id = _parse_uuid(mail_id, "mail_id") m_id = _parse_uuid(mail_id, "mail_id")
l_id = _parse_uuid(data.label_id, "label_id") l_id = _parse_uuid(data.label_id, "label_id")
mail = ( mail = (
@@ -1380,6 +1401,8 @@ async def assign_label(
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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 = ( label = (
await db.execute( await db.execute(
select(MailLabel).where(and_(MailLabel.id == l_id, MailLabel.tenant_id == tenant_id)) select(MailLabel).where(and_(MailLabel.id == l_id, MailLabel.tenant_id == tenant_id))
@@ -1408,12 +1431,13 @@ async def assign_label(
async def create_draft( async def create_draft(
data: MailDraftCreate, data: MailDraftCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:write")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
acc_id = _parse_uuid(data.account_id, "account_id") acc_id = _parse_uuid(data.account_id, "account_id")
await _get_account(db, acc_id, tenant_id, user_id) account = await _get_account(db, acc_id, tenant_id, user_id)
await _check_delegate_access(db, account, user_id, "write")
mail = await save_draft(db, acc_id, tenant_id, user_id, data.model_dump()) mail = await save_draft(db, acc_id, tenant_id, user_id, data.model_dump())
return mail_to_response(mail) return mail_to_response(mail)
@@ -1423,10 +1447,18 @@ async def update_draft_route(
mail_id: str, mail_id: str,
data: MailDraftCreate, data: MailDraftCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:write")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
m_id = _parse_uuid(mail_id, "mail_id") m_id = _parse_uuid(mail_id, "mail_id")
mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none()
if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
account = await _get_account(db, mail.account_id, tenant_id, user_id)
await _check_delegate_access(db, account, user_id, "write")
mail = await update_draft(db, m_id, tenant_id, data.model_dump()) mail = await update_draft(db, m_id, tenant_id, data.model_dump())
return mail_to_response(mail) return mail_to_response(mail)
@@ -1438,15 +1470,18 @@ async def update_draft_route(
async def delete_mail( async def delete_mail(
mail_id: str, mail_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:delete")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
m_id = _parse_uuid(mail_id, "mail_id") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
account = await _get_account(db, mail.account_id, tenant_id, user_id)
await _check_delegate_access(db, account, user_id, "delete")
# Soft-delete: set deleted_at = now() # Soft-delete: set deleted_at = now()
mail.deleted_at = datetime.now(UTC) mail.deleted_at = datetime.now(UTC)
await db.flush() await db.flush()
@@ -1463,9 +1498,10 @@ async def move_mail(
mail_id: str, mail_id: str,
data: MailMoveRequest, data: MailMoveRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:write")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
m_id = _parse_uuid(mail_id, "mail_id") m_id = _parse_uuid(mail_id, "mail_id")
target_f_id = _parse_uuid(data.target_folder_id, "target_folder_id") target_f_id = _parse_uuid(data.target_folder_id, "target_folder_id")
mail = ( mail = (
@@ -1473,6 +1509,8 @@ async def move_mail(
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"})
account = await _get_account(db, mail.account_id, tenant_id, user_id)
await _check_delegate_access(db, account, user_id, "write")
# Verify target folder exists and belongs to same tenant # Verify target folder exists and belongs to same tenant
target_folder = ( target_folder = (
await db.execute( await db.execute(
@@ -1509,7 +1547,7 @@ async def list_mails(
sort_by: str = Query("date", pattern="^(date|from|subject)$"), sort_by: str = Query("date", pattern="^(date|from|subject)$"),
sort_order: str = Query("desc", pattern="^(asc|desc)$"), sort_order: str = Query("desc", pattern="^(asc|desc)$"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(require_permission("mail:read")),
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
stmt = select(Mail).where(Mail.tenant_id == tenant_id) stmt = select(Mail).where(Mail.tenant_id == tenant_id)
@@ -1542,15 +1580,18 @@ async def list_mails(
@router.get("/{mail_id}") @router.get("/{mail_id}")
async def get_mail( async def get_mail(
mail_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) mail_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
): ):
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
m_id = _parse_uuid(mail_id, "mail_id") m_id = _parse_uuid(mail_id, "mail_id")
mail = ( mail = (
await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id)))
).scalar_one_or_none() ).scalar_one_or_none()
if not mail: if not mail:
raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) 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 = ( attachments = (
(await db.execute(select(MailAttachment).where(MailAttachment.mail_id == mail.id))) (await db.execute(select(MailAttachment).where(MailAttachment.mail_id == mail.id)))
.scalars() .scalars()