Skip to content

Instantly share code, notes, and snippets.

@cprima
Last active October 8, 2025 21:16
Show Gist options
  • Select an option

  • Save cprima/e92b57206598ed77de7670fdebf747c2 to your computer and use it in GitHub Desktop.

Select an option

Save cprima/e92b57206598ed77de7670fdebf747c2 to your computer and use it in GitHub Desktop.
imap-organizer v0.1.0

imap-organizer

Automatically organize your email inbox by filing messages into yearly folders. Run it once a month and find any email in seconds.

The Problem

Your inbox has thousands of emails. Finding that receipt from last year means scrolling forever or remembering exact search terms. Old emails clutter your view, but deleting them feels risky.

The Solution

Every email gets filed into year-based subfolders: Inbox/2025, Inbox/2024, Inbox/2023, etc.

Before:

Inbox (10,247 emails)

After:

Inbox (52 emails from this year)
Inbox/2025 (52 emails)
Inbox/2024 (1,183 emails)
Inbox/2023 (2,891 emails)
...

Find old emails instantly by year. Your main inbox stays clean with only current emails.

Quick Start

1. Install:

pip install git+https://gist.github.com/[your-gist-url]

2. Set up your email connection:

# Create config file
cat > .imap-organizer.toml << EOF
[connection]
host = "imap.gmail.com"
port = 993
user = "[email protected]"

[folders]
default = ["INBOX"]
EOF

# Add your password to .env file
echo "IMAP_PASSWORD=your_app_password" > .env

3. See what would happen (dry-run, safe):

imap-organizer file

You'll see output like:

📁 Folder: INBOX
📅 Year: 2025 only

Found 47 messages to file:
├─ INBOX/2025: 47 messages

🔍 DRY-RUN - No changes made
💡 Run 'imap-organizer file --apply' to move messages

4. Actually organize your emails:

imap-organizer file --apply

Done! Your emails are now organized by year.

How It Works

  1. Connects to your email server (Gmail, Outlook, any IMAP server)
  2. Scans your inbox for emails
  3. Reads each email's date
  4. Creates year folders (Inbox/2025, Inbox/2024, etc.)
  5. Moves emails to the matching year folder

Safe by default:

  • Dry-run mode shows what will happen before any changes
  • Circuit breaker stops after errors to prevent data loss
  • Only processes current year by default (fast)

Common Tasks

Run monthly to stay organized

imap-organizer file --apply

Organizes new emails from the current year. Takes seconds.

Organize multiple folders

imap-organizer file "INBOX" "Sent Items" --apply

Files emails in Inbox and Sent Items.

Initial setup: organize all old emails

imap-organizer file --backfill --apply

Processes all years. Use this once when you first start using the tool.

See all your email folders

imap-organizer list-folders

Shows all folders on your email server.

Organize a specific year

imap-organizer file --year 2024 --apply

Useful for fixing up a specific year.

Configuration

Edit .imap-organizer.toml to set your preferences:

[connection]
host = "imap.gmail.com"    # Your email server
port = 993                  # IMAP SSL port
user = "[email protected]"      # Your email address

[folders]
default = ["INBOX", "Sent"] # Folders to organize

[behavior]
max_failures = 3            # Stop after this many errors

Password security: Never put your password in the config file. Always use the .env file:

IMAP_PASSWORD=your_password_here

Email Provider Settings

Gmail:

host = "imap.gmail.com"
port = 993

Note: Use an App Password, not your regular password.

Outlook/Office 365:

host = "outlook.office365.com"
port = 993

Other providers: Check your email provider's IMAP settings (usually on their support site).

Troubleshooting

"Authentication failed"

  • Gmail users: You need an App Password, not your regular password
  • Check your password: Make sure IMAP_PASSWORD is set correctly in .env
  • Enable IMAP: Go to your email settings and enable IMAP access

"HALT file exists"

The tool stopped itself after errors for safety. Check what went wrong:

cat .imap-organizer.HALT

Fix the issue, then remove the halt file:

rm .imap-organizer.HALT

"No folders specified"

Add folders to your config:

[folders]
default = ["INBOX"]

Or specify them on the command line:

imap-organizer file INBOX

Emails aren't being organized

  • Check the year: By default, only current year emails are processed. Use --backfill for all years.
  • Dry-run mode: Did you forget --apply? Without it, nothing gets moved.
  • Check logs: Look at imap_organizer_*.log for details.

Advanced Usage

Use different configs for different accounts

# Work email
imap-organizer file --config .imap-organizer-work.toml --apply

# Personal email
imap-organizer file --config .imap-organizer-personal.toml --apply

Test safely with a sandbox account

Create .imap-organizer-sandbox.toml and .env-sandbox for testing on a throwaway email account before using on your real inbox.

Requirements

  • Python 3.11 or higher
  • IMAP-enabled email account
  • Internet connection

How to Uninstall

Your emails stay organized in folders. To stop using the tool:

pip uninstall imap-organizer

To undo the organization (move emails back to main folder), you'll need to do that manually in your email client.

Privacy & Security

  • Your password is stored locally in .env (never in code or config)
  • Connects directly to your email server (no third-party services)
  • All operations logged to local file
  • Open source - inspect the code yourself

Development

Want to contribute or modify the code? See CLAUDE.md for architecture and development workflow.

Quick test:

uv sync                    # Install dev dependencies
uv run pytest             # Run 63 tests (~2 seconds)

License

CC-BY (Creative Commons Attribution) - use freely for personal or commercial use.

Author: Christian Prior-Mamulyan [email protected]

IMAP_PASSWORD=abcdefghijklmnopqrstuvwyxz
# IMAP Sorter - Credentials
# Copy this to .env and fill in your password
# All other settings (host, user, folders, etc.) go in .imap-sorter.toml
IMAP_PASSWORD=your-app-specific-password-here
# User credentials and config
# Ignore all env and config files by default (safety first)
.env*
.imap-organizer*.toml
# Exception: Allow dev profile (safe to commit - dummy/mock data) and examples
!.env.example
!.env-dev
!.imap-organizer-example.toml
!.imap-organizer-dev.toml
# Runtime logs
*.log
# Circuit breaker halt flag
.imap-organizer.HALT
# Python cache
__pycache__/
.venv/
.pytest_cache/
nul
.claude
.coverage
INSTRUCTIONS*.md
TODO.md
test_imap_organizer_archive.py
[connection]
host = "mock.test.local"
port = 993
user = "[email protected]"
# IMAP Organizer Configuration Template
# Copy this to .imap-organizer.toml and customize
#
# Config/Env Pairing:
# The --config argument automatically pairs config files with .env files
# Pattern: .imap-organizer-{PROFILE}.toml -> .env-{PROFILE}
# Examples:
# --config .imap-organizer-dev.toml -> loads .env-dev
# --config .imap-organizer-sandbox.toml -> loads .env-sandbox
# (no --config) -> loads .imap-organizer.toml + .env
#
# Usage:
# uv run imap-organizer list-folders --config .imap-organizer-dev.toml
[connection]
# IMAP server settings
host = "imap.example.comorg"
port = 993
user = "[email protected]"
# Password: MUST be set in IMAP_PASSWORD environment variable (see .env)
[folders]
# Folders to process by default when running 'imap-organizer'
# Add your frequently-used folders here
default = ["Einkauf", "Finanzen", "Admin", "Versicherungen"]
[behavior]
# Date source for sorting:
# false = INTERNALDATE (server date, faster, recommended)
# true = Date header (email header, slower, may be more accurate)
use_header_date = false
# Circuit breaker: Maximum failures before halting operations
# If this many create_folder or move_message failures occur, a .imap-organizer.HALT
# file is created and all further operations are blocked until manually resolved.
# Default: 3
max_failures = 3
# Notes:
# - By default, only emails from the current year are processed (fast, safe)
# - Use 'imap-organizer file --backfill' to process all historical years
# - Use 'imap-organizer file --year 2024' to process specific year(s)
# - Default is dry-run mode; use 'imap-organizer file --apply' to actually move emails

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

This is a GitHub Gist containing an IMAP email organizer tool that files emails into year-based subfolders (e.g., Inbox/2025). The gist structure enforces a flat file layout with no subdirectories - all files live at the root level.

Single-file module architecture: The entire application is in imap_organizer.py (distributable as a gist, installable via pip).

Core Architecture

Three-Layer Design

  1. Configuration Layer (Config class)

    • Auto-discovers config files: ./.imap-organizer.toml~/.config/imap-organizer/config.toml
    • Profile-based .env pairing: .imap-organizer-dev.toml.env-dev
    • Precedence: CLI args > ENV vars > TOML config > defaults
  2. IMAP Operations Layer (EmailOrganizer class)

    • Connection management with IMAPClient
    • Message operations (fetch, move, year extraction)
    • Folder operations (list, create, ensure existence)
    • Circuit breaker pattern for safety (halt flag at .imap-organizer.HALT)
  3. CLI Layer (Typer framework)

    • Commands: file (organize emails), list-folders (show IMAP folders)
    • Rich console output for progress and formatting
    • Dry-run by default (--apply required for actual moves)

Circuit Breaker Pattern

Critical safety feature that halts operations after failures:

  • Creates .imap-organizer.HALT file when threshold reached
  • Prevents cascading failures and data loss
  • Must be manually removed after investigating failures
  • Configurable threshold via behavior.max_failures in config

Configuration Profiles

Three profiles for different environments:

  • dev: Mock testing (.env-dev + no config needed, auto-loaded by conftest.py)
  • sandbox: Integration testing (.imap-organizer-sandbox.toml + .env-sandbox)
  • prod: Default production (.imap-organizer.toml + .env)

Development Commands

Setup

# Install dependencies (uses uv package manager)
uv sync

# Activate virtual environment (if needed)
source .venv/bin/activate  # Linux/Mac
.venv\Scripts\activate     # Windows

Testing

# Run all tests (63 tests, ~1.8s, 90%+ coverage)
uv run pytest test_imap_organizer.py

# Run with coverage report
uv run pytest test_imap_organizer.py --cov=imap_organizer --cov-report=term-missing

# Run single test by name
uv run pytest test_imap_organizer.py::test_connect__success__calls_login_once -v

# Run tests matching pattern
uv run pytest test_imap_organizer.py -k "circuit" -v

# Quick test run (quiet mode, no coverage)
uv run pytest test_imap_organizer.py -q --no-cov

Coverage requirements: pytest.ini enforces 90% branch coverage minimum.

Running the CLI

# List IMAP folders (dry-run, safe)
uv run imap-organizer list-folders

# File emails into year subfolders (dry-run, current year only)
uv run imap-organizer file

# Actually move emails (requires --apply)
uv run imap-organizer file --apply

# File specific folders
uv run imap-organizer file Inbox "Sent Items"

# Backfill all years (for initial setup)
uv run imap-organizer file --backfill --apply

# Use specific config profile
uv run imap-organizer file --config .imap-organizer-sandbox.toml

Test-Driven Development Workflow

This project uses strict TDD: RED → GREEN → REFACTOR

  1. Write failing test first (proves test works)
  2. Write minimal code to make test pass
  3. Refactor while keeping tests green

Test Organization

Tests are organized in test_imap_organizer.py by functional area:

  • Config & env loading
  • Logging
  • IMAP connection
  • IMAP folder operations
  • IMAP message year extraction
  • IMAP move message (capability matrix for MOVE/UIDPLUS support)
  • IMAP file_by_year (core filing logic)
  • Circuit breaker
  • IMAP close
  • CLI: file command
  • CLI: list-folders command

Test fixtures (in test file):

  • tmp_env: Isolated tmp directory as CWD + clean environment + isolated HALT file
  • mock_imap_client: Controllable fake IMAP client (no network)
  • message_sets: Test data with various date distributions
  • runner: Shared CliRunner for CLI tests

