PhoenixKitInbox.Mailboxes (PhoenixKitInbox v0.2.0)

Copy Markdown View Source

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.

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

access()

@type access() :: String.t()

Functions

access_level(arg1, user_uuid)

@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.

archive_mailbox(mailbox)

@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.

authorize(mailbox, user_uuid, required)

@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")

change_mailbox(mailbox, attrs \\ %{})

@spec change_mailbox(PhoenixKitInbox.Schemas.Mailbox.t(), map()) :: Ecto.Changeset.t()

Changeset for mailbox forms.

create_shared_mailbox(owner_uuid, attrs)

@spec create_shared_mailbox(binary(), map()) ::
  {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, Ecto.Changeset.t()}

Creates a shared mailbox owned by owner_uuid.

attrs needs at least :name; :slug is derived from the name when absent.

ensure_user_mailbox(user)

@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.

fetch_mailbox(uuid)

@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.

fetch_mailbox_by_recipient(term)

@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 slug or name"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:

  1. 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.
  2. 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.

fetch_mailbox_by_slug(slug)

@spec fetch_mailbox_by_slug(String.t()) ::
  {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, :mailbox_not_found}

Fetches a mailbox by its unique slug.

grant_access(mailbox_uuid, user_uuid, access, opts \\ [])

@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.

list_accessible_mailboxes(user_uuid)

@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.

list_grants(mailbox_uuid)

@spec list_grants(binary()) :: [PhoenixKitInbox.Schemas.MailboxGrant.t()]

All grants on a mailbox, for the mailbox admin page.

list_shared_mailboxes()

@spec list_shared_mailboxes() :: [PhoenixKitInbox.Schemas.Mailbox.t()]

All shared mailboxes, for the admin management page.

restore_mailbox(mailbox)

@spec restore_mailbox(PhoenixKitInbox.Schemas.Mailbox.t()) ::
  {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, Ecto.Changeset.t()}

Reverses archive_mailbox/1.

revoke_access(mailbox_uuid, user_uuid)

@spec revoke_access(binary(), binary()) :: :ok

Removes a user's grant on a mailbox. Idempotent.

search_mailboxes(_, _, _)

search_recipients(user_uuid, term \\ "", opts \\ [])

@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.

unseen_counts(mailbox_uuid)

@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).

update_mailbox(mailbox, attrs)

@spec update_mailbox(PhoenixKitInbox.Schemas.Mailbox.t(), map()) ::
  {:ok, PhoenixKitInbox.Schemas.Mailbox.t()} | {:error, Ecto.Changeset.t()}

Updates a mailbox's name/address/settings.

user_display_name(user)

@spec user_display_name(map()) :: String.t()

Best-effort display name for a user — full name, else username, else the local part of their email, else a short uuid.