offline_protocol/protocol/send.rs
1//! Send pipeline, outbox management, and delivery tracking.
2
3use super::custody::CUSTODY_META_KEY;
4use super::{
5 base64_encode, internal_prefixes, lifetime_expired, lock_shared_state,
6 ConnectionAcceptedPayload, ConnectionRequestPayload, KeyPackagePayload, MediaSendOptions,
7 MediaTransferDescriptor, OfflineProtocol, OutboundMediaTransfer, OutboundSendPreparation,
8 OutboxEntry, OutboxReseal, PendingConnectionRequest, PendingMessage, PendingProvenance,
9 PresencePayload, ProtocolState, ReadReceiptPayload, RichPayloadV1, RichSendExtras,
10 SendMessageOptions, TypingIndicatorPayload, WelcomeDeliveryState, MAX_INITIAL_MESSAGE_BYTES,
11 MAX_KEY_PACKAGE_SENT_TO, MAX_MESSAGE_CONTENT_BYTES, MAX_PENDING_CONNECTION_REQUESTS,
12 MAX_PENDING_EXPIRIES_PER_PASS, MAX_PENDING_MESSAGES_GLOBAL, MAX_PENDING_MESSAGES_PER_PEER,
13 MAX_PENDING_MESSAGE_BYTES_GLOBAL, MAX_PENDING_MESSAGE_BYTES_PER_PEER, MAX_READ_RECEIPT_IDS,
14 MAX_RICH_EXTRAS_BYTES, MLS_ENVELOPE_COMPACT_V1, PENDING_CONNECTION_REQUEST_TTL,
15 RICH_PAYLOAD_V1, SEND_FAIL_REASON_RECIPIENT_UNREACHABLE, SEND_FAIL_REASON_RELAY_PUSHED,
16 SEND_FAIL_REASON_RELAY_PUSHED_STORED, SEND_FAIL_REASON_RELAY_STORED,
17 SEND_FAIL_REASON_TRANSPORT, WELCOME_NO_CARRIER_RETRY_SECS, WELCOME_UNREACHABLE_RETRY_CAP_SECS,
18};
19use super::{classify_transport_send_error, send_failure_token};
20use crate::constants::{
21 ACK_FOR_KEY, ACK_HOP_COUNT_KEY, ACK_TRANSPORT_KEY, MAX_FORWARD_COUNT, MAX_OUTBOX_ENTRIES,
22};
23use crate::events::{DecryptionFailureCode, Event, PresenceStatus};
24use crate::file_transfer::{FileChunk, OutboundTransferState};
25use crate::media_envelope::{encode_media_envelope, MediaChunkPlaintext, MediaRichExtras};
26use crate::mls_observability::{DecryptionFailureKind, MlsErrorCategory, MlsOperationContext};
27use crate::protocol::reachability::{Claim, FactSource};
28use crate::transport_manager::TransportManager;
29use crate::{Error, Result};
30use chrono::{DateTime, Utc};
31use offline_protocol_core::{
32 AppId, ContentType, ForwardInfo, MediaMetadata, Message, MessageId, MessagePriority, UserId,
33 TTL,
34};
35use offline_protocol_mls::{EncryptedMessage, MlsManager};
36use offline_protocol_transport::TransportType;
37use std::collections::HashSet;
38use std::sync::{Arc, RwLock};
39use std::time::{Duration as StdDuration, Instant};
40use tracing::{debug, error, info, warn};
41
42/// Most delivery acknowledgements held for want of a route (see
43/// `route_ack`). ~200 bytes each, so a ceiling of ~100 KB.
44pub(crate) const MAX_UNROUTED_ACKS: usize = 500;
45
46/// How long a held acknowledgement stays worth sending. Short on purpose:
47/// it bridges a carrier coming up moments later, and a sender still waiting
48/// past it re-sends, which the duplicate re-ACK path answers.
49pub(crate) const UNROUTED_ACK_TTL: StdDuration = StdDuration::from_secs(600);
50
51impl OfflineProtocol {
52 // ========================================================================
53 // CORE SEND
54 // ========================================================================
55
56 /// Sends a message.
57 ///
58 /// # Arguments
59 ///
60 /// * `recipient` - Recipient's user ID
61 /// * `content` - Message content
62 /// * `priority` - Message priority (optional, defaults to Medium)
63 /// * `reply_to_msg` - ID of the message this is replying to (optional)
64 ///
65 /// # Returns
66 ///
67 /// Returns the message ID if successful.
68 ///
69 /// # Auto-Encryption
70 ///
71 /// When encryption is enabled and MLS is initialized, messages are automatically
72 /// encrypted before sending. If no session exists with the recipient but we have
73 /// their key package, a session is created automatically. If no key package is
74 /// available and `store_pending` is enabled, the message is queued until a key
75 /// package is received.
76 pub fn send_message(
77 &mut self,
78 recipient: impl Into<String>,
79 content: impl Into<String>,
80 priority: Option<MessagePriority>,
81 reply_to_msg: Option<impl Into<String>>,
82 ) -> Result<MessageId> {
83 self.send_message_with(
84 recipient,
85 content,
86 SendMessageOptions {
87 priority,
88 reply_to_msg: reply_to_msg.map(Into::into),
89 ..Default::default()
90 },
91 )
92 }
93
94 /// Sends a message with rich options: priority and reply threading (as
95 /// on [`Self::send_message`]), plus quoted-reply context, rich media
96 /// metadata, and forward attribution.
97 ///
98 /// The rich fields only ever travel inside the MLS-sealed `__RICH_V1__`
99 /// body, and only toward recipients whose key package advertised
100 /// `rich_versions` support (gated by
101 /// `EncryptionConfig::rich_payload_enabled`). Toward anyone else they
102 /// are silently dropped — never sent cleartext — so the message degrades
103 /// to plain text with `reply_to_msg` threading intact.
104 ///
105 /// Returns `InvalidArgument` for `ContentType::FileChunk` (an internal
106 /// transport content type — the receiver would swallow the message into
107 /// its file-transfer manager) and for rich extras whose serialized size
108 /// exceeds 32 KiB (oversized bodies inflate the MLS plaintext into heavy
109 /// transport fragmentation).
110 pub fn send_message_with(
111 &mut self,
112 recipient: impl Into<String>,
113 content: impl Into<String>,
114 options: SendMessageOptions,
115 ) -> Result<MessageId> {
116 // Check if protocol is running
117 {
118 let state = lock_shared_state(&self.shared_state)?;
119 if state.state != ProtocolState::Running {
120 return Err(Error::NotStarted);
121 }
122 }
123
124 let recipient_str: String = recipient.into();
125 let content_str: String = content.into();
126 let priority = options.priority.unwrap_or(MessagePriority::Medium);
127 Self::validate_outbound_recipient(&recipient_str)?;
128 // Parsed first, before anything can queue the send or tick the
129 // Lamport clock: a bad id must leave no durable trace.
130 let app_id = Self::parse_send_app_id(options.app_id.as_deref())?;
131
132 // Prevent sending messages to blocked users. Blocking is bidirectional:
133 // we neither receive from nor send to a blocked peer.
134 if self.is_user_blocked(&recipient_str) {
135 return Err(Error::UserBlocked(recipient_str));
136 }
137
138 Self::check_content_size(&content_str)?;
139
140 // Reject content that starts with an internal control prefix to prevent
141 // injection of protocol-level messages through the public API.
142 if Self::is_internal_prefix(&content_str) {
143 return Err(Error::InvalidArgument(
144 "Message content must not start with a reserved internal prefix".to_string(),
145 ));
146 }
147
148 // FileChunk is an internal transport content type: the receiver
149 // routes a message stamped with it into handle_incoming_file_chunk
150 // (after ACKing delivery) where a non-chunk fails to parse and is
151 // dropped without surfacing. Reject rather than silently lose it.
152 if options.content_type == Some(ContentType::FileChunk) {
153 return Err(Error::InvalidArgument(
154 "FileChunk is an internal content type and cannot be sent directly".to_string(),
155 ));
156 }
157
158 // Parse reply_to_msg if provided
159 let reply_to_msg_id = options
160 .reply_to_msg
161 .as_deref()
162 .map(MessageId::from_str)
163 .transpose()
164 .map_err(|e| Error::InvalidArgument(format!("Invalid reply_to_msg: {}", e)))?;
165
166 let rich = RichSendExtras {
167 reply_context: options.reply_context,
168 media_metadata: options.media_metadata,
169 forward_info: options.forward_info,
170 };
171
172 // Bound the extras here at the boundary — not at seal time — so the
173 // pending queue only ever holds extras that are known to seal: the
174 // flush path re-seals against current capability, and a seal-time
175 // failure there would re-queue the message forever. The cap keeps an
176 // oversized quote or thumbnail from inflating the MLS plaintext into
177 // heavy transport fragmentation.
178 rich.check_size()?;
179
180 let final_content = match self.prepare_outbound_content(
181 &recipient_str,
182 &content_str,
183 priority,
184 reply_to_msg_id.clone(),
185 None,
186 options.content_type.unwrap_or_default(),
187 None,
188 Some(&rich),
189 app_id.as_ref(),
190 None,
191 "send_message_session_pending",
192 )? {
193 OutboundSendPreparation::Ready(content) => content,
194 OutboundSendPreparation::Queued(message_id) => return Ok(message_id),
195 };
196
197 // Create message with potentially encrypted content. Rich extras are
198 // deliberately NOT copied onto the outer message — they are either
199 // inside the sealed body by now or dropped; only the coarse
200 // content_type rendering hint rides outer.
201 let reseal_reply_to = reply_to_msg_id.clone();
202 let mut message = self.create_message_for_app(
203 &recipient_str,
204 final_content,
205 Some(priority),
206 reply_to_msg_id,
207 app_id,
208 )?;
209 if let Some(content_type) = options.content_type {
210 message.content_type = content_type;
211 }
212
213 // Tier 2: if the send was encrypted, keep the plaintext provenance so
214 // each resend re-seals against the peer's current session instead of
215 // replaying dead-epoch ciphertext (see `OutboxReseal`).
216 if message.content.starts_with(internal_prefixes::ENCRYPTED) {
217 self.stage_outbox_reseal(
218 &message.id,
219 OutboxReseal {
220 content: content_str,
221 priority,
222 reply_to_msg: reseal_reply_to,
223 forwarded_from: None,
224 content_type: message.content_type,
225 media_metadata: None,
226 rich: Some(rich).filter(|r| r.is_any()),
227 },
228 );
229 }
230
231 match options.via_transport {
232 Some(transport) => self.dispatch_prepared_message_via(message, transport),
233 None => self.dispatch_prepared_message(message),
234 }
235 }
236
237 /// Forwards a message to a new recipient with attribution to the original sender.
238 ///
239 /// Creates a new message with the original content and attaches `ForwardInfo`
240 /// tracking the original sender, message ID, timestamp, and forward count.
241 /// If the message was already forwarded, the original attribution is preserved
242 /// and `forward_count` is incremented.
243 ///
244 /// Toward a recipient that advertised the sealed rich payload, the
245 /// attribution and the original `media_metadata` — including any
246 /// cloud-media `encryption_key`/`iv` secrets — travel inside the MLS-sealed
247 /// `__RICH_V1__` body, so forwarded cloud media stays openable. Toward
248 /// anyone else they ride the legacy cleartext outer fields, from which
249 /// the transport chokepoint strips the secrets — attribution and display
250 /// hints survive, but the media key cannot be delivered.
251 ///
252 /// # Arguments
253 ///
254 /// * `original_message` - The message to forward
255 /// * `new_recipient` - Recipient's user ID
256 /// * `priority` - Message priority (optional, defaults to Medium)
257 ///
258 /// # Returns
259 ///
260 /// Returns the new message ID if successful.
261 pub fn forward_message(
262 &mut self,
263 original_message: &Message,
264 new_recipient: impl Into<String>,
265 priority: Option<MessagePriority>,
266 ) -> Result<MessageId> {
267 {
268 let state = lock_shared_state(&self.shared_state)?;
269 if state.state != ProtocolState::Running {
270 return Err(Error::NotStarted);
271 }
272 }
273
274 let recipient_str: String = new_recipient.into();
275 let priority = priority.unwrap_or(MessagePriority::Medium);
276 Self::validate_outbound_recipient(&recipient_str)?;
277
278 if self.is_user_blocked(&recipient_str) {
279 return Err(Error::UserBlocked(recipient_str));
280 }
281
282 // Reject content that starts with an internal control prefix to prevent
283 // injection of protocol-level messages through the forwarding API.
284 if Self::is_internal_prefix(&original_message.content) {
285 return Err(Error::InvalidArgument(
286 "Cannot forward a message with reserved internal prefix content".to_string(),
287 ));
288 }
289
290 // FileChunk is an internal transport content type (see
291 // `send_message_with`): a forwarded message stamped with it would be
292 // ACKed and then dropped by the receiver's file-transfer manager.
293 if original_message.content_type == ContentType::FileChunk {
294 return Err(Error::InvalidArgument(
295 "FileChunk is an internal content type and cannot be forwarded".to_string(),
296 ));
297 }
298
299 // Build ForwardInfo: preserve original attribution, increment count
300 let forward_info = ForwardInfo::from_message(original_message);
301
302 if forward_info.forward_count > MAX_FORWARD_COUNT {
303 return Err(Error::InvalidArgument(format!(
304 "Forward count {} exceeds maximum of {}",
305 forward_info.forward_count, MAX_FORWARD_COUNT,
306 )));
307 }
308
309 // Sealed-path extras: toward a rich-capable recipient the attribution
310 // and the original media metadata (including cloud-media key/iv
311 // secrets, which only the sealed body may carry — the wire chokepoint
312 // strips them from the cleartext outer field) travel inside the
313 // `__RICH_V1__` body. The outer copies set below remain the fallback
314 // for non-capable recipients; the receiver's sealed-body restore is
315 // authoritative and overwrites them when the seal happened.
316 let rich = RichSendExtras {
317 reply_context: None,
318 media_metadata: original_message.media_metadata.clone(),
319 forward_info: Some(forward_info.clone()),
320 };
321 // Same boundary caps as `send_message_with` — enforced here, not at
322 // seal time, so a forward queued behind session establishment is
323 // always known to seal at flush and always fits the queue's budget.
324 Self::check_content_size(&original_message.content)?;
325 rich.check_size()?;
326
327 // Prepare content (may encrypt). ForwardInfo is threaded through so
328 // it survives the pending-message queue if the session isn't ready.
329 let final_content = match self.prepare_outbound_content(
330 &recipient_str,
331 &original_message.content,
332 priority,
333 None,
334 Some(forward_info.clone()),
335 original_message.content_type,
336 original_message.media_metadata.clone(),
337 Some(&rich),
338 None,
339 None,
340 "forward_message_session_pending",
341 )? {
342 OutboundSendPreparation::Ready(content) => content,
343 OutboundSendPreparation::Queued(message_id) => return Ok(message_id),
344 };
345
346 // Create and dispatch the forwarded message
347 let mut message =
348 self.create_message(&recipient_str, final_content, Some(priority), None)?;
349 message.forwarded_from = Some(forward_info);
350 message.content_type = original_message.content_type;
351 message.media_metadata = original_message.media_metadata.clone();
352
353 // Tier 2 provenance (see `send_message_with`): re-seal forwarded encrypted
354 // DMs against the peer's current session on resend.
355 if message.content.starts_with(internal_prefixes::ENCRYPTED) {
356 self.stage_outbox_reseal(
357 &message.id,
358 OutboxReseal {
359 content: original_message.content.clone(),
360 priority,
361 reply_to_msg: None,
362 forwarded_from: message.forwarded_from.clone(),
363 content_type: message.content_type,
364 media_metadata: message.media_metadata.clone(),
365 rich: Some(rich).filter(|r| r.is_any()),
366 },
367 );
368 }
369
370 self.dispatch_prepared_message(message)
371 }
372
373 /// Shared send path: dedup, transport send, success/failure handling, event emission.
374 ///
375 /// Used by `forward_message`, `flush_pending_messages` (for forwarded messages),
376 /// and any path that has a fully-constructed `Message` ready to send.
377 fn dispatch_prepared_message(&mut self, message: Message) -> Result<MessageId> {
378 let message_id = message.id.clone();
379
380 if self.deduplicator.is_duplicate(&message_id) {
381 return Err(crate::Error::Other("Duplicate message".to_string()));
382 }
383
384 self.deduplicator.mark_seen_local(message_id.clone());
385
386 let previous_transport = self.transport_manager.current_transport();
387
388 // A recipient this device holds a live mesh link to is reached over
389 // that link, even with infrastructure up. Without this the online
390 // sender standing next to an offline recipient routes through the
391 // relay, which answers `recipient_unreachable`, and delivery waits for
392 // the park machinery to offer the frame to the very neighbour that was
393 // addressable all along. DORS cannot make this call: it scores
394 // carriers, and the fact that decides it is about the recipient.
395 //
396 // The link is ground truth, not a stored claim, so there is nothing to
397 // go stale. A refusal here is not terminal: the frame falls through to
398 // selection exactly as if this had never been tried, which keeps a
399 // marginal radio link from stranding a message another carrier could
400 // take.
401 let direct_mesh = self
402 .transport_manager
403 .mesh_transport_for(message.recipient.as_str());
404 let mut sent_via_mesh = None;
405 if let Some(transport) = direct_mesh {
406 match self
407 .transport_manager
408 .send_via_transport(&message, transport)
409 {
410 Ok(()) => sent_via_mesh = Some(transport),
411 Err(err) => {
412 self.transport_manager.record_retry_failure(transport);
413 debug!(
414 message_id = %message.id,
415 transport = ?transport,
416 error = %err,
417 "Direct mesh link refused the frame; falling back to transport selection"
418 );
419 }
420 }
421 }
422
423 let (send_result, current_transport) = match sent_via_mesh {
424 Some(transport) => (Ok(()), Some(transport)),
425 None => {
426 let result = self.transport_manager.send(&message);
427 (result, self.transport_manager.current_transport())
428 }
429 };
430
431 match send_result {
432 Ok(()) => {
433 self.handle_send_success(&message, current_transport)?;
434 self.emit_transport_switch_event(previous_transport, current_transport)?;
435 self.emit_message_sent_event(&message)?;
436 Ok(message_id)
437 }
438 Err(err) => {
439 let next_retry_at =
440 self.handle_send_failure(&message, current_transport.or(previous_transport))?;
441 warn!(
442 message_id = %message.id,
443 error = %err,
444 "Send failed, message deferred"
445 );
446 self.emit_event(Event::message_deferred(
447 message.id.clone(),
448 message.recipient.as_str().to_string(),
449 send_failure_token(&err),
450 0,
451 next_retry_at.map(|at| at.timestamp_millis()),
452 ));
453 Ok(message_id)
454 }
455 }
456 }
457
458 /// [`Self::dispatch_prepared_message`] variant that sends via a specific
459 /// transport (bypassing DORS) — the shared tail for
460 /// `send_message_via_transport` and rich sends carrying `via_transport`.
461 /// Unlike `TransportManager::send`, `send_via_transport` does not record
462 /// retry failures internally, so the failure arm records one explicitly.
463 fn dispatch_prepared_message_via(
464 &mut self,
465 message: Message,
466 transport: TransportType,
467 ) -> Result<MessageId> {
468 let message_id = message.id.clone();
469
470 if self.deduplicator.is_duplicate(&message_id) {
471 return Err(crate::Error::Other("Duplicate message".to_string()));
472 }
473
474 self.deduplicator.mark_seen_local(message_id.clone());
475
476 let previous_transport = self.transport_manager.current_transport();
477 let send_result = self
478 .transport_manager
479 .send_via_transport(&message, transport);
480 let current_transport = Some(transport);
481
482 match send_result {
483 Ok(()) => {
484 self.handle_send_success(&message, current_transport)?;
485 self.emit_transport_switch_event(previous_transport, current_transport)?;
486 self.emit_message_sent_event(&message)?;
487 Ok(message_id)
488 }
489 Err(err) => {
490 let next_retry_at =
491 self.handle_send_failure(&message, current_transport.or(previous_transport))?;
492 // send_via_transport does not record retry failures internally
493 // (unlike TransportManager::send), so record explicitly here.
494 self.transport_manager.record_retry_failure(transport);
495 warn!(
496 message_id = %message.id,
497 transport = ?transport,
498 error = %err,
499 "Send via forced transport failed, message deferred"
500 );
501 // The forced transport is in the `warn!` above, not on the
502 // event: it is a routing detail, and the token names the
503 // failure class either way.
504 self.emit_event(Event::message_deferred(
505 message.id.clone(),
506 message.recipient.as_str().to_string(),
507 send_failure_token(&err),
508 0,
509 next_retry_at.map(|at| at.timestamp_millis()),
510 ));
511 Ok(message_id)
512 }
513 }
514 }
515
516 /// Sends an internal protocol message (connection requests, etc.) via DORS.
517 ///
518 /// Handles the full send orchestration: state check, deduplication, transport send,
519 /// success/failure handling, and transport switch events. Does NOT emit a
520 /// `MessageSent` event — internal messages are not user-visible content.
521 pub(crate) fn send_internal_message(
522 &mut self,
523 recipient: &str,
524 content: String,
525 priority: MessagePriority,
526 ) -> Result<MessageId> {
527 {
528 let state = lock_shared_state(&self.shared_state)?;
529 if state.state != ProtocolState::Running {
530 return Err(Error::NotStarted);
531 }
532 }
533
534 let mut message = self.create_message(recipient, content, Some(priority), None)?;
535 self.sign_control_message(&mut message)?;
536 let message_id = message.id.clone();
537
538 if self.deduplicator.is_duplicate(&message_id) {
539 return Err(crate::Error::Other("Duplicate message".to_string()));
540 }
541
542 self.deduplicator.mark_seen_local(message_id.clone());
543
544 let previous_transport = self.transport_manager.current_transport();
545 let send_result = self.transport_manager.send(&message);
546 let current_transport = self.transport_manager.current_transport();
547
548 match send_result {
549 Ok(()) => {
550 self.handle_send_success(&message, current_transport)?;
551 }
552 Err(err) => {
553 let _ =
554 self.handle_send_failure(&message, current_transport.or(previous_transport))?;
555 warn!(
556 message_id = %message.id,
557 recipient = %recipient,
558 error = %err,
559 "Internal message send failed, message deferred"
560 );
561 }
562 }
563
564 self.emit_transport_switch_event(previous_transport, current_transport)?;
565 Ok(message_id)
566 }
567
568 /// Sends a self-addressed relay *hint* frame — `__GRP_RELAY_REG__` or
569 /// `__GRP_RELAY_BCAST__` — pinned to the Internet transport, without the
570 /// ACK ladder.
571 ///
572 /// A hint frame is not traffic. The platform bridge recognizes it via
573 /// [`Self::internet_control_op`] and **replaces** it with a relay-native
574 /// frame (`CreateGroup` + member deltas, or `SendGroupMessage`), so the
575 /// frame itself never reaches a peer and no delivery ACK can ever come
576 /// back for it. Two properties follow, and both are load-bearing:
577 ///
578 /// 1. **`requires_ack = false`.** On the ordinary ladder an un-ACKable
579 /// frame is retransmitted `DEFAULT_MAX_RETRIES` times over ~800s, each
580 /// resend producing another full relay fan-out (under a fresh
581 /// relay-minted id, so receiver dedup misses it), and terminates in a
582 /// `MessageFailed` for an id the app was never told about plus a
583 /// `record_delivery_failure` that degrades DORS' score for the very
584 /// transport the frame requires. Opting out of the ladder is what makes
585 /// these frames honest one-shots; retry policy for them lives at the
586 /// application layer instead (`RelayRegisterPending` for registration,
587 /// the caller's per-member fallback for broadcast).
588 /// 2. **Pinned to [`TransportType::Internet`].** These frames are
589 /// self-addressed, and DORS demotes Internet below every mesh transport
590 /// (`INTERNET_FALLBACK_DEMOTION`), so ordinary routing hands them to
591 /// BLE/Wi-Fi Direct first. BLE fails closed (self is never a connected
592 /// peer), but Wi-Fi Direct and Reticulum enqueue unconditionally and
593 /// return `Ok` — swallowing the frame while reporting success, which on
594 /// the broadcast path means the group message is delivered to nobody.
595 ///
596 /// Errors propagate to the caller rather than routing through
597 /// [`Self::handle_send_failure`]: a hint frame has no outbox entry to
598 /// defer into, and its callers already have a better recovery than a
599 /// retry (fall back to per-member fan-out; leave the group unregistered
600 /// for the next sync tick). The failure is also deliberately *not*
601 /// recorded against Internet's DORS metrics — "the relay bridge isn't up"
602 /// is not evidence about the transport's delivery quality.
603 ///
604 /// No `TransportChanged` event is emitted, matching
605 /// [`Self::send_delivery_ack`] — the other fire-and-forget control frame
606 /// sent over a forced transport. A hint frame is not user traffic, and
607 /// announcing a switch for one would report a transport change the app's
608 /// messages did not take.
609 pub(crate) fn send_relay_hint_message(
610 &mut self,
611 content: String,
612 priority: MessagePriority,
613 ) -> Result<MessageId> {
614 {
615 let state = lock_shared_state(&self.shared_state)?;
616 if state.state != ProtocolState::Running {
617 return Err(Error::NotStarted);
618 }
619 }
620
621 let self_id = self.local_id.clone();
622 let mut message = self.create_message(&self_id, content, Some(priority), None)?;
623 // Set before signing for clarity; the canonical payload covers only
624 // sender/id/recipient/content, so ordering is not security-relevant.
625 message.requires_ack = false;
626 self.sign_control_message(&mut message)?;
627 let message_id = message.id.clone();
628
629 if self.deduplicator.is_duplicate(&message_id) {
630 return Err(crate::Error::Other("Duplicate message".to_string()));
631 }
632
633 // Mark seen so a bridge that passes the frame through verbatim (an
634 // adapter without a translator) cannot have the relay echo it back
635 // into our own receive path.
636 self.deduplicator.mark_seen_local(message_id.clone());
637
638 self.transport_manager
639 .send_via_transport(&message, TransportType::Internet)?;
640
641 Ok(message_id)
642 }
643
644 /// Sends one MLS-encrypted session-confirm marker to `peer_id`.
645 ///
646 /// Used by the Welcome-adopt path: after we adopt the peer's `session:` group
647 /// (and confirm our own side), the peer may be the both-create "owner" that
648 /// kept its own group and confirms ONLY on a group-aware decrypt from us. A
649 /// plaintext probe/ack does not count, and we may have no user traffic to
650 /// send — so without this the owner stays Pending forever and the 1:1
651 /// connection never completes. The marker is `SESSION_CONFIRM_ENCRYPTED`
652 /// wrapped in the normal `ENCRYPTED` envelope; the peer consumes it on decrypt
653 /// (never surfaced). Mirrors [`Self::send_internal_message`] but encrypts
654 /// (data-plane, so NOT signed) and never propagates errors — it is re-sent on
655 /// every received Welcome while still adopting, so a lost confirm is retried
656 /// in lockstep with the owner's Welcome retransmission.
657 pub(super) fn send_session_confirm_encrypted(&mut self, peer_id: &str) {
658 let encrypted = match self.encrypt_content_for_recipient(
659 peer_id,
660 internal_prefixes::SESSION_CONFIRM_ENCRYPTED,
661 MessagePriority::High,
662 ) {
663 Ok(content) => content,
664 Err(err) => {
665 debug!(
666 peer = %peer_id,
667 error = %err,
668 "Skipped proactive session-confirm (encryption not ready); welcome retry remains"
669 );
670 return;
671 }
672 };
673
674 let message =
675 match self.create_message(peer_id, encrypted, Some(MessagePriority::High), None) {
676 Ok(message) => message,
677 Err(err) => {
678 warn!(peer = %peer_id, error = %err, "Failed to build session-confirm message");
679 return;
680 }
681 };
682
683 if self.deduplicator.is_duplicate(&message.id) {
684 return;
685 }
686 self.deduplicator.mark_seen_local(message.id.clone());
687
688 let previous_transport = self.transport_manager.current_transport();
689 match self.transport_manager.send(&message) {
690 Ok(()) => {
691 let current_transport = self.transport_manager.current_transport();
692 let _ = self.handle_send_success(&message, current_transport);
693 info!(peer = %peer_id, "Sent proactive encrypted session-confirm to owner");
694 }
695 Err(err) => {
696 let current_transport = self.transport_manager.current_transport();
697 let _ =
698 self.handle_send_failure(&message, current_transport.or(previous_transport));
699 debug!(
700 peer = %peer_id,
701 error = %err,
702 "Proactive session-confirm deferred (will retry with welcome)"
703 );
704 }
705 }
706 }
707
708 /// Sends a message via a specific transport type (bypassing DORS).
709 pub fn send_message_via_transport(
710 &mut self,
711 recipient: impl Into<String>,
712 content: impl Into<String>,
713 priority: Option<MessagePriority>,
714 transport: TransportType,
715 reply_to_msg: Option<impl Into<String>>,
716 ) -> Result<MessageId> {
717 self.send_message_with(
718 recipient,
719 content,
720 SendMessageOptions {
721 priority,
722 reply_to_msg: reply_to_msg.map(Into::into),
723 via_transport: Some(transport),
724 ..Default::default()
725 },
726 )
727 }
728
729 /// Rejects a recipient token that is not a well-formed [`UserId`].
730 ///
731 /// Enforced at every outbound boundary so an app-owned placeholder (an
732 /// `unresolved:token`, an empty string) cannot become durable protocol
733 /// state — an outbox entry or pending-queue record that is retried, stored,
734 /// and restored forever against an address no transport can ever resolve.
735 pub(crate) fn validate_outbound_recipient(recipient: &str) -> Result<()> {
736 UserId::new(recipient)
737 .map(|_| ())
738 .map_err(|error| Error::InvalidArgument(format!("Invalid recipient user ID: {error}")))
739 }
740
741 /// Creates a new message stamped with the configured app id.
742 ///
743 /// Every engine-originated frame goes through here: control traffic,
744 /// key packages, Welcomes, data-sync frames and forwards. Those belong
745 /// to the engine, not to an application, so they always carry
746 /// [`crate::ProtocolConfig::app_id`]. Only an application's own send
747 /// can name another id, through [`Self::create_message_for_app`].
748 pub(super) fn create_message(
749 &mut self,
750 recipient: impl Into<String>,
751 content: impl Into<String>,
752 priority: Option<MessagePriority>,
753 reply_to_msg: Option<MessageId>,
754 ) -> Result<Message> {
755 self.create_message_for_app(recipient, content, priority, reply_to_msg, None)
756 }
757
758 /// Validates a per-send app id from the rich send surface.
759 ///
760 /// Same rules as the configured id and the wire decoder (`AppId::new`),
761 /// reported as `InvalidArgument` because it is a call argument, not
762 /// configuration.
763 pub(super) fn parse_send_app_id(app_id: Option<&str>) -> Result<Option<AppId>> {
764 app_id
765 .map(AppId::new)
766 .transpose()
767 .map_err(|e| Error::InvalidArgument(format!("Invalid app_id: {e}")))
768 }
769
770 /// Like [`Self::create_message`], stamping `app_id` when given and the
771 /// configured id otherwise.
772 pub(super) fn create_message_for_app(
773 &mut self,
774 recipient: impl Into<String>,
775 content: impl Into<String>,
776 priority: Option<MessagePriority>,
777 reply_to_msg: Option<MessageId>,
778 app_id: Option<AppId>,
779 ) -> Result<Message> {
780 let sender = UserId::new(&self.local_id)?;
781 let recipient = UserId::new(recipient)?;
782 let app_id = match app_id {
783 Some(app_id) => app_id,
784 None => AppId::new(&self.config.app_id)?,
785 };
786
787 let clock_value = self.lamport_clock.tick();
788 self.persist_lamport_clock();
789
790 let mut builder = Message::builder(sender, recipient, app_id)
791 .content(content)
792 .priority(priority.unwrap_or(MessagePriority::Medium))
793 .ttl(TTL::new(self.config.initial_ttl)?)
794 .lamport_clock(clock_value);
795
796 if let Some(reply_to) = reply_to_msg {
797 builder = builder.reply_to_msg(reply_to);
798 }
799
800 Ok(builder.build())
801 }
802
803 // ========================================================================
804 // ENCRYPTION
805 // ========================================================================
806
807 pub(super) fn encrypt_content_for_recipient(
808 &mut self,
809 recipient: &str,
810 content: &str,
811 _priority: MessagePriority,
812 ) -> Result<String> {
813 let encrypted = self.encrypt_bytes_for_recipient(recipient, content.as_bytes())?;
814 self.seal_encrypted_content(recipient, &encrypted)
815 }
816
817 /// Bytes-level core of [`Self::encrypt_content_for_recipient`]: initiates
818 /// session establishment when a key package is available and encrypts once
819 /// the session is confirmed. Text content and media chunk plaintexts share
820 /// this path.
821 pub(super) fn encrypt_bytes_for_recipient(
822 &mut self,
823 recipient: &str,
824 plaintext: &[u8],
825 ) -> Result<EncryptedMessage> {
826 // Clone the Arc to avoid borrow issues
827 let mls = self.mls_manager.clone().ok_or_else(|| {
828 self.emit_mls_session_missing(
829 Some(recipient),
830 None,
831 MlsOperationContext::SessionLookup,
832 MlsErrorCategory::NotInitialized,
833 );
834 Error::MlsNotInitialized
835 })?;
836
837 // Fast path: if the session is already confirmed (in-memory cache hit),
838 // skip the has_session() storage call — we know the session exists.
839 if self.confirmed_sessions.contains(recipient) {
840 return self.encrypt_bytes_confirmed_session(&mls, recipient, plaintext);
841 }
842
843 self.ensure_session_establishment(&mls, recipient)?;
844
845 // Only encrypt if session is confirmed (Welcome processed or successful decrypt).
846 // Confirmation truth comes from persisted session state.
847 if !self.is_session_confirmed(recipient)? {
848 debug!(recipient = %recipient, "Session exists but not confirmed, queuing message");
849 return Err(Error::SessionNotReady(self.establishment_state(recipient)?));
850 }
851
852 // Encrypt the message
853 let encrypted = {
854 let manager = mls
855 .read()
856 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
857 manager
858 .encrypt_for_user(recipient, plaintext)
859 .map_err(|_| Error::EncryptFailed("encryption operation failed".to_string()))?
860 };
861
862 self.emit_mls_encryption_used(recipient);
863 Ok(encrypted)
864 }
865
866 /// Ensures MLS session establishment with `recipient` has at least been
867 /// initiated: when no session exists in storage, imports a stored/pending
868 /// key package, creates the session, and sends the Welcome.
869 ///
870 /// Returns `Ok(())` when a session already exists (possibly unconfirmed —
871 /// callers must still gate on `is_session_confirmed`) or when no usable
872 /// key package remains (expired). Returns `Err(SessionNotReady)` when no
873 /// key package is available, or right after creating a session with
874 /// `store_pending` enabled (the session cannot be confirmed yet).
875 pub(super) fn ensure_session_establishment(
876 &mut self,
877 mls: &Arc<RwLock<MlsManager>>,
878 recipient: &str,
879 ) -> Result<()> {
880 // Check for existing session (requires storage I/O via load_group)
881 let has_session = {
882 let manager = mls
883 .read()
884 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
885 manager.has_session(recipient)?
886 };
887
888 if !has_session {
889 // Try loading key package from storage (e.g. after restart) then create session from memory
890 self.try_load_key_package_from_storage_into_memory(recipient);
891 // Try to create session from stored key package
892 // Clone first, only remove after all operations succeed to avoid losing the key package on failure
893 if let Some(received_pkg) = self.pending_key_packages.get(recipient).cloned() {
894 // Check if key package has expired (using local clock)
895 let now_ms = Utc::now().timestamp_millis() as u64;
896 if now_ms >= received_pkg.local_expires_at_ms {
897 warn!(recipient = %recipient, "Received key package has expired, discarding");
898 self.pending_key_packages.remove(recipient);
899 self.delete_peer_key_package_from_storage(recipient);
900 } else {
901 self.import_pending_key_package(
902 mls,
903 recipient,
904 &received_pkg.key_package_data,
905 )?;
906
907 // Create session and send welcome message
908 let welcome = {
909 let manager = mls
910 .read()
911 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
912 manager.create_session(recipient)?
913 };
914 // The handshake starts here, and this is the branch it
915 // usually starts on: with `auto_key_exchange` the peer's
916 // key package has normally arrived already, so the `else`
917 // below never runs and no `session_missing` is raised.
918 // Without this emit the latency percentile would see only
919 // the handshakes that had to wait for a key package.
920 self.emit_mls_session_establishing(
921 recipient,
922 welcome.group_id.as_str(),
923 MlsOperationContext::Welcome,
924 );
925 self.mark_encryption_capable(recipient);
926
927 // All operations succeeded, now safe to remove the key package
928 self.pending_key_packages.remove(recipient);
929 self.delete_peer_key_package_from_storage(recipient);
930
931 let group_id = welcome.group_id.as_str().to_string();
932 let is_session = group_id.starts_with("session:");
933
934 if let Err(err) =
935 self.ensure_session_state_entry(recipient, "session_created_local")
936 {
937 warn!(
938 recipient = %recipient,
939 error = %err,
940 "Failed to persist pending session state"
941 );
942 }
943
944 let welcome_sent = self.send_welcome_message(recipient, &welcome)?;
945
946 debug!(
947 recipient = %recipient,
948 group_id = %group_id,
949 welcome_sent = welcome_sent,
950 "Created MLS session and scheduled welcome lifecycle"
951 );
952
953 if welcome_sent {
954 debug!(recipient = %recipient, group_id = %group_id, is_session, "Welcome synchronously sent");
955 }
956
957 // Don't encrypt immediately after creating session.
958 // Queue message until session is confirmed (peer processes our Welcome
959 // and we successfully decrypt their first message, or we receive their Welcome).
960 // This avoids race conditions where both peers create sessions.
961 if self.config.encryption.store_pending {
962 return Err(Error::SessionNotReady(self.establishment_state(recipient)?));
963 }
964 }
965 } else {
966 // No key package available (memory nor storage)
967 self.emit_mls_session_missing(
968 Some(recipient),
969 None,
970 MlsOperationContext::SessionLookup,
971 MlsErrorCategory::SessionStateMissing,
972 );
973 return Err(Error::SessionNotReady(self.establishment_state(recipient)?));
974 }
975 }
976
977 Ok(())
978 }
979
980 /// Serializes an [`EncryptedMessage`] into the prefixed string form used
981 /// by the text path.
982 ///
983 /// Recipients that advertised [`MLS_ENVELOPE_COMPACT_V1`] in their key
984 /// package (with our own `compact_envelope_enabled` on) get the compact
985 /// envelope — `__MLS_ENC__` + base64 of [`EncryptedMessage::to_bytes`] —
986 /// roughly 2.7x smaller than the legacy JSON form, whose `ciphertext`
987 /// field renders as an integer array. Everyone else gets `__MLS_ENC__` +
988 /// JSON, the permanent floor. Receivers sniff the byte after the prefix
989 /// (`{` = JSON), so no per-message signaling is needed, and the choice is
990 /// per-recipient end-to-end state, valid however the message is later
991 /// routed or retried.
992 pub(super) fn seal_encrypted_content(
993 &self,
994 recipient: &str,
995 encrypted: &EncryptedMessage,
996 ) -> Result<String> {
997 if self.config.encryption.compact_envelope_enabled
998 && self.peer_compact_envelope.contains(recipient)
999 {
1000 return Ok(format!(
1001 "{}{}",
1002 internal_prefixes::ENCRYPTED,
1003 base64_encode(&encrypted.to_bytes())
1004 ));
1005 }
1006 let serialized =
1007 serde_json::to_string(encrypted).map_err(|e| Error::Serialization(e.to_string()))?;
1008 Ok(format!("{}{}", internal_prefixes::ENCRYPTED, serialized))
1009 }
1010
1011 /// The sync versions this device advertises.
1012 ///
1013 /// Empty unless the data layer is both compiled in and switched on. A
1014 /// capability advertised with nothing behind it is worse than silence:
1015 /// the peer starts sending frames and waiting for answers that never
1016 /// come, and nothing in that exchange looks like an error to either
1017 /// side.
1018 pub(crate) fn advertised_data_versions(&self) -> Vec<u8> {
1019 #[cfg(feature = "data")]
1020 {
1021 if self.config.data.enabled {
1022 return vec![
1023 super::types::DATA_SYNC_V1,
1024 super::types::DATA_GROUP_V1,
1025 super::types::DATA_MEDIA_V1,
1026 super::types::DATA_TOMBSTONE_V1,
1027 super::types::DATA_INTEREST_V1,
1028 super::types::DATA_GROUP_BLOB_V1,
1029 super::types::DATA_CUSTODY_V1,
1030 ];
1031 }
1032 }
1033 Vec::new()
1034 }
1035
1036 /// Whether document sync is live toward `recipient`: the layer is
1037 /// compiled in and enabled here, the peer is not blocked, and they
1038 /// advertised [`DATA_SYNC_V1`] in their key package.
1039 ///
1040 /// Consulted per send rather than cached at session establishment, like
1041 /// the two capability gates below it: a peer that downgrades stops
1042 /// receiving frames on their next key package, not at their next
1043 /// session.
1044 ///
1045 /// The block check is here rather than at the call sites because this is
1046 /// the only gate they share, and because replication is not reached
1047 /// through a send API: every public send surface checks blocking for
1048 /// itself, while these frames are triggered by a commit or a discovery.
1049 /// A blocked peer still holds a live session, so nothing further down
1050 /// would have refused the frame. Inbound needs no equivalent: the
1051 /// receive path drops a blocked sender before any dispatch.
1052 #[cfg_attr(not(feature = "data"), allow(dead_code))]
1053 pub(super) fn data_sync_active(&self, recipient: &str) -> bool {
1054 #[cfg(feature = "data")]
1055 {
1056 self.config.data.enabled
1057 && self.peer_data_sync.contains(recipient)
1058 && !self.is_user_blocked(recipient)
1059 }
1060 #[cfg(not(feature = "data"))]
1061 {
1062 let _ = recipient;
1063 false
1064 }
1065 }
1066
1067 /// Whether blobs may travel to `recipient`: 1:1 replication is live with
1068 /// them and they advertised [`DATA_MEDIA_V1`], so they understand the
1069 /// fetch frames and route a data-purposed media transfer into the data
1070 /// layer rather than to their user.
1071 ///
1072 /// Strictly narrower than [`Self::data_sync_active`] and checked in
1073 /// addition to it, never instead: every blob exchange is part of a
1074 /// replication that the first gate already had to allow, and a peer who
1075 /// stops replicating stops receiving blobs at the same moment.
1076 ///
1077 /// [`DATA_MEDIA_V1`]: crate::protocol::types::DATA_MEDIA_V1
1078 #[cfg_attr(not(feature = "data"), allow(dead_code))]
1079 pub(super) fn data_media_active(&self, recipient: &str) -> bool {
1080 #[cfg(feature = "data")]
1081 {
1082 self.data_sync_active(recipient) && self.peer_data_media.contains(recipient)
1083 }
1084 #[cfg(not(feature = "data"))]
1085 {
1086 let _ = recipient;
1087 false
1088 }
1089 }
1090
1091 /// Whether removals may be carried toward `recipient`: 1:1 replication
1092 /// is live with them and they advertised [`DATA_TOMBSTONE_V1`], so they
1093 /// read the `gone` field rather than ignoring it.
1094 ///
1095 /// Unlike the two gates above it, this one guards traffic rather than
1096 /// correctness. A peer without the entry ignores the field, keeps its
1097 /// copy and re-offers it; this device refuses the offer and any blob
1098 /// behind it against its own floor, so the removal holds here either
1099 /// way. Sending the field anyway would be harmless and useless, and
1100 /// leaving it out keeps that exchange as small as it can be.
1101 ///
1102 /// [`DATA_TOMBSTONE_V1`]: crate::protocol::types::DATA_TOMBSTONE_V1
1103 #[cfg_attr(not(feature = "data"), allow(dead_code))]
1104 pub(super) fn data_tombstones_active(&self, recipient: &str) -> bool {
1105 #[cfg(feature = "data")]
1106 {
1107 self.data_sync_active(recipient) && self.peer_data_tombstones.contains(recipient)
1108 }
1109 #[cfg(not(feature = "data"))]
1110 {
1111 let _ = recipient;
1112 false
1113 }
1114 }
1115
1116 /// Whether a narrowed interest may be declared toward `recipient`: 1:1
1117 /// replication is live with them and they advertised
1118 /// [`DATA_INTEREST_V1`], so they answer inside it rather than ignoring
1119 /// it.
1120 ///
1121 /// A traffic gate, like the one above it. A peer without the entry sends
1122 /// everything it holds and this device refuses what it did not ask for,
1123 /// so a narrowed space is narrow either way; what the entry buys is the
1124 /// bytes never leaving the peer.
1125 ///
1126 /// [`DATA_INTEREST_V1`]: crate::protocol::types::DATA_INTEREST_V1
1127 #[cfg_attr(not(feature = "data"), allow(dead_code))]
1128 pub(super) fn data_interest_active(&self, recipient: &str) -> bool {
1129 #[cfg(feature = "data")]
1130 {
1131 self.data_sync_active(recipient) && self.peer_data_interest.contains(recipient)
1132 }
1133 #[cfg(not(feature = "data"))]
1134 {
1135 let _ = recipient;
1136 false
1137 }
1138 }
1139
1140 /// Whether `peer` is known to intercept replication frames inside a
1141 /// group ([`DATA_GROUP_V1`]), either directly or by an inviter's
1142 /// attestation.
1143 ///
1144 /// Positive knowledge, and the opposite rule to its blob sibling below,
1145 /// because it answers a different question. This one decides whether a
1146 /// `__DATA_V1__` frame may be *put in front of* a member: one that does
1147 /// not intercept them renders the frame to its user as literal text, so
1148 /// an unknown member closes this and an application is told at the call.
1149 /// The blob sibling decides whether a member can usefully answer a
1150 /// question they will certainly receive, where the honest answer to "we
1151 /// have not heard" is to ask.
1152 ///
1153 /// The roster-wide gate is this predicate over every member, which is
1154 /// not a coincidence to be refactored apart: a directed frame can be
1155 /// promoted to a roster-wide delivery, so the two must agree about what
1156 /// a member has to be known to do before a frame reaches them.
1157 ///
1158 /// [`DATA_GROUP_V1`]: crate::protocol::types::DATA_GROUP_V1
1159 #[cfg_attr(not(feature = "data"), allow(dead_code))]
1160 pub(super) fn data_group_frames_known_received(&self, peer: &str) -> bool {
1161 #[cfg(feature = "data")]
1162 {
1163 self.peer_data_group.contains(peer) || self.peer_data_group_attested.contains(peer)
1164 }
1165 #[cfg(not(feature = "data"))]
1166 {
1167 let _ = peer;
1168 false
1169 }
1170 }
1171
1172 /// Whether `peer` is *known* not to carry attachment bytes inside a
1173 /// group.
1174 ///
1175 /// Knowledge, never its absence. Members of a group do not exchange key
1176 /// packages with each other, so "we have not heard" is the ordinary case
1177 /// and must not read as "they cannot": that would refuse every fetch put
1178 /// to a member this device has never dealt with directly. An inviter's
1179 /// attestation fills the gap where there is one.
1180 ///
1181 /// [`DATA_GROUP_BLOB_V1`]: crate::protocol::types::DATA_GROUP_BLOB_V1
1182 #[cfg_attr(not(feature = "data"), allow(dead_code))]
1183 pub(super) fn data_group_blob_known_absent(&self, peer: &str) -> bool {
1184 #[cfg(feature = "data")]
1185 {
1186 // Any direct knowledge of what this peer replicates, not just
1187 // the first entry. A key package carries the whole list at
1188 // once, so a peer in one of these sets is one whose
1189 // advertisement this device has actually read, and the blob
1190 // entry's absence from it is knowledge rather than silence.
1191 // Reading entry 1 alone would make a peer known only by a
1192 // later entry look like one never heard from, and the refusal
1193 // this predicate exists to make would never fire for them.
1194 let heard_directly =
1195 self.peer_data_sync.contains(peer) || self.peer_data_group.contains(peer);
1196 let attested = self.peer_data_group_blob_attested.contains(peer);
1197 let carries = self.peer_data_group_blob.contains(peer) || attested;
1198 (heard_directly || attested) && !carries
1199 }
1200 #[cfg(not(feature = "data"))]
1201 {
1202 let _ = peer;
1203 false
1204 }
1205 }
1206
1207 /// Whether rich extras seal for `recipient`: our own kill switch is on
1208 /// and the peer advertised [`RICH_PAYLOAD_V1`] in their key package.
1209 pub(super) fn rich_seal_active(&self, recipient: &str) -> bool {
1210 self.config.encryption.rich_payload_enabled && self.peer_rich_payload.contains(recipient)
1211 }
1212
1213 /// Whether rich extras may seal into a group message: our own kill
1214 /// switch is on and every non-self member is known to parse
1215 /// [`RICH_PAYLOAD_V1`] — either self-advertised in a directly received
1216 /// key package (`peer_rich_payload`) or attested by a group inviter on
1217 /// the Add commit / Welcome (`peer_rich_attested`; members added by
1218 /// someone else never exchange key packages with us directly). Group
1219 /// MLS encryption produces a single ciphertext for all members, so one
1220 /// non-capable or capability-unknown member forces the extras to drop
1221 /// for the whole group. Conservative by design: an unknown member must
1222 /// never receive a sealed body their SDK would render as literal
1223 /// `__RICH_V1__` JSON.
1224 ///
1225 /// Attestation is third-party and may be stale or forged by a rogue
1226 /// admin; the worst case is exactly the degraded display below, never a
1227 /// leak, and direct contact evicts the attested entry. Best-effort
1228 /// beyond that: callers pass the `group_mesh.members` fan-out cache,
1229 /// which trails MLS membership until the add/remove notification
1230 /// lands. In that window a just-added non-capable member can receive
1231 /// one sealed body and render it as literal JSON — degraded display,
1232 /// not a leak (every group member is entitled to the sealed contents).
1233 pub(crate) fn group_rich_seal_active(&self, members: &[String]) -> bool {
1234 self.config.encryption.rich_payload_enabled
1235 && members
1236 .iter()
1237 .filter(|m| m.as_str() != self.local_id)
1238 .all(|m| {
1239 self.peer_rich_payload.contains(m.as_str())
1240 || self.peer_rich_attested.contains(m.as_str())
1241 })
1242 }
1243
1244 /// Group members (non-self) not known to parse the sealed rich payload
1245 /// — neither directly advertised nor inviter-attested. These are the
1246 /// members that hold `group_rich_seal_active` closed; the set absence
1247 /// cannot distinguish "never heard from" from "known not capable".
1248 pub(crate) fn group_rich_unknown_members(&self, members: &[String]) -> Vec<String> {
1249 members
1250 .iter()
1251 .filter(|m| {
1252 m.as_str() != self.local_id
1253 && !self.peer_rich_payload.contains(m.as_str())
1254 && !self.peer_rich_attested.contains(m.as_str())
1255 })
1256 .cloned()
1257 .collect()
1258 }
1259
1260 /// The rich-payload versions we can attest for a peer when inviting it
1261 /// into (or welcoming it to) a group: [`RICH_PAYLOAD_V1`] when the peer
1262 /// is known capable (directly or itself attested — attestation chains
1263 /// transitively, which is how knowledge reaches members several adds
1264 /// removed from any direct exchange), `None` when unknown. `None` is
1265 /// deliberately not a downgrade signal: absence of knowledge must never
1266 /// evict what another member learned first-hand.
1267 pub(crate) fn attestable_rich_versions(&self, peer_id: &str) -> Option<Vec<u8>> {
1268 (self.peer_rich_payload.contains(peer_id) || self.peer_rich_attested.contains(peer_id))
1269 .then(|| vec![RICH_PAYLOAD_V1])
1270 }
1271
1272 /// Whether documents may replicate into a group: the layer is enabled
1273 /// here and every non-self member is known to intercept group sync
1274 /// frames — either self-advertised in a directly received key package
1275 /// (`peer_data_group`) or attested by a group inviter on the Add commit
1276 /// / Welcome (`peer_data_group_attested`).
1277 ///
1278 /// All-members rather than per-member, and that is not a convenience.
1279 /// Group MLS encryption produces one ciphertext for everyone, so a
1280 /// frame sent for the capable members is also delivered to the rest;
1281 /// one member that cannot intercept it renders it to its user as
1282 /// literal `__DATA_V1__` text. The gate is therefore closed by a single
1283 /// unknown member, which is the same conservatism
1284 /// [`Self::group_rich_seal_active`] applies to sealed rich bodies, for
1285 /// the same reason and with a worse symptom if skipped.
1286 ///
1287 /// Blocking is deliberately not consulted here. A blocked *peer* is a
1288 /// 1:1 relationship; a group send is one ciphertext to a roster, and
1289 /// dropping the whole group's replication because one member is blocked
1290 /// would be a surprising amount of collateral. The 1:1 gate keeps its
1291 /// block check because there the recipient and the relationship are the
1292 /// same thing.
1293 #[cfg_attr(not(feature = "data"), allow(dead_code))]
1294 pub(crate) fn group_data_sync_active(&self, members: &[String]) -> bool {
1295 #[cfg(feature = "data")]
1296 {
1297 self.config.data.enabled
1298 && members
1299 .iter()
1300 .filter(|m| m.as_str() != self.local_id)
1301 .all(|m| self.data_group_frames_known_received(m.as_str()))
1302 }
1303 #[cfg(not(feature = "data"))]
1304 {
1305 let _ = members;
1306 false
1307 }
1308 }
1309
1310 /// Group members (non-self) not known to intercept group sync frames.
1311 ///
1312 /// These are the members holding [`Self::group_data_sync_active`]
1313 /// closed. Handed to [`Self::backfill_group_rich_capabilities`], whose
1314 /// probe is capability-agnostic: it sends our key package so the peer's
1315 /// reply teaches us everything they advertise at once.
1316 #[cfg_attr(not(feature = "data"), allow(dead_code))]
1317 pub(crate) fn group_data_unknown_members(&self, members: &[String]) -> Vec<String> {
1318 #[cfg(feature = "data")]
1319 {
1320 members
1321 .iter()
1322 .filter(|m| {
1323 m.as_str() != self.local_id
1324 && !self.peer_data_group.contains(m.as_str())
1325 && !self.peer_data_group_attested.contains(m.as_str())
1326 })
1327 .cloned()
1328 .collect()
1329 }
1330 #[cfg(not(feature = "data"))]
1331 {
1332 let _ = members;
1333 Vec::new()
1334 }
1335 }
1336
1337 /// The sync versions we can attest for a peer when inviting it into (or
1338 /// welcoming it to) a group.
1339 ///
1340 /// [`DATA_GROUP_V1`] when the peer is known group-capable, directly or
1341 /// itself attested — attestation chains transitively, which is how
1342 /// knowledge reaches members several adds removed from any direct
1343 /// exchange. `None` when unknown, and `None` is deliberately not a
1344 /// downgrade signal: absence of knowledge must never evict what another
1345 /// member learned first-hand.
1346 ///
1347 /// [`DATA_GROUP_V1`]: crate::protocol::types::DATA_GROUP_V1
1348 #[cfg_attr(not(feature = "data"), allow(dead_code))]
1349 pub(crate) fn attestable_data_versions(&self, peer_id: &str) -> Option<Vec<u8>> {
1350 #[cfg(feature = "data")]
1351 {
1352 // Every group-relevant entry this device knows about the peer,
1353 // rather than the one it used to be. An inviter is the only
1354 // source a member has for another member, so anything it leaves
1355 // out is something nobody else can supply.
1356 let mut versions = Vec::new();
1357 if self.peer_data_group.contains(peer_id)
1358 || self.peer_data_group_attested.contains(peer_id)
1359 {
1360 versions.push(super::types::DATA_GROUP_V1);
1361 }
1362 if self.peer_data_group_blob.contains(peer_id)
1363 || self.peer_data_group_blob_attested.contains(peer_id)
1364 {
1365 versions.push(super::types::DATA_GROUP_BLOB_V1);
1366 }
1367 // Entry 2 is what makes a member reachable at all, so an
1368 // attestation without it says nothing usable and is not sent.
1369 versions
1370 .first()
1371 .is_some_and(|first| *first == super::types::DATA_GROUP_V1)
1372 .then_some(versions)
1373 }
1374 #[cfg(not(feature = "data"))]
1375 {
1376 let _ = peer_id;
1377 None
1378 }
1379 }
1380
1381 /// Best-effort capability backfill for group members whose rich support
1382 /// is unknown (pre-attestation groups, or an attestation chain broken by
1383 /// an old-SDK inviter): send them our key package so their
1384 /// `auto_key_exchange` reply teaches us theirs. Guarded by
1385 /// `key_package_sent_to`, so repeated gate-failing group sends don't
1386 /// re-probe the same peer (the only repeat is
1387 /// `rearm_key_package_for_peer`, on its doubling backoff); a peer that
1388 /// never replies is either
1389 /// unreachable, old-SDK, or opted out — all of which correctly leave
1390 /// the gate closed.
1391 pub(crate) fn backfill_group_rich_capabilities(&mut self, unknown_members: &[String]) {
1392 for member in unknown_members {
1393 if self.key_package_sent_to.contains_key(member.as_str()) {
1394 continue;
1395 }
1396 if let Err(e) = self.send_key_package_to(member, false) {
1397 debug!(
1398 member = %member,
1399 error = %e,
1400 "Rich-capability backfill key package deferred (peer unreachable)"
1401 );
1402 }
1403 }
1404 }
1405
1406 /// Wraps plaintext content and rich extras into the sealed
1407 /// `__RICH_V1__`-prefixed JSON body ([`RichPayloadV1`]). Callers must
1408 /// only invoke this on a path that MLS-encrypts the result — the sealed
1409 /// body is the sole carrier for reply context and media secrets and must
1410 /// never leave as cleartext.
1411 ///
1412 /// The outer `content_type` hint is always copied into the body (not
1413 /// just when non-Text) so the receiver can treat the sealed copy as
1414 /// authoritative and a relay cannot rewrite the hint in either
1415 /// direction on a rich message.
1416 pub(crate) fn seal_rich_payload(
1417 content: &str,
1418 extras: &RichSendExtras,
1419 content_type: ContentType,
1420 ) -> Result<String> {
1421 let payload = RichPayloadV1 {
1422 text: content.to_string(),
1423 reply_context: extras.reply_context.clone(),
1424 media_metadata: extras.media_metadata.clone(),
1425 forward_info: extras.forward_info.clone(),
1426 content_type: Some(content_type),
1427 };
1428 let serialized =
1429 serde_json::to_string(&payload).map_err(|e| Error::Serialization(e.to_string()))?;
1430 Ok(format!("{}{}", internal_prefixes::RICH_V1, serialized))
1431 }
1432
1433 /// Encrypts a plaintext for a recipient whose session is known to be
1434 /// confirmed (in-memory cache hit). Uses `encrypt_for_existing_session` to
1435 /// skip both the external `has_session()` and the internal one inside
1436 /// `encrypt_for_user`, reducing storage I/O from 2 round-trips to 1
1437 /// (`load_group` for encrypt).
1438 ///
1439 /// Only evicts the cache on `SessionNotFound` (session deleted externally).
1440 /// Transient errors (crypto, storage I/O) propagate without cache eviction
1441 /// so the fast path is preserved for the next attempt.
1442 pub(super) fn encrypt_bytes_confirmed_session(
1443 &mut self,
1444 mls: &Arc<RwLock<MlsManager>>,
1445 recipient: &str,
1446 plaintext: &[u8],
1447 ) -> Result<EncryptedMessage> {
1448 let encrypt_result = {
1449 let manager = mls
1450 .read()
1451 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
1452 manager.encrypt_for_existing_session(recipient, plaintext)
1453 };
1454
1455 let encrypted = match encrypt_result {
1456 Ok(enc) => enc,
1457 Err(offline_protocol_mls::MlsError::SessionNotFound(_)) => {
1458 // Session was deleted externally — evict stale cache entry and
1459 // return SessionNotReady so the send pipeline can queue the
1460 // message (when store_pending is enabled) rather than dropping it.
1461 warn!(
1462 recipient = %recipient,
1463 "Confirmed session missing from MLS storage, evicting cache"
1464 );
1465 self.confirmed_sessions.remove(recipient);
1466 let state = self
1467 .establishment_state(recipient)
1468 .unwrap_or(crate::EstablishmentState::NoKeyPackage);
1469 return Err(Error::SessionNotReady(state));
1470 }
1471 Err(e) => {
1472 // Transient error (crypto, storage I/O, etc.) — do NOT evict
1473 // cache, the session likely still exists.
1474 return Err(Error::EncryptFailed(format!(
1475 "encryption operation failed: {}",
1476 e
1477 )));
1478 }
1479 };
1480
1481 self.emit_mls_encryption_used(recipient);
1482 Ok(encrypted)
1483 }
1484
1485 pub(super) fn encrypt_content_for_recipient_strict(
1486 &mut self,
1487 recipient: &str,
1488 content: &str,
1489 ) -> Result<String> {
1490 let encrypted = self.encrypt_bytes_for_recipient_strict(recipient, content.as_bytes())?;
1491 self.seal_encrypted_content(recipient, &encrypted)
1492 }
1493
1494 /// Bytes-level core of [`Self::encrypt_content_for_recipient_strict`]:
1495 /// requires an existing confirmed session and never initiates
1496 /// establishment.
1497 pub(super) fn encrypt_bytes_for_recipient_strict(
1498 &mut self,
1499 recipient: &str,
1500 plaintext: &[u8],
1501 ) -> Result<EncryptedMessage> {
1502 let mls = self.mls_manager.clone().ok_or_else(|| {
1503 self.emit_mls_session_missing(
1504 Some(recipient),
1505 None,
1506 MlsOperationContext::SessionLookup,
1507 MlsErrorCategory::NotInitialized,
1508 );
1509 Error::MlsNotInitialized
1510 })?;
1511
1512 // Fast path: if session is confirmed (in-memory cache), skip has_session() I/O
1513 if self.confirmed_sessions.contains(recipient) {
1514 return self.encrypt_bytes_confirmed_session(&mls, recipient, plaintext);
1515 }
1516
1517 let has_session = {
1518 let manager = mls
1519 .read()
1520 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
1521 manager.has_session(recipient)?
1522 };
1523
1524 if !has_session {
1525 self.try_load_key_package_from_storage_into_memory(recipient);
1526 let now_ms = Utc::now().timestamp_millis() as u64;
1527 let has_valid_key_package = match self.pending_key_packages.get(recipient) {
1528 Some(pkg) if now_ms < pkg.local_expires_at_ms => true,
1529 Some(_) => {
1530 self.pending_key_packages.remove(recipient);
1531 self.delete_peer_key_package_from_storage(recipient);
1532 false
1533 }
1534 None => false,
1535 };
1536
1537 if has_valid_key_package {
1538 return Err(Error::SessionNotReady(self.establishment_state(recipient)?));
1539 }
1540
1541 self.emit_mls_session_missing(
1542 Some(recipient),
1543 None,
1544 MlsOperationContext::SessionLookup,
1545 MlsErrorCategory::SessionStateMissing,
1546 );
1547 return Err(Error::SessionNotReady(self.establishment_state(recipient)?));
1548 }
1549
1550 if !self.is_session_confirmed(recipient)? {
1551 return Err(Error::SessionNotReady(self.establishment_state(recipient)?));
1552 }
1553
1554 let encrypted = {
1555 let manager = mls
1556 .read()
1557 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
1558 manager
1559 .encrypt_for_user(recipient, plaintext)
1560 .map_err(|_| Error::EncryptFailed("encryption operation failed".to_string()))?
1561 };
1562
1563 self.emit_mls_encryption_used(recipient);
1564 Ok(encrypted)
1565 }
1566
1567 #[allow(clippy::too_many_arguments)]
1568 pub(super) fn prepare_outbound_content(
1569 &mut self,
1570 recipient: &str,
1571 content: &str,
1572 priority: MessagePriority,
1573 reply_to_msg_id: Option<MessageId>,
1574 forwarded_from: Option<ForwardInfo>,
1575 content_type: ContentType,
1576 media_metadata: Option<MediaMetadata>,
1577 rich: Option<&RichSendExtras>,
1578 app_id: Option<&AppId>,
1579 provenance: Option<PendingProvenance>,
1580 reconciliation_reason: &'static str,
1581 ) -> Result<OutboundSendPreparation> {
1582 if self.should_auto_encrypt() {
1583 // Rich extras travel only inside the sealed body: seal for a
1584 // capable recipient, drop for everyone else — never cleartext.
1585 // The decision is made here, at actual-send time; a message
1586 // queued behind session establishment stores the raw extras and
1587 // re-evaluates capability at flush (the key package that
1588 // confirms the session may be the one that advertised it).
1589 let rich_extras = rich.filter(|extras| extras.is_any());
1590 let sealed_body;
1591 let outbound: &str = match rich_extras {
1592 Some(extras) if self.rich_seal_active(recipient) => {
1593 sealed_body = Self::seal_rich_payload(content, extras, content_type)?;
1594 &sealed_body
1595 }
1596 Some(_) => {
1597 debug!(
1598 recipient = %recipient,
1599 "Recipient lacks sealed rich payload capability, dropping rich extras"
1600 );
1601 content
1602 }
1603 // No rich extras, but a non-Text content_type toward a
1604 // capable recipient still seals a hint-only body: the outer
1605 // hint is relay-writable, and restamping it FileChunk gets
1606 // the decrypted message ACKed and then dropped by the
1607 // file-transfer manager — worse than a plain relay drop,
1608 // which at least fails the ACK and retries. Only when
1609 // nothing rides outer (`forwarded_from`/`media_metadata`
1610 // both absent): fresh forwards thread their attribution and
1611 // media metadata through `rich` above, so this outer-only
1612 // shape survives solely in messages queued by an older build
1613 // (persisted `PendingMessage`s with no `rich`), where the
1614 // receiver's wholesale sealed-body restore would wipe the
1615 // outer copies a hint-only seal can't replicate. Bare Text
1616 // seals nothing: no hint to protect, and the unsealed form
1617 // keeps the plaintext floor maximal.
1618 None if content_type != ContentType::Text
1619 && forwarded_from.is_none()
1620 && media_metadata.is_none()
1621 && self.rich_seal_active(recipient) =>
1622 {
1623 sealed_body =
1624 Self::seal_rich_payload(content, &RichSendExtras::default(), content_type)?;
1625 &sealed_body
1626 }
1627 None => content,
1628 };
1629 if self.config.encryption.require_encryption {
1630 match self.encrypt_content_for_recipient_strict(recipient, outbound) {
1631 Ok(encrypted) => return Ok(OutboundSendPreparation::Ready(encrypted)),
1632 Err(Error::SessionNotReady(_)) if self.config.encryption.store_pending => {
1633 // Session not ready but store_pending is enabled — queue
1634 // the message so it gets encrypted and sent once the
1635 // session is confirmed, rather than dropping it.
1636 //
1637 // Kick establishment first (import a stored key package,
1638 // create the session, send the Welcome), matching the
1639 // non-strict and media paths: without this a peer whose
1640 // key package arrived but whose arrival-time
1641 // auto-establish failed would stall until the peer
1642 // initiates. SessionNotReady from the kick is the
1643 // expected "created, awaiting confirmation" outcome;
1644 // the message queues regardless.
1645 if let Some(mls) = self.mls_manager.clone() {
1646 match self.ensure_session_establishment(&mls, recipient) {
1647 Ok(()) | Err(Error::SessionNotReady(_)) => {}
1648 Err(err) => {
1649 warn!(
1650 recipient = %recipient,
1651 error = %err,
1652 "Session establishment kick failed; message queued anyway"
1653 );
1654 }
1655 }
1656 }
1657 let queued_id = self.queue_message_for_session_establishment(
1658 recipient,
1659 content,
1660 priority,
1661 reply_to_msg_id,
1662 forwarded_from,
1663 content_type,
1664 media_metadata,
1665 rich_extras.cloned(),
1666 app_id,
1667 provenance,
1668 reconciliation_reason,
1669 )?;
1670 return Ok(OutboundSendPreparation::Queued(queued_id));
1671 }
1672 Err(e) => return Err(e),
1673 }
1674 }
1675
1676 match self.encrypt_content_for_recipient(recipient, outbound, priority) {
1677 Ok(encrypted) => Ok(OutboundSendPreparation::Ready(encrypted)),
1678 Err(Error::SessionNotReady(state)) => {
1679 if !self.config.encryption.store_pending {
1680 return Err(Error::SessionNotReady(state));
1681 }
1682
1683 let queued_id = self.queue_message_for_session_establishment(
1684 recipient,
1685 content,
1686 priority,
1687 reply_to_msg_id,
1688 forwarded_from,
1689 content_type,
1690 media_metadata,
1691 rich_extras.cloned(),
1692 app_id,
1693 provenance,
1694 reconciliation_reason,
1695 )?;
1696 Ok(OutboundSendPreparation::Queued(queued_id))
1697 }
1698 Err(e) => Err(e),
1699 }
1700 } else if self.config.encryption.require_encryption {
1701 Err(Error::EncryptFailed(
1702 "MLS encryption is required (the default) but MLS is not initialized — \
1703 call initialize_mls() with secure and protocol-state storage, or explicitly opt out with \
1704 require_encryption=false to send plaintext"
1705 .to_string(),
1706 ))
1707 } else {
1708 // Explicit opt-out: require_encryption=false with encryption
1709 // disabled or MLS uninitialized. The message leaves as plaintext;
1710 // surface that loudly (once per peer) instead of a debug log.
1711 // Rich extras are dropped here unconditionally — they only ever
1712 // travel inside the sealed body, never as cleartext.
1713 if rich.is_some_and(|extras| extras.is_any()) {
1714 debug!(
1715 recipient = %recipient,
1716 "Plaintext send path, dropping rich extras (sealed-only)"
1717 );
1718 }
1719 self.warn_plaintext_send(recipient);
1720 Ok(OutboundSendPreparation::Ready(content.to_string()))
1721 }
1722 }
1723
1724 // ========================================================================
1725 // PENDING / FLUSH
1726 // ========================================================================
1727
1728 /// Enforces [`MAX_MESSAGE_CONTENT_BYTES`] on application-supplied content.
1729 ///
1730 /// Enforced at the public send boundary rather than at transmit time,
1731 /// because a message that has to wait for MLS session establishment is
1732 /// queued — in memory and on disk — long before it ever reaches the
1733 /// transport's own size check. Without this cap a handful of very large
1734 /// sends could exhaust mobile memory and protocol-state storage while
1735 /// still satisfying the pending queue's entry-count limits.
1736 ///
1737 /// Applies to the plaintext the app hands us. MLS ciphertext, base64, and
1738 /// the JSON wire envelope expand it further, which is why the cap sits
1739 /// well under the transport's 1 MiB ceiling: everything accepted here can
1740 /// actually be delivered.
1741 ///
1742 /// Deliberately *not* applied to the group surface: a group send has no
1743 /// durable pre-session queue behind it (there is no per-recipient MLS
1744 /// session to wait on), so it is bounded by the transport alone — the
1745 /// documented "no send-side limit" behavior that
1746 /// `test_group_mls_send_large_content_no_send_side_limit` pins.
1747 pub(crate) fn check_content_size(content: &str) -> Result<()> {
1748 if content.len() > MAX_MESSAGE_CONTENT_BYTES {
1749 return Err(Error::InvalidArgument(format!(
1750 "Message content too large: {} bytes (max {}); use send_media for large payloads",
1751 content.len(),
1752 MAX_MESSAGE_CONTENT_BYTES
1753 )));
1754 }
1755 Ok(())
1756 }
1757
1758 #[allow(clippy::too_many_arguments)]
1759 pub(super) fn queue_message_for_session_establishment(
1760 &mut self,
1761 recipient: &str,
1762 content: &str,
1763 priority: MessagePriority,
1764 reply_to_msg_id: Option<MessageId>,
1765 forwarded_from: Option<ForwardInfo>,
1766 content_type: ContentType,
1767 media_metadata: Option<MediaMetadata>,
1768 rich: Option<RichSendExtras>,
1769 app_id: Option<&AppId>,
1770 provenance: Option<PendingProvenance>,
1771 reconciliation_reason: &'static str,
1772 ) -> Result<MessageId> {
1773 // Fresh sends mint an ID without ticking the Lamport clock — the
1774 // real tick happens when flush_pending_messages re-sends after the
1775 // session is established. A flush-time re-queue passes the id the
1776 // caller already holds so Deferred/Sent/Delivered stay correlatable,
1777 // and the timestamp it first entered the queue so the absolute
1778 // pending lifetime is not renewed by the round trip.
1779 let (message_id, first_queued_at) = match provenance {
1780 Some(provenance) => (provenance.message_id, provenance.first_queued_at),
1781 None => (MessageId::default(), None),
1782 };
1783
1784 debug!(
1785 recipient = %recipient,
1786 message_id = %message_id,
1787 "Message queued pending session establishment"
1788 );
1789 self.queue_pending_message_at(
1790 recipient,
1791 content,
1792 priority,
1793 message_id.clone(),
1794 reply_to_msg_id,
1795 forwarded_from,
1796 content_type,
1797 media_metadata,
1798 rich,
1799 app_id.map(|id| id.as_str().to_string()),
1800 first_queued_at.unwrap_or_else(Utc::now),
1801 );
1802 self.kick_pending_session_reconciliation(reconciliation_reason);
1803 if self.has_terminal_welcome_failure(recipient) {
1804 self.abort_pending_session_for_peer(
1805 recipient,
1806 crate::events::WelcomeReasonCode::RetryExhausted,
1807 );
1808 return Err(Error::Other(format!(
1809 "Welcome delivery failed for {}",
1810 recipient
1811 )));
1812 }
1813
1814 Ok(message_id)
1815 }
1816
1817 /// Queues a message with a specific message ID for later sending when
1818 /// session is established, starting a fresh pending lifetime.
1819 ///
1820 /// A re-queue must go through [`Self::queue_pending_message_at`] with the
1821 /// entry's original timestamp instead — which is why production code always
1822 /// calls that one directly (through
1823 /// `queue_message_for_session_establishment`, where the distinction is
1824 /// made) and this convenience exists only for tests.
1825 #[cfg(test)]
1826 #[allow(clippy::too_many_arguments)]
1827 pub(super) fn queue_pending_message(
1828 &mut self,
1829 recipient: &str,
1830 content: &str,
1831 priority: MessagePriority,
1832 message_id: MessageId,
1833 reply_to_msg: Option<MessageId>,
1834 forwarded_from: Option<ForwardInfo>,
1835 content_type: ContentType,
1836 media_metadata: Option<MediaMetadata>,
1837 rich: Option<RichSendExtras>,
1838 ) {
1839 self.queue_pending_message_at(
1840 recipient,
1841 content,
1842 priority,
1843 message_id,
1844 reply_to_msg,
1845 forwarded_from,
1846 content_type,
1847 media_metadata,
1848 rich,
1849 None,
1850 Utc::now(),
1851 );
1852 }
1853
1854 /// Queues a message with an explicit first-queued timestamp.
1855 ///
1856 /// `queued_at` is the instant the message *first* entered the pending
1857 /// queue, which is what `pending_message_max_lifetime_ms` is measured from.
1858 /// Re-queueing an entry (a flush that found the session still unavailable)
1859 /// must pass the original value: stamping `now()` there would hand the
1860 /// message a fresh window on every failed reconciliation, so a message
1861 /// could outlive an "absolute" lifetime indefinitely.
1862 ///
1863 /// Admission is bounded on two axes — entry count and serialized bytes,
1864 /// per peer and globally — because message content is
1865 /// application-supplied and a count alone bounds neither memory nor
1866 /// durable storage. Both axes evict oldest-first and settle each evicted
1867 /// message as failed.
1868 #[allow(clippy::too_many_arguments)]
1869 pub(super) fn queue_pending_message_at(
1870 &mut self,
1871 recipient: &str,
1872 content: &str,
1873 priority: MessagePriority,
1874 message_id: MessageId,
1875 reply_to_msg: Option<MessageId>,
1876 forwarded_from: Option<ForwardInfo>,
1877 content_type: ContentType,
1878 media_metadata: Option<MediaMetadata>,
1879 rich: Option<RichSendExtras>,
1880 app_id: Option<String>,
1881 queued_at: DateTime<Utc>,
1882 ) {
1883 let message_id_str = message_id.as_str().to_string();
1884 let mut pending = PendingMessage {
1885 content: content.to_string(),
1886 priority,
1887 message_id,
1888 reply_to_msg,
1889 forwarded_from,
1890 content_type,
1891 media_metadata,
1892 rich,
1893 app_id,
1894 queued_at,
1895 serialized_bytes: 0,
1896 };
1897 pending.measure();
1898 let incoming_bytes = pending.serialized_bytes;
1899
1900 let mut evicted = Vec::new();
1901
1902 while self
1903 .pending_encrypted_messages
1904 .get(recipient)
1905 .is_some_and(|messages| messages.len() >= MAX_PENDING_MESSAGES_PER_PEER)
1906 {
1907 if let Some(message) = self.evict_pending_message_at(recipient, 0) {
1908 evicted.push(message);
1909 } else {
1910 break;
1911 }
1912 }
1913
1914 // The running totals are decremented as entries are evicted rather
1915 // than recomputed each turn, so a queue at capacity does not make
1916 // admission quadratic.
1917 let mut peer_bytes = self.pending_message_bytes_for(recipient);
1918 while peer_bytes.saturating_add(incoming_bytes) > MAX_PENDING_MESSAGE_BYTES_PER_PEER {
1919 let Some(message) = self.evict_pending_message_at(recipient, 0) else {
1920 break;
1921 };
1922 peer_bytes = peer_bytes.saturating_sub(message.serialized_bytes);
1923 evicted.push(message);
1924 }
1925
1926 while self.total_pending_message_count() >= MAX_PENDING_MESSAGES_GLOBAL {
1927 let Some((_, message)) = self.evict_oldest_pending_message() else {
1928 break;
1929 };
1930 evicted.push(message);
1931 }
1932
1933 let mut global_bytes = self.total_pending_message_bytes();
1934 while global_bytes.saturating_add(incoming_bytes) > MAX_PENDING_MESSAGE_BYTES_GLOBAL {
1935 let Some((_, message)) = self.evict_oldest_pending_message() else {
1936 break;
1937 };
1938 global_bytes = global_bytes.saturating_sub(message.serialized_bytes);
1939 evicted.push(message);
1940 }
1941
1942 // Push to in-memory queue first, then persist (the in-memory queue
1943 // is the source of truth; storage is a crash-recovery backup).
1944 //
1945 // One record per message, so this writes the incoming entry and nothing
1946 // else — the queue it joins is already on disk, entry by entry, and the
1947 // entries the caps just evicted are deleted by id rather than rewritten
1948 // around. Which recipients were touched no longer matters.
1949 self.pending_encrypted_messages
1950 .entry(recipient.to_string())
1951 .or_default()
1952 .push(pending);
1953 self.note_pending_message_expiry(queued_at);
1954
1955 if let Some(queued) = self
1956 .pending_encrypted_messages
1957 .get(recipient)
1958 .and_then(|messages| messages.last())
1959 {
1960 self.persist_pending_message(recipient, queued);
1961 }
1962
1963 for message in evicted {
1964 self.delete_pending_message_from_storage(&message.message_id);
1965 self.emit_event(Event::message_failed(
1966 message.message_id,
1967 "Pending session queue capacity exceeded",
1968 0,
1969 ));
1970 }
1971
1972 debug!(recipient = %recipient, message_id = %message_id_str, "Queued message pending session establishment");
1973 }
1974
1975 pub(super) fn total_pending_message_count(&self) -> usize {
1976 self.pending_encrypted_messages.values().map(Vec::len).sum()
1977 }
1978
1979 /// Total serialized footprint of the pending-session queue across all
1980 /// peers. Summing precomputed per-entry sizes, so this stays a cheap
1981 /// integer walk rather than re-serializing the queue.
1982 pub(super) fn total_pending_message_bytes(&self) -> usize {
1983 self.pending_encrypted_messages
1984 .values()
1985 .flatten()
1986 .map(|message| message.serialized_bytes)
1987 .sum()
1988 }
1989
1990 /// Serialized footprint of one peer's pending-session queue.
1991 pub(super) fn pending_message_bytes_for(&self, recipient: &str) -> usize {
1992 self.pending_encrypted_messages
1993 .get(recipient)
1994 .map(|messages| {
1995 messages
1996 .iter()
1997 .map(|message| message.serialized_bytes)
1998 .sum()
1999 })
2000 .unwrap_or(0)
2001 }
2002
2003 fn evict_pending_message_at(
2004 &mut self,
2005 recipient: &str,
2006 index: usize,
2007 ) -> Option<PendingMessage> {
2008 let (message, remove_recipient) = {
2009 let messages = self.pending_encrypted_messages.get_mut(recipient)?;
2010 if index >= messages.len() {
2011 return None;
2012 }
2013 let message = messages.remove(index);
2014 (message, messages.is_empty())
2015 };
2016 if remove_recipient {
2017 self.pending_encrypted_messages.remove(recipient);
2018 }
2019 Some(message)
2020 }
2021
2022 pub(super) fn evict_oldest_pending_message(&mut self) -> Option<(String, PendingMessage)> {
2023 let (recipient, index) = self
2024 .pending_encrypted_messages
2025 .iter()
2026 .flat_map(|(recipient, messages)| {
2027 messages
2028 .iter()
2029 .enumerate()
2030 .map(move |(index, message)| (recipient, index, message))
2031 })
2032 .min_by(|left, right| {
2033 left.2
2034 .queued_at
2035 .cmp(&right.2.queued_at)
2036 .then_with(|| left.2.message_id.as_str().cmp(&right.2.message_id.as_str()))
2037 })
2038 .map(|(recipient, index, _)| (recipient.clone(), index))?;
2039 let message = self.evict_pending_message_at(&recipient, index)?;
2040 Some((recipient, message))
2041 }
2042
2043 /// Drops a recipient's whole queue from memory *and* from storage, settling
2044 /// every dropped entry as `MessageFailed`.
2045 ///
2046 /// The storage half is a delete per entry rather than one whole-queue
2047 /// delete, which is the cost side of keying records by message id. Callers
2048 /// are the paths where every queued message for a peer becomes undeliverable
2049 /// at once — blocking, an aborted session.
2050 ///
2051 /// Settlement happens *here* rather than in the callers on purpose. The
2052 /// caller holds ids handed to the app by `send_message*` at queue time, so
2053 /// a silent drop leaves the app waiting forever on ids that will never
2054 /// resolve — the same contract the blocked-recipient branch of
2055 /// [`Self::flush_pending_messages`] and the pending-expiry pass already
2056 /// honour. Returning the messages for the caller to settle is what let all
2057 /// three call sites discard them instead, so there is nothing to discard
2058 /// any more.
2059 pub(super) fn drop_pending_queue_for_peer(
2060 &mut self,
2061 recipient: &str,
2062 reason: &'static str,
2063 ) -> usize {
2064 let messages = self
2065 .pending_encrypted_messages
2066 .remove(recipient)
2067 .unwrap_or_default();
2068 self.delete_pending_messages_from_storage(messages.iter().map(|m| &m.message_id));
2069 for message in &messages {
2070 self.emit_event(Event::message_failed(message.message_id.clone(), reason, 0));
2071 }
2072 self.recompute_next_pending_message_expiry();
2073 messages.len()
2074 }
2075
2076 fn pending_message_expiry(queued_at: DateTime<Utc>, lifetime_ms: u64) -> Option<DateTime<Utc>> {
2077 let lifetime_ms = i64::try_from(lifetime_ms).ok()?;
2078 queued_at.checked_add_signed(chrono::Duration::milliseconds(lifetime_ms))
2079 }
2080
2081 fn note_pending_message_expiry(&mut self, queued_at: DateTime<Utc>) {
2082 let Some(expiry) = Self::pending_message_expiry(
2083 queued_at,
2084 self.config
2085 .reliability
2086 .retry
2087 .pending_message_max_lifetime_ms,
2088 ) else {
2089 return;
2090 };
2091 self.next_pending_message_expiry = Some(
2092 self.next_pending_message_expiry
2093 .map_or(expiry, |current| current.min(expiry)),
2094 );
2095 }
2096
2097 pub(super) fn recompute_next_pending_message_expiry(&mut self) {
2098 let lifetime_ms = self
2099 .config
2100 .reliability
2101 .retry
2102 .pending_message_max_lifetime_ms;
2103 self.next_pending_message_expiry = self
2104 .pending_encrypted_messages
2105 .values()
2106 .flat_map(|messages| messages.iter())
2107 .filter_map(|message| Self::pending_message_expiry(message.queued_at, lifetime_ms))
2108 .min();
2109 }
2110
2111 /// Flushes pending messages for a recipient after session is established.
2112 ///
2113 /// Every message flushes through one unified path — prepare (re-making
2114 /// the seal-or-drop decision for rich extras against the recipient's
2115 /// *current* capability; the key package that confirmed this session may
2116 /// have advertised it), create, restore outer fields, then dispatch —
2117 /// and keeps the `message_id` the caller received from `send_message*`
2118 /// at queue time (no event fires on queueing — the returned id is the
2119 /// correlation anchor), so that id matches the eventual
2120 /// `MessageSent`/`MessageDelivered`/`MessageFailed`. A re-queue (session
2121 /// flapped back to not-ready) keeps the id too.
2122 pub(super) fn flush_pending_messages(&mut self, recipient: &str) -> Result<()> {
2123 if let Some(pending) = self.pending_encrypted_messages.remove(recipient) {
2124 // Blocking is bidirectional (we neither receive from nor send
2125 // to): a recipient blocked after messages were queued drops the
2126 // queue outright — retrying would fail with UserBlocked forever.
2127 if self.is_user_blocked(recipient) {
2128 info!(
2129 recipient = %recipient,
2130 count = pending.len(),
2131 "Dropping pending messages for blocked recipient"
2132 );
2133 // The caller holds these ids (returned by `send_message*`
2134 // at queue time) — settle each one so the app isn't left
2135 // waiting on ids that will never resolve.
2136 for msg in &pending {
2137 self.delete_pending_message_from_storage(&msg.message_id);
2138 self.emit_event(Event::message_failed(
2139 msg.message_id.clone(),
2140 "Recipient blocked",
2141 0,
2142 ));
2143 }
2144 return Ok(());
2145 }
2146
2147 info!(recipient = %recipient, count = pending.len(), "Flushing pending messages");
2148 let original_order: Vec<String> =
2149 pending.iter().map(|m| m.message_id.as_str()).collect();
2150 let mut remaining = Vec::new();
2151 let now = Utc::now();
2152 let lifetime_ms = self
2153 .config
2154 .reliability
2155 .retry
2156 .pending_message_max_lifetime_ms;
2157
2158 for msg in pending {
2159 // The absolute pending lifetime is enforced here as well as in
2160 // the expiry pass, because that pass is bounded per tick
2161 // (`MAX_PENDING_EXPIRIES_PER_PASS`) — a restore can hand this
2162 // flush entries already past their deadline, and dispatching
2163 // one would settle it `MessageSent` after the lifetime promised
2164 // `message_failed`. Back into `remaining` instead: the entry
2165 // keeps its record, and the next expiry pass settles and
2166 // deletes it as a pair.
2167 if lifetime_expired(now, msg.queued_at, lifetime_ms) {
2168 remaining.push(msg);
2169 continue;
2170 }
2171
2172 // With stable ids the deduplicator now guards against a
2173 // double flush (e.g. a stale storage snapshot restored after
2174 // the message already went out): a dedup hit means this id
2175 // was dispatched before — drop it, don't retry forever.
2176 if self.deduplicator.is_duplicate(&msg.message_id) {
2177 if self.deduplicator.is_exact() {
2178 // Exact mode: the hit is authoritative — this id was
2179 // already dispatched and (barring an internal error
2180 // after the transport accepted it) settled then via
2181 // MessageSent or MessageDeferred; re-dispatching
2182 // would duplicate on the wire.
2183 debug!(
2184 message_id = %msg.message_id,
2185 "Pending message already dispatched (dedup hit), dropping"
2186 );
2187 } else {
2188 // Bloom mode: the hit may be a false positive for a
2189 // message that never went out, and dispatching anyway
2190 // would error-loop on the same filter. Settle loudly
2191 // so the app can resend under a fresh id instead of
2192 // losing the message silently.
2193 warn!(
2194 message_id = %msg.message_id,
2195 "Pending message dropped on probabilistic dedup hit"
2196 );
2197 self.emit_event(Event::message_failed(
2198 msg.message_id.clone(),
2199 "Duplicate suppressed at flush (probabilistic dedup; possible false positive)",
2200 0,
2201 ));
2202 }
2203 // Leaves the queue for good either way, so its record goes
2204 // with it. Only the entries that end up back in `remaining`
2205 // below keep theirs — those were never deleted, and their
2206 // contents have not changed, so re-queuing them costs no
2207 // write at all.
2208 self.delete_pending_message_from_storage(&msg.message_id);
2209 continue;
2210 }
2211
2212 // Validated at admission and sealed at rest, so a failure
2213 // here means a corrupted record. Sending under the configured
2214 // id beats re-queueing an entry that can never flush.
2215 let flush_app_id = match Self::parse_send_app_id(msg.app_id.as_deref()) {
2216 Ok(app_id) => app_id,
2217 Err(e) => {
2218 warn!(message_id = %msg.message_id, error = %e, "Pending message app id unreadable, using the configured id");
2219 None
2220 }
2221 };
2222
2223 let final_content = match self.prepare_outbound_content(
2224 recipient,
2225 &msg.content,
2226 msg.priority,
2227 msg.reply_to_msg.clone(),
2228 msg.forwarded_from.clone(),
2229 msg.content_type,
2230 msg.media_metadata.clone(),
2231 msg.rich.as_ref(),
2232 flush_app_id.as_ref(),
2233 Some(PendingProvenance::requeued(&msg)),
2234 "flush_pending",
2235 ) {
2236 Ok(OutboundSendPreparation::Ready(c)) => c,
2237 Ok(OutboundSendPreparation::Queued(message_id)) => {
2238 // Re-queued for later (session still not ready),
2239 // keeping the original id.
2240 debug!(message_id = %message_id, "Pending message re-queued");
2241 continue;
2242 }
2243 Err(e) => {
2244 warn!(message_id = %msg.message_id, error = %e, "Failed to prepare pending message");
2245 remaining.push(msg);
2246 continue;
2247 }
2248 };
2249
2250 let message = match self.create_message_for_app(
2251 recipient,
2252 final_content,
2253 Some(msg.priority),
2254 msg.reply_to_msg.clone(),
2255 flush_app_id,
2256 ) {
2257 Ok(mut message) => {
2258 // Restore outer fields: forward attribution and its
2259 // media_metadata ride outer as the fallback for
2260 // non-capable recipients (secrets stripped at the
2261 // wire); when `msg.rich` sealed above, the receiver's
2262 // sealed-body restore overwrites these wholesale.
2263 // Plain sends store None for both — their rich
2264 // copies are sealed or dropped by prepare above.
2265 message.forwarded_from = msg.forwarded_from.clone();
2266 message.content_type = msg.content_type;
2267 message.media_metadata = msg.media_metadata.clone();
2268 message.id = msg.message_id.clone();
2269 message
2270 }
2271 Err(e) => {
2272 warn!(message_id = %msg.message_id, error = %e, "Failed to create pending message");
2273 remaining.push(msg);
2274 continue;
2275 }
2276 };
2277
2278 // Tier 2 provenance: a flushed pending message that sealed
2279 // carries its plaintext forward so later resends re-seal against
2280 // the peer's current session (see `send_message_with`).
2281 if message.content.starts_with(internal_prefixes::ENCRYPTED) {
2282 self.stage_outbox_reseal(
2283 &message.id,
2284 OutboxReseal {
2285 content: msg.content.clone(),
2286 priority: msg.priority,
2287 reply_to_msg: msg.reply_to_msg.clone(),
2288 forwarded_from: msg.forwarded_from.clone(),
2289 content_type: msg.content_type,
2290 media_metadata: msg.media_metadata.clone(),
2291 rich: msg.rich.clone(),
2292 },
2293 );
2294 }
2295
2296 match self.dispatch_prepared_message(message) {
2297 Ok(id) => {
2298 debug!(message_id = %id, "Sent pending message");
2299 // Handed off to the outbox, which persists its own
2300 // record under the same id — so the pending copy has to
2301 // go, or a restart would restore and re-flush it.
2302 self.delete_pending_message_from_storage(&id);
2303 }
2304 Err(e) => {
2305 warn!(message_id = %msg.message_id, error = %e, "Failed to send pending message");
2306 remaining.push(msg);
2307 }
2308 }
2309 }
2310
2311 // Messages that re-queued above re-inserted themselves into the
2312 // map (and rewrote their own record) — merge failures in without
2313 // clobbering the entry. Nothing to clear when neither survives: each
2314 // entry that left the queue already took its own record with it.
2315 if remaining.is_empty() {
2316 return Ok(());
2317 }
2318 if self.has_terminal_welcome_failure(recipient) {
2319 // A prepare failure above came from a terminal Welcome
2320 // failure: abort_pending_session_for_peer already cleared
2321 // the queue and its storage and settled the peer with
2322 // secure_session_failed — re-inserting `remaining` would
2323 // resurrect messages the abort just settled, on every
2324 // future flush, forever.
2325 //
2326 // The abort could only delete the records of entries still in
2327 // the map, and this flush took the queue out of it at the top,
2328 // so these records are still on disk and have to go here or the
2329 // next launch restores exactly what the abort settled.
2330 debug!(
2331 recipient = %recipient,
2332 count = remaining.len(),
2333 "Dropping flush failures for aborted pending session"
2334 );
2335 for msg in &remaining {
2336 self.delete_pending_message_from_storage(&msg.message_id);
2337 }
2338 } else {
2339 // Every other path into `pending_encrypted_messages` notes the
2340 // entry's deadline. This one is a *re*-insertion of entries the
2341 // queue already held, so today the cached minimum is still
2342 // correct without it — but only because nothing recomputes that
2343 // cache while a flush is in progress. Noting them keeps
2344 // "`next_pending_message_expiry` is never later than the true
2345 // minimum" an invariant of the code rather than of the call
2346 // graph: if it ever broke, these entries would stop being
2347 // scanned and would never expire, silently, on the one queue the
2348 // absolute pending lifetime exists to bound.
2349 let merged_deadlines: Vec<DateTime<Utc>> =
2350 remaining.iter().map(|m| m.queued_at).collect();
2351 let queue = self
2352 .pending_encrypted_messages
2353 .entry(recipient.to_string())
2354 .or_default();
2355 queue.extend(remaining);
2356 // A failure that preceded a mid-flush re-queue would land
2357 // behind it — restore the original queue order (stable
2358 // sort; every id here came from this flush's queue).
2359 queue.sort_by_key(|m| {
2360 original_order
2361 .iter()
2362 .position(|id| *id == m.message_id.as_str())
2363 .unwrap_or(usize::MAX)
2364 });
2365 for queued_at in merged_deadlines {
2366 self.note_pending_message_expiry(queued_at);
2367 }
2368 // No write: these entries never left the queue durably. Their
2369 // records were written at enqueue, were not deleted above, and
2370 // their contents have not changed — only their position in the
2371 // in-memory queue, which the restore ordering rebuilds from
2372 // `queued_at` rather than from storage order.
2373 }
2374 }
2375 Ok(())
2376 }
2377
2378 pub(super) fn flush_restored_confirmed_pending_messages(&mut self) {
2379 let recipients: Vec<String> = self.pending_encrypted_messages.keys().cloned().collect();
2380
2381 for recipient in recipients {
2382 match self.is_session_confirmed(&recipient) {
2383 Ok(true) => {
2384 if let Err(err) = self.flush_pending_messages(&recipient) {
2385 warn!(
2386 recipient = %recipient,
2387 error = %err,
2388 "Failed to flush restored pending messages for confirmed session"
2389 );
2390 }
2391 }
2392 Ok(false) => {}
2393 Err(err) => {
2394 warn!(
2395 recipient = %recipient,
2396 error = %err,
2397 "Failed to read session confirmation state while restoring pending messages"
2398 );
2399 }
2400 }
2401 }
2402 }
2403
2404 // ========================================================================
2405 // MEDIA
2406 // ========================================================================
2407
2408 /// Sends a media attachment (image, video, audio, file, etc.) to a recipient.
2409 ///
2410 /// The file data is chunked and each chunk is sent as an individual message
2411 /// with `content_type: FileChunk`. The first chunk carries the full
2412 /// `MediaMetadata` so the receiver can display a preview before all chunks
2413 /// arrive. Individual chunk messages require ACKs and participate in retry
2414 /// logic so delivery is tracked and recoverable per chunk. `MediaSent` is
2415 /// emitted only after all chunks are ACKed.
2416 ///
2417 /// # Encryption
2418 ///
2419 /// With auto-encryption active (encryption enabled and MLS initialized),
2420 /// chunk bytes — and the chunk-0 media metadata and original content type —
2421 /// travel inside MLS ciphertext, using the same session the text path uses.
2422 /// The session must already be confirmed: media is never queued pending
2423 /// establishment and never falls back to plaintext. When the session is
2424 /// not ready this returns [`Error::SessionNotReady`] (after kicking
2425 /// establishment if a key package is available); retry after the
2426 /// `secure_session_established` event. When encryption is required but MLS
2427 /// is not initialized this returns [`Error::EncryptFailed`]. Plaintext
2428 /// chunks are sent only when auto-encryption is inactive AND
2429 /// `require_encryption` was explicitly set to `false` (it defaults to
2430 /// `true`): the explicit encryption opt-out, or encryption enabled but
2431 /// MLS never initialized (matching the text path). Every plaintext
2432 /// transfer emits a
2433 /// [`crate::events::SecurityWarningCode::PlaintextSend`] warning, once
2434 /// per peer.
2435 ///
2436 /// Returns a `file_id` that can be used to track progress or cancel.
2437 pub fn send_media(
2438 &mut self,
2439 recipient: impl Into<String>,
2440 file_data: Vec<u8>,
2441 file_name: impl Into<String>,
2442 content_type: ContentType,
2443 media_metadata: Option<MediaMetadata>,
2444 ) -> Result<String> {
2445 self.send_media_with(
2446 recipient,
2447 file_data,
2448 file_name,
2449 content_type,
2450 MediaSendOptions {
2451 media_metadata,
2452 ..Default::default()
2453 },
2454 )
2455 }
2456
2457 /// Sends a media attachment with rich options: the chunk-0 media
2458 /// metadata (as on [`Self::send_media`]), plus caption, reply threading,
2459 /// quoted-reply context, forward attribution, and an optional
2460 /// caller-supplied `file_id`.
2461 ///
2462 /// The rich fields only ever travel inside the MLS-sealed chunk-0
2463 /// plaintext (under the v2 media envelope), and only toward recipients
2464 /// whose key package advertised `rich_versions` support (gated by
2465 /// `EncryptionConfig::rich_payload_enabled`). Toward anyone else —
2466 /// including every plaintext (encryption opt-out) transfer — they are
2467 /// silently dropped, never sent cleartext.
2468 ///
2469 /// Returns `InvalidArgument` for an unparsable `reply_to_msg`, rich
2470 /// extras whose serialized size exceeds 32 KiB, or a `file_id` that is
2471 /// empty, over the wire field bound, or already carried by an active
2472 /// outbound transfer. Encryption preconditions match
2473 /// [`Self::send_media`].
2474 pub fn send_media_with(
2475 &mut self,
2476 recipient: impl Into<String>,
2477 file_data: Vec<u8>,
2478 file_name: impl Into<String>,
2479 content_type: ContentType,
2480 options: MediaSendOptions,
2481 ) -> Result<String> {
2482 // The public surface never carries a data purpose. Every transfer
2483 // reachable from an application is a transfer for its user, and the
2484 // one argument that could change that is not in `MediaSendOptions`
2485 // and must never be added to it: a caller able to mark a send as
2486 // data-purposed could feed bytes of its choosing to a peer's
2487 // document engine while that peer's user saw nothing arrive.
2488 self.send_media_inner(recipient, file_data, file_name, content_type, options, None)
2489 }
2490
2491 /// The body of [`Self::send_media_with`], plus the one argument the
2492 /// public surface may not supply.
2493 ///
2494 /// `data_purpose` marks a transfer as belonging to the replicated-
2495 /// document layer. It is `pub(crate)` and reached only from the data
2496 /// layer's own send seams.
2497 #[cfg_attr(not(feature = "data"), allow(dead_code))]
2498 pub(crate) fn send_media_inner(
2499 &mut self,
2500 recipient: impl Into<String>,
2501 file_data: Vec<u8>,
2502 file_name: impl Into<String>,
2503 content_type: ContentType,
2504 options: MediaSendOptions,
2505 data_purpose: Option<crate::media_envelope::DataPurpose>,
2506 ) -> Result<String> {
2507 {
2508 let state = lock_shared_state(&self.shared_state)?;
2509 if state.state != ProtocolState::Running {
2510 return Err(Error::NotStarted);
2511 }
2512 }
2513
2514 let recipient_str: String = recipient.into();
2515 let file_name_str: String = file_name.into();
2516 let media_metadata = options.media_metadata;
2517 Self::validate_outbound_recipient(&recipient_str)?;
2518 // Before any transfer state exists, like the recipient check.
2519 let app_id = Self::parse_send_app_id(options.app_id.as_deref())?;
2520
2521 // Validate the reply id like send_message_with does — a malformed id
2522 // fails the call instead of riding sealed to the receiver.
2523 if let Some(reply_to) = options.reply_to_msg.as_deref() {
2524 MessageId::from_str(reply_to)
2525 .map_err(|e| Error::InvalidArgument(format!("Invalid reply_to_msg: {}", e)))?;
2526 }
2527
2528 let rich_extras = MediaRichExtras {
2529 caption: options.caption,
2530 reply_to_msg: options.reply_to_msg,
2531 reply_context: options.reply_context,
2532 forward_info: options.forward_info,
2533 };
2534 // Same boundary cap as send_message_with: an oversized quote or
2535 // caption would inflate the sealed chunk-0 plaintext into heavy
2536 // fragmentation. Enforced before the capability gate so the caller
2537 // hears about it even when the extras would be dropped.
2538 if rich_extras.is_any() {
2539 let extras_len = serde_json::to_vec(&rich_extras)
2540 .map_err(|e| Error::Serialization(e.to_string()))?
2541 .len();
2542 if extras_len > MAX_RICH_EXTRAS_BYTES {
2543 return Err(Error::InvalidArgument(format!(
2544 "Rich extras too large: {} bytes serialized (max {})",
2545 extras_len, MAX_RICH_EXTRAS_BYTES
2546 )));
2547 }
2548 }
2549
2550 if let Some(file_id) = options.file_id.as_deref() {
2551 if file_id.is_empty() || file_id.len() > FileChunk::MAX_STRING_FIELD_LEN {
2552 return Err(Error::InvalidArgument(format!(
2553 "file_id length must be 1..={} bytes",
2554 FileChunk::MAX_STRING_FIELD_LEN
2555 )));
2556 }
2557 if self.outbound_media_transfers.contains_key(file_id) {
2558 return Err(Error::InvalidArgument(format!(
2559 "file_id {} already has an active outbound transfer",
2560 file_id
2561 )));
2562 }
2563 // A restored descriptor under this file_id means this send is
2564 // the app answering MediaResendRequired: it must target the
2565 // original recipient (a redirect would consume the descriptor
2566 // and orphan the interrupted transfer's resend forever) and
2567 // re-supply the original bytes — the receiver may already hold
2568 // chunks of the interrupted attempt, and a silent content swap
2569 // under the same id would fail its integrity check at best.
2570 if let Some(descriptor) = self.restored_media_descriptors.get(file_id) {
2571 if descriptor.recipient != recipient_str {
2572 return Err(Error::InvalidArgument(format!(
2573 "file_id {} belongs to an interrupted transfer to a different recipient",
2574 file_id
2575 )));
2576 }
2577 use sha2::{Digest, Sha256};
2578 let resupplied_checksum = format!("{:x}", Sha256::digest(&file_data));
2579 if resupplied_checksum != descriptor.file_checksum {
2580 return Err(Error::InvalidArgument(format!(
2581 "file_id {} resend bytes do not match the interrupted transfer's checksum",
2582 file_id
2583 )));
2584 }
2585 }
2586 }
2587
2588 // Prevent sending media to blocked users. Blocking is bidirectional:
2589 // we neither receive from nor send to a blocked peer.
2590 if self.is_user_blocked(&recipient_str) {
2591 return Err(Error::UserBlocked(recipient_str));
2592 }
2593
2594 // A data-purposed transfer goes only to a peer that said it routes
2595 // one into its document layer. Toward anybody else the bytes would
2596 // be handed to a person as a downloaded file, so this refuses rather
2597 // than degrading: there is no useful weaker form of this send.
2598 if data_purpose.is_some() && !self.data_media_active(&recipient_str) {
2599 return Err(Error::InvalidArgument(format!(
2600 "peer {recipient_str} did not advertise attachment carriage"
2601 )));
2602 }
2603
2604 // And only where it can be sealed. The marking exists nowhere but
2605 // inside the encrypted chunk-0 plaintext, so a transfer leaving by
2606 // the plaintext opt-out path arrives stripped of it: the receiver
2607 // has no way to know what the bytes are and hands its user a
2608 // downloaded file named by a hash, which is the outcome the whole
2609 // marking exists to prevent. Refused rather than degraded, for the
2610 // same reason the capability gate above refuses.
2611 if data_purpose.is_some() && !self.should_auto_encrypt() {
2612 return Err(Error::EncryptFailed(
2613 "a document-layer transfer cannot be sent unsealed: its marking travels \
2614 inside the encrypted chunk, and a receiver would surface the bytes to its \
2615 user as an ordinary file"
2616 .to_string(),
2617 ));
2618 }
2619
2620 // SEC-H1: media rides the same MLS session machinery as text. With
2621 // auto-encryption active the session must already be confirmed —
2622 // files are too large for the pending-message queue, so media is
2623 // never queued pending establishment and never falls back to
2624 // plaintext. Callers should retry after `secure_session_established`.
2625 if self.should_auto_encrypt() {
2626 if !self.is_session_confirmed(&recipient_str)? {
2627 // Kick session establishment exactly like the text path
2628 // (import a stored key package, create the session, send the
2629 // Welcome), then report not-ready: the session cannot be
2630 // confirmed synchronously, so callers retry after
2631 // `secure_session_established`.
2632 if let Some(mls) = self.mls_manager.clone() {
2633 self.ensure_session_establishment(&mls, &recipient_str)?;
2634 }
2635 return Err(Error::SessionNotReady(
2636 self.establishment_state(&recipient_str)?,
2637 ));
2638 }
2639
2640 // Encrypted chunks share the recipient's session ratchet with
2641 // text, and the receiver keeps out-of-order message keys for a
2642 // bounded number of generations — cap concurrent transfers so
2643 // the combined in-flight windows cannot push a delayed chunk
2644 // beyond that tolerance and permanently stall it.
2645 use crate::constants::{
2646 MAX_CONCURRENT_INTERNAL_MEDIA_TRANSFERS_PER_PEER,
2647 MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER,
2648 };
2649 let active_transfers = self
2650 .outbound_media_transfers
2651 .values()
2652 .filter(|transfer| transfer.recipient == recipient_str)
2653 .count();
2654 if active_transfers >= MAX_CONCURRENT_MEDIA_TRANSFERS_PER_PEER {
2655 return Err(Error::MediaTransferLimit(recipient_str));
2656 }
2657
2658 // An SDK-internal transfer takes at most one of those slots.
2659 // The cap above bounds the ratchet gap and so cannot be raised
2660 // for our own traffic; what is left is to leave the application
2661 // a slot it can account for. The in-flight dedup stops a second
2662 // copy of one transfer, not two different ones, and two
2663 // different ones are ordinary here: a document snapshot and an
2664 // answered blob request are separate errands to the same peer.
2665 // Filling both slots with them fails the app's own send with a
2666 // limit whose cause it cannot see, because what holds the slots
2667 // is invisible to it by design.
2668 if data_purpose.is_some() {
2669 let internal_transfers = self
2670 .outbound_media_transfers
2671 .values()
2672 .filter(|transfer| {
2673 transfer.recipient == recipient_str && transfer.data_purpose.is_some()
2674 })
2675 .count();
2676 if internal_transfers >= MAX_CONCURRENT_INTERNAL_MEDIA_TRANSFERS_PER_PEER {
2677 return Err(Error::MediaTransferLimit(recipient_str));
2678 }
2679 }
2680 } else if self.config.encryption.require_encryption {
2681 return Err(Error::EncryptFailed(
2682 "MLS encryption is required (the default) but MLS is not initialized — \
2683 call initialize_mls() with secure and protocol-state storage, or explicitly opt out with \
2684 require_encryption=false to send plaintext media"
2685 .to_string(),
2686 ));
2687 } else {
2688 // Explicit opt-out: the whole transfer leaves as legacy plaintext
2689 // chunks. Warn once per peer, not per chunk.
2690 self.warn_plaintext_send(&recipient_str);
2691 }
2692
2693 // Rich extras seal only toward recipients that advertised the rich
2694 // payload, and only on the encrypted path; everyone else — including
2695 // every plaintext (opt-out) transfer — gets the plain transfer.
2696 // Dropped here, never sent cleartext.
2697 let rich_extras = if !rich_extras.is_any() {
2698 None
2699 } else if self.should_auto_encrypt() && self.rich_seal_active(&recipient_str) {
2700 Some(rich_extras)
2701 } else {
2702 debug!(
2703 recipient = %recipient_str,
2704 "Dropping rich media extras: recipient did not advertise sealed rich payload support"
2705 );
2706 None
2707 };
2708
2709 let file_id = options
2710 .file_id
2711 .unwrap_or_else(|| format!("file_{}", MessageId::new().as_str()));
2712 let pinned_transport = self.select_media_transport(&recipient_str)?;
2713
2714 let (chunk_size, window_size) = match pinned_transport {
2715 TransportType::BLE => {
2716 use crate::constants::{CHUNK_SIZE_BLE, MEDIA_WINDOW_SIZE_BLE};
2717 (CHUNK_SIZE_BLE, MEDIA_WINDOW_SIZE_BLE)
2718 }
2719 TransportType::Internet => {
2720 use crate::constants::{CHUNK_SIZE_INTERNET, MEDIA_WINDOW_SIZE_INTERNET};
2721 (CHUNK_SIZE_INTERNET, MEDIA_WINDOW_SIZE_INTERNET)
2722 }
2723 TransportType::WiFiDirect => {
2724 use crate::constants::{DEFAULT_CHUNK_SIZE, DEFAULT_MEDIA_WINDOW_SIZE};
2725 (DEFAULT_CHUNK_SIZE, DEFAULT_MEDIA_WINDOW_SIZE)
2726 }
2727 TransportType::Reticulum => {
2728 // Reticulum is low-bandwidth (LoRa); use BLE-like chunk sizes.
2729 use crate::constants::{CHUNK_SIZE_BLE, MEDIA_WINDOW_SIZE_BLE};
2730 (CHUNK_SIZE_BLE, MEDIA_WINDOW_SIZE_BLE)
2731 }
2732 TransportType::Nostr => {
2733 // Nostr relays have decent bandwidth; use Internet-like chunk sizes.
2734 use crate::constants::{CHUNK_SIZE_INTERNET, MEDIA_WINDOW_SIZE_INTERNET};
2735 (CHUNK_SIZE_INTERNET, MEDIA_WINDOW_SIZE_INTERNET)
2736 }
2737 };
2738 let chunks = self.file_transfer_manager.chunk_file(
2739 file_id.clone(),
2740 file_name_str,
2741 file_data,
2742 Some(chunk_size),
2743 )?;
2744
2745 let total_chunks = chunks.len() as u32;
2746 self.outbound_media_transfers.insert(
2747 file_id.clone(),
2748 OutboundMediaTransfer {
2749 content_type,
2750 recipient: recipient_str.clone(),
2751 pinned_transport,
2752 total_chunks,
2753 delivered_chunks: HashSet::new(),
2754 last_updated_at: Instant::now(),
2755 media_metadata: media_metadata.clone(),
2756 rich_extras: rich_extras.clone(),
2757 data_purpose: data_purpose.clone(),
2758 app_id: app_id.clone(),
2759 },
2760 );
2761
2762 // Persist the crash-recovery descriptor (no chunk bytes) so a
2763 // restart mid-transfer can tell the app to re-initiate via
2764 // MediaResendRequired. A same-file_id resend consumes the restored
2765 // copy here — its checksum was validated at the boundary above.
2766 if let Some(first_chunk) = chunks.first() {
2767 let descriptor = MediaTransferDescriptor {
2768 file_id: file_id.clone(),
2769 recipient: recipient_str.clone(),
2770 file_name: first_chunk.file_name.clone(),
2771 file_size: first_chunk.file_size,
2772 file_checksum: first_chunk.file_checksum.clone(),
2773 content_type,
2774 queued_at: Utc::now(),
2775 data_purpose: data_purpose.clone(),
2776 app_id: app_id.as_ref().map(|id| id.as_str().to_string()),
2777 };
2778 self.restored_media_descriptors.remove(&file_id);
2779 self.persist_media_descriptor(&descriptor);
2780 }
2781
2782 let mut window_state = OutboundTransferState::new(chunks, window_size);
2783 let initial_batch = window_state.next_chunks_to_send();
2784 self.outbound_media_windows
2785 .insert(file_id.clone(), window_state);
2786
2787 // A data-purposed transfer is invisible to the application on this
2788 // side too. The receiver's user must not be shown a download they did
2789 // not start; the sender's must not be shown an upload they did not
2790 // make, and `MediaSent` for one would put a file in a conversation.
2791 if data_purpose.is_none() {
2792 let state = lock_shared_state(&self.shared_state)?;
2793 state.emit_event(Event::file_progress(file_id.clone(), 0, total_chunks));
2794 drop(state);
2795 }
2796
2797 self.send_media_chunk_batch(
2798 &file_id,
2799 initial_batch,
2800 &recipient_str,
2801 pinned_transport,
2802 content_type,
2803 media_metadata.as_ref(),
2804 rich_extras.as_ref(),
2805 data_purpose.as_ref(),
2806 app_id.as_ref(),
2807 )?;
2808
2809 Ok(file_id)
2810 }
2811
2812 /// Sends a batch of file chunks, wiring each into the outbox and media tracking.
2813 #[allow(clippy::too_many_arguments)]
2814 pub(super) fn send_media_chunk_batch(
2815 &mut self,
2816 file_id: &str,
2817 chunks: Vec<FileChunk>,
2818 recipient: &str,
2819 pinned_transport: TransportType,
2820 content_type: ContentType,
2821 media_metadata: Option<&MediaMetadata>,
2822 rich_extras: Option<&MediaRichExtras>,
2823 data_purpose: Option<&crate::media_envelope::DataPurpose>,
2824 app_id: Option<&AppId>,
2825 ) -> Result<()> {
2826 for chunk in chunks {
2827 let chunk_index = chunk.chunk_index;
2828
2829 let meta_for_chunk = if chunk_index == 0 {
2830 media_metadata.cloned()
2831 } else {
2832 None
2833 };
2834
2835 // SEC-H1: with auto-encryption active, chunk bytes AND the chunk-0
2836 // metadata (file name, preview thumbnail, original content type)
2837 // travel inside the MLS ciphertext — the wire Message carries none
2838 // of them. The plaintext fields are only populated when
2839 // auto-encryption is inactive. Rich extras (caption, reply,
2840 // forward) exist only on this sealed path, chunk 0 only; their
2841 // presence bumps the envelope to v2 so a pre-rich receiver
2842 // rejects cleanly instead of misparsing.
2843 let (binary_payload, wire_metadata, wire_original_ct) = if self.should_auto_encrypt() {
2844 let inner = MediaChunkPlaintext {
2845 chunk_bytes: chunk.to_bytes(),
2846 media_metadata: meta_for_chunk,
2847 original_content_type: (chunk_index == 0).then_some(content_type),
2848 rich_extras: if chunk_index == 0 {
2849 rich_extras.cloned()
2850 } else {
2851 None
2852 },
2853 data_purpose: if chunk_index == 0 {
2854 data_purpose.cloned()
2855 } else {
2856 None
2857 },
2858 };
2859 let envelope_version = inner.envelope_version();
2860 let sealed = inner
2861 .encode()
2862 .map_err(Error::Serialization)
2863 .and_then(|plaintext| {
2864 self.encrypt_bytes_for_recipient_strict(recipient, &plaintext)
2865 });
2866 let encrypted = match sealed {
2867 Ok(encrypted) => encrypted,
2868 Err(err) => {
2869 // The chunks this batch popped from the window are
2870 // marked in-flight with no outbox entry — nothing
2871 // would ever ACK or retry them, so the transfer can
2872 // only wedge while holding a per-peer transfer slot.
2873 // Encryption failure is not per-chunk transient:
2874 // abort the whole transfer loudly.
2875 self.abort_outbound_media_transfer(file_id, "chunk encryption failed");
2876 return Err(err);
2877 }
2878 };
2879 (
2880 encode_media_envelope(&encrypted, envelope_version),
2881 None,
2882 false,
2883 )
2884 } else {
2885 (chunk.to_bytes(), meta_for_chunk, chunk_index == 0)
2886 };
2887
2888 let mut message = self.create_media_message(
2889 recipient,
2890 String::new(),
2891 ContentType::FileChunk,
2892 wire_metadata,
2893 app_id.cloned(),
2894 )?;
2895 message.binary_content = Some(binary_payload);
2896
2897 if wire_original_ct {
2898 use crate::constants::ORIGINAL_CONTENT_TYPE_KEY;
2899 message.metadata.insert(
2900 ORIGINAL_CONTENT_TYPE_KEY.to_string(),
2901 content_type.to_string(),
2902 );
2903 }
2904 self.outbound_media_chunks
2905 .insert(message.id.clone(), (file_id.to_string(), chunk_index));
2906
2907 let previous_transport = self.transport_manager.current_transport();
2908 let send_result = self
2909 .transport_manager
2910 .send_via_transport(&message, pinned_transport);
2911 let current_transport = Some(pinned_transport);
2912
2913 match send_result {
2914 Ok(()) => {
2915 self.handle_send_success(&message, current_transport)?;
2916 self.emit_transport_switch_event(previous_transport, current_transport)?;
2917 }
2918 Err(err) => {
2919 let _ = self
2920 .handle_send_failure(&message, current_transport.or(previous_transport))?;
2921 // send_via_transport does not record retry failures internally.
2922 self.transport_manager
2923 .record_retry_failure(pinned_transport);
2924 warn!(
2925 file_id = %file_id,
2926 chunk_index = chunk_index,
2927 transport = ?pinned_transport,
2928 error = %err,
2929 "File chunk send failed, message deferred"
2930 );
2931 }
2932 }
2933 if !self.outbound_media_transfers.contains_key(file_id) {
2934 return Err(Error::Other(format!(
2935 "Media transfer {} could not be scheduled for reliable delivery",
2936 file_id
2937 )));
2938 }
2939 }
2940 Ok(())
2941 }
2942
2943 /// Pumps all active windowed media transfers, sending the next batch of
2944 /// chunks for any transfer whose window has capacity (previous chunks ACKed).
2945 /// Should be called from the periodic tick/poll loop.
2946 pub(super) fn pump_media_transfers(&mut self) {
2947 let file_ids: Vec<String> = self.outbound_media_windows.keys().cloned().collect();
2948
2949 for file_id in file_ids {
2950 let transfer = match self.outbound_media_transfers.get(&file_id) {
2951 Some(t) => t.clone(),
2952 None => {
2953 self.outbound_media_windows.remove(&file_id);
2954 continue;
2955 }
2956 };
2957
2958 let window = match self.outbound_media_windows.get_mut(&file_id) {
2959 Some(w) => w,
2960 None => continue,
2961 };
2962
2963 if !window.has_capacity() {
2964 continue;
2965 }
2966
2967 let batch = window.next_chunks_to_send();
2968 if batch.is_empty() {
2969 continue;
2970 }
2971
2972 if let Err(err) = self.send_media_chunk_batch(
2973 &file_id,
2974 batch,
2975 &transfer.recipient,
2976 transfer.pinned_transport,
2977 transfer.content_type,
2978 transfer.media_metadata.as_ref(),
2979 transfer.rich_extras.as_ref(),
2980 transfer.data_purpose.as_ref(),
2981 transfer.app_id.as_ref(),
2982 ) {
2983 warn!(
2984 file_id = %file_id,
2985 error = %err,
2986 "Failed to pump media transfer chunks"
2987 );
2988 }
2989 }
2990 }
2991
2992 /// Creates a message carrying media content (file chunks, etc.).
2993 ///
2994 /// Like `create_message` but sets `content_type`, `media_metadata`, marks
2995 /// the message as internet-preferred via metadata, and requires per-chunk ACKs.
2996 pub(super) fn create_media_message(
2997 &mut self,
2998 recipient: &str,
2999 content: impl Into<String>,
3000 content_type: ContentType,
3001 media_metadata: Option<MediaMetadata>,
3002 app_id: Option<AppId>,
3003 ) -> Result<Message> {
3004 use crate::constants::{TRANSPORT_PREFERENCE_INTERNET, TRANSPORT_PREFERENCE_KEY};
3005
3006 let sender = UserId::new(&self.local_id)?;
3007 let recipient = UserId::new(recipient)?;
3008 let app_id = match app_id {
3009 Some(app_id) => app_id,
3010 None => AppId::new(&self.config.app_id)?,
3011 };
3012
3013 let clock_value = self.lamport_clock.tick();
3014 self.persist_lamport_clock();
3015
3016 let mut builder = Message::builder(sender, recipient, app_id)
3017 .content(content)
3018 .content_type(content_type)
3019 .priority(MessagePriority::Medium)
3020 .ttl(TTL::new(self.config.initial_ttl)?)
3021 .lamport_clock(clock_value)
3022 .metadata(TRANSPORT_PREFERENCE_KEY, TRANSPORT_PREFERENCE_INTERNET)
3023 .requires_ack(true);
3024
3025 if let Some(meta) = media_metadata {
3026 builder = builder.media_metadata(meta);
3027 }
3028
3029 Ok(builder.build())
3030 }
3031
3032 // ========================================================================
3033 // OUTBOX
3034 // ========================================================================
3035
3036 pub(super) fn ensure_outbox_entry(&mut self, message: &Message) {
3037 if !message.requires_ack {
3038 return;
3039 }
3040
3041 let is_media = Self::is_media_outbox_message(message);
3042 let (outbox, capacity) = if is_media {
3043 use crate::constants::MAX_MEDIA_OUTBOX_ENTRIES;
3044 (&mut self.media_outbox, MAX_MEDIA_OUTBOX_ENTRIES)
3045 } else {
3046 (&mut self.outbox, MAX_OUTBOX_ENTRIES)
3047 };
3048
3049 if !outbox.contains_key(&message.id) && outbox.len() >= capacity {
3050 let oldest_id = outbox
3051 .iter()
3052 .min_by_key(|(_, entry)| entry.last_sent_at)
3053 .map(|(id, _)| id.clone());
3054 if let Some(oldest_id) = oldest_id {
3055 // Retired, not just removed: this function is also how the
3056 // retry and flush paths re-create an entry before resending,
3057 // so a retry entry left behind here would bring the message
3058 // back and evict another in its place.
3059 let attempt_count = self
3060 .retire_undeliverable_message(&oldest_id, "outbox eviction")
3061 .map_or(0, |entry| entry.attempt_count);
3062 if !is_media {
3063 // Capacity eviction is as terminal as expiry: the entry
3064 // and its persisted copy are gone, so without an event
3065 // the app shows the message as pending forever. (Media
3066 // chunks surface through the transfer abort above.)
3067 self.emit_event(Event::message_failed(
3068 oldest_id.clone(),
3069 "Outbox capacity exceeded",
3070 attempt_count,
3071 ));
3072 if let Some(recipient) = self.take_undeliverable_connection_request(&oldest_id)
3073 {
3074 warn!(
3075 recipient = %recipient,
3076 message_id = %oldest_id,
3077 "Connection request undeliverable: outbox capacity exceeded"
3078 );
3079 self.emit_event(Event::connection_request_undeliverable(
3080 recipient,
3081 oldest_id.as_str(),
3082 "outbox_capacity_exceeded",
3083 ));
3084 }
3085 }
3086 }
3087 }
3088
3089 // Consume staged re-seal provenance only when first creating a
3090 // (non-media) entry here (the failure/retry path); later calls find the
3091 // entry already present and leave its `reseal` intact.
3092 let staged_reseal = self.take_staged_reseal(message, is_media);
3093 let outbox = if is_media {
3094 &mut self.media_outbox
3095 } else {
3096 &mut self.outbox
3097 };
3098 let is_new = !outbox.contains_key(&message.id);
3099 outbox
3100 .entry(message.id.clone())
3101 .or_insert_with(|| OutboxEntry {
3102 message: message.clone(),
3103 attempt_count: 0,
3104 first_sent_at: Utc::now(),
3105 last_sent_at: Utc::now(),
3106 last_transport: None,
3107 reseal: staged_reseal,
3108 relay_pushed: false,
3109 });
3110 // Persist newly-created main-outbox entries so they survive a restart.
3111 // media entries are intentionally not persisted.
3112 if is_new && !is_media {
3113 if let Some(entry) = self.outbox.get(&message.id) {
3114 self.persist_outbox_entry(entry);
3115 }
3116 }
3117 }
3118
3119 pub(super) fn mark_message_sent(
3120 &mut self,
3121 message: &Message,
3122 transport: Option<TransportType>,
3123 attempt_hint: Option<u32>,
3124 ) {
3125 if !message.requires_ack {
3126 return;
3127 }
3128
3129 let now = Utc::now();
3130 let is_media = Self::is_media_outbox_message(message);
3131 // Consume the staged re-seal provenance only when first creating a
3132 // (non-media) outbox entry; on later attempts the entry already carries
3133 // it. Staging for media is never populated.
3134 let staged_reseal = self.take_staged_reseal(message, is_media);
3135 let outbox = if is_media {
3136 &mut self.media_outbox
3137 } else {
3138 &mut self.outbox
3139 };
3140 let is_new = !outbox.contains_key(&message.id);
3141 let entry = outbox
3142 .entry(message.id.clone())
3143 .or_insert_with(|| OutboxEntry {
3144 message: message.clone(),
3145 attempt_count: 0,
3146 first_sent_at: now,
3147 last_sent_at: now,
3148 last_transport: transport,
3149 reseal: staged_reseal,
3150 relay_pushed: false,
3151 });
3152
3153 entry.message = message.clone();
3154 if entry.attempt_count == 0 {
3155 entry.first_sent_at = now;
3156 }
3157 entry.attempt_count = attempt_hint.unwrap_or(entry.attempt_count.saturating_add(1));
3158 entry.last_sent_at = now;
3159 entry.last_transport = transport;
3160
3161 // Persist only when the entry is newly created here. The success path
3162 // never calls ensure_outbox_entry, so this is the one point a
3163 // successfully-sent message first enters the durable outbox before its
3164 // ACK. Later attempts don't re-persist: the durable copy already exists
3165 // and attempt/timestamp churn isn't restore-critical (the TTL is
3166 // refreshed on restore anyway), which keeps secure-storage writes off
3167 // the retry hot path. Media entries are never persisted.
3168 if is_new && !is_media {
3169 if let Some(entry) = self.outbox.get(&message.id) {
3170 self.persist_outbox_entry(entry);
3171 }
3172 }
3173 }
3174
3175 /// Stages re-seal provenance for a message about to be dispatched, so the
3176 /// outbox entry created during dispatch carries it. Callers gate on the
3177 /// content being a sealed encrypted DM (`__MLS_ENC__`); media and plaintext
3178 /// never stage and thus always replay verbatim.
3179 pub(super) fn stage_outbox_reseal(&mut self, id: &MessageId, reseal: OutboxReseal) {
3180 self.pending_reseal.insert(id.clone(), reseal);
3181 }
3182
3183 /// Consumes any staged re-seal provenance for `message`, attaching it to a
3184 /// (non-media) main-outbox entry being created for the first time. On later
3185 /// attempts the entry already exists and carries its `reseal`, so this
3186 /// returns `None`. Media never stages.
3187 ///
3188 /// **Always removes the staged copy** (it does not leave it behind when it
3189 /// returns `None`): the staging map is strictly transient and must never
3190 /// outlive its message, or a message that stages but is then dropped before
3191 /// an outbox entry is created (e.g. `dispatch_prepared_message` early-returns
3192 /// on a duplicate id) would strand plaintext in `pending_reseal` for the
3193 /// process lifetime. Removing unconditionally here — plus the belt-and-braces
3194 /// clear in [`Self::remove_outbox_entry`] — bounds the map to genuinely
3195 /// in-flight sends. Kept as one helper so the two outbox-entry creation sites
3196 /// ([`Self::ensure_outbox_entry`] and [`Self::mark_message_sent`]) stay in
3197 /// lockstep on the consume condition.
3198 fn take_staged_reseal(&mut self, message: &Message, is_media: bool) -> Option<OutboxReseal> {
3199 let staged = self.pending_reseal.remove(&message.id);
3200 if is_media || self.outbox.contains_key(&message.id) {
3201 return None;
3202 }
3203 staged
3204 }
3205
3206 /// Re-seals an outbound encrypted DM against the recipient's *current* MLS
3207 /// session for a resend, returning fresh sealed content, or `None` to replay
3208 /// verbatim. Returns `None` when it is not a re-sealable encrypted DM, when
3209 /// there is no stored provenance (plaintext, media, or a pre-upgrade
3210 /// persisted entry), when the session is not currently confirmed, or when
3211 /// the re-seal produced identical bytes.
3212 ///
3213 /// This is the Tier 2 core: after a desync + re-key the recipient's session
3214 /// is rebuilt at a new epoch, so replaying the ciphertext sealed at the old
3215 /// epoch is undecryptable forever. Re-sealing here rebuilds only the
3216 /// ciphertext `content`; the outer `Message.id` is preserved so the
3217 /// receiver's dedup and ACK correlation are unaffected. Gating on a
3218 /// confirmed session guarantees `prepare_outbound_content` takes the
3219 /// encrypt-now path and never the `store_pending` queueing path.
3220 fn reseal_resend_content(&mut self, message: &Message) -> Option<String> {
3221 if !message.content.starts_with(internal_prefixes::ENCRYPTED) {
3222 return None;
3223 }
3224 let recipient = message.recipient.as_str().to_string();
3225 if !self.confirmed_sessions.contains(&recipient) {
3226 return None;
3227 }
3228 let reseal = self.outbox.get(&message.id)?.reseal.as_ref()?.clone();
3229 match self.prepare_outbound_content(
3230 &recipient,
3231 &reseal.content,
3232 reseal.priority,
3233 reseal.reply_to_msg.clone(),
3234 reseal.forwarded_from.clone(),
3235 reseal.content_type,
3236 reseal.media_metadata.clone(),
3237 reseal.rich.as_ref(),
3238 Some(&message.app_id),
3239 Some(PendingProvenance::for_id(message.id.clone())),
3240 "resend_reseal",
3241 ) {
3242 Ok(OutboundSendPreparation::Ready(sealed)) if sealed != message.content => Some(sealed),
3243 // Identical bytes, or an error: replay verbatim this round. Tier 1
3244 // keeps the sender retrying until the session heals.
3245 //
3246 // The `Queued` arm is all but unreachable — the `confirmed_sessions`
3247 // gate above runs synchronously with no yield before the encrypt, so
3248 // the session cannot flip to not-ready in between. If it ever does
3249 // (e.g. `confirmed_sessions` outlived an externally-deleted MLS
3250 // session), `prepare_outbound_content` has enqueued a pending message
3251 // as a side effect, keyed by our SAME `existing_id`. That is benign:
3252 // this round still replays the verbatim outbox bytes, and when the
3253 // pending copy later flushes, the sender's own `dispatch_prepared_message`
3254 // dedup drops it as a duplicate id. So no double-delivery, just a
3255 // transient pending entry that self-clears on flush.
3256 _ => None,
3257 }
3258 }
3259
3260 /// Re-seals `message` in place against the current session if it is a
3261 /// re-sealable encrypted DM (see [`Self::reseal_resend_content`]). Used at
3262 /// the resend transmit points so the bytes that go out — and the outbox copy
3263 /// `mark_message_sent` then stores — are freshly sealed.
3264 pub(super) fn reseal_for_resend_in_place(&mut self, message: &mut Message) {
3265 if let Some(fresh) = self.reseal_resend_content(message) {
3266 message.content = fresh;
3267 }
3268 }
3269
3270 pub(super) fn remove_outbox_entry(&mut self, message_id: &MessageId) -> Option<OutboxEntry> {
3271 // Belt-and-braces: drop any still-staged re-seal provenance for this id
3272 // so it never outlives the message (see `take_staged_reseal`). Normally
3273 // staging is consumed at entry creation; this covers the id being torn
3274 // down before that ever happened.
3275 self.pending_reseal.remove(message_id);
3276 // A receipt names an outbox entry; with the entry gone it names
3277 // nothing, and keeping it would only grow the map.
3278 self.custody_receipts.remove(message_id);
3279 if let Some(entry) = self.outbox.remove(message_id) {
3280 if !Self::is_confirmation_probe(&entry.message) {
3281 self.clear_outbox_entry_from_storage(message_id);
3282 }
3283 return Some(entry);
3284 }
3285 self.media_outbox.remove(message_id)
3286 }
3287
3288 /// Takes `message_id` out of the three places that can send it again:
3289 /// the retry queue, the pending-ACK tracker and the outbox. One without
3290 /// the others resurrects it (see [`Self::retire_undeliverable_message`]).
3291 /// No failure is recorded against the carrier: that is the give-up
3292 /// path's, and a message withdrawn for being superseded did not fail.
3293 pub(super) fn forget_outbound_message(
3294 &mut self,
3295 message_id: &MessageId,
3296 ) -> Option<OutboxEntry> {
3297 self.retry_queue.remove(&message_id.as_str());
3298 self.ack_manager.remove_ack(message_id);
3299 self.remove_outbox_entry(message_id)
3300 }
3301
3302 /// Takes a message this device has given up on out of everything that
3303 /// could send it again, returning its outbox entry if it still had one.
3304 ///
3305 /// A settlement is only terminal if nothing can bring the message back
3306 /// afterwards, and three things can: the retry queue, the pending-ACK
3307 /// tracker (whose timeout re-queues it), and the outbox. The retry and
3308 /// flush paths re-create a missing outbox entry before resending, with
3309 /// fresh clocks, so an id dropped from the outbox but left queued for
3310 /// retry is never expired by anything. At capacity each of those resends
3311 /// evicted a live message to make room, and that message's own retry entry
3312 /// did the same: a device holding more than a full outbox of undeliverable
3313 /// messages reported a "terminal" `message_failed` on every retry,
3314 /// indefinitely, for ids it had already settled. An id left in the ACK
3315 /// tracker is settled a second time when its timeout finds no entry.
3316 ///
3317 /// Every give-up path calls this, so the teardown is written once. It
3318 /// emits nothing: which event a drop owes the application differs by
3319 /// path, and stays with the caller. `media_reason` is what the aborted
3320 /// transfer reports when the id is a media chunk.
3321 pub(super) fn retire_undeliverable_message(
3322 &mut self,
3323 message_id: &MessageId,
3324 media_reason: &str,
3325 ) -> Option<OutboxEntry> {
3326 self.handle_outbound_media_chunk_failed(message_id, media_reason);
3327 let entry = self.forget_outbound_message(message_id);
3328 if let Some(transport) = entry.as_ref().and_then(|entry| entry.last_transport) {
3329 self.transport_manager.record_delivery_failure(transport);
3330 }
3331 entry
3332 }
3333
3334 /// Settles the pending connection-request entry for `message_id` when a
3335 /// terminal drop occurs (max retries, outbox expiry, capacity eviction),
3336 /// returning the recipient the caller should emit
3337 /// `connection_request_undeliverable` for. The TTL gates emission — a
3338 /// signal that stale belongs to a request the app has long stopped
3339 /// waiting on — but the entry is removed either way.
3340 pub(super) fn take_undeliverable_connection_request(
3341 &mut self,
3342 message_id: &MessageId,
3343 ) -> Option<String> {
3344 self.pending_connection_requests
3345 .remove(&message_id.as_str())
3346 .filter(|pending| pending.sent_at.elapsed() <= PENDING_CONNECTION_REQUEST_TTL)
3347 .map(|pending| pending.recipient)
3348 }
3349
3350 /// Removes messages that have waited too long for MLS session
3351 /// establishment. This gives the pre-outbox queue the same bounded
3352 /// lifecycle guarantee as dispatched messages.
3353 pub(super) fn cleanup_expired_pending_messages(&mut self) {
3354 self.cleanup_expired_pending_messages_at(Utc::now());
3355 }
3356
3357 /// Runs pending-message expiry only when the earliest queued deadline is
3358 /// due. Bindings call `process()` every 100 ms, so blindly scanning the
3359 /// entire queue there wastes CPU and battery for almost the whole default
3360 /// seven-day lifetime.
3361 pub(super) fn cleanup_expired_pending_messages_if_due(&mut self) {
3362 let now = Utc::now();
3363 if self
3364 .next_pending_message_expiry
3365 .is_some_and(|expiry| now >= expiry)
3366 {
3367 self.cleanup_expired_pending_messages_at(now);
3368 }
3369 }
3370
3371 fn cleanup_expired_pending_messages_at(&mut self, now: DateTime<Utc>) {
3372 let lifetime_ms = self
3373 .config
3374 .reliability
3375 .retry
3376 .pending_message_max_lifetime_ms;
3377 let mut changed_recipients = Vec::new();
3378 let mut expired_ids = Vec::new();
3379
3380 for (recipient, messages) in &mut self.pending_encrypted_messages {
3381 let previous_len = messages.len();
3382 messages.retain(|pending| {
3383 // Bounded per pass: each expiry costs a durable delete, and a
3384 // burst of entries queued together comes due together. The
3385 // remainder stays queued past its deadline and drains on the
3386 // next tick — see `MAX_PENDING_EXPIRIES_PER_PASS`.
3387 if expired_ids.len() < MAX_PENDING_EXPIRIES_PER_PASS
3388 && lifetime_expired(now, pending.queued_at, lifetime_ms)
3389 {
3390 expired_ids.push(pending.message_id.clone());
3391 false
3392 } else {
3393 true
3394 }
3395 });
3396 if messages.len() != previous_len {
3397 changed_recipients.push(recipient.clone());
3398 }
3399 }
3400
3401 for recipient in changed_recipients {
3402 if self
3403 .pending_encrypted_messages
3404 .get(&recipient)
3405 .is_some_and(Vec::is_empty)
3406 {
3407 self.pending_encrypted_messages.remove(&recipient);
3408 }
3409 }
3410 // The survivors' records are untouched, so only the expired entries are
3411 // written to storage — one delete each, paired with the settlement
3412 // below.
3413 self.delete_pending_messages_from_storage(expired_ids.iter());
3414 self.recompute_next_pending_message_expiry();
3415
3416 for message_id in expired_ids {
3417 // Reached both from `process()` (emits immediately) and from
3418 // restore, which runs before the event pipeline is live.
3419 self.settle_restored_message_failure(Event::message_failed(
3420 message_id,
3421 "Pending session lifetime exceeded",
3422 0,
3423 ));
3424 }
3425 }
3426
3427 pub(super) fn cleanup_outbox(&mut self) {
3428 let now = Utc::now();
3429 let lifetime_ms = self.config.reliability.retry.outbox_max_lifetime_ms;
3430 // In-process twin of the restore path's absolute cap
3431 // (`restore_outbox`): the carrier-relative window slides on every
3432 // send, and an unreachable-parked DM's mesh reachability probe keeps
3433 // sending — without an absolute bound from `first_sent_at`, a
3434 // long-lived process would keep such an entry alive forever.
3435 let absolute_lifetime_ms =
3436 lifetime_ms.saturating_mul(crate::constants::OUTBOX_ABSOLUTE_LIFETIME_FACTOR as u64);
3437
3438 let mut expired_from_outbox = Vec::new();
3439 for (message_id, entry) in &self.outbox {
3440 // A relay-pushed entry's fixed window is a hard stop: it does not
3441 // wait out an in-flight probe, whose re-sends are exactly what
3442 // the window exists to end.
3443 let push_window_closed = super::relay_push_window_closed(now, entry);
3444 if !push_window_closed
3445 && !lifetime_expired(now, entry.last_sent_at, lifetime_ms)
3446 && !lifetime_expired(now, entry.first_sent_at, absolute_lifetime_ms)
3447 {
3448 continue;
3449 }
3450 if !push_window_closed
3451 && entry.message.requires_ack
3452 && self.ack_manager.is_waiting_for_ack(&entry.message.id)
3453 {
3454 continue;
3455 }
3456 expired_from_outbox.push((message_id.clone(), entry.attempt_count, entry.relay_pushed));
3457 }
3458 for (message_id, attempt_count, relay_pushed) in expired_from_outbox {
3459 self.retire_undeliverable_message(&message_id, "outbox lifetime exceeded");
3460
3461 // Outcome unknown, so nothing app-facing: the push may already
3462 // have delivered it (the `park_relay_pushed_dm` contract), and
3463 // `MessageSent` already told the app it left. `message_failed`
3464 // would invite a manual resend under a fresh id that the
3465 // recipient's dedup cannot collapse. Only plain DMs are ever
3466 // flagged, so there is no connection request to settle either.
3467 if relay_pushed {
3468 debug!(
3469 message_id = %message_id,
3470 "Relay-pushed DM reached its redelivery window; dropping it silently"
3471 );
3472 continue;
3473 }
3474
3475 // Expiry is terminal: without an event the app shows the message
3476 // as pending forever. Mirrors handle_max_retries_exceeded, which
3477 // settles the same way when the retry budget runs out.
3478 self.emit_event(Event::message_failed(
3479 message_id.clone(),
3480 "Outbox lifetime exceeded",
3481 attempt_count,
3482 ));
3483 if let Some(recipient) = self.take_undeliverable_connection_request(&message_id) {
3484 warn!(
3485 recipient = %recipient,
3486 message_id = %message_id,
3487 "Connection request undeliverable: outbox lifetime exceeded"
3488 );
3489 self.emit_event(Event::connection_request_undeliverable(
3490 recipient,
3491 message_id.as_str(),
3492 "outbox_lifetime_exceeded",
3493 ));
3494 }
3495 }
3496
3497 // Only peers that still hold outbox entries can retain an
3498 // unreachable-park counter (bounds the map at the outbox cap).
3499 if !self.dm_unreachable_parks.is_empty() {
3500 let active: HashSet<&str> = self
3501 .outbox
3502 .values()
3503 .map(|entry| entry.message.recipient.as_str())
3504 .collect();
3505 self.dm_unreachable_parks
3506 .retain(|peer, _| active.contains(peer.as_str()));
3507 }
3508
3509 let mut expired_from_media = Vec::new();
3510 for (message_id, entry) in &self.media_outbox {
3511 if !lifetime_expired(now, entry.last_sent_at, lifetime_ms) {
3512 continue;
3513 }
3514 if entry.message.requires_ack && self.ack_manager.is_waiting_for_ack(&entry.message.id)
3515 {
3516 continue;
3517 }
3518 expired_from_media.push(message_id.clone());
3519 }
3520 for message_id in expired_from_media {
3521 self.retire_undeliverable_message(&message_id, "outbox lifetime exceeded");
3522 }
3523 }
3524
3525 pub(super) fn ensure_ack_registration(&mut self, message: &Message) -> Result<bool> {
3526 if !message.requires_ack {
3527 return Ok(false);
3528 }
3529
3530 if self.ack_manager.is_waiting_for_ack(&message.id) {
3531 Ok(false)
3532 } else {
3533 if let Some(eviction) = self
3534 .ack_manager
3535 .register_pending_ack(message.id.clone(), None)?
3536 {
3537 self.emit_event(Event::ack_evicted(
3538 eviction.message_id,
3539 eviction.priority.as_str(),
3540 "capacity".to_string(),
3541 ));
3542 }
3543 Ok(true)
3544 }
3545 }
3546
3547 pub(super) fn is_media_outbox_message(message: &Message) -> bool {
3548 message.content_type == ContentType::FileChunk
3549 }
3550
3551 /// A session confirmation probe lives in memory only. It carries no user
3552 /// data, and a restarted process does not know which probe was the last,
3553 /// so a restored one could never be superseded; the next scan asks the
3554 /// same question. Persisting it cost a secure-storage write per probe and
3555 /// a delete per supersede, under the protocol lock.
3556 pub(super) fn is_confirmation_probe(message: &Message) -> bool {
3557 message
3558 .content
3559 .starts_with(internal_prefixes::SESSION_CONFIRM_PROBE)
3560 }
3561
3562 // ========================================================================
3563 // DELIVERY TRACKING
3564 // ========================================================================
3565
3566 /// Handles successful message send.
3567 /// Whether a frame for `peer_id` can arrive without a neighbour carrying
3568 /// it, given what this device knows about *this recipient*.
3569 ///
3570 /// [`TransportManager::can_reach_without_carrying`] answers the same
3571 /// question from carrier status alone, and so says yes for every recipient
3572 /// while any infrastructure carrier is up. This is that answer with the
3573 /// per-recipient facts applied: a carrier that has already told us it
3574 /// cannot reach this recipient stops counting as a way to reach them.
3575 ///
3576 /// Two properties hold by construction:
3577 ///
3578 /// - **A carrier holding no fact is unchanged.** Nostr cannot report
3579 /// per-recipient delivery at all, so it never produces a fact and keeps
3580 /// its blanket claim. That is deliberate: the mixed-neighbourhood
3581 /// residual stays open for Nostr-only devices rather than closing by
3582 /// accident under a design that never gave them a verdict to act on.
3583 /// - **A live mesh link always wins.** It is ground truth rather than a
3584 /// claim, and it is checked whether or not any fact exists.
3585 pub(crate) fn can_reach_recipient(&self, peer_id: &str) -> bool {
3586 let now = Instant::now();
3587 let infrastructure_reaches_peer = self
3588 .transport_manager
3589 .available_infrastructure_carriers()
3590 .into_iter()
3591 .any(|carrier| {
3592 !matches!(
3593 self.reachability.claim_for(peer_id, carrier, now),
3594 Some(Claim::Unreachable)
3595 )
3596 });
3597 infrastructure_reaches_peer || self.transport_manager.mesh_can_address(peer_id)
3598 }
3599
3600 pub(super) fn handle_send_success(
3601 &mut self,
3602 message: &Message,
3603 transport: Option<TransportType>,
3604 ) -> Result<()> {
3605 self.mark_message_sent(message, transport, Some(1));
3606 self.ensure_ack_registration(message)?;
3607
3608 // A transport reporting success is not always evidence the frame can
3609 // arrive. Wi-Fi Direct and Reticulum accept any recipient and return
3610 // `Ok`, so a send to someone we hold no link to is queued for a link
3611 // that never drains — and because the mesh hand-off used to hang off
3612 // the *failure* path, a device with either carrier up would swallow the
3613 // frame instead of asking its neighbors to carry it. That is precisely
3614 // the out-of-range case forwarding exists for, so the question is asked
3615 // directly rather than inferred from an error that never comes.
3616 //
3617 // Offering here is additive, never a replacement: the outbox entry and
3618 // the ACK registration above stand either way, and if the accepting
3619 // carrier did deliver after all, the recipient's deduplicator absorbs
3620 // the second copy.
3621 if !self.can_reach_recipient(message.recipient.as_str()) {
3622 let handed_to_mesh = self.offer_to_mesh(message);
3623 if handed_to_mesh > 0 {
3624 debug!(
3625 message_id = %message.id,
3626 recipient = %message.recipient,
3627 handed_to_mesh,
3628 "Transport accepted a recipient it cannot reach; also handed to neighbors"
3629 );
3630 }
3631 }
3632 Ok(())
3633 }
3634
3635 /// Handles failed message send by persisting to outbox and scheduling retry.
3636 ///
3637 /// EDGE CASE HANDLING:
3638 /// - Ensures message is persisted to outbox for recovery
3639 /// - Schedules retry with exponential backoff
3640 /// - Handles case where all transports are unavailable
3641 ///
3642 /// NOTE: This does NOT call `record_retry_failure` — callers that need it
3643 /// (e.g. `send_via_forced_transport`) must record the failure themselves.
3644 /// `TransportManager::send()` already records failures internally, so
3645 /// calling it here would double-count.
3646 /// Returns the absolute time the retry was scheduled for (`None` when the
3647 /// message was already queued for retry), so callers can surface it in
3648 /// `MessageDeferred` instead of guessing.
3649 pub(super) fn handle_send_failure(
3650 &mut self,
3651 message: &Message,
3652 transport: Option<TransportType>,
3653 ) -> Result<Option<DateTime<Utc>>> {
3654 // Ensure message is persisted to outbox for recovery
3655 self.ensure_outbox_entry(message);
3656
3657 // No transport could reach the recipient directly — but a neighbor
3658 // might be able to, or know someone who can. Offer the frame to the
3659 // mesh before settling for a retry: this is the case the whole
3660 // forwarding path exists for, someone out of our range but inside the
3661 // crowd's.
3662 //
3663 // The outbox entry and retry stay in place regardless. Handing a frame
3664 // to neighbors is not proof it arrived, and the acknowledgement coming
3665 // back is what settles it.
3666 let handed_to_mesh = self.offer_to_mesh(message);
3667
3668 // Schedule retry (enqueue is infallible — no attempt limit)
3669 let next_retry_at = self.retry_queue.enqueue(message.clone(), 0);
3670
3671 warn!(
3672 message_id = %message.id,
3673 transport = ?transport,
3674 handed_to_mesh,
3675 "Deferred message due to send error"
3676 );
3677 Ok(next_retry_at)
3678 }
3679
3680 /// Hands a locally-originated frame to nearby devices so it can travel
3681 /// toward a recipient we cannot reach ourselves.
3682 ///
3683 /// Returns how many neighbors took a copy. Zero means we are alone, or
3684 /// every neighbor refused the link — in both cases the retry ladder and the
3685 /// outbox remain the recovery path, exactly as before.
3686 ///
3687 /// Unlike a forwarded frame, this one is not held back: the delay before
3688 /// forwarding exists so neighbors holding the *same* frame do not all
3689 /// transmit at once, and nobody else is holding this one yet.
3690 pub(super) fn offer_to_mesh(&mut self, message: &Message) -> usize {
3691 // Deliberately not gated on `allow_relay`. That setting is about what
3692 // this device does for *other people* — whether it spends its battery
3693 // carrying their traffic. This is the device's own message, and the
3694 // ones it must be able to send include the acknowledgement for a
3695 // message it just received. Gating it here would leave a
3696 // relay-declining device unable to answer anything that reached it
3697 // across the mesh, so its sender would retransmit to exhaustion and
3698 // report a failure for a message that was delivered and read.
3699
3700 // Never hand a self-addressed frame to the mesh. Those are the relay
3701 // hint frames (`__GRP_RELAY_REG__`, `__GRP_RELAY_BCAST__`), which mean
3702 // something only to our own bridge: a neighbor receiving one would see
3703 // a frame addressed to somebody else and carry it further, spending
3704 // airtime to deliver a control op nobody can act on. They are pinned to
3705 // Internet for the same reason.
3706 if message.recipient.as_str() == self.local_id {
3707 return 0;
3708 }
3709
3710 let neighbors = self.transport_manager.mesh_neighbors();
3711 if neighbors.is_empty() {
3712 return 0;
3713 }
3714
3715 // Ours to originate: record it as handled so a copy coming back to us
3716 // through the mesh is recognized and not forwarded again.
3717 self.mesh_relay.mark_handled(&message.id.as_str());
3718
3719 // If the recipient is standing right there, hand it to them. Reached
3720 // here it means the ordinary send could not use that link, but trying
3721 // it costs one call and beats routing around the destination.
3722 let recipient = message.recipient.as_str();
3723 let targets = if neighbors.iter().any(|n| n.peer_id == recipient) {
3724 vec![recipient.to_string()]
3725 } else {
3726 // Full width regardless of this device's battery. Capability bias
3727 // narrows the fan-out for *forwarded* frames, where neighbors hold
3728 // copies and the sender is still retrying so the cost is
3729 // redundancy. This frame is ours and nobody else is holding it, so
3730 // the fan-out is the delivery attempt itself — and since targets
3731 // are chosen stably per message id, a narrowed set would be the
3732 // same narrowed set on every retransmission.
3733 self.mesh_relay.select_origination_targets(
3734 neighbors
3735 .iter()
3736 .map(|n| (n.peer_id.as_str(), n.link_quality())),
3737 &[],
3738 &message.id.as_str(),
3739 )
3740 };
3741
3742 // The deposit request (`docs/spec/custody.md`, "What the depositor
3743 // writes"): a class token on this device's own sealed replication
3744 // frame, judged from the plaintext retained for re-sealing, written
3745 // only here, where the frame is handed to proven mesh neighbours
3746 // because the recipient is out of reach. Never toward a custodian
3747 // whose receipt for this frame is still live.
3748 let custody_class = self.custody_class_for(message);
3749
3750 let mut handed_to = 0usize;
3751 for target in targets {
3752 // Metered against the same ceiling as carrying other people's
3753 // traffic: it is the same radio. Without this a large transfer,
3754 // which offers every chunk separately, would put an unbounded burst
3755 // on the air in one call.
3756 //
3757 // Own traffic spends from the reserve that forwarding leaves alone,
3758 // so a device carrying the neighborhood at its ceiling can still
3759 // get its own frames out — an acknowledgement above all, whose
3760 // absence makes a delivered message look lost to its sender.
3761 if !self.mesh_relay.take_own_send_token() {
3762 debug!(
3763 message_id = %message.id,
3764 "At the forwarding limit; not handing this to further neighbors"
3765 );
3766 break;
3767 }
3768
3769 let deposit;
3770 let frame: &Message = match custody_class {
3771 Some(class) if !self.custody_suppressed_toward(&message.id, &target) => {
3772 let mut stamped = message.clone();
3773 stamped
3774 .metadata
3775 .insert(CUSTODY_META_KEY.to_string(), class.to_string());
3776 deposit = stamped;
3777 &deposit
3778 }
3779 _ => message,
3780 };
3781
3782 match self.transport_manager.send_to_neighbor(&target, frame) {
3783 Ok(transport) => {
3784 handed_to += 1;
3785 debug!(
3786 message_id = %message.id,
3787 recipient = %message.recipient,
3788 next_hop = %target,
3789 transport = ?transport,
3790 deposit = frame.metadata.contains_key(CUSTODY_META_KEY),
3791 "Handed message to a neighbor to carry"
3792 );
3793 }
3794 Err(err) => {
3795 debug!(
3796 message_id = %message.id,
3797 next_hop = %target,
3798 error = %err,
3799 "Neighbor could not take the message"
3800 );
3801 }
3802 }
3803 }
3804
3805 handed_to
3806 }
3807
3808 /// Confirms that a message was successfully sent by the transport layer.
3809 pub fn on_transport_send_confirmed(&mut self, message_id: &str) -> Result<()> {
3810 let Some(peer_id) = self.find_welcome_peer_by_message_id(message_id) else {
3811 return Ok(());
3812 };
3813
3814 let updated = self
3815 .welcome_lifecycles
3816 .get(&peer_id)
3817 .cloned()
3818 .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
3819
3820 if matches!(
3821 updated.state,
3822 WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
3823 ) {
3824 return Ok(());
3825 }
3826
3827 // A relay DeliveryError verdict outranks a late wire confirm: the
3828 // socket write succeeded, but the relay saw the frame and dropped it
3829 // (recipient offline). When the verdict raced ahead of this confirm,
3830 // the record is already parked `Failed`/`PeerUnreachable`; letting
3831 // the confirm proceed would resurrect the false `Sent` the verdict
3832 // just corrected — and emit welcome_send_succeeded *after* the
3833 // corrective welcome_send_failed. Any legitimate new send attempt
3834 // moves the record to `SendAttempted` first, so `Failed` here means
3835 // this confirm belongs to the very send the relay already failed.
3836 if updated.state == WelcomeDeliveryState::Failed
3837 && updated.last_reason_code == Some(crate::events::WelcomeReasonCode::PeerUnreachable)
3838 {
3839 return Ok(());
3840 }
3841
3842 // A wire confirm for a reachability probe must not upgrade the
3843 // record to `Sent`. With a live unreachable-park counter the send in
3844 // flight IS the escalating probe — every reachability edge clears the
3845 // counter before sending anything else — and the socket write
3846 // succeeding adds nothing the park didn't already know. Whenever the
3847 // relay's push fallback succeeds it returns no `DeliveryError` at
3848 // all, so a probe marked `Sent` here would go quiet forever:
3849 // `next_retry_at` cleared below, `Sent` outside every retry scan, and
3850 // `rearm_welcome_for_peer` a no-op on it — the exact edge-only dead
3851 // end the probe exists to close, reachable only through this bridge
3852 // confirm (mesh carriers never wire-confirm). Leaving the record
3853 // `SendAttempted` keeps the confirm deadline armed: a relay verdict
3854 // re-parks it, and an accepted-but-unanswered frame resolves at the
3855 // confirm timeout, which re-parks via
3856 // `welcome_probe_repark_permitted`. Genuine delivery still converges
3857 // regardless — the peer proving the session marks the welcome `Sent`
3858 // through `confirm_session_state`. This also withholds the
3859 // `welcome_send_succeeded` a probe's socket write would otherwise
3860 // emit, which the next verdict or timeout would only have to correct.
3861 if matches!(updated.state, WelcomeDeliveryState::SendAttempted)
3862 && updated.unreachable_parks > 0
3863 {
3864 return Ok(());
3865 }
3866
3867 {
3868 let record = self.welcome_lifecycles.get_mut(&peer_id).ok_or_else(|| {
3869 Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
3870 })?;
3871 record.next_retry_at = None;
3872 record.last_reason_code = None;
3873 record.last_transport_error = None;
3874 }
3875 self.transition_welcome_state(&peer_id, WelcomeDeliveryState::Sent, "transport_confirmed")?;
3876
3877 let sent_snapshot = self
3878 .welcome_lifecycles
3879 .get(&peer_id)
3880 .cloned()
3881 .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
3882 if let Ok(state) = lock_shared_state(&self.shared_state) {
3883 state.emit_event(Event::welcome_send_succeeded(
3884 peer_id.clone(),
3885 sent_snapshot.welcome_message.id.as_str().to_string(),
3886 sent_snapshot.group_id,
3887 sent_snapshot.attempt,
3888 ));
3889 }
3890
3891 // The welcome is now confirmed as sent. Send a confirmation probe
3892 // immediately so the session can be confirmed without waiting for the
3893 // next process() tick — an optimistic fast-path for the common case
3894 // where the transport confirms promptly.
3895 self.send_session_confirmation_probe(&peer_id, "transport_confirmed");
3896
3897 Ok(())
3898 }
3899
3900 /// Handles asynchronous transport send failures for pending welcome
3901 /// sends and outbound connection requests.
3902 pub fn on_transport_send_failed(
3903 &mut self,
3904 message_id: &str,
3905 transport_error: Option<String>,
3906 ) -> Result<()> {
3907 self.on_transport_send_failed_via(message_id, transport_error, None)
3908 }
3909
3910 /// [`Self::on_transport_send_failed`] with the carrier that reported it.
3911 ///
3912 /// The carrier is what turns a failure into a *fact*: "the recipient is
3913 /// not reachable" is meaningless on its own, since a device may hold
3914 /// several carriers and only one of them is making the claim. Bridges that
3915 /// know which transport answered pass it; the plain form above keeps
3916 /// working for callers that do not, and records no fact rather than
3917 /// guessing one.
3918 pub fn on_transport_send_failed_via(
3919 &mut self,
3920 message_id: &str,
3921 transport_error: Option<String>,
3922 carrier: Option<TransportType>,
3923 ) -> Result<()> {
3924 // Classified at the boundary, once, and never carried past it.
3925 //
3926 // The platform bridges build this string from the relay's own
3927 // `DeliveryError.reason` (`recipient_unreachable: <relay text>`), and
3928 // the Nostr bridge from a relay's `OK` rejection text — remote-chosen,
3929 // unbounded, and downstream of here it reaches three event fields the
3930 // telemetry scrubber ships verbatim, each beside a recipient it
3931 // hashes. Even the honest relay renders the peer into its wording.
3932 //
3933 // The token itself is load-bearing and survives: the two prefix tests
3934 // below and the DM park key on `recipient_unreachable`, so classifying
3935 // to the bare token keeps every protocol decision intact and drops
3936 // only the prose. The raw string stays in this one device log.
3937 if let Some(raw) = transport_error.as_deref() {
3938 debug!(
3939 message_id = %message_id,
3940 transport_error = %raw.chars().take(256).collect::<String>(),
3941 "Platform reported a send failure"
3942 );
3943 }
3944 let transport_error = transport_error
3945 .as_deref()
3946 .map(classify_transport_send_error);
3947 // Whether the relay's mailbox holds this frame is orthogonal to why
3948 // the socket write missed, and it changes exactly one thing: a parked
3949 // plain DM needs no reachability probe, because the relay re-sends the
3950 // held copy on the recipient's next connection. So it is lifted out
3951 // here and the token normalized onto the vocabulary the rest of this
3952 // function already speaks — which is what leaves connection requests
3953 // and Welcomes on the paths they are on today.
3954 let stored = matches!(
3955 transport_error,
3956 Some(SEND_FAIL_REASON_RELAY_STORED | SEND_FAIL_REASON_RELAY_PUSHED_STORED)
3957 );
3958 let transport_error = match transport_error {
3959 Some(SEND_FAIL_REASON_RELAY_STORED) => Some(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE),
3960 Some(SEND_FAIL_REASON_RELAY_PUSHED_STORED) => Some(SEND_FAIL_REASON_RELAY_PUSHED),
3961 other => other,
3962 };
3963 // A relay push is not a failure, so it is handled before either typed
3964 // branch below can read it as one: a connection request would be
3965 // fast-failed as undeliverable and a Welcome moved to `Failed`, both
3966 // for a frame the push may have delivered.
3967 if transport_error == Some(SEND_FAIL_REASON_RELAY_PUSHED) {
3968 self.park_relay_pushed_dm(message_id, carrier, stored);
3969 return Ok(());
3970 }
3971 // Connection requests first: the relay's "recipient offline"
3972 // DeliveryError is the only fast, authoritative failure signal a
3973 // request gets (there is no ACK from an offline peer — the app
3974 // would otherwise wait out the full retry budget for a generic
3975 // message_failed). Non-unreachable errors stay with the generic
3976 // retry machinery: the request is queued/retried and may still
3977 // deliver.
3978 if let Some(pending) = self.pending_connection_requests.get(message_id) {
3979 // The TTL must hold at read time too: the insert-path prune only
3980 // runs on the next `send_connection_request`, so an idle map can
3981 // hold an entry far past the correlation window — a failure
3982 // signal that stale belongs to a request the app has long
3983 // stopped waiting on and must not fire the event.
3984 if pending.sent_at.elapsed() > PENDING_CONNECTION_REQUEST_TTL {
3985 self.pending_connection_requests.remove(message_id);
3986 } else {
3987 let unreachable = transport_error == Some(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE);
3988 if unreachable {
3989 let recipient = pending.recipient.clone();
3990 self.pending_connection_requests.remove(message_id);
3991 warn!(
3992 recipient = %recipient,
3993 message_id = %message_id,
3994 "Connection request undeliverable: recipient unreachable"
3995 );
3996 if let Ok(state) = lock_shared_state(&self.shared_state) {
3997 state.emit_event(Event::connection_request_undeliverable(
3998 recipient,
3999 message_id.to_string(),
4000 transport_error.unwrap_or(SEND_FAIL_REASON_TRANSPORT),
4001 ));
4002 }
4003 return Ok(());
4004 }
4005 }
4006 }
4007
4008 let Some(peer_id) = self.find_welcome_peer_by_message_id(message_id) else {
4009 // Not a connection request, not a welcome: a plain DM or media
4010 // chunk. Surface the relay's verdict and park plain DMs so the
4011 // ACK retry budget stops burning against an offline peer.
4012 self.handle_recipient_unreachable_for_message(
4013 message_id,
4014 transport_error,
4015 carrier,
4016 stored,
4017 );
4018 return Ok(());
4019 };
4020 // A reason tagged "recipient_unreachable" (the internet bridge's
4021 // translation of the relay's DeliveryError) is authoritative proof
4022 // the frame was dropped: the carrier is up but *this peer* is not on
4023 // it. It must be handled even for records already wire-confirmed
4024 // (`Sent`) — the bridge confirms on socket-write success, before the
4025 // relay can answer, so the DeliveryError normally arrives when the
4026 // record is already Sent and the plain failure path below would
4027 // no-op on it, stranding a false `Sent`.
4028 let peer_unreachable = transport_error == Some(SEND_FAIL_REASON_RECIPIENT_UNREACHABLE);
4029 if peer_unreachable {
4030 if let Some(carrier) = carrier {
4031 self.reachability.record(
4032 &peer_id,
4033 carrier,
4034 Claim::Unreachable,
4035 FactSource::GatewayVerdict,
4036 Instant::now(),
4037 );
4038 }
4039 // A held verdict that finds its Welcome already `Failed` is the
4040 // second report of one send, not a second failure. The bridges
4041 // report the id a `stored` verdict names even after that
4042 // recipient's in-flight set was drained, because for a plain DM
4043 // the second report is what retires the probe the first one
4044 // scheduled. So a Welcome anywhere but first in a burst is failed
4045 // by the drain as `recipient_unreachable`, then reported again as
4046 // `relay_stored` by its own verdict. Applied twice, the failure
4047 // climbs two rungs of the Welcome ladder for one send and tells
4048 // the app `welcome_send_failed` twice.
4049 //
4050 // `Failed` is what identifies the repeat: a resend moves the record
4051 // to `SendAttempted` before it writes, so the verdict for a fresh
4052 // send never finds it there. Holding the frame changes nothing for
4053 // a Welcome, which keeps its own ladder either way, so skipping the
4054 // repeat loses nothing. The fact above is still recorded, because
4055 // the relay did say it again.
4056 if stored
4057 && self
4058 .welcome_lifecycles
4059 .get(&peer_id)
4060 .is_some_and(|record| record.state == WelcomeDeliveryState::Failed)
4061 {
4062 debug!(
4063 peer_id = %peer_id,
4064 message_id = %message_id,
4065 "Held verdict for a Welcome this send already failed; not failing it twice"
4066 );
4067 return Ok(());
4068 }
4069 return self.apply_recipient_unreachable_failure(&peer_id, transport_error);
4070 }
4071 let reason = crate::events::WelcomeReasonCode::TransportUnavailable;
4072 // No raw error is available on this async path, so infer no-carrier
4073 // from live connectivity: with no transport currently available the
4074 // peer is simply unreachable and the Welcome must be kept alive, not
4075 // aged.
4076 let no_carrier = self.transport_manager.get_available_transports().is_empty();
4077 let _ = self.apply_welcome_send_failure(
4078 &peer_id,
4079 reason,
4080 transport_error,
4081 no_carrier,
4082 "transport_failed",
4083 )?;
4084 Ok(())
4085 }
4086
4087 /// Handles a relay `recipient_unreachable` verdict for a plain DM or
4088 /// media chunk: emits the non-terminal [`Event::MessageUndeliverable`],
4089 /// then *parks* plain DMs.
4090 ///
4091 /// Parking drops the pending ACK and the retry-queue entry while the
4092 /// outbox entry stays put — exactly the "stranded" state every re-drive
4093 /// edge already flushes (`flush_outbox_all` on reconnect/start,
4094 /// `flush_outbox_for_peer_via` on discovery and presence-online, the latter
4095 /// fed by [`Self::presence_watch_peers`]). Without the park, the offline
4096 /// peer's missing ACK burns the full `max_retries` budget in minutes
4097 /// (`process_timed_out_acks`) and settles a 7-day outbox message
4098 /// terminally; a re-driven send re-registers a fresh ACK, so the budget
4099 /// normally burns only against a peer believed reachable. The exception
4100 /// is the reachability probe below: its send re-enters the ACK machinery
4101 /// but may never earn a relay verdict (a mesh carrier cannot produce one
4102 /// at all), so exhaustion with a live park counter *re-parks* instead of
4103 /// settling ([`Self::try_repark_exhausted_dm`]). Settlement is reserved
4104 /// for delivery or outbox-lifetime expiry.
4105 ///
4106 /// The park always keeps a timed reachability probe whose interval
4107 /// escalates per consecutive park (15s → 600s cap, per-peer counter reset
4108 /// on any reachability edge). It is deliberately carrier-agnostic: with a
4109 /// local mesh carrier the peer may be a room away — possibly already a
4110 /// discovered neighbor, so no future edge would fire for it — and on an
4111 /// internet-only device the external edges (presence rotation, reconnect)
4112 /// are the *only* other recovery, which leaves delivery hostage to a
4113 /// bridge's polling cadence. See [`Self::park_unreachable_dm`] for why
4114 /// probing over the relay is self-limiting.
4115 ///
4116 /// Media chunks are never parked: their offline story is retry
4117 /// exhaustion → transfer abort → persisted descriptor →
4118 /// `MediaResendRequired` with app-resupplied bytes. Messages without an
4119 /// outbox entry (nothing to attribute a recipient from) are skipped.
4120 fn handle_recipient_unreachable_for_message(
4121 &mut self,
4122 message_id: &str,
4123 transport_error: Option<&'static str>,
4124 carrier: Option<TransportType>,
4125 stored: bool,
4126 ) {
4127 let Some(reason) = transport_error.filter(|r| *r == SEND_FAIL_REASON_RECIPIENT_UNREACHABLE)
4128 else {
4129 return;
4130 };
4131 let Ok(parsed_id) = MessageId::from_str(message_id) else {
4132 return;
4133 };
4134 let (entry, is_media) = match self.outbox.get(&parsed_id) {
4135 Some(entry) => (entry, false),
4136 None => match self.media_outbox.get(&parsed_id) {
4137 Some(entry) => (entry, true),
4138 None => {
4139 if !self.note_superseded_probe_verdict(&parsed_id, carrier) {
4140 debug!(
4141 message_id = %message_id,
4142 "Unreachable verdict for message without outbox entry, dropping"
4143 );
4144 }
4145 return;
4146 }
4147 },
4148 };
4149 let recipient = entry.message.recipient.as_str().to_string();
4150 let attempt_count = entry.attempt_count;
4151 // A verdict for a frame the relay has already pushed is the answer to
4152 // our own probe (`OutboxEntry::relay_pushed`): the relay refuses to
4153 // notify twice, so it says `DeliveryError` for a message that may be
4154 // sitting delivered on the recipient's device. Everything below still
4155 // applies (the recipient is not on the relay, so record it, back the
4156 // probe off, and re-park), except the app-facing event: telling the
4157 // app the message is undeliverable would be exactly the claim
4158 // `park_relay_pushed_dm` exists not to make. Media is never pushed
4159 // and never parked, so the flag is only ever read off the main outbox.
4160 let relay_pushed = !is_media && entry.relay_pushed;
4161 // The verdict, recorded as a fact before anything acts on it. Parking
4162 // below reacts to *this* message; the fact is what lets the next send
4163 // to the same recipient skip a carrier that has already said no.
4164 if let Some(carrier) = carrier {
4165 self.reachability.record(
4166 &recipient,
4167 carrier,
4168 Claim::Unreachable,
4169 FactSource::GatewayVerdict,
4170 Instant::now(),
4171 );
4172 }
4173 // Media never parks, so the mesh offer the park performs for a DM has
4174 // to happen here instead. Cloned while the entry is still borrowed;
4175 // only on a verdict, so not a per-chunk cost.
4176 let media_frame = is_media.then(|| entry.message.clone());
4177 let file_id = self
4178 .outbound_media_chunks
4179 .get(&parsed_id)
4180 .map(|(file_id, _)| file_id.clone());
4181 // If this peer is also an unconfirmed-session probe target, fold the
4182 // verdict into the probe schedule so it backs off (15s -> 600s) instead
4183 // of re-probing every 5s forever. No-op for peers that are not probe
4184 // targets (e.g. an ordinary DM to a confirmed peer). Done here, after
4185 // the `entry` borrow of `self.outbox` has ended.
4186 self.note_confirmation_probe_unreachable(&recipient);
4187 if relay_pushed {
4188 debug!(
4189 message_id = %message_id,
4190 "Recipient unreachable for a relay-pushed DM: re-parking without notifying the app"
4191 );
4192 } else {
4193 warn!(
4194 message_id = %message_id,
4195 file_id = ?file_id,
4196 parked = !is_media,
4197 "Recipient unreachable for in-flight message (non-terminal)"
4198 );
4199 self.emit_event(Event::message_undeliverable(
4200 parsed_id.clone(),
4201 recipient.clone(),
4202 reason,
4203 file_id,
4204 ));
4205 }
4206 if let Some(frame) = media_frame {
4207 // The recipient is not on the relay, but they may be a few devices
4208 // away. A chunk keeps its pending ACK (media is never parked), so
4209 // an answer coming back over the mesh settles it through the
4210 // ordinary path — nothing else is needed here.
4211 let handed_to_mesh = self.offer_to_mesh(&frame);
4212 if handed_to_mesh > 0 {
4213 debug!(
4214 message_id = %message_id,
4215 recipient = %recipient,
4216 handed_to_mesh,
4217 "Relay cannot reach this chunk's recipient; handed it to neighbors"
4218 );
4219 }
4220 return;
4221 }
4222
4223 self.park_unreachable_dm(&parsed_id, &recipient, attempt_count, stored);
4224 }
4225
4226 /// Whether a platform send report also belongs in the carrier's delivery
4227 /// metrics (`Transport::report_send_failure`).
4228 ///
4229 /// Every report does except the two push tokens. The bridges hand the
4230 /// relay's `MessageSent { pushed: true }` to
4231 /// [`Self::on_transport_send_failed_via`] because that is their one call
4232 /// for "the relay answered about this id", but the relay took the frame
4233 /// into a device push, which is not a failed send. A wrapper that scores
4234 /// the report without asking here records a failure whenever the relay's
4235 /// answer beats the bridge's own write confirmation, against the carrier
4236 /// the router ranks by that ratio.
4237 ///
4238 /// `relay_stored` is not in that company: it answers a `DeliveryError`,
4239 /// where the write really did miss. That the mailbox kept a copy says
4240 /// nothing about the carrier.
4241 pub fn send_report_is_carrier_failure(reason: Option<&str>) -> bool {
4242 !matches!(
4243 reason.map(classify_transport_send_error),
4244 Some(SEND_FAIL_REASON_RELAY_PUSHED | SEND_FAIL_REASON_RELAY_PUSHED_STORED)
4245 )
4246 }
4247
4248 /// Handles the relay's `MessageSent { pushed: true }` answer: the recipient
4249 /// has no live socket, and the frame went out in a push notification.
4250 ///
4251 /// A plain DM is parked exactly as for a `DeliveryError`: without the park
4252 /// the missing ACK burns the retry budget to a terminal `message_failed`.
4253 /// If the push did deliver, the recipient's acknowledgement settles the
4254 /// parked entry (`settle_parked_dm_from_ack`).
4255 ///
4256 /// `stored` is the relay saying its mailbox also holds the frame
4257 /// (`MessageSent { pushed: true, stored: true }`, reported as
4258 /// [`SEND_FAIL_REASON_RELAY_PUSHED_STORED`]). It reaches
4259 /// [`Self::park_unreachable_dm`] and nothing else: the park is identical
4260 /// but for its recovery, which becomes the relay's own redelivery instead
4261 /// of a timed probe. Without it — an older relay, a mailbox turned off, a
4262 /// store that failed — the probe stands, because then a lost push really
4263 /// does mean nothing re-delivers the frame when the recipient reconnects.
4264 ///
4265 /// Two differences from the `DeliveryError` path, both because the push
4266 /// may have delivered the frame:
4267 ///
4268 /// - **Plain DMs only.** A connection request keeps its typed tracking and
4269 /// a Welcome its lifecycle, both awaiting the acknowledgement or session
4270 /// confirmation a delivered push produces. `is_parkable_plain_dm`
4271 /// excludes both, and media chunks are never in `outbox`.
4272 /// - **No `MessageUndeliverable`, now or on any later probe.** The app
4273 /// would be told a message is undeliverable when it may already have
4274 /// arrived. The park's timed probe resends the same id, and the relay
4275 /// answers a retry of a pushed message with `DeliveryError` rather than
4276 /// a second notification, which the bridges can only report as
4277 /// `recipient_unreachable`. So the entry is flagged
4278 /// (`OutboxEntry::relay_pushed`, persisted) and
4279 /// [`Self::handle_recipient_unreachable_for_message`] re-parks a flagged
4280 /// entry silently. Without the flag the guarantee would last exactly
4281 /// one probe interval.
4282 ///
4283 /// The flag also bounds the entry: it is dropped, silently and for the
4284 /// same reason, once [`offline_protocol_reliability::constants::REDELIVERY_WINDOW_MS`]
4285 /// has passed since `first_sent_at`, in process (`cleanup_outbox`) and at
4286 /// restore alike. Neither the sliding lifetime nor the restore refresh
4287 /// extends it — a re-send past the relay's push-claim TTL is a second
4288 /// visible notification, and past the recipient's dedup window it is a
4289 /// replay that fails to decrypt and is never acknowledged.
4290 fn park_relay_pushed_dm(
4291 &mut self,
4292 message_id: &str,
4293 carrier: Option<TransportType>,
4294 stored: bool,
4295 ) {
4296 let Ok(parsed_id) = MessageId::from_str(message_id) else {
4297 return;
4298 };
4299 if !self.is_parkable_plain_dm(&parsed_id) {
4300 self.note_superseded_probe_verdict(&parsed_id, carrier);
4301 return;
4302 }
4303 let Some(entry) = self.outbox.get_mut(&parsed_id) else {
4304 return;
4305 };
4306 let recipient = entry.message.recipient.as_str().to_string();
4307 let attempt_count = entry.attempt_count;
4308 let newly_pushed = !entry.relay_pushed;
4309 entry.relay_pushed = true;
4310 if newly_pushed {
4311 // Re-persisted on the flip only: the entry was written when it
4312 // was created, and the flag has to survive a restart for the
4313 // silent re-park to hold on the first probe after one.
4314 if let Some(entry) = self.outbox.get(&parsed_id) {
4315 self.persist_outbox_entry(entry);
4316 }
4317 }
4318 // The relay has said this recipient is not on it, which is the same
4319 // fact a `DeliveryError` records, and the same thing the probe backoff
4320 // keys on.
4321 if let Some(carrier) = carrier {
4322 self.reachability.record(
4323 &recipient,
4324 carrier,
4325 Claim::Unreachable,
4326 FactSource::GatewayVerdict,
4327 Instant::now(),
4328 );
4329 }
4330 self.note_confirmation_probe_unreachable(&recipient);
4331 debug!(
4332 message_id = %message_id,
4333 stored = stored,
4334 "Relay pushed a DM to an offline recipient; parking it"
4335 );
4336 self.park_unreachable_dm(&parsed_id, &recipient, attempt_count, stored);
4337 }
4338
4339 /// The park action shared by the relay-verdict path
4340 /// ([`Self::handle_recipient_unreachable_for_message`]) and the
4341 /// exhausted-probe path ([`Self::try_repark_exhausted_dm`]): drops the
4342 /// pending ACK and any scheduled retry so nothing burns budget against
4343 /// the offline peer, offers the frame to the mesh, then schedules the
4344 /// escalating reachability probe.
4345 ///
4346 /// `stored` drops that last step. The relay's mailbox is holding the
4347 /// frame and will deliver it on the recipient's next connection, so the
4348 /// park keeps everything that stops budget burning and adds no traffic of
4349 /// its own; recovery is the relay's drain, with the reachability edge
4350 /// behind it as for every parked frame. Everything the probe rationale
4351 /// below argues for still holds for an unheld frame.
4352 ///
4353 /// Two things that rationale does *not* cover, both deliberate:
4354 ///
4355 /// - **What is actually saved is the rungs, not the re-drive.** The edge
4356 /// fires on any reconnect, not only on an eviction, and
4357 /// [`Self::flush_outbox_for_peer_via`] selects on "in the outbox, not
4358 /// awaiting an ACK" — so a returning peer re-drives a held frame
4359 /// alongside the relay's own drain, exactly as it would an unheld one.
4360 /// Dedup collapses the pair. What the flag removes is the 15s→600s
4361 /// ladder *while the peer is away*, which is the traffic the relay asks
4362 /// senders not to generate. It follows that past the mailbox's own
4363 /// retention the delivery guarantee is that edge and the outbox
4364 /// lifetime — the same trade `edge_driven_unreachable_dm` opts into for
4365 /// every parked frame, taken unconditionally here because the mailbox is
4366 /// already committed to the redelivery.
4367 /// - **`stored` is not persisted on the entry, unlike
4368 /// `OutboxEntry::relay_pushed`.** Nothing needs it to be: a restart
4369 /// re-drives the outbox, the relay answers `stored` again for a frame it
4370 /// still holds, and the park is re-derived from that answer — whereas
4371 /// `relay_pushed` has to survive, because it suppresses an app-facing
4372 /// event no later answer would restate. Persisting this one would also
4373 /// be wrong on its own terms: it records what the relay's mailbox held
4374 /// at park time, and the mailbox can evict between then and the next
4375 /// launch, so a restored flag would suppress the probe on the strength
4376 /// of a copy that may be gone.
4377 ///
4378 /// The mesh offer is here rather than at either caller so both parks make
4379 /// it, and because this is where the frame is already at hand. Its rationale
4380 /// is inline below; the short version is that a relay verdict is the only
4381 /// per-peer reachability fact this device ever receives, and it says the
4382 /// recipient is somewhere the relay cannot go.
4383 ///
4384 /// The probe is carrier-agnostic — DORS picks its transport like any
4385 /// other send — because an internet-only device is the common
4386 /// configuration, not an exotic one, and parking it edge-only leaves the
4387 /// message with *no* self-recovery at all: no pending ACK to time out, no
4388 /// retry entry, nothing on a timer. Recovery then depends entirely on an
4389 /// external reachability edge (`flush_outbox_all` on reconnect/start, or
4390 /// a presence-online answer), i.e. on a bridge's presence-polling cadence
4391 /// — tens of seconds to minutes, and nothing at all for a headless
4392 /// consumer that never polls presence.
4393 ///
4394 /// Probing over the relay is self-limiting in every outcome, which is why
4395 /// no carrier guard is needed:
4396 /// - the peer is still offline and the relay's push fallback fails → a
4397 /// fresh `DeliveryError` re-enters this park, escalating the interval
4398 /// (15s → 600s cap) and re-emitting the non-terminal
4399 /// [`Event::MessageUndeliverable`];
4400 /// - the relay's push fallback succeeds → the relay answers
4401 /// `MessageSent { pushed: true }`, which parks the message again
4402 /// ([`Self::park_relay_pushed_dm`]) and escalates the interval. That
4403 /// happens once per push: the relay remembers which
4404 /// `(sender, recipient, message_id)` triples it has pushed, for 7 days
4405 /// (one day on older relays), and answers a repeat inside that window
4406 /// with `DeliveryError`
4407 /// (`already_pushed`) instead of a second notification. The outbox id
4408 /// is stable across every probe, so each later rung earns that
4409 /// `DeliveryError`, which re-parks the entry silently
4410 /// (`OutboxEntry::relay_pushed`) rather than re-emitting
4411 /// [`Event::MessageUndeliverable`] for a frame the push may have
4412 /// delivered;
4413 /// - the peer is back → the probe *is* the delivery, which beats waiting
4414 /// for any presence edge.
4415 ///
4416 /// The counter is per-*peer* while the probes are per-*message*: a burst
4417 /// of N DMs to one offline peer escalates the shared ladder once per
4418 /// park, so no individual message climbs 15s → 600s — the Nth already
4419 /// starts high on it. The compensating edge is delivery: an ACK for any
4420 /// one of the peer's DMs re-drives the rest immediately
4421 /// ([`Self::handle_ack_message`]) instead of leaving them on their
4422 /// escalated timers.
4423 ///
4424 /// Relay traffic is bounded differently in each of those branches, and
4425 /// the difference matters for capacity planning now that the whole
4426 /// internet-only fleet is on this path:
4427 /// - verdict branch: the escalation is the bound — one frame per interval
4428 /// per parked *message*, settling at one per 600s;
4429 /// - accepted branch: the probe registers a fresh ACK, and
4430 /// [`crate::protocol::OfflineProtocol::process_retry_queue`] seeds that
4431 /// ACK with the retry-queue entry's carried count (`retry_count + 1`)
4432 /// rather than letting it restart at 0. A probe whose entry is fresh (a
4433 /// reachability edge re-drove it with a new budget) still walks the full
4434 /// ladder — up to `max_retries` sends, 1s → 300s backoff, ~800s
4435 /// cumulative on the defaults — but a probe whose entry already carries
4436 /// backoff resumes near its current position and re-parks after roughly
4437 /// one more timeout instead of replaying the 1s floor. Plan relay
4438 /// capacity against the fresh-entry number; the carried case only sends
4439 /// less. Either way `try_repark_exhausted_dm` re-parks it at the
4440 /// escalated interval. The carry-forward is load-bearing: without it a
4441 /// never-ACKing (offline) recipient's probe pins `delay_for_retry` at
4442 /// its 1s floor and floods the relay ~once per second.
4443 ///
4444 /// The outbox lifetime bounds the entry itself — and note the probe
4445 /// refreshes `last_sent_at` on every send, so the sliding 7-day window
4446 /// stops binding and settlement moves out to the absolute cap
4447 /// ([`crate::constants::OUTBOX_ABSOLUTE_LIFETIME_FACTOR`] × the lifetime),
4448 /// or, for a relay-pushed entry, to its fixed redelivery window (see
4449 /// [`Self::park_relay_pushed_dm`]).
4450 /// Note also that a probe can be routed onto the relay when a mesh
4451 /// carrier is up — DORS has always been free to choose it — so this path
4452 /// is not new behavior for the relay, only newly reachable on
4453 /// internet-only devices.
4454 ///
4455 /// The counter increment is unconditional for the same reason: it is what
4456 /// arms [`Self::try_repark_exhausted_dm`], so a probe that exhausts its
4457 /// ACK budget re-parks instead of settling terminally.
4458 fn park_unreachable_dm(
4459 &mut self,
4460 message_id: &MessageId,
4461 recipient: &str,
4462 attempt_count: u32,
4463 stored: bool,
4464 ) {
4465 // Park: no pending ACK to time out, no backoff-scheduled resend.
4466 self.ack_manager.remove_ack(message_id);
4467 self.retry_queue.remove(&message_id.as_str());
4468
4469 let parks = {
4470 let count = self
4471 .dm_unreachable_parks
4472 .entry(recipient.to_string())
4473 .or_insert(0);
4474 *count = count.saturating_add(1);
4475 *count
4476 };
4477 // parks >= 1 here; shift clamped so 15 << 6 = 960 is the largest
4478 // pre-cap value — no overflow risk.
4479 let retry_in_secs = (WELCOME_NO_CARRIER_RETRY_SECS << (parks - 1).min(6))
4480 .min(WELCOME_UNREACHABLE_RETRY_CAP_SECS);
4481 let mut handed_to_mesh = 0usize;
4482 if let Some(entry) = self.outbox.get(message_id) {
4483 let message = entry.message.clone();
4484 // Ask the neighbors. The relay has just told us something no local
4485 // transport status can: that *this peer* is not on it. That is the
4486 // peer-specific fact `can_reach_without_carrying` cannot supply —
4487 // it answers yes for every recipient while an infrastructure
4488 // carrier is up, which is why the send-time offer never fires on an
4489 // online device and why the whole mixed-neighborhood case (an
4490 // online sender, a recipient reachable only across the mesh) used
4491 // to wait here for a peer that was never going to come online.
4492 //
4493 // Additive, exactly like the send-time offer: the outbox entry and
4494 // the probe below stand either way, since a neighbor taking a copy
4495 // is not proof of arrival. What settles it is the acknowledgement
4496 // coming back — over the mesh, for a message the relay could not
4497 // deliver, which is why parked DMs must be settleable without a
4498 // pending ACK (`settle_parked_dm_from_ack`). Without that arm this
4499 // offer would deliver messages the sender never learns about.
4500 //
4501 // Re-offering on every park is cheap by construction: neighbors
4502 // suppress a copy of an id they have already carried for the whole
4503 // `RelaySeenCache` retention window, the escalation (15s → 600s)
4504 // bounds how often we ask, and each offer spends the same own-send
4505 // tokens as any other frame of ours.
4506 handed_to_mesh = self.offer_to_mesh(&message);
4507 // Default: keep the documented perpetual timed probe (15s->600s cap
4508 // on every carrier). Opt-in (`edge_driven_unreachable_dm`): after a
4509 // bounded number of probes, stop the timer and leave the message in
4510 // the outbox edge-driven — re-driven only when the peer next proves
4511 // reachable (inbound frame / presence-online -> on_neighbor_discovered
4512 // -> flush_outbox_for_peer_via). This zeroes steady-state relay
4513 // traffic to a durably-gone peer, at the cost of the timed-probe
4514 // self-recovery guarantee for a silent returning peer.
4515 // `stored`: the relay's mailbox holds this frame and re-sends it
4516 // on the recipient's next connection, so the probe has nothing
4517 // left to achieve — it would re-store the same row, once per rung,
4518 // for as long as the peer stays away. Edge-driven unconditionally
4519 // here, not behind `edge_driven_unreachable_dm`: that opt-in
4520 // trades away a delivery guarantee, while this is the one case
4521 // where something else already carries it.
4522 let edge_driven = stored
4523 || (self.config.reliability.retry.edge_driven_unreachable_dm
4524 && parks > crate::protocol::types::DM_UNREACHABLE_PROBE_LIMIT);
4525 if !edge_driven {
4526 let _ = self.retry_queue.enqueue_with_delay(
4527 message,
4528 attempt_count,
4529 (retry_in_secs * 1000) as u64,
4530 );
4531 }
4532 }
4533 debug!(
4534 message_id = %message_id,
4535 recipient = %recipient,
4536 retry_in_secs = retry_in_secs,
4537 parks = parks,
4538 handed_to_mesh,
4539 stored = stored,
4540 "Parked unreachable DM"
4541 );
4542 }
4543
4544 /// Re-parks a plain DM whose ACK retry budget just exhausted while its
4545 /// recipient still holds a live unreachable-park counter, returning
4546 /// `true` when the caller (`handle_max_retries_exceeded`) must skip
4547 /// terminal settlement.
4548 ///
4549 /// A live counter means the relay declared the peer offline and no
4550 /// reachability edge has fired since (every edge clears it), so the
4551 /// exhausted budget was burnt by reachability probes — sends that succeed
4552 /// locally without proving the peer is back — not by a peer believed
4553 /// reachable. Over a mesh carrier such a probe can never earn a relay
4554 /// verdict at all; over the relay a verdict normally re-parks the message
4555 /// (resetting its ACK budget) well before exhaustion, so reaching here on
4556 /// an internet-only device means the probes were accepted for delivery
4557 /// but never answered. Settling terminally here would
4558 /// reintroduce the ~15-minute `message_failed` the park exists to
4559 /// prevent; re-parking keeps settlement reserved for delivery or
4560 /// outbox-lifetime expiry (the escalation cap bounds probe traffic, the
4561 /// outbox lifetime + absolute cap bound the entry itself).
4562 ///
4563 /// Deliberately narrow: pending connection requests keep their typed
4564 /// terminal path, welcomes keep their own lifecycle, and media chunks
4565 /// (never in `outbox`, and never counted) keep retry exhaustion →
4566 /// transfer abort → `MediaResendRequired`.
4567 pub(super) fn try_repark_exhausted_dm(&mut self, message_id: &MessageId) -> bool {
4568 if !self.is_parkable_plain_dm(message_id) {
4569 return false;
4570 }
4571 let Some(entry) = self.outbox.get(message_id) else {
4572 return false;
4573 };
4574 let recipient = entry.message.recipient.as_str();
4575 if !self.dm_unreachable_parks.contains_key(recipient) {
4576 return false;
4577 }
4578 let recipient = recipient.to_string();
4579 let attempt_count = entry.attempt_count;
4580 debug!(
4581 message_id = %message_id,
4582 recipient = %recipient,
4583 "ACK budget exhausted by reachability probes; re-parking instead of settling"
4584 );
4585 // Not `stored`: a held DM is never probed, so it does not burn an ACK
4586 // budget and does not arrive here. Anything that does got here by
4587 // probing, and keeps the probe it was re-parked from.
4588 self.park_unreachable_dm(message_id, &recipient, attempt_count, false);
4589 true
4590 }
4591
4592 /// True when `message_id` is a plain DM eligible for the
4593 /// unreachable-park machinery: not a pending connection request (typed
4594 /// terminal path), not an MLS welcome (own lifecycle), and still present
4595 /// in the outbox. Media chunks live in `media_outbox`, so they never
4596 /// pass the outbox check.
4597 pub(super) fn is_parkable_plain_dm(&self, message_id: &MessageId) -> bool {
4598 if self
4599 .pending_connection_requests
4600 .contains_key(&message_id.as_str())
4601 {
4602 return false;
4603 }
4604 if self
4605 .find_welcome_peer_by_message_id(&message_id.as_str())
4606 .is_some()
4607 {
4608 return false;
4609 }
4610 self.outbox.contains_key(message_id)
4611 }
4612
4613 /// Cancels the in-flight probe state (pending ACK + scheduled retry) of
4614 /// every parkable plain DM addressed to `peer_id`, so a
4615 /// reachability-edge flush can re-drive them.
4616 ///
4617 /// Only acts when the peer holds a live unreachable-park counter: a
4618 /// live counter means the relay declared the peer offline and no edge
4619 /// has fired since, so a pending ACK on the peer's DMs belongs to a
4620 /// reachability probe — a send that locally succeeded but has not been
4621 /// answered — not to a delivery in good standing.
4622 /// Without this cancel, the flush edges' awaiting-ACK filters skip
4623 /// exactly the probing messages while still clearing the counter,
4624 /// stranding them on minutes-scale ACK backoff after the relay already
4625 /// reported the peer online — and, with the counter gone, exposing them
4626 /// to terminal settlement on exhaustion (`try_repark_exhausted_dm`
4627 /// finds nothing). Canceling an ACK that was genuinely about to arrive
4628 /// is safe: receivers dedupe and re-ACK duplicates.
4629 ///
4630 /// Does NOT clear the counter itself — the flush edge owns that reset,
4631 /// after this runs.
4632 pub(super) fn cancel_probe_state_for_parked_peer(&mut self, peer_id: &str) {
4633 if !self.dm_unreachable_parks.contains_key(peer_id) {
4634 return;
4635 }
4636 let probing: Vec<MessageId> = self
4637 .outbox
4638 .values()
4639 .filter(|entry| entry.message.recipient.as_str() == peer_id)
4640 .map(|entry| entry.message.id.clone())
4641 .collect();
4642 for message_id in probing {
4643 if !self.is_parkable_plain_dm(&message_id) {
4644 continue;
4645 }
4646 self.ack_manager.remove_ack(&message_id);
4647 self.retry_queue.remove(&message_id.as_str());
4648 }
4649 }
4650
4651 /// Classifies an outbound internet frame as a server-plane control op the
4652 /// platform bridge should translate to (or mirror as) a relay-native
4653 /// message instead of an opaque `SendMessage`.
4654 ///
4655 /// Returns `(op, payload)` where `payload` is the JSON after the prefix
4656 /// (empty string for payload-less ops). `None` means normal traffic —
4657 /// send verbatim.
4658 ///
4659 /// Ops and their bridge semantics:
4660 /// - `group_relay_register` / `group_relay_broadcast` — self-addressed
4661 /// relay hints (`send_group_message` optimization); REPLACE with
4662 /// relay-native `CreateGroup`+member deltas / `SendGroupMessage`. The
4663 /// relay does not intercept content prefixes, so untranslated frames
4664 /// would be echoed back uselessly.
4665 /// - `group_mls_leave` — TAP, don't replace: the per-member leave
4666 /// notification must still be delivered verbatim, but the bridge also
4667 /// sends one relay-native `LeaveGroup` so the relay's group registry
4668 /// (which feeds invite links and broadcast fan-out) doesn't go stale.
4669 ///
4670 /// Connection ops (`__CONN_REQ__`/`__CONN_ACC__`/`__CONN_REJ__`/
4671 /// `__CONN_CAN__`) deliberately do NOT classify: they ship verbatim as
4672 /// opaque `SendMessage` frames so the Ed25519 control signature in the
4673 /// message metadata survives to the receiver's security gate. The former
4674 /// relay-native translation (`SendConnectionRequest` & co.) rebuilt them
4675 /// unsigned on the receiving bridge, which the gate now rejects
4676 /// unconditionally — control traffic must be signed. Verbatim
4677 /// also gains the relay's push-notification fallback for offline
4678 /// recipients, which the relay-native connection frames never had. The
4679 /// cost is that pre-SDK relay clients (which only speak the relay-native
4680 /// frames) no longer interoperate with connection ops — an accepted
4681 /// trade: the SDK's internet transport is the only supported relay
4682 /// client.
4683 ///
4684 /// Only frames *originated by this device* classify. The mesh relays
4685 /// third-party messages verbatim (`try_relay_message`). The relay-hint
4686 /// ops additionally require the self-addressed recipient (that is how
4687 /// the core marks them as hints rather than traffic), so they classify
4688 /// only when sender AND recipient are both this device.
4689 ///
4690 /// Self-origination is proven by `hop_count == 0`, not by the `sender`
4691 /// field: `sender` is an unauthenticated wire field, so a mesh peer
4692 /// could forge `sender == self` on a frame we then relay — but
4693 /// `try_relay_message` increments the hop before re-sending, while every
4694 /// locally-originated frame leaves `send_internal_message` at hop 0.
4695 /// (Defense in depth: the receive loop also drops inbound frames
4696 /// claiming our own origin before they can reach the relay path.)
4697 pub fn internet_control_op(&self, message: &Message) -> Option<(&'static str, String)> {
4698 if message.hop_count.value() != 0 {
4699 return None;
4700 }
4701 let content = message.content.as_str();
4702 if message.sender.as_str() == self.local_id {
4703 if let Some(payload) = content.strip_prefix(internal_prefixes::GROUP_MLS_LEAVE) {
4704 return Some(("group_mls_leave", payload.to_string()));
4705 }
4706 }
4707 if message.sender.as_str() == self.local_id && message.recipient.as_str() == self.local_id {
4708 if let Some(payload) = content.strip_prefix(internal_prefixes::GROUP_RELAY_REGISTER) {
4709 return Some(("group_relay_register", payload.to_string()));
4710 }
4711 if let Some(payload) = content.strip_prefix(internal_prefixes::GROUP_RELAY_BROADCAST) {
4712 return Some(("group_relay_broadcast", payload.to_string()));
4713 }
4714 }
4715 None
4716 }
4717
4718 pub(super) fn handle_outbound_media_chunk_delivered(&mut self, message_id: &MessageId) {
4719 let Some((file_id, chunk_index)) = self.outbound_media_chunks.remove(message_id) else {
4720 return;
4721 };
4722
4723 if let Some(window) = self.outbound_media_windows.get_mut(&file_id) {
4724 window.on_chunk_ack(chunk_index);
4725 }
4726
4727 let Some(transfer) = self.outbound_media_transfers.get_mut(&file_id) else {
4728 return;
4729 };
4730
4731 transfer.delivered_chunks.insert(chunk_index);
4732 transfer.last_updated_at = Instant::now();
4733
4734 let delivered_chunks = transfer.delivered_chunks.len() as u32;
4735 let total_chunks = transfer.total_chunks;
4736 let content_type = transfer.content_type;
4737 let recipient = transfer.recipient.clone();
4738 let completed = delivered_chunks == total_chunks;
4739 let carries_data = transfer.data_purpose.is_some();
4740
4741 if !carries_data {
4742 if let Ok(state) = lock_shared_state(&self.shared_state) {
4743 state.emit_event(Event::file_progress(
4744 file_id.clone(),
4745 delivered_chunks,
4746 total_chunks,
4747 ));
4748 if completed {
4749 state.emit_event(Event::media_sent(file_id.clone(), content_type, recipient));
4750 }
4751 }
4752 }
4753
4754 if completed {
4755 self.outbound_media_transfers.remove(&file_id);
4756 self.outbound_media_windows.remove(&file_id);
4757 self.remove_media_descriptor(&file_id);
4758 }
4759 }
4760
4761 pub(super) fn handle_outbound_media_chunk_failed(
4762 &mut self,
4763 message_id: &MessageId,
4764 reason: &str,
4765 ) {
4766 let Some((file_id, _chunk_index)) = self.outbound_media_chunks.remove(message_id) else {
4767 return;
4768 };
4769 self.abort_outbound_media_transfer(&file_id, reason);
4770 }
4771
4772 /// Aborts an active outbound media transfer: removes all transfer
4773 /// tracking (freeing its per-peer transfer slot) and emits
4774 /// [`Event::MediaSendFailed`] so the app learns the transfer will never
4775 /// complete. Idempotent — a second call for the same `file_id` is a no-op.
4776 ///
4777 /// A data-purposed transfer is the one exception to the event: it has no
4778 /// application-facing identity, so the report is withheld rather than
4779 /// naming a `file_id` the app never saw.
4780 pub(super) fn abort_outbound_media_transfer(&mut self, file_id: &str, reason: &str) {
4781 let Some(transfer) = self.outbound_media_transfers.remove(file_id) else {
4782 return;
4783 };
4784 self.outbound_media_chunks
4785 .retain(|_, (candidate_file_id, _)| candidate_file_id.as_str() != file_id);
4786 self.outbound_media_windows.remove(file_id);
4787 // A terminal abort is settled state — the app hears MediaSendFailed
4788 // now, so a MediaResendRequired for it after a restart would be
4789 // stale noise.
4790 self.remove_media_descriptor(file_id);
4791 warn!(
4792 file_id = %file_id,
4793 reason = %reason,
4794 "Aborting outbound media transfer"
4795 );
4796 // A data-purposed transfer has no application-facing identity to
4797 // fail, so reporting one would name a file_id the app has never
4798 // seen. The requester's own fetch expires and can be asked again,
4799 // and a snapshot is re-offered by the next anti-entropy exchange, so
4800 // nothing is stranded by staying quiet here.
4801 if transfer.data_purpose.is_some() {
4802 debug!(
4803 file_id = %file_id,
4804 reason = %reason,
4805 "A data-layer transfer was aborted; the asking side will retry"
4806 );
4807 return;
4808 }
4809 if let Ok(state) = lock_shared_state(&self.shared_state) {
4810 state.emit_event(Event::media_send_failed(
4811 file_id.to_string(),
4812 transfer.recipient,
4813 reason.to_string(),
4814 ));
4815 }
4816 }
4817
4818 pub(super) fn cleanup_stale_media_state(&mut self, max_age: StdDuration) {
4819 let now = Instant::now();
4820
4821 self.pending_media_metadata
4822 .retain(|_, metadata| now.duration_since(metadata.last_updated_at) <= max_age);
4823
4824 let stale_outbound_file_ids: HashSet<String> = self
4825 .outbound_media_transfers
4826 .iter()
4827 .filter_map(|(file_id, transfer)| {
4828 if now.duration_since(transfer.last_updated_at) > max_age {
4829 return Some(file_id.clone());
4830 }
4831 None
4832 })
4833 .collect();
4834
4835 if stale_outbound_file_ids.is_empty() {
4836 return;
4837 }
4838
4839 self.outbound_media_transfers
4840 .retain(|file_id, _| !stale_outbound_file_ids.contains(file_id));
4841 self.outbound_media_chunks
4842 .retain(|_, (file_id, _)| !stale_outbound_file_ids.contains(file_id));
4843 self.outbound_media_windows
4844 .retain(|file_id, _| !stale_outbound_file_ids.contains(file_id));
4845 for file_id in &stale_outbound_file_ids {
4846 self.remove_media_descriptor(file_id);
4847 }
4848 }
4849
4850 // ========================================================================
4851 // ACK
4852 // ========================================================================
4853
4854 pub(super) fn send_delivery_ack(
4855 &mut self,
4856 message: &Message,
4857 inbound_transport: TransportType,
4858 ) -> Result<()> {
4859 let sender = UserId::new(&self.local_id)?;
4860 let recipient = message.sender.clone();
4861 let app_id = AppId::new(&self.config.app_id)?;
4862 let ttl = TTL::new(self.config.initial_ttl).unwrap_or_else(|_| TTL::default());
4863
4864 let ack_message = Message::builder(sender, recipient, app_id)
4865 .content(String::new())
4866 .priority(MessagePriority::Low)
4867 .ttl(ttl)
4868 .requires_ack(false)
4869 .metadata(ACK_FOR_KEY, message.id.as_str())
4870 .metadata(ACK_HOP_COUNT_KEY, message.hop_count.value().to_string())
4871 .metadata(ACK_TRANSPORT_KEY, Self::transport_label(inbound_transport))
4872 .build();
4873
4874 self.route_ack(&ack_message, inbound_transport)
4875 }
4876
4877 /// Gets an acknowledgement back to whoever sent us the message it answers.
4878 ///
4879 /// The single route-selection point for every delivery ACK, direct or
4880 /// group. Keeping one ladder is deliberate: it has four callers across two
4881 /// builders, and the failure this ordering exists to prevent is silent —
4882 /// a copy of the ladder that drifted would look fine and lose answers.
4883 ///
4884 /// Three routes, in order:
4885 ///
4886 /// 1. **The link it arrived on**, when that carrier can actually address
4887 /// the sender. Preferred because it is known to work for this peer.
4888 /// 2. **The mesh**, when the sender is not a direct mesh neighbor and
4889 /// either nothing we hold reaches them directly *or* the message itself
4890 /// arrived over a mesh carrier: the answer travels back the way the
4891 /// message came, carried by the devices in between. Without this,
4892 /// multi-hop delivery looks like failure to a sender who retransmits a
4893 /// message we already have and read.
4894 /// 3. **DORS**, which picks among whatever else is available.
4895 ///
4896 /// The arrival-over-mesh half of step 2 is what makes this work in a mixed
4897 /// neighborhood. A frame that reached us across the mesh came from someone
4898 /// the mesh can reach and — having failed step 1 — someone we cannot hand it
4899 /// straight back to, which says nothing about whether *our* internet is up.
4900 /// Deciding from local carrier status alone, an online recipient answers a
4901 /// mesh-delivered message over the relay, where an offline sender cannot see
4902 /// it: delivered and read, reported failed. In that case the answer goes to
4903 /// the mesh **and** on to step 3, since an online sender is equally
4904 /// plausible and a duplicate acknowledgement costs one frame the sender's
4905 /// ack handling already absorbs. When nothing reaches the sender directly
4906 /// the mesh remains the whole answer and step 3 is skipped, as before.
4907 ///
4908 /// Step 1 is *gated on addressability* rather than on a send error, and
4909 /// that gate is load-bearing. A transport returning `Ok` is not evidence
4910 /// the frame can arrive: Wi-Fi Direct enqueues for any recipient, and it is
4911 /// the preferred mesh carrier — so on the last hop of a forwarded frame the
4912 /// answer would be queued for a link that never drains, reported as sent,
4913 /// and step 2 would never run. The sender's retransmissions would take the
4914 /// same path every time, ending in a failure report for a delivered
4915 /// message.
4916 ///
4917 /// An acknowledgement no route takes is held, not dropped
4918 /// ([`Self::hold_unrouted_ack`]): it was owed the moment the message was
4919 /// accepted, and the common miss is a carrier that is merely not up yet —
4920 /// a push-injected frame acknowledged before the relay socket has
4921 /// authenticated. Held is reported as `Ok`, like any carrier queue.
4922 fn route_ack(&mut self, ack_message: &Message, inbound_transport: TransportType) -> Result<()> {
4923 if self.try_route_ack(ack_message, inbound_transport) {
4924 return Ok(());
4925 }
4926 debug!(
4927 ack_to = %ack_message.recipient,
4928 "No route back for a delivery acknowledgement; holding it for the next carrier edge"
4929 );
4930 self.hold_unrouted_ack(ack_message.clone(), inbound_transport, Instant::now());
4931 Ok(())
4932 }
4933
4934 /// Holds an acknowledgement until [`Self::flush_unrouted_acks`]. Bounded
4935 /// both ways: [`MAX_UNROUTED_ACKS`] (oldest dropped first) and
4936 /// [`UNROUTED_ACK_TTL`]. Nothing past either is lost for good — a sender
4937 /// that never hears back re-sends, and the duplicate is re-ACKed.
4938 fn hold_unrouted_ack(&mut self, ack: Message, inbound: TransportType, held_at: Instant) {
4939 // One ACK answers every copy of a message, and a peer replaying it
4940 // while no carrier is up would otherwise spend a slot per copy.
4941 let acked = ack.metadata.get(ACK_FOR_KEY).cloned();
4942 self.unrouted_acks.retain(|(held, _, _)| {
4943 held.recipient != ack.recipient || held.metadata.get(ACK_FOR_KEY).cloned() != acked
4944 });
4945 if self.unrouted_acks.len() >= MAX_UNROUTED_ACKS {
4946 self.unrouted_acks.pop_front();
4947 warn!("Unrouted acknowledgement queue full; dropped the oldest");
4948 }
4949 self.unrouted_acks.push_back((ack, inbound, held_at));
4950 }
4951
4952 /// Re-routes every held acknowledgement through the same ladder. Run on
4953 /// the carrier-level edge (`flush_outbox_all`: start, and every carrier's
4954 /// false→true), ahead of the outbox, because an ACK is what lets the
4955 /// peer's outbox stop re-sending to us. Still unroutable stays held, on
4956 /// its original clock.
4957 ///
4958 /// A peer blocked since its ACK was held gets nothing: the receive path
4959 /// never answers a blocked sender, so its presence is not leaked, and a
4960 /// held ACK must not reverse that decision late.
4961 pub(super) fn flush_unrouted_acks(&mut self) {
4962 for (ack, inbound, held_at) in std::mem::take(&mut self.unrouted_acks) {
4963 if held_at.elapsed() > UNROUTED_ACK_TTL
4964 || self.is_user_blocked(ack.recipient.as_str())
4965 || self.try_route_ack(&ack, inbound)
4966 {
4967 continue;
4968 }
4969 self.hold_unrouted_ack(ack, inbound, held_at);
4970 }
4971 }
4972
4973 /// The ladder itself; `true` when some route took the acknowledgement.
4974 fn try_route_ack(&mut self, ack_message: &Message, inbound_transport: TransportType) -> bool {
4975 let ack_to = ack_message.recipient.as_str().to_string();
4976
4977 if self
4978 .transport_manager
4979 .can_address_via(inbound_transport, &ack_to)
4980 && self
4981 .transport_manager
4982 .send_via_transport(ack_message, inbound_transport)
4983 .is_ok()
4984 {
4985 return true;
4986 }
4987
4988 debug!(
4989 ack_to = %ack_to,
4990 inbound_transport = ?inbound_transport,
4991 "Inbound transport cannot answer this sender; looking for another route"
4992 );
4993
4994 // No route at all without help: the mesh is the entire answer.
4995 let no_direct_route = !self.can_reach_recipient(&ack_to);
4996 // Reached us through other devices, and we cannot hand it straight
4997 // back: the way it came is a route we know exists, whatever our own
4998 // carriers claim.
4999 let carried_to_us = TransportManager::is_mesh_transport(inbound_transport)
5000 && !self.transport_manager.mesh_can_address(&ack_to);
5001
5002 let mut handed_to_mesh = 0usize;
5003 if no_direct_route || carried_to_us {
5004 handed_to_mesh = self.offer_to_mesh(ack_message);
5005 if handed_to_mesh > 0 {
5006 debug!(
5007 ack_to = %ack_to,
5008 handed_to_mesh,
5009 "Handed the acknowledgement to the mesh"
5010 );
5011 if no_direct_route {
5012 return true;
5013 }
5014 }
5015 }
5016
5017 // Neighbors took it, so the answer is on its way even though nothing
5018 // else could carry it.
5019 self.transport_manager.send(ack_message).is_ok() || handed_to_mesh > 0
5020 }
5021
5022 /// Builds and sends a delivery ACK from the group-message drain, which
5023 /// holds only the acked id and the original wire sender (not the full
5024 /// inbound [`Message`]). Mirrors [`Self::send_delivery_ack`]; the hop count
5025 /// is unknown at drain time and reported as 0 (sender-side metrics only).
5026 pub(crate) fn send_group_delivery_ack(
5027 &mut self,
5028 ack_to: &str,
5029 acked_message_id: &str,
5030 inbound_transport: TransportType,
5031 ) -> Result<()> {
5032 let sender = UserId::new(&self.local_id)?;
5033 let recipient = UserId::new(ack_to)?;
5034 let app_id = AppId::new(&self.config.app_id)?;
5035 let ttl = TTL::new(self.config.initial_ttl).unwrap_or_else(|_| TTL::default());
5036
5037 let ack_message = Message::builder(sender, recipient, app_id)
5038 .content(String::new())
5039 .priority(MessagePriority::Low)
5040 .ttl(ttl)
5041 .requires_ack(false)
5042 .metadata(ACK_FOR_KEY, acked_message_id)
5043 .metadata(ACK_HOP_COUNT_KEY, "0")
5044 .metadata(ACK_TRANSPORT_KEY, Self::transport_label(inbound_transport))
5045 .build();
5046
5047 // Same ladder as a direct-message ACK, mesh fallback included: a group
5048 // frame reaches its members over the same hops a DM does.
5049 self.route_ack(&ack_message, inbound_transport)
5050 }
5051
5052 pub(super) fn handle_ack_message(&mut self, message: &Message) {
5053 if let Some(ack_for) = message.metadata.get(ACK_FOR_KEY) {
5054 if let Ok(message_id) = MessageId::from_str(ack_for) {
5055 // Whoever answers has to be whoever we sent it to. An
5056 // acknowledgement names a message id and nothing else ties it
5057 // to a delivery, so without this any third party that merely
5058 // saw the frame — a mesh carrier that took a copy, the relay —
5059 // settles it: the outbox entry is dropped, the retries stop and
5060 // the app is told `message_delivered` for a message that
5061 // arrived nowhere. Refusing delivery is something those parties
5062 // could always do; a false confirmation is the strictly worse
5063 // one, because it is the version the sender never finds out
5064 // about.
5065 //
5066 // Checked before anything is removed. The settle work below
5067 // starts by taking the pending ACK, and a frame rejected after
5068 // that point would have already cost the message its retry
5069 // record — turning a refused acknowledgement into the silent
5070 // loss this exists to prevent.
5071 //
5072 // Safe on this path — which every delivery passes through —
5073 // because delivery already implies the equality: a device
5074 // processes a frame only when its own `local_id` equals the
5075 // frame's recipient (`receive_message` forwards anything else),
5076 // and the acknowledgement it builds carries that same
5077 // `local_id` as its sender. A genuine confirmation therefore
5078 // matches whatever identifier namespace the app addresses peers
5079 // in — the profile before MLS initialization, the `off1…`
5080 // address after it.
5081 //
5082 // It is **not** authentication and must not be read as one: an
5083 // acknowledgement carries no signature (empty content is not a
5084 // security-gated prefix, and the receive loop handles it before
5085 // the gate would run anyway), so a forger who saw the frame saw
5086 // its recipient too and can simply write that name. What this
5087 // removes is the unattributed forgery, and the divergence
5088 // between the two settle paths below. Binding an
5089 // acknowledgement to its sender for real needs signed acks.
5090 if !self.ack_is_from_the_message_recipient(&message_id, message) {
5091 debug!(
5092 message_id = %message_id,
5093 claimed_sender = %message.sender,
5094 "Ignoring an acknowledgement from someone this message was not sent to"
5095 );
5096 return;
5097 }
5098
5099 // A delivery ack settles an outbound connection request:
5100 // the recipient provably received it, so a later stale
5101 // recipient_unreachable signal must not fire a false
5102 // ConnectionRequestUndeliverable. Outside the ack_manager
5103 // branch — a duplicate ack (second transport) or one
5104 // arriving after retry exhaustion is still proof.
5105 self.pending_connection_requests
5106 .remove(&message_id.as_str());
5107 if let Some(pending) = self.ack_manager.remove_ack(&message_id) {
5108 let latency = Utc::now()
5109 .signed_duration_since(pending.sent_at)
5110 .num_milliseconds()
5111 .max(0) as u64;
5112
5113 let hop_count = message
5114 .metadata
5115 .get(ACK_HOP_COUNT_KEY)
5116 .and_then(|v| v.parse::<u8>().ok())
5117 .unwrap_or(0);
5118
5119 let transport = message
5120 .metadata
5121 .get(ACK_TRANSPORT_KEY)
5122 .map(|label| Self::transport_from_label(label))
5123 .unwrap_or(TransportType::BLE);
5124
5125 // Emit event if we can lock the state, but don't fail if we can't
5126 if let Ok(state) = lock_shared_state(&self.shared_state) {
5127 state.emit_event(Event::message_delivered(
5128 message_id.clone(),
5129 latency,
5130 hop_count,
5131 transport,
5132 ));
5133 drop(state);
5134 } else {
5135 error!(
5136 "Failed to lock shared state for ACK event, skipping event emission"
5137 );
5138 }
5139
5140 self.transport_manager.reset_retry_count(transport);
5141 self.transport_manager.record_delivery_success(
5142 transport,
5143 latency.min(u32::MAX as u64) as u32,
5144 hop_count,
5145 );
5146 self.handle_outbound_media_chunk_delivered(&message_id);
5147 self.retry_queue.remove(&message_id.as_str());
5148 if let Some(entry) = self.remove_outbox_entry(&message_id) {
5149 // Delivery proves reachability, so the escalating
5150 // unreachable-probe interval starts over. Clearing the
5151 // counter is not enough on its own: it is per-peer
5152 // while the probes are per-message, so a burst of DMs
5153 // to an offline peer escalates the shared ladder once
5154 // per park and leaves the later ones scheduled minutes
5155 // out (at the 600s cap after seven). Delivery of any
5156 // one of them proves the rest can go now, and on a
5157 // consumer that never polls presence this ACK is the
5158 // only edge that will say so. Flushing owns the
5159 // counter reset — including restoring it when every
5160 // re-drive fails — so this must not pre-clear it.
5161 //
5162 // The override is our own record of the transport the
5163 // delivered send went out on (`last_transport`), which
5164 // this ACK just proved reaches the peer — genuine
5165 // per-transport reachability, pinning the re-drive the
5166 // way presence-online pins Internet. Deliberately NOT
5167 // the ACK's `transport` above: ACK_TRANSPORT_KEY is
5168 // peer-supplied, and `from_label` maps any absent,
5169 // stale, or hostile label to BLE — pinning a burst of
5170 // sibling sends to a carrier chosen by the peer.
5171 // Filtered on local availability (the transport may
5172 // have dropped since the send); `None` falls back to
5173 // DORS, so a missing carrier costs routing freedom,
5174 // never the re-drive itself.
5175 let recipient = entry.message.recipient.as_str().to_string();
5176 if self.dm_unreachable_parks.contains_key(&recipient) {
5177 let redrive_via = entry.last_transport.filter(|t| {
5178 self.transport_manager
5179 .get_available_transports()
5180 .contains_key(t)
5181 });
5182 self.flush_outbox_for_peer_via(&recipient, redrive_via);
5183 }
5184 }
5185 } else {
5186 // No pending ACK. Ordinarily that means a duplicate answer
5187 // or one arriving after the message already settled — but a
5188 // *parked* DM has no pending ACK either, because parking
5189 // removed it, and its delivery is the one this device has
5190 // no other way to learn about.
5191 self.settle_parked_dm_from_ack(&message_id, message);
5192 }
5193 }
5194 }
5195 }
5196
5197 /// Whether `ack` comes from the peer the message it answers was addressed
5198 /// to — the one thing about an acknowledgement that is not simply asserted
5199 /// by whoever sent it. See [`Self::handle_ack_message`] for what that does
5200 /// and does not buy.
5201 ///
5202 /// Both outboxes are consulted because both hold ack-bearing sends: plain
5203 /// DMs and control frames in `outbox`, media chunks in `media_outbox`.
5204 ///
5205 /// Answers `true` when neither holds the id, which is the ordinary shape of
5206 /// a duplicate answer arriving after the message already settled — there is
5207 /// no record left to judge against, and inventing a verdict would start
5208 /// dropping the answers this device has always accepted. The fail-open is
5209 /// not reachable by an attacker: it needs our own outbox entry to be gone,
5210 /// which nothing a peer sends can arrange.
5211 fn ack_is_from_the_message_recipient(&self, message_id: &MessageId, ack: &Message) -> bool {
5212 let Some(entry) = self
5213 .outbox
5214 .get(message_id)
5215 .or_else(|| self.media_outbox.get(message_id))
5216 else {
5217 return true;
5218 };
5219 ack.sender.as_str() == entry.message.recipient.as_str()
5220 }
5221
5222 /// Settles a parked DM whose acknowledgement arrived with no pending ACK to
5223 /// match it — the delivery a park can otherwise never learn about.
5224 ///
5225 /// Parking is what makes this necessary: it removes the pending ACK so the
5226 /// retry budget stops burning against a peer the relay says is offline. Once
5227 /// the park also hands the frame to the mesh
5228 /// ([`Self::park_unreachable_dm`]), a neighbor can carry it to a recipient
5229 /// the relay could not reach — and the answer coming back would land here,
5230 /// on the branch that used to drop it. The message would sit in the outbox
5231 /// being probed until its lifetime expired, delivered and read the whole
5232 /// time. The offer and this arm are one change; neither is correct alone.
5233 ///
5234 /// Gated three ways, because an acknowledgement is unauthenticated (it
5235 /// carries no internal prefix, so it never reaches the control gate, and it
5236 /// is handled before the security gate runs on the receive path):
5237 ///
5238 /// 1. the id is a parkable plain DM — welcomes keep their own lifecycle,
5239 /// and media chunks are never parked, so they keep their pending ACK and
5240 /// settle on the branch above. This gate does **not** exclude connection
5241 /// requests, whatever [`Self::is_parkable_plain_dm`] looks like it
5242 /// promises: the caller retires their typed tracking a few lines earlier
5243 /// and unconditionally, so by the time that check runs the map no longer
5244 /// holds the id. The outcome is the wanted one — a request whose typed
5245 /// window has already lapsed, carried to its recipient and answered, was
5246 /// delivered, and settling it is the same proof the pending-ACK branch
5247 /// acts on for an already-untracked request — but it is the caller's
5248 /// ordering that decides it, not this gate;
5249 /// 2. it is still in the outbox;
5250 /// 3. its recipient holds a live park counter, so the relay did declare this
5251 /// peer unreachable and no edge has cleared it since.
5252 ///
5253 /// Attribution — that the answer comes from the recipient rather than from
5254 /// any party that handled the frame — is deliberately *not* a fourth gate
5255 /// here. [`Self::handle_ack_message`] now applies it to every
5256 /// acknowledgement it accepts, so both settle paths judge by one rule
5257 /// against one record; a copy of it here would be unreachable, and the kind
5258 /// of unreachable that reads as load-bearing to the next person.
5259 ///
5260 /// What remains after all of them is a forgery by someone who saw the frame
5261 /// *and* knows the relay refused it — the carriers and the relay, parties
5262 /// who can already deny delivery outright. What they gain is making a
5263 /// parked message look delivered rather than staying parked, which is the
5264 /// same class as forging an acknowledgement for an in-flight message.
5265 ///
5266 /// Reports latency end to end (from the first send) rather than per attempt:
5267 /// there is no pending record to measure the last attempt against, and for a
5268 /// message that waited out a park the honest number is how long delivery
5269 /// actually took. For the same reason no transport metric is recorded — the
5270 /// carrier label is peer-supplied and there is no attempt to attribute it
5271 /// to.
5272 fn settle_parked_dm_from_ack(&mut self, message_id: &MessageId, ack: &Message) {
5273 if !self.is_parkable_plain_dm(message_id) {
5274 return;
5275 }
5276 let Some(entry) = self.outbox.get(message_id) else {
5277 return;
5278 };
5279 let recipient = entry.message.recipient.as_str().to_string();
5280 if !self.dm_unreachable_parks.contains_key(&recipient) {
5281 return;
5282 }
5283
5284 let latency = Utc::now()
5285 .signed_duration_since(entry.first_sent_at)
5286 .num_milliseconds()
5287 .max(0) as u64;
5288 let hop_count = ack
5289 .metadata
5290 .get(ACK_HOP_COUNT_KEY)
5291 .and_then(|v| v.parse::<u8>().ok())
5292 .unwrap_or(0);
5293 let transport = ack
5294 .metadata
5295 .get(ACK_TRANSPORT_KEY)
5296 .map(|label| Self::transport_from_label(label))
5297 .unwrap_or(TransportType::BLE);
5298
5299 debug!(
5300 message_id = %message_id,
5301 recipient = %recipient,
5302 latency_ms = latency,
5303 "Parked DM was delivered after all; settling from its acknowledgement"
5304 );
5305
5306 self.retry_queue.remove(&message_id.as_str());
5307 let redrive_via = self.remove_outbox_entry(message_id).and_then(|entry| {
5308 // Same reasoning as the pending-ACK path: our own record of where
5309 // the delivered send went, never the peer-supplied label, and only
5310 // while that carrier is still up. The park counter stays for the
5311 // flush to reset, so a re-drive that fails everywhere restores it.
5312 entry.last_transport.filter(|t| {
5313 self.transport_manager
5314 .get_available_transports()
5315 .contains_key(t)
5316 })
5317 });
5318 if let Ok(state) = lock_shared_state(&self.shared_state) {
5319 state.emit_event(Event::message_delivered(
5320 message_id.clone(),
5321 latency,
5322 hop_count,
5323 transport,
5324 ));
5325 drop(state);
5326 } else {
5327 error!("Failed to lock shared state for parked-ACK event, skipping event emission");
5328 }
5329 // The peer answered, so the rest of their parked traffic can go now
5330 // rather than waiting out its own escalated probe timers.
5331 self.flush_outbox_for_peer_via(&recipient, redrive_via);
5332 }
5333
5334 pub(super) fn handle_missing_outbox_entry(
5335 &mut self,
5336 message_id: &MessageId,
5337 retry_count: u32,
5338 ) -> Result<()> {
5339 let state = lock_shared_state(&self.shared_state).map_err(|e| {
5340 error!(
5341 "Failed to lock shared state for missing outbox entry event: {}",
5342 e
5343 );
5344 e
5345 })?;
5346 state.emit_event(Event::message_failed(
5347 message_id.clone(),
5348 "Message missing from outbox (cannot retry)",
5349 retry_count,
5350 ));
5351 drop(state);
5352
5353 self.retire_undeliverable_message(message_id, "missing outbox entry");
5354 Ok(())
5355 }
5356
5357 // ========================================================================
5358 // EVENTS
5359 // ========================================================================
5360
5361 /// Emits a message sent event.
5362 pub(super) fn emit_message_sent_event(&self, message: &Message) -> Result<()> {
5363 let state = lock_shared_state(&self.shared_state).map_err(|e| {
5364 error!("Failed to lock shared state for message sent event: {}", e);
5365 e
5366 })?;
5367 state.emit_event(Event::message_sent(message));
5368 drop(state);
5369 Ok(())
5370 }
5371
5372 /// Emits a transport switched event if the transport changed.
5373 pub(super) fn emit_transport_switch_event(
5374 &self,
5375 previous_transport: Option<TransportType>,
5376 current_transport: Option<TransportType>,
5377 ) -> Result<()> {
5378 if current_transport != previous_transport {
5379 if let Some(new_transport) = current_transport {
5380 let state = lock_shared_state(&self.shared_state).map_err(|e| {
5381 error!(
5382 "Failed to lock shared state for transport switch event: {}",
5383 e
5384 );
5385 e
5386 })?;
5387 state.emit_event(Event::transport_switched(
5388 previous_transport,
5389 new_transport,
5390 "DORS selected better transport".to_string(),
5391 ));
5392 drop(state);
5393 }
5394 }
5395 Ok(())
5396 }
5397
5398 // ========================================================================
5399 // SOCIAL
5400 // ========================================================================
5401
5402 /// Sends a connection request to another user via any available transport.
5403 ///
5404 /// The request is routed through DORS, so it works over Internet, BLE, or WiFi Direct.
5405 ///
5406 /// # Arguments
5407 ///
5408 /// * `recipient` - The user ID of the recipient
5409 /// * `sender_name` - Display name of the sender
5410 /// * `key_package` - Optional MLS key package for encrypted session setup
5411 /// * `initial_message` - Optional first message shown with the request
5412 /// (surfaced verbatim in the recipient's `ConnectionRequestReceived`
5413 /// event; apps typically seed the conversation with it on accept).
5414 /// At most 4096 UTF-8 bytes (`MAX_INITIAL_MESSAGE_BYTES`, crate-private)
5415 /// — longer input is rejected with `Error::InvalidArgument`.
5416 ///
5417 /// # Encryption
5418 ///
5419 /// Connection requests are internal control messages sent in plaintext,
5420 /// exempt from `require_encryption` (same as key packages and welcome
5421 /// messages) — an `initial_message` is therefore NOT end-to-end
5422 /// encrypted and should be treated like the sender display name.
5423 pub fn send_connection_request(
5424 &mut self,
5425 recipient: &str,
5426 sender_name: &str,
5427 key_package: Option<Vec<u8>>,
5428 initial_message: Option<String>,
5429 ) -> Result<MessageId> {
5430 Self::validate_outbound_recipient(recipient)?;
5431 if let Some(msg) = initial_message.as_deref() {
5432 if msg.len() > MAX_INITIAL_MESSAGE_BYTES {
5433 return Err(Error::InvalidArgument(format!(
5434 "initial_message exceeds {} bytes (got {})",
5435 MAX_INITIAL_MESSAGE_BYTES,
5436 msg.len()
5437 )));
5438 }
5439 }
5440
5441 // Connection requests are internal control messages (not user content),
5442 // so they are exempt from require_encryption — same as key packages.
5443 if self.is_user_blocked(recipient) {
5444 return Err(Error::UserBlocked(recipient.to_string()));
5445 }
5446
5447 let payload = ConnectionRequestPayload {
5448 sender_name: sender_name.to_string(),
5449 timestamp_ms: Utc::now().timestamp_millis(),
5450 key_package,
5451 initial_message,
5452 };
5453
5454 let serialized =
5455 serde_json::to_string(&payload).map_err(|e| Error::Serialization(e.to_string()))?;
5456 let content = format!("{}{}", internal_prefixes::CONN_REQUEST, serialized);
5457
5458 let message_id = self.send_internal_message(recipient, content, MessagePriority::High)?;
5459 self.track_pending_connection_request(&message_id, recipient);
5460 info!(recipient = %recipient, "Sent connection request");
5461 Ok(message_id)
5462 }
5463
5464 /// Records an outbound connection request so a later transport-level
5465 /// "recipient offline" verdict can be surfaced as a typed event
5466 /// (see `on_transport_send_failed`). TTL-prunes and caps the map here,
5467 /// on the write path, so it needs no periodic sweep.
5468 fn track_pending_connection_request(&mut self, message_id: &MessageId, recipient: &str) {
5469 let now = std::time::Instant::now();
5470 self.pending_connection_requests
5471 .retain(|_, p| now.duration_since(p.sent_at) <= PENDING_CONNECTION_REQUEST_TTL);
5472 if self.pending_connection_requests.len() >= MAX_PENDING_CONNECTION_REQUESTS {
5473 if let Some(oldest) = self
5474 .pending_connection_requests
5475 .iter()
5476 .min_by_key(|(_, p)| p.sent_at)
5477 .map(|(id, _)| id.clone())
5478 {
5479 self.pending_connection_requests.remove(&oldest);
5480 }
5481 }
5482 self.pending_connection_requests.insert(
5483 message_id.as_str(),
5484 PendingConnectionRequest {
5485 recipient: recipient.to_string(),
5486 sent_at: now,
5487 },
5488 );
5489 }
5490
5491 /// Accepts a connection request from another user via any available transport.
5492 ///
5493 /// The response is routed through DORS, so it works over Internet, BLE, or WiFi Direct.
5494 ///
5495 /// # Arguments
5496 ///
5497 /// * `recipient` - The user ID of the original requester
5498 /// * `accepter_name` - Display name of the accepting party
5499 /// * `key_package` - Optional MLS key package for encrypted session setup
5500 ///
5501 /// # Encryption
5502 ///
5503 /// Connection accepts are internal control messages sent in plaintext,
5504 /// exempt from `require_encryption` (same as key packages and welcome messages).
5505 pub fn accept_connection_request(
5506 &mut self,
5507 recipient: &str,
5508 accepter_name: &str,
5509 key_package: Option<Vec<u8>>,
5510 ) -> Result<MessageId> {
5511 Self::validate_outbound_recipient(recipient)?;
5512 // Accept messages are internal control messages (not user content),
5513 // so they are exempt from require_encryption — same as key packages.
5514 if self.is_user_blocked(recipient) {
5515 return Err(Error::UserBlocked(recipient.to_string()));
5516 }
5517
5518 let payload = ConnectionAcceptedPayload {
5519 accepted_by_name: accepter_name.to_string(),
5520 timestamp_ms: Utc::now().timestamp_millis(),
5521 key_package,
5522 };
5523
5524 let serialized =
5525 serde_json::to_string(&payload).map_err(|e| Error::Serialization(e.to_string()))?;
5526 let content = format!("{}{}", internal_prefixes::CONN_ACCEPT, serialized);
5527
5528 let message_id = self.send_internal_message(recipient, content, MessagePriority::High)?;
5529 info!(recipient = %recipient, "Accepted connection request");
5530 Ok(message_id)
5531 }
5532
5533 /// Rejects a connection request from another user via any available transport.
5534 ///
5535 /// The response is routed through DORS, so it works over Internet, BLE, or WiFi Direct.
5536 ///
5537 /// # Arguments
5538 ///
5539 /// * `recipient` - The user ID of the original requester
5540 ///
5541 /// # Encryption
5542 ///
5543 /// Connection rejects are internal control messages sent in plaintext,
5544 /// exempt from `require_encryption` (same as key packages and welcome messages).
5545 pub fn reject_connection_request(&mut self, recipient: &str) -> Result<MessageId> {
5546 Self::validate_outbound_recipient(recipient)?;
5547 // Reject messages are internal control messages (not user content),
5548 // so they are exempt from require_encryption — same as key packages.
5549 if self.is_user_blocked(recipient) {
5550 return Err(Error::UserBlocked(recipient.to_string()));
5551 }
5552
5553 let content = internal_prefixes::CONN_REJECT.to_string();
5554
5555 let message_id = self.send_internal_message(recipient, content, MessagePriority::High)?;
5556 info!(recipient = %recipient, "Rejected connection request");
5557 Ok(message_id)
5558 }
5559
5560 /// Cancels a previously sent connection request via any available transport.
5561 ///
5562 /// The cancellation is routed through DORS, so it works over Internet, BLE, or WiFi Direct.
5563 ///
5564 /// # Arguments
5565 ///
5566 /// * `recipient` - The user ID of the original request recipient
5567 ///
5568 /// # Encryption
5569 ///
5570 /// Connection cancellations are internal control messages sent in plaintext,
5571 /// exempt from `require_encryption` (same as key packages and welcome messages).
5572 pub fn cancel_connection_request(&mut self, recipient: &str) -> Result<MessageId> {
5573 Self::validate_outbound_recipient(recipient)?;
5574 if self.is_user_blocked(recipient) {
5575 return Err(Error::UserBlocked(recipient.to_string()));
5576 }
5577
5578 let content = internal_prefixes::CONN_CANCEL.to_string();
5579
5580 let message_id = self.send_internal_message(recipient, content, MessagePriority::High)?;
5581 info!(recipient = %recipient, "Cancelled connection request");
5582 Ok(message_id)
5583 }
5584
5585 /// Sends a presence update to a single peer (unicast).
5586 ///
5587 /// This sends to one recipient at a time. To broadcast presence to multiple
5588 /// peers, the caller must invoke this method once per peer.
5589 ///
5590 /// # Arguments
5591 ///
5592 /// * `recipient` - The user ID of the peer to notify
5593 /// * `status` - Presence status
5594 pub fn send_presence_update(
5595 &mut self,
5596 recipient: &str,
5597 status: PresenceStatus,
5598 ) -> Result<MessageId> {
5599 Self::validate_outbound_recipient(recipient)?;
5600 if self.is_user_blocked(recipient) {
5601 return Err(Error::UserBlocked(recipient.to_string()));
5602 }
5603
5604 let payload = PresencePayload {
5605 status,
5606 timestamp_ms: Utc::now().timestamp_millis(),
5607 };
5608
5609 let serialized =
5610 serde_json::to_string(&payload).map_err(|e| Error::Serialization(e.to_string()))?;
5611 let content = format!("{}{}", internal_prefixes::PRESENCE, serialized);
5612
5613 let message_id = self.send_internal_message(recipient, content, MessagePriority::Low)?;
5614 debug!(recipient = %recipient, status = ?status, "Sent presence update");
5615 Ok(message_id)
5616 }
5617
5618 /// Sends a typing indicator to a peer.
5619 ///
5620 /// # Arguments
5621 ///
5622 /// * `recipient` - The user ID of the peer to notify
5623 /// * `conversation_id` - Opaque conversation key, carried to the peer and
5624 /// echoed back on its event. Never parsed or routed on. Conventionally
5625 /// the peer's address for a DM and the group id for a group; any stable
5626 /// string works, but a display name does not (those change).
5627 /// * `is_typing` - Whether the user started or stopped typing
5628 pub fn send_typing_indicator(
5629 &mut self,
5630 recipient: &str,
5631 conversation_id: &str,
5632 is_typing: bool,
5633 ) -> Result<MessageId> {
5634 Self::validate_outbound_recipient(recipient)?;
5635 if self.is_user_blocked(recipient) {
5636 return Err(Error::UserBlocked(recipient.to_string()));
5637 }
5638 if conversation_id.is_empty() {
5639 return Err(Error::InvalidArgument(
5640 "conversation_id must not be empty".to_string(),
5641 ));
5642 }
5643
5644 let payload = TypingIndicatorPayload {
5645 conversation_id: conversation_id.to_string(),
5646 is_typing,
5647 timestamp_ms: Utc::now().timestamp_millis(),
5648 };
5649
5650 let serialized =
5651 serde_json::to_string(&payload).map_err(|e| Error::Serialization(e.to_string()))?;
5652 let content = format!("{}{}", internal_prefixes::TYPING_INDICATOR, serialized);
5653
5654 let message_id = self.send_internal_message(recipient, content, MessagePriority::Low)?;
5655 debug!(recipient = %recipient, is_typing = %is_typing, "Sent typing indicator");
5656 Ok(message_id)
5657 }
5658
5659 /// Sends a read receipt to a peer, indicating that the given messages have been read.
5660 ///
5661 /// # Arguments
5662 ///
5663 /// * `recipient` - The user ID of the peer who sent the messages
5664 /// * `message_ids` - IDs of the messages that were read (max 256)
5665 pub fn send_read_receipt(
5666 &mut self,
5667 recipient: &str,
5668 message_ids: Vec<String>,
5669 ) -> Result<MessageId> {
5670 Self::validate_outbound_recipient(recipient)?;
5671 if self.is_user_blocked(recipient) {
5672 return Err(Error::UserBlocked(recipient.to_string()));
5673 }
5674 if message_ids.is_empty() {
5675 return Err(Error::InvalidArgument(
5676 "message_ids must not be empty".to_string(),
5677 ));
5678 }
5679 if message_ids.len() > MAX_READ_RECEIPT_IDS {
5680 return Err(Error::InvalidArgument(format!(
5681 "message_ids exceeds maximum of {MAX_READ_RECEIPT_IDS}"
5682 )));
5683 }
5684
5685 let payload = ReadReceiptPayload {
5686 message_ids,
5687 timestamp_ms: Utc::now().timestamp_millis(),
5688 };
5689
5690 let serialized =
5691 serde_json::to_string(&payload).map_err(|e| Error::Serialization(e.to_string()))?;
5692 let content = format!("{}{}", internal_prefixes::READ_RECEIPT, serialized);
5693
5694 let message_id = self.send_internal_message(recipient, content, MessagePriority::Low)?;
5695 debug!(recipient = %recipient, "Sent read receipt");
5696 Ok(message_id)
5697 }
5698
5699 /// Builds the advertisement body carried by a key package, whether it is
5700 /// pushed to a peer or published for a stranger to fetch.
5701 ///
5702 /// Shared so the two paths cannot drift: a published record that advertised
5703 /// a different capability set from a pushed one would make a peer's
5704 /// behaviour depend on which way they first met us.
5705 pub(crate) fn build_key_package_payload(
5706 &self,
5707 key_pkg: &offline_protocol_mls::KeyPackageBundle,
5708 session_reset: bool,
5709 ) -> KeyPackagePayload {
5710 KeyPackagePayload {
5711 user_id: self.local_id.clone(),
5712 key_package_data: key_pkg.key_package_data.clone(),
5713 remaining_lifetime_ms: key_pkg.remaining_lifetime_ms(),
5714 timestamp_ms: Utc::now().timestamp_millis() as u64,
5715 session_reset,
5716 wire_versions: if self.config.transport.binary_wire_enabled {
5717 vec![offline_protocol_core::WIRE_VERSION_V1]
5718 } else {
5719 Vec::new()
5720 },
5721 env_versions: if self.config.encryption.compact_envelope_enabled {
5722 vec![MLS_ENVELOPE_COMPACT_V1]
5723 } else {
5724 Vec::new()
5725 },
5726 rich_versions: if self.config.encryption.rich_payload_enabled {
5727 vec![RICH_PAYLOAD_V1]
5728 } else {
5729 Vec::new()
5730 },
5731 data_versions: self.advertised_data_versions(),
5732 // Advertised unconditionally, including with freshness
5733 // enforcement switched off. The two are different questions: this
5734 // field says which signatures we can *read*, which is always both
5735 // of them, while the kill switch decides whether a readable
5736 // signature is also refused for being stale. Withholding it under
5737 // the switch would make peers sign the older payload at us, and
5738 // the peer-capability record that closes the downgrade could then
5739 // never be written.
5740 ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2],
5741 // Present only when the Nostr transport is installed. Advertised
5742 // regardless of the sealing kill switch, which gates what *we*
5743 // publish, not what a peer may seal to us: withholding it would
5744 // downgrade the peer's traffic to the bootstrap key as a side
5745 // effect of a local setting.
5746 nostr_pubkey: self.transport_manager.nostr_public_key(),
5747 }
5748 }
5749
5750 /// Advertises this device's key package to `peer_id`.
5751 ///
5752 /// The package comes from the peer-keyed pool
5753 /// ([`MlsManager::take_push_key_package`]): `peer_id` gets its own init key,
5754 /// re-handed on every push until a Welcome consumes it. Before that, this
5755 /// path handed the *same* package to every peer until somebody used it,
5756 /// which both weakened forward secrecy at session establishment (one
5757 /// compromised init key opens every Welcome built against it) and made the
5758 /// second peer's Welcome permanently unprocessable.
5759 pub(crate) fn send_key_package_to(&mut self, peer_id: &str, session_reset: bool) -> Result<()> {
5760 let mls = self.mls_manager.as_ref().ok_or(Error::MlsNotInitialized)?;
5761
5762 let push = {
5763 let manager = mls
5764 .read()
5765 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
5766 manager.take_push_key_package(peer_id)?
5767 };
5768
5769 if push.pool_exhausted {
5770 self.warn_push_key_package_pool_exhausted(peer_id);
5771 }
5772
5773 let key_pkg = push.bundle;
5774 let payload = self.build_key_package_payload(&key_pkg, session_reset);
5775
5776 let serialized =
5777 serde_json::to_string(&payload).map_err(|e| Error::Serialization(e.to_string()))?;
5778 let content = format!("{}{}", internal_prefixes::KEY_PACKAGE, serialized);
5779
5780 let mut message =
5781 self.create_message(peer_id, content, Some(MessagePriority::Low), None)?;
5782 self.sign_control_message(&mut message)?;
5783
5784 match self.transport_manager.send(&message) {
5785 Ok(()) => {
5786 // SECURITY (resource exhaustion): `key_package_sent_to` is keyed
5787 // by the wire-claimed peer id, so a forged-sender key-package
5788 // flood (each reply routes through here) would grow it without
5789 // bound. Reset at capacity like `plaintext_receive_warned` — the
5790 // only cost of forgetting a peer is one idempotent re-send.
5791 if !self.key_package_sent_to.contains_key(peer_id)
5792 && self.key_package_sent_to.len() >= MAX_KEY_PACKAGE_SENT_TO
5793 {
5794 self.key_package_sent_to.clear();
5795 }
5796 // Re-stamped, and the repeat count (the re-arm's backoff) is
5797 // kept: only a reset push, which opens a new exchange, or a
5798 // removal (session reset, neighbour lost) starts it over.
5799 let now = Instant::now();
5800 let entry = self
5801 .key_package_sent_to
5802 .entry(peer_id.to_string())
5803 .or_insert((now, 0));
5804 entry.0 = now;
5805 if session_reset {
5806 entry.1 = 0;
5807 }
5808 debug!(peer_id = %peer_id, message_id = %message.id, "Sent key package");
5809 Ok(())
5810 }
5811 Err(err) => {
5812 // Don't mark as sent and don't enqueue for retry -- if the peer is
5813 // unreachable now, on_neighbor_discovered will fire again when they
5814 // reconnect, generating a fresh exchange.
5815 debug!(peer_id = %peer_id, error = %err, "Key package send deferred");
5816 Err(err)
5817 }
5818 }
5819 }
5820
5821 // ========================================================================
5822 // TRANSPORT HELPERS
5823 // ========================================================================
5824
5825 pub(super) fn transport_from_label(label: &str) -> TransportType {
5826 TransportType::from_label(label)
5827 }
5828
5829 pub(super) fn transport_label(transport: TransportType) -> &'static str {
5830 transport.label()
5831 }
5832
5833 /// The carrier a media transfer to `recipient` is pinned to, for every
5834 /// chunk and every retry.
5835 ///
5836 /// The peer stream is eligible only while a stream has proved `recipient`
5837 /// itself. The stream carries one peer, so pinned toward anyone else it
5838 /// refuses every chunk; the refused chunks, sized and windowed for a
5839 /// stream, are then offered to the mesh and reach a Bluetooth LE
5840 /// neighbour with no window at all, and the ones past the own-send budget
5841 /// retry against the pin until the transfer fails.
5842 pub(super) fn select_media_transport(&self, recipient: &str) -> Result<TransportType> {
5843 let available = self.transport_manager.get_available_transports();
5844 let eligible = |transport: TransportType| {
5845 available.contains_key(&transport)
5846 && (transport != TransportType::WiFiDirect
5847 || self
5848 .transport_manager
5849 .holds_mesh_link(TransportType::WiFiDirect, recipient))
5850 };
5851
5852 if let Some(current) = self.transport_manager.current_transport() {
5853 if eligible(current) {
5854 return Ok(current);
5855 }
5856 }
5857
5858 // The daemon carrier is left out of the fallback order: its reference
5859 // backbone is scarce-class (gateway-contract.md, rate class).
5860 for preferred in [
5861 TransportType::Internet,
5862 TransportType::WiFiDirect,
5863 TransportType::BLE,
5864 ] {
5865 if eligible(preferred) {
5866 return Ok(preferred);
5867 }
5868 }
5869
5870 Err(Error::Other(
5871 "No available transport for media transfer".to_string(),
5872 ))
5873 }
5874
5875 pub(super) fn pinned_media_transport_for_message(
5876 &self,
5877 message_id: &MessageId,
5878 ) -> Option<TransportType> {
5879 let (file_id, _) = self.outbound_media_chunks.get(message_id)?;
5880 self.outbound_media_transfers
5881 .get(file_id)
5882 .map(|transfer| transfer.pinned_transport)
5883 }
5884
5885 pub(super) fn decryption_failure_code_from_kind(
5886 kind: DecryptionFailureKind,
5887 ) -> DecryptionFailureCode {
5888 match kind {
5889 DecryptionFailureKind::NotInitialized => DecryptionFailureCode::NotInitialized,
5890 DecryptionFailureKind::InvalidCiphertext => DecryptionFailureCode::InvalidCiphertext,
5891 DecryptionFailureKind::IdentityMismatch => DecryptionFailureCode::IdentityMismatch,
5892 DecryptionFailureKind::CryptoFailure => DecryptionFailureCode::CryptoFailure,
5893 DecryptionFailureKind::SessionNotFound | DecryptionFailureKind::Unknown => {
5894 DecryptionFailureCode::Unknown
5895 }
5896 }
5897 }
5898}