Skip to main content

oxios_kernel/tools/builtin/
email_tool.rs

1//! Email tool — wraps `SmtpClient` behind the `AgentTool` interface.
2//!
3//! Provides agents with email sending capabilities. Agents compose HTML,
4//! manage templates, and decide content — we only provide the SMTP pipe.
5//!
6//! ## Actions
7//!
8//! | Mode | Description | Required params |
9//! |------|-------------|-----------------|
10//! | Send (direct) | Send an HTML email | `subject`, `body_html` |
11//! | Send (template) | Send using a saved template | `subject`, `use_template` |
12//! | Save template | Send + save as template | `subject`, `body_html` or `use_template`, `save_template_as` |
13//! | List templates | List available templates | `list_templates: true` |
14
15use async_trait::async_trait;
16use parking_lot::RwLock;
17use std::collections::HashMap;
18use std::sync::Arc;
19
20use oxi_sdk::{AgentTool as OxiAgentTool, AgentToolResult, ToolContext};
21use serde::Deserialize;
22use serde_json::{Value, json};
23
24use crate::kernel_handle::EmailApi;
25
26/// Maximum HTML body size (1 MB).
27const MAX_HTML_BYTES: usize = 1_000_000;
28/// Maximum subject length.
29const MAX_SUBJECT_LEN: usize = 200;
30
31/// Arguments for the `send_email` tool.
32#[derive(Debug, Deserialize)]
33struct EmailArgs {
34    /// Email subject line.
35    subject: Option<String>,
36    /// HTML body (full document or body fragment).
37    body_html: Option<String>,
38    /// Plain text fallback (recommended but optional).
39    body_text: Option<String>,
40    /// Save this email as a reusable template.
41    save_template_as: Option<String>,
42    /// Use a saved template (body_html is ignored).
43    use_template: Option<String>,
44    /// Key-value pairs to substitute in template. `{{key}}` → value.
45    template_vars: Option<HashMap<String, String>>,
46    /// If true, list available templates and return.
47    list_templates: Option<bool>,
48}
49
50/// A single sent email record (stored in `email_sent/`).
51#[derive(Debug, serde::Serialize, serde::Deserialize)]
52struct SentRecord {
53    /// Unique ID.
54    id: String,
55    /// Timestamp.
56    sent_at: String,
57    /// Email subject.
58    subject: String,
59    /// Recipient (always `my_email` in v1).
60    to: String,
61    /// Template used (if any).
62    template_used: Option<String>,
63    /// SMTP message ID.
64    message_id: String,
65    /// First 500 chars of HTML for preview.
66    html_preview: String,
67    /// Full HTML body (원문).
68    html_full: String,
69    /// Plain text fallback.
70    body_text: Option<String>,
71    /// Associated cron job name (if triggered by cron).
72    cron_job: Option<String>,
73}
74
75/// Email tool — provides `send_email` to agents.
76///
77/// Wraps [`EmailApi`] and adds:
78/// - Template loading/saving/rendering
79/// - Rate limiting
80/// - Sent history recording
81/// - EventBus notification on success
82pub struct EmailTool {
83    api: Arc<RwLock<Option<EmailApi>>>,
84}
85
86impl EmailTool {
87    /// Create a new `EmailTool` from a `KernelHandle`.
88    ///
89    /// Always returns a tool — the shared `RwLock<Option<EmailApi>>` slot
90    /// starts as `None` when email is unconfigured and is swapped in at
91    /// runtime when the user sets up email via the web UI. This avoids
92    /// requiring a daemon restart to register the tool.
93    pub fn from_kernel(kernel: &crate::KernelHandle) -> Self {
94        Self {
95            api: kernel.email.clone(),
96        }
97    }
98}
99
100impl std::fmt::Debug for EmailTool {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("EmailTool").finish()
103    }
104}
105
106#[async_trait]
107
108impl OxiAgentTool for EmailTool {
109    fn name(&self) -> &str {
110        "send_email"
111    }
112
113    fn label(&self) -> &str {
114        "Send Email"
115    }
116
117    fn description(&self) -> &'static str {
118        "Compose and send an HTML email. You decide the format, layout, and content. \
119         For recurring sends, save as template and reuse. Templates are stored in \
120         ~/.oxios/workspace/email_templates/."
121    }
122
123    fn parameters_schema(&self) -> Value {
124        json!({
125            "type": "object",
126            "properties": {
127                "subject": {
128                    "type": "string",
129                    "description": "Email subject line"
130                },
131                "body_html": {
132                    "type": "string",
133                    "description": "HTML body. Full <html> document or <body> fragment. Inline CSS only (email clients strip <style>)."
134                },
135                "body_text": {
136                    "type": "string",
137                    "description": "Plain text fallback. Optional but recommended for accessibility."
138                },
139                "save_template_as": {
140                    "type": "string",
141                    "description": "Save this email as a reusable template with this name. Stored in email_templates/<name>.html"
142                },
143                "use_template": {
144                    "type": "string",
145                    "description": "Name of a saved template to use. body_html is ignored; template_vars are substituted."
146                },
147                "template_vars": {
148                    "type": "object",
149                    "description": "Key-value pairs to substitute in template. {{key}} → value."
150                },
151                "list_templates": {
152                    "type": "boolean",
153                    "description": "If true, list available templates and return. All other params ignored."
154                }
155            }
156        })
157    }
158
159    async fn execute(
160        &self,
161        _tool_call_id: &str,
162        params: Value,
163        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
164        _ctx: &ToolContext,
165    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
166        // Clone the EmailApi from the shared slot (cheap — Arc-based).
167        // Lock is dropped before any async calls so handle_email_setup's
168        // write-lock never blocks during an SMTP round-trip.
169        let api = {
170            let guard = self.api.read();
171            guard
172                .as_ref()
173                .ok_or("Email is not configured. Set it up in Settings → Email.")?
174                .clone()
175        };
176
177        let args: EmailArgs =
178            serde_json::from_value(params).map_err(|e| format!("Invalid arguments: {e}"))?;
179
180        // ── List templates mode ───────────────────────────────────
181        if args.list_templates.unwrap_or(false) {
182            let templates = api
183                .list_templates()
184                .map_err(|e| format!("Failed to list templates: {e}"))?;
185            return Ok(AgentToolResult::success(
186                serde_json::to_string_pretty(&json!({
187                    "templates": templates,
188                }))
189                .unwrap_or_default(),
190            ));
191        }
192
193        // ── Validate subject ──────────────────────────────────────
194        let subject = args.subject.as_deref().ok_or("subject is required")?;
195
196        if subject.len() > MAX_SUBJECT_LEN {
197            return Err(format!(
198                "Subject too long ({} chars, max {})",
199                subject.len(),
200                MAX_SUBJECT_LEN
201            ));
202        }
203
204        // ── Resolve HTML body ─────────────────────────────────────
205        let html = if let Some(name) = &args.use_template {
206            let template = api
207                .load_template(name)
208                .map_err(|e| format!("Template error: {e}"))?;
209            render_template(&template, &args.template_vars.unwrap_or_default())
210                .map_err(|e| format!("Template render error: {e}"))?
211        } else {
212            args.body_html
213                .as_deref()
214                .ok_or("body_html or use_template is required")?
215                .to_string()
216        };
217
218        // ── Validate HTML size ────────────────────────────────────
219        if html.len() > MAX_HTML_BYTES {
220            return Err(format!(
221                "HTML body too large ({} bytes, max {} bytes)",
222                html.len(),
223                MAX_HTML_BYTES
224            ));
225        }
226
227        // ── Rate limit check ──────────────────────────────────────
228        let rate_limit = api.rate_limit();
229        let sent_count = api
230            .count_recent_sent(1)
231            .await
232            .map_err(|e| format!("Rate limit check failed: {e}"))?;
233        if sent_count >= rate_limit {
234            return Err(format!(
235                "Rate limit: {rate_limit} emails per hour. Try later."
236            ));
237        }
238
239        // ── Send ──────────────────────────────────────────────────
240        let receipt = api
241            .send(subject, &html, args.body_text.as_deref())
242            .await
243            .map_err(|e| format!("SMTP send failed: {e}"))?;
244
245        // ── Save template (if requested) ──────────────────────────
246        if let Some(name) = &args.save_template_as {
247            api.save_template(name, &html)
248                .map_err(|e| format!("Failed to save template: {e}"))?;
249        }
250
251        // ── Record sent history ───────────────────────────────────
252        let record = SentRecord {
253            id: uuid::Uuid::new_v4().to_string(),
254            sent_at: receipt.sent_at.to_rfc3339(),
255            subject: subject.to_string(),
256            to: api.default_to().to_string(),
257            template_used: args.use_template.clone().or(args.save_template_as.clone()),
258            message_id: receipt.message_id.clone(),
259            html_preview: html.chars().take(500).collect(),
260            html_full: html,
261            body_text: args.body_text,
262            cron_job: None,
263        };
264        if let Err(e) = api.save_sent_record(&record).await {
265            tracing::warn!(error = %e, "Failed to save email sent record");
266        }
267
268        // ── EventBus notification ─────────────────────────────────
269        api.notify_sent(
270            subject.to_string(),
271            receipt.message_id.clone(),
272            args.save_template_as.clone(),
273        );
274
275        Ok(AgentToolResult::success(
276            serde_json::to_string_pretty(&json!({
277                "status": "sent",
278                "message_id": receipt.message_id,
279                "template_saved": args.save_template_as.is_some(),
280            }))
281            .unwrap_or_default(),
282        ))
283    }
284}
285
286/// Render a template by substituting `{{key}}` placeholders.
287///
288/// Keys not found in `vars` are left as-is (not stripped).
289fn render_template(template: &str, vars: &HashMap<String, String>) -> Result<String, String> {
290    let mut result = template.to_string();
291    for (key, value) in vars {
292        let placeholder = format!("{{{{{key}}}}}"); // {{key}}
293        result = result.replace(&placeholder, value);
294    }
295    Ok(result)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_render_template_basic() {
304        let template = "<h1>Hello {{name}}</h1><p>{{message}}</p>";
305        let mut vars = HashMap::new();
306        vars.insert("name".to_string(), "World".to_string());
307        vars.insert("message".to_string(), "Welcome!".to_string());
308
309        let result = render_template(template, &vars).unwrap();
310        assert_eq!(result, "<h1>Hello World</h1><p>Welcome!</p>");
311    }
312
313    #[test]
314    fn test_render_template_missing_vars_left_as_is() {
315        let template = "<h1>Hello {{name}}</h1><p>{{missing}}</p>";
316        let mut vars = HashMap::new();
317        vars.insert("name".to_string(), "World".to_string());
318
319        let result = render_template(template, &vars).unwrap();
320        assert_eq!(result, "<h1>Hello World</h1><p>{{missing}}</p>");
321    }
322
323    #[test]
324    fn test_render_template_empty_vars() {
325        let template = "<h1>Hello {{name}}</h1>";
326        let vars = HashMap::new();
327
328        let result = render_template(template, &vars).unwrap();
329        assert_eq!(result, "<h1>Hello {{name}}</h1>");
330    }
331
332    #[test]
333    fn test_render_template_html_in_values() {
334        let template = "<ul>{{items}}</ul>";
335        let mut vars = HashMap::new();
336        vars.insert("items".to_string(), "<li>A</li><li>B</li>".to_string());
337
338        let result = render_template(template, &vars).unwrap();
339        assert_eq!(result, "<ul><li>A</li><li>B</li></ul>");
340    }
341}