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
+~~~