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/// `template` carries any device-side filter (currently just `read_status`) applied to every
60/// page; its `max_count`/`offset` are ignored — this function owns pagination. Listing only — no
61/// bodies are fetched, so this is cheap relative to [`fetch_folder`] and is what the live
62/// `threads` aggregation uses. Pure device read: no store access. Returns entries in device
63/// listing order.
64///
65/// # Errors
66///
67/// Returns an error if any MAP `set_folder`/`list_messages` operation fails.
68pub async fn list_folder<T: AsyncRead + AsyncWrite + Unpin>(
69    client: &mut MapClient<T>,
70    folder: Folder,
71    template: &ListMessagesFilter,
72) -> anyhow::Result<Vec<MessageEntry>> {
73    const PAGE: u16 = 1024;
74
75    client.set_folder(folder).await?;
76    let mut out = Vec::new();
77    let mut offset: u16 = 0;
78    loop {
79        let filter = ListMessagesFilter { max_count: PAGE, offset, ..template.clone() };
80        let entries = client.list_messages(&filter).await?;
81        let count = entries.len();
82        out.extend(entries);
83        if count < usize::from(PAGE) {
84            break;
85        }
86        match offset.checked_add(PAGE) {
87            Some(next) => offset = next,
88            None => break,
89        }
90    }
91    Ok(out)
92}
93
94/// Lists `folder` and fetches each message body since `since_ms`.
95///
96/// `template` is forwarded to [`list_folder`] for device-side filtering (currently just
97/// `read_status`). Entries older than `since_ms` (by listing datetime) are skipped before the
98/// body fetch, so a caller passing the per-folder cursor anchor fetches only new messages; `None`
99/// fetches the full window. `now` is the fallback timestamp for entries with an absent/malformed
100/// datetime. Pure device read: no store access, no cursor writes.
101///
102/// # Errors
103///
104/// Returns an error if any MAP `set_folder`/`list_messages`/`get_message` operation fails.
105pub async fn fetch_folder<T: AsyncRead + AsyncWrite + Unpin>(
106    client: &mut MapClient<T>,
107    folder: Folder,
108    since_ms: Option<i64>,
109    now: i64,
110    template: &ListMessagesFilter,
111) -> anyhow::Result<Vec<FetchedMessage>> {
112    let folder_str = folder.as_str();
113    let mut out = Vec::new();
114    for entry in list_folder(client, folder, template).await? {
115        if since_ms.is_some_and(|since| datetime_to_ms(&entry.datetime).unwrap_or(i64::MAX) < since)
116        {
117            continue;
118        }
119        let bmsg = client.get_message(&entry.handle).await?;
120        let text = bmsg.envelope().body.text.clone();
121        out.push(FetchedMessage::from_entry(&entry, folder_str, text, now));
122    }
123    Ok(out)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn entry(sent: bool) -> MessageEntry {
131        MessageEntry {
132            handle: "0400".to_owned(),
133            subject: "hi".to_owned(),
134            datetime: "20260101T120000".to_owned(),
135            sender_name: "Alice".to_owned(),
136            sender_addressing: "+15550001".to_owned(),
137            recipient_name: "Bob".to_owned(),
138            recipient_addressing: "+15550002".to_owned(),
139            msg_type: "SMS_GSM".to_owned(),
140            size: 2,
141            read: true,
142            sent,
143        }
144    }
145
146    #[test]
147    fn received_resolves_address_to_sender() {
148        let m = FetchedMessage::from_entry(&entry(false), "inbox", "body".to_owned(), 0);
149        assert!(!m.sent);
150        assert_eq!(m.address, "+15550001");
151        assert_eq!(m.folder, "inbox");
152        assert!(m.read);
153    }
154
155    #[test]
156    fn sent_resolves_address_to_recipient() {
157        let m = FetchedMessage::from_entry(&entry(true), "sent", "body".to_owned(), 0);
158        assert!(m.sent);
159        assert_eq!(m.address, "+15550002");
160    }
161
162    #[test]
163    fn malformed_datetime_falls_back_to_now() {
164        let mut e = entry(false);
165        e.datetime = "not-a-date".to_owned();
166        let m = FetchedMessage::from_entry(&e, "inbox", "body".to_owned(), 42);
167        assert_eq!(m.timestamp_ms, 42);
168    }
169}