1pub mod gmail_push;
15pub mod imap_client;
16pub mod smtp_client;
17pub mod types;
18
19use std::collections::HashMap;
20
21use anyhow::Result;
22use serde::{Deserialize, Serialize};
23use serde_json::{Value, json};
24
25use brainwires_core::{Tool, ToolContext, ToolInputSchema, ToolResult};
26
27use self::imap_client::ImapClient;
28use self::smtp_client::SmtpClient;
29use self::types::EmailSearchQuery;
30
31#[derive(Debug, Clone)]
39pub enum EmailSource {
40 Imap(EmailConfig),
42 GmailPush(gmail_push::GmailPushConfig),
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum EmailProvider {
50 ImapSmtp {
52 imap_host: String,
54 imap_port: u16,
56 smtp_host: String,
58 smtp_port: u16,
60 username: String,
62 password: String,
64 tls: bool,
66 },
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct EmailConfig {
72 pub provider: EmailProvider,
74 pub from_address: String,
76}
77
78pub struct EmailTool;
80
81impl EmailTool {
82 pub fn get_tools() -> Vec<Tool> {
84 vec![
85 Self::email_send_tool(),
86 Self::email_search_tool(),
87 Self::email_read_tool(),
88 Self::email_list_tool(),
89 ]
90 }
91
92 fn email_send_tool() -> Tool {
93 let mut properties = HashMap::new();
94 properties.insert(
95 "to".to_string(),
96 json!({"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"}),
97 );
98 properties.insert(
99 "subject".to_string(),
100 json!({"type": "string", "description": "Email subject line"}),
101 );
102 properties.insert(
103 "body".to_string(),
104 json!({"type": "string", "description": "Plain-text email body"}),
105 );
106 properties.insert(
107 "cc".to_string(),
108 json!({"type": "array", "items": {"type": "string"}, "description": "CC recipients"}),
109 );
110 properties.insert(
111 "bcc".to_string(),
112 json!({"type": "array", "items": {"type": "string"}, "description": "BCC recipients"}),
113 );
114 Tool {
115 name: "email_send".to_string(),
116 description: "Send an email message via SMTP.".to_string(),
117 input_schema: ToolInputSchema::object(
118 properties,
119 vec!["to".to_string(), "subject".to_string(), "body".to_string()],
120 ),
121 requires_approval: true,
122 ..Default::default()
123 }
124 }
125
126 fn email_search_tool() -> Tool {
127 let mut properties = HashMap::new();
128 properties.insert(
129 "folder".to_string(),
130 json!({"type": "string", "description": "IMAP folder to search (default: INBOX)"}),
131 );
132 properties.insert(
133 "from".to_string(),
134 json!({"type": "string", "description": "Filter by sender address"}),
135 );
136 properties.insert(
137 "to".to_string(),
138 json!({"type": "string", "description": "Filter by recipient address"}),
139 );
140 properties.insert(
141 "subject".to_string(),
142 json!({"type": "string", "description": "Filter by subject text"}),
143 );
144 properties.insert(
145 "body".to_string(),
146 json!({"type": "string", "description": "Filter by body text"}),
147 );
148 properties.insert(
149 "since".to_string(),
150 json!({"type": "string", "description": "Messages on or after this date (IMAP date format)"}),
151 );
152 properties.insert(
153 "before".to_string(),
154 json!({"type": "string", "description": "Messages before this date (IMAP date format)"}),
155 );
156 Tool {
157 name: "email_search".to_string(),
158 description: "Search email messages in an IMAP folder using filter criteria."
159 .to_string(),
160 input_schema: ToolInputSchema::object(properties, vec![]),
161 requires_approval: false,
162 ..Default::default()
163 }
164 }
165
166 fn email_read_tool() -> Tool {
167 let mut properties = HashMap::new();
168 properties.insert(
169 "uid".to_string(),
170 json!({"type": "integer", "description": "IMAP message UID to read"}),
171 );
172 properties.insert(
173 "folder".to_string(),
174 json!({"type": "string", "description": "IMAP folder containing the message (default: INBOX)"}),
175 );
176 Tool {
177 name: "email_read".to_string(),
178 description: "Read a full email message by UID, including body and attachments."
179 .to_string(),
180 input_schema: ToolInputSchema::object(properties, vec!["uid".to_string()]),
181 requires_approval: false,
182 ..Default::default()
183 }
184 }
185
186 fn email_list_tool() -> Tool {
187 let mut properties = HashMap::new();
188 properties.insert(
189 "folder".to_string(),
190 json!({"type": "string", "description": "IMAP folder to list (default: INBOX)"}),
191 );
192 properties.insert(
193 "limit".to_string(),
194 json!({"type": "integer", "description": "Maximum number of messages to return (default: 20)"}),
195 );
196 properties.insert(
197 "offset".to_string(),
198 json!({"type": "integer", "description": "Offset for pagination (default: 0)"}),
199 );
200 Tool {
201 name: "email_list".to_string(),
202 description: "List email message summaries from an IMAP folder.".to_string(),
203 input_schema: ToolInputSchema::object(properties, vec![]),
204 requires_approval: false,
205 ..Default::default()
206 }
207 }
208
209 #[tracing::instrument(name = "tool.execute", skip(input, context), fields(tool_name))]
211 pub async fn execute(
212 tool_use_id: &str,
213 tool_name: &str,
214 input: &Value,
215 context: &ToolContext,
216 ) -> ToolResult {
217 let result = match tool_name {
218 "email_send" => Self::handle_send(input, context).await,
219 "email_search" => Self::handle_search(input, context).await,
220 "email_read" => Self::handle_read(input, context).await,
221 "email_list" => Self::handle_list(input, context).await,
222 _ => Err(anyhow::anyhow!("Unknown email tool: {}", tool_name)),
223 };
224 match result {
225 Ok(output) => ToolResult::success(tool_use_id.to_string(), output),
226 Err(e) => ToolResult::error(
227 tool_use_id.to_string(),
228 format!("Email operation failed: {}", e),
229 ),
230 }
231 }
232
233 async fn handle_send(input: &Value, context: &ToolContext) -> Result<String> {
236 let config = Self::get_config(context)?;
237
238 match &config.provider {
239 EmailProvider::ImapSmtp {
240 smtp_host,
241 smtp_port,
242 username,
243 password,
244 tls,
245 ..
246 } => {
247 let client = SmtpClient::new(
248 smtp_host,
249 *smtp_port,
250 username,
251 password,
252 *tls,
253 &config.from_address,
254 )?;
255
256 #[derive(Deserialize)]
257 struct SendInput {
258 to: Vec<String>,
259 subject: String,
260 body: String,
261 #[serde(default)]
262 cc: Vec<String>,
263 #[serde(default)]
264 bcc: Vec<String>,
265 }
266
267 let params: SendInput = serde_json::from_value(input.clone())?;
268 client
269 .send_email(
270 ¶ms.to,
271 ¶ms.cc,
272 ¶ms.bcc,
273 ¶ms.subject,
274 ¶ms.body,
275 &[],
276 )
277 .await
278 }
279 }
280 }
281
282 async fn handle_search(input: &Value, context: &ToolContext) -> Result<String> {
283 let config = Self::get_config(context)?;
284
285 match &config.provider {
286 EmailProvider::ImapSmtp {
287 imap_host,
288 imap_port,
289 username,
290 password,
291 tls,
292 ..
293 } => {
294 let mut client =
295 ImapClient::connect(imap_host, *imap_port, username, password, *tls).await?;
296
297 let folder = input
298 .get("folder")
299 .and_then(|v| v.as_str())
300 .unwrap_or("INBOX");
301
302 let query = EmailSearchQuery {
303 from: input.get("from").and_then(|v| v.as_str()).map(String::from),
304 to: input.get("to").and_then(|v| v.as_str()).map(String::from),
305 subject: input
306 .get("subject")
307 .and_then(|v| v.as_str())
308 .map(String::from),
309 body: input.get("body").and_then(|v| v.as_str()).map(String::from),
310 since: input
311 .get("since")
312 .and_then(|v| v.as_str())
313 .map(String::from),
314 before: input
315 .get("before")
316 .and_then(|v| v.as_str())
317 .map(String::from),
318 flags: vec![],
319 };
320
321 let uids = client.search_messages(&query, folder).await?;
322 let _ = client.logout().await;
323
324 Ok(serde_json::to_string_pretty(&uids)?)
325 }
326 }
327 }
328
329 async fn handle_read(input: &Value, context: &ToolContext) -> Result<String> {
330 let config = Self::get_config(context)?;
331
332 match &config.provider {
333 EmailProvider::ImapSmtp {
334 imap_host,
335 imap_port,
336 username,
337 password,
338 tls,
339 ..
340 } => {
341 let mut client =
342 ImapClient::connect(imap_host, *imap_port, username, password, *tls).await?;
343
344 let uid = input
345 .get("uid")
346 .and_then(|v| v.as_u64())
347 .ok_or_else(|| anyhow::anyhow!("'uid' is required"))?
348 as u32;
349
350 let folder = input
351 .get("folder")
352 .and_then(|v| v.as_str())
353 .unwrap_or("INBOX");
354
355 client.list_messages(folder, 0, 0).await.ok();
357
358 let msg = client.read_message(uid).await?;
359 let _ = client.logout().await;
360
361 Ok(serde_json::to_string_pretty(&msg)?)
362 }
363 }
364 }
365
366 async fn handle_list(input: &Value, context: &ToolContext) -> Result<String> {
367 let config = Self::get_config(context)?;
368
369 match &config.provider {
370 EmailProvider::ImapSmtp {
371 imap_host,
372 imap_port,
373 username,
374 password,
375 tls,
376 ..
377 } => {
378 let mut client =
379 ImapClient::connect(imap_host, *imap_port, username, password, *tls).await?;
380
381 let folder = input
382 .get("folder")
383 .and_then(|v| v.as_str())
384 .unwrap_or("INBOX");
385 let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as u32;
386 let offset = input.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
387
388 let messages = client.list_messages(folder, limit, offset).await?;
389 let _ = client.logout().await;
390
391 Ok(serde_json::to_string_pretty(&messages)?)
392 }
393 }
394 }
395
396 fn get_config(context: &ToolContext) -> Result<EmailConfig> {
398 let config_json = context.metadata.get("email_config").ok_or_else(|| {
399 anyhow::anyhow!(
400 "Email configuration not found. Set 'email_config' in ToolContext.metadata."
401 )
402 })?;
403 let config: EmailConfig = serde_json::from_str(config_json)?;
404 Ok(config)
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn test_get_tools() {
414 let tools = EmailTool::get_tools();
415 assert_eq!(tools.len(), 4);
416
417 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
418 assert!(names.contains(&"email_send"));
419 assert!(names.contains(&"email_search"));
420 assert!(names.contains(&"email_read"));
421 assert!(names.contains(&"email_list"));
422 }
423
424 #[test]
425 fn test_email_send_requires_approval() {
426 let tools = EmailTool::get_tools();
427 let send = tools.iter().find(|t| t.name == "email_send").unwrap();
428 assert!(send.requires_approval);
429 }
430
431 #[test]
432 fn test_email_send_required_fields() {
433 let tools = EmailTool::get_tools();
434 let send = tools.iter().find(|t| t.name == "email_send").unwrap();
435 let required = send.input_schema.required.as_ref().unwrap();
436 assert!(required.contains(&"to".to_string()));
437 assert!(required.contains(&"subject".to_string()));
438 assert!(required.contains(&"body".to_string()));
439 }
440
441 #[test]
442 fn test_email_read_required_fields() {
443 let tools = EmailTool::get_tools();
444 let read = tools.iter().find(|t| t.name == "email_read").unwrap();
445 let required = read.input_schema.required.as_ref().unwrap();
446 assert!(required.contains(&"uid".to_string()));
447 }
448
449 #[test]
450 fn test_email_config_serde_roundtrip() {
451 let config = EmailConfig {
452 provider: EmailProvider::ImapSmtp {
453 imap_host: "imap.example.com".to_string(),
454 imap_port: 993,
455 smtp_host: "smtp.example.com".to_string(),
456 smtp_port: 587,
457 username: "user@example.com".to_string(),
458 password: "secret".to_string(),
459 tls: true,
460 },
461 from_address: "user@example.com".to_string(),
462 };
463 let json = serde_json::to_string(&config).unwrap();
464 let deserialized: EmailConfig = serde_json::from_str(&json).unwrap();
465 assert_eq!(deserialized.from_address, "user@example.com");
466 }
467
468 #[tokio::test]
469 async fn test_execute_unknown_tool() {
470 let context = ToolContext {
471 working_directory: ".".to_string(),
472 ..Default::default()
473 };
474 let input = json!({});
475 let result = EmailTool::execute("1", "unknown_email_tool", &input, &context).await;
476 assert!(result.is_error);
477 }
478}