1use crate::client::Response;
4use crate::error::Error;
5use crate::generated::routes;
6use crate::generated::types::{
7 CreateMessageRequestContent, MessageAddressed, MessageEntryPayload, MessagePayload,
8};
9
10pub use crate::generated::services::messages::*;
11
12const DRAFTED: &str = "drafted";
15
16#[derive(Debug, Clone, Default, PartialEq)]
18pub struct MessageContent {
19 pub subject: String,
21 pub content: String,
23 pub to: Vec<String>,
25 pub cc: Vec<String>,
27 pub bcc: Vec<String>,
29 pub acting_sender_id: Option<i64>,
31}
32
33#[derive(Debug, Clone, Default, PartialEq)]
35pub struct DeliverySchedule {
36 pub date: String,
38 pub hour: u8,
40}
41
42#[derive(Debug, Clone, Default, PartialEq)]
46pub struct DraftContent {
47 pub subject: String,
49 pub content: String,
51 pub to: Vec<String>,
53 pub cc: Vec<String>,
55 pub bcc: Vec<String>,
57 pub acting_sender_id: Option<i64>,
62 pub schedule: Option<DeliverySchedule>,
64}
65
66impl Messages<'_> {
67 pub async fn send(&self, message: &MessageContent) -> Result<(), Error> {
70 if !has_recipients(&message.to, &message.cc, &message.bcc) {
71 return Err(Error::usage(
72 "a message needs at least one recipient (to, cc or bcc)",
73 ));
74 }
75
76 let body = CreateMessageRequestContent {
77 acting_sender_id: self.sender_for(message.acting_sender_id).await?,
78 message: MessagePayload {
79 subject: message.subject.clone(),
80 content: message.content.clone(),
81 },
82 entry: Some(delivered_entry(&message.to, &message.cc, &message.bcc)),
83 };
84 let mut operation = self.client().operation(&routes::CREATE_MESSAGE, &[]);
85 operation.json(&body)?;
86 self.client().send_unit(operation).await
87 }
88
89 pub async fn create_draft(&self, draft: &DraftContent) -> Result<i64, Error> {
94 let body = self.drafted_request(draft).await?;
95 let mut operation = self.client().operation(&routes::CREATE_MESSAGE, &[]);
96 operation.json(&body)?;
97 let response = self.client().execute(operation).await?;
98 entry_id_from_location(&response)
99 }
100
101 pub async fn update_draft(&self, entry_id: i64, draft: &DraftContent) -> Result<(), Error> {
104 let body = self.drafted_request(draft).await?;
105 let mut operation = self
106 .client()
107 .operation(&routes::UPDATE_MESSAGE, &[&entry_id]);
108 operation.json(&body)?;
109 self.client().send_unit(operation).await
110 }
111
112 pub async fn send_draft(&self, entry_id: i64, draft: &DraftContent) -> Result<(), Error> {
120 if !has_recipients(&draft.to, &draft.cc, &draft.bcc) {
121 return Err(Error::usage(
122 "sending a draft needs at least one recipient (to, cc or bcc)",
123 ));
124 }
125
126 let body = CreateMessageRequestContent {
127 acting_sender_id: self.sender_for(draft.acting_sender_id).await?,
128 message: MessagePayload {
129 subject: draft.subject.clone(),
130 content: draft.content.clone(),
131 },
132 entry: Some(delivered_entry(&draft.to, &draft.cc, &draft.bcc)),
133 };
134 let mut operation = self
135 .client()
136 .operation(&routes::UPDATE_MESSAGE, &[&entry_id]);
137 operation.json(&body)?;
138 operation.idempotent(false);
139 self.client().send_unit(operation).await
140 }
141
142 async fn sender_for(&self, chosen: Option<i64>) -> Result<i64, Error> {
143 match chosen {
144 Some(id) => Ok(id),
145 None => self.client().default_sender_id().await,
146 }
147 }
148
149 async fn drafted_request(
150 &self,
151 draft: &DraftContent,
152 ) -> Result<CreateMessageRequestContent, Error> {
153 let mut entry = drafted_entry(&draft.to, &draft.cc, &draft.bcc);
154 if let Some(schedule) = &draft.schedule {
155 entry.scheduled_delivery = Some("true".to_string());
156 entry.scheduled_delivery_at_date = Some(schedule.date.clone());
157 entry.scheduled_delivery_at_hour = Some(schedule.hour.to_string());
158 }
159 Ok(CreateMessageRequestContent {
160 acting_sender_id: self.sender_for(draft.acting_sender_id).await?,
161 message: MessagePayload {
162 subject: draft.subject.clone(),
163 content: draft.content.clone(),
164 },
165 entry: Some(entry),
166 })
167 }
168}
169
170pub(crate) fn has_recipients(to: &[String], cc: &[String], bcc: &[String]) -> bool {
171 !to.is_empty() || !cc.is_empty() || !bcc.is_empty()
172}
173
174pub(crate) fn delivered_entry(to: &[String], cc: &[String], bcc: &[String]) -> MessageEntryPayload {
177 let addressed = MessageAddressed {
178 directly: recipients(to),
179 copied: recipients(cc),
180 blindcopied: recipients(bcc),
181 };
182 MessageEntryPayload {
183 addressed: Some(addressed),
184 ..MessageEntryPayload::default()
185 }
186}
187
188fn recipients(addresses: &[String]) -> Option<Vec<String>> {
189 if addresses.is_empty() {
190 None
191 } else {
192 Some(addresses.to_vec())
193 }
194}
195
196pub(crate) fn drafted_entry(to: &[String], cc: &[String], bcc: &[String]) -> MessageEntryPayload {
200 let addressed = MessageAddressed {
201 directly: Some(to.to_vec()),
202 copied: Some(cc.to_vec()),
203 blindcopied: Some(bcc.to_vec()),
204 };
205 MessageEntryPayload {
206 addressed: Some(addressed),
207 status: Some(DRAFTED.to_string()),
208 ..MessageEntryPayload::default()
209 }
210}
211
212pub(crate) fn entry_id_from_location(response: &Response) -> Result<i64, Error> {
215 let status = response.status.as_u16();
216 let location = response.header("location").ok_or_else(|| {
217 Error::api(
218 status,
219 "draft saved but the response named no Location; cannot report the draft's id",
220 )
221 })?;
222 let url = response.url.join(location).map_err(|error| {
223 Error::api(
224 status,
225 format!("draft saved but its Location {location:?} is unreadable: {error}"),
226 )
227 })?;
228 match url.path().rsplit('/').next().unwrap_or_default().parse() {
229 Ok(entry_id) if entry_id > 0 => Ok(entry_id),
230 _ => Err(Error::api(
231 status,
232 format!("draft saved but its Location {location:?} names no entry id"),
233 )),
234 }
235}
236
237#[cfg(all(test, feature = "reqwest"))]
239mod tests {
240 use std::time::Duration;
241
242 use serde_json::{Value, json};
243 use wiremock::matchers::{method, path};
244 use wiremock::{Mock, MockServer, ResponseTemplate};
245
246 use super::*;
247 use crate::auth::StaticTokenProvider;
248 use crate::client::Client;
249 use crate::config::Config;
250 use crate::error::ErrorCode;
251
252 #[tokio::test]
253 async fn send_refuses_a_message_addressed_to_nobody() {
254 let server = MockServer::start().await;
255 let message = MessageContent {
256 subject: "Hello".to_string(),
257 content: "Body".to_string(),
258 ..MessageContent::default()
259 };
260
261 let error = client(&server).messages().send(&message).await.unwrap_err();
262
263 assert_eq!(error.code(), ErrorCode::Usage);
264 assert!(server.received_requests().await.unwrap().is_empty());
265 }
266
267 #[tokio::test]
268 async fn send_resolves_the_identity_default_sender() {
269 let server = MockServer::start().await;
270 let identity = json!({ "id": 1, "senders": [{ "id": 41 }, { "id": 42, "default": true }] });
271 Mock::given(method("GET"))
272 .and(path("/identity.json"))
273 .respond_with(ResponseTemplate::new(200).set_body_json(identity))
274 .mount(&server)
275 .await;
276 Mock::given(method("POST"))
277 .and(path("/messages.json"))
278 .respond_with(ResponseTemplate::new(204))
279 .mount(&server)
280 .await;
281 let message = MessageContent {
282 subject: "Hello".to_string(),
283 content: "Body".to_string(),
284 to: vec!["someone@example.com".to_string()],
285 ..MessageContent::default()
286 };
287
288 client(&server).messages().send(&message).await.unwrap();
289
290 let requests = server.received_requests().await.unwrap();
291 assert_eq!(requests.len(), 2);
292 assert_eq!(requests[0].url.path(), "/identity.json");
293 let body: Value = serde_json::from_slice(&requests[1].body).unwrap();
294 assert_eq!(body["acting_sender_id"], 42);
295 assert_eq!(
296 body["message"],
297 json!({ "subject": "Hello", "content": "Body" })
298 );
299 assert_eq!(
300 body["entry"],
301 json!({ "addressed": { "directly": ["someone@example.com"] } })
302 );
303 }
304
305 #[tokio::test]
306 async fn create_draft_saves_it_drafted_and_answers_the_entry_id() {
307 let server = MockServer::start().await;
308 Mock::given(method("POST"))
309 .and(path("/messages.json"))
310 .respond_with(
311 ResponseTemplate::new(204)
312 .insert_header("Location", "https://app.hey.com/messages/777"),
313 )
314 .mount(&server)
315 .await;
316 let draft = DraftContent {
317 subject: "From the support address".to_string(),
318 content: "Draft body".to_string(),
319 acting_sender_id: Some(314),
320 schedule: Some(DeliverySchedule {
321 date: "2026-09-02".to_string(),
322 hour: 0,
323 }),
324 ..DraftContent::default()
325 };
326
327 let entry_id = client(&server)
328 .messages()
329 .create_draft(&draft)
330 .await
331 .unwrap();
332
333 assert_eq!(entry_id, 777);
334 let body = sent_json(&server).await;
335 assert_eq!(body["acting_sender_id"], 314);
336 assert_eq!(
337 body["entry"],
338 json!({
339 "addressed": { "directly": [], "copied": [], "blindcopied": [] },
340 "status": "drafted",
341 "scheduled_delivery": "true",
342 "scheduled_delivery_at_date": "2026-09-02",
343 "scheduled_delivery_at_hour": "0"
344 })
345 );
346 }
347
348 #[tokio::test]
349 async fn update_draft_says_the_acting_sender_back() {
350 let server = MockServer::start().await;
351 Mock::given(method("PUT"))
352 .and(path("/messages/777.json"))
353 .respond_with(ResponseTemplate::new(204))
354 .mount(&server)
355 .await;
356 let draft = DraftContent {
357 subject: "From the support address (v2)".to_string(),
358 content: "Rewritten body".to_string(),
359 to: vec!["someone@example.com".to_string()],
360 acting_sender_id: Some(314),
361 ..DraftContent::default()
362 };
363
364 client(&server)
365 .messages()
366 .update_draft(777, &draft)
367 .await
368 .unwrap();
369
370 let body = sent_json(&server).await;
371 assert_eq!(body["acting_sender_id"], 314);
372 assert_eq!(body["entry"]["status"], "drafted");
373 assert_eq!(
374 body["entry"]["addressed"],
375 json!({ "directly": ["someone@example.com"], "copied": [], "blindcopied": [] })
376 );
377 assert!(body["entry"]["scheduled_delivery"].is_null());
378 }
379
380 #[tokio::test]
381 async fn send_draft_delivers_it_by_leaving_the_status_off() {
382 let server = MockServer::start().await;
383 Mock::given(method("PUT"))
384 .and(path("/messages/777.json"))
385 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 777 })))
386 .mount(&server)
387 .await;
388
389 client(&server)
390 .messages()
391 .send_draft(777, &deliverable_draft())
392 .await
393 .unwrap();
394
395 let body = sent_json(&server).await;
396 assert_eq!(body["acting_sender_id"], 314);
397 assert!(body["entry"]["status"].is_null());
398 assert_eq!(
399 body["entry"]["addressed"],
400 json!({ "directly": ["someone@example.com"] })
401 );
402 }
403
404 #[tokio::test]
405 async fn send_draft_refuses_a_draft_addressed_to_nobody() {
406 let server = MockServer::start().await;
407 let draft = DraftContent {
408 to: Vec::new(),
409 ..deliverable_draft()
410 };
411
412 let error = client(&server)
413 .messages()
414 .send_draft(777, &draft)
415 .await
416 .unwrap_err();
417
418 assert_eq!(error.code(), ErrorCode::Usage);
419 assert!(server.received_requests().await.unwrap().is_empty());
420 }
421
422 #[tokio::test]
423 async fn send_draft_is_never_resent() {
424 let server = MockServer::start().await;
425 Mock::given(method("PUT"))
426 .and(path("/messages/777.json"))
427 .respond_with(ResponseTemplate::new(503))
428 .mount(&server)
429 .await;
430 let client = Client::builder(Config::default().with_base_url(server.uri()))
431 .token_provider(StaticTokenProvider::new("t"))
432 .base_delay(Duration::from_millis(1))
433 .build()
434 .unwrap();
435
436 let error = client
437 .messages()
438 .send_draft(777, &deliverable_draft())
439 .await
440 .unwrap_err();
441
442 assert_eq!(error.http_status(), Some(503));
443 assert_eq!(server.received_requests().await.unwrap().len(), 1);
444 }
445
446 #[tokio::test]
447 async fn a_draft_save_without_a_location_is_an_error() {
448 let server = MockServer::start().await;
449 Mock::given(method("POST"))
450 .and(path("/messages.json"))
451 .respond_with(ResponseTemplate::new(204))
452 .mount(&server)
453 .await;
454
455 let draft = DraftContent {
456 acting_sender_id: Some(314),
457 ..DraftContent::default()
458 };
459
460 let error = client(&server)
461 .messages()
462 .create_draft(&draft)
463 .await
464 .unwrap_err();
465
466 assert_eq!(error.code(), ErrorCode::Api);
467 }
468
469 fn deliverable_draft() -> DraftContent {
470 DraftContent {
471 subject: "From the support address".to_string(),
472 content: "Final body".to_string(),
473 to: vec!["someone@example.com".to_string()],
474 acting_sender_id: Some(314),
475 ..DraftContent::default()
476 }
477 }
478
479 fn client(server: &MockServer) -> Client {
480 Client::builder(Config::default().with_base_url(server.uri()))
481 .token_provider(StaticTokenProvider::new("t"))
482 .max_retries(0)
483 .build()
484 .unwrap()
485 }
486
487 async fn sent_json(server: &MockServer) -> Value {
488 let requests = server.received_requests().await.unwrap();
489 assert_eq!(requests.len(), 1);
490 serde_json::from_slice(&requests[0].body).unwrap()
491 }
492}