Files
forge/apps/api/forge/channels/email.py
T
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
2026-07-28 01:49:19 +05:30

239 lines
10 KiB
Python

"""Email channel - inbound parsing and outbound (SMTP) replies.
Inbound supports two shapes:
- A raw RFC-822 message (bytes/str), e.g. from an IMAP poll.
- A provider inbound-parse payload (Mailgun/SendGrid/Postmark post form/JSON fields).
Outbound sends a threaded reply via SMTP (creds resolved from the channel's secret
refs). SMTP/IMAP run in worker threads so they don't block the event loop.
"""
from __future__ import annotations
import asyncio
import email
import logging
import re
import smtplib
from email.message import EmailMessage
from email.utils import make_msgid, parseaddr
from html.parser import HTMLParser
from typing import Any
from forge.channels.retry import retry_send
from forge.secrets.store import SecretStore
log = logging.getLogger("forge.channels.email")
_BLOCK_ENDERS = {"br", "p", "div", "li", "tr"}
_SKIP_TAGS = {"script", "style"}
class _HTMLTextExtractor(HTMLParser):
"""Pull visible text out of HTML with the stdlib parser instead of regexes, so a hostile
body can't trigger catastrophic backtracking (ReDoS): skip <script>/<style> content, turn
common block-enders into newlines, and let the parser unescape entities (convert_charrefs)."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
self._skip_depth = 0
def handle_starttag(self, tag: str, attrs: Any) -> None:
if tag in _SKIP_TAGS:
self._skip_depth += 1
elif tag == "br":
self._parts.append("\n")
def handle_startendtag(self, tag: str, attrs: Any) -> None:
if tag == "br":
self._parts.append("\n")
def handle_endtag(self, tag: str) -> None:
if tag in _SKIP_TAGS:
if self._skip_depth:
self._skip_depth -= 1
elif tag in _BLOCK_ENDERS:
self._parts.append("\n")
def handle_data(self, data: str) -> None:
if not self._skip_depth:
self._parts.append(data)
def get_text(self) -> str:
return "".join(self._parts)
def _html_to_text(html: str) -> str:
"""Best-effort HTML -> plain text WITHOUT a new dependency, so an HTML-only email's body
reaches the workflow instead of an empty string (audit F). Parser-based (not regex) to stay
ReDoS-safe on attacker-controlled input."""
if not html:
return ""
parser = _HTMLTextExtractor()
try:
parser.feed(html)
parser.close()
except Exception: # noqa: BLE001 - malformed HTML must never crash the public inbound webhook
pass
text = parser.get_text()
# collapse horizontal whitespace and excess blank lines; every pattern here is linear.
text = re.sub(r"[ \t\f\v]+", " ", text)
text = re.sub(r" *\n *", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _thread_ref(references: str | None, in_reply_to: str | None, message_id: str | None) -> str | None:
"""A stable per-conversation key for threading replies (audit F6): the ROOT of the
References chain (shared by every message in the thread), else the In-Reply-To parent,
else this message's own id (a brand-new thread)."""
if references:
first = references.split()[0].strip() if references.split() else ""
if first:
return first
return (in_reply_to or "").strip() or (message_id or "").strip() or None
def _merge_references(references: str | None, parent_id: str | None) -> str:
"""The outbound `References` header: the inbound References chain with the parent's
Message-ID appended (RFC 5322). Clients thread on the full References chain, not just
In-Reply-To, so preserving the whole chain keeps deep threads together (audit G)."""
ids: list[str] = []
for tok in (references or "").split():
tok = tok.strip()
if tok and tok not in ids:
ids.append(tok)
if parent_id:
pid = parent_id.strip()
if pid and pid not in ids:
ids.append(pid)
return " ".join(ids)
def parse_inbound(payload: Any) -> dict:
"""Normalize an inbound email (raw MIME or provider dict) to a common shape."""
if isinstance(payload, dict):
# Provider inbound-parse fields vary; accept the common ones.
sender = payload.get("from") or payload.get("sender") or payload.get("From", "")
subject = payload.get("subject") or payload.get("Subject", "")
text = payload.get("text") or payload.get("body-plain") or payload.get("stripped-text") or payload.get("TextBody") or ""
if not (text or "").strip():
# HTML-only email: fall back to the provider's HTML field, stripped to text, so the
# workflow gets the body instead of an empty message (audit F).
html = payload.get("html") or payload.get("body-html") or payload.get("HtmlBody") or payload.get("stripped-html") or ""
text = _html_to_text(html)
msg_id = payload.get("message-id") or payload.get("Message-Id") or payload.get("MessageID")
references = payload.get("References") or payload.get("references")
in_reply_to = payload.get("In-Reply-To") or payload.get("in-reply-to")
return {"from_addr": parseaddr(sender)[1] or sender, "from_name": parseaddr(sender)[0],
"subject": subject, "text": (text or "").strip(), "message_id": msg_id,
"references": references,
"thread_ref": _thread_ref(references, in_reply_to, msg_id)}
raw = payload.encode() if isinstance(payload, str) else payload
msg = email.message_from_bytes(raw)
body = ""
html_body = ""
if msg.is_multipart():
for part in msg.walk():
ctype = part.get_content_type()
if "attachment" in str(part.get("Content-Disposition", "")):
continue
# decode=True returns None for a part with no decodable payload - guard it so a
# malformed MIME message can't 500 the public inbound webhook (audit F-low).
if ctype == "text/plain" and not body:
raw_payload = part.get_payload(decode=True) or b""
body = raw_payload.decode(part.get_content_charset() or "utf-8", "replace")
elif ctype == "text/html" and not html_body:
raw_payload = part.get_payload(decode=True) or b""
html_body = raw_payload.decode(part.get_content_charset() or "utf-8", "replace")
else:
payload_bytes = msg.get_payload(decode=True) or b""
decoded = payload_bytes.decode(msg.get_content_charset() or "utf-8", "replace")
if msg.get_content_type() == "text/html":
html_body = decoded
else:
body = decoded
# No text/plain part -> fall back to the HTML part, stripped to text (audit F).
if not body.strip() and html_body:
body = _html_to_text(html_body)
name, addr = parseaddr(msg.get("From", ""))
return {"from_addr": addr, "from_name": name, "subject": msg.get("Subject", ""),
"text": body.strip(), "message_id": msg.get("Message-ID"),
"references": msg.get("References"),
"thread_ref": _thread_ref(msg.get("References"), msg.get("In-Reply-To"), msg.get("Message-ID"))}
def build_input_text(parsed: dict, include_subject: bool = True) -> str:
if include_subject and parsed.get("subject"):
return f"Subject: {parsed['subject']}\n\n{parsed.get('text', '')}"
return parsed.get("text", "")
def build_reply(*, to_addr: str, subject: str, body: str, from_addr: str,
in_reply_to: str | None = None, references: str | None = None,
message_id: str | None = None) -> EmailMessage:
msg = EmailMessage()
msg["From"] = from_addr
msg["To"] = to_addr
msg["Subject"] = subject if subject.lower().startswith("re:") else f"Re: {subject}" if subject else "Re:"
if in_reply_to:
msg["In-Reply-To"] = in_reply_to
# References = the inbound chain + the parent id, so deep threads stay grouped (audit G).
refs = _merge_references(references, in_reply_to)
if refs:
msg["References"] = refs
# Set an explicit Message-ID so a downstream reply can reference THIS message and clients
# can dedupe (a missing Message-ID makes some MTAs generate an unstable one) (audit G).
try:
msg["Message-ID"] = message_id or make_msgid()
except Exception: # noqa: BLE001 - make_msgid can fail on odd hostnames; header is optional
pass
msg.set_content(body)
return msg
def _send_sync(host: str, port: int, username: str | None, password: str | None, use_tls: bool, msg: EmailMessage) -> None:
with smtplib.SMTP(host, port, timeout=30) as server:
if use_tls:
server.starttls()
if username and password:
server.login(username, password)
server.send_message(msg)
async def send_reply(channel, parsed: dict, answer: str) -> bool:
"""Send the workflow's answer as a threaded SMTP reply, with bounded retry + backoff.
Returns True when an email was actually sent, False when SMTP isn't configured / there's
nowhere to send (a no-op, NOT a failure). Raises after exhausting retries so the caller can
record a real delivery status and avoid marking a handoff 'answered' on a failed send (E)."""
cfg = channel.config or {}
smtp = cfg.get("smtp") or {}
host = smtp.get("host")
if not host or not parsed.get("from_addr"):
return False
secrets = SecretStore()
username = smtp.get("username")
password = None
if smtp.get("password_ref"):
try:
password = await secrets.read_ref(tenant_id=channel.tenant_id, project_id=channel.project_id, ref=smtp["password_ref"])
except Exception: # noqa: BLE001
password = None
from_addr = smtp.get("from") or username or "bot@forge.local"
msg = build_reply(to_addr=parsed["from_addr"], subject=parsed.get("subject", ""), body=answer,
from_addr=from_addr, in_reply_to=parsed.get("message_id"),
references=parsed.get("references"))
port = int(smtp.get("port", 587))
use_tls = bool(smtp.get("use_tls", True))
pw = str(password) if password else None
async def _attempt(_n: int) -> bool:
await asyncio.to_thread(_send_sync, host, port, username, pw, use_tls, msg)
return True
await retry_send(_attempt, label=f"email->{parsed['from_addr']}")
return True