Skip to main content

imsg_session/live/
mod.rs

1//! Live-query orchestration: `list`/`threads`/`get` read straight from the device and return
2//! lean models, with no store writes and no cursor advances.
3//!
4//! Both read arms (the in-process CLI path and the broker path) call these. `map_core` protocol
5//! types (`BMessage`, `MessageEntry`) are normalized here and never cross outward — only the lean
6//! models in [`models`] do.
7
8pub mod models;
9
10use std::collections::HashMap;
11
12use map_core::client::MapClient;
13use map_core::folders::Folder;
14use map_core::messages::{ListMessagesFilter, MessageEntry, ReadStatus};
15use map_core::{BMessage, MessageStatus};
16use store::PhoneField;
17use tokio::io::{AsyncRead, AsyncWrite};
18
19use crate::fetch::{fetch_folder, list_folder};
20use crate::util::{datetime_to_ms, now_ms};
21use models::{Direction, LiveBody, LiveFolder, LiveMessage, LiveThread};
22
23/// Filters for a live [`list`], mirroring `store::list_messages` semantics.
24///
25/// `unread` is pushed down to the device via `FilterReadStatus`. `from`/`since_ms`/`limit`/
26/// `offset` are applied in memory over the device's listing window: the device returns its whole
27/// fixed window regardless of offset, so paging is meaningless against it.
28#[derive(Debug, Default)]
29pub struct ListFilter {
30    /// Keep only unread messages.
31    pub unread: bool,
32    /// Keep only messages whose resolved address equals this value exactly.
33    pub from: Option<String>,
34    /// Keep only messages at or after this epoch-millisecond datetime.
35    pub since_ms: Option<i64>,
36    /// Maximum rows after `offset`; `None` keeps the rest of the window.
37    pub limit: Option<u16>,
38    /// Rows to skip from the front of the newest-first window.
39    pub offset: u16,
40}
41
42/// Lists `folder` live and returns lean messages newest-first, after applying `filter`.
43///
44/// `unread` is filtered device-side via `FilterReadStatus`, so an unread-only request never
45/// fetches a read message's body. Remaining filters, sort, and windowing happen in memory over
46/// whatever the device returns. No store access, no cursor advance.
47///
48/// # Errors
49///
50/// Returns an error if any MAP listing or body fetch fails.
51pub async fn list<T: AsyncRead + AsyncWrite + Unpin>(
52    client: &mut MapClient<T>,
53    folder: Folder,
54    filter: &ListFilter,
55) -> anyhow::Result<Vec<LiveMessage>> {
56    let template =
57        ListMessagesFilter { read_status: read_status_for(filter), ..Default::default() };
58    let mut msgs: Vec<LiveMessage> = fetch_folder(client, folder, None, now_ms(), &template)
59        .await?
60        .into_iter()
61        .map(|m| LiveMessage {
62            handle: m.handle,
63            timestamp_ms: m.timestamp_ms,
64            address: canonical(&m.address),
65            folder: m.folder,
66            read: m.read,
67            text: m.text,
68        })
69        .filter(|m| keep(m, filter))
70        .collect();
71    msgs.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms));
72    Ok(window(msgs, filter))
73}
74
75fn read_status_for(f: &ListFilter) -> Option<ReadStatus> {
76    f.unread.then_some(ReadStatus::Unread)
77}
78
79// best-effort E.164 for grouping/matching; the raw form when it can't be resolved
80fn canonical(raw: &str) -> String {
81    PhoneField::new(raw, None).display().to_owned()
82}
83
84fn keep(m: &LiveMessage, f: &ListFilter) -> bool {
85    // `m.address` is already canonical; normalise the filter value so formatting differences
86    // don't cause a miss.
87    if f.from.as_deref().is_some_and(|addr| canonical(addr) != m.address) {
88        return false;
89    }
90    f.since_ms.is_none_or(|since| m.timestamp_ms >= since)
91}
92
93fn window(msgs: Vec<LiveMessage>, f: &ListFilter) -> Vec<LiveMessage> {
94    let rest = msgs.into_iter().skip(usize::from(f.offset));
95    match f.limit {
96        Some(n) => rest.take(usize::from(n)).collect(),
97        None => rest.collect(),
98    }
99}
100
101/// Aggregates Inbox and Sent listings into per-contact thread summaries, newest-first.
102///
103/// Listings only — no bodies are fetched. Mirrors `store::threads`: `total` counts all messages,
104/// `unread` counts received-and-unread, `latest_ms` is the max datetime, empty-address entries are
105/// dropped. Counts are approximate (device window, not full corpus). No store access.
106///
107/// # Errors
108///
109/// Returns an error if either folder listing fails.
110pub async fn threads<T: AsyncRead + AsyncWrite + Unpin>(
111    client: &mut MapClient<T>,
112) -> anyhow::Result<Vec<LiveThread>> {
113    let mut acc: HashMap<String, LiveThread> = HashMap::new();
114    for folder in [Folder::Inbox, Folder::Sent] {
115        for entry in list_folder(client, folder, &ListMessagesFilter::default()).await? {
116            accumulate(&mut acc, &entry);
117        }
118    }
119    let mut threads: Vec<LiveThread> = acc.into_values().collect();
120    threads.sort_by(|a, b| b.latest_ms.cmp(&a.latest_ms));
121    Ok(threads)
122}
123
124/// Lists the device's MAP message folders under `telecom/msg`, in device-reported document order.
125///
126/// Navigates to `telecom/msg` first, so the result is the message-folder level rather than
127/// whatever directory the session was left in. Leaves the client parked there. No store access.
128///
129/// # Errors
130///
131/// Returns an error if any SETPATH fails, the device rejects the listing, or the listing XML is
132/// malformed.
133pub async fn folders<T: AsyncRead + AsyncWrite + Unpin>(
134    client: &mut MapClient<T>,
135) -> anyhow::Result<Vec<LiveFolder>> {
136    let listing = client.list_message_folders().await?;
137    Ok(listing.folders().iter().map(|f| LiveFolder { name: f.name().to_owned() }).collect())
138}
139
140fn accumulate(acc: &mut HashMap<String, LiveThread>, entry: &MessageEntry) {
141    let address = peer_address(entry);
142    if address.is_empty() {
143        return;
144    }
145    // Group on the canonical form so formatting variants collapse into one thread.
146    let address = canonical(&address);
147    let ts = datetime_to_ms(&entry.datetime).unwrap_or(0);
148    let t = acc.entry(address.clone()).or_insert_with(|| LiveThread {
149        address,
150        latest_ms: ts,
151        total: 0,
152        unread: 0,
153    });
154    t.total = t.total.saturating_add(1);
155    t.unread = t.unread.saturating_add(u32::from(!entry.sent && !entry.read));
156    if ts > t.latest_ms {
157        t.latest_ms = ts;
158    }
159}
160
161fn peer_address(entry: &MessageEntry) -> String {
162    if entry.sent {
163        entry.recipient_addressing.clone()
164    } else {
165        entry.sender_addressing.clone()
166    }
167}
168
169/// Fetches one message body live by `handle` and normalizes it to a [`LiveBody`].
170///
171/// `direction` is derived from the bMessage folder (containing `sent`/`outbox` ⇒ [`Direction::Sent`]);
172/// the address is the originator for received messages and the first recipient for sent. No
173/// timestamp — a `BMessage` carries no datetime. No store access.
174///
175/// # Errors
176///
177/// Returns an error if the MAP `GetMessage` fails.
178pub async fn get<T: AsyncRead + AsyncWrite + Unpin>(
179    client: &mut MapClient<T>,
180    handle: String,
181) -> anyhow::Result<LiveBody> {
182    let bmsg = client.get_message(&handle).await?;
183    Ok(to_live_body(handle, &bmsg))
184}
185
186fn to_live_body(handle: String, bmsg: &BMessage) -> LiveBody {
187    let direction = direction_of(bmsg.folder());
188    let address = match direction {
189        Direction::Sent => bmsg.envelope().recipients.first().map(|v| v.tel.clone()),
190        Direction::Received => bmsg.originator().map(|v| v.tel.clone()),
191    }
192    .unwrap_or_default();
193    LiveBody {
194        handle,
195        direction,
196        address: canonical(&address),
197        folder: bmsg.folder().to_owned(),
198        read: matches!(bmsg.status(), MessageStatus::Read),
199        text: bmsg.envelope().body.text.clone(),
200    }
201}
202
203fn direction_of(folder: &str) -> Direction {
204    let f = folder.to_ascii_lowercase();
205    if f.contains("sent") || f.contains("outbox") {
206        Direction::Sent
207    } else {
208        Direction::Received
209    }
210}
211
212#[cfg(test)]
213mod tests;