oxios_kernel/tools/builtin/
email_tool.rs1use 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
26const MAX_HTML_BYTES: usize = 1_000_000;
28const MAX_SUBJECT_LEN: usize = 200;
30
31#[derive(Debug, Deserialize)]
33struct EmailArgs {
34 subject: Option<String>,
36 body_html: Option<String>,
38 body_text: Option<String>,
40 save_template_as: Option<String>,
42 use_template: Option<String>,
44 template_vars: Option<HashMap<String, String>>,
46 list_templates: Option<bool>,
48}
49
50#[derive(Debug, serde::Serialize, serde::Deserialize)]
52struct SentRecord {
53 id: String,
55 sent_at: String,
57 subject: String,
59 to: String,
61 template_used: Option<String>,
63 message_id: String,
65 html_preview: String,
67 html_full: String,
69 body_text: Option<String>,
71 cron_job: Option<String>,
73}
74
75pub struct EmailTool {
83 api: Arc<EmailApi>,
84}
85
86impl EmailTool {
87 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 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 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 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 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 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 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 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 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 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
276fn 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}}}}}"); 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}