1use std::collections::HashMap;
2
3use miette::Result;
4
5use crate::{
6 EmailConfig,
7 alert::{AlertDefinition, InternalContext},
8 templates::{load_templates, render_alert},
9};
10
11pub mod canopy;
12mod default;
13mod email;
14mod slack;
15
16pub use canopy::{CanopyConfig, TargetCanopy};
17pub use default::determine_default_target;
18pub use email::TargetEmail;
19pub use slack::TargetSlack;
20
21#[derive(serde::Deserialize, Debug, Clone)]
22#[serde(rename_all = "snake_case")]
23#[serde(untagged)]
24pub enum SendTarget {
25 Simple {
27 id: String,
28 subject: Option<String>,
29 template: String,
30 },
31 External {
33 target: String, id: String,
35 subject: Option<String>,
36 template: String,
37 },
38}
39
40impl SendTarget {
41 pub fn id(&self) -> &str {
42 match self {
43 Self::Simple { id, .. } => id,
44 Self::External { id, .. } => id,
45 }
46 }
47
48 pub fn subject(&self) -> &Option<String> {
49 match self {
50 Self::Simple { subject, .. } => subject,
51 Self::External { subject, .. } => subject,
52 }
53 }
54
55 pub fn template(&self) -> &str {
56 match self {
57 Self::Simple { template, .. } => template,
58 Self::External { template, .. } => template,
59 }
60 }
61
62 pub fn resolve_external(
63 &self,
64 external_targets: &HashMap<String, Vec<ExternalTarget>>,
65 ) -> Vec<ResolvedTarget> {
66 external_targets
67 .get(self.id())
68 .map(|exts| {
69 exts.iter()
70 .map(|ext| ResolvedTarget {
71 target_id: ext.id.clone(),
72 subject: self.subject().clone(),
73 template: self.template().to_string(),
74 conn: ext.conn.clone(),
75 })
76 .collect()
77 })
78 .unwrap_or_default()
79 }
80}
81
82#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
83#[serde(untagged)]
84pub enum TargetConnection {
85 Slack(TargetSlack),
86 Email(TargetEmail),
87 Canopy(TargetCanopy),
88}
89
90#[derive(Debug, Clone)]
91pub struct ResolvedTarget {
92 pub target_id: String,
93 pub subject: Option<String>,
94 pub template: String,
95 pub conn: TargetConnection,
96}
97
98impl ResolvedTarget {
99 pub async fn send(
100 &self,
101 alert: &AlertDefinition,
102 tera_ctx: &mut tera::Context,
103 email: Option<&EmailConfig>,
104 ctx: &InternalContext,
105 dry_run: bool,
106 ) -> Result<()> {
107 let tera = load_templates(&self.subject, &self.template)?;
108 let (subject, body) = render_alert(&tera, tera_ctx)?;
109
110 match &self.conn {
111 TargetConnection::Email(target) => {
112 target.send(alert, email, &subject, &body, dry_run).await
113 }
114 TargetConnection::Slack(target) => {
115 target
116 .send(
117 &ctx.http_client,
118 slack::SlackSendParams {
119 alert,
120 subject: &subject,
121 body: &body,
122 tera: &tera,
123 tera_ctx,
124 dry_run,
125 },
126 )
127 .await
128 }
129 TargetConnection::Canopy(target) => {
130 target
131 .send(
132 ctx.canopy_client.as_deref(),
133 alert,
134 &self.target_id,
135 &subject,
136 &body,
137 dry_run,
138 )
139 .await
140 }
141 }
142 }
143
144 pub async fn send_clear(
148 &self,
149 alert: &AlertDefinition,
150 ctx: &InternalContext,
151 dry_run: bool,
152 ) -> Result<()> {
153 match &self.conn {
154 TargetConnection::Canopy(target) => {
155 target
156 .send_clear(
157 ctx.canopy_client.as_deref(),
158 alert,
159 &self.target_id,
160 dry_run,
161 )
162 .await
163 }
164 _ => Ok(()),
165 }
166 }
167}
168
169#[derive(serde::Deserialize, Debug)]
170pub struct AlertTargets {
171 pub targets: Vec<ExternalTarget>,
172}
173
174#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
175#[serde(rename_all = "snake_case")]
176pub struct ExternalTarget {
177 pub id: String,
178 #[serde(flatten)]
179 pub conn: TargetConnection,
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn test_send_target_simple_format() {
188 let yaml = r#"
189id: test-target
190subject: Test Subject
191template: Test template
192"#;
193 let target: SendTarget = serde_yaml::from_str(yaml).unwrap();
194 assert_eq!(target.id(), "test-target");
195 assert_eq!(target.subject(), &Some("Test Subject".to_string()));
196 assert_eq!(target.template(), "Test template");
197 }
198
199 #[test]
200 fn test_send_target_external_format() {
201 let yaml = r#"
202target: external
203id: test-target
204subject: Test Subject
205template: Test template
206"#;
207 let target: SendTarget = serde_yaml::from_str(yaml).unwrap();
208 assert_eq!(target.id(), "test-target");
209 assert_eq!(target.subject(), &Some("Test Subject".to_string()));
210 assert_eq!(target.template(), "Test template");
211 }
212
213 #[test]
214 fn test_send_target_without_subject() {
215 let yaml = r#"
216id: test-target
217template: Test template
218"#;
219 let target: SendTarget = serde_yaml::from_str(yaml).unwrap();
220 assert_eq!(target.id(), "test-target");
221 assert_eq!(target.subject(), &None);
222 assert_eq!(target.template(), "Test template");
223 }
224
225 #[test]
226 fn test_external_target_email() {
227 let yaml = r#"
228id: ops-team
229addresses:
230 - ops@example.com
231 - oncall@example.com
232"#;
233 let target: ExternalTarget = serde_yaml::from_str(yaml).unwrap();
234 assert_eq!(target.id, "ops-team");
235 assert!(matches!(target.conn, TargetConnection::Email(_)));
236 if let TargetConnection::Email(email) = &target.conn {
237 assert_eq!(email.addresses.len(), 2);
238 assert_eq!(email.addresses[0], "ops@example.com");
239 }
240 }
241
242 #[test]
243 fn test_external_target_slack() {
244 let yaml = r#"
245id: slack-alerts
246webhook: https://hooks.example.com/services/T00/B00/xxx
247"#;
248 let target: ExternalTarget = serde_yaml::from_str(yaml).unwrap();
249 assert_eq!(target.id, "slack-alerts");
250 assert!(matches!(target.conn, TargetConnection::Slack(_)));
251 if let TargetConnection::Slack(slack) = &target.conn {
252 assert_eq!(
253 slack.webhook.as_str(),
254 "https://hooks.example.com/services/T00/B00/xxx"
255 );
256 }
257 }
258
259 #[test]
260 fn test_external_target_slack_with_fields() {
261 let yaml = r#"
262id: slack-custom
263webhook: https://hooks.example.com/services/T00/B00/xxx
264fields:
265 - name: text
266 field: body
267 - name: server
268 value: production
269"#;
270 let target: ExternalTarget = serde_yaml::from_str(yaml).unwrap();
271 assert_eq!(target.id, "slack-custom");
272 assert!(matches!(target.conn, TargetConnection::Slack(_)));
273 if let TargetConnection::Slack(slack) = &target.conn {
274 assert_eq!(slack.fields.len(), 2);
275 }
276 }
277
278 #[test]
279 fn test_alert_targets_mixed() {
280 let yaml = r#"
281targets:
282 - id: email-team
283 addresses:
284 - team@example.com
285 - id: slack-channel
286 webhook: https://hooks.example.com/services/T00/B00/xxx
287"#;
288 let targets: AlertTargets = serde_yaml::from_str(yaml).unwrap();
289 assert_eq!(targets.targets.len(), 2);
290 assert!(matches!(
291 targets.targets[0].conn,
292 TargetConnection::Email(_)
293 ));
294 assert!(matches!(
295 targets.targets[1].conn,
296 TargetConnection::Slack(_)
297 ));
298 }
299
300 #[test]
301 fn test_resolve_email_target() {
302 let mut external_targets = HashMap::new();
303 external_targets.insert(
304 "ops".to_string(),
305 vec![ExternalTarget {
306 id: "ops".to_string(),
307 conn: TargetConnection::Email(TargetEmail {
308 addresses: vec!["ops@example.com".to_string()],
309 }),
310 }],
311 );
312
313 let send = SendTarget::Simple {
314 id: "ops".to_string(),
315 subject: Some("Test".to_string()),
316 template: "Body".to_string(),
317 };
318
319 let resolved = send.resolve_external(&external_targets);
320 assert_eq!(resolved.len(), 1);
321 assert!(matches!(resolved[0].conn, TargetConnection::Email(_)));
322 }
323
324 #[test]
325 fn test_resolve_slack_target() {
326 let mut external_targets = HashMap::new();
327 external_targets.insert(
328 "slack".to_string(),
329 vec![ExternalTarget {
330 id: "slack".to_string(),
331 conn: TargetConnection::Slack(TargetSlack {
332 webhook: "https://hooks.example.com/services/T00/B00/xxx"
333 .parse()
334 .unwrap(),
335 fields: slack::SlackField::default_set(),
336 }),
337 }],
338 );
339
340 let send = SendTarget::Simple {
341 id: "slack".to_string(),
342 subject: Some("Test".to_string()),
343 template: "Body".to_string(),
344 };
345
346 let resolved = send.resolve_external(&external_targets);
347 assert_eq!(resolved.len(), 1);
348 assert!(matches!(resolved[0].conn, TargetConnection::Slack(_)));
349 }
350
351 #[test]
352 fn test_resolve_mixed_targets_same_id() {
353 let mut external_targets = HashMap::new();
354 external_targets.insert(
355 "all".to_string(),
356 vec![
357 ExternalTarget {
358 id: "all".to_string(),
359 conn: TargetConnection::Email(TargetEmail {
360 addresses: vec!["team@example.com".to_string()],
361 }),
362 },
363 ExternalTarget {
364 id: "all".to_string(),
365 conn: TargetConnection::Slack(TargetSlack {
366 webhook: "https://hooks.example.com/services/T00/B00/xxx"
367 .parse()
368 .unwrap(),
369 fields: slack::SlackField::default_set(),
370 }),
371 },
372 ],
373 );
374
375 let send = SendTarget::Simple {
376 id: "all".to_string(),
377 subject: Some("Test".to_string()),
378 template: "Body".to_string(),
379 };
380
381 let resolved = send.resolve_external(&external_targets);
382 assert_eq!(resolved.len(), 2);
383 assert!(matches!(resolved[0].conn, TargetConnection::Email(_)));
384 assert!(matches!(resolved[1].conn, TargetConnection::Slack(_)));
385 }
386}