harness_loop/scheduler/
channel.rs1use crate::scheduler::store::Job;
6use async_trait::async_trait;
7use std::collections::HashMap;
8use std::sync::Arc;
9
10#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum ChannelError {
13 #[error("channel send: {0}")]
14 Send(String),
15}
16
17#[async_trait]
18pub trait Channel: Send + Sync {
19 fn key(&self) -> &str;
21 async fn send(&self, output: &str, job: &Job) -> Result<(), ChannelError>;
23}
24
25pub struct StdoutChannel;
27impl StdoutChannel {
28 pub fn new() -> Self {
29 Self
30 }
31}
32impl Default for StdoutChannel {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38#[async_trait]
39impl Channel for StdoutChannel {
40 fn key(&self) -> &str {
41 "stdout"
42 }
43 async fn send(&self, output: &str, job: &Job) -> Result<(), ChannelError> {
44 println!("\n=== {} ===\n{}\n", job.name, output);
45 Ok(())
46 }
47}
48
49pub struct EmailChannel {
51 api_key: String,
52 from: String,
53 client: reqwest::Client,
54}
55
56pub fn resend_body(from: &str, to: &str, subject: &str, text: &str) -> serde_json::Value {
58 serde_json::json!({ "from": from, "to": [to], "subject": subject, "text": text })
59}
60
61impl EmailChannel {
62 pub fn new(api_key: impl Into<String>, from: impl Into<String>) -> Self {
63 Self {
64 api_key: api_key.into(),
65 from: from.into(),
66 client: reqwest::Client::new(),
67 }
68 }
69 pub fn from_env() -> Option<Self> {
72 let key = std::env::var("RESEND_API_KEY")
73 .ok()
74 .filter(|k| !k.is_empty())?;
75 let from = std::env::var("DIGEST_FROM")
76 .unwrap_or_else(|_| "Scheduler <onboarding@resend.dev>".into());
77 Some(Self::new(key, from))
78 }
79}
80
81#[async_trait]
82impl Channel for EmailChannel {
83 fn key(&self) -> &str {
84 "email"
85 }
86 async fn send(&self, output: &str, job: &Job) -> Result<(), ChannelError> {
87 let to = job
88 .target
89 .as_deref()
90 .ok_or_else(|| ChannelError::Send("email job has no target recipient".into()))?;
91 let body = resend_body(&self.from, to, &job.name, output);
92 let resp = self
93 .client
94 .post("https://api.resend.com/emails")
95 .bearer_auth(&self.api_key)
96 .json(&body)
97 .timeout(std::time::Duration::from_secs(20))
98 .send()
99 .await
100 .map_err(|e| ChannelError::Send(format!("resend post: {e}")))?;
101 if resp.status().is_success() {
102 Ok(())
103 } else {
104 let code = resp.status();
105 let t = resp.text().await.unwrap_or_default();
106 Err(ChannelError::Send(format!("resend {code}: {t}")))
107 }
108 }
109}
110
111#[derive(Default, Clone)]
113pub struct ChannelRegistry {
114 map: HashMap<String, Arc<dyn Channel>>,
115}
116
117impl ChannelRegistry {
118 pub fn new() -> Self {
119 Self::default()
120 }
121 pub fn register(&mut self, c: Arc<dyn Channel>) {
122 self.map.insert(c.key().to_string(), c);
123 }
124 pub fn get(&self, key: &str) -> Option<&Arc<dyn Channel>> {
125 self.map.get(key)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn resend_body_shape() {
135 let b = resend_body("Me <m@x.com>", "u@y.com", "Daily", "hello");
136 assert_eq!(b["from"], "Me <m@x.com>");
137 assert_eq!(b["to"][0], "u@y.com");
138 assert_eq!(b["subject"], "Daily");
139 assert_eq!(b["text"], "hello");
140 }
141
142 #[tokio::test]
143 async fn email_without_target_errors() {
144 let ch = EmailChannel::new("re_x", "Me <m@x.com>");
145 let job = Job::new("j", "daily 08:00", "p", "email", 1);
146 let err = ch.send("out", &job).await;
147 assert!(err.is_err());
148 }
149
150 #[tokio::test]
151 async fn registry_lookup() {
152 let mut reg = ChannelRegistry::new();
153 reg.register(Arc::new(StdoutChannel::new()));
154 assert!(reg.get("stdout").is_some());
155 assert!(reg.get("email").is_none());
156 }
157}