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 std::collections::HashMap;
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use oxi_sdk::{AgentTool as OxiAgentTool, AgentToolResult, ToolContext};
20use serde::Deserialize;
21use serde_json::{json, Value};
22use tokio::sync::oneshot;
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<EmailApi>,
84}
85
86impl EmailTool {
87    /// Create a new `EmailTool` from a `KernelHandle`.
88    ///
89    /// Returns `None` if email is not configured.
90    pub fn try_from_kernel(kernel: &crate::KernelHandle) -> Option<Self> {
91        kernel.email.as_ref().map(|api| Self {
92            api: Arc::new(api.clone()),
93        })
94    }
95}
96
97impl std::fmt::Debug for EmailTool {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("EmailTool").finish()
100    }
101}
102
103#[async_trait]
104impl OxiAgentTool for EmailTool {
105    fn name(&self) -> &str {
106        "send_email"
107    }
108
109    fn label(&self) -> &str {
110        "Send Email"
111    }
112
113    fn description(&self) -> &'static str {
114        "Compose and send an HTML email. You decide the format, layout, and content. \
115         For recurring sends, save as template and reuse. Templates are stored in \
116         ~/.oxios/workspace/email_templates/."
117    }
118
119    fn parameters_schema(&self) -> Value {
120        json!({
121            "type": "object",
122            "properties": {
123                "subject": {
124                    "type": "string",
125                    "description": "Email subject line"
126                },
127                "body_html": {
128                    "type": "string",
129                    "description": "HTML body. Full <html> document or <body> fragment. Inline CSS only (email clients strip <style>)."
130                },
131                "body_text": {
132                    "type": "string",
133                    "description": "Plain text fallback. Optional but recommended for accessibility."
134                },
135                "save_template_as": {
136                    "type": "string",
137                    "description": "Save this email as a reusable template with this name. Stored in email_templates/<name>.html"
138                },
139                "use_template": {
140                    "type": "string",
141                    "description": "Name of a saved template to use. body_html is ignored; template_vars are substituted."
142                },
143                "template_vars": {
144                    "type": "object",
145                    "description": "Key-value pairs to substitute in template. {{key}} → value."
146                },
147                "list_templates": {
148                    "type": "boolean",
149                    "description": "If true, list available templates and return. All other params ignored."
150                }
151            }
152        })
153    }
154
155    async fn execute(
156        &self,
157        _tool_call_id: &str,
158        params: Value,
159        _signal: Option<oneshot::Receiver<()>>,
160        _ctx: &ToolContext,
161    ) -> Result<AgentToolResult, String> {
162        let args: EmailArgs =
163            serde_json::from_value(params).map_err(|e| format!("Invalid arguments: {e}"))?;
164
165        // ── List templates mode ───────────────────────────────────
166        if args.list_templates.unwrap_or(false) {
167            let templates = self
168                .api
169                .list_templates()
170                .map_err(|e| format!("Failed to list templates: {e}"))?;
171            return Ok(AgentToolResult::success(
172                serde_json::to_string_pretty(&json!({
173                    "templates": templates,
174                }))
175                .unwrap_or_default(),
176            ));
177        }
178
179        // ── Validate subject ──────────────────────────────────────
180        let subject = args.subject.as_deref().ok_or("subject is required")?;
181
182        if subject.len() > MAX_SUBJECT_LEN {
183            return Err(format!(
184                "Subject too long ({} chars, max {})",
185                subject.len(),
186                MAX_SUBJECT_LEN
187            ));
188        }
189
190        // ── Resolve HTML body ─────────────────────────────────────
191        let html = if let Some(name) = &args.use_template {
192            let template = self
193                .api
194                .load_template(name)
195                .map_err(|e| format!("Template error: {e}"))?;
196            render_template(&template, &args.template_vars.unwrap_or_default())
197                .map_err(|e| format!("Template render error: {e}"))?
198        } else {
199            args.body_html
200                .as_deref()
201                .ok_or("body_html or use_template is required")?
202                .to_string()
203        };
204
205        // ── Validate HTML size ────────────────────────────────────
206        if html.len() > MAX_HTML_BYTES {
207            return Err(format!(
208                "HTML body too large ({} bytes, max {} bytes)",
209                html.len(),
210                MAX_HTML_BYTES
211            ));
212        }
213
214        // ── Rate limit check ──────────────────────────────────────
215        let rate_limit = self.api.rate_limit();
216        let sent_count = self
217            .api
218            .count_recent_sent(1)
219            .await
220            .map_err(|e| format!("Rate limit check failed: {e}"))?;
221        if sent_count >= rate_limit {
222            return Err(format!(
223                "Rate limit: {rate_limit} emails per hour. Try later."
224            ));
225        }
226
227        // ── Send ──────────────────────────────────────────────────
228        let receipt = self
229            .api
230            .send(subject, &html, args.body_text.as_deref())
231            .await
232            .map_err(|e| format!("SMTP send failed: {e}"))?;
233
234        // ── Save template (if requested) ──────────────────────────
235        if let Some(name) = &args.save_template_as {
236            self.api
237                .save_template(name, &html)
238                .map_err(|e| format!("Failed to save template: {e}"))?;
239        }
240
241        // ── Record sent history ───────────────────────────────────
242        let record = SentRecord {
243            id: uuid::Uuid::new_v4().to_string(),
244            sent_at: receipt.sent_at.to_rfc3339(),
245            subject: subject.to_string(),
246            to: self.api.default_to().to_string(),
247            template_used: args.use_template.clone().or(args.save_template_as.clone()),
248            message_id: receipt.message_id.clone(),
249            html_preview: html.chars().take(500).collect(),
250            html_full: html,
251            body_text: args.body_text,
252            cron_job: None,
253        };
254        if let Err(e) = self.api.save_sent_record(&record).await {
255            tracing::warn!(error = %e, "Failed to save email sent record");
256        }
257
258        // ── EventBus notification ─────────────────────────────────
259        self.api.notify_sent(
260            subject.to_string(),
261            receipt.message_id.clone(),
262            args.save_template_as.clone(),
263        );
264
265        Ok(AgentToolResult::success(
266            serde_json::to_string_pretty(&json!({
267                "status": "sent",
268                "message_id": receipt.message_id,
269                "template_saved": args.save_template_as.is_some(),
270            }))
271            .unwrap_or_default(),
272        ))
273    }
274}
275
276/// Render a template by substituting `{{key}}` placeholders.
277///
278/// Keys not found in `vars` are left as-is (not stripped).
279fn render_template(template: &str, vars: &HashMap<String, String>) -> Result<String, String> {
280    let mut result = template.to_string();
281    for (key, value) in vars {
282        let placeholder = format!("{{{{{key}}}}}"); // {{key}}
283        result = result.replace(&placeholder, value);
284    }
285    Ok(result)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn test_render_template_basic() {
294        let template = "<h1>Hello {{name}}</h1><p>{{message}}</p>";
295        let mut vars = HashMap::new();
296        vars.insert("name".to_string(), "World".to_string());
297        vars.insert("message".to_string(), "Welcome!".to_string());
298
299        let result = render_template(template, &vars).unwrap();
300        assert_eq!(result, "<h1>Hello World</h1><p>Welcome!</p>");
301    }
302
303    #[test]
304    fn test_render_template_missing_vars_left_as_is() {
305        let template = "<h1>Hello {{name}}</h1><p>{{missing}}</p>";
306        let mut vars = HashMap::new();
307        vars.insert("name".to_string(), "World".to_string());
308
309        let result = render_template(template, &vars).unwrap();
310        assert_eq!(result, "<h1>Hello World</h1><p>{{missing}}</p>");
311    }
312
313    #[test]
314    fn test_render_template_empty_vars() {
315        let template = "<h1>Hello {{name}}</h1>";
316        let vars = HashMap::new();
317
318        let result = render_template(template, &vars).unwrap();
319        assert_eq!(result, "<h1>Hello {{name}}</h1>");
320    }
321
322    #[test]
323    fn test_render_template_html_in_values() {
324        let template = "<ul>{{items}}</ul>";
325        let mut vars = HashMap::new();
326        vars.insert("items".to_string(), "<li>A</li><li>B</li>".to_string());
327
328        let result = render_template(template, &vars).unwrap();
329        assert_eq!(result, "<ul><li>A</li><li>B</li></ul>");
330    }
331}