use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use oxi_sdk::{AgentTool as OxiAgentTool, AgentToolResult, ToolContext};
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::sync::oneshot;
use crate::kernel_handle::EmailApi;
const MAX_HTML_BYTES: usize = 1_000_000;
const MAX_SUBJECT_LEN: usize = 200;
#[derive(Debug, Deserialize)]
struct EmailArgs {
subject: Option<String>,
body_html: Option<String>,
body_text: Option<String>,
save_template_as: Option<String>,
use_template: Option<String>,
template_vars: Option<HashMap<String, String>>,
list_templates: Option<bool>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct SentRecord {
id: String,
sent_at: String,
subject: String,
to: String,
template_used: Option<String>,
message_id: String,
html_preview: String,
html_full: String,
body_text: Option<String>,
cron_job: Option<String>,
}
pub struct EmailTool {
api: Arc<EmailApi>,
}
impl EmailTool {
pub fn try_from_kernel(kernel: &crate::KernelHandle) -> Option<Self> {
kernel.email.as_ref().map(|api| Self {
api: Arc::new(api.clone()),
})
}
}
impl std::fmt::Debug for EmailTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmailTool").finish()
}
}
#[async_trait]
impl OxiAgentTool for EmailTool {
fn name(&self) -> &str {
"send_email"
}
fn label(&self) -> &str {
"Send Email"
}
fn description(&self) -> &'static str {
"Compose and send an HTML email. You decide the format, layout, and content. \
For recurring sends, save as template and reuse. Templates are stored in \
~/.oxios/workspace/email_templates/."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"subject": {
"type": "string",
"description": "Email subject line"
},
"body_html": {
"type": "string",
"description": "HTML body. Full <html> document or <body> fragment. Inline CSS only (email clients strip <style>)."
},
"body_text": {
"type": "string",
"description": "Plain text fallback. Optional but recommended for accessibility."
},
"save_template_as": {
"type": "string",
"description": "Save this email as a reusable template with this name. Stored in email_templates/<name>.html"
},
"use_template": {
"type": "string",
"description": "Name of a saved template to use. body_html is ignored; template_vars are substituted."
},
"template_vars": {
"type": "object",
"description": "Key-value pairs to substitute in template. {{key}} → value."
},
"list_templates": {
"type": "boolean",
"description": "If true, list available templates and return. All other params ignored."
}
}
})
}
async fn execute(
&self,
_tool_call_id: &str,
params: Value,
_signal: Option<oneshot::Receiver<()>>,
_ctx: &ToolContext,
) -> Result<AgentToolResult, String> {
let args: EmailArgs =
serde_json::from_value(params).map_err(|e| format!("Invalid arguments: {e}"))?;
if args.list_templates.unwrap_or(false) {
let templates = self
.api
.list_templates()
.map_err(|e| format!("Failed to list templates: {e}"))?;
return Ok(AgentToolResult::success(
serde_json::to_string_pretty(&json!({
"templates": templates,
}))
.unwrap_or_default(),
));
}
let subject = args.subject.as_deref().ok_or("subject is required")?;
if subject.len() > MAX_SUBJECT_LEN {
return Err(format!(
"Subject too long ({} chars, max {})",
subject.len(),
MAX_SUBJECT_LEN
));
}
let html = if let Some(name) = &args.use_template {
let template = self
.api
.load_template(name)
.map_err(|e| format!("Template error: {e}"))?;
render_template(&template, &args.template_vars.unwrap_or_default())
.map_err(|e| format!("Template render error: {e}"))?
} else {
args.body_html
.as_deref()
.ok_or("body_html or use_template is required")?
.to_string()
};
if html.len() > MAX_HTML_BYTES {
return Err(format!(
"HTML body too large ({} bytes, max {} bytes)",
html.len(),
MAX_HTML_BYTES
));
}
let rate_limit = self.api.rate_limit();
let sent_count = self
.api
.count_recent_sent(1)
.await
.map_err(|e| format!("Rate limit check failed: {e}"))?;
if sent_count >= rate_limit {
return Err(format!(
"Rate limit: {rate_limit} emails per hour. Try later."
));
}
let receipt = self
.api
.send(subject, &html, args.body_text.as_deref())
.await
.map_err(|e| format!("SMTP send failed: {e}"))?;
if let Some(name) = &args.save_template_as {
self.api
.save_template(name, &html)
.map_err(|e| format!("Failed to save template: {e}"))?;
}
let record = SentRecord {
id: uuid::Uuid::new_v4().to_string(),
sent_at: receipt.sent_at.to_rfc3339(),
subject: subject.to_string(),
to: self.api.default_to().to_string(),
template_used: args.use_template.clone().or(args.save_template_as.clone()),
message_id: receipt.message_id.clone(),
html_preview: html.chars().take(500).collect(),
html_full: html,
body_text: args.body_text,
cron_job: None,
};
if let Err(e) = self.api.save_sent_record(&record).await {
tracing::warn!(error = %e, "Failed to save email sent record");
}
self.api.notify_sent(
subject.to_string(),
receipt.message_id.clone(),
args.save_template_as.clone(),
);
Ok(AgentToolResult::success(
serde_json::to_string_pretty(&json!({
"status": "sent",
"message_id": receipt.message_id,
"template_saved": args.save_template_as.is_some(),
}))
.unwrap_or_default(),
))
}
}
fn render_template(template: &str, vars: &HashMap<String, String>) -> Result<String, String> {
let mut result = template.to_string();
for (key, value) in vars {
let placeholder = format!("{{{{{key}}}}}"); result = result.replace(&placeholder, value);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_template_basic() {
let template = "<h1>Hello {{name}}</h1><p>{{message}}</p>";
let mut vars = HashMap::new();
vars.insert("name".to_string(), "World".to_string());
vars.insert("message".to_string(), "Welcome!".to_string());
let result = render_template(template, &vars).unwrap();
assert_eq!(result, "<h1>Hello World</h1><p>Welcome!</p>");
}
#[test]
fn test_render_template_missing_vars_left_as_is() {
let template = "<h1>Hello {{name}}</h1><p>{{missing}}</p>";
let mut vars = HashMap::new();
vars.insert("name".to_string(), "World".to_string());
let result = render_template(template, &vars).unwrap();
assert_eq!(result, "<h1>Hello World</h1><p>{{missing}}</p>");
}
#[test]
fn test_render_template_empty_vars() {
let template = "<h1>Hello {{name}}</h1>";
let vars = HashMap::new();
let result = render_template(template, &vars).unwrap();
assert_eq!(result, "<h1>Hello {{name}}</h1>");
}
#[test]
fn test_render_template_html_in_values() {
let template = "<ul>{{items}}</ul>";
let mut vars = HashMap::new();
vars.insert("items".to_string(), "<li>A</li><li>B</li>".to_string());
let result = render_template(template, &vars).unwrap();
assert_eq!(result, "<ul><li>A</li><li>B</li></ul>");
}
}