feat(email): add file attachment support for email replies via RFC

linuztx committed Mar 15, 2026 at 14:49 UTC 6ab8680bf880aeaf1d320a066a7360c000494e69
9 files changed +175 -19
plugins/_email_integration/extensions/python/job_loop/_10_email_poll.py
+14
@@ -1,3 +1,5 @@
1 +"""Per-handler email poll loop with configurable seconds/cron intervals."""
2 +
3 import asyncio
4
5 from crontab import CronTab
@@ -15,6 +17,10 @@ MIN_INTERVAL = 5
17 _poll_tasks: dict[str, asyncio.Task] = {}
18
19
20 +# ------------------------------------------------------------------
21 +# Poll interval
22 +# ------------------------------------------------------------------
23 +
24 def _get_sleep_seconds(handler_cfg: dict) -> float:
25 mode = handler_cfg.get("poll_mode", "seconds")
26 if mode == "cron":
@@ -26,6 +32,10 @@ def _get_sleep_seconds(handler_cfg: dict) -> float:
32 return max(handler_cfg.get("poll_interval_seconds", DEFAULT_INTERVAL), MIN_INTERVAL)
33
34
35 +# ------------------------------------------------------------------
36 +# Per-handler poll loop
37 +# ------------------------------------------------------------------
38 +
39 async def _handler_poll_loop(handler_name: str):
40 from plugins._email_integration.helpers.handler import (
41 _poll_single_handler,
@@ -56,6 +66,10 @@ async def _handler_poll_loop(handler_name: str):
66 await asyncio.sleep(sleep_sec)
67
68
69 +# ------------------------------------------------------------------
70 +# Extension entry point
71 +# ------------------------------------------------------------------
72 +
73 class EmailAutoPoll(Extension):
74
75 async def execute(self, **kwargs):
plugins/_email_integration/extensions/python/process_chain_end/_55_email_reply.py
+16 -5
@@ -1,7 +1,10 @@
1 +"""Auto-send email reply when agent responds in an email session."""
2 +
3 import asyncio
4 from helpers.extension import Extension
5 +from helpers.print_style import PrintStyle
6 from agent import AgentContext, LoopData
4 -from plugins._email_integration.helpers.dispatcher import CTX_EMAIL_HANDLER
7 +from plugins._email_integration.helpers.dispatcher import CTX_EMAIL_HANDLER, CTX_EMAIL_ATTACHMENTS
8
9
10 class EmailAutoReply(Extension):
@@ -14,17 +17,25 @@ class EmailAutoReply(Extension):
17 if not context.data.get(CTX_EMAIL_HANDLER):
18 return
19
17 - # Extract response from last log item
20 response_text = _extract_last_response(context)
21 if not response_text:
22 return
23
22 - asyncio.create_task(self._send_reply(context, response_text))
24 + attachments = context.data.pop(CTX_EMAIL_ATTACHMENTS, [])
25 + if attachments:
26 + PrintStyle.info(f"Email: sending reply with {len(attachments)} attachment(s)")
27 + asyncio.create_task(self._send_reply(context, response_text, attachments))
28
24 - async def _send_reply(self, context: AgentContext, response_text: str):
29 + async def _send_reply(
30 + self, context: AgentContext, response_text: str, attachments: list[str],
31 + ):
32 from plugins._email_integration.helpers.handler import send_email_reply
26 - await send_email_reply(context, response_text)
33 + await send_email_reply(context, response_text, attachments)
34 +
35
36 +# ------------------------------------------------------------------
37 +# Helpers
38 +# ------------------------------------------------------------------
39
40 def _extract_last_response(context: AgentContext) -> str:
41 with context.log._lock:
plugins/_email_integration/extensions/python/system_prompt/_20_email_context.py
+2 -1
@@ -1,3 +1,5 @@
1 +"""Inject email conversation context into system prompt for email sessions."""
2 +
3 from helpers.extension import Extension
4 from agent import LoopData
5 from plugins._email_integration.helpers.dispatcher import CTX_EMAIL_HANDLER
@@ -14,7 +16,6 @@ class EmailContextPrompt(Extension):
16 if not self.agent:
17 return
18
17 - # Only inject email conversation context if this chat was started by email
19 if self.agent.context.data.get(CTX_EMAIL_HANDLER):
20 prompt = self.agent.read_prompt("fw.email.system_context_reply.md")
21 system_prompt.append(prompt)
plugins/_email_integration/extensions/python/tool_execute_after/_50_email_attachments.py new
+25
@@ -0,0 +1,25 @@
1 +"""Intercept response tool to capture email attachments."""
2 +
3 +from helpers.extension import Extension
4 +from helpers.print_style import PrintStyle
5 +from plugins._email_integration.helpers.dispatcher import CTX_EMAIL_HANDLER, CTX_EMAIL_ATTACHMENTS
6 +
7 +
8 +class EmailResponseAttachments(Extension):
9 +
10 + async def execute(self, tool_name: str = "", **kwargs):
11 + if tool_name != "response":
12 + return
13 + if not self.agent:
14 + return
15 + if not self.agent.context.data.get(CTX_EMAIL_HANDLER):
16 + return
17 +
18 + tool = self.agent.loop_data.current_tool
19 + if not tool:
20 + return
21 +
22 + attachments = tool.args.get("attachments", [])
23 + if attachments:
24 + self.agent.context.data[CTX_EMAIL_ATTACHMENTS] = attachments
25 + PrintStyle.info(f"Email: queued {len(attachments)} attachment(s)")
plugins/_email_integration/helpers/attachment_reader.py new
+40
@@ -0,0 +1,40 @@
1 +"""
2 +Read attachment files from execution runtime.
3 +
4 +No agent/tool dependencies.
5 +"""
6 +
7 +import base64
8 +import os
9 +from typing import TypedDict
10 +
11 +
12 +# ------------------------------------------------------------------
13 +# Data models
14 +# ------------------------------------------------------------------
15 +
16 +class AttachmentData(TypedDict):
17 + name: str
18 + content_b64: str
19 + error: str
20 +
21 +
22 +# ------------------------------------------------------------------
23 +# File reader
24 +# ------------------------------------------------------------------
25 +
26 +def read_attachment(path: str) -> AttachmentData:
27 + try:
28 + if not os.path.isfile(path):
29 + return AttachmentData(
30 + name="", content_b64="", error=f"file not found: {path}")
31 + name = os.path.basename(path)
32 + with open(path, "rb") as f:
33 + content = f.read()
34 + return AttachmentData(
35 + name=name,
36 + content_b64=base64.b64encode(content).decode(),
37 + error="",
38 + )
39 + except Exception as e:
40 + return AttachmentData(name="", content_b64="", error=str(e))
plugins/_email_integration/helpers/dispatcher.py
+6 -1
@@ -1,4 +1,8 @@
1 -"""Email dispatch logic — routes inbound emails to chats. No agent deps in pure helpers."""
1 +"""
2 +Email dispatch logic — routes inbound emails to chats.
3 +
4 +No agent deps in pure helpers.
5 +"""
6
7 import re
8 from dataclasses import dataclass
@@ -15,6 +19,7 @@ CTX_EMAIL_THREAD_ID = "_email_thread_id"
19 CTX_EMAIL_SUBJECT = "_email_subject"
20 CTX_EMAIL_MESSAGE_ID = "_email_message_id"
21 CTX_EMAIL_REFERENCES = "_email_references"
22 +CTX_EMAIL_ATTACHMENTS = "_email_response_attachments"
23
24 DispatchAction = Literal["new_chat", "continue_chat", "intervene_soft", "intervene_hard"]
25
plugins/_email_integration/helpers/handler.py
+42 -3
@@ -1,11 +1,16 @@
1 -"""Email handler — orchestrates poll, dispatch, and reply. Requires agent context."""
1 +"""
2 +Email handler — orchestrates poll, dispatch, and reply.
3 +
4 +Requires agent context.
5 +"""
6
7 import asyncio
8 +import base64
9 import json
10 import os
11
12 from agent import Agent, AgentContext, UserMessage
8 -from helpers import guids, plugins, files
13 +from helpers import guids, plugins, files, runtime
14 from helpers import message_queue as mq
15 from helpers.persist_chat import save_tmp_chat
16 from helpers.print_style import PrintStyle
@@ -349,7 +354,11 @@ def _build_user_message(agent: Agent, msg: InboundMessage, handler_cfg: dict) ->
354 # Reply sending (called from process_chain_end extension)
355 # ------------------------------------------------------------------
356
352 -async def send_email_reply(context: AgentContext, response_text: str):
357 +async def send_email_reply(
358 + context: AgentContext,
359 + response_text: str,
360 + attachments: list[str] | None = None,
361 +):
362 handler_name = context.data.get(disp.CTX_EMAIL_HANDLER)
363 if not handler_name:
364 return
@@ -374,6 +383,9 @@ async def send_email_reply(context: AgentContext, response_text: str):
383 password=cfg.get("password", ""),
384 )
385
386 + # Read attachment files via RFC (they live in the execution runtime)
387 + attachment_data = await _read_attachments_via_rfc(attachments)
388 +
389 await send_reply(
390 config=smtp_cfg,
391 to=sender,
@@ -381,9 +393,36 @@ async def send_email_reply(context: AgentContext, response_text: str):
393 body=response_text,
394 in_reply_to=original_msg_id,
395 references=references,
396 + attachments=attachment_data or None,
397 )
398
399
400 +# ------------------------------------------------------------------
401 +# Attachment reading (via RFC into execution runtime)
402 +# ------------------------------------------------------------------
403 +
404 +async def _read_attachments_via_rfc(
405 + paths: list[str] | None,
406 +) -> list[tuple[str, bytes]]:
407 + if not paths:
408 + return []
409 +
410 + from plugins._email_integration.helpers.attachment_reader import read_attachment
411 +
412 + results: list[tuple[str, bytes]] = []
413 + for path in paths:
414 + data = await runtime.call_development_function(read_attachment, path)
415 + if data["error"]:
416 + PrintStyle.error(f"Email attachment: {data['error']}")
417 + continue
418 + results.append((data["name"], base64.b64decode(data["content_b64"])))
419 + return results
420 +
421 +
422 +# ------------------------------------------------------------------
423 +# Config lookup
424 +# ------------------------------------------------------------------
425 +
426 def _get_handler_config(handler_name: str) -> dict | None:
427 config = plugins.get_plugin_config(PLUGIN_NAME) or {}
428 handlers = config.get("handlers", [])
plugins/_email_integration/helpers/smtp_client.py
+17 -9
@@ -1,4 +1,8 @@
1 -"""SMTP email sender. No agent/tool dependencies."""
1 +"""
2 +SMTP email sender.
3 +
4 +No agent/tool dependencies.
5 +"""
6
7 import asyncio
8 import smtplib
@@ -7,12 +11,15 @@ from email.mime.multipart import MIMEMultipart
11 from email.mime.base import MIMEBase
12 from email import encoders
13 from dataclasses import dataclass
10 -import os
14
15 from helpers.errors import format_error
16 from helpers.print_style import PrintStyle
17
18
19 +# ------------------------------------------------------------------
20 +# Data models
21 +# ------------------------------------------------------------------
22 +
23 @dataclass
24 class SmtpConfig:
25 server: str
@@ -22,6 +29,10 @@ class SmtpConfig:
29 use_tls: bool = True
30
31
32 +# ------------------------------------------------------------------
33 +# Send reply
34 +# ------------------------------------------------------------------
35 +
36 async def send_reply(
37 config: SmtpConfig,
38 to: str,
@@ -29,7 +40,7 @@ async def send_reply(
40 body: str,
41 in_reply_to: str = "",
42 references: str = "",
32 - attachments: list[str] | None = None,
43 + attachments: list[tuple[str, bytes]] | None = None,
44 ) -> bool:
45 loop = asyncio.get_event_loop()
46
@@ -38,16 +49,13 @@ async def send_reply(
49
50 if attachments:
51 msg.attach(MIMEText(body, "plain", "utf-8"))
41 - for path in attachments:
42 - if not os.path.isfile(path):
43 - continue
52 + for filename, content in attachments:
53 part = MIMEBase("application", "octet-stream")
45 - with open(path, "rb") as f:
46 - part.set_payload(f.read())
54 + part.set_payload(content)
55 encoders.encode_base64(part)
56 part.add_header(
57 "Content-Disposition",
50 - f"attachment; filename={os.path.basename(path)}",
58 + f"attachment; filename={filename}",
59 )
60 msg.attach(part)
61
plugins/_email_integration/prompts/fw.email.system_context_reply.md
+13
@@ -4,3 +4,16 @@ 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
7 +
8 +### Sending file attachments
9 +to attach files to your email reply, add `attachments` list with absolute file paths to the response tool:
10 +~~~json
11 +{
12 + ...
13 + "tool_name": "response",
14 + "tool_args": {
15 + "text": "Here is the file you requested.",
16 + "attachments": ["/path/to/file.txt"]
17 + }
18 +}
19 +~~~