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