Skip to main content

cratefield_adapter_owlpost/
inbound.rs

1//! Owlpost's inbound mail: the `/v1/inbound/messages` API (issue #682).
2//!
3//! **A held body is only ever an explicit fetch.** A held message is listed as
4//! a [`MessageSummary`], which has no body field at all: the HTML and text
5//! come from [`Owlpost::get_message`] or [`Owlpost::raw_message`], never from a
6//! listing. Inboxes, threads and routes follow.
7
8use cratefield_core::{MailError, Message};
9use http::Method;
10use std::fmt::Write as _;
11
12use crate::{Owlpost, OwlpostError, valid_email_id};
13
14/// The `/v1/inbound` root every path hangs off.
15const INBOUND: &str = "/v1/inbound/messages";
16
17impl Owlpost {
18    /// One page of `GET {base}/v1/inbound/messages`, filtered and cursor-paged
19    /// by [`MessageQuery`]. Scope `inbound:read`.
20    ///
21    /// # Errors
22    ///
23    /// [`OwlpostError`] from the provider, or `NotConfigured` with no request.
24    pub async fn list_messages(&self, query: &MessageQuery) -> Result<MessagePage, OwlpostError> {
25        let mut params: Vec<(&str, String)> = Vec::new();
26        for (key, value) in [
27            ("inbox", query.inbox.as_deref()),
28            ("thread", query.thread.as_deref()),
29            ("q", query.q.as_deref()),
30            ("before", query.before.as_deref()),
31        ] {
32            if let Some(value) = value.filter(|value| !value.is_empty()) {
33                params.push((key, value.to_owned()));
34            }
35        }
36        if let Some(limit) = query.limit {
37            params.push(("limit", limit.to_string()));
38        }
39        if query.held {
40            params.push(("status", "held".to_owned()));
41        }
42        self.get(&query_path(INBOUND, &params)).await
43    }
44
45    /// `GET {base}/v1/inbound/messages?status=held` for one inbox. Scope `inbound:read`.
46    ///
47    /// # Errors
48    ///
49    /// As [`Owlpost::list_messages`].
50    pub async fn list_held(
51        &self,
52        inbox: &str,
53        before: Option<&str>,
54    ) -> Result<MessagePage, OwlpostError> {
55        self.list_messages(&MessageQuery {
56            inbox: Some(inbox.to_owned()),
57            before: before.map(str::to_owned),
58            held: true,
59            ..MessageQuery::default()
60        })
61        .await
62    }
63
64    /// `GET {base}/v1/inbound/messages/{id}`, the listing-free way to read a
65    /// body. Scope `inbound:read`.
66    ///
67    /// # Errors
68    ///
69    /// [`OwlpostError`] for an empty or path-like id, or a provider failure.
70    pub async fn get_message(&self, id: &str) -> Result<MessageDetail, OwlpostError> {
71        refuse_id(id)?;
72        self.get(&format!("{INBOUND}/{id}")).await
73    }
74
75    /// `GET {base}/v1/inbound/messages/{id}/raw`, the RFC 822 source.
76    /// Scope `inbound:read`.
77    ///
78    /// # Errors
79    ///
80    /// As [`Owlpost::get_message`].
81    pub async fn raw_message(&self, id: &str) -> Result<String, OwlpostError> {
82        refuse_id(id)?;
83        // The source verbatim: a body that happens to parse as `{"id": …}` is
84        // still the message, so nothing here inspects it.
85        self.client
86            .call(Method::GET, &format!("{INBOUND}/{id}/raw"), None, None)
87            .await
88    }
89
90    /// `POST {base}/v1/inbound/messages/{id}/reply`, answering the reply's id.
91    /// Only the message's `to`, `subject`, `text` and `html` travel — Owlpost
92    /// takes the sender from the inbox the reply is on. Scope `inbound:manage`.
93    ///
94    /// # Errors
95    ///
96    /// [`OwlpostError`] for an empty or path-like id, or an answer that is not
97    /// a `{"id": …}` body.
98    pub async fn reply(&self, id: &str, message: Message) -> Result<String, OwlpostError> {
99        refuse_id(id)?;
100        let body = serde_json::json!({
101            "to": message.to,
102            "subject": message.subject,
103            "text": message.text,
104            "html": message.html,
105        });
106        let bytes = serde_json::to_vec(&body).map_err(|err| self.client.transport(err))?;
107        let text = self
108            .client
109            .call(
110                Method::POST,
111                &format!("{INBOUND}/{id}/reply"),
112                Some(bytes),
113                message.idempotency_key.as_deref(),
114            )
115            .await?;
116        Ok(self.parse::<crate::SendResponse>(&text)?.id)
117    }
118
119    /// `POST {base}/v1/inbound/messages/{id}/release`, stopping the hold.
120    /// Scope `inbound:manage`.
121    ///
122    /// # Errors
123    ///
124    /// As [`Owlpost::get_message`].
125    pub async fn release(&self, id: &str) -> Result<(), OwlpostError> {
126        refuse_id(id)?;
127        // Any 2xx answers; the body is not worth reading.
128        self.client
129            .call(Method::POST, &format!("{INBOUND}/{id}/release"), None, None)
130            .await
131            .map(|_| ())
132    }
133
134    /// One `GET`, parsed.
135    async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, OwlpostError> {
136        self.parse(&self.client.call(Method::GET, path, None, None).await?)
137    }
138
139    fn parse<T: serde::de::DeserializeOwned>(&self, text: &str) -> Result<T, OwlpostError> {
140        serde_json::from_str(text).map_err(|err| self.client.transport(err))
141    }
142}
143
144/// The filters and cursor for [`Owlpost::list_messages`]: an inbox or thread
145/// id, a full-text `q`, a page `limit`, `before` — the previous page's
146/// [`MessagePage::next`] — and `held`, the only lifecycle state
147/// (`status=held`) a listing can be filtered to. Every field is optional, and
148/// an empty one is left off the query string. `#[non_exhaustive]`: build with
149/// [`MessageQuery::default`] and set fields.
150#[derive(Debug, Clone, Default)]
151#[non_exhaustive]
152pub struct MessageQuery {
153    pub inbox: Option<String>,
154    pub thread: Option<String>,
155    pub q: Option<String>,
156    pub limit: Option<u32>,
157    pub before: Option<String>,
158    pub held: bool,
159}
160
161/// One page of messages, and the cursor for the page before them (`None` on
162/// the last page). `#[non_exhaustive]`.
163#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq, Eq)]
164#[non_exhaustive]
165pub struct MessagePage {
166    #[serde(default)]
167    pub data: Vec<MessageSummary>,
168    /// The cursor for the page of older messages: pass it back as
169    /// [`MessageQuery::before`]. `None` on the last page.
170    #[serde(default)]
171    pub next: Option<String>,
172}
173
174/// What a listing says about a message — and all it may say: there is no body
175/// field, so a held message's HTML and text cannot leak into a list; fetch
176/// them with [`Owlpost::get_message`]. Fields are defaulted one by one, not by
177/// a container `#[serde(default)]`, which a flattened [`MessageDetail`] cannot
178/// see through. `#[non_exhaustive]`.
179#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq, Eq)]
180#[non_exhaustive]
181pub struct MessageSummary {
182    pub id: String,
183    pub inbox: String,
184    pub status: String,
185    #[serde(default)]
186    pub thread: Option<String>,
187    #[serde(default)]
188    pub from: String,
189    #[serde(default)]
190    pub to: Vec<String>,
191    #[serde(default)]
192    pub subject: String,
193    #[serde(default)]
194    pub received_at: String,
195}
196
197/// One message with its body — the only inbound type that carries one.
198/// `#[non_exhaustive]`.
199#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq, Eq)]
200#[non_exhaustive]
201pub struct MessageDetail {
202    /// The summary fields a listing returns …
203    #[serde(flatten)]
204    pub summary: MessageSummary,
205    /// … plus the parsed body, which a held message exposes only here.
206    #[serde(default)]
207    pub text: Option<String>,
208    #[serde(default)]
209    pub html: Option<String>,
210}
211
212/// Refuses an id that is empty or path-like before any request, exactly as
213/// [`Owlpost::get_email`] does for a sent email id.
214fn refuse_id(id: &str) -> Result<(), OwlpostError> {
215    if !valid_email_id(id) {
216        return Err(MailError::Invalid {
217            detail: format!("invalid inbound id {id:?}"),
218        }
219        .into());
220    }
221    Ok(())
222}
223
224/// Appends `?k=v&k=v` to `path`; [`crate::OwlpostClient::call`] splices the
225/// path into the URI raw, so every key and value is percent-encoded here.
226fn query_path(path: &str, params: &[(&str, String)]) -> String {
227    let mut path = path.to_owned();
228    for (index, (key, value)) in params.iter().enumerate() {
229        path.push(if index == 0 { '?' } else { '&' });
230        path.push_str(&percent_encode(key));
231        path.push('=');
232        path.push_str(&percent_encode(value));
233    }
234    path
235}
236
237/// Percent-encodes everything outside the unreserved set, so a space or an
238/// `&` in a search term cannot end the query early.
239fn percent_encode(value: &str) -> String {
240    let mut encoded = String::with_capacity(value.len());
241    for byte in value.bytes() {
242        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
243            encoded.push(char::from(byte));
244        } else {
245            let _ = write!(encoded, "%{byte:02X}");
246        }
247    }
248    encoded
249}