Skip to main content

offline_protocol/protocol/
send.rs

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