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}