Skip to content

Commit 7cc39d7

Browse files
committed
Add SMTP email-send and PDF read/assert actions
Round out the headless toolkit with two integrations, each wired through the full stack (headless core, facade, AC_ executor command, MCP tool, script-builder schema, tests): - email: send_email / AC_send_email sends mail via stdlib smtplib with TLS on by default (STARTTLS or implicit SSL, verified context), attachments and multiple recipients, so a flow can mail its report. - PDF: extract_pdf_text / pdf_metadata / assert_pdf_text plus the AC_pdf_to_var and AC_assert_pdf_text commands, backed by the optional pypdf extra (clear error when absent), to verify generated documents.
1 parent 8184f80 commit 7cc39d7

13 files changed

Lines changed: 590 additions & 2 deletions

File tree

‎je_auto_control/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@
357357
from je_auto_control.utils.http_client.http_client import http_request
358358
# Ad-hoc read-only SQL query against SQLite
359359
from je_auto_control.utils.sql.sql_query import query_sqlite
360+
# Send email via SMTP
361+
from je_auto_control.utils.email_send.email_sender import send_email
362+
# PDF document text extraction + assertion (optional pypdf backend)
363+
from je_auto_control.utils.pdf.pdf_reader import (
364+
assert_pdf_text, extract_pdf_text, pdf_metadata, pdf_page_count,
365+
)
360366
# package manager
361367
from je_auto_control.utils.package_manager.package_manager_class import \
362368
package_manager
@@ -449,6 +455,8 @@ def start_autocontrol_gui(*args, **kwargs):
449455
"execute_action", "execute_files", "executor",
450456
"execute_action_with_vars", "record_to_json",
451457
"generate_code", "generate_code_file", "http_request", "query_sqlite",
458+
"send_email", "assert_pdf_text", "extract_pdf_text", "pdf_metadata",
459+
"pdf_page_count",
452460
"add_command_to_executor", "test_record_instance", "pil_screenshot",
453461
# OCR
454462
"TextMatch", "find_text_matches", "locate_text_center", "wait_for_text",

