oxios_kernel/tools/builtin/
email_tool.rs1use 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
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<RwLock<Option<EmailApi>>>,
84}
85
86impl EmailTool {
87 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 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 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 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 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 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 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 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 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 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 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
286fn 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}}}}}"); 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}