hey_sdk/services/
entries.rs1use 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#[derive(Debug, Clone, Default, PartialEq)]
14pub struct ReplyContent {
15 pub acting_sender_id: i64,
20 pub subject: String,
23 pub content: String,
26 pub to: Vec<String>,
28 pub cc: Vec<String>,
30 pub bcc: Vec<String>,
32}
33
34impl Entries<'_> {
35 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 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#[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}