pub struct MailboxEngine<S> { /* private fields */ }Expand description
The mailbox engine. Generic over its MailStore so the same logic
runs against crate::InMemoryStore in tests and, later, a persistent
store – neither of which this crate needs to know about here.
Implementations§
Source§impl<S: MailStore> MailboxEngine<S>
impl<S: MailStore> MailboxEngine<S>
pub fn new(store: S) -> Self
Sourcepub fn authenticate(
&self,
presented_secret: &str,
) -> Result<ParticipantId, MailError>
pub fn authenticate( &self, presented_secret: &str, ) -> Result<ParticipantId, MailError>
Authenticates a presented secret and derives the caller’s identity
from the match – never from anything the caller states. This is
the one place a ParticipantId is allowed to enter the engine from
outside; every other method takes an already-authenticated identity
as an argument (see mail4agent/CLAUDE.md’s “a sender is never a
field the caller fills in”).
Authenticates the account only. Which session, if any, is
calling is a separate fact the caller establishes by kernel
attestation (mail4agent-attest, outside this crate) and hands to
Self::ensure_session – this method has no notion of a session at
all.
Participants are indexed by the SHA-256 digest of their secret and
looked up by that exact digest, not scanned. That lookup is safe to
do by direct index rather than in constant time because the secret
is 32 random bytes: 256 bits of entropy an attacker cannot already
be close to guessing, so nothing the lookup structure’s timing could
reveal (which bucket, how many probes) narrows the search in any
useful way. The digest found is then confirmed against the presented
one with a constant-time compare (subtle) before being trusted –
redundant with an exact map lookup by construction, but it costs
nothing and removes any dependency on the map’s own equality/hashing
behaviour for the actual authentication decision.
Sourcepub fn register_participant(
&mut self,
id: ParticipantId,
label: Option<String>,
permissions: ParticipantPermissions,
) -> Result<String, MailError>
pub fn register_participant( &mut self, id: ParticipantId, label: Option<String>, permissions: ParticipantPermissions, ) -> Result<String, MailError>
Registers a new participant and returns its secret. The secret is
generated here, handed back once, and never stored – only its
digest is kept, in ParticipantRecord::secret_digest.
Sourcepub fn deregister_participant(
&mut self,
id: &ParticipantId,
) -> Result<(), MailError>
pub fn deregister_participant( &mut self, id: &ParticipantId, ) -> Result<(), MailError>
Removes a participant’s registration. Its secret stops authenticating
immediately; stale entries in a room’s member set naming this id are
inert (see MailStore::deregister_participant’s doc comment).
Sourcepub fn rotate_participant_secret(
&mut self,
id: &ParticipantId,
) -> Result<String, MailError>
pub fn rotate_participant_secret( &mut self, id: &ParticipantId, ) -> Result<String, MailError>
Issues a fresh secret for an existing participant, invalidating the
old one. Shares its mechanism with Self::revoke_participant_secret
– revoking is rotating and discarding the new secret rather than
returning it.
Sourcepub fn revoke_participant_secret(
&mut self,
id: &ParticipantId,
) -> Result<(), MailError>
pub fn revoke_participant_secret( &mut self, id: &ParticipantId, ) -> Result<(), MailError>
Invalidates a participant’s current secret without issuing a new
one usable by anyone: it is replaced by the digest of a fresh secret
that is generated and immediately discarded, so no plaintext maps to
it. The participant must call Self::rotate_participant_secret (or
be re-registered) to authenticate again.
Sourcepub fn set_listener(
&mut self,
id: &ParticipantId,
url: String,
) -> Result<(), MailError>
pub fn set_listener( &mut self, id: &ParticipantId, url: String, ) -> Result<(), MailError>
Registers (or replaces) the URL the mailbox POSTs a
mail4agent_api::DeliveryNotification to whenever mail arrives
for id or any of its sessions. Operator-only at the daemon’s own
admin surface (POST /admin/listener); this method itself only
enforces that id is a real, already-registered participant and
that url is a well-formed loopback URL – see
[validate_listener_url] for exactly what that means and why.
Sourcepub fn remove_listener(&mut self, id: &ParticipantId) -> Result<(), MailError>
pub fn remove_listener(&mut self, id: &ParticipantId) -> Result<(), MailError>
Removes id’s registered delivery listener, if any. Idempotent:
removing an account with no listener registered is not an error.
Sourcepub fn create_room(
&mut self,
id: RoomId,
now_unix_ms: u64,
) -> Result<(), MailError>
pub fn create_room( &mut self, id: RoomId, now_unix_ms: u64, ) -> Result<(), MailError>
Creates a room with no members. now_unix_ms is threaded through by
the caller (not read from a clock here) so the engine stays a pure
function of its inputs – the same discipline Self::send and
Self::ack follow.
Sourcepub fn add_room_member(
&mut self,
room: &RoomId,
participant: ParticipantId,
) -> Result<(), MailError>
pub fn add_room_member( &mut self, room: &RoomId, participant: ParticipantId, ) -> Result<(), MailError>
Adds participant to room. Idempotent: adding an existing member
is not an error. Membership is always an account’s: every session
of participant inherits it (see Self::resolve_identity).
Sourcepub fn remove_room_member(
&mut self,
room: &RoomId,
participant: &ParticipantId,
) -> Result<(), MailError>
pub fn remove_room_member( &mut self, room: &RoomId, participant: &ParticipantId, ) -> Result<(), MailError>
Removes participant from room. Idempotent: removing a
non-member is not an error. Readability of a room is present-tense
(see Self::is_readable): once removed, the participant’s next
Self::inbox call shows none of that room’s mail at all, past or
future – there is no partial history left behind for a former
member.
Sourcepub fn ensure_session(
&mut self,
account: ParticipantId,
session_id: SessionId,
card: SessionCard,
now_unix_ms: u64,
) -> Result<SessionId, MailError>
pub fn ensure_session( &mut self, account: ParticipantId, session_id: SessionId, card: SessionCard, now_unix_ms: u64, ) -> Result<SessionId, MailError>
Registers a session, or refreshes an already-registered one –
idempotent, and the only way a session enters the mailbox at all.
This replaces enrolment as a separate step
(mailbox-service-extraction-and-signed-session-identity-2026-09-16.md
§5e): the first call for a given session_id creates it under
account; every later call for the same id refreshes last_seen and
card’s attested/corroborated groups.
Never touches card.declared. Self::set_declared is the only
way that group is ever written – so this preserves whatever is
already on file (empty, the first time) regardless of what the
caller passed in card.declared. A caller that wants to declare
something calls Self::set_declared itself; passing it here would
let attestation traffic silently overwrite what a session said about
itself.
Refuses MailError::SessionAccountMismatch if session_id is
already on file under a different account: a session’s account
cannot change out from under it, only be created once.
Sourcepub fn set_declared(
&mut self,
session: &SessionId,
working_on: Option<String>,
role: Option<String>,
parent: Option<SessionId>,
) -> Result<(), MailError>
pub fn set_declared( &mut self, session: &SessionId, working_on: Option<String>, role: Option<String>, parent: Option<SessionId>, ) -> Result<(), MailError>
Sets session’s declared group – what it is working on, its role,
which session spawned it. The only way that group is ever
written; Self::ensure_session never touches it (see that
method’s own doc comment). Refuses MailError::UnknownSession if
session has never been through Self::ensure_session.
Sourcepub fn send(
&mut self,
sender: &Address,
request: SendRequest,
now_unix_ms: u64,
) -> Result<SendResponse, MailError>
pub fn send( &mut self, sender: &Address, request: SendRequest, now_unix_ms: u64, ) -> Result<SendResponse, MailError>
Sends a message. sender must already be the address
Self::authenticate (plus, for a session, Self::ensure_session)
established – never a field read out of request; SendRequest
has no from, and must never grow one (mail4agent/CLAUDE.md).
sender is the session’s own address when a session sends, the
account’s when an account does – see Address.
Sending to a room never requires membership; reading one does.
This mirrors the mailbox being ported: anyone could write to a task
forum, but only those who could see the task could read it (see
mailbox-service-extraction-and-signed-session-identity-2026-09-16.md
§5b). It is a deliberate parity choice, not an oversight, and it is
worth revisiting once this crate has its own callers: a mailbox that
lets any registered participant write into a room it cannot itself
read is a wider write surface than most groupware would choose.
A message’s id is derived from its content and, when present, from
SendRequest::idempotency_key (see [derive_message_id]); a
repeat send carrying the same key from the same sender address
returns the original SendResponse and creates nothing, checked
and recorded atomically by MailStore::insert_message. Without a
key, a repeat send is a second message – correct, because sending
the same text twice on purpose should produce two messages.
Sourcepub fn inbox(
&self,
reader: &Address,
since_unix_ms: u64,
limit: u16,
) -> Result<InboxPage, MailError>
pub fn inbox( &self, reader: &Address, since_unix_ms: u64, limit: u16, ) -> Result<InboxPage, MailError>
Returns a page of reader’s own inbox: messages addressed
directly to reader (its own session address if reader is a
session, plus its account’s direct mail – see
Self::own_messages), plus messages to any room its account is
currently a member of (membership is evaluated now, not at send
time), no older than since_unix_ms, ordered by created_at_unix_ms
then message_id, truncated to limit. unread counts every
currently-readable message with no ack on file for reader,
independent of since and limit.
This never widens for an operator. “An operator may read any
address” means any address it names, one at a time – see
Self::inbox_of, the door through which an operator reaches
someone else’s inbox. An operator calling this method sees only its
own mail, exactly like anyone else.
Sourcepub fn inbox_of(
&self,
caller: &Address,
target: &Address,
since_unix_ms: u64,
limit: u16,
) -> Result<InboxPage, MailError>
pub fn inbox_of( &self, caller: &Address, target: &Address, since_unix_ms: u64, limit: u16, ) -> Result<InboxPage, MailError>
Returns target’s inbox by exactly Self::inbox’s own rule –
never widened, regardless of who is asking. Requires
caller.operator or caller == target; refuses
PermissionDenied { need: "mail:operator" } otherwise.
This is the operator door onto a named address, one at a time –
not a firehose over the whole mailbox. target may name an account
or one specific session of it. When caller == target this is
exactly Self::inbox under another name (and still requires
target’s own may_read); when an operator names someone else,
the target’s own may_read is not consulted, because the
authorization has already been established by the operator bit.
Sourcepub fn ack(
&mut self,
reader: &Address,
message_id: &MessageId,
now_unix_ms: u64,
) -> Result<Ack, MailError>
pub fn ack( &mut self, reader: &Address, message_id: &MessageId, now_unix_ms: u64, ) -> Result<Ack, MailError>
Records reader’s acknowledgement of message_id. Refuses
NotAddressedToYou unless reader may read the message (see
Self::is_readable, which keeps its operator override: a named
single message is a different thing from a bulk inbox listing).
Idempotent on (message_id, reader): a second ack of the same
message by the same reader address returns the first ack unchanged
rather than overwriting its timestamp.
now_unix_ms is threaded through by the caller for the same reason
Self::send takes it: the engine reads no clock of its own.
Sourcepub fn message_get(
&self,
reader: &Address,
message_id: &MessageId,
) -> Result<Message, MailError>
pub fn message_get( &self, reader: &Address, message_id: &MessageId, ) -> Result<Message, MailError>
Fetches one message by id. Refuses UnknownMessage if no such
message exists, NotAddressedToYou if it exists but reader may
not read it (see Self::is_readable, which keeps its operator
override for the same reason Self::ack does).
Sourcepub fn unread_count_of(
&self,
caller: &Address,
target: &Address,
) -> Result<UnreadCount, MailError>
pub fn unread_count_of( &self, caller: &Address, target: &Address, ) -> Result<UnreadCount, MailError>
Returns how many currently-readable messages target has not yet
acked – the same figure InboxPage::unread carries for the same
address. Requires caller.operator or caller == target, the same
authorization Self::inbox_of uses, enforced here rather than left
to a wire layer that could forget it.
Sourcepub fn directory(
&self,
caller: &Address,
is_alive: LivenessCheck<'_>,
) -> Result<Directory, MailError>
pub fn directory( &self, caller: &Address, is_alive: LivenessCheck<'_>, ) -> Result<Directory, MailError>
Returns the mailbox’s own directory: every registered account (id
and label; never a secret digest or a permission bit – see
crate::ParticipantSummary), each with its own live sessions
nested under it, and every room the mailbox tracks, each marked with
whether caller’s account currently belongs to it.
is_alive is given, never performed here: this crate learns
nothing about processes (see LivenessCheck). It is called once
per listed session, with that session’s own (pid, started_at_unix_ms), to fill SessionEntry::live.
Gated on caller.may_read, the same capability Self::inbox and
Self::message_get require: seeing who else exists is a read of
the mailbox, not a distinct capability. A participant is visible
to every other participant that may read at all, with no exception
for a listed participant’s own permission bits – knowing someone
exists is not the capability that matters (reading their mail is,
and that is unaffected by this), so gating the directory’s
completeness on each target’s may_read/may_send would only
make it an unreliable directory for no privacy this mailbox
actually provides.