‎je_auto_control/gui/script_builder/command_schema.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,33 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None:
624624
),
625625
description="Request a URL; store the body or a JSON field in a variable.",
626626
))
627+
specs.append(CommandSpec(
628+
"AC_pdf_to_var", "Report", "PDF Text into Variable",
629+
fields=(
630+
FieldSpec("path", FieldType.FILE_PATH),
631+
FieldSpec("var", FieldType.STRING, default="pdf_text"),
632+
FieldSpec("page", FieldType.INT, optional=True, min_value=1),
633+
),
634+
description="Extract a PDF's text (all pages or one) into a variable.",
635+
))
636+
specs.append(CommandSpec(
637+
"AC_assert_pdf_text", "Report", "Assert PDF Text",
638+
fields=(
639+
FieldSpec("path", FieldType.FILE_PATH),
640+
FieldSpec("text", FieldType.STRING),
641+
FieldSpec("present", FieldType.BOOL, optional=True, default=True),
642+
FieldSpec("page", FieldType.INT, optional=True, min_value=1),
643+
FieldSpec("case_sensitive", FieldType.BOOL, optional=True,
644+
default=True),
645+
),
646+
description="Assert text is present (or absent) in a PDF document.",
647+
))
648+
specs.append(CommandSpec(
649+
"AC_send_email", "Report", "Send Email",
650+
description=("Send an email via SMTP. Configure the 'message' "
651+
"{sender,to,subject,body,attachments} and 'smtp' "
652+
"{host,port,username,password} dicts in the JSON view."),
653+
))
627654
specs.append(CommandSpec(
628655
"AC_http_request", "Report", "HTTP Request",
629656
fields=(
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Send email via SMTP (the sending companion to the email trigger)."""
2+
from je_auto_control.utils.email_send.email_sender import send_email
3+
4+
__all__ = ["send_email"]
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Send email via SMTP for the ``AC_send_email`` action step.
2+
3+
Dependency-free (stdlib ``smtplib`` + ``email``). Parameters are grouped
4+
into two dicts so the action stays JSON-friendly and within the project's
5+
argument-count limit:
6+
7+
* ``message`` — ``{sender, to, subject, body, cc?, html?, attachments?}``
8+
* ``smtp`` — ``{host, port?, username?, password?, use_tls?, use_ssl?,
9+
timeout?}``
10+
11+
Security: TLS is on by default (STARTTLS on 587, or implicit SSL when
12+
``use_ssl`` is set), the connection uses a verified default SSL context,
13+
every call has an explicit timeout, and credentials are never logged.
14+
Imports no ``PySide6`` so it stays fully headless.
15+
"""
16+
import mimetypes
17+
import os
18+
import smtplib
19+
import ssl
20+
from email.message import EmailMessage
21+
from typing import Any, Dict, List, Mapping, Optional
22+
23+
24+
def _as_list(value: Any) -> List[str]:
25+
"""Normalise a string / iterable of addresses into a list of strings."""
26+
if value is None:
27+
return []
28+
if isinstance(value, str):
29+
return [value]
30+
return [str(item) for item in value]
31+
32+
33+
def _attach_files(mime: EmailMessage, attachments: Any) -> None:
34+
"""Attach each file path in ``attachments`` to ``mime``."""
35+
for raw in attachments or []:
36+
path = os.path.realpath(str(raw))
37+
if not os.path.isfile(path):
38+
raise FileNotFoundError(f"attachment not found: {raw}")
39+
ctype, _ = mimetypes.guess_type(path)
40+
maintype, subtype = (
41+
ctype.split("/", 1) if ctype else ("application", "octet-stream"))
42+
with open(path, "rb") as handle:
43+
mime.add_attachment(handle.read(), maintype=maintype,
44+
subtype=subtype, filename=os.path.basename(path))
45+
46+
47+
def _build_message(message: Mapping[str, Any]) -> EmailMessage:
48+
"""Assemble an :class:`EmailMessage` from the ``message`` spec."""
49+
sender = message.get("sender") or message.get("from")
50+
recipients = _as_list(message.get("to"))
51+
if not sender or not recipients:
52+
raise ValueError("email requires 'sender' and at least one 'to'")
53+
mime = EmailMessage()
54+
mime["From"] = str(sender)
55+
mime["To"] = ", ".join(recipients)
56+
cc = _as_list(message.get("cc"))
57+
if cc:
58+
mime["Cc"] = ", ".join(cc)
59+
mime["Subject"] = str(message.get("subject", ""))
60+
body = str(message.get("body", ""))
61+
mime.set_content(body, subtype="html" if message.get("html") else "plain")
62+
_attach_files(mime, message.get("attachments"))
63+
return mime
64+
65+
66+
def _login_send(server: smtplib.SMTP, username: Optional[str],
67+
password: Optional[str], mime: EmailMessage) -> None:
68+
"""Authenticate (when credentials are given) and send the message."""
69+
if username and password:
70+
server.login(username, password)
71+
server.send_message(mime)
72+
73+
74+
def _deliver(mime: EmailMessage, smtp: Mapping[str, Any]) -> None:
75+
"""Open an SMTP(S) connection per ``smtp`` config and send ``mime``."""
76+
host = smtp.get("host")
77+
if not host:
78+
raise ValueError("smtp 'host' is required")
79+
port = int(smtp.get("port", 587))
80+
timeout = float(smtp.get("timeout", 30.0))
81+
username, password = smtp.get("username"), smtp.get("password")
82+
if bool(smtp.get("use_ssl", False)):
83+
context = ssl.create_default_context()
84+
with smtplib.SMTP_SSL(str(host), port, timeout=timeout,
85+
context=context) as server:
86+
_login_send(server, username, password, mime)
87+
return
88+
with smtplib.SMTP(str(host), port, timeout=timeout) as server:
89+
if bool(smtp.get("use_tls", True)):
90+
server.starttls(context=ssl.create_default_context())
91+
_login_send(server, username, password, mime)
92+
93+
94+
def send_email(message: Mapping[str, Any],
95+
smtp: Mapping[str, Any]) -> Dict[str, Any]:
96+
"""Send an email and return a small result dict.
97+
98+
:param message: ``{sender, to, subject, body, cc?, html?, attachments?}``.
99+
:param smtp: ``{host, port?, username?, password?, use_tls?, use_ssl?,
100+
timeout?}``; TLS is enabled by default.
101+
"""
102+
mime = _build_message(message)
103+
_deliver(mime, smtp)
104+
return {"sent": True, "to": mime["To"], "subject": mime["Subject"]}

‎je_auto_control/utils/executor/action_executor.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2143,6 +2143,22 @@ def _generate_code(source: Any, output: Optional[str] = None,
21432143
return generate_code(actions, target=target, name=name, style=style)
21442144

21452145

2146+
def _send_email(message: Any, smtp: Any) -> Dict[str, Any]:
2147+
"""Adapter: send an email via SMTP (message/smtp config dicts)."""
2148+
from je_auto_control.utils.email_send.email_sender import send_email
2149+
return send_email(message, smtp)
2150+
2151+
2152+
def _assert_pdf_text(path: str, text: str, present: bool = True,
2153+
page: Any = None, case_sensitive: bool = True,
2154+
raise_on_fail: bool = True) -> Dict[str, Any]:
2155+
"""Adapter: assert text is present/absent in a PDF document."""
2156+
from je_auto_control.utils.pdf.pdf_reader import assert_pdf_text
2157+
return assert_pdf_text(path, text, present=bool(present), page=page,
2158+
case_sensitive=bool(case_sensitive),
2159+
raise_on_fail=bool(raise_on_fail))
2160+
2161+
21462162
class Executor:
21472163
"""
21482164
Executor
@@ -2206,6 +2222,8 @@ def __init__(self):
22062222
"AC_generate_json_report": generate_json_report,
22072223
"AC_generate_xml_report": generate_xml_report,
22082224
"AC_generate_code": _generate_code,
2225+
"AC_send_email": _send_email,
2226+
"AC_assert_pdf_text": _assert_pdf_text,
22092227
"AC_http_request": http_request,
22102228

22112229
# Record 錄製

‎je_auto_control/utils/executor/flow_control.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,15 @@ def exec_assert_var(executor: Any, args: Mapping[str, Any]) -> Dict[str, Any]:
431431
).to_dict()
432432

433433

434+
def exec_pdf_to_var(executor: Any, args: Mapping[str, Any]) -> Dict[str, Any]:
435+
"""Extract a PDF's text (all pages or one page) into a flow variable."""
436+
from je_auto_control.utils.pdf.pdf_reader import extract_pdf_text
437+
text = extract_pdf_text(args["path"], pages=args.get("page"))
438+
var_name = args.get("var", "pdf_text")
439+
executor.variables.set(var_name, text)
440+
return {"var": var_name, "length": len(text)}
441+
442+
434443
def exec_sql_to_var(executor: Any, args: Mapping[str, Any]) -> Dict[str, Any]:
435444
"""Run a read-only SQLite query and store its result in a flow variable."""
436445
from je_auto_control.utils.sql.sql_query import query_sqlite
@@ -660,6 +669,7 @@ def exec_call_macro(executor: Any, args: Mapping[str, Any]) -> Any:
660669
"AC_ocr_to_var": exec_ocr_to_var,
661670
"AC_shell_to_var": exec_shell_to_var,
662671
"AC_read_file_to_var": exec_read_file_to_var,
672+
"AC_pdf_to_var": exec_pdf_to_var,
663673
"AC_sql_to_var": exec_sql_to_var,
664674
"AC_assert_db": exec_assert_db,
665675
"AC_http_to_var": exec_http_to_var,

‎je_auto_control/utils/mcp_server/tools/_factories.py‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2142,6 +2142,61 @@ def data_source_tools() -> List[MCPTool]:
21422142
]
21432143

21442144

2145+
def pdf_tools() -> List[MCPTool]:
2146+
return [
2147+
MCPTool(
2148+
name="ac_extract_pdf_text",
2149+
description=("Extract text from a PDF file. 'pages' is null (all "
2150+
"pages), a 1-based page number, or a list of them. "
2151+
"Requires the optional pypdf package."),
2152+
input_schema=schema({
2153+
"path": {"type": "string"},
2154+
"pages": {"type": ["integer", "array", "null"]},
2155+
}, required=["path"]),
2156+
handler=h.extract_pdf_text,
2157+
annotations=READ_ONLY,
2158+
),
2159+
MCPTool(
2160+
name="ac_assert_pdf_text",
2161+
description=("Assert that text is present (or absent when "
2162+
"present=false) in a PDF, optionally restricted to a "
2163+
"1-based 'page'. Set case_sensitive=false for a "
2164+
"case-insensitive match. Raises on failure unless "
2165+
"raise_on_fail is false."),
2166+
input_schema=schema({
2167+
"path": {"type": "string"},
2168+
"text": {"type": "string"},
2169+
"present": {"type": "boolean"},
2170+
"page": {"type": "integer"},
2171+
"case_sensitive": {"type": "boolean"},
2172+
"raise_on_fail": {"type": "boolean"},
2173+
}, required=["path", "text"]),
2174+
handler=h.assert_pdf_text,
2175+
annotations=READ_ONLY,
2176+
),
2177+
]
2178+
2179+
2180+
def email_tools() -> List[MCPTool]:
2181+
return [
2182+
MCPTool(
2183+
name="ac_send_email",
2184+
description=("Send an email via SMTP. 'message' = {sender, to, "
2185+
"subject, body, cc?, html?, attachments?} (to/cc may "
2186+
"be a string or list; attachments are file paths). "
2187+
"'smtp' = {host, port?, username?, password?, "
2188+
"use_tls?, use_ssl?, timeout?}; TLS is on by default. "
2189+
"Sends mail (irreversible side effect)."),
2190+
input_schema=schema({
2191+
"message": {"type": "object"},
2192+
"smtp": {"type": "object"},
2193+
}, required=["message", "smtp"]),
2194+
handler=h.send_email,
2195+
annotations=SIDE_EFFECT_ONLY,
2196+
),
2197+
]
2198+
2199+
21452200
def sql_tools() -> List[MCPTool]:
21462201
return [
21472202
MCPTool(
@@ -2429,7 +2484,7 @@ def media_assert_tools() -> List[MCPTool]:
24292484
scheduler_tools, trigger_tools, hotkey_tools, screen_record_tools,
24302485
process_and_shell_tools, remote_desktop_tools, gamepad_tools,
24312486
usb_passthrough_tools, assertion_tools, data_source_tools,
2432-
sql_tools, http_tools, codegen_tools, flakiness_tools, suite_tools,
2433-
quarantine_tools,
2487+
sql_tools, http_tools, email_tools, pdf_tools, codegen_tools,
2488+
flakiness_tools, suite_tools, quarantine_tools,
24342489
a11y_audit_tools, device_matrix_tools, media_assert_tools,
24352490
)

‎je_auto_control/utils/mcp_server/tools/_handlers.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1789,6 +1789,36 @@ def assert_db(database: str, query: str, params: Any = None,
17891789
).to_dict()
17901790

17911791

1792+
# --- PDF text + assertion --------------------------------------------------
1793+
1794+
def extract_pdf_text(path: str, pages: Any = None) -> str:
1795+
from je_auto_control.utils.pdf.pdf_reader import (
1796+
extract_pdf_text as _extract,
1797+
)
1798+
return _extract(path, pages=pages)
1799+
1800+
1801+
def assert_pdf_text(path: str, text: str, present: bool = True,
1802+
page: Any = None, case_sensitive: bool = True,
1803+
raise_on_fail: bool = True) -> Dict[str, Any]:
1804+
from je_auto_control.utils.pdf.pdf_reader import (
1805+
assert_pdf_text as _assert,
1806+
)
1807+
return _assert(path, text, present=bool(present), page=page,
1808+
case_sensitive=bool(case_sensitive),
1809+
raise_on_fail=bool(raise_on_fail))
1810+
1811+
1812+
# --- Send email (SMTP) -----------------------------------------------------
1813+
1814+
def send_email(message: Dict[str, Any],
1815+
smtp: Dict[str, Any]) -> Dict[str, Any]:
1816+
from je_auto_control.utils.email_send.email_sender import (
1817+
send_email as _send,
1818+
)
1819+
return _send(message, smtp)
1820+
1821+
17921822
# --- HTTP / API request ----------------------------------------------------
17931823

17941824
def http_request(url: str, method: str = "GET",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Read and assert on PDF documents (optional pypdf backend)."""
2+
from je_auto_control.utils.pdf.pdf_reader import (
3+
assert_pdf_text,
4+
extract_pdf_text,
5+
pdf_metadata,
6+
pdf_page_count,
7+
)
8+
9+
__all__ = [
10+
"assert_pdf_text", "extract_pdf_text", "pdf_metadata", "pdf_page_count",
11+
]

0 commit comments

Comments
 (0)