cratefield_adapter_owlpost/
inbound.rs1use cratefield_core::{MailError, Message};
9use http::Method;
10use std::fmt::Write as _;
11
12use crate::{Owlpost, OwlpostError, valid_email_id};
13
14const INBOUND: &str = "/v1/inbound/messages";
16
17impl Owlpost {
18 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, ¶ms)).await
43 }
44
45 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 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 pub async fn raw_message(&self, id: &str) -> Result<String, OwlpostError> {
82 refuse_id(id)?;
83 self.client
86 .call(Method::GET, &format!("{INBOUND}/{id}/raw"), None, None)
87 .await
88 }
89
90 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 pub async fn release(&self, id: &str) -> Result<(), OwlpostError> {
126 refuse_id(id)?;
127 self.client
129 .call(Method::POST, &format!("{INBOUND}/{id}/release"), None, None)
130 .await
131 .map(|_| ())
132 }
133
134 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#[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#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq, Eq)]
164#[non_exhaustive]
165pub struct MessagePage {
166 #[serde(default)]
167 pub data: Vec<MessageSummary>,
168 #[serde(default)]
171 pub next: Option<String>,
172}
173
174#[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#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq, Eq)]
200#[non_exhaustive]
201pub struct MessageDetail {
202 #[serde(flatten)]
204 pub summary: MessageSummary,
205 #[serde(default)]
207 pub text: Option<String>,
208 #[serde(default)]
209 pub html: Option<String>,
210}
211
212fn 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
224fn 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
237fn 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}