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