Files
lasuite-messages/docs/permissions.md
T
Sylvain Zimmerandjbpenrath 732b6028d3 (imports) refactor import code: retries, continuous mode, list UI (#742)
Largely improves user & operator visibility on imports, resiliency with retries,
direct-to-offload storage, import cancellation (deleting imported messages) & more.
Remove the modal import and manage all within the import tab in the settings modal
2026-07-22 16:11:18 +02:00

8.2 KiB

Permissions & Data Model

Core Data Model

Entity Relationships

User
├── MailboxAccess (role: VIEWER|EDITOR|SENDER|ADMIN)
│   └── Mailbox
│       ├── ThreadAccess (role: VIEWER|EDITOR)
│       │   └── Thread
│       │       ├── Message
│       │       │   ├── sender → Contact
│       │       │   ├── recipients → MessageRecipient → Contact
│       │       │   ├── parent → Message (reply chain)
│       │       │   ├── blob → Blob (raw MIME)
│       │       │   ├── draft_blob → Blob (JSON draft content)
│       │       │   └── attachments → Attachment → Blob (only for drafts)
│       │       ├── events → ThreadEvent (im / assign / unassign)
│       │       │   └── user_events → UserEvent (mention / assign)
│       │       ├── accesses → ThreadAccess (multiple mailboxes)
│       │       └── labels → Label (M2M)
│       ├── contacts → Contact
│       ├── labels → Label
│       └── blobs → Blob
├── user_events → UserEvent (per-user notifications, thread-scoped)
└── MailDomainAccess (role: ADMIN)
    └── MailDomain
        └── mailboxes → Mailbox (via domain FK)

Key Models

Model Purpose Key Fields
User Identity (OIDC) sub, email, full_name
Mailbox Email account local_part, domain (FK)
MailboxAccess User→Mailbox permission user, mailbox, role (unique together)
Thread Message thread subject, denormalized flags (has_trashed, is_spam, etc.)
ThreadAccess Mailbox→Thread permission thread, mailbox, role (unique together)
Message Email message thread, sender, parent, flags (is_draft, is_trashed, etc.)
ThreadEvent Timeline entry on a thread (comment, assign, unassign) thread, type, author, data (JSON, schema-validated per type)
UserEvent Per-user notification derived from a ThreadEvent user, thread, thread_event, type, read_at
Contact Email address entity email, mailbox, name
Label Folder/tag (hierarchical) name, slug, mailbox, threads (M2M)

Role Hierarchies

MailboxRoleChoices (User access to Mailbox)

VIEWER = 1   # Read-only: view mailbox threads/messages
EDITOR = 2   # Edit: create drafts, flag, delete, manage thread access
SENDER = 3   # Send: EDITOR + can send messages
ADMIN  = 4   # Admin: SENDER + manage mailbox accesses, labels, templates, import

Role groups defined in enums.py:

  • MAILBOX_ROLES_CAN_EDIT = [EDITOR, SENDER, ADMIN]
  • MAILBOX_ROLES_CAN_SEND = [SENDER, ADMIN]

ThreadAccessRoleChoices (Mailbox access to Thread)

VIEWER = 1   # Read-only: view thread messages and events
EDITOR = 2   # Edit: create replies, flag messages, manage thread sharing, assign

Role group:

  • THREAD_ROLES_CAN_EDIT = [EDITOR]

Event Types

# ThreadEvent.type (stored on the thread timeline)
IM       = "im"        # Internal comment, may embed mentions in data
ASSIGN   = "assign"    # User(s) newly assigned to the thread
UNASSIGN = "unassign"  # User(s) removed from the thread

# UserEvent.type (per-user notification, derived from ThreadEvent)
MENTION  = "mention"   # One per (user, message mention); read_at tracks ack
ASSIGN   = "assign"    # At most one per (user, thread); source of truth for "assigned"

UserEvent is not mailbox-scoped: a user reachable through several mailboxes sees the same notification everywhere.

Permission Classes

Defined in core/api/permissions.py. The table below lists the main ones and the rule they enforce.

Class Rule
IsAuthenticated Baseline — user is logged in.
IsAllowedToAccess Read access to a Mailbox/Thread/Message/ThreadEvent via any MailboxAccessThreadAccess path.
HasThreadEditAccess Full edit rights: ThreadAccess.role == EDITOR AND MailboxAccess.role ∈ MAILBOX_ROLES_CAN_EDIT on the same mailbox.
HasThreadCommentAccess Allowed to author internal comments: any ThreadAccess (viewer or editor) on a mailbox where the user has MAILBOX_ROLES_CAN_EDIT.
HasThreadEventWriteAccess Type-aware: im events follow the comment rule; every other ThreadEvent type requires full edit rights. Update/destroy is author-only.
IsAllowedToCreateMessage User must have MAILBOX_ROLES_CAN_EDIT on the sender mailbox (plus EDITOR ThreadAccess when replying).
IsAllowedToManageThreadAccess Managing a ThreadAccess requires full edit rights on the thread.
IsMailboxAdmin / IsMailDomainAdmin Admin paths for mailbox and maildomain management.
HasChannelScope Scope check for Channel-authenticated calls; CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY further requires scope_level=global.

Shared ORM helpers (core/models.py):

  • ThreadAccess.objects.editable_by(user, mailbox_id=None) — rows matching the full-edit-rights rule.
  • ThreadAccess.objects.editor_user_ids(thread_id, user_ids=None) — user ids with full edit rights on a thread.

Django Admin

The Django admin (/admin/) is instance-operator tooling only. is_staff accounts are trusted operators of the deployment — real users are never given staff access and interact exclusively through the API/frontend, where the DRF permission classes above are the authorization layer.

Consequently, admin-only code paths (and the service functions they call) do not re-check per-mailbox roles: a staff operator may act on any mailbox by design. Authorization for end users lives in the API permission classes, not in the service layer.

Key Design Principles

  1. Two-level permission model. User→Mailbox (MailboxAccess) and Mailbox→Thread (ThreadAccess) are independent and composed for every access check. Edit-level actions always verify both sides.
  2. ThreadAccess is per-mailbox. Each mailbox has its own role on a given thread, enabling selective sharing and per-mailbox read_at / starred_at state.
  3. Flags are shared state. Message flags (is_trashed, is_spam, is_unread, etc.) live on the Message and mutate the thread for everyone — they require EDITOR ThreadAccess.
  4. Thread stats are denormalized. Thread has boolean fields (has_trashed, is_spam, …) updated by thread.update_stats() after message flag changes. Mention/assignment stats (has_mention, has_unread_mention, has_assigned_to_me, has_unassigned) are not stored: they are computed per request via Exists(UserEvent...) annotations in ThreadViewSet.
  5. Comments relax the thread role. Posting or editing an im ThreadEvent only requires VIEWER ThreadAccess + mailbox edit rights. Assign/unassign and any other event type keep the stricter full-edit-rights policy.
  6. Event mutations are author-only. Update and destroy of a ThreadEvent are refused for non-authors, regardless of role. A configurable window (settings.MAX_THREAD_EVENT_EDIT_DELAY) can close the edit/delete path entirely after creation.
  7. Assignment is derived from the event log. UserEvent(type=ASSIGN) is the source of truth for "who is assigned"; there is no denormalized field on Thread. A partial UniqueConstraint enforces at most one active ASSIGN per (user, thread) and absorbs races between concurrent ASSIGN requests.
  8. Undo window for assignments. An UNASSIGN within UNDO_WINDOW_SECONDS (120s) of the matching ASSIGN, by the same author, is absorbed: the original ASSIGN ThreadEvent is trimmed or deleted, the UserEvent ASSIGN is removed, and no UNASSIGN event is emitted.
  9. Access changes cascade to assignments. Downgrading or removing a ThreadAccess / MailboxAccess triggers cleanup_invalid_assignments, which emits a single system ThreadEvent(type=UNASSIGN, author=None) for any assignee who lost full edit rights (re-evaluated across all their mailboxes).
  10. Mentions survive edits idempotently. Editing an im event diffs the mentions payload and reconciles UserEvent(MENTION) rows; unchanged mentions keep their read_at, removed ones disappear from the user's "Mentioned" view, new ones are created.