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:
40
components/protonmail/CLAUDE.md
Normal file
40
components/protonmail/CLAUDE.md
Normal 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.
|
||||
72
components/protonmail/README.md
Normal file
72
components/protonmail/README.md
Normal 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.
|
||||
24
components/protonmail/pyproject.toml
Normal file
24
components/protonmail/pyproject.toml
Normal 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"]
|
||||
3
components/protonmail/src/protonmail/__init__.py
Normal file
3
components/protonmail/src/protonmail/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""ProtonMail email sync via Bridge."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
598
components/protonmail/src/protonmail/main.py
Normal file
598
components/protonmail/src/protonmail/main.py
Normal 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())
|
||||
0
components/protonmail/tests/__init__.py
Normal file
0
components/protonmail/tests/__init__.py
Normal file
195
components/protonmail/tests/test_main.py
Normal file
195
components/protonmail/tests/test_main.py
Normal 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
79
components/protonmail/uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user