feat: Add email integration plugin with IMAP/Exchange polling and SMTP reply

linuztx committed Mar 13, 2026 at 08:33 UTC 34f2354cb16021ca56741258773cb94f3e270b75
23 files changed +1488
helpers/job_loop.py
+4
@@ -40,6 +40,10 @@ async def scheduler_tick():
40 # Run the scheduler tick
41 await scheduler.tick()
42
43 + # Run job_loop extensions (e.g. email polling)
44 + from helpers.extension import call_extensions_async
45 + await call_extensions_async("job_loop")
46 +
47
48 def pause_loop():
49 global keep_running, pause_time
plugins/_email_integration/default_config.yaml new
+18
@@ -0,0 +1,18 @@
1 +handlers: []
2 +# Example handler:
3 +# - name: support
4 +# enabled: true
5 +# account_type: imap
6 +# imap_server: imap.gmail.com
7 +# imap_port: 993
8 +# smtp_server: smtp.gmail.com
9 +# smtp_port: 587
10 +# username: ""
11 +# password: ""
12 +# poll_mode: seconds
13 +# poll_interval_seconds: 15
14 +# poll_interval_cron: "*/2 * * * *"
15 +# sender_whitelist: []
16 +# project: ""
17 +# dispatcher_instructions: ""
18 +# agent_instructions: ""
plugins/_email_integration/extensions/python/job_loop/_10_email_poll.py new
+76
@@ -0,0 +1,76 @@
1 +import asyncio
2 +
3 +from crontab import CronTab
4 +
5 +from helpers.extension import Extension
6 +from helpers.errors import format_error
7 +from helpers.print_style import PrintStyle
8 +from helpers import plugins
9 +
10 +
11 +PLUGIN_NAME = "_email_integration"
12 +DEFAULT_INTERVAL = 15
13 +MIN_INTERVAL = 5
14 +
15 +_poll_tasks: dict[str, asyncio.Task] = {}
16 +
17 +
18 +def _get_sleep_seconds(handler_cfg: dict) -> float:
19 + mode = handler_cfg.get("poll_mode", "seconds")
20 + if mode == "cron":
21 + expr = handler_cfg.get("poll_interval_cron", "*/2 * * * *")
22 + try:
23 + return max(CronTab(expr).next(default_utc=True), MIN_INTERVAL)
24 + except Exception:
25 + return DEFAULT_INTERVAL
26 + return max(handler_cfg.get("poll_interval_seconds", DEFAULT_INTERVAL), MIN_INTERVAL)
27 +
28 +
29 +async def _handler_poll_loop(handler_name: str):
30 + from plugins._email_integration.helpers.handler import (
31 + _poll_single_handler,
32 + _load_state,
33 + _save_state,
34 + _state_lock,
35 + )
36 +
37 + while True:
38 + config = plugins.get_plugin_config(PLUGIN_NAME) or {}
39 + handlers = config.get("handlers", [])
40 + handler_cfg = next(
41 + (h for h in handlers if h.get("name") == handler_name and h.get("enabled")),
42 + None,
43 + )
44 + if handler_cfg is None:
45 + break
46 +
47 + try:
48 + async with _state_lock:
49 + state = _load_state()
50 + await _poll_single_handler(handler_cfg, state)
51 + _save_state(state)
52 + except Exception as e:
53 + PrintStyle.error(f"Email poll error ({handler_name}): {format_error(e)}")
54 +
55 + sleep_sec = _get_sleep_seconds(handler_cfg)
56 + await asyncio.sleep(sleep_sec)
57 +
58 +
59 +class EmailAutoPoll(Extension):
60 +
61 + async def execute(self, **kwargs):
62 + config = plugins.get_plugin_config(PLUGIN_NAME) or {}
63 + handlers = config.get("handlers", [])
64 + enabled_names = {
65 + h["name"] for h in handlers if h.get("enabled") and h.get("name")
66 + }
67 +
68 + for name in list(_poll_tasks):
69 + if name not in enabled_names or _poll_tasks[name].done():
70 + task = _poll_tasks.pop(name, None)
71 + if task and not task.done():
72 + task.cancel()
73 +
74 + for name in enabled_names:
75 + if name not in _poll_tasks or _poll_tasks[name].done():
76 + _poll_tasks[name] = asyncio.create_task(_handler_poll_loop(name))
plugins/_email_integration/extensions/python/process_chain_end/_55_email_reply.py new
+37
@@ -0,0 +1,37 @@
1 +import asyncio
2 +from helpers.extension import Extension
3 +from agent import AgentContext, LoopData
4 +from plugins._email_integration.helpers.dispatcher import CTX_EMAIL_HANDLER
5 +
6 +
7 +class EmailAutoReply(Extension):
8 +
9 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10 + if not self.agent or self.agent.number != 0:
11 + return
12 +
13 + context = self.agent.context
14 + if not context.data.get(CTX_EMAIL_HANDLER):
15 + return
16 +
17 + # Extract response from last log item
18 + response_text = _extract_last_response(context)
19 + if not response_text:
20 + return
21 +
22 + asyncio.create_task(self._send_reply(context, response_text))
23 +
24 + async def _send_reply(self, context: AgentContext, response_text: str):
25 + from plugins._email_integration.helpers.handler import send_email_reply
26 + await send_email_reply(context, response_text)
27 +
28 +
29 +def _extract_last_response(context: AgentContext) -> str:
30 + with context.log._lock:
31 + logs = list(context.log.logs)
32 + if not logs:
33 + return ""
34 + for item in reversed(logs):
35 + if item.type == "response":
36 + return item.content or ""
37 + return ""
plugins/_email_integration/extensions/python/system_prompt/_20_email_context.py new
+20
@@ -0,0 +1,20 @@
1 +from helpers.extension import Extension
2 +from agent import LoopData
3 +from plugins._email_integration.helpers.dispatcher import CTX_EMAIL_HANDLER
4 +
5 +
6 +class EmailContextPrompt(Extension):
7 +
8 + async def execute(
9 + self,
10 + system_prompt: list[str] = [],
11 + loop_data: LoopData = LoopData(),
12 + **kwargs,
13 + ):
14 + if not self.agent:
15 + return
16 +
17 + # Only inject email conversation context if this chat was started by email
18 + if self.agent.context.data.get(CTX_EMAIL_HANDLER):
19 + prompt = self.agent.read_prompt("agent.system.tool.email_reply.md")
20 + system_prompt.append(prompt)
plugins/_email_integration/helpers/__init__.py
plugins/_email_integration/helpers/dispatcher.py new
+85
@@ -0,0 +1,85 @@
1 +"""Email dispatch logic — routes inbound emails to chats. No agent deps in pure helpers."""
2 +
3 +import re
4 +from dataclasses import dataclass
5 +from typing import Literal
6 +
7 +# Pattern for extracting chat thread ID from email subject
8 +# Matches: [a0-xxxxxxxx] at end of subject
9 +_THREAD_ID_RE = re.compile(r"\[a0-([a-zA-Z0-9]+)\]")
10 +
11 +# Context data keys
12 +CTX_EMAIL_HANDLER = "_email_handler"
13 +CTX_EMAIL_SENDER = "_email_sender"
14 +CTX_EMAIL_THREAD_ID = "_email_thread_id"
15 +CTX_EMAIL_SUBJECT = "_email_subject"
16 +CTX_EMAIL_MESSAGE_ID = "_email_message_id"
17 +CTX_EMAIL_REFERENCES = "_email_references"
18 +
19 +DispatchAction = Literal["new_chat", "continue_chat", "intervene_soft", "intervene_hard"]
20 +
21 +
22 +@dataclass
23 +class DispatchDecision:
24 + action: DispatchAction
25 + context_id: str = ""
26 + reason: str = ""
27 +
28 +
29 +def extract_thread_id(subject: str) -> str:
30 + match = _THREAD_ID_RE.search(subject)
31 + return match.group(1) if match else ""
32 +
33 +
34 +def build_reply_subject(original_subject: str, thread_id: str) -> str:
35 + clean = _THREAD_ID_RE.sub("", original_subject).strip()
36 + if not clean.lower().startswith("re:"):
37 + clean = f"Re: {clean}"
38 + return f"{clean} [a0-{thread_id}]"
39 +
40 +
41 +def build_chat_summary(context_id: str, data: dict) -> dict:
42 + return {
43 + "context_id": context_id,
44 + "thread_id": data.get(CTX_EMAIL_THREAD_ID, ""),
45 + "sender": data.get(CTX_EMAIL_SENDER, ""),
46 + "subject": data.get(CTX_EMAIL_SUBJECT, ""),
47 + "handler": data.get(CTX_EMAIL_HANDLER, ""),
48 + }
49 +
50 +
51 +# ------------------------------------------------------------------
52 +# Dispatcher prompt builders
53 +# ------------------------------------------------------------------
54 +
55 +def format_chats_list(existing_chats: list[dict]) -> str:
56 + if not existing_chats:
57 + return "No existing chats for this handler."
58 + lines = []
59 + for c in existing_chats[:20]:
60 + lines.append(
61 + f"- context_id={c['context_id']} thread_id={c.get('thread_id', '')} "
62 + f"sender={c.get('sender', '')} subject={c.get('subject', '')}"
63 + )
64 + return "\n".join(lines)
65 +
66 +
67 +def parse_dispatcher_response(response: str) -> DispatchDecision:
68 + line = response.strip().split("\n")[0].strip()
69 + parts = line.split(None, 2)
70 +
71 + if len(parts) < 2:
72 + return DispatchDecision(action="new_chat", reason="unparseable response")
73 +
74 + action_raw = parts[0].upper()
75 + ctx_id = parts[1] if parts[1] != "_" else ""
76 + reason = parts[2] if len(parts) > 2 else ""
77 +
78 + action_map: dict[str, DispatchAction] = {
79 + "NEW_CHAT": "new_chat",
80 + "CONTINUE": "continue_chat",
81 + "INTERVENE_SOFT": "intervene_soft",
82 + "INTERVENE_HARD": "intervene_hard",
83 + }
84 + action = action_map.get(action_raw, "new_chat")
85 + return DispatchDecision(action=action, context_id=ctx_id, reason=reason)
plugins/_email_integration/helpers/handler.py new
+393
@@ -0,0 +1,393 @@
1 +"""Email handler — orchestrates poll, dispatch, and reply. Requires agent context."""
2 +
3 +import asyncio
4 +import json
5 +import os
6 +
7 +from agent import Agent, AgentContext, UserMessage
8 +from helpers import guids, plugins, files
9 +from helpers import message_queue as mq
10 +from helpers.persist_chat import save_tmp_chat
11 +from helpers.print_style import PrintStyle
12 +from helpers.errors import format_error
13 +from initialize import initialize_agent
14 +
15 +from plugins._email_integration.helpers import dispatcher as disp
16 +from plugins._email_integration.helpers.imap_client import (
17 + InboundMessage,
18 + connect_imap,
19 + disconnect_imap,
20 + fetch_new,
21 + get_highest_uid,
22 + connect_exchange,
23 + fetch_unread_exchange,
24 +)
25 +from plugins._email_integration.helpers.smtp_client import SmtpConfig, send_reply
26 +
27 +
28 +PLUGIN_NAME = "_email_integration"
29 +DOWNLOAD_FOLDER = "usr/email/attachments"
30 +STATE_FILE = "usr/email/state.json"
31 +
32 +_PROMPTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "prompts")
33 +
34 +
35 +def _read_fw(filename: str, **kwargs: str) -> str:
36 + return files.read_prompt_file(filename, _directories=[_PROMPTS_DIR], **kwargs)
37 +
38 +
39 +# ------------------------------------------------------------------
40 +# UID state persistence
41 +# ------------------------------------------------------------------
42 +
43 +_state_lock = asyncio.Lock()
44 +
45 +def _load_state() -> dict:
46 + path = files.get_abs_path(STATE_FILE)
47 + if os.path.isfile(path):
48 + try:
49 + return json.loads(files.read_file(path))
50 + except Exception:
51 + return {}
52 + return {}
53 +
54 +
55 +def _save_state(state: dict):
56 + path = files.get_abs_path(STATE_FILE)
57 + files.make_dirs(path)
58 + files.write_file(path, json.dumps(state))
59 +
60 +
61 +# ------------------------------------------------------------------
62 +# Main auto-poll entry point (called from job_loop extension)
63 +# ------------------------------------------------------------------
64 +
65 +async def poll_all_handlers():
66 + config = plugins.get_plugin_config(PLUGIN_NAME) or {}
67 + handlers = config.get("handlers", [])
68 + enabled = [h for h in handlers if h.get("enabled", False)]
69 +
70 + if not enabled:
71 + return
72 +
73 + state = _load_state()
74 +
75 + for handler_cfg in enabled:
76 + try:
77 + await _poll_single_handler(handler_cfg, state)
78 + except Exception as e:
79 + name = handler_cfg.get("name", "?")
80 + PrintStyle.error(f"Email poll error ({name}): {format_error(e)}")
81 +
82 + _save_state(state)
83 +
84 +
85 +async def _poll_single_handler(handler_cfg: dict, state: dict):
86 + name = handler_cfg.get("name", "default")
87 + account_type = handler_cfg.get("account_type", "imap")
88 + whitelist = handler_cfg.get("sender_whitelist") or []
89 + last_uid = state.get(name, {}).get("last_uid", 0)
90 +
91 + if account_type == "exchange":
92 + messages = await _fetch_exchange(handler_cfg, whitelist)
93 + if messages:
94 + await _dispatch_all(handler_cfg, messages)
95 + return
96 +
97 + client = await connect_imap(
98 + server=handler_cfg.get("imap_server", ""),
99 + port=int(handler_cfg.get("imap_port", 993)),
100 + username=handler_cfg.get("username", ""),
101 + password=handler_cfg.get("password", ""),
102 + )
103 + try:
104 + # First run: record current highest UID, don't process old emails
105 + if last_uid == 0:
106 + highest = await get_highest_uid(client)
107 + state[name] = {"last_uid": highest}
108 + PrintStyle.info(f"Email ({name}): initialized, tracking from UID {highest}")
109 + return
110 +
111 + messages, new_uid = await fetch_new(
112 + client, DOWNLOAD_FOLDER, last_uid, whitelist or None,
113 + )
114 +
115 + if new_uid > last_uid:
116 + state[name] = {"last_uid": new_uid}
117 +
118 + if messages:
119 + PrintStyle.info(f"Email ({name}): {len(messages)} new messages")
120 + await _dispatch_all(handler_cfg, messages)
121 +
122 + finally:
123 + await disconnect_imap(client)
124 +
125 +
126 +async def _fetch_exchange(cfg: dict, whitelist: list[str]) -> list[InboundMessage]:
127 + account = await connect_exchange(
128 + server=cfg.get("imap_server", ""),
129 + username=cfg.get("username", ""),
130 + password=cfg.get("password", ""),
131 + )
132 + return await fetch_unread_exchange(account, DOWNLOAD_FOLDER, whitelist or None)
133 +
134 +
135 +async def _dispatch_all(handler_cfg: dict, messages: list[InboundMessage]):
136 + # Need an agent for dispatcher AI calls — use first available or create temp
137 + ctx = AgentContext.first()
138 + if not ctx:
139 + agent_config = initialize_agent()
140 + ctx = AgentContext(agent_config, name="Email Dispatcher")
141 + agent = ctx.agent0
142 +
143 + for msg in messages:
144 + try:
145 + await _dispatch_message(agent, handler_cfg, msg)
146 + except Exception as e:
147 + PrintStyle.error(f"Email dispatch error: {format_error(e)}")
148 +
149 +
150 +# ------------------------------------------------------------------
151 +# Dispatch a single inbound message
152 +# ------------------------------------------------------------------
153 +
154 +async def _dispatch_message(agent: Agent, handler_cfg: dict, msg: InboundMessage):
155 + handler_name = handler_cfg.get("name", "default")
156 + thread_id = disp.extract_thread_id(msg.subject)
157 +
158 + existing = _find_handler_chats(handler_name, msg.sender)
159 +
160 + # Fast path: thread ID in subject matches a known chat
161 + if thread_id:
162 + for chat in existing:
163 + if chat["thread_id"] == thread_id:
164 + await _route_to_chat(
165 + agent, handler_cfg, msg, chat["context_id"], "continue_chat",
166 + )
167 + return
168 +
169 + # Dispatcher AI decides
170 + decision = await _call_dispatcher(agent, handler_cfg, msg, existing)
171 +
172 + if decision.action == "new_chat":
173 + await _start_new_chat(agent, handler_cfg, msg)
174 + elif decision.action in ("continue_chat", "intervene_soft", "intervene_hard"):
175 + ctx = AgentContext.get(decision.context_id)
176 + if not ctx:
177 + PrintStyle.warning(
178 + f"Dispatcher referenced unknown context {decision.context_id}, starting new chat"
179 + )
180 + await _start_new_chat(agent, handler_cfg, msg)
181 + else:
182 + await _route_to_chat(
183 + agent, handler_cfg, msg, decision.context_id, decision.action,
184 + )
185 + else:
186 + await _start_new_chat(agent, handler_cfg, msg)
187 +
188 +
189 +async def _call_dispatcher(
190 + agent: Agent,
191 + handler_cfg: dict,
192 + msg: InboundMessage,
193 + existing_chats: list[dict],
194 +) -> disp.DispatchDecision:
195 + body_preview = msg.body[:2000] if len(msg.body) > 2000 else msg.body
196 + chats_text = disp.format_chats_list(existing_chats)
197 +
198 + prompt = agent.read_prompt(
199 + "fw.email.dispatcher_prompt.md",
200 + sender=msg.sender,
201 + subject=msg.subject,
202 + body=body_preview,
203 + chats=chats_text,
204 + )
205 +
206 + extra = handler_cfg.get("dispatcher_instructions", "")
207 + if extra:
208 + prompt += agent.read_prompt(
209 + "fw.email.dispatcher_extra.md", instructions=extra,
210 + )
211 +
212 + system = agent.read_prompt("fw.email.dispatcher_system.md")
213 +
214 + try:
215 + response = await agent.call_utility_model(system=system, message=prompt)
216 + return disp.parse_dispatcher_response(str(response))
217 + except Exception as e:
218 + PrintStyle.error(f"Dispatcher error: {format_error(e)}")
219 + return disp.DispatchDecision(action="new_chat", reason="dispatcher error")
220 +
221 +
222 +# ------------------------------------------------------------------
223 +# Chat creation and routing
224 +# ------------------------------------------------------------------
225 +
226 +async def _start_new_chat(agent: Agent, handler_cfg: dict, msg: InboundMessage):
227 + from helpers import projects
228 +
229 + handler_name = handler_cfg.get("name", "default")
230 + thread_id = guids.generate_id()
231 +
232 + config = initialize_agent()
233 + context = AgentContext(config, name=f"Email: {msg.subject[:50]}")
234 +
235 + context.data[disp.CTX_EMAIL_HANDLER] = handler_name
236 + context.data[disp.CTX_EMAIL_SENDER] = msg.sender
237 + context.data[disp.CTX_EMAIL_THREAD_ID] = thread_id
238 + context.data[disp.CTX_EMAIL_SUBJECT] = msg.subject
239 + context.data[disp.CTX_EMAIL_MESSAGE_ID] = msg.message_id
240 + context.data[disp.CTX_EMAIL_REFERENCES] = msg.references
241 +
242 + project = handler_cfg.get("project", "")
243 + if project:
244 + projects.activate_project(context.id, project)
245 +
246 + save_tmp_chat(context)
247 +
248 + user_msg = _build_user_message(agent, msg, handler_cfg)
249 + system_ctx = agent.read_prompt("fw.email.system_context.md")
250 +
251 + mq.log_user_message(context, user_msg, msg.attachments or [], source=" (email)")
252 + context.communicate(UserMessage(
253 + message=user_msg,
254 + system_message=[system_ctx],
255 + attachments=msg.attachments,
256 + ))
257 +
258 + PrintStyle.success(f"Email: new chat {context.id} for '{msg.subject}' from {msg.sender}")
259 +
260 +
261 +async def _route_to_chat(
262 + agent: Agent,
263 + handler_cfg: dict,
264 + msg: InboundMessage,
265 + context_id: str,
266 + action: disp.DispatchAction,
267 +):
268 + context = AgentContext.get(context_id)
269 + if not context:
270 + return
271 +
272 + context.data[disp.CTX_EMAIL_MESSAGE_ID] = msg.message_id
273 + if msg.references:
274 + context.data[disp.CTX_EMAIL_REFERENCES] = msg.references
275 +
276 + if action == "intervene_soft":
277 + soft_msg = agent.read_prompt(
278 + "fw.email.intervene_soft.md",
279 + sender=msg.sender,
280 + subject=msg.subject,
281 + body=msg.body,
282 + )
283 + mq.add(context, soft_msg, msg.attachments)
284 + PrintStyle.info(f"Email: queued soft intervention for chat {context_id}")
285 +
286 + elif action == "intervene_hard":
287 + user_msg = _build_user_message(agent, msg, handler_cfg)
288 + system_ctx = agent.read_prompt("fw.email.system_context.md")
289 + mq.log_user_message(context, user_msg, msg.attachments or [], source=" (email)")
290 + context.communicate(UserMessage(
291 + message=user_msg,
292 + system_message=[system_ctx],
293 + attachments=msg.attachments,
294 + ))
295 + PrintStyle.info(f"Email: hard intervention on chat {context_id}")
296 +
297 + else:
298 + user_msg = _build_user_message(agent, msg, handler_cfg)
299 + mq.log_user_message(context, user_msg, msg.attachments or [], source=" (email)")
300 + context.communicate(UserMessage(
301 + message=user_msg,
302 + attachments=msg.attachments,
303 + ))
304 + PrintStyle.info(f"Email: continuing chat {context_id}")
305 +
306 + save_tmp_chat(context)
307 +
308 +
309 +# ------------------------------------------------------------------
310 +# Chat discovery
311 +# ------------------------------------------------------------------
312 +
313 +def _find_handler_chats(handler_name: str, sender: str) -> list[dict]:
314 + results = []
315 + for ctx_id, ctx in AgentContext._contexts.items():
316 + if not isinstance(ctx, AgentContext):
317 + continue
318 + data = ctx.data
319 + if data.get(disp.CTX_EMAIL_HANDLER) != handler_name:
320 + continue
321 + if data.get(disp.CTX_EMAIL_SENDER, "").lower() != sender.lower():
322 + continue
323 + results.append(disp.build_chat_summary(ctx_id, data))
324 +
325 + results.sort(key=lambda c: c["context_id"], reverse=True)
326 + return results[:20]
327 +
328 +
329 +# ------------------------------------------------------------------
330 +# Message builders
331 +# ------------------------------------------------------------------
332 +
333 +def _build_user_message(agent: Agent, msg: InboundMessage, handler_cfg: dict) -> str:
334 + text = agent.read_prompt(
335 + "fw.email.user_message.md",
336 + sender=msg.sender,
337 + subject=msg.subject,
338 + body=msg.body,
339 + )
340 + instructions = handler_cfg.get("agent_instructions", "")
341 + if instructions:
342 + text += agent.read_prompt(
343 + "fw.email.user_message_instructions.md", instructions=instructions,
344 + )
345 + return text
346 +
347 +
348 +# ------------------------------------------------------------------
349 +# Reply sending (called from process_chain_end extension)
350 +# ------------------------------------------------------------------
351 +
352 +async def send_email_reply(context: AgentContext, response_text: str):
353 + handler_name = context.data.get(disp.CTX_EMAIL_HANDLER)
354 + if not handler_name:
355 + return
356 +
357 + cfg = _get_handler_config(handler_name)
358 + if not cfg:
359 + PrintStyle.error(f"Email: handler config not found for '{handler_name}'")
360 + return
361 +
362 + sender = context.data.get(disp.CTX_EMAIL_SENDER, "")
363 + original_subject = context.data.get(disp.CTX_EMAIL_SUBJECT, "")
364 + thread_id = context.data.get(disp.CTX_EMAIL_THREAD_ID, "")
365 + original_msg_id = context.data.get(disp.CTX_EMAIL_MESSAGE_ID, "")
366 + references = context.data.get(disp.CTX_EMAIL_REFERENCES, "")
367 +
368 + subject = disp.build_reply_subject(original_subject, thread_id)
369 +
370 + smtp_cfg = SmtpConfig(
371 + server=cfg.get("smtp_server", cfg.get("imap_server", "")),
372 + port=int(cfg.get("smtp_port", 587)),
373 + username=cfg.get("username", ""),
374 + password=cfg.get("password", ""),
375 + )
376 +
377 + await send_reply(
378 + config=smtp_cfg,
379 + to=sender,
380 + subject=subject,
381 + body=response_text,
382 + in_reply_to=original_msg_id,
383 + references=references,
384 + )
385 +
386 +
387 +def _get_handler_config(handler_name: str) -> dict | None:
388 + config = plugins.get_plugin_config(PLUGIN_NAME) or {}
389 + handlers = config.get("handlers", [])
390 + for h in handlers:
391 + if h.get("name") == handler_name:
392 + return h
393 + return None
plugins/_email_integration/helpers/imap_client.py new
+370
@@ -0,0 +1,370 @@
1 +"""IMAP/Exchange email reader. No agent/tool dependencies."""
2 +
3 +import asyncio
4 +import email
5 +import os
6 +import re
7 +import uuid
8 +from dataclasses import dataclass, field
9 +from email.header import decode_header
10 +from email.message import Message as EmailMessage
11 +from fnmatch import fnmatch
12 +
13 +import html2text
14 +from bs4 import BeautifulSoup
15 +from imapclient import IMAPClient
16 +
17 +from helpers import files
18 +from helpers.errors import format_error
19 +from helpers.print_style import PrintStyle
20 +
21 +
22 +# ------------------------------------------------------------------
23 +# Data models
24 +# ------------------------------------------------------------------
25 +
26 +@dataclass
27 +class InboundMessage:
28 + sender: str
29 + subject: str
30 + body: str
31 + attachments: list[str] = field(default_factory=list)
32 + message_id: str = ""
33 + in_reply_to: str = ""
34 + references: str = ""
35 +
36 +
37 +# ------------------------------------------------------------------
38 +# IMAP connection
39 +# ------------------------------------------------------------------
40 +
41 +async def connect_imap(
42 + server: str,
43 + port: int = 993,
44 + username: str = "",
45 + password: str = "",
46 + ssl: bool = True,
47 + timeout: int = 30,
48 +) -> IMAPClient:
49 + loop = asyncio.get_event_loop()
50 +
51 + def _sync():
52 + client = IMAPClient(server, port=port, ssl=ssl, timeout=timeout)
53 + client._imap._maxline = 100000
54 + client.login(username, password)
55 + return client
56 +
57 + return await loop.run_in_executor(None, _sync)
58 +
59 +
60 +async def disconnect_imap(client: IMAPClient) -> None:
61 + try:
62 + loop = asyncio.get_event_loop()
63 + await loop.run_in_executor(None, client.logout)
64 + except Exception as e:
65 + PrintStyle.error(f"IMAP disconnect error: {format_error(e)}")
66 +
67 +
68 +# ------------------------------------------------------------------
69 +# Fetch messages
70 +# ------------------------------------------------------------------
71 +
72 +async def fetch_new(
73 + client: IMAPClient,
74 + download_folder: str,
75 + last_uid: int = 0,
76 + sender_whitelist: list[str] | None = None,
77 + max_messages: int = 10,
78 +) -> tuple[list[InboundMessage], int]:
79 + """Fetch emails newer than last_uid. Returns (messages, new_last_uid)."""
80 + loop = asyncio.get_event_loop()
81 +
82 + def _search():
83 + client.select_folder("INBOX")
84 + # Use Gmail category filter if supported, otherwise plain UNSEEN
85 + try:
86 + return client.gmail_search("category:primary is:unread")
87 + except Exception:
88 + return client.search(["UNSEEN"])
89 +
90 + msg_ids = await loop.run_in_executor(None, _search)
91 + if not msg_ids:
92 + return [], last_uid
93 +
94 + # Filter out already-seen UIDs
95 + if last_uid > 0:
96 + msg_ids = [uid for uid in msg_ids if uid > last_uid]
97 +
98 + if not msg_ids:
99 + return [], last_uid
100 +
101 + new_last_uid = max(msg_ids)
102 +
103 + # Cap to most recent
104 + if len(msg_ids) > max_messages:
105 + PrintStyle.standard(
106 + f"Email: {len(msg_ids)} new, processing latest {max_messages}"
107 + )
108 + msg_ids = msg_ids[-max_messages:]
109 + else:
110 + PrintStyle.standard(f"Email: found {len(msg_ids)} new messages")
111 +
112 + results: list[InboundMessage] = []
113 +
114 + for msg_id in msg_ids:
115 + try:
116 + msg = await _fetch_single(client, msg_id, download_folder, sender_whitelist)
117 + if msg:
118 + results.append(msg)
119 + except Exception as e:
120 + PrintStyle.error(f"Email: error processing message {msg_id}: {format_error(e)}")
121 + return results, new_last_uid
122 +
123 +
124 +async def get_highest_uid(client: IMAPClient) -> int:
125 + """Get the highest UID in inbox without fetching any messages."""
126 + loop = asyncio.get_event_loop()
127 +
128 + def _search():
129 + client.select_folder("INBOX")
130 + uids = client.search(["ALL"])
131 + return max(uids) if uids else 0
132 +
133 + return await loop.run_in_executor(None, _search)
134 +
135 +
136 +async def _fetch_single(
137 + client: IMAPClient,
138 + msg_id: int,
139 + download_folder: str,
140 + sender_whitelist: list[str] | None,
141 +) -> InboundMessage | None:
142 + loop = asyncio.get_event_loop()
143 +
144 + def _sync_fetch():
145 + data = client.fetch([msg_id], ["RFC822"])[msg_id]
146 + # Explicitly mark as read — RFC822 fetch doesn't always set \Seen on all servers
147 + client.add_flags([msg_id], [b"\\Seen"])
148 + return data
149 +
150 + raw = await loop.run_in_executor(None, _sync_fetch)
151 + email_data = raw.get(b"RFC822")
152 + if not email_data:
153 + return None
154 +
155 + email_msg = email.message_from_bytes(email_data)
156 +
157 + sender = _decode_header(email_msg.get("From", ""))
158 + if sender_whitelist and not _matches_whitelist(sender, sender_whitelist):
159 + return None
160 +
161 + subject = _decode_header(email_msg.get("Subject", ""))
162 + message_id = email_msg.get("Message-ID", "")
163 + in_reply_to = email_msg.get("In-Reply-To", "")
164 + references = email_msg.get("References", "")
165 +
166 + body, attachments = await _parse_body(email_msg, download_folder)
167 +
168 + return InboundMessage(
169 + sender=sender,
170 + subject=subject,
171 + body=body,
172 + attachments=attachments,
173 + message_id=message_id,
174 + in_reply_to=in_reply_to,
175 + references=references,
176 + )
177 +
178 +
179 +# ------------------------------------------------------------------
180 +# Exchange connection
181 +# ------------------------------------------------------------------
182 +
183 +async def connect_exchange(
184 + server: str,
185 + username: str,
186 + password: str,
187 +):
188 + from exchangelib import Account, Configuration, Credentials, DELEGATE
189 +
190 + loop = asyncio.get_event_loop()
191 +
192 + def _sync():
193 + creds = Credentials(username=username, password=password)
194 + config = Configuration(server=server, credentials=creds)
195 + return Account(
196 + primary_smtp_address=username,
197 + config=config,
198 + autodiscover=False,
199 + access_type=DELEGATE,
200 + )
201 +
202 + return await loop.run_in_executor(None, _sync)
203 +
204 +
205 +async def fetch_unread_exchange(
206 + account,
207 + download_folder: str,
208 + sender_whitelist: list[str] | None = None,
209 +) -> list[InboundMessage]:
210 + from exchangelib import Q
211 +
212 + loop = asyncio.get_event_loop()
213 +
214 + def _sync():
215 + return list(account.inbox.filter(Q(is_read=False)))
216 +
217 + items = await loop.run_in_executor(None, _sync)
218 + results: list[InboundMessage] = []
219 +
220 + for item in items:
221 + sender = str(item.sender.email_address) if item.sender else ""
222 + if sender_whitelist and not _matches_whitelist(sender, sender_whitelist):
223 + continue
224 +
225 + body = str(item.text_body or item.body or "")
226 + if item.body and str(item.body).strip().startswith("<"):
227 + body = _html_to_text(str(item.body))
228 +
229 + attachment_paths: list[str] = []
230 + if item.attachments:
231 + for att in item.attachments:
232 + if hasattr(att, "content") and att.name:
233 + path = await _save_attachment(att.name, att.content, download_folder)
234 + attachment_paths.append(path)
235 +
236 + results.append(InboundMessage(
237 + sender=sender,
238 + subject=str(item.subject or ""),
239 + body=body,
240 + attachments=attachment_paths,
241 + message_id=str(getattr(item, "message_id", "") or ""),
242 + in_reply_to=str(getattr(item, "in_reply_to", "") or ""),
243 + references="",
244 + ))
245 +
246 + return results
247 +
248 +
249 +# ------------------------------------------------------------------
250 +# Parsing helpers
251 +# ------------------------------------------------------------------
252 +
253 +async def _parse_body(
254 + email_msg: EmailMessage,
255 + download_folder: str,
256 +) -> tuple[str, list[str]]:
257 + body = ""
258 + attachments: list[str] = []
259 + cid_map: dict[str, str] = {}
260 +
261 + if email_msg.is_multipart():
262 + for part in email_msg.walk():
263 + content_type = part.get_content_type()
264 + disposition = str(part.get("Content-Disposition", ""))
265 +
266 + if part.get_content_maintype() == "multipart":
267 + continue
268 +
269 + if "attachment" in disposition or part.get("Content-ID"):
270 + filename = part.get_filename()
271 + if filename:
272 + filename = _decode_header(filename)
273 + content = part.get_payload(decode=True)
274 + if content:
275 + path = await _save_attachment(filename, content, download_folder)
276 + attachments.append(path)
277 + cid = part.get("Content-ID")
278 + if cid:
279 + cid_map[cid.strip("<>")] = path
280 +
281 + elif content_type == "text/plain" and not body:
282 + charset = part.get_content_charset() or "utf-8"
283 + body = part.get_payload(decode=True).decode(charset, errors="ignore")
284 +
285 + elif content_type == "text/html" and not body:
286 + charset = part.get_content_charset() or "utf-8"
287 + html = part.get_payload(decode=True).decode(charset, errors="ignore")
288 + body = _html_to_text(html, cid_map)
289 + else:
290 + content_type = email_msg.get_content_type()
291 + charset = email_msg.get_content_charset() or "utf-8"
292 + content = email_msg.get_payload(decode=True)
293 + if content:
294 + if content_type == "text/html":
295 + body = _html_to_text(content.decode(charset, errors="ignore"))
296 + else:
297 + body = content.decode(charset, errors="ignore")
298 +
299 + body = _strip_quoted_reply(body)
300 + return body, attachments
301 +
302 +
303 +def _strip_quoted_reply(text: str) -> str:
304 + """Remove quoted reply chains (e.g. 'On ... wrote:' + '>' lines)."""
305 + if not text:
306 + return text
307 + lines = text.splitlines()
308 + cut = len(lines)
309 + for i, line in enumerate(lines):
310 + # Match "On <date> <someone> wrote:" pattern
311 + if re.match(r"^On .+ wrote:\s*$", line.strip()):
312 + # Verify next non-empty lines are quoted
313 + rest = [l for l in lines[i + 1:] if l.strip()]
314 + if not rest or rest[0].strip().startswith(">"):
315 + cut = i
316 + break
317 + # Also strip trailing blank lines before the cut
318 + while cut > 0 and not lines[cut - 1].strip():
319 + cut -= 1
320 + return "\n".join(lines[:cut]).strip()
321 +
322 +
323 +def _html_to_text(html_content: str, cid_map: dict[str, str] | None = None) -> str:
324 + if cid_map:
325 + soup = BeautifulSoup(html_content, "html.parser")
326 + for img in soup.find_all("img"):
327 + src = img.get("src", "")
328 + if src.startswith("cid:"):
329 + cid = src[4:]
330 + if cid in cid_map:
331 + img.replace_with(soup.new_string(f"[file://{cid_map[cid]}]"))
332 + html_content = str(soup)
333 +
334 + h = html2text.HTML2Text()
335 + h.ignore_links = False
336 + h.ignore_images = False
337 + h.ignore_emphasis = False
338 + h.body_width = 0
339 + text = h.handle(html_content)
340 + text = re.sub(r"\n{3,}", "\n\n", text).strip()
341 + return text
342 +
343 +
344 +async def _save_attachment(filename: str, content: bytes, download_folder: str) -> str:
345 + filename = files.safe_file_name(filename)
346 + name, ext = os.path.splitext(filename)
347 + unique = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
348 + rel_path = os.path.join(download_folder, unique)
349 + files.write_file_bin(rel_path, content)
350 + return files.get_abs_path(rel_path)
351 +
352 +
353 +def _decode_header(header: str) -> str:
354 + if not header:
355 + return ""
356 + parts = []
357 + for part, encoding in decode_header(header):
358 + if isinstance(part, bytes):
359 + parts.append(part.decode(encoding or "utf-8", errors="ignore"))
360 + else:
361 + parts.append(str(part))
362 + return " ".join(parts)
363 +
364 +
365 +def _matches_whitelist(sender: str, whitelist: list[str]) -> bool:
366 + sender_lower = sender.lower()
367 + for pattern in whitelist:
368 + if fnmatch(sender_lower, pattern.lower()):
369 + return True
370 + return False
plugins/_email_integration/helpers/smtp_client.py new
+80
@@ -0,0 +1,80 @@
1 +"""SMTP email sender. No agent/tool dependencies."""
2 +
3 +import asyncio
4 +import smtplib
5 +from email.mime.text import MIMEText
6 +from email.mime.multipart import MIMEMultipart
7 +from email.mime.base import MIMEBase
8 +from email import encoders
9 +from dataclasses import dataclass
10 +import os
11 +
12 +from helpers.errors import format_error
13 +from helpers.print_style import PrintStyle
14 +
15 +
16 +@dataclass
17 +class SmtpConfig:
18 + server: str
19 + port: int = 587
20 + username: str = ""
21 + password: str = ""
22 + use_tls: bool = True
23 +
24 +
25 +async def send_reply(
26 + config: SmtpConfig,
27 + to: str,
28 + subject: str,
29 + body: str,
30 + in_reply_to: str = "",
31 + references: str = "",
32 + attachments: list[str] | None = None,
33 +) -> bool:
34 + loop = asyncio.get_event_loop()
35 +
36 + def _sync_send():
37 + msg = MIMEMultipart() if attachments else MIMEText(body, "plain", "utf-8")
38 +
39 + if attachments:
40 + msg.attach(MIMEText(body, "plain", "utf-8"))
41 + for path in attachments:
42 + if not os.path.isfile(path):
43 + continue
44 + part = MIMEBase("application", "octet-stream")
45 + with open(path, "rb") as f:
46 + part.set_payload(f.read())
47 + encoders.encode_base64(part)
48 + part.add_header(
49 + "Content-Disposition",
50 + f"attachment; filename={os.path.basename(path)}",
51 + )
52 + msg.attach(part)
53 +
54 + msg["From"] = config.username
55 + msg["To"] = to
56 + msg["Subject"] = subject
57 +
58 + if in_reply_to:
59 + msg["In-Reply-To"] = in_reply_to
60 + msg["References"] = references or in_reply_to
61 +
62 + if config.use_tls:
63 + with smtplib.SMTP(config.server, config.port) as server:
64 + server.ehlo()
65 + server.starttls()
66 + server.ehlo()
67 + server.login(config.username, config.password)
68 + server.send_message(msg)
69 + else:
70 + with smtplib.SMTP_SSL(config.server, config.port) as server:
71 + server.login(config.username, config.password)
72 + server.send_message(msg)
73 +
74 + try:
75 + await loop.run_in_executor(None, _sync_send)
76 + PrintStyle.success(f"Email sent to {to}: {subject}")
77 + return True
78 + except Exception as e:
79 + PrintStyle.error(f"Email send failed: {format_error(e)}")
80 + return False
plugins/_email_integration/plugin.yaml new
+8
@@ -0,0 +1,8 @@
1 +name: _email_integration
2 +title: Email Integration
3 +description: Communicate with Agent Zero via email. Supports IMAP/Exchange inbox polling with SMTP replies.
4 +version: 0.1.0
5 +settings_sections:
6 + - external
7 +per_project_config: true
8 +per_agent_config: false
plugins/_email_integration/prompts/agent.system.tool.email_reply.md new
+6
@@ -0,0 +1,6 @@
1 +## Email conversation
2 +you are in an email conversation, user communicates via email
3 +your response (via response tool) will be sent as email reply automatically
4 +be concise, professional, clear
5 +do not include email headers or signatures — they are added automatically
6 +if task requires extended processing, provide brief status then continue work
plugins/_email_integration/prompts/fw.email.dispatcher_extra.md new
+3
@@ -0,0 +1,3 @@
1 +
2 +## Additional Instructions
3 +{{instructions}}
plugins/_email_integration/prompts/fw.email.dispatcher_prompt.md new
+8
@@ -0,0 +1,8 @@
1 +## New Email
2 +From: {{sender}}
3 +Subject: {{subject}}
4 +
5 +{{body}}
6 +
7 +## Existing Chats
8 +{{chats}}
plugins/_email_integration/prompts/fw.email.dispatcher_system.md new
+19
@@ -0,0 +1,19 @@
1 +You route inbound emails to agent chats.
2 +You receive: the new email (sender, subject, body) and a list of existing chats for this email handler.
3 +
4 +Rules:
5 +- If the email subject contains a thread ID marker [a0-XXXX] AND a matching chat exists, reply with CONTINUE and that chat's context_id
6 +- If no thread ID but the email content clearly relates to an existing chat (same topic, same sender), reply with CONTINUE and the best matching context_id
7 +- If the email is a status question or low-priority follow-up on a running chat, reply with INTERVENE_SOFT and the context_id — the agent will receive it after current step
8 +- If the email is urgent and requires immediate attention on a running chat, reply with INTERVENE_HARD and the context_id
9 +- Otherwise reply with NEW_CHAT
10 +
11 +Respond with EXACTLY one line in this format:
12 +ACTION context_id reason
13 +
14 +Examples:
15 +NEW_CHAT _ new request from user
16 +CONTINUE ctx_abc123 thread ID matches
17 +CONTINUE ctx_def456 same sender discussing AWS deployment
18 +INTERVENE_SOFT ctx_abc123 user asking for status update
19 +INTERVENE_HARD ctx_abc123 urgent correction needed
plugins/_email_integration/prompts/fw.email.intervene_soft.md new
+5
@@ -0,0 +1,5 @@
1 +[Email from {{sender}}] user following up, respond and continue current work
2 +
3 +Subject: {{subject}}
4 +
5 +{{body}}
plugins/_email_integration/prompts/fw.email.poll_error.md new
+1
@@ -0,0 +1 @@
1 +email poll error: {{error}}
plugins/_email_integration/prompts/fw.email.poll_ok.md new
+1
@@ -0,0 +1 @@
1 +email poll complete: {{result}}
plugins/_email_integration/prompts/fw.email.system_context.md new
+3
@@ -0,0 +1,3 @@
1 +you are communicating via email, user sent email message
2 +use response tool to send reply — response sent as email reply automatically
3 +be concise, professional, clear
plugins/_email_integration/prompts/fw.email.user_message.md new
+4
@@ -0,0 +1,4 @@
1 +[Email from {{sender}}]
2 +Subject: {{subject}}
3 +
4 +{{body}}
plugins/_email_integration/prompts/fw.email.user_message_instructions.md new
+2
@@ -0,0 +1,2 @@
1 +
2 +[Handler instructions: {{instructions}}]
plugins/_email_integration/test_connection.py new
+111
@@ -0,0 +1,111 @@
1 +"""
2 +Email integration connection test.
3 +Usage: EMAIL_USER=you@gmail.com EMAIL_PASS=xxxx python plugins/_email_integration/test_connection.py
4 +
5 +Tests:
6 + 1. IMAP connect + get highest UID (baseline)
7 + 2. Fetch new emails since that UID (should be 0 on first run)
8 + 3. SMTP send test email to self
9 + 4. Fetch again (should find the test email)
10 +"""
11 +
12 +import asyncio
13 +import os
14 +import sys
15 +
16 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + "/../..")
17 +
18 +from plugins._email_integration.helpers.imap_client import (
19 + connect_imap,
20 + disconnect_imap,
21 + fetch_new,
22 + get_highest_uid,
23 +)
24 +from plugins._email_integration.helpers.smtp_client import SmtpConfig, send_reply
25 +
26 +
27 +async def test_full_flow(user: str, password: str):
28 + print(f"\n--- Full Flow Test ({user}) ---\n")
29 +
30 + # Step 1: Connect and get baseline UID
31 + client = await connect_imap(
32 + server="imap.gmail.com", port=993,
33 + username=user, password=password,
34 + )
35 + print("[OK] IMAP login")
36 +
37 + baseline_uid = await get_highest_uid(client)
38 + print(f"[OK] Baseline UID: {baseline_uid}")
39 +
40 + # Step 2: Fetch new since baseline — should be 0
41 + messages, new_uid = await fetch_new(
42 + client, "/tmp/email_test_attachments",
43 + last_uid=baseline_uid, max_messages=5,
44 + )
45 + print(f"[OK] Fetch new since UID {baseline_uid}: {len(messages)} messages (expected 0)")
46 +
47 + await disconnect_imap(client)
48 +
49 + # Step 3: Send test email to self
50 + print("\n--- Sending test email to self ---")
51 + cfg = SmtpConfig(
52 + server="smtp.gmail.com", port=587,
53 + username=user, password=password,
54 + )
55 + ok = await send_reply(
56 + config=cfg, to=user,
57 + subject="A0 Email Test [a0-test123]",
58 + body="Test email from Agent Zero. If you see this, SMTP works.",
59 + )
60 + print(f"[{'OK' if ok else 'FAIL'}] SMTP send")
61 +
62 + if not ok:
63 + return
64 +
65 + # Step 4: Wait for email to arrive, then fetch
66 + print("\n--- Waiting 5s for email delivery ---")
67 + await asyncio.sleep(5)
68 +
69 + client2 = await connect_imap(
70 + server="imap.gmail.com", port=993,
71 + username=user, password=password,
72 + )
73 +
74 + messages2, new_uid2 = await fetch_new(
75 + client2, "/tmp/email_test_attachments",
76 + last_uid=baseline_uid, max_messages=5,
77 + )
78 + print(f"[OK] Fetch new since UID {baseline_uid}: {len(messages2)} messages")
79 + for msg in messages2:
80 + print(f" From: {msg.sender}")
81 + print(f" Subject: {msg.subject}")
82 + print(f" Body: {msg.body[:80]}...")
83 + print()
84 +
85 + print(f"[OK] New last_uid: {new_uid2}")
86 +
87 + # Step 5: Fetch again with updated UID — should be 0
88 + messages3, _ = await fetch_new(
89 + client2, "/tmp/email_test_attachments",
90 + last_uid=new_uid2, max_messages=5,
91 + )
92 + print(f"[OK] Re-fetch since UID {new_uid2}: {len(messages3)} messages (expected 0)")
93 +
94 + await disconnect_imap(client2)
95 + print("\n--- ALL TESTS PASSED ---")
96 +
97 +
98 +async def main():
99 + user = os.environ.get("EMAIL_USER", "")
100 + password = os.environ.get("EMAIL_PASS", "")
101 +
102 + if not user or not password:
103 + print("Set EMAIL_USER and EMAIL_PASS environment variables")
104 + print("Example: EMAIL_USER=you@gmail.com EMAIL_PASS=xxxx python ...")
105 + sys.exit(1)
106 +
107 + await test_full_flow(user, password)
108 +
109 +
110 +if __name__ == "__main__":
111 + asyncio.run(main())
plugins/_email_integration/webui/config.html new
+234
@@ -0,0 +1,234 @@
1 +<html>
2 +<head>
3 + <title>Email Integration</title>
4 +</head>
5 +
6 +<body>
7 + <div x-data="{
8 + get handlers() { return config?.handlers || [] },
9 + editing: null,
10 + add_handler() {
11 + if (!config.handlers) config.handlers = [];
12 + config.handlers.push({
13 + name: 'handler_' + (config.handlers.length + 1),
14 + enabled: false,
15 + account_type: 'imap',
16 + imap_server: '',
17 + imap_port: 993,
18 + smtp_server: '',
19 + smtp_port: 587,
20 + username: '',
21 + password: '',
22 + poll_mode: 'seconds',
23 + poll_interval_seconds: 15,
24 + poll_interval_cron: '*/2 * * * *',
25 + sender_whitelist: [],
26 + project: '',
27 + dispatcher_instructions: '',
28 + agent_instructions: ''
29 + });
30 + this.editing = config.handlers.length - 1;
31 + },
32 + remove_handler(idx) {
33 + config.handlers.splice(idx, 1);
34 + this.editing = null;
35 + },
36 + whitelist_text(handler) {
37 + return (handler.sender_whitelist || []).join(', ');
38 + },
39 + set_whitelist(handler, val) {
40 + handler.sender_whitelist = val.split(',').map(s => s.trim()).filter(s => s);
41 + }
42 + }">
43 + <template x-if="config">
44 + <div>
45 + <div class="section-title">Email Integration</div>
46 + <div class="section-description">
47 + Configure email handlers to communicate with Agent Zero via email.
48 + Each handler connects to an email account and polls for new messages.
49 + </div>
50 +
51 + <!-- Handler list -->
52 + <template x-for="(handler, idx) in handlers" :key="idx">
53 + <div style="border: 1px solid var(--border-color, #333); border-radius: 8px; padding: 12px; margin-bottom: 8px; margin-top: 8px;">
54 +
55 + <!-- Header row -->
56 + <div style="display: flex; justify-content: space-between; align-items: center; cursor: pointer;"
57 + @click="editing = editing === idx ? null : idx">
58 + <div>
59 + <span style="font-weight: bold;" x-text="handler.name"></span>
60 + <span style="opacity: 0.6; margin-left: 8px;" x-text="handler.enabled ? 'Enabled' : 'Disabled'"></span>
61 + </div>
62 + <div style="display: flex; gap: 8px; align-items: center;">
63 + <label @click.stop style="display: flex; align-items: center; gap: 4px;">
64 + <input type="checkbox" x-model="handler.enabled" />
65 + </label>
66 + <button class="btn-cancel" @click.stop="remove_handler(idx)" style="padding: 2px 10px; font-size: 0.8rem;">Remove</button>
67 + </div>
68 + </div>
69 +
70 + <!-- Expanded editor -->
71 + <template x-if="editing === idx">
72 + <div style="margin-top: 16px;">
73 +
74 + <div class="field">
75 + <div class="field-label">
76 + <div class="field-title">Handler Name</div>
77 + </div>
78 + <div class="field-control">
79 + <input type="text" x-model="handler.name" placeholder="e.g. support" />
80 + </div>
81 + </div>
82 +
83 + <div class="field">
84 + <div class="field-label">
85 + <div class="field-title">Account Type</div>
86 + </div>
87 + <div class="field-control">
88 + <select x-model="handler.account_type">
89 + <option value="imap">IMAP</option>
90 + <option value="exchange">Exchange</option>
91 + </select>
92 + </div>
93 + </div>
94 +
95 + <div class="field">
96 + <div class="field-label">
97 + <div class="field-title">IMAP Server</div>
98 + </div>
99 + <div class="field-control">
100 + <input type="text" x-model="handler.imap_server" placeholder="imap.gmail.com" />
101 + </div>
102 + </div>
103 +
104 + <div class="field">
105 + <div class="field-label">
106 + <div class="field-title">IMAP Port</div>
107 + </div>
108 + <div class="field-control">
109 + <input type="number" x-model.number="handler.imap_port" />
110 + </div>
111 + </div>
112 +
113 + <div class="field">
114 + <div class="field-label">
115 + <div class="field-title">SMTP Server</div>
116 + </div>
117 + <div class="field-control">
118 + <input type="text" x-model="handler.smtp_server" placeholder="smtp.gmail.com" />
119 + </div>
120 + </div>
121 +
122 + <div class="field">
123 + <div class="field-label">
124 + <div class="field-title">SMTP Port</div>
125 + </div>
126 + <div class="field-control">
127 + <input type="number" x-model.number="handler.smtp_port" />
128 + </div>
129 + </div>
130 +
131 + <div class="field">
132 + <div class="field-label">
133 + <div class="field-title">Username</div>
134 + </div>
135 + <div class="field-control">
136 + <input type="text" x-model="handler.username" placeholder="user@domain.com" />
137 + </div>
138 + </div>
139 +
140 + <div class="field">
141 + <div class="field-label">
142 + <div class="field-title">Password</div>
143 + </div>
144 + <div class="field-control">
145 + <input type="password" x-model="handler.password" />
146 + </div>
147 + </div>
148 +
149 + <div class="field">
150 + <div class="field-label">
151 + <div class="field-title">Poll Mode</div>
152 + </div>
153 + <div class="field-control">
154 + <select x-model="handler.poll_mode">
155 + <option value="seconds">Interval (seconds)</option>
156 + <option value="cron">Cron expression</option>
157 + </select>
158 + </div>
159 + </div>
160 +
161 + <div class="field" x-show="handler.poll_mode === 'seconds'">
162 + <div class="field-label">
163 + <div class="field-title">Poll Interval (seconds)</div>
164 + <div class="field-description">How often to check for new emails</div>
165 + </div>
166 + <div class="field-control">
167 + <input type="number" x-model.number="handler.poll_interval_seconds" min="5" placeholder="15" />
168 + </div>
169 + </div>
170 +
171 + <div class="field" x-show="handler.poll_mode !== 'seconds'">
172 + <div class="field-label">
173 + <div class="field-title">Cron Expression</div>
174 + <div class="field-description">e.g. */2 * * * * for every 2 minutes</div>
175 + </div>
176 + <div class="field-control">
177 + <input type="text" x-model="handler.poll_interval_cron" placeholder="*/2 * * * *" />
178 + </div>
179 + </div>
180 +
181 + <div class="field">
182 + <div class="field-label">
183 + <div class="field-title">Sender Whitelist</div>
184 + <div class="field-description">Comma-separated. Empty = allow all. Wildcards supported (e.g. *@company.com)</div>
185 + </div>
186 + <div class="field-control">
187 + <input type="text" :value="whitelist_text(handler)" @input="set_whitelist(handler, $event.target.value)" placeholder="*@company.com, boss@other.com" />
188 + </div>
189 + </div>
190 +
191 + <div class="field">
192 + <div class="field-label">
193 + <div class="field-title">Project</div>
194 + <div class="field-description">Project to activate for email chats (optional)</div>
195 + </div>
196 + <div class="field-control">
197 + <input type="text" x-model="handler.project" placeholder="my-project" />
198 + </div>
199 + </div>
200 +
201 + <div class="field">
202 + <div class="field-label">
203 + <div class="field-title">Dispatcher Instructions</div>
204 + <div class="field-description">Extra instructions for the AI that routes emails to chats</div>
205 + </div>
206 + <div class="field-control">
207 + <textarea x-model="handler.dispatcher_instructions" rows="3" placeholder="e.g. Always start a new chat for emails from support@..."></textarea>
208 + </div>
209 + </div>
210 +
211 + <div class="field">
212 + <div class="field-label">
213 + <div class="field-title">Agent Instructions</div>
214 + <div class="field-description">Extra instructions for the agent in email chats</div>
215 + </div>
216 + <div class="field-control">
217 + <textarea x-model="handler.agent_instructions" rows="3" placeholder="e.g. Always respond in formal English..."></textarea>
218 + </div>
219 + </div>
220 +
221 + </div>
222 + </template>
223 + </div>
224 + </template>
225 +
226 + <button class="btn-ok" @click="add_handler()" style="margin-top: 8px;">
227 + Add Handler
228 + </button>
229 + </div>
230 + </template>
231 + </div>
232 +</body>
233 +
234 +</html>