Skip to main content

im_core/messages/
service.rs

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(&current_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}