1pub mod inbound_email;
2use reqwest::{Method, header::ACCEPT};
3use secrecy::{ExposeSecret, SecretString};
4use serde::{Deserialize, Serialize, Serializer};
5
6use super::{
7 ClientError, WekanClient, datetime::validate_response_date, transport::decode_success,
8};
9
10#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
11#[serde(deny_unknown_fields, rename_all = "camelCase")]
12pub struct IntegrationDocument {
13 #[serde(rename = "_id")]
14 pub integration_id: String,
15 pub enabled: bool,
16 pub title: Option<String>,
17 #[serde(rename = "type")]
18 pub integration_type: String,
19 pub activities: Vec<String>,
20 pub url: String,
21 pub board_id: String,
22 pub created_at: String,
23 pub modified_at: String,
24 pub user_id: String,
25 pub notification_delivery: Option<WebhookNotificationDelivery>,
26 pub mattermost_direct_messages: Option<bool>,
27 pub bot: Option<bool>,
28}
29
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(deny_unknown_fields)]
32pub struct WebhookNotificationDelivery {
33 pub webhook: Option<super::metadata::WebhookDelivery>,
34}
35
36#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
37#[serde(deny_unknown_fields)]
38pub struct IntegrationIdResponse {
39 #[serde(rename = "_id")]
40 pub integration_id: String,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
44#[serde(deny_unknown_fields)]
45pub struct IntegrationActivitiesResponse {
46 #[serde(rename = "_id")]
47 pub integration_id: String,
48 pub activities: Vec<String>,
49}
50
51#[derive(Clone, Debug, Serialize)]
52pub struct CreateIntegrationRequest {
53 pub url: String,
54}
55
56#[derive(Debug, Default, Serialize)]
57pub struct UpdateIntegrationRequest {
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub enabled: Option<bool>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub title: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub url: Option<String>,
64 #[serde(
65 skip_serializing_if = "Option::is_none",
66 serialize_with = "serialize_token"
67 )]
68 pub token: Option<SecretString>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub activities: Option<Vec<String>>,
71 #[serde(
72 rename = "notificationDelivery",
73 skip_serializing_if = "Option::is_none",
74 serialize_with = "serialize_delivery"
75 )]
76 pub notification_delivery: Option<Option<WebhookNotificationDelivery>>,
77}
78
79fn serialize_delivery<S: Serializer>(
80 value: &Option<Option<WebhookNotificationDelivery>>,
81 serializer: S,
82) -> Result<S::Ok, S::Error> {
83 use serde::ser::SerializeMap;
84 struct NativeChannel<'a>(&'a super::metadata::WebhookDelivery);
85 impl Serialize for NativeChannel<'_> {
86 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
87 let mut map = serializer.serialize_map(None)?;
88 let d = self.0;
89 macro_rules! field {
90 ($key:literal, $field:ident) => {
91 if let Some(value) = &d.$field {
92 map.serialize_entry($key, value)?;
93 }
94 };
95 }
96 field!("text", text);
97 field!("fields", fields);
98 field!("grouping", grouping);
99 field!("schedule", schedule);
100 field!("intervalMinutes", interval_minutes);
101 field!("dailyTime", daily_time);
102 field!("quietStart", quiet_start);
103 field!("quietEnd", quiet_end);
104 field!("timezone", timezone);
105 map.end()
106 }
107 }
108 match value.as_ref().and_then(Option::as_ref) {
109 None => serializer.serialize_none(),
110 Some(delivery) => {
111 let mut map = serializer.serialize_map(None)?;
112 if let Some(webhook) = &delivery.webhook {
113 map.serialize_entry("webhook", &NativeChannel(webhook))?;
114 }
115 map.end()
116 }
117 }
118}
119
120fn serialize_token<S: Serializer>(token: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
121 token.as_ref().map(ExposeSecret::expose_secret).serialize(s)
122}
123
124#[derive(Clone, Debug, Serialize)]
125pub struct IntegrationActivitiesRequest {
126 pub activities: Vec<String>,
127}
128
129impl WekanClient {
130 pub async fn board_integrations(
131 &self,
132 board: &str,
133 token: &SecretString,
134 ) -> Result<Vec<IntegrationDocument>, ClientError> {
135 let body = self
136 .execute_success_request(
137 self.http
138 .get(self.integration_endpoint(board, None, false)?)
139 .header(ACCEPT, "application/json")
140 .bearer_auth(token.expose_secret()),
141 )
142 .await?;
143 let documents: Vec<IntegrationDocument> =
144 decode_success(&body, "integration collection", true)?;
145 for document in &documents {
146 validate_document(document, board, None)?;
147 }
148 Ok(documents)
149 }
150
151 pub async fn integration(
152 &self,
153 board: &str,
154 integration: &str,
155 token: &SecretString,
156 ) -> Result<Option<IntegrationDocument>, ClientError> {
157 let body = self
158 .execute_success_request(
159 self.http
160 .get(self.integration_endpoint(board, Some(integration), false)?)
161 .header(ACCEPT, "application/json")
162 .bearer_auth(token.expose_secret()),
163 )
164 .await?;
165 if body.is_empty() {
166 return Ok(None);
167 }
168 let document: IntegrationDocument = decode_success(&body, "integration lookup", true)?;
169 validate_document(&document, board, Some(integration))?;
170 Ok(Some(document))
171 }
172
173 pub async fn create_integration(
174 &self,
175 board: &str,
176 request: &CreateIntegrationRequest,
177 token: &SecretString,
178 ) -> Result<IntegrationIdResponse, ClientError> {
179 let body = self
180 .execute_success_request(
181 self.http
182 .post(self.integration_endpoint(board, None, false)?)
183 .header(ACCEPT, "application/json")
184 .bearer_auth(token.expose_secret())
185 .json(request),
186 )
187 .await?;
188 decode_id(&body, None)
189 }
190
191 pub async fn update_integration(
192 &self,
193 board: &str,
194 integration: &str,
195 request: &UpdateIntegrationRequest,
196 token: &SecretString,
197 ) -> Result<IntegrationIdResponse, ClientError> {
198 let body = self
199 .execute_success_request(
200 self.http
201 .put(self.integration_endpoint(board, Some(integration), false)?)
202 .header(ACCEPT, "application/json")
203 .bearer_auth(token.expose_secret())
204 .json(request),
205 )
206 .await?;
207 decode_id(&body, Some(integration))
208 }
209
210 pub async fn delete_integration(
211 &self,
212 board: &str,
213 integration: &str,
214 token: &SecretString,
215 ) -> Result<IntegrationIdResponse, ClientError> {
216 let body = self
217 .execute_success_request(
218 self.http
219 .delete(self.integration_endpoint(board, Some(integration), false)?)
220 .header(ACCEPT, "application/json")
221 .bearer_auth(token.expose_secret()),
222 )
223 .await?;
224 decode_id(&body, Some(integration))
225 }
226
227 pub async fn add_integration_activities(
228 &self,
229 board: &str,
230 integration: &str,
231 request: &IntegrationActivitiesRequest,
232 token: &SecretString,
233 ) -> Result<Option<IntegrationActivitiesResponse>, ClientError> {
234 self.mutate_integration_activities(Method::POST, board, integration, request, token)
235 .await
236 }
237
238 pub async fn remove_integration_activities(
239 &self,
240 board: &str,
241 integration: &str,
242 request: &IntegrationActivitiesRequest,
243 token: &SecretString,
244 ) -> Result<Option<IntegrationActivitiesResponse>, ClientError> {
245 self.mutate_integration_activities(Method::DELETE, board, integration, request, token)
246 .await
247 }
248
249 async fn mutate_integration_activities(
250 &self,
251 method: Method,
252 board: &str,
253 integration: &str,
254 request: &IntegrationActivitiesRequest,
255 token: &SecretString,
256 ) -> Result<Option<IntegrationActivitiesResponse>, ClientError> {
257 let body = self
258 .execute_success_request(
259 self.http
260 .request(
261 method,
262 self.integration_endpoint(board, Some(integration), true)?,
263 )
264 .header(ACCEPT, "application/json")
265 .bearer_auth(token.expose_secret())
266 .json(request),
267 )
268 .await?;
269 if body.is_empty() {
270 return Ok(None);
271 }
272 let response: IntegrationActivitiesResponse =
273 decode_success(&body, "integration activities", true)?;
274 validate_response_id(&response.integration_id, Some(integration))?;
275 Ok(Some(response))
276 }
277
278 fn integration_endpoint(
279 &self,
280 board: &str,
281 integration: Option<&str>,
282 activities: bool,
283 ) -> Result<reqwest::Url, ClientError> {
284 for id in [Some(board), integration].into_iter().flatten() {
285 if id.trim().is_empty() || matches!(id, "." | "..") {
286 return Err(protocol_error(
287 "integration endpoint IDs must be nonempty and not dot-only; no request was sent",
288 false,
289 ));
290 }
291 }
292 let mut endpoint = self
293 .server()
294 .join("api/boards")
295 .map_err(|_| protocol_error("could not build the integration endpoint", false))?;
296 let mut segments = endpoint.path_segments_mut().map_err(|()| {
297 protocol_error("could not add integration endpoint identifiers", false)
298 })?;
299 segments.extend([board, "integrations"]);
300 if let Some(integration) = integration {
301 segments.push(integration);
302 }
303 if activities {
304 segments.push("activities");
305 }
306 drop(segments);
307 Ok(endpoint)
308 }
309}
310
311fn validate_document(
312 document: &IntegrationDocument,
313 board: &str,
314 integration: Option<&str>,
315) -> Result<(), ClientError> {
316 validate_response_id(&document.integration_id, integration)?;
317 for id in [&document.board_id, &document.user_id] {
318 validate_response_id(id, None)?;
319 }
320 if document.board_id != board {
321 return Err(protocol_error(
322 "the integration response board ID did not match the requested scope",
323 true,
324 ));
325 }
326 for date in [&document.created_at, &document.modified_at] {
327 validate_response_date(date).map_err(|()| {
328 protocol_error(
329 "the integration response contained an invalid date-time",
330 true,
331 )
332 })?;
333 }
334 Ok(())
335}
336
337fn decode_id(body: &[u8], expected: Option<&str>) -> Result<IntegrationIdResponse, ClientError> {
338 let response: IntegrationIdResponse =
339 decode_success(body, "integration acknowledgement", true)?;
340 validate_response_id(&response.integration_id, expected)?;
341 Ok(response)
342}
343
344fn validate_response_id(id: &str, expected: Option<&str>) -> Result<(), ClientError> {
345 if id.trim().is_empty() {
346 return Err(protocol_error(
347 "the integration response contained an empty identifier",
348 true,
349 ));
350 }
351 if expected.is_some_and(|expected| expected != id) {
352 return Err(protocol_error(
353 "Wekan returned a different integration ID than requested",
354 true,
355 ));
356 }
357 Ok(())
358}
359
360fn protocol_error(message: &str, success_status_received: bool) -> ClientError {
361 ClientError::Protocol {
362 diagnostic: None,
363 message: message.to_owned(),
364 success_status_received,
365 }
366}