Skip to main content

hey_sdk/services/
messages.rs

1//! Delivering messages and keeping drafts, on top of the generated message routes.
2
3use 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
12/// What `entry.status` carries to keep an entry a draft. Any other value — or omitting
13/// the status — has HEY deliver it.
14const DRAFTED: &str = "drafted";
15
16/// A message to deliver: the subject, the Trix HTML body and the recipients per kind.
17#[derive(Debug, Clone, Default, PartialEq)]
18pub struct MessageContent {
19    /// The subject line.
20    pub subject: String,
21    /// The body, as Trix HTML.
22    pub content: String,
23    /// The addresses the message goes to.
24    pub to: Vec<String>,
25    /// The addresses copied on it.
26    pub cc: Vec<String>,
27    /// The addresses copied on it without the others seeing.
28    pub bcc: Vec<String>,
29    /// The identity the message goes out as. `None` resolves the client's default sender.
30    pub acting_sender_id: Option<i64>,
31}
32
33/// When a draft goes out, read in the identity's time zone. HEY schedules to the hour.
34#[derive(Debug, Clone, Default, PartialEq)]
35pub struct DeliverySchedule {
36    /// `YYYY-MM-DD`, "today" or "tomorrow".
37    pub date: String,
38    /// 0 through 23.
39    pub hour: u8,
40}
41
42/// The whole of what a draft carries. HEY revises a draft from the whole of it, so a
43/// caller edits by reading the draft, changing fields and sending everything back: empty
44/// recipients remove them, and a `None` schedule clears one already set.
45#[derive(Debug, Clone, Default, PartialEq)]
46pub struct DraftContent {
47    /// The subject line.
48    pub subject: String,
49    /// The body, as Trix HTML.
50    pub content: String,
51    /// The addresses the draft goes to.
52    pub to: Vec<String>,
53    /// The addresses copied on it.
54    pub cc: Vec<String>,
55    /// The addresses copied on it without the others seeing.
56    pub bcc: Vec<String>,
57    /// The identity the draft is saved — and ultimately delivered — as. `None` resolves
58    /// the client's default sender. A caller who chose an alternate identity carries it
59    /// on every revision, since a revision that leaves it out hands the draft back to
60    /// the default one.
61    pub acting_sender_id: Option<i64>,
62    /// When HEY delivers the draft on its own. `None` is a draft that waits to be sent.
63    pub schedule: Option<DeliverySchedule>,
64}
65
66impl Messages<'_> {
67    /// Delivers a new message through HEY's undo-delay window. Delivery needs somebody to
68    /// deliver to, so at least one recipient is required.
69    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    /// Saves a new message as a draft instead of delivering it, and answers the draft's
90    /// entry id — the id [`Messages::get_edit`], [`Messages::update_draft`],
91    /// [`Messages::send_draft`] and `Entries::delete_draft` take. A draft needs no
92    /// recipients; whatever it carries is kept for the send.
93    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    /// Revises a draft in place from the whole of `draft`. A trashed draft is silently
102    /// restored by the revision.
103    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    /// Delivers a draft through HEY's undo-delay window. The revision and the delivery are
113    /// one request, so the draft's final state rides along: subject, body and recipients
114    /// are replaced with what is sent, exactly as [`Messages::update_draft`] replaces them.
115    ///
116    /// The request is never retried, despite the PUT: it triggers a delivery, and a resend
117    /// after an ambiguous first attempt could send the message twice. Reading the draft, or
118    /// the thread, is the caller's way out of an ambiguous failure.
119    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
174/// The entry of a message HEY delivers, whose recipient kinds are the ones that name
175/// somebody.
176pub(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
196/// The entry of a message HEY keeps as a draft. Every recipient kind is present, empty
197/// ones included: a draft addressed to nobody yet is the normal case, and on a revision
198/// an empty list is how recipients are removed.
199pub(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
212/// The entry id out of the `Location` a draft save answers with: the save serves no body,
213/// so the header is the only place the id is named.
214pub(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// Wire tests on the shipped HTTP client; without the `reqwest` feature there is none.
238#[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}