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 ¶ms,
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}