1pub struct MessageService<'a> {
2 client: &'a crate::core::ImClient,
3}
4
5#[cfg(test)]
6mod direct_send_result_identity_tests {
7 use crate::internal::local_state::owner_scope::{
8 direct_conversation_id_for_peer_scope, DirectPeerScope,
9 };
10
11 #[test]
12 fn direct_send_result_normalization_uses_peer_scope_thread() {
13 let scope = DirectPeerScope::new("user-bob", "Bob.AWiki.Test").expect("valid peer scope");
14 let expected_thread_id = direct_conversation_id_for_peer_scope(&scope);
15 let mut result = send_result("msg-peer-scope");
16
17 super::normalize_direct_send_result_for_peer_scope(
18 &mut result,
19 Some(&scope),
20 Some("bob.awiki.test"),
21 Some("did:example:bob-current"),
22 )
23 .expect("normalize");
24
25 match &result.message.thread {
26 crate::messages::ThreadRef::Thread(thread) => {
27 assert_eq!(thread.as_str(), expected_thread_id);
28 }
29 other => panic!("expected thread ref, got {other:?}"),
30 }
31 assert_eq!(
32 attribute(&result, "peer_user_id").as_deref(),
33 Some("user-bob")
34 );
35 assert_eq!(
36 attribute(&result, "peer_full_handle").as_deref(),
37 Some("bob.awiki.test")
38 );
39 assert_eq!(
40 attribute(&result, "target_handle").as_deref(),
41 Some("bob.awiki.test")
42 );
43 assert_eq!(
44 attribute(&result, "resolved_target_did").as_deref(),
45 Some("did:example:bob-current")
46 );
47 assert_eq!(
48 attribute(&result, "peer_current_did").as_deref(),
49 Some("did:example:bob-current")
50 );
51 assert_eq!(
52 result.message.receiver.as_ref().map(|peer| peer.as_str()),
53 Some("bob.awiki.test")
54 );
55 }
56
57 #[test]
58 fn direct_send_result_normalization_without_peer_scope_is_noop() {
59 let mut result = send_result("msg-direct-did");
60
61 super::normalize_direct_send_result_for_peer_scope(
62 &mut result,
63 None,
64 Some("bob.awiki.test"),
65 Some("did:example:bob"),
66 )
67 .expect("normalize");
68
69 match &result.message.thread {
70 crate::messages::ThreadRef::Direct(peer) => {
71 assert_eq!(peer.as_str(), "did:example:bob");
72 }
73 other => panic!("expected direct ref, got {other:?}"),
74 }
75 assert!(attribute(&result, "peer_user_id").is_none());
76 }
77
78 fn send_result(message_id: &str) -> crate::messages::SendMessageResult {
79 crate::messages::SendMessageResult {
80 message: crate::messages::Message {
81 id: crate::ids::MessageId::parse(message_id).unwrap(),
82 thread: crate::messages::ThreadRef::Direct(
83 crate::ids::PeerRef::parse("did:example:bob", "").unwrap(),
84 ),
85 direction: crate::messages::MessageDirection::Outgoing,
86 sender: crate::ids::PeerRef::parse("did:example:alice", "").unwrap(),
87 receiver: Some(crate::ids::PeerRef::parse("bob.awiki.test", "").unwrap()),
88 group: None,
89 body: crate::messages::MessageBodyView::Text {
90 text: "hello".to_owned(),
91 kind: crate::messages::MessageKind::Text,
92 },
93 sent_at: Some("2026-07-04T00:00:00Z".to_owned()),
94 received_at: None,
95 metadata: crate::messages::MessageMetadata::default(),
96 },
97 delivery: crate::messages::DeliveryState::Accepted,
98 warnings: Vec::new(),
99 }
100 }
101
102 fn attribute(result: &crate::messages::SendMessageResult, key: &str) -> Option<String> {
103 result
104 .message
105 .metadata
106 .attributes
107 .iter()
108 .find(|attribute| attribute.key == key)
109 .map(|attribute| attribute.value.clone())
110 }
111}
112
113#[cfg(test)]
114mod stale_delivery_target_tests {
115 use serde_json::json;
116
117 #[test]
118 fn stale_delivery_target_from_error_extracts_current_binding() {
119 let err = crate::ImError::Service {
120 status_code: None,
121 code: Some("1406".to_owned()),
122 message: "stale did".to_owned(),
123 data: Some(json!({
124 "reason": "stale_did",
125 "target_did": "did:example:bob-old",
126 "current_did": "did:example:bob-current",
127 "full_handle": "bob.awiki.test",
128 "user_id": "user-bob"
129 })),
130 };
131
132 let stale = super::stale_delivery_target_from_error(&err, "did:example:alice")
133 .expect("stale target");
134
135 assert_eq!(
136 stale.current_did.as_deref(),
137 Some("did:example:bob-current")
138 );
139 assert_eq!(stale.full_handle.as_deref(), Some("bob.awiki.test"));
140 assert_eq!(stale.user_id.as_deref(), Some("user-bob"));
141 }
142
143 #[test]
144 fn stale_delivery_target_from_error_ignores_plain_invalid_binding() {
145 let err = crate::ImError::Service {
146 status_code: None,
147 code: Some("1406".to_owned()),
148 message: "invalid target".to_owned(),
149 data: Some(json!({
150 "reason": "proof_mismatch",
151 "current_did": "did:example:bob-current"
152 })),
153 };
154
155 assert!(super::stale_delivery_target_from_error(&err, "did:example:alice").is_none());
156 }
157}
158
159#[cfg(all(test, feature = "sqlite"))]
160mod conversation_mark_read_request_tests {
161 use serde_json::json;
162 use std::fs;
163 use std::path::PathBuf;
164
165 #[test]
166 fn mark_conversation_read_maps_direct_storage_and_remote_thread_separately() {
167 let fixture = Fixture::new("mark-read-direct");
168 let client = fixture.client();
169 let request = crate::messages::MarkConversationReadRequest {
170 conversation: crate::messages::ConversationReadRef::new("dm:did:example:bob").unwrap(),
171 watermark: None,
172 fallback_max_message_ids: Some(25),
173 };
174
175 let mapped = super::mark_read_input_for_conversation(&client, request).unwrap();
176
177 match mapped.request.thread {
178 crate::messages::ThreadRef::Thread(thread) => {
179 assert_eq!(thread.as_str(), "dm:did:example:bob");
180 }
181 other => panic!("expected storage thread ref, got {other:?}"),
182 }
183 match mapped.remote_thread.expect("remote thread") {
184 crate::messages::ThreadRef::Direct(peer) => {
185 assert_eq!(peer.as_str(), "did:example:bob");
186 }
187 other => panic!("expected remote direct ref, got {other:?}"),
188 }
189 assert!(mapped.request.watermark.is_none());
190 assert_eq!(mapped.request.fallback_max_message_ids, Some(25));
191 }
192
193 #[test]
194 fn mark_conversation_read_maps_peer_scope_to_remote_direct_from_projection() {
195 let fixture = Fixture::new("mark-read-peer-scope");
196 let client = fixture.client();
197 let peer_scope = crate::internal::local_state::owner_scope::DirectPeerScope::new(
198 "user-bob",
199 "bob.awiki.test",
200 )
201 .unwrap();
202 let conversation_id =
203 crate::internal::local_state::owner_scope::direct_conversation_id_for_peer_scope(
204 &peer_scope,
205 );
206 fixture.seed_message(crate::internal::local_state::messages::MessageRecord {
207 msg_id: "msg-peer-scope-read".to_owned(),
208 conversation_id: conversation_id.clone(),
209 thread_id: conversation_id.clone(),
210 sender_did: "did:example:bob-current".to_owned(),
211 receiver_did: "did:example:alice".to_owned(),
212 metadata: json!({
213 "peer_user_id": "user-bob",
214 "peer_full_handle": "bob.awiki.test",
215 "peer_current_did": "did:example:bob-current"
216 })
217 .to_string(),
218 ..Fixture::message_record_defaults()
219 });
220 let request = crate::messages::MarkConversationReadRequest {
221 conversation: crate::messages::ConversationReadRef::new(conversation_id.clone())
222 .unwrap(),
223 watermark: Some(crate::messages::ReadWatermark {
224 last_read_message_id: None,
225 last_read_thread_seq: Some("42".to_owned()),
226 read_at: None,
227 }),
228 fallback_max_message_ids: None,
229 };
230
231 let mapped = super::mark_read_input_for_conversation(&client, request).unwrap();
232
233 match mapped.request.thread {
234 crate::messages::ThreadRef::Thread(thread) => {
235 assert_eq!(thread.as_str(), conversation_id);
236 }
237 other => panic!("expected storage thread ref, got {other:?}"),
238 }
239 match mapped.remote_thread.expect("remote thread") {
240 crate::messages::ThreadRef::Direct(peer) => {
241 assert_eq!(peer.as_str(), "did:example:bob-current");
242 }
243 other => panic!("expected remote direct ref, got {other:?}"),
244 }
245 assert_eq!(
246 mapped
247 .request
248 .watermark
249 .as_ref()
250 .and_then(|watermark| watermark.last_read_thread_seq.as_deref()),
251 Some("42")
252 );
253 }
254
255 #[test]
256 fn mark_conversation_read_maps_group_storage_and_remote_thread_separately() {
257 let fixture = Fixture::new("mark-read-group");
258 let client = fixture.client();
259 let request = crate::messages::MarkConversationReadRequest {
260 conversation: crate::messages::ConversationReadRef::new("group:did:example:group")
261 .unwrap(),
262 watermark: None,
263 fallback_max_message_ids: None,
264 };
265
266 let mapped = super::mark_read_input_for_conversation(&client, request).unwrap();
267
268 match mapped.request.thread {
269 crate::messages::ThreadRef::Thread(thread) => {
270 assert_eq!(thread.as_str(), "group:did:example:group");
271 }
272 other => panic!("expected group storage thread ref, got {other:?}"),
273 }
274 match mapped.remote_thread.expect("remote thread") {
275 crate::messages::ThreadRef::Group(group) => {
276 assert_eq!(group.as_str(), "did:example:group");
277 }
278 other => panic!("expected remote group ref, got {other:?}"),
279 }
280 }
281
282 struct Fixture {
283 root: PathBuf,
284 }
285
286 impl Fixture {
287 fn new(prefix: &str) -> Self {
288 let nanos = std::time::SystemTime::now()
289 .duration_since(std::time::UNIX_EPOCH)
290 .unwrap()
291 .as_nanos();
292 let root = std::env::temp_dir().join(format!(
293 "im-core-mark-read-{prefix}-{}-{nanos}",
294 std::process::id()
295 ));
296 let identity_root = root.join("identities");
297 let identity_dir = identity_root.join("alice");
298 fs::create_dir_all(&identity_dir).unwrap();
299 fs::write(identity_root.join("default"), "alice\n").unwrap();
300 fs::write(
301 identity_root.join("registry.json"),
302 r#"{
303 "default_identity": "alice",
304 "identities": [{
305 "id": "alice-id",
306 "did": "did:example:alice",
307 "local_alias": "alice",
308 "ready_for_auth": true,
309 "ready_for_messaging": true,
310 "device_id": "device-alice",
311 "missing": []
312 }]
313 }"#,
314 )
315 .unwrap();
316 Self { root }
317 }
318
319 fn client(&self) -> crate::core::ImClient {
320 crate::core::ImCore::new(
321 crate::ImCoreConfig {
322 service_base_url: crate::ServiceEndpoint::parse("https://example.test")
323 .unwrap(),
324 did_domain: "awiki.test".to_owned(),
325 user_service_endpoint: None,
326 message_service_endpoint: None,
327 mail_service_endpoint: None,
328 anp_service_endpoint: None,
329 anp_service_did: None,
330 ca_bundle: None,
331 transport_policy: crate::MessageTransportPolicy::HttpOnly,
332 },
333 crate::ImCorePaths {
334 identities: crate::paths::IdentityRegistryPaths {
335 identity_root_dir: self.root.join("identities"),
336 registry_path: self.root.join("identities").join("registry.json"),
337 default_identity_path: Some(self.root.join("identities").join("default")),
338 },
339 local_state: crate::paths::LocalStatePaths {
340 sqlite_path: self.root.join("local").join("im.sqlite"),
341 },
342 runtime: crate::paths::RuntimePaths {
343 cache_dir: self.root.join("cache"),
344 temp_dir: self.root.join("tmp"),
345 },
346 },
347 )
348 .unwrap()
349 .client(crate::identity::IdentitySelector::LocalAlias(
350 "alice".to_owned(),
351 ))
352 .unwrap()
353 }
354
355 fn seed_message(&self, record: crate::internal::local_state::messages::MessageRecord) {
356 let connection = crate::internal::local_state::open_writable(
357 &self.root.join("local").join("im.sqlite"),
358 )
359 .unwrap();
360 crate::internal::local_state::messages::upsert_message(&connection, &record).unwrap();
361 }
362
363 fn message_record_defaults() -> crate::internal::local_state::messages::MessageRecord {
364 crate::internal::local_state::messages::MessageRecord {
365 owner_identity_id: "alice-id".to_owned(),
366 owner_did: "did:example:alice".to_owned(),
367 direction: 0,
368 content_type: "text/plain".to_owned(),
369 content: "hello from projection".to_owned(),
370 sent_at: "2026-07-05T00:00:00Z".to_owned(),
371 stored_at: "2026-07-05T00:00:00Z".to_owned(),
372 ..crate::internal::local_state::messages::MessageRecord::default()
373 }
374 }
375 }
376}
377
378#[cfg(all(test, feature = "sqlite"))]
379mod conversation_read_model_tests {
380 use serde_json::json;
381 use std::fs;
382 use std::path::PathBuf;
383
384 #[test]
385 fn local_conversation_timeline_reads_projection_by_conversation_id() {
386 let fixture = Fixture::new("conversation-timeline");
387 let client = fixture.client();
388 let connection =
389 crate::internal::local_state::open_writable(&fixture.sqlite_path()).unwrap();
390 crate::internal::local_state::messages::upsert_message(
391 &connection,
392 &crate::internal::local_state::messages::MessageRecord {
393 msg_id: "msg-conversation-local".to_owned(),
394 owner_identity_id: "alice-id".to_owned(),
395 owner_did: "did:example:alice".to_owned(),
396 conversation_id: "dm:did:example:bob".to_owned(),
397 thread_id: "dm:did:example:bob".to_owned(),
398 direction: 0,
399 sender_did: "did:example:bob".to_owned(),
400 receiver_did: "did:example:alice".to_owned(),
401 content_type: "text/plain".to_owned(),
402 content: "hello from projection".to_owned(),
403 sent_at: "2026-07-05T00:00:00Z".to_owned(),
404 stored_at: "2026-07-05T00:00:00Z".to_owned(),
405 server_seq: Some(7),
406 ..crate::internal::local_state::messages::MessageRecord::default()
407 },
408 )
409 .unwrap();
410
411 let page = client
412 .messages()
413 .local_conversation_timeline_with_metadata(
414 crate::messages::ConversationReadRef::new("dm:did:example:bob").unwrap(),
415 crate::messages::LocalHistoryQuery {
416 limit: crate::ids::PageLimit(20),
417 cursor: None,
418 },
419 )
420 .unwrap();
421
422 assert_eq!(page.items.len(), 1);
423 assert_eq!(page.items[0].id.as_str(), "msg-conversation-local");
424 assert_eq!(
425 page.items[0].metadata.server_sequence,
426 Some(7),
427 "conversation timeline should read committed projection metadata"
428 );
429 assert!(!page.has_more);
430 }
431
432 #[test]
433 fn sync_conversation_after_resolves_direct_conversation_to_syncable_thread() {
434 let fixture = Fixture::new("conversation-sync-direct");
435 let client = fixture.client();
436
437 let resolved = super::resolve_service_conversation_thread(
438 &client,
439 &crate::messages::ConversationReadRef::new("dm:did:example:bob").unwrap(),
440 )
441 .unwrap();
442
443 match resolved.thread {
444 crate::messages::ThreadRef::Direct(peer) => {
445 assert_eq!(peer.as_str(), "did:example:bob");
446 }
447 other => panic!("expected direct thread, got {other:?}"),
448 }
449 assert_eq!(resolved.resolved_did.as_deref(), Some("did:example:bob"));
450 assert!(resolved.peer_scope.is_none());
451 }
452
453 #[test]
454 fn sync_conversation_after_rejects_non_canonical_direct_alias() {
455 let fixture = Fixture::new("conversation-sync-direct-alias");
456 let client = fixture.client();
457
458 let err = super::resolve_service_conversation_thread(
459 &client,
460 &crate::messages::ConversationReadRef::new("dm:bob.awiki.test").unwrap(),
461 )
462 .unwrap_err();
463
464 assert!(matches!(
465 err,
466 crate::ImError::InvalidInput {
467 field: Some(ref field),
468 ..
469 } if field == "conversation_id"
470 ));
471 }
472
473 #[test]
474 fn sync_conversation_after_rejects_legacy_sorted_direct_alias() {
475 let fixture = Fixture::new("conversation-sync-sorted-alias");
476 let client = fixture.client();
477
478 let err = super::resolve_service_conversation_thread(
479 &client,
480 &crate::messages::ConversationReadRef::new("dm:did:example:alice:did:example:bob")
481 .unwrap(),
482 )
483 .unwrap_err();
484
485 assert!(matches!(
486 err,
487 crate::ImError::InvalidInput {
488 field: Some(ref field),
489 ..
490 } if field == "conversation_id"
491 ));
492 }
493
494 #[test]
495 fn sync_conversation_after_public_path_does_not_route_as_raw_thread() {
496 let fixture = Fixture::new("conversation-sync-public-path");
497 let client = fixture.client();
498
499 let err = client
500 .messages()
501 .sync_conversation_after(crate::messages::SyncConversationAfterRequest {
502 conversation: crate::messages::ConversationReadRef::new("dm:did:example:bob")
503 .unwrap(),
504 after_server_seq: Some("0".to_owned()),
505 limit: Some(1),
506 })
507 .unwrap_err();
508
509 assert!(
510 !matches!(
511 err,
512 crate::ImError::UnsupportedCapability { ref capability }
513 if capability == "sync-thread-after-raw-thread"
514 ),
515 "sync_conversation_after must resolve conversation_id before calling thread-after"
516 );
517 }
518
519 #[test]
520 fn sync_conversation_after_resolves_group_conversation_to_syncable_thread() {
521 let fixture = Fixture::new("conversation-sync-group");
522 let client = fixture.client();
523
524 let resolved = super::resolve_service_conversation_thread(
525 &client,
526 &crate::messages::ConversationReadRef::new("group:did:example:group").unwrap(),
527 )
528 .unwrap();
529
530 match resolved.thread {
531 crate::messages::ThreadRef::Group(group) => {
532 assert_eq!(group.as_str(), "did:example:group");
533 }
534 other => panic!("expected group thread, got {other:?}"),
535 }
536 assert!(resolved.resolved_did.is_none());
537 assert!(resolved.peer_scope.is_none());
538 }
539
540 #[test]
541 fn sync_conversation_after_resolves_peer_scope_from_projection_metadata() {
542 let fixture = Fixture::new("conversation-sync-peer-scope");
543 let client = fixture.client();
544 let peer_scope = crate::internal::local_state::owner_scope::DirectPeerScope::new(
545 "user-bob",
546 "Bob.AWiki.Test",
547 )
548 .unwrap();
549 let conversation_id =
550 crate::internal::local_state::owner_scope::direct_conversation_id_for_peer_scope(
551 &peer_scope,
552 );
553 fixture.seed_message(crate::internal::local_state::messages::MessageRecord {
554 msg_id: "msg-peer-scope-sync".to_owned(),
555 conversation_id: conversation_id.clone(),
556 thread_id: conversation_id.clone(),
557 sender_did: "did:example:bob-current".to_owned(),
558 receiver_did: "did:example:alice".to_owned(),
559 metadata: json!({
560 "peer_user_id": "user-bob",
561 "peer_full_handle": "bob.awiki.test",
562 "peer_current_did": "did:example:bob-current"
563 })
564 .to_string(),
565 ..Fixture::message_record_defaults()
566 });
567
568 let resolved = super::resolve_service_conversation_thread(
569 &client,
570 &crate::messages::ConversationReadRef::new(conversation_id).unwrap(),
571 )
572 .unwrap();
573
574 match resolved.thread {
575 crate::messages::ThreadRef::Direct(peer) => {
576 assert_eq!(peer.as_str(), "did:example:bob-current");
577 }
578 other => panic!("expected direct thread, got {other:?}"),
579 }
580 assert_eq!(
581 resolved.resolved_did.as_deref(),
582 Some("did:example:bob-current")
583 );
584 let resolved_scope = resolved.peer_scope.expect("peer scope");
585 assert_eq!(resolved_scope.user_id, "user-bob");
586 assert_eq!(resolved_scope.full_handle, "bob.awiki.test");
587 }
588
589 #[test]
590 fn sync_conversation_after_peer_scope_prefers_metadata_did_over_legacy_outgoing_rows() {
591 let fixture = Fixture::new("conversation-sync-peer-scope-rotation");
592 let client = fixture.client();
593 let peer_scope = crate::internal::local_state::owner_scope::DirectPeerScope::new(
594 "user-bob",
595 "bob.awiki.test",
596 )
597 .unwrap();
598 let conversation_id =
599 crate::internal::local_state::owner_scope::direct_conversation_id_for_peer_scope(
600 &peer_scope,
601 );
602 fixture.seed_message(crate::internal::local_state::messages::MessageRecord {
603 msg_id: "msg-legacy-latest".to_owned(),
604 conversation_id: conversation_id.clone(),
605 thread_id: conversation_id.clone(),
606 direction: 1,
607 sender_did: "did:example:alice".to_owned(),
608 receiver_did: "did:example:bob-old".to_owned(),
609 sent_at: "2026-07-05T00:02:00Z".to_owned(),
610 stored_at: "2026-07-05T00:02:00Z".to_owned(),
611 ..Fixture::message_record_defaults()
612 });
613 fixture.seed_message(crate::internal::local_state::messages::MessageRecord {
614 msg_id: "msg-metadata-older".to_owned(),
615 conversation_id: conversation_id.clone(),
616 thread_id: conversation_id.clone(),
617 direction: 1,
618 sender_did: "did:example:alice".to_owned(),
619 receiver_did: "did:example:bob-old".to_owned(),
620 sent_at: "2026-07-05T00:01:00Z".to_owned(),
621 stored_at: "2026-07-05T00:01:00Z".to_owned(),
622 metadata: json!({
623 "peer_user_id": "user-bob",
624 "peer_full_handle": "bob.awiki.test",
625 "peer_current_did": "did:example:bob-current"
626 })
627 .to_string(),
628 ..Fixture::message_record_defaults()
629 });
630
631 let resolved = super::resolve_service_conversation_thread(
632 &client,
633 &crate::messages::ConversationReadRef::new(conversation_id).unwrap(),
634 )
635 .unwrap();
636
637 match resolved.thread {
638 crate::messages::ThreadRef::Direct(peer) => {
639 assert_eq!(peer.as_str(), "did:example:bob-current");
640 }
641 other => panic!("expected direct thread, got {other:?}"),
642 }
643 assert_eq!(
644 resolved.resolved_did.as_deref(),
645 Some("did:example:bob-current")
646 );
647 }
648
649 #[test]
650 fn sync_conversation_after_peer_scope_prefers_latest_incoming_did_over_stale_metadata() {
651 let fixture = Fixture::new("conversation-sync-peer-scope-latest-incoming");
652 let client = fixture.client();
653 let peer_scope = crate::internal::local_state::owner_scope::DirectPeerScope::new(
654 "user-bob",
655 "bob.awiki.test",
656 )
657 .unwrap();
658 let conversation_id =
659 crate::internal::local_state::owner_scope::direct_conversation_id_for_peer_scope(
660 &peer_scope,
661 );
662 fixture.seed_message(crate::internal::local_state::messages::MessageRecord {
663 msg_id: "msg-stale-metadata-latest".to_owned(),
664 conversation_id: conversation_id.clone(),
665 thread_id: conversation_id.clone(),
666 sender_did: "did:example:bob-current".to_owned(),
667 receiver_did: "did:example:alice".to_owned(),
668 sent_at: "2026-07-05T00:02:00Z".to_owned(),
669 stored_at: "2026-07-05T00:02:00Z".to_owned(),
670 metadata: json!({
671 "peer_user_id": "user-bob",
672 "peer_full_handle": "bob.awiki.test",
673 "peer_current_did": "did:example:bob-old"
674 })
675 .to_string(),
676 ..Fixture::message_record_defaults()
677 });
678
679 let resolved = super::resolve_service_conversation_thread(
680 &client,
681 &crate::messages::ConversationReadRef::new(conversation_id).unwrap(),
682 )
683 .unwrap();
684
685 match resolved.thread {
686 crate::messages::ThreadRef::Direct(peer) => {
687 assert_eq!(peer.as_str(), "did:example:bob-current");
688 }
689 other => panic!("expected direct thread, got {other:?}"),
690 }
691 assert_eq!(
692 resolved.resolved_did.as_deref(),
693 Some("did:example:bob-current")
694 );
695 }
696
697 struct Fixture {
698 root: PathBuf,
699 }
700
701 impl Fixture {
702 fn new(prefix: &str) -> Self {
703 let nanos = std::time::SystemTime::now()
704 .duration_since(std::time::UNIX_EPOCH)
705 .unwrap()
706 .as_nanos();
707 let root = std::env::temp_dir().join(format!(
708 "im-core-service-{prefix}-{}-{nanos}",
709 std::process::id()
710 ));
711 let identity_root = root.join("identities");
712 let identity_dir = identity_root.join("alice");
713 fs::create_dir_all(&identity_dir).unwrap();
714 fs::create_dir_all(root.join("local")).unwrap();
715 fs::write(identity_root.join("default"), "alice\n").unwrap();
716 fs::write(
717 identity_root.join("registry.json"),
718 json!({
719 "default_identity": "alice",
720 "identities": [{
721 "id": "alice-id",
722 "did": "did:example:alice",
723 "local_alias": "alice",
724 "ready_for_auth": true,
725 "ready_for_messaging": true,
726 "missing": []
727 }]
728 })
729 .to_string(),
730 )
731 .unwrap();
732 fs::write(identity_dir.join("did.json"), "{}").unwrap();
733 Self { root }
734 }
735
736 fn client(&self) -> crate::core::ImClient {
737 crate::core::ImCore::new(
738 crate::ImCoreConfig {
739 service_base_url: crate::ServiceEndpoint::parse("https://example.test")
740 .unwrap(),
741 did_domain: "awiki.test".to_owned(),
742 user_service_endpoint: None,
743 message_service_endpoint: None,
744 mail_service_endpoint: None,
745 anp_service_endpoint: None,
746 anp_service_did: None,
747 ca_bundle: None,
748 transport_policy: crate::MessageTransportPolicy::HttpOnly,
749 },
750 crate::ImCorePaths {
751 identities: crate::IdentityRegistryPaths {
752 identity_root_dir: self.root.join("identities"),
753 registry_path: self.root.join("identities").join("registry.json"),
754 default_identity_path: Some(self.root.join("identities").join("default")),
755 },
756 local_state: crate::LocalStatePaths {
757 sqlite_path: self.sqlite_path(),
758 },
759 runtime: crate::RuntimePaths {
760 cache_dir: self.root.join("cache"),
761 temp_dir: self.root.join("tmp"),
762 },
763 },
764 )
765 .unwrap()
766 .client(crate::identity::IdentitySelector::LocalAlias(
767 "alice".to_owned(),
768 ))
769 .unwrap()
770 }
771
772 fn sqlite_path(&self) -> PathBuf {
773 self.root.join("local").join("im.sqlite")
774 }
775
776 fn seed_message(&self, record: crate::internal::local_state::messages::MessageRecord) {
777 let connection =
778 crate::internal::local_state::open_writable(&self.sqlite_path()).unwrap();
779 crate::internal::local_state::messages::upsert_message(&connection, &record).unwrap();
780 }
781
782 fn message_record_defaults() -> crate::internal::local_state::messages::MessageRecord {
783 crate::internal::local_state::messages::MessageRecord {
784 owner_identity_id: "alice-id".to_owned(),
785 owner_did: "did:example:alice".to_owned(),
786 direction: 0,
787 content_type: "text/plain".to_owned(),
788 content: "hello from projection".to_owned(),
789 sent_at: "2026-07-05T00:00:00Z".to_owned(),
790 stored_at: "2026-07-05T00:00:00Z".to_owned(),
791 ..crate::internal::local_state::messages::MessageRecord::default()
792 }
793 }
794 }
795}
796
797#[cfg(all(test, feature = "sqlite"))]
798mod direct_e2ee_async_persistence_tests {
799 use serde_json::json;
800
801 use crate::internal::local_state::owner_scope::DirectPeerScope;
802 use crate::internal::secure_direct::send::{
803 DirectSecureAttachmentLocalEffect, DirectSecureAttachmentSendResult,
804 DirectSecureLocalEffect, DirectSecureTextSendResult,
805 };
806
807 #[tokio::test]
808 async fn deferred_direct_e2ee_success_projection_uses_db_actor() {
809 let fixture = Fixture::new("direct-e2ee-success-actor");
810 let client = fixture.client();
811 let sdk_result = sdk_result("msg-secure-actor", "accepted", Some(17));
812
813 let result = super::persist_deferred_direct_e2ee_effect(
814 &client,
815 DirectSecureTextSendResult {
816 sdk_result,
817 queued_outbox_id: None,
818 target_did: "did:example:bob".to_owned(),
819 target_handle: None,
820 peer_scope: None,
821 text: "actor persisted secret".to_owned(),
822 kind: crate::messages::MessageKind::Text,
823 raw: Some(json!({ "accepted": true })),
824 local_effect: DirectSecureLocalEffect::PersistOutgoing,
825 },
826 None,
827 None,
828 )
829 .await
830 .unwrap();
831
832 assert_eq!(result.message.id.as_str(), "msg-secure-actor");
833 let db = rusqlite::Connection::open(fixture.sqlite_path()).unwrap();
834 let stored = db
835 .query_row(
836 r#"
837SELECT content, is_e2ee, server_seq, metadata
838FROM messages
839WHERE owner_identity_id = 'alice-id' AND msg_id = 'msg-secure-actor'"#,
840 [],
841 |row| {
842 Ok((
843 row.get::<_, String>(0)?,
844 row.get::<_, i64>(1)?,
845 row.get::<_, Option<i64>>(2)?,
846 row.get::<_, String>(3)?,
847 ))
848 },
849 )
850 .unwrap();
851 assert_eq!(stored.0, "actor persisted secret");
852 assert_eq!(stored.1, 1);
853 assert_eq!(stored.2, Some(17));
854 let metadata: serde_json::Value = serde_json::from_str(&stored.3).unwrap();
855 assert_eq!(metadata["security"], "direct-e2ee");
856 assert_eq!(metadata["contains_sensitive"], false);
857 }
858
859 #[tokio::test]
860 async fn deferred_direct_e2ee_peer_scope_projection_and_result_use_same_thread() {
861 let fixture = Fixture::new("direct-e2ee-peer-scope");
862 let client = fixture.client();
863 let peer_scope = DirectPeerScope::new("user-bob", "bob.awiki.test").unwrap();
864 let sdk_result = sdk_result("msg-secure-peer-scope", "accepted", Some(18));
865
866 let result = super::persist_deferred_direct_e2ee_effect(
867 &client,
868 DirectSecureTextSendResult {
869 sdk_result,
870 queued_outbox_id: None,
871 target_did: "did:example:bob-current".to_owned(),
872 target_handle: Some("bob.awiki.test".to_owned()),
873 peer_scope: Some(peer_scope),
874 text: "peer scoped secret".to_owned(),
875 kind: crate::messages::MessageKind::Text,
876 raw: Some(json!({ "accepted": true })),
877 local_effect: DirectSecureLocalEffect::PersistOutgoing,
878 },
879 None,
880 None,
881 )
882 .await
883 .unwrap();
884 let result_thread = match &result.message.thread {
885 crate::messages::ThreadRef::Thread(thread) => thread.as_str().to_owned(),
886 other => panic!("expected peer-scope thread result, got {other:?}"),
887 };
888
889 let db = rusqlite::Connection::open(fixture.sqlite_path()).unwrap();
890 let stored = db
891 .query_row(
892 r#"
893SELECT conversation_id, receiver_did, metadata
894FROM messages
895WHERE owner_identity_id = 'alice-id' AND msg_id = 'msg-secure-peer-scope'"#,
896 [],
897 |row| {
898 Ok((
899 row.get::<_, String>(0)?,
900 row.get::<_, String>(1)?,
901 row.get::<_, String>(2)?,
902 ))
903 },
904 )
905 .unwrap();
906 assert_eq!(stored.0, result_thread);
907 assert_eq!(stored.1, "did:example:bob-current");
908 let metadata: serde_json::Value = serde_json::from_str(&stored.2).unwrap();
909 assert_eq!(metadata["security"], "direct-e2ee");
910 assert_eq!(metadata["target_handle"], "bob.awiki.test");
911 assert_eq!(metadata["peer_user_id"], "user-bob");
912 assert_eq!(metadata["peer_full_handle"], "bob.awiki.test");
913 assert_eq!(metadata["resolved_target_did"], "did:example:bob-current");
914 }
915
916 #[tokio::test]
917 async fn deferred_direct_e2ee_attachment_peer_scope_projection_and_result_use_same_thread() {
918 let fixture = Fixture::new("direct-e2ee-attachment-peer-scope");
919 let client = fixture.client();
920 let peer_scope = DirectPeerScope::new("user-bob", "bob.awiki.test").unwrap();
921 let sdk_result =
922 sdk_attachment_result("msg-secure-attachment-peer-scope", "accepted", Some(19));
923 let redacted_manifest = json!({
924 "primary_attachment_id": "att-peer",
925 "attachments": [{
926 "attachment_id": "att-peer",
927 "filename": "peer.txt",
928 "mime_type": "text/plain",
929 "size": "4",
930 "object_uri": "awiki://object/peer"
931 }]
932 });
933
934 let result = super::persist_deferred_direct_e2ee_attachment_effect(
935 &client,
936 DirectSecureAttachmentSendResult {
937 sdk_result,
938 target_did: "did:example:bob-current".to_owned(),
939 target_handle: Some("bob.awiki.test".to_owned()),
940 peer_scope: Some(peer_scope),
941 redacted_manifest,
942 raw: Some(json!({ "accepted": true })),
943 local_effect: DirectSecureAttachmentLocalEffect::PersistOutgoing,
944 },
945 None,
946 None,
947 )
948 .await
949 .unwrap();
950 let result_thread = match &result.message.thread {
951 crate::messages::ThreadRef::Thread(thread) => thread.as_str().to_owned(),
952 other => panic!("expected peer-scope thread result, got {other:?}"),
953 };
954
955 let db = rusqlite::Connection::open(fixture.sqlite_path()).unwrap();
956 let stored = db
957 .query_row(
958 r#"
959SELECT conversation_id, receiver_did, metadata
960FROM messages
961WHERE owner_identity_id = 'alice-id' AND msg_id = 'msg-secure-attachment-peer-scope'"#,
962 [],
963 |row| {
964 Ok((
965 row.get::<_, String>(0)?,
966 row.get::<_, String>(1)?,
967 row.get::<_, String>(2)?,
968 ))
969 },
970 )
971 .unwrap();
972 assert_eq!(stored.0, result_thread);
973 assert_eq!(stored.1, "did:example:bob-current");
974 let metadata: serde_json::Value = serde_json::from_str(&stored.2).unwrap();
975 assert_eq!(metadata["security"], "direct-e2ee");
976 assert_eq!(metadata["target_handle"], "bob.awiki.test");
977 assert_eq!(metadata["peer_user_id"], "user-bob");
978 assert_eq!(metadata["peer_full_handle"], "bob.awiki.test");
979 assert_eq!(metadata["resolved_target_did"], "did:example:bob-current");
980 assert_eq!(metadata["attachment_id"], "att-peer");
981 }
982
983 #[tokio::test]
984 async fn deferred_direct_e2ee_pending_outbox_uses_db_actor() {
985 let fixture = Fixture::new("direct-e2ee-outbox-actor");
986 let client = fixture.client();
987 let sdk_result = sdk_result("msg-secure-queued", "queued", None);
988 let scope = crate::internal::store::e2ee_outbox::E2eeOutboxOwnerScope::for_client(&client);
989
990 super::persist_deferred_direct_e2ee_effect(
991 &client,
992 DirectSecureTextSendResult {
993 sdk_result,
994 queued_outbox_id: Some("outbox-actor".to_owned()),
995 target_did: "did:example:bob".to_owned(),
996 target_handle: None,
997 peer_scope: None,
998 text: "queued actor secret".to_owned(),
999 kind: crate::messages::MessageKind::Markdown,
1000 raw: None,
1001 local_effect: DirectSecureLocalEffect::QueueOutbox(
1002 crate::internal::store::e2ee_outbox::E2eeOutboxRecord {
1003 outbox_id: "outbox-actor".to_owned(),
1004 owner_identity_id: scope.owner_identity_id,
1005 owner_did: scope.owner_did,
1006 credential_name: scope.credential_name,
1007 peer_did: "did:example:bob".to_owned(),
1008 original_type: "markdown".to_owned(),
1009 plaintext: "queued actor secret".to_owned(),
1010 local_status: "queued".to_owned(),
1011 last_error_code: "pending-confirmation".to_owned(),
1012 retry_hint: "retry".to_owned(),
1013 ..Default::default()
1014 },
1015 ),
1016 },
1017 None,
1018 None,
1019 )
1020 .await
1021 .unwrap();
1022
1023 let db = rusqlite::Connection::open(fixture.sqlite_path()).unwrap();
1024 let stored = db
1025 .query_row(
1026 r#"
1027SELECT peer_did, original_type, plaintext, local_status, last_error_code
1028FROM e2ee_outbox
1029WHERE owner_identity_id = 'alice-id' AND outbox_id = 'outbox-actor'"#,
1030 [],
1031 |row| {
1032 Ok((
1033 row.get::<_, String>(0)?,
1034 row.get::<_, String>(1)?,
1035 row.get::<_, String>(2)?,
1036 row.get::<_, String>(3)?,
1037 row.get::<_, String>(4)?,
1038 ))
1039 },
1040 )
1041 .unwrap();
1042 assert_eq!(stored.0, "did:example:bob");
1043 assert_eq!(stored.1, "markdown");
1044 assert_eq!(stored.2, "queued actor secret");
1045 assert_eq!(stored.3, "queued");
1046 assert_eq!(stored.4, "pending-confirmation");
1047 }
1048
1049 fn sdk_result(
1050 message_id: &str,
1051 delivery_state: &str,
1052 server_sequence: Option<i64>,
1053 ) -> crate::messages::SendMessageResult {
1054 crate::messages::SendMessageResult {
1055 message: crate::messages::Message {
1056 id: crate::ids::MessageId::parse(message_id).unwrap(),
1057 thread: crate::messages::ThreadRef::Direct(
1058 crate::ids::PeerRef::parse("did:example:bob", "").unwrap(),
1059 ),
1060 direction: crate::messages::MessageDirection::Outgoing,
1061 sender: crate::ids::PeerRef::parse("did:example:alice", "").unwrap(),
1062 receiver: Some(crate::ids::PeerRef::parse("did:example:bob", "").unwrap()),
1063 group: None,
1064 body: crate::messages::MessageBodyView::Text {
1065 text: "redacted from db builder".to_owned(),
1066 kind: crate::messages::MessageKind::Text,
1067 },
1068 sent_at: Some("2026-05-24T00:00:00Z".to_owned()),
1069 received_at: None,
1070 metadata: crate::messages::MessageMetadata {
1071 operation_id: Some(message_id.to_owned()),
1072 delivery_state: Some(delivery_state.to_owned()),
1073 send_state: None,
1074 retry_plan: None,
1075 server_sequence,
1076 content_type: Some("text/plain".to_owned()),
1077 conversation_identity: None,
1078 attributes: vec![crate::messages::MessageMetadataAttribute {
1079 key: "security".to_owned(),
1080 value: "direct-e2ee".to_owned(),
1081 }],
1082 },
1083 },
1084 delivery: crate::messages::DeliveryState::Accepted,
1085 warnings: Vec::new(),
1086 }
1087 }
1088
1089 fn sdk_attachment_result(
1090 message_id: &str,
1091 delivery_state: &str,
1092 server_sequence: Option<i64>,
1093 ) -> crate::messages::SendMessageResult {
1094 crate::messages::SendMessageResult {
1095 message: crate::messages::Message {
1096 id: crate::ids::MessageId::parse(message_id).unwrap(),
1097 thread: crate::messages::ThreadRef::Direct(
1098 crate::ids::PeerRef::parse("did:example:bob", "").unwrap(),
1099 ),
1100 direction: crate::messages::MessageDirection::Outgoing,
1101 sender: crate::ids::PeerRef::parse("did:example:alice", "").unwrap(),
1102 receiver: Some(crate::ids::PeerRef::parse("did:example:bob", "").unwrap()),
1103 group: None,
1104 body: crate::messages::MessageBodyView::Unsupported {
1105 content_type: Some(
1106 crate::attachments::manifest::attachment_manifest_content_type().to_owned(),
1107 ),
1108 },
1109 sent_at: Some("2026-05-24T00:00:00Z".to_owned()),
1110 received_at: None,
1111 metadata: crate::messages::MessageMetadata {
1112 operation_id: Some(message_id.to_owned()),
1113 delivery_state: Some(delivery_state.to_owned()),
1114 send_state: None,
1115 retry_plan: None,
1116 server_sequence,
1117 content_type: Some(
1118 crate::attachments::manifest::attachment_manifest_content_type().to_owned(),
1119 ),
1120 conversation_identity: None,
1121 attributes: vec![crate::messages::MessageMetadataAttribute {
1122 key: "security".to_owned(),
1123 value: "direct-e2ee".to_owned(),
1124 }],
1125 },
1126 },
1127 delivery: crate::messages::DeliveryState::Accepted,
1128 warnings: Vec::new(),
1129 }
1130 }
1131
1132 struct Fixture {
1133 root: std::path::PathBuf,
1134 }
1135
1136 impl Fixture {
1137 fn new(prefix: &str) -> Self {
1138 let nanos = std::time::SystemTime::now()
1139 .duration_since(std::time::UNIX_EPOCH)
1140 .unwrap()
1141 .as_nanos();
1142 let root = std::env::temp_dir()
1143 .join(format!("im-core-{prefix}-{}-{nanos}", std::process::id()));
1144 let identity_root = root.join("identities");
1145 let identity_dir = identity_root.join("alice");
1146 std::fs::create_dir_all(&identity_dir).unwrap();
1147 std::fs::create_dir_all(root.join("local")).unwrap();
1148 std::fs::write(identity_root.join("default"), "alice\n").unwrap();
1149 std::fs::write(
1150 identity_root.join("registry.json"),
1151 json!({
1152 "default_identity": "alice",
1153 "identities": [{
1154 "id": "alice-id",
1155 "did": "did:example:alice",
1156 "local_alias": "alice",
1157 "ready_for_auth": true,
1158 "ready_for_messaging": true,
1159 "missing": []
1160 }]
1161 })
1162 .to_string(),
1163 )
1164 .unwrap();
1165 std::fs::write(identity_dir.join("did.json"), "{}").unwrap();
1166 Self { root }
1167 }
1168
1169 fn client(&self) -> crate::core::ImClient {
1170 crate::core::ImCore::new(
1171 crate::ImCoreConfig {
1172 service_base_url: crate::ServiceEndpoint::parse("https://example.test")
1173 .unwrap(),
1174 did_domain: "awiki.test".to_owned(),
1175 user_service_endpoint: None,
1176 message_service_endpoint: None,
1177 mail_service_endpoint: None,
1178 anp_service_endpoint: None,
1179 anp_service_did: None,
1180 ca_bundle: None,
1181 transport_policy: crate::MessageTransportPolicy::HttpOnly,
1182 },
1183 crate::ImCorePaths {
1184 identities: crate::IdentityRegistryPaths {
1185 identity_root_dir: self.root.join("identities"),
1186 registry_path: self.root.join("identities").join("registry.json"),
1187 default_identity_path: Some(self.root.join("identities").join("default")),
1188 },
1189 local_state: crate::LocalStatePaths {
1190 sqlite_path: self.sqlite_path(),
1191 },
1192 runtime: crate::RuntimePaths {
1193 cache_dir: self.root.join("cache"),
1194 temp_dir: self.root.join("tmp"),
1195 },
1196 },
1197 )
1198 .unwrap()
1199 .client(crate::identity::IdentitySelector::LocalAlias(
1200 "alice".to_owned(),
1201 ))
1202 .unwrap()
1203 }
1204
1205 fn sqlite_path(&self) -> std::path::PathBuf {
1206 self.root.join("local").join("im.sqlite")
1207 }
1208 }
1209}
1210
1211impl<'a> MessageService<'a> {
1212 pub(crate) fn new(client: &'a crate::core::ImClient) -> Self {
1213 Self { client }
1214 }
1215
1216 pub fn send(
1217 &self,
1218 request: super::SendMessageRequest,
1219 ) -> crate::ImResult<super::SendMessageResult> {
1220 validate_body(&request.body)?;
1221 validate_send_mode(&request.target, &request.security)?;
1222 validate_attachment_security(&request.body, &request.security)?;
1223 validate_delegated_send_scope(&request)?;
1224 match (&request.target, &request.security) {
1225 (
1226 super::MessageTarget::Direct(_),
1227 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::SecureDirect,
1228 ) => self.send_direct_e2ee(resolve_send_request(self.client, request)?),
1229 (
1230 super::MessageTarget::Direct(_),
1231 super::MessageSecurityMode::DefaultPlain | super::MessageSecurityMode::Plain,
1232 ) if matches!(request.body, super::MessageBody::Attachment { .. }) => {
1233 self.send_plain_attachment(request)
1234 }
1235 (super::MessageTarget::Direct(_), _) => {
1236 let resolved = resolve_send_request(self.client, request)?;
1237 let direct_handle = resolved.direct_handle().map(str::to_owned);
1238 let peer_scope = resolved.peer_scope.clone();
1239 let mut result = crate::internal::message_runtime::direct::DirectTextSender::new(
1240 self.client,
1241 crate::internal::auth::session::FileSessionProvider::new(self.client),
1242 crate::internal::transport::CoreHttpTransport::new(self.client),
1243 )
1244 .send(
1245 crate::internal::message_runtime::direct::DirectTextSend {
1246 request: resolved.request.clone(),
1247 resolved_target_did: resolved.target_did.clone(),
1248 credentials: None,
1249 },
1250 )?;
1251 normalize_resolved_direct_send_result(&mut result.sdk_result, &resolved)?;
1252 #[cfg(feature = "sqlite")]
1253 match
1254 crate::internal::message_runtime::local_projection::persist_direct_outgoing_result(
1255 self.client,
1256 &result.target_did,
1257 direct_handle.as_deref(),
1258 peer_scope.as_ref(),
1259 &result.sdk_result,
1260 )
1261 {
1262 Ok(()) => self
1263 .client
1264 .emit_committed_local_message_projection("local_send"),
1265 Err(err) => {
1266 result
1267 .sdk_result
1268 .warnings
1269 .push(format!("Failed to persist local message: {err}"));
1270 }
1271 }
1272 Ok(result.sdk_result)
1273 }
1274 (
1275 super::MessageTarget::Group(_),
1276 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::GroupE2ee,
1277 ) => self.send_group_e2ee(request),
1278 (
1279 super::MessageTarget::Group(_),
1280 super::MessageSecurityMode::DefaultPlain | super::MessageSecurityMode::Plain,
1281 ) if matches!(request.body, super::MessageBody::Attachment { .. }) => {
1282 self.send_plain_attachment(request)
1283 }
1284 (super::MessageTarget::Group(_), _) => {
1285 let mut result = crate::internal::message_runtime::group::GroupTextSender::new(
1286 self.client,
1287 crate::internal::auth::session::FileSessionProvider::new(self.client),
1288 crate::internal::transport::CoreHttpTransport::new(self.client),
1289 )
1290 .send(crate::internal::message_runtime::group::GroupTextSend {
1291 request,
1292 credentials: None,
1293 })?;
1294 #[cfg(feature = "sqlite")]
1295 match
1296 crate::internal::message_runtime::local_projection::persist_group_outgoing_result(
1297 self.client,
1298 &result.group_did,
1299 &result.sdk_result,
1300 )
1301 {
1302 Ok(()) => self
1303 .client
1304 .emit_committed_local_message_projection("local_send"),
1305 Err(err) => {
1306 result
1307 .sdk_result
1308 .warnings
1309 .push(format!("Failed to persist local group message: {err}"));
1310 }
1311 }
1312 Ok(result.sdk_result)
1313 }
1314 }
1315 }
1316
1317 pub(crate) fn send_secure_attachment(
1318 &self,
1319 request: super::SendMessageRequest,
1320 ) -> crate::ImResult<super::SendMessageResult> {
1321 validate_body(&request.body)?;
1322 validate_send_mode(&request.target, &request.security)?;
1323 validate_secure_attachment_request(&request.body, &request.security)?;
1324 match (&request.target, &request.security) {
1325 (
1326 super::MessageTarget::Direct(_),
1327 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::SecureDirect,
1328 ) => self.send_direct_e2ee(resolve_send_request(self.client, request)?),
1329 (
1330 super::MessageTarget::Group(_),
1331 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::GroupE2ee,
1332 ) => self.send_group_e2ee(request),
1333 _ => Err(crate::ImError::unsupported("secure-attachment")),
1334 }
1335 }
1336
1337 pub async fn send_async(
1338 &self,
1339 request: super::SendMessageRequest,
1340 ) -> crate::ImResult<super::SendMessageResult> {
1341 validate_body(&request.body)?;
1342 validate_send_mode(&request.target, &request.security)?;
1343 validate_attachment_security(&request.body, &request.security)?;
1344 validate_delegated_send_scope(&request)?;
1345 match (&request.target, &request.security) {
1346 (
1347 super::MessageTarget::Direct(_),
1348 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::SecureDirect,
1349 ) => {
1350 self.send_direct_e2ee_async(resolve_send_request_async(self.client, request).await?)
1351 .await
1352 }
1353 (
1354 super::MessageTarget::Direct(_),
1355 super::MessageSecurityMode::DefaultPlain | super::MessageSecurityMode::Plain,
1356 ) if matches!(request.body, super::MessageBody::Attachment { .. }) => {
1357 self.send_plain_attachment_async(request).await
1358 }
1359 (super::MessageTarget::Direct(_), _) => {
1360 let resolved = resolve_send_request_async(self.client, request).await?;
1361 let direct_handle = resolved.direct_handle().map(str::to_owned);
1362 let peer_scope = resolved.peer_scope.clone();
1363 let mut result = crate::internal::message_runtime::direct::DirectTextSender::new(
1364 self.client,
1365 crate::internal::auth::session::FileSessionProvider::new(self.client),
1366 crate::internal::transport::CoreHttpTransport::new(self.client),
1367 )
1368 .send_async(crate::internal::message_runtime::direct::DirectTextSend {
1369 request: resolved.request.clone(),
1370 resolved_target_did: resolved.target_did.clone(),
1371 credentials: None,
1372 })
1373 .await?;
1374 normalize_resolved_direct_send_result(&mut result.sdk_result, &resolved)?;
1375 #[cfg(feature = "sqlite")]
1376 match
1377 crate::internal::message_runtime::local_projection::persist_direct_outgoing_result_async(
1378 self.client,
1379 &result.target_did,
1380 direct_handle.as_deref(),
1381 peer_scope.as_ref(),
1382 &result.sdk_result,
1383 )
1384 .await
1385 {
1386 Ok(()) => self
1387 .client
1388 .emit_committed_local_message_projection("local_send"),
1389 Err(err) => {
1390 result
1391 .sdk_result
1392 .warnings
1393 .push(format!("Failed to persist local message: {err}"));
1394 }
1395 }
1396 Ok(result.sdk_result)
1397 }
1398 (
1399 super::MessageTarget::Group(_),
1400 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::GroupE2ee,
1401 ) => self.send_group_e2ee_async(request).await,
1402 (
1403 super::MessageTarget::Group(_),
1404 super::MessageSecurityMode::DefaultPlain | super::MessageSecurityMode::Plain,
1405 ) if matches!(request.body, super::MessageBody::Attachment { .. }) => {
1406 self.send_plain_attachment_async(request).await
1407 }
1408 (super::MessageTarget::Group(_), _) => {
1409 let mut result = crate::internal::message_runtime::group::GroupTextSender::new(
1410 self.client,
1411 crate::internal::auth::session::FileSessionProvider::new(self.client),
1412 crate::internal::transport::CoreHttpTransport::new(self.client),
1413 )
1414 .send_async(crate::internal::message_runtime::group::GroupTextSend {
1415 request,
1416 credentials: None,
1417 })
1418 .await?;
1419 #[cfg(feature = "sqlite")]
1420 match
1421 crate::internal::message_runtime::local_projection::persist_group_outgoing_result_async(
1422 self.client,
1423 &result.group_did,
1424 &result.sdk_result,
1425 )
1426 .await
1427 {
1428 Ok(()) => self
1429 .client
1430 .emit_committed_local_message_projection("local_send"),
1431 Err(err) => {
1432 result
1433 .sdk_result
1434 .warnings
1435 .push(format!("Failed to persist local group message: {err}"));
1436 }
1437 }
1438 Ok(result.sdk_result)
1439 }
1440 }
1441 }
1442
1443 pub async fn send_conversation_text_async(
1444 &self,
1445 request: super::SendConversationTextRequest,
1446 ) -> crate::ImResult<super::SendMessageResult> {
1447 let body = super::MessageBody::Text {
1448 text: request.text,
1449 kind: if request.markdown {
1450 super::MessageKind::Markdown
1451 } else {
1452 super::MessageKind::Text
1453 },
1454 };
1455 let send_request = conversation_send_request(
1456 self.client,
1457 request.conversation,
1458 body,
1459 request.security,
1460 request.client_message_id,
1461 request.idempotency_key,
1462 request.wait_for_final_acceptance,
1463 request.delegated_signing,
1464 )?;
1465 self.send_conversation_request_async(send_request).await
1466 }
1467
1468 pub async fn send_conversation_payload_async(
1469 &self,
1470 request: super::SendConversationPayloadRequest,
1471 ) -> crate::ImResult<super::SendMessageResult> {
1472 let send_request = conversation_send_request(
1473 self.client,
1474 request.conversation,
1475 super::MessageBody::Payload {
1476 payload: request.payload,
1477 },
1478 request.security,
1479 request.client_message_id,
1480 request.idempotency_key,
1481 request.wait_for_final_acceptance,
1482 request.delegated_signing,
1483 )?;
1484 self.send_conversation_request_async(send_request).await
1485 }
1486
1487 async fn send_conversation_request_async(
1488 &self,
1489 resolved: ResolvedConversationSendRequest,
1490 ) -> crate::ImResult<super::SendMessageResult> {
1491 validate_plain_conversation_send(&resolved.request)?;
1492 let message_id = resolved
1493 .request
1494 .client_message_id
1495 .as_ref()
1496 .expect("conversation send request should always have a client message id")
1497 .clone();
1498 let operation_id = resolved.request.delivery.idempotency_key.as_deref();
1499 let target_did = resolved.target_did.as_deref();
1500 let target_handle = resolved.target_handle.as_deref();
1501 let peer_scope = resolved.peer_scope.as_ref();
1502
1503 crate::internal::message_runtime::local_projection::persist_send_projection_async(
1504 self.client,
1505 &resolved.request.target,
1506 &resolved.request.body,
1507 &message_id,
1508 operation_id,
1509 super::DeliveryState::StoredLocally,
1510 target_did,
1511 target_handle,
1512 peer_scope,
1513 )
1514 .await?;
1515 self.client
1516 .emit_committed_local_message_projection("local_send_pending");
1517
1518 match self.send_plain_conversation_network_async(&resolved).await {
1519 Ok(result) => Ok(result),
1520 Err(err) => {
1521 let mut failed_resolved = resolved.clone();
1522 let err = match self
1523 .stale_delivery_retry_target_async(&resolved, &err)
1524 .await
1525 {
1526 Ok(Some(retry_resolved)) => {
1527 match self
1528 .send_plain_conversation_network_async(&retry_resolved)
1529 .await
1530 {
1531 Ok(result) => return Ok(result),
1532 Err(retry_err) => {
1533 failed_resolved = retry_resolved;
1534 retry_err
1535 }
1536 }
1537 }
1538 Ok(None) => err,
1539 Err(resolve_err) => resolve_err,
1540 };
1541 self.persist_failed_conversation_send_projection_async(&failed_resolved, &err)
1542 .await;
1543 Err(err)
1544 }
1545 }
1546 }
1547
1548 async fn stale_delivery_retry_target_async(
1549 &self,
1550 resolved: &ResolvedConversationSendRequest,
1551 err: &crate::ImError,
1552 ) -> crate::ImResult<Option<ResolvedConversationSendRequest>> {
1553 let Some(stale) = stale_delivery_target_from_error(err, self.client.did().as_str()) else {
1554 return Ok(None);
1555 };
1556 let super::MessageTarget::Direct(_) = &resolved.request.target else {
1557 return Ok(None);
1558 };
1559
1560 let mut current_did = stale.current_did;
1561 let mut full_handle = stale
1562 .full_handle
1563 .or_else(|| resolved.target_handle.clone())
1564 .or_else(|| {
1565 resolved
1566 .peer_scope
1567 .as_ref()
1568 .map(|scope| scope.full_handle.clone())
1569 });
1570 let mut user_id = stale.user_id.or_else(|| {
1571 let handle = full_handle.as_deref()?;
1572 let scope = resolved.peer_scope.as_ref()?;
1573 (scope.full_handle.eq_ignore_ascii_case(handle)).then(|| scope.user_id.clone())
1574 });
1575
1576 if let Some(handle) = full_handle.clone() {
1577 if current_did.is_none() || user_id.is_none() {
1578 let lookup = crate::internal::handle_discovery::resolve_direct_handle_async(
1579 self.client,
1580 &handle,
1581 )
1582 .await?;
1583 current_did = Some(lookup.target_did);
1584 full_handle = Some(lookup.full_handle);
1585 user_id = Some(lookup.user_id);
1586 }
1587 }
1588
1589 let Some(current_did) = current_did
1590 .as_deref()
1591 .and_then(|did| canonical_peer_did_value(did, self.client.did().as_str()))
1592 else {
1593 return Ok(None);
1594 };
1595 if resolved
1596 .target_did
1597 .as_deref()
1598 .is_some_and(|did| did.trim() == current_did)
1599 {
1600 return Ok(None);
1601 }
1602
1603 let full_handle = full_handle
1604 .map(|value| value.trim().to_owned())
1605 .filter(|value| !value.is_empty());
1606 let peer_scope = match (user_id, full_handle.as_ref()) {
1607 (Some(user_id), Some(handle)) => Some(
1608 crate::internal::local_state::owner_scope::DirectPeerScope::new(
1609 user_id,
1610 handle.clone(),
1611 )?,
1612 ),
1613 _ => None,
1614 };
1615 let target_peer = match full_handle.as_deref() {
1616 Some(handle) => crate::ids::PeerRef::parse(handle, "")?,
1617 None => crate::ids::PeerRef::parse(¤t_did, "")?,
1618 };
1619
1620 let mut retry = resolved.clone();
1621 retry.request.target = super::MessageTarget::Direct(target_peer);
1622 retry.target_did = Some(current_did);
1623 retry.target_handle = full_handle;
1624 retry.peer_scope = peer_scope;
1625 Ok(Some(retry))
1626 }
1627
1628 async fn persist_failed_conversation_send_projection_async(
1629 &self,
1630 resolved: &ResolvedConversationSendRequest,
1631 err: &crate::ImError,
1632 ) {
1633 let Some(message_id) = resolved.request.client_message_id.as_ref() else {
1634 return;
1635 };
1636 let failed =
1637 crate::internal::message_runtime::local_projection::persist_send_projection_async(
1638 self.client,
1639 &resolved.request.target,
1640 &resolved.request.body,
1641 message_id,
1642 resolved.request.delivery.idempotency_key.as_deref(),
1643 super::DeliveryState::Failed {
1644 reason: err.to_string(),
1645 },
1646 resolved.target_did.as_deref(),
1647 resolved.target_handle.as_deref(),
1648 resolved.peer_scope.as_ref(),
1649 )
1650 .await;
1651 match failed {
1652 Ok(_) => self
1653 .client
1654 .emit_committed_local_message_projection("local_send_failed"),
1655 Err(_persist_err) => {}
1656 }
1657 }
1658
1659 async fn send_plain_conversation_network_async(
1660 &self,
1661 resolved: &ResolvedConversationSendRequest,
1662 ) -> crate::ImResult<super::SendMessageResult> {
1663 match &resolved.request.target {
1664 super::MessageTarget::Direct(_) => {
1665 let mut result = crate::internal::message_runtime::direct::DirectTextSender::new(
1666 self.client,
1667 crate::internal::auth::session::FileSessionProvider::new(self.client),
1668 crate::internal::transport::CoreHttpTransport::new(self.client),
1669 )
1670 .send_async(crate::internal::message_runtime::direct::DirectTextSend {
1671 request: resolved.request.clone(),
1672 resolved_target_did: resolved.target_did.clone(),
1673 credentials: None,
1674 })
1675 .await?;
1676 normalize_direct_send_result_for_peer_scope(
1677 &mut result.sdk_result,
1678 resolved.peer_scope.as_ref(),
1679 resolved.target_handle.as_deref(),
1680 Some(result.target_did.as_str()),
1681 )?;
1682 #[cfg(feature = "sqlite")]
1683 match crate::internal::message_runtime::local_projection::persist_direct_outgoing_result_async(
1684 self.client,
1685 &result.target_did,
1686 resolved.target_handle.as_deref(),
1687 resolved.peer_scope.as_ref(),
1688 &result.sdk_result,
1689 )
1690 .await
1691 {
1692 Ok(()) => self
1693 .client
1694 .emit_committed_local_message_projection("local_send"),
1695 Err(err) => {
1696 result
1697 .sdk_result
1698 .warnings
1699 .push(format!("Failed to persist local conversation message: {err}"));
1700 }
1701 }
1702 Ok(result.sdk_result)
1703 }
1704 super::MessageTarget::Group(_) => {
1705 let mut result = crate::internal::message_runtime::group::GroupTextSender::new(
1706 self.client,
1707 crate::internal::auth::session::FileSessionProvider::new(self.client),
1708 crate::internal::transport::CoreHttpTransport::new(self.client),
1709 )
1710 .send_async(crate::internal::message_runtime::group::GroupTextSend {
1711 request: resolved.request.clone(),
1712 credentials: None,
1713 })
1714 .await?;
1715 #[cfg(feature = "sqlite")]
1716 match crate::internal::message_runtime::local_projection::persist_group_outgoing_result_async(
1717 self.client,
1718 &result.group_did,
1719 &result.sdk_result,
1720 )
1721 .await
1722 {
1723 Ok(()) => self
1724 .client
1725 .emit_committed_local_message_projection("local_send"),
1726 Err(err) => {
1727 result
1728 .sdk_result
1729 .warnings
1730 .push(format!("Failed to persist local conversation group message: {err}"));
1731 }
1732 }
1733 Ok(result.sdk_result)
1734 }
1735 }
1736 }
1737
1738 pub(crate) async fn send_secure_attachment_async(
1739 &self,
1740 request: super::SendMessageRequest,
1741 ) -> crate::ImResult<super::SendMessageResult> {
1742 validate_body(&request.body)?;
1743 validate_send_mode(&request.target, &request.security)?;
1744 validate_secure_attachment_request(&request.body, &request.security)?;
1745 match (&request.target, &request.security) {
1746 (
1747 super::MessageTarget::Direct(_),
1748 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::SecureDirect,
1749 ) => {
1750 self.send_direct_e2ee_async(resolve_send_request_async(self.client, request).await?)
1751 .await
1752 }
1753 (
1754 super::MessageTarget::Group(_),
1755 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::GroupE2ee,
1756 ) => self.send_group_e2ee_async(request).await,
1757 _ => Err(crate::ImError::unsupported("secure-attachment")),
1758 }
1759 }
1760
1761 fn send_plain_attachment(
1762 &self,
1763 request: super::SendMessageRequest,
1764 ) -> crate::ImResult<super::SendMessageResult> {
1765 let target = request.target.clone();
1766 let attachment = attachment_request_from_message_request(request)?;
1767 self.client
1768 .attachments()
1769 .send(target, attachment)
1770 .map(|result| result.message)
1771 }
1772
1773 async fn send_plain_attachment_async(
1774 &self,
1775 request: super::SendMessageRequest,
1776 ) -> crate::ImResult<super::SendMessageResult> {
1777 let target = request.target.clone();
1778 let attachment = attachment_request_from_message_request(request)?;
1779 self.client
1780 .attachments()
1781 .send_async(target, attachment)
1782 .await
1783 .map(|result| result.message)
1784 }
1785
1786 fn send_direct_e2ee(
1787 &self,
1788 resolved: ResolvedSendRequest,
1789 ) -> crate::ImResult<super::SendMessageResult> {
1790 #[cfg(all(feature = "sqlite", feature = "blocking"))]
1791 {
1792 send_direct_e2ee_with_client(self.client, resolved)
1793 }
1794 #[cfg(all(feature = "sqlite", not(feature = "blocking")))]
1795 {
1796 let _ = resolved;
1797 Err(crate::ImError::unsupported("sync-secure-direct-send"))
1798 }
1799 #[cfg(not(feature = "sqlite"))]
1800 {
1801 let _ = resolved;
1802 Err(crate::ImError::unsupported("secure-direct"))
1803 }
1804 }
1805
1806 #[cfg(feature = "sqlite")]
1807 async fn send_direct_e2ee_async(
1808 &self,
1809 resolved: ResolvedSendRequest,
1810 ) -> crate::ImResult<super::SendMessageResult> {
1811 let target_handle = resolved.direct_handle().map(str::to_owned);
1812 let peer_scope = resolved.peer_scope.clone();
1813 if matches!(resolved.request.body, super::MessageBody::Attachment { .. }) {
1814 let committed =
1815 crate::internal::attachment_runtime::upload::AttachmentUploadRuntime::new(
1816 self.client,
1817 crate::internal::auth::session::FileSessionProvider::new(self.client),
1818 crate::internal::transport::CoreHttpTransport::new(self.client),
1819 )
1820 .prepare_and_commit_object_async(
1821 crate::internal::attachment_runtime::upload::AttachmentPrepareObjectInput {
1822 target: resolved.request.target.clone(),
1823 request: attachment_request_from_message_request(resolved.request.clone())?,
1824 resolved_target_did: resolved.target_did.clone(),
1825 message_security_profile: "direct-e2ee",
1826 },
1827 )
1828 .await?;
1829 let async_result =
1830 crate::internal::secure_direct::async_send::AsyncDirectSecureTextSender::new(
1831 self.client,
1832 crate::internal::auth::session::FileSessionProvider::new(self.client),
1833 crate::internal::transport::CoreHttpTransport::new(self.client),
1834 crate::internal::transport::CoreHttpTransport::new(self.client),
1835 )
1836 .send_attachment_async_if_ready(
1837 crate::internal::secure_direct::send::DirectSecureAttachmentSend {
1838 request: resolved.request.clone(),
1839 resolved_target_did: resolved.target_did.clone(),
1840 target_handle: target_handle.clone(),
1841 peer_scope: peer_scope.clone(),
1842 committed: committed.clone(),
1843 local_persistence:
1844 crate::internal::secure_direct::send::DirectSecureLocalPersistence::Deferred,
1845 },
1846 )
1847 .await?;
1848 if let Some(result) = async_result {
1849 return persist_deferred_direct_e2ee_attachment_effect(
1850 self.client,
1851 result,
1852 target_handle.as_deref(),
1853 peer_scope.as_ref(),
1854 )
1855 .await;
1856 }
1857 #[cfg(feature = "blocking")]
1858 {
1859 let client = self.client.clone();
1860 let result = crate::internal::runtime::worker::run_blocking(move || {
1861 send_direct_e2ee_attachment_with_client_and_persistence(
1862 &client,
1863 resolved,
1864 committed,
1865 crate::internal::secure_direct::send::DirectSecureLocalPersistence::Deferred,
1866 )
1867 })
1868 .await
1869 .map_err(|err| crate::ImError::Internal {
1870 message: err.to_string(),
1871 })??;
1872 return persist_deferred_direct_e2ee_attachment_effect(
1873 self.client,
1874 result,
1875 target_handle.as_deref(),
1876 peer_scope.as_ref(),
1877 )
1878 .await;
1879 }
1880 #[cfg(not(feature = "blocking"))]
1881 {
1882 let _ = committed;
1883 return Err(crate::ImError::LocalStateUnavailable {
1884 detail: "direct E2EE async attachment send requires an established local session; sync compatibility fallback is disabled".to_owned(),
1885 });
1886 }
1887 }
1888 let async_input = crate::internal::secure_direct::send::DirectSecureTextSend {
1889 request: resolved.request.clone(),
1890 resolved_target_did: resolved.target_did.clone(),
1891 target_handle: target_handle.clone(),
1892 peer_scope: peer_scope.clone(),
1893 local_persistence:
1894 crate::internal::secure_direct::send::DirectSecureLocalPersistence::Deferred,
1895 };
1896 match crate::internal::secure_direct::async_send::AsyncDirectSecureTextSender::new(
1897 self.client,
1898 crate::internal::auth::session::FileSessionProvider::new(self.client),
1899 crate::internal::transport::CoreHttpTransport::new(self.client),
1900 crate::internal::transport::CoreHttpTransport::new(self.client),
1901 )
1902 .send_async_if_ready(async_input)
1903 .await?
1904 {
1905 crate::internal::secure_direct::async_send::AsyncDirectSecureSendOutcome::Sent(
1906 result,
1907 ) => {
1908 return persist_deferred_direct_e2ee_effect(
1909 self.client,
1910 result,
1911 target_handle.as_deref(),
1912 peer_scope.as_ref(),
1913 )
1914 .await
1915 }
1916 crate::internal::secure_direct::async_send::AsyncDirectSecureSendOutcome::Fallback(
1917 crate::internal::secure_direct::async_send::AsyncDirectSecureSendFallback::NoEstablishedSession,
1918 ) => {}
1919 }
1920
1921 #[cfg(feature = "blocking")]
1922 {
1923 let client = self.client.clone();
1924 let result = crate::internal::runtime::worker::run_blocking(move || {
1925 send_direct_e2ee_with_client_and_persistence(
1926 &client,
1927 resolved,
1928 crate::internal::secure_direct::send::DirectSecureLocalPersistence::Deferred,
1929 )
1930 })
1931 .await
1932 .map_err(|err| crate::ImError::Internal {
1933 message: err.to_string(),
1934 })??;
1935 persist_deferred_direct_e2ee_effect(
1936 self.client,
1937 result,
1938 target_handle.as_deref(),
1939 peer_scope.as_ref(),
1940 )
1941 .await
1942 }
1943 #[cfg(not(feature = "blocking"))]
1944 {
1945 let _ = resolved;
1946 Err(crate::ImError::LocalStateUnavailable {
1947 detail: "direct E2EE async send requires an established local session; sync compatibility fallback is disabled".to_owned(),
1948 })
1949 }
1950 }
1951
1952 #[cfg(not(feature = "sqlite"))]
1953 async fn send_direct_e2ee_async(
1954 &self,
1955 resolved: ResolvedSendRequest,
1956 ) -> crate::ImResult<super::SendMessageResult> {
1957 let _ = resolved;
1958 Err(crate::ImError::unsupported("secure-direct"))
1959 }
1960
1961 #[cfg(feature = "group-e2ee")]
1962 fn send_group_e2ee(
1963 &self,
1964 request: super::SendMessageRequest,
1965 ) -> crate::ImResult<super::SendMessageResult> {
1966 #[cfg(feature = "blocking")]
1967 {
1968 let session_provider =
1969 crate::internal::auth::session::FileSessionProvider::new(self.client);
1970 let mut transport = crate::internal::transport::CoreHttpTransport::new(self.client);
1971 crate::internal::group_e2ee::lifecycle::ensure_group_e2ee_service_available(
1972 self.client,
1973 &session_provider,
1974 &mut transport,
1975 crate::internal::group_e2ee::lifecycle::GroupE2eeServiceAvailabilityInput {
1976 credentials: None,
1977 service_did: None,
1978 check_key_package: false,
1979 },
1980 )?;
1981 let provider =
1982 crate::internal::group_e2ee::storage::native_provider_for_client(self.client)?;
1983 if matches!(request.body, super::MessageBody::Attachment { .. }) {
1984 let committed =
1985 crate::internal::attachment_runtime::upload::AttachmentUploadRuntime::new(
1986 self.client,
1987 crate::internal::auth::session::FileSessionProvider::new(self.client),
1988 crate::internal::transport::CoreHttpTransport::new(self.client),
1989 )
1990 .prepare_and_commit_object(
1991 crate::internal::attachment_runtime::upload::AttachmentPrepareObjectInput {
1992 target: request.target.clone(),
1993 request: attachment_request_from_message_request(request.clone())?,
1994 resolved_target_did: None,
1995 message_security_profile: "group-e2ee",
1996 },
1997 )?;
1998 return crate::internal::group_e2ee::runtime::GroupE2eeTextSender::new(
1999 self.client,
2000 crate::internal::auth::session::FileSessionProvider::new(self.client),
2001 crate::internal::transport::CoreHttpTransport::new(self.client),
2002 provider,
2003 )
2004 .send_attachment(
2005 crate::internal::group_e2ee::runtime::GroupE2eeAttachmentSend {
2006 request,
2007 group_state_ref: None,
2008 credentials: None,
2009 committed,
2010 },
2011 )
2012 .map(|result| result.sdk_result);
2013 }
2014 crate::internal::group_e2ee::runtime::GroupE2eeTextSender::new(
2015 self.client,
2016 crate::internal::auth::session::FileSessionProvider::new(self.client),
2017 crate::internal::transport::CoreHttpTransport::new(self.client),
2018 provider,
2019 )
2020 .send(crate::internal::group_e2ee::runtime::GroupE2eeTextSend {
2021 request,
2022 group_state_ref: None,
2023 credentials: None,
2024 })
2025 .map(|result| result.sdk_result)
2026 }
2027 #[cfg(not(feature = "blocking"))]
2028 {
2029 let _ = request;
2030 Err(crate::ImError::unsupported("sync-group-e2ee-send"))
2031 }
2032 }
2033
2034 #[cfg(not(feature = "group-e2ee"))]
2035 fn send_group_e2ee(
2036 &self,
2037 _request: super::SendMessageRequest,
2038 ) -> crate::ImResult<super::SendMessageResult> {
2039 Err(crate::ImError::unsupported("group-e2ee"))
2040 }
2041
2042 #[cfg(feature = "group-e2ee")]
2043 async fn send_group_e2ee_async(
2044 &self,
2045 request: super::SendMessageRequest,
2046 ) -> crate::ImResult<super::SendMessageResult> {
2047 let provider =
2048 crate::internal::group_e2ee::storage::native_provider_for_client(self.client)?;
2049 if matches!(request.body, super::MessageBody::Attachment { .. }) {
2050 let committed =
2051 crate::internal::attachment_runtime::upload::AttachmentUploadRuntime::new(
2052 self.client,
2053 crate::internal::auth::session::FileSessionProvider::new(self.client),
2054 crate::internal::transport::CoreHttpTransport::new(self.client),
2055 )
2056 .prepare_and_commit_object_async(
2057 crate::internal::attachment_runtime::upload::AttachmentPrepareObjectInput {
2058 target: request.target.clone(),
2059 request: attachment_request_from_message_request(request.clone())?,
2060 resolved_target_did: None,
2061 message_security_profile: "group-e2ee",
2062 },
2063 )
2064 .await?;
2065 return crate::internal::group_e2ee::runtime::GroupE2eeTextSender::new(
2066 self.client,
2067 crate::internal::auth::session::FileSessionProvider::new(self.client),
2068 crate::internal::transport::CoreHttpTransport::new(self.client),
2069 provider,
2070 )
2071 .send_attachment_async(
2072 crate::internal::group_e2ee::runtime::GroupE2eeAttachmentSend {
2073 request,
2074 group_state_ref: None,
2075 credentials: None,
2076 committed,
2077 },
2078 )
2079 .await
2080 .map(|result| result.sdk_result);
2081 }
2082 match crate::internal::group_e2ee::runtime::GroupE2eeTextSender::new(
2083 self.client,
2084 crate::internal::auth::session::FileSessionProvider::new(self.client),
2085 crate::internal::transport::CoreHttpTransport::new(self.client),
2086 provider,
2087 )
2088 .send_async(crate::internal::group_e2ee::runtime::GroupE2eeTextSend {
2089 request: request.clone(),
2090 group_state_ref: None,
2091 credentials: None,
2092 })
2093 .await
2094 {
2095 Ok(result) => Ok(result.sdk_result),
2096 Err(err)
2097 if crate::internal::group_e2ee::runtime::is_group_e2ee_epoch_mismatch(&err) =>
2098 {
2099 #[cfg(feature = "blocking")]
2100 {
2101 let client = self.client.clone();
2102 crate::internal::runtime::worker::run_blocking(move || {
2103 client.messages().send_group_e2ee(request)
2104 })
2105 .await
2106 .map_err(|join| crate::ImError::Internal {
2107 message: join.to_string(),
2108 })?
2109 }
2110 #[cfg(not(feature = "blocking"))]
2111 {
2112 let _ = request;
2113 Err(err)
2114 }
2115 }
2116 Err(err) => Err(err),
2117 }
2118 }
2119
2120 #[cfg(not(feature = "group-e2ee"))]
2121 async fn send_group_e2ee_async(
2122 &self,
2123 _request: super::SendMessageRequest,
2124 ) -> crate::ImResult<super::SendMessageResult> {
2125 Err(crate::ImError::unsupported("group-e2ee"))
2126 }
2127
2128 pub fn inbox(
2129 &self,
2130 query: super::InboxQuery,
2131 ) -> crate::ImResult<crate::ids::Page<super::Message>> {
2132 self.inbox_with_metadata(query)
2133 .map(super::MessagePage::into_page)
2134 }
2135
2136 pub async fn inbox_async(
2137 &self,
2138 query: super::InboxQuery,
2139 ) -> crate::ImResult<crate::ids::Page<super::Message>> {
2140 self.inbox_with_metadata_async(query)
2141 .await
2142 .map(super::MessagePage::into_page)
2143 }
2144
2145 pub fn inbox_with_metadata(
2146 &self,
2147 query: super::InboxQuery,
2148 ) -> crate::ImResult<super::MessagePage> {
2149 crate::internal::message_runtime::read::MessageReadRuntime::new(
2150 self.client,
2151 crate::internal::auth::session::FileSessionProvider::new(self.client),
2152 crate::internal::transport::CoreHttpTransport::new(self.client),
2153 crate::internal::transport::CoreHttpTransport::new(self.client),
2154 )
2155 .inbox(crate::internal::message_runtime::read::InboxRead { query })
2156 .map(message_page_from_read_result)?
2157 }
2158
2159 pub async fn inbox_with_metadata_async(
2160 &self,
2161 query: super::InboxQuery,
2162 ) -> crate::ImResult<super::MessagePage> {
2163 crate::internal::message_runtime::read::MessageReadRuntime::new(
2164 self.client,
2165 crate::internal::auth::session::FileSessionProvider::new(self.client),
2166 crate::internal::transport::CoreHttpTransport::new(self.client),
2167 crate::internal::transport::CoreHttpTransport::new(self.client),
2168 )
2169 .inbox_async(crate::internal::message_runtime::read::InboxRead { query })
2170 .await
2171 .map(message_page_from_read_result)?
2172 }
2173
2174 pub fn history(
2175 &self,
2176 thread: super::ThreadRef,
2177 query: super::HistoryQuery,
2178 ) -> crate::ImResult<crate::ids::Page<super::Message>> {
2179 self.history_with_metadata(thread, query)
2180 .map(super::MessagePage::into_page)
2181 }
2182
2183 pub async fn history_async(
2184 &self,
2185 thread: super::ThreadRef,
2186 query: super::HistoryQuery,
2187 ) -> crate::ImResult<crate::ids::Page<super::Message>> {
2188 self.history_with_metadata_async(thread, query)
2189 .await
2190 .map(super::MessagePage::into_page)
2191 }
2192
2193 pub fn history_with_metadata(
2194 &self,
2195 thread: super::ThreadRef,
2196 query: super::HistoryQuery,
2197 ) -> crate::ImResult<super::MessagePage> {
2198 let resolved = resolve_history_thread(self.client, thread)?;
2199 crate::internal::message_runtime::read::MessageReadRuntime::new(
2200 self.client,
2201 crate::internal::auth::session::FileSessionProvider::new(self.client),
2202 crate::internal::transport::CoreHttpTransport::new(self.client),
2203 crate::internal::transport::CoreHttpTransport::new(self.client),
2204 )
2205 .history(crate::internal::message_runtime::read::HistoryRead {
2206 thread: resolved.thread,
2207 query,
2208 resolved_peer_did: resolved.resolved_did.clone(),
2209 peer_scope: resolved.peer_scope.clone(),
2210 })
2211 .map(|result| {
2212 let mut page = message_page_from_read_result(result)?;
2213 #[cfg(feature = "sqlite")]
2214 if let Some((handle, current_did)) = resolved.handle_peer.as_ref() {
2215 if let Ok(dids) =
2216 crate::internal::message_runtime::local_projection::peer_dids_for_handle(
2217 self.client,
2218 handle,
2219 current_did,
2220 )
2221 {
2222 page.resolved_dids = dids
2223 .into_iter()
2224 .filter_map(|did| crate::ids::Did::parse(did).ok())
2225 .collect();
2226 }
2227 }
2228 if page.resolved_dids.is_empty() {
2229 if let Some(did) = resolved.resolved_did {
2230 if let Ok(did) = crate::ids::Did::parse(did) {
2231 page.resolved_dids.push(did);
2232 }
2233 }
2234 }
2235 Ok(page)
2236 })?
2237 }
2238
2239 pub async fn history_with_metadata_async(
2240 &self,
2241 thread: super::ThreadRef,
2242 query: super::HistoryQuery,
2243 ) -> crate::ImResult<super::MessagePage> {
2244 let resolved = resolve_history_thread_async(self.client, thread).await?;
2245 let mut page = crate::internal::message_runtime::read::MessageReadRuntime::new(
2246 self.client,
2247 crate::internal::auth::session::FileSessionProvider::new(self.client),
2248 crate::internal::transport::CoreHttpTransport::new(self.client),
2249 crate::internal::transport::CoreHttpTransport::new(self.client),
2250 )
2251 .history_async(crate::internal::message_runtime::read::HistoryRead {
2252 thread: resolved.thread,
2253 query,
2254 resolved_peer_did: resolved.resolved_did.clone(),
2255 peer_scope: resolved.peer_scope.clone(),
2256 })
2257 .await
2258 .map(message_page_from_read_result)??;
2259 #[cfg(feature = "sqlite")]
2260 if let Some((handle, current_did)) = resolved.handle_peer.as_ref() {
2261 if let Ok(dids) =
2262 crate::internal::message_runtime::local_projection::peer_dids_for_handle_async(
2263 self.client,
2264 handle,
2265 current_did,
2266 )
2267 .await
2268 {
2269 page.resolved_dids = dids
2270 .into_iter()
2271 .filter_map(|did| crate::ids::Did::parse(did).ok())
2272 .collect();
2273 }
2274 }
2275 if page.resolved_dids.is_empty() {
2276 if let Some(did) = resolved.resolved_did {
2277 if let Ok(did) = crate::ids::Did::parse(did) {
2278 page.resolved_dids.push(did);
2279 }
2280 }
2281 }
2282 Ok(page)
2283 }
2284
2285 pub fn local_history(
2286 &self,
2287 thread: super::ThreadRef,
2288 query: super::LocalHistoryQuery,
2289 ) -> crate::ImResult<crate::ids::Page<super::Message>> {
2290 self.local_history_with_metadata(thread, query)
2291 .map(super::MessagePage::into_page)
2292 }
2293
2294 pub async fn local_history_async(
2295 &self,
2296 thread: super::ThreadRef,
2297 query: super::LocalHistoryQuery,
2298 ) -> crate::ImResult<crate::ids::Page<super::Message>> {
2299 self.local_history_with_metadata_async(thread, query)
2300 .await
2301 .map(super::MessagePage::into_page)
2302 }
2303
2304 pub fn local_history_with_metadata(
2305 &self,
2306 thread: super::ThreadRef,
2307 query: super::LocalHistoryQuery,
2308 ) -> crate::ImResult<super::MessagePage> {
2309 crate::internal::message_runtime::read::MessageReadRuntime::new(
2310 self.client,
2311 crate::internal::auth::session::FileSessionProvider::new(self.client),
2312 crate::internal::transport::CoreHttpTransport::new(self.client),
2313 crate::internal::transport::CoreHttpTransport::new(self.client),
2314 )
2315 .local_history(crate::internal::message_runtime::read::LocalHistoryRead { thread, query })
2316 .map(message_page_from_read_result)?
2317 }
2318
2319 pub async fn local_history_with_metadata_async(
2320 &self,
2321 thread: super::ThreadRef,
2322 query: super::LocalHistoryQuery,
2323 ) -> crate::ImResult<super::MessagePage> {
2324 crate::internal::message_runtime::read::MessageReadRuntime::new(
2325 self.client,
2326 crate::internal::auth::session::FileSessionProvider::new(self.client),
2327 crate::internal::transport::CoreHttpTransport::new(self.client),
2328 crate::internal::transport::CoreHttpTransport::new(self.client),
2329 )
2330 .local_history_async(crate::internal::message_runtime::read::LocalHistoryRead {
2331 thread,
2332 query,
2333 })
2334 .await
2335 .map(message_page_from_read_result)?
2336 }
2337
2338 pub fn local_conversation_timeline(
2339 &self,
2340 conversation: super::ConversationReadRef,
2341 query: super::LocalHistoryQuery,
2342 ) -> crate::ImResult<crate::ids::Page<super::Message>> {
2343 self.local_conversation_timeline_with_metadata(conversation, query)
2344 .map(super::MessagePage::into_page)
2345 }
2346
2347 pub async fn local_conversation_timeline_async(
2348 &self,
2349 conversation: super::ConversationReadRef,
2350 query: super::LocalHistoryQuery,
2351 ) -> crate::ImResult<crate::ids::Page<super::Message>> {
2352 self.local_conversation_timeline_with_metadata_async(conversation, query)
2353 .await
2354 .map(super::MessagePage::into_page)
2355 }
2356
2357 pub fn local_conversation_timeline_with_metadata(
2358 &self,
2359 conversation: super::ConversationReadRef,
2360 query: super::LocalHistoryQuery,
2361 ) -> crate::ImResult<super::MessagePage> {
2362 self.local_history_with_metadata(conversation.as_thread_ref()?, query)
2363 }
2364
2365 pub async fn local_conversation_timeline_with_metadata_async(
2366 &self,
2367 conversation: super::ConversationReadRef,
2368 query: super::LocalHistoryQuery,
2369 ) -> crate::ImResult<super::MessagePage> {
2370 self.local_history_with_metadata_async(conversation.as_thread_ref()?, query)
2371 .await
2372 }
2373
2374 pub fn mark_read(
2375 &self,
2376 ids: Vec<crate::ids::MessageId>,
2377 ) -> crate::ImResult<super::MarkReadResult> {
2378 #[cfg(feature = "blocking")]
2379 {
2380 crate::internal::message_runtime::mark_read::MessageMarkReadRuntime::new(
2381 self.client,
2382 crate::internal::auth::session::FileSessionProvider::new(self.client),
2383 crate::internal::transport::CoreHttpTransport::new(self.client),
2384 )
2385 .mark_read(crate::internal::message_runtime::mark_read::MarkReadInput {
2386 message_ids: ids,
2387 })
2388 .map(|result| result.sdk_result)
2389 }
2390 #[cfg(not(feature = "blocking"))]
2391 {
2392 let _ = ids;
2393 Err(crate::ImError::unsupported("sync-message-mark-read"))
2394 }
2395 }
2396
2397 pub async fn mark_read_async(
2398 &self,
2399 ids: Vec<crate::ids::MessageId>,
2400 ) -> crate::ImResult<super::MarkReadResult> {
2401 crate::internal::message_runtime::mark_read::MessageMarkReadRuntime::new(
2402 self.client,
2403 crate::internal::auth::session::FileSessionProvider::new(self.client),
2404 crate::internal::transport::CoreHttpTransport::new(self.client),
2405 )
2406 .mark_read_async(crate::internal::message_runtime::mark_read::MarkReadInput {
2407 message_ids: ids,
2408 })
2409 .await
2410 .map(|result| result.sdk_result)
2411 }
2412
2413 pub fn mark_thread_read(
2414 &self,
2415 request: super::MarkThreadReadRequest,
2416 ) -> crate::ImResult<super::MarkThreadReadResult> {
2417 #[cfg(feature = "blocking")]
2418 {
2419 crate::internal::message_runtime::mark_read::MessageMarkReadRuntime::new(
2420 self.client,
2421 crate::internal::auth::session::FileSessionProvider::new(self.client),
2422 crate::internal::transport::CoreHttpTransport::new(self.client),
2423 )
2424 .mark_thread_read(
2425 crate::internal::message_runtime::mark_read::MarkThreadReadInput {
2426 request,
2427 remote_thread: None,
2428 },
2429 )
2430 .map(|result| result.sdk_result)
2431 }
2432 #[cfg(not(feature = "blocking"))]
2433 {
2434 let _ = request;
2435 Err(crate::ImError::unsupported("sync-message-mark-thread-read"))
2436 }
2437 }
2438
2439 pub async fn mark_thread_read_async(
2440 &self,
2441 request: super::MarkThreadReadRequest,
2442 ) -> crate::ImResult<super::MarkThreadReadResult> {
2443 crate::internal::message_runtime::mark_read::MessageMarkReadRuntime::new(
2444 self.client,
2445 crate::internal::auth::session::FileSessionProvider::new(self.client),
2446 crate::internal::transport::CoreHttpTransport::new(self.client),
2447 )
2448 .mark_thread_read_async(
2449 crate::internal::message_runtime::mark_read::MarkThreadReadInput {
2450 request,
2451 remote_thread: None,
2452 },
2453 )
2454 .await
2455 .map(|result| result.sdk_result)
2456 }
2457
2458 pub fn mark_conversation_read(
2459 &self,
2460 request: super::MarkConversationReadRequest,
2461 ) -> crate::ImResult<super::MarkThreadReadResult> {
2462 #[cfg(feature = "blocking")]
2463 {
2464 let mapped = mark_read_input_for_conversation(self.client, request)?;
2465 crate::internal::message_runtime::mark_read::MessageMarkReadRuntime::new(
2466 self.client,
2467 crate::internal::auth::session::FileSessionProvider::new(self.client),
2468 crate::internal::transport::CoreHttpTransport::new(self.client),
2469 )
2470 .mark_thread_read(mapped)
2471 .map(|result| result.sdk_result)
2472 }
2473 #[cfg(not(feature = "blocking"))]
2474 {
2475 let _ = request;
2476 Err(crate::ImError::unsupported("sync-message-mark-thread-read"))
2477 }
2478 }
2479
2480 pub async fn mark_conversation_read_async(
2481 &self,
2482 request: super::MarkConversationReadRequest,
2483 ) -> crate::ImResult<super::MarkThreadReadResult> {
2484 let mapped = mark_read_input_for_conversation_async(self.client, request).await?;
2485 crate::internal::message_runtime::mark_read::MessageMarkReadRuntime::new(
2486 self.client,
2487 crate::internal::auth::session::FileSessionProvider::new(self.client),
2488 crate::internal::transport::CoreHttpTransport::new(self.client),
2489 )
2490 .mark_thread_read_async(mapped)
2491 .await
2492 .map(|result| result.sdk_result)
2493 }
2494
2495 pub fn sync_thread_after(
2496 &self,
2497 request: super::SyncThreadAfterRequest,
2498 ) -> crate::ImResult<super::SyncThreadAfterResult> {
2499 #[cfg(feature = "blocking")]
2500 {
2501 let resolved = resolve_history_thread(self.client, request.thread.clone())?;
2502 crate::internal::message_runtime::sync::MessageSyncRuntime::new(
2503 self.client,
2504 crate::internal::auth::session::FileSessionProvider::new(self.client),
2505 crate::internal::transport::CoreHttpTransport::new(self.client),
2506 crate::internal::transport::CoreHttpTransport::new(self.client),
2507 )
2508 .sync_thread_after(
2509 crate::internal::message_runtime::sync::SyncThreadAfterInput {
2510 request: super::SyncThreadAfterRequest {
2511 thread: resolved.thread,
2512 ..request
2513 },
2514 resolved_peer_did: resolved.resolved_did,
2515 peer_scope: resolved.peer_scope,
2516 },
2517 )
2518 }
2519 #[cfg(not(feature = "blocking"))]
2520 {
2521 let _ = request;
2522 Err(crate::ImError::unsupported("sync-thread-after"))
2523 }
2524 }
2525
2526 pub async fn sync_thread_after_async(
2527 &self,
2528 request: super::SyncThreadAfterRequest,
2529 ) -> crate::ImResult<super::SyncThreadAfterResult> {
2530 let resolved = resolve_history_thread_async(self.client, request.thread.clone()).await?;
2531 crate::internal::message_runtime::sync::MessageSyncRuntime::new(
2532 self.client,
2533 crate::internal::auth::session::FileSessionProvider::new(self.client),
2534 crate::internal::transport::CoreHttpTransport::new(self.client),
2535 crate::internal::transport::CoreHttpTransport::new(self.client),
2536 )
2537 .sync_thread_after_async(
2538 crate::internal::message_runtime::sync::SyncThreadAfterInput {
2539 request: super::SyncThreadAfterRequest {
2540 thread: resolved.thread,
2541 ..request
2542 },
2543 resolved_peer_did: resolved.resolved_did,
2544 peer_scope: resolved.peer_scope,
2545 },
2546 )
2547 .await
2548 }
2549
2550 pub fn sync_conversation_after(
2551 &self,
2552 request: super::SyncConversationAfterRequest,
2553 ) -> crate::ImResult<super::SyncThreadAfterResult> {
2554 #[cfg(feature = "blocking")]
2555 {
2556 let resolved = resolve_service_conversation_thread(self.client, &request.conversation)?;
2557 crate::internal::message_runtime::sync::MessageSyncRuntime::new(
2558 self.client,
2559 crate::internal::auth::session::FileSessionProvider::new(self.client),
2560 crate::internal::transport::CoreHttpTransport::new(self.client),
2561 crate::internal::transport::CoreHttpTransport::new(self.client),
2562 )
2563 .sync_thread_after(
2564 crate::internal::message_runtime::sync::SyncThreadAfterInput {
2565 request: super::SyncThreadAfterRequest {
2566 thread: resolved.thread,
2567 after_server_seq: request.after_server_seq,
2568 limit: request.limit,
2569 },
2570 resolved_peer_did: resolved.resolved_did,
2571 peer_scope: resolved.peer_scope,
2572 },
2573 )
2574 }
2575 #[cfg(not(feature = "blocking"))]
2576 {
2577 let _ = request;
2578 Err(crate::ImError::unsupported("sync-conversation-after"))
2579 }
2580 }
2581
2582 pub async fn sync_conversation_after_async(
2583 &self,
2584 request: super::SyncConversationAfterRequest,
2585 ) -> crate::ImResult<super::SyncThreadAfterResult> {
2586 let resolved =
2587 resolve_service_conversation_thread_async(self.client, &request.conversation).await?;
2588 crate::internal::message_runtime::sync::MessageSyncRuntime::new(
2589 self.client,
2590 crate::internal::auth::session::FileSessionProvider::new(self.client),
2591 crate::internal::transport::CoreHttpTransport::new(self.client),
2592 crate::internal::transport::CoreHttpTransport::new(self.client),
2593 )
2594 .sync_thread_after_async(
2595 crate::internal::message_runtime::sync::SyncThreadAfterInput {
2596 request: super::SyncThreadAfterRequest {
2597 thread: resolved.thread,
2598 after_server_seq: request.after_server_seq,
2599 limit: request.limit,
2600 },
2601 resolved_peer_did: resolved.resolved_did,
2602 peer_scope: resolved.peer_scope,
2603 },
2604 )
2605 .await
2606 }
2607
2608 pub fn sync_delta(
2609 &self,
2610 request: super::SyncDeltaRequest,
2611 ) -> crate::ImResult<super::SyncDeltaResult> {
2612 #[cfg(feature = "blocking")]
2613 {
2614 crate::internal::message_runtime::sync::MessageSyncRuntime::new(
2615 self.client,
2616 crate::internal::auth::session::FileSessionProvider::new(self.client),
2617 crate::internal::transport::CoreHttpTransport::new(self.client),
2618 crate::internal::transport::CoreHttpTransport::new(self.client),
2619 )
2620 .sync_delta(crate::internal::message_runtime::sync::SyncDeltaInput { request })
2621 }
2622 #[cfg(not(feature = "blocking"))]
2623 {
2624 let _ = request;
2625 Err(crate::ImError::unsupported("sync-delta"))
2626 }
2627 }
2628
2629 pub async fn sync_delta_async(
2630 &self,
2631 request: super::SyncDeltaRequest,
2632 ) -> crate::ImResult<super::SyncDeltaResult> {
2633 crate::internal::message_runtime::sync::MessageSyncRuntime::new(
2634 self.client,
2635 crate::internal::auth::session::FileSessionProvider::new(self.client),
2636 crate::internal::transport::CoreHttpTransport::new(self.client),
2637 crate::internal::transport::CoreHttpTransport::new(self.client),
2638 )
2639 .sync_delta_async(crate::internal::message_runtime::sync::SyncDeltaInput { request })
2640 .await
2641 }
2642
2643 pub fn conversations(
2644 &self,
2645 query: super::ConversationQuery,
2646 ) -> crate::ImResult<crate::ids::Page<super::Conversation>> {
2647 #[cfg(feature = "blocking")]
2648 {
2649 crate::internal::message_runtime::conversations::MessageConversationRuntime::new(
2650 self.client,
2651 )
2652 .conversations(query)
2653 }
2654 #[cfg(not(feature = "blocking"))]
2655 {
2656 let _ = query;
2657 Err(crate::ImError::unsupported("sync-message-conversations"))
2658 }
2659 }
2660
2661 pub async fn conversations_async(
2662 &self,
2663 query: super::ConversationQuery,
2664 ) -> crate::ImResult<crate::ids::Page<super::Conversation>> {
2665 crate::internal::message_runtime::conversations::MessageConversationRuntime::new(
2666 self.client,
2667 )
2668 .conversations_async(query)
2669 .await
2670 }
2671
2672 pub fn load_conversation_snapshot(
2673 &self,
2674 ) -> crate::ImResult<Option<super::ConversationListSnapshot>> {
2675 crate::internal::snapshot::conversation_snapshot::load_for_client(self.client)
2676 }
2677
2678 pub async fn load_conversation_snapshot_async(
2679 &self,
2680 ) -> crate::ImResult<Option<super::ConversationListSnapshot>> {
2681 self.load_conversation_snapshot()
2682 }
2683
2684 pub fn clear_conversation_snapshot(&self) -> crate::ImResult<()> {
2685 crate::internal::snapshot::conversation_snapshot::clear_for_client(self.client)
2686 }
2687
2688 pub async fn clear_conversation_snapshot_async(&self) -> crate::ImResult<()> {
2689 self.clear_conversation_snapshot()
2690 }
2691
2692 pub fn watch_conversation_patches(&self) -> crate::ImResult<super::ConversationPatchSession> {
2693 self.client
2694 .conversation_store()
2695 .watch_for_client(self.client)
2696 }
2697
2698 pub async fn watch_conversation_patches_async(
2699 &self,
2700 ) -> crate::ImResult<super::ConversationPatchSession> {
2701 self.watch_conversation_patches()
2702 }
2703
2704 pub fn repair_conversation_store(&self) -> crate::ImResult<super::ConversationStorePatch> {
2705 self.client
2706 .conversation_store()
2707 .repair_from_client(self.client)
2708 }
2709
2710 pub async fn repair_conversation_store_async(
2711 &self,
2712 ) -> crate::ImResult<super::ConversationStorePatch> {
2713 self.repair_conversation_store()
2714 }
2715
2716 pub fn watch_thread_patches(
2717 &self,
2718 thread: super::ThreadRef,
2719 limit: Option<u32>,
2720 ) -> crate::ImResult<super::ThreadMessagePatchSession> {
2721 self.client
2722 .message_store()
2723 .watch_for_client(self.client, thread, limit)
2724 }
2725
2726 pub async fn watch_thread_patches_async(
2727 &self,
2728 thread: super::ThreadRef,
2729 limit: Option<u32>,
2730 ) -> crate::ImResult<super::ThreadMessagePatchSession> {
2731 self.watch_thread_patches(thread, limit)
2732 }
2733
2734 pub fn watch_conversation_timeline_patches(
2735 &self,
2736 conversation: super::ConversationReadRef,
2737 limit: Option<u32>,
2738 ) -> crate::ImResult<super::ThreadMessagePatchSession> {
2739 self.watch_thread_patches(conversation.as_thread_ref()?, limit)
2740 }
2741
2742 pub async fn watch_conversation_timeline_patches_async(
2743 &self,
2744 conversation: super::ConversationReadRef,
2745 limit: Option<u32>,
2746 ) -> crate::ImResult<super::ThreadMessagePatchSession> {
2747 self.watch_conversation_timeline_patches(conversation, limit)
2748 }
2749
2750 pub fn repair_thread_store(
2751 &self,
2752 thread: super::ThreadRef,
2753 limit: Option<u32>,
2754 ) -> crate::ImResult<super::ThreadMessageStorePatch> {
2755 self.client.message_store().repair_thread_from_client(
2756 self.client,
2757 thread,
2758 limit.unwrap_or(100),
2759 )
2760 }
2761
2762 pub async fn repair_thread_store_async(
2763 &self,
2764 thread: super::ThreadRef,
2765 limit: Option<u32>,
2766 ) -> crate::ImResult<super::ThreadMessageStorePatch> {
2767 self.repair_thread_store(thread, limit)
2768 }
2769
2770 pub fn repair_conversation_timeline_store(
2771 &self,
2772 conversation: super::ConversationReadRef,
2773 limit: Option<u32>,
2774 ) -> crate::ImResult<super::ThreadMessageStorePatch> {
2775 self.repair_thread_store(conversation.as_thread_ref()?, limit)
2776 }
2777
2778 pub async fn repair_conversation_timeline_store_async(
2779 &self,
2780 conversation: super::ConversationReadRef,
2781 limit: Option<u32>,
2782 ) -> crate::ImResult<super::ThreadMessageStorePatch> {
2783 self.repair_conversation_timeline_store(conversation, limit)
2784 }
2785}
2786
2787#[cfg(all(feature = "sqlite", feature = "blocking"))]
2788fn send_direct_e2ee_with_client(
2789 client: &crate::core::ImClient,
2790 resolved: ResolvedSendRequest,
2791) -> crate::ImResult<super::SendMessageResult> {
2792 send_direct_e2ee_with_client_attachment_aware(
2793 client,
2794 resolved,
2795 crate::internal::secure_direct::send::DirectSecureLocalPersistence::LegacySqlite,
2796 )
2797}
2798
2799#[cfg(all(feature = "sqlite", feature = "blocking"))]
2800fn send_direct_e2ee_with_client_and_persistence(
2801 client: &crate::core::ImClient,
2802 resolved: ResolvedSendRequest,
2803 local_persistence: crate::internal::secure_direct::send::DirectSecureLocalPersistence,
2804) -> crate::ImResult<crate::internal::secure_direct::send::DirectSecureTextSendResult> {
2805 if matches!(resolved.request.body, super::MessageBody::Attachment { .. }) {
2806 return Err(crate::ImError::unsupported(
2807 "direct-e2ee-attachment-text-result",
2808 ));
2809 }
2810 let target_handle = resolved.direct_handle().map(str::to_owned);
2811 let peer_scope = resolved.peer_scope.clone();
2812 let mut result = crate::internal::secure_direct::send::DirectSecureTextSender::new(
2813 client,
2814 crate::internal::auth::session::FileSessionProvider::new(client),
2815 crate::internal::transport::CoreHttpTransport::new(client),
2816 crate::internal::transport::CoreHttpTransport::new(client),
2817 )
2818 .send(crate::internal::secure_direct::send::DirectSecureTextSend {
2819 request: resolved.request,
2820 resolved_target_did: resolved.target_did,
2821 target_handle: target_handle.clone(),
2822 peer_scope: peer_scope.clone(),
2823 local_persistence,
2824 })?;
2825 apply_peer_scope_to_direct_secure_text_result(
2826 &mut result,
2827 target_handle.as_deref(),
2828 peer_scope.as_ref(),
2829 )?;
2830 Ok(result)
2831}
2832
2833#[cfg(all(feature = "sqlite", feature = "blocking"))]
2834fn send_direct_e2ee_with_client_attachment_aware(
2835 client: &crate::core::ImClient,
2836 resolved: ResolvedSendRequest,
2837 local_persistence: crate::internal::secure_direct::send::DirectSecureLocalPersistence,
2838) -> crate::ImResult<super::SendMessageResult> {
2839 if matches!(resolved.request.body, super::MessageBody::Attachment { .. }) {
2840 let committed = crate::internal::attachment_runtime::upload::AttachmentUploadRuntime::new(
2841 client,
2842 crate::internal::auth::session::FileSessionProvider::new(client),
2843 crate::internal::transport::CoreHttpTransport::new(client),
2844 )
2845 .prepare_and_commit_object(
2846 crate::internal::attachment_runtime::upload::AttachmentPrepareObjectInput {
2847 target: resolved.request.target.clone(),
2848 request: attachment_request_from_message_request(resolved.request.clone())?,
2849 resolved_target_did: resolved.target_did.clone(),
2850 message_security_profile: "direct-e2ee",
2851 },
2852 )?;
2853 return send_direct_e2ee_attachment_with_client_and_persistence(
2854 client,
2855 resolved,
2856 committed,
2857 local_persistence,
2858 )
2859 .map(|result| result.sdk_result);
2860 }
2861 send_direct_e2ee_with_client_and_persistence(client, resolved, local_persistence)
2862 .map(|result| result.sdk_result)
2863}
2864
2865#[cfg(all(feature = "sqlite", feature = "blocking"))]
2866fn send_direct_e2ee_attachment_with_client_and_persistence(
2867 client: &crate::core::ImClient,
2868 resolved: ResolvedSendRequest,
2869 committed: crate::internal::attachment_runtime::upload::PreparedCommittedAttachment,
2870 local_persistence: crate::internal::secure_direct::send::DirectSecureLocalPersistence,
2871) -> crate::ImResult<crate::internal::secure_direct::send::DirectSecureAttachmentSendResult> {
2872 let target_handle = resolved.direct_handle().map(str::to_owned);
2873 let peer_scope = resolved.peer_scope.clone();
2874 let mut result = crate::internal::secure_direct::send::DirectSecureTextSender::new(
2875 client,
2876 crate::internal::auth::session::FileSessionProvider::new(client),
2877 crate::internal::transport::CoreHttpTransport::new(client),
2878 crate::internal::transport::CoreHttpTransport::new(client),
2879 )
2880 .send_attachment(
2881 crate::internal::secure_direct::send::DirectSecureAttachmentSend {
2882 request: resolved.request,
2883 resolved_target_did: resolved.target_did,
2884 target_handle: target_handle.clone(),
2885 peer_scope: peer_scope.clone(),
2886 committed,
2887 local_persistence,
2888 },
2889 )?;
2890 apply_peer_scope_to_direct_secure_attachment_result(
2891 &mut result,
2892 target_handle.as_deref(),
2893 peer_scope.as_ref(),
2894 )?;
2895 Ok(result)
2896}
2897
2898#[cfg(feature = "sqlite")]
2899async fn persist_deferred_direct_e2ee_effect(
2900 client: &crate::core::ImClient,
2901 mut result: crate::internal::secure_direct::send::DirectSecureTextSendResult,
2902 target_handle: Option<&str>,
2903 peer_scope: Option<&crate::internal::local_state::owner_scope::DirectPeerScope>,
2904) -> crate::ImResult<super::SendMessageResult> {
2905 apply_peer_scope_to_direct_secure_text_result(&mut result, target_handle, peer_scope)?;
2906 match result.local_effect {
2907 crate::internal::secure_direct::send::DirectSecureLocalEffect::None => {}
2908 crate::internal::secure_direct::send::DirectSecureLocalEffect::PersistOutgoing => {
2909 match
2910 crate::internal::message_runtime::local_projection::persist_direct_e2ee_outgoing_async(
2911 client,
2912 &result.target_did,
2913 result.target_handle.as_deref(),
2914 result.peer_scope.as_ref(),
2915 &result.text,
2916 &result.kind,
2917 &result.sdk_result,
2918 )
2919 .await
2920 {
2921 Ok(()) => client.emit_committed_local_message_projection("local_send"),
2922 Err(err) => {
2923 result
2924 .sdk_result
2925 .warnings
2926 .push(format!("Failed to persist local secure direct message: {err}"));
2927 }
2928 }
2929 }
2930 crate::internal::secure_direct::send::DirectSecureLocalEffect::QueueOutbox(record) => {
2931 client
2932 .core_inner()
2933 .local_state_db()
2934 .await?
2935 .queue_e2ee_outbox(record)
2936 .await?;
2937 }
2938 }
2939 Ok(result.sdk_result)
2940}
2941
2942#[cfg(feature = "sqlite")]
2943async fn persist_deferred_direct_e2ee_attachment_effect(
2944 client: &crate::core::ImClient,
2945 mut result: crate::internal::secure_direct::send::DirectSecureAttachmentSendResult,
2946 target_handle: Option<&str>,
2947 peer_scope: Option<&crate::internal::local_state::owner_scope::DirectPeerScope>,
2948) -> crate::ImResult<super::SendMessageResult> {
2949 apply_peer_scope_to_direct_secure_attachment_result(&mut result, target_handle, peer_scope)?;
2950 match result.local_effect {
2951 crate::internal::secure_direct::send::DirectSecureAttachmentLocalEffect::None => {}
2952 crate::internal::secure_direct::send::DirectSecureAttachmentLocalEffect::PersistOutgoing => {
2953 match
2954 crate::internal::message_runtime::local_projection::persist_direct_e2ee_attachment_outgoing_async(
2955 client,
2956 &result.target_did,
2957 result.target_handle.as_deref(),
2958 result.peer_scope.as_ref(),
2959 &result.redacted_manifest,
2960 &result.sdk_result,
2961 )
2962 .await
2963 {
2964 Ok(()) => client.emit_committed_local_message_projection("local_send"),
2965 Err(err) => {
2966 result
2967 .sdk_result
2968 .warnings
2969 .push(format!("Failed to persist local secure direct attachment: {err}"));
2970 }
2971 }
2972 }
2973 }
2974 Ok(result.sdk_result)
2975}
2976
2977fn apply_peer_scope_to_direct_secure_text_result(
2978 result: &mut crate::internal::secure_direct::send::DirectSecureTextSendResult,
2979 target_handle: Option<&str>,
2980 peer_scope: Option<&crate::internal::local_state::owner_scope::DirectPeerScope>,
2981) -> crate::ImResult<()> {
2982 let handle = target_handle
2983 .map(str::trim)
2984 .filter(|value| !value.is_empty());
2985 if result.target_handle.is_none() {
2986 result.target_handle = handle.map(str::to_owned);
2987 }
2988 if result.peer_scope.is_none() {
2989 result.peer_scope = peer_scope.cloned();
2990 }
2991 normalize_direct_send_result_for_peer_scope(
2992 &mut result.sdk_result,
2993 result.peer_scope.as_ref(),
2994 result.target_handle.as_deref(),
2995 Some(result.target_did.as_str()),
2996 )
2997}
2998
2999fn apply_peer_scope_to_direct_secure_attachment_result(
3000 result: &mut crate::internal::secure_direct::send::DirectSecureAttachmentSendResult,
3001 target_handle: Option<&str>,
3002 peer_scope: Option<&crate::internal::local_state::owner_scope::DirectPeerScope>,
3003) -> crate::ImResult<()> {
3004 let handle = target_handle
3005 .map(str::trim)
3006 .filter(|value| !value.is_empty());
3007 if result.target_handle.is_none() {
3008 result.target_handle = handle.map(str::to_owned);
3009 }
3010 if result.peer_scope.is_none() {
3011 result.peer_scope = peer_scope.cloned();
3012 }
3013 normalize_direct_send_result_for_peer_scope(
3014 &mut result.sdk_result,
3015 result.peer_scope.as_ref(),
3016 result.target_handle.as_deref(),
3017 Some(result.target_did.as_str()),
3018 )
3019}
3020
3021#[derive(Clone)]
3022struct ResolvedSendRequest {
3023 request: super::SendMessageRequest,
3024 target_did: Option<String>,
3025 peer_scope: Option<crate::internal::local_state::owner_scope::DirectPeerScope>,
3026}
3027
3028#[derive(Clone)]
3029struct ResolvedConversationSendRequest {
3030 request: super::SendMessageRequest,
3031 target_did: Option<String>,
3032 target_handle: Option<String>,
3033 peer_scope: Option<crate::internal::local_state::owner_scope::DirectPeerScope>,
3034}
3035
3036impl ResolvedSendRequest {
3037 fn direct_handle(&self) -> Option<&str> {
3038 self.peer_scope
3039 .as_ref()
3040 .map(|scope| scope.full_handle.as_str())
3041 .or_else(|| direct_handle_from_target(&self.request.target))
3042 }
3043}
3044
3045pub(crate) fn normalize_direct_send_result_for_peer_scope(
3046 result: &mut super::SendMessageResult,
3047 peer_scope: Option<&crate::internal::local_state::owner_scope::DirectPeerScope>,
3048 target_handle: Option<&str>,
3049 target_did: Option<&str>,
3050) -> crate::ImResult<()> {
3051 let Some(scope) = peer_scope else {
3052 return Ok(());
3053 };
3054 let thread_id = crate::messages::direct_peer_scope_thread_id(
3055 scope.user_id.as_str(),
3056 scope.full_handle.as_str(),
3057 )?;
3058 result.message.thread = super::ThreadRef::Thread(thread_id);
3059 upsert_message_attribute(
3060 &mut result.message.metadata.attributes,
3061 "peer_user_id",
3062 scope.user_id.as_str(),
3063 );
3064 upsert_message_attribute(
3065 &mut result.message.metadata.attributes,
3066 "peer_full_handle",
3067 scope.full_handle.as_str(),
3068 );
3069 if let Some(handle) = target_handle
3070 .map(str::trim)
3071 .filter(|value| !value.is_empty())
3072 {
3073 upsert_message_attribute(
3074 &mut result.message.metadata.attributes,
3075 "target_handle",
3076 handle,
3077 );
3078 }
3079 if let Some(did) = target_did.map(str::trim).filter(|value| !value.is_empty()) {
3080 upsert_message_attribute(
3081 &mut result.message.metadata.attributes,
3082 "resolved_target_did",
3083 did,
3084 );
3085 upsert_message_attribute(
3086 &mut result.message.metadata.attributes,
3087 "peer_current_did",
3088 did,
3089 );
3090 }
3091 Ok(())
3092}
3093
3094fn conversation_send_request(
3095 client: &crate::core::ImClient,
3096 conversation: super::ConversationReadRef,
3097 body: super::MessageBody,
3098 security: super::MessageSecurityMode,
3099 client_message_id: Option<crate::ids::MessageId>,
3100 idempotency_key: Option<String>,
3101 wait_for_final_acceptance: bool,
3102 delegated_signing: Option<super::DelegatedSigningOptions>,
3103) -> crate::ImResult<ResolvedConversationSendRequest> {
3104 let resolved = resolve_service_conversation_thread(client, &conversation)?;
3105 let client_message_id = match client_message_id {
3106 Some(id) => id,
3107 None => crate::ids::MessageId::parse(format!(
3108 "msg-{}",
3109 crate::internal::wire::common::generate_operation_id()
3110 ))?,
3111 };
3112 let idempotency_key = idempotency_key
3113 .map(|value| value.trim().to_owned())
3114 .filter(|value| !value.is_empty())
3115 .or_else(|| Some(format!("op-{}", client_message_id.as_str())));
3116 let resolved_target = conversation_send_target(resolved)?;
3117 let request = super::SendMessageRequest {
3118 target: resolved_target.target,
3119 body,
3120 security,
3121 client_message_id: Some(client_message_id),
3122 delivery: super::MessageDeliveryOptions {
3123 idempotency_key,
3124 wait_for_final_acceptance,
3125 },
3126 delegated_signing,
3127 };
3128 Ok(ResolvedConversationSendRequest {
3129 request,
3130 target_did: resolved_target.target_did,
3131 target_handle: resolved_target.target_handle,
3132 peer_scope: resolved_target.peer_scope,
3133 })
3134}
3135
3136fn conversation_send_target(
3137 resolved: ResolvedConversationServiceThread,
3138) -> crate::ImResult<ResolvedConversationSendTarget> {
3139 match resolved.thread {
3140 super::ThreadRef::Direct(peer) => {
3141 let target_did = resolved.resolved_did.clone().or_else(|| {
3142 peer.as_str()
3143 .starts_with("did:")
3144 .then(|| peer.as_str().to_owned())
3145 });
3146 let target_handle = resolved
3147 .peer_scope
3148 .as_ref()
3149 .map(|scope| scope.full_handle.clone());
3150 let target_peer = target_handle
3151 .as_deref()
3152 .map(|handle| crate::ids::PeerRef::parse(handle, ""))
3153 .transpose()?
3154 .unwrap_or(peer);
3155 Ok(ResolvedConversationSendTarget {
3156 target: super::MessageTarget::Direct(target_peer),
3157 target_did,
3158 target_handle,
3159 peer_scope: resolved.peer_scope,
3160 })
3161 }
3162 super::ThreadRef::Group(group) => Ok(ResolvedConversationSendTarget {
3163 target: super::MessageTarget::Group(group),
3164 target_did: None,
3165 target_handle: None,
3166 peer_scope: None,
3167 }),
3168 super::ThreadRef::Thread(_) => Err(crate::ImError::invalid_input(
3169 Some("conversation_id".to_owned()),
3170 "send_conversation requires a canonical dm: or group: conversation_id",
3171 )),
3172 }
3173}
3174
3175pub(crate) fn resolve_conversation_send_target(
3176 client: &crate::core::ImClient,
3177 conversation: &super::ConversationReadRef,
3178) -> crate::ImResult<ResolvedConversationSendTarget> {
3179 conversation_send_target(resolve_service_conversation_thread(client, conversation)?)
3180}
3181
3182fn validate_plain_conversation_send(request: &super::SendMessageRequest) -> crate::ImResult<()> {
3183 validate_body(&request.body)?;
3184 if matches!(request.body, super::MessageBody::Attachment { .. }) {
3185 return Err(crate::ImError::unsupported("conversation-attachment-send"));
3186 }
3187 match request.security {
3188 super::MessageSecurityMode::DefaultPlain | super::MessageSecurityMode::Plain => {}
3189 super::MessageSecurityMode::E2eeRequired
3190 | super::MessageSecurityMode::SecureDirect
3191 | super::MessageSecurityMode::GroupE2ee => {
3192 return Err(crate::ImError::unsupported(
3193 "secure-conversation-send-local-echo",
3194 ));
3195 }
3196 }
3197 validate_send_mode(&request.target, &request.security)?;
3198 validate_delegated_send_scope(request)
3199}
3200
3201fn normalize_resolved_direct_send_result(
3202 result: &mut super::SendMessageResult,
3203 resolved: &ResolvedSendRequest,
3204) -> crate::ImResult<()> {
3205 normalize_direct_send_result_for_peer_scope(
3206 result,
3207 resolved.peer_scope.as_ref(),
3208 resolved.direct_handle(),
3209 resolved.target_did.as_deref(),
3210 )
3211}
3212
3213fn upsert_message_attribute(
3214 attributes: &mut Vec<super::MessageMetadataAttribute>,
3215 key: &str,
3216 value: &str,
3217) {
3218 let value = value.trim();
3219 if value.is_empty() {
3220 return;
3221 }
3222 if let Some(attribute) = attributes.iter_mut().find(|attribute| attribute.key == key) {
3223 attribute.value = value.to_owned();
3224 return;
3225 }
3226 attributes.push(super::MessageMetadataAttribute {
3227 key: key.to_owned(),
3228 value: value.to_owned(),
3229 });
3230}
3231
3232fn resolve_send_request(
3233 client: &crate::core::ImClient,
3234 request: super::SendMessageRequest,
3235) -> crate::ImResult<ResolvedSendRequest> {
3236 let (target_did, peer_scope) = match &request.target {
3237 super::MessageTarget::Direct(peer) => {
3238 let resolved = resolve_direct_peer(client, peer)?;
3239 (resolved.target_did, resolved.peer_scope)
3240 }
3241 super::MessageTarget::Group(_) => (None, None),
3242 };
3243 Ok(ResolvedSendRequest {
3244 request,
3245 target_did,
3246 peer_scope,
3247 })
3248}
3249
3250async fn resolve_send_request_async(
3251 client: &crate::core::ImClient,
3252 request: super::SendMessageRequest,
3253) -> crate::ImResult<ResolvedSendRequest> {
3254 let (target_did, peer_scope) = match &request.target {
3255 super::MessageTarget::Direct(peer) => {
3256 let resolved = resolve_direct_peer_async(client, peer).await?;
3257 (resolved.target_did, resolved.peer_scope)
3258 }
3259 super::MessageTarget::Group(_) => (None, None),
3260 };
3261 Ok(ResolvedSendRequest {
3262 request,
3263 target_did,
3264 peer_scope,
3265 })
3266}
3267
3268struct ResolvedDirectPeer {
3269 target_did: Option<String>,
3270 peer_scope: Option<crate::internal::local_state::owner_scope::DirectPeerScope>,
3271}
3272
3273fn resolve_direct_peer(
3274 client: &crate::core::ImClient,
3275 peer: &crate::ids::PeerRef,
3276) -> crate::ImResult<ResolvedDirectPeer> {
3277 let raw = peer.as_str().trim();
3278 if raw.is_empty() || raw.starts_with("did:") {
3279 return Ok(ResolvedDirectPeer {
3280 target_did: None,
3281 peer_scope: None,
3282 });
3283 }
3284 let lookup = crate::internal::handle_discovery::resolve_direct_handle(client, raw)?;
3285 Ok(ResolvedDirectPeer {
3286 target_did: Some(lookup.target_did),
3287 peer_scope: Some(
3288 crate::internal::local_state::owner_scope::DirectPeerScope::new(
3289 lookup.user_id,
3290 lookup.full_handle,
3291 )?,
3292 ),
3293 })
3294}
3295
3296async fn resolve_direct_peer_async(
3297 client: &crate::core::ImClient,
3298 peer: &crate::ids::PeerRef,
3299) -> crate::ImResult<ResolvedDirectPeer> {
3300 let raw = peer.as_str().trim();
3301 if raw.is_empty() || raw.starts_with("did:") {
3302 return Ok(ResolvedDirectPeer {
3303 target_did: None,
3304 peer_scope: None,
3305 });
3306 }
3307 let lookup =
3308 crate::internal::handle_discovery::resolve_direct_handle_async(client, raw).await?;
3309 Ok(ResolvedDirectPeer {
3310 target_did: Some(lookup.target_did),
3311 peer_scope: Some(
3312 crate::internal::local_state::owner_scope::DirectPeerScope::new(
3313 lookup.user_id,
3314 lookup.full_handle,
3315 )?,
3316 ),
3317 })
3318}
3319
3320fn validate_send_mode(
3321 target: &super::MessageTarget,
3322 security: &super::MessageSecurityMode,
3323) -> crate::ImResult<()> {
3324 match (target, security) {
3325 (_, super::MessageSecurityMode::DefaultPlain | super::MessageSecurityMode::Plain) => Ok(()),
3326 (
3327 super::MessageTarget::Direct(_),
3328 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::SecureDirect,
3329 ) => Ok(()),
3330 (super::MessageTarget::Group(_), super::MessageSecurityMode::SecureDirect) => {
3331 Err(crate::ImError::unsupported("secure-direct"))
3332 }
3333 (
3334 super::MessageTarget::Group(_),
3335 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::GroupE2ee,
3336 ) => validate_group_e2ee_security(),
3337 (super::MessageTarget::Direct(_), super::MessageSecurityMode::GroupE2ee) => {
3338 Err(crate::ImError::unsupported("group-e2ee"))
3339 }
3340 }
3341}
3342
3343fn validate_body(body: &super::MessageBody) -> crate::ImResult<()> {
3344 match body {
3345 super::MessageBody::Text { text, .. } if text.trim().is_empty() => {
3346 Err(crate::ImError::invalid_input(
3347 Some("text".to_string()),
3348 "text message must not be empty",
3349 ))
3350 }
3351 super::MessageBody::Text { .. } => Ok(()),
3352 super::MessageBody::Payload { payload } if !payload.is_object() => {
3353 Err(crate::ImError::invalid_input(
3354 Some("payload".to_string()),
3355 "message payload must be a JSON object",
3356 ))
3357 }
3358 super::MessageBody::Payload { .. } => Ok(()),
3359 super::MessageBody::Attachment { .. } => Ok(()),
3360 }
3361}
3362
3363fn validate_attachment_security(
3364 body: &super::MessageBody,
3365 security: &super::MessageSecurityMode,
3366) -> crate::ImResult<()> {
3367 if !matches!(body, super::MessageBody::Attachment { .. }) {
3368 return Ok(());
3369 }
3370 match security {
3371 super::MessageSecurityMode::DefaultPlain
3372 | super::MessageSecurityMode::Plain
3373 | super::MessageSecurityMode::E2eeRequired
3374 | super::MessageSecurityMode::SecureDirect
3375 | super::MessageSecurityMode::GroupE2ee => Ok(()),
3376 }
3377}
3378
3379fn validate_delegated_send_scope(request: &super::SendMessageRequest) -> crate::ImResult<()> {
3380 if request.delegated_signing.is_none() {
3381 return Ok(());
3382 }
3383 if !matches!(request.target, super::MessageTarget::Direct(_)) {
3384 return Err(crate::ImError::unsupported("delegated-group-send"));
3385 }
3386 if matches!(request.body, super::MessageBody::Attachment { .. }) {
3387 return Err(crate::ImError::unsupported("delegated-attachment-send"));
3388 }
3389 match request.security {
3390 super::MessageSecurityMode::DefaultPlain | super::MessageSecurityMode::Plain => Ok(()),
3391 super::MessageSecurityMode::E2eeRequired | super::MessageSecurityMode::SecureDirect => {
3392 Err(crate::ImError::unsupported("delegated-e2ee-send"))
3393 }
3394 super::MessageSecurityMode::GroupE2ee => {
3395 Err(crate::ImError::unsupported("delegated-group-e2ee-send"))
3396 }
3397 }
3398}
3399
3400fn validate_secure_attachment_request(
3401 body: &super::MessageBody,
3402 security: &super::MessageSecurityMode,
3403) -> crate::ImResult<()> {
3404 if !matches!(body, super::MessageBody::Attachment { .. }) {
3405 return Err(crate::ImError::unsupported("secure-attachment"));
3406 }
3407 match security {
3408 super::MessageSecurityMode::E2eeRequired
3409 | super::MessageSecurityMode::SecureDirect
3410 | super::MessageSecurityMode::GroupE2ee => Ok(()),
3411 super::MessageSecurityMode::DefaultPlain | super::MessageSecurityMode::Plain => {
3412 Err(crate::ImError::unsupported("secure-attachment"))
3413 }
3414 }
3415}
3416
3417fn attachment_request_from_message_request(
3418 request: super::SendMessageRequest,
3419) -> crate::ImResult<crate::attachments::AttachmentSendRequest> {
3420 match request.body {
3421 super::MessageBody::Attachment {
3422 input,
3423 caption,
3424 mention_payload,
3425 mime_type,
3426 filename,
3427 } => Ok(crate::attachments::AttachmentSendRequest {
3428 input,
3429 caption,
3430 mention_payload,
3431 mime_type,
3432 filename,
3433 delivery: request.delivery,
3434 security: crate::messages::MessageSecurityMode::DefaultPlain,
3435 }),
3436 _ => Err(crate::ImError::unsupported("attachments")),
3437 }
3438}
3439
3440#[cfg(feature = "group-e2ee")]
3441fn validate_group_e2ee_security() -> crate::ImResult<()> {
3442 Ok(())
3443}
3444
3445#[cfg(not(feature = "group-e2ee"))]
3446fn validate_group_e2ee_security() -> crate::ImResult<()> {
3447 Err(crate::ImError::unsupported("group-e2ee"))
3448}
3449
3450fn message_kind_from_result(
3451 result: &super::SendMessageResult,
3452) -> crate::ImResult<super::MessageKind> {
3453 match &result.message.body {
3454 super::MessageBodyView::Text { kind, .. } => Ok(kind.clone()),
3455 super::MessageBodyView::Payload { .. } => Err(crate::ImError::unsupported(
3456 "message-projection-body:application/json",
3457 )),
3458 super::MessageBodyView::Unsupported { content_type } => {
3459 Err(crate::ImError::unsupported(format!(
3460 "message-projection-body:{}",
3461 content_type.as_deref().unwrap_or("unknown")
3462 )))
3463 }
3464 }
3465}
3466
3467fn direct_handle_from_target(target: &super::MessageTarget) -> Option<&str> {
3468 match target {
3469 super::MessageTarget::Direct(peer) if !peer.as_str().starts_with("did:") => {
3470 Some(peer.as_str())
3471 }
3472 _ => None,
3473 }
3474}
3475
3476struct StaleDeliveryTarget {
3477 current_did: Option<String>,
3478 full_handle: Option<String>,
3479 user_id: Option<String>,
3480}
3481
3482fn stale_delivery_target_from_error(
3483 err: &crate::ImError,
3484 owner_did: &str,
3485) -> Option<StaleDeliveryTarget> {
3486 let crate::ImError::Service {
3487 code: Some(code),
3488 data: Some(data),
3489 ..
3490 } = err
3491 else {
3492 return None;
3493 };
3494 if code != "1406" {
3495 return None;
3496 }
3497 if data.get("reason").and_then(serde_json::Value::as_str) != Some("stale_did") {
3498 return None;
3499 }
3500 let current_did = data
3501 .get("current_did")
3502 .and_then(serde_json::Value::as_str)
3503 .and_then(|did| canonical_peer_did_value(did, owner_did));
3504 let full_handle = data
3505 .get("full_handle")
3506 .and_then(serde_json::Value::as_str)
3507 .map(str::trim)
3508 .filter(|value| !value.is_empty())
3509 .map(ToOwned::to_owned);
3510 let user_id = data
3511 .get("user_id")
3512 .and_then(serde_json::Value::as_str)
3513 .map(str::trim)
3514 .filter(|value| !value.is_empty())
3515 .map(ToOwned::to_owned);
3516 (current_did.is_some() || full_handle.is_some()).then_some(StaleDeliveryTarget {
3517 current_did,
3518 full_handle,
3519 user_id,
3520 })
3521}
3522
3523struct ResolvedHistoryThread {
3524 thread: super::ThreadRef,
3525 resolved_did: Option<String>,
3526 handle_peer: Option<(String, String)>,
3527 peer_scope: Option<crate::internal::local_state::owner_scope::DirectPeerScope>,
3528}
3529
3530#[derive(Debug)]
3531struct ResolvedConversationServiceThread {
3532 thread: super::ThreadRef,
3533 resolved_did: Option<String>,
3534 peer_scope: Option<crate::internal::local_state::owner_scope::DirectPeerScope>,
3535}
3536
3537#[derive(Debug, Clone)]
3538pub(crate) struct ResolvedConversationSendTarget {
3539 pub(crate) target: super::MessageTarget,
3540 pub(crate) target_did: Option<String>,
3541 pub(crate) target_handle: Option<String>,
3542 pub(crate) peer_scope: Option<crate::internal::local_state::owner_scope::DirectPeerScope>,
3543}
3544
3545fn resolve_history_thread(
3546 client: &crate::core::ImClient,
3547 thread: super::ThreadRef,
3548) -> crate::ImResult<ResolvedHistoryThread> {
3549 let super::ThreadRef::Direct(peer) = thread else {
3550 return Ok(ResolvedHistoryThread {
3551 thread,
3552 resolved_did: None,
3553 handle_peer: None,
3554 peer_scope: None,
3555 });
3556 };
3557 if peer.as_str().starts_with("did:") {
3558 return Ok(ResolvedHistoryThread {
3559 thread: super::ThreadRef::Direct(peer.clone()),
3560 resolved_did: Some(peer.as_str().to_owned()),
3561 handle_peer: None,
3562 peer_scope: None,
3563 });
3564 }
3565 let lookup = crate::internal::handle_discovery::resolve_direct_handle(client, peer.as_str())?;
3566 let did = lookup.target_did;
3567 let full_handle = lookup.full_handle;
3568 let peer_scope = crate::internal::local_state::owner_scope::DirectPeerScope::new(
3569 lookup.user_id,
3570 full_handle.clone(),
3571 )?;
3572 Ok(ResolvedHistoryThread {
3573 thread: super::ThreadRef::Direct(crate::ids::PeerRef::parse(&did, "")?),
3574 resolved_did: Some(did.clone()),
3575 handle_peer: Some((full_handle, did)),
3576 peer_scope: Some(peer_scope),
3577 })
3578}
3579
3580fn resolve_service_conversation_thread(
3581 client: &crate::core::ImClient,
3582 conversation: &super::ConversationReadRef,
3583) -> crate::ImResult<ResolvedConversationServiceThread> {
3584 let conversation_id = conversation.conversation_id.trim();
3585 if let Some(group) = conversation_id.strip_prefix("group:") {
3586 let group = crate::ids::GroupRef::parse(group)?;
3587 return Ok(ResolvedConversationServiceThread {
3588 thread: super::ThreadRef::Group(group),
3589 resolved_did: None,
3590 peer_scope: None,
3591 });
3592 }
3593 if let Some(peer) = conversation_id.strip_prefix("dm:") {
3594 if peer.starts_with("peer-scope:") {
3595 return resolve_peer_scope_service_conversation_thread(client, conversation);
3596 }
3597 if !is_single_did_ref(peer) {
3598 return Err(unresolvable_service_conversation(
3599 &conversation.conversation_id,
3600 ));
3601 }
3602 let peer = crate::ids::PeerRef::parse(peer, "")?;
3603 let resolved_did = peer
3604 .as_str()
3605 .starts_with("did:")
3606 .then(|| peer.as_str().to_owned());
3607 return Ok(ResolvedConversationServiceThread {
3608 thread: super::ThreadRef::Direct(peer),
3609 resolved_did,
3610 peer_scope: None,
3611 });
3612 }
3613 Err(crate::ImError::invalid_input(
3614 Some("conversation_id".to_owned()),
3615 "conversation service operations require a canonical dm: or group: conversation_id",
3616 ))
3617}
3618
3619fn mark_read_input_for_conversation(
3620 client: &crate::core::ImClient,
3621 request: super::MarkConversationReadRequest,
3622) -> crate::ImResult<crate::internal::message_runtime::mark_read::MarkThreadReadInput> {
3623 let remote_thread = resolve_service_conversation_thread(client, &request.conversation)?.thread;
3624 Ok(
3625 crate::internal::message_runtime::mark_read::MarkThreadReadInput {
3626 request: mark_thread_read_request_for_conversation(request)?,
3627 remote_thread: Some(remote_thread),
3628 },
3629 )
3630}
3631
3632fn mark_thread_read_request_for_conversation(
3633 request: super::MarkConversationReadRequest,
3634) -> crate::ImResult<super::MarkThreadReadRequest> {
3635 Ok(super::MarkThreadReadRequest {
3636 thread: request.conversation.as_thread_ref()?,
3637 watermark: request.watermark,
3638 fallback_max_message_ids: request.fallback_max_message_ids,
3639 })
3640}
3641
3642async fn resolve_service_conversation_thread_async(
3643 client: &crate::core::ImClient,
3644 conversation: &super::ConversationReadRef,
3645) -> crate::ImResult<ResolvedConversationServiceThread> {
3646 resolve_service_conversation_thread(client, conversation)
3647}
3648
3649async fn mark_read_input_for_conversation_async(
3650 client: &crate::core::ImClient,
3651 request: super::MarkConversationReadRequest,
3652) -> crate::ImResult<crate::internal::message_runtime::mark_read::MarkThreadReadInput> {
3653 let remote_thread = resolve_service_conversation_thread_async(client, &request.conversation)
3654 .await?
3655 .thread;
3656 Ok(
3657 crate::internal::message_runtime::mark_read::MarkThreadReadInput {
3658 request: mark_thread_read_request_for_conversation(request)?,
3659 remote_thread: Some(remote_thread),
3660 },
3661 )
3662}
3663
3664#[cfg(feature = "sqlite")]
3665fn resolve_peer_scope_service_conversation_thread(
3666 client: &crate::core::ImClient,
3667 conversation: &super::ConversationReadRef,
3668) -> crate::ImResult<ResolvedConversationServiceThread> {
3669 let records = conversation_projection_records(client, conversation)?;
3670 let Some(peer_did) = peer_current_did_from_records(&records, client.did().as_str()) else {
3671 return Err(unresolvable_service_conversation(
3672 &conversation.conversation_id,
3673 ));
3674 };
3675 Ok(ResolvedConversationServiceThread {
3676 thread: super::ThreadRef::Direct(crate::ids::PeerRef::parse(&peer_did, "")?),
3677 resolved_did: Some(peer_did),
3678 peer_scope: records.iter().find_map(peer_scope_from_record),
3679 })
3680}
3681
3682#[cfg(not(feature = "sqlite"))]
3683fn resolve_peer_scope_service_conversation_thread(
3684 _client: &crate::core::ImClient,
3685 conversation: &super::ConversationReadRef,
3686) -> crate::ImResult<ResolvedConversationServiceThread> {
3687 Err(unresolvable_service_conversation(
3688 &conversation.conversation_id,
3689 ))
3690}
3691
3692#[cfg(feature = "sqlite")]
3693fn conversation_projection_records(
3694 client: &crate::core::ImClient,
3695 conversation: &super::ConversationReadRef,
3696) -> crate::ImResult<Vec<crate::internal::local_state::messages::MessageRecord>> {
3697 let connection = crate::internal::local_state::open_writable(
3698 &client.core_inner().sdk_paths().local_state.sqlite_path,
3699 )?;
3700 let page =
3701 crate::internal::local_state::messages::list_messages_for_thread_ref_for_owner_identity(
3702 &connection,
3703 client.current_identity().id.as_str(),
3704 client.did().as_str(),
3705 &conversation.as_thread_ref()?,
3706 20,
3707 None,
3708 )?;
3709 Ok(page.records)
3710}
3711
3712#[cfg(feature = "sqlite")]
3713fn peer_current_did_from_records(
3714 records: &[crate::internal::local_state::messages::MessageRecord],
3715 owner_did: &str,
3716) -> Option<String> {
3717 records
3718 .iter()
3719 .find_map(|record| incoming_participant_peer_did_from_record(record, owner_did))
3720 .or_else(|| {
3721 records
3722 .iter()
3723 .find_map(|record| metadata_peer_current_did_from_record(record, owner_did))
3724 })
3725 .or_else(|| {
3726 records
3727 .iter()
3728 .find_map(|record| participant_peer_did_from_record(record, owner_did))
3729 })
3730}
3731
3732#[cfg(feature = "sqlite")]
3733fn metadata_peer_current_did_from_record(
3734 record: &crate::internal::local_state::messages::MessageRecord,
3735 owner_did: &str,
3736) -> Option<String> {
3737 let metadata = parse_message_metadata(&record.metadata);
3738 for candidate in [
3739 metadata_value(&metadata, "peer_current_did"),
3740 metadata_value(&metadata, "resolved_target_did"),
3741 ] {
3742 if let Some(candidate) = canonical_peer_did(candidate?, owner_did) {
3743 return Some(candidate.to_owned());
3744 }
3745 }
3746 None
3747}
3748
3749#[cfg(feature = "sqlite")]
3750fn incoming_participant_peer_did_from_record(
3751 record: &crate::internal::local_state::messages::MessageRecord,
3752 owner_did: &str,
3753) -> Option<String> {
3754 if record.direction != 0 {
3755 return None;
3756 }
3757 participant_peer_did_from_record(record, owner_did)
3758}
3759
3760#[cfg(feature = "sqlite")]
3761fn participant_peer_did_from_record(
3762 record: &crate::internal::local_state::messages::MessageRecord,
3763 owner_did: &str,
3764) -> Option<String> {
3765 for candidate in [record.sender_did.as_str(), record.receiver_did.as_str()] {
3766 if let Some(candidate) = canonical_peer_did(candidate, owner_did) {
3767 return Some(candidate.to_owned());
3768 }
3769 }
3770 None
3771}
3772
3773fn canonical_peer_did<'a>(candidate: &'a str, owner_did: &str) -> Option<&'a str> {
3774 let candidate = candidate.trim();
3775 (is_single_did_ref(candidate) && candidate != owner_did).then_some(candidate)
3776}
3777
3778fn canonical_peer_did_value(candidate: &str, owner_did: &str) -> Option<String> {
3779 canonical_peer_did(candidate, owner_did).map(ToOwned::to_owned)
3780}
3781
3782fn is_single_did_ref(value: &str) -> bool {
3783 let value = value.trim();
3784 value.starts_with("did:") && !value["did:".len()..].contains(":did:")
3785}
3786
3787#[cfg(feature = "sqlite")]
3788fn peer_scope_from_record(
3789 record: &crate::internal::local_state::messages::MessageRecord,
3790) -> Option<crate::internal::local_state::owner_scope::DirectPeerScope> {
3791 let metadata = parse_message_metadata(&record.metadata);
3792 crate::internal::local_state::owner_scope::DirectPeerScope::new(
3793 metadata_value(&metadata, "peer_user_id")?,
3794 metadata_value(&metadata, "peer_full_handle")?,
3795 )
3796 .ok()
3797}
3798
3799#[cfg(feature = "sqlite")]
3800fn parse_message_metadata(metadata: &str) -> serde_json::Map<String, serde_json::Value> {
3801 serde_json::from_str::<serde_json::Value>(metadata)
3802 .ok()
3803 .and_then(|value| value.as_object().cloned())
3804 .unwrap_or_default()
3805}
3806
3807#[cfg(feature = "sqlite")]
3808fn metadata_value<'a>(
3809 metadata: &'a serde_json::Map<String, serde_json::Value>,
3810 key: &str,
3811) -> Option<&'a str> {
3812 metadata
3813 .get(key)
3814 .and_then(serde_json::Value::as_str)
3815 .map(str::trim)
3816 .filter(|value| !value.is_empty())
3817}
3818
3819fn unresolvable_service_conversation(conversation_id: &str) -> crate::ImError {
3820 crate::ImError::invalid_input(
3821 Some("conversation_id".to_owned()),
3822 format!(
3823 "conversation_id {conversation_id} cannot be resolved to a service direct or group thread"
3824 ),
3825 )
3826}
3827
3828async fn resolve_history_thread_async(
3829 client: &crate::core::ImClient,
3830 thread: super::ThreadRef,
3831) -> crate::ImResult<ResolvedHistoryThread> {
3832 let super::ThreadRef::Direct(peer) = thread else {
3833 return Ok(ResolvedHistoryThread {
3834 thread,
3835 resolved_did: None,
3836 handle_peer: None,
3837 peer_scope: None,
3838 });
3839 };
3840 if peer.as_str().starts_with("did:") {
3841 return Ok(ResolvedHistoryThread {
3842 thread: super::ThreadRef::Direct(peer.clone()),
3843 resolved_did: Some(peer.as_str().to_owned()),
3844 handle_peer: None,
3845 peer_scope: None,
3846 });
3847 }
3848 let lookup =
3849 crate::internal::handle_discovery::resolve_direct_handle_async(client, peer.as_str())
3850 .await?;
3851 let did = lookup.target_did;
3852 let full_handle = lookup.full_handle;
3853 let peer_scope = crate::internal::local_state::owner_scope::DirectPeerScope::new(
3854 lookup.user_id,
3855 full_handle.clone(),
3856 )?;
3857 Ok(ResolvedHistoryThread {
3858 thread: super::ThreadRef::Direct(crate::ids::PeerRef::parse(&did, "")?),
3859 resolved_did: Some(did.clone()),
3860 handle_peer: Some((full_handle, did)),
3861 peer_scope: Some(peer_scope),
3862 })
3863}
3864
3865fn message_page_from_read_result(
3866 result: crate::internal::message_runtime::read::ReadPageResult,
3867) -> crate::ImResult<super::MessagePage> {
3868 let source = result
3869 .raw
3870 .get("source")
3871 .and_then(serde_json::Value::as_str)
3872 .map(str::trim)
3873 .filter(|value| !value.is_empty())
3874 .map(ToOwned::to_owned);
3875 let resolved_dids = result
3876 .raw
3877 .get("resolved_dids")
3878 .and_then(serde_json::Value::as_array)
3879 .into_iter()
3880 .flatten()
3881 .filter_map(|value| value.as_str())
3882 .filter_map(|did| crate::ids::Did::parse(did).ok())
3883 .collect();
3884 let warnings = result
3885 .raw
3886 .get("warnings")
3887 .and_then(serde_json::Value::as_array)
3888 .into_iter()
3889 .flatten()
3890 .filter_map(|value| value.as_str())
3891 .map(ToOwned::to_owned)
3892 .collect();
3893 Ok(super::MessagePage {
3894 items: result.page.items,
3895 next_cursor: result.page.next_cursor,
3896 has_more: result.page.has_more,
3897 source,
3898 resolved_dids,
3899 warnings,
3900 })
3901}
3902
3903#[cfg(all(test, feature = "group-e2ee"))]
3904mod group_e2ee_public_send_tests {
3905 use std::fs;
3906 use std::io::{Read, Write};
3907 use std::net::{TcpListener, TcpStream};
3908 use std::path::{Path, PathBuf};
3909 use std::sync::{Arc, Mutex};
3910 use std::thread;
3911 use std::time::{Duration, Instant};
3912
3913 use anp::group_e2ee::operations::{CreateGroupInput, FinalizeCommitInput};
3914 use serde_json::{json, Value};
3915
3916 use crate::internal::group_e2ee::provider::GroupMlsProvider;
3917
3918 #[cfg(feature = "blocking")]
3919 #[test]
3920 fn public_group_e2ee_send_uses_native_provider_and_sends_cipher_only() {
3921 let fixture = Fixture::new();
3922 let server = RpcTestServer::spawn(vec![
3923 json!({
3924 "group_state_ref": {
3925 "group_did": "did:wba:awiki.test:groups:group-e2ee-preflight",
3926 "group_state_version": "preflight-state"
3927 }
3928 }),
3929 json!({
3930 "group_state_ref": {
3931 "group_did": fixture.group_did,
3932 "group_state_version": "service-state-1"
3933 }
3934 }),
3935 json!({
3936 "accepted": true,
3937 "final_acceptance": true,
3938 "group_did": fixture.group_did,
3939 "message_id": "server-message-id",
3940 "operation_id": "op-public-group-e2ee",
3941 "group_event_seq": "91",
3942 "group_state_version": "service-state-2",
3943 "accepted_at": "2026-05-21T00:00:00Z"
3944 }),
3945 ]);
3946 let core =
3947 crate::core::ImCore::new(fixture.config(server.base_url()), fixture.paths()).unwrap();
3948 let client = core
3949 .client(crate::identity::IdentitySelector::LocalAlias(
3950 "alice".to_owned(),
3951 ))
3952 .unwrap();
3953 prepare_local_mls_group(&client, &fixture.group_did);
3954
3955 let result = client
3956 .messages()
3957 .send(crate::messages::SendMessageRequest {
3958 target: crate::messages::MessageTarget::Group(
3959 crate::ids::GroupRef::parse(&fixture.group_did).unwrap(),
3960 ),
3961 body: crate::messages::MessageBody::Text {
3962 text: "public group secret".to_owned(),
3963 kind: crate::messages::MessageKind::Text,
3964 },
3965 security: crate::messages::MessageSecurityPolicy::E2eeRequired,
3966 client_message_id: Some(
3967 crate::ids::MessageId::parse("msg-public-group-e2ee").unwrap(),
3968 ),
3969 delivery: crate::messages::MessageDeliveryOptions {
3970 idempotency_key: Some("op-public-group-e2ee".to_owned()),
3971 wait_for_final_acceptance: true,
3972 },
3973 delegated_signing: None,
3974 })
3975 .unwrap();
3976
3977 assert_eq!(result.message.metadata.server_sequence, Some(91));
3978 assert!(matches!(
3979 result.delivery,
3980 crate::messages::DeliveryState::Accepted
3981 ));
3982 let requests = server.requests();
3983 assert_eq!(requests.len(), 3);
3984 assert_eq!(requests[0].rpc_method, "group.e2ee.head");
3985 assert_eq!(
3986 requests[0].params["body"]["group_did"],
3987 "did:wba:awiki.test:groups:group-e2ee-preflight"
3988 );
3989 assert_eq!(requests[1].rpc_method, "group.e2ee.head");
3990 assert_eq!(requests[1].params["body"]["group_did"], fixture.group_did);
3991 assert_eq!(requests[2].rpc_method, "group.e2ee.send");
3992 assert_eq!(
3993 requests[2].params["meta"]["security_profile"],
3994 anp::group_e2ee::SECURITY_PROFILE
3995 );
3996 assert_eq!(
3997 requests[2].params["meta"]["content_type"],
3998 anp::group_e2ee::GROUP_CIPHER_CONTENT_TYPE
3999 );
4000 assert_eq!(
4001 requests[2].params["body"]["group_state_ref"]["group_state_version"],
4002 "service-state-1"
4003 );
4004 assert!(
4005 requests[2].params["body"]["private_message_b64u"]
4006 .as_str()
4007 .map(|value| !value.trim().is_empty())
4008 .unwrap_or(false),
4009 "group.e2ee.send must carry an MLS private message"
4010 );
4011 let encoded_send = serde_json::to_string(&requests[2].params).unwrap();
4012 assert!(!encoded_send.contains("public group secret"));
4013 assert!(!encoded_send.contains("application_plaintext"));
4014 assert!(!encoded_send.contains("provider"));
4015 assert!(!encoded_send.contains("StorageProvider"));
4016 assert!(!encoded_send.contains("mls_state.sqlite"));
4017 assert!(!encoded_send.contains("openmls_group_id_b64u"));
4018 }
4019
4020 #[cfg(not(feature = "blocking"))]
4021 #[test]
4022 fn public_group_e2ee_sync_send_fails_closed_by_default() {
4023 let fixture = Fixture::new();
4024 let core = crate::core::ImCore::new(
4025 fixture.config("https://example.test".to_owned()),
4026 fixture.paths(),
4027 )
4028 .unwrap();
4029 let client = core
4030 .client(crate::identity::IdentitySelector::LocalAlias(
4031 "alice".to_owned(),
4032 ))
4033 .unwrap();
4034
4035 let result = client.messages().send(crate::messages::SendMessageRequest {
4036 target: crate::messages::MessageTarget::Group(
4037 crate::ids::GroupRef::parse(&fixture.group_did).unwrap(),
4038 ),
4039 body: crate::messages::MessageBody::Text {
4040 text: "public group secret".to_owned(),
4041 kind: crate::messages::MessageKind::Text,
4042 },
4043 security: crate::messages::MessageSecurityPolicy::E2eeRequired,
4044 client_message_id: Some(
4045 crate::ids::MessageId::parse("msg-public-group-e2ee-sync").unwrap(),
4046 ),
4047 delivery: crate::messages::MessageDeliveryOptions::default(),
4048 delegated_signing: None,
4049 });
4050
4051 assert!(matches!(
4052 result,
4053 Err(crate::ImError::UnsupportedCapability { capability }) if capability == "sync-group-e2ee-send"
4054 ));
4055 }
4056
4057 #[tokio::test]
4058 async fn public_group_e2ee_send_async_uses_async_transport_and_db_actor_projection() {
4059 let fixture = Fixture::new();
4060 let server = RpcTestServer::spawn(vec![
4061 json!({
4062 "group_state_ref": {
4063 "group_did": fixture.group_did,
4064 "group_state_version": "service-state-async-1"
4065 }
4066 }),
4067 json!({
4068 "accepted": true,
4069 "final_acceptance": true,
4070 "group_did": fixture.group_did,
4071 "message_id": "server-message-async-id",
4072 "operation_id": "op-public-group-e2ee-async",
4073 "group_event_seq": "92",
4074 "group_state_version": "service-state-async-2",
4075 "accepted_at": "2026-05-21T00:00:00Z"
4076 }),
4077 ]);
4078 let core = crate::core::ImCore::open(fixture.config(server.base_url()), fixture.paths())
4079 .await
4080 .unwrap();
4081 let client = core
4082 .client_async(crate::identity::IdentitySelector::LocalAlias(
4083 "alice".to_owned(),
4084 ))
4085 .await
4086 .unwrap();
4087 prepare_local_mls_group(&client, &fixture.group_did);
4088
4089 let result = client
4090 .messages()
4091 .send_async(crate::messages::SendMessageRequest {
4092 target: crate::messages::MessageTarget::Group(
4093 crate::ids::GroupRef::parse(&fixture.group_did).unwrap(),
4094 ),
4095 body: crate::messages::MessageBody::Text {
4096 text: "async group secret".to_owned(),
4097 kind: crate::messages::MessageKind::Text,
4098 },
4099 security: crate::messages::MessageSecurityPolicy::E2eeRequired,
4100 client_message_id: Some(
4101 crate::ids::MessageId::parse("msg-public-group-e2ee-async").unwrap(),
4102 ),
4103 delivery: crate::messages::MessageDeliveryOptions {
4104 idempotency_key: Some("op-public-group-e2ee-async".to_owned()),
4105 wait_for_final_acceptance: true,
4106 },
4107 delegated_signing: None,
4108 })
4109 .await
4110 .unwrap();
4111
4112 assert_eq!(result.message.metadata.server_sequence, Some(92));
4113 let requests = server.requests();
4114 assert_eq!(requests.len(), 2);
4115 assert_eq!(requests[0].rpc_method, "group.e2ee.head");
4116 assert_eq!(requests[0].params["body"]["group_did"], fixture.group_did);
4117 assert_eq!(requests[1].rpc_method, "group.e2ee.send");
4118 assert_eq!(
4119 requests[1].params["meta"]["content_type"],
4120 anp::group_e2ee::GROUP_CIPHER_CONTENT_TYPE
4121 );
4122 let encoded_send = serde_json::to_string(&requests[1].params).unwrap();
4123 assert!(!encoded_send.contains("async group secret"));
4124
4125 let stored = stored_group_message(&fixture, &client, result.message.id.as_str());
4126 assert_eq!(stored.thread_id, format!("group:{}", fixture.group_did));
4127 assert_eq!(stored.group_did, fixture.group_did);
4128 assert_eq!(stored.content, "async group secret");
4129 assert!(stored.is_e2ee);
4130 assert_eq!(stored.server_seq, Some(92));
4131 let metadata: Value = serde_json::from_str(&stored.metadata).unwrap();
4132 assert_eq!(metadata["security"], "group-e2ee");
4133 }
4134
4135 #[test]
4136 fn public_group_e2ee_mode_still_rejects_direct_targets() {
4137 let fixture = Fixture::new();
4138 let core = crate::core::ImCore::new(
4139 fixture.config("https://example.test".to_owned()),
4140 fixture.paths(),
4141 )
4142 .unwrap();
4143 let client = core
4144 .client(crate::identity::IdentitySelector::LocalAlias(
4145 "alice".to_owned(),
4146 ))
4147 .unwrap();
4148
4149 let result = client.messages().send(crate::messages::SendMessageRequest {
4150 target: crate::messages::MessageTarget::Direct(
4151 crate::ids::PeerRef::parse("did:example:bob", "").unwrap(),
4152 ),
4153 body: crate::messages::MessageBody::Text {
4154 text: "not group".to_owned(),
4155 kind: crate::messages::MessageKind::Text,
4156 },
4157 security: crate::messages::MessageSecurityMode::GroupE2ee,
4158 client_message_id: None,
4159 delivery: crate::messages::MessageDeliveryOptions::default(),
4160 delegated_signing: None,
4161 });
4162
4163 assert!(matches!(
4164 result,
4165 Err(crate::ImError::UnsupportedCapability { capability }) if capability == "group-e2ee"
4166 ));
4167 }
4168
4169 fn prepare_local_mls_group(client: &crate::core::ImClient, group_did: &str) {
4170 let provider = crate::internal::group_e2ee::storage::native_provider_for_client(client)
4171 .expect("native provider");
4172 let prepared = provider
4173 .create_group_prepare(CreateGroupInput {
4174 creator_did: client.did().as_str().to_owned(),
4175 device_id: crate::internal::group_e2ee::DEFAULT_GROUP_MLS_DEVICE_ID.to_owned(),
4176 group_did: group_did.to_owned(),
4177 operation_id: "op-public-group-e2ee-create".to_owned(),
4178 request_id: "req-public-group-e2ee-create".to_owned(),
4179 pending_commit_id: Some("pc-public-group-e2ee-create".to_owned()),
4180 })
4181 .expect("create group");
4182 provider
4183 .finalize_commit(FinalizeCommitInput {
4184 pending_commit_id: prepared.pending_commit_id,
4185 request_id: "req-public-group-e2ee-finalize".to_owned(),
4186 })
4187 .expect("finalize group");
4188 }
4189
4190 struct Fixture {
4191 root: PathBuf,
4192 group_did: String,
4193 }
4194
4195 impl Fixture {
4196 fn new() -> Self {
4197 let root = unique_temp_root();
4198 write_identity_fixture(&root, "alice", "did:example:alice");
4199 Self {
4200 root,
4201 group_did: "did:example:groups:public-e2ee".to_owned(),
4202 }
4203 }
4204
4205 fn config(&self, base_url: String) -> crate::ImCoreConfig {
4206 crate::ImCoreConfig {
4207 service_base_url: crate::ServiceEndpoint::parse(base_url).unwrap(),
4208 did_domain: "awiki.test".to_owned(),
4209 user_service_endpoint: None,
4210 message_service_endpoint: None,
4211 mail_service_endpoint: None,
4212 anp_service_endpoint: None,
4213 anp_service_did: None,
4214 ca_bundle: None,
4215 transport_policy: crate::MessageTransportPolicy::HttpOnly,
4216 }
4217 }
4218
4219 fn paths(&self) -> crate::ImCorePaths {
4220 crate::ImCorePaths {
4221 identities: crate::IdentityRegistryPaths {
4222 identity_root_dir: self.root.join("identities"),
4223 registry_path: self.root.join("identities").join("registry.json"),
4224 default_identity_path: Some(self.root.join("identities").join("default")),
4225 },
4226 local_state: crate::LocalStatePaths {
4227 sqlite_path: self.root.join("local").join("im.sqlite"),
4228 },
4229 runtime: crate::RuntimePaths {
4230 cache_dir: self.root.join("cache"),
4231 temp_dir: self.root.join("tmp"),
4232 },
4233 }
4234 }
4235 }
4236
4237 fn write_identity_fixture(root: &Path, alias: &str, did: &str) {
4238 let identity_root = root.join("identities");
4239 let identity_dir = identity_root.join(alias);
4240 fs::create_dir_all(&identity_dir).unwrap();
4241 fs::write(identity_root.join("default"), format!("{alias}\n")).unwrap();
4242 fs::write(
4243 identity_root.join("registry.json"),
4244 json!({
4245 "default_identity": alias,
4246 "identities": [{
4247 "id": "alice-id",
4248 "did": did,
4249 "local_alias": alias,
4250 "device_id": crate::internal::group_e2ee::DEFAULT_GROUP_MLS_DEVICE_ID,
4251 "ready_for_auth": true,
4252 "ready_for_messaging": true,
4253 "missing": []
4254 }]
4255 })
4256 .to_string(),
4257 )
4258 .unwrap();
4259 let bundle = anp::authentication::create_did_wba_document(
4260 "awiki.test",
4261 anp::authentication::DidDocumentOptions {
4262 path_segments: vec!["user".to_owned()],
4263 domain: Some("awiki.test".to_owned()),
4264 challenge: Some("group-e2ee-public-send-test".to_owned()),
4265 ..anp::authentication::DidDocumentOptions::default()
4266 },
4267 )
4268 .unwrap();
4269 fs::write(
4270 identity_dir.join("did.json"),
4271 serde_json::to_vec_pretty(&bundle.did_document).unwrap(),
4272 )
4273 .unwrap();
4274 fs::write(
4275 identity_dir.join("private.key"),
4276 bundle.private_key_pem("key-1").unwrap(),
4277 )
4278 .unwrap();
4279 fs::write(
4280 identity_dir.join("auth.json"),
4281 r#"{"jwt_token":"test-token"}"#,
4282 )
4283 .unwrap();
4284 }
4285
4286 #[derive(Debug)]
4287 struct StoredGroupMessage {
4288 thread_id: String,
4289 group_did: String,
4290 content: String,
4291 is_e2ee: bool,
4292 server_seq: Option<i64>,
4293 metadata: String,
4294 }
4295
4296 fn stored_group_message(
4297 fixture: &Fixture,
4298 client: &crate::core::ImClient,
4299 message_id: &str,
4300 ) -> StoredGroupMessage {
4301 let connection =
4302 rusqlite::Connection::open(fixture.root.join("local").join("im.sqlite")).unwrap();
4303 connection
4304 .query_row(
4305 r#"
4306SELECT thread_id, group_did, content, is_e2ee, server_seq, metadata
4307FROM messages
4308WHERE owner_identity_id = ?1 AND owner_did = ?2 AND msg_id = ?3
4309"#,
4310 rusqlite::params![
4311 client.current_identity().id.as_str(),
4312 client.did().as_str(),
4313 message_id
4314 ],
4315 |row| {
4316 Ok(StoredGroupMessage {
4317 thread_id: row.get(0)?,
4318 group_did: row.get(1)?,
4319 content: row.get(2)?,
4320 is_e2ee: row.get::<_, i64>(3)? != 0,
4321 server_seq: row.get(4)?,
4322 metadata: row.get(5)?,
4323 })
4324 },
4325 )
4326 .unwrap()
4327 }
4328
4329 #[derive(Debug, Clone)]
4330 struct CapturedRpc {
4331 rpc_method: String,
4332 params: Value,
4333 }
4334
4335 struct RpcTestServer {
4336 address: String,
4337 requests: Arc<Mutex<Vec<CapturedRpc>>>,
4338 join: Option<thread::JoinHandle<()>>,
4339 }
4340
4341 impl RpcTestServer {
4342 fn spawn(responses: Vec<Value>) -> Self {
4343 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
4344 listener.set_nonblocking(true).unwrap();
4345 let address = format!("http://{}", listener.local_addr().unwrap());
4346 let requests = Arc::new(Mutex::new(Vec::new()));
4347 let server_requests = Arc::clone(&requests);
4348 let join = thread::spawn(move || {
4349 let deadline = Instant::now() + Duration::from_secs(10);
4350 for response in responses {
4351 let mut stream = accept_before_deadline(&listener, deadline);
4352 let request = read_rpc_request(&mut stream);
4353 let id = request.id.clone();
4354 server_requests.lock().unwrap().push(CapturedRpc {
4355 rpc_method: request.rpc_method,
4356 params: request.params,
4357 });
4358 write_rpc_response(&mut stream, id, response);
4359 }
4360 });
4361 Self {
4362 address,
4363 requests,
4364 join: Some(join),
4365 }
4366 }
4367
4368 fn base_url(&self) -> String {
4369 self.address.clone()
4370 }
4371
4372 fn requests(&self) -> Vec<CapturedRpc> {
4373 self.requests.lock().unwrap().clone()
4374 }
4375 }
4376
4377 impl Drop for RpcTestServer {
4378 fn drop(&mut self) {
4379 if let Some(join) = self.join.take() {
4380 let _ = join.join();
4381 }
4382 }
4383 }
4384
4385 #[derive(Debug)]
4386 struct RawRpcRequest {
4387 rpc_method: String,
4388 params: Value,
4389 id: Value,
4390 }
4391
4392 fn accept_before_deadline(listener: &TcpListener, deadline: Instant) -> TcpStream {
4393 loop {
4394 match listener.accept() {
4395 Ok((stream, _)) => return stream,
4396 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
4397 assert!(Instant::now() < deadline, "timed out waiting for RPC");
4398 thread::sleep(Duration::from_millis(10));
4399 }
4400 Err(err) => panic!("accept RPC request: {err}"),
4401 }
4402 }
4403 }
4404
4405 fn read_rpc_request(stream: &mut TcpStream) -> RawRpcRequest {
4406 stream
4407 .set_read_timeout(Some(Duration::from_secs(5)))
4408 .unwrap();
4409 let mut raw = Vec::new();
4410 let mut buffer = [0_u8; 4096];
4411 let header_end = loop {
4412 let count = stream.read(&mut buffer).unwrap();
4413 assert!(count > 0, "RPC request closed before headers");
4414 raw.extend_from_slice(&buffer[..count]);
4415 if let Some(index) = raw.windows(4).position(|window| window == b"\r\n\r\n") {
4416 break index;
4417 }
4418 };
4419 let headers_text = std::str::from_utf8(&raw[..header_end]).unwrap();
4420 let headers = headers_text
4421 .lines()
4422 .skip(1)
4423 .filter_map(|line| {
4424 let (key, value) = line.split_once(':')?;
4425 Some((key.trim().to_ascii_lowercase(), value.trim().to_owned()))
4426 })
4427 .collect::<std::collections::BTreeMap<_, _>>();
4428 let content_length = headers
4429 .get("content-length")
4430 .and_then(|value| value.parse::<usize>().ok())
4431 .unwrap_or_default();
4432 let body_start = header_end + 4;
4433 while raw.len() < body_start + content_length {
4434 let count = stream.read(&mut buffer).unwrap();
4435 assert!(count > 0, "RPC request closed before body");
4436 raw.extend_from_slice(&buffer[..count]);
4437 }
4438 let payload: Value = serde_json::from_slice(&raw[body_start..body_start + content_length])
4439 .expect("json rpc request body");
4440 RawRpcRequest {
4441 rpc_method: payload["method"].as_str().unwrap().to_owned(),
4442 params: payload["params"].clone(),
4443 id: payload["id"].clone(),
4444 }
4445 }
4446
4447 fn write_rpc_response(stream: &mut TcpStream, id: Value, result: Value) {
4448 let body = json!({
4449 "jsonrpc": "2.0",
4450 "id": id,
4451 "result": result,
4452 })
4453 .to_string();
4454 write!(
4455 stream,
4456 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
4457 body.len(),
4458 body
4459 )
4460 .unwrap();
4461 }
4462
4463 fn unique_temp_root() -> PathBuf {
4464 let nanos = std::time::SystemTime::now()
4465 .duration_since(std::time::UNIX_EPOCH)
4466 .unwrap()
4467 .as_nanos();
4468 std::env::temp_dir().join(format!(
4469 "im-core-group-e2ee-public-send-{}-{nanos}",
4470 std::process::id()
4471 ))
4472 }
4473}