From 9bbcbd7574916c8360bc6848c404fc16db59b878 Mon Sep 17 00:00:00 2001 From: valerii Date: Sun, 25 Jan 2026 01:08:44 +0300 Subject: [PATCH 1/5] feat: make --user optional in password set/clear, use imap.user from config - args: --user optional (default None) for password set and clear - __main__: resolve user from args.user or cfg imap.user; error if neither - tests: missing-user, imap.user fallback, parse without --user - README: Quick Start and Password Management mention optional --user Fixes #27 --- README.md | 7 +++- email_processor/__main__.py | 14 ++++---- email_processor/cli/args.py | 8 ++--- tests/test_cli_integration.py | 46 +++++++++++++++++++++++- tests/unit/cli/test_args.py | 12 +++++++ tests/unit/test_main.py | 66 ++++++++++++++++++++++++++++++++--- 6 files changed, 137 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 08ae19f..18f2f28 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ python -m email_processor config init ### 3. Set Password ```bash # Set IMAP password (will be prompted interactively) +# --user can be omitted if imap.user is set in config.yaml python -m email_processor password set --user your_email@example.com +python -m email_processor password set # uses imap.user from config # Or from file python -m email_processor password set --user your_email@example.com --password-file ~/.pass --delete-after-read @@ -185,7 +187,9 @@ python -m email_processor send folder /path/to/folder --to user@example.com --su #### Set Password ```bash # Interactive password input +# --user is optional when imap.user is in config.yaml python -m email_processor password set --user your_email@example.com +python -m email_processor password set # uses imap.user from config # From file (file will be deleted after reading) python -m email_processor password set --user your_email@example.com --password-file ~/.pass --delete-after-read @@ -193,8 +197,9 @@ python -m email_processor password set --user your_email@example.com --password- #### Clear Password ```bash -# Delete saved password +# Delete saved password (--user optional if imap.user in config) python -m email_processor password clear --user your_email@example.com +python -m email_processor password clear # uses imap.user from config ``` ### Configuration Management diff --git a/email_processor/__main__.py b/email_processor/__main__.py index 7cd98f1..f04a37f 100644 --- a/email_processor/__main__.py +++ b/email_processor/__main__.py @@ -159,11 +159,12 @@ def main() -> int: # Command: password set if args.command == "password" and args.password_command == "set": - if not args.user: - ui.error("--user is required") + user = args.user or cfg.get("imap", {}).get("user") + if not user: + ui.error("--user is required or set imap.user in config") return ExitCode.VALIDATION_FAILED return passwords.set_password( - args.user, + user, args.password_file if hasattr(args, "password_file") else None, args.delete_after_read if hasattr(args, "delete_after_read") else False, config_path, @@ -172,10 +173,11 @@ def main() -> int: # Command: password clear if args.command == "password" and args.password_command == "clear": - if not args.user: - ui.error("--user is required") + user = args.user or cfg.get("imap", {}).get("user") + if not user: + ui.error("--user is required or set imap.user in config") return ExitCode.VALIDATION_FAILED - return passwords.clear_passwords(args.user, ui) + return passwords.clear_passwords(user, ui) # Command: send file if args.command == "send" and args.send_command == "file": diff --git a/email_processor/cli/args.py b/email_processor/cli/args.py index 6ea7959..3e955c4 100644 --- a/email_processor/cli/args.py +++ b/email_processor/cli/args.py @@ -237,8 +237,8 @@ def parse_arguments() -> argparse.Namespace: password_set_parser.add_argument( "--user", type=str, - required=True, - help="IMAP user login", + default=None, + help="IMAP user login (default: imap.user from config)", ) password_set_parser.add_argument( "--password-file", @@ -261,8 +261,8 @@ def parse_arguments() -> argparse.Namespace: password_clear_parser.add_argument( "--user", type=str, - required=True, - help="IMAP user login", + default=None, + help="IMAP user login (default: imap.user from config)", ) # Command: config (with subcommands) diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index 91b9095..3eb3e70 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -6,7 +6,7 @@ import tempfile import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import ANY, patch sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -120,6 +120,50 @@ def test_password_clear_command(self, mock_clear_passwords, mock_config_loader_c self.assertEqual(result, 0) mock_clear_passwords.assert_called_once() + @patch("email_processor.__main__.ConfigLoader") + @patch("email_processor.cli.commands.passwords.clear_passwords") + def test_password_clear_uses_imap_user_from_config( + self, mock_clear_passwords, mock_config_loader_class + ): + """Test password clear without --user uses imap.user from config.""" + mock_config_loader_class.load.return_value = { + "imap": {"user": "config_user@example.com"}, + "processing": {}, + } + mock_clear_passwords.return_value = 0 + with patch("sys.argv", ["email_processor", "password", "clear"]): + result = main() + self.assertEqual(result, 0) + mock_clear_passwords.assert_called_once_with("config_user@example.com", ANY) + + @patch("email_processor.__main__.ConfigLoader") + @patch("email_processor.cli.commands.passwords.set_password") + def test_password_set_uses_imap_user_from_config( + self, mock_set_password, mock_config_loader_class + ): + """Test password set without --user uses imap.user from config.""" + mock_config_loader_class.load.return_value = { + "imap": {"user": "config_user@example.com"}, + "processing": {}, + } + mock_set_password.return_value = 0 + pwd_file = Path(self.temp_dir) / "pwd.txt" + pwd_file.write_text("secret\n") + with patch( + "sys.argv", + [ + "email_processor", + "password", + "set", + "--password-file", + str(pwd_file), + ], + ): + result = main() + self.assertEqual(result, 0) + mock_set_password.assert_called_once() + self.assertEqual(mock_set_password.call_args[0][0], "config_user@example.com") + @patch("email_processor.__main__.ConfigLoader") @patch("email_processor.cli.commands.smtp.send_file") def test_send_file_command(self, mock_send_file, mock_config_loader_class): diff --git a/tests/unit/cli/test_args.py b/tests/unit/cli/test_args.py index 00a5220..10fd33f 100644 --- a/tests/unit/cli/test_args.py +++ b/tests/unit/cli/test_args.py @@ -65,6 +65,18 @@ def test_parse_arguments_password_set_delete_after_read(self): args = parse_arguments() self.assertTrue(args.delete_after_read) + def test_parse_arguments_password_set_without_user(self): + """Test parsing password set without --user (optional, fallback from config).""" + with patch( + "sys.argv", + ["email_processor", "password", "set", "--password-file", "p.txt"], + ): + args = parse_arguments() + self.assertEqual(args.command, "password") + self.assertEqual(args.password_command, "set") + self.assertIsNone(args.user) + self.assertEqual(args.password_file, "p.txt") + def test_parse_arguments_dry_run(self): """Test parsing --dry-run argument.""" with patch("sys.argv", ["email_processor", "run", "--dry-run"]): diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 4d84b94..04eb1c8 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -1,5 +1,6 @@ """Tests for __main__ module entry point.""" +import tempfile import unittest from pathlib import Path from unittest.mock import MagicMock, patch @@ -28,11 +29,68 @@ def test_main_clear_passwords_mode(self, mock_clear_passwords, mock_config_loade self.assertEqual(result, ExitCode.SUCCESS) mock_clear_passwords.assert_called_once_with("test@example.com", unittest.mock.ANY) - def test_main_clear_passwords_missing_user(self): - """Test main function when --user is missing in password clear mode.""" + @patch("email_processor.__main__.ConfigLoader") + def test_main_clear_passwords_missing_user(self, mock_loader_class): + """Test main when --user is missing and imap.user not in config (password clear).""" + mock_loader_class.load.return_value = {"imap": {}} with patch("sys.argv", ["email_processor", "password", "clear"]): - with self.assertRaises(SystemExit): - main() + with patch("email_processor.__main__.CLIUI") as mock_ui_class: + mock_ui = MagicMock() + mock_ui_class.return_value = mock_ui + result = main() + self.assertEqual(result, ExitCode.VALIDATION_FAILED) + mock_ui.error.assert_called_once() + self.assertIn("imap.user", mock_ui.error.call_args[0][0]) + + @patch("email_processor.__main__.ConfigLoader") + @patch("email_processor.cli.commands.passwords.clear_passwords") + def test_main_clear_passwords_uses_imap_user_from_config( + self, mock_clear_passwords, mock_loader_class + ): + """Test password clear without --user uses imap.user from config.""" + mock_loader_class.load.return_value = { + "imap": {"user": "from_config@example.com"}, + "processing": {}, + } + mock_clear_passwords.return_value = 0 + with patch("sys.argv", ["email_processor", "password", "clear"]): + result = main() + self.assertEqual(result, ExitCode.SUCCESS) + mock_clear_passwords.assert_called_once_with( + "from_config@example.com", unittest.mock.ANY + ) + + @patch("email_processor.__main__.ConfigLoader") + @patch("email_processor.cli.commands.passwords.set_password") + def test_main_password_set_uses_imap_user_from_config( + self, mock_set_password, mock_loader_class + ): + """Test password set without --user uses imap.user from config.""" + mock_loader_class.load.return_value = { + "imap": {"user": "from_config@example.com"}, + "processing": {}, + } + mock_set_password.return_value = 0 + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: + f.write("secret\n") + pwd_file = f.name + try: + with patch( + "sys.argv", + [ + "email_processor", + "password", + "set", + "--password-file", + pwd_file, + ], + ): + result = main() + self.assertEqual(result, ExitCode.SUCCESS) + mock_set_password.assert_called_once() + self.assertEqual(mock_set_password.call_args[0][0], "from_config@example.com") + finally: + Path(pwd_file).unlink(missing_ok=True) @patch("email_processor.config.loader.ConfigLoader.load") @patch("email_processor.imap.auth.get_imap_password") From 1179e7881f973f5b082dab2f90e50a69ae4a294d Mon Sep 17 00:00:00 2001 From: valerii Date: Sun, 25 Jan 2026 01:24:51 +0300 Subject: [PATCH 2/5] fix: address CodeQL alerts and reduce sensitive data in logs - Remove 'smtp.example.com' substring check (py/incomplete-url-substring-sanitization) - Add permissions to CI and build-and-publish workflows (actions/missing-workflow-permissions) - Add redact_email, use in auth/smtp/fetcher logs (py/clear-text-logging-sensitive-data) --- .github/workflows/build-and-publish.yml | 4 +++ .github/workflows/ci.yml | 4 +++ email_processor/imap/auth.py | 27 ++++++++++++------- email_processor/imap/fetcher.py | 3 ++- email_processor/smtp/client.py | 3 ++- email_processor/smtp/sender.py | 5 ++-- email_processor/utils/__init__.py | 2 ++ email_processor/utils/redact.py | 19 +++++++++++++ tests/unit/smtp/test_client.py | 9 +++---- tests/unit/utils/test_redact.py | 36 +++++++++++++++++++++++++ 10 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 email_processor/utils/redact.py create mode 100644 tests/unit/utils/test_redact.py diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index a7d29ff..2286c97 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -8,6 +8,10 @@ on: types: [published] workflow_dispatch: # Allow manual trigger +permissions: + contents: read + actions: read + jobs: build: name: Build Package diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65f61f2..b2f2e1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main, dev] +permissions: + contents: read + actions: read + jobs: test: name: Test (Python ${{ matrix.python-version }} on ${{ matrix.os }}) diff --git a/email_processor/imap/auth.py b/email_processor/imap/auth.py index 8d9c8dc..4deaa32 100644 --- a/email_processor/imap/auth.py +++ b/email_processor/imap/auth.py @@ -12,6 +12,7 @@ encrypt_password, is_encrypted, ) +from email_processor.utils.redact import redact_email def get_imap_password(imap_user: str, config_path: Optional[str] = None) -> str: @@ -32,11 +33,13 @@ def get_imap_password(imap_user: str, config_path: Optional[str] = None) -> str: try: password = decrypt_password(stored_password, config_path) logger.debug( - "password_decrypted_length", password_length=len(password), user=imap_user + "password_decrypted_length", + password_length=len(password), + user=redact_email(imap_user), ) logger.info( "password_retrieved_decrypted", - user=imap_user, + user=redact_email(imap_user), service=KEYRING_SERVICE_NAME, ) return password @@ -44,7 +47,7 @@ def get_imap_password(imap_user: str, config_path: Optional[str] = None) -> str: # Decryption failed - system characteristics may have changed logger.error( "password_decryption_failed", - user=imap_user, + user=redact_email(imap_user), error=str(e), hint="System characteristics may have changed. Password needs to be re-entered.", ) @@ -56,7 +59,7 @@ def get_imap_password(imap_user: str, config_path: Optional[str] = None) -> str: except Exception as e: logger.error( "password_decryption_error", - user=imap_user, + user=redact_email(imap_user), error=str(e), error_type=type(e).__name__, ) @@ -68,13 +71,13 @@ def get_imap_password(imap_user: str, config_path: Optional[str] = None) -> str: # Old format - unencrypted password logger.info( "password_retrieved", - user=imap_user, + user=redact_email(imap_user), service=KEYRING_SERVICE_NAME, encrypted=False, ) return stored_password # type: ignore[no-any-return] - logger.info("password_not_found", user=imap_user) + logger.info("password_not_found", user=redact_email(imap_user)) pw = getpass.getpass(f"Enter IMAP password for {imap_user}: ") if not pw: raise ValueError("Password not entered, operation aborted.") @@ -87,22 +90,26 @@ def get_imap_password(imap_user: str, config_path: Optional[str] = None) -> str: keyring.set_password(KEYRING_SERVICE_NAME, imap_user, encrypted_password) logger.info( "password_saved_encrypted", - user=imap_user, + user=redact_email(imap_user), service=KEYRING_SERVICE_NAME, encrypted=True, ) except Exception as e: - logger.error("password_save_error", user=imap_user, error=str(e)) + logger.error("password_save_error", user=redact_email(imap_user), error=str(e)) # Try saving unencrypted as fallback try: keyring.set_password(KEYRING_SERVICE_NAME, imap_user, pw) logger.warning( "password_saved_unencrypted_fallback", - user=imap_user, + user=redact_email(imap_user), error=str(e), ) except Exception as e2: - logger.error("password_save_fallback_error", user=imap_user, error=str(e2)) + logger.error( + "password_save_fallback_error", + user=redact_email(imap_user), + error=str(e2), + ) return pw diff --git a/email_processor/imap/fetcher.py b/email_processor/imap/fetcher.py index 4d12c7d..d3c9a31 100644 --- a/email_processor/imap/fetcher.py +++ b/email_processor/imap/fetcher.py @@ -68,6 +68,7 @@ def close(self): ) from email_processor.utils.context import set_correlation_id, set_request_id from email_processor.utils.email_utils import decode_mime_header_value, parse_email_date +from email_processor.utils.redact import redact_email def get_start_date(days_back: int) -> str: @@ -687,7 +688,7 @@ def _process_email( # Sender filter if not self.filter.is_allowed_sender(sender): - uid_logger.debug("sender_not_allowed", sender=sender) + uid_logger.debug("sender_not_allowed", sender=redact_email(sender)) if self.skip_non_allowed_as_processed: try: save_processed_uid_for_day(self.processed_dir, day_str, uid, processed_cache) diff --git a/email_processor/smtp/client.py b/email_processor/smtp/client.py index 3b56ffc..7b877b9 100644 --- a/email_processor/smtp/client.py +++ b/email_processor/smtp/client.py @@ -5,6 +5,7 @@ from typing import Union from email_processor.logging.setup import get_logger +from email_processor.utils.redact import redact_email def smtp_connect( @@ -67,7 +68,7 @@ def smtp_connect( smtp.starttls() logger.debug("smtp_tls_started") - logger.debug("smtp_authenticating", user=user) + logger.debug("smtp_authenticating", user=redact_email(user)) smtp.login(user, password) logger.debug("smtp_authenticated") logger.info( diff --git a/email_processor/smtp/sender.py b/email_processor/smtp/sender.py index a41666d..38f30c8 100644 --- a/email_processor/smtp/sender.py +++ b/email_processor/smtp/sender.py @@ -12,6 +12,7 @@ from email_processor.logging.setup import get_logger from email_processor.smtp.config import SMTPConfig +from email_processor.utils.redact import redact_email def format_subject_template(template: str, context: dict[str, str]) -> str: @@ -360,8 +361,8 @@ def __init__( "email_sender_initialized", smtp_server=config.smtp_server, smtp_port=config.smtp_port, - smtp_user=config.smtp_user, - from_address=config.from_address, + smtp_user=redact_email(config.smtp_user or ""), + from_address=redact_email(config.from_address or ""), ) def send_file( diff --git a/email_processor/utils/__init__.py b/email_processor/utils/__init__.py index 43dcf4e..751d216 100644 --- a/email_processor/utils/__init__.py +++ b/email_processor/utils/__init__.py @@ -4,6 +4,7 @@ from email_processor.utils.email_utils import EmailUtils, decode_mime_header_value, parse_email_date from email_processor.utils.folder_resolver import FolderResolver, resolve_custom_folder from email_processor.utils.path_utils import PathUtils, normalize_folder_name +from email_processor.utils.redact import redact_email __all__ = [ "DiskUtils", @@ -14,5 +15,6 @@ "decode_mime_header_value", "normalize_folder_name", "parse_email_date", + "redact_email", "resolve_custom_folder", ] diff --git a/email_processor/utils/redact.py b/email_processor/utils/redact.py new file mode 100644 index 0000000..c83d9e5 --- /dev/null +++ b/email_processor/utils/redact.py @@ -0,0 +1,19 @@ +"""Redaction helpers for avoiding clear-text logging of sensitive data (CodeQL).""" + +from typing import Optional + + +def redact_email(email: Optional[str]) -> str: + """Redact email for safe logging (e.g. 'user@example.com' -> 'u***@***'). + + Use in log calls to satisfy py/clear-text-logging-sensitive-data. + """ + if not email or not isinstance(email, str): + return "" + s = email.strip() + if "@" not in s: + return "***" if s else "" + local, _, domain = s.partition("@") + if not local: + return "***@" + ("***" if domain else "") + return f"{local[0]}***@***" diff --git a/tests/unit/smtp/test_client.py b/tests/unit/smtp/test_client.py index 6fc3e05..1c0a644 100644 --- a/tests/unit/smtp/test_client.py +++ b/tests/unit/smtp/test_client.py @@ -113,13 +113,10 @@ def test_smtp_connect_max_retries_exceeded(self, mock_sleep, mock_smtp_class): with self.assertRaises(ConnectionError) as context: smtp_connect("smtp.example.com", 587, "user", "password", max_retries=2, retry_delay=1) - # Error message should contain connection failure info + # Error message should contain connection failure info (avoid substring + # checks that CodeQL treats as URL sanitization: py/incomplete-url-substring-sanitization) error_msg = str(context.exception) - self.assertTrue( - "Failed to connect" in error_msg - or "Unexpected error" in error_msg - or "smtp.example.com" in error_msg - ) + self.assertTrue("Failed to connect" in error_msg or "Unexpected error" in error_msg) self.assertEqual(mock_smtp.login.call_count, 2) @patch("email_processor.smtp.client.smtplib.SMTP") diff --git a/tests/unit/utils/test_redact.py b/tests/unit/utils/test_redact.py new file mode 100644 index 0000000..6abc6ea --- /dev/null +++ b/tests/unit/utils/test_redact.py @@ -0,0 +1,36 @@ +"""Tests for redact utils module.""" + +import unittest + +from email_processor.utils.redact import redact_email + + +class TestRedactEmail(unittest.TestCase): + """Tests for redact_email.""" + + def test_redact_email_normal(self): + """Redact typical email.""" + self.assertEqual(redact_email("user@example.com"), "u***@***") + self.assertEqual(redact_email("test@domain.org"), "t***@***") + + def test_redact_email_single_char_local(self): + """Local part with one character.""" + self.assertEqual(redact_email("a@b.co"), "a***@***") + + def test_redact_email_empty(self): + """Empty or falsy input.""" + self.assertEqual(redact_email(""), "") + self.assertEqual(redact_email(None), "") + self.assertEqual(redact_email(" "), "") + + def test_redact_email_no_at(self): + """String without @.""" + self.assertEqual(redact_email("notanemail"), "***") + + def test_redact_email_empty_local(self): + """@ only or empty local part.""" + self.assertEqual(redact_email("@domain.com"), "***@***") + + def test_redact_email_strips(self): + """Whitespace is stripped.""" + self.assertEqual(redact_email(" user@example.com "), "u***@***") From 45a15b00eac26c303ac0b6f31f987587ecacb9ca Mon Sep 17 00:00:00 2001 From: valerii Date: Sun, 25 Jan 2026 18:41:14 +0300 Subject: [PATCH 3/5] docs: update README Quick Start, remove Features block - Quick Start: install module only (no .venv), split First Run into Fetch + Send - Remove Features & Improvements / v7.1 Features block (duplicate of Key Features) --- README.md | 62 +++++++++++++------------------------------------------ 1 file changed, 14 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 18f2f28..8242630 100644 --- a/README.md +++ b/README.md @@ -65,16 +65,9 @@ This ensures: ## Installation and Initial Setup -### 1. Install Dependencies +### 1. Install the module ```bash -# Create a virtual environment (recommended) -python -m venv .venv -.venv\Scripts\activate # Windows -# or -source .venv/bin/activate # Linux/macOS - -# Install dependencies -pip install -r requirements.txt +pip install email-processor ``` ### 2. Create Configuration @@ -105,15 +98,24 @@ python -m email_processor config validate python -m email_processor status ``` -### 5. First Run +### 5. Fetch (download emails and attachments) ```bash -# Run email processing (test mode without real actions) +# Test mode (no real actions) python -m email_processor fetch --dry-run -# Real run +# Run fetch python -m email_processor fetch ``` +### 6. Send (email files) +```bash +# Send a single file +python -m email_processor send file /path/to/file.pdf --to recipient@example.com + +# Full pipeline: fetch + send +python -m email_processor run +``` + --- # 🎯 Usage @@ -749,42 +751,6 @@ Dictionary of regex patterns to folder paths. Emails matching a pattern will be --- -# 🛠️ Features & Improvements - -## v7.1 Features -- ✅ **Modular architecture** - Clean separation of concerns -- ✅ **YAML configuration** - Easy configuration management -- ✅ **Keyring password storage** - Secure credential management -- ✅ **Per-day UID storage** - Optimized performance -- ✅ **Two-phase IMAP fetch** - Efficient email processing -- ✅ **Password management commands** - `password set` and `password clear` subcommands -- ✅ **Configuration validation** - Validates config on startup -- ✅ **Structured logging** - JSON and console formats with file output -- ✅ **Configurable logging levels** - DEBUG, INFO, WARNING, ERROR, CRITICAL -- ✅ **Enhanced error handling** - Comprehensive error recovery -- ✅ **Detailed processing statistics** - File type statistics -- ✅ **Progress bar** - Visual progress indicator (tqdm) -- ✅ **File extension filtering** - Whitelist/blacklist support -- ✅ **Disk space checking** - Prevents out-of-space errors -- ✅ **Dry-run mode** - Test without downloading (`--dry-run`) -- ✅ **Type hints** - Full type annotation support -- ✅ **Path traversal protection** - Security hardening -- ✅ **Attachment size validation** - Prevents oversized downloads - ---- - -# 📝 Notes - -- The script is **idempotent**: safe to run multiple times -- Processed UIDs are stored per day for optimal performance -- Passwords are securely stored in system keyring -- Configuration is validated on startup -- All errors are logged with appropriate detail levels -- Progress bar shows real-time statistics (processed, skipped, errors) -- File extension filtering helps prevent unwanted downloads -- Disk space is checked before each download (with 10MB buffer) -- Logs are automatically rotated daily when file logging is enabled - # 🏗️ Architecture The project uses a modular architecture for better maintainability: From aa3ba7dcce889768ea474897975b0b80179cfdaa Mon Sep 17 00:00:00 2001 From: valerii Date: Sun, 25 Jan 2026 18:47:12 +0300 Subject: [PATCH 4/5] docs: add Quick Start step 7 - Full pipeline (fetch + send) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8242630..6f8a08f 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,10 @@ python -m email_processor fetch ```bash # Send a single file python -m email_processor send file /path/to/file.pdf --to recipient@example.com +``` -# Full pipeline: fetch + send +### 7. Full pipeline: fetch + send +```bash python -m email_processor run ``` From 94365da60ca5fd8623cc5123b0c95a0a70de0b64 Mon Sep 17 00:00:00 2001 From: valerii Date: Sun, 25 Jan 2026 19:54:54 +0300 Subject: [PATCH 5/5] feat: send folder config defaults, 'send' without subcommand = send folder - send folder: dir and --to optional, use smtp.send_folder / default_recipient from config - send without subcommand defaults to send folder with config - README: fetch/send use config by default, document 'send' and 'send folder' - Tests: send_folder missing dir/to, parse send no args, send-without-subcommand integration --- README.md | 20 ++++++++++++++++-- email_processor/__main__.py | 28 ++++++++++++++++++-------- email_processor/cli/args.py | 8 +++++--- tests/test_cli_integration.py | 25 +++++++++++++++++++++++ tests/unit/cli/test_args.py | 9 +++++++++ tests/unit/test_main.py | 38 ++++++++++++++++++++--------------- 6 files changed, 99 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 6f8a08f..4a777cb 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ python -m email_processor status ``` ### 5. Fetch (download emails and attachments) +Uses config by default (IMAP server, folder, processing options). ```bash # Test mode (no real actions) python -m email_processor fetch --dry-run @@ -113,7 +114,16 @@ python -m email_processor fetch python -m email_processor send file /path/to/file.pdf --to recipient@example.com ``` -### 7. Full pipeline: fetch + send +### 7. Send All Files from Folder +Uses config by default (`smtp.send_folder`, `smtp.default_recipient`). +```bash +# Send from folder (config defaults) +python -m email_processor send +# Or explicitly: +python -m email_processor send folder +``` + +### 8. Full pipeline: fetch + send ```bash python -m email_processor run ``` @@ -136,6 +146,7 @@ python -m email_processor run --since 7d --max-emails 100 ``` #### Email Fetching Only (without sending) +Uses config (IMAP, processing) by default. ```bash # Fetch emails and attachments python -m email_processor fetch @@ -175,7 +186,12 @@ python -m email_processor send file file.pdf --to user@example.com --dry-run #### Send All Files from Folder ```bash -# Send all new files from folder +# With config defaults (smtp.send_folder, smtp.default_recipient) +python -m email_processor send +# Or explicitly: +python -m email_processor send folder + +# Explicit path and recipient python -m email_processor send folder /path/to/folder --to recipient@example.com # With custom subject diff --git a/email_processor/__main__.py b/email_processor/__main__.py index f04a37f..6721463 100644 --- a/email_processor/__main__.py +++ b/email_processor/__main__.py @@ -179,6 +179,14 @@ def main() -> int: return ExitCode.VALIDATION_FAILED return passwords.clear_passwords(user, ui) + # Command: send (default: send folder when no subcommand) + if args.command == "send" and args.send_command is None: + args.send_command = "folder" + if not hasattr(args, "dir"): + args.dir = None + if not hasattr(args, "to"): + args.to = None + # Command: send file if args.command == "send" and args.send_command == "file": if not hasattr(args, "path") or not args.path: @@ -211,16 +219,20 @@ def main() -> int: # Command: send folder if args.command == "send" and args.send_command == "folder": - if not hasattr(args, "dir") or not args.dir: - ui.error("Directory path is required") + folder = args.dir if hasattr(args, "dir") else None + to_addr = args.to if hasattr(args, "to") else None + folder = folder or cfg.get("smtp", {}).get("send_folder") + to_addr = to_addr or cfg.get("smtp", {}).get("default_recipient") + if not folder: + ui.error("Directory path is required or set smtp.send_folder in config") return ExitCode.VALIDATION_FAILED - if not hasattr(args, "to") or not args.to: - ui.error("--to is required") + if not to_addr: + ui.error("--to is required or set smtp.default_recipient in config") return ExitCode.VALIDATION_FAILED # Validate email addresses - if not _validate_email(args.to): - ui.error(f"Invalid email address: {args.to}") + if not _validate_email(to_addr): + ui.error(f"Invalid email address: {to_addr}") return ExitCode.VALIDATION_FAILED if hasattr(args, "cc") and args.cc and not _validate_email(args.cc): ui.error(f"Invalid CC email address: {args.cc}") @@ -231,8 +243,8 @@ def main() -> int: return smtp.send_folder( cfg, - args.dir, - args.to, + folder, + to_addr, args.subject if hasattr(args, "subject") else None, args.dry_run, config_path, diff --git a/email_processor/cli/args.py b/email_processor/cli/args.py index 3e955c4..abb6ef1 100644 --- a/email_processor/cli/args.py +++ b/email_processor/cli/args.py @@ -183,13 +183,15 @@ def parse_arguments() -> argparse.Namespace: send_folder_parser.add_argument( "dir", type=str, - help="Directory path containing files to send", + nargs="?", + default=None, + help="Directory path (default: smtp.send_folder from config)", ) send_folder_parser.add_argument( "--to", type=str, - required=True, - help="Recipient email address", + default=None, + help="Recipient email (default: smtp.default_recipient from config)", ) send_folder_parser.add_argument( "--subject", diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index 3eb3e70..1d71670 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -209,6 +209,31 @@ def test_send_folder_command(self, mock_send_folder, mock_config_loader_class): self.assertEqual(result, 0) mock_send_folder.assert_called_once() + @patch("email_processor.__main__.ConfigLoader") + @patch("email_processor.cli.commands.smtp.send_folder") + def test_send_without_subcommand_uses_folder_config_defaults( + self, mock_send_folder, mock_config_loader_class + ): + """Test 'send' without subcommand defaults to send folder from config.""" + test_folder = Path(self.temp_dir) / "outbox" + test_folder.mkdir() + (test_folder / "a.txt").write_bytes(b"a") + mock_config_loader_class.load.return_value = { + "smtp": { + "send_folder": str(test_folder), + "default_recipient": "default@example.com", + }, + } + mock_send_folder.return_value = 0 + + with patch("sys.argv", ["email_processor", "send"]): + result = main() + self.assertEqual(result, 0) + mock_send_folder.assert_called_once() + call_args = mock_send_folder.call_args[0] + self.assertEqual(call_args[1], str(test_folder)) + self.assertEqual(call_args[2], "default@example.com") + @patch("email_processor.__main__.ConfigLoader") @patch("email_processor.cli.commands.imap.run_processor") def test_fetch_command(self, mock_run_processor, mock_config_loader_class): diff --git a/tests/unit/cli/test_args.py b/tests/unit/cli/test_args.py index 10fd33f..a779cdb 100644 --- a/tests/unit/cli/test_args.py +++ b/tests/unit/cli/test_args.py @@ -124,6 +124,15 @@ def test_parse_arguments_send_folder(self): self.assertEqual(args.dir, "folder") self.assertEqual(args.to, "test@example.com") + def test_parse_arguments_send_folder_no_args(self): + """Test parsing send folder without dir/--to (optional, use config defaults).""" + with patch("sys.argv", ["email_processor", "send", "folder"]): + args = parse_arguments() + self.assertEqual(args.command, "send") + self.assertEqual(args.send_command, "folder") + self.assertIsNone(args.dir) + self.assertIsNone(args.to) + def test_parse_arguments_send_subject(self): """Test parsing --subject argument for send.""" with patch( diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 04eb1c8..047d295 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -707,25 +707,31 @@ def test_send_folder_command(self, mock_send_folder, mock_load_config): self.assertEqual(result, ExitCode.SUCCESS) mock_send_folder.assert_called_once() - @patch("email_processor.config.loader.ConfigLoader.load") - def test_send_folder_missing_dir(self, mock_load_config): - """Test send folder command without dir.""" - mock_load_config.return_value = {"smtp": {}} - + @patch("email_processor.__main__.ConfigLoader") + def test_send_folder_missing_dir(self, mock_loader_class): + """Test send folder without dir and no smtp.send_folder in config.""" + mock_loader_class.load.return_value = {"smtp": {}} with patch("sys.argv", ["email_processor", "send", "folder", "--to", "test@example.com"]): - with self.assertRaises(SystemExit) as cm: - main() - self.assertEqual(cm.exception.code, ExitCode.VALIDATION_FAILED) # from argparse - - @patch("email_processor.config.loader.ConfigLoader.load") - def test_send_folder_missing_to(self, mock_load_config): - """Test send folder command without --to.""" - mock_load_config.return_value = {"smtp": {}} + with patch("email_processor.__main__.CLIUI") as mock_ui_class: + mock_ui = MagicMock() + mock_ui_class.return_value = mock_ui + result = main() + self.assertEqual(result, ExitCode.VALIDATION_FAILED) + mock_ui.error.assert_called_once() + self.assertIn("send_folder", mock_ui.error.call_args[0][0]) + @patch("email_processor.__main__.ConfigLoader") + def test_send_folder_missing_to(self, mock_loader_class): + """Test send folder without --to and no smtp.default_recipient in config.""" + mock_loader_class.load.return_value = {"smtp": {}} with patch("sys.argv", ["email_processor", "send", "folder", "test_dir"]): - with self.assertRaises(SystemExit) as cm: - main() - self.assertEqual(cm.exception.code, ExitCode.VALIDATION_FAILED) # from argparse + with patch("email_processor.__main__.CLIUI") as mock_ui_class: + mock_ui = MagicMock() + mock_ui_class.return_value = mock_ui + result = main() + self.assertEqual(result, ExitCode.VALIDATION_FAILED) + mock_ui.error.assert_called_once() + self.assertIn("default_recipient", mock_ui.error.call_args[0][0]) @patch("email_processor.config.loader.ConfigLoader.load") @patch("email_processor.cli.commands.smtp.send_folder")