Skip to main content

offline_protocol/protocol/
send.rs

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