Test naming convention: test_<unit>__<scenario>__<expected>()

Example: test_move__capability_matrix__correct_path tests the move operation's capability matrix to ensure correct code path.

Configuration File Structure

[connection]
host = "imap.mailbox.org"
port = 993
user = "[email protected]"
# password from IMAP_PASSWORD env var

[folders]
default = ["Inbox", "Sent"]

[behavior]
max_failures = 3  # Circuit breaker threshold

Environment variables (in .env file):

IMAP_PASSWORD=your_password_here

Key Design Patterns

Profile-Based Configuration Pairing

Config filename automatically determines .env file:

  • .imap-organizer-dev.toml.env-dev
  • .imap-organizer-sandbox.toml.env-sandbox
  • .imap-organizer.toml.env

IMAP Capability Detection

Code automatically detects and uses:

  • MOVE extension (efficient single-operation move)
  • UIDPLUS extension (safe EXPUNGE without deleting other messages)
  • Fallback: COPY + DELETE + EXPUNGE when extensions unavailable

Folder Caching

file_by_year() caches created folders per-year to avoid redundant CREATE calls when processing multiple messages for the same year.

Common Pitfalls

Don't Break the Gist Structure

  • NO subdirectories - this is a gist (flat file structure)
  • All code stays in imap_organizer.py
  • All tests stay in test_imap_organizer.py
  • Documentation uses filename prefixes for sort order (!README.md, docs_ADR-001.md)

Test Isolation is Critical

  • Always use tmp_env fixture for new tests
  • Never manually delete .imap-organizer.HALT files (they represent application state)
  • Mock datetime for time-dependent tests to avoid year-boundary flakes

Mock Setup Pattern

When testing EmailOrganizer, always setup config mock properly:

config = Mock()
config.get.side_effect = lambda s, k, d=None: d  # Returns defaults

Configuration Precedence

Remember the order: CLI arguments > Environment variables > Config file > Defaults

Architecture Decision Records

See docs_ADR-001.md for Typer CLI and config-driven design decisions. See docs_ADR-002.md for testing strategy and TDD approach.

File Naming Conventions

Because this is a gist (flat structure), filenames use prefixes for organization:

  • !README.md - Readme (! for sort order)
  • docs_ADR-*.md - Architecture Decision Records
  • INSTRUCTIONS*.md - Development methodology docs (gitignored)
  • TODO.md - Project tasks (gitignored)
  • .env-* - Environment files by profile
  • .imap-organizer-*.toml - Config files by profile
"""
pytest configuration for imap-organizer unit tests
This file automatically loads .env-dev for all pytest runs.
The -dev profile provides environment variables (like IMAP_PASSWORD)
that are needed by Config and test fixtures, even though IMAP connections
are mocked and never actually connect to a real server.
"""
from dotenv import load_dotenv
from pathlib import Path
# Load .env-dev for all pytest runs (before any tests execute)
# This ensures environment variables are available for Config tests
load_dotenv(Path(__file__).parent / ".env-dev", override=True)
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
flowchart LR
  %% Activity/Flow – file_by_year

  subgraph Organizer
    S([Start]) --> B{Mode}
    B -->|backfill| YB[Target years: all present]
    B -->|current| YC[Target year: current]
    B -->|specific year| YS[Target year: given]
    YB --> Y
    YC --> Y
    YS --> Y
    Y{For each UID} --> GY["get_message_year(uid)"]
    GY -->|None| SKIP[Skip UID]
    GY -->|Year=y| DF[dest = folder + delim + y]
    DF --> EF["ensure_folder(dest, dry_run)"]
    EF --> DR{Dry-run?}
    DR -->|Yes| LOG[Log planned move]
    DR -->|No| MV["move_message(uid -> dest)"]
    SKIP --> Y
    LOG --> Y
    MV --> Y
    Y -->|done| E([End])
  end

  subgraph "IMAP Client"
    IF1[("select/search/fetch")]:::api
    IF2[("create/subscribe")]:::api
    IF3[("move/copy/delete/expunge")]:::api
  end

  Organizer -->|calls| IF1

  classDef api fill:#eee8d5,stroke:#268bd2,color:#073642
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
graph LR
  %% Component/Container (C4-2/3)

  CLI["Typer CLI (app, commands)"]:::component -->|args| Config
  CLI -->|pair .env| EnvLoader[load_env_for_config]:::component
  EnvLoader -->|".env-<profile>"| Config[Config]:::component

  CLI -->|init| Logger["Logger (setup_logging)"]:::component
  CLI -->|"file / list_folders"| Organizer[EmailOrganizer]:::component
  Organizer -->|"connect()/close()"| IMAPClient["IMAPClient (library)"]:::component

  IMAPClient -->|"list_folders()"| IMAP[("IMAP Server")]:::external
  IMAPClient -->|"fetch()/move()/copy()/delete()/expunge()"| IMAP
  Organizer -->|"ensure_folder()"| IMAPClient
  Organizer -->|"get_message_year()"| IMAPClient
  Organizer -->|"move_message()"| IMAPClient
  Organizer -->|"file_by_year()"| IMAPClient

  %% Test seam
  Tester[["Fake/Mock IMAPClient"]]:::test --> IMAPClient

  %% Legend
  subgraph Legend
    Lc[Component]:::component
    Le[External]:::external
    Lt[Test seam]:::test
  end

  classDef external fill:#fdf6e3,stroke:#657b83,color:#073642;
  classDef component fill:#eee8d5,stroke:#268bd2,color:#073642;
  classDef test fill:#eee8d5,stroke:#859900,color:#073642,stroke-dasharray: 3 3;
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
graph LR
  %% System Context (C4-1)
  %% Purpose: who talks to whom

  User((User)):::external --> CLI[Shell / Typer CLI]:::component

  subgraph "Local FS (CWD)"
    Logger[("Log File")]:::data
    Halt["(.imap-organizer.HALT)"]:::data
    TOML[".imap-organizer*.toml"]:::data
    ENV[".env / .env-<profile>"]:::data
  end

  CLI --> EnvLoader["EnvLoader (load_env_for_config)"]:::component
  EnvLoader --> ENV
  EnvLoader --> TOML
  EnvLoader --> Config[Config]:::component

  CLI --> LoggerSvc["Logger (setup_logging)"]:::component
  LoggerSvc --> Logger
  CLI --> Organizer[EmailOrganizer]:::component
  Organizer -->|TLS 993| IMAP[("IMAP Server")]:::external

  Organizer --> Halt
  LoggerSvc --> Logger

  %% Legend
  subgraph Legend
    L1[External]:::external
    L2[Component]:::component
    L3[Data/Artifact]:::data
  end

  classDef external fill:#fdf6e3,stroke:#657b83,color:#073642;
  classDef component fill:#eee8d5,stroke:#268bd2,color:#073642;
  classDef data fill:#fdf6e3,stroke:#b58900,color:#073642;
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
flowchart LR
  %% Data Flow – Config & Env Precedence

  ENV[.env / .env-<profile>]:::data --> P1[Pair via get_env_file_from_config]:::action
  TOML[.imap-organizer*.toml]:::data --> M[Merge precedence]:::action
  DEF[Defaults]:::data --> M
  P1 --> M
  M --> CAST[Type/format handling]:::action
  CAST --> SPLIT["List split (space-separated -> list)"]:::action
  SPLIT --> CFG["Config.get()/get_list()"]:::component

  %% Legend
  subgraph Legend
    Ld[Data]:::data
    La[Action]:::action
    Lc[Component]:::component
  end

  classDef data fill:#fdf6e3,stroke:#b58900,color:#073642
  classDef action fill:#eee8d5,stroke:#859900,color:#073642
  classDef component fill:#eee8d5,stroke:#268bd2,color:#073642
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
flowchart TD
  %% Decision Tree – MOVE/COPY/EXPUNGE

  R[Start] --> C1{Server supports MOVE?}
  C1 -->|Yes| M1[Use UID MOVE]
  C1 -->|No| C2{Supports UIDPLUS?}
  C2 -->|Yes| P1[UID COPY → UID STORE +FLAGS \\Deleted → UID EXPUNGE]
  C2 -->|No| P2["UID COPY → STORE +FLAGS \\Deleted → EXPUNGE (mailbox-wide)"]

  %% Risks
  subgraph Risks
    R1[No UIDPLUS: EXPUNGE may remove other deleted messages]:::risk
    R2[Cross-namespace MOVE/COPY can fail; handle errors]:::risk
    R3[Create/subscribe races]:::risk
  end

  classDef risk fill:#fdf6e3,stroke:#dc322f,color:#dc322f
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
flowchart TD
  %% Error Handling / Observability Map

  START([CLI entry]) --> TRY{Execute command}
  TRY -->|ok| OK[Exit 0]
  TRY -->|ValueError| VERR[Config error -> exit 1]
  TRY -->|Exception| CATCH[Catch in CLI -> exit 1]
  CATCH --> MAP[User-friendly console message]
  CATCH --> LOGE["logger.error(traceback)"]:::log
  TRY --> WARN[Non-critical error?]:::cond
  WARN -->|yes| LOGW[logger.warning]:::log
  WARN -->|no| CONT[Continue]:::action

  subgraph "Verbose Flag"
    V1[--verbose] --> Lvl[Set level=DEBUG]
    V2[default] --> Lvl2[Set level=INFO]
  end

  Lvl --> LOGGER[Logger sinks: console + file]:::log
  Lvl2 --> LOGGER
  LOGE --> LOGGER
  LOGW --> LOGGER

  %% DRY-RUN vs APPLY
  DR[Mode]:::cond -->|dry-run| P[Plan only, no side effects]:::action
  DR -->|apply| SIDE[Perform move/copy/delete/expunge]:::action

  classDef cond fill:#fdf6e3,stroke:#268bd2,color:#073642,stroke-dasharray: 3 3
  classDef action fill:#eee8d5,stroke:#859900,color:#073642
  classDef log fill:#fdf6e3,stroke:#d33682,color:#d33682
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
graph LR
  %% Runtime/Deployment

  subgraph "Host (Single Process)"
    CLI[Typer CLI]:::component
    ORG[EmailOrganizer]:::component
    LOG[(log file)]:::data
    HALT[(.imap-organizer.HALT)]:::data
    CFG[(.imap-organizer*.toml, .env-<profile>)]:::data
  end

  CLI --> ORG
  CLI --> LOG
  ORG --> LOG
  ORG --> HALT
  CLI --> CFG

  ORG ---|TLS 1.2+ :993| IMAP[(IMAP Server)]:::external

  classDef component fill:#eee8d5,stroke:#268bd2,color:#073642
  classDef data fill:#fdf6e3,stroke:#b58900,color:#073642
  classDef external fill:#fdf6e3,stroke:#657b83,color:#073642
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
sequenceDiagram
  %% Sequence – CLI `file` (happy path)
  participant U as User
  participant C as CLI (Typer)
  participant EL as EnvLoader
  participant G as Config
  participant L as Logger
  participant O as EmailOrganizer
  participant I as IMAPClient

  U->>C: imap-organizer file [--apply|--backfill|--year|--use-header-date]
  Note over C: Mode: DRY-RUN (default) vs APPLY
  C->>EL: load_env_for_config(--config)
  EL-->>G: env loaded (paired .env)
  C->>L: setup_logging(verbose)
  C->>O: new EmailOrganizer(Config, Logger)
  O->>I: connect()
  I-->>O: connected

  loop for each folder (args/config)
    alt --year specified (multiple)
      Note over C,O: CLI loops per year
    end

    O->>I: select_folder(folder)
    O->>I: search ALL -> uids
    loop for each uid
      O->>I: fetch INTERNALDATE or ENVELOPE
      O-->>O: get_message_year(uid)
      alt year == None
        O-->>L: warn: skip UID
      else year determined
        O-->>O: dest = folder + delim + year
        O->>O: ensure_folder(dest, dry_run)
        alt dry-run
          O-->>L: log planned move
        else apply
          O->>I: move or copy+delete+expunge
        end
      end
    end
  end

  O->>I: close()
  C-->>U: summary (total, filed, dry-run note)
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
sequenceDiagram
  %% Sequence – CLI `list_folders`
  participant U as User
  participant C as CLI (Typer)
  participant EL as EnvLoader
  participant G as Config
  participant L as Logger
  participant O as EmailOrganizer
  participant I as IMAPClient
  participant R as Rich

  U->>C: imap-organizer list-folders
  C->>EL: load_env_for_config()
  EL-->>G: env loaded
  C->>L: setup_logging(verbose)
  C->>O: new EmailOrganizer(Config, Logger)
  O->>I: connect()
  O->>I: list_folders()
  I-->>O: [(flags, delim, name)...]
  O-->>C: folders[]
  C->>R: render table (Folder Name, Flags)
  R-->>U: printed folders + count
  O->>I: close()
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
stateDiagram-v2
  %% State Machine – Circuit/Halt

  [*] --> Normal: "start / read HALT?"
  Normal --> Halted: "critical failure (move_message)"
  Normal --> Normal: "non-critical fail [count < N]"
  Normal --> Halted: "non-critical fail [count == N]"
  Halted --> Halted: "next runs until HALT cleared"

  state Normal {
    [*] --> Running
    Running: "entry / ensure HALT absent"
    Running --> Running: "ops succeed / reset failure_count"
    Running --> [*]: "graceful exit"
  }

  state Halted {
    [*] --> Trip
    Trip: "entry / write .imap-organizer.HALT"
    Trip --> [*]
  }
Loading
%%{init: {'theme':'base', 'themeVariables': {'background':'#fdf6e3','primaryColor':'#eee8d5','primaryBorderColor':'#268bd2','primaryTextColor':'#073642','lineColor':'#268bd2','secondaryColor':'#fdf6e3','tertiaryColor':'#eee8d5'}}}%%
flowchart LR
  %% Test Architecture (optional)

  subgraph Pytest
    FX[Fixtures]:::comp --> TMP[temp CWD]:::data
    FX --> FIMAP[Fake/Mock IMAPClient]:::comp
    FX --> ENVF[sample .env/.toml]:::data
    RUN[CliRunner]:::comp --> CLI[Typer CLI]:::comp
  end

  CLI --> ORG[EmailOrganizer]:::comp
  ORG --> FIMAP

  subgraph Coverage
    C1[MOVE path]:::cov
    C2[UIDPLUS fallback path]:::cov
    C3[Current year vs backfill]:::cov
    C4[Errors & HALT behavior]:::cov
  end

  Tests[[test_*]]:::comp --> C1
  Tests --> C2
  Tests --> C3
  Tests --> C4

  classDef comp fill:#eee8d5,stroke:#268bd2,color:#073642
  classDef data fill:#fdf6e3,stroke:#b58900,color:#073642
  classDef cov fill:#eee8d5,stroke:#859900,color:#073642
Loading

ADR-001: Typer-based CLI with Config-Driven Repeated Use

Note: Originally written for imap-organizer, renamed to imap-organizer with "file" command terminology.

Status

Proposed (2025-10-08)

Context

The IMAP email organizer tool needs a clean, maintainable CLI that supports:

  1. Repeated bi-weekly usage pattern - Users run this every ~2 weeks to maintain email organization
  2. Annual filing strategy - Emails filed into Folder/YYYY subfolders
  3. Safe defaults - Prevent accidental bulk operations
  4. Current year focus - Most runs only process current year emails (fast)
  5. Backfill capability - Initial setup or year transitions need historical processing
  6. Configuration persistence - Avoid repeating connection details and folder lists

The original implementation used argparse with many flags, requiring users to remember and type long commands for routine operations.

Decision

CLI Framework: Typer

Use Typer for the CLI implementation with these characteristics:

Command Structure:

imap-organizer [COMMAND] [OPTIONS] [FOLDERS...]

Commands:

  • file: File emails into annual subfolders (current year, dry-run by default)
  • file --apply: Execute the filing operation
  • list: List all IMAP folders
  • config init: Create configuration file interactively
  • config show: Display current configuration

Configuration File:

  • Format: TOML (.imap-organizer.toml in working directory or ~/.config/imap-organizer/config.toml)
  • Contains: connection settings, default folder list, behavior preferences
  • Git-friendly (credentials from env vars, not in file)

Default Behavior:

[connection]
host = "imap.mailbox.org"
port = 993
user = "[email protected]"
# password from IMAP_PASSWORD env or interactive prompt

[folders]
default = ["Einkauf", "Finanzen", "Admin"]

[behavior]
dry_run = true              # Safe by default
current_year_only = true    # Fast repeated runs

Typical Workflow:

# First time setup
$ imap-organizer config init
# Creates .imap-organizer.toml interactively

# Regular bi-weekly use
$ imap-organizer file
# Scans default folders (Einkauf, Finanzen, Admin)
# Shows what would be filed (dry-run)
# Only processes current year (2025)

# Apply changes
$ imap-organizer file --apply

# Year transition or initial setup
$ imap-organizer file --backfill
# Processes ALL years for backfilling

# File specific folders
$ imap-organizer file Einkauf Finanzen
# File only specified folders

# File specific year
$ imap-organizer file --year 2024

Configuration Precedence:

CLI arguments > Environment variables > Config file > Interactive prompts

Output: Rich Console

Use Rich library for:

  • Progress bars during processing
  • Formatted tables for folder listings
  • Color-coded status messages
  • Clear dry-run vs. actual operation indicators

Example output:

📁 Configuration: .imap-organizer.toml
🔍 Folders: Einkauf, Finanzen, Admin
📅 Year: 2025 only

Scanning...
├─ Einkauf: 45 messages to file
├─ Finanzen: 23 messages to file
└─ Admin: 12 messages to file

📊 Total: 80 messages → 3 folders

🔍 DRY RUN - No changes made
💡 Run 'imap-organizer file --apply' to move messages

Consequences

Positive

  1. Ergonomic for repeated use - Zero-config runs after setup: just imap-organizer
  2. Safe by default - Dry-run prevents accidents, explicit --apply required
  3. Fast default operation - Current year only means quick scans
  4. Clear mental model - Config file visible, easy to understand and modify
  5. Type safety - Typer provides automatic validation and help text
  6. Better UX - Rich output makes status clear, progress visible
  7. Git-friendly - Config file can be versioned (without credentials)
  8. Extensible - Easy to add new commands/options as needs evolve

Negative

  1. New dependencies - Adds typer and rich to requirements
  2. Breaking change - Existing command-line usage must change
  3. Config file management - Users must understand config precedence
  4. More complex codebase - Config parsing, multiple code paths
  5. TOML learning curve - Some users unfamiliar with TOML format

Mitigation Strategies

  • Provide migration guide from old CLI to new
  • config init wizard makes TOML editing optional
  • Environment variables still work for CI/automation
  • Comprehensive examples in README
  • Keep old script as sort_by_year.py for emergency fallback

Alternatives Considered

Alternative 1: Keep argparse with long flags

python sort_by_year.py --folders Einkauf Finanzen --dry-run --start-year 2025

Rejected because: Tedious for repeated use, error-prone

Alternative 2: Shell script wrapper

#!/bin/bash
python sort_by_year.py --folders Einkauf Finanzen --dry-run --start-year $(date +%Y)

Rejected because: Doesn't solve config management, platform-specific

Alternative 3: Click framework

Rejected because: Typer provides better type hints and modern Python conventions

Implementation Notes

Phase 1: Core Typer CLI

  • Implement basic imap-organizer command
  • Config file loading (TOML)
  • list and file commands
  • Rich output for progress

Phase 2: Config Management

  • config init interactive wizard
  • config show command
  • Config validation

Phase 3: Advanced Features

  • --backfill mode
  • +folder syntax for additions
  • --only override
  • --year specific year selection

References

ADR-002: Testing Strategy - TDD with Mocks

Status

Accepted (2025-10-08)

Context

Testing IMAP email operations is dangerous (data loss risk), slow (network), and unreliable (changing state). Need fast, safe, repeatable tests within gist's flat file structure.

Decision

Test-Driven Development (TDD)

All features implemented using RED → GREEN → REFACTOR cycle:

  1. RED: Write failing test first (proves test works)
  2. GREEN: Write minimal code to make test pass
  3. REFACTOR: Clean up while keeping tests green

Example from circuit breaker implementation:

# RED: Test written first, fails (no max_failures attribute)
def test_circuit_breaker_default_threshold():
    assert organizer.max_failures == 3  # ❌ Fails

# GREEN: Implement to pass
class EmailOrganizer:
    def __init__(self, config, logger):
        self.max_failures = 3  # ✅ Passes

# REFACTOR: Make configurable
self.max_failures = int(config.get("behavior", "max_failures", 3))

Three-Tier Testing

Tier 1: Unit Tests (56 tests, all mocked)

  • File: test_imap_organizer.py
  • Mock IMAPClient using unittest.mock
  • Fast (<1s), safe, no network
  • Primary development feedback

Tier 2: Integration Tests (manual, sandbox profile)

  • Config: .imap-organizer-sandbox.toml
  • Real server: mail.wildduck.email
  • Validates actual IMAP behavior
  • Run before releases

Tier 3: Production (default profile)

  • Config: .imap-organizer.toml
  • Real mailbox: mailbox.org
  • User's actual data

Test Coverage (56 tests)

Categories:

  • Config/env pairing (7 tests)
  • Connection management (4 tests)
  • Folder operations (6 tests)
  • Message operations (4 tests)
  • Circuit breaker (8 tests)
  • Safe EXPUNGE (4 tests)
  • file_by_year logic (9 tests)
  • CLI commands (3 tests)

Running tests:

make test              # All tests
make test-verbose      # With details
make sandbox-list      # Integration test (real IMAP)

Profile structure:

  • dev → mocked (conftest.py auto-loads .env-dev)
  • sandbox → wildduck.email (integration tests)
  • prod → mailbox.org (default, no --config)

Test Isolation Philosophy

Halt flag is program state, not a test artifact:

  • The .imap-organizer.HALT file represents application state (circuit breaker triggered)
  • Tests must NOT blindly clean up halt flags
  • Tests should either:
    1. Isolate to tmp_path (new tests, preferred)
    2. Assert on halt flag presence/absence (old tests)

When to use tmp_path isolation:

def test_something(mock_config, mock_logger, mock_imap_client, tmp_path, monkeypatch):
    # Isolate halt flag to tmp directory
    halt_file = tmp_path / ".imap-organizer.HALT"
    monkeypatch.setattr("imap_organizer.EmailOrganizer.HALT_FLAG_FILE", halt_file)

    # Test now isolated from real filesystem and other tests
    organizer = EmailOrganizer(mock_config, mock_logger)
    # ... test logic ...

Benefits:

  • No state pollution between tests
  • Parallel test execution safe
  • No accidental filesystem modifications

Current status:

  • Circuit breaker tests (8 tests): ✓ Fully isolated with tmp_path
  • Safe EXPUNGE tests (4 tests): ✓ Fully isolated with tmp_path
  • Other tests (44 tests): ⚠️ Not isolated (13 fail when halt flag exists)

Consequences

Pros:

  • Fast (<1s), safe (mocks), no infrastructure
  • TDD enforces design, prevents regressions
  • Gist-compatible (flat structure)

Cons:

  • Mock drift risk (mitigated by sandbox testing)
  • Manual testing still needed before releases
  • Single file grows (56 tests currently)

Alternatives Rejected

  • Docker IMAP server: Too complex for gist users
  • VCR.py recordings: IMAP poorly supported, recordings go stale
  • No tests: Too risky for email-moving tool

References

#!/usr/bin/env python3
"""
IMAP Email Organizer - Organize emails by filing into annual subfolders
Distributed as GitHub Gist (single-file constraint)
See INSTRUCTIONS.md for development guide
"""
# ============================================================================
# IMPORTS
# ============================================================================
# Standard library
import os
import sys
import logging
import datetime
from pathlib import Path
from typing import Optional
# Third-party
import typer
from rich.console import Console
from rich.table import Table
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
from rich.panel import Panel
from dotenv import load_dotenv
from imapclient import IMAPClient
# TOML parser (Python 3.11+)
try:
import tomllib # Python 3.11+
except ImportError:
import tomli as tomllib # Fallback for Python <3.11
# ============================================================================
# CONFIGURATION
# ============================================================================
def get_env_file_from_config(config_path: Path) -> Path:
"""
Auto-detect .env file based on config filename using convention-based pairing.
Examples:
.imap-organizer-dev.toml -> .env-dev
.imap-organizer-test.toml -> .env-test
.imap-organizer.toml -> .env
Args:
config_path: Path to config file
Returns:
Path to corresponding .env file
"""
import re
filename = config_path.name
# Match: .imap-organizer-{PROFILE}.toml
match = re.match(r'\.imap-organizer-(.+)\.toml', filename)
if match:
profile = match.group(1)
return config_path.parent / f".env-{profile}"
# Default: .env (for .imap-organizer.toml or other names)
return config_path.parent / ".env"
class Config:
"""
Configuration management with precedence: ENV > TOML > Defaults
Searches for config file in:
1. Explicit path (if provided via --config)
2. ./.imap-organizer.toml (project-specific)
3. ~/.config/imap-organizer/config.toml (user-global)
"""
def __init__(self, config_file: Optional[Path] = None):
"""
Initialize config.
Args:
config_file: Optional explicit path to config file (from --config argument)
"""
if config_file:
# Explicit path provided
if not config_file.exists():
raise RuntimeError(f"Config file not found: {config_file}")
self.config_file = config_file
else:
# Search for config file
self.config_file = self._find_config_file()
self.config_data = self._load_toml() if self.config_file else {}
def _find_config_file(self) -> Optional[Path]:
"""Search for config in current directory, then user config dir"""
candidates = [
Path.cwd() / ".imap-organizer.toml",
Path.home() / ".config" / "imap-organizer" / "config.toml"
]
for path in candidates:
if path.exists():
return path
return None
def _load_toml(self) -> dict:
"""Load TOML config file"""
try:
with open(self.config_file, "rb") as f:
return tomllib.load(f)
except Exception as e:
# If config file exists but can't be loaded, this is an error
raise RuntimeError(f"Failed to load config {self.config_file}: {e}")
def get(self, section: str, key: str, default=None):
"""
Get config value with precedence: ENV > TOML > default
Example: get("connection", "host") checks IMAP_HOST env var first
"""
# Try environment variable (IMAP_HOST, IMAP_PORT, etc.)
env_key = f"IMAP_{key.upper()}"
env_val = os.getenv(env_key)
if env_val is not None:
return env_val
# Try TOML config
if section in self.config_data:
if key in self.config_data[section]:
return self.config_data[section][key]
return default
def get_list(self, section: str, key: str, default: list = None) -> list:
"""Get list value from config"""
val = self.get(section, key, default or [])
if isinstance(val, str):
# Handle space-separated string (from env var)
return val.split()
return val
# ============================================================================
# LOGGING
# ============================================================================
def setup_logging(verbose: bool = False) -> logging.Logger:
"""
Configure logging:
- Console: INFO level (or DEBUG if verbose), clean output
- File: Always DEBUG level, detailed logs
- Suppress: imapclient library debug spam from console
"""
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
log_file = Path.cwd() / f"imap_organizer_{timestamp}.log"
# Root logger at WARNING to suppress library logs
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(log_file, encoding="utf-8"),
logging.StreamHandler()
],
)
# Our logger: configurable level
logger = logging.getLogger("imap_organizer")
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
logger.info(f"Log file: {log_file}")
return logger
# ============================================================================
# IMAP OPERATIONS
# ============================================================================
class EmailOrganizer:
"""
Handles IMAP connection and email filing operations with circuit breaker
"""
HALT_FLAG_FILE = Path.cwd() / ".imap-organizer.HALT"
def __init__(self, config: Config, logger: logging.Logger):
self.config = config
self.logger = logger
self.client: Optional[IMAPClient] = None
self.failure_count = 0
self.max_failures = int(self.config.get("behavior", "max_failures", 3) or 3)
def _check_halt_flag(self):
"""Check if halt flag exists and raise error if present"""
if self.HALT_FLAG_FILE.exists():
msg = f"HALTED: {self.HALT_FLAG_FILE.read_text()}"
self.logger.error(msg)
raise RuntimeError(msg)
def _record_failure(self, operation: str, error: Exception, critical: bool = False):
"""Record failure and create halt flag if threshold reached
Args:
operation: Name of the operation that failed
error: The exception that occurred
critical: If True, halt immediately (for move operations)
"""
self.failure_count += 1
if critical:
# Critical operations (move) halt immediately
msg = f"HALTED after critical failure in {operation}: {error}"
self.HALT_FLAG_FILE.write_text(msg)
self.logger.critical(f"Critical failure - created halt flag: {self.HALT_FLAG_FILE}")
raise RuntimeError(f"HALTED: {msg}")
else:
# Non-critical operations count towards threshold
self.logger.error(f"Failure {self.failure_count}/{self.max_failures} in {operation}: {error}")
if self.failure_count >= self.max_failures:
msg = f"HALTED after {self.failure_count} failures. Last error: {error}"
self.HALT_FLAG_FILE.write_text(msg)
self.logger.critical(f"Created halt flag: {self.HALT_FLAG_FILE}")
raise RuntimeError(f"HALTED: {msg}")
def connect(self) -> IMAPClient:
"""Establish IMAP connection"""
host = self.config.get("connection", "host")
port = self.config.get("connection", "port", 993)
user = self.config.get("connection", "user")
password = os.getenv("IMAP_PASSWORD")
if not host:
raise ValueError("Missing IMAP host. Set in .imap-organizer.toml or IMAP_HOST env var")
if not user:
raise ValueError("Missing IMAP user. Set in .imap-organizer.toml or IMAP_USER env var")
if not password:
raise ValueError("Missing IMAP password. Set IMAP_PASSWORD environment variable")
self.logger.info(f"Connecting to {host}:{port} as {user}...")
self.client = IMAPClient(host, port=port, ssl=True)
self.client.login(user, password)
self.logger.info(f"✓ Connected to {host}:{port}")
return self.client
def list_folders(self) -> list:
"""List all IMAP folders"""
if not self.client:
self.connect()
folders = self.client.list_folders()
return [(flags, delimiter, name) for flags, delimiter, name in folders]
def get_folder_delimiter(self) -> str:
"""Detect folder hierarchy delimiter"""
if not self.client:
self.connect()
folders = self.client.list_folders()
if folders and len(folders[0]) >= 2:
delimiter = folders[0][1] # Delimiter is second element
# Decode bytes to string if necessary
if isinstance(delimiter, bytes):
return delimiter.decode('utf-8')
return delimiter
return "/"
def folder_exists(self, folder: str) -> bool:
"""Check if folder exists"""
try:
self.client.folder_status(folder, ['UIDNEXT'])
return True
except Exception:
return False
def ensure_folder(self, folder: str, dry_run: bool = False):
"""Create folder if it doesn't exist"""
self._check_halt_flag()
if not self.folder_exists(folder):
self.logger.info(f"[CREATE] {folder}")
if not dry_run:
try:
self.client.create_folder(folder)
self.client.subscribe_folder(folder)
except Exception as e:
self.logger.debug(f"Create folder error: {e}")
self._record_failure("ensure_folder", e)
def get_message_year(self, uid: int, use_header_date: bool = False) -> Optional[int]:
"""Extract year from message date"""
try:
if use_header_date:
# Fetch Date header from ENVELOPE
data = self.client.fetch([uid], ['ENVELOPE'])
if uid in data and data[uid][b'ENVELOPE'].date:
return data[uid][b'ENVELOPE'].date.year
else:
# Use INTERNALDATE (faster)
data = self.client.fetch([uid], ['INTERNALDATE'])
if uid in data and data[uid][b'INTERNALDATE']:
return data[uid][b'INTERNALDATE'].year
except Exception as e:
self.logger.debug(f"Error getting year for UID {uid}: {e}")
return None
def move_message(self, uid: int, dest_folder: str, dry_run: bool = False):
"""Move message to destination folder"""
self._check_halt_flag()
self.logger.info(f"[MOVE] UID {uid} -> {dest_folder}")
if dry_run:
return
# Validate destination folder exists (network untrusted!)
if not self.folder_exists(dest_folder):
error_msg = f"Destination folder does not exist: {dest_folder}"
self.logger.error(error_msg)
self._record_failure("move_message", Exception(error_msg), critical=True)
return # Unreachable but explicit
try:
if b'MOVE' in self.client.capabilities():
# Best: Use native MOVE extension (atomic operation)
self.client.move([uid], dest_folder)
else:
# Fallback: COPY + DELETE + EXPUNGE
self.client.copy([uid], dest_folder)
self.client.delete_messages([uid])
# Check if UIDPLUS is available for safe UID EXPUNGE
if b'UIDPLUS' in self.client.capabilities():
# Safe: Expunge only this specific message
self.client.expunge([uid])
else:
# Unsafe: Expunges ALL deleted messages in folder!
self.logger.warning(
f"Server lacks UIDPLUS - expunging ALL deleted messages in folder "
f"(not just UID {uid}). Consider upgrading IMAP server."
)
self.client.expunge()
except Exception as e:
self.logger.error(f"Failed to move UID {uid}: {e}")
self._record_failure("move_message", e, critical=True)
def file_by_year(
self,
folder: str,
year: Optional[int] = None,
backfill: bool = False,
use_header_date: bool = False,
dry_run: bool = True
) -> tuple[int, int]:
"""
File messages in folder by year into annual subfolders
Args:
folder: Folder name to process
year: Specific year to file (None = current year or backfill)
backfill: If True, file all years (overrides year parameter)
use_header_date: Use Date header instead of INTERNALDATE
dry_run: Preview mode (don't actually move)
Returns:
(total_messages, filed_messages)
"""
from datetime import datetime
# Determine target year(s)
if backfill:
target_years = None # All years
elif year is None:
target_years = [datetime.now().year] # Current year only
else:
target_years = [year] # Specific year
# Select folder
self.logger.info(f"[FOLDER] {folder}: Scanning messages...")
self.client.select_folder(folder)
# Search for all messages
uids = self.client.search(['ALL'])
total = len(uids)
self.logger.info(f"[FOLDER] {folder}: Found {total} messages")
if total == 0:
return (0, 0)
# Get delimiter for subfolder naming
delimiter = self.get_folder_delimiter()
# Process each message
filed = 0
year_folders = {} # Cache: {year: folder_name}
for uid in uids:
# Get year
msg_year = self.get_message_year(uid, use_header_date=use_header_date)
if msg_year is None:
self.logger.warning(f"UID {uid}: Could not determine year, skipping")
continue
# Check if year matches filter
if target_years is not None and msg_year not in target_years:
self.logger.debug(f"UID {uid}: year {msg_year} not in {target_years}, skipping")
continue
# Determine destination folder
dest_folder = f"{folder}{delimiter}{msg_year}"
# Ensure destination folder exists (create if needed)
if msg_year not in year_folders:
self.ensure_folder(dest_folder, dry_run=dry_run)
year_folders[msg_year] = dest_folder
# Move message
self.move_message(uid, dest_folder, dry_run=dry_run)
filed += 1
self.logger.info(f"[FOLDER] {folder}: Filed {filed}/{total} messages")
return (total, filed)
def close(self):
"""Close IMAP connection"""
if self.client:
try:
self.client.logout()
self.logger.debug("IMAP connection closed")
except Exception as e:
self.logger.debug(f"Error closing connection: {e}")
# ============================================================================
# TYPER CLI
# ============================================================================
app = typer.Typer(
name="imap-organizer",
help="IMAP Email Organizer - File emails into annual subfolders",
add_completion=False
)
console = Console()
@app.command()
def file(
folders: Optional[list[str]] = typer.Argument(
None,
help="Folders to file (default: from config)"
),
config_file: Optional[str] = typer.Option(
None,
"--config",
help="Path to config file (auto-pairs with .env file)"
),
apply: bool = typer.Option(
False,
"--apply",
help="Actually move emails (default: dry-run)"
),
backfill: bool = typer.Option(
False,
"--backfill",
help="Process all years (default: current year only)"
),
year: Optional[list[int]] = typer.Option(
None,
"--year",
help="Process specific year(s)"
),
use_header_date: bool = typer.Option(
False,
"--use-header-date",
help="Use Date header instead of INTERNALDATE"
),
verbose: bool = typer.Option(
False,
"--verbose", "-v",
help="Verbose output"
),
):
"""File emails into annual subfolders (default: current year, dry-run)"""
try:
# Parse config file path
config_path = Path(config_file) if config_file else None
# Load environment variables from paired .env file
load_env_for_config(config_path)
# Load config and setup logging
config = Config(config_file=config_path)
logger = setup_logging(verbose=verbose)
# Determine dry-run mode
dry_run = not apply
mode_str = "[yellow]DRY-RUN[/yellow]" if dry_run else "[red]APPLY[/red]"
console.print(f"Mode: {mode_str}")
# Determine folders to process
if folders:
target_folders = folders
else:
target_folders = config.get_list("folders", "default")
if not target_folders:
console.print("[red]Error:[/red] No folders specified and no default folders in config", stderr=True)
raise typer.Exit(code=1)
logger.info(f"Target folders: {target_folders}")
# Connect to IMAP
organizer = EmailOrganizer(config, logger)
organizer.connect()
# Process each folder
total_messages = 0
total_filed = 0
for folder in target_folders:
# Determine year(s) to process
if year:
# Process each specified year
for y in year:
total, filed = organizer.file_by_year(
folder=folder,
year=y,
backfill=False,
use_header_date=use_header_date,
dry_run=dry_run
)
total_messages += total
total_filed += filed
else:
# Single call (current year or backfill)
total, filed = organizer.file_by_year(
folder=folder,
year=None,
backfill=backfill,
use_header_date=use_header_date,
dry_run=dry_run
)
total_messages += total
total_filed += filed
# Display summary
console.print(f"\n[bold green]Summary:[/bold green]")
console.print(f" Total messages: {total_messages}")
console.print(f" Filed messages: {total_filed}")
if dry_run:
console.print(f"\n[yellow]ℹ This was a dry-run. Use --apply to actually move emails.[/yellow]")
# Clean up
organizer.close()
except ValueError as e:
console.print(f"[red]Configuration error:[/red] {e}", stderr=True)
raise typer.Exit(code=1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}", stderr=True)
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(code=1)
@app.command()
def list_folders(
config_file: Optional[str] = typer.Option(
None,
"--config",
help="Path to config file (auto-pairs with .env file)"
),
verbose: bool = typer.Option(
False,
"--verbose", "-v",
help="Verbose output"
),
):
"""List all IMAP folders"""
try:
# Parse config file path
config_path = Path(config_file) if config_file else None
# Load environment variables from paired .env file
load_env_for_config(config_path)
# Load config and setup logging
config = Config(config_file=config_path)
logger = setup_logging(verbose=verbose)
# Connect to IMAP and get folders
organizer = EmailOrganizer(config, logger)
organizer.connect()
folders = organizer.list_folders()
# Display folders in Rich table
table = Table(title="IMAP Folders", show_header=True, header_style="bold cyan")
table.add_column("Folder Name", style="green")
table.add_column("Flags", style="dim")
for flags, delimiter, name in folders:
flag_str = ", ".join(f.decode() if isinstance(f, bytes) else str(f) for f in flags)
table.add_row(name, flag_str)
console.print(table)
console.print(f"\n[dim]Total folders: {len(folders)}[/dim]")
# Clean up
organizer.close()
except ValueError as e:
console.print(f"[red]Configuration error:[/red] {e}", stderr=True)
raise typer.Exit(code=1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}", stderr=True)
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(code=1)
@app.command()
def config(
init: bool = typer.Option(
False,
"--init",
help="Create config file interactively"
),
show: bool = typer.Option(
False,
"--show",
help="Show current configuration"
),
):
"""Manage configuration"""
# TODO: Implement config command
# --init: Interactive wizard to create .imap-organizer.toml
# --show: Display current config (with sources)
console.print("[yellow]TODO: Implement config command[/yellow]")
# ============================================================================
# ENTRY POINT
# ============================================================================
def load_env_for_config(config_file: Optional[Path] = None):
"""
Load .env file based on config file path.
If config_file is provided, automatically pairs it with corresponding .env file:
- .imap-organizer-dev.toml -> .env-dev
- .imap-organizer.toml -> .env
Args:
config_file: Optional config file path
"""
if config_file:
env_file = get_env_file_from_config(config_file)
if env_file.exists():
load_dotenv(env_file, override=True)
else:
# Fall back to default .env in same directory
fallback_env = config_file.parent / ".env"
if fallback_env.exists():
load_dotenv(fallback_env, override=True)
else:
# No config specified, load default .env from current directory
default_env = Path.cwd() / ".env"
if default_env.exists():
load_dotenv(default_env, override=True)
def cli_main():
"""CLI entry point - loads .env then runs Typer app"""
# Note: .env loading is now handled per-command to support --config
# Each command will call load_env_for_config() before creating Config
app()
if __name__ == "__main__":
cli_main()
.PHONY: clean test test-verbose test-sandbox install dev help lint
.PHONY: dev-list sandbox-list prod-list list-folders
.PHONY: dev-file sandbox-file prod-file file
help:
@echo TESTING
@echo test - Run unit tests (dev profile, mocked)
@echo test-verbose - Run tests with verbose output
@echo test-sandbox - Integration test against wildduck.email
@echo LIST FOLDERS
@echo list-folders - List folders (dev profile, mocked) [SHORT/SAFE]
@echo dev-list - List folders (dev profile, mocked)
@echo sandbox-list - List folders (wildduck.email)
@echo prod-list - List folders (mailbox.org) [PRODUCTION]
@echo FILE EMAILS
@echo file - File emails (dev profile, mocked) [SHORT/SAFE]
@echo dev-file - File emails (dev profile, mocked)
@echo sandbox-file - File emails dry-run (wildduck.email)
@echo prod-file - File emails dry-run (mailbox.org) [PRODUCTION]
@echo UTILITIES
@echo clean - Remove log files and Python cache
@echo install - Install dependencies with uv
@echo dev - Install with dev dependencies (includes pytest)
@echo lint - Run linters
clean:
@echo Cleaning log files and cache...
-@if exist *.log del /Q *.log 2>nul
-@if exist __pycache__ rmdir /S /Q __pycache__ 2>nul
-@if exist .pytest_cache rmdir /S /Q .pytest_cache 2>nul
@echo Clean complete.
install:
uv sync
dev:
uv sync --extra dev
test:
uv run pytest test_imap_organizer.py
test-verbose:
uv run pytest test_imap_organizer.py -v
test-sandbox:
uv run imap-organizer list-folders --config .imap-organizer-sandbox.toml
lint:
uv run ruff check .
# List folders targets
dev-list:
uv run imap-organizer list-folders --config .imap-organizer-dev.toml
sandbox-list:
uv run imap-organizer list-folders --config .imap-organizer-sandbox.toml
prod-list:
uv run imap-organizer list-folders
list-folders: dev-list
# File emails targets
dev-file:
uv run imap-organizer file --config .imap-organizer-dev.toml
sandbox-file:
uv run imap-organizer file --config .imap-organizer-sandbox.toml
prod-file:
uv run imap-organizer file
file: dev-file
[project]
name = "imap-organizer"
version = "0.0.1"
description = "Organize IMAP mail by filing into year-based subfolders"
requires-python = ">=3.11"
dependencies = [
"python-dotenv>=1.0",
"imapclient>=3.0",
"typer>=0.9.0",
"rich>=13.0.0",
"tomli>=2.0.0; python_version < '3.11'",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-mock>=3.0",
"pytest-cov>=4.0",
]
[project.scripts]
imap-organizer = "imap_organizer:cli_main"
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[tool.uv]
package = true
index-strategy = "first-index"
# Tell Hatch which files to put in the wheel (single-file module)
[tool.hatch.build.targets.wheel]
packages = [] # no packages/dirs
only-include = ["imap_organizer.py"]# ship this module
[tool.hatch.build.targets.sdist]
include = [
"imap_organizer.py",
"pyproject.toml",
".env.example",
"config.example.toml",
"!README.md",
"INSTRUCTIONS.md",
"docs_ADR-*.md"
]
[pytest]
testpaths = .
python_files = test_*.py
python_functions = test_*
addopts =
--cov=imap_organizer
--cov-branch
--cov-fail-under=90
--tb=short
#!/usr/bin/env python3
"""
Unit tests for IMAP Organizer - Behavior-driven testing
Methodology: INSTRUCTIONS.md
- Test behaviors (contracts), NOT implementation details
- Naming: test_<unit>__<scenario>__<expected>()
- Isolation: tmp_path as CWD, controlled env, no network
- Parametrize variants
- Real CLI tests with CliRunner
- <25 lines per test
Run: pytest test_imap_organizer.py -v
Coverage: pytest --cov=imap_organizer test_imap_organizer.py
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime
from pathlib import Path
import os
from typer.testing import CliRunner
# Import code under test
from imap_organizer import (
Config,
get_env_file_from_config,
load_env_for_config,
setup_logging,
EmailOrganizer,
app,
)
# ==================================================================
# HARNESS & FIXTURES
# ==================================================================
@pytest.fixture
def tmp_env(tmp_path, monkeypatch):
"""Isolated environment: tmp CWD, clean env, isolated HALT file"""
# Change to tmp directory
monkeypatch.chdir(tmp_path)
# Isolate HALT file to tmp directory
halt_file = tmp_path / ".imap-organizer.HALT"
monkeypatch.setattr("imap_organizer.EmailOrganizer.HALT_FLAG_FILE", halt_file)
# Clean environment
for key in ["IMAP_HOST", "IMAP_PORT", "IMAP_USER", "IMAP_PASSWORD"]:
monkeypatch.delenv(key, raising=False)
return tmp_path
@pytest.fixture
def mock_imap_client():
"""Controllable fake IMAP client"""
client = Mock()
# Connection
client.login.return_value = None
client.capabilities.return_value = [b'IMAP4REV1', b'MOVE', b'UIDPLUS']
# Folders
client.list_folders.return_value = [
([b'\\HasNoChildren'], b'/', 'INBOX'),
([b'\\HasNoChildren'], b'/', 'Einkauf'),
]
client.folder_status.side_effect = Exception("Folder not found")
client.create_folder.return_value = None
client.subscribe_folder.return_value = None
# Messages
client.select_folder.return_value = (b'OK', [b'3 messages'])
client.search.return_value = [101, 102, 103]
client.fetch.return_value = {
101: {b'INTERNALDATE': datetime(2025, 1, 15)},
102: {b'INTERNALDATE': datetime(2025, 3, 20)},
103: {b'INTERNALDATE': datetime(2024, 12, 1)},
}
# Move
client.move.return_value = None
client.copy.return_value = None
client.delete_messages.return_value = None
client.expunge.return_value = None
# Cleanup
client.logout.return_value = None
return client
@pytest.fixture
def message_sets():
"""Test data: message sets with various date distributions
Uses fixed year 2025 to avoid year-boundary flakes.
"""
return {
"current_year": {
101: {b'INTERNALDATE': datetime(2025, 1, 15)},
102: {b'INTERNALDATE': datetime(2025, 6, 20)},
},
"mixed_years": {
101: {b'INTERNALDATE': datetime(2025, 1, 15)},
102: {b'INTERNALDATE': datetime(2024, 6, 1)},
103: {b'INTERNALDATE': datetime(2023, 12, 15)},
},
"with_missing": {
101: {b'INTERNALDATE': datetime(2025, 1, 15)},
102: {}, # Missing date
103: {b'INTERNALDATE': datetime(2024, 6, 1)},
},
}
@pytest.fixture
def runner():
"""Shared CliRunner for CLI tests
Note: Typer's CliRunner doesn't support mix_stderr parameter.
Rich console stderr output is not well-captured by CliRunner,
so CLI error tests focus on exit codes rather than error messages.
"""
return CliRunner()
# ==================================================================
# CONFIG & ENV LOADING
# ==================================================================
def test_config__env_overrides_toml__precedence(tmp_env, monkeypatch):
"""Ensures ENV variables override TOML config values"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "toml.local"\nport = 143\n')
monkeypatch.setenv("IMAP_HOST", "env.local")
config = Config(config_file=config_file)
assert config.get("connection", "host") == "env.local" # ENV wins
assert config.get("connection", "port") == 143 # TOML preserved
@pytest.mark.parametrize("profile,expected_env", [
("dev", ".env-dev"),
("sandbox", ".env-sandbox"),
("prod", ".env"), # .imap-organizer.toml → .env
])
def test_config__paired_env__loads(tmp_env, monkeypatch, profile, expected_env):
"""Ensures config filename pairs with correct .env file"""
if profile == "prod":
config_file = tmp_env / ".imap-organizer.toml"
else:
config_file = tmp_env / f".imap-organizer-{profile}.toml"
config_file.write_text('[connection]\nhost = "test.local"\n')
env_file = tmp_env / expected_env
env_file.write_text(f"IMAP_PASSWORD={profile}-password\n")
# Clean env
monkeypatch.delenv("IMAP_PASSWORD", raising=False)
# Load env for config
load_env_for_config(config_file)
assert os.getenv("IMAP_PASSWORD") == f"{profile}-password"
def test_config__paired_env_missing__falls_back_to_default(tmp_env, monkeypatch):
"""Ensures fallback to .env when paired env file missing"""
config_file = tmp_env / ".imap-organizer-dev.toml"
config_file.write_text('[connection]\nhost = "test.local"\n')
# Only default .env exists
default_env = tmp_env / ".env"
default_env.write_text("IMAP_PASSWORD=fallback-password\n")
monkeypatch.delenv("IMAP_PASSWORD", raising=False)
load_env_for_config(config_file)
assert os.getenv("IMAP_PASSWORD") == "fallback-password"
def test_config__discovery__finds_cwd_then_home(tmp_env, monkeypatch):
"""Ensures config discovery: CWD first, then home"""
# No CWD config, create home config
home_dir = tmp_env / "home"
home_dir.mkdir()
(home_dir / ".config" / "imap-organizer").mkdir(parents=True)
home_config = home_dir / ".config" / "imap-organizer" / "config.toml"
home_config.write_text('[connection]\nhost = "home.local"\n')
monkeypatch.setattr(Path, "home", lambda: home_dir)
config = Config()
assert config.config_file == home_config
assert config.get("connection", "host") == "home.local"
def test_config__bad_toml__raises_runtime_error(tmp_env):
"""Ensures malformed TOML raises RuntimeError with clear message"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('bad toml syntax [[[')
with pytest.raises(RuntimeError, match="Failed to load config"):
Config(config_file=config_file)
def test_config__explicit_missing__raises_not_found(tmp_env):
"""Ensures explicit missing config path raises 'not found' error"""
missing_file = tmp_env / ".imap-organizer-nonexistent.toml"
with pytest.raises(RuntimeError, match="Config file not found"):
Config(config_file=missing_file)
def test_config__no_config_loads_default_env(tmp_env, monkeypatch):
"""Ensures load_env_for_config(None) loads default .env from CWD"""
# Create default .env in CWD
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=default_password\n")
monkeypatch.delenv("IMAP_PASSWORD", raising=False)
# Call with no config_file
load_env_for_config(None)
# Verify default .env was loaded
assert os.getenv("IMAP_PASSWORD") == "default_password"
# ==================================================================
# LOGGING
# ==================================================================
def test_logging__creates_timestamped_file__in_cwd(tmp_env):
"""Ensures logging creates timestamped file in CWD"""
logger = setup_logging(verbose=False)
log_files = list(tmp_env.glob("imap_organizer_*.log"))
assert len(log_files) == 1
assert logger.name == "imap_organizer"
# ==================================================================
# IMAP: CONNECTION
# ==================================================================
@patch('imap_organizer.IMAPClient')
def test_connect__success__calls_login_once(MockIMAPClient, tmp_env, monkeypatch):
"""Ensures successful connection constructs IMAP and calls login once"""
monkeypatch.setenv("IMAP_PASSWORD", "testpass")
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nport = 993\nuser = "[email protected]"\n')
mock_client = Mock()
MockIMAPClient.return_value = mock_client
config = Config(config_file=config_file)
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
result = organizer.connect()
assert result == mock_client
MockIMAPClient.assert_called_once_with("test.local", port=993, ssl=True)
mock_client.login.assert_called_once_with("[email protected]", "testpass")
@pytest.mark.parametrize("missing,env_var,match_text", [
("host", "IMAP_HOST", "Missing IMAP host"),
("user", "IMAP_USER", "Missing IMAP user"),
("password", "IMAP_PASSWORD", "Missing IMAP password"),
])
def test_connect__missing_credential__raises(tmp_env, monkeypatch, missing, env_var, match_text):
"""Ensures missing credentials raise ValueError with clear message"""
# Set all except the missing one
if missing != "host":
monkeypatch.setenv("IMAP_HOST", "test.local")
if missing != "user":
monkeypatch.setenv("IMAP_USER", "[email protected]")
if missing != "password":
monkeypatch.setenv("IMAP_PASSWORD", "testpass")
config = Mock()
if missing == "host":
config.get.side_effect = lambda s, k, d=None: None if k == "host" else (993 if k == "port" else "[email protected]" if k == "user" else d)
elif missing == "user":
config.get.side_effect = lambda s, k, d=None: "test.local" if k == "host" else (993 if k == "port" else None if k == "user" else d)
else: # password
config.get.side_effect = lambda s, k, d=None: "test.local" if k == "host" else (993 if k == "port" else "[email protected]" if k == "user" else d)
monkeypatch.delenv("IMAP_PASSWORD", raising=False)
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
with pytest.raises(ValueError, match=match_text):
organizer.connect()
# ==================================================================
# IMAP: FOLDER OPERATIONS
# ==================================================================
def test_list_folders__returns_normalized_tuples(tmp_env, mock_imap_client):
"""Ensures list_folders returns normalized (flags, delimiter, name) tuples"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
folders = organizer.list_folders()
assert len(folders) == 2
assert folders[0][2] == 'INBOX'
assert folders[1][2] == 'Einkauf'
def test_get_folder_delimiter__bytes_to_string__with_fallback(tmp_env, mock_imap_client):
"""Ensures delimiter converted from bytes to string, with '/' fallback"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
delimiter = organizer.get_folder_delimiter()
assert delimiter == '/'
# Test fallback when list empty
mock_imap_client.list_folders.return_value = []
delimiter = organizer.get_folder_delimiter()
assert delimiter == '/'
def test_folder_exists__status_success_or_exception(tmp_env, mock_imap_client):
"""Ensures folder_exists returns True on status success, False on exception"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# False when exception
assert organizer.folder_exists("NonExistent") is False
# True when success
mock_imap_client.folder_status.side_effect = None
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]}
assert organizer.folder_exists("Einkauf") is True
@pytest.mark.parametrize("exists,dry_run,should_create", [
(False, False, True), # Missing, apply → create
(False, True, False), # Missing, dry-run → no create
(True, False, False), # Exists, apply → no create
(True, True, False), # Exists, dry-run → no create
])
def test_ensure_folder__creates_when_missing__respects_dry_run(
tmp_env, mock_imap_client, exists, dry_run, should_create
):
"""Ensures ensure_folder creates only when missing and not dry-run"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
if exists:
mock_imap_client.folder_status.side_effect = None
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]}
organizer.ensure_folder("Test/2025", dry_run=dry_run)
if should_create:
mock_imap_client.create_folder.assert_called_once()
mock_imap_client.subscribe_folder.assert_called_once()
else:
mock_imap_client.create_folder.assert_not_called()
# ==================================================================
# IMAP: MESSAGE YEAR EXTRACTION
# ==================================================================
def test_get_message_year__internaldate__returns_year(tmp_env, mock_imap_client):
"""Ensures INTERNALDATE path returns message year"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
year = organizer.get_message_year(101, use_header_date=False)
assert year == 2025
mock_imap_client.fetch.assert_called_once_with([101], ['INTERNALDATE'])
def test_get_message_year__envelope__returns_year(tmp_env, mock_imap_client):
"""Ensures ENVELOPE path returns header date year"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_envelope = Mock()
mock_envelope.date = datetime(2024, 6, 15)
mock_imap_client.fetch.return_value = {101: {b'ENVELOPE': mock_envelope}}
year = organizer.get_message_year(101, use_header_date=True)
assert year == 2024
mock_imap_client.fetch.assert_called_once_with([101], ['ENVELOPE'])
def test_get_message_year__missing_or_error__returns_none(tmp_env, mock_imap_client):
"""Ensures missing fields or errors return None"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Missing data
mock_imap_client.fetch.return_value = {}
assert organizer.get_message_year(999) is None
# Fetch error
mock_imap_client.fetch.side_effect = Exception("Fetch failed")
assert organizer.get_message_year(101) is None
# ==================================================================
# IMAP: MOVE MESSAGE
# ==================================================================
@pytest.mark.parametrize("capabilities,uses_move,uses_copy,uses_uidplus_expunge", [
([b'IMAP4REV1', b'MOVE', b'UIDPLUS'], True, False, False), # Best: MOVE
([b'IMAP4REV1', b'MOVE'], True, False, False), # MOVE only
([b'IMAP4REV1', b'UIDPLUS'], False, True, True), # UIDPLUS only (safe expunge)
([b'IMAP4REV1'], False, True, False), # Neither (unsafe expunge)
])
def test_move__capability_matrix__correct_path(
tmp_env, mock_imap_client, capabilities, uses_move, uses_copy, uses_uidplus_expunge
):
"""Ensures move uses correct IMAP path based on server capabilities"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Destination exists
mock_imap_client.folder_status.side_effect = None
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]}
mock_imap_client.capabilities.return_value = capabilities
organizer.move_message(101, "Test/2025", dry_run=False)
if uses_move:
mock_imap_client.move.assert_called_once()
mock_imap_client.copy.assert_not_called()
elif uses_copy:
mock_imap_client.copy.assert_called_once()
mock_imap_client.delete_messages.assert_called_once()
if uses_uidplus_expunge:
mock_imap_client.expunge.assert_called_once_with([101])
else:
mock_imap_client.expunge.assert_called_once_with()
def test_move__destination_missing__critical_halt(tmp_env, mock_imap_client):
"""Ensures missing destination triggers critical halt immediately"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Destination doesn't exist
mock_imap_client.folder_status.side_effect = Exception("Not found")
halt_file = tmp_env / ".imap-organizer.HALT"
with pytest.raises(RuntimeError, match="HALTED.*does not exist"):
organizer.move_message(101, "Test/2025", dry_run=False)
assert halt_file.exists()
assert "move_message" in halt_file.read_text()
mock_imap_client.move.assert_not_called()
def test_move__move_error__critical_halt(tmp_env, mock_imap_client):
"""Ensures move operation error triggers critical halt"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Destination exists
mock_imap_client.folder_status.side_effect = None
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]}
# Move fails
mock_imap_client.move.side_effect = Exception("Connection lost")
halt_file = tmp_env / ".imap-organizer.HALT"
with pytest.raises(RuntimeError, match="HALTED"):
organizer.move_message(101, "Test/2025", dry_run=False)
assert halt_file.exists()
assert "move_message" in halt_file.read_text()
def test_move__dry_run__no_side_effects(tmp_env, mock_imap_client):
"""Ensures dry-run mode logs but doesn't move"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
organizer.move_message(101, "Test/2025", dry_run=True)
mock_imap_client.move.assert_not_called()
mock_imap_client.copy.assert_not_called()
def test_move__without_uidplus__warns(tmp_env, mock_imap_client):
"""Ensures missing UIDPLUS logs warning about unsafe expunge"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = Mock()
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Destination exists
mock_imap_client.folder_status.side_effect = None
mock_imap_client.folder_status.return_value = {'UIDNEXT': [1]}
# No MOVE, no UIDPLUS
mock_imap_client.capabilities.return_value = [b'IMAP4REV1']
organizer.move_message(101, "Test/2025", dry_run=False)
# Should warn
logger.warning.assert_called()
warning_msg = logger.warning.call_args[0][0]
assert "UIDPLUS" in warning_msg or "all deleted messages" in warning_msg.lower()
# ==================================================================
# IMAP: file_by_year
# ==================================================================
@patch('imap_organizer.datetime')
def test_file_by_year__current_year__filters_correctly(mock_datetime_module, tmp_env, mock_imap_client, message_sets):
"""Ensures current year mode files only current year messages
Uses fixed year 2025 to avoid year-boundary flakes.
"""
# Fix current year to 2025 to avoid year-boundary flakes
# Mock needs to return proper datetime for both .year and .strftime()
mock_now = Mock()
mock_now.year = 2025
mock_now.strftime.return_value = "20250615_120000" # Fixed timestamp for logs
mock_datetime_module.datetime.now.return_value = mock_now
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_imap_client.search.return_value = [101, 102, 103, 104]
mock_imap_client.fetch.return_value = {
101: {b'INTERNALDATE': datetime(2025, 1, 15)},
102: {b'INTERNALDATE': datetime(2025, 3, 20)},
103: {b'INTERNALDATE': datetime(2024, 6, 1)},
104: {b'INTERNALDATE': datetime(2024, 12, 15)},
}
total, filed = organizer.file_by_year("TestFolder", year=None, backfill=False, dry_run=True)
assert total == 4
assert filed == 2 # Only current year
def test_file_by_year__specific_year__filters_correctly(tmp_env, mock_imap_client, message_sets):
"""Ensures specific year mode files only that year's messages"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_imap_client.search.return_value = [101, 102, 103]
mock_imap_client.fetch.return_value = message_sets["mixed_years"]
total, filed = organizer.file_by_year("TestFolder", year=2024, backfill=False, dry_run=True)
assert total == 3
assert filed == 1 # Only 2024
def test_file_by_year__backfill__processes_all_years(tmp_env, mock_imap_client, message_sets):
"""Ensures backfill mode processes all years"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_imap_client.search.return_value = [101, 102, 103]
mock_imap_client.fetch.return_value = message_sets["mixed_years"]
total, filed = organizer.file_by_year("TestFolder", year=None, backfill=True, dry_run=True)
assert total == 3
assert filed == 3 # All years
def test_file_by_year__empty_folder__returns_zero(tmp_env, mock_imap_client):
"""Ensures empty folder returns (0, 0)"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_imap_client.search.return_value = []
total, filed = organizer.file_by_year("EmptyFolder", dry_run=True)
assert total == 0
assert filed == 0
def test_file_by_year__missing_year__skips_message(tmp_env, mock_imap_client, message_sets):
"""Ensures messages with undetermined year are skipped"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_imap_client.search.return_value = [101, 102, 103]
mock_imap_client.fetch.return_value = message_sets["with_missing"]
total, filed = organizer.file_by_year("TestFolder", backfill=True, dry_run=True)
assert total == 3
assert filed == 2 # Skip UID 102 (missing date)
def test_file_by_year__dry_run__no_move_or_create(tmp_env, mock_imap_client):
"""Ensures dry-run doesn't call create or move"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_imap_client.search.return_value = [101]
mock_imap_client.fetch.return_value = {
101: {b'INTERNALDATE': datetime(2025, 1, 15)},
}
total, filed = organizer.file_by_year("TestFolder", dry_run=True)
mock_imap_client.create_folder.assert_not_called()
mock_imap_client.move.assert_not_called()
mock_imap_client.copy.assert_not_called()
def test_file_by_year__delimiter_used_in_destination(tmp_env, mock_imap_client):
"""Ensures delimiter is used when constructing destination folder path"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Setup list_folders to return "." delimiter
mock_imap_client.list_folders.return_value = [
([b'\\HasNoChildren'], b'.', 'Root'),
]
# Mock folder_status to indicate destination folder exists after creation
mock_imap_client.folder_status.side_effect = [
Exception("Folder not found"), # First check: doesn't exist
(b'OK', [b'EXISTS 0']) # After creation: exists
]
mock_imap_client.search.return_value = [101]
mock_imap_client.fetch.return_value = {
101: {b'INTERNALDATE': datetime(2025, 1, 15)},
}
total, filed = organizer.file_by_year("Root", year=2025, dry_run=False)
# Assert move called with delimiter-separated path "Root.2025"
assert mock_imap_client.move.called or mock_imap_client.copy.called
if mock_imap_client.move.called:
call_args = mock_imap_client.move.call_args[0]
assert call_args[1] == "Root.2025", f"Expected 'Root.2025', got '{call_args[1]}'"
else:
call_args = mock_imap_client.copy.call_args[0]
assert call_args[1] == "Root.2025", f"Expected 'Root.2025', got '{call_args[1]}'"
def test_file_by_year__folder_created_once_cache(tmp_env, mock_imap_client):
"""Ensures folder is created only once per year (cache working)"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Mock folder_status: folder doesn't exist first time, then exists
mock_imap_client.folder_status.side_effect = [
Exception("Folder not found"), # First check: doesn't exist
(b'OK', [b'EXISTS 0']), # After creation: exists (for first move)
(b'OK', [b'EXISTS 0']), # Second move: still exists
(b'OK', [b'EXISTS 0']), # Third move: still exists
]
# 3 messages from same year (2025)
mock_imap_client.search.return_value = [101, 102, 103]
mock_imap_client.fetch.return_value = {
101: {b'INTERNALDATE': datetime(2025, 1, 15)},
102: {b'INTERNALDATE': datetime(2025, 3, 20)},
103: {b'INTERNALDATE': datetime(2025, 6, 10)},
}
total, filed = organizer.file_by_year("TestFolder", year=2025, dry_run=False)
# Assert create_folder called ONCE (cached for subsequent messages)
assert mock_imap_client.create_folder.call_count == 1
# Assert move called 3 times (one per message)
move_calls = mock_imap_client.move.call_count + mock_imap_client.copy.call_count
assert move_calls == 3
assert total == 3
assert filed == 3
# ==================================================================
# CIRCUIT BREAKER
# ==================================================================
@pytest.mark.parametrize("max_failures", [3, 5])
def test_circuit__non_critical__counts_to_threshold(tmp_env, mock_imap_client, max_failures):
"""Ensures non-critical failures count to configurable threshold"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: max_failures if k == "max_failures" else d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
assert organizer.max_failures == max_failures
# Mock create failures
mock_imap_client.folder_status.side_effect = Exception("Not found")
mock_imap_client.create_folder.side_effect = Exception("Permission denied")
halt_file = tmp_env / ".imap-organizer.HALT"
# Count up to threshold-1
for i in range(max_failures - 1):
organizer.ensure_folder(f"Folder{i}/2025", dry_run=False)
assert organizer.failure_count == i + 1
assert not halt_file.exists()
# Threshold reached
with pytest.raises(RuntimeError, match="HALTED"):
organizer.ensure_folder(f"Folder{max_failures}/2025", dry_run=False)
assert halt_file.exists()
def test_circuit__critical__halts_immediately(tmp_env, mock_imap_client):
"""Ensures critical failures halt immediately without counting"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Destination missing (critical failure)
mock_imap_client.folder_status.side_effect = Exception("Not found")
halt_file = tmp_env / ".imap-organizer.HALT"
with pytest.raises(RuntimeError, match="HALTED"):
organizer.move_message(101, "Test/2025", dry_run=False)
assert halt_file.exists()
assert organizer.failure_count == 1 # Still increments
def test_circuit__halt_exists__blocks_operations(tmp_env, mock_imap_client):
"""Ensures existing HALT file blocks operations immediately"""
halt_file = tmp_env / ".imap-organizer.HALT"
halt_file.write_text("Previous failure")
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
with pytest.raises(RuntimeError, match="HALTED"):
organizer.ensure_folder("Test/2025", dry_run=False)
def test_circuit__halt_exists__blocks_file_by_year(tmp_env, mock_imap_client):
"""Ensures existing HALT file blocks file_by_year immediately"""
halt_file = tmp_env / ".imap-organizer.HALT"
halt_file.write_text("Previous failure")
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_imap_client.search.return_value = [101]
mock_imap_client.fetch.return_value = {
101: {b'INTERNALDATE': datetime(2025, 1, 15)},
}
# Should raise immediately due to HALT file, even though we have messages
with pytest.raises(RuntimeError, match="HALTED"):
organizer.file_by_year("TestFolder", dry_run=False)
with pytest.raises(RuntimeError, match="HALTED"):
organizer.move_message(101, "Test/2025", dry_run=False)
def test_circuit__threshold_reached__creates_halt_file(tmp_env, mock_imap_client):
"""Ensures HALT file created when threshold reached"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: 2 if k == "max_failures" else d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_imap_client.folder_status.side_effect = Exception("Not found")
mock_imap_client.create_folder.side_effect = Exception("Fail")
halt_file = tmp_env / ".imap-organizer.HALT"
organizer.ensure_folder("Folder1/2025", dry_run=False)
with pytest.raises(RuntimeError, match="HALTED"):
organizer.ensure_folder("Folder2/2025", dry_run=False)
assert halt_file.exists()
def test_circuit__halt_file_content__includes_operation_and_error(tmp_env, mock_imap_client):
"""Ensures HALT file content includes operation name and error"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Trigger critical failure
mock_imap_client.folder_status.side_effect = Exception("Not found")
halt_file = tmp_env / ".imap-organizer.HALT"
with pytest.raises(RuntimeError):
organizer.move_message(101, "Test/2025", dry_run=False)
content = halt_file.read_text()
assert "move_message" in content
assert "does not exist" in content
def test_circuit__dry_run__does_not_trigger(tmp_env, mock_imap_client):
"""Ensures dry-run never triggers circuit breaker"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
# Multiple dry-run operations
organizer.ensure_folder("Test1/2025", dry_run=True)
organizer.move_message(101, "Test2/2025", dry_run=True)
organizer.ensure_folder("Test3/2025", dry_run=True)
halt_file = tmp_env / ".imap-organizer.HALT"
assert not halt_file.exists()
assert organizer.failure_count == 0
# ==================================================================
# IMAP: CLOSE
# ==================================================================
def test_close__success__calls_logout(tmp_env, mock_imap_client):
"""Ensures close calls logout"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
organizer.close()
mock_imap_client.logout.assert_called_once()
def test_close__error__swallows_exception(tmp_env, mock_imap_client):
"""Ensures close swallows logout exceptions"""
config = Mock()
config.get.side_effect = lambda s, k, d=None: d
logger = setup_logging(verbose=False)
organizer = EmailOrganizer(config, logger)
organizer.client = mock_imap_client
mock_imap_client.logout.side_effect = Exception("Logout failed")
# Should not raise
organizer.close()
# ==================================================================
# CLI: file
# ==================================================================
@patch('imap_organizer.EmailOrganizer')
def test_cli_file__dry_run_default__shows_dry_run_message(MockOrganizer, tmp_env, runner):
"""Ensures file command defaults to dry-run with message"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n')
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.file_by_year.return_value = (10, 5)
result = runner.invoke(app, ["file"])
assert result.exit_code == 0
assert "DRY-RUN" in result.stdout
# Verify dry_run=True passed
calls = mock_org.file_by_year.call_args_list
assert all(call.kwargs.get('dry_run', True) for call in calls)
@patch('imap_organizer.EmailOrganizer')
def test_cli_file__apply__moves_emails(MockOrganizer, tmp_env, runner):
"""Ensures --apply flag actually moves emails"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n')
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.file_by_year.return_value = (10, 5)
result = runner.invoke(app, ["file", "--apply"])
assert result.exit_code == 0
assert "APPLY" in result.stdout
# Verify dry_run=False passed
calls = mock_org.file_by_year.call_args_list
assert all(call.kwargs['dry_run'] == False for call in calls)
@patch('imap_organizer.EmailOrganizer')
def test_cli_file__backfill__processes_all_years(MockOrganizer, tmp_env, runner):
"""Ensures --backfill flag processes all years"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n')
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.file_by_year.return_value = (10, 10)
result = runner.invoke(app, ["file", "--backfill"])
assert result.exit_code == 0
# Verify backfill=True passed
call = mock_org.file_by_year.call_args
assert call.kwargs['backfill'] == True
@patch('imap_organizer.EmailOrganizer')
def test_cli_file__year_flag__processes_specific_years(MockOrganizer, tmp_env, runner):
"""Ensures --year flag processes specific years"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n')
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.file_by_year.return_value = (5, 5)
result = runner.invoke(app, ["file", "--year", "2024", "--year", "2023"])
assert result.exit_code == 0
# Verify year parameter passed
calls = mock_org.file_by_year.call_args_list
years_called = [call.kwargs['year'] for call in calls]
assert 2024 in years_called
assert 2023 in years_called
@patch('imap_organizer.EmailOrganizer')
def test_cli_file__use_header_date__passes_flag(MockOrganizer, tmp_env, runner):
"""Ensures --use-header-date flag is passed to file_by_year"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n')
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.file_by_year.return_value = (10, 5)
result = runner.invoke(app, ["file", "--use-header-date"])
assert result.exit_code == 0
# Verify use_header_date=True passed
call = mock_org.file_by_year.call_args
assert call.kwargs['use_header_date'] == True
@patch('imap_organizer.EmailOrganizer')
def test_cli_file__folder_arg__overrides_config(MockOrganizer, tmp_env, runner):
"""Ensures positional folder arguments override config default"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["ConfigFolder"]\n')
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.file_by_year.return_value = (10, 5)
result = runner.invoke(app, ["file", "CLIFolder1", "CLIFolder2"])
assert result.exit_code == 0
# Verify CLI folders used (not config default)
calls = mock_org.file_by_year.call_args_list
# Extract folder from each call (first positional arg or 'folder' kwarg)
folders_called = []
for call in calls:
if call.args:
folders_called.append(call.args[0])
elif 'folder' in call.kwargs:
folders_called.append(call.kwargs['folder'])
assert "CLIFolder1" in folders_called
assert "CLIFolder2" in folders_called
assert "ConfigFolder" not in folders_called
def test_cli_file__no_folders__exits_1(tmp_env, runner):
"""Ensures missing folders exits with code 1"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n')
# No [folders] section
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
result = runner.invoke(app, ["file"])
# Primary assertion: exit code (error handling behavior)
assert result.exit_code == 1
# Note: Rich console stderr output not well-captured by CliRunner
def test_cli_file__missing_password__exits_1(tmp_env, runner):
"""Ensures missing IMAP_PASSWORD exits with code 1"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n')
# No .env file, no IMAP_PASSWORD
result = runner.invoke(app, ["file"])
# Primary assertion: exit code (error handling behavior)
assert result.exit_code == 1
# Note: Rich console stderr output not well-captured by CliRunner
@patch('imap_organizer.EmailOrganizer')
def test_cli_file__config_option__loads_paired_env(MockOrganizer, tmp_env, monkeypatch, runner):
"""Ensures --config option loads paired env file"""
# Clean env
monkeypatch.delenv("IMAP_PASSWORD", raising=False)
config_file = tmp_env / ".imap-organizer-dev.toml"
config_file.write_text('[connection]\nhost = "dev.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n')
env_file = tmp_env / ".env-dev"
env_file.write_text("IMAP_PASSWORD=dev-password\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.file_by_year.return_value = (1, 1)
result = runner.invoke(app, ["file", "--config", str(config_file)])
assert result.exit_code == 0
# Verify env loaded
assert os.getenv("IMAP_PASSWORD") == "dev-password"
@patch('imap_organizer.EmailOrganizer')
def test_cli_file__verbose__shows_traceback(MockOrganizer, tmp_env, runner):
"""Ensures --verbose flag shows traceback on errors"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n[folders]\ndefault = ["Test"]\n')
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.connect.side_effect = Exception("Connection failed")
result = runner.invoke(app, ["file", "--verbose"])
# Primary assertion: exit code (error handling behavior)
assert result.exit_code == 1
# Note: Rich console stderr output not well-captured by CliRunner
# Exit code verification is sufficient per INSTRUCTIONS.md section 10
# ==================================================================
# CLI: list_folders
# ==================================================================
@patch('imap_organizer.EmailOrganizer')
def test_cli_list__success__displays_table_and_count(MockOrganizer, tmp_env, runner):
"""Ensures list-folders displays table with folder names and count"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n')
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.list_folders.return_value = [
([b'\\HasNoChildren'], '/', 'INBOX'),
([b'\\HasNoChildren'], '/', 'Einkauf'),
]
result = runner.invoke(app, ["list-folders"])
assert result.exit_code == 0
assert "INBOX" in result.stdout
assert "Einkauf" in result.stdout
assert "2" in result.stdout # Count
@patch('imap_organizer.EmailOrganizer')
def test_cli_list__connection_error__exits_1(MockOrganizer, tmp_env, runner):
"""Ensures connection errors exit with code 1"""
config_file = tmp_env / ".imap-organizer.toml"
config_file.write_text('[connection]\nhost = "test.local"\nuser = "[email protected]"\n')
env_file = tmp_env / ".env"
env_file.write_text("IMAP_PASSWORD=test\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.connect.side_effect = ValueError("Missing IMAP host")
result = runner.invoke(app, ["list-folders"])
# Primary assertion: exit code (error handling behavior)
assert result.exit_code == 1
# Note: Rich console stderr output not well-captured by CliRunner
# Exit code verification is sufficient per INSTRUCTIONS.md section 10
@patch('imap_organizer.EmailOrganizer')
def test_cli_list__config_option__loads_paired_env(MockOrganizer, tmp_env, monkeypatch, runner):
"""Ensures --config option loads paired env file"""
monkeypatch.delenv("IMAP_PASSWORD", raising=False)
config_file = tmp_env / ".imap-organizer-sandbox.toml"
config_file.write_text('[connection]\nhost = "sandbox.local"\nuser = "[email protected]"\n')
env_file = tmp_env / ".env-sandbox"
env_file.write_text("IMAP_PASSWORD=sandbox-password\n")
mock_org = Mock()
MockOrganizer.return_value = mock_org
mock_org.list_folders.return_value = [([b'\\HasNoChildren'], '/', 'INBOX')]
result = runner.invoke(app, ["list-folders", "--config", str(config_file)])
assert result.exit_code == 0
assert os.getenv("IMAP_PASSWORD") == "sandbox-password"
version = 1
revision = 2
requires-python = ">=3.11"
[[package]]
name = "click"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.10.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" },
{ url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" },
{ url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" },
{ url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" },
{ url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" },
{ url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" },
{ url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" },
{ url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" },
{ url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" },
{ url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" },
{ url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" },
{ url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" },
{ url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" },
{ url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" },
{ url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" },
{ url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" },
{ url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" },
{ url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" },
{ url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" },
{ url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" },
{ url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" },
{ url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" },
{ url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" },
{ url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" },
{ url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" },
{ url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" },
{ url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" },
{ url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" },
{ url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" },
{ url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" },
{ url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" },
{ url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" },
{ url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" },
{ url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" },
{ url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" },
{ url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" },
{ url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" },
{ url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" },
{ url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" },
{ url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" },
{ url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" },
{ url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" },
{ url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" },
{ url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" },
{ url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" },
{ url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" },
{ url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" },
{ url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" },
{ url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" },
{ url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" },
{ url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" },
{ url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" },
{ url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" },
{ url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" },
{ url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" },
{ url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" },
{ url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" },
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "imap-organizer"
version = "0.0.1"
source = { editable = "." }
dependencies = [
{ name = "imapclient" },
{ name = "python-dotenv" },
{ name = "rich" },
{ name = "typer" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-mock" },
]
[package.metadata]
requires-dist = [
{ name = "imapclient", specifier = ">=3.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.0" },
{ name = "python-dotenv", specifier = ">=1.0" },
{ name = "rich", specifier = ">=13.0.0" },
{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" },
{ name = "typer", specifier = ">=0.9.0" },
]
provides-extras = ["dev"]
[[package]]
name = "imapclient"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/63/0eea51c9c263c18021cdc5866def55c98393f3bd74bbb8e3053e36f0f81a/IMAPClient-3.0.1.zip", hash = "sha256:78e6d62fbfbbe233e1f0e0e993160fd665eb1fd35973acddc61c15719b22bc02", size = 244222, upload-time = "2023-12-02T08:24:15.344Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/8a/d1364c1c6d8f53ea390e8f1c6da220a4f9ee478ac8a473ae0669a2fb6f51/IMAPClient-3.0.1-py2.py3-none-any.whl", hash = "sha256:d77d77caa4123e0233b5cf2b9c54a078522e63270b88d3f48653a28637fd8828", size = 182490, upload-time = "2023-12-02T08:24:11.854Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
name = "rich"
version = "14.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "tomli"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
]
[[package]]
name = "typer"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment