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