Skip to main content

hey_sdk/services/
entries.rs

1//! Replying to an entry, delivered or kept as a draft.
2
3use crate::error::Error;
4use crate::generated::routes;
5use crate::generated::types::{CreateReplyRequestContent, ReplyMessagePayload};
6use crate::services::messages::{
7    delivered_entry, drafted_entry, entry_id_from_location, has_recipients,
8};
9
10pub use crate::generated::services::entries::*;
11
12/// A reply, as the reply prefill (`Entries::new_reply`) hands its parts over.
13#[derive(Debug, Clone, Default, PartialEq)]
14pub struct ReplyContent {
15    /// The identity the reply goes out as. HEY resolves a reply's sender from the thread
16    /// — on a shared or alternate address that is not the account default — and hands it
17    /// back as the prefill's sender; pass that id. Zero falls back to the client's
18    /// default sender, which on such a thread is the wrong identity.
19    pub acting_sender_id: i64,
20    /// HEY derives no subject for a reply, so pass the prefill's "Re: …" — a draft saved
21    /// without one reads "No subject". Empty leaves it off the wire.
22    pub subject: String,
23    /// The reply body alone: HEY appends the quoted original at delivery, so the
24    /// prefill's quoted content must not be echoed back.
25    pub content: String,
26    /// The addresses the reply goes to.
27    pub to: Vec<String>,
28    /// The addresses copied on it.
29    pub cc: Vec<String>,
30    /// The addresses copied on it without the others seeing.
31    pub bcc: Vec<String>,
32}
33
34impl Entries<'_> {
35    /// Delivers a reply to an entry. HEY does not reply-all on the caller's behalf, and
36    /// saves an unaddressed reply as a draft rather than delivering it, so the thread's
37    /// recipients — the prefill's — are required.
38    pub async fn reply(&self, entry_id: i64, reply: &ReplyContent) -> Result<(), Error> {
39        if !has_recipients(&reply.to, &reply.cc, &reply.bcc) {
40            return Err(Error::usage(
41                "a reply needs at least one recipient (to, cc or bcc); HEY saves an unaddressed reply as a draft",
42            ));
43        }
44
45        let body = CreateReplyRequestContent {
46            acting_sender_id: self.sender_for(reply).await?,
47            message: reply_payload(reply),
48            entry: Some(delivered_entry(&reply.to, &reply.cc, &reply.bcc)),
49        };
50        let mut operation = self.client().operation(&routes::CREATE_REPLY, &[&entry_id]);
51        operation.json(&body)?;
52        self.client().send_unit(operation).await
53    }
54
55    /// Saves a reply as a draft instead of delivering it, and answers the draft's entry
56    /// id. Unlike [`Entries::reply`] it needs no recipients; whatever it carries is kept
57    /// for the send.
58    pub async fn reply_draft(&self, entry_id: i64, reply: &ReplyContent) -> Result<i64, Error> {
59        let body = CreateReplyRequestContent {
60            acting_sender_id: self.sender_for(reply).await?,
61            message: reply_payload(reply),
62            entry: Some(drafted_entry(&reply.to, &reply.cc, &reply.bcc)),
63        };
64        let mut operation = self.client().operation(&routes::CREATE_REPLY, &[&entry_id]);
65        operation.json(&body)?;
66        let response = self.client().execute(operation).await?;
67        entry_id_from_location(&response)
68    }
69
70    async fn sender_for(&self, reply: &ReplyContent) -> Result<i64, Error> {
71        if reply.acting_sender_id == 0 {
72            self.client().default_sender_id().await
73        } else {
74            Ok(reply.acting_sender_id)
75        }
76    }
77}
78
79fn reply_payload(reply: &ReplyContent) -> ReplyMessagePayload {
80    let subject = if reply.subject.is_empty() {
81        None
82    } else {
83        Some(reply.subject.clone())
84    };
85    ReplyMessagePayload {
86        subject,
87        content: reply.content.clone(),
88    }
89}
90
91// Wire tests on the shipped HTTP client; without the `reqwest` feature there is none.
92#[cfg(all(test, feature = "reqwest"))]
93mod tests {
94    use serde_json::{Value, json};
95    use wiremock::matchers::{method, path};
96    use wiremock::{Mock, MockServer, ResponseTemplate};
97
98    use super::*;
99    use crate::auth::StaticTokenProvider;
100    use crate::client::Client;
101    use crate::config::Config;
102    use crate::error::ErrorCode;
103
104    #[tokio::test]
105    async fn reply_sends_the_chosen_acting_sender_untouched() {
106        let server = MockServer::start().await;
107        Mock::given(method("POST"))
108            .and(path("/entries/456/replies.json"))
109            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "notice": "sent" })))
110            .mount(&server)
111            .await;
112
113        client(&server)
114            .entries()
115            .reply(456, &prefilled_reply())
116            .await
117            .unwrap();
118
119        let body = sent_json(&server).await;
120        assert_eq!(body["acting_sender_id"], 314);
121        assert_eq!(
122            body["message"],
123            json!({ "subject": "Re: From the support address", "content": "Reply text" })
124        );
125        assert_eq!(
126            body["entry"],
127            json!({ "addressed": { "directly": ["someone@example.com"] } })
128        );
129    }
130
131    #[tokio::test]
132    async fn reply_refuses_a_reply_addressed_to_nobody() {
133        let server = MockServer::start().await;
134        let reply = ReplyContent {
135            to: Vec::new(),
136            ..prefilled_reply()
137        };
138
139        let error = client(&server)
140            .entries()
141            .reply(456, &reply)
142            .await
143            .unwrap_err();
144
145        assert_eq!(error.code(), ErrorCode::Usage);
146        assert!(server.received_requests().await.unwrap().is_empty());
147    }
148
149    #[tokio::test]
150    async fn reply_draft_saves_it_drafted_and_answers_the_entry_id() {
151        let server = MockServer::start().await;
152        Mock::given(method("POST"))
153            .and(path("/entries/456/replies.json"))
154            .respond_with(
155                ResponseTemplate::new(204)
156                    .insert_header("Location", "https://app.hey.com/messages/777"),
157            )
158            .mount(&server)
159            .await;
160        let reply = ReplyContent {
161            to: Vec::new(),
162            content: "Draft text".to_string(),
163            ..prefilled_reply()
164        };
165
166        let entry_id = client(&server)
167            .entries()
168            .reply_draft(456, &reply)
169            .await
170            .unwrap();
171
172        assert_eq!(entry_id, 777);
173        let body = sent_json(&server).await;
174        assert_eq!(body["acting_sender_id"], 314);
175        assert_eq!(body["message"]["subject"], "Re: From the support address");
176        assert_eq!(
177            body["entry"],
178            json!({
179                "addressed": { "directly": [], "copied": [], "blindcopied": [] },
180                "status": "drafted"
181            })
182        );
183    }
184
185    #[tokio::test]
186    async fn a_reply_without_a_subject_leaves_it_off_the_wire() {
187        let server = MockServer::start().await;
188        Mock::given(method("POST"))
189            .and(path("/entries/456/replies.json"))
190            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "notice": "sent" })))
191            .mount(&server)
192            .await;
193        let reply = ReplyContent {
194            subject: String::new(),
195            ..prefilled_reply()
196        };
197
198        client(&server).entries().reply(456, &reply).await.unwrap();
199
200        let body = sent_json(&server).await;
201        assert_eq!(body["message"], json!({ "content": "Reply text" }));
202    }
203
204    fn prefilled_reply() -> ReplyContent {
205        ReplyContent {
206            acting_sender_id: 314,
207            subject: "Re: From the support address".to_string(),
208            content: "Reply text".to_string(),
209            to: vec!["someone@example.com".to_string()],
210            ..ReplyContent::default()
211        }
212    }
213
214    fn client(server: &MockServer) -> Client {
215        Client::builder(Config::default().with_base_url(server.uri()))
216            .token_provider(StaticTokenProvider::new("t"))
217            .max_retries(0)
218            .build()
219            .unwrap()
220    }
221
222    async fn sent_json(server: &MockServer) -> Value {
223        let requests = server.received_requests().await.unwrap();
224        assert_eq!(requests.len(), 1);
225        serde_json::from_slice(&requests[0].body).unwrap()
226    }
227}