Skip to main content

offline_protocol/protocol/
send.rs

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