Skip to main content

imsg_session/
fetch.rs

1//! Shared device read, normalized to a store-agnostic [`FetchedMessage`].
2//!
3//! Lists a MAP folder and fetches each message body. Consumed by both the persist tail
4//! (`sync::backfill_folder`) and the live read path (`live`), so the `list_messages` +
5//! per-entry `get_message` loop exists once. No store access — the cursor that derives
6//! `since_ms` is read by the caller.
7
8use map_core::client::MapClient;
9use map_core::folders::Folder;
10use map_core::messages::{ListMessagesFilter, MessageEntry};
11use tokio::io::{AsyncRead, AsyncWrite};
12
13use crate::util::datetime_to_ms;
14
15/// A message read from the device and normalized, before it is persisted or rendered.
16///
17/// `address` is already resolved to the peer (recipient for sent, sender for received). `sent`
18/// is retained so the persist tail can derive the stored `direction`. Carries no store identity
19/// (`rowid`/`synced_at`) — those have no meaning until/unless the row is written.
20pub struct FetchedMessage {
21    /// Opaque device-assigned MAP handle.
22    pub handle: String,
23    /// Milliseconds since Unix epoch; falls back to the caller's `now` when the entry datetime
24    /// is absent or malformed.
25    pub timestamp_ms: i64,
26    /// MAP folder leaf the message was listed in.
27    pub folder: String,
28    /// `true` for outbound messages from this device; `false` for received.
29    pub sent: bool,
30    /// Peer phone number/address: recipient for sent messages, sender for received.
31    pub address: String,
32    /// Device-reported read state.
33    pub read: bool,
34    /// Decoded message body text.
35    pub text: String,
36}
37
38impl FetchedMessage {
39    fn from_entry(entry: &MessageEntry, folder: &str, text: String, now: i64) -> Self {
40        let address = if entry.sent {
41            entry.recipient_addressing.clone()
42        } else {
43            entry.sender_addressing.clone()
44        };
45        Self {
46            handle: entry.handle.clone(),
47            timestamp_ms: datetime_to_ms(&entry.datetime).unwrap_or(now),
48            folder: folder.to_owned(),
49            sent: entry.sent,
50            address,
51            read: entry.read,
52            text,
53        }
54    }
55}
56
57/// Lists `folder`, paging at 1024 entries per request and accumulating across pages.
58///
59/// Listing only — no bodies are fetched, so this is cheap relative to [`fetch_folder`] and is
60/// what the live `threads` aggregation uses. Pure device read: no store access. Returns entries
61/// in device listing order.
62///
63/// # Errors
64///
65/// Returns an error if any MAP `set_folder`/`list_messages` operation fails.
66pub async fn list_folder<T: AsyncRead + AsyncWrite + Unpin>(
67    client: &mut MapClient<T>,
68    folder: Folder,
69) -> anyhow::Result<Vec<MessageEntry>> {
70    const PAGE: u16 = 1024;
71
72    client.set_folder(folder).await?;
73    let mut out = Vec::new();
74    let mut offset: u16 = 0;
75    loop {
76        let filter = ListMessagesFilter { max_count: PAGE, offset, ..Default::default() };
77        let entries = client.list_messages(&filter).await?;
78        let count = entries.len();
79        out.extend(entries);
80        if count < usize::from(PAGE) {
81            break;
82        }
83        match offset.checked_add(PAGE) {
84            Some(next) => offset = next,
85            None => break,
86        }
87    }
88    Ok(out)
89}
90
91/// Lists `folder` and fetches each message body since `since_ms`.
92///
93/// Entries older than `since_ms` (by listing datetime) are skipped before the body fetch, so a
94/// caller passing the per-folder cursor anchor fetches only new messages; `None` fetches the full
95/// window. `now` is the fallback timestamp for entries with an absent/malformed datetime. Pure
96/// device read: no store access, no cursor writes.
97///
98/// # Errors
99///
100/// Returns an error if any MAP `set_folder`/`list_messages`/`get_message` operation fails.
101pub async fn fetch_folder<T: AsyncRead + AsyncWrite + Unpin>(
102    client: &mut MapClient<T>,
103    folder: Folder,
104    since_ms: Option<i64>,
105    now: i64,
106) -> anyhow::Result<Vec<FetchedMessage>> {
107    let folder_str = folder.as_str();
108    let mut out = Vec::new();
109    for entry in list_folder(client, folder).await? {
110        if since_ms.is_some_and(|since| datetime_to_ms(&entry.datetime).unwrap_or(i64::MAX) < since)
111        {
112            continue;
113        }
114        let bmsg = client.get_message(&entry.handle).await?;
115        let text = bmsg.envelope().body.text.clone();
116        out.push(FetchedMessage::from_entry(&entry, folder_str, text, now));
117    }
118    Ok(out)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn entry(sent: bool) -> MessageEntry {
126        MessageEntry {
127            handle: "0400".to_owned(),
128            subject: "hi".to_owned(),
129            datetime: "20260101T120000".to_owned(),
130            sender_name: "Alice".to_owned(),
131            sender_addressing: "+15550001".to_owned(),
132            recipient_name: "Bob".to_owned(),
133            recipient_addressing: "+15550002".to_owned(),
134            msg_type: "SMS_GSM".to_owned(),
135            size: 2,
136            read: true,
137            sent,
138        }
139    }
140
141    #[test]
142    fn received_resolves_address_to_sender() {
143        let m = FetchedMessage::from_entry(&entry(false), "inbox", "body".to_owned(), 0);
144        assert!(!m.sent);
145        assert_eq!(m.address, "+15550001");
146        assert_eq!(m.folder, "inbox");
147        assert!(m.read);
148    }
149
150    #[test]
151    fn sent_resolves_address_to_recipient() {
152        let m = FetchedMessage::from_entry(&entry(true), "sent", "body".to_owned(), 0);
153        assert!(m.sent);
154        assert_eq!(m.address, "+15550002");
155    }
156
157    #[test]
158    fn malformed_datetime_falls_back_to_now() {
159        let mut e = entry(false);
160        e.datetime = "not-a-date".to_owned();
161        let m = FetchedMessage::from_entry(&e, "inbox", "body".to_owned(), 42);
162        assert_eq!(m.timestamp_ms, 42);
163    }
164}