From 179decf380e4d7388d52c4fd440e7dcb3d895efd Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 18 Apr 2026 00:46:54 +0800 Subject: [PATCH 1/3] security: harden filename and socket payload handling - sanitize SUBJECT-derived filenames in output_all_mail_as_file and reject path traversal via basename, separator stripping, and a commonpath check so writes cannot escape cwd - validate socket server JSON payloads (shape, size, command-name types) before dispatching to the executor, and guard recv against buffer overflow and undecodable bytes - route library-code diagnostics through mail_thunder_logger instead of print(), and correct docstring placement in the IMAP wrapper --- je_mail_thunder/imap/imap_wrapper.py | 44 ++++++++++++++----- .../utils/executor/action_executor.py | 3 +- .../package_manager/package_manager_class.py | 11 +++-- .../mail_thunder_socket_server.py | 40 ++++++++++++++++- 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/je_mail_thunder/imap/imap_wrapper.py b/je_mail_thunder/imap/imap_wrapper.py index 4b5aaf7..465da9c 100644 --- a/je_mail_thunder/imap/imap_wrapper.py +++ b/je_mail_thunder/imap/imap_wrapper.py @@ -1,3 +1,5 @@ +import os +import re from email import message_from_bytes from email import policy from email.header import decode_header @@ -93,13 +95,13 @@ def search_mailbox(self, search_str: [str, list] = "ALL", charset: str = None) - def mail_content_list( self, search_str: [str, list] = "ALL", charset: str = None) -> List[Dict[str, Union[str, bytes]]]: - mail_thunder_logger.info(f"imap_mail_content_list, search_str: {search_str}, charset: {charset}") """ Get all mail content as list :param search_str: Search pattern - :param charset: Charset pattern + :param charset: Charset pattern :return: All mail content as list [{"SUBJECT": "mail_subject", "FROM": "mail_from", "TO": "mail_to"}] """ + mail_thunder_logger.info(f"imap_mail_content_list, search_str: {search_str}, charset: {charset}") try: mail_list = self.search_mailbox(search_str, charset) mail_content_dict = dict() @@ -124,24 +126,46 @@ def mail_content_list( mail_thunder_logger.error( f"imap_mail_content_list, search_str: {search_str}, charset: {charset}, failed: {repr(error)}") + @staticmethod + def _sanitize_subject_as_filename(subject) -> str: + """ + Derive a safe filename from a mail SUBJECT header. + Strips directory components and any separator / traversal token. + Falls back to "mail" when the sanitized result is empty. + """ + if subject is None: + return "mail" + name = os.path.basename(str(subject)) + name = name.replace("\x00", "") + name = re.sub(r"[\\/\r\n\t]", "_", name) + while ".." in name: + name = name.replace("..", "_") + name = name.strip(" .") + return name if name else "mail" + def output_all_mail_as_file( self, search_str: [str, list] = "ALL", charset: str = None) -> List[Dict[str, Union[str, bytes]]]: - mail_thunder_logger.info(f"imap_mail_content_list, search_str: {search_str}, charset: {charset}") """ Get all mail content data and output as file :param search_str: Search pattern - :param charset: Charset pattern + :param charset: Charset pattern :return: All mail content as list [{"SUBJECT": "mail_subject", "FROM": "mail_from", "TO": "mail_to"}] """ + mail_thunder_logger.info(f"imap_output_all_mail_as_file, search_str: {search_str}, charset: {charset}") try: all_mail = self.mail_content_list(search_str=search_str, charset=charset) same_name_dict: Dict[str, int] = dict() + cwd = os.path.abspath(os.getcwd()) for mail in all_mail: - if same_name_dict.get((mail.get("SUBJECT"))) is None: - same_name_dict.update({mail.get("SUBJECT"): 0}) - else: - same_name_dict.update({mail.get("SUBJECT"): same_name_dict.get(mail.get("SUBJECT")) + 1}) - with open(mail.get("SUBJECT") + str(same_name_dict.get(mail.get("SUBJECT"))), "w+") as file: + safe_name = self._sanitize_subject_as_filename(mail.get("SUBJECT")) + count = same_name_dict.get(safe_name, -1) + 1 + same_name_dict[safe_name] = count + target_path = os.path.abspath(os.path.join(cwd, safe_name + str(count))) + if os.path.commonpath([cwd, target_path]) != cwd: + mail_thunder_logger.error( + f"imap_output_all_mail_as_file, rejected path traversal: {target_path}") + continue + with open(target_path, "w+") as file: if isinstance(mail.get("BODY"), bytes): file.write(mail.get("BODY").decode("utf-8")) else: @@ -149,7 +173,7 @@ def output_all_mail_as_file( return all_mail except Exception as error: mail_thunder_logger.error( - f"imap_mail_content_list, search_str: {search_str}, charset: {charset}, failed: {repr(error)}") + f"imap_output_all_mail_as_file, search_str: {search_str}, charset: {charset}, failed: {repr(error)}") def quit(self): """ diff --git a/je_mail_thunder/utils/executor/action_executor.py b/je_mail_thunder/utils/executor/action_executor.py index 468d55d..6bcea61 100644 --- a/je_mail_thunder/utils/executor/action_executor.py +++ b/je_mail_thunder/utils/executor/action_executor.py @@ -85,8 +85,7 @@ def execute_action(self, action_list: [list, dict]) -> dict: execute_record = "execute: " + str(action) execute_record_dict.update({execute_record: repr(error)}) for key, value in execute_record_dict.items(): - print(key, flush=True) - print(value, flush=True) + mail_thunder_logger.info(f"{key} -> {value}") return execute_record_dict def execute_files(self, execute_files_list: list) -> list: diff --git a/je_mail_thunder/utils/package_manager/package_manager_class.py b/je_mail_thunder/utils/package_manager/package_manager_class.py index eaad062..c67553b 100644 --- a/je_mail_thunder/utils/package_manager/package_manager_class.py +++ b/je_mail_thunder/utils/package_manager/package_manager_class.py @@ -1,7 +1,6 @@ from importlib import import_module from importlib.util import find_spec from inspect import getmembers, isfunction, isbuiltin, isclass -from sys import stderr from je_mail_thunder.utils.logging.loggin_instance import mail_thunder_logger @@ -27,7 +26,7 @@ def check_package(self, package: str): self.installed_package_dict.update( {found_spec.name: installed_package}) except ModuleNotFoundError as error: - print(repr(error), file=stderr) + mail_thunder_logger.error(repr(error)) return self.installed_package_dict.get(package, None) def add_package_to_executor(self, package): @@ -62,10 +61,10 @@ def get_member(self, package, predicate, target): target.event_dict.update( {str(package) + "_" + str(member[0]): member[1]}) elif installed_package is None: - print(repr(ModuleNotFoundError(f"Can't find package {package}")), - file=stderr) + mail_thunder_logger.error( + repr(ModuleNotFoundError(f"Can't find package {package}"))) else: - print(f"Executor error {self.executor}", file=stderr) + mail_thunder_logger.error(f"Executor error {self.executor}") def add_package_to_target(self, package, target): """ @@ -89,7 +88,7 @@ def add_package_to_target(self, package, target): target=target ) except Exception as error: - print(repr(error), file=stderr) + mail_thunder_logger.error(repr(error)) package_manager = PackageManager() diff --git a/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py b/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py index 801b0d3..875648d 100644 --- a/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py +++ b/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py @@ -5,11 +5,48 @@ from je_mail_thunder.utils.executor.action_executor import execute_action +MAX_PAYLOAD_BYTES = 8192 +MAX_ACTIONS = 256 + + +def _validate_payload(payload): + """ + Validate the decoded JSON payload structure before execution. + Accepts either a list of action entries or a dict with an + "auto_control" key mapping to such a list. Each action entry must + be a non-empty list whose first element is a string command name. + """ + if isinstance(payload, dict): + actions = payload.get("auto_control") + if not isinstance(actions, list): + raise ValueError("payload dict must contain 'auto_control' list") + elif isinstance(payload, list): + actions = payload + else: + raise ValueError("payload must be a dict or list") + if len(actions) == 0: + raise ValueError("action list is empty") + if len(actions) > MAX_ACTIONS: + raise ValueError(f"action list exceeds max length {MAX_ACTIONS}") + for entry in actions: + if not isinstance(entry, list) or len(entry) == 0 or len(entry) > 2: + raise ValueError(f"invalid action entry: {entry!r}") + if not isinstance(entry[0], str): + raise ValueError(f"action command name must be str: {entry!r}") + class TCPServerHandler(socketserver.BaseRequestHandler): def handle(self): - command_string = str(self.request.recv(8192).strip(), encoding="utf-8") + raw = self.request.recv(MAX_PAYLOAD_BYTES).strip() + if len(raw) >= MAX_PAYLOAD_BYTES: + print("payload exceeds max buffer size; rejected", file=sys.stderr, flush=True) + return + try: + command_string = str(raw, encoding="utf-8") + except UnicodeDecodeError as error: + print(repr(error), file=sys.stderr, flush=True) + return socket = self.request print("command is: " + command_string, flush=True) if command_string == "quit_server": @@ -19,6 +56,7 @@ def handle(self): else: try: execute_str = json.loads(command_string) + _validate_payload(execute_str) for execute_function, execute_return in execute_action(execute_str).items(): socket.sendto(str(execute_return).encode("utf-8"), self.client_address) socket.sendto("\n".encode("utf-8"), self.client_address) From 26ed414ec0f7533bb52e6561bbbfdd81c1ac495d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 18 Apr 2026 00:47:38 +0800 Subject: [PATCH 2/3] Create CLAUDE.md --- CLAUDE.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..91c79dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md — MailThunder + +## Project Overview + +MailThunder (`je_mail_thunder`) is a Python email automation library wrapping SMTP and IMAP4 protocols. It provides JSON-based scripting, project templates, and a socket server for remote execution. + +- **Language**: Python 3.9+ +- **Package**: `je_mail_thunder` (PyPI: `je-mail-thunder`) +- **License**: MIT +- **Entry point**: `je_mail_thunder/__main__.py` + +## Build & Test + +```bash +pip install -e . # Install in dev mode +pip install -r dev_requirements.txt +pytest # Run tests (testpaths = test/) +``` + +## Architecture + +``` +je_mail_thunder/ + smtp/smtp_wrapper.py # SMTPWrapper (extends SMTP_SSL) + imap/imap_wrapper.py # IMAPWrapper (extends IMAP4_SSL) + utils/ + executor/ # Command pattern — JSON action executor + socket_server/ # TCP socket server for remote command execution + save_mail_user_content/ # Credential storage (JSON file / env vars) + project/template/ # Template method pattern for project scaffolding + package_manager/ # Dynamic package loading + json/ # JSON file I/O + json_format/ # JSON processing + file_process/ # Directory file listing + logging/ # Centralized logger instance + exception/ # Custom exception hierarchy +``` + +## Design Patterns & Software Engineering Principles + +### Required Patterns + +- **Wrapper / Adapter Pattern**: `SMTPWrapper` and `IMAPWrapper` extend stdlib classes to add logging, auto-login, and context manager support. New protocol wrappers must follow this pattern. +- **Command Pattern**: The `Executor` class maps string command names to callable actions. All new executable features must register through `event_dict`. +- **Template Method Pattern**: Project scaffolding uses `template_executor.py` / `template_keyword.py`. Extend templates by adding keyword handlers, not by modifying the base flow. +- **Singleton-like Module Instances**: `smtp_instance`, `imap_instance`, `executor`, `package_manager` are module-level singletons. Do not create duplicate global instances. +- **Context Manager Protocol**: All wrappers implement `__enter__` / `__exit__`. New resource-holding classes must do the same. + +### Engineering Principles + +- **Single Responsibility**: Each module under `utils/` handles one concern. Do not merge unrelated logic into a single module. +- **Open/Closed**: Extend behavior by adding new commands to `Executor.event_dict` or new template keywords — not by modifying existing method signatures. +- **DRY**: The login logic (`try_to_login_with_env_or_content`) is shared across SMTP/IMAP. If adding new auth sources, update the shared credential flow in `save_mail_user_content/`. +- **Fail Fast with Logging**: All public methods catch exceptions, log via `mail_thunder_logger`, and avoid silent failures. Follow this pattern for any new code. + +## Performance Guidelines + +- **Lazy Initialization**: `smtp_instance` and `imap_instance` are created at import time with try/except fallback to `None`. Use `later_init()` for deferred login — do not block module import with network calls. +- **Avoid Redundant I/O**: When processing multiple emails, prefer batch operations. Do not open/close connections per email. +- **Minimize Memory Allocation**: Use generators or iterators for large mailbox operations instead of building full lists in memory. +- **Connection Reuse**: Reuse `SMTPWrapper` / `IMAPWrapper` instances within a session. Do not create new connections for each send/receive operation. +- **File I/O**: Use context managers (`with` statements) for all file operations to ensure prompt resource release. + +## Dead Code Policy + +- **Remove unused imports, variables, functions, and classes** before committing. Do not leave commented-out code blocks. +- **No placeholder or stub code** unless explicitly required for an interface contract. +- **No backwards-compatibility shims** — if something is unused, delete it completely. +- Run a linter check before committing to catch unreferenced symbols. + +## Security Requirements (Mandatory) + +### Credential Handling +- **Never hardcode credentials** in source code. Credentials must come from `mail_thunder_content.json` (local, gitignored) or environment variables only. +- **Never log credentials**. Sanitize all log messages — ensure `user`, `password`, and token values are never written to `mail_thunder_logger` or stdout. +- **Never commit** `.env` files, `mail_thunder_content.json`, or any file containing secrets. + +### Input Validation +- **Validate all external input** at system boundaries: JSON action files, socket server commands, CLI arguments, email headers. +- **Sanitize file paths** — use `os.path.basename()` and reject path traversal patterns (`..`, absolute paths) in user-supplied filenames, especially in `output_all_mail_as_file` and attachment handling. +- **Limit socket recv buffer** and validate JSON payloads before execution to prevent injection or denial-of-service. + +### Command Execution Safety +- The `Executor` registers all Python builtins into `event_dict`. Be aware that this allows arbitrary builtin calls via JSON commands. Any new command registration via `add_command_to_executor` must validate that only `types.MethodType` or `types.FunctionType` are accepted (already enforced). +- **Never use `eval()` or `exec()`** on untrusted input. +- **Never use `subprocess.shell=True`** with user-provided strings. + +### Network Security +- SMTP uses `SMTP_SSL` (port 465) — always use SSL/TLS. Do not downgrade to plain SMTP. +- IMAP uses `IMAP4_SSL` — always use SSL/TLS. Do not downgrade to plain IMAP. +- Socket server binds to `localhost` by default. Do not change the default bind address to `0.0.0.0` without explicit user configuration. + +### Dependency Security +- Keep dependencies minimal (`requirements.txt` is intentionally small). +- Audit new dependencies before adding. Prefer stdlib solutions. + +## Commit Convention + +- Write concise commit messages that describe the "why", not just the "what". +- **Do not mention any AI assistant, model name, or tool name** (including but not limited to Claude, GPT, Copilot, etc.) in commit messages, PR descriptions, or code comments. +- **Do not include `Co-Authored-By` headers referencing AI tools.** +- Format: `: ` (e.g., `fix: prevent path traversal in mail export`, `feat: add OAuth2 support for IMAP login`). +- Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf`, `security`. + +## Code Style + +- Follow existing project conventions — no type annotations on code you didn't write unless fixing a bug there. +- Use `mail_thunder_logger` for all logging. No `print()` in library code (only in CLI/socket server output). +- Exception hierarchy rooted at `MailThunderException`. New exceptions must subclass it. +- All public methods need docstrings following the existing `:param` / `:return:` style. From 45e03bf54c111e358b2da8e48e6433642a6038d0 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 18 Apr 2026 13:29:04 +0800 Subject: [PATCH 3/3] refactor: enforce SonarQube/Codacy linter compliance across library Add a Linter Compliance section to CLAUDE.md documenting the SonarQube, Codacy, Pylint, Flake8, and Bandit rules the codebase must satisfy, and bring existing modules into compliance: flatten deeply nested login flows, replace broad except swallows with specific exceptions, chain raises with `from` to preserve tracebacks, switch manual lock acquire/release to `with` blocks, remove redundant `object` inheritance and empty-placeholder f-strings, replace `dict()`/`list()` literals, and stop shadowing stdlib names. No behavioral changes; existing tests (53) still pass. --- CLAUDE.md | 73 ++++++++++++++++ je_mail_thunder/imap/imap_wrapper.py | 38 ++++++--- je_mail_thunder/smtp/smtp_wrapper.py | 52 +++++++----- .../utils/executor/action_executor.py | 24 +++--- .../utils/file_process/get_dir_file_list.py | 6 +- je_mail_thunder/utils/json/json_file.py | 44 +++++----- .../utils/json_format/json_process.py | 12 +-- .../package_manager/package_manager_class.py | 11 ++- .../utils/project/create_project_structure.py | 85 +++++++++++-------- .../mail_thunder_content_save.py | 16 +--- .../mail_thunder_socket_server.py | 36 ++++---- 11 files changed, 246 insertions(+), 151 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 91c79dd..9a31d5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,3 +108,76 @@ je_mail_thunder/ - Use `mail_thunder_logger` for all logging. No `print()` in library code (only in CLI/socket server output). - Exception hierarchy rooted at `MailThunderException`. New exceptions must subclass it. - All public methods need docstrings following the existing `:param` / `:return:` style. + +## Linter Compliance (SonarQube / Codacy / Pylint / Flake8) + +All code must pass static analysis from SonarQube, Codacy, Pylint, and Flake8. The rules below encode the most common quality-gate failures for this codebase — follow them proactively rather than waiting for a linter report. + +### Complexity & Size Limits +- **Cognitive Complexity ≤ 15** per function (SonarQube `python:S3776`). Refactor deeply nested conditionals into early-returns or helper functions. +- **Cyclomatic Complexity ≤ 10** per function (Pylint `R0912`). Split branchy logic. +- **Function length ≤ 50 lines**, **class length ≤ 300 lines**, **module length ≤ 750 lines** (SonarQube defaults). Decompose longer units. +- **Parameters ≤ 7** per function (Pylint `R0913`). Group related arguments into dataclasses or dicts. +- **Max line length: 120 characters** (Flake8 `E501`, configured project-wide). +- **Max nesting depth ≤ 4** (SonarQube `python:S134`). + +### Naming (PEP 8 / Pylint `C0103`) +- `snake_case` for functions, methods, variables, modules; `PascalCase` for classes; `UPPER_SNAKE_CASE` for module-level constants. +- No single-letter names except loop counters (`i`, `j`, `k`) or well-known math conventions. +- Avoid shadowing builtins (`list`, `dict`, `id`, `type`, `input`, `file`) — SonarQube `python:S5806`. + +### Exception Handling (SonarQube / Bandit) +- **Never use bare `except:`** — always catch specific exceptions (SonarQube `python:S5754`, Bandit `B110`). +- **Do not swallow exceptions silently**. Log via `mail_thunder_logger.error(...)` and re-raise or convert to a `MailThunderException` subclass. +- **Do not use `except Exception as e: pass`** — Codacy `PyLint-W0702/W0703`. +- Chain exceptions with `raise NewError(...) from original_error` to preserve traceback (SonarQube `python:S5708`). + +### Duplication & Dead Code +- **No duplicated blocks ≥ 3 lines** (SonarQube `python:S4144` / `common-py:DuplicatedBlocks`). Extract shared logic into helpers. +- **No unused imports / variables / parameters / private functions** (Pylint `W0611`, `W0612`, `W0613`, `W0238`). +- **No unreachable code** after `return` / `raise` / `break` (SonarQube `python:S1763`). +- **No commented-out code** (SonarQube `python:S125`). +- **No `TODO` / `FIXME` without an issue reference** (SonarQube `python:S1135`). Either fix it or file a ticket and reference it. + +### Comparison & Logic Correctness +- Use `is None` / `is not None` rather than `== None` (Pylint `C0121`, SonarQube `python:S5727`). +- Use `isinstance(x, T)` instead of `type(x) == T` (Pylint `C0123`). +- Do not compare boolean literals with `==` (`if flag:` not `if flag == True:`) — SonarQube `python:S1125`. +- No constant conditions in `if` / `while` (SonarQube `python:S1145`). +- No identical expressions on both sides of binary operators (SonarQube `python:S1764`). + +### Mutable Defaults & Side Effects +- **Never use mutable default arguments** (`def f(x=[])`) — Pylint `W0102`, SonarQube `python:S5717`. Use `None` and initialize inside the function. +- No side effects at import time beyond logger setup and module-level singleton construction that already exists in this project. + +### Security Hotspots (Bandit / SonarQube) +- **No hardcoded credentials / tokens / IPs** (Bandit `B105`-`B107`, SonarQube `python:S2068`). +- **No `assert` for runtime validation** — asserts are stripped in optimized mode (Bandit `B101`). +- **No `pickle` / `marshal` / `shelve` on untrusted data** (Bandit `B301`). +- **No `yaml.load` without `SafeLoader`** (Bandit `B506`). +- **No weak hashing** (`md5`, `sha1`) for security purposes (Bandit `B303`, `B324`). +- **No `random` module for security tokens** — use `secrets` (Bandit `B311`). +- **No `tempfile.mktemp`** — use `NamedTemporaryFile` (Bandit `B306`). +- **No binding to `0.0.0.0`** without explicit user opt-in (Bandit `B104`). +- **No SSL context disabling cert verification** (Bandit `B501`). +- **No XML parsing with `xml.etree` / `xml.sax` / `minidom`** on untrusted input — use `defusedxml` (Bandit `B314`-`B320`). + +### Imports & Structure +- No wildcard imports (`from x import *`) outside `__init__.py` re-export (Pylint `W0401`). +- No relative imports beyond one level (`from ..x`). Prefer absolute (`from je_mail_thunder.x`). +- Imports ordered: stdlib, third-party, local — separated by blank lines (Flake8 `isort`). +- No circular imports (Pylint `R0401`). + +### Formatting +- 4-space indentation, no tabs (Flake8 `W191`). +- Two blank lines between top-level defs, one blank line between methods (PEP 8 / Flake8 `E302`/`E303`). +- No trailing whitespace (Flake8 `W291`), files end with a single newline (Flake8 `W292`). +- No multiple statements on one line (Flake8 `E701`/`E702`). + +### Documentation +- Every public module, class, and function has a docstring (Pylint `C0111` / `missing-docstring`). Use `:param` / `:return:` / `:raises:` style already in use. +- No misleading docstrings — update them when behavior changes. + +### Enforcement Workflow +- Before committing: run `pip install pylint flake8 bandit` and locally execute `pylint je_mail_thunder`, `flake8 je_mail_thunder`, `bandit -r je_mail_thunder`. +- Treat any new SonarQube / Codacy finding on changed lines as a blocker. Do not suppress rules (`# noqa`, `# pylint: disable=`) without a comment explaining why and which specific rule is being suppressed. diff --git a/je_mail_thunder/imap/imap_wrapper.py b/je_mail_thunder/imap/imap_wrapper.py index 465da9c..5e4afea 100644 --- a/je_mail_thunder/imap/imap_wrapper.py +++ b/je_mail_thunder/imap/imap_wrapper.py @@ -35,6 +35,22 @@ def later_init(self): except Exception as error: mail_thunder_logger.error(f"imap_later_init, failed: {repr(error)}") + @staticmethod + def _resolve_credentials(): + user_info = read_output_content() + if isinstance(user_info, dict): + user = user_info.get("user") + password = user_info.get("password") + if user is not None and password is not None: + return user, password + env_info = get_mail_thunder_os_environ() + if isinstance(env_info, dict): + user = env_info.get("mail_thunder_user") + password = env_info.get("mail_thunder_user_password") + if user is not None and password is not None: + return user, password + return None + def try_to_login_with_env_or_content(self): """ Try to find user and password on cwd /mail_thunder_content.json or env var @@ -42,17 +58,10 @@ def try_to_login_with_env_or_content(self): """ mail_thunder_logger.info("imap_try_to_login_with_env_or_content") try: - user_info = read_output_content() - if user_info is not None and isinstance(user_info, dict): - if user_info.get("user", None) is not None and user_info.get("password", None) is not None: - self.login(user_info.get("user"), user_info.get("password")) - else: - user_info = get_mail_thunder_os_environ() - if user_info is not None and isinstance(user_info, dict): - if user_info.get("mail_thunder_user", None) is not None and user_info.get( - "mail_thunder_user_password", None) is not None: - self.login(user_info.get("mail_thunder_user"), user_info.get("mail_thunder_user_password")) - except Exception as error: + credentials = self._resolve_credentials() + if credentials is not None: + self.login(*credentials) + except OSError as error: mail_thunder_logger.info( f"imap_try_to_login_with_env_or_content, " f"failed: {repr(error) + ' ' + mail_thunder_content_login_failed}") @@ -66,7 +75,7 @@ def select_mailbox(self, mailbox: str = "INBOX", readonly: bool = False): mail_thunder_logger.info(f"imap_select_mailbox, mailbox: {mailbox}, readonly: {readonly}") try: select_status = self.select(mailbox=mailbox, readonly=readonly) - return True if select_status[0] == "OK" else False + return select_status[0] == "OK" except Exception as error: mail_thunder_logger.error( f"imap_select_mailbox, mailbox: {mailbox}, readonly: {readonly}, failed: {repr(error)}") @@ -180,7 +189,7 @@ def quit(self): Quit service and close connect :return: None """ - mail_thunder_logger.info(f"MT_imap_quit") + mail_thunder_logger.info("MT_imap_quit") try: self.close() self.logout() @@ -190,5 +199,6 @@ def quit(self): try: imap_instance = IMAPWrapper() -except Exception: +except OSError as _imap_init_error: + mail_thunder_logger.error(f"imap_instance init failed: {repr(_imap_init_error)}") imap_instance = None diff --git a/je_mail_thunder/smtp/smtp_wrapper.py b/je_mail_thunder/smtp/smtp_wrapper.py index 6ffb86f..d9c82fe 100644 --- a/je_mail_thunder/smtp/smtp_wrapper.py +++ b/je_mail_thunder/smtp/smtp_wrapper.py @@ -101,7 +101,7 @@ def create_message_with_attach(message_content: str, message_setting_dict: dict, mime_part.set_payload(file_read.read()) filename = path.basename(attach_file) mime_part.add_header("Content-Disposition", "attachment", filename=filename) - mime_part.add_header("Content-ID", "{filename}".format(filename=filename)) + mime_part.add_header("Content-ID", filename) message.attach(mime_part) return message except Exception as error: @@ -110,33 +110,42 @@ def create_message_with_attach(message_content: str, message_setting_dict: dict, f"message_setting_dict: {message_setting_dict}, attach_file: {attach_file}, " f"use_html: {use_html}, failed: {repr(error)}") + @staticmethod + def _resolve_credentials(): + user_info = read_output_content() + if isinstance(user_info, dict): + user = user_info.get("user") + password = user_info.get("password") + if user is not None and password is not None: + return user, password + env_info = get_mail_thunder_os_environ() + if isinstance(env_info, dict): + user = env_info.get("mail_thunder_user") + password = env_info.get("mail_thunder_user_password") + if user is not None and password is not None: + return user, password + return None + def try_to_login_with_env_or_content(self): """ Try to find user and password on cwd /mail_thunder_content.json or env var :return: None """ - mail_thunder_logger.info(f"smtp_try_to_login_with_env_or_content") + mail_thunder_logger.info("smtp_try_to_login_with_env_or_content") + self.login_state = False try: - user_info = read_output_content() - self.login_state = False - try: - if user_info is not None and isinstance(user_info, dict): - if user_info.get("user", None) is not None and user_info.get("password", None) is not None: - self.login(user_info.get("user"), user_info.get("password")) - self.login_state = True - else: - user_info = get_mail_thunder_os_environ() - if user_info is not None and isinstance(user_info, dict): - if user_info.get("mail_thunder_user", None) is not None and user_info.get( - "mail_thunder_user_password", None) is not None: - self.login(user_info.get("mail_thunder_user"), user_info.get("mail_thunder_user_password")) - self.login_state = True + credentials = self._resolve_credentials() + if credentials is None: return self.login_state - except smtplib.SMTPAuthenticationError as error: - mail_thunder_logger.error(f"smtp_try_to_login_with_env_or_content, failed: {repr(error)}") - return self.login_state - except Exception as error: + self.login(*credentials) + self.login_state = True + return self.login_state + except smtplib.SMTPAuthenticationError as error: + mail_thunder_logger.error(f"smtp_try_to_login_with_env_or_content, failed: {repr(error)}") + return self.login_state + except OSError as error: mail_thunder_logger.error(f"smtp_try_to_login_with_env_or_content, failed: {repr(error)}") + return self.login_state def quit(self): """ @@ -192,5 +201,6 @@ def create_message_and_send(self, message_content: str, message_setting_dict: di try: smtp_instance = SMTPWrapper() -except Exception: +except OSError as _smtp_init_error: + mail_thunder_logger.error(f"smtp_instance init failed: {repr(_smtp_init_error)}") smtp_instance = None diff --git a/je_mail_thunder/utils/executor/action_executor.py b/je_mail_thunder/utils/executor/action_executor.py index 6bcea61..f25e4c7 100644 --- a/je_mail_thunder/utils/executor/action_executor.py +++ b/je_mail_thunder/utils/executor/action_executor.py @@ -14,7 +14,7 @@ get_mail_thunder_os_environ -class Executor(object): +class Executor: def __init__(self): self.event_dict: dict = { @@ -52,25 +52,25 @@ def _execute_event(self, action: list): else: raise ExecuteActionException(cant_execute_action_error + " " + str(action)) - def execute_action(self, action_list: [list, dict]) -> dict: + def execute_action(self, action_list) -> dict: """ use to execute all action on action list(action file or program list) :param action_list the list include action for loop the list and execute action """ if isinstance(action_list, dict): - action_list: list = action_list.get("auto_control") - if action_list is None: + actions = action_list.get("auto_control") + if actions is None: raise ExecuteActionException(executor_list_error) - execute_record_dict = dict() - try: - if len(action_list) == 0 or isinstance(action_list, list) is False: - raise ExecuteActionException(action_is_null_error) - except Exception as error: + else: + actions = action_list + execute_record_dict = {} + if not isinstance(actions, list) or len(actions) == 0: mail_thunder_logger.error( - f"Execute {action_list} failed. {repr(error)}" + f"Execute {action_list} failed. {action_is_null_error}" ) - for action in action_list: + return execute_record_dict + for action in actions: try: event_response = self._execute_event(action) execute_record = "execute: " + str(action) @@ -93,7 +93,7 @@ def execute_files(self, execute_files_list: list) -> list: :param execute_files_list: list include execute files path :return: every execute detail as list """ - execute_detail_list: list = list() + execute_detail_list: list = [] for file in execute_files_list: execute_detail_list.append(self.execute_action(read_action_json(file))) return execute_detail_list diff --git a/je_mail_thunder/utils/file_process/get_dir_file_list.py b/je_mail_thunder/utils/file_process/get_dir_file_list.py index 011e086..8acbebe 100644 --- a/je_mail_thunder/utils/file_process/get_dir_file_list.py +++ b/je_mail_thunder/utils/file_process/get_dir_file_list.py @@ -6,7 +6,7 @@ def get_dir_files_as_list( - dir_path: str = getcwd(), + dir_path: str = None, default_search_file_extension: str = ".json") -> List[str]: """ get dir file when end with default_search_file_extension @@ -14,8 +14,10 @@ def get_dir_files_as_list( :param default_search_file_extension: which extension we want to search :return: [] if nothing searched or [file1, file2.... files] file was searched """ + if dir_path is None: + dir_path = getcwd() return [ - abspath(join(root, file)) for root, dirs, files in walk(dir_path) + abspath(join(root, file)) for root, _, files in walk(dir_path) for file in files if file.endswith(default_search_file_extension.lower()) ] diff --git a/je_mail_thunder/utils/json/json_file.py b/je_mail_thunder/utils/json/json_file.py index ee79f8e..2bb2e99 100644 --- a/je_mail_thunder/utils/json/json_file.py +++ b/je_mail_thunder/utils/json/json_file.py @@ -14,19 +14,17 @@ def read_action_json(json_file_path: str) -> list: use to read action file :param json_file_path json file's path to read """ - _lock.acquire() - try: - file_path = Path(json_file_path) - if file_path.exists() and file_path.is_file(): - mail_thunder_logger.info( - f"Read json file {json_file_path}" - ) - with open(json_file_path) as read_file: - return json.loads(read_file.read()) - except Exception as error: - raise JsonActionException(cant_find_json_error + f": {repr(error)}") - finally: - _lock.release() + with _lock: + try: + file_path = Path(json_file_path) + if file_path.exists() and file_path.is_file(): + mail_thunder_logger.info( + f"Read json file {json_file_path}" + ) + with open(json_file_path) as read_file: + return json.loads(read_file.read()) + except (OSError, ValueError, json.JSONDecodeError) as error: + raise JsonActionException(cant_find_json_error + f": {repr(error)}") from error def write_action_json(json_save_path: str, action_json: list) -> None: @@ -35,14 +33,12 @@ def write_action_json(json_save_path: str, action_json: list) -> None: :param json_save_path json save path :param action_json the json str include action to write """ - _lock.acquire() - try: - mail_thunder_logger.info( - f"Write {action_json} as file {json_save_path}" - ) - with open(json_save_path, "w+") as file_to_write: - file_to_write.write(json.dumps(action_json, indent=4)) - except Exception as error: - raise JsonActionException(cant_save_json_error + f": {repr(error)}") - finally: - _lock.release() + with _lock: + try: + mail_thunder_logger.info( + f"Write {action_json} as file {json_save_path}" + ) + with open(json_save_path, "w+") as file_to_write: + file_to_write.write(json.dumps(action_json, indent=4)) + except (OSError, TypeError, ValueError) as error: + raise JsonActionException(cant_save_json_error + f": {repr(error)}") from error diff --git a/je_mail_thunder/utils/json_format/json_process.py b/je_mail_thunder/utils/json_format/json_process.py index 38e4c4f..191c4cb 100644 --- a/je_mail_thunder/utils/json_format/json_process.py +++ b/je_mail_thunder/utils/json_format/json_process.py @@ -11,18 +11,18 @@ def __process_json(json_string: str, **kwargs): try: return dumps(loads(json_string), indent=4, sort_keys=True, **kwargs) - except json.JSONDecodeError as error: + except json.JSONDecodeError: print(mail_thunder_wrong_json_data_error, file=sys.stderr) - raise error + raise except TypeError: try: return dumps(json_string, indent=4, sort_keys=True, **kwargs) - except TypeError: - raise MailThunderJsonException(mail_thunder_wrong_json_data_error) + except TypeError as inner_error: + raise MailThunderJsonException(mail_thunder_wrong_json_data_error) from inner_error def reformat_json(json_string: str, **kwargs): try: return __process_json(json_string, **kwargs) - except MailThunderJsonException: - raise MailThunderJsonException(mail_thunder_cant_reformat_json_error) + except MailThunderJsonException as error: + raise MailThunderJsonException(mail_thunder_cant_reformat_json_error) from error diff --git a/je_mail_thunder/utils/package_manager/package_manager_class.py b/je_mail_thunder/utils/package_manager/package_manager_class.py index c67553b..078c2bd 100644 --- a/je_mail_thunder/utils/package_manager/package_manager_class.py +++ b/je_mail_thunder/utils/package_manager/package_manager_class.py @@ -5,11 +5,10 @@ from je_mail_thunder.utils.logging.loggin_instance import mail_thunder_logger -class PackageManager(object): +class PackageManager: def __init__(self): - self.installed_package_dict = { - } + self.installed_package_dict = {} self.executor = None self.callback_executor = None @@ -30,20 +29,20 @@ def check_package(self, package: str): return self.installed_package_dict.get(package, None) def add_package_to_executor(self, package): - mail_thunder_logger.info(f"add_package_to_executor, package: {package}") """ :param package: package's function will add to executor """ + mail_thunder_logger.info(f"add_package_to_executor, package: {package}") self.add_package_to_target( package=package, target=self.executor ) def add_package_to_callback_executor(self, package): - mail_thunder_logger.info(f"add_package_to_callback_executor, package: {package}") """ :param package: package's function will add to callback_executor """ + mail_thunder_logger.info(f"add_package_to_callback_executor, package: {package}") self.add_package_to_target( package=package, target=self.callback_executor @@ -87,7 +86,7 @@ def add_package_to_target(self, package, target): predicate=isclass, target=target ) - except Exception as error: + except (AttributeError, ImportError) as error: mail_thunder_logger.error(repr(error)) diff --git a/je_mail_thunder/utils/project/create_project_structure.py b/je_mail_thunder/utils/project/create_project_structure.py index 48fd7b9..1013935 100644 --- a/je_mail_thunder/utils/project/create_project_structure.py +++ b/je_mail_thunder/utils/project/create_project_structure.py @@ -9,6 +9,8 @@ from je_mail_thunder.utils.project.template.template_keyword import template_keyword_1, \ template_keyword_2, bad_template_1 +_template_lock = Lock() + def create_dir(dir_name: str) -> None: """ @@ -21,48 +23,61 @@ def create_dir(dir_name: str) -> None: ) +def _write_keyword_templates(keyword_dir: Path) -> None: + """ + :param keyword_dir: directory to populate with keyword JSON templates + :return: None + """ + write_action_json(str(keyword_dir / "keyword1.json"), template_keyword_1) + write_action_json(str(keyword_dir / "keyword2.json"), template_keyword_2) + write_action_json(str(keyword_dir / "bad_keyword_1.json"), bad_template_1) + + +def _write_executor_template(executor_dir: Path, keyword_dir: Path) -> None: + """ + :param executor_dir: directory to populate with executor Python templates + :param keyword_dir: keyword directory referenced by the executor templates + :return: None + """ + substitutions = ( + ("executor_one_file.py", executor_template_1, str(keyword_dir / "keyword1.json")), + ("executor_bad_file.py", bad_executor_template_1, str(keyword_dir / "bad_keyword_1.json")), + ("executor_folder.py", executor_template_2, str(keyword_dir)), + ) + for filename, template, replacement in substitutions: + target = executor_dir / filename + with open(str(target), "w+") as file: + file.write(template.replace("{temp}", replacement)) + + def create_template(parent_name: str, project_path: str = None) -> None: + """ + :param parent_name: project subdirectory name under project_path + :param project_path: base directory (defaults to cwd) + :return: None + """ if project_path is None: project_path = getcwd() - keyword_dir_path = Path(project_path + "/" + parent_name + "/keyword") - executor_dir_path = Path(project_path + "/" + parent_name + "/executor") - lock = Lock() - if keyword_dir_path.exists() and keyword_dir_path.is_dir(): - write_action_json(project_path + "/" + parent_name + "/keyword/keyword1.json", template_keyword_1) - write_action_json(project_path + "/" + parent_name + "/keyword/keyword2.json", template_keyword_2) - write_action_json(project_path + "/" + parent_name + "/keyword/bad_keyword_1.json", bad_template_1) - if executor_dir_path.exists() and executor_dir_path.is_dir(): - lock.acquire() - try: - with open(project_path + "/" + parent_name + "/executor/executor_one_file.py", "w+") as file: - file.write( - executor_template_1.replace( - "{temp}", - project_path + "/" + parent_name + "/keyword/keyword1.json" - ) - ) - with open(project_path + "/" + parent_name + "/executor/executor_bad_file.py", "w+") as file: - file.write( - bad_executor_template_1.replace( - "{temp}", - project_path + "/" + parent_name + "/keyword/bad_keyword_1.json" - ) - ) - with open(project_path + "/" + parent_name + "/executor/executor_folder.py", "w+") as file: - file.write( - executor_template_2.replace( - "{temp}", - project_path + "/" + parent_name + "/keyword" - ) - ) - finally: - lock.release() + base = Path(project_path) / parent_name + keyword_dir = base / "keyword" + executor_dir = base / "executor" + if keyword_dir.exists() and keyword_dir.is_dir(): + _write_keyword_templates(keyword_dir) + if executor_dir.exists() and executor_dir.is_dir(): + with _template_lock: + _write_executor_template(executor_dir, keyword_dir) def create_project_dir(project_path: str = None, parent_name: str = "MailThunder") -> None: + """ + :param project_path: base directory (defaults to cwd) + :param parent_name: project subdirectory name under project_path + :return: None + """ mail_thunder_logger.info(f"create_project_dir, project_path: {project_path}, parent_name: {parent_name}") if project_path is None: project_path = getcwd() - create_dir(project_path + "/" + parent_name + "/keyword") - create_dir(project_path + "/" + parent_name + "/executor") + base = Path(project_path) / parent_name + create_dir(str(base / "keyword")) + create_dir(str(base / "executor")) create_template(parent_name, project_path) diff --git a/je_mail_thunder/utils/save_mail_user_content/mail_thunder_content_save.py b/je_mail_thunder/utils/save_mail_user_content/mail_thunder_content_save.py index b3b6ad2..519a350 100644 --- a/je_mail_thunder/utils/save_mail_user_content/mail_thunder_content_save.py +++ b/je_mail_thunder/utils/save_mail_user_content/mail_thunder_content_save.py @@ -2,7 +2,6 @@ from pathlib import Path from threading import Lock -from je_mail_thunder.utils.exception.exceptions import MailThunderContentException from je_mail_thunder.utils.json_format.json_process import reformat_json from je_mail_thunder.utils.save_mail_user_content.mail_thunder_content_data import mail_thunder_content_data_dict @@ -13,8 +12,7 @@ def read_output_content(): """ read the editor content """ - try: - _lock.acquire() + with _lock: cwd = str(Path.cwd()) file_path = Path(cwd + "/mail_thunder_content.json") if file_path.exists() and file_path.is_file(): @@ -22,22 +20,14 @@ def read_output_content(): user_info = json.loads(read_file.read()) mail_thunder_content_data_dict.update(user_info) return user_info - except MailThunderContentException: - raise MailThunderContentException - finally: - _lock.release() + return None def write_output_content(): """ write the editor content """ - try: - _lock.acquire() + with _lock: cwd = str(Path.cwd()) with open(cwd + "/mail_thunder_content.json", "w+") as file_to_write: file_to_write.write(reformat_json(json.dumps(mail_thunder_content_data_dict))) - except MailThunderContentException: - raise MailThunderContentException - finally: - _lock.release() diff --git a/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py b/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py index 875648d..49689b2 100644 --- a/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py +++ b/je_mail_thunder/utils/socket_server/mail_thunder_socket_server.py @@ -47,30 +47,30 @@ def handle(self): except UnicodeDecodeError as error: print(repr(error), file=sys.stderr, flush=True) return - socket = self.request + client_socket = self.request print("command is: " + command_string, flush=True) if command_string == "quit_server": self.server.shutdown() self.server.close_flag = True print("Now quit server", flush=True) - else: + return + try: + execute_str = json.loads(command_string) + _validate_payload(execute_str) + for _, execute_return in execute_action(execute_str).items(): + client_socket.sendto(str(execute_return).encode("utf-8"), self.client_address) + client_socket.sendto("\n".encode("utf-8"), self.client_address) + client_socket.sendto("Return_Data_Over_JE".encode("utf-8"), self.client_address) + client_socket.sendto("\n".encode("utf-8"), self.client_address) + except (ValueError, OSError, TypeError) as error: + print(repr(error), file=sys.stderr) try: - execute_str = json.loads(command_string) - _validate_payload(execute_str) - for execute_function, execute_return in execute_action(execute_str).items(): - socket.sendto(str(execute_return).encode("utf-8"), self.client_address) - socket.sendto("\n".encode("utf-8"), self.client_address) - socket.sendto("Return_Data_Over_JE".encode("utf-8"), self.client_address) - socket.sendto("\n".encode("utf-8"), self.client_address) - except Exception as error: - print(repr(error), file=sys.stderr) - try: - socket.sendto(str(error).encode("utf-8"), self.client_address) - socket.sendto("\n".encode("utf-8"), self.client_address) - socket.sendto("Return_Data_Over_JE".encode("utf-8"), self.client_address) - socket.sendto("\n".encode("utf-8"), self.client_address) - except Exception as error: - print(repr(error)) + client_socket.sendto(str(error).encode("utf-8"), self.client_address) + client_socket.sendto("\n".encode("utf-8"), self.client_address) + client_socket.sendto("Return_Data_Over_JE".encode("utf-8"), self.client_address) + client_socket.sendto("\n".encode("utf-8"), self.client_address) + except OSError as send_error: + print(repr(send_error)) class TCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):