Skip to main content

brainwires_tool_builtins/email/
mod.rs

1//! Email tools: send, search, read, and list email messages via IMAP/SMTP.
2//!
3//! Inbound email can be ingested two ways:
4//!
5//! - **IMAP polling** — the historical path, driven by [`ImapClient`].
6//! - **Gmail push** — the low-latency path, driven by [`gmail_push::GmailPushHandler`].
7//!   Gmail delivers Pub/Sub webhooks to the BrainClaw gateway which
8//!   authenticates Google's signed JWT and pulls the new messages via the
9//!   Gmail REST API.
10//!
11//! When both are configured for the same account, Gmail push wins — see
12//! [`EmailSource`] and the startup warning emitted by the daemon.
13
14pub 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/// Where inbound email for an account is pulled from.
32///
33/// This is an operator-facing configuration pivot: each account may be
34/// connected via classical IMAP polling *or* Gmail Pub/Sub push. When
35/// both are configured for the same address, BrainClaw prefers push and
36/// suppresses IMAP to avoid double delivery. See the daemon startup logs
37/// for the warning that documents the choice.
38#[derive(Debug, Clone)]
39pub enum EmailSource {
40    /// Classical IMAP polling — the historical path.
41    Imap(EmailConfig),
42    /// Gmail push via Google Pub/Sub — the low-latency path.
43    GmailPush(gmail_push::GmailPushConfig),
44}
45
46/// Email provider configuration variants.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum EmailProvider {
50    /// IMAP + SMTP provider (most common).
51    ImapSmtp {
52        /// IMAP server hostname.
53        imap_host: String,
54        /// IMAP server port (993 for TLS).
55        imap_port: u16,
56        /// SMTP server hostname.
57        smtp_host: String,
58        /// SMTP server port (587 for STARTTLS, 465 for TLS).
59        smtp_port: u16,
60        /// Login username.
61        username: String,
62        /// Login password.
63        password: String,
64        /// Whether to use TLS.
65        tls: bool,
66    },
67}
68
69/// Configuration for the email tool.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct EmailConfig {
72    /// Email provider settings.
73    pub provider: EmailProvider,
74    /// Default "from" address for sending.
75    pub from_address: String,
76}
77
78/// Email tool implementation providing send, search, read, and list operations.
79pub struct EmailTool;
80
81impl EmailTool {
82    /// Return tool definitions for email operations.
83    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    /// Execute an email tool by name.
210    #[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    // ── Handler implementations ─────────────────────────────────────────────
234
235    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                        &params.to,
271                        &params.cc,
272                        &params.bcc,
273                        &params.subject,
274                        &params.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                // Select the folder before reading
356                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    /// Extract email configuration from the tool context metadata.
397    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}