Skip to main content

oxios_kernel/kernel_handle/
email_api.rs

1//! Email API — KernelHandle domain facade for email.
2//!
3//! Wraps [`SmtpClient`] and provides:
4//! - Email sending (delegated to `SmtpClient`)
5//! - Template management (load/save/list)
6//! - Sent history recording (via `StateStore`)
7//! - EventBus notification on send
8//! - Rate limit tracking
9
10use 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/// Email API facade — typed API in [`KernelHandle`].
21///
22/// Constructed during kernel assembly (only when `[email]` is configured)
23/// and stored in `KernelHandle.email`.
24#[derive(Clone)]
25pub struct EmailApi {
26    /// SMTP client for sending emails.
27    smtp: Arc<SmtpClient>,
28    /// Template directory (`~/.oxios/workspace/email_templates/`).
29    template_dir: PathBuf,
30    /// State store for sent history.
31    state_store: Arc<StateStore>,
32    /// Optional event bus for notifications.
33    event_bus: Option<EventBus>,
34    /// Rate limit (emails per hour).
35    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    /// Create a new `EmailApi`.
48    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        // Ensure template directory exists
56        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    /// Send an email (delegated to `SmtpClient`).
67    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    /// Test the SMTP connection.
77    pub async fn test_connection(&self) -> anyhow::Result<()> {
78        self.smtp.test_connection().await
79    }
80
81    /// The default recipient address (user's own email).
82    pub fn default_to(&self) -> &str {
83        self.smtp.default_to()
84    }
85
86    /// The sender address.
87    pub fn from_addr(&self) -> &str {
88        self.smtp.from_addr()
89    }
90
91    // ── Templates ──────────────────────────────────────────────────
92
93    /// Load a template by name.
94    ///
95    /// Templates are stored as `email_templates/<name>.html`.
96    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    /// Save a template.
104    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    /// List all available template names.
113    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    // ── Sent History ───────────────────────────────────────────────
131
132    /// Save a sent email record to the state store.
133    ///
134    /// Filename format: `{timestamp}_{short_id}.json` for rate-limit parsing.
135    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        // Build filename: 2026-06-06_080012_{short_id}.json
140        let ts = sent_at
141            .get(..19)
142            .unwrap_or("unknown")
143            .replace([':', '-'], "");
144        // sent_at is RFC3339: "2026-06-06T08:00:12+09:00"
145        // Extract YYYYMMDD_HHMMSS
146        let ts_filename = if sent_at.len() >= 19 {
147            let d = &sent_at[..10].replace('-', ""); // 20260606
148            let t = &sent_at[11..19].replace(':', ""); // 080012
149            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    /// Count emails sent in the last `hours` hours (for rate limiting).
161    ///
162    /// Expects filenames: `YYYYMMDD_HHMMSS_shortid.json`
163    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                // Filename: 20260606_080012_abcd1234.json
176                let filename = entry.file_name().to_string_lossy().to_string();
177                // Parse YYYYMMDD_HHMMSS (first 15 chars)
178                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    // ── EventBus ───────────────────────────────────────────────────
201
202    /// Publish an `EmailSent` event to the event bus.
203    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    /// Rate limit (emails per hour).
214    pub fn rate_limit(&self) -> usize {
215        self.rate_limit
216    }
217}