Mailbox lifecycle and access control.
Two ownership models share one table (see
PhoenixKitInbox.Schemas.Mailbox):
- every user gets exactly one personal mailbox, created lazily on first
visit by
ensure_user_mailbox/1 - shared mailboxes (
support,sales, …) are created by an admin and reached through grants
Access is a two-level check, matching how phoenix_kit_calendar gates other
people's calendars: the module-level "inbox" permission decides whether a
user can open Inbox at all, and a grant decides which mailboxes they see once
inside. Ownership always implies "admin" access — the owner never needs a
grant to their own mailbox.
Every function here takes and returns plain data; the LiveViews own no queries of their own.
Summary
Functions
The access level user_uuid holds on mailbox, or nil for none.
Archives a mailbox — the soft-delete form used across this workspace (a
sentinel on the existing status column, never a deleted_at timestamp).
Whether user_uuid holds at least required access on mailbox.
Changeset for mailbox forms.
Creates a shared mailbox owned by owner_uuid.
Returns the user's personal mailbox, creating it on first call.
Fetches a mailbox by uuid.
Resolves what someone typed in a To/Cc/Bcc field to a mailbox.
Fetches a mailbox by its unique slug.
Grants (or re-grants) user_uuid access to a mailbox.
Every mailbox user_uuid may open, personal first then shared, each
alphabetical. Archived and deleted mailboxes are excluded.
All grants on a mailbox, for the mailbox admin page.
All shared mailboxes, for the admin management page.
Reverses archive_mailbox/1.
Removes a user's grant on a mailbox. Idempotent.
Addressable recipients, for the compose field's suggestion list.
Unseen message count per folder for a mailbox, as %{folder => count}.
Updates a mailbox's name/address/settings.
Best-effort display name for a user — full name, else username, else the local part of their email, else a short uuid.
Types
@type access() :: String.t()
Functions
@spec access_level(PhoenixKitInbox.Schemas.Mailbox.t(), binary()) :: access() | nil
The access level user_uuid holds on mailbox, or nil for none.
Ownership short-circuits to "admin" — the owner is never listed in their
own grants table.
@spec archive_mailbox(PhoenixKitInbox.Schemas.Mailbox.t()) :: {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, Ecto.Changeset.t() | :cannot_archive_personal_mailbox}
Archives a mailbox — the soft-delete form used across this workspace (a
sentinel on the existing status column, never a deleted_at timestamp).
Personal mailboxes cannot be archived: a user always has somewhere for mail to land.
@spec authorize(PhoenixKitInbox.Schemas.Mailbox.t(), binary(), access()) :: :ok | {:error, :mailbox_access_denied}
Whether user_uuid holds at least required access on mailbox.
authorize(mailbox, user_uuid, "write")
@spec change_mailbox(PhoenixKitInbox.Schemas.Mailbox.t(), map()) :: Ecto.Changeset.t()
Changeset for mailbox forms.
@spec ensure_user_mailbox(PhoenixKit.Users.Auth.User.t() | map()) :: {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, Ecto.Changeset.t() | :invalid_user}
Returns the user's personal mailbox, creating it on first call.
Lazy creation rather than a hook on user signup: it keeps Inbox from needing to reach into core's registration flow, and it means the module works correctly for users who already existed before it was installed.
The unique_index ... where kind = 'user' in V01 makes the race safe — two
concurrent mounts can both miss the read, and the loser of the insert falls
back to reading the winner's row.
@spec fetch_mailbox(binary()) :: {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, :mailbox_not_found}
Fetches a mailbox by uuid.
Returns {:error, :mailbox_not_found} rather than nil so callers can pipe
through with and let PhoenixKitInbox.Errors render the reason.
@spec fetch_mailbox_by_recipient(String.t()) :: {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, :recipient_not_found}
Resolves what someone typed in a To/Cc/Bcc field to a mailbox.
Accepted, case-insensitively:
- a mailbox
address—"alice@example.com" - a shared mailbox
slugorname—"support"or"Customer Support" - a username —
"alice" - a user's login email, even if they have no mailbox yet
Why users are looked up, not just mailboxes
Two things were wrong when this only queried phoenix_kit_inbox_mailboxes
on address/slug:
- A username matched nothing. It lives on the user record and was never
copied onto the mailbox, so
"alice"was unresolvable while"alice@example.com"worked — with no hint which was expected. - Personal mailboxes are created lazily on first visit to Inbox, so a user who had never opened the page had no mailbox row and was unaddressable by any spelling. You could not message a colleague until they happened to click the tab.
Falling back to a user lookup fixes both: an account is reachable from the
moment it exists, and its mailbox is created here, on demand, when the first
message is addressed to it. That write happens outside the send transaction
(Messages.send_message/3 resolves recipients before opening it), so a send
that later fails leaves behind only a mailbox the user would have got on
their next visit anyway.
@spec fetch_mailbox_by_slug(String.t()) :: {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, :mailbox_not_found}
Fetches a mailbox by its unique slug.
@spec grant_access(binary(), binary(), access(), keyword()) :: {:ok, PhoenixKitInbox.Schemas.MailboxGrant.t()} | {:error, Ecto.Changeset.t()}
Grants (or re-grants) user_uuid access to a mailbox.
Upsert semantics: re-granting an existing user simply changes their level, which is what "set access to write" means in the UI.
@spec list_accessible_mailboxes(binary()) :: [PhoenixKitInbox.Schemas.Mailbox.t()]
Every mailbox user_uuid may open, personal first then shared, each
alphabetical. Archived and deleted mailboxes are excluded.
This is the sidebar query — one round trip, no N+1 over grants.
@spec list_grants(binary()) :: [PhoenixKitInbox.Schemas.MailboxGrant.t()]
All grants on a mailbox, for the mailbox admin page.
@spec restore_mailbox(PhoenixKitInbox.Schemas.Mailbox.t()) :: {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, Ecto.Changeset.t()}
Reverses archive_mailbox/1.
Removes a user's grant on a mailbox. Idempotent.
@spec search_recipients(binary(), String.t(), keyword()) :: [ %{handle: String.t(), label: String.t()} ]
Addressable recipients, for the compose field's suggestion list.
Returns %{handle: String.t(), label: String.t()} — handle is what goes in
the To field and is guaranteed to resolve through
fetch_mailbox_by_recipient/1; label is the human description shown beside
it.
Covers shared mailboxes and users, not just existing mailboxes. Searching only mailboxes is what made this useless: a colleague who had never opened Inbox had no mailbox row and so never appeared, which is exactly the case where a suggestion is most needed.
Usernames are preferred as the handle because they are what people know each other by; the email is shown in the label so an ambiguous display name can still be told apart.
@spec unseen_counts(binary()) :: %{required(String.t()) => non_neg_integer()}
Unseen message count per folder for a mailbox, as %{folder => count}.
Folders with nothing unseen are absent from the map — callers use
Map.get(counts, folder, 0).
@spec update_mailbox(PhoenixKitInbox.Schemas.Mailbox.t(), map()) :: {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, Ecto.Changeset.t()}
Updates a mailbox's name/address/settings.
Best-effort display name for a user — full name, else username, else the local part of their email, else a short uuid.