Skip to main content

mail4agent_core/
engine.rs

1//! The mailbox engine: authentication, the participant/room/session
2//! registry, and the mail operations (send, inbox, ack, message lookup)
3//! laid over a [`crate::MailStore`].
4
5use mail4agent_api::{
6    Ack, Address, Directory, DirectoryEntry, InboxPage, MailError, Message, MessageId, Participant,
7    ParticipantId, RoomEntry, RoomId, SendRequest, SendResponse, SessionCard, SessionEntry, SessionId,
8    UnreadCount, MESSAGE_ID_HEX_LEN, MESSAGE_ID_PREFIX,
9};
10use sha2::{Digest, Sha256};
11use subtle::ConstantTimeEq;
12
13use crate::store::{InsertMessageOutcome, MailStore, ParticipantRecord, SecretDigest, SessionRecord, StoreError};
14
15/// Length, in lower-hex characters, of a freshly generated participant
16/// secret: 32 random bytes (256 bits of entropy) rendered as hex. See
17/// `MailboxEngine::authenticate` for why that much entropy is exactly what
18/// makes an exact-digest lookup safe.
19pub const SECRET_HEX_LEN: usize = 64;
20
21/// What a newly registered (or re-permissioned) participant may do.
22/// Distinct from [`ParticipantRecord`]: this is the caller-facing shape a
23/// registration call takes, without the label or the secret digest, which
24/// the engine derives itself.
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
26pub struct ParticipantPermissions {
27    pub may_send: bool,
28    pub may_read: bool,
29    pub operator: bool,
30}
31
32/// A liveness check the engine is *given*, never one it performs itself:
33/// `mail4agent-core` learns nothing about processes or Windows (see
34/// `mail4agent-attest`, which lives outside this crate entirely and is
35/// exactly the shape this closure expects -- `mail4agent_attest::is_alive`
36/// coerces to it directly). Takes `(pid, started_at_unix_ms)`, the same pair
37/// [`mail4agent_api::SessionAttested`] carries, and answers whether that
38/// process is still the one that started at that time.
39pub type LivenessCheck<'a> = &'a dyn Fn(u32, u64) -> bool;
40
41/// The mailbox engine. Generic over its [`MailStore`] so the same logic
42/// runs against [`crate::InMemoryStore`] in tests and, later, a persistent
43/// store -- neither of which this crate needs to know about here.
44pub struct MailboxEngine<S> {
45    store: S,
46}
47
48impl<S: MailStore> MailboxEngine<S> {
49    pub fn new(store: S) -> Self {
50        Self { store }
51    }
52
53    /// Authenticates a presented secret and derives the caller's identity
54    /// from the match -- **never** from anything the caller states. This is
55    /// the one place a [`ParticipantId`] is allowed to enter the engine from
56    /// outside; every other method takes an already-authenticated identity
57    /// as an argument (see `mail4agent/CLAUDE.md`'s "a sender is never a
58    /// field the caller fills in").
59    ///
60    /// Authenticates the **account** only. Which session, if any, is
61    /// calling is a separate fact the caller establishes by kernel
62    /// attestation (`mail4agent-attest`, outside this crate) and hands to
63    /// [`Self::ensure_session`] -- this method has no notion of a session at
64    /// all.
65    ///
66    /// Participants are indexed by the SHA-256 digest of their secret and
67    /// looked up by that exact digest, not scanned. That lookup is safe to
68    /// do by direct index rather than in constant time because the secret
69    /// is 32 random bytes: 256 bits of entropy an attacker cannot already
70    /// be close to guessing, so nothing the lookup structure's timing could
71    /// reveal (which bucket, how many probes) narrows the search in any
72    /// useful way. The digest found is then confirmed against the presented
73    /// one with a constant-time compare (`subtle`) before being trusted --
74    /// redundant with an exact map lookup by construction, but it costs
75    /// nothing and removes any dependency on the map's own equality/hashing
76    /// behaviour for the actual authentication decision.
77    pub fn authenticate(&self, presented_secret: &str) -> Result<ParticipantId, MailError> {
78        let digest = sha256_digest(presented_secret.as_bytes());
79        let found = self
80            .store
81            .find_participant_by_digest(&digest)
82            .map_err(|err| store_unavailable("find_participant_by_digest", err))?;
83        let Some((id, record)) = found else {
84            return Err(MailError::PermissionDenied { need: "mail:authenticate".to_string() });
85        };
86        if bool::from(record.secret_digest.ct_eq(&digest)) {
87            Ok(id)
88        } else {
89            Err(MailError::PermissionDenied { need: "mail:authenticate".to_string() })
90        }
91    }
92
93    /// Registers a new participant and returns its secret. The secret is
94    /// generated here, handed back **once**, and never stored -- only its
95    /// digest is kept, in [`ParticipantRecord::secret_digest`].
96    pub fn register_participant(
97        &mut self,
98        id: ParticipantId,
99        label: Option<String>,
100        permissions: ParticipantPermissions,
101    ) -> Result<String, MailError> {
102        Participant { id: id.clone(), label: label.clone() }.validate()?;
103        let existing = self.store.get_participant(&id).map_err(|err| store_unavailable("get_participant", err))?;
104        if existing.is_some() {
105            return Err(MailError::Malformed {
106                field: "participant".to_string(),
107                reason: format!("participant \"{id}\" is already registered"),
108            });
109        }
110        let secret = generate_secret();
111        let record = ParticipantRecord {
112            label,
113            secret_digest: sha256_digest(secret.as_bytes()),
114            may_send: permissions.may_send,
115            may_read: permissions.may_read,
116            operator: permissions.operator,
117            listener_url: None,
118        };
119        self.store.register_participant(id, record).map_err(|err| store_unavailable("register_participant", err))?;
120        Ok(secret)
121    }
122
123    /// Removes a participant's registration. Its secret stops authenticating
124    /// immediately; stale entries in a room's member set naming this id are
125    /// inert (see [`MailStore::deregister_participant`]'s doc comment).
126    pub fn deregister_participant(&mut self, id: &ParticipantId) -> Result<(), MailError> {
127        self.require_participant(id)?;
128        self.store.deregister_participant(id).map_err(|err| store_unavailable("deregister_participant", err))
129    }
130
131    /// Issues a fresh secret for an existing participant, invalidating the
132    /// old one. Shares its mechanism with [`Self::revoke_participant_secret`]
133    /// -- revoking is rotating and discarding the new secret rather than
134    /// returning it.
135    pub fn rotate_participant_secret(&mut self, id: &ParticipantId) -> Result<String, MailError> {
136        self.require_participant(id)?;
137        let secret = generate_secret();
138        self.store
139            .set_participant_secret_digest(id, sha256_digest(secret.as_bytes()))
140            .map_err(|err| store_unavailable("set_participant_secret_digest", err))?;
141        Ok(secret)
142    }
143
144    /// Invalidates a participant's current secret without issuing a new
145    /// one usable by anyone: it is replaced by the digest of a fresh secret
146    /// that is generated and immediately discarded, so no plaintext maps to
147    /// it. The participant must call [`Self::rotate_participant_secret`] (or
148    /// be re-registered) to authenticate again.
149    pub fn revoke_participant_secret(&mut self, id: &ParticipantId) -> Result<(), MailError> {
150        self.rotate_participant_secret(id).map(|_secret| ())
151    }
152
153    /// Registers (or replaces) the URL the mailbox POSTs a
154    /// [`mail4agent_api::DeliveryNotification`] to whenever mail arrives
155    /// for `id` or any of its sessions. Operator-only at the daemon's own
156    /// admin surface (`POST /admin/listener`); this method itself only
157    /// enforces that `id` is a real, already-registered participant and
158    /// that `url` is a well-formed loopback URL -- see
159    /// [`validate_listener_url`] for exactly what that means and why.
160    pub fn set_listener(&mut self, id: &ParticipantId, url: String) -> Result<(), MailError> {
161        validate_listener_url(&url)?;
162        self.require_participant(id)?;
163        self.store.set_listener_url(id, Some(url)).map_err(|err| store_unavailable("set_listener_url", err))
164    }
165
166    /// Removes `id`'s registered delivery listener, if any. Idempotent:
167    /// removing an account with no listener registered is not an error.
168    pub fn remove_listener(&mut self, id: &ParticipantId) -> Result<(), MailError> {
169        self.require_participant(id)?;
170        self.store.set_listener_url(id, None).map_err(|err| store_unavailable("set_listener_url", err))
171    }
172
173    /// Creates a room with no members. `now_unix_ms` is threaded through by
174    /// the caller (not read from a clock here) so the engine stays a pure
175    /// function of its inputs -- the same discipline [`Self::send`] and
176    /// [`Self::ack`] follow.
177    pub fn create_room(&mut self, id: RoomId, now_unix_ms: u64) -> Result<(), MailError> {
178        let existing = self.store.get_room(&id).map_err(|err| store_unavailable("get_room", err))?;
179        if existing.is_some() {
180            return Err(MailError::Malformed {
181                field: "room".to_string(),
182                reason: format!("room \"{id}\" already exists"),
183            });
184        }
185        self.store.create_room(id, now_unix_ms).map_err(|err| store_unavailable("create_room", err))
186    }
187
188    /// Adds `participant` to `room`. Idempotent: adding an existing member
189    /// is not an error. Membership is always an *account*'s: every session
190    /// of `participant` inherits it (see [`Self::resolve_identity`]).
191    pub fn add_room_member(&mut self, room: &RoomId, participant: ParticipantId) -> Result<(), MailError> {
192        let room_record = self.store.get_room(room).map_err(|err| store_unavailable("get_room", err))?;
193        if room_record.is_none() {
194            return Err(MailError::UnknownRoom { room: room.clone() });
195        }
196        let participant_record =
197            self.store.get_participant(&participant).map_err(|err| store_unavailable("get_participant", err))?;
198        if participant_record.is_none() {
199            return Err(MailError::UnknownParticipant { participant });
200        }
201        self.store.add_room_member(room, participant).map_err(|err| store_unavailable("add_room_member", err))
202    }
203
204    /// Removes `participant` from `room`. Idempotent: removing a
205    /// non-member is not an error. Readability of a room is present-tense
206    /// (see [`Self::is_readable`]): once removed, the participant's next
207    /// [`Self::inbox`] call shows none of that room's mail at all, past or
208    /// future -- there is no partial history left behind for a former
209    /// member.
210    pub fn remove_room_member(&mut self, room: &RoomId, participant: &ParticipantId) -> Result<(), MailError> {
211        let room_record = self.store.get_room(room).map_err(|err| store_unavailable("get_room", err))?;
212        if room_record.is_none() {
213            return Err(MailError::UnknownRoom { room: room.clone() });
214        }
215        self.store.remove_room_member(room, participant).map_err(|err| store_unavailable("remove_room_member", err))
216    }
217
218    /// Registers a session, or refreshes an already-registered one --
219    /// idempotent, and the only way a session enters the mailbox at all.
220    /// This replaces enrolment as a separate step
221    /// (`mailbox-service-extraction-and-signed-session-identity-2026-09-16.md`
222    /// §5e): the first call for a given `session_id` creates it under
223    /// `account`; every later call for the same id refreshes `last_seen` and
224    /// `card`'s `attested`/`corroborated` groups.
225    ///
226    /// **Never touches `card.declared`.** [`Self::set_declared`] is the only
227    /// way that group is ever written -- so this preserves whatever is
228    /// already on file (empty, the first time) regardless of what the
229    /// caller passed in `card.declared`. A caller that wants to declare
230    /// something calls [`Self::set_declared`] itself; passing it here would
231    /// let attestation traffic silently overwrite what a session said about
232    /// itself.
233    ///
234    /// Refuses [`MailError::SessionAccountMismatch`] if `session_id` is
235    /// already on file under a *different* account: a session's account
236    /// cannot change out from under it, only be created once.
237    pub fn ensure_session(
238        &mut self,
239        account: ParticipantId,
240        session_id: SessionId,
241        card: SessionCard,
242        now_unix_ms: u64,
243    ) -> Result<SessionId, MailError> {
244        card.validate()?;
245        session_id.validate()?;
246        self.require_participant(&account)?;
247
248        let existing = self.store.get_session(&session_id).map_err(|err| store_unavailable("get_session", err))?;
249        let declared = match &existing {
250            Some(record) if record.account == account => record.card.declared.clone(),
251            Some(record) => {
252                return Err(MailError::SessionAccountMismatch {
253                    session: session_id,
254                    expected: record.account.clone(),
255                    presented: account,
256                });
257            }
258            None => Default::default(),
259        };
260        let merged = SessionCard { attested: card.attested, corroborated: card.corroborated, declared };
261        merged.validate()?;
262
263        let record = SessionRecord { account, card: merged, last_seen_unix_ms: now_unix_ms };
264        self.store.upsert_session(session_id.clone(), record).map_err(|err| store_unavailable("upsert_session", err))?;
265        Ok(session_id)
266    }
267
268    /// Sets `session`'s declared group -- what it is working on, its role,
269    /// which session spawned it. The **only** way that group is ever
270    /// written; [`Self::ensure_session`] never touches it (see that
271    /// method's own doc comment). Refuses [`MailError::UnknownSession`] if
272    /// `session` has never been through [`Self::ensure_session`].
273    pub fn set_declared(
274        &mut self,
275        session: &SessionId,
276        working_on: Option<String>,
277        role: Option<String>,
278        parent: Option<SessionId>,
279    ) -> Result<(), MailError> {
280        session.validate()?;
281        let mut record = self
282            .store
283            .get_session(session)
284            .map_err(|err| store_unavailable("get_session", err))?
285            .ok_or_else(|| MailError::UnknownSession { session: session.clone() })?;
286        let declared = mail4agent_api::SessionDeclared { working_on, role, parent };
287        declared.validate()?;
288        record.card.declared = declared;
289        self.store.upsert_session(session.clone(), record).map_err(|err| store_unavailable("upsert_session", err))
290    }
291
292    /// Sends a message. `sender` must already be the address
293    /// [`Self::authenticate`] (plus, for a session, [`Self::ensure_session`])
294    /// established -- **never** a field read out of `request`; [`SendRequest`]
295    /// has no `from`, and must never grow one (`mail4agent/CLAUDE.md`).
296    /// `sender` is the session's own address when a session sends, the
297    /// account's when an account does -- see [`Address`].
298    ///
299    /// **Sending to a room never requires membership; reading one does.**
300    /// This mirrors the mailbox being ported: anyone could write to a task
301    /// forum, but only those who could see the task could read it (see
302    /// `mailbox-service-extraction-and-signed-session-identity-2026-09-16.md`
303    /// §5b). It is a deliberate parity choice, not an oversight, and it is
304    /// worth revisiting once this crate has its own callers: a mailbox that
305    /// lets any registered participant write into a room it cannot itself
306    /// read is a wider write surface than most groupware would choose.
307    ///
308    /// A message's id is derived from its content and, when present, from
309    /// [`SendRequest::idempotency_key`] (see [`derive_message_id`]); a
310    /// repeat send carrying the same key from the same sender address
311    /// returns the original [`SendResponse`] and creates nothing, checked
312    /// and recorded atomically by [`MailStore::insert_message`]. Without a
313    /// key, a repeat send is a second message -- correct, because sending
314    /// the same text twice on purpose should produce two messages.
315    pub fn send(&mut self, sender: &Address, request: SendRequest, now_unix_ms: u64) -> Result<SendResponse, MailError> {
316        request.validate()?;
317        let (_, sender_record) = self.resolve_identity(sender)?;
318        if !sender_record.may_send {
319            return Err(MailError::PermissionDenied { need: "mail:send".to_string() });
320        }
321        match &request.to {
322            Address::Direct { participant } => {
323                let exists =
324                    self.store.get_participant(participant).map_err(|err| store_unavailable("get_participant", err))?;
325                if exists.is_none() {
326                    return Err(MailError::UnknownParticipant { participant: participant.clone() });
327                }
328            }
329            Address::Session { participant, session } => {
330                let session_record =
331                    self.store.get_session(session).map_err(|err| store_unavailable("get_session", err))?;
332                match session_record {
333                    Some(record) if &record.account == participant => {}
334                    Some(record) => {
335                        return Err(MailError::SessionAccountMismatch {
336                            session: session.clone(),
337                            expected: record.account,
338                            presented: participant.clone(),
339                        });
340                    }
341                    None => return Err(MailError::UnknownSession { session: session.clone() }),
342                }
343            }
344            Address::Room { room } => {
345                let exists = self.store.get_room(room).map_err(|err| store_unavailable("get_room", err))?;
346                if exists.is_none() {
347                    return Err(MailError::UnknownRoom { room: room.clone() });
348                }
349            }
350        }
351
352        let idempotency = request.idempotency_key.clone().map(|key| (sender.clone(), key));
353        let message_id = derive_message_id(sender, &request, now_unix_ms);
354        let message = Message {
355            message_id: message_id.clone(),
356            from: sender.clone(),
357            to: request.to,
358            subject: request.subject,
359            body: request.body,
360            reply_to: request.reply_to,
361            correlation: request.correlation,
362            refs: request.refs,
363            created_at_unix_ms: now_unix_ms,
364        };
365        message.validate()?;
366
367        let outcome =
368            self.store.insert_message(message, idempotency).map_err(|err| store_unavailable("insert_message", err))?;
369        let message_id = match outcome {
370            InsertMessageOutcome::Inserted => message_id,
371            InsertMessageOutcome::Deduplicated { message_id } => message_id,
372        };
373        Ok(SendResponse { message_id, from: sender.clone() })
374    }
375
376    /// Returns a page of `reader`'s **own** inbox: messages addressed
377    /// directly to `reader` (its own session address if `reader` is a
378    /// session, plus its account's direct mail -- see
379    /// [`Self::own_messages`]), plus messages to any room its account is
380    /// *currently* a member of (membership is evaluated now, not at send
381    /// time), no older than `since_unix_ms`, ordered by `created_at_unix_ms`
382    /// then `message_id`, truncated to `limit`. `unread` counts every
383    /// currently-readable message with no ack on file for `reader`,
384    /// independent of `since` and `limit`.
385    ///
386    /// **This never widens for an operator.** "An operator may read any
387    /// address" means any address it *names*, one at a time -- see
388    /// [`Self::inbox_of`], the door through which an operator reaches
389    /// someone else's inbox. An operator calling this method sees only its
390    /// own mail, exactly like anyone else.
391    pub fn inbox(&self, reader: &Address, since_unix_ms: u64, limit: u16) -> Result<InboxPage, MailError> {
392        let (account, record) = self.resolve_identity(reader)?;
393        self.require_read_permission(&record)?;
394        self.build_inbox(reader, &account, since_unix_ms, limit)
395    }
396
397    /// Returns `target`'s inbox by exactly [`Self::inbox`]'s own rule --
398    /// never widened, regardless of who is asking. Requires
399    /// `caller.operator` or `caller == target`; refuses
400    /// `PermissionDenied { need: "mail:operator" }` otherwise.
401    ///
402    /// This is the operator door onto a *named* address, one at a time --
403    /// not a firehose over the whole mailbox. `target` may name an account
404    /// or one specific session of it. When `caller == target` this is
405    /// exactly [`Self::inbox`] under another name (and still requires
406    /// `target`'s own `may_read`); when an operator names someone else,
407    /// the target's own `may_read` is not consulted, because the
408    /// authorization has already been established by the operator bit.
409    pub fn inbox_of(
410        &self,
411        caller: &Address,
412        target: &Address,
413        since_unix_ms: u64,
414        limit: u16,
415    ) -> Result<InboxPage, MailError> {
416        let (_, caller_record) = self.resolve_identity(caller)?;
417        if caller != target && !caller_record.operator {
418            return Err(MailError::PermissionDenied { need: "mail:operator".to_string() });
419        }
420        let (target_account, target_record) = self.resolve_identity(target)?;
421        if caller == target {
422            self.require_read_permission(&target_record)?;
423        }
424        self.build_inbox(target, &target_account, since_unix_ms, limit)
425    }
426
427    /// Records `reader`'s acknowledgement of `message_id`. Refuses
428    /// `NotAddressedToYou` unless `reader` may read the message (see
429    /// [`Self::is_readable`], which keeps its operator override: a named
430    /// single message is a different thing from a bulk inbox listing).
431    /// Idempotent on `(message_id, reader)`: a second ack of the same
432    /// message by the same reader address returns the first ack unchanged
433    /// rather than overwriting its timestamp.
434    ///
435    /// `now_unix_ms` is threaded through by the caller for the same reason
436    /// [`Self::send`] takes it: the engine reads no clock of its own.
437    pub fn ack(&mut self, reader: &Address, message_id: &MessageId, now_unix_ms: u64) -> Result<Ack, MailError> {
438        let (account, record) = self.resolve_identity(reader)?;
439        self.require_read_permission(&record)?;
440        let message = self
441            .store
442            .get_message(message_id)
443            .map_err(|err| store_unavailable("get_message", err))?
444            .ok_or_else(|| MailError::UnknownMessage { message_id: message_id.clone() })?;
445        if !self.is_readable(&message, reader, &account, &record)? {
446            return Err(MailError::NotAddressedToYou { message_id: message_id.clone() });
447        }
448        let ack = Ack { message_id: message_id.clone(), reader: reader.clone(), acked_at_unix_ms: now_unix_ms };
449        ack.validate()?;
450        self.store.record_ack(ack).map_err(|err| store_unavailable("record_ack", err))
451    }
452
453    /// Fetches one message by id. Refuses `UnknownMessage` if no such
454    /// message exists, `NotAddressedToYou` if it exists but `reader` may
455    /// not read it (see [`Self::is_readable`], which keeps its operator
456    /// override for the same reason [`Self::ack`] does).
457    pub fn message_get(&self, reader: &Address, message_id: &MessageId) -> Result<Message, MailError> {
458        let (account, record) = self.resolve_identity(reader)?;
459        self.require_read_permission(&record)?;
460        let message = self
461            .store
462            .get_message(message_id)
463            .map_err(|err| store_unavailable("get_message", err))?
464            .ok_or_else(|| MailError::UnknownMessage { message_id: message_id.clone() })?;
465        if !self.is_readable(&message, reader, &account, &record)? {
466            return Err(MailError::NotAddressedToYou { message_id: message_id.clone() });
467        }
468        Ok(message)
469    }
470
471    /// Returns how many currently-readable messages `target` has not yet
472    /// acked -- the same figure [`InboxPage::unread`] carries for the same
473    /// address. Requires `caller.operator` or `caller == target`, the same
474    /// authorization [`Self::inbox_of`] uses, enforced here rather than left
475    /// to a wire layer that could forget it.
476    pub fn unread_count_of(&self, caller: &Address, target: &Address) -> Result<UnreadCount, MailError> {
477        let (_, caller_record) = self.resolve_identity(caller)?;
478        if caller != target && !caller_record.operator {
479            return Err(MailError::PermissionDenied { need: "mail:operator".to_string() });
480        }
481        let (target_account, target_record) = self.resolve_identity(target)?;
482        if caller == target {
483            self.require_read_permission(&target_record)?;
484        }
485        let unread = self.count_unread(target, &target_account)?;
486        Ok(UnreadCount { target: target.clone(), unread })
487    }
488
489    /// Returns the mailbox's own directory: every registered account (id
490    /// and label; never a secret digest or a permission bit -- see
491    /// [`crate::ParticipantSummary`]), each with its own live sessions
492    /// nested under it, and every room the mailbox tracks, each marked with
493    /// whether `caller`'s account currently belongs to it.
494    ///
495    /// `is_alive` is *given*, never performed here: this crate learns
496    /// nothing about processes (see [`LivenessCheck`]). It is called once
497    /// per listed session, with that session's own `(pid,
498    /// started_at_unix_ms)`, to fill [`SessionEntry::live`].
499    ///
500    /// Gated on `caller.may_read`, the same capability [`Self::inbox`] and
501    /// [`Self::message_get`] require: seeing who else exists is a read of
502    /// the mailbox, not a distinct capability. **A participant is visible
503    /// to every other participant that may read at all, with no exception
504    /// for a listed participant's own permission bits** -- knowing someone
505    /// exists is not the capability that matters (reading their mail is,
506    /// and that is unaffected by this), so gating the directory's
507    /// completeness on each *target's* `may_read`/`may_send` would only
508    /// make it an unreliable directory for no privacy this mailbox
509    /// actually provides.
510    pub fn directory(&self, caller: &Address, is_alive: LivenessCheck<'_>) -> Result<Directory, MailError> {
511        let (account, record) = self.resolve_identity(caller)?;
512        self.require_read_permission(&record)?;
513
514        let participants = self.store.list_participants().map_err(|err| store_unavailable("list_participants", err))?;
515        let mut entries = Vec::with_capacity(participants.len());
516        for summary in participants {
517            let sessions = self
518                .store
519                .sessions_of(&summary.id)
520                .map_err(|err| store_unavailable("sessions_of", err))?
521                .into_iter()
522                .map(|(id, record)| SessionEntry {
523                    live: is_alive(record.card.attested.pid, record.card.attested.started_at_unix_ms),
524                    last_seen_unix_ms: record.last_seen_unix_ms,
525                    card: record.card,
526                    id,
527                })
528                .collect();
529            entries.push(DirectoryEntry { id: summary.id, label: summary.label, sessions });
530        }
531
532        let rooms = self
533            .store
534            .list_rooms()
535            .map_err(|err| store_unavailable("list_rooms", err))?
536            .into_iter()
537            .map(|summary| RoomEntry { member: summary.members.contains(&account), id: summary.id })
538            .collect();
539
540        Ok(Directory { participants: entries, rooms })
541    }
542
543    fn require_participant(&self, id: &ParticipantId) -> Result<ParticipantRecord, MailError> {
544        self.store
545            .get_participant(id)
546            .map_err(|err| store_unavailable("get_participant", err))?
547            .ok_or_else(|| MailError::UnknownParticipant { participant: id.clone() })
548    }
549
550    /// Resolves any [`Address`] that can identify a caller (a
551    /// [`Address::Direct`] account or a [`Address::Session`] one -- never a
552    /// [`Address::Room`], which names somewhere mail goes, not someone) to
553    /// the account it acts as and that account's registry record. A session
554    /// borrows its account's permission bits wholesale: it carries none of
555    /// its own (see [`SessionRecord`]).
556    ///
557    /// Refuses [`MailError::UnknownSession`] if `address` names a session
558    /// that has never reached [`Self::ensure_session`], and
559    /// [`MailError::SessionAccountMismatch`] if it names a session that has,
560    /// but under a different account than the one given alongside it.
561    fn resolve_identity(&self, address: &Address) -> Result<(ParticipantId, ParticipantRecord), MailError> {
562        address.validate()?;
563        match address {
564            Address::Direct { participant } => {
565                let record = self.require_participant(participant)?;
566                Ok((participant.clone(), record))
567            }
568            Address::Session { participant, session } => {
569                let session_record = self
570                    .store
571                    .get_session(session)
572                    .map_err(|err| store_unavailable("get_session", err))?
573                    .ok_or_else(|| MailError::UnknownSession { session: session.clone() })?;
574                if &session_record.account != participant {
575                    return Err(MailError::SessionAccountMismatch {
576                        session: session.clone(),
577                        expected: session_record.account,
578                        presented: participant.clone(),
579                    });
580                }
581                let record = self.require_participant(participant)?;
582                Ok((participant.clone(), record))
583            }
584            Address::Room { room } => Err(MailError::Malformed {
585                field: "address".to_string(),
586                reason: format!("a room (\"{room}\") cannot act as a participant identity"),
587            }),
588        }
589    }
590
591    fn require_read_permission(&self, record: &ParticipantRecord) -> Result<(), MailError> {
592        if record.may_read || record.operator {
593            Ok(())
594        } else {
595            Err(MailError::PermissionDenied { need: "mail:read".to_string() })
596        }
597    }
598
599    /// Whether `reader` may read `message`: always true for an operator
600    /// (`record.operator`, "an operator may read any *named* address");
601    /// otherwise depends on `message.to`. Mail to an account
602    /// ([`Address::Direct`]) is readable by that account or by any of its
603    /// sessions -- a session inherits its account's mail, per
604    /// `mailbox-service-extraction-and-signed-session-identity-2026-09-16.md`
605    /// §5e. Mail to one specific session ([`Address::Session`]) is readable
606    /// only by that exact session, never by its account or a sibling
607    /// session. Mail to a room is readable by any current member of it,
608    /// account-wide. Used only by [`Self::ack`] and [`Self::message_get`],
609    /// which each already name one specific message -- unlike
610    /// [`Self::inbox`]/[`Self::inbox_of`], which never let an operator bit
611    /// widen a bulk listing.
612    fn is_readable(
613        &self,
614        message: &Message,
615        reader: &Address,
616        account: &ParticipantId,
617        record: &ParticipantRecord,
618    ) -> Result<bool, MailError> {
619        if record.operator {
620            return Ok(true);
621        }
622        match &message.to {
623            Address::Direct { participant } => Ok(participant == account),
624            Address::Session { .. } => Ok(reader == &message.to),
625            Address::Room { room } => {
626                let room_record = self.store.get_room(room).map_err(|err| store_unavailable("get_room", err))?;
627                Ok(room_record.is_some_and(|room_record| room_record.members.contains(account)))
628            }
629        }
630    }
631
632    /// `identity`'s own readable messages, no older than `since_unix_ms`:
633    /// mail addressed to `identity` exactly, plus -- when `identity` is a
634    /// session -- its account's direct mail too, plus mail to rooms
635    /// `account` currently belongs to. Never widened by an operator bit --
636    /// that authorization question is answered by [`Self::inbox_of`]'s
637    /// caller/target check before this runs, not by this function reading
638    /// more than `identity`'s own mail.
639    fn own_messages(&self, identity: &Address, account: &ParticipantId, since_unix_ms: u64) -> Result<Vec<Message>, MailError> {
640        let mut messages = self
641            .store
642            .messages_to_since(identity, since_unix_ms)
643            .map_err(|err| store_unavailable("messages_to_since", err))?;
644        if matches!(identity, Address::Session { .. }) {
645            let account_address = Address::Direct { participant: account.clone() };
646            let account_messages = self
647                .store
648                .messages_to_since(&account_address, since_unix_ms)
649                .map_err(|err| store_unavailable("messages_to_since", err))?;
650            messages.extend(account_messages);
651        }
652        let rooms = self.store.rooms_containing(account).map_err(|err| store_unavailable("rooms_containing", err))?;
653        for room in rooms {
654            let room_messages = self
655                .store
656                .room_messages_since(&room, since_unix_ms)
657                .map_err(|err| store_unavailable("room_messages_since", err))?;
658            messages.extend(room_messages);
659        }
660        Ok(messages)
661    }
662
663    fn build_inbox(
664        &self,
665        identity: &Address,
666        account: &ParticipantId,
667        since_unix_ms: u64,
668        limit: u16,
669    ) -> Result<InboxPage, MailError> {
670        let mut messages = self.own_messages(identity, account, since_unix_ms)?;
671        messages.sort_by(|a, b| a.created_at_unix_ms.cmp(&b.created_at_unix_ms).then_with(|| a.message_id.cmp(&b.message_id)));
672        messages.truncate(usize::from(limit));
673        let unread = self.count_unread(identity, account)?;
674        Ok(InboxPage { messages, unread })
675    }
676
677    /// Counts what `identity` has not yet acknowledged.
678    ///
679    /// `identity`'s **own** messages never count. They reach its inbox --
680    /// a room is a shared log and its author belongs in it -- but an author
681    /// has by definition read what it wrote, and counting it would invite
682    /// exactly the loop this mailbox exists to avoid: an agent polls, sees
683    /// something unread, and answers itself. This is address-exact: a
684    /// message a *sibling* session sent still counts as unread for
685    /// `identity`, the same way Matrix tracks read state per device rather
686    /// than per account.
687    fn count_unread(&self, identity: &Address, account: &ParticipantId) -> Result<u32, MailError> {
688        let messages = self.own_messages(identity, account, 0)?;
689        let mut unread = 0u32;
690        for message in messages {
691            if &message.from == identity {
692                continue;
693            }
694            let ack = self
695                .store
696                .get_ack(&message.message_id, identity)
697                .map_err(|err| store_unavailable("get_ack", err))?;
698            if ack.is_none() {
699                unread += 1;
700            }
701        }
702        Ok(unread)
703    }
704}
705
706/// Maps a [`StoreError`] into [`MailError::StoreUnavailable`], naming only
707/// `operation`. The [`StoreError`] itself -- the actual filesystem message,
708/// lock state, or corruption detail -- is logged here via `tracing::error!`
709/// for the operator and goes no further: nothing about *why* the store
710/// failed crosses into a caller-visible refusal.
711fn store_unavailable(operation: &'static str, err: StoreError) -> MailError {
712    tracing::error!(operation, error = %err, "mail store operation failed");
713    MailError::StoreUnavailable { operation: operation.to_string() }
714}
715
716/// Bound on a registered listener URL's length. Reuses the same order of
717/// magnitude `mail4agent_api::REF_LOCATOR_MAX_BYTES` picks for a comparable
718/// free-text pointer field, rather than inventing a third bound for one more
719/// plain string this crate happens to store.
720const LISTENER_URL_MAX_BYTES: usize = 512;
721
722/// Structural validation for `MailboxEngine::set_listener`'s `url`:
723/// bounded, no control characters, and -- the rule that actually matters
724/// here -- **loopback only**. This mailbox is a local service; a listener
725/// URL pointing off the machine would turn every future message to that
726/// account into an outbound call to somewhere the operator may not have
727/// meant, and there is no reason to allow that yet (`mail4agent/CLAUDE.md`
728/// task brief, "Only `http://127.0.0.1:*` and `http://localhost:*` URLs are
729/// accepted, refused by name otherwise").
730///
731/// Deliberately hand-parsed rather than pulled through a URL-parsing crate
732/// (this crate's own dependency list stays short and I/O-free) but not a
733/// naive prefix check either: `http://127.0.0.1.evil.example/` and
734/// `http://user:pass@evil.example` both look like they start with an
735/// accepted prefix under a plain `starts_with`, and neither is loopback.
736/// The authority component is isolated first (everything up to the first
737/// `/`, `?`, or `#`, exactly where a URL's authority ends), a bare `@`
738/// inside it is refused outright (a loopback listener never needs
739/// userinfo), and only then is the part before an optional `:port` compared
740/// against `127.0.0.1` / `localhost` for an exact match.
741fn validate_listener_url(url: &str) -> Result<(), MailError> {
742    let malformed = |reason: &str| MailError::Malformed { field: "url".to_string(), reason: reason.to_string() };
743
744    if url.len() > LISTENER_URL_MAX_BYTES {
745        return Err(MailError::TooLarge {
746            field: "url".to_string(),
747            limit: LISTENER_URL_MAX_BYTES,
748            actual: url.len(),
749        });
750    }
751    if url.chars().any(char::is_control) {
752        return Err(malformed("must not contain control characters"));
753    }
754
755    const LOOPBACK_REFUSAL: &str = "must be an http://127.0.0.1:* or http://localhost:* URL -- this mailbox is \
756         a local service and never turns a message into an outbound call anywhere else";
757
758    let Some(after_scheme) = url.strip_prefix("http://") else {
759        return Err(malformed(LOOPBACK_REFUSAL));
760    };
761    let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
762    if authority.contains('@') {
763        return Err(malformed("must not carry userinfo (\"user:pass@\") in a loopback listener URL"));
764    }
765    let host = authority.split(':').next().unwrap_or("");
766    if !host.eq_ignore_ascii_case("127.0.0.1") && !host.eq_ignore_ascii_case("localhost") {
767        return Err(malformed(LOOPBACK_REFUSAL));
768    }
769    Ok(())
770}
771
772fn generate_secret() -> String {
773    let mut bytes = [0u8; SECRET_HEX_LEN / 2];
774    getrandom::getrandom(&mut bytes).expect("OS random source unavailable: cannot mint a participant secret without it");
775    hex::encode(bytes)
776}
777
778fn sha256_digest(data: &[u8]) -> SecretDigest {
779    let mut hasher = Sha256::new();
780    hasher.update(data);
781    let mut digest = [0u8; 32];
782    digest.copy_from_slice(&hasher.finalize());
783    digest
784}
785
786/// Derives a [`MessageId`] from a send's content and, when present, its
787/// idempotency key -- the "derivation" half of "derivation plus an explicit
788/// idempotency key" that replaces the operation ledger the ported mailbox
789/// shared with task mutations
790/// (`mailbox-service-extraction-and-signed-session-identity-2026-09-16.md`).
791///
792/// When [`SendRequest::idempotency_key`] is `Some`, the hash input is fully
793/// determined by `(sender, request, now_unix_ms, key)`. `sender` is mixed in
794/// through its `Display` form, which is injective across [`Address`]'s three
795/// shapes (`claude`, `claude/s-...`, `#room-1` never collide -- see
796/// [`Address`]'s own `Display`/`FromStr` doc comment), so two different
797/// sending addresses never derive the same id by coincidence. When the key
798/// is `None`, this mixes in 16 fresh random bytes: without them, two
799/// deliberately identical sends (no key -- by design a second message, see
800/// [`SendRequest::idempotency_key`]) would derive the same id and collide.
801/// This is the one place derivation is not pure content-hashing, and it is
802/// harmless to correctness either way: whether a send is stored is decided
803/// by [`MailStore::insert_message`]'s own idempotency check, never by
804/// whether two derived ids happen to match.
805fn derive_message_id(sender: &Address, request: &SendRequest, now_unix_ms: u64) -> MessageId {
806    let mut hasher = Sha256::new();
807    hasher.update(sender.to_string().as_bytes());
808    hasher.update([0u8]);
809    match &request.to {
810        Address::Direct { participant } => {
811            hasher.update(b"direct:");
812            hasher.update(participant.as_str().as_bytes());
813        }
814        Address::Session { participant, session } => {
815            hasher.update(b"session:");
816            hasher.update(participant.as_str().as_bytes());
817            hasher.update([0u8]);
818            hasher.update(session.as_str().as_bytes());
819        }
820        Address::Room { room } => {
821            hasher.update(b"room:");
822            hasher.update(room.as_str().as_bytes());
823        }
824    }
825    hasher.update([0u8]);
826    hasher.update(request.subject.as_bytes());
827    hasher.update([0u8]);
828    hasher.update(request.body.as_bytes());
829    hasher.update([0u8]);
830    if let Some(reply_to) = &request.reply_to {
831        hasher.update(reply_to.as_str().as_bytes());
832    }
833    hasher.update([0u8]);
834    if let Some(correlation) = &request.correlation {
835        hasher.update(correlation.as_bytes());
836    }
837    hasher.update([0u8]);
838    for reference in &request.refs {
839        hasher.update(reference.kind.as_bytes());
840        hasher.update([0u8]);
841        hasher.update(reference.locator.as_bytes());
842        hasher.update([0u8]);
843        if let Some(digest) = &reference.digest {
844            hasher.update(digest.as_bytes());
845        }
846        hasher.update([0u8]);
847    }
848    hasher.update(now_unix_ms.to_le_bytes());
849    hasher.update([0u8]);
850    match &request.idempotency_key {
851        Some(key) => hasher.update(key.as_bytes()),
852        None => {
853            let mut nonce = [0u8; 16];
854            getrandom::getrandom(&mut nonce).expect("OS random source unavailable: cannot mint a message id without it");
855            hasher.update(nonce);
856        }
857    }
858    let digest = hasher.finalize();
859    let hex_digest = hex::encode(digest);
860    let body = &hex_digest[..MESSAGE_ID_HEX_LEN];
861    MessageId::new(format!("{MESSAGE_ID_PREFIX}{body}")).expect("derived message id always matches MessageId's own shape")
862}