Skip to main content

imsg_session/
outbox.rs

1//! Outbox drain: push-error classification, single-message send, and outbox flush.
2
3use map_core::{client::MapClient, folders::Folder, MapError};
4use store::{Direction, NewMessage, OutboxStatus, OutgoingStatus, PhoneField, Store, STATUS_READ};
5use tokio::io::{AsyncRead, AsyncWrite};
6
7/// Classifies a MAP push error into the appropriate outbox and message delivery states.
8///
9/// Returns `(OutboxStatus, OutgoingStatus)` to be written to the outbox entry and the
10/// speculative message row respectively.
11///
12/// Transport errors map to `Unknown` because the PUT may have been transmitted before
13/// the connection dropped; reconciliation against the Sent folder is required to resolve.
14/// Server and input rejections map to `FailedPermanent`; all other errors to `FailedRetryable`.
15#[must_use]
16pub const fn classify_push_error(e: &MapError) -> (OutboxStatus, OutgoingStatus) {
17    match e {
18        // Transport errors: the PUT may have been sent before the drop — outcome is ambiguous.
19        MapError::Transport(_) | MapError::UnexpectedEof => {
20            (OutboxStatus::Unknown, OutgoingStatus::Unknown)
21        }
22        // Definitive rejections: retrying will not help.
23        MapError::InvalidInput(_) | MapError::ServerError(_) => {
24            (OutboxStatus::Failed, OutgoingStatus::FailedPermanent)
25        }
26        // OBEX protocol, encoding, or parse errors: transient, worth retrying.
27        _ => (OutboxStatus::Failed, OutgoingStatus::FailedRetryable),
28    }
29}
30
31/// Returns `true` when `e` indicates the RFCOMM/OBEX transport has died and no further MAP
32/// operations will succeed on this session.
33///
34/// `Transport` and `UnexpectedEof` are the only `MapError` variants that represent a dead
35/// stream. All others (server rejections, parse errors, encoding failures) leave the session
36/// alive. Use [`is_fatal_anyhow`] when working with `anyhow::Error` return values.
37#[must_use]
38pub const fn is_session_fatal(e: &MapError) -> bool {
39    matches!(e, MapError::Transport(_) | MapError::UnexpectedEof)
40}
41
42/// Returns `true` when any error in the chain is a fatal MAP transport error.
43///
44/// Walks the full `anyhow` cause chain, so callers may freely add `.context()` without
45/// breaking classification. Only [`MapError::Transport`] and [`MapError::UnexpectedEof`]
46/// are considered fatal; all other variants leave the session alive.
47#[must_use]
48pub fn is_fatal_anyhow(e: &anyhow::Error) -> bool {
49    e.chain().filter_map(|cause| cause.downcast_ref::<MapError>()).any(is_session_fatal)
50}
51
52/// Records the outbound message in the store, pushes it to the device, and updates the
53/// outcome — all as a single atomic sequence.
54///
55/// Enqueues a `Queued` outbox entry, navigates to the MAP Outbox folder, marks the entry
56/// `Sending`, calls `push_message`, then either commits success via `complete_send` or
57/// records the classified failure via `resolve` + `update_outgoing_status`. Store errors on
58/// the failure path are logged as warnings so the push error is always the returned value.
59///
60/// Returns the sent confirmation string (`"sent to {number} (handle {handle})"`) on success.
61///
62/// # Errors
63///
64/// Returns an error if the store enqueue fails, the MAP folder navigation fails, or the
65/// push fails. A `MapError::Transport` or `MapError::UnexpectedEof` root indicates the
66/// session is dead; callers should check with [`is_fatal_anyhow`] to decide whether to
67/// propagate the failure or surface it as a non-fatal response.
68pub async fn send_sms<T: AsyncRead + AsyncWrite + Unpin>(
69    client: &mut MapClient<T>,
70    store: &Store,
71    number: &str,
72    message: &str,
73    now: i64,
74) -> anyhow::Result<String> {
75    let params = format!("{number}\x1F{message}");
76    let (_, outbox_id) = store
77        .enqueue_send(
78            NewMessage {
79                map_handle: String::new(),
80                timestamp_ms: now,
81                folder: Folder::Sent.as_str().to_owned(),
82                direction: Direction::Sent,
83                address: PhoneField::new(number, None),
84                status: STATUS_READ,
85                synced_at: now,
86                text: message.to_owned(),
87                outgoing_status: Some(OutgoingStatus::Queued),
88            },
89            "send_sms",
90            &params,
91            now,
92        )
93        .await?;
94    let placeholder = format!("local:{outbox_id}");
95
96    client.set_folder(Folder::Outbox).await?;
97    store.resolve(outbox_id, OutboxStatus::Sending, now, None).await?;
98
99    match client.push_message(number, message).await {
100        Ok(handle) => {
101            store.complete_send(outbox_id, &placeholder, &handle, now).await?;
102            Ok(format!("sent to {number} (handle {handle})"))
103        }
104        Err(e) => {
105            let (outbox_status, outgoing_status) = classify_push_error(&e);
106            if let Err(se) = store.resolve(outbox_id, outbox_status, now, Some(e.to_string())).await
107            {
108                tracing::warn!("resolve outbox {outbox_id}: {se}");
109            }
110            if let Err(se) = store.update_outgoing_status(&placeholder, outgoing_status).await {
111                tracing::warn!("update outgoing status {placeholder}: {se}");
112            }
113            // Return MapError as root so callers can classify via is_fatal_anyhow.
114            Err(anyhow::Error::from(e))
115        }
116    }
117}
118
119/// Pushes an outgoing SMS to the device without recording it in the store.
120///
121/// The non-opted-in `send` path: navigates to the MAP Outbox folder and pushes, returning the same
122/// confirmation string as [`send_sms`]. Fire-and-forget — no outbox row, so no delivery tracking or
123/// retry; a transient failure surfaces to the caller to re-send. No store access.
124///
125/// # Errors
126///
127/// Returns an error if the MAP folder navigation or push fails. A `MapError::Transport` or
128/// `MapError::UnexpectedEof` root indicates a dead session; classify with [`is_fatal_anyhow`].
129pub async fn push_sms<T: AsyncRead + AsyncWrite + Unpin>(
130    client: &mut MapClient<T>,
131    number: &str,
132    message: &str,
133) -> anyhow::Result<String> {
134    client.set_folder(Folder::Outbox).await?;
135    let handle = client.push_message(number, message).await?;
136    Ok(format!("sent to {number} (handle {handle})"))
137}
138
139/// Fetches all `queued` outbox entries and pushes each to the device, updating the store
140/// with the outcome.
141///
142/// Navigates the MAP client to the Outbox folder once before iterating. Each entry is
143/// processed independently: a push failure on one entry is logged and does not abort the
144/// rest. Entries with unparseable payloads are skipped with a warning and left `queued`.
145///
146/// The payload format is `"{number}\x1F{message}"` as written by `send::run`.
147///
148/// # Errors
149///
150/// Returns an error if `store.pending()` fails or navigating to the Outbox folder fails.
151/// Individual push failures are recorded in the store and do not propagate.
152pub async fn drain_outbox<T: AsyncRead + AsyncWrite + Unpin>(
153    client: &mut MapClient<T>,
154    store: &Store,
155    now: i64,
156) -> anyhow::Result<()> {
157    let pending = store.pending().await?;
158    if pending.is_empty() {
159        return Ok(());
160    }
161    client.set_folder(Folder::Outbox).await?;
162    for entry in pending {
163        process_entry(client, store, entry, now).await;
164    }
165    Ok(())
166}
167
168// entries with unparseable payloads are skipped with a warning and left queued. Push and
169// store errors are logged but don't propagate — callers continue with remaining entries
170async fn process_entry<T: AsyncRead + AsyncWrite + Unpin>(
171    client: &mut MapClient<T>,
172    store: &Store,
173    entry: store::OutboxRow,
174    now: i64,
175) {
176    let Some((number, message)) = entry.payload.split_once('\x1F') else {
177        tracing::warn!("drain_outbox: entry {} has unparseable payload — skipped", entry.id);
178        return;
179    };
180    let placeholder = format!("local:{}", entry.id);
181    match client.push_message(number, message).await {
182        Ok(handle) => record_send_ok(store, entry.id, &placeholder, &handle, now).await,
183        Err(e) => record_send_err(store, entry.id, &placeholder, &e, now).await,
184    }
185}
186
187// store failures are logged and swallowed — the push already succeeded on the device
188async fn record_send_ok(store: &Store, entry_id: i64, placeholder: &str, handle: &str, now: i64) {
189    store.complete_send(entry_id, placeholder, handle, now).await.unwrap_or_else(|e| {
190        tracing::warn!("drain_outbox: store update failed for entry {entry_id}: {e:#}");
191    });
192}
193
194// store failures are logged and swallowed to ensure all update attempts run regardless
195async fn record_send_err(
196    store: &Store,
197    entry_id: i64,
198    placeholder: &str,
199    e: &map_core::MapError,
200    now: i64,
201) {
202    let (outbox_status, outgoing_status) = classify_push_error(e);
203    tracing::warn!("drain_outbox: push failed for entry {entry_id}: {e}");
204    let err_str = e.to_string();
205    store.resolve(entry_id, outbox_status, now, Some(err_str)).await.unwrap_or_else(|se| {
206        tracing::warn!("drain_outbox: resolve failed for entry {entry_id}: {se:#}");
207    });
208    store.update_outgoing_status(placeholder, outgoing_status).await.unwrap_or_else(|se| {
209        tracing::warn!("drain_outbox: status update failed for entry {entry_id}: {se:#}");
210    });
211}