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>