Composing, sending, listing, and filing messages.
The send path
send_message/3 is one Ecto.Multi, so a message is never half-delivered:
- resolve every recipient string to a mailbox (unknown ones fail the whole send — silently dropping a recipient is worse than refusing)
- insert or update the message with
status: "sent"and asent_atstamp - fan out one
Deliveryper recipient into their"inbox", plus the sender's own copy into"sent"
Notifications are fired after the transaction commits, by
PhoenixKitInbox.Notify — a notification for a message that got rolled back
would be a lie, and an outbound-email failure must never roll back a
successfully delivered internal message.
Reads
Every list query is scoped to a single mailbox and folder, hitting the
(mailbox_uuid, folder, inserted_at) index from V01. Nothing here reads
across mailboxes — that's the whole point of the per-mailbox delivery row.
Summary
Types
A message as the UI consumes it: the delivery row (folder/seen/starred state for this mailbox) plus the message it points at.
Functions
Changeset for the compose form.
Row count for a folder, for pagination and the sidebar totals.
Discards a draft and its delivery row. Only the author's own drafts.
Fetches one message as seen by one mailbox.
Lists one folder of one mailbox, newest first.
The recipient mailboxes of a message, with their roles — the To/Cc chips in
the reading pane. bcc rows are included; callers showing a message to a
non-sender should filter them out.
Every message in a thread, oldest first — the conversation view.
Marks a delivery seen. Idempotent — an already-seen row keeps its original stamp.
Marks a delivery unseen.
Moves a delivery to another folder — the one operation behind Archive, Spam, Trash, and Restore in the UI.
Permanently removes a mailbox's copy of a message. The message itself and other mailboxes' copies are untouched — deleting your copy of a group thread doesn't delete anyone else's.
Saves a draft owned by mailbox, creating it on first save and updating it
on subsequent ones. The draft gets exactly one delivery — into the author's
own "drafts" folder.
Sends a message from mailbox.
Flips the star on a delivery.
Types
@type listed() :: %{ delivery: PhoenixKitInbox.Schemas.Delivery.t(), message: PhoenixKitInbox.Schemas.Message.t() }
A message as the UI consumes it: the delivery row (folder/seen/starred state for this mailbox) plus the message it points at.
Functions
@spec change_message(PhoenixKitInbox.Schemas.Message.t(), map()) :: Ecto.Changeset.t()
Changeset for the compose form.
@spec count_folder(binary(), String.t(), keyword()) :: non_neg_integer()
Row count for a folder, for pagination and the sidebar totals.
@spec delete_draft(PhoenixKitInbox.Schemas.Mailbox.t(), binary()) :: :ok | {:error, :message_not_found}
Discards a draft and its delivery row. Only the author's own drafts.
Fetches one message as seen by one mailbox.
Scoped to the delivery on purpose: passing a message uuid a mailbox was never
sent is a {:error, :message_not_found}, not a read of someone else's mail.
Lists one folder of one mailbox, newest first.
Options
:limit— default 50:offset— default 0:unseen_only— only rows with noseen_at:search— case-insensitive match on subject or body
@spec list_recipients(binary()) :: [ %{role: String.t(), mailbox: PhoenixKitInbox.Schemas.Mailbox.t()} ]
The recipient mailboxes of a message, with their roles — the To/Cc chips in
the reading pane. bcc rows are included; callers showing a message to a
non-sender should filter them out.
@spec list_thread(binary()) :: [PhoenixKitInbox.Schemas.Message.t()]
Every message in a thread, oldest first — the conversation view.
@spec mark_seen(binary(), binary()) :: {:ok, PhoenixKitInbox.Schemas.Delivery.t()} | {:error, :message_not_found}
Marks a delivery seen. Idempotent — an already-seen row keeps its original stamp.
@spec mark_unseen(binary(), binary()) :: {:ok, PhoenixKitInbox.Schemas.Delivery.t()} | {:error, :message_not_found}
Marks a delivery unseen.
@spec move_to_folder(binary(), binary(), String.t()) :: {:ok, PhoenixKitInbox.Schemas.Delivery.t()} | {:error, :message_not_found | Ecto.Changeset.t()}
Moves a delivery to another folder — the one operation behind Archive, Spam, Trash, and Restore in the UI.
Permanently removes a mailbox's copy of a message. The message itself and other mailboxes' copies are untouched — deleting your copy of a group thread doesn't delete anyone else's.
@spec save_draft(PhoenixKitInbox.Schemas.Mailbox.t(), binary(), map()) :: {:ok, PhoenixKitInbox.Schemas.Message.t()} | {:error, Ecto.Changeset.t() | atom()}
Saves a draft owned by mailbox, creating it on first save and updating it
on subsequent ones. The draft gets exactly one delivery — into the author's
own "drafts" folder.
Recipients are not resolved here. A half-typed address is a normal state for a draft; validation happens at send.
@spec send_message(PhoenixKitInbox.Schemas.Mailbox.t(), binary(), map()) :: {:ok, PhoenixKitInbox.Schemas.Message.t()} | {:error, term()}
Sends a message from mailbox.
attrs keys:
"to"/"cc"/"bcc"— comma-separated recipient strings, or lists. Each entry is a mailbox slug or address (seeMailboxes.fetch_mailbox_by_recipient/1)."subject","body","body_format""uuid"— promotes an existing draft instead of creating a new message"parent_uuid"— set when replying; the thread is inherited from it
Returns {:error, {:unknown_recipients, ["typo@example.com"]}} when an
address doesn't resolve — the whole send is refused so the sender finds out
immediately rather than discovering a missing recipient later.
@spec toggle_star(binary(), binary()) :: {:ok, PhoenixKitInbox.Schemas.Delivery.t()} | {:error, :message_not_found}
Flips the star on a delivery.