oxios_kernel/kernel_handle/
email_api.rs1use std::path::PathBuf;
11use std::sync::Arc;
12
13use chrono::Utc;
14use serde::Serialize;
15
16use crate::email::SmtpClient;
17use crate::event_bus::{EventBus, KernelEvent};
18use crate::state_store::StateStore;
19
20#[derive(Clone)]
25pub struct EmailApi {
26 smtp: Arc<SmtpClient>,
28 template_dir: PathBuf,
30 state_store: Arc<StateStore>,
32 event_bus: Option<EventBus>,
34 rate_limit: usize,
36}
37
38impl std::fmt::Debug for EmailApi {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.debug_struct("EmailApi")
41 .field("template_dir", &self.template_dir)
42 .finish()
43 }
44}
45
46impl EmailApi {
47 pub fn new(
49 smtp: SmtpClient,
50 template_dir: PathBuf,
51 state_store: Arc<StateStore>,
52 event_bus: Option<EventBus>,
53 rate_limit: usize,
54 ) -> Self {
55 let _ = std::fs::create_dir_all(&template_dir);
57 Self {
58 smtp: Arc::new(smtp),
59 template_dir,
60 state_store,
61 event_bus,
62 rate_limit,
63 }
64 }
65
66 pub async fn send(
68 &self,
69 subject: &str,
70 html: &str,
71 text: Option<&str>,
72 ) -> anyhow::Result<crate::email::SendReceipt> {
73 self.smtp.send("", subject, html, text).await
74 }
75
76 pub async fn test_connection(&self) -> anyhow::Result<()> {
78 self.smtp.test_connection().await
79 }
80
81 pub fn default_to(&self) -> &str {
83 self.smtp.default_to()
84 }
85
86 pub fn from_addr(&self) -> &str {
88 self.smtp.from_addr()
89 }
90
91 pub fn load_template(&self, name: &str) -> anyhow::Result<String> {
97 let path = self.template_dir.join(format!("{name}.html"));
98 anyhow::ensure!(path.exists(), "Template '{name}' not found");
99 let content = std::fs::read_to_string(&path)?;
100 Ok(content)
101 }
102
103 pub fn save_template(&self, name: &str, html: &str) -> anyhow::Result<()> {
105 let _ = std::fs::create_dir_all(&self.template_dir);
106 let path = self.template_dir.join(format!("{name}.html"));
107 std::fs::write(&path, html)?;
108 tracing::info!(template = %name, "Email template saved");
109 Ok(())
110 }
111
112 pub fn list_templates(&self) -> anyhow::Result<Vec<String>> {
114 if !self.template_dir.exists() {
115 return Ok(Vec::new());
116 }
117 let mut templates = Vec::new();
118 for entry in std::fs::read_dir(&self.template_dir)? {
119 let entry = entry?;
120 if let Some(name) = entry.path().file_stem() {
121 if entry.path().extension().is_some_and(|ext| ext == "html") {
122 templates.push(name.to_string_lossy().to_string());
123 }
124 }
125 }
126 templates.sort();
127 Ok(templates)
128 }
129
130 pub async fn save_sent_record<T: Serialize>(&self, record: &T) -> anyhow::Result<()> {
136 let val = serde_json::to_value(record)?;
137 let id = val.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
138 let sent_at = val.get("sent_at").and_then(|v| v.as_str()).unwrap_or("");
139 let ts = sent_at
141 .get(..19)
142 .unwrap_or("unknown")
143 .replace([':', '-'], "");
144 let ts_filename = if sent_at.len() >= 19 {
147 let d = &sent_at[..10].replace('-', ""); let t = &sent_at[11..19].replace(':', ""); format!("{d}_{t}")
150 } else {
151 ts
152 };
153 let short_id = &id[..8.min(id.len())];
154 let filename = format!("{ts_filename}_{short_id}");
155 self.state_store
156 .save_json("email_sent", &filename, record)
157 .await
158 }
159
160 pub async fn count_recent_sent(&self, hours: u64) -> anyhow::Result<usize> {
164 let sent_dir = self.state_store.base_path.join("email_sent");
165 if !sent_dir.exists() {
166 return Ok(0);
167 }
168
169 let cutoff = Utc::now() - chrono::Duration::hours(hours as i64);
170 let mut count = 0;
171
172 for entry in std::fs::read_dir(&sent_dir)? {
173 let entry = entry?;
174 if entry.path().extension().is_some_and(|ext| ext == "json") {
175 let filename = entry.file_name().to_string_lossy().to_string();
177 let datetime_str = format!(
179 "{}-{}-{}T{}:{}:{}",
180 &filename[0..4],
181 &filename[4..6],
182 &filename[6..8],
183 &filename[9..11],
184 &filename[11..13],
185 &filename[13..15]
186 );
187 if let Ok(dt) =
188 chrono::NaiveDateTime::parse_from_str(&datetime_str, "%Y-%m-%dT%H:%M:%S")
189 {
190 if dt.and_utc() > cutoff {
191 count += 1;
192 }
193 }
194 }
195 }
196
197 Ok(count)
198 }
199
200 pub fn notify_sent(&self, subject: String, message_id: String, template_name: Option<String>) {
204 if let Some(bus) = &self.event_bus {
205 let _ = bus.publish(KernelEvent::EmailSent {
206 subject,
207 message_id,
208 template_name,
209 });
210 }
211 }
212
213 pub fn rate_limit(&self) -> usize {
215 self.rate_limit
216 }
217}