feat: Add various tools for document processing and management

- Introduced `docx-extractor` for extracting content and metadata from Word .docx files.
- Added `docx2md` for converting Word .docx files to Markdown format.
- Implemented `gpt` for generating text using OpenAI's GPT models.
- Created `html2text` for converting HTML content to plain text.
- Developed `mbox2eml` for converting MBOX mailbox files to individual .eml files.
- Added `md2pdf` for converting Markdown files to PDF format.
- Introduced `mdscraper` for combining text files into a single markdown document.
- Created `pdf-extractor` for extracting content and metadata from PDF files.
- Developed `pdf2md` for converting PDF files to Markdown format.
- Implemented `protonmail` for managing ProtonMail emails via Bridge.
- Added `schedule` for managing systemd timers and services.
- Introduced `search` for managing searchable collections of files.
- Added `text-extractor` for extracting content and metadata from text files.
- Removed outdated recommendations document.
This commit is contained in:
2026-02-22 22:18:41 -08:00
parent eab6f8b535
commit 6d0332e32b
75 changed files with 62 additions and 235 deletions

View File

@@ -0,0 +1,40 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working
with code in this repository.
## Overview
protonmail is a CLI tool for accessing and managing ProtonMail emails via Bridge.
All commands work with local cached .eml files for fast, offline access.
## Commands
```bash
uv sync # Install dependencies
uv run protonmail --version # Show version
uv run protonmail sync # Sync emails from Bridge
uv run protonmail list # List cached emails
uv run protonmail read <file> # Read a specific email
uv run protonmail search <query> # Search by subject/sender
uv run pytest tests/ -v # Run tests
uv run ruff check . # Lint
uv run ruff format . # Format
```
## Architecture
- `src/protonmail/main.py` — CLI entry point, all email operations
- `src/protonmail/__init__.py` — Package version (`__version__`)
- `tests/` — pytest tests
## Configuration
Environment variables (set by castle or manually):
- `PROTONMAIL_USERNAME` — ProtonMail Bridge username
- `PROTONMAIL_API_KEY` — ProtonMail Bridge API key
- `PROTONMAIL_DATA_DIR` — Where to store synced .eml files (default: /data/messages/email/protonmail)
## Dependencies
stdlib-only — no third-party packages required.

View File

@@ -0,0 +1,72 @@
# protonmail
CLI tool for syncing and managing ProtonMail emails via ProtonMail Bridge. Downloads emails as `.eml` files for fast, offline access. All read/list/search commands work against the local cache.
## Prerequisites
[ProtonMail Bridge](https://proton.me/mail/bridge) must be running locally with IMAP enabled (default port 1143).
## Usage
```bash
# Sync all emails from Bridge to local .eml files
protonmail sync
# Sync to a custom directory
protonmail sync -o ~/emails
# List recent emails (from local cache)
protonmail list
protonmail list Sent
protonmail list -n 50
# Read a specific email
protonmail read "2024-07-17_01-14-40_from_alice_example_com_to_bob_example_com.eml"
# Search by subject or sender
protonmail search "invoice"
protonmail search "alice" -f Sent
# Send an email (interactive, requires Bridge)
protonmail send
```
## Configuration
Set via environment variables (castle manages these automatically):
| Variable | Description | Default |
|----------|-------------|---------|
| `PROTONMAIL_USERNAME` | Bridge username (required) | - |
| `PROTONMAIL_API_KEY` | Bridge API key (required) | - |
| `PROTONMAIL_DATA_DIR` | Where to store synced .eml files | `/data/messages/email/protonmail` |
In castle, the API key is stored as a secret:
```yaml
env:
PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY}
```
## How sync works
1. Connects to Bridge via IMAP on `127.0.0.1:1143`
2. Lists all folders (INBOX, Sent, Drafts, etc.)
3. For each folder, downloads new messages as `.eml` files
4. Skips emails already in the local cache (matched by Message-ID)
5. Filenames follow the pattern: `YYYY-MM-DD_HH-MM-SS_from_SENDER_to_RECIPIENT_SUBJECT.eml`
## Castle integration
Registered as both a **tool** (installed to PATH) and a **job** (syncs every 5 minutes via systemd timer).
## Development
```bash
uv sync # Install dependencies
uv run protonmail --version # Verify
uv run pytest tests/ -v # Run tests
uv run ruff check . # Lint
```
No third-party dependencies -- stdlib only.

View File

@@ -0,0 +1,24 @@
[project]
name = "protonmail"
version = "0.1.0"
description = "ProtonMail email sync via Bridge"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
protonmail = "protonmail.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/protonmail"]
[dependency-groups]
dev = [
"pytest>=7.0.0",
]
[tool.ruff.lint.isort]
known-first-party = ["protonmail"]

View File

@@ -0,0 +1,3 @@
"""ProtonMail email sync via Bridge."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,598 @@
#!/usr/bin/env python3
"""protonmail: Access and manage ProtonMail emails via Bridge.
Access your ProtonMail inbox through the ProtonMail Bridge using IMAP/SMTP.
All commands now work with local cached .eml files for fast, offline access.
Run 'protonmail sync' first to download emails locally.
Usage: protonmail [options] [command]
Commands:
sync Sync all emails to local .eml files
list [folder] List emails (default: INBOX, uses local cache)
read <filename> Read a specific email by filename (uses local cache)
send Send a new email (interactive, requires IMAP)
search <query> Search emails by subject or sender (uses local cache)
Examples:
protonmail sync # Sync all emails
protonmail sync -o ~/emails # Sync to custom directory
protonmail list # List emails in INBOX from local cache
protonmail list Sent # List emails in Sent folder
protonmail read "2024-07-17_01-14-40_from_..._.eml"
protonmail search "invoice" # Search by subject or sender
"""
import argparse
import email
import imaplib
import os
import re
import smtplib
import sys
from email.message import EmailMessage, Message
from email.utils import formatdate, make_msgid, parsedate_to_datetime
from pathlib import Path
from typing import Any
from protonmail import __version__
__all__ = ["main"]
def load_config() -> dict[str, Any]:
"""Load configuration from environment variables."""
username = os.environ.get("PROTONMAIL_USERNAME", "")
if not username:
print(
"Error: PROTONMAIL_USERNAME environment variable not set", file=sys.stderr
)
sys.exit(1)
api_key = os.environ.get("PROTONMAIL_API_KEY", "")
if not api_key:
print("Error: PROTONMAIL_API_KEY environment variable not set", file=sys.stderr)
sys.exit(1)
config: dict[str, Any] = {
"IMAP": {
"hostname": "127.0.0.1",
"port": 1143,
"username": username,
"password": api_key,
"security": "STARTTLS",
},
"SMTP": {
"hostname": "127.0.0.1",
"port": 1025,
"username": username,
"password": api_key,
"security": "STARTTLS",
},
}
return config
def connect_imap(config: dict[str, Any]) -> imaplib.IMAP4:
"""Connect to IMAP server."""
imap_config = config["IMAP"]
if imap_config["security"] == "SSL/TLS":
imap = imaplib.IMAP4_SSL(imap_config["hostname"], imap_config["port"])
else:
imap = imaplib.IMAP4(imap_config["hostname"], imap_config["port"])
if imap_config["security"] == "STARTTLS":
imap.starttls()
imap.login(imap_config["username"], imap_config["password"])
return imap
def list_folders(imap: imaplib.IMAP4) -> list[str]:
"""List all available folders."""
status, folders = imap.list()
if status != "OK":
return []
folder_list: list[str] = []
for folder in folders:
if isinstance(folder, bytes):
folder_str = folder.decode()
else:
folder_str = str(folder)
folder_parts = folder_str.split(' "/"')
if len(folder_parts) > 1:
folder_name = folder_parts[-1].strip().strip('"')
folder_list.append(folder_name)
return folder_list
def get_sync_dir() -> Path:
"""Get the sync directory path.
Checks PROTONMAIL_DATA_DIR first (set by castle), then DDATA fallback.
"""
data_dir = os.environ.get("PROTONMAIL_DATA_DIR")
if data_dir:
return Path(data_dir)
ddata = os.environ.get("DDATA", "/data")
return Path(f"{ddata}/messages/email/protonmail")
def sanitize_filename(filename: str) -> str:
"""Sanitize a string to be used as a filename."""
filename = re.sub(r'[<>:"/\\|?*]', "_", filename)
filename = re.sub(r"[\x00-\x1f\x7f]", "", filename)
if len(filename) > 200:
filename = filename[:200]
return filename.strip()
def sanitize_email(email_addr: str) -> str:
"""Sanitize email address for use in filename."""
email_addr = email_addr.replace("@", "_")
email_addr = re.sub(r'[<>"\']', "", email_addr)
email_addr = re.sub(r"[/\\:*?|]", "_", email_addr)
return email_addr.strip()
def extract_email_address(email_field: str) -> str:
"""Extract just the email address from a field like 'Name <email@domain.com>'."""
match = re.search(r"<([^>]+)>", email_field)
if match:
return match.group(1)
if "@" in email_field:
return email_field.strip().strip("\"'")
return email_field.strip()
def generate_email_filename(msg: Message, max_length: int = 255) -> str:
"""Generate filename in format: YYYY-MM-DD_HH-MM-SS_from_FROM_to_TO_SUBJECT.eml."""
msg_date = msg.get("Date", "")
try:
date_obj = parsedate_to_datetime(msg_date)
date_str = date_obj.strftime("%Y-%m-%d_%H-%M-%S")
except (TypeError, ValueError):
date_str = "unknown-date"
from_field = msg.get("From", "unknown")
from_email = extract_email_address(from_field)
from_safe = sanitize_email(from_email)
to_field = msg.get("To", "unknown")
if "," in to_field:
to_field = to_field.split(",")[0]
to_email = extract_email_address(to_field)
to_safe = sanitize_email(to_email)
subject = msg.get("Subject", "no-subject")
subject_safe = sanitize_filename(subject)
prefix = f"{date_str}_from_{from_safe}_to_{to_safe}_"
suffix = ".eml"
available_length = max_length - len(prefix) - len(suffix)
if len(subject_safe) > available_length:
subject_safe = subject_safe[:available_length]
return f"{prefix}{subject_safe}{suffix}"
def list_emails_local(folder: str = "INBOX", limit: int = 20) -> bool:
"""List emails from local cache. Returns True if successful."""
try:
sync_dir = get_sync_dir()
if not sync_dir.exists():
return False
safe_folder = sanitize_filename(folder)
folder_path = sync_dir / safe_folder
if not folder_path.exists():
print(f"Folder '{folder}' not found in local cache", file=sys.stderr)
return False
eml_files = list(folder_path.glob("*.eml"))
if not eml_files:
print(f"No emails found in folder '{folder}'")
return True
eml_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
eml_files = eml_files[:limit]
print(
f"\nEmails in {folder} (showing last {len(eml_files)} from local cache):\n"
)
for eml_file in eml_files:
try:
with open(eml_file, "rb") as f:
msg = email.message_from_bytes(f.read())
date = msg.get("Date", "No date")
from_addr = msg.get("From", "No sender")
subject = msg.get("Subject", "No subject")
print(f"File: {eml_file.name}")
print(f" Date: {date}")
print(f" From: {from_addr}")
print(f" Subject: {subject}")
print()
except Exception:
continue
return True
except Exception as e:
print(f"Error reading local cache: {e}", file=sys.stderr)
return False
def list_emails(folder: str = "INBOX", limit: int = 20) -> None:
"""List emails in the specified folder from local cache."""
if not list_emails_local(folder, limit):
print(
"\nLocal cache not available. Run 'protonmail sync' first to download emails.",
file=sys.stderr,
)
sys.exit(1)
def read_email_local(filename: str) -> bool:
"""Read an email from local cache by filename. Returns True if successful."""
try:
sync_dir = get_sync_dir()
if not sync_dir.exists():
return False
eml_file = None
for folder_path in sync_dir.iterdir():
if folder_path.is_dir():
potential_file = folder_path / filename
if potential_file.exists():
eml_file = potential_file
break
if not eml_file:
print(f"Email file '{filename}' not found in local cache", file=sys.stderr)
return False
with open(eml_file, "rb") as f:
msg = email.message_from_bytes(f.read())
print(f"\nFrom: {msg.get('From', 'Unknown')}")
print(f"To: {msg.get('To', 'Unknown')}")
print(f"Subject: {msg.get('Subject', 'No subject')}")
print(f"Date: {msg.get('Date', 'Unknown')}")
print("\n" + "=" * 70 + "\n")
body = ""
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
if content_type == "text/plain":
payload = part.get_payload(decode=True)
if isinstance(payload, bytes):
body = payload.decode("utf-8", errors="ignore")
break
else:
payload = msg.get_payload(decode=True)
if payload and isinstance(payload, bytes):
body = payload.decode("utf-8", errors="ignore")
print(body)
print()
return True
except Exception as e:
print(f"Error reading email from local cache: {e}", file=sys.stderr)
return False
def read_email(filename: str) -> None:
"""Read a specific email by filename from local cache."""
if not read_email_local(filename):
print(
"\nLocal cache not available. Run 'protonmail sync' first to download emails.",
file=sys.stderr,
)
sys.exit(1)
def search_emails_local(query: str, folder: str = "INBOX") -> bool:
"""Search emails in local cache by subject or sender. Returns True if successful."""
try:
sync_dir = get_sync_dir()
if not sync_dir.exists():
return False
safe_folder = sanitize_filename(folder)
folder_path = sync_dir / safe_folder
if not folder_path.exists():
print(f"Folder '{folder}' not found in local cache", file=sys.stderr)
return False
matches: list[tuple[Path, Message]] = []
query_lower = query.lower()
for eml_file in folder_path.glob("*.eml"):
try:
with open(eml_file, "rb") as f:
msg = email.message_from_bytes(f.read())
subject = msg.get("Subject", "").lower()
from_addr = msg.get("From", "").lower()
if query_lower in subject or query_lower in from_addr:
matches.append((eml_file, msg))
except Exception:
continue
if not matches:
print(f"No emails found matching '{query}'")
return True
print(
f"\nFound {len(matches)} email(s) matching '{query}' (from local cache):\n"
)
matches.sort(key=lambda x: x[0].stat().st_mtime, reverse=True)
for eml_file, msg in matches:
date = msg.get("Date", "No date")
from_addr = msg.get("From", "No sender")
subject = msg.get("Subject", "No subject")
print(f"File: {eml_file.name}")
print(f" Date: {date}")
print(f" From: {from_addr}")
print(f" Subject: {subject}")
print()
return True
except Exception as e:
print(f"Error searching local cache: {e}", file=sys.stderr)
return False
def search_emails(query: str, folder: str = "INBOX") -> None:
"""Search emails by subject or sender in local cache."""
if not search_emails_local(query, folder):
print(
"\nLocal cache not available. Run 'protonmail sync' first to download emails.",
file=sys.stderr,
)
sys.exit(1)
def send_email(config: dict[str, Any]) -> None:
"""Send a new email interactively."""
try:
to_email = input("To: ")
subject = input("Subject: ")
body_lines: list[str] = []
while True:
try:
line = input()
if line == ".":
break
body_lines.append(line)
except EOFError:
break
body = "\n".join(body_lines)
msg = EmailMessage()
msg["From"] = config["SMTP"]["username"]
msg["To"] = to_email
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=config["SMTP"]["username"].split("@")[-1])
msg.set_content(body)
smtp_config = config["SMTP"]
if smtp_config["security"] == "SSL/TLS":
smtp = smtplib.SMTP_SSL(smtp_config["hostname"], smtp_config["port"])
else:
smtp = smtplib.SMTP(smtp_config["hostname"], smtp_config["port"])
if smtp_config["security"] == "STARTTLS":
smtp.starttls()
smtp.login(smtp_config["username"], smtp_config["password"])
smtp.send_message(msg)
smtp.quit()
print(f"\nEmail sent successfully to {to_email}")
except (smtplib.SMTPException, OSError, EOFError) as e:
print(f"Error sending email: {e}", file=sys.stderr)
sys.exit(1)
def sync_emails(config: dict[str, Any], output_dir: str | None = None) -> None:
"""Sync all emails from ProtonMail to local .eml files."""
try:
if output_dir is None:
output_path = get_sync_dir()
else:
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
imap = connect_imap(config)
folders = list_folders(imap)
if not folders:
folders = ["INBOX"]
print(f"Syncing emails from {len(folders)} folder(s) to {output_path}\n")
total_synced = 0
total_skipped = 0
for folder in folders:
safe_folder = sanitize_filename(folder)
folder_path = output_path / safe_folder
folder_path.mkdir(exist_ok=True)
status, data = imap.select(f'"{folder}"' if " " in folder else folder)
if status != "OK":
print(f"Warning: Could not access folder '{folder}', skipping...")
continue
status, data = imap.search(None, "ALL")
if status != "OK":
print(f"Warning: Could not search folder '{folder}', skipping...")
continue
message_ids = data[0].split()
if not message_ids:
print(f" {folder}: No messages")
continue
print(f" {folder}: Processing {len(message_ids)} message(s)...")
synced = 0
skipped = 0
existing_message_ids: set[str] = set()
for existing_file in folder_path.glob("*.eml"):
try:
with open(existing_file, "rb") as f:
existing_msg = email.message_from_bytes(f.read())
existing_msg_id = existing_msg.get("Message-ID", "")
if existing_msg_id:
existing_message_ids.add(existing_msg_id)
except Exception:
pass
for msg_id in message_ids:
try:
status, data = imap.fetch(msg_id, "(RFC822)")
if status != "OK" or not data or not data[0]:
continue
raw_email = data[0][1]
if not isinstance(raw_email, bytes):
continue
msg = email.message_from_bytes(raw_email)
msg_message_id = msg.get("Message-ID", "")
if msg_message_id and msg_message_id in existing_message_ids:
skipped += 1
continue
filename = generate_email_filename(msg)
filepath = folder_path / filename
with open(filepath, "wb") as f:
f.write(raw_email)
synced += 1
if msg_message_id:
existing_message_ids.add(msg_message_id)
except Exception as e:
print(
f" Warning: Error processing message {msg_id.decode()}: {e}"
)
continue
print(f" Synced: {synced} new, {skipped} already existed")
total_synced += synced
total_skipped += skipped
imap.logout()
print("\nSync complete!")
print(f" Total new emails synced: {total_synced}")
print(f" Total emails skipped (already existed): {total_skipped}")
print(f" Output directory: {output_path}")
except (imaplib.IMAP4.error, OSError, ValueError) as e:
print(f"Error syncing emails: {e}", file=sys.stderr)
sys.exit(1)
def main() -> int:
"""Run the protonmail CLI."""
parser = argparse.ArgumentParser(
description="Access and manage ProtonMail emails via Bridge",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--version", action="version", version=f"protonmail {__version__}"
)
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
# List command
list_parser = subparsers.add_parser("list", help="List emails")
list_parser.add_argument(
"folder",
nargs="?",
default="INBOX",
help="Folder to list (default: INBOX)",
)
list_parser.add_argument(
"-n",
"--limit",
type=int,
default=20,
help="Number of emails to show (default: 20)",
)
# Read command
read_parser = subparsers.add_parser("read", help="Read an email")
read_parser.add_argument("message_id", help="ID of the message to read")
# Search command
search_parser = subparsers.add_parser("search", help="Search for emails")
search_parser.add_argument("query", help="Search term (subject or sender)")
search_parser.add_argument(
"-f",
"--folder",
default="INBOX",
help="Folder to search (default: INBOX)",
)
# Send command
subparsers.add_parser("send", help="Send a new email (interactive)")
# Sync command
sync_parser = subparsers.add_parser(
"sync", help="Sync all emails to local .eml files"
)
sync_parser.add_argument(
"-o",
"--output",
default=None,
help="Output directory (default: PROTONMAIL_DATA_DIR or /data/messages/email/protonmail)",
)
args = parser.parse_args()
if args.command == "list":
list_emails(args.folder, args.limit)
elif args.command == "read":
read_email(args.message_id)
elif args.command == "search":
search_emails(args.query, args.folder)
elif args.command == "send":
send_email(load_config())
elif args.command == "sync":
sync_emails(load_config(), args.output)
else:
list_emails()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

View File

@@ -0,0 +1,195 @@
"""Tests for protonmail tool."""
import subprocess
import sys
from email.message import Message
from pathlib import Path
from unittest.mock import patch
from protonmail.main import (
extract_email_address,
generate_email_filename,
get_sync_dir,
list_emails_local,
sanitize_email,
sanitize_filename,
search_emails_local,
)
class TestCLI:
"""CLI interface tests."""
def test_version(self) -> None:
"""--version prints version string."""
result = subprocess.run(
[sys.executable, "-m", "protonmail.main", "--version"],
capture_output=True,
text=True,
)
assert "protonmail" in result.stdout
assert "0.1.0" in result.stdout
def test_help(self) -> None:
"""--help shows usage information."""
result = subprocess.run(
[sys.executable, "-m", "protonmail.main", "--help"],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "ProtonMail" in result.stdout
class TestSanitize:
"""Tests for filename sanitization."""
def test_sanitize_filename_basic(self) -> None:
"""Basic filename sanitization."""
assert sanitize_filename("hello world") == "hello world"
def test_sanitize_filename_special_chars(self) -> None:
"""Special characters replaced with underscores."""
assert sanitize_filename('file<>:"/\\|?*name') == "file_________name"
def test_sanitize_filename_truncation(self) -> None:
"""Long filenames get truncated."""
long_name = "x" * 300
result = sanitize_filename(long_name)
assert len(result) == 200
def test_sanitize_email_basic(self) -> None:
"""Email sanitization replaces @."""
assert sanitize_email("user@domain.com") == "user_domain.com"
def test_sanitize_email_special(self) -> None:
"""Email with angle brackets cleaned."""
assert sanitize_email("<user@domain.com>") == "user_domain.com"
class TestExtractEmail:
"""Tests for email address extraction."""
def test_angle_brackets(self) -> None:
"""Extract email from angle brackets."""
assert extract_email_address("Name <user@example.com>") == "user@example.com"
def test_plain_email(self) -> None:
"""Extract plain email address."""
assert extract_email_address("user@example.com") == "user@example.com"
def test_no_email(self) -> None:
"""Return trimmed string when no email present."""
assert extract_email_address("unknown") == "unknown"
class TestGenerateFilename:
"""Tests for email filename generation."""
def test_basic_filename(self) -> None:
"""Generate filename from message headers."""
msg = Message()
msg["Date"] = "Thu, 17 Jul 2024 01:14:40 +0000"
msg["From"] = "sender@example.com"
msg["To"] = "recipient@example.com"
msg["Subject"] = "Test Subject"
filename = generate_email_filename(msg)
assert filename.startswith("2024-07-17_01-14-40_from_sender_example.com_to_recipient_example.com_")
assert filename.endswith(".eml")
assert "Test Subject" in filename
def test_missing_date(self) -> None:
"""Handle missing date gracefully."""
msg = Message()
msg["From"] = "sender@example.com"
msg["To"] = "recipient@example.com"
filename = generate_email_filename(msg)
assert "unknown-date" in filename
def test_length_limit(self) -> None:
"""Filename respects max_length."""
msg = Message()
msg["Date"] = "Thu, 17 Jul 2024 01:14:40 +0000"
msg["From"] = "sender@example.com"
msg["To"] = "recipient@example.com"
msg["Subject"] = "x" * 300
filename = generate_email_filename(msg, max_length=255)
assert len(filename) <= 255
class TestGetSyncDir:
"""Tests for sync directory resolution."""
def test_protonmail_data_dir_env(self) -> None:
"""PROTONMAIL_DATA_DIR takes priority."""
with patch.dict("os.environ", {"PROTONMAIL_DATA_DIR": "/custom/path"}):
assert get_sync_dir() == Path("/custom/path")
def test_ddata_fallback(self) -> None:
"""Falls back to DDATA when PROTONMAIL_DATA_DIR not set."""
env = {"DDATA": "/mydata"}
with patch.dict("os.environ", env, clear=True):
assert get_sync_dir() == Path("/mydata/messages/email/protonmail")
def test_default(self) -> None:
"""Default path when no env vars set."""
with patch.dict("os.environ", {}, clear=True):
assert get_sync_dir() == Path("/data/messages/email/protonmail")
class TestListEmailsLocal:
"""Tests for local email listing."""
def test_missing_cache(self, tmp_path: Path) -> None:
"""Returns False when cache doesn't exist."""
with patch("protonmail.main.get_sync_dir", return_value=tmp_path / "nope"):
assert list_emails_local() is False
def test_empty_folder(self, tmp_path: Path) -> None:
"""Shows message when folder is empty."""
inbox = tmp_path / "INBOX"
inbox.mkdir()
with patch("protonmail.main.get_sync_dir", return_value=tmp_path):
assert list_emails_local() is True
def test_lists_eml_files(self, tmp_path: Path) -> None:
"""Lists .eml files from cache."""
inbox = tmp_path / "INBOX"
inbox.mkdir()
eml_content = b"From: test@example.com\nSubject: Hello\nDate: Thu, 1 Jan 2024 00:00:00 +0000\n\nBody"
(inbox / "test.eml").write_bytes(eml_content)
with patch("protonmail.main.get_sync_dir", return_value=tmp_path):
assert list_emails_local() is True
class TestSearchEmailsLocal:
"""Tests for local email search."""
def test_missing_cache(self, tmp_path: Path) -> None:
"""Returns False when cache doesn't exist."""
with patch("protonmail.main.get_sync_dir", return_value=tmp_path / "nope"):
assert search_emails_local("test") is False
def test_finds_matching_email(self, tmp_path: Path) -> None:
"""Finds emails matching query."""
inbox = tmp_path / "INBOX"
inbox.mkdir()
eml_content = b"From: test@example.com\nSubject: Invoice 123\nDate: Thu, 1 Jan 2024 00:00:00 +0000\n\nBody"
(inbox / "test.eml").write_bytes(eml_content)
with patch("protonmail.main.get_sync_dir", return_value=tmp_path):
assert search_emails_local("invoice") is True
def test_no_match(self, tmp_path: Path) -> None:
"""Returns True but prints nothing when no match."""
inbox = tmp_path / "INBOX"
inbox.mkdir()
eml_content = b"From: test@example.com\nSubject: Hello\nDate: Thu, 1 Jan 2024 00:00:00 +0000\n\nBody"
(inbox / "test.eml").write_bytes(eml_content)
with patch("protonmail.main.get_sync_dir", return_value=tmp_path):
assert search_emails_local("nonexistent") is True

79
components/protonmail/uv.lock generated Normal file
View File

@@ -0,0 +1,79 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[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 = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[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 = "protonmail"
version = "0.1.0"
source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=7.0.0" }]
[[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 = "9.0.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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]