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