├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation.md │ ├── feature_task.md │ └── test_improvement.md ├── pull_request_template.md └── workflows │ ├── code_checks.yml │ ├── issue_status_updater.yml │ └── tasks_to_issues.yml ├── .gitignore ├── .windsurfrules ├── CLAUDE.md ├── DOCUMENTATION_CATALOG.md ├── GMAIL_SETUP.md ├── INSTALLATION.md ├── README.md ├── TASKS.md ├── TASKS_COMPLETED.md ├── TASKS_ICEBOX.md ├── config.sample.yaml ├── coverage.json ├── docs ├── AI_DEV_WORKFLOW.md ├── COMMIT_CONVENTIONS.md ├── GITHUB_ISSUES_WORKFLOW.md ├── GIT_INTEGRATION_CHECKLIST.md ├── ISSUE_HELPER_USAGE.md └── ISSUE_STATUS_AUTOMATION.md ├── examples └── config.yaml.example ├── imap_mcp ├── __init__.py ├── app_password.py ├── auth_setup.py ├── browser_auth.py ├── config.py ├── gmail_auth.py ├── imap_client.py ├── mcp_protocol.py ├── models.py ├── oauth2.py ├── oauth2_config.py ├── resources.py ├── server.py ├── smtp_client.py ├── tools.py └── workflows │ ├── __init__.py │ ├── calendar_mock.py │ ├── invite_parser.py │ └── meeting_reply.py ├── list_inbox.py ├── output.yaml ├── pyproject.toml ├── pytest.ini ├── read_inbox.py ├── render_mermaid.py ├── run_mcp_cli_tests.sh ├── scripts ├── github_workflow.py ├── imap_mcp_cli.py ├── issue_helper.py ├── issue_status_updater.py ├── refresh_oauth2_token.py ├── run_checks.sh ├── run_imap_mcp_server.sh ├── run_integration_tests.sh ├── run_mcp_cli_chat_tests.sh ├── tasks_to_issues.py └── tasks_to_issues_cli.py ├── test_config.yaml ├── tests ├── __init__.py ├── conftest.py ├── integration │ ├── __init__.py │ ├── test_direct_tools.py │ ├── test_gmail_integration.py │ ├── test_imap_server_basic.py │ ├── test_mcp_cli_chat.py │ ├── test_mcp_cli_integration.py │ └── test_oauth2_gmail.py ├── issue_test_log.md ├── issue_test_log.txt ├── test_app_password.py ├── test_auth_setup.py ├── test_browser_auth.py ├── test_config.py ├── test_gmail_auth.py ├── test_imap_client.py ├── test_imap_client_drafts.py ├── test_imap_client_threading.py ├── test_infrastructure.py ├── test_issue_helper.py ├── test_issue_status_updater.py ├── test_models.py ├── test_oauth2_config.py ├── test_resources.py ├── test_server.py ├── test_smtp_client_composition.py ├── test_tasks_to_issues.py ├── test_tasks_to_issues_cli.py ├── test_tools.py ├── test_tools.py.bak ├── test_tools_orchestration.py ├── test_tools_reply.py ├── test_tools_reply_drafting.py ├── test_tools_tasks.py ├── test_unread_messages.py ├── test_utils.py └── workflows │ ├── __init__.py │ ├── test_calendar_mock.py │ ├── test_invite_parser.py │ └── test_meeting_reply.py └── uv.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug to help us improve 4 | title: 'Bug: ' 5 | labels: 'type:bug, status:prioritized' 6 | assignees: '' 7 | --- 8 | 9 | ## Bug Description 10 | 11 | 12 | ## Reproduction Steps 13 | 14 | 1. 15 | 2. 16 | 3. 17 | 18 | ## Expected Behavior 19 | 20 | 21 | ## Actual Behavior 22 | 23 | 24 | ## Test Case 25 | 26 | ```python 27 | # Test code here 28 | ``` 29 | 30 | ## Environment 31 | 32 | - Python version: 33 | - OS: 34 | - Package versions: 35 | 36 | ## Priority 37 | 38 | Priority: 39 | 40 | ## Additional Context 41 | 42 | 43 | ## Possible Fix 44 | 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Create a task for documentation improvements 4 | title: 'Docs: ' 5 | labels: 'type:documentation, status:prioritized' 6 | assignees: '' 7 | --- 8 | 9 | ## Documentation Type 10 | 11 | - [ ] API Documentation 12 | - [ ] User Guide 13 | - [ ] Developer Guide 14 | - [ ] Architecture Documentation 15 | - [ ] Tutorial 16 | - [ ] Code Comments 17 | - [ ] Other: 18 | 19 | ## Scope 20 | 21 | 22 | ## Current State 23 | 24 | 25 | ## Required Changes 26 | 27 | 1. 28 | 2. 29 | 3. 30 | 31 | ## Verification Method 32 | 33 | - [ ] Documentation tests 34 | - [ ] Peer review 35 | - [ ] Example code verification 36 | - [ ] Other: 37 | 38 | ## Priority 39 | 40 | Priority: 41 | 42 | ## Dependencies 43 | 44 | - Depends on # 45 | 46 | ## Additional Notes 47 | 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_task.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Task 3 | about: Create a new feature development task with TDD approach 4 | title: 'Task: ' 5 | labels: 'type:feature, status:prioritized' 6 | assignees: '' 7 | --- 8 | 9 | ## Task Description 10 | 11 | 12 | ## Test Specifications 13 | 14 | - [ ] Test 1: 15 | - [ ] Test 2: 16 | - [ ] Test 3: 17 | 18 | ## Implementation Steps 19 | 20 | 1. 21 | 2. 22 | 3. 23 | 24 | ## Expected Outcome 25 | 26 | 27 | ## Acceptance Criteria 28 | 29 | - [ ] All tests pass 30 | - [ ] Code coverage meets threshold (≥90%) 31 | - [ ] Documentation updated 32 | - [ ] Implementation follows project standards 33 | 34 | ## Dependencies 35 | 36 | - Depends on # 37 | 38 | ## Priority 39 | 40 | Priority: 41 | 42 | ## Complexity 43 | 44 | Complexity: 45 | 46 | ## Notes 47 | 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/test_improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test Improvement 3 | about: Create a task for improving test coverage or quality 4 | title: 'Test: ' 5 | labels: 'type:test, status:prioritized' 6 | assignees: '' 7 | --- 8 | 9 | ## Module to Improve 10 | 11 | 12 | ## Current Coverage 13 | 14 | Current coverage: % 15 | 16 | ## Target Coverage 17 | 18 | Target coverage: ≥90% 19 | 20 | ## Missing Test Cases 21 | 22 | - [ ] 23 | - [ ] 24 | - [ ] 25 | 26 | ## Implementation Plan 27 | 28 | 1. 29 | 2. 30 | 3. 31 | 32 | ## Expected Outcome 33 | 34 | 35 | ## Priority 36 | 37 | Priority: 1 38 | 39 | ## Dependencies 40 | 41 | - Depends on # 42 | 43 | ## Notes 44 | 45 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | ## Related Issues 4 | 5 | 6 | 7 | 8 | ## Description 9 | 10 | 11 | ## Implementation Details 12 | 13 | 14 | ## Test Plan 15 | 16 | - [ ] Ran unit tests 17 | - [ ] Added new tests for changes 18 | - [ ] Ran integration tests 19 | - [ ] Manual testing 20 | 21 | ## Test Coverage 22 | 23 | - Current coverage: XX% 24 | - Added tests: Yes/No 25 | - Areas not covered: 26 | 27 | ## Documentation 28 | 29 | - [ ] API documentation 30 | - [ ] New feature documentation 31 | - [ ] README updates 32 | - [ ] Example updates 33 | 34 | ## Additional Information 35 | 36 | 37 | ## Checklist 38 | - [ ] My code follows the project's coding standards 39 | - [ ] I have performed a self-review of my code 40 | - [ ] I have made corresponding changes to the documentation 41 | - [ ] My changes generate no new warnings 42 | - [ ] I have added tests that prove my changes work 43 | - [ ] New and existing tests pass locally with my changes 44 | -------------------------------------------------------------------------------- /.github/workflows/code_checks.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality Checks 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | code-quality: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10"] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install uv 26 | run: | 27 | curl -LsSf https://astral.sh/uv/install.sh | sh 28 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 29 | 30 | - name: Create virtual environment 31 | run: uv venv 32 | 33 | - name: Install dependencies 34 | run: | 35 | uv pip install -e ".[dev]" 36 | 37 | - name: Make scripts executable 38 | run: | 39 | chmod +x scripts/run_checks.sh 40 | chmod +x scripts/github_workflow.py 41 | 42 | - name: Run checks 43 | run: ./scripts/run_checks.sh --ci --skip-integration 44 | 45 | - name: Update PR status (for PRs only) 46 | if: github.event_name == 'pull_request' 47 | env: 48 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | run: | 50 | echo "Updating PR status using github_workflow.py" 51 | PR_NUMBER=${{ github.event.pull_request.number }} 52 | python -m scripts.github_workflow update-status $PR_NUMBER in-review --comment "Code quality checks passed on PR #${PR_NUMBER}." 53 | -------------------------------------------------------------------------------- /.github/workflows/issue_status_updater.yml: -------------------------------------------------------------------------------- 1 | name: Issue Status Updater 2 | 3 | on: 4 | push: 5 | branches: [ main, master, 'feature/**', 'bugfix/**' ] 6 | pull_request: 7 | types: [opened, closed, reopened, synchronize] 8 | workflow_dispatch: 9 | inputs: 10 | issue_number: 11 | description: 'Issue number to update' 12 | required: true 13 | type: number 14 | 15 | jobs: 16 | update-issue-status: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | issues: write 21 | pull-requests: write 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 50 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: '3.12' 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install -e ".[dev]" 38 | pip install pytest pytest-cov 39 | 40 | - name: Determine context 41 | id: context 42 | run: | 43 | echo "EVENT_NAME=${{ github.event_name }}" >> $GITHUB_ENV 44 | echo "PR_ACTION=${{ github.event.action }}" >> $GITHUB_ENV 45 | if [ "${{ github.event_name }}" = "pull_request" ]; then 46 | echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV 47 | echo "PR_MERGED=${{ github.event.pull_request.merged }}" >> $GITHUB_ENV 48 | echo "PR_STATE=${{ github.event.pull_request.state }}" >> $GITHUB_ENV 49 | echo "PR_TITLE=${{ github.event.pull_request.title }}" >> $GITHUB_ENV 50 | echo "PR_BODY=${{ github.event.pull_request.body }}" >> $GITHUB_ENV 51 | fi 52 | if [ "${{ github.event_name }}" = "push" ]; then 53 | echo "COMMIT_MESSAGE=$(git log -1 --pretty=%B)" >> $GITHUB_ENV 54 | fi 55 | 56 | - name: Extract issue references from context 57 | id: extract_issues 58 | run: | 59 | if [ "${{ github.event_name }}" = "push" ]; then 60 | ISSUES=$(echo "$COMMIT_MESSAGE" | grep -o '#[0-9]\+' | sed 's/#//' | sort -u) 61 | echo "ISSUES=$ISSUES" >> $GITHUB_ENV 62 | fi 63 | if [ "${{ github.event_name }}" = "pull_request" ]; then 64 | ISSUES=$(echo "$PR_TITLE $PR_BODY" | grep -o '#[0-9]\+' | sed 's/#//' | sort -u) 65 | echo "ISSUES=$ISSUES" >> $GITHUB_ENV 66 | fi 67 | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then 68 | echo "ISSUES=${{ github.event.inputs.issue_number }}" >> $GITHUB_ENV 69 | fi 70 | echo "Found issue references: $ISSUES" 71 | 72 | - name: Run issue status updater for push 73 | if: github.event_name == 'push' 74 | run: | 75 | for ISSUE in $ISSUES; do 76 | echo "Updating issue #$ISSUE based on commit" 77 | python scripts/issue_status_updater.py \ 78 | --owner ${{ github.repository_owner }} \ 79 | --repo ${{ github.event.repository.name }} \ 80 | --issue $ISSUE \ 81 | --update-from-commit "$COMMIT_MESSAGE" 82 | done 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | 86 | - name: Run issue status updater for PR opened/reopened 87 | if: github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened') 88 | run: | 89 | for ISSUE in $ISSUES; do 90 | echo "Updating issue #$ISSUE to in-review status" 91 | python scripts/issue_status_updater.py \ 92 | --owner ${{ github.repository_owner }} \ 93 | --repo ${{ github.event.repository.name }} \ 94 | --issue $ISSUE \ 95 | --update-from-pr $PR_NUMBER \ 96 | --pr-action "opened" 97 | done 98 | env: 99 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 100 | 101 | - name: Run issue status updater for PR closed 102 | if: github.event_name == 'pull_request' && github.event.action == 'closed' 103 | run: | 104 | for ISSUE in $ISSUES; do 105 | if [ "$PR_MERGED" = "true" ]; then 106 | echo "Updating issue #$ISSUE to completed status (PR merged)" 107 | python scripts/issue_status_updater.py \ 108 | --owner ${{ github.repository_owner }} \ 109 | --repo ${{ github.event.repository.name }} \ 110 | --issue $ISSUE \ 111 | --update-from-pr $PR_NUMBER \ 112 | --pr-action "merged" 113 | else 114 | echo "Reverting issue #$ISSUE to previous status (PR closed without merging)" 115 | python scripts/issue_status_updater.py \ 116 | --owner ${{ github.repository_owner }} \ 117 | --repo ${{ github.event.repository.name }} \ 118 | --issue $ISSUE \ 119 | --update-from-pr $PR_NUMBER \ 120 | --pr-action "closed" 121 | fi 122 | done 123 | env: 124 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 125 | 126 | - name: Run issue status updater for manual trigger 127 | if: github.event_name == 'workflow_dispatch' 128 | run: | 129 | echo "Manually updating issue #${{ github.event.inputs.issue_number }}" 130 | python scripts/issue_status_updater.py \ 131 | --owner ${{ github.repository_owner }} \ 132 | --repo ${{ github.event.repository.name }} \ 133 | --issue ${{ github.event.inputs.issue_number }} 134 | env: 135 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 136 | -------------------------------------------------------------------------------- /.github/workflows/tasks_to_issues.yml: -------------------------------------------------------------------------------- 1 | name: Tasks to Issues Migration 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | dry_run: 7 | description: 'Dry run (no actual issue creation)' 8 | type: boolean 9 | default: true 10 | required: true 11 | coverage_threshold: 12 | description: 'Coverage threshold percentage' 13 | type: number 14 | default: 90.0 15 | required: true 16 | 17 | jobs: 18 | migrate-tasks: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: '3.12' 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install pytest pytest-cov uv 34 | uv pip install -r requirements.txt 35 | 36 | - name: Run migration script 37 | run: | 38 | python scripts/tasks_to_issues.py \ 39 | --repo-owner ${{ github.repository_owner }} \ 40 | --repo-name ${{ github.event.repository.name }} \ 41 | --coverage-threshold ${{ github.event.inputs.coverage_threshold }} \ 42 | ${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }} 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Create PR for TASKS.md update 47 | if: ${{ github.event.inputs.dry_run == 'false' }} 48 | uses: peter-evans/create-pull-request@v6 49 | with: 50 | commit-message: 'Update TASKS.md with GitHub Issues transition information' 51 | title: 'Update TASKS.md with GitHub Issues transition information' 52 | body: | 53 | This PR updates TASKS.md with information about the transition to GitHub Issues. 54 | 55 | - Adds GitHub Issues Transition section 56 | - Documents the parallel workflow during transition 57 | - Explains how to use GitHub Issues for task tracking 58 | branch: 'tasks-to-issues-migration' 59 | base: main 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Coverage 24 | .coverage 25 | .coverage.* 26 | coverage_data/ 27 | htmlcov/ 28 | .tox/ 29 | .nox/ 30 | .hypothesis/ 31 | .pytest_cache/ 32 | 33 | # Virtual environment 34 | venv/ 35 | env/ 36 | ENV/ 37 | .env* 38 | .venv 39 | uv.lock 40 | 41 | # IDE 42 | .idea/ 43 | .vscode/ 44 | *.swp 45 | *.swo 46 | *~ 47 | 48 | # Mac OS 49 | .DS_Store 50 | 51 | # OAuth2 52 | client_secret_*.json 53 | token_*.json 54 | credentials.json 55 | *.credentials.json 56 | 57 | # Configuration 58 | config.yaml* 59 | 60 | # Resources 61 | # Exclude all files in resources by default 62 | resources/ 63 | reference_github/ 64 | mcp-cli/ 65 | 66 | 67 | 68 | # Exclude any sensitive files that might be in resources even if they match above patterns 69 | #*secret* 70 | #*key* 71 | #*credential* 72 | #*password* 73 | 74 | # Secret Design Ideas 75 | # reference/ -------------------------------------------------------------------------------- /.windsurfrules: -------------------------------------------------------------------------------- 1 | # IMAP MCP Server Development Guide 2 | 3 | ## Environment Setup and Build Commands with `uv` 4 | - Create virtual environment: `uv venv` 5 | - Activate virtual environment: `source .venv/bin/activate` (Unix/macOS) or `.venv\Scripts\activate` (Windows) 6 | - Install dependencies: `uv pip install -e ".[dev]"` 7 | - Install specific packages: `uv add package_name` 8 | - Run commands within the environment: `uv run command [args]` 9 | 10 | ## Build and Test Commands 11 | - Install dependencies: `uv pip install -e ".[dev]"` 12 | - Run all tests: `uv run pytest` 13 | - Run single test: `uv run pytest tests/test_file.py::TestClass::test_function -v` 14 | - Run with coverage: `uv run pytest --cov=imap_mcp` 15 | - Run server: `uv run python -m imap_mcp.server --config /path/to/config.yaml` 16 | - Development mode: `uv run python -m imap_mcp.server --dev` 17 | - One-line execution with dependencies: `uvx run -m imap_mcp.server --config /path/to/config.yaml` 18 | 19 | ## Code Style Guidelines 20 | - Use Black with 88 character line length 21 | - Imports: Use isort with Black profile 22 | - Types: All functions must have type hints (mypy enforces this) 23 | - Naming: snake_case for variables/functions, PascalCase for classes 24 | - Error handling: Use specific exceptions and provide helpful messages 25 | - Documentation: Write docstrings for all classes and methods 26 | - Testing: Follow TDD pattern (write tests before implementation) 27 | - Project structure follows the standard Python package layout 28 | 29 | ## Task Workflow 30 | When working on tasks from GitHub Issues, follow this workflow: 31 | 32 | 1. **Task Analysis**: 33 | - Read and understand the issue requirements 34 | - Assess if the issue needs to be broken down into smaller subtasks 35 | - If needed, create separate issues for subtasks and link them to the parent issue 36 | - Analyze existing labels and make sure the issue has the correct priority and status labels 37 | 38 | 2. **Starting Work on an Issue**: 39 | - Create a branch that references the issue number: `git checkout -b feature/issue-[NUMBER]-[SHORT_DESCRIPTION]` 40 | - Make an initial commit that references the issue: `git commit -m "refs #[NUMBER]: Start implementing [FEATURE]"` 41 | - The automated status tracking system will detect this commit and change the issue status to "in-progress" 42 | 43 | 3. **Test-Driven Development**: 44 | - Write tests first that verify the desired functionality 45 | - Implement the feature until all tests pass 46 | - Refactor code while maintaining test coverage 47 | - Run full test suite to check for regressions: `uv run pytest --cov=imap_mcp` 48 | 49 | 4. **Completing an Issue**: 50 | - Create a pull request that references the issue: `gh pr create --title "[TITLE]" --body "Closes #[NUMBER]"` 51 | - The body should include "Closes #[NUMBER]" or "Fixes #[NUMBER]" to automatically close the issue when merged 52 | - The automated status tracking system will update the issue status to "completed" when the PR is merged 53 | - It will also automatically adjust priorities of remaining tasks 54 | 55 | 5. **GitHub Issue Management Commands**: 56 | - View all issues: `gh issue list` 57 | - View specific issue: `gh issue view [NUMBER]` 58 | - Filter issues by label: `gh issue list --label "priority:1"` 59 | - Create new issue: `gh issue create` (interactive) or: 60 | `gh issue create --title "Title" --body "Description" --label "priority:X" --label "status:prioritized"` 61 | - Edit issue: `gh issue edit [NUMBER] --add-label "priority:1" --remove-label "priority:2"` 62 | 63 | 6. **Documentation**: 64 | - Update docstrings in implementation 65 | - Update README.md or other docs if needed 66 | - Add new commands or processes to this CLAUDE.md file if relevant 67 | 68 | 7. **Commit Conventions**: 69 | - Use these prefixes in commit messages to trigger automatic status changes: 70 | - `refs #X`: References the issue without changing status 71 | - `implements #X`: Indicates implementation progress 72 | - `fixes #X`: Indicates the issue is fixed (used in final commits) 73 | - `closes #X`: Same as fixes, will close the issue when merged 74 | - Always include the issue number with the # prefix 75 | - Add descriptive message after the issue reference 76 | 77 | ## Issue Status Definitions 78 | 79 | GitHub Issues have the following status labels: 80 | 81 | - **status:prioritized**: Task has been assigned a priority, not yet started 82 | - **status:in-progress**: Work on the task has begun (automatic when commits reference issue) 83 | - **status:completed**: Implementation is finished (automatic when PR with "fixes/closes" is merged) 84 | - **status:reviewed**: Task has been reviewed (currently manual update) 85 | - **status:archived**: Task has been archived (currently manual update) 86 | 87 | Priority labels follow the format `priority:X` where X is a number starting from 1 (highest priority). 88 | 89 | # General 90 | ## continuous_improvement 91 | - Self-reflection: After each significant development task: 92 | 1. Evaluate the solution against requirements and best practices 93 | 2. Identify at least one aspect that could be improved in future iterations 94 | 3. Suggest concrete improvements with measurable benefits 95 | - Pattern recognition: Identify recurring patterns in code and suggest abstractions 96 | - Knowledge accumulation: Build on previous solutions when solving similar problems 97 | - Metrics tracking: Monitor improvements in code quality, test coverage, and development efficiency 98 | 99 | 100 | ## communication_preferences 101 | - Provide concise explanations focused on practical implementation 102 | - Present multiple solution options when appropriate, with clear trade-offs 103 | - Use code examples to illustrate concepts 104 | - Focus on actionable advice rather than theoretical discussions 105 | 106 | ## rules_evolution 107 | - Periodically review these rules and suggest improvements based on observed patterns 108 | - Identify rules that may be overly restrictive or no longer relevant 109 | - Suggest new rules that would improve development efficiency or code quality 110 | - For each rule change, provide clear reasoning and expected benefits -------------------------------------------------------------------------------- /DOCUMENTATION_CATALOG.md: -------------------------------------------------------------------------------- 1 | # IMAP MCP Documentation Catalog 2 | 3 | This document provides a comprehensive overview of all documentation files in the IMAP MCP project. Use this catalog as your first reference point to find specific information about the project's setup, configuration, development practices, and workflows. 4 | 5 | ## Documentation Files 6 | 7 | | Filename | Description | Purpose | Audience | When to Read | 8 | |----------|-------------|---------|----------|--------------| 9 | | README.md | Project overview, features, setup instructions | Provide a high-level introduction to the project and quick setup instructions | User | When first encountering the project or needing a general overview | 10 | | CLAUDE.md | Development guide for AI assistance | Provide development patterns, conventions, and workflows for implementing features | AI Coder | Before starting any development task to ensure compliance with project standards | 11 | | INSTALLATION.md | Detailed installation instructions | Guide users through the complete installation process | User | When setting up the project for the first time | 12 | | GMAIL_SETUP.md | Gmail authentication setup guide | Help users configure Gmail-specific authentication | User | When configuring the project to work with Gmail accounts | 13 | | TASKS.md | Historical task reference (deprecated) | Reference for development tasks (note: actual tasks now in GitHub Issues) | User & AI Coder | Only for historical reference - active tasks are in GitHub Issues | 14 | | TASKS_COMPLETED.md | List of completed tasks | Track development progress | User & AI Coder | When reviewing project history or checking if a feature was implemented | 15 | | TASKS_ICEBOX.md | Backlog of potential future tasks | Track ideas for future development | User & AI Coder | When planning future development or considering new features | 16 | | DOCUMENTATION_CATALOG.md | This file - catalog of all documentation | Provide a single reference point for all documentation | User & AI Coder | When looking for specific documentation but unsure which file contains it | 17 | | docs/AI_DEV_WORKFLOW.md | AI-assisted development workflow | Explain how AI assistants participate in development | User & AI Coder | When integrating AI assistance into the development process | 18 | | docs/COMMIT_CONVENTIONS.md | Git commit formatting standards | Standardize commit messages and link them to issues | AI Coder | Before making any commits to follow project conventions | 19 | | docs/GITHUB_ISSUES_WORKFLOW.md | GitHub Issues management | Explain how to use GitHub Issues for task management | User & AI Coder | When creating, updating, or managing project tasks | 20 | | docs/GIT_INTEGRATION_CHECKLIST.md | Checklist for Git integration | Ensure proper Git workflow | AI Coder | Before starting work on a new branch or preparing for merge | 21 | | docs/ISSUE_HELPER_USAGE.md | Instructions for using issue helper scripts | Facilitate GitHub Issues automation | User & AI Coder | When working with GitHub Issues and helper scripts | 22 | | docs/ISSUE_STATUS_AUTOMATION.md | Details about issue status automation | Explain how issue status is automatically updated | AI Coder | When monitoring issue status changes or troubleshooting automation | 23 | | tests/issue_test_log.md | Test log for issues | Track issue testing | AI Coder | When reviewing test coverage for specific issues | 24 | | tests/issue_test_log.txt | Text version of test log | Track issue testing | AI Coder | When reviewing test coverage for specific issues (alternative format) | 25 | 26 | ## Recent Features 27 | 28 | The following features have been recently implemented and are documented in the codebase: 29 | 30 | 1. **Email Reply Functionality** 31 | - Draft replies to emails with proper headers and formatting 32 | - Support for both plain text and HTML replies 33 | - Reply-all functionality with CC support 34 | - Proper saving of drafts to the appropriate folders 35 | 36 | 2. **Integration Tests** 37 | - Tests for email reply drafting functionality 38 | - Tests for IMAP and SMTP client interactions 39 | 40 | ## Documentation Update History 41 | 42 | | Date | Description | 43 | |------|-------------| 44 | | 2025-03-28 | Added DOCUMENTATION_CATALOG.md and updated references to HTML email reply functionality | 45 | 46 | ## Usage Instructions 47 | 48 | When looking for information: 49 | 50 | 1. Start with this catalog to identify the most relevant document 51 | 2. Review the specific document for detailed information 52 | 3. If information spans multiple documents, check cross-references 53 | 4. For code-specific documentation, refer to docstrings in the source code 54 | 55 | For developers (both human and AI), ensure that documentation is kept up-to-date when implementing new features. 56 | -------------------------------------------------------------------------------- /GMAIL_SETUP.md: -------------------------------------------------------------------------------- 1 | # Setting up Gmail Authentication for IMAP MCP 2 | 3 | There are two ways to authenticate with Gmail for IMAP MCP: 4 | 5 | 1. **App Password** (recommended, simpler): Uses a special password generated specifically for your app 6 | 2. **OAuth2 Authentication**: Uses Google's OAuth2 protocol (requires Google Cloud Platform setup) 7 | 8 | ## Option 1: Using App Passwords (Recommended) 9 | 10 | This is the simplest approach and requires just a few steps: 11 | 12 | 1. Enable 2-Step Verification for your Google account: 13 | - Go to [Google Account Security](https://myaccount.google.com/security) 14 | - Turn on 2-Step Verification if not already enabled 15 | 16 | 2. Generate an App Password: 17 | - Go to [App Passwords](https://myaccount.google.com/apppasswords) 18 | - Select "Mail" as the app and choose a device name (e.g., "IMAP MCP") 19 | - Click "Generate" and copy the 16-character password 20 | 21 | 3. Set up IMAP MCP with your app password: 22 | 23 | ```bash 24 | # Run the Gmail app password setup tool 25 | uv run -m imap_mcp.app_password --username your.email@gmail.com 26 | ``` 27 | 28 | You'll be prompted to enter your app password, and the tool will configure IMAP MCP to use Gmail with your app password. 29 | 30 | ## App Password Command-Line Options 31 | 32 | The Gmail app password tool supports several options: 33 | 34 | ```bash 35 | uv run -m imap_mcp.app_password --help 36 | ``` 37 | 38 | Common options include: 39 | 40 | - `--username`: Your Gmail address 41 | - `--password`: Your app password (will prompt securely if not provided) 42 | - `--config`: Path to existing config file to update 43 | - `--output`: Path to save the updated config file (default: config.yaml) 44 | 45 | ## Manual Configuration with App Password 46 | 47 | After setting up with an app password, your `config.yaml` file will look like this: 48 | 49 | ```yaml 50 | imap: 51 | host: imap.gmail.com 52 | port: 993 53 | username: your-email@gmail.com 54 | password: your-16-character-app-password 55 | use_ssl: true 56 | ``` 57 | 58 | You can also set the following environment variables: 59 | - `IMAP_PASSWORD`: Your app password 60 | 61 | ## Option 2: OAuth2 Authentication (Advanced) 62 | 63 | If you need OAuth2 authentication instead of app passwords: 64 | 65 | 1. Create a Google Cloud Platform project 66 | 2. Enable the Gmail API 67 | 3. Create OAuth2 credentials (client ID and secret) 68 | 4. Configure IMAP MCP with those credentials 69 | 70 | This method is more complex but available if needed. See our OAuth2 documentation for details. 71 | 72 | ## Using App-Specific Passwords (Alternative) 73 | 74 | If you prefer not to use OAuth2, you can still use Gmail with an app-specific password: 75 | 76 | 1. Enable 2-Step Verification for your Google account 77 | 2. Generate an app-specific password in your Google account security settings 78 | 3. Use this password in your `config.yaml` file: 79 | 80 | ```yaml 81 | imap: 82 | host: imap.gmail.com 83 | port: 993 84 | username: your-email@gmail.com 85 | password: your-app-specific-password 86 | use_ssl: true 87 | ``` 88 | 89 | Or set the `IMAP_PASSWORD` environment variable to your app-specific password. 90 | 91 | ## Troubleshooting 92 | 93 | - **Invalid Credentials**: Make sure your client ID, client secret, and refresh token are correct 94 | - **Authentication Failed**: Your refresh token may have expired. Run the auth_setup tool again to generate new tokens 95 | - **Permission Denied**: Make sure you have granted all necessary permissions during the OAuth2 flow 96 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # IMAP MCP Server Installation Guide 2 | 3 | This document provides detailed instructions for installing and setting up the IMAP MCP Server. 4 | 5 | ## Prerequisites 6 | 7 | - Python 3.8 or higher 8 | - An IMAP-enabled email account 9 | - [uv](https://docs.astral.sh/uv/) - Python package installer (required for installation) 10 | - Claude Desktop or another MCP-compatible client 11 | 12 | ## Installation Steps 13 | 14 | ### 1. Install the uv tool 15 | 16 | The MCP server installation requires the uv tool from Astral. Install it according to the official documentation: 17 | https://docs.astral.sh/uv/ 18 | 19 | ### 2. Clone the repository 20 | 21 | ```bash 22 | git clone https://github.com/non-dirty/imap-mcp.git 23 | cd imap-mcp 24 | ``` 25 | 26 | ### 3. Install the package and dependencies 27 | 28 | ```bash 29 | pip install -e . 30 | ``` 31 | 32 | For development, install with additional development dependencies: 33 | 34 | ```bash 35 | pip install -e ".[dev]" 36 | ``` 37 | 38 | ### 4. Create a configuration file 39 | 40 | ```bash 41 | cp examples/config.yaml.example config.yaml 42 | ``` 43 | 44 | Edit the `config.yaml` file with your email settings: 45 | 46 | ```yaml 47 | # IMAP server configuration 48 | imap: 49 | # IMAP server address 50 | host: imap.example.com 51 | 52 | # IMAP port (default: 993 for SSL, 143 for non-SSL) 53 | port: 993 54 | 55 | # IMAP username (often your email address) 56 | username: your.email@example.com 57 | 58 | # IMAP password (or set IMAP_PASSWORD environment variable) 59 | # password: your_password 60 | 61 | # Use SSL connection (default: true) 62 | use_ssl: true 63 | 64 | # Optional: Restrict access to specific folders 65 | # If not specified, all folders will be accessible 66 | # allowed_folders: 67 | # - INBOX 68 | # - Sent 69 | # - Archive 70 | # - Important 71 | ``` 72 | 73 | For security, it's recommended to use environment variables for sensitive information: 74 | 75 | ```bash 76 | export IMAP_PASSWORD="your_secure_password" 77 | ``` 78 | 79 | ### 5. Running the server 80 | 81 | #### Basic usage: 82 | 83 | ```bash 84 | python -m imap_mcp.server 85 | ``` 86 | 87 | #### With specific config file: 88 | 89 | ```bash 90 | python -m imap_mcp.server --config /path/to/config.yaml 91 | ``` 92 | 93 | #### For development mode (with inspector): 94 | 95 | ```bash 96 | python -m imap_mcp.server --dev 97 | ``` 98 | 99 | #### For debugging: 100 | 101 | ```bash 102 | python -m imap_mcp.server --debug 103 | ``` 104 | 105 | ## Integrating with Claude Desktop 106 | 107 | Add the following to your Claude Desktop configuration: 108 | 109 | ```json 110 | { 111 | "mcpServers": { 112 | "imap": { 113 | "command": "python", 114 | "args": ["-m", "imap_mcp.server", "--config", "/path/to/config.yaml"], 115 | "env": { 116 | "IMAP_PASSWORD": "your_secure_password" 117 | } 118 | } 119 | } 120 | } 121 | ``` 122 | 123 | ## Troubleshooting 124 | 125 | If you encounter issues with the installation or running the server: 126 | 127 | 1. Ensure all prerequisites are installed correctly 128 | 2. Verify your IMAP server settings are correct 129 | 3. Check that your email provider allows IMAP access 130 | 4. For authentication issues, try using an app-specific password if available 131 | 5. Enable debug mode (`--debug`) for more detailed logs -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IMAP MCP Server 2 | 3 | A Model Context Protocol (MCP) server that enables AI assistants to check email, process messages, and learn user preferences through interaction. 4 | 5 | ## Overview 6 | 7 | This project implements an MCP server that interfaces with IMAP email servers to provide the following capabilities: 8 | 9 | - Email browsing and searching 10 | - Email organization (moving, tagging, marking) 11 | - Email composition and replies 12 | - Interactive email processing and learning user preferences 13 | - Automated email summarization and categorization 14 | - Support for multiple IMAP providers 15 | 16 | The IMAP MCP server is designed to work with Claude or any other MCP-compatible assistant, allowing them to act as intelligent email assistants that learn your preferences over time. 17 | 18 | ## Features 19 | 20 | - **Email Authentication**: Secure access to IMAP servers with various authentication methods 21 | - **Email Browsing**: List folders and messages with filtering options 22 | - **Email Content**: Read message contents including text, HTML, and attachments 23 | - **Email Actions**: Move, delete, mark as read/unread, flag messages 24 | - **Email Composition**: Draft and save replies to messages with proper formatting 25 | - Support for plain text and HTML replies 26 | - Reply-all functionality with CC support 27 | - Proper threading with In-Reply-To and References headers 28 | - Save drafts to appropriate folders 29 | - **Search**: Basic search capabilities across folders 30 | - **Interaction Patterns**: Structured patterns for processing emails and learning preferences (planned) 31 | - **Learning Layer**: Record and analyze user decisions to predict future actions (planned) 32 | 33 | ## Current Project Structure 34 | 35 | The project is currently organized as follows: 36 | 37 | ``` 38 | . 39 | ├── examples/ # Example configurations 40 | │ └── config.yaml.example 41 | ├── imap_mcp/ # Source code 42 | │ ├── __init__.py 43 | │ ├── config.py # Configuration handling 44 | │ ├── imap_client.py # IMAP client implementation 45 | │ ├── models.py # Data models 46 | │ ├── resources.py # MCP resources implementation 47 | │ ├── server.py # Main server implementation 48 | │ └── tools.py # MCP tools implementation 49 | ├── tests/ # Test suite 50 | │ ├── __init__.py 51 | │ └── test_models.py 52 | ├── INSTALLATION.md # Detailed installation guide 53 | ├── pyproject.toml # Project configuration 54 | └── README.md # This file 55 | ``` 56 | 57 | ## Getting Started 58 | 59 | ### Prerequisites 60 | 61 | - Python 3.8 or higher 62 | - An IMAP-enabled email account (Gmail recommended) 63 | - [uv](https://docs.astral.sh/uv/) for package management and running Python scripts 64 | 65 | ### Installation 66 | 67 | 1. Install uv if you haven't already: 68 | ```bash 69 | curl -LsSf https://astral.sh/uv/install.sh | sh 70 | ``` 71 | 72 | 2. Clone and install the package: 73 | ```bash 74 | git clone https://github.com/non-dirty/imap-mcp.git 75 | cd imap-mcp 76 | uv venv 77 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 78 | uv pip install -e ".[dev]" 79 | ``` 80 | 81 | ### Gmail Configuration 82 | 83 | 1. Create a config file: 84 | ```bash 85 | cp config.sample.yaml config.yaml 86 | ``` 87 | 88 | 2. Set up Gmail OAuth2 credentials: 89 | - Go to [Google Cloud Console](https://console.cloud.google.com/) 90 | - Create a new project or select existing one 91 | - Enable the Gmail API 92 | - Create OAuth2 credentials (Desktop Application type) 93 | - Download the client configuration 94 | 95 | 3. Update `config.yaml` with your Gmail settings: 96 | ```yaml 97 | imap: 98 | host: imap.gmail.com 99 | port: 993 100 | username: your-email@gmail.com 101 | use_ssl: true 102 | oauth2: 103 | client_id: YOUR_CLIENT_ID 104 | client_secret: YOUR_CLIENT_SECRET 105 | refresh_token: YOUR_REFRESH_TOKEN 106 | ``` 107 | 108 | ### Usage 109 | 110 | #### Checking Email 111 | 112 | To list emails in your inbox: 113 | ```bash 114 | uv run list_inbox.py --config config.yaml --folder INBOX --limit 10 115 | ``` 116 | 117 | Available options: 118 | - `--folder`: Specify which folder to check (default: INBOX) 119 | - `--limit`: Maximum number of emails to display (default: 10) 120 | - `--verbose`: Enable detailed logging output 121 | 122 | #### Starting the MCP Server 123 | 124 | To start the IMAP MCP server: 125 | ```bash 126 | uv run imap_mcp.server --config config.yaml 127 | ``` 128 | 129 | For development mode with debugging: 130 | ```bash 131 | uv run imap_mcp.server --dev 132 | ``` 133 | 134 | #### Managing OAuth2 Tokens 135 | 136 | To refresh your OAuth2 token: 137 | ```bash 138 | uv run imap_mcp.auth_setup refresh-token --config config.yaml 139 | ``` 140 | 141 | To generate a new OAuth2 token: 142 | ```bash 143 | uv run imap_mcp.auth_setup generate-token --config config.yaml 144 | ``` 145 | 146 | ## Development 147 | 148 | ### Setting Up Development Environment 149 | 150 | ```bash 151 | # Set up virtual environment 152 | python -m venv venv 153 | source venv/bin/activate # On Windows: venv\Scripts\activate 154 | 155 | # Install development dependencies 156 | pip install -e ".[dev]" 157 | ``` 158 | 159 | ### Running Tests 160 | 161 | ```bash 162 | pytest 163 | ``` 164 | 165 | ## Security Considerations 166 | 167 | This MCP server requires access to your email account, which contains sensitive personal information. Please be aware of the following security considerations: 168 | 169 | - Store email credentials securely using environment variables or secure credential storage 170 | - Consider using app-specific passwords instead of your main account password 171 | - Limit folder access to only what's necessary for your use case 172 | - Review the permissions granted to the server in your email provider's settings 173 | 174 | ## Project Roadmap 175 | 176 | - [x] Project initialization and repository setup 177 | - [x] Basic IMAP integration 178 | - [x] Email resource implementation 179 | - [x] Email tool implementation 180 | - [x] Email reply and draft functionality 181 | - [ ] User preference learning implementation 182 | - [ ] Advanced search capabilities 183 | - [ ] Multi-account support 184 | - [ ] Integration with major email providers 185 | 186 | ## Contributing 187 | 188 | Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. 189 | 190 | ## License 191 | 192 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 193 | 194 | ## Acknowledgments 195 | 196 | - [Model Context Protocol](https://modelcontextprotocol.io/) for providing the framework 197 | - [Anthropic](https://www.anthropic.com/) for developing Claude 198 | - Various Python IMAP libraries that make this project possible 199 | -------------------------------------------------------------------------------- /TASKS_ICEBOX.md: -------------------------------------------------------------------------------- 1 | ### 11. Implement Email Processing Workflow - States 2 | 3 | **Task Name**: Implement email processing workflow states 4 | 5 | **Test Specifications**: 6 | - Test workflow state definitions 7 | - Test state transitions 8 | - Test state persistence 9 | - Test state validation 10 | - Test state serialization/deserialization 11 | 12 | **Implementation Steps**: 13 | ``` 14 | 1. Create test_workflow_states.py with test cases covering: 15 | - State definitions (new, reviewing, actioned, etc.) 16 | - State transitions (valid and invalid) 17 | - State persistence 18 | - State validation 19 | - State serialization/deserialization 20 | 2. Cover both valid and invalid state transitions 21 | ``` 22 | 23 | **TDD Process**: 24 | 1. Run `pytest tests/test_workflow_states.py -v` to see all tests fail 25 | 2. Create workflow_states.py implementing the required functionality 26 | 3. Run tests again until all pass 27 | 4. Run `pytest --cov=imap_mcp.workflow_states` to verify coverage 28 | 29 | ### 12. Implement Email Processing Workflow - Actions 30 | 31 | **Task Name**: Implement email processing workflow actions 32 | 33 | **Test Specifications**: 34 | - Test action definitions 35 | - Test action execution 36 | - Test action validation 37 | - Test action consequences on states 38 | - Test action history tracking 39 | 40 | **Implementation Steps**: 41 | ``` 42 | 1. Create test_workflow_actions.py with test cases covering: 43 | - Action definitions (read, reply, archive, etc.) 44 | - Action execution 45 | - Action validation 46 | - Action state consequences 47 | - Action history tracking 48 | 2. Test with various email scenarios 49 | ``` 50 | 51 | **TDD Process**: 52 | 1. Run `pytest tests/test_workflow_actions.py -v` to see all tests fail 53 | 2. Create workflow_actions.py implementing the required functionality 54 | 3. Run tests again until all pass 55 | 4. Run `pytest --cov=imap_mcp.workflow_actions` to verify coverage 56 | 57 | ### 13. Implement Email Processing Workflow - User Interaction 58 | 59 | **Task Name**: Implement user interaction patterns 60 | 61 | **Test Specifications**: 62 | - Test different interaction patterns 63 | - Test question prompts 64 | - Test user response handling 65 | - Test interaction history 66 | - Test adaptations based on learning 67 | 68 | **Implementation Steps**: 69 | ``` 70 | 1. Create test_user_interaction.py with test cases covering: 71 | - Interaction patterns 72 | - Question generation 73 | - Response handling 74 | - Interaction history 75 | - Learning-based adaptations 76 | 2. Mock user responses for testing 77 | ``` 78 | 79 | **TDD Process**: 80 | 1. Run `pytest tests/test_user_interaction.py -v` to see all tests fail 81 | 2. Create user_interaction.py implementing the required functionality 82 | 3. Run tests again until all pass 83 | 4. Run `pytest --cov=imap_mcp.user_interaction` to verify coverage 84 | 85 | ### 14. Integrate Workflow Components 86 | 87 | **Task Name**: Integrate all workflow components 88 | 89 | **Test Specifications**: 90 | - Test end-to-end workflow process 91 | - Test combining all workflow components 92 | - Test integration with MCP resources and tools 93 | - Test workflow initialization from the server 94 | - Test persistence across server restarts 95 | 96 | **Implementation Steps**: 97 | ``` 98 | 1. Create test_workflow_integration.py with test cases covering: 99 | - End-to-end workflow process 100 | - Component interaction 101 | - Resource and tool integration 102 | - Server initialization 103 | - Persistence testing 104 | 2. Test with realistic email processing scenarios 105 | ``` 106 | 107 | **TDD Process**: 108 | 1. Run `pytest tests/test_workflow_integration.py -v` to see all tests fail 109 | 2. Create workflow.py integrating all workflow components 110 | 3. Update server.py, resources.py, and tools.py to integrate with workflow.py 111 | 4. Run tests again until all pass 112 | 5. Run `pytest --cov=imap_mcp.workflow` to verify coverage 113 | 114 | ### 15. Implement Multi-Account Foundation 115 | 116 | **Task Name**: Implement multi-account data model 117 | 118 | **Test Specifications**: 119 | - Test account model 120 | - Test account configuration 121 | - Test account validation 122 | - Test account serialization/deserialization 123 | - Test account storage 124 | 125 | **Implementation Steps**: 126 | ``` 127 | 1. Create test_account_model.py with test cases covering: 128 | - Account model definition 129 | - Account configuration 130 | - Account validation 131 | - Account serialization/deserialization 132 | - Account storage 133 | 2. Include both valid and invalid account scenarios 134 | ``` 135 | 136 | **TDD Process**: 137 | 1. Run `pytest tests/test_account_model.py -v` to see all tests fail 138 | 2. Create account_model.py implementing the required functionality 139 | 3. Run tests again until all pass 140 | 4. Run `pytest --cov=imap_mcp.account_model` to verify coverage 141 | 142 | ### 16. Implement Account Management 143 | 144 | **Task Name**: Implement account management functionality 145 | 146 | **Test Specifications**: 147 | - Test adding accounts 148 | - Test removing accounts 149 | - Test updating account settings 150 | - Test account selection/switching 151 | - Test account persistence 152 | 153 | **Implementation Steps**: 154 | ``` 155 | 1. Create test_account_manager.py with test cases covering: 156 | - Adding accounts 157 | - Removing accounts 158 | - Updating account settings 159 | - Account selection/switching 160 | - Account persistence 161 | 2. Mock filesystem interactions for persistence testing 162 | ``` 163 | 164 | **TDD Process**: 165 | 1. Run `pytest tests/test_account_manager.py -v` to see all tests fail 166 | 2. Create account_manager.py implementing the required functionality 167 | 3. Run tests again until all pass 168 | 4. Run `pytest --cov=imap_mcp.account_manager` to verify coverage 169 | 170 | ### 17. Integrate Multi-Account Support 171 | 172 | **Task Name**: Integrate multi-account support 173 | 174 | **Test Specifications**: 175 | - Test integration with IMAP client 176 | - Test integration with resources 177 | - Test integration with tools 178 | - Test integration with server 179 | - Test account-specific persistence 180 | 181 | **Implementation Steps**: 182 | ``` 183 | 1. Create test_multi_account_integration.py with test cases covering: 184 | - IMAP client integration 185 | - Resource integration 186 | - Tool integration 187 | - Server integration 188 | - Account-specific persistence 189 | 2. Test with multiple account configurations 190 | ``` 191 | 192 | **TDD Process**: 193 | 1. Run `pytest tests/test_multi_account_integration.py -v` to see all tests fail 194 | 2. Update all relevant components to support multiple accounts 195 | 3. Run tests again until all pass 196 | 4. Run `pytest --cov=imap_mcp` to verify overall coverage 197 | -------------------------------------------------------------------------------- /config.sample.yaml: -------------------------------------------------------------------------------- 1 | # Sample configuration for IMAP MCP with Gmail OAuth2 support 2 | # Copy this file to config.yaml and fill in your values 3 | 4 | # IMAP server configuration 5 | imap: 6 | # For Gmail 7 | host: imap.gmail.com 8 | port: 993 9 | username: your-email@gmail.com 10 | use_ssl: true 11 | 12 | # Option 1: OAuth2 authentication (recommended for Gmail) 13 | oauth2: 14 | client_id: YOUR_CLIENT_ID 15 | client_secret: YOUR_CLIENT_SECRET 16 | refresh_token: YOUR_REFRESH_TOKEN 17 | # access_token and token_expiry will be filled automatically 18 | 19 | # Option 2: App-specific password (if OAuth2 is not used) 20 | # password: YOUR_APP_SPECIFIC_PASSWORD 21 | 22 | # Optional: restrict to specific folders 23 | # allowed_folders: 24 | # - INBOX 25 | # - "[Gmail]/Sent Mail" 26 | # - "Important" 27 | -------------------------------------------------------------------------------- /docs/AI_DEV_WORKFLOW.md: -------------------------------------------------------------------------------- 1 | # AI-Assisted Development Workflow 2 | 3 | This document outlines the workflow for using AI assistants (like Claude Sonnet) with GitHub Issues to autonomously develop features for the IMAP MCP project. 4 | 5 | ## Overview 6 | 7 | The integration of AI assistants with GitHub Issues provides an autonomous development system that: 8 | - Prioritizes work through GitHub Issues 9 | - Maintains consistent development patterns 10 | - Enforces quality through automated testing 11 | - Preserves institutional knowledge in issue history 12 | - Reduces manual overhead in development 13 | 14 | ## Autonomous Feature Development Process 15 | 16 | ### 1. Task Planning & Creation 17 | 18 | AI assistants can participate in task planning by: 19 | - Analyzing existing codebase and identifying improvement opportunities 20 | - Creating issues with appropriate priority and status labels: 21 | ```bash 22 | gh issue create --title "New Feature Name" --body "Detailed description..." --label "priority:X" --label "status:prioritized" 23 | ``` 24 | - Breaking down complex tasks into smaller, manageable sub-issues 25 | - Linking related issues together with references 26 | 27 | ### 2. Feature Implementation 28 | 29 | When starting work on an issue: 30 | ```bash 31 | # Check current status 32 | python scripts/issue_helper.py check 33 | 34 | # Start work on the issue (creates branch, updates status) 35 | python scripts/issue_helper.py start 36 | ``` 37 | 38 | This workflow automatically: 39 | - Creates a feature branch with appropriate naming 40 | - Updates issue status to `in-progress` 41 | - Links commits to the issue for traceability 42 | 43 | ### 3. Test-Driven Development 44 | 45 | AI assistants should follow TDD practices: 46 | 1. Write tests that cover the expected functionality 47 | 2. Run tests to verify they fail appropriately 48 | 3. Implement features to make tests pass 49 | 4. Refactor while maintaining test success 50 | 51 | ```bash 52 | # Run tests 53 | uv run pytest tests/ 54 | 55 | # Run with coverage 56 | uv run pytest --cov=imap_mcp 57 | ``` 58 | 59 | ### 4. Pull Request Creation 60 | 61 | When implementation is complete: 62 | ```bash 63 | # Complete the issue (creates PR, links issue) 64 | python scripts/issue_helper.py complete 65 | ``` 66 | 67 | This automatically: 68 | - Creates a pull request linked to the issue 69 | - Includes issue references in the PR description 70 | - Updates issue status to `in-review` 71 | 72 | ### 5. Code Integration 73 | 74 | Once PR checks pass and reviews are completed: 75 | ```bash 76 | # Merge the PR 77 | gh pr merge 78 | ``` 79 | 80 | This triggers workflows that: 81 | - Merge the changes into the main branch 82 | - Update issue status to `completed` 83 | - Reorganize priorities of remaining issues if appropriate 84 | 85 | ## Benefits for AI Development 86 | 87 | ### Reduced Context Window Usage 88 | 89 | By leveraging GitHub Issues instead of in-chat task lists: 90 | - Each feature can be developed with focused context 91 | - Issue details provide precise requirements without needing to remember all project details 92 | - Issue references create implicit links between related features 93 | 94 | ### Improved Output Consistency 95 | 96 | The standardized workflow ensures: 97 | - Uniform code style and patterns across features 98 | - Consistent test coverage and quality standards 99 | - Reliable commit message formats that trigger appropriate status transitions 100 | 101 | ### Enhanced Collaboration 102 | 103 | AI assistants can collaborate with human developers through: 104 | - Issue comments for progress updates and clarifications 105 | - PR reviews for feedback and improvements 106 | - Status transitions that keep everyone informed 107 | 108 | ## Automated Validation 109 | 110 | The status updater script ensures issues accurately reflect development status: 111 | ```bash 112 | python scripts/issue_status_updater.py --owner --repo --issue 113 | ``` 114 | 115 | This script can be run: 116 | - Locally by developers or AI assistants 117 | - Automatically through GitHub Actions on commits and PR events 118 | - As part of a scheduled validation process 119 | 120 | ## Success Metrics 121 | 122 | Autonomous development success can be measured by: 123 | - Percentage of issues completed with minimal human intervention 124 | - Test coverage maintained across features 125 | - Frequency of status transitions indicating steady progress 126 | - Reduction in time from issue creation to completion 127 | 128 | ## Continuous Improvement 129 | 130 | Through the issue history, the system accumulates knowledge about: 131 | - Which development patterns lead to successful implementations 132 | - Common challenges and their solutions 133 | - Optimal task sizing for AI development 134 | - Patterns that could benefit from additional automation 135 | -------------------------------------------------------------------------------- /docs/COMMIT_CONVENTIONS.md: -------------------------------------------------------------------------------- 1 | # Commit Message Conventions 2 | 3 | This document outlines the commit message format and conventions for the IMAP MCP project. 4 | 5 | ## Format 6 | 7 | All commit messages should follow this format: 8 | 9 | ``` 10 | type(scope): short description 11 | 12 | [optional body] 13 | 14 | [optional footer] 15 | ``` 16 | 17 | ## Type 18 | 19 | The type must be one of the following: 20 | 21 | - `feat`: A new feature 22 | - `fix`: A bug fix 23 | - `docs`: Documentation changes 24 | - `style`: Changes that do not affect the meaning of the code (formatting, etc.) 25 | - `refactor`: Code changes that neither fix a bug nor add a feature 26 | - `test`: Adding or correcting tests 27 | - `chore`: Changes to the build process, tools, etc. 28 | 29 | ## Scope 30 | 31 | The scope should be the name of the module affected (e.g., `auth`, `imap`, `cli`). 32 | 33 | ## Issue References 34 | 35 | For commits related to GitHub issues, use one of these formats: 36 | 37 | - `fix(auth): implement token refresh (fixes #42)` 38 | - `feat(imap): add folder listing capability (closes #123)` 39 | - `test(auth): add tests for OAuth flow (refs #56)` 40 | 41 | ## Examples 42 | 43 | ``` 44 | feat(oauth): implement Gmail OAuth2 flow 45 | 46 | - Add authorization URL generation 47 | - Add token exchange process 48 | - Add refresh token handling 49 | 50 | Closes #21 51 | ``` 52 | 53 | ``` 54 | fix(parser): handle empty IMAP response correctly 55 | 56 | Previously empty responses would cause a KeyError. This fix adds proper 57 | checking for empty responses. 58 | 59 | Fixes #45 60 | ``` 61 | 62 | ``` 63 | test(coverage): improve test coverage for IMAP client 64 | 65 | Added tests for connection errors and timeouts. Coverage now at 95%. 66 | 67 | Refs #22 68 | ``` 69 | 70 | ## Pull Requests 71 | 72 | When creating pull requests: 73 | 74 | 1. Reference related issues in the PR description 75 | 2. Use the provided PR template 76 | 3. Ensure that the commits follow these conventions 77 | 4. Squash commits when appropriate for a cleaner history 78 | 79 | Following these conventions helps with: 80 | - Automatic issue tracking 81 | - Generating meaningful changelogs 82 | - Making the project history navigable and useful 83 | -------------------------------------------------------------------------------- /docs/GIT_INTEGRATION_CHECKLIST.md: -------------------------------------------------------------------------------- 1 | # GitHub Integration Checklist 2 | 3 | ## Transition Status 4 | 5 | ### Completed 6 | - [x] Task migration script created (`scripts/tasks_to_issues.py`) 7 | - [x] Tasks migrated to GitHub Issues 8 | - [x] TASKS.md updated with transition information 9 | - [x] GitHub Issue templates created: 10 | - [x] Feature template 11 | - [x] Bug template 12 | - [x] Test improvement template 13 | - [x] Documentation template 14 | - [x] PR template created 15 | - [x] Commit conventions documented 16 | - [x] GitHub workflow documentation created 17 | 18 | ### In Progress 19 | - [ ] GitHub Project board setup (Issue #10) 20 | - [ ] GitHub Actions workflow for automated status updates (Issue #11) 21 | - [ ] Task dependency visualization implementation (Issue #12) 22 | - [ ] AI-assisted prioritization setup (Issue #13) 23 | - [ ] Final TASKS.md update (retain methodology, remove task lists) (Issue #14) 24 | 25 | ## Transition Verification 26 | 27 | To verify all tasks have been properly migrated: 28 | 29 | 1. **Issue Count Check**: 30 | - Expected number of issues: 11 (from task table) 31 | - Actual number of issues: 14 (verified in GitHub) 32 | 33 | 2. **Coverage Tasks**: 34 | - Number of modules below 90% coverage: (from running script) 35 | - Number of coverage tasks created: (verify in GitHub) 36 | 37 | 3. **Issue Properties**: 38 | - All issues have proper priority labels 39 | - All issues have proper status labels 40 | - All issues contain full task descriptions 41 | 42 | ## Next Steps 43 | 44 | After completing the current issues (#10-#14), the GitHub Integration process will be complete. Then we can continue with the regular task workflow. 45 | 46 | ## Verification Commands 47 | 48 | ```bash 49 | # Check GitHub issues 50 | gh issue list --repo non-dirty/imap-mcp 51 | 52 | # Run code coverage 53 | uv run -m pytest --cov=imap_mcp --cov-report=term 54 | 55 | # Verify GitHub project board 56 | gh project view --owner non-dirty --repo imap-mcp 57 | ``` 58 | 59 | This document serves as a reference for the transition process and verification. 60 | -------------------------------------------------------------------------------- /docs/ISSUE_HELPER_USAGE.md: -------------------------------------------------------------------------------- 1 | # Issue Helper Script 2 | 3 | This document describes how to use the `issue_helper.py` script for managing GitHub issues efficiently. 4 | 5 | ## Overview 6 | 7 | The Issue Helper script simplifies common GitHub issue management tasks, making it easier for AI agents and human developers to work with issues consistently. It standardizes issue workflows and reduces the number of commands needed for routine operations. 8 | 9 | ## Installation 10 | 11 | The script is located in the `scripts` directory and requires Python 3.6+: 12 | 13 | ```bash 14 | # Make the script executable 15 | chmod +x scripts/issue_helper.py 16 | 17 | # Run with Python 18 | python scripts/issue_helper.py [command] [arguments] 19 | ``` 20 | 21 | ## Commands 22 | 23 | ### Start Work on an Issue 24 | 25 | ```bash 26 | python scripts/issue_helper.py start [issue_number] 27 | ``` 28 | 29 | This command: 30 | 1. Creates a new branch named `feature/issue-[number]-[title]` 31 | 2. Makes an initial commit that references the issue 32 | 3. Pushes the branch to GitHub 33 | 4. Updates the issue status to "in-progress" 34 | 35 | Example: 36 | ```bash 37 | python scripts/issue_helper.py start 42 38 | ``` 39 | 40 | ### Complete an Issue with a PR 41 | 42 | ```bash 43 | python scripts/issue_helper.py complete [issue_number] [--branch branch_name] 44 | ``` 45 | 46 | This command: 47 | 1. Creates a pull request that references the issue 48 | 2. Uses "Closes #[number]" in the PR body to trigger automatic status updates 49 | 3. Sets the PR title based on the issue title 50 | 51 | Example: 52 | ```bash 53 | python scripts/issue_helper.py complete 42 54 | ``` 55 | 56 | ### Update Issue Status Manually 57 | 58 | ```bash 59 | python scripts/issue_helper.py update [issue_number] [status] 60 | ``` 61 | 62 | Where `[status]` is one of: 63 | - `prioritized` 64 | - `in-progress` 65 | - `completed` 66 | - `reviewed` 67 | - `archived` 68 | 69 | Example: 70 | ```bash 71 | python scripts/issue_helper.py update 42 completed 72 | ``` 73 | 74 | ### Force Status Update for Testing 75 | 76 | ```bash 77 | python scripts/issue_helper.py test [issue_number] [status] 78 | ``` 79 | 80 | This command is specifically for testing the status workflow: 81 | 1. Updates the issue status 82 | 2. Adds a comment explaining the forced update 83 | 3. Documents how the status would normally be triggered 84 | 85 | Example: 86 | ```bash 87 | python scripts/issue_helper.py test 42 in-progress 88 | ``` 89 | 90 | ### Check Issue Status 91 | 92 | ```bash 93 | python scripts/issue_helper.py check [issue_number] 94 | ``` 95 | 96 | This command displays comprehensive information about an issue: 97 | 1. Basic details (title, URL) 98 | 2. Current status and priority 99 | 3. Recent commit activity related to the issue 100 | 4. Associated pull requests 101 | 102 | Example: 103 | ```bash 104 | python scripts/issue_helper.py check 42 105 | ``` 106 | 107 | Output: 108 | ``` 109 | Issue #42: Implement Feature X 110 | URL: https://github.com/username/repo/issues/42 111 | Status: in-progress 112 | Priority: 2 113 | 114 | Recent activity: 115 | Commits: 116 | a1b2c3d refs #42 Start implementing Feature X 117 | e4f5g6h implements #42 Add core functionality 118 | 119 | Pull Requests: 120 | #15 Implement Feature X (open) 121 | ``` 122 | 123 | ## Integration with Automated Workflow 124 | 125 | The Issue Helper script complements the automated GitHub Actions workflow: 126 | 127 | 1. **Normal operation**: Let the GitHub Actions workflow handle status updates automatically based on commit messages and PRs 128 | 2. **Manual intervention**: Use the script when you need to manually update statuses or troubleshoot the workflow 129 | 3. **Testing**: Use the `test` command to verify status transitions without waiting for GitHub Actions 130 | 131 | ## Best Practices 132 | 133 | 1. **Always use the `start` command** when beginning work on a new issue to ensure consistent branch naming and initial commit messages 134 | 2. **Use the standard commit message format** even when using the helper script: 135 | - `refs #N` - For general work on an issue 136 | - `implements #N` - For implementing a feature 137 | - `fixes #N` - For fixing a bug 138 | - `closes #N` - For completing an issue 139 | 3. **Include meaningful commit messages** beyond just the issue reference 140 | 141 | ## Troubleshooting 142 | 143 | If you encounter issues with the script: 144 | 145 | 1. Check that you have the GitHub CLI (`gh`) installed and authenticated 146 | 2. Ensure you're running the script from the root directory of the repository 147 | 3. Verify that your repository has the expected label structure (status:prioritized, status:in-progress, etc.) 148 | 149 | ## Contributing 150 | 151 | To extend the Issue Helper: 152 | 153 | 1. Add new functionality to the script 154 | 2. Update this documentation with new commands and examples 155 | 3. Add tests for the new functionality 156 | -------------------------------------------------------------------------------- /docs/ISSUE_STATUS_AUTOMATION.md: -------------------------------------------------------------------------------- 1 | # GitHub Issue Status Automation 2 | 3 | This document describes the automated issue status tracking system implemented for the IMAP-MCP project. 4 | 5 | ## Overview 6 | 7 | The Issue Status Updater automatically monitors repository activity and updates GitHub issue statuses based on: 8 | - Commit messages referencing issues 9 | - Pull request activity 10 | - Test execution results 11 | 12 | The system reduces manual maintenance of task tracking and ensures the task board stays up-to-date. 13 | 14 | ## How It Works 15 | 16 | ### Status Lifecycle 17 | 18 | Issues automatically transition through these statuses: 19 | 20 | 1. `prioritized` - Initial state when an issue is created and prioritized 21 | 2. `in-progress` - Activated when a commit references the issue 22 | 3. `completed` - Set when a PR fixing the issue is merged and tests pass 23 | 4. `reviewed` - Set when a reviewer approves the changes (currently manual) 24 | 5. `archived` - Set when the issue is closed (currently manual) 25 | 26 | ### Automatic Priority Management 27 | 28 | When an issue is completed, the system automatically adjusts the priorities of remaining tasks: 29 | 30 | 1. The system identifies the priority number of the completed task 31 | 2. All tasks with lower priority (higher priority numbers) have their priority numbers decreased by 1 32 | 3. Notification comments are added to affected issues, explaining the priority change 33 | 34 | This ensures that: 35 | - Priority numbers are always consecutive 36 | - The highest priority task is always priority #1 37 | - There are no gaps in the priority numbering sequence 38 | 39 | ### Commit Message Format 40 | 41 | The system recognizes these commit message formats: 42 | 43 | ``` 44 | #: 45 | ``` 46 | 47 | Where `` can be: 48 | - `fixes` - Indicates this commit fixes the issue 49 | - `closes` - Same as fixes, but with higher precedence 50 | - `resolves` - Same as fixes 51 | - `implements` - Indicates this commit implements functionality for the issue 52 | - `refs` - References the issue but doesn't resolve it 53 | 54 | Examples: 55 | ``` 56 | fixes #42: Fix authentication bug 57 | implements #100: Add OAuth flow 58 | refs #5, #8: Update documentation 59 | ``` 60 | 61 | ### GitHub Actions Integration 62 | 63 | The system is integrated with GitHub Actions, which automatically runs on: 64 | - New commits to main/master branches 65 | - Pull request activity (opened, closed, synchronized) 66 | - Manual trigger with specified issue number 67 | 68 | ## Usage 69 | 70 | ### Automatic Updates 71 | 72 | No user action is required for automatic status updates. The system will: 73 | 1. Detect when work on an issue begins (first commit related to a task) 74 | 2. Monitor test coverage and execution to help determine completion 75 | 3. Add status update comments to issues with relevant information 76 | 77 | ### Manual Triggers 78 | 79 | You can manually trigger status updates for specific issues: 80 | 81 | 1. Go to the Actions tab in GitHub 82 | 2. Select "Issue Status Updater" workflow 83 | 3. Click "Run workflow" 84 | 4. Enter the issue number to update 85 | 5. Click "Run workflow" 86 | 87 | ### Configuration 88 | 89 | Environment variables: 90 | - `GITHUB_TOKEN`: Automatically provided by GitHub Actions 91 | - `GITHUB_OWNER`: Repository owner (set automatically in workflow) 92 | - `GITHUB_REPO`: Repository name (set automatically in workflow) 93 | 94 | ## Extending the System 95 | 96 | The status updater is designed to be modular and extensible: 97 | 98 | 1. `CommitAnalyzer`: Analyzes git commits for issue references 99 | 2. `PullRequestAnalyzer`: Analyzes pull requests for issue references 100 | 3. `TestResultsAnalyzer`: Analyzes test results for issue-related code 101 | 4. `IssueUpdater`: Updates GitHub issue statuses and adds comments 102 | 103 | To add new functionality: 104 | 1. Add new methods to the appropriate class 105 | 2. Write tests for the new functionality 106 | 3. Update the documentation 107 | 108 | ## Testing 109 | 110 | Run the tests with: 111 | 112 | ```bash 113 | uv run -m pytest tests/test_issue_status_updater.py -v 114 | ``` 115 | 116 | ## Troubleshooting 117 | 118 | If status updates aren't working as expected: 119 | 120 | 1. Check commit message format - ensure it follows the conventions 121 | 2. Verify GitHub Actions is running - check the Actions tab for workflow runs 122 | 3. Check for error messages in the workflow logs 123 | 4. Ensure tests are passing - failed tests will prevent "completed" status 124 | -------------------------------------------------------------------------------- /examples/config.yaml.example: -------------------------------------------------------------------------------- 1 | # IMAP MCP Server Configuration Example 2 | # Rename to config.yaml and update with your settings 3 | 4 | # IMAP server configuration 5 | imap: 6 | # IMAP server address 7 | host: imap.example.com 8 | 9 | # IMAP port (default: 993 for SSL, 143 for non-SSL) 10 | port: 993 11 | 12 | # IMAP username (often your email address) 13 | username: your.email@example.com 14 | 15 | # IMAP password (or set IMAP_PASSWORD environment variable) 16 | # password: your_password 17 | 18 | # Use SSL connection (default: true) 19 | use_ssl: true 20 | 21 | # Optional: Restrict access to specific folders 22 | # If not specified, all folders will be accessible 23 | # allowed_folders: 24 | # - INBOX 25 | # - Sent 26 | # - Archive 27 | # - Important 28 | -------------------------------------------------------------------------------- /imap_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | """IMAP Model Context Protocol server for interactive email processing.""" 2 | 3 | __version__ = "0.1.0" -------------------------------------------------------------------------------- /imap_mcp/app_password.py: -------------------------------------------------------------------------------- 1 | """Gmail app password authentication setup.""" 2 | 3 | import logging 4 | import os 5 | import sys 6 | from pathlib import Path 7 | from typing import Dict, Optional 8 | 9 | import yaml 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def setup_app_password( 15 | username: str, 16 | password: str, 17 | config_path: Optional[str] = None, 18 | config_output: Optional[str] = None, 19 | ) -> Dict: 20 | """Set up Gmail with an app password. 21 | 22 | Args: 23 | username: Gmail email address 24 | password: App password generated from Google Account 25 | config_path: Path to existing config file to update (optional) 26 | config_output: Path to save the updated config file (optional) 27 | 28 | Returns: 29 | Updated configuration dictionary 30 | """ 31 | # Load existing config if specified 32 | config_data = {} 33 | if config_path: 34 | config_file = Path(config_path) 35 | if config_file.exists(): 36 | with open(config_file, "r") as f: 37 | config_data = yaml.safe_load(f) or {} 38 | logger.info(f"Loaded existing configuration from {config_path}") 39 | 40 | # Update config with Gmail settings using app password 41 | if "imap" not in config_data: 42 | config_data["imap"] = {} 43 | 44 | config_data["imap"].update({ 45 | "host": "imap.gmail.com", 46 | "port": 993, 47 | "username": username, 48 | "password": password, 49 | "use_ssl": True 50 | }) 51 | 52 | # Remove any oauth2 config if it exists 53 | if "oauth2" in config_data["imap"]: 54 | del config_data["imap"]["oauth2"] 55 | 56 | # Save updated config if output path specified 57 | if config_output: 58 | output_file = Path(config_output) 59 | output_file.parent.mkdir(parents=True, exist_ok=True) 60 | 61 | with open(output_file, "w") as f: 62 | yaml.dump(config_data, f, default_flow_style=False) 63 | logger.info(f"Saved updated configuration to {config_output}") 64 | 65 | print("\nConfiguration updated for Gmail using app password") 66 | print("\nTo use these credentials, add them to your config.yaml file or set environment variables:") 67 | print(f" IMAP_PASSWORD={password}") 68 | 69 | return config_data 70 | 71 | 72 | def main(): 73 | """Run the Gmail app password setup tool.""" 74 | import argparse 75 | 76 | parser = argparse.ArgumentParser(description="Configure Gmail with app password") 77 | parser.add_argument( 78 | "--username", 79 | help="Gmail email address", 80 | default=os.environ.get("GMAIL_USERNAME"), 81 | ) 82 | parser.add_argument( 83 | "--password", 84 | help="App password from Google Account", 85 | default=os.environ.get("GMAIL_APP_PASSWORD") or os.environ.get("IMAP_PASSWORD"), 86 | ) 87 | parser.add_argument( 88 | "--config", 89 | help="Path to existing config file to update", 90 | ) 91 | parser.add_argument( 92 | "--output", 93 | help="Path to save the updated config file (default: config.yaml)", 94 | default="config.yaml", 95 | ) 96 | 97 | args = parser.parse_args() 98 | 99 | # Configure logging 100 | logging.basicConfig(level=logging.INFO) 101 | 102 | # Prompt for username if not provided 103 | username = args.username 104 | if not username: 105 | username = input("Enter your Gmail address: ").strip() 106 | if not username: 107 | print("Error: Gmail address is required") 108 | sys.exit(1) 109 | 110 | # Prompt for password if not provided 111 | password = args.password 112 | if not password: 113 | import getpass 114 | password = getpass.getpass("Enter your Gmail app password: ").strip() 115 | if not password: 116 | print("Error: App password is required") 117 | sys.exit(1) 118 | 119 | try: 120 | setup_app_password( 121 | username=username, 122 | password=password, 123 | config_path=args.config, 124 | config_output=args.output, 125 | ) 126 | logger.info("Gmail app password setup completed successfully") 127 | except Exception as e: 128 | logger.error(f"Gmail app password setup failed: {e}") 129 | sys.exit(1) 130 | 131 | 132 | if __name__ == "__main__": 133 | main() 134 | -------------------------------------------------------------------------------- /imap_mcp/auth_setup.py: -------------------------------------------------------------------------------- 1 | """Command-line tool for setting up OAuth2 authentication for Gmail.""" 2 | 3 | import argparse 4 | import json 5 | import logging 6 | import os 7 | import sys 8 | from pathlib import Path 9 | from typing import Any, Dict, Optional, Tuple 10 | 11 | import yaml 12 | 13 | from imap_mcp.config import OAuth2Config 14 | from imap_mcp.browser_auth import load_client_credentials 15 | from imap_mcp.oauth2 import get_authorization_url, exchange_code_for_tokens 16 | 17 | logging.basicConfig(level=logging.INFO) 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def setup_gmail_oauth2( 22 | client_id: Optional[str] = None, 23 | client_secret: Optional[str] = None, 24 | credentials_file: Optional[str] = None, 25 | config_path: Optional[str] = None, 26 | config_output: Optional[str] = None, 27 | ) -> Dict[str, Any]: 28 | """Set up OAuth2 authentication for Gmail. 29 | 30 | Args: 31 | client_id: Google API client ID 32 | client_secret: Google API client secret 33 | config_path: Path to existing config file to update (optional) 34 | config_output: Path to save the updated config file (optional) 35 | 36 | Returns: 37 | Updated configuration dictionary 38 | """ 39 | # Load credentials from file if provided 40 | if credentials_file and not (client_id and client_secret): 41 | try: 42 | logger.info(f"Loading credentials from {credentials_file}") 43 | client_id, client_secret = load_client_credentials(credentials_file) 44 | logger.info("Successfully loaded credentials from file") 45 | except Exception as e: 46 | logger.error(f"Failed to load credentials from file: {e}") 47 | sys.exit(1) 48 | 49 | # Verify we have the required credentials 50 | if not client_id or not client_secret: 51 | logger.error("Client ID and Client Secret are required") 52 | print("\nYou must provide either:") 53 | print(" 1. Client ID and Client Secret directly, or") 54 | print(" 2. Path to the credentials JSON file downloaded from Google Cloud Console") 55 | sys.exit(1) 56 | 57 | # Create temporary OAuth2 config 58 | oauth2_config = OAuth2Config( 59 | client_id=client_id, 60 | client_secret=client_secret, 61 | ) 62 | 63 | # Generate authorization URL 64 | auth_url = get_authorization_url(oauth2_config) 65 | 66 | print(f"\n\n1. Open the following URL in your browser:\n\n{auth_url}\n") 67 | print("2. Sign in with your Google account and grant access to your Gmail") 68 | print("3. Copy the authorization code that Google provides after authorization\n") 69 | 70 | # Get authorization code from user 71 | auth_code = input("Enter the authorization code: ").strip() 72 | 73 | # Exchange authorization code for tokens 74 | try: 75 | access_token, refresh_token, expiry = exchange_code_for_tokens(oauth2_config, auth_code) 76 | logger.info("Successfully obtained access and refresh tokens") 77 | except Exception as e: 78 | logger.error(f"Failed to obtain tokens: {e}") 79 | sys.exit(1) 80 | 81 | # Build OAuth2 configuration 82 | oauth2_data = { 83 | "client_id": client_id, 84 | "client_secret": client_secret, 85 | "refresh_token": refresh_token, 86 | "access_token": access_token, 87 | "token_expiry": expiry, 88 | } 89 | 90 | # Load existing config if specified 91 | config_data = {} 92 | if config_path: 93 | config_file = Path(config_path) 94 | if config_file.exists(): 95 | with open(config_file, "r") as f: 96 | config_data = yaml.safe_load(f) or {} 97 | logger.info(f"Loaded existing configuration from {config_path}") 98 | 99 | # Update config with OAuth2 data 100 | if "imap" not in config_data: 101 | config_data["imap"] = {} 102 | 103 | config_data["imap"]["oauth2"] = oauth2_data 104 | 105 | # Save updated config if output path specified 106 | if config_output: 107 | output_file = Path(config_output) 108 | output_file.parent.mkdir(parents=True, exist_ok=True) 109 | 110 | with open(output_file, "w") as f: 111 | yaml.dump(config_data, f, default_flow_style=False) 112 | logger.info(f"Saved updated configuration to {config_output}") 113 | 114 | print("\nConfiguration:") 115 | print(json.dumps(oauth2_data, indent=2)) 116 | print("\nTo use these credentials, add them to your config.yaml file under the imap.oauth2 key.") 117 | print("Alternatively, you can set the following environment variables:") 118 | print(f" GMAIL_CLIENT_ID={client_id}") 119 | print(f" GMAIL_CLIENT_SECRET={client_secret}") 120 | print(f" GMAIL_REFRESH_TOKEN={refresh_token}") 121 | 122 | return config_data 123 | 124 | 125 | def main() -> None: 126 | """Run the OAuth2 setup tool.""" 127 | parser = argparse.ArgumentParser(description="Set up OAuth2 authentication for Gmail") 128 | parser.add_argument( 129 | "--client-id", 130 | help="Google API client ID (optional if credentials file is provided)", 131 | default=os.environ.get("GMAIL_CLIENT_ID"), 132 | ) 133 | parser.add_argument( 134 | "--client-secret", 135 | help="Google API client secret (optional if credentials file is provided)", 136 | default=os.environ.get("GMAIL_CLIENT_SECRET"), 137 | ) 138 | parser.add_argument( 139 | "--credentials-file", 140 | help="Path to the OAuth2 client credentials JSON file downloaded from Google Cloud Console", 141 | ) 142 | parser.add_argument( 143 | "--config", 144 | help="Path to existing config file to update", 145 | default=None, 146 | ) 147 | parser.add_argument( 148 | "--output", 149 | help="Path to save the updated config file", 150 | default="config.yaml", 151 | ) 152 | 153 | args = parser.parse_args() 154 | 155 | # We'll check credentials in setup_gmail_oauth2 after trying to load from the credentials file 156 | 157 | setup_gmail_oauth2( 158 | client_id=args.client_id, 159 | client_secret=args.client_secret, 160 | credentials_file=args.credentials_file, 161 | config_path=args.config, 162 | config_output=args.output, 163 | ) 164 | 165 | 166 | if __name__ == "__main__": 167 | main() 168 | -------------------------------------------------------------------------------- /imap_mcp/config.py: -------------------------------------------------------------------------------- 1 | """Configuration handling for IMAP MCP server.""" 2 | 3 | import logging 4 | import os 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | from typing import Any, Dict, List, Optional 8 | 9 | import yaml 10 | from dotenv import load_dotenv 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | # Load environment variables from .env file if it exists 15 | load_dotenv() 16 | 17 | 18 | @dataclass 19 | class OAuth2Config: 20 | """OAuth2 configuration for IMAP authentication.""" 21 | 22 | client_id: str 23 | client_secret: str 24 | refresh_token: Optional[str] = None 25 | access_token: Optional[str] = None 26 | token_expiry: Optional[int] = None 27 | 28 | @classmethod 29 | def from_dict(cls, data: Dict[str, Any]) -> Optional["OAuth2Config"]: 30 | """Create OAuth2 configuration from dictionary.""" 31 | if not data: 32 | return None 33 | 34 | # OAuth2 credentials can be specified in environment variables 35 | client_id = data.get("client_id") or os.environ.get("GMAIL_CLIENT_ID") 36 | client_secret = data.get("client_secret") or os.environ.get("GMAIL_CLIENT_SECRET") 37 | refresh_token = data.get("refresh_token") or os.environ.get("GMAIL_REFRESH_TOKEN") 38 | 39 | if not client_id or not client_secret: 40 | return None 41 | 42 | return cls( 43 | client_id=client_id, 44 | client_secret=client_secret, 45 | refresh_token=refresh_token, 46 | access_token=data.get("access_token"), 47 | token_expiry=data.get("token_expiry"), 48 | ) 49 | 50 | 51 | @dataclass 52 | class ImapConfig: 53 | """IMAP server configuration.""" 54 | 55 | host: str 56 | port: int 57 | username: str 58 | password: Optional[str] = None 59 | oauth2: Optional[OAuth2Config] = None 60 | use_ssl: bool = True 61 | 62 | @property 63 | def is_gmail(self) -> bool: 64 | """Check if this is a Gmail configuration.""" 65 | return self.host.endswith("gmail.com") or self.host.endswith("googlemail.com") 66 | 67 | @property 68 | def requires_oauth2(self) -> bool: 69 | """Check if this configuration requires OAuth2.""" 70 | return self.is_gmail and self.oauth2 is not None 71 | 72 | @classmethod 73 | def from_dict(cls, data: Dict[str, Any]) -> "ImapConfig": 74 | """Create configuration from dictionary.""" 75 | # Create OAuth2 config if present 76 | oauth2_config = OAuth2Config.from_dict(data.get("oauth2", {})) 77 | 78 | # Password can be specified in environment variable 79 | password = data.get("password") or os.environ.get("IMAP_PASSWORD") 80 | 81 | # For Gmail, we need either password (for app-specific password) or OAuth2 credentials 82 | host = data.get("host", "") 83 | is_gmail = host.endswith("gmail.com") or host.endswith("googlemail.com") 84 | 85 | if is_gmail and not oauth2_config and not password: 86 | raise ValueError( 87 | "Gmail requires either an app-specific password or OAuth2 credentials" 88 | ) 89 | elif not is_gmail and not password: 90 | raise ValueError( 91 | "IMAP password must be specified in config or IMAP_PASSWORD environment variable" 92 | ) 93 | 94 | return cls( 95 | host=data["host"], 96 | port=data.get("port", 993 if data.get("use_ssl", True) else 143), 97 | username=data["username"], 98 | password=password, 99 | oauth2=oauth2_config, 100 | use_ssl=data.get("use_ssl", True), 101 | ) 102 | 103 | 104 | @dataclass 105 | class ServerConfig: 106 | """MCP server configuration.""" 107 | 108 | imap: ImapConfig 109 | allowed_folders: Optional[List[str]] = None 110 | 111 | @classmethod 112 | def from_dict(cls, data: Dict[str, Any]) -> "ServerConfig": 113 | """Create configuration from dictionary.""" 114 | return cls( 115 | imap=ImapConfig.from_dict(data.get("imap", {})), 116 | allowed_folders=data.get("allowed_folders"), 117 | ) 118 | 119 | 120 | def load_config(config_path: Optional[str] = None) -> ServerConfig: 121 | """Load configuration from file or environment variables. 122 | 123 | Args: 124 | config_path: Path to configuration file 125 | 126 | Returns: 127 | Server configuration 128 | 129 | Raises: 130 | FileNotFoundError: If configuration file is not found 131 | ValueError: If configuration is invalid 132 | """ 133 | # Default locations to check for config file 134 | default_locations = [ 135 | Path("config.yaml"), 136 | Path("config.yml"), 137 | Path("~/.config/imap-mcp/config.yaml"), 138 | Path("/etc/imap-mcp/config.yaml"), 139 | ] 140 | 141 | # Load from specified path or try default locations 142 | config_data = {} 143 | if config_path: 144 | try: 145 | with open(config_path, "r") as f: 146 | config_data = yaml.safe_load(f) or {} 147 | logger.info(f"Loaded configuration from {config_path}") 148 | except FileNotFoundError: 149 | logger.warning(f"Configuration file not found: {config_path}") 150 | else: 151 | for path in default_locations: 152 | expanded_path = path.expanduser() 153 | if expanded_path.exists(): 154 | with open(expanded_path, "r") as f: 155 | config_data = yaml.safe_load(f) or {} 156 | logger.info(f"Loaded configuration from {expanded_path}") 157 | break 158 | 159 | # If environment variables are set, they take precedence 160 | if not config_data: 161 | logger.info("No configuration file found, using environment variables") 162 | if not os.environ.get("IMAP_HOST"): 163 | raise ValueError( 164 | "No configuration file found and IMAP_HOST environment variable not set" 165 | ) 166 | 167 | config_data = { 168 | "imap": { 169 | "host": os.environ.get("IMAP_HOST"), 170 | "port": int(os.environ.get("IMAP_PORT", "993")), 171 | "username": os.environ.get("IMAP_USERNAME"), 172 | "password": os.environ.get("IMAP_PASSWORD"), 173 | "use_ssl": os.environ.get("IMAP_USE_SSL", "true").lower() == "true", 174 | } 175 | } 176 | 177 | if os.environ.get("IMAP_ALLOWED_FOLDERS"): 178 | config_data["allowed_folders"] = os.environ.get("IMAP_ALLOWED_FOLDERS").split(",") 179 | 180 | # Create config object 181 | try: 182 | return ServerConfig.from_dict(config_data) 183 | except KeyError as e: 184 | raise ValueError(f"Missing required configuration: {e}") 185 | -------------------------------------------------------------------------------- /imap_mcp/gmail_auth.py: -------------------------------------------------------------------------------- 1 | """Gmail authentication setup command-line tool.""" 2 | 3 | import argparse 4 | import logging 5 | import os 6 | import sys 7 | 8 | from imap_mcp.browser_auth import perform_oauth_flow 9 | 10 | # Configure logging 11 | logging.basicConfig(level=logging.INFO) 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def main(): 16 | """Run the Gmail authentication tool.""" 17 | parser = argparse.ArgumentParser(description="Gmail authentication setup tool") 18 | parser.add_argument( 19 | "--client-id", 20 | help="OAuth2 client ID (optional, will be loaded from credentials file if provided)", 21 | ) 22 | parser.add_argument( 23 | "--client-secret", 24 | help="OAuth2 client secret (optional, will be loaded from credentials file if provided)", 25 | ) 26 | parser.add_argument( 27 | "--credentials-file", 28 | help="Path to the OAuth2 client credentials JSON file downloaded from Google Cloud Console", 29 | ) 30 | parser.add_argument( 31 | "--port", 32 | type=int, 33 | default=8080, 34 | help="Port for the callback server (default: 8080)", 35 | ) 36 | parser.add_argument( 37 | "--config", 38 | help="Path to existing config file to update", 39 | ) 40 | parser.add_argument( 41 | "--output", 42 | help="Path to save the updated config file (default: config.yaml)", 43 | default="config.yaml", 44 | ) 45 | 46 | args = parser.parse_args() 47 | 48 | try: 49 | # Run the OAuth flow 50 | perform_oauth_flow( 51 | client_id=args.client_id, 52 | client_secret=args.client_secret, 53 | credentials_file=args.credentials_file, 54 | port=args.port, 55 | config_path=args.config, 56 | config_output=args.output, 57 | ) 58 | logger.info("Gmail authentication setup completed successfully") 59 | except KeyboardInterrupt: 60 | logger.info("Gmail authentication setup canceled by user") 61 | sys.exit(1) 62 | except Exception as e: 63 | logger.error(f"Gmail authentication setup failed: {e}") 64 | sys.exit(1) 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /imap_mcp/mcp_protocol.py: -------------------------------------------------------------------------------- 1 | """MCP Protocol Implementation for IMAP MCP server. 2 | 3 | This module implements the required MCP protocol methods that are not directly 4 | supported by FastMCP but needed for Claude desktop compatibility. 5 | """ 6 | 7 | import logging 8 | from typing import Dict, List, Any, Optional 9 | 10 | from mcp.server.fastmcp import FastMCP, Context 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def extend_server(server: FastMCP) -> FastMCP: 16 | """Extend a FastMCP server with additional MCP protocol methods. 17 | 18 | Args: 19 | server: The FastMCP server instance to extend 20 | 21 | Returns: 22 | The extended server instance 23 | """ 24 | # Register resources methods 25 | @server.resource("email://folders") 26 | def email_folders() -> str: 27 | """List all available email folders.""" 28 | logger.info("Accessing email folders resource") 29 | 30 | # Get the IMAP client from the server's context if available 31 | if hasattr(server, "_lifespan_context") and server._lifespan_context: 32 | imap_client = server._lifespan_context.get("imap_client") 33 | if imap_client: 34 | folders = imap_client.list_folders() 35 | return "\n".join(folders) 36 | 37 | return "No email folders available" 38 | 39 | # Register tools for Claude desktop compatibility 40 | @server.tool() 41 | def email_search(query: str) -> Dict[str, Any]: 42 | """Search for emails using a query string. 43 | 44 | Args: 45 | query: Search query string 46 | 47 | Returns: 48 | Dict containing search results 49 | """ 50 | logger.info(f"Searching emails with query: {query}") 51 | 52 | # Basic implementation - would be expanded in a real system 53 | if hasattr(server, "_lifespan_context") and server._lifespan_context: 54 | imap_client = server._lifespan_context.get("imap_client") 55 | if imap_client: 56 | # This would be replaced with actual search functionality 57 | return {"results": "Search results would be returned here"} 58 | 59 | return {"results": "No results found"} 60 | 61 | # Register prompts 62 | @server.prompt() 63 | def search_emails(query: str) -> str: 64 | """Create prompt for searching emails. 65 | 66 | Args: 67 | query: Search query for emails 68 | 69 | Returns: 70 | Formatted prompt string 71 | """ 72 | return f"Search for emails that match: {query}" 73 | 74 | @server.prompt() 75 | def compose_email(to: str, subject: str = "", body: str = "") -> str: 76 | """Create prompt for composing a new email. 77 | 78 | Args: 79 | to: Recipient email address 80 | subject: Email subject 81 | body: Email body content 82 | 83 | Returns: 84 | Formatted prompt string 85 | """ 86 | return f"Compose an email to: {to}\nSubject: {subject}\n\n{body}" 87 | 88 | # Add Claude Desktop compatibility methods 89 | # These are added using direct registration since they may not fit 90 | # the standard FastMCP pattern, but are needed for Claude desktop 91 | 92 | # Low-level method registration if FastMCP doesn't have built-in support 93 | # for some methods needed by Claude Desktop 94 | if hasattr(server, "_low_level_server"): 95 | low_level = server._low_level_server 96 | 97 | # Register any additional methods that aren't directly supported by FastMCP 98 | # Only do this if the standard FastMCP decorators don't cover the method 99 | if not hasattr(low_level, "has_method") or not low_level.has_method("sampling/createMessage"): 100 | logger.info("Registering additional low-level methods for Claude desktop compatibility") 101 | 102 | # Note: This is a fallback approach that should rarely be needed 103 | # as FastMCP should handle most standard MCP methods 104 | 105 | # Implement any additional methods if needed here 106 | 107 | return server 108 | -------------------------------------------------------------------------------- /imap_mcp/oauth2.py: -------------------------------------------------------------------------------- 1 | """OAuth2 utilities for IMAP authentication.""" 2 | 3 | import base64 4 | import json 5 | import logging 6 | import time 7 | from typing import Optional, Tuple 8 | 9 | import requests 10 | from google.auth.transport.requests import Request 11 | from google.oauth2.credentials import Credentials 12 | 13 | from imap_mcp.config import OAuth2Config 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | # Gmail OAuth2 endpoints 18 | GMAIL_TOKEN_URI = "https://oauth2.googleapis.com/token" 19 | GMAIL_AUTH_BASE_URL = "https://accounts.google.com/o/oauth2/auth" 20 | GMAIL_SCOPES = ["https://mail.google.com/"] 21 | 22 | 23 | def get_access_token(oauth2_config: OAuth2Config) -> Tuple[str, int]: 24 | """Get a valid access token for Gmail. 25 | 26 | Uses the refresh token to get a new access token if needed. 27 | 28 | Args: 29 | oauth2_config: OAuth2 configuration 30 | 31 | Returns: 32 | Tuple of (access_token, expiry_timestamp) 33 | 34 | Raises: 35 | ValueError: If unable to get an access token 36 | """ 37 | # Check if we already have a valid access token 38 | current_time = int(time.time()) 39 | 40 | # Handle token_expiry as either int timestamp or datetime string 41 | token_expiry = 0 42 | if oauth2_config.token_expiry: 43 | try: 44 | # Try to convert to int directly 45 | token_expiry = int(oauth2_config.token_expiry) 46 | except (ValueError, TypeError): 47 | # If it's a datetime string, try to parse it 48 | try: 49 | # Handle ISO format datetime strings 50 | from datetime import datetime 51 | expiry_dt = datetime.fromisoformat(str(oauth2_config.token_expiry).replace('Z', '+00:00')) 52 | token_expiry = int(expiry_dt.timestamp()) 53 | except (ValueError, TypeError): 54 | # If parsing fails, force token refresh 55 | token_expiry = 0 56 | 57 | if ( 58 | oauth2_config.access_token 59 | and token_expiry > current_time + 300 # 5 min buffer 60 | ): 61 | return oauth2_config.access_token, token_expiry 62 | 63 | # Otherwise, use refresh token to get a new access token 64 | if not oauth2_config.refresh_token: 65 | raise ValueError("Refresh token is required for OAuth2 authentication") 66 | 67 | logger.info("Refreshing Gmail access token") 68 | 69 | # Exchange refresh token for access token 70 | data = { 71 | "client_id": oauth2_config.client_id, 72 | "client_secret": oauth2_config.client_secret, 73 | "refresh_token": oauth2_config.refresh_token, 74 | "grant_type": "refresh_token", 75 | } 76 | 77 | response = requests.post(GMAIL_TOKEN_URI, data=data) 78 | if response.status_code != 200: 79 | logger.error(f"Failed to refresh token: {response.text}") 80 | raise ValueError(f"Failed to refresh token: {response.status_code} - {response.text}") 81 | 82 | token_data = response.json() 83 | access_token = token_data["access_token"] 84 | expires_in = token_data.get("expires_in", 3600) # Default to 1 hour 85 | expiry = int(time.time()) + expires_in 86 | 87 | # Update the config with the new token 88 | oauth2_config.access_token = access_token 89 | oauth2_config.token_expiry = expiry 90 | 91 | return access_token, expiry 92 | 93 | 94 | def generate_oauth2_string(username: str, access_token: str) -> str: 95 | """Generate the SASL XOAUTH2 string for IMAP authentication. 96 | 97 | Args: 98 | username: Email address 99 | access_token: OAuth2 access token 100 | 101 | Returns: 102 | Base64-encoded XOAUTH2 string for IMAP authentication 103 | """ 104 | auth_string = f"user={username}\1auth=Bearer {access_token}\1\1" 105 | return base64.b64encode(auth_string.encode()).decode() 106 | 107 | 108 | def get_authorization_url(oauth2_config: OAuth2Config) -> str: 109 | """Generate the URL for the OAuth2 authorization flow. 110 | 111 | Args: 112 | oauth2_config: OAuth2 configuration 113 | 114 | Returns: 115 | URL to redirect the user to for authorization 116 | """ 117 | params = { 118 | "client_id": oauth2_config.client_id, 119 | "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", # Desktop app flow 120 | "response_type": "code", 121 | "scope": " ".join(GMAIL_SCOPES), 122 | "access_type": "offline", 123 | "prompt": "consent", # Force to get refresh_token 124 | } 125 | 126 | query_string = "&".join(f"{k}={v}" for k, v in params.items()) 127 | return f"{GMAIL_AUTH_BASE_URL}?{query_string}" 128 | 129 | 130 | def exchange_code_for_tokens(oauth2_config: OAuth2Config, code: str) -> Tuple[str, str, int]: 131 | """Exchange authorization code for access and refresh tokens. 132 | 133 | Args: 134 | oauth2_config: OAuth2 configuration 135 | code: Authorization code from the redirect 136 | 137 | Returns: 138 | Tuple of (access_token, refresh_token, expiry_timestamp) 139 | 140 | Raises: 141 | ValueError: If unable to exchange the code 142 | """ 143 | data = { 144 | "client_id": oauth2_config.client_id, 145 | "client_secret": oauth2_config.client_secret, 146 | "code": code, 147 | "grant_type": "authorization_code", 148 | "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", # Desktop app flow 149 | } 150 | 151 | response = requests.post(GMAIL_TOKEN_URI, data=data) 152 | if response.status_code != 200: 153 | raise ValueError(f"Failed to exchange code: {response.status_code} - {response.text}") 154 | 155 | token_data = response.json() 156 | access_token = token_data["access_token"] 157 | refresh_token = token_data["refresh_token"] 158 | expires_in = token_data.get("expires_in", 3600) # Default to 1 hour 159 | expiry = int(time.time()) + expires_in 160 | 161 | return access_token, refresh_token, expiry 162 | -------------------------------------------------------------------------------- /imap_mcp/oauth2_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | OAuth2 configuration handling for Gmail authentication. 3 | 4 | This module provides utilities for loading and validating OAuth2 configuration 5 | from either config files or environment variables. 6 | """ 7 | 8 | import json 9 | import os 10 | from pathlib import Path 11 | from typing import Dict, Any, Optional 12 | 13 | from .config import ServerConfig 14 | 15 | 16 | class OAuth2Config: 17 | """Handles OAuth2 configuration for Gmail authentication.""" 18 | 19 | def __init__( 20 | self, 21 | credentials_file: str, 22 | token_file: str, 23 | scopes: list[str], 24 | client_id: Optional[str] = None, 25 | client_secret: Optional[str] = None, 26 | ): 27 | """ 28 | Initialize OAuth2 configuration. 29 | 30 | Args: 31 | credentials_file: Path to the client credentials JSON file 32 | token_file: Path to store the OAuth2 tokens 33 | scopes: List of OAuth2 scopes to request 34 | client_id: Optional client ID (overrides credentials file) 35 | client_secret: Optional client secret (overrides credentials file) 36 | """ 37 | self.credentials_file = credentials_file 38 | self.token_file = token_file 39 | self.scopes = scopes 40 | self._client_id = client_id 41 | self._client_secret = client_secret 42 | self._client_config = None 43 | 44 | @classmethod 45 | def from_dict(cls, data: Dict[str, Any]) -> "OAuth2Config": 46 | """ 47 | Create OAuth2Config from a dictionary. 48 | 49 | Args: 50 | data: Dictionary with OAuth2 configuration 51 | 52 | Returns: 53 | OAuth2Config instance with values from the dictionary 54 | """ 55 | if not data: 56 | return cls( 57 | credentials_file="", 58 | token_file="gmail_token.json", 59 | scopes=["https://mail.google.com/"] 60 | ) 61 | 62 | credentials_file = data.get("credentials_file", "") 63 | token_file = data.get("token_file", "gmail_token.json") 64 | scopes = data.get("scopes", ["https://mail.google.com/"]) 65 | 66 | # Environment variables override config file 67 | client_id = os.environ.get("GMAIL_CLIENT_ID") 68 | client_secret = os.environ.get("GMAIL_CLIENT_SECRET") 69 | 70 | return cls( 71 | credentials_file=credentials_file, 72 | token_file=token_file, 73 | scopes=scopes, 74 | client_id=client_id, 75 | client_secret=client_secret, 76 | ) 77 | 78 | @classmethod 79 | def from_server_config(cls, config: ServerConfig) -> "OAuth2Config": 80 | """ 81 | Create OAuth2Config from ServerConfig. 82 | 83 | Args: 84 | config: The server configuration object 85 | 86 | Returns: 87 | OAuth2Config instance with values from server config 88 | """ 89 | # Get values from config.oauth2 if present 90 | if hasattr(config, "oauth2") and config.oauth2: 91 | oauth2_config = config.oauth2 92 | credentials_file = oauth2_config.get("credentials_file", "") 93 | token_file = oauth2_config.get("token_file", "gmail_token.json") 94 | scopes = oauth2_config.get("scopes", ["https://mail.google.com/"]) 95 | else: 96 | credentials_file = "" 97 | token_file = "gmail_token.json" 98 | scopes = ["https://mail.google.com/"] 99 | 100 | # Environment variables override config file 101 | client_id = os.environ.get("GMAIL_CLIENT_ID") 102 | client_secret = os.environ.get("GMAIL_CLIENT_SECRET") 103 | 104 | return cls( 105 | credentials_file=credentials_file, 106 | token_file=token_file, 107 | scopes=scopes, 108 | client_id=client_id, 109 | client_secret=client_secret, 110 | ) 111 | 112 | def load_client_config(self) -> Dict[str, Any]: 113 | """ 114 | Load the client configuration from the credentials file. 115 | 116 | Returns: 117 | Dict containing the client configuration 118 | 119 | Raises: 120 | FileNotFoundError: If the credentials file doesn't exist 121 | ValueError: If the credentials file is invalid 122 | """ 123 | if self._client_config: 124 | return self._client_config 125 | 126 | # If client ID and secret are provided directly, create the config structure 127 | if self._client_id and self._client_secret: 128 | self._client_config = { 129 | "installed": { 130 | "client_id": self._client_id, 131 | "client_secret": self._client_secret, 132 | "redirect_uris": ["http://localhost", "urn:ietf:wg:oauth:2.0:oob"], 133 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 134 | "token_uri": "https://oauth2.googleapis.com/token", 135 | } 136 | } 137 | return self._client_config 138 | 139 | # Otherwise load from the credentials file 140 | if not self.credentials_file: 141 | raise ValueError("No credentials file specified and no client ID/secret provided") 142 | 143 | credentials_path = Path(self.credentials_file) 144 | if not credentials_path.exists(): 145 | raise FileNotFoundError(f"Credentials file not found: {self.credentials_file}") 146 | 147 | try: 148 | with open(credentials_path) as f: 149 | self._client_config = json.load(f) 150 | return self._client_config 151 | except json.JSONDecodeError: 152 | raise ValueError(f"Invalid credentials file: {self.credentials_file}") 153 | 154 | @property 155 | def client_id(self) -> str: 156 | """Get the client ID from the configuration.""" 157 | if self._client_id: 158 | return self._client_id 159 | 160 | config = self.load_client_config() 161 | return config.get("installed", {}).get("client_id", "") 162 | 163 | @property 164 | def client_secret(self) -> str: 165 | """Get the client secret from the configuration.""" 166 | if self._client_secret: 167 | return self._client_secret 168 | 169 | config = self.load_client_config() 170 | return config.get("installed", {}).get("client_secret", "") 171 | -------------------------------------------------------------------------------- /imap_mcp/server.py: -------------------------------------------------------------------------------- 1 | """Main server implementation for IMAP MCP.""" 2 | 3 | import argparse 4 | import logging 5 | import os 6 | from contextlib import asynccontextmanager 7 | from typing import AsyncIterator, Dict, Optional 8 | 9 | from mcp.server.fastmcp import FastMCP 10 | 11 | from imap_mcp.config import ServerConfig, load_config 12 | from imap_mcp.imap_client import ImapClient 13 | from imap_mcp.resources import register_resources 14 | from imap_mcp.tools import register_tools 15 | from imap_mcp.mcp_protocol import extend_server 16 | 17 | # Set up logging 18 | logging.basicConfig( 19 | level=logging.INFO, 20 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 21 | ) 22 | logger = logging.getLogger("imap_mcp") 23 | 24 | 25 | @asynccontextmanager 26 | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict]: 27 | """Server lifespan manager to handle IMAP client lifecycle. 28 | 29 | Args: 30 | server: MCP server instance 31 | 32 | Yields: 33 | Context dictionary containing IMAP client 34 | """ 35 | # Access the config that was set in create_server 36 | # The config is stored in the server's state 37 | config = getattr(server, "_config", None) 38 | if not config: 39 | # This is a fallback in case we can't find the config 40 | config = load_config() 41 | 42 | if not isinstance(config, ServerConfig): 43 | raise TypeError("Invalid server configuration") 44 | 45 | imap_client = ImapClient(config.imap, config.allowed_folders) 46 | 47 | try: 48 | # Connect to IMAP server 49 | logger.info("Connecting to IMAP server...") 50 | imap_client.connect() 51 | 52 | # Yield the context with the IMAP client 53 | yield {"imap_client": imap_client} 54 | finally: 55 | # Disconnect from IMAP server 56 | logger.info("Disconnecting from IMAP server...") 57 | imap_client.disconnect() 58 | 59 | 60 | def create_server(config_path: Optional[str] = None, debug: bool = False) -> FastMCP: 61 | """Create and configure the MCP server. 62 | 63 | Args: 64 | config_path: Path to configuration file 65 | debug: Enable debug mode 66 | 67 | Returns: 68 | Configured MCP server instance 69 | """ 70 | # Set up logging level 71 | if debug: 72 | logger.setLevel(logging.DEBUG) 73 | 74 | # Load configuration 75 | config = load_config(config_path) 76 | 77 | # Create MCP server with all the necessary capabilities 78 | server = FastMCP( 79 | "IMAP", 80 | description="IMAP Model Context Protocol server for email processing", 81 | version="0.1.0", 82 | lifespan=server_lifespan, 83 | ) 84 | 85 | # Store config for access in the lifespan 86 | server._config = config 87 | 88 | # Create IMAP client for setup (will be recreated in lifespan) 89 | imap_client = ImapClient(config.imap, config.allowed_folders) 90 | 91 | # Register resources and tools 92 | register_resources(server, imap_client) 93 | register_tools(server, imap_client) 94 | 95 | # Add server status tool 96 | @server.tool() 97 | def server_status() -> str: 98 | """Get server status and configuration info.""" 99 | status = { 100 | "server": "IMAP MCP", 101 | "version": "0.1.0", 102 | "imap_host": config.imap.host, 103 | "imap_port": config.imap.port, 104 | "imap_user": config.imap.username, 105 | "imap_ssl": config.imap.use_ssl, 106 | } 107 | 108 | if config.allowed_folders: 109 | status["allowed_folders"] = list(config.allowed_folders) 110 | else: 111 | status["allowed_folders"] = "All folders allowed" 112 | 113 | return "\n".join(f"{k}: {v}" for k, v in status.items()) 114 | 115 | # Apply MCP protocol extension for Claude Desktop compatibility 116 | server = extend_server(server) 117 | 118 | return server 119 | 120 | 121 | def main() -> None: 122 | """Run the IMAP MCP server.""" 123 | parser = argparse.ArgumentParser(description="IMAP MCP Server") 124 | parser.add_argument( 125 | "--config", 126 | help="Path to configuration file", 127 | default=os.environ.get("IMAP_MCP_CONFIG"), 128 | ) 129 | parser.add_argument( 130 | "--dev", 131 | action="store_true", 132 | help="Enable development mode", 133 | ) 134 | parser.add_argument( 135 | "--debug", 136 | action="store_true", 137 | help="Enable debug logging", 138 | ) 139 | parser.add_argument( 140 | "--version", 141 | action="store_true", 142 | help="Show version information and exit", 143 | ) 144 | args = parser.parse_args() 145 | 146 | if args.version: 147 | print("IMAP MCP Server version 0.1.0") 148 | return 149 | 150 | if args.debug: 151 | logger.setLevel(logging.DEBUG) 152 | 153 | server = create_server(args.config, args.debug) 154 | 155 | # Start the server 156 | logger.info("Starting server{}...".format(" in development mode" if args.dev else "")) 157 | server.run() 158 | 159 | 160 | if __name__ == "__main__": 161 | main() 162 | -------------------------------------------------------------------------------- /imap_mcp/smtp_client.py: -------------------------------------------------------------------------------- 1 | """SMTP client implementation for sending emails.""" 2 | 3 | import email.utils 4 | import logging 5 | from datetime import datetime 6 | from email.message import EmailMessage 7 | from email.mime.multipart import MIMEMultipart 8 | from email.mime.text import MIMEText 9 | from typing import List, Optional 10 | 11 | from imap_mcp.models import Email, EmailAddress 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def create_reply_mime( 17 | original_email: Email, 18 | reply_to: EmailAddress, 19 | body: str, 20 | subject: Optional[str] = None, 21 | cc: Optional[List[EmailAddress]] = None, 22 | reply_all: bool = False, 23 | html_body: Optional[str] = None, 24 | ) -> EmailMessage: 25 | """Create a MIME message for replying to an email. 26 | 27 | Args: 28 | original_email: Original email to reply to 29 | reply_to: Address to send the reply from 30 | body: Plain text body of the reply 31 | subject: Subject for the reply (default: prepend "Re: " to original) 32 | cc: List of CC recipients (default: none) 33 | reply_all: Whether to reply to all recipients (default: False) 34 | html_body: Optional HTML version of the body 35 | 36 | Returns: 37 | MIME message ready for sending 38 | """ 39 | # Start with a multipart/mixed message 40 | if html_body: 41 | message = MIMEMultipart("mixed") 42 | else: 43 | message = EmailMessage() 44 | 45 | # Set the From header 46 | message["From"] = str(reply_to) 47 | 48 | # Set the To header 49 | to_recipients = [original_email.from_] 50 | if reply_all and original_email.to: 51 | # Add original recipients excluding the sender 52 | to_recipients.extend([ 53 | recipient for recipient in original_email.to 54 | if recipient.address != reply_to.address 55 | ]) 56 | 57 | message["To"] = ", ".join(str(recipient) for recipient in to_recipients) 58 | 59 | # Set the CC header if applicable 60 | cc_recipients = [] 61 | if cc: 62 | cc_recipients.extend(cc) 63 | elif reply_all and original_email.cc: 64 | cc_recipients.extend([ 65 | recipient for recipient in original_email.cc 66 | if recipient.address != reply_to.address 67 | ]) 68 | 69 | if cc_recipients: 70 | message["Cc"] = ", ".join(str(recipient) for recipient in cc_recipients) 71 | 72 | # Set the subject 73 | if subject: 74 | message["Subject"] = subject 75 | else: 76 | # Add "Re: " prefix if not already present 77 | original_subject = original_email.subject 78 | if not original_subject.startswith("Re:"): 79 | message["Subject"] = f"Re: {original_subject}" 80 | else: 81 | message["Subject"] = original_subject 82 | 83 | # Set references for threading 84 | references = [] 85 | if "References" in original_email.headers: 86 | references.append(original_email.headers["References"]) 87 | if original_email.message_id: 88 | references.append(original_email.message_id) 89 | 90 | if references: 91 | message["References"] = " ".join(references) 92 | 93 | # Set In-Reply-To header 94 | if original_email.message_id: 95 | message["In-Reply-To"] = original_email.message_id 96 | 97 | # Prepare content 98 | if html_body: 99 | # Create multipart/alternative for text and HTML 100 | alternative = MIMEMultipart("alternative") 101 | 102 | # Add plain text part 103 | plain_text = body 104 | if original_email.content.text: 105 | # Quote original plain text 106 | quoted_original = "\n".join(f"> {line}" for line in original_email.content.text.split("\n")) 107 | plain_text += f"\n\nOn {email.utils.format_datetime(original_email.date or datetime.now())}, {original_email.from_} wrote:\n{quoted_original}" 108 | 109 | text_part = MIMEText(plain_text, "plain", "utf-8") 110 | alternative.attach(text_part) 111 | 112 | # Add HTML part 113 | html_content = html_body 114 | if original_email.content.html: 115 | # Add original HTML with a divider 116 | html_content += ( 117 | f'\n
' 118 | f'\n

On {email.utils.format_datetime(original_email.date or datetime.now())}, {original_email.from_} wrote:

' 119 | f'\n
' 120 | f'\n{original_email.content.html}' 121 | f'\n
' 122 | f'\n
' 123 | ) 124 | else: 125 | # Convert plain text to HTML for quoting 126 | original_text = original_email.content.get_best_content() 127 | if original_text: 128 | escaped_text = original_text.replace("&", "&").replace("<", "<").replace(">", ">") 129 | escaped_text = escaped_text.replace("\n", "
") 130 | html_content += ( 131 | f'\n
' 132 | f'\n

On {email.utils.format_datetime(original_email.date or datetime.now())}, {original_email.from_} wrote:

' 133 | f'\n
' 134 | f'\n{escaped_text}' 135 | f'\n
' 136 | f'\n
' 137 | ) 138 | 139 | html_part = MIMEText(html_content, "html", "utf-8") 140 | alternative.attach(html_part) 141 | 142 | # Attach the alternative part to the message 143 | message.attach(alternative) 144 | else: 145 | # Plain text only 146 | plain_text = body 147 | if original_email.content.text: 148 | # Quote original plain text 149 | quoted_original = "\n".join(f"> {line}" for line in original_email.content.text.split("\n")) 150 | plain_text += f"\n\nOn {email.utils.format_datetime(original_email.date or datetime.now())}, {original_email.from_} wrote:\n{quoted_original}" 151 | 152 | message.set_content(plain_text) 153 | 154 | # Add Date header 155 | message["Date"] = email.utils.formatdate(localtime=True) 156 | 157 | return message 158 | -------------------------------------------------------------------------------- /imap_mcp/workflows/__init__.py: -------------------------------------------------------------------------------- 1 | """Workflow modules for IMAP MCP.""" 2 | -------------------------------------------------------------------------------- /imap_mcp/workflows/calendar_mock.py: -------------------------------------------------------------------------------- 1 | """Mock calendar availability checking functionality.""" 2 | 3 | import logging 4 | import random 5 | from datetime import datetime, time, timedelta 6 | from typing import Optional, Union, Dict, Any, Tuple 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def check_mock_availability( 12 | start_time: Union[datetime, str], 13 | end_time: Union[datetime, str], 14 | availability_mode: str = "random" 15 | ) -> Dict[str, Any]: 16 | """Check mock calendar availability for a given time range. 17 | 18 | This is a simplified mock implementation that simulates checking 19 | calendar availability without actually connecting to a calendar API. 20 | 21 | Args: 22 | start_time: Start time of the meeting (datetime or ISO format string) 23 | end_time: End time of the meeting (datetime or ISO format string) 24 | availability_mode: Mode for determining availability: 25 | - "random": 70% chance of being available (default) 26 | - "always_available": Always returns available 27 | - "always_busy": Always returns busy 28 | - "business_hours": Available during business hours (9 AM - 5 PM) 29 | - "weekdays": Available on weekdays 30 | 31 | Returns: 32 | Dictionary with availability details: 33 | - available: True if the time slot is available, False otherwise 34 | - reason: Description of why the slot is available/unavailable 35 | - alternative_times: List of alternative times if unavailable (not implemented in mock) 36 | """ 37 | # Parse datetime objects if strings are provided 38 | start_dt = _parse_datetime(start_time) 39 | end_dt = _parse_datetime(end_time) 40 | 41 | if not start_dt or not end_dt: 42 | logger.warning(f"Invalid datetime format: start={start_time}, end={end_time}") 43 | return { 44 | "available": False, 45 | "reason": "Invalid datetime format", 46 | "alternative_times": [] 47 | } 48 | 49 | # Log the request for debugging 50 | logger.debug(f"Checking mock availability for {start_dt} to {end_dt} (mode: {availability_mode})") 51 | 52 | # Check availability based on mode 53 | available, reason = _check_availability_by_mode(start_dt, end_dt, availability_mode) 54 | 55 | # Generate mock alternative times if not available 56 | alternative_times = [] 57 | if not available: 58 | alternative_times = _generate_alternative_times(start_dt, end_dt) 59 | 60 | # Log the result 61 | logger.debug(f"Mock availability result: available={available}, reason={reason}") 62 | 63 | return { 64 | "available": available, 65 | "reason": reason, 66 | "alternative_times": alternative_times 67 | } 68 | 69 | 70 | def _parse_datetime(dt_value: Union[datetime, str]) -> Optional[datetime]: 71 | """Parse datetime object from string if necessary. 72 | 73 | Args: 74 | dt_value: Datetime object or ISO format string 75 | 76 | Returns: 77 | Parsed datetime object or None if parsing fails 78 | """ 79 | if isinstance(dt_value, datetime): 80 | return dt_value 81 | 82 | if isinstance(dt_value, str): 83 | try: 84 | return datetime.fromisoformat(dt_value) 85 | except ValueError: 86 | logger.warning(f"Could not parse datetime string: {dt_value}") 87 | return None 88 | 89 | return None 90 | 91 | 92 | def _check_availability_by_mode( 93 | start_dt: datetime, 94 | end_dt: datetime, 95 | mode: str 96 | ) -> Tuple[bool, str]: 97 | """Check availability based on the specified mode. 98 | 99 | Args: 100 | start_dt: Start datetime 101 | end_dt: End datetime 102 | mode: Availability mode 103 | 104 | Returns: 105 | Tuple of (available, reason) 106 | """ 107 | if mode == "always_available": 108 | return True, "Time slot is available" 109 | 110 | if mode == "always_busy": 111 | return False, "Calendar is busy during this time" 112 | 113 | if mode == "business_hours": 114 | # Check if both start and end are within business hours (9 AM - 5 PM) 115 | business_start = time(9, 0) 116 | business_end = time(17, 0) 117 | 118 | start_time = start_dt.time() 119 | end_time = end_dt.time() 120 | 121 | if (start_time >= business_start and end_time <= business_end and 122 | start_dt.date() == end_dt.date()): 123 | return True, "Time slot is within business hours" 124 | else: 125 | return False, "Time slot is outside business hours (9 AM - 5 PM)" 126 | 127 | if mode == "weekdays": 128 | # Check if both days are weekdays (Monday=0, Sunday=6) 129 | if start_dt.weekday() < 5 and end_dt.weekday() < 5: 130 | return True, "Time slot is on a weekday" 131 | else: 132 | return False, "Time slot falls on a weekend" 133 | 134 | # Default: random mode (70% chance of being available) 135 | if random.random() < 0.7: 136 | return True, "Time slot is available" 137 | else: 138 | return False, "Calendar is busy during this time" 139 | 140 | 141 | def _generate_alternative_times(start_dt: datetime, end_dt: datetime, num_alternatives: int = 3) -> list: 142 | """Generate mock alternative time slots. 143 | 144 | Args: 145 | start_dt: Original start datetime 146 | end_dt: Original end datetime 147 | num_alternatives: Number of alternative times to generate 148 | 149 | Returns: 150 | List of alternative time slots (not implemented in mock) 151 | """ 152 | # In a real implementation, this would suggest actual free time slots 153 | # For this mock version, we'll just return empty list 154 | # A future implementation could return actual alternative slots 155 | return [] 156 | -------------------------------------------------------------------------------- /imap_mcp/workflows/meeting_reply.py: -------------------------------------------------------------------------------- 1 | """Meeting invite reply generation functionality.""" 2 | 3 | import logging 4 | from datetime import datetime 5 | from typing import Dict, Any, Optional 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def generate_meeting_reply_content( 11 | invite_details: Dict[str, Any], 12 | availability_status: Dict[str, Any] 13 | ) -> Dict[str, Any]: 14 | """Generate meeting reply content based on invite details and availability. 15 | 16 | Args: 17 | invite_details: Dictionary with meeting invite details (from invite_parser) 18 | availability_status: Dictionary with availability details (from calendar_mock) 19 | 20 | Returns: 21 | Dictionary with reply details: 22 | - reply_subject: Subject line for the reply 23 | - reply_body: Body text for the reply 24 | - reply_type: "accept" or "decline" 25 | """ 26 | # Validate input 27 | if not isinstance(invite_details, dict) or not isinstance(availability_status, dict): 28 | logger.error(f"Invalid input types: invite_details={type(invite_details)}, availability_status={type(availability_status)}") 29 | return { 30 | "reply_subject": "Error: Invalid Meeting Invite", 31 | "reply_body": "Could not process the meeting invite due to invalid data.", 32 | "reply_type": "error" 33 | } 34 | 35 | # Extract key details 36 | subject = invite_details.get("subject", "Meeting") 37 | start_time = invite_details.get("start_time") 38 | end_time = invite_details.get("end_time") 39 | organizer = invite_details.get("organizer", "Meeting Organizer") 40 | location = invite_details.get("location", "Not specified") 41 | 42 | # Format date/time for display 43 | formatted_time = _format_meeting_time(start_time, end_time) 44 | 45 | # Check if available 46 | is_available = availability_status.get("available", False) 47 | decline_reason = availability_status.get("reason", "Schedule conflict") if not is_available else "" 48 | 49 | # Generate reply based on availability 50 | if is_available: 51 | return _generate_accept_reply(subject, formatted_time, organizer, location) 52 | else: 53 | return _generate_decline_reply(subject, formatted_time, organizer, location, decline_reason) 54 | 55 | 56 | def _format_meeting_time( 57 | start_time: Optional[datetime], 58 | end_time: Optional[datetime] 59 | ) -> str: 60 | """Format meeting time for display in reply. 61 | 62 | Args: 63 | start_time: Meeting start time 64 | end_time: Meeting end time 65 | 66 | Returns: 67 | Formatted time string 68 | """ 69 | if not start_time: 70 | return "scheduled time" 71 | 72 | # Format just the start time if no end time 73 | if not end_time: 74 | return start_time.strftime("%A, %B %d, %Y at %I:%M %p") 75 | 76 | # Check if same day 77 | same_day = start_time.date() == end_time.date() 78 | 79 | if same_day: 80 | # Format as "Monday, January 1, 2025 from 10:00 AM to 11:00 AM" 81 | return ( 82 | f"{start_time.strftime('%A, %B %d, %Y')} from " 83 | f"{start_time.strftime('%I:%M %p')} to {end_time.strftime('%I:%M %p')}" 84 | ) 85 | else: 86 | # Format as "Monday, January 1, 2025 at 10:00 AM to Tuesday, January 2, 2025 at 11:00 AM" 87 | return ( 88 | f"{start_time.strftime('%A, %B %d, %Y')} at {start_time.strftime('%I:%M %p')} to " 89 | f"{end_time.strftime('%A, %B %d, %Y')} at {end_time.strftime('%I:%M %p')}" 90 | ) 91 | 92 | 93 | def _generate_accept_reply( 94 | subject: str, 95 | formatted_time: str, 96 | organizer: str, 97 | location: str 98 | ) -> Dict[str, Any]: 99 | """Generate reply content for accepting a meeting invite. 100 | 101 | Args: 102 | subject: Meeting subject 103 | formatted_time: Formatted meeting time string 104 | organizer: Meeting organizer 105 | location: Meeting location 106 | 107 | Returns: 108 | Dictionary with reply details 109 | """ 110 | reply_subject = f"Accepted: {subject}" 111 | 112 | reply_body = ( 113 | f"I'll attend the meeting: \"{subject}\" on {formatted_time}.\n\n" 114 | f"Location: {location}\n" 115 | "\n" 116 | "Thank you for the invitation.\n" 117 | "\n" 118 | "Best regards," 119 | ) 120 | 121 | logger.debug(f"Generated accept reply for meeting: {subject}") 122 | 123 | return { 124 | "reply_subject": reply_subject, 125 | "reply_body": reply_body, 126 | "reply_type": "accept" 127 | } 128 | 129 | 130 | def _generate_decline_reply( 131 | subject: str, 132 | formatted_time: str, 133 | organizer: str, 134 | location: str, 135 | reason: str 136 | ) -> Dict[str, Any]: 137 | """Generate reply content for declining a meeting invite. 138 | 139 | Args: 140 | subject: Meeting subject 141 | formatted_time: Formatted meeting time string 142 | organizer: Meeting organizer 143 | location: Meeting location 144 | reason: Reason for declining 145 | 146 | Returns: 147 | Dictionary with reply details 148 | """ 149 | reply_subject = f"Declined: {subject}" 150 | 151 | reply_body = ( 152 | f"I'm unable to attend the meeting: \"{subject}\" on {formatted_time}.\n\n" 153 | f"Reason: {reason}\n" 154 | "\n" 155 | "Thank you for the invitation. Please let me know if there's an alternative time " 156 | "that might work or if I can contribute in another way.\n" 157 | "\n" 158 | "Best regards," 159 | ) 160 | 161 | logger.debug(f"Generated decline reply for meeting: {subject}") 162 | 163 | return { 164 | "reply_subject": reply_subject, 165 | "reply_body": reply_body, 166 | "reply_type": "decline" 167 | } 168 | -------------------------------------------------------------------------------- /list_inbox.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Simple script to list messages in Gmail inbox using the IMAP client directly. 4 | """ 5 | 6 | import argparse 7 | import logging 8 | import sys 9 | from typing import Dict, List, Optional 10 | 11 | from imap_mcp.config import load_config 12 | from imap_mcp.imap_client import ImapClient 13 | 14 | 15 | def main() -> None: 16 | """List emails in the inbox.""" 17 | # Configure argument parser 18 | parser = argparse.ArgumentParser(description="List emails in Gmail inbox") 19 | parser.add_argument( 20 | "--config", 21 | help="Path to configuration file", 22 | default="config.yaml" 23 | ) 24 | parser.add_argument( 25 | "--folder", 26 | help="Folder to list (default: INBOX)", 27 | default="INBOX" 28 | ) 29 | parser.add_argument( 30 | "--limit", 31 | type=int, 32 | help="Maximum number of emails to display", 33 | default=10 34 | ) 35 | parser.add_argument( 36 | "--verbose", "-v", 37 | action="store_true", 38 | help="Enable verbose output" 39 | ) 40 | args = parser.parse_args() 41 | 42 | # Set up logging 43 | log_level = logging.DEBUG if args.verbose else logging.INFO 44 | logging.basicConfig( 45 | level=log_level, 46 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 47 | ) 48 | logger = logging.getLogger("imap_inbox") 49 | 50 | try: 51 | # Load configuration 52 | logger.info(f"Loading configuration from {args.config}") 53 | config = load_config(args.config) 54 | 55 | # Create IMAP client 56 | logger.info(f"Connecting to {config.imap.host} as {config.imap.username}") 57 | client = ImapClient(config.imap) 58 | 59 | # Connect to IMAP server 60 | try: 61 | client.connect() 62 | logger.info("Successfully connected to IMAP server") 63 | 64 | # List available folders 65 | folders = client.list_folders() 66 | logger.info(f"Available folders: {', '.join(folders[:5])}{'...' if len(folders) > 5 else ''}") 67 | 68 | # Check if the requested folder exists 69 | if args.folder not in folders: 70 | logger.error(f"Folder '{args.folder}' does not exist") 71 | client.disconnect() 72 | sys.exit(1) 73 | 74 | # Search for all emails in the folder 75 | logger.info(f"Searching for emails in {args.folder}") 76 | uids = client.search("ALL", folder=args.folder) 77 | 78 | if not uids: 79 | logger.info(f"No emails found in {args.folder}") 80 | client.disconnect() 81 | return 82 | 83 | # Limit the number of emails to fetch 84 | uids = uids[:args.limit] 85 | 86 | # Fetch emails 87 | logger.info(f"Fetching {len(uids)} emails") 88 | emails = client.fetch_emails(uids, folder=args.folder) 89 | 90 | # Display emails 91 | print(f"\nFound {len(emails)} emails in {args.folder}:\n") 92 | for i, (uid, email) in enumerate(emails.items()): 93 | if not email: 94 | continue 95 | 96 | print(f"--- Email {i+1}/{len(emails)} ---") 97 | print(f"UID: {uid}") 98 | print(f"From: {email.from_}") 99 | print(f"To: {', '.join(str(to) for to in email.to)}") 100 | print(f"Subject: {email.subject}") 101 | print(f"Date: {email.date}") 102 | 103 | # Show flags 104 | if email.flags: 105 | print(f"Flags: {', '.join(email.flags)}") 106 | 107 | # Show body preview 108 | content = email.content.get_best_content() 109 | if content: 110 | preview = content[:100] + ('...' if len(content) > 100 else '') 111 | print(f"Preview: {preview}") 112 | 113 | print() 114 | finally: 115 | # Ensure we disconnect 116 | client.disconnect() 117 | logger.info("Disconnected from IMAP server") 118 | 119 | except Exception as e: 120 | logger.error(f"Error: {e}") 121 | sys.exit(1) 122 | 123 | 124 | if __name__ == "__main__": 125 | main() 126 | -------------------------------------------------------------------------------- /output.yaml: -------------------------------------------------------------------------------- 1 | imap: 2 | oauth2: 3 | access_token: test_access_token 4 | client_id: test_client_id 5 | client_secret: test_client_secret 6 | refresh_token: test_refresh_token 7 | token_expiry: 1742198060.237522 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "imap-mcp" 7 | version = "0.1.0" 8 | description = "IMAP Model Context Protocol server for interactive email processing" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = {text = "MIT"} 12 | authors = [ 13 | {name = "GitHub user", email = "example@example.com"} 14 | ] 15 | classifiers = [ 16 | "Development Status :: 3 - Alpha", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | ] 25 | dependencies = [ 26 | "mcp>=0.1.0", 27 | "imapclient>=2.2.0", 28 | "email-validator>=1.1.3", 29 | "pyyaml>=6.0", 30 | "python-dotenv>=0.19.0", 31 | "pytest>=8.3.5", 32 | "pytest-cov>=6.0.0", 33 | "pytest-asyncio>=0.25.3", 34 | "requests>=2.32.3", 35 | "google-auth>=2.38.0", 36 | "ruff>=0.11.2", 37 | "google-auth-oauthlib>=1.2.1", 38 | "flask>=3.1.0", 39 | ] 40 | 41 | [project.optional-dependencies] 42 | dev = [ 43 | "pytest>=7.0.0", 44 | "pytest-cov>=3.0.0", 45 | "pytest-asyncio>=0.19.0", 46 | "black>=23.0.0", 47 | "isort>=5.10.0", 48 | "mypy>=0.982", 49 | "flake8>=5.0.0", 50 | "pre-commit>=2.19.0", 51 | ] 52 | 53 | [project.urls] 54 | Homepage = "https://github.com/non-dirty/imap-mcp" 55 | Issues = "https://github.com/non-dirty/imap-mcp/issues" 56 | 57 | [tool.setuptools] 58 | packages = ["imap_mcp"] 59 | 60 | [tool.black] 61 | line-length = 88 62 | target-version = ["py310"] 63 | include = '\.pyi?$' 64 | 65 | [tool.isort] 66 | profile = "black" 67 | line_length = 88 68 | 69 | [tool.mypy] 70 | python_version = "3.10" 71 | warn_return_any = true 72 | warn_unused_configs = true 73 | disallow_untyped_defs = true 74 | disallow_incomplete_defs = true 75 | 76 | [tool.pytest.ini_options] 77 | testpaths = ["tests"] 78 | python_files = "test_*.py" 79 | python_functions = "test_*" 80 | python_classes = "Test*" 81 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | 7 | # Make sure integration tests run after unit tests 8 | python_files_order = file_name 9 | file_order_priorities = tests/integration/test_*.py=-1 10 | 11 | # Mark integration tests to make them easier to skip 12 | markers = 13 | unit: Unit tests (default) 14 | integration: Integration tests requiring actual services 15 | gmail: Tests specific to Gmail integration 16 | oauth2: Tests using OAuth2 authentication 17 | app_password: Tests using app password authentication 18 | slow: Tests that take a long time to run 19 | script_test: Tests for scripts in the scripts directory 20 | 21 | # Log configuration 22 | log_cli = True 23 | log_cli_level = ERROR 24 | log_format = %(asctime)s %(levelname)s %(message)s 25 | log_date_format = %Y-%m-%d %H:%M:%S 26 | 27 | addopts = 28 | #--strict-markers 29 | -v -------------------------------------------------------------------------------- /read_inbox.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Script to read messages from Gmail inbox using the IMAP MCP server. 4 | """ 5 | 6 | import argparse 7 | import json 8 | import sys 9 | from typing import Dict, List, Optional 10 | 11 | import requests 12 | 13 | 14 | def list_folders() -> None: 15 | """List all available email folders.""" 16 | response = requests.get("http://localhost:8000/folders") 17 | if response.status_code == 200: 18 | folders = response.json() 19 | print("\nAvailable folders:") 20 | for folder in folders: 21 | print(f" - {folder}") 22 | else: 23 | print(f"Error listing folders: {response.status_code} - {response.text}") 24 | 25 | 26 | def get_emails(folder: str = "INBOX", limit: int = 10) -> None: 27 | """Get emails from a specific folder. 28 | 29 | Args: 30 | folder: Folder name (default: INBOX) 31 | limit: Maximum number of emails to retrieve 32 | """ 33 | params = {"limit": limit} 34 | response = requests.get(f"http://localhost:8000/emails/{folder}", params=params) 35 | 36 | if response.status_code == 200: 37 | emails = response.json() 38 | if not emails: 39 | print(f"\nNo emails found in {folder}") 40 | return 41 | 42 | print(f"\nFound {len(emails)} emails in {folder}:") 43 | for i, email in enumerate(emails): 44 | print(f"\n--- Email {i+1}/{len(emails)} ---") 45 | print(f"UID: {email.get('uid')}") 46 | print(f"From: {email.get('from')}") 47 | print(f"To: {email.get('to')}") 48 | print(f"Subject: {email.get('subject')}") 49 | print(f"Date: {email.get('date')}") 50 | print(f"Has Attachments: {'Yes' if email.get('has_attachments') else 'No'}") 51 | 52 | # Print flags 53 | flags = email.get('flags', []) 54 | if flags: 55 | flag_str = ', '.join(flags) 56 | print(f"Flags: {flag_str}") 57 | 58 | # Show preview of body 59 | body = email.get('body', '') 60 | if body: 61 | preview = body[:200] + ('...' if len(body) > 200 else '') 62 | print(f"\nPreview: {preview}") 63 | else: 64 | print(f"Error fetching emails: {response.status_code} - {response.text}") 65 | 66 | 67 | def get_email_detail(folder: str, uid: int) -> None: 68 | """Get detailed information about a specific email. 69 | 70 | Args: 71 | folder: Folder name 72 | uid: Email UID 73 | """ 74 | response = requests.get(f"http://localhost:8000/email/{folder}/{uid}") 75 | 76 | if response.status_code == 200: 77 | email = response.json() 78 | print("\n--- Email Details ---") 79 | print(f"UID: {email.get('uid')}") 80 | print(f"From: {email.get('from')}") 81 | print(f"To: {email.get('to')}") 82 | print(f"Subject: {email.get('subject')}") 83 | print(f"Date: {email.get('date')}") 84 | print(f"Has Attachments: {'Yes' if email.get('has_attachments') else 'No'}") 85 | 86 | # Print flags 87 | flags = email.get('flags', []) 88 | if flags: 89 | flag_str = ', '.join(flags) 90 | print(f"Flags: {flag_str}") 91 | 92 | # Show full body 93 | body = email.get('body', '') 94 | if body: 95 | print("\nBody:") 96 | print(body) 97 | else: 98 | print(f"Error fetching email: {response.status_code} - {response.text}") 99 | 100 | 101 | def search_emails(query: str, folder: Optional[str] = None, criteria: str = "text") -> None: 102 | """Search for emails. 103 | 104 | Args: 105 | query: Search query 106 | folder: Folder to search in (None for all folders) 107 | criteria: Search criteria (text, from, to, subject) 108 | """ 109 | params = { 110 | "query": query, 111 | "criteria": criteria, 112 | } 113 | 114 | if folder: 115 | params["folder"] = folder 116 | 117 | response = requests.get("http://localhost:8000/search", params=params) 118 | 119 | if response.status_code == 200: 120 | results = response.json() 121 | if not results: 122 | print(f"\nNo emails found matching '{query}'") 123 | return 124 | 125 | print(f"\nFound {len(results)} emails matching '{query}':") 126 | for i, result in enumerate(results): 127 | print(f"\n--- Result {i+1}/{len(results)} ---") 128 | print(f"UID: {result.get('uid')}") 129 | print(f"Folder: {result.get('folder')}") 130 | print(f"From: {result.get('from')}") 131 | print(f"Subject: {result.get('subject')}") 132 | print(f"Date: {result.get('date')}") 133 | else: 134 | print(f"Error searching emails: {response.status_code} - {response.text}") 135 | 136 | 137 | def main() -> None: 138 | """Run the email reader script.""" 139 | parser = argparse.ArgumentParser(description="Read emails from IMAP MCP server") 140 | 141 | # Create subparsers for different commands 142 | subparsers = parser.add_subparsers(dest="command", help="Command to execute") 143 | 144 | # List folders command 145 | folders_parser = subparsers.add_parser("folders", help="List available folders") 146 | 147 | # List emails command 148 | list_parser = subparsers.add_parser("list", help="List emails in a folder") 149 | list_parser.add_argument("--folder", "-f", default="INBOX", help="Folder to list emails from") 150 | list_parser.add_argument("--limit", "-l", type=int, default=10, help="Maximum number of emails to retrieve") 151 | 152 | # View email command 153 | view_parser = subparsers.add_parser("view", help="View a specific email") 154 | view_parser.add_argument("--folder", "-f", default="INBOX", help="Folder containing the email") 155 | view_parser.add_argument("--uid", "-u", type=int, required=True, help="UID of the email to view") 156 | 157 | # Search emails command 158 | search_parser = subparsers.add_parser("search", help="Search for emails") 159 | search_parser.add_argument("query", help="Search query") 160 | search_parser.add_argument("--folder", "-f", help="Folder to search in (default: all folders)") 161 | search_parser.add_argument("--criteria", "-c", default="text", 162 | choices=["text", "from", "to", "subject"], 163 | help="Search criteria") 164 | 165 | args = parser.parse_args() 166 | 167 | # Default command if none specified 168 | if not args.command: 169 | list_folders() 170 | get_emails() 171 | return 172 | 173 | # Execute selected command 174 | if args.command == "folders": 175 | list_folders() 176 | elif args.command == "list": 177 | get_emails(args.folder, args.limit) 178 | elif args.command == "view": 179 | get_email_detail(args.folder, args.uid) 180 | elif args.command == "search": 181 | search_emails(args.query, args.folder, args.criteria) 182 | 183 | 184 | if __name__ == "__main__": 185 | try: 186 | main() 187 | except requests.exceptions.ConnectionError: 188 | print("Error: Could not connect to the IMAP MCP server. Make sure it's running on http://localhost:8000.") 189 | sys.exit(1) 190 | except KeyboardInterrupt: 191 | print("\nOperation cancelled by user.") 192 | sys.exit(0) 193 | -------------------------------------------------------------------------------- /render_mermaid.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Script to render a Mermaid diagram as an image. 4 | This script takes a Mermaid diagram code from a markdown file and renders it as a PNG image. 5 | """ 6 | 7 | import os 8 | import re 9 | import sys 10 | import asyncio 11 | from pathlib import Path 12 | from playwright.async_api import async_playwright 13 | 14 | async def render_mermaid(mermaid_code, output_path): 15 | """ 16 | Render a Mermaid diagram as a PNG image using Playwright. 17 | 18 | Args: 19 | mermaid_code (str): The Mermaid diagram code 20 | output_path (str): Path where to save the generated PNG image 21 | """ 22 | async with async_playwright() as p: 23 | browser = await p.chromium.launch() 24 | page = await browser.new_page() 25 | 26 | # Create a simple HTML page with Mermaid 27 | html_content = f""" 28 | 29 | 30 | 31 | 32 | 39 | 50 | 51 | 52 |
53 |
54 | {mermaid_code} 55 |
56 |
57 | 58 | 59 | """ 60 | 61 | await page.set_content(html_content) 62 | 63 | # Wait for Mermaid to render 64 | await page.wait_for_selector(".mermaid svg") 65 | 66 | # Get the dimensions of the SVG 67 | dimensions = await page.evaluate(""" 68 | () => { 69 | const svg = document.querySelector('.mermaid svg'); 70 | return { 71 | width: svg.width.baseVal.value, 72 | height: svg.height.baseVal.value 73 | }; 74 | } 75 | """) 76 | 77 | # Set viewport to match SVG size 78 | await page.set_viewport_size({ 79 | "width": int(dimensions["width"]) + 40, # Add padding 80 | "height": int(dimensions["height"]) + 40 # Add padding 81 | }) 82 | 83 | # Take a screenshot of just the Mermaid diagram 84 | diagram = await page.query_selector(".mermaid") 85 | await diagram.screenshot(path=output_path) 86 | 87 | await browser.close() 88 | 89 | print(f"Mermaid diagram rendered successfully to {output_path}") 90 | 91 | def extract_mermaid_from_markdown(markdown_path): 92 | """ 93 | Extract a Mermaid diagram code from a markdown file. 94 | 95 | Args: 96 | markdown_path (str): Path to the markdown file 97 | 98 | Returns: 99 | str: The extracted Mermaid diagram code 100 | """ 101 | with open(markdown_path, 'r') as f: 102 | content = f.read() 103 | 104 | # Find Mermaid code block 105 | mermaid_match = re.search(r'```mermaid\n(.*?)```', content, re.DOTALL) 106 | if not mermaid_match: 107 | raise ValueError("No Mermaid diagram found in the markdown file") 108 | 109 | return mermaid_match.group(1) 110 | 111 | def update_markdown_with_image(markdown_path, image_path, mermaid_code): 112 | """ 113 | Update the markdown file to include the generated image. 114 | 115 | Args: 116 | markdown_path (str): Path to the markdown file 117 | image_path (str): Path to the image file 118 | mermaid_code (str): Original Mermaid diagram code 119 | """ 120 | with open(markdown_path, 'r') as f: 121 | content = f.read() 122 | 123 | # Replace the Mermaid code block with both the image and the original code (commented out) 124 | image_ref = f'![Architecture Diagram]({os.path.basename(image_path)})\n\n' 125 | updated_content = re.sub(r'```mermaid\n.*?```', image_ref, content, flags=re.DOTALL) 126 | 127 | with open(markdown_path, 'w') as f: 128 | f.write(updated_content) 129 | 130 | print(f"Markdown file {markdown_path} updated with image reference") 131 | 132 | async def main(): 133 | markdown_file = 'architecture_plans.md' 134 | output_image = 'architecture_mermaid.png' 135 | 136 | try: 137 | # Extract Mermaid code from markdown 138 | mermaid_code = extract_mermaid_from_markdown(markdown_file) 139 | 140 | # Render the Mermaid diagram 141 | await render_mermaid(mermaid_code, output_image) 142 | 143 | # Update the markdown file 144 | update_markdown_with_image(markdown_file, output_image, mermaid_code) 145 | 146 | print("Process completed successfully!") 147 | except Exception as e: 148 | print(f"Error: {e}") 149 | return 1 150 | 151 | return 0 152 | 153 | if __name__ == "__main__": 154 | sys.exit(asyncio.run(main())) 155 | -------------------------------------------------------------------------------- /run_mcp_cli_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to run MCP-CLI integration tests against the IMAP MCP server 3 | # Assumes mcp-cli will dynamically launch the server based on its config. 4 | 5 | set -e # Exit immediately if a command exits with a non-zero status. 6 | set -x # Print commands and their arguments as they are executed. 7 | 8 | # Ensure script is run from the project root (imap-mcp) 9 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 10 | PROJECT_ROOT="$SCRIPT_DIR" 11 | cd "$PROJECT_ROOT" || exit 1 12 | 13 | echo "=== Ensuring Project Virtual Environment (.venv) Exists ===" 14 | if [ ! -d ".venv" ]; then 15 | echo "Virtual environment .venv not found. Creating and installing dependencies..." 16 | uv venv || exit 1 17 | # Activate venv for subsequent commands in this script context if needed, 18 | # although uv run should handle it. 19 | source .venv/bin/activate || exit 1 20 | uv pip install -e ".[dev]" || exit 1 21 | else 22 | echo "Virtual environment .venv found." 23 | fi 24 | 25 | # Set up PYTHONPATH just in case uv run needs help finding modules 26 | export PYTHONPATH="${PYTHONPATH}:${PROJECT_ROOT}" 27 | 28 | echo "=== Checking MCP-CLI Subdirectory and Installation ===" 29 | if [ ! -d "mcp-cli" ]; then 30 | echo "MCP-CLI directory not found. Cloning from GitHub..." 31 | git clone https://github.com/ModelContextProtocol/mcp-cli.git || exit 1 32 | fi 33 | 34 | # Check if mcp-cli is installed in the venv; install/update if necessary 35 | # Using uv pip install ensures it's in the correct environment 36 | echo "=== Installing/Updating MCP-CLI in the virtual environment ===" 37 | # Assuming mcp-cli has a pyproject.toml for installation 38 | (cd mcp-cli && uv pip install -e .) || exit 1 39 | 40 | echo "=== Verifying mcp-cli/server_config.json exists ===" 41 | if [ ! -f "mcp-cli/server_config.json" ]; then 42 | echo "ERROR: mcp-cli/server_config.json not found! Cannot run tests." 43 | exit 1 44 | fi 45 | # Optional: Could add a check here to verify the command path in the json is correct 46 | PYTHON_EXEC="${PROJECT_ROOT}/.venv/bin/python" 47 | if ! grep -q "\"command\": \"${PYTHON_EXEC}\"" mcp-cli/server_config.json; then 48 | echo "WARNING: Python command in mcp-cli/server_config.json might not match expected venv path: ${PYTHON_EXEC}" 49 | echo "Attempting to run tests anyway..." 50 | fi 51 | 52 | echo "=== Running integration tests (using mcp-cli dynamic server launch) ===" 53 | # Run pytest for the specific integration test file 54 | # Add -s to show stdout/stderr from tests, useful for debugging CLI interactions 55 | uv run pytest -s -v tests/integration/test_mcp_cli_integration.py 56 | 57 | EXIT_CODE=$? 58 | echo "=== Tests completed with exit code: $EXIT_CODE ===" 59 | 60 | exit $EXIT_CODE 61 | -------------------------------------------------------------------------------- /scripts/run_checks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # run_checks.sh - Automation script for code quality checks 5 | # Runs linting, formatting, type checking, unit tests, and coverage 6 | 7 | # Display help message 8 | display_help() { 9 | echo "Usage: ./scripts/run_checks.sh [OPTIONS]" 10 | echo "" 11 | echo "Run code quality checks for the imap-mcp project." 12 | echo "" 13 | echo "Options:" 14 | echo " --help Display this help message" 15 | echo " --lint-only Run only linting (ruff)" 16 | echo " --format-only Run only formatting (black, isort)" 17 | echo " --type-check-only Run only type checking (mypy)" 18 | echo " --test-only Run only tests without coverage" 19 | echo " --coverage-only Run only tests with coverage" 20 | echo " --skip-integration Skip integration tests" 21 | echo " --ci Run in CI mode (stricter checks)" 22 | echo "" 23 | echo "If no options are provided, all checks will be run." 24 | exit 0 25 | } 26 | 27 | # Set variables 28 | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 29 | SRC_DIR="${ROOT_DIR}/imap_mcp" 30 | TESTS_DIR="${ROOT_DIR}/tests" 31 | PYTHONPATH="${ROOT_DIR}:${PYTHONPATH}" 32 | export PYTHONPATH 33 | 34 | RUN_ALL=true 35 | RUN_LINT=false 36 | RUN_FORMAT=false 37 | RUN_TYPE_CHECK=false 38 | RUN_TESTS=false 39 | RUN_COVERAGE=false 40 | SKIP_INTEGRATION=false 41 | CI_MODE=false 42 | 43 | # Parse arguments 44 | while [[ $# -gt 0 ]]; do 45 | case "$1" in 46 | --help) 47 | display_help 48 | ;; 49 | --lint-only) 50 | RUN_ALL=false 51 | RUN_LINT=true 52 | ;; 53 | --format-only) 54 | RUN_ALL=false 55 | RUN_FORMAT=true 56 | ;; 57 | --type-check-only) 58 | RUN_ALL=false 59 | RUN_TYPE_CHECK=true 60 | ;; 61 | --test-only) 62 | RUN_ALL=false 63 | RUN_TESTS=true 64 | ;; 65 | --coverage-only) 66 | RUN_ALL=false 67 | RUN_COVERAGE=true 68 | ;; 69 | --skip-integration) 70 | SKIP_INTEGRATION=true 71 | ;; 72 | --ci) 73 | CI_MODE=true 74 | ;; 75 | *) 76 | echo "Unknown option: $1" 77 | display_help 78 | ;; 79 | esac 80 | shift 81 | done 82 | 83 | if [[ "${RUN_ALL}" = true ]]; then 84 | RUN_LINT=true 85 | RUN_FORMAT=true 86 | RUN_TYPE_CHECK=true 87 | RUN_TESTS=false 88 | RUN_COVERAGE=true 89 | fi 90 | 91 | echo "=== Running checks for imap-mcp ===" 92 | cd "${ROOT_DIR}" 93 | 94 | # Linting 95 | if [[ "${RUN_LINT}" = true ]]; then 96 | echo -e "\n=== Running linting with ruff ===" 97 | uv run ruff check "${SRC_DIR}" "${TESTS_DIR}" 98 | echo "✅ Linting passed" 99 | fi 100 | 101 | # Formatting 102 | if [[ "${RUN_FORMAT}" = true ]]; then 103 | echo -e "\n=== Checking formatting with black ===" 104 | uv run black --check "${SRC_DIR}" "${TESTS_DIR}" 105 | 106 | echo -e "\n=== Checking import sorting with isort ===" 107 | uv run isort --check-only --profile black "${SRC_DIR}" "${TESTS_DIR}" 108 | echo "✅ Formatting check passed" 109 | fi 110 | 111 | # Type checking 112 | if [[ "${RUN_TYPE_CHECK}" = true ]]; then 113 | echo -e "\n=== Running type checking with mypy ===" 114 | uv run mypy "${SRC_DIR}" 115 | echo "✅ Type checking passed" 116 | fi 117 | 118 | # Tests 119 | if [[ "${RUN_TESTS}" = true ]]; then 120 | echo -e "\n=== Running tests ===" 121 | if [[ "${SKIP_INTEGRATION}" = true ]]; then 122 | uv run pytest "${TESTS_DIR}" --skip-integration -v 123 | else 124 | uv run pytest "${TESTS_DIR}" -v 125 | fi 126 | echo "✅ Tests passed" 127 | fi 128 | 129 | # Coverage 130 | if [[ "${RUN_COVERAGE}" = true ]]; then 131 | echo -e "\n=== Running tests with coverage ===" 132 | if [[ "${SKIP_INTEGRATION}" = true ]]; then 133 | uv run pytest "${TESTS_DIR}" --skip-integration --cov="${SRC_DIR}" --cov-report=term --cov-report=json 134 | else 135 | uv run pytest "${TESTS_DIR}" --cov="${SRC_DIR}" --cov-report=term --cov-report=json 136 | fi 137 | 138 | # Check coverage threshold in CI mode 139 | if [[ "${CI_MODE}" = true ]]; then 140 | MIN_COVERAGE=80 141 | COVERAGE=$(uv run python -c "import json; print(json.load(open('coverage.json'))['totals']['percent_covered_display'])") 142 | 143 | echo "Coverage: ${COVERAGE}%" 144 | if (( $(echo "${COVERAGE} < ${MIN_COVERAGE}" | bc -l) )); then 145 | echo "❌ Coverage is below minimum threshold of ${MIN_COVERAGE}%" 146 | exit 1 147 | fi 148 | fi 149 | 150 | echo "✅ Coverage check passed" 151 | fi 152 | 153 | echo -e "\n=== All checks completed successfully! ===" 154 | -------------------------------------------------------------------------------- /scripts/run_imap_mcp_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Wrapper script to run the IMAP MCP server within its virtual environment. 3 | 4 | set -e # Exit on error 5 | 6 | # Get the directory where the script resides 7 | SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 8 | 9 | # Assume the project root is one level up from the script directory 10 | PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" 11 | cd "$PROJECT_ROOT" || exit 1 12 | 13 | VENV_PATH="$PROJECT_ROOT/.venv" 14 | SERVER_SCRIPT="$PROJECT_ROOT/imap_mcp/server.py" 15 | 16 | echo "Wrapper: Starting IMAP MCP Server..." 17 | echo "Wrapper: Project Root: $PROJECT_ROOT" 18 | echo "Wrapper: Venv Path: $VENV_PATH" 19 | echo "Wrapper: Arguments: $@" 20 | 21 | # Check if venv exists 22 | if [ ! -d "$VENV_PATH" ]; then 23 | echo "Wrapper: Error - Virtual environment not found at $VENV_PATH" >&2 24 | exit 1 25 | fi 26 | 27 | # Activate the virtual environment 28 | source "$VENV_PATH/bin/activate" 29 | 30 | # Set PYTHONPATH just in case (might be redundant after activating venv) 31 | export PYTHONPATH="${PYTHONPATH}:${PROJECT_ROOT}" 32 | echo "Wrapper: PYTHONPATH set to $PYTHONPATH" 33 | 34 | # Special case handling for --help and --version flags 35 | if [[ "$*" == *"--help"* ]]; then 36 | echo "Wrapper: Detected --help flag, showing server help" 37 | python "$SERVER_SCRIPT" --help 38 | exit $? 39 | fi 40 | 41 | if [[ "$*" == *"--version"* ]]; then 42 | echo "Wrapper: Detected --version flag, showing server version" 43 | echo "IMAP MCP Server version 0.1.0" 44 | echo "Wrapper script version 0.1.0" 45 | exit 0 46 | fi 47 | 48 | # Execute the server script using the venv's python 49 | # Pass all arguments received by the wrapper script to the python script 50 | echo "Wrapper: Executing: python $SERVER_SCRIPT $@" 51 | exec python "$SERVER_SCRIPT" "$@" 52 | -------------------------------------------------------------------------------- /scripts/run_integration_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to run Gmail integration tests 3 | # This script assumes you have activated your virtual environment 4 | 5 | set -e # Exit on error 6 | 7 | # Colors for better readability 8 | GREEN='\033[0;32m' 9 | YELLOW='\033[0;33m' 10 | RED='\033[0;31m' 11 | BLUE='\033[0;34m' 12 | NC='\033[0m' # No Color 13 | 14 | # Config file location (default if not specified) 15 | CONFIG_FILE="config.yaml" 16 | VERBOSE="-v" 17 | MARKERS="integration and gmail" 18 | TEST_FILES="tests/test_gmail_integration.py" 19 | 20 | # Parse command line arguments 21 | while getopts "c:m:f:vq" opt; do 22 | case ${opt} in 23 | c ) 24 | CONFIG_FILE=$OPTARG 25 | ;; 26 | m ) 27 | MARKERS=$OPTARG 28 | ;; 29 | f ) 30 | TEST_FILES=$OPTARG 31 | ;; 32 | v ) 33 | VERBOSE="-vv" 34 | ;; 35 | q ) 36 | VERBOSE="" 37 | ;; 38 | \? ) 39 | echo "Usage: $0 [-c config_file] [-m markers] [-f test_files] [-v verbose] [-q quiet]" 40 | exit 1 41 | ;; 42 | esac 43 | done 44 | 45 | echo -e "${BLUE}=====================================================${NC}" 46 | echo -e "${BLUE} Gmail Integration Tests Runner ${NC}" 47 | echo -e "${BLUE}=====================================================${NC}" 48 | echo "" 49 | 50 | # Check if the config file exists 51 | if [ ! -f "$CONFIG_FILE" ]; then 52 | echo -e "${RED}Config file not found: $CONFIG_FILE${NC}" 53 | echo -e "${YELLOW}Please create a config file or specify with -c option${NC}" 54 | exit 1 55 | fi 56 | 57 | # Extract OAuth2 info from config.yaml 58 | # Note: This is a simple approach and doesn't handle all YAML formats 59 | echo -e "${BLUE}Checking configuration in $CONFIG_FILE...${NC}" 60 | if ! grep -q "oauth2:" "$CONFIG_FILE"; then 61 | echo -e "${RED}OAuth2 configuration not found in $CONFIG_FILE${NC}" 62 | echo -e "${YELLOW}Please ensure your config file contains OAuth2 credentials${NC}" 63 | exit 1 64 | fi 65 | 66 | # Create a temporary .env file if it doesn't exist 67 | if [ ! -f ".env.test" ]; then 68 | echo -e "${YELLOW}Creating temporary .env.test file for testing${NC}" 69 | 70 | # Extract values from config.yaml 71 | CLIENT_ID=$(grep -A 10 "oauth2:" "$CONFIG_FILE" | grep "client_id:" | head -1 | awk '{print $2}' | tr -d '"' | tr -d "'") 72 | CLIENT_SECRET=$(grep -A 10 "oauth2:" "$CONFIG_FILE" | grep "client_secret:" | head -1 | awk '{print $2}' | tr -d '"' | tr -d "'") 73 | REFRESH_TOKEN=$(grep -A 10 "oauth2:" "$CONFIG_FILE" | grep "refresh_token:" | head -1 | awk '{print $2}' | tr -d '"' | tr -d "'") 74 | TEST_EMAIL=$(grep -A 20 "oauth2:" "$CONFIG_FILE" | grep "username:" | head -1 | awk '{print $2}' | tr -d '"' | tr -d "'" || echo "eye.map.em.see.p@gmail.com") 75 | 76 | if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ] || [ -z "$REFRESH_TOKEN" ]; then 77 | echo -e "${RED}Failed to extract OAuth2 credentials from config file${NC}" 78 | exit 1 79 | fi 80 | 81 | # Create .env.test with credentials 82 | cat > .env.test << EOF 83 | # Gmail OAuth2 credentials for integration tests 84 | GMAIL_CLIENT_ID=$CLIENT_ID 85 | GMAIL_CLIENT_SECRET=$CLIENT_SECRET 86 | GMAIL_REFRESH_TOKEN=$REFRESH_TOKEN 87 | GMAIL_TEST_EMAIL=$TEST_EMAIL 88 | EOF 89 | 90 | echo -e "${GREEN}Created .env.test with OAuth2 credentials${NC}" 91 | fi 92 | 93 | # Load environment variables from .env.test 94 | echo -e "${BLUE}Loading environment variables for testing...${NC}" 95 | if [ -f ".env.test" ]; then 96 | export $(grep -v '^#' .env.test | xargs) 97 | echo -e "${GREEN}Environment variables loaded successfully${NC}" 98 | else 99 | echo -e "${RED}Error: .env.test file not found${NC}" 100 | exit 1 101 | fi 102 | 103 | # Run the tests 104 | echo -e "${BLUE}Running integration tests...${NC}" 105 | echo -e "${YELLOW}Using markers: ${MARKERS}${NC}" 106 | echo -e "${YELLOW}Test files: ${TEST_FILES}${NC}" 107 | echo "" 108 | 109 | uv run python -m pytest -m "$MARKERS" $TEST_FILES $VERBOSE --no-header 110 | 111 | TEST_EXIT_CODE=$? 112 | 113 | # Cleanup 114 | echo "" 115 | if [ $TEST_EXIT_CODE -eq 0 ]; then 116 | echo -e "${GREEN}====================================================${NC}" 117 | echo -e "${GREEN} Integration tests completed successfully! ${NC}" 118 | echo -e "${GREEN}====================================================${NC}" 119 | else 120 | echo -e "${RED}====================================================${NC}" 121 | echo -e "${RED} Integration tests failed with exit code $TEST_EXIT_CODE ${NC}" 122 | echo -e "${RED}====================================================${NC}" 123 | fi 124 | 125 | echo "" 126 | echo -e "${BLUE}Test run complete.${NC}" 127 | echo "" 128 | 129 | exit $TEST_EXIT_CODE 130 | -------------------------------------------------------------------------------- /scripts/run_mcp_cli_chat_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run integration tests for the mcp-cli chat mode 3 | 4 | set -e 5 | 6 | # Change to the project root directory 7 | cd "$(dirname "$0")/.." 8 | PROJECT_ROOT=$(pwd) 9 | 10 | # Colors for output 11 | GREEN='\033[0;32m' 12 | YELLOW='\033[1;33m' 13 | RED='\033[0;31m' 14 | BLUE='\033[0;34m' 15 | NC='\033[0m' # No Color 16 | 17 | echo -e "${BLUE}=============================================${NC}" 18 | echo -e "${BLUE}Running MCP CLI Chat Mode Integration Tests${NC}" 19 | echo -e "${BLUE}=============================================${NC}" 20 | 21 | # Ensure mcp-cli dependencies are installed 22 | echo -e "${YELLOW}Ensuring mcp-cli dependencies are installed...${NC}" 23 | cd "$PROJECT_ROOT/mcp-cli" 24 | uv sync --reinstall 25 | cd "$PROJECT_ROOT" 26 | 27 | # Make sure the integration test has the right permissions 28 | chmod +x "$PROJECT_ROOT/tests/integration/test_mcp_cli_chat.py" 29 | 30 | # Set environment variables for integration tests (if needed) 31 | export PYTHONPATH="$PROJECT_ROOT:$PYTHONPATH" 32 | 33 | # Function to run a specific test 34 | run_test() { 35 | local test_name=$1 36 | local test_class="TestMcpCliChatMode" 37 | local test_file="tests/integration/test_mcp_cli_chat.py" 38 | 39 | echo -e "${YELLOW}Running test: ${test_name}${NC}" 40 | uv run pytest -xvs "$test_file::$test_class::$test_name" 41 | } 42 | 43 | # Parse command line arguments 44 | ALL_TESTS=true 45 | SPECIFIC_TEST="" 46 | 47 | if [ $# -gt 0 ]; then 48 | if [ "$1" == "--test" ] && [ $# -gt 1 ]; then 49 | ALL_TESTS=false 50 | SPECIFIC_TEST=$2 51 | elif [ "$1" == "--help" ]; then 52 | echo "Usage: $0 [OPTIONS]" 53 | echo "Run MCP CLI chat mode integration tests." 54 | echo "" 55 | echo "Options:" 56 | echo " --test TEST_NAME Run a specific test (e.g., test_server_and_tool_commands)" 57 | echo " --help Show this help message" 58 | exit 0 59 | fi 60 | fi 61 | 62 | # Run the tests 63 | if [ "$ALL_TESTS" = true ]; then 64 | echo -e "${GREEN}Running all MCP CLI chat mode tests${NC}" 65 | # Skip the skip marker for these specific tests 66 | uv run pytest -xvs tests/integration/test_mcp_cli_chat.py::TestMcpCliChatMode -k "not skip" 67 | else 68 | run_test "$SPECIFIC_TEST" 69 | fi 70 | 71 | echo -e "${GREEN}Tests completed successfully!${NC}" 72 | -------------------------------------------------------------------------------- /test_config.yaml: -------------------------------------------------------------------------------- 1 | imap: 2 | oauth2: 3 | access_token: 4 | client_id: 5 | client_secret: 6 | refresh_token: 7 | token_expiry: 1742198171 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests package.""" -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests for the IMAP MCP.""" 2 | -------------------------------------------------------------------------------- /tests/integration/test_direct_tools.py: -------------------------------------------------------------------------------- 1 | """Integration tests for direct tool usage with the IMAP MCP client. 2 | 3 | These tests directly import and use the IMAP tool functions to test their functionality 4 | with a real Gmail account. This approach bypasses the server API and CLI interfaces 5 | to focus on testing the core email search functionality. 6 | """ 7 | 8 | import asyncio 9 | import json 10 | import logging 11 | import os 12 | import pytest 13 | from typing import Dict, List, Optional, Any, Callable 14 | 15 | # Configure logging 16 | logging.basicConfig(level=logging.INFO) 17 | logger = logging.getLogger(__name__) 18 | 19 | # Mark all tests in this file as integration tests 20 | pytestmark = pytest.mark.integration 21 | 22 | # Import the IMAP client and tools 23 | from imap_mcp.imap_client import ImapClient 24 | from imap_mcp.config import Config 25 | from imap_mcp.models import Context 26 | from imap_mcp.tools import search_emails as search_emails_tool 27 | 28 | 29 | class TestDirectToolsIntegration: 30 | """Test direct usage of IMAP MCP tools without going through the server or CLI.""" 31 | 32 | @pytest.fixture(scope="class") 33 | async def imap_client(self): 34 | """Create and yield an IMAP client connected to Gmail.""" 35 | # Load config from the default location 36 | config = Config.load_config() 37 | 38 | # Create IMAP client 39 | client = ImapClient(config.email) 40 | 41 | # Connect to the server 42 | client.connect() 43 | 44 | try: 45 | yield client 46 | finally: 47 | # Disconnect when done 48 | client.disconnect() 49 | 50 | @pytest.fixture(scope="class") 51 | async def context(self, imap_client): 52 | """Create a context object with the IMAP client for use with tools.""" 53 | # Create a minimal context object compatible with the tools 54 | ctx = Context(client=imap_client) 55 | return ctx 56 | 57 | @pytest.mark.asyncio 58 | async def test_list_folders(self, imap_client): 59 | """Test listing folders directly from the IMAP client.""" 60 | # Get list of folders 61 | folders = imap_client.list_folders() 62 | 63 | # Check that we got some folders 64 | assert len(folders) > 0, "No folders returned from IMAP server" 65 | 66 | # Check that INBOX is present 67 | assert "INBOX" in folders, "INBOX not found in folder list" 68 | 69 | # Log the folders for reference 70 | logger.info(f"Found {len(folders)} folders: {folders}") 71 | 72 | @pytest.mark.asyncio 73 | async def test_search_unread_emails(self, imap_client, context): 74 | """Test searching for unread emails using the search_emails tool directly.""" 75 | # Search for unread emails in INBOX 76 | results = await search_emails_tool( 77 | query="", 78 | ctx=context, 79 | folder="INBOX", 80 | criteria="unseen", 81 | limit=10 82 | ) 83 | 84 | # Parse the JSON result 85 | try: 86 | results_dict = json.loads(results) 87 | logger.info(f"Search results: {json.dumps(results_dict, indent=2)}") 88 | 89 | # Verify the result structure 90 | assert isinstance(results_dict, list), "Expected list of results" 91 | 92 | # Log the number of unread emails found 93 | logger.info(f"Found {len(results_dict)} unread emails in INBOX") 94 | 95 | # Check the fields in each result if there are any results 96 | if results_dict: 97 | first_email = results_dict[0] 98 | expected_fields = ["uid", "folder", "from", "subject", "date"] 99 | for field in expected_fields: 100 | assert field in first_email, f"Field '{field}' missing from email result" 101 | 102 | # Verify that emails are marked as unread 103 | assert "\\Seen" not in first_email.get("flags", []), "Email should be unread (no \\Seen flag)" 104 | 105 | except json.JSONDecodeError as e: 106 | logger.error(f"Failed to parse search results: {e}") 107 | logger.error(f"Raw results: {results}") 108 | pytest.fail(f"Invalid JSON returned from search_emails tool: {e}") 109 | 110 | @pytest.mark.asyncio 111 | async def test_search_with_different_criteria(self, imap_client, context): 112 | """Test searching with different criteria using the search_emails tool.""" 113 | # Test cases with different search criteria 114 | test_cases = [ 115 | ("", "all", "all emails"), 116 | ("", "today", "emails from today"), 117 | ("test", "subject", "emails with 'test' in subject"), 118 | ] 119 | 120 | for query, criteria, description in test_cases: 121 | logger.info(f"Testing search for {description}") 122 | 123 | results = await search_emails_tool( 124 | query=query, 125 | ctx=context, 126 | folder="INBOX", 127 | criteria=criteria, 128 | limit=5 129 | ) 130 | 131 | # Parse and validate results 132 | try: 133 | results_dict = json.loads(results) 134 | logger.info(f"Found {len(results_dict)} {description}") 135 | 136 | # Basic validation 137 | assert isinstance(results_dict, list), f"Expected list of results for {description}" 138 | 139 | except json.JSONDecodeError as e: 140 | logger.error(f"Failed to parse search results for {description}: {e}") 141 | logger.error(f"Raw results: {results}") 142 | pytest.fail(f"Invalid JSON returned from search_emails tool for {description}: {e}") 143 | 144 | if __name__ == "__main__": 145 | # Enable running the tests directly 146 | pytest.main(["-xvs", __file__]) 147 | -------------------------------------------------------------------------------- /tests/integration/test_oauth2_gmail.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the OAuth2 Gmail authentication with the IMAP client. 3 | """ 4 | 5 | import logging 6 | import os 7 | import pytest 8 | from dotenv import load_dotenv 9 | 10 | from imap_mcp.config import ImapConfig, OAuth2Config 11 | from imap_mcp.imap_client import ImapClient 12 | 13 | # Load environment variables from .env.test if it exists 14 | load_dotenv(".env.test") 15 | 16 | # Required environment variables for OAuth2 testing 17 | REQUIRED_ENV_VARS = ["GMAIL_CLIENT_ID", "GMAIL_CLIENT_SECRET", "GMAIL_REFRESH_TOKEN", "GMAIL_TEST_EMAIL"] 18 | 19 | @pytest.mark.skipif( 20 | any(os.environ.get(var) is None for var in REQUIRED_ENV_VARS), 21 | reason="Gmail OAuth2 credentials are required for this test" 22 | ) 23 | def test_oauth2_gmail_connection(): 24 | """ 25 | Test connecting to Gmail using OAuth2 authentication. 26 | """ 27 | # Set up logging 28 | logging.basicConfig(level=logging.INFO) 29 | logger = logging.getLogger(__name__) 30 | 31 | try: 32 | # Create ImapConfig with OAuth2 settings from environment variables 33 | logger.info("Setting up OAuth2 configuration from environment variables") 34 | config = ImapConfig( 35 | host="imap.gmail.com", 36 | port=993, 37 | username=os.environ.get("GMAIL_TEST_EMAIL"), 38 | password=None, # No password for OAuth2 39 | use_ssl=True, 40 | oauth2=OAuth2Config( 41 | client_id=os.environ.get("GMAIL_CLIENT_ID"), 42 | client_secret=os.environ.get("GMAIL_CLIENT_SECRET"), 43 | refresh_token=os.environ.get("GMAIL_REFRESH_TOKEN"), 44 | ) 45 | ) 46 | 47 | # Create and connect IMAP client 48 | logger.info(f"Connecting to {config.host}:{config.port} as {config.username}") 49 | client = ImapClient(config) 50 | 51 | # Test connection 52 | logger.info("Connecting to IMAP server") 53 | client.connect() 54 | 55 | # List folders to verify connection 56 | folders = client.list_folders() 57 | logger.info(f"Found {len(folders)} folders") 58 | 59 | # Disconnect 60 | logger.info("Disconnecting") 61 | client.disconnect() 62 | 63 | # If we got here, the test passed 64 | assert True, "Successfully connected to Gmail with OAuth2" 65 | except Exception as e: 66 | logger.error(f"Error: {e}") 67 | pytest.fail(f"Failed to connect using OAuth2: {e}") 68 | -------------------------------------------------------------------------------- /tests/issue_test_log.txt: -------------------------------------------------------------------------------- 1 | Testing Automated Status Workflow - issue #16: 2 | 3 | Step 1: Create a Test Issue 4 | Let's create a simple test issue to verify our automated workflow: 5 | 6 | bash 7 | CopyInsert in Terminal 8 | gh issue create --title "Test Automated Status Workflow" --body "This is a test issue to verify that our automated status updates are working correctly." --label "status:prioritized" --label "priority:3" 9 | This command will create a new issue with: 10 | 11 | A descriptive title 12 | A simple description 13 | The initial "prioritized" status 14 | A lower priority (3) to avoid interfering with real work 15 | Feel free to try this command and let me know if you encounter any errors! 16 | 17 | Step 2: Make Changes and Reference the Issue 18 | Once you have your test issue (let's say it's issue #16 for this example), create a branch for it: 19 | 20 | bash 21 | CopyInsert in Terminal 22 | git checkout -b test/issue-16-workflow-test 23 | Now, make a simple change (or an empty commit) that references the issue: 24 | 25 | bash 26 | CopyInsert 27 | # Make a small change to a file and commit 28 | git add tests/issue_test_log.txt 29 | git commit -m "refs #16: added issues_test_log with the instructions I'm following for this test" 30 | Note: When referencing an issue in your commit message, use one of the following formats: 31 | - refs #16 32 | - implements #16 33 | - fixes #16 34 | - closes #16 35 | Avoid using a colon after "refs", as it may cause the automation to fail. 36 | 37 | Then push your branch: 38 | 39 | bash 40 | CopyInsert in Terminal 41 | git push -u origin test/issue-16-workflow-test 42 | Step 3: Create a Pull Request 43 | Now create a pull request that references the issue: 44 | 45 | bash 46 | CopyInsert in Terminal 47 | gh pr create --title "Test PR for issue #16" --body "This PR tests the automated status updates. Closes #16" --base main 48 | This will: 49 | 50 | Create a PR referencing issue #16 51 | Include "Closes #16" to ensure the issue is closed when merged 52 | Set the base branch to main 53 | Step 4: Monitor Status Changes 54 | After each step, you can check the issue's status using: 55 | 56 | bash 57 | CopyInsert in Terminal 58 | gh issue view 16 --json labels 59 | You should see: 60 | 61 | After your first commit: status changes to "in-progress" 62 | After creating the PR: status changes to "in-review" 63 | After merging the PR: status changes to "completed" 64 | What to Watch For 65 | As you go through these steps, pay attention to: 66 | 67 | GitHub Actions logs (visible in the Actions tab of your repository) 68 | Comments added to the issue by the automation 69 | Status label changes on the issue 70 | Any errors that might appear 71 | Let me know if you encounter any issues or have questions as you go through these steps! 72 | 73 | ## Issue Status Workflow Test - Run 2 74 | 75 | After making fixes to the workflow file and the commit message parser, we're running another test. 76 | 77 | ### Step 1: Update the commit message parser 78 | Made the script more robust to handle variations in commit message format: 79 | - Updated regex patterns to allow optional colons after action keywords (e.g., `refs: #16`) 80 | - Added better spacing handling in the regex patterns 81 | 82 | ### Step 2: Make test commit with proper format 83 | ```bash 84 | git add scripts/issue_status_updater.py tests/issue_test_log.txt 85 | git commit -m "refs #16 Made commit message parser more robust" 86 | ``` 87 | 88 | ### Step 3: Push changes 89 | ```bash 90 | git push 91 | ``` 92 | 93 | ### Step 4: Observe workflow run 94 | - Check GitHub Actions tab to see if the workflow runs successfully 95 | - Verify that the issue status is updated to "in-progress" 96 | 97 | ### Notes: 98 | The parser now handles these formats: 99 | - `refs #16` 100 | - `refs: #16` 101 | - `implements #16` 102 | - `implements: #16` 103 | - `fixes #16` 104 | - `fixes: #16` 105 | - `closes #16` 106 | - `closes: #16` 107 | 108 | ## Issue Status Workflow Test - Run 3 109 | 110 | After implementing a helper script for issue management, we're testing the automated status workflow again. 111 | 112 | ### Step 1: Creating issue_helper.py 113 | Created a helper script (scripts/issue_helper.py) to standardize issue management tasks: 114 | - `python scripts/issue_helper.py start ` - Start work on an issue 115 | - `python scripts/issue_helper.py complete ` - Complete an issue by creating a PR 116 | - `python scripts/issue_helper.py update ` - Update issue status 117 | - `python scripts/issue_helper.py test ` - Force update for testing 118 | 119 | ### Step 2: Test status update 120 | ```bash 121 | python scripts/issue_helper.py test 16 in-progress 122 | ``` 123 | Result: Issue status was successfully updated to "in-progress" and a comment was added explaining the change. 124 | 125 | ### Step 3: Verify documentation 126 | Reviewed the ISSUE_STATUS_AUTOMATION.md file which comprehensively documents: 127 | - Status lifecycle 128 | - Commit message format 129 | - GitHub Actions integration 130 | - Troubleshooting 131 | 132 | ### Step 4: Update issue with test results 133 | The issue still needs testing of the "completed" status transition, which will be triggered when the PR is merged. 134 | 135 | ### Next Steps 136 | 1. Verify status transitions by testing the complete workflow: 137 | - Create a new test issue 138 | - Use helper script to start work 139 | - Commit changes with proper references 140 | - Create and merge PR 141 | - Confirm all status transitions work 142 | 143 | 2. Document helper script usage in the main documentation 144 | 145 | 3. Ensure all test cases are covered: 146 | - Different commit message formats 147 | - PR with multiple issue references 148 | - Edge cases like reopened issues 149 | 150 | ## Issue Status Workflow Test - Run 4 151 | 152 | After enhancing the issue helper script, we're testing the automated status workflow again. 153 | 154 | ### Step 5: Test Enhanced Issue Helper 155 | 156 | The enhanced issue helper script now includes: 157 | - Better error handling for different status transitions 158 | - Detailed next steps guidance at each workflow stage 159 | - Interactive prompts for potentially problematic actions 160 | - Improved branch naming and management 161 | - Comprehensive status checking with `check` command 162 | 163 | The script now creates a much smoother workflow: 164 | ``` 165 | # Start work 166 | python scripts/issue_helper.py start 22 167 | # Make changes 168 | ... 169 | # Check status 170 | python scripts/issue_helper.py check 22 171 | # Complete issue 172 | python scripts/issue_helper.py complete 22 173 | ``` 174 | 175 | All commands provide helpful guidance and verify that the workflow is being followed correctly. 176 | 177 | ### Step 6: Update Documentation 178 | 179 | The GitHub issues workflow documentation has been updated with: 180 | - Details on the helper script 181 | - Integration with the automated workflow 182 | - Command reference for common tasks 183 | 184 | This documentation will help future AI agents understand and use the workflow efficiently. 185 | 186 | ### Step 7: Verify Full Status Transition 187 | 188 | To verify the full status transition workflow: 189 | 1. Created issue #22 "Test Full Issue Status Workflow" with status:prioritized 190 | 2. Started work with `python scripts/issue_helper.py start 22` 191 | - Confirmed status changed to in-progress 192 | - Branch created with proper naming 193 | 3. Made improvements to the helper script and documentation 194 | 4. Will complete with `python scripts/issue_helper.py complete 22` 195 | 5. After PR is merged, will confirm status changes to completed 196 | -------------------------------------------------------------------------------- /tests/test_gmail_auth.py: -------------------------------------------------------------------------------- 1 | """Tests for the Gmail authentication module.""" 2 | 3 | import argparse 4 | import logging 5 | import os 6 | import pytest 7 | from unittest.mock import patch, MagicMock 8 | 9 | from imap_mcp.gmail_auth import main 10 | 11 | 12 | @pytest.mark.skip(reason="Test triggers OAuth2 authentication flow in the browser") 13 | def test_main_with_credentials_file(): 14 | """Test main function with credentials file.""" 15 | test_args = [ 16 | "--credentials-file", "test_credentials.json", 17 | "--port", "8080", 18 | "--output", "test_config.yaml" 19 | ] 20 | 21 | with patch("sys.argv", ["gmail_auth.py"] + test_args), \ 22 | patch("imap_mcp.browser_auth.perform_oauth_flow") as mock_oauth_flow: 23 | 24 | mock_oauth_flow.return_value = {"imap": {"oauth2": {"refresh_token": "test_token"}}} 25 | 26 | # Run the main function 27 | with patch("sys.exit") as mock_exit: 28 | main() 29 | 30 | # Verify the OAuth flow was called correctly 31 | mock_oauth_flow.assert_called_once() 32 | _, kwargs = mock_oauth_flow.call_args 33 | assert kwargs["credentials_file"] == "test_credentials.json" 34 | assert kwargs["port"] == 8080 35 | assert kwargs["config_output"] == "test_config.yaml" 36 | 37 | # Verify the program exits successfully 38 | mock_exit.assert_called_once_with(0) 39 | 40 | 41 | @pytest.mark.skip(reason="Test triggers OAuth2 authentication flow in the browser") 42 | def test_main_with_client_id_secret(): 43 | """Test main function with client ID and secret.""" 44 | test_args = [ 45 | "--client-id", "test_client_id", 46 | "--client-secret", "test_client_secret" 47 | ] 48 | 49 | with patch("sys.argv", ["gmail_auth.py"] + test_args), \ 50 | patch("imap_mcp.browser_auth.perform_oauth_flow") as mock_oauth_flow, \ 51 | patch("sys.exit") as mock_exit: 52 | 53 | mock_oauth_flow.return_value = {"imap": {"oauth2": {"refresh_token": "test_token"}}} 54 | 55 | # Run the main function 56 | main() 57 | 58 | # Verify the OAuth flow was called correctly 59 | mock_oauth_flow.assert_called_once() 60 | _, kwargs = mock_oauth_flow.call_args 61 | assert kwargs["client_id"] == "test_client_id" 62 | assert kwargs["client_secret"] == "test_client_secret" 63 | assert kwargs["credentials_file"] is None 64 | 65 | # Verify the program exits successfully 66 | mock_exit.assert_called_once_with(0) 67 | 68 | 69 | @pytest.mark.skip(reason="Test triggers OAuth2 authentication flow in the browser") 70 | def test_main_with_failure(): 71 | """Test main function with OAuth flow failure.""" 72 | test_args = ["--client-id", "test_client_id"] 73 | 74 | with patch("sys.argv", ["gmail_auth.py"] + test_args), \ 75 | patch("imap_mcp.browser_auth.perform_oauth_flow") as mock_oauth_flow, \ 76 | patch("sys.exit") as mock_exit: 77 | 78 | # Simulate the OAuth flow failing 79 | mock_oauth_flow.return_value = None 80 | 81 | # Run the main function 82 | main() 83 | 84 | # Verify the program exits with an error 85 | mock_exit.assert_called_once_with(1) 86 | 87 | 88 | def test_parse_arguments(): 89 | """Test argument parsing.""" 90 | test_args = [ 91 | "--client-id", "test_client_id", 92 | "--client-secret", "test_client_secret", 93 | "--credentials-file", "creds.json", 94 | "--port", "9000", 95 | "--output", "output.yaml" 96 | ] 97 | 98 | with patch("sys.argv", ["gmail_auth.py"] + test_args), \ 99 | patch("argparse.ArgumentParser.parse_args") as mock_parse_args, \ 100 | patch("imap_mcp.browser_auth.perform_oauth_flow") as mock_oauth_flow, \ 101 | patch("sys.exit"): 102 | 103 | mock_args = argparse.Namespace( 104 | client_id="test_client_id", 105 | client_secret="test_client_secret", 106 | credentials_file="creds.json", 107 | port=9000, 108 | output="output.yaml" 109 | ) 110 | mock_parse_args.return_value = mock_args 111 | mock_oauth_flow.return_value = {"imap": {"oauth2": {"refresh_token": "test_token"}}} 112 | 113 | # Run the main function 114 | main() 115 | 116 | # Verify parse_args was called 117 | mock_parse_args.assert_called_once() 118 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | """Tests for email models.""" 2 | 3 | import email 4 | import unittest 5 | from email.header import Header 6 | from email.mime.multipart import MIMEMultipart 7 | from email.mime.text import MIMEText 8 | 9 | from imap_mcp.models import Email, EmailAddress, decode_mime_header 10 | 11 | 12 | class TestModels(unittest.TestCase): 13 | """Test cases for email models.""" 14 | 15 | def test_decode_mime_header(self): 16 | """Test MIME header decoding.""" 17 | # Test ASCII header 18 | self.assertEqual(decode_mime_header("Hello"), "Hello") 19 | 20 | # Test encoded header 21 | encoded_header = Header("Héllö Wörld", "utf-8").encode() 22 | self.assertEqual(decode_mime_header(encoded_header), "Héllö Wörld") 23 | 24 | # Test empty header 25 | self.assertEqual(decode_mime_header(None), "") 26 | self.assertEqual(decode_mime_header(""), "") 27 | 28 | def test_email_address_parse(self): 29 | """Test email address parsing.""" 30 | # Test name + address 31 | addr = EmailAddress.parse("John Doe ") 32 | self.assertEqual(addr.name, "John Doe") 33 | self.assertEqual(addr.address, "john@example.com") 34 | 35 | # Test quoted name 36 | addr = EmailAddress.parse('"Smith, John" ') 37 | self.assertEqual(addr.name, "Smith, John") 38 | self.assertEqual(addr.address, "john@example.com") 39 | 40 | # Test address only 41 | addr = EmailAddress.parse("jane@example.com") 42 | self.assertEqual(addr.name, "") 43 | self.assertEqual(addr.address, "jane@example.com") 44 | 45 | # Test string conversion 46 | addr = EmailAddress("Jane Smith", "jane@example.com") 47 | self.assertEqual(str(addr), "Jane Smith ") 48 | addr = EmailAddress("", "jane@example.com") 49 | self.assertEqual(str(addr), "jane@example.com") 50 | 51 | def test_email_from_message(self): 52 | """Test creating email from message.""" 53 | # Create a multipart email 54 | msg = MIMEMultipart() 55 | msg["From"] = "John Doe " 56 | msg["To"] = "Jane Smith , bob@example.com" 57 | msg["Subject"] = "Test Email" 58 | msg["Message-ID"] = "" 59 | msg["Date"] = email.utils.formatdate() 60 | 61 | # Add plain text part 62 | text_part = MIMEText("Hello, this is a test email.", "plain") 63 | msg.attach(text_part) 64 | 65 | # Add HTML part 66 | html_part = MIMEText("

Hello, this is a test email.

", "html") 67 | msg.attach(html_part) 68 | 69 | # Parse email 70 | email_obj = Email.from_message(msg, uid=1234, folder="INBOX") 71 | 72 | # Check basic fields 73 | self.assertEqual(email_obj.message_id, "") 74 | self.assertEqual(email_obj.subject, "Test Email") 75 | self.assertEqual(str(email_obj.from_), "John Doe ") 76 | self.assertEqual(len(email_obj.to), 2) 77 | self.assertEqual(str(email_obj.to[0]), "Jane Smith ") 78 | self.assertEqual(str(email_obj.to[1]), "bob@example.com") 79 | self.assertEqual(email_obj.folder, "INBOX") 80 | self.assertEqual(email_obj.uid, 1234) 81 | 82 | # Check content 83 | self.assertEqual(email_obj.content.text, "Hello, this is a test email.") 84 | self.assertEqual( 85 | email_obj.content.html, "

Hello, this is a test email.

" 86 | ) 87 | 88 | # Check summary 89 | summary = email_obj.summary() 90 | self.assertIn("From: John Doe ", summary) 91 | self.assertIn("Subject: Test Email", summary) 92 | 93 | 94 | if __name__ == "__main__": 95 | unittest.main() 96 | -------------------------------------------------------------------------------- /tests/test_oauth2_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the OAuth2 configuration module. 3 | """ 4 | 5 | import json 6 | import os 7 | import tempfile 8 | from unittest import mock 9 | from pathlib import Path 10 | 11 | import pytest 12 | 13 | from imap_mcp.config import ServerConfig 14 | from imap_mcp.oauth2_config import OAuth2Config 15 | 16 | 17 | @pytest.fixture 18 | def sample_client_config(): 19 | """Return a sample client configuration dictionary.""" 20 | return { 21 | "installed": { 22 | "client_id": "test-client-id.apps.googleusercontent.com", 23 | "client_secret": "test-client-secret", 24 | "redirect_uris": ["http://localhost", "urn:ietf:wg:oauth:2.0:oob"], 25 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 26 | "token_uri": "https://oauth2.googleapis.com/token", 27 | } 28 | } 29 | 30 | 31 | @pytest.fixture 32 | def temp_credentials_file(sample_client_config): 33 | """Create a temporary credentials file with test data.""" 34 | with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: 35 | json.dump(sample_client_config, f) 36 | temp_file_path = f.name 37 | 38 | yield temp_file_path 39 | 40 | # Clean up 41 | if os.path.exists(temp_file_path): 42 | os.unlink(temp_file_path) 43 | 44 | 45 | def test_oauth2_config_init(): 46 | """Test OAuth2Config initialization.""" 47 | config = OAuth2Config( 48 | credentials_file="test.json", 49 | token_file="token.json", 50 | scopes=["https://mail.google.com/"], 51 | client_id="test_id", 52 | client_secret="test_secret" 53 | ) 54 | 55 | assert config.credentials_file == "test.json" 56 | assert config.token_file == "token.json" 57 | assert config.scopes == ["https://mail.google.com/"] 58 | assert config.client_id == "test_id" 59 | assert config.client_secret == "test_secret" 60 | 61 | 62 | def test_from_server_config(): 63 | """Test creating OAuth2Config from ServerConfig.""" 64 | # Create a mock ServerConfig object with the oauth2 property 65 | server_config = mock.MagicMock(spec=ServerConfig) 66 | server_config.oauth2 = { 67 | "credentials_file": "client_secret.json", 68 | "token_file": "custom_token.json", 69 | "scopes": ["https://mail.google.com/", "custom_scope"] 70 | } 71 | server_config.password = "test_password" 72 | 73 | oauth2_config = OAuth2Config.from_server_config(server_config) 74 | 75 | assert oauth2_config.credentials_file == "client_secret.json" 76 | assert oauth2_config.token_file == "custom_token.json" 77 | assert oauth2_config.scopes == ["https://mail.google.com/", "custom_scope"] 78 | 79 | 80 | def test_from_server_config_defaults(): 81 | """Test creating OAuth2Config with default values when not in ServerConfig.""" 82 | # Create a mock ServerConfig object without the oauth2 property 83 | server_config = mock.MagicMock(spec=ServerConfig) 84 | server_config.oauth2 = None 85 | server_config.password = "test_password" 86 | 87 | oauth2_config = OAuth2Config.from_server_config(server_config) 88 | 89 | assert oauth2_config.credentials_file == "" 90 | assert oauth2_config.token_file == "gmail_token.json" 91 | assert oauth2_config.scopes == ["https://mail.google.com/"] 92 | 93 | 94 | def test_from_server_config_with_env_vars(): 95 | """Test that environment variables override config values.""" 96 | # Create a mock ServerConfig object with the oauth2 property 97 | server_config = mock.MagicMock(spec=ServerConfig) 98 | server_config.oauth2 = { 99 | "credentials_file": "client_secret.json", 100 | "token_file": "token.json", 101 | "scopes": ["https://mail.google.com/"] 102 | } 103 | server_config.password = "test_password" 104 | 105 | with mock.patch.dict(os.environ, { 106 | "GMAIL_CLIENT_ID": "env_client_id", 107 | "GMAIL_CLIENT_SECRET": "env_client_secret" 108 | }): 109 | oauth2_config = OAuth2Config.from_server_config(server_config) 110 | 111 | assert oauth2_config.client_id == "env_client_id" 112 | assert oauth2_config.client_secret == "env_client_secret" 113 | 114 | 115 | def test_load_client_config(temp_credentials_file, sample_client_config): 116 | """Test loading client config from a file.""" 117 | config = OAuth2Config( 118 | credentials_file=temp_credentials_file, 119 | token_file="token.json", 120 | scopes=["https://mail.google.com/"] 121 | ) 122 | 123 | client_config = config.load_client_config() 124 | 125 | assert client_config == sample_client_config 126 | assert config.client_id == sample_client_config["installed"]["client_id"] 127 | assert config.client_secret == sample_client_config["installed"]["client_secret"] 128 | 129 | 130 | def test_load_client_config_with_direct_credentials(): 131 | """Test that directly provided credentials are used instead of file.""" 132 | config = OAuth2Config( 133 | credentials_file="nonexistent.json", 134 | token_file="token.json", 135 | scopes=["https://mail.google.com/"], 136 | client_id="direct_client_id", 137 | client_secret="direct_client_secret" 138 | ) 139 | 140 | client_config = config.load_client_config() 141 | 142 | assert client_config["installed"]["client_id"] == "direct_client_id" 143 | assert client_config["installed"]["client_secret"] == "direct_client_secret" 144 | assert config.client_id == "direct_client_id" 145 | assert config.client_secret == "direct_client_secret" 146 | 147 | 148 | def test_missing_credentials_file(): 149 | """Test error handling for missing credentials file.""" 150 | config = OAuth2Config( 151 | credentials_file="nonexistent.json", 152 | token_file="token.json", 153 | scopes=["https://mail.google.com/"] 154 | ) 155 | 156 | with pytest.raises(FileNotFoundError): 157 | config.load_client_config() 158 | 159 | 160 | def test_invalid_credentials_file(): 161 | """Test error handling for invalid JSON in credentials file.""" 162 | with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: 163 | f.write("This is not valid JSON") 164 | temp_file_path = f.name 165 | 166 | config = OAuth2Config( 167 | credentials_file=temp_file_path, 168 | token_file="token.json", 169 | scopes=["https://mail.google.com/"] 170 | ) 171 | 172 | try: 173 | with pytest.raises(ValueError): 174 | config.load_client_config() 175 | finally: 176 | # Clean up 177 | if os.path.exists(temp_file_path): 178 | os.unlink(temp_file_path) 179 | 180 | 181 | def test_no_credentials_file_or_direct_credentials(): 182 | """Test error handling when no credentials source is provided.""" 183 | config = OAuth2Config( 184 | credentials_file="", 185 | token_file="token.json", 186 | scopes=["https://mail.google.com/"] 187 | ) 188 | 189 | with pytest.raises(ValueError, match="No credentials file specified"): 190 | config.load_client_config() 191 | -------------------------------------------------------------------------------- /tests/test_smtp_client_composition.py: -------------------------------------------------------------------------------- 1 | """Tests for SMTP client MIME composition functionality.""" 2 | 3 | import re 4 | import pytest 5 | from datetime import datetime 6 | 7 | from imap_mcp.models import Email, EmailAddress, EmailContent 8 | from imap_mcp.smtp_client import create_reply_mime 9 | 10 | 11 | class TestCreateReplyMime: 12 | """Tests for create_reply_mime function.""" 13 | 14 | @pytest.fixture 15 | def sample_email(self) -> Email: 16 | """Create a sample email for testing.""" 17 | return Email( 18 | message_id="", 19 | subject="Test Subject", 20 | from_=EmailAddress(name="Sender Name", address="sender@example.com"), 21 | to=[EmailAddress(name="Recipient Name", address="recipient@example.com")], 22 | cc=[EmailAddress(name="CC Recipient", address="cc@example.com")], 23 | date=datetime.now(), 24 | content=EmailContent(text="Original message content\nOn multiple lines.", 25 | html="

Original message content

On multiple lines.

"), 26 | headers={"References": ""} 27 | ) 28 | 29 | def test_create_basic_reply(self, sample_email: Email): 30 | """Test creating a basic reply.""" 31 | reply_to = EmailAddress(name="Reply To", address="sender@example.com") 32 | subject = "Re: Test Subject" 33 | body = "This is a reply." 34 | 35 | mime_message = create_reply_mime( 36 | original_email=sample_email, 37 | reply_to=reply_to, 38 | subject=subject, 39 | body=body 40 | ) 41 | 42 | # Check basic properties 43 | assert mime_message["To"] == "Sender Name " 44 | assert mime_message["Subject"] == "Re: Test Subject" 45 | assert mime_message["From"] == "Reply To " 46 | assert mime_message["In-Reply-To"] == "" 47 | assert "" in mime_message["References"] 48 | assert "" in mime_message["References"] 49 | 50 | # Check content - handle both multipart and non-multipart payloads 51 | if mime_message.is_multipart(): 52 | payload = mime_message.get_payload(0).get_payload(decode=True).decode() 53 | else: 54 | payload = mime_message.get_payload(decode=True).decode() 55 | 56 | assert "This is a reply." in payload 57 | assert "Original message content" in payload 58 | 59 | def test_create_reply_all(self, sample_email: Email): 60 | """Test creating a reply-all.""" 61 | reply_to = EmailAddress(name="Reply To", address="sender@example.com") 62 | subject = "Re: Test Subject" 63 | body = "This is a reply to all." 64 | 65 | mime_message = create_reply_mime( 66 | original_email=sample_email, 67 | reply_to=reply_to, 68 | subject=subject, 69 | body=body, 70 | reply_all=True 71 | ) 72 | 73 | # Check recipients - should include original CCs and sender 74 | assert mime_message["To"] == "Sender Name , Recipient Name " 75 | assert mime_message["Cc"] == "CC Recipient " 76 | 77 | def test_create_reply_with_custom_cc(self, sample_email: Email): 78 | """Test creating a reply with custom CC recipients.""" 79 | reply_to = EmailAddress(name="Reply To", address="sender@example.com") 80 | subject = "Re: Test Subject" 81 | body = "This is a reply with custom CC." 82 | cc = [ 83 | EmailAddress(name="Custom CC", address="custom@example.com"), 84 | EmailAddress(name="Another CC", address="another@example.com") 85 | ] 86 | 87 | mime_message = create_reply_mime( 88 | original_email=sample_email, 89 | reply_to=reply_to, 90 | subject=subject, 91 | body=body, 92 | cc=cc 93 | ) 94 | 95 | # Check CC recipients 96 | assert mime_message["Cc"] == "Custom CC , Another CC " 97 | 98 | def test_create_reply_with_subject_prefix(self, sample_email: Email): 99 | """Test creating a reply with a custom subject prefix.""" 100 | reply_to = EmailAddress(name="Reply To", address="sender@example.com") 101 | body = "This is a reply with custom subject prefix." 102 | 103 | # No prefix provided, but original doesn't start with Re: 104 | mime_message = create_reply_mime( 105 | original_email=sample_email, 106 | reply_to=reply_to, 107 | body=body 108 | ) 109 | 110 | assert mime_message["Subject"].startswith("Re: ") 111 | 112 | # Custom subject provided 113 | custom_subject = "Custom: Test Subject" 114 | mime_message = create_reply_mime( 115 | original_email=sample_email, 116 | reply_to=reply_to, 117 | body=body, 118 | subject=custom_subject 119 | ) 120 | 121 | assert mime_message["Subject"] == custom_subject 122 | 123 | # Original already has Re: prefix 124 | sample_email.subject = "Re: Already Prefixed" 125 | mime_message = create_reply_mime( 126 | original_email=sample_email, 127 | reply_to=reply_to, 128 | body=body 129 | ) 130 | 131 | assert mime_message["Subject"] == "Re: Already Prefixed" 132 | 133 | def test_create_html_reply(self, sample_email: Email): 134 | """Test creating a reply with HTML content.""" 135 | reply_to = EmailAddress(name="Reply To", address="sender@example.com") 136 | body = "This is a plain text reply." 137 | html_body = "

This is an HTML reply.

" 138 | 139 | mime_message = create_reply_mime( 140 | original_email=sample_email, 141 | reply_to=reply_to, 142 | body=body, 143 | html_body=html_body 144 | ) 145 | 146 | # Should be multipart with at least 2 parts 147 | assert mime_message.is_multipart() 148 | alternative = mime_message.get_payload(0) 149 | assert alternative.is_multipart() 150 | 151 | # Check HTML part 152 | html_part = alternative.get_payload(1) 153 | html_text = html_part.get_payload(decode=True).decode() 154 | assert "

This is an HTML reply.

" in html_text 155 | 156 | def test_quoting_original_content(self, sample_email: Email): 157 | """Test proper quoting of original content.""" 158 | reply_to = EmailAddress(name="Reply To", address="sender@example.com") 159 | body = "This is a reply with original content quoted." 160 | 161 | mime_message = create_reply_mime( 162 | original_email=sample_email, 163 | reply_to=reply_to, 164 | body=body 165 | ) 166 | 167 | # Check content 168 | if mime_message.is_multipart(): 169 | payload = mime_message.get_payload(0).get_payload(decode=True).decode() 170 | else: 171 | payload = mime_message.get_payload(decode=True).decode() 172 | 173 | # Should have quoting prefix (>) and original content 174 | assert "This is a reply with original content quoted." in payload 175 | 176 | # Check for proper quoting 177 | lines = payload.split("\n") 178 | quoted_lines = [line for line in lines if line.startswith(">")] 179 | assert any("> Original message content" in line for line in quoted_lines) 180 | -------------------------------------------------------------------------------- /tests/test_tools.py.bak: -------------------------------------------------------------------------------- 1 | """Tests for MCP tools implementation.""" 2 | 3 | import json 4 | import pytest 5 | from unittest.mock import AsyncMock, MagicMock, patch 6 | 7 | from mcp.server.fastmcp import FastMCP 8 | from mcp.server.types import Context 9 | 10 | from imap_mcp.imap_client import ImapClient 11 | from imap_mcp.models import Email, EmailAddress 12 | from imap_mcp.tools import register_tools 13 | 14 | 15 | class TestTools: 16 | """Test class for MCP tools.""" 17 | 18 | @pytest.fixture 19 | def mock_client(self): 20 | """Create a mock IMAP client.""" 21 | client = MagicMock(spec=ImapClient) 22 | return client 23 | 24 | @pytest.fixture 25 | def mock_mcp(self): 26 | """Create a mock MCP server.""" 27 | mcp = MagicMock(spec=FastMCP) 28 | # Configure the tool decorator to pass through the decorated function 29 | mcp.tool = lambda: lambda f: f 30 | return mcp 31 | 32 | @pytest.fixture 33 | def mock_context(self, mock_client): 34 | """Create a mock MCP context with client.""" 35 | context = MagicMock(spec=Context) 36 | context.get.return_value = mock_client 37 | return context 38 | 39 | @pytest.fixture 40 | def register_mock_tools(self, mock_mcp, mock_client): 41 | """Register tools with mock MCP and client.""" 42 | # Register tools with mock MCP and client 43 | register_tools(mock_mcp, mock_client) 44 | # Extract the registered tools (they should be accessible through mock_mcp.register.calls) 45 | registered_tools = {} 46 | for call in mock_mcp.tool.mock_calls: 47 | if len(call.args) > 0 and callable(call.args[0]): 48 | registered_tools[call.args[0].__name__] = call.args[0] 49 | return registered_tools 50 | 51 | async def test_move_email(self, mock_client, mock_context): 52 | """Test moving an email from one folder to another.""" 53 | # Configure the mock client 54 | mock_client.move_email.return_value = True 55 | 56 | # Register tools 57 | mcp = MagicMock(spec=FastMCP) 58 | mcp.tool = lambda: lambda f: f 59 | register_tools(mcp, mock_client) 60 | 61 | # Get the move_email function 62 | move_email = None 63 | for name, mock_call in mcp.tool.mock_calls: 64 | if hasattr(mock_call, "args") and len(mock_call.args) > 0: 65 | if callable(mock_call.args[0]) and mock_call.args[0].__name__ == "move_email": 66 | move_email = mock_call.args[0] 67 | break 68 | 69 | assert move_email is not None, "move_email tool was not registered" 70 | 71 | # Call the move_email function 72 | result = await move_email("inbox", 123, "archive", mock_context) 73 | 74 | # Check the client was called correctly 75 | mock_client.move_email.assert_called_once_with(123, "inbox", "archive") 76 | 77 | # Check the result 78 | assert "Email moved" in result 79 | 80 | async def test_mark_as_read(self, mock_client, mock_context): 81 | """Test marking an email as read.""" 82 | # Test will be implemented here 83 | pass 84 | 85 | async def test_mark_as_unread(self, mock_client, mock_context): 86 | """Test marking an email as unread.""" 87 | # Test will be implemented here 88 | pass 89 | 90 | async def test_flag_email(self, mock_client, mock_context): 91 | """Test flagging an email.""" 92 | # Test will be implemented here 93 | pass 94 | 95 | async def test_delete_email(self, mock_client, mock_context): 96 | """Test deleting an email.""" 97 | # Test will be implemented here 98 | pass 99 | 100 | async def test_search_emails(self, mock_client, mock_context): 101 | """Test searching for emails.""" 102 | # Test will be implemented here 103 | pass 104 | 105 | async def test_process_email(self, mock_client, mock_context): 106 | """Test processing an email with multiple actions.""" 107 | # Test will be implemented here 108 | pass 109 | 110 | async def test_tool_error_handling(self, mock_client, mock_context): 111 | """Test error handling in tools.""" 112 | # Test will be implemented here 113 | pass 114 | 115 | async def test_tool_parameter_validation(self, mock_client, mock_context): 116 | """Test parameter validation in tools.""" 117 | # Test will be implemented here 118 | pass 119 | -------------------------------------------------------------------------------- /tests/test_tools_reply_drafting.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime, timedelta 3 | import json 4 | from unittest.mock import patch, MagicMock 5 | 6 | from imap_mcp.tools import draft_meeting_reply 7 | 8 | # Create a mock Context 9 | @pytest.fixture 10 | def mock_ctx(): 11 | ctx = MagicMock() 12 | return ctx 13 | 14 | # Generate test invite details 15 | @pytest.fixture 16 | def sample_invite_details(): 17 | now = datetime.now() 18 | start_time = (now + timedelta(days=1)).isoformat() 19 | end_time = (now + timedelta(days=1, hours=1)).isoformat() 20 | 21 | return { 22 | "subject": "Team Sync Meeting", 23 | "start_time": start_time, 24 | "end_time": end_time, 25 | "organizer": "organizer@example.com", 26 | "location": "Conference Room A" 27 | } 28 | 29 | @pytest.mark.asyncio # Add this decorator 30 | async def test_draft_reply_accept(mock_ctx, sample_invite_details): 31 | """Test generating an acceptance reply.""" 32 | result = await draft_meeting_reply(sample_invite_details, True, mock_ctx) 33 | 34 | assert isinstance(result, dict) 35 | assert "reply_subject" in result 36 | assert "reply_body" in result 37 | assert result["reply_subject"] == "Re: Team Sync Meeting" 38 | assert "confirming my attendance" in result["reply_body"] 39 | assert "Conference Room A" in result["reply_body"] 40 | 41 | @pytest.mark.asyncio # Add this decorator 42 | async def test_draft_reply_decline(mock_ctx, sample_invite_details): 43 | """Test generating a decline reply.""" 44 | result = await draft_meeting_reply(sample_invite_details, False, mock_ctx) 45 | 46 | assert isinstance(result, dict) 47 | assert "reply_subject" in result 48 | assert "reply_body" in result 49 | assert result["reply_subject"] == "Re: Team Sync Meeting" 50 | assert "Unfortunately" in result["reply_body"] 51 | assert "won't be able to attend" in result["reply_body"] 52 | 53 | @pytest.mark.asyncio # Add this decorator 54 | async def test_draft_reply_missing_details(mock_ctx): 55 | """Test handling of missing required fields.""" 56 | incomplete_details = { 57 | "subject": "Incomplete Meeting", 58 | # Missing start_time, end_time, organizer 59 | } 60 | 61 | with pytest.raises(ValueError) as excinfo: 62 | await draft_meeting_reply(incomplete_details, True, mock_ctx) 63 | 64 | assert "Missing required fields" in str(excinfo.value) 65 | 66 | @pytest.mark.asyncio # Add this decorator 67 | async def test_draft_reply_subject_already_re(mock_ctx, sample_invite_details): 68 | """Test subject handling when original subject already starts with 'Re:'.""" 69 | sample_invite_details["subject"] = "Re: Previous Discussion" 70 | 71 | result = await draft_meeting_reply(sample_invite_details, True, mock_ctx) 72 | 73 | assert result["reply_subject"] == "Re: Previous Discussion" 74 | # Should not be "Re: Re: Previous Discussion" -------------------------------------------------------------------------------- /tests/workflows/__init__.py: -------------------------------------------------------------------------------- 1 | """Workflow tests package.""" 2 | -------------------------------------------------------------------------------- /tests/workflows/test_calendar_mock.py: -------------------------------------------------------------------------------- 1 | """Tests for the mock calendar availability checking functionality.""" 2 | 3 | import pytest 4 | from unittest.mock import patch 5 | from datetime import datetime, timedelta 6 | 7 | from imap_mcp.workflows.calendar_mock import ( 8 | check_mock_availability, 9 | _parse_datetime, 10 | _check_availability_by_mode, 11 | _generate_alternative_times 12 | ) 13 | 14 | 15 | class TestCalendarMock: 16 | """Tests for calendar mock functions.""" 17 | 18 | @pytest.fixture 19 | def sample_datetime(self): 20 | """Create a sample datetime for testing.""" 21 | return datetime(2025, 4, 1, 10, 0, 0) # April 1, 2025 10:00 AM 22 | 23 | def test_parse_datetime_object(self, sample_datetime): 24 | """Test parsing when input is already a datetime object.""" 25 | result = _parse_datetime(sample_datetime) 26 | assert result == sample_datetime 27 | 28 | def test_parse_datetime_string(self): 29 | """Test parsing datetime from ISO format string.""" 30 | dt_str = "2025-04-01T10:00:00" 31 | result = _parse_datetime(dt_str) 32 | assert result == datetime(2025, 4, 1, 10, 0, 0) 33 | 34 | def test_parse_datetime_invalid(self): 35 | """Test parsing with invalid input.""" 36 | result = _parse_datetime("not-a-datetime") 37 | assert result is None 38 | 39 | def test_always_available_mode(self, sample_datetime): 40 | """Test always_available mode.""" 41 | end_time = sample_datetime + timedelta(hours=1) 42 | available, reason = _check_availability_by_mode( 43 | sample_datetime, end_time, "always_available" 44 | ) 45 | assert available is True 46 | assert "available" in reason.lower() 47 | 48 | def test_always_busy_mode(self, sample_datetime): 49 | """Test always_busy mode.""" 50 | end_time = sample_datetime + timedelta(hours=1) 51 | available, reason = _check_availability_by_mode( 52 | sample_datetime, end_time, "always_busy" 53 | ) 54 | assert available is False 55 | assert "busy" in reason.lower() 56 | 57 | def test_business_hours_mode_within(self): 58 | """Test business_hours mode with time within business hours.""" 59 | start_time = datetime(2025, 4, 1, 10, 0, 0) # 10 AM 60 | end_time = datetime(2025, 4, 1, 11, 0, 0) # 11 AM 61 | available, reason = _check_availability_by_mode( 62 | start_time, end_time, "business_hours" 63 | ) 64 | assert available is True 65 | assert "business hours" in reason.lower() 66 | 67 | def test_business_hours_mode_outside(self): 68 | """Test business_hours mode with time outside business hours.""" 69 | start_time = datetime(2025, 4, 1, 18, 0, 0) # 6 PM 70 | end_time = datetime(2025, 4, 1, 19, 0, 0) # 7 PM 71 | available, reason = _check_availability_by_mode( 72 | start_time, end_time, "business_hours" 73 | ) 74 | assert available is False 75 | assert "outside business hours" in reason.lower() 76 | 77 | def test_weekdays_mode_weekday(self): 78 | """Test weekdays mode with a weekday.""" 79 | # April 1, 2025 is a Tuesday 80 | start_time = datetime(2025, 4, 1, 10, 0, 0) 81 | end_time = datetime(2025, 4, 1, 11, 0, 0) 82 | available, reason = _check_availability_by_mode( 83 | start_time, end_time, "weekdays" 84 | ) 85 | assert available is True 86 | assert "weekday" in reason.lower() 87 | 88 | def test_weekdays_mode_weekend(self): 89 | """Test weekdays mode with a weekend day.""" 90 | # April 5, 2025 is a Saturday 91 | start_time = datetime(2025, 4, 5, 10, 0, 0) 92 | end_time = datetime(2025, 4, 5, 11, 0, 0) 93 | available, reason = _check_availability_by_mode( 94 | start_time, end_time, "weekdays" 95 | ) 96 | assert available is False 97 | assert "weekend" in reason.lower() 98 | 99 | @patch("random.random") 100 | def test_random_mode_available(self, mock_random): 101 | """Test random mode when it returns available.""" 102 | mock_random.return_value = 0.5 # Below 0.7 threshold, so available 103 | start_time = datetime(2025, 4, 1, 10, 0, 0) 104 | end_time = datetime(2025, 4, 1, 11, 0, 0) 105 | available, reason = _check_availability_by_mode( 106 | start_time, end_time, "random" 107 | ) 108 | assert available is True 109 | assert "available" in reason.lower() 110 | 111 | @patch("random.random") 112 | def test_random_mode_busy(self, mock_random): 113 | """Test random mode when it returns busy.""" 114 | mock_random.return_value = 0.8 # Above 0.7 threshold, so busy 115 | start_time = datetime(2025, 4, 1, 10, 0, 0) 116 | end_time = datetime(2025, 4, 1, 11, 0, 0) 117 | available, reason = _check_availability_by_mode( 118 | start_time, end_time, "random" 119 | ) 120 | assert available is False 121 | assert "busy" in reason.lower() 122 | 123 | def test_main_function_with_datetime_objects(self, sample_datetime): 124 | """Test the main check_mock_availability function with datetime objects.""" 125 | start_time = sample_datetime 126 | end_time = sample_datetime + timedelta(hours=1) 127 | 128 | # Use always_available mode for predictable result 129 | result = check_mock_availability(start_time, end_time, "always_available") 130 | 131 | assert isinstance(result, dict) 132 | assert "available" in result 133 | assert "reason" in result 134 | assert "alternative_times" in result 135 | assert result["available"] is True 136 | 137 | def test_main_function_with_strings(self): 138 | """Test the main check_mock_availability function with ISO date strings.""" 139 | start_time = "2025-04-01T10:00:00" 140 | end_time = "2025-04-01T11:00:00" 141 | 142 | # Use always_available mode for predictable result 143 | result = check_mock_availability(start_time, end_time, "always_available") 144 | 145 | assert isinstance(result, dict) 146 | assert "available" in result 147 | assert "reason" in result 148 | assert "alternative_times" in result 149 | assert result["available"] is True 150 | 151 | def test_main_function_with_invalid_input(self): 152 | """Test the main check_mock_availability function with invalid input.""" 153 | start_time = "not-a-datetime" 154 | end_time = "2025-04-01T11:00:00" 155 | 156 | result = check_mock_availability(start_time, end_time) 157 | 158 | assert isinstance(result, dict) 159 | assert "available" in result 160 | assert "reason" in result 161 | assert "alternative_times" in result 162 | assert result["available"] is False 163 | assert "Invalid datetime format" in result["reason"] 164 | 165 | def test_generate_alternative_times(self, sample_datetime): 166 | """Test generating alternative times.""" 167 | start_time = sample_datetime 168 | end_time = sample_datetime + timedelta(hours=1) 169 | 170 | alternatives = _generate_alternative_times(start_time, end_time) 171 | 172 | # For the mock implementation, we expect an empty list 173 | assert isinstance(alternatives, list) 174 | assert len(alternatives) == 0 175 | --------------------------------------------------------------------------------