From a4560344c91a0621dd58e0e5c2db27399457d797 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 16 Jul 2026 00:34:36 +0200 Subject: [PATCH] feat: phase 5 - mail plugin RBAC integration with delegate access levels (read/write/delete/full) --- app/config.py | 4 + app/plugins/builtins/mail/models.py | 9 +- app/plugins/builtins/mail/plugin.py | 2 +- app/plugins/builtins/mail/routes.py | 155 ++++++++++++++++++---------- 4 files changed, 111 insertions(+), 59 deletions(-) diff --git a/app/config.py b/app/config.py index badf3fc..84fd8bc 100644 --- a/app/config.py +++ b/app/config.py @@ -77,3 +77,7 @@ class Settings(BaseSettings): def get_settings() -> Settings: """Get cached settings instance.""" return Settings() + + +# Module-level singleton for backward-compatible imports +settings = get_settings() diff --git a/app/plugins/builtins/mail/models.py b/app/plugins/builtins/mail/models.py index 52e66dc..96745ad 100644 --- a/app/plugins/builtins/mail/models.py +++ b/app/plugins/builtins/mail/models.py @@ -329,7 +329,14 @@ class MailSeenBy(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" __table_args__ = ( diff --git a/app/plugins/builtins/mail/plugin.py b/app/plugins/builtins/mail/plugin.py index 2c2fa92..4b8563e 100644 --- a/app/plugins/builtins/mail/plugin.py +++ b/app/plugins/builtins/mail/plugin.py @@ -47,7 +47,7 @@ class MailPlugin(BasePlugin): ], events=[], migrations=["0001_initial.sql"], - permissions=[], + permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"], ) async def on_activate( diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index 37bd461..b3bb797 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -18,7 +18,7 @@ from sqlalchemy import and_, asc, desc, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db -from app.deps import get_current_user +from app.deps import require_permission from app.plugins.builtins.mail.models import ( ContactPgpKey, Mail, @@ -185,11 +185,15 @@ async def _check_send_permission( raise HTTPException(403, detail={"detail": "No send permission", "code": "forbidden"}) -async def _check_delegate_full_access( - db: AsyncSession, account: MailAccount, user_id: uuid.UUID +_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 + return # Owner has full access delegate = ( await db.execute( select(MailAccountDelegate).where( @@ -201,10 +205,13 @@ async def _check_delegate_full_access( ) ).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": "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": "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") 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -242,7 +249,7 @@ async def list_accounts( async def create_account( data: MailAccountCreate, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -254,7 +261,7 @@ async def create_account( @router.get("/accounts/shared") 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"]) accounts = ( @@ -276,7 +283,7 @@ async def update_account( account_id: str, data: MailAccountUpdate, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -290,7 +297,7 @@ async def update_account( async def delete_account( account_id: str, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -306,7 +313,7 @@ async def assign_shared_users( account_id: str, data: SharedMailboxUserAssign, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -348,7 +355,7 @@ async def create_delegate( account_id: str, data: DelegateCreate, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -389,7 +396,7 @@ async def create_send_permission( account_id: str, data: SendPermissionCreate, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -422,7 +429,7 @@ async def create_send_permission( async def test_connection( account_id: str, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -458,7 +465,7 @@ async def test_connection( async def trigger_imap_sync( account_id: str, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -475,7 +482,7 @@ async def trigger_imap_sync( async def list_folders( account_id: str = Query(...), 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -499,7 +506,7 @@ async def list_folders( async def create_folder( data: MailFolderCreate, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -532,7 +539,7 @@ async def update_folder( folder_id: str, data: MailFolderUpdate, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -555,7 +562,7 @@ async def update_folder( async def delete_folder( folder_id: str, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -568,7 +575,7 @@ async def delete_folder( 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 _check_delegate_access(db, account, user_id, "full") try: await imap_delete_folder(db, f_id, tenant_id) except Exception: @@ -587,7 +594,7 @@ async def delete_folder( async def upload_attachment( file: UploadFile = File(...), 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. @@ -643,7 +650,7 @@ async def upload_attachment( async def send_mail( data: MailSendRequest, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -708,7 +715,7 @@ async def search_mails( 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), + current_user: dict = Depends(require_permission("mail:read")), ): tenant_id = uuid.UUID(current_user["tenant_id"]) pattern = f"%{q}%" @@ -739,7 +746,7 @@ async def search_mails( async def list_threads( account_id: str | None = None, 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"]) stmt = select(Mail).where(Mail.tenant_id == tenant_id) @@ -764,7 +771,7 @@ async def list_threads( async def create_template( data: MailTemplateCreate, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -782,7 +789,7 @@ async def create_template( @router.get("/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"]) templates = ( @@ -797,7 +804,7 @@ async def list_templates( async def substitute_template( data: TemplateSubstituteRequest, 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"]) tpl_id = _parse_uuid(data.template_id, "template_id") @@ -823,7 +830,7 @@ async def substitute_template( async def create_signature( data: MailSignatureCreate, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -843,7 +850,7 @@ async def create_signature( @router.get("/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"]) sigs = ( @@ -861,7 +868,7 @@ async def list_signatures( async def create_rule( data: MailRuleCreate, 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"]) 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") 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"]) rules = ( @@ -898,7 +905,7 @@ async def list_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) + 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") @@ -919,7 +926,7 @@ async def delete_rule( async def configure_vacation( data: VacationConfig, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -948,7 +955,7 @@ async def test_vacation_dedup( account_id: str = Query(...), sender: str = Query(...), 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -972,7 +979,7 @@ async def test_vacation_dedup( async def import_pgp_key( data: PgpKeyImport, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -992,7 +999,7 @@ async def import_pgp_key( @router.get("/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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -1015,7 +1022,7 @@ async def list_pgp_keys( async def encrypt_message( data: dict = Body(...), 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"]) contact_id = ( @@ -1043,7 +1050,7 @@ async def encrypt_message( async def create_label( data: MailLabelCreate, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -1055,7 +1062,7 @@ async def create_label( @router.get("/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"]) labels = ( @@ -1074,7 +1081,7 @@ async def store_contact_pgp_key( contact_id: str, data: dict = Body(...), 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"]) c_id = _parse_uuid(contact_id, "contact_id") @@ -1109,7 +1116,7 @@ async def store_contact_pgp_key( @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) + 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") @@ -1130,7 +1137,7 @@ async def reply_mail( mail_id: str, data: MailReplyRequest, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -1141,6 +1148,7 @@ async def reply_mail( 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: @@ -1175,7 +1183,7 @@ async def forward_mail_endpoint( mail_id: str, data: MailForwardRequest, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -1186,6 +1194,7 @@ async def forward_mail_endpoint( 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: @@ -1221,15 +1230,18 @@ async def update_flags( mail_id: str, data: MailFlagsUpdate, 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"]) + 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: @@ -1255,9 +1267,10 @@ async def download_attachment( mail_id: str, att_id: str, 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"]) + 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 = ( @@ -1265,6 +1278,8 @@ async def download_attachment( ).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( @@ -1300,15 +1315,18 @@ async def link_mail( mail_id: str, data: MailLinkRequest, 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"]) + user_id = uuid.UUID(current_user["user_id"]) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) + account = await _get_account(db, mail.account_id, tenant_id, user_id) + await _check_delegate_access(db, account, user_id, "write") if data.contact_id: mail.contact_id = _parse_uuid(data.contact_id, "contact_id") if data.company_id: @@ -1326,7 +1344,7 @@ async def create_event_from_mail( mail_id: str, data: MailCreateEventRequest, 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"]) user_id = uuid.UUID(current_user["user_id"]) @@ -1336,6 +1354,8 @@ async def create_event_from_mail( ).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: @@ -1370,9 +1390,10 @@ async def assign_label( mail_id: str, data: MailLabelAssign, 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"]) + 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 = ( @@ -1380,6 +1401,8 @@ async def assign_label( ).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)) @@ -1408,12 +1431,13 @@ async def assign_label( async def create_draft( data: MailDraftCreate, 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"]) 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) + account = await _get_account(db, acc_id, tenant_id, user_id) + await _check_delegate_access(db, account, user_id, "write") mail = await save_draft(db, acc_id, tenant_id, user_id, data.model_dump()) return mail_to_response(mail) @@ -1423,10 +1447,18 @@ async def update_draft_route( mail_id: str, data: MailDraftCreate, 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"]) + user_id = uuid.UUID(current_user["user_id"]) m_id = _parse_uuid(mail_id, "mail_id") + mail = ( + await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) + ).scalar_one_or_none() + if not mail: + raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) + account = await _get_account(db, mail.account_id, tenant_id, user_id) + await _check_delegate_access(db, account, user_id, "write") mail = await update_draft(db, m_id, tenant_id, data.model_dump()) return mail_to_response(mail) @@ -1438,15 +1470,18 @@ async def update_draft_route( async def delete_mail( mail_id: str, 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"]) + user_id = uuid.UUID(current_user["user_id"]) m_id = _parse_uuid(mail_id, "mail_id") mail = ( await db.execute(select(Mail).where(and_(Mail.id == m_id, Mail.tenant_id == tenant_id))) ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) + account = await _get_account(db, mail.account_id, tenant_id, user_id) + await _check_delegate_access(db, account, user_id, "delete") # Soft-delete: set deleted_at = now() mail.deleted_at = datetime.now(UTC) await db.flush() @@ -1463,9 +1498,10 @@ async def move_mail( mail_id: str, data: MailMoveRequest, 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"]) + user_id = uuid.UUID(current_user["user_id"]) m_id = _parse_uuid(mail_id, "mail_id") target_f_id = _parse_uuid(data.target_folder_id, "target_folder_id") mail = ( @@ -1473,6 +1509,8 @@ async def move_mail( ).scalar_one_or_none() if not mail: raise HTTPException(404, detail={"detail": "Mail not found", "code": "not_found"}) + account = await _get_account(db, mail.account_id, tenant_id, user_id) + await _check_delegate_access(db, account, user_id, "write") # Verify target folder exists and belongs to same tenant target_folder = ( await db.execute( @@ -1509,7 +1547,7 @@ async def list_mails( 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(get_current_user), + 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) @@ -1542,15 +1580,18 @@ async def list_mails( @router.get("/{mail_id}") 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"]) + 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()