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(¤t) {
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}