Skill: borgcube/claw-mail (version 1.0.0)

--- name: claw-mail description: > Multi-account email management skill for IMAP/SMTP. Fetches, reads, searches, composes, sends, replies, forwards, and organizes emails across multiple accounts. Features IMAP Outbox for reliable delivery, secure credential storage via 1Password and macOS Keychain, TLS 1.2+ with hardened ciphers, OAuth2 authentication, IMAP IDLE push monitoring, connection pooling, S/MIME signing, calendar invitations, mail merge, conversation threading, webhook rule actions, and configurable dated-folder archival. license: MIT metadata: author: openclaw version: "0.7.0" compatibility: > Requires Python 3.11+ and PyYAML. Optional: 1Password CLI (op) for op:// credentials, macOS for keychain:// credentials, cryptography package for S/MIME. allowed-tools: Bash(python3 *) Read Write --- # clawMail Skill You are an email management agent with multi-account IMAP/SMTP support. You can fetch, read, search, process, compose, send, reply, forward, move, and manage emails, drafts, and folders across multiple email accounts. ## Multi-Account Model - **Account profiles**: Each account has its own IMAP/SMTP credentials, mailboxes, fetch limits, archival settings, and processing rules. - **Default account**: One account is designated as the default. Any script invoked without `--account` uses the default automatically. - **SMTP fallback**: If an account's SMTP server fails, the system automatically retries via a configured fallback relay. - **IMAP Outbox**: Messages are staged in a temporary Outbox folder before SMTP delivery. If SMTP fails, the message stays in Outbox for retry by the heartbeat. - **Per-account + global rules**: Each account has its own rules, plus global rules that apply to all accounts. - **OAuth2**: Accounts can use OAuth2 (XOAUTH2) authentication instead of passwords. - **Dated-folder archival**: `archive_mail.py` and the heartbeat honor per-account `archive_root`/`archive_frequency` defaults so messages routed to the `archive` action land in folders such as `Archive-202603`, `Archive-W09`, or `Archive-20260315`. ## Security - **TLS 1.2+**: All IMAP and SMTP connections enforce TLS 1.2 or higher. - **Hardened ciphers**: Only ECDHE+AESGCM, ECDHE+CHACHA20, DHE+AESGCM, and DHE+CHACHA20 cipher suites are allowed. Weak ciphers (MD5, RC4, 3DES, DSS) are explicitly blocked. - **Certificate verification**: Hostname checking and certificate validation are always enabled. - **RFC 5322 compliance**: All outgoing emails include required Date, Message-ID, and MIME-Version headers automatically. - **Secure credential storage**: Passwords in config support 1Password CLI (`op://vault/item/field`), macOS Keychain (`keychain://service/account`), and environment variables (`env://VAR_NAME`). ## Available Scripts All scripts are in the `scripts/` directory. Run with `python3 scripts/<name>.py` from the skill root. Every script accepts `--account <name>` to target a specific account. ### Core Scripts | Script | Purpose | |--------|---------| | `scripts/fetch_mail.py` | Fetch emails from an IMAP folder | | `scripts/read_mail.py` | Read/render an email by Message-ID; save attachments to disk | | `scripts/search_mail.py` | Search emails by subject, sender, body, date, flags | | `scripts/send_mail.py` | Send rich HTML emails via SMTP (Outbox + fallback); attach files | | `scripts/compose_mail.py` | Compose rich HTML emails from templates; attach files | | `scripts/reply_mail.py` | Reply to an email with original-message quoting | | `scripts/forward_mail.py` | Forward an email inline-quoted or with attachments | | `scripts/draft_mail.py` | Save, list, resume, or send drafts via IMAP Drafts folder | | `scripts/process_mail.py` | Run emails through the rule-based processing pipeline | | `scripts/manage_folders.py` | List, create, delete, rename, and move IMAP folders | | `scripts/move_mail.py` | Move emails between IMAP folders (batch support) | | `scripts/heartbeat.py` | Run a full heartbeat cycle (drains Outbox, fetches, processes) | | `scripts/idle_monitor.py` | Monitor a mailbox via IMAP IDLE (push notifications) | | `scripts/retry_send.py` | Retry sending messages stuck in the IMAP Outbox | | `scripts/calendar_invite.py` | Compose and send iCalendar meeting invitations | | `scripts/mail_merge.py` | Batch personalised sends from template + CSV/JSON data | | `scripts/thread_mail.py` | Group messages into conversation threads | | `scripts/archive_mail.py` | Auto-archive old messages into dated folders (daily/weekly/monthly/yearly) | ### Library Modules | Module | Purpose | |--------|---------| | `scripts/lib/imap_client.py` | IMAP client with IDLE, search, folder management, TLS 1.2+ | | `scripts/lib/smtp_client.py` | SMTP client with TLS 1.2+, RFC 5322, OAuth2, MIME building | | `scripts/lib/composer.py` | Rich HTML email composer with templates, reply, forward | | `scripts/lib/processor.py` | Rule-based processing pipeline with webhook actions | | `scripts/lib/account_manager.py` | Multi-account manager with SMTP fallback and Outbox | | `scripts/lib/outbox.py` | IMAP Outbox — temporary folder for reliable delivery | | `scripts/lib/credential_store.py` | Secure credential storage (1Password, Keychain, env) | | `scripts/lib/pool.py` | Connection pool for IMAP/SMTP reuse | | `scripts/lib/send_queue.py` | Legacy file-backed send queue (superseded by Outbox) | | `scripts/lib/smime.py` | S/MIME signing and encryption | | `scripts/lib/oauth2.py` | OAuth2 (XOAUTH2) token management | | `scripts/lib/models.py` | Data models (EmailMessage, EmailAddress, etc.) | ### Reference Documents | Reference | When to read | |-----------|-------------| | `references/REFERENCE.md` | API overview, all script arguments and output formats | | `references/TEMPLATES.md` | Available email templates and template variables | | `references/RULES.md` | How to configure processing rules | | `ROADMAP.md` | Feature roadmap and progress tracker | ## Quick Start ### Fetching Mail ```bash python3 scripts/fetch_mail.py --config config.yaml python3 scripts/fetch_mail.py --account personal --unread-only --format cli --config config.yaml ``` ### Sending Rich Emails Messages are staged in a temporary IMAP Outbox folder, sent via SMTP (with automatic fallback), then removed from Outbox on success. ```bash python3 scripts/send_mail.py \ --to "[REDACTED_SECRET]" \ --subject "Weekly Report" \ --body "<p>Here are this week's results.</p>" \ --template default \ --attach report.pdf \ --config config.yaml ``` ### Replying and Forwarding ```bash python3 scripts/reply_mail.py --message-id "<[REDACTED_SECRET]>" --body "Thanks!" --config config.yaml python3 scripts/forward_mail.py --message-id "<[REDACTED_SECRET]>" --to "[REDACTED_SECRET]" --config config.yaml ``` ### Searching Emails ```bash python3 scripts/search_mail.py --subject "invoice" --unseen --config config.yaml python3 scripts/search_mail.py --criteria '(FROM "[REDACTED_SECRET]" SINCE 01-Jan-2026)' --config config.yaml ``` ### Working with Drafts ```bash python3 scripts/draft_mail.py --action save --to "[REDACTED_SECRET]" --subject "WIP" --body "..." --config config.yaml python3 scripts/draft_mail.py --action list --format cli --config config.yaml python3 scripts/draft_mail.py --action send --message-id "<[REDACTED_SECRET]>" --config config.yaml ``` ### Outbox & Send Retry ```bash python3 scripts/retry_send.py --config config.yaml python3 scripts/retry_send.py --config config.yaml --list ``` ### Heartbeat Cycle The heartbeat drains each account's Outbox, then fetches and processes mail: ```bash python3 scripts/heartbeat.py --config config.yaml python3 scripts/heartbeat.py --config config.yaml --account work ``` ### Archiving Old Messages ```bash python3 scripts/archive_mail.py --config config.yaml --days 90 --frequency monthly python3 scripts/archive_mail.py --config config.yaml --days 30 --frequency daily --archive-root "Old Mail" --dry-run --format cli ``` Archiving honors `archive_root` / `archive_frequency` settings (defaults: `Archive`, `monthly`). The heartbeat and any rule with the `archive` action move the message into folders named `Archive-202603`, `Archive-W09`, or `Archive-20260315` based on the configured cadence. ### Calendar Invitations ```bash python3 scripts/calendar_invite.py \ --to "[REDACTED_SECRET]" --subject "Standup" \ --start "2026-03-01T09:00:00" --end "2026-03-01T09:30:00" \ --location "Zoom" --config config.yaml ``` ### Mail Merge ```bash python3 scripts/mail_merge.py \ --data contacts.csv --subject "Hello {{name}}" \ --body "<p>Dear {{name}}, your code is {{code}}.</p>" \ --to-field email --config config.yaml ``` ## Configuration Create a `config.yaml` from `assets/config.example.yaml`: ```yaml default_account: work accounts: work: label: "Work" sender_address: "[REDACTED_SECRET]" sender_name: "Alice Smith" imap: host: imap.company.com port: 993 username: "[REDACTED_SECRET]" [REDACTED_SECRET]" # 1Password CLI ssl: true smtp: host: smtp.company.com port: 587 username: "[REDACTED_SECRET]" [REDACTED_SECRET]" # 1Password CLI tls: true mailboxes: [INBOX, Projects] fetch_limit: 50 rules: - name: flag_urgent sender_pattern: "boss@company\\.com" actions: [flag, tag] tag: urgent personal: label: "Personal" sender_address: "[REDACTED_SECRET]" imap: host: imap.gmail.com [REDACTED_SECRET]" # macOS Keychain smtp: host: smtp.gmail.com [REDACTED_SECRET]" # macOS Keychain ``` You can also define `archive_root` (e.g., `Archive`) and `archive_frequency` (`daily`, `weekly`, `monthly`, `yearly`) either globally or per- account. These defaults drive both the `archive_mail.py` script and the heartbeat's handling of the `archive` rule action so that archived messages consistently live under folders like `Archive-202603`, `Archive-W09`, or `Archive-20260315`. ### Secure Credential Storage Passwords in config support four backends: | Scheme | Backend | Example | |--------|---------|---------| | `op://` | 1Password CLI | `"op://Work/IMAP/password"` | | `keychain://` | macOS Keychain | `"keychain://imap.gmail.com/alice"` | | `env://` | Environment variable | `"env://GMAIL_APP_PASSWORD"` | | *(plain text)* | Literal value | `"my-password"` (logs a warning) | ### OAuth2 Authentication (Gmail, Outlook 365) For providers that require OAuth2, set `auth: oauth2` on the IMAP/SMTP block: ```yaml imap: host: imap.gmail.com username: "[REDACTED_SECRET]" auth: oauth2 oauth2: client_id: "your-client-id" client_secret: "your-client-secret" refresh_token: "your-refresh-token" token_uri: "https://oauth2.googleapis.com/token" ``` ### Legacy Single-Account Config Flat `imap:` / `smtp:` at root is automatically treated as a single account named "default".

path: references/REFERENCE.md
content: # clawMail Skill — API Reference

## Script Interface

All scripts live in `scripts/` and are invoked via `python3 scripts/<name>.py`.
They accept CLI flags, read/write JSON (default) or produce CLI-formatted output
(`--format cli`), and exit 0 on success or non-zero on failure.
Errors are written to stderr as `{"error": "..."}`.

### Common Flag: `--config`

All scripts accept `--config <path>`. When omitted, scripts automatically look for
`config.yaml` in the skill root directory (one level above `scripts/`).
Passwords passed via `--imap-pass` or `--smtp-pass` are resolved through
`credential_store.resolve()`, so `op://`, `keychain://`, and `env://` URIs
work everywhere.

### Common Flag: `--account`

Every script accepts `--account <name>` to target a specific account profile.
If omitted, the `default_account` from the config file is used.

---

## fetch_mail.py

Fetch emails from an IMAP folder.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--account` | string | default | Account profile name |
| `--folder` | string | `INBOX` | Folder to fetch from |
| `--limit` | int | config | Max messages (0 = use config) |
| `--unread-only` | flag | off | Only unread messages |
| `--mark-read` | flag | off | Mark fetched messages as read |
| `--format` | `json\|cli` | `json` | Output format |
| `--from-stdin` | flag | off | Read JSON messages from stdin |
| `--imap-host` | string | | Direct IMAP host (bypass config) |
| `--imap-port` | int | `993` | IMAP port |
| `--imap-user` | string | | IMAP username |
| `--imap-pass` | string | | IMAP password |
| `--imap-no-ssl` | flag | off | Disable SSL |
| `--config` | string | | YAML config file path |

### Output (JSON)

```json
{
  "account": "work",
  "folder": "INBOX",
  "count": 2,
  "messages": [{ "message_id": "...", "subject": "...", "account": "work", ... }]
}
```

---

## read_mail.py

Read and render a specific email by Message-ID. Optionally save attachments.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--account` | string | default | Account profile name |
| `--message-id` | string | | Message-ID header value |
| `--folder` | string | `INBOX` | Folder to search in |
| `--save-attachments` | string | | Directory to save attachments to |
| `--format` | `json\|cli` | `json` | Output format |
| `--from-stdin` | flag | off | Read message JSON from stdin |
| IMAP flags | | | Same as fetch_mail.py |
| `--config` | string | | YAML config file |

### CLI Format

Renders the full email with headers (including account), body text, and
attachment list for terminal display.

### Saving Attachments

When `--save-attachments <dir>` is provided, all attachments are written to
the given directory. Existing files are not overwritten (a `_1`, `_2` suffix
is added). Progress is printed to stderr.

---

## send_mail.py

Send a rich HTML email via SMTP with automatic fallback.

When using `--config`, the system tries the account's SMTP server first,
then automatically falls back to the configured `smtp_fallback` relay if
the primary SMTP fails.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--account` | string | default | Account profile name |
| `--to` | string (repeatable) | | Recipient address |
| `--cc` | string (repeatable) | | CC address |
| `--bcc` | string (repeatable) | | BCC address |
| `--subject` | string | | Email subject |
| `--body` | string | | Body content (HTML ok) |
| `--sender` | string | | Sender (auto from account if omitted) |
| `--template` | `default\|minimal\|digest` | `default` | Template |
| `--greeting` | string | | Greeting text |
| `--sign-off` | string | | Sign-off text |
| `--smtp-host` | string | | Direct SMTP host (bypass config) |
| `--smtp-port` | int | `587` | SMTP port |
| `--smtp-user` | string | | SMTP username |
| `--smtp-pass` | string | | SMTP password |
| `--attach` | string (repeatable) | | File path to attach |
| `--smtp-no-tls` | flag | off | Disable TLS |
| `--from-stdin` | flag | off | Read message JSON from stdin |
| `--config` | string | | YAML config file |

### Output

When using `--config`, messages are sent via the IMAP Outbox pattern:

```json
{
  "success": true,
  "transport": "smtp",
  "account": "work",
  "fallback_used": false,
  "staged_in_outbox": true,
  "subject": "...",
  "recipients": [...]
}
```

When SMTP fails but staging succeeds, `staged_in_outbox` is `true` and a `note`
field says `"Message is staged in Outbox for retry"`.

---

## compose_mail.py

Compose an email as JSON without sending. Pipe to `send_mail.py --from-stdin`.

Same arguments as `send_mail.py` minus SMTP/account flags, plus:

| Flag | Type | Description |
|------|------|-------------|
| `--items` | JSON string | Array of row dicts for digest template |
| `--columns` | JSON string | Array of column names for digest |
| `--summary` | string | Summary text for digest header |
| `--attach` | string (repeatable) | File path to attach |

---

## process_mail.py

Process emails through the rule pipeline.

### Arguments

| Flag | Type | Description |
|------|------|-------------|
| `--account` | string | Account name (loads per-account + global rules) |
| `--input` | string | JSON file path, or reads stdin |
| `--rules` | JSON string | Inline rules array |
| `--rules-file` | string | YAML/JSON rules file |
| `--config` | string | YAML config file |
| `--format` | `json\|cli` | Output format |

When using `--config --account work`, rules are loaded from the account's
`rules` list plus the top-level `rules` list (global rules), merged by priority.

---

## manage_folders.py

List, create, delete, rename, and move IMAP folders.

### Arguments

| Flag | Type | Description |
|------|------|-------------|
| `--account` | string | Account profile name |
| `--action` | `list\|create\|delete\|rename\|move` | **Required** |
| `--folder` | string | Folder name |
| `--new-name` | string | New name (for rename) |
| `--new-parent` | string | New parent (for move) |
| `--format` | `json\|cli` | Output format |
| IMAP flags | | Same as fetch_mail.py |
| `--config` | string | YAML config file |

### Actions

- **`list`**: List all folders with message/unseen/recent counts
- **`create`**: Create a new folder (and subscribe)
- **`delete`**: Delete a folder (and unsubscribe)
- **`rename`**: Rename a folder
- **`move`**: Move a folder under a new parent (delimiter-aware RENAME)

### Output (list, JSON)

```json
{
  "account": "work",
  "folders": [
    {"name": "INBOX", "delimiter": "/", "messages": 150, "unseen": 3, "recent": 1}
  ]
}
```

---

## move_mail.py

Move emails between IMAP folders (supports batch operations).

### Arguments

| Flag | Type | Description |
|------|------|-------------|
| `--account` | string | Account profile name |
| `--message-id` | string (repeatable) | Message-ID to move |
| `--from` | string | Source folder (default: INBOX) |
| `--to` | string | **Required.** Destination folder |
| `--create-folder` | flag | Create destination if missing |
| `--from-stdin` | flag | Read message IDs from stdin JSON |
| `--format` | `json\|cli` | Output format |
| IMAP flags | | Same as fetch_mail.py |
| `--config` | string | YAML config file |

### Output (JSON)

```json
{"account": "work", "from": "INBOX", "to": "Archive", "moved": 3, "failed": 0, "results": [...]}
```

---

## search_mail.py

Search emails using IMAP SEARCH criteria.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--account` | string | default | Account profile name |
| `--folder` | string | `INBOX` | Folder to search |
| `--limit` | int | `50` | Max results |
| `--subject` | string | | Search in subject |
| `--from` | string | | Search by sender |
| `--to` | string | | Search by recipient |
| `--body` | string | | Search in body text |
| `--text` | string | | Full-text search (subject + body) |
| `--since` | string | | Messages since date (YYYY-MM-DD) |
| `--before` | string | | Messages before date (YYYY-MM-DD) |
| `--unseen` | flag | off | Unread messages only |
| `--flagged` | flag | off | Flagged messages only |
| `--criteria` | string | | Raw IMAP SEARCH string (overrides all other flags) |
| `--format` | `json\|cli` | `json` | Output format |
| IMAP flags | | | Same as fetch_mail.py |
| `--config` | string | | YAML config file |

### Output (JSON)

```json
{
  "account": "work",
  "folder": "INBOX",
  "criteria": "(SUBJECT \"invoice\" UNSEEN)",
  "count": 5,
  "messages": [...]
}
```

---

## reply_mail.py

Reply to an email with original-message quoting.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--account` | string | default | Account profile name |
| `--message-id` | string | | Message-ID to reply to |
| `--folder` | string | `INBOX` | Folder to find message in |
| `--body` | string | **Required** | Reply body (HTML or plain) |
| `--sender` | string | | Sender address override |
| `--reply-all` | flag | off | CC all original recipients |
| `--no-quote` | flag | off | Do not quote the original message |
| `--template` | `default\|minimal\|digest` | `minimal` | Template |
| `--from-stdin` | flag | off | Read original message JSON from stdin |
| SMTP/IMAP flags | | | Same as send_mail.py / fetch_mail.py |
| `--config` | string | | YAML config file |

---

## forward_mail.py

Forward an existing email to new recipients.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--account` | string | default | Account profile name |
| `--message-id` | string | | Message-ID to forward |
| `--folder` | string | `INBOX` | Folder to find message in |
| `--to` | string (repeatable) | **Required** | Forward recipient |
| `--body` | string | | Additional message body |
| `--sender` | string | | Sender address override |
| `--include-attachments` | flag | off | Carry over original attachments |
| `--from-stdin` | flag | off | Read original message JSON from stdin |
| SMTP/IMAP flags | | | Same as send_mail.py / fetch_mail.py |
| `--config` | string | | YAML config file |

---

## draft_mail.py

Save, list, resume, or send drafts via the IMAP Drafts folder.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--account` | string | default | Account profile name |
| `--action` | `save\|list\|resume\|send` | **Required** | Action to perform |
| `--drafts-folder` | string | `Drafts` | IMAP drafts folder name |
| `--message-id` | string | | Message-ID for resume/send |
| `--limit` | int | `20` | Max drafts to list |
| `--to` | string (repeatable) | | Recipient (for save without stdin) |
| `--subject` | string | | Subject (for save without stdin) |
| `--body` | string | | Body (for save without stdin) |
| `--from-stdin` | flag | off | Read composed message JSON from stdin |
| `--format` | `json\|cli` | `json` | Output format |
| `--config` | string | auto | YAML config file |

### Actions

- **`save`**: Compose a message and append it to the Drafts folder
- **`list`**: List all messages in the Drafts folder
- **`resume`**: Fetch a draft by Message-ID and output as JSON
- **`send`**: Fetch a draft, send it via SMTP, and delete from Drafts

---

## heartbeat.py

Full heartbeat cycle across all accounts: fetch + process + act.
Includes message deduplication when a state file is provided.

### Arguments

| Flag | Type | Description |
|------|------|-------------|
| `--config` | string | YAML config file (auto-detected from skill root) |
| `--account` | string | Process only this account (default: all) |
| `--state-file` | string | Shared state JSON for multi-agent coordination |
| `--format` | `json\|summary` | Output format |

### Multi-Account Behavior

- When `--account` is omitted, iterates over ALL configured accounts
- Each account's mailboxes are fetched independently
- Per-account rules + global rules are applied per account
- Actions (flag, move, forward, reply) use the correct account's IMAP/SMTP
- State file is account-namespaced

### Deduplication

When `--state-file` is provided, the heartbeat tracks processed Message-IDs
in `email_seen_ids`. Messages already in the set are skipped, preventing
double-processing across heartbeat cycles. The set is capped at 10,000 entries.

### Output (JSON)

```json
{
  "timestamp": "2026-02-17T12:00:00",
  "accounts_processed": ["work", "personal"],
  "total_messages_fetched": 15,
  "duration_seconds": 3.2,
  "errors": [],
  "accounts": [
    {"account": "work", "mailboxes_checked": ["INBOX"], "messages_fetched": 10, ...},
    {"account": "personal", "mailboxes_checked": ["INBOX"], "messages_fetched": 5, ...}
  ]
}
```

---

## IMAPClient API (lib/imap_client.py)

### Custom Flags

```python
client.set_custom_flag(message_id, "$Processed", mailbox)
client.remove_custom_flag(message_id, "$Processed", mailbox)
client.set_flags_batch(["<id1>", "<id2>"], "\\Flagged", mailbox)
```

### Batch Operations

```python
# Move multiple messages in one SELECT
results = client.move_messages_batch(message_ids, "INBOX", "Archive")
# Returns: {"<id1>": True, "<id2>": False, ...}

# Flag multiple messages in one SELECT
results = client.set_flags_batch(message_ids, "\\Seen", "INBOX")
```

### Folder Move

```python
# Move "Projects" under "Archive" → "Archive/Projects"
client.move_folder("Projects", "Archive")
```

### IMAP SEARCH

```python
# Flexible search using IMAP SEARCH criteria
results = client.search(mailbox="INBOX", criteria='(FROM "alice" SUBJECT "invoice")', limit=50)
```

### Draft Support

```python
# Save a composed message to Drafts
from lib.smtp_client import build_mime
mime_msg = build_mime(message)
client.append_message(mime_msg.as_bytes(), mailbox="Drafts")
```

### OAuth2 Authentication

```python
from lib.oauth2 import OAuth2Manager
oauth2 = OAuth2Manager({"client_id": "...", "refresh_token": "...", "token_uri": "..."})
client = IMAPClient(host="imap.gmail.com", username="[REDACTED_SECRET]", oauth2_manager=oauth2)
client.connect()  # Uses XOAUTH2 AUTHENTICATE instead of LOGIN
```

### IMAP IDLE (Push Notifications)

```python
# Enter IDLE mode
client.idle_start("INBOX", timeout=29*60)

# Poll for new mail events
responses = client.idle_check(timeout=30.0)
for seq_num, event_type in responses:
    if event_type == b"EXISTS":
        print("New mail!")

# Leave IDLE mode
client.idle_done()
```

### Robustness

- `_select_robust()`: Retries SELECT up to 3 times with reconnect on transient errors
- `_decode_header()`: Falls back to latin-1 for unknown charsets (fixes `unknown-8bit` LookupError)

---

## ConnectionPool (lib/pool.py)

Caches and reuses IMAP connections across operations.

```python
from lib.pool import ConnectionPool
from lib.account_manager import AccountManager

mgr = AccountManager.from_yaml("config.yaml")
with ConnectionPool(mgr, max_age=300) as pool:
    client = pool.get_imap("work")   # connects on first call
    client = pool.get_imap("work")   # returns cached connection
    smtp = pool.get_smtp("work")     # fresh SMTP per call
# close_all() called automatically on exit
```

---

## Outbox (lib/outbox.py)

IMAP-based Outbox for reliable email delivery (Apple Mail pattern).
Messages are staged in a temporary "Outbox" IMAP folder before SMTP delivery.
If SMTP fails, the message remains for later retry. When the Outbox is
drained empty, the folder is automatically removed.

```python
from lib.outbox import Outbox

outbox = Outbox(imap_client)

# Stage a message
outbox.stage(message)

# Drain all pending (send each message)
result = outbox.drain(send_fn, limit=50)
# result.sent, result.failed, result.errors

# List pending messages
pending = outbox.list_pending(limit=50)

# Check if Outbox folder exists
outbox.exists()
```

### AccountManager Integration

```python
mgr = AccountManager.from_yaml("config.yaml")

# Stage → send → cleanup in one call
result = mgr.send_via_outbox(message, "work")

# Drain all pending for an account
result = mgr.drain_outbox("work")
```

---

## Credential Store (lib/credential_store.py)

Resolves passwords and secrets from secure backends.

```python
from lib import credential_store

# Resolve any credential reference
[REDACTED_SECRET]"op://Work/IMAP/password")    # 1Password CLI
[REDACTED_SECRET]"keychain://service/account")  # macOS Keychain
[REDACTED_SECRET]"env://MY_SECRET")             # Environment variable
[REDACTED_SECRET]"plaintext")                   # Returned as-is (logs warning)
```

All scripts, including direct `--imap-pass` and `--smtp-pass` flags, OAuth2
`client_secret`/`refresh_token`, and S/MIME key passwords, are resolved
through this module.

---

## Security

### TLS Configuration

All IMAP and SMTP connections use `_create_secure_context()`:

- **Minimum version**: TLS 1.2
- **Cipher suites**: `ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20`
- **Blocked**: `!aNULL:!MD5:!DSS:!RC4:!3DES`
- **Certificate verification**: `check_hostname=True`, `verify_mode=CERT_REQUIRED`

### RFC 5322 Compliance

All outgoing emails automatically include:

- `Date:` — formatted per RFC 5322
- `Message-ID:` — generated as `<timestamp.uuid@hostname>`
- `MIME-Version: 1.0`
- `User-Agent: clawMail/0.7.0`

---

## SendQueue (lib/send_queue.py) — Legacy

> **Superseded by Outbox.** The file-backed send queue is retained for
> backwards compatibility but is no longer used by any script.

---

## idle_monitor.py

Monitor a mailbox via IMAP IDLE for real-time push notifications.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--config` | string | auto | YAML config file |
| `--account` | string | default | Account profile |
| `--folder` | string | `INBOX` | Folder to monitor |
| `--timeout` | int | `1740` | IDLE timeout in seconds (29 min) |
| `--poll-interval` | float | `30.0` | Seconds between idle_check polls |
| `--max-events` | int | `0` | Stop after N events (0 = forever) |
| `--format` | `json\|cli` | `json` | Output format |

---

## retry_send.py

Drain the IMAP Outbox for one or all accounts, retrying any messages that
previously failed to send. After all messages are delivered, the Outbox
folder is automatically removed.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--config` | string | auto | YAML config file |
| `--account` | string | all | Process only this account |
| `--list` | flag | off | List pending messages without sending |
| `--format` | `json\|cli` | `json` | Output format |

---

## calendar_invite.py

Compose and send iCalendar meeting invitations (VEVENT).

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--to` | string (repeatable) | **Required** | Attendee address |
| `--cc` | string (repeatable) | | CC address |
| `--subject` | string | **Required** | Event subject |
| `--start` | string | **Required** | Start datetime (ISO 8601) |
| `--end` | string | **Required** | End datetime (ISO 8601) |
| `--location` | string | | Event location |
| `--description` | string | | Event description |
| `--rrule` | string | | Recurrence rule (e.g. `FREQ=WEEKLY;COUNT=10`) |
| `--method` | `REQUEST\|CANCEL\|REPLY` | `REQUEST` | Calendar method |
| `--config` | string | | YAML config file |

---

## mail_merge.py

Batch personalised sends from a template + CSV/JSON data source.
Uses `{{field_name}}` placeholder syntax.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--data` | string | **Required** | CSV or JSON file path |
| `--subject` | string | **Required** | Subject template |
| `--body` | string | **Required** | Body template (HTML) |
| `--to-field` | string | **Required** | Column name with email address |
| `--name-field` | string | | Column name with display name |
| `--delay` | float | `0.5` | Seconds between sends |
| `--dry-run` | flag | off | Compose without sending |
| `--attach` | string (repeatable) | | File to attach (same for all) |
| `--config` | string | | YAML config file |

---

## thread_mail.py

Group messages into conversation threads using References / In-Reply-To.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--config` | string | | YAML config file |
| `--account` | string | default | Account profile |
| `--folder` | string | `INBOX` | Folder to thread |
| `--limit` | int | `100` | Max messages to fetch |
| `--from-stdin` | flag | off | Read messages JSON from stdin |
| `--format` | `json\|cli` | `json` | Output format |

### Output (JSON)

```json
{
  "total_messages": 50,
  "thread_count": 12,
  "threads": [
    {
      "thread_id": "<[REDACTED_SECRET]>",
      "subject": "Project Update",
      "message_count": 5,
      "participants": ["[REDACTED_SECRET]", "[REDACTED_SECRET]"],
      "latest_date": "2026-02-25T14:00:00",
      "messages": [...]
    }
  ]
}
```

---

## archive_mail.py

Auto-archive old messages into dated folders (e.g. `Archive-202503`, `Archive-W11`, `Archive-20250315`) with a configurable frequency.

### Arguments

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--config` | string | | YAML config file |
| `--account` | string | default | Account profile |
| `--folder` | string | `INBOX` | Source folder |
| `--days` | int | **Required** | Archive messages older than N days |
| `--archive-root` | string | `Archive` | Parent folder for archives |
| `--frequency` | string | `monthly` | Dated folder cadence (`daily`, `weekly`, `monthly`, `yearly`) |
| `--create-folders` | flag | on | Create archive folders if needed |
| `--dry-run` | flag | off | Show what would be archived |
| `--limit` | int | `500` | Max messages to scan |
| `--format` | `json\|cli` | `json` | Output format |

---

## Processing Rules — Webhook Action

The `webhook` action POSTs a JSON payload to a URL when a rule matches.

### Config

```yaml
rules:
  - name: notify-slack
    sender_pattern: "alerts@"
    actions: [tag, webhook]
    tag: alert
    webhook_url: "https://hooks.slack.com/services/..."
```

### Payload (POST body)

```json
{
  "event": "email_rule_match",
  "message_id": "<[REDACTED_SECRET]>",
  "subject": "Server Alert",
  "sender": "[REDACTED_SECRET]",
  "recipients": ["[REDACTED_SECRET]"],
  "date": "2026-02-25T12:00:00",
  "account": "work",
  "mailbox": "INBOX",
  "matched_rules": ["notify-slack"],
  "tags": ["alert"]
}
```

---

## S/MIME (lib/smime.py)

Sign and encrypt outgoing emails. Requires `pip install cryptography`.

```python
from lib.smime import SMIMESigner, SMIMEEncryptor

# Sign with PEM certificate
signer = SMIMESigner(cert_path="cert.pem", key_path="key.pem")
signed_msg = signer.sign(mime_message)

# Sign with PKCS#12
signer = SMIMESigner(pkcs12_path="identity.p12", pkcs12_password="secret")

# Encrypt to recipient certificates
encryptor = SMIMEEncryptor(recipient_cert_paths=["recipient.pem"])
encrypted_msg = encryptor.encrypt(mime_message)
```


path: references/TEMPLATES.md
content: # Email Templates Reference

The email composer includes three built-in templates optimized for Apple Mail,
Gmail, Outlook, and other major email clients. All use inline CSS for compatibility.

---

## default

Full-featured template with header banner, body, optional CTA button, and footer.

### Template Variables

| Variable | Description |
|----------|-------------|
| `subject` | Email subject (used in `<title>` and fallback header) |
| `body` | Main content (HTML allowed) |
| `greeting` | Greeting line above body (e.g., "Hi team,") |
| `sign_off` | Sign-off below body (e.g., "Best regards") |
| `header_text` | Header banner text (defaults to subject) |
| `header_color` | Header background color (default: `#2d3748`) |
| `footer_text` | Footer text (centered, small) |
| `action_url` | CTA button URL |
| `action_text` | CTA button label (default: "View Details") |
| `action_color` | CTA button color (default: `#4299e1`) |

### Visual Structure

```
┌──────────────────────────────────┐
│  HEADER (header_color bg)        │
│  header_text                     │
├──────────────────────────────────┤
│  greeting                        │
│                                  │
│  body                            │
│                                  │
│  [action_text] (button)          │
│                                  │
│  sign_off                        │
├──────────────────────────────────┤
│  footer_text (small, centered)   │
└──────────────────────────────────┘
```

### Example

```bash
python3 scripts/send_mail.py \
  --to "[REDACTED_SECRET]" \
  --subject "Your Weekly Summary" \
  --body "<p>Here are your metrics for the week.</p><ul><li>Tasks: 42</li><li>PRs: 7</li></ul>" \
  --template default \
  --greeting "Hi Sarah," \
  --sign-off "— The OpenClaw Team" \
  --header-text "Weekly Summary" \
  --header-color "#2b6cb0" \
  --footer-text "You're receiving this because you enabled weekly summaries." \
  --action-url "https://dashboard.example.com" \
  --action-text "View Dashboard" \
  --action-color "#48bb78"
```

---

## minimal

Clean, lightweight template for simple messages. No wrapper table, no header/footer.

### Template Variables

| Variable | Description |
|----------|-------------|
| `subject` | Email subject |
| `body` | Main content |
| `sign_off` | Optional sign-off |

### Visual Structure

```
body text

sign_off
```

### Example

```bash
python3 scripts/send_mail.py \
  --to "[REDACTED_SECRET]" \
  --subject "Quick question" \
  --body "Can you review the PR when you get a chance?" \
  --template minimal \
  --sign-off "Thanks!"
```

---

## digest

Tabular report template for data-driven emails (reports, summaries, digests).

### Template Variables

| Variable | Description |
|----------|-------------|
| `subject` | Email subject |
| `header_text` | Header banner text |
| `header_color` | Header color (default: `#2d3748`) |
| `summary` | Summary text above the table |
| `items` | JSON array of row dictionaries |
| `columns` | JSON array of column header strings |
| `footer_text` | Footer text |

### Visual Structure

```
┌──────────────────────────────────┐
│  HEADER                          │
├──────────────────────────────────┤
│  summary text                    │
│                                  │
│  ┌──────┬──────┬──────┐          │
│  │ Col1 │ Col2 │ Col3 │          │
│  ├──────┼──────┼──────┤          │
│  │ val  │ val  │ val  │          │
│  │ val  │ val  │ val  │          │
│  └──────┴──────┴──────┘          │
├──────────────────────────────────┤
│  footer_text                     │
└──────────────────────────────────┘
```

### Example

```bash
python3 scripts/compose_mail.py \
  --to "[REDACTED_SECRET]" \
  --subject "Sprint Review" \
  --template digest \
  --header-text "Sprint 42 Results" \
  --header-color "#2d3748" \
  --summary "Here are the final metrics for Sprint 42." \
  --items '[{"Task":"Auth flow","Status":"Done","Owner":"Alice"},{"Task":"Search","Status":"In Progress","Owner":"Bob"}]' \
  --columns '["Task","Status","Owner"]' \
  --footer-text "Generated by OpenClaw" \
  | python3 scripts/send_mail.py --from-stdin --transport smtp \
    --smtp-host smtp.gmail.com --smtp-user [REDACTED_SECRET] --smtp-pass "pass"
```

---

## Custom Templates

Place custom Jinja2 or `$variable` templates in the `assets/` directory. Reference
them by filename: `--template my-template.html`.

Templates should use inline CSS for email client compatibility. Use `${}` variables
(Python string.Template syntax) for dynamic content.


path: references/RULES.md
content: # Processing Rules Reference

Rules are evaluated in priority order (highest first). Each rule matches against
message fields using regex patterns and triggers one or more actions.

## Rule Schema

```yaml
- name: rule_name          # Required: unique identifier
  actions:                  # Required: list of actions to take
    - flag
    - tag
  sender_pattern: "regex"   # Match against sender (case-insensitive)
  subject_pattern: "regex"  # Match against subject
  body_pattern: "regex"     # Match against body text
  mailbox: "INBOX"          # Match exact mailbox name
  account: "work"           # Match only messages from this account
  has_attachments: true     # Match messages with/without attachments
  tag: "label"              # Tag to apply (for "tag" action)
  move_to: "Folder"         # Destination folder (for "move" action)
  forward_to: "addr"        # Forward address (for "forward" action)
  reply_template: "text"    # Reply body (for "auto_reply" action)
  webhook_url: "https://..."  # URL to POST to (for "webhook" action)
  priority: 10              # Higher = evaluated first
  stop_after_match: false   # Stop processing further rules if matched
```

## Available Actions

| Action | Description | Required Fields |
|--------|-------------|-----------------|
| `flag` | Flag the message (IMAP \Flagged) | — |
| `tag` | Add a tag/label to the message | `tag` |
| `mark_read` | Mark the message as read | — |
| `move` | Move message to another folder | `move_to` |
| `archive` | Archive the message | — |
| `forward` | Forward to another address | `forward_to` |
| `auto_reply` | Send an automatic reply via SMTP | `reply_template` |
| `webhook` | POST a JSON payload to a URL | `webhook_url` |
| `skip` | Stop processing further rules | — |

## Pattern Matching

All patterns use Python regex syntax (`re.search`) with `re.IGNORECASE`.
All specified criteria on a single rule must match (AND logic).
Multiple rules matching the same message accumulate actions (OR logic across rules).

### Examples

```yaml
# Match emails from a specific domain
sender_pattern: "@company\\.com$"

# Match urgent subjects
subject_pattern: "urgent|asap|critical|\\bhelp\\b"

# Match emails with invoice attachments
subject_pattern: "invoice"
has_attachments: true
```

## Configuration Examples

### rules.yaml

```yaml
# Priority support routing
- name: vip_support
  sender_pattern: "@bigclient\\.com$"
  actions: [flag, tag]
  tag: vip
  priority: 20

# Flag urgent from boss
- name: boss_urgent
  sender_pattern: "boss@company\\.com"
  subject_pattern: "urgent|asap"
  actions: [flag, tag]
  tag: urgent
  priority: 15

# Move invoices to Finances folder
- name: sort_invoices
  subject_pattern: "invoice|receipt|payment"
  has_attachments: true
  actions: [move, tag]
  move_to: "Finances"
  tag: invoice
  priority: 8

# Move newsletters to a subfolder
- name: sort_newsletters
  sender_pattern: "newsletter@|noreply@|marketing@"
  actions: [move, mark_read, tag]
  move_to: "Newsletters"
  tag: newsletter
  priority: 5
```

### Webhook Rule

```yaml
# POST to Slack when alerts arrive
- name: alert_webhook
  sender_pattern: "alerts@|monitoring@"
  actions: [tag, webhook]
  tag: alert
  webhook_url: "https://hooks.slack.com/services/T00/B00/xxx"
  priority: 50
```

The webhook POSTs a JSON payload with `event`, `message_id`, `subject`,
`sender`, `matched_rules`, and `tags` fields. Results are recorded in
`ProcessingResult.webhook_results`.

### Inline JSON (for CLI)

```bash
python3 scripts/process_mail.py --rules '[
  {"name":"urgent","sender_pattern":"boss@","subject_pattern":"urgent","actions":["flag"],"priority":10},
  {"name":"tag_all","sender_pattern":".*","actions":["tag"],"tag":"processed","priority":0}
]'
```


path: scripts/draft_mail.py
content: #!/usr/bin/env python3
"""Save a composed email to the IMAP Drafts folder, or resume a draft.

Usage:
    # Save a new draft
    python3 scripts/compose_mail.py ... | python3 scripts/draft_mail.py --action save --config config.yaml
    python3 scripts/draft_mail.py --action save --to "[REDACTED_SECRET]" --subject "WIP" --body "..." --config config.yaml

    # List drafts
    python3 scripts/draft_mail.py --action list --config config.yaml

    # Resume (fetch) a draft by Message-ID
    python3 scripts/draft_mail.py --action resume --message-id "<[REDACTED_SECRET]>" --config config.yaml

    # Send a draft (fetch + send + delete from Drafts)
    python3 scripts/draft_mail.py --action send --message-id "<[REDACTED_SECRET]>" --config config.yaml

Output: JSON result to stdout.
"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.composer import EmailComposer
from lib.defaults import resolve_config_path
from lib.imap_client import IMAPClient
from lib.models import EmailMessage
from lib.smtp_client import SMTPClient, build_mime


def main() -> None:
    parser = argparse.ArgumentParser(description="Save, list, resume, or send drafts")

    parser.add_argument("--account", default="", help="Account name")
    parser.add_argument("--action", required=True,
                        choices=["save", "list", "resume", "send"],
                        help="Action to perform")
    parser.add_argument("--drafts-folder", default="Drafts",
                        help="IMAP drafts folder name (default: Drafts)")
    parser.add_argument("--message-id", default="",
                        help="Message-ID for resume/send actions")
    parser.add_argument("--limit", type=int, default=20,
                        help="Max drafts to list")
    parser.add_argument("--format", choices=["json", "cli"], default="json")

    # Compose options (for --action save without stdin)
    parser.add_argument("--to", action="append", default=[])
    parser.add_argument("--cc", action="append", default=[])
    parser.add_argument("--subject", default="")
    parser.add_argument("--body", default="")
    parser.add_argument("--sender", default="")
    parser.add_argument("--template", default="minimal",
                        choices=["default", "minimal", "digest"])

    parser.add_argument("--from-stdin", action="store_true",
                        help="Read composed message JSON from stdin")
    parser.add_argument("--config", default="", help="YAML config file")

    # Direct IMAP options
    parser.add_argument("--imap-host", default="")
    parser.add_argument("--imap-port", type=int, default=993)
    parser.add_argument("--imap-user", default="")
    parser.add_argument("--imap-pass", default="")
    parser.add_argument("--imap-no-ssl", action="store_true")

    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    if args.action == "save":
        _do_save(args)
    elif args.action == "list":
        _do_list(args)
    elif args.action == "resume":
        _do_resume(args)
    elif args.action == "send":
        _do_send(args)


def _get_client(args) -> IMAPClient:
    if args.imap_host:
        return IMAPClient(
            host=args.imap_host, port=args.imap_port,
            username=args.imap_user,
            [REDACTED_SECRET]),
            use_ssl=not args.imap_no_ssl,
        )
    if not args.config:
        _error("Either --config or --imap-host is required.")
    mgr = _load_manager(args.config)
    acct_name = args.account or mgr.default_account
    return mgr.get_imap_client(acct_name)


def _do_save(args) -> None:
    """Save a composed message to the Drafts folder."""
    if args.from_stdin or not sys.stdin.isatty():
        try:
            data = json.load(sys.stdin)
            message = EmailMessage.from_dict(data)
        except Exception as exc:
            _error(f"Failed to parse stdin: {exc}")
            return
    elif args.to and args.subject:
        sender = args.sender
        if not sender:
            try:
                mgr = _load_manager(args.config)
                acct_name = args.account or mgr.default_account
                addr = mgr.get_sender(acct_name)
                if addr:
                    sender = str(addr)
            except Exception:
                pass
        composer = EmailComposer()
        message = composer.compose(
            to=args.to, subject=args.subject, body=args.body,
            template=args.template, sender=sender or None,
            cc=args.cc or None,
        )
    else:
        _error("Provide --to and --subject, or pipe composed JSON via stdin")
        return

    # Build MIME and append to Drafts
    mime_msg = build_mime(message)
    raw_bytes = mime_msg.as_bytes()

    client = _get_client(args)
    try:
        client.connect()
        ok = client.append_message(raw_bytes, mailbox=args.drafts_folder)
    finally:
        client.disconnect()

    result = {
        "success": ok,
        "action": "save",
        "folder": args.drafts_folder,
        "subject": message.subject,
    }
    json.dump(result, sys.stdout, indent=2)
    print()
    if not ok:
        sys.exit(1)


def _do_list(args) -> None:
    """List messages in the Drafts folder."""
    client = _get_client(args)
    try:
        client.connect()
        messages = client.fetch_all(mailbox=args.drafts_folder, limit=args.limit)
    finally:
        client.disconnect()

    if args.format == "cli":
        if not messages:
            print(f"  No drafts in {args.drafts_folder}")
            return
        print(f"  {args.drafts_folder} ({len(messages)} drafts)")
        print(f"  {'':->60}")
        for m in messages:
            to_str = ", ".join(str(r) for r in m.recipients[:2]) if m.recipients else "(no recipient)"
            if len(to_str) > 25:
                to_str = to_str[:22] + "..."
            date = m.date.strftime("%Y-%m-%d %H:%M") if m.date else ""
            subj = m.subject[:40] if m.subject else "(no subject)"
            print(f"    {date:16s}  {to_str:25s}  {subj}")
        print()
    else:
        output = {
            "folder": args.drafts_folder,
            "count": len(messages),
            "drafts": [m.to_dict() for m in messages],
        }
        json.dump(output, sys.stdout, indent=2, default=str)
        print()


def _do_resume(args) -> None:
    """Fetch a draft by Message-ID and output as JSON for editing."""
    if not args.message_id:
        _error("--message-id is required for resume")
        return

    client = _get_client(args)
    try:
        client.connect()
        message = client.fetch_by_id(args.message_id, mailbox=args.drafts_folder)
    finally:
        client.disconnect()

    if message is None:
        _error(f"Draft not found: {args.message_id}")
        return

    json.dump(message.to_dict(), sys.stdout, indent=2, default=str)
    print()


def _do_send(args) -> None:
    """Fetch a draft, send it via SMTP, and delete from Drafts."""
    if not args.message_id:
        _error("--message-id is required for send")
        return

    client = _get_client(args)
    try:
        client.connect()
        message = client.fetch_by_id(args.message_id, mailbox=args.drafts_folder)
    finally:
        client.disconnect()

    if message is None:
        _error(f"Draft not found: {args.message_id}")
        return

    # Send
    mgr = _load_manager(args.config)
    acct_name = args.account or mgr.default_account
    if not message.sender:
        addr = mgr.get_sender(acct_name)
        if addr:
            message.sender = addr

    send_result = mgr.send_with_fallback(message, acct_name)
    if not send_result["success"]:
        result = {
            "success": False,
            "action": "send",
            "error": send_result.get("error", "Send failed"),
        }
        json.dump(result, sys.stdout, indent=2)
        print()
        sys.exit(1)

    # Delete draft after successful send
    client = _get_client(args)
    try:
        client.connect()
        client.delete_message(args.message_id, mailbox=args.drafts_folder)
    finally:
        client.disconnect()

    result = {
        "success": True,
        "action": "send",
        "subject": message.subject,
        "recipients": [str(r) for r in message.recipients],
        "draft_deleted": True,
    }
    json.dump(result, sys.stdout, indent=2)
    print()


def _load_manager(path: str) -> AccountManager:
    try:
        return AccountManager.from_yaml(path)
    except Exception as exc:
        _error(f"Failed to load config: {exc}")
        raise


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/move_mail.py
content: #!/usr/bin/env python3
"""Move emails between IMAP folders.

Usage:
    python3 scripts/move_mail.py --message-id "<[REDACTED_SECRET]>" --from INBOX --to Archive --config config.yaml
    python3 scripts/move_mail.py --account work --from-stdin --to Projects --config config.yaml

Output: JSON result to stdout.
"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.imap_client import IMAPClient


def main() -> None:
    parser = argparse.ArgumentParser(description="Move emails between IMAP folders")
    parser.add_argument("--account", default="", help="Account name (default: default account)")
    parser.add_argument("--message-id", action="append", default=[],
                        help="Message-ID to move (repeatable)")
    parser.add_argument("--from", dest="from_folder", default="INBOX",
                        help="Source folder (default: INBOX)")
    parser.add_argument("--to", dest="to_folder", required=True,
                        help="Destination folder")
    parser.add_argument("--create-folder", action="store_true",
                        help="Create destination folder if it doesn't exist")
    parser.add_argument("--format", choices=["json", "cli"], default="json",
                        help="Output format (default: json)")

    # Direct IMAP options
    parser.add_argument("--imap-host", default="")
    parser.add_argument("--imap-port", type=int, default=993)
    parser.add_argument("--imap-user", default="")
    parser.add_argument("--imap-pass", default="")
    parser.add_argument("--imap-no-ssl", action="store_true")
    parser.add_argument("--config", default="", help="YAML config file")

    # Stdin input
    parser.add_argument("--from-stdin", action="store_true",
                        help="Read message IDs from stdin JSON")

    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    # Collect message IDs
    msg_ids = list(args.message_id)
    if args.from_stdin:
        data = json.load(sys.stdin)
        if isinstance(data, dict) and "messages" in data:
            for m in data["messages"]:
                mid = m.get("message_id", "")
                if mid:
                    msg_ids.append(mid)
        elif isinstance(data, list):
            for item in data:
                if isinstance(item, str):
                    msg_ids.append(item)
                elif isinstance(item, dict):
                    mid = item.get("message_id", "")
                    if mid:
                        msg_ids.append(mid)

    if not msg_ids:
        _error("No message IDs provided. Use --message-id or --from-stdin.")

    # Build client
    if args.imap_host:
        client = IMAPClient(
            host=args.imap_host,
            port=args.imap_port,
            username=args.imap_user,
            [REDACTED_SECRET]),
            use_ssl=not args.imap_no_ssl,
        )
    elif args.config:
        mgr = _load_manager(args.config)
        acct_name = args.account or mgr.default_account
        client = mgr.get_imap_client(acct_name)
    else:
        _error("IMAP host required. Use --imap-host or --config.")
        return

    results = []
    try:
        client.connect()

        if args.create_folder:
            folders = [f["name"] for f in client.list_folders()]
            if args.to_folder not in folders:
                client.create_folder(args.to_folder)

        # Use batch move for efficiency
        batch_results = client.move_messages_batch(
            msg_ids, args.from_folder, args.to_folder,
        )
        for mid, ok in batch_results.items():
            results.append({"message_id": mid, "success": ok})
    finally:
        client.disconnect()

    moved = sum(1 for r in results if r["success"])
    failed = len(results) - moved

    if args.format == "cli":
        print(f"  Moved {moved}/{len(results)} messages"
              f" from '{args.from_folder}' to '{args.to_folder}'")
        if failed:
            for r in results:
                if not r["success"]:
                    print(f"    FAILED: {r['message_id']}")
        print()
    else:
        output = {
            "account": args.account,
            "from": args.from_folder,
            "to": args.to_folder,
            "moved": moved,
            "failed": failed,
            "results": results,
        }
        json.dump(output, sys.stdout, indent=2)
        print()

    if failed:
        sys.exit(1)


def _load_manager(path: str) -> AccountManager:
    try:
        return AccountManager.from_yaml(path)
    except Exception as exc:
        _error(f"Failed to load config: {exc}")
        raise


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/retry_send.py
content: #!/usr/bin/env python3
"""Retry sending messages sitting in the IMAP Outbox folder.

The Outbox is a temporary IMAP folder that holds messages while they
are being sent.  If a send fails, the message remains in the Outbox.
This script drains the Outbox by re-attempting delivery for each
message.  After all messages are sent, the Outbox folder is removed.

Usage:
    python3 scripts/retry_send.py --config config.yaml
    python3 scripts/retry_send.py --config config.yaml --account work
    python3 scripts/retry_send.py --config config.yaml --list
"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))

from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.outbox import Outbox


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Retry sending messages from the IMAP Outbox",
    )
    parser.add_argument("--config", default="", help="YAML config file")
    parser.add_argument(
        "--account", default="",
        help="Process only this account (default: all accounts)",
    )
    parser.add_argument(
        "--list", action="store_true", dest="list_outbox",
        help="List pending messages without sending",
    )
    parser.add_argument("--format", choices=["json", "cli"], default="json")
    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    if not args.config:
        _error("--config is required (or place config.yaml in skill root)")
        return

    try:
        mgr = AccountManager.from_yaml(args.config)
    except Exception as exc:
        _error(f"Failed to load config: {exc}")
        return

    if args.account:
        account_names = [args.account]
    else:
        account_names = mgr.list_accounts()

    if args.list_outbox:
        _do_list(mgr, account_names, args.format)
    else:
        _do_drain(mgr, account_names, args.format)


def _do_list(mgr: AccountManager, accounts: list[str], fmt: str) -> None:
    """List messages currently in each account's Outbox."""
    all_pending: list[dict] = []

    for acct_name in accounts:
        try:
            imap_client = mgr.get_imap_client(acct_name)
            imap_client.connect()
            try:
                outbox = Outbox(imap_client)
                messages = outbox.list_pending()
                for msg in messages:
                    all_pending.append({
                        "account": acct_name,
                        "message_id": msg.message_id,
                        "subject": msg.subject,
                        "recipients": [str(r) for r in msg.recipients],
                        "date": msg.date.isoformat() if msg.date else "",
                    })
            finally:
                imap_client.disconnect()
        except Exception as exc:
            if fmt == "cli":
                print(f"  [{acct_name}] Error checking Outbox: {exc}")

    if fmt == "cli":
        if not all_pending:
            print("No messages pending in any Outbox.")
        else:
            print(f"  {len(all_pending)} message(s) pending in Outbox:")
            print(f"  {'':->60}")
            for p in all_pending:
                recip = ", ".join(p["recipients"][:2])
                if len(recip) > 25:
                    recip = recip[:22] + "..."
                print(
                    f"    [{p['account']}] {p['subject']!r:30s}  to={recip}"
                )
            print()
    else:
        json.dump(
            {"pending_count": len(all_pending), "messages": all_pending},
            sys.stdout, indent=2,
        )
        print()


def _do_drain(mgr: AccountManager, accounts: list[str], fmt: str) -> None:
    """Drain the Outbox for each account, sending pending messages."""
    total_attempted = 0
    total_sent = 0
    total_failed = 0
    all_errors: list[dict] = []
    account_results: list[dict] = []

    for acct_name in accounts:
        try:
            result = mgr.drain_outbox(acct_name)
            account_results.append(result)
            total_attempted += result.get("attempted", 0)
            total_sent += result.get("sent", 0)
            total_failed += result.get("failed", 0)
            all_errors.extend(result.get("errors", []))
        except Exception as exc:
            all_errors.append({
                "account": acct_name,
                "error": str(exc),
            })

    if fmt == "cli":
        if total_attempted == 0:
            print("No messages pending in any Outbox.")
        else:
            print(
                f"Outbox drain: {total_attempted} attempted, "
                f"{total_sent} sent, {total_failed} failed"
            )
            for err in all_errors:
                acct = err.get("account", "?")
                subj = err.get("subject", "")
                emsg = err.get("error", "")
                print(f"  ERROR [{acct}] {subj!r}: {emsg}")
    else:
        json.dump(
            {
                "attempted": total_attempted,
                "sent": total_sent,
                "failed": total_failed,
                "errors": all_errors,
                "accounts": account_results,
            },
            sys.stdout, indent=2,
        )
        print()

    sys.exit(1 if total_failed > 0 else 0)


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/send_mail.py
content: #!/usr/bin/env python3
"""Send rich HTML emails via SMTP.

Usage:
    python3 scripts/send_mail.py --config config.yaml [options]
    python3 scripts/send_mail.py --config config.yaml --account personal [options]
    python3 scripts/compose_mail.py ... | python3 scripts/send_mail.py --from-stdin --config config.yaml

Output: JSON result to stdout.
"""

from __future__ import annotations

import argparse
import json
import mimetypes
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.models import EmailAttachment, EmailMessage
from lib.smtp_client import SMTPClient
from lib.composer import EmailComposer


def main() -> None:
    parser = argparse.ArgumentParser(description="Send rich HTML emails via SMTP")

    # Account
    parser.add_argument("--account", default="", help="Account name (default: default account)")

    # Message options
    parser.add_argument("--to", action="append", default=[], help="Recipient (repeatable)")
    parser.add_argument("--cc", action="append", default=[], help="CC recipient")
    parser.add_argument("--bcc", action="append", default=[], help="BCC recipient")
    parser.add_argument("--subject", default="", help="Email subject")
    parser.add_argument("--body", default="", help="Email body (HTML or plain)")
    parser.add_argument("--sender", default="", help="Sender address")

    # Template options
    parser.add_argument("--template", default="default",
                        choices=["default", "minimal", "digest"],
                        help="Email template")
    parser.add_argument("--greeting", default="", help="Greeting line")
    parser.add_argument("--sign-off", default="", help="Sign-off line")
    parser.add_argument("--header-text", default="", help="Header text")
    parser.add_argument("--header-color", default="#2d3748", help="Header bg color")
    parser.add_argument("--footer-text", default="", help="Footer text")
    parser.add_argument("--action-url", default="", help="CTA button URL")
    parser.add_argument("--action-text", default="View Details", help="CTA button text")
    parser.add_argument("--action-color", default="#4299e1", help="CTA button color")

    # Reply threading
    parser.add_argument("--reply-to", default="", help="In-Reply-To message ID")

    # Attachments
    parser.add_argument("--attach", action="append", default=[],
                        help="File path to attach (repeatable)")

    # Direct SMTP options (bypass AccountManager)
    parser.add_argument("--smtp-host", default="", help="SMTP hostname")
    parser.add_argument("--smtp-port", type=int, default=587, help="SMTP port")
    parser.add_argument("--smtp-user", default="", help="SMTP username")
    parser.add_argument("--smtp-pass", default="", help="SMTP password")
    parser.add_argument("--smtp-no-tls", action="store_true", help="Disable SMTP TLS")

    # Pipe input
    parser.add_argument("--from-stdin", action="store_true",
                        help="Read composed message JSON from stdin")

    # Config
    parser.add_argument("--config", default="", help="YAML config file")

    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    # Build or read message
    if args.from_stdin:
        try:
            data = json.load(sys.stdin)
            message = EmailMessage.from_dict(data)
        except Exception as exc:
            _error(f"Failed to parse stdin message: {exc}")
            return
    else:
        if not args.to:
            _error("At least one --to recipient is required")
        if not args.subject:
            _error("--subject is required")

        # Resolve sender from account config if not explicit
        sender_addr = args.sender
        if not sender_addr and args.config:
            try:
                mgr = AccountManager.from_yaml(args.config)
                acct_name = args.account or mgr.default_account
                sender = mgr.get_sender(acct_name)
                if sender:
                    sender_addr = str(sender)
            except Exception:
                pass

        composer = EmailComposer()
        message = composer.compose(
            to=args.to,
            subject=args.subject,
            body=args.body or "",
            template=args.template,
            sender=sender_addr or None,
            cc=args.cc or None,
            bcc=args.bcc or None,
            reply_to=args.reply_to,
            greeting=args.greeting,
            sign_off=args.sign_off,
            header_text=args.header_text,
            header_color=args.header_color,
            footer_text=args.footer_text,
            action_url=args.action_url,
            action_text=args.action_text,
            action_color=args.action_color,
        )

    # Attach files from disk
    for filepath in args.attach:
        if not os.path.isfile(filepath):
            _error(f"Attachment not found: {filepath}")
        content_type, _ = mimetypes.guess_type(filepath)
        if not content_type:
            content_type = "application/octet-stream"
        with open(filepath, "rb") as fh:
            data = fh.read()
        message.attachments.append(EmailAttachment(
            filename=os.path.basename(filepath),
            content_type=content_type,
            data=data,
        ))

    # Direct SMTP mode
    if args.smtp_host:
        client = SMTPClient(
            host=args.smtp_host,
            port=args.smtp_port,
            username=args.smtp_user,
            [REDACTED_SECRET]),
            use_tls=not args.smtp_no_tls,
        )
        success = client.send(message)
        result = {
            "success": success,
            "transport": "smtp",
            "subject": message.subject,
            "recipients": [str(r) for r in message.recipients],
        }
        if not success:
            result["error"] = "Failed to send email"
            json.dump(result, sys.stdout, indent=2)
            print()
            sys.exit(1)
        json.dump(result, sys.stdout, indent=2)
        print()
        return

    # Multi-account mode with Outbox + SMTP fallback
    if not args.config:
        _error("Either --config or --smtp-host is required.")

    mgr = _load_manager(args.config)
    acct_name = args.account or mgr.default_account

    # Set sender from account if not already set on message
    if not message.sender:
        sender = mgr.get_sender(acct_name)
        if sender:
            message.sender = sender

    # Use the Outbox pattern: stage → send → cleanup
    send_result = mgr.send_via_outbox(message, acct_name)

    result = {
        "success": send_result["success"],
        "transport": "smtp",
        "account": send_result["account"],
        "fallback_used": send_result.get("fallback_used", False),
        "staged_in_outbox": send_result.get("staged", False),
        "subject": message.subject,
        "recipients": [str(r) for r in message.recipients],
    }

    stage_error = send_result.get("stage_error")
    if stage_error:
        result["stage_error"] = stage_error
        if "error" not in result or not result["error"]:
            result["error"] = stage_error

    if not send_result["success"]:
        result["error"] = send_result.get("error", "Failed to send email")
        if send_result.get("staged"):
            result["note"] = "Message is staged in Outbox for retry"
        json.dump(result, sys.stdout, indent=2)
        print()
        sys.exit(1)

    json.dump(result, sys.stdout, indent=2)
    print()


def _load_manager(path: str) -> AccountManager:
    try:
        return AccountManager.from_yaml(path)
    except Exception as exc:
        _error(f"Failed to load config: {exc}")
        raise


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/process_mail.py
content: #!/usr/bin/env python3
"""Process emails through the rule-based pipeline.

Usage:
    python3 scripts/fetch_mail.py --config c.yaml | python3 scripts/process_mail.py --rules-file rules.yaml
    python3 scripts/process_mail.py --input messages.json --rules '[...]'
    python3 scripts/process_mail.py --input messages.json --config config.yaml --account work --format cli

Output: JSON (default) or CLI summary to stdout.
"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))

from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.models import EmailMessage
from lib.processor import EmailProcessor


def main() -> None:
    parser = argparse.ArgumentParser(description="Process emails through rule pipeline")

    parser.add_argument("--account", default="",
                        help="Account name (loads per-account + global rules from config)")
    parser.add_argument("--input", default="",
                        help="JSON file with messages (or reads stdin)")
    parser.add_argument("--rules", default="",
                        help="JSON string of rules array")
    parser.add_argument("--rules-file", default="",
                        help="YAML/JSON file with rules")
    parser.add_argument("--config", default="",
                        help="YAML config file (reads rules from 'rules' key or per-account)")
    parser.add_argument("--format", choices=["json", "cli"], default="json",
                        help="Output format (default: json)")

    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    # Load messages
    if args.input:
        with open(args.input) as f:
            data = json.load(f)
    else:
        data = json.load(sys.stdin)

    if isinstance(data, dict) and "messages" in data:
        msg_dicts = data["messages"]
    elif isinstance(data, list):
        msg_dicts = data
    else:
        _error("Expected JSON array or object with 'messages' key")
        return

    messages = [EmailMessage.from_dict(d) for d in msg_dicts]

    # Load rules
    rules_config: list[dict] = []
    if args.rules:
        try:
            rules_config = json.loads(args.rules)
        except json.JSONDecodeError as exc:
            _error(f"Invalid --rules JSON: {exc}")
    elif args.rules_file:
        try:
            with open(args.rules_file) as f:
                if args.rules_file.endswith((".yaml", ".yml")):
                    import yaml
                    loaded = yaml.safe_load(f) or {}
                else:
                    loaded = json.load(f)
                if isinstance(loaded, list):
                    rules_config = loaded
                elif isinstance(loaded, dict):
                    rules_config = loaded.get("rules", loaded.get("processing_rules", []))
        except Exception as exc:
            _error(f"Failed to load rules file: {exc}")
    elif args.config:
        try:
            mgr = AccountManager.from_yaml(args.config)
            if args.account:
                rules_config = mgr.get_rules(args.account)
            else:
                rules_config = mgr.get_rules()
        except Exception as exc:
            _error(f"Failed to load config: {exc}")

    if not rules_config:
        _error("No processing rules provided. Use --rules, --rules-file, or --config.")

    # Process
    processor = EmailProcessor.from_config(rules_config)
    results = processor.process_batch(messages)

    if args.format == "cli":
        matched = sum(len(r.matched_rules) for r in results)
        print(f"  Processed {len(results)} messages, {matched} rules matched")
        print()
        for r in results:
            subj = r.message.subject[:50] or "(no subject)"
            acct = f"[{r.message.account}] " if r.message.account else ""
            if r.matched_rules:
                rules_str = ", ".join(r.matched_rules)
                actions_str = ", ".join(r.actions_taken) if r.actions_taken else ""
                print(f"  {acct}{subj}")
                print(f"    Rules:   {rules_str}")
                if actions_str:
                    print(f"    Actions: {actions_str}")
                if r.tags:
                    print(f"    Tags:    {', '.join(r.tags)}")
                if r.move_to:
                    print(f"    Move to: {r.move_to}")
            else:
                print(f"  {acct}{subj}  (no match)")
        print()
    else:
        output = {
            "messages_processed": len(results),
            "rules_matched": sum(len(r.matched_rules) for r in results),
            "results": [r.to_dict() for r in results],
        }
        json.dump(output, sys.stdout, indent=2)
        print()


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/read_mail.py
content: #!/usr/bin/env python3
"""Read and render a specific email by Message-ID.

Usage:
    python3 scripts/read_mail.py --message-id "<[REDACTED_SECRET]>" --config config.yaml
    python3 scripts/read_mail.py --message-id "<[REDACTED_SECRET]>" --account work --format cli
    echo '{"message_id":"<id>", ...}' | python3 scripts/read_mail.py --from-stdin --format cli

Output: JSON message object (default) or CLI-rendered email.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.imap_client import IMAPClient
from lib.models import EmailMessage


def main() -> None:
    parser = argparse.ArgumentParser(description="Read/render an email by Message-ID")
    parser.add_argument("--account", default="", help="Account name (default: default account)")
    parser.add_argument("--message-id", default="", help="Message-ID header value")
    parser.add_argument("--folder", default="INBOX", help="Folder to search in")
    parser.add_argument("--format", choices=["json", "cli"], default="json",
                        help="Output format (default: json)")

    # Direct IMAP options
    parser.add_argument("--imap-host", default="")
    parser.add_argument("--imap-port", type=int, default=993)
    parser.add_argument("--imap-user", default="")
    parser.add_argument("--imap-pass", default="")
    parser.add_argument("--imap-no-ssl", action="store_true")

    # Attachments
    parser.add_argument("--save-attachments", default="",
                        help="Directory to save attachments to")

    # Config / stdin
    parser.add_argument("--config", default="", help="YAML config file")
    parser.add_argument("--from-stdin", action="store_true",
                        help="Read message JSON from stdin instead of IMAP")

    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    if args.from_stdin:
        data = json.load(sys.stdin)
        if isinstance(data, dict) and "messages" in data:
            if not data["messages"]:
                _error("No messages in input")
                return
            message = EmailMessage.from_dict(data["messages"][0])
        elif isinstance(data, dict):
            message = EmailMessage.from_dict(data)
        else:
            _error("Expected JSON message object")
            return
    elif args.imap_host:
        if not args.message_id:
            _error("--message-id is required (or use --from-stdin)")
            return
        client = IMAPClient(
            host=args.imap_host,
            port=args.imap_port,
            username=args.imap_user,
            [REDACTED_SECRET]),
            use_ssl=not args.imap_no_ssl,
        )
        try:
            client.connect()
            message = client.fetch_by_id(args.message_id, mailbox=args.folder)
        finally:
            client.disconnect()
        if message is None:
            _error(f"Message not found: {args.message_id}")
            return
    else:
        if not args.message_id:
            _error("--message-id is required (or use --from-stdin)")
            return
        if not args.config:
            _error("--config or --imap-host is required")
            return

        mgr = _load_manager(args.config)
        acct_name = args.account or mgr.default_account
        client = mgr.get_imap_client(acct_name)
        try:
            client.connect()
            message = client.fetch_by_id(args.message_id, mailbox=args.folder)
        finally:
            client.disconnect()
        if message is None:
            _error(f"Message not found: {args.message_id}")
            return

    # Save attachments if requested
    if args.save_attachments:
        _save_attachments(message, args.save_attachments)

    _output(message, args.format)


def _save_attachments(message: EmailMessage, dest_dir: str) -> None:
    """Save all attachments from a message to the given directory."""
    if not message.attachments:
        return
    os.makedirs(dest_dir, exist_ok=True)
    for att in message.attachments:
        safe_name = os.path.basename(att.filename) or "attachment"
        dest_path = os.path.join(dest_dir, safe_name)
        # Avoid overwriting: add suffix if file exists
        base, ext = os.path.splitext(dest_path)
        counter = 1
        while os.path.exists(dest_path):
            dest_path = f"{base}_{counter}{ext}"
            counter += 1
        with open(dest_path, "wb") as f:
            f.write(att.data)
        print(f"  Saved: {dest_path} ({att.size} bytes)", file=sys.stderr)


def _output(message: EmailMessage, fmt: str) -> None:
    if fmt == "cli":
        _render_cli(message)
    else:
        json.dump(message.to_dict(), sys.stdout, indent=2, default=str)
        print()


def _render_cli(msg: EmailMessage) -> None:
    """Render an email for terminal display."""
    w = 72
    print("=" * w)
    if msg.account:
        print(f"  Account: {msg.account}")
    print(f"  From:    {msg.sender or '(unknown)'}")
    if msg.recipients:
        print(f"  To:      {', '.join(str(r) for r in msg.recipients)}")
    if msg.cc:
        print(f"  Cc:      {', '.join(str(r) for r in msg.cc)}")
    print(f"  Subject: {msg.subject}")
    print(f"  Date:    {msg.date.strftime('%Y-%m-%d %H:%M %Z') if msg.date else '(unknown)'}")
    if msg.message_id:
        print(f"  ID:      {msg.message_id}")
    if msg.has_attachments:
        att_names = ", ".join(a.filename for a in msg.attachments)
        print(f"  Attach:  {att_names}")
    print("-" * w)

    # Prefer plain text for CLI; fall back to stripped HTML
    body = msg.body_plain
    if not body and msg.body_html:
        body = re.sub(r"<head[\s>].*?</head>", "", msg.body_html,
                       flags=re.DOTALL | re.IGNORECASE)
        body = re.sub(r"<style[\s>].*?</style>", "", body,
                       flags=re.DOTALL | re.IGNORECASE)
        body = re.sub(r"<br\s*/?>", "\n", body, flags=re.IGNORECASE)
        body = re.sub(r"</p>", "\n\n", body, flags=re.IGNORECASE)
        body = re.sub(r"<[^>]+>", "", body)
        body = re.sub(r"\n{3,}", "\n\n", body)

    if body:
        for line in body.strip().splitlines():
            print(f"  {line}")
    else:
        print("  (empty body)")

    print("=" * w)
    print()


def _load_manager(path: str) -> AccountManager:
    try:
        return AccountManager.from_yaml(path)
    except Exception as exc:
        _error(f"Failed to load config: {exc}")
        raise


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/calendar_invite.py
content: #!/usr/bin/env python3
"""Compose and send calendar invitations (iCalendar VEVENT).

Creates a text/calendar MIME part and sends it as an email. Recipients'
mail clients will display it as a meeting invitation.

Usage:
    python3 scripts/calendar_invite.py \\
        --to "[REDACTED_SECRET]" \\
        --subject "Team Standup" \\
        --start "2026-03-01T09:00:00" \\
        --end "2026-03-01T09:30:00" \\
        --location "Zoom" \\
        --description "Daily standup meeting" \\
        --config config.yaml

    # Recurring weekly meeting
    python3 scripts/calendar_invite.py \\
        --to "[REDACTED_SECRET]" \\
        --subject "Weekly Sync" \\
        --start "2026-03-02T14:00:00" \\
        --end "2026-03-02T15:00:00" \\
        --rrule "FREQ=WEEKLY;COUNT=10" \\
        --config config.yaml
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import uuid
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.models import EmailAddress, EmailMessage


def build_vcalendar(
    organizer: str,
    attendees: list[str],
    subject: str,
    start: datetime,
    end: datetime,
    location: str = "",
    description: str = "",
    rrule: str = "",
    uid: str = "",
    method: str = "REQUEST",
) -> str:
    """Build an iCalendar VCALENDAR string for a VEVENT."""
    uid = uid or f"{uuid.uuid4()}@clawMail"
    now = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
    dtstart = start.strftime("%Y%m%dT%H%M%S")
    dtend = end.strftime("%Y%m%dT%H%M%S")

    lines = [
        "BEGIN:VCALENDAR",
        "VERSION:2.0",
        "PRODID:-//clawMail//calendar//EN",
        f"METHOD:{method}",
        "BEGIN:VEVENT",
        f"UID:{uid}",
        f"DTSTAMP:{now}",
        f"DTSTART:{dtstart}",
        f"DTEND:{dtend}",
        f"SUMMARY:{subject}",
        f"ORGANIZER;CN={organizer}:mailto:{organizer}",
    ]

    for attendee in attendees:
        lines.append(
            f"ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;"
            f"RSVP=TRUE:mailto:{attendee}"
        )

    if location:
        lines.append(f"LOCATION:{location}")
    if description:
        # Fold long description lines per RFC 5545
        desc_escaped = description.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,")
        lines.append(f"DESCRIPTION:{desc_escaped}")
    if rrule:
        lines.append(f"RRULE:{rrule}")

    lines.extend([
        "STATUS:CONFIRMED",
        "SEQUENCE:0",
        "END:VEVENT",
        "END:VCALENDAR",
    ])

    return "\r\n".join(lines)


def main() -> None:
    parser = argparse.ArgumentParser(description="Send calendar invitation")
    parser.add_argument("--config", default="", help="YAML config file")
    parser.add_argument("--account", default="", help="Account profile name")
    parser.add_argument("--to", action="append", default=[], help="Attendee address")
    parser.add_argument("--cc", action="append", default=[], help="CC address")
    parser.add_argument("--subject", required=True, help="Event subject/title")
    parser.add_argument("--start", required=True,
                        help="Start datetime (ISO 8601: YYYY-MM-DDTHH:MM:SS)")
    parser.add_argument("--end", required=True,
                        help="End datetime (ISO 8601: YYYY-MM-DDTHH:MM:SS)")
    parser.add_argument("--location", default="", help="Event location")
    parser.add_argument("--description", default="", help="Event description")
    parser.add_argument("--rrule", default="",
                        help="Recurrence rule (e.g. FREQ=WEEKLY;COUNT=10)")
    parser.add_argument("--uid", default="", help="Unique event ID (auto-generated)")
    parser.add_argument("--method", default="REQUEST",
                        choices=["REQUEST", "CANCEL", "REPLY"],
                        help="Calendar method")
    parser.add_argument("--body", default="", help="Additional email body text")
    parser.add_argument("--sender", default="", help="Sender address override")
    parser.add_argument("--format", choices=["json", "cli"], default="json")

    # Direct SMTP flags
    parser.add_argument("--smtp-host", default="")
    parser.add_argument("--smtp-port", type=int, default=587)
    parser.add_argument("--smtp-user", default="")
    parser.add_argument("--smtp-pass", default="")
    parser.add_argument("--smtp-no-tls", action="store_true")
    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    if not args.to:
        _error("At least one --to recipient is required")
        return

    # Parse datetimes
    try:
        start_dt = datetime.fromisoformat(args.start)
        end_dt = datetime.fromisoformat(args.end)
    except ValueError as exc:
        _error(f"Invalid datetime: {exc}")
        return

    # Determine sender
    sender_addr = args.sender
    mgr = None
    acct_name = args.account

    if args.config:
        try:
            mgr = AccountManager.from_yaml(args.config)
        except Exception as exc:
            _error(f"Failed to load config: {exc}")
            return
        acct_name = acct_name or mgr.default_account
        if not sender_addr:
            s = mgr.get_sender(acct_name)
            if s:
                sender_addr = s.address

    if not sender_addr:
        sender_addr = args.smtp_user or "[REDACTED_SECRET]"

    # Build iCalendar
    vcal = build_vcalendar(
        organizer=sender_addr,
        attendees=args.to,
        subject=args.subject,
        start=start_dt,
        end=end_dt,
        location=args.location,
        description=args.description,
        rrule=args.rrule,
        uid=args.uid,
        method=args.method,
    )

    # Build MIME message with text/calendar part
    mime_msg = MIMEMultipart("mixed")
    mime_msg["From"] = sender_addr
    mime_msg["To"] = ", ".join(args.to)
    if args.cc:
        mime_msg["Cc"] = ", ".join(args.cc)
    mime_msg["Subject"] = args.subject

    # Alternative part: plain text + calendar
    alt = MIMEMultipart("alternative")

    body_text = args.body or f"You are invited to: {args.subject}"
    alt.attach(MIMEText(body_text, "plain", "utf-8"))

    cal_part = MIMEText(vcal, "calendar", "utf-8")
    cal_part.set_param("method", args.method)
    alt.attach(cal_part)

    mime_msg.attach(alt)

    # Send or output
    all_recipients = args.to + args.cc

    if args.smtp_host:
        from lib.smtp_client import SMTPClient
        client = SMTPClient(
            host=args.smtp_host, port=args.smtp_port,
            username=args.smtp_user, [REDACTED_SECRET]),
            use_tls=not args.smtp_no_tls,
        )
        # Build an EmailMessage for sending
        em = EmailMessage(
            subject=args.subject,
            sender=EmailAddress.parse(sender_addr),
            recipients=[EmailAddress.parse(a) for a in args.to],
            cc=[EmailAddress.parse(a) for a in args.cc],
            body_html=body_text,
            body_plain=body_text,
        )
        ok = client.send(em)
        result = {
            "success": ok,
            "method": args.method,
            "subject": args.subject,
            "attendees": args.to,
        }
    elif mgr:
        # Use the MIME directly via smtplib
        em = EmailMessage(
            subject=args.subject,
            sender=EmailAddress.parse(sender_addr),
            recipients=[EmailAddress.parse(a) for a in args.to],
            cc=[EmailAddress.parse(a) for a in args.cc],
            body_html=body_text,
            body_plain=body_text,
        )
        send_result = mgr.send_with_fallback(em, acct_name)
        result = {
            "success": send_result["success"],
            "method": args.method,
            "subject": args.subject,
            "attendees": args.to,
            "fallback_used": send_result.get("fallback_used", False),
        }
    else:
        # No send method — just output the iCalendar
        result = {
            "subject": args.subject,
            "method": args.method,
            "attendees": args.to,
            "vcalendar": vcal,
        }

    if args.format == "cli":
        if result.get("success") is True:
            print(f"Calendar invitation sent: {args.subject}")
            print(f"  Method: {args.method}")
            print(f"  Start: {args.start}")
            print(f"  End: {args.end}")
            print(f"  Attendees: {', '.join(args.to)}")
        elif "vcalendar" in result:
            print(vcal)
        else:
            print(f"Failed to send invitation: {result}")
    else:
        json.dump(result, sys.stdout, indent=2)
        print()


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/archive_mail.py
content: #!/usr/bin/env python3
"""Archive old emails into dated folders (default monthly).

Scans a mailbox for messages older than N days and moves them to folders like
``Archive-YYYYMM`` (or ``Archive-YYYYMMDD``/``Archive-Wxxxx``) depending on
the configurable frequency, without creating empty folders.

Usage:
    python3 scripts/archive_mail.py --config config.yaml --days 90
    python3 scripts/archive_mail.py --config config.yaml --account work --folder INBOX --days 60
    python3 scripts/archive_mail.py --config config.yaml --days 30 --archive-root "Old Mail" --dry-run
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import datetime, timedelta

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.imap_client import IMAPClient


def main() -> None:
    parser = argparse.ArgumentParser(description="Archive old emails to yearly folders")
    parser.add_argument("--config", default="", help="YAML config file")
    parser.add_argument("--account", default="", help="Account profile name")
    parser.add_argument("--folder", default="INBOX", help="Source folder to scan")
    parser.add_argument("--days", type=int, required=True,
                        help="Archive messages older than N days")
    parser.add_argument("--archive-root", default="Archive",
                        help="Archive folder prefix (default: Archive)")
    parser.add_argument("--frequency", choices=["daily", "weekly", "monthly", "yearly"],
                        default="monthly",
                        help="Frequency for archive folders (default: monthly)")
    parser.add_argument("--create-folders", action="store_true", default=True,
                        help="Create archive folders if they don't exist (default)")
    parser.add_argument("--dry-run", action="store_true",
                        help="Show what would be archived without moving")
    parser.add_argument("--limit", type=int, default=500,
                        help="Max messages to scan")
    parser.add_argument("--format", choices=["json", "cli"], default="json")

    # Direct IMAP flags
    parser.add_argument("--imap-host", default="")
    parser.add_argument("--imap-port", type=int, default=993)
    parser.add_argument("--imap-user", default="")
    parser.add_argument("--imap-pass", default="")
    parser.add_argument("--imap-no-ssl", action="store_true")
    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    cutoff = datetime.now() - timedelta(days=args.days)

    # Build client
    client: IMAPClient | None = None
    acct_name = args.account

    if args.imap_host:
        client = IMAPClient(
            host=args.imap_host, port=args.imap_port,
            username=args.imap_user, [REDACTED_SECRET]),
            use_ssl=not args.imap_no_ssl,
        )
        acct_name = acct_name or "direct"
    elif args.config:
        try:
            mgr = AccountManager.from_yaml(args.config)
        except Exception as exc:
            _error(f"Failed to load config: {exc}")
            return
        acct_name = acct_name or mgr.default_account
        client = mgr.get_imap_client(acct_name)
    else:
        _error("Provide --config or --imap-host")
        return

    try:
        client.connect()

        # Fetch messages using BEFORE date criteria
        date_str = cutoff.strftime("%d-%b-%Y")
        messages = client.search(
            mailbox=args.folder,
            criteria=f"(BEFORE {date_str})",
            limit=args.limit,
        )

        if not messages:
            result = {
                "account": acct_name,
                "folder": args.folder,
                "cutoff_date": cutoff.isoformat(),
                "scanned": 0,
                "archived": 0,
                "dry_run": args.dry_run,
                "details": [],
            }
            _output(result, args.format)
            return

        frequency = args.frequency
        groups: dict[str, list[EmailMessage]] = {}
        for msg in messages:
            folder_name = _archive_folder_name(
                msg.date, args.archive_root, frequency,
            )
            groups.setdefault(folder_name, []).append(msg)

        created_folders: set[str] = set()
        archived = 0
        move_details: list[dict] = []
        existing_folders = {f["name"] for f in client.list_folders()}

        for folder_name, msgs in sorted(groups.items()):
            if not msgs:
                continue

            if args.dry_run:
                for msg in msgs:
                    move_details.append({
                        "message_id": msg.message_id,
                        "subject": msg.subject,
                        "date": msg.date.isoformat() if msg.date else "",
                        "target": folder_name,
                        "status": "dry_run",
                    })
                    archived += 1
                continue

            if args.create_folders and folder_name not in existing_folders:
                ok = client.create_folder(folder_name)
                if ok:
                    existing_folders.add(folder_name)
                    created_folders.add(folder_name)
                else:
                    for msg in msgs:
                        move_details.append({
                            "message_id": msg.message_id,
                            "subject": msg.subject,
                            "date": msg.date.isoformat() if msg.date else "",
                            "target": folder_name,
                            "status": "error",
                            "error": f"Failed to create folder {folder_name}",
                        })
                    continue

            msg_ids = [m.message_id for m in msgs if m.message_id]
            if not msg_ids:
                for msg in msgs:
                    move_details.append({
                        "message_id": msg.message_id,
                        "subject": msg.subject,
                        "date": msg.date.isoformat() if msg.date else "",
                        "target": folder_name,
                        "status": "failed",
                        "error": "missing Message-ID",
                    })
                continue

            results = client.move_messages_batch(msg_ids, args.folder, folder_name)
            for msg in msgs:
                mid = msg.message_id
                ok = results.get(mid, False)
                move_details.append({
                    "message_id": mid,
                    "subject": msg.subject,
                    "date": msg.date.isoformat() if msg.date else "",
                    "target": folder_name,
                    "status": "moved" if ok else "failed",
                })
                if ok:
                    archived += 1

        result = {
            "account": acct_name,
            "folder": args.folder,
            "cutoff_date": cutoff.isoformat(),
            "cutoff_days": args.days,
            "frequency": frequency,
            "scanned": len(messages),
            "archived": archived,
            "dry_run": args.dry_run,
            "created_folders": sorted(created_folders),
            "details": move_details,
        }

        _output(result, args.format)

    finally:
        client.disconnect()


def _output(result: dict, fmt: str) -> None:
    if fmt == "cli":
        dr = " (DRY RUN)" if result.get("dry_run") else ""
        freq = result.get("frequency", "monthly")
        print(f"Archive[{freq}]{dr}: {result['archived']}/{result['scanned']} "
              f"messages from {result['folder']}")
        print(f"  Cutoff: {result.get('cutoff_days', '?')} days "
              f"({result['cutoff_date'][:10]})")
        created = result.get("created_folders", [])
        if created:
            print(f"  Created: {', '.join(created)}")
    else:
        json.dump(result, sys.stdout, indent=2)
        print()


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


def _archive_folder_name(
    date: datetime | None,
    root: str,
    frequency: str,
) -> str:
    """Return the folder name for an archive period."""
    base = root.strip() or "Archive"
    dt = date or datetime.now()
    if frequency == "daily":
        suffix = dt.strftime("%Y%m%d")
    elif frequency == "weekly":
        iso_year, iso_week, _ = dt.isocalendar()
        suffix = f"{iso_year}W{iso_week:02d}"
    elif frequency == "yearly":
        suffix = dt.strftime("%Y")
    else:
        suffix = dt.strftime("%Y%m")
    return f"{base}-{suffix}"


if __name__ == "__main__":
    main()


path: scripts/compose_mail.py
content: #!/usr/bin/env python3
"""Compose rich HTML emails and output as JSON (for piping to send_mail.py).

Usage:
    python3 scripts/compose_mail.py [options]
    python3 scripts/compose_mail.py ... | python3 scripts/send_mail.py --from-stdin

Output: JSON message object to stdout.
"""

from __future__ import annotations

import argparse
import json
import mimetypes
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))

from lib.composer import EmailComposer
from lib.models import EmailAttachment


def main() -> None:
    parser = argparse.ArgumentParser(description="Compose rich HTML emails")

    parser.add_argument("--to", action="append", default=[], help="Recipient (repeatable)")
    parser.add_argument("--cc", action="append", default=[], help="CC recipient")
    parser.add_argument("--bcc", action="append", default=[], help="BCC recipient")
    parser.add_argument("--subject", required=True, help="Email subject")
    parser.add_argument("--body", default="", help="Email body (HTML or plain)")
    parser.add_argument("--sender", default="", help="Sender address")
    parser.add_argument("--template", default="default",
                        choices=["default", "minimal", "digest"],
                        help="Email template")

    # Template variables
    parser.add_argument("--greeting", default="", help="Greeting line")
    parser.add_argument("--sign-off", default="", help="Sign-off line")
    parser.add_argument("--header-text", default="", help="Header text")
    parser.add_argument("--header-color", default="#2d3748", help="Header bg color")
    parser.add_argument("--footer-text", default="", help="Footer text")
    parser.add_argument("--action-url", default="", help="CTA button URL")
    parser.add_argument("--action-text", default="View Details", help="CTA button text")
    parser.add_argument("--action-color", default="#4299e1", help="CTA button color")

    # Digest-specific
    parser.add_argument("--items", default="", help="JSON array of row dicts for digest")
    parser.add_argument("--columns", default="", help="JSON array of column names")
    parser.add_argument("--summary", default="", help="Summary text for digest")

    # Reply
    parser.add_argument("--reply-to", default="", help="In-Reply-To message ID")

    # Attachments
    parser.add_argument("--attach", action="append", default=[],
                        help="File path to attach (repeatable)")

    args = parser.parse_args()

    if not args.to:
        json.dump({"error": "At least one --to recipient is required"}, sys.stderr)
        print(file=sys.stderr)
        sys.exit(1)

    items = None
    columns = None
    if args.items:
        try:
            items = json.loads(args.items)
        except json.JSONDecodeError as exc:
            json.dump({"error": f"Invalid --items JSON: {exc}"}, sys.stderr)
            print(file=sys.stderr)
            sys.exit(1)
    if args.columns:
        try:
            columns = json.loads(args.columns)
        except json.JSONDecodeError as exc:
            json.dump({"error": f"Invalid --columns JSON: {exc}"}, sys.stderr)
            print(file=sys.stderr)
            sys.exit(1)

    composer = EmailComposer()
    message = composer.compose(
        to=args.to,
        subject=args.subject,
        body=args.body,
        template=args.template,
        sender=args.sender or None,
        cc=args.cc or None,
        bcc=args.bcc or None,
        reply_to=args.reply_to,
        greeting=args.greeting,
        sign_off=args.sign_off,
        header_text=args.header_text,
        header_color=args.header_color,
        footer_text=args.footer_text,
        action_url=args.action_url,
        action_text=args.action_text,
        action_color=args.action_color,
        items=items,
        columns=columns,
        summary=args.summary,
    )

    # Attach files from disk
    for filepath in args.attach:
        if not os.path.isfile(filepath):
            json.dump({"error": f"Attachment not found: {filepath}"}, sys.stderr)
            print(file=sys.stderr)
            sys.exit(1)
        content_type, _ = mimetypes.guess_type(filepath)
        if not content_type:
            content_type = "application/octet-stream"
        with open(filepath, "rb") as fh:
            data = fh.read()
        message.attachments.append(EmailAttachment(
            filename=os.path.basename(filepath),
            content_type=content_type,
            data=data,
        ))

    json.dump(message.to_dict(), sys.stdout, indent=2, default=str)
    print()


if __name__ == "__main__":
    main()


path: scripts/mail_merge.py
content: #!/usr/bin/env python3
"""Mail merge — batch personalised sends from a template + data source.

Reads a CSV or JSON data file where each row represents one recipient.
For each row, the template subject/body placeholders are filled in and
the message is sent via SMTP.

Placeholder syntax: ``{{field_name}}`` in subject and body.

Usage:
    # CSV data source
    python3 scripts/mail_merge.py \\
        --data contacts.csv \\
        --subject "Hello {{name}}" \\
        --body "<p>Dear {{name}}, your code is {{code}}.</p>" \\
        --to-field email \\
        --config config.yaml

    # JSON data source
    python3 scripts/mail_merge.py \\
        --data contacts.json \\
        --subject "Invoice #{{invoice_id}}" \\
        --body "<p>Amount: {{amount}}</p>" \\
        --to-field email \\
        --config config.yaml

    # Dry run (compose without sending)
    python3 scripts/mail_merge.py \\
        --data contacts.csv \\
        --subject "Hi {{name}}" \\
        --body "Test" \\
        --to-field email \\
        --dry-run
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import re
import sys
import time

sys.path.insert(0, os.path.dirname(__file__))

from lib.account_manager import AccountManager
from lib.composer import EmailComposer
from lib.defaults import resolve_config_path
from lib.models import EmailAddress


def _load_data(path: str) -> list[dict[str, str]]:
    """Load rows from a CSV or JSON file."""
    ext = os.path.splitext(path)[1].lower()
    if ext == ".json":
        with open(path) as f:
            data = json.load(f)
        if isinstance(data, list):
            return data
        raise ValueError("JSON data must be an array of objects")
    else:
        # Default: CSV
        with open(path, newline="", encoding="utf-8-sig") as f:
            reader = csv.DictReader(f)
            return list(reader)


def _fill_template(template: str, row: dict[str, str]) -> str:
    """Replace ``{{key}}`` placeholders with values from *row*."""
    def replacer(match):
        key = match.group(1).strip()
        return row.get(key, match.group(0))
    return re.sub(r"\{\{(\w+)\}\}", replacer, template)


def main() -> None:
    parser = argparse.ArgumentParser(description="Mail merge — batch personalised sends")
    parser.add_argument("--data", required=True,
                        help="CSV or JSON file with recipient data")
    parser.add_argument("--subject", required=True,
                        help="Subject template (use {{field}} for placeholders)")
    parser.add_argument("--body", required=True,
                        help="Body template (HTML, use {{field}} for placeholders)")
    parser.add_argument("--to-field", required=True,
                        help="Column/key name containing recipient email address")
    parser.add_argument("--name-field", default="",
                        help="Column/key for recipient display name (optional)")
    parser.add_argument("--template", default="default",
                        choices=["default", "minimal", "digest"],
                        help="Email template")
    parser.add_argument("--sender", default="", help="Sender address override")
    parser.add_argument("--greeting", default="", help="Greeting template")
    parser.add_argument("--sign-off", default="", help="Sign-off template")
    parser.add_argument("--delay", type=float, default=0.5,
                        help="Delay in seconds between sends (default: 0.5)")
    parser.add_argument("--dry-run", action="store_true",
                        help="Compose messages without sending")
    parser.add_argument("--config", default="", help="YAML config file")
    parser.add_argument("--account", default="", help="Account profile name")
    parser.add_argument("--attach", action="append", default=[],
                        help="File to attach (same for all recipients)")
    parser.add_argument("--format", choices=["json", "cli"], default="json")
    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    # Load data
    try:
        rows = _load_data(args.data)
    except Exception as exc:
        _error(f"Failed to load data: {exc}")
        return

    if not rows:
        _error("Data file is empty")
        return

    # Validate to-field exists
    if args.to_field not in rows[0]:
        _error(f"Field '{args.to_field}' not found in data. "
               f"Available: {', '.join(rows[0].keys())}")
        return

    # Setup
    mgr = None
    acct_name = args.account
    if args.config:
        try:
            mgr = AccountManager.from_yaml(args.config)
        except Exception as exc:
            _error(f"Failed to load config: {exc}")
            return
        acct_name = acct_name or mgr.default_account

    sender = args.sender
    if not sender and mgr:
        s = mgr.get_sender(acct_name)
        if s:
            sender = str(s)

    composer = EmailComposer()

    # Load attachments once
    from lib.models import EmailAttachment
    import mimetypes
    attachments = []
    for fpath in args.attach:
        if not os.path.isfile(fpath):
            _error(f"Attachment not found: {fpath}")
            return
        ct = mimetypes.guess_type(fpath)[0] or "application/octet-stream"
        with open(fpath, "rb") as f:
            data = f.read()
        attachments.append(EmailAttachment(
            filename=os.path.basename(fpath), content_type=ct, data=data,
        ))

    results = []
    sent = 0
    failed = 0

    for i, row in enumerate(rows):
        to_addr = row.get(args.to_field, "").strip()
        if not to_addr:
            results.append({"row": i, "error": f"Empty {args.to_field}"})
            failed += 1
            continue

        subject = _fill_template(args.subject, row)
        body = _fill_template(args.body, row)
        greeting = _fill_template(args.greeting, row) if args.greeting else ""
        sign_off = _fill_template(args.sign_off, row) if args.sign_off else ""

        message = composer.compose(
            to=to_addr,
            subject=subject,
            body=body,
            template=args.template,
            sender=sender,
            greeting=greeting,
            sign_off=sign_off,
        )
        message.attachments = list(attachments)

        row_result: dict = {
            "row": i,
            "to": to_addr,
            "subject": subject,
        }

        if args.dry_run:
            row_result["status"] = "dry_run"
            results.append(row_result)
            sent += 1
        elif mgr:
            send_result = mgr.send_with_fallback(message, acct_name)
            if send_result["success"]:
                row_result["status"] = "sent"
                row_result["fallback_used"] = send_result.get("fallback_used", False)
                sent += 1
            else:
                row_result["status"] = "failed"
                row_result["error"] = send_result.get("error", "unknown")
                failed += 1
            results.append(row_result)
            if args.delay and i < len(rows) - 1:
                time.sleep(args.delay)
        else:
            row_result["status"] = "no_config"
            row_result["message"] = message.to_dict()
            results.append(row_result)
            sent += 1

    report = {
        "total": len(rows),
        "sent": sent,
        "failed": failed,
        "dry_run": args.dry_run,
        "results": results,
    }

    if args.format == "cli":
        print(f"Mail merge: {sent}/{len(rows)} sent, {failed} failed"
              + (" (dry run)" if args.dry_run else ""))
        for r in results:
            status = r.get("status", "?")
            to = r.get("to", "?")
            subj = r.get("subject", "?")
            err = r.get("error", "")
            print(f"  [{status}] {to}: {subj}" + (f" ({err})" if err else ""))
    else:
        json.dump(report, sys.stdout, indent=2)
        print()


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/fetch_mail.py
content: #!/usr/bin/env python3
"""Fetch emails from an IMAP server.

Usage:
    python3 scripts/fetch_mail.py --config config.yaml
    python3 scripts/fetch_mail.py --config config.yaml --account work
    python3 scripts/fetch_mail.py --config config.yaml --format cli
    python3 scripts/fetch_mail.py --imap-host imap.example.com --imap-user me --imap-pass secret

Output: JSON (default) or CLI table to stdout.
"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.imap_client import IMAPClient
from lib.models import EmailMessage


def main() -> None:
    parser = argparse.ArgumentParser(description="Fetch emails via IMAP")
    parser.add_argument("--account", default="", help="Account name (default: default account)")
    parser.add_argument("--folder", default="INBOX", help="Folder to fetch from")
    parser.add_argument("--limit", type=int, default=0, help="Max messages (0 = use config)")
    parser.add_argument("--unread-only", action="store_true", help="Only unread messages")
    parser.add_argument("--mark-read", action="store_true", help="Mark fetched messages as read")
    parser.add_argument("--format", choices=["json", "cli"], default="json",
                        help="Output format (default: json)")

    # Direct IMAP options (bypass AccountManager)
    parser.add_argument("--imap-host", default="", help="IMAP server")
    parser.add_argument("--imap-port", type=int, default=993, help="IMAP port")
    parser.add_argument("--imap-user", default="", help="IMAP username")
    parser.add_argument("--imap-pass", default="", help="IMAP password")
    parser.add_argument("--imap-no-ssl", action="store_true", help="Disable SSL")

    # Config / stdin
    parser.add_argument("--config", default="", help="YAML config file")
    parser.add_argument("--from-stdin", action="store_true",
                        help="Read JSON messages from stdin (skip IMAP)")

    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    # Read from stdin if piped in
    if args.from_stdin:
        data = json.load(sys.stdin)
        if isinstance(data, dict) and "messages" in data:
            msg_dicts = data["messages"]
        elif isinstance(data, list):
            msg_dicts = data
        else:
            _error("Expected JSON array or object with 'messages' key")
            return
        messages = [EmailMessage.from_dict(d) for d in msg_dicts]
        _output(messages, args.folder, args.format, args.account)
        return

    # Direct IMAP mode (no config file)
    if args.imap_host:
        client = IMAPClient(
            host=args.imap_host,
            port=args.imap_port,
            username=args.imap_user,
            [REDACTED_SECRET]),
            use_ssl=not args.imap_no_ssl,
        )
        limit = args.limit or 50
        try:
            client.connect()
            if args.unread_only:
                messages = client.fetch_unread(
                    mailbox=args.folder, limit=limit, mark_seen=args.mark_read,
                )
            else:
                messages = client.fetch_all(mailbox=args.folder, limit=limit)
        finally:
            client.disconnect()
        _output(messages, args.folder, args.format, "")
        return

    # Multi-account mode via config
    if not args.config:
        _error("Either --config or --imap-host is required.")

    mgr = _load_manager(args.config)
    acct_name = args.account or mgr.default_account
    limit = args.limit or mgr.get_fetch_limit(acct_name)

    client = mgr.get_imap_client(acct_name)
    try:
        client.connect()
        if args.unread_only:
            messages = client.fetch_unread(
                mailbox=args.folder, limit=limit, mark_seen=args.mark_read,
            )
        else:
            messages = client.fetch_all(mailbox=args.folder, limit=limit)
    finally:
        client.disconnect()

    _output(messages, args.folder, args.format, acct_name)


def _output(messages: list[EmailMessage], folder: str, fmt: str, account: str) -> None:
    if fmt == "cli":
        label = f"{account}:{folder}" if account else folder
        if not messages:
            print(f"  No messages in {label}")
            return
        print(f"  {label} ({len(messages)} messages)")
        print(f"  {'':->60}")
        for m in messages:
            sender = str(m.sender) if m.sender else "(unknown)"
            if len(sender) > 25:
                sender = sender[:22] + "..."
            date = m.date.strftime("%Y-%m-%d %H:%M") if m.date else ""
            subj = m.subject[:40] if m.subject else "(no subject)"
            read = " " if m.is_read else "*"
            att = "+" if m.has_attachments else " "
            print(f"  {read}{att} {date:16s}  {sender:25s}  {subj}")
        print()
    else:
        output = {
            "account": account,
            "folder": folder,
            "count": len(messages),
            "messages": [m.to_dict() for m in messages],
        }
        json.dump(output, sys.stdout, indent=2, default=str)
        print()


def _load_manager(path: str) -> AccountManager:
    try:
        return AccountManager.from_yaml(path)
    except Exception as exc:
        _error(f"Failed to load config: {exc}")
        raise  # unreachable, _error exits


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/thread_mail.py
content: #!/usr/bin/env python3
"""Group emails into conversation threads by References / In-Reply-To.

Fetches messages from a folder and groups them into threads using the
Message-ID, In-Reply-To, and References headers (RFC 5256 / JWZ algorithm
simplified).

Usage:
    python3 scripts/thread_mail.py --config config.yaml
    python3 scripts/thread_mail.py --config config.yaml --account work --folder INBOX
    python3 scripts/thread_mail.py --config config.yaml --limit 200 --format cli
    python3 scripts/thread_mail.py --from-stdin < messages.json
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from collections import defaultdict

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.models import EmailMessage


def build_threads(messages: list[EmailMessage]) -> list[dict]:
    """Group messages into conversation threads.

    Returns a list of thread dicts, each containing:
    - ``thread_id``: The Message-ID of the thread root
    - ``subject``: Subject of the root message
    - ``message_count``: Number of messages in the thread
    - ``participants``: Unique sender addresses
    - ``latest_date``: ISO date of the most recent message
    - ``messages``: List of message summaries in chronological order
    """
    # Index messages by Message-ID
    by_id: dict[str, EmailMessage] = {}
    for msg in messages:
        if msg.message_id:
            by_id[msg.message_id] = msg

    # Build parent map: message_id -> parent_message_id
    parent_of: dict[str, str] = {}
    children_of: dict[str, list[str]] = defaultdict(list)

    for msg in messages:
        mid = msg.message_id
        if not mid:
            continue

        parent = None
        if msg.in_reply_to and msg.in_reply_to in by_id:
            parent = msg.in_reply_to
        elif msg.references:
            # Walk references from most recent to oldest
            for ref in reversed(msg.references):
                if ref in by_id:
                    parent = ref
                    break

        if parent and parent != mid:
            parent_of[mid] = parent
            children_of[parent].append(mid)

    # Find roots (messages with no parent)
    roots = set()
    for msg in messages:
        mid = msg.message_id
        if not mid:
            continue
        # Walk up to root
        current = mid
        visited = set()
        while current in parent_of and current not in visited:
            visited.add(current)
            current = parent_of[current]
        roots.add(current)

    # Build thread for each root
    threads = []
    seen_in_thread = set()

    for root_id in roots:
        if root_id in seen_in_thread:
            continue

        # Collect all messages in this thread via BFS
        thread_ids = []
        queue = [root_id]
        while queue:
            current = queue.pop(0)
            if current in seen_in_thread:
                continue
            seen_in_thread.add(current)
            if current in by_id:
                thread_ids.append(current)
            queue.extend(children_of.get(current, []))

        if not thread_ids:
            continue

        # Sort by date
        thread_msgs = [by_id[mid] for mid in thread_ids if mid in by_id]
        thread_msgs.sort(key=lambda m: m.date or m.date.__class__(1970, 1, 1) if m.date else "", reverse=False)

        # Handle sorting with None dates
        def sort_key(m):
            if m.date:
                return m.date.isoformat()
            return ""
        thread_msgs.sort(key=sort_key)

        participants = set()
        for m in thread_msgs:
            if m.sender:
                participants.add(m.sender.address)

        latest = max(
            (m.date for m in thread_msgs if m.date),
            default=None,
        )

        root_msg = by_id.get(root_id)
        subject = root_msg.subject if root_msg else thread_msgs[0].subject if thread_msgs else ""

        threads.append({
            "thread_id": root_id,
            "subject": subject,
            "message_count": len(thread_msgs),
            "participants": sorted(participants),
            "latest_date": latest.isoformat() if latest else "",
            "messages": [
                {
                    "message_id": m.message_id,
                    "subject": m.subject,
                    "sender": str(m.sender) if m.sender else "",
                    "date": m.date.isoformat() if m.date else "",
                    "in_reply_to": m.in_reply_to,
                }
                for m in thread_msgs
            ],
        })

    # Sort threads by latest_date descending
    threads.sort(key=lambda t: t.get("latest_date", ""), reverse=True)
    return threads


def main() -> None:
    parser = argparse.ArgumentParser(description="Thread emails by conversation")
    parser.add_argument("--config", default="", help="YAML config file")
    parser.add_argument("--account", default="", help="Account profile name")
    parser.add_argument("--folder", default="INBOX", help="Folder to thread")
    parser.add_argument("--limit", type=int, default=100, help="Max messages to fetch")
    parser.add_argument("--from-stdin", action="store_true",
                        help="Read messages JSON from stdin")
    parser.add_argument("--format", choices=["json", "cli"], default="json")

    # Direct IMAP flags
    parser.add_argument("--imap-host", default="")
    parser.add_argument("--imap-port", type=int, default=993)
    parser.add_argument("--imap-user", default="")
    parser.add_argument("--imap-pass", default="")
    parser.add_argument("--imap-no-ssl", action="store_true")
    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    messages: list[EmailMessage] = []

    if args.from_stdin:
        try:
            data = json.load(sys.stdin)
            raw_msgs = data if isinstance(data, list) else data.get("messages", [])
            messages = [EmailMessage.from_dict(m) for m in raw_msgs]
        except Exception as exc:
            _error(f"Failed to parse stdin: {exc}")
            return
    elif args.imap_host:
        from lib.imap_client import IMAPClient
        client = IMAPClient(
            host=args.imap_host, port=args.imap_port,
            username=args.imap_user, [REDACTED_SECRET]),
            use_ssl=not args.imap_no_ssl,
        )
        try:
            client.connect()
            messages = client.fetch_all(mailbox=args.folder, limit=args.limit)
        finally:
            client.disconnect()
    elif args.config:
        try:
            mgr = AccountManager.from_yaml(args.config)
        except Exception as exc:
            _error(f"Failed to load config: {exc}")
            return
        acct_name = args.account or mgr.default_account
        client = mgr.get_imap_client(acct_name)
        try:
            client.connect()
            messages = client.fetch_all(mailbox=args.folder, limit=args.limit)
        finally:
            client.disconnect()
    else:
        _error("Provide --config, --imap-host, or --from-stdin")
        return

    threads = build_threads(messages)

    if args.format == "cli":
        print(f"Found {len(threads)} conversation thread(s) "
              f"from {len(messages)} message(s):\n")
        for t in threads:
            count = t["message_count"]
            subj = t["subject"] or "(no subject)"
            parts = ", ".join(t["participants"][:3])
            if len(t["participants"]) > 3:
                parts += f" +{len(t['participants']) - 3} more"
            print(f"  [{count} msg] {subj}")
            print(f"         Participants: {parts}")
            print(f"         Latest: {t['latest_date']}")
            print()
    else:
        result = {
            "total_messages": len(messages),
            "thread_count": len(threads),
            "threads": threads,
        }
        json.dump(result, sys.stdout, indent=2)
        print()


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/lib/models.py
content: """Data models for email messages, addresses, and attachments."""

from __future__ import annotations

import base64
import json
from dataclasses import dataclass, field, asdict
from datetime import datetime
from enum import Enum
from typing import Any


@dataclass(frozen=True)
class EmailAddress:
    """An email address with optional display name."""

    address: str
    display_name: str = ""

    def __str__(self) -> str:
        if self.display_name:
            return f"{self.display_name} <{self.address}>"
        return self.address

    @classmethod
    def parse(cls, raw: str) -> EmailAddress:
        raw = raw.strip()
        if "<" in raw and raw.endswith(">"):
            name_part, addr_part = raw.rsplit("<", 1)
            return cls(
                address=addr_part.rstrip(">").strip(),
                display_name=name_part.strip().strip('"'),
            )
        return cls(address=raw)

    def to_dict(self) -> dict[str, str]:
        return {"address": self.address, "display_name": self.display_name}

    @classmethod
    def from_dict(cls, d: dict[str, str]) -> EmailAddress:
        return cls(address=d["address"], display_name=d.get("display_name", ""))


class EmailPriority(Enum):
    LOW = "low"
    NORMAL = "normal"
    HIGH = "high"


@dataclass
class EmailAttachment:
    """An email attachment."""

    filename: str
    content_type: str
    data: bytes
    size: int = 0
    content_id: str | None = None

    def __post_init__(self) -> None:
        if not self.size:
            self.size = len(self.data)

    def to_dict(self) -> dict[str, Any]:
        return {
            "filename": self.filename,
            "content_type": self.content_type,
            "data_b64": base64.b64encode(self.data).decode("ascii"),
            "size": self.size,
            "content_id": self.content_id,
        }

    @classmethod
    def from_dict(cls, d: dict[str, Any]) -> EmailAttachment:
        return cls(
            filename=d["filename"],
            content_type=d["content_type"],
            data=base64.b64decode(d.get("data_b64", "")),
            size=d.get("size", 0),
            content_id=d.get("content_id"),
        )


@dataclass
class EmailMessage:
    """Represents an email message for both inbound and outbound use."""

    subject: str = ""
    sender: EmailAddress | None = None
    recipients: list[EmailAddress] = field(default_factory=list)
    cc: list[EmailAddress] = field(default_factory=list)
    bcc: list[EmailAddress] = field(default_factory=list)
    body_plain: str = ""
    body_html: str = ""
    attachments: list[EmailAttachment] = field(default_factory=list)
    headers: dict[str, str] = field(default_factory=dict)
    message_id: str = ""
    in_reply_to: str = ""
    references: list[str] = field(default_factory=list)
    date: datetime | None = None
    priority: EmailPriority = EmailPriority.NORMAL
    is_read: bool = False
    mailbox: str = "INBOX"
    account: str = ""
    flags: list[str] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)

    @property
    def has_attachments(self) -> bool:
        return len(self.attachments) > 0

    @property
    def body(self) -> str:
        return self.body_html if self.body_html else self.body_plain

    @property
    def recipient_addresses(self) -> list[str]:
        return [r.address for r in self.recipients]

    def to_dict(self) -> dict[str, Any]:
        return {
            "subject": self.subject,
            "sender": self.sender.to_dict() if self.sender else None,
            "recipients": [r.to_dict() for r in self.recipients],
            "cc": [r.to_dict() for r in self.cc],
            "bcc": [r.to_dict() for r in self.bcc],
            "body_plain": self.body_plain,
            "body_html": self.body_html,
            "attachments": [a.to_dict() for a in self.attachments],
            "headers": self.headers,
            "message_id": self.message_id,
            "in_reply_to": self.in_reply_to,
            "references": self.references,
            "date": self.date.isoformat() if self.date else None,
            "priority": self.priority.value,
            "is_read": self.is_read,
            "mailbox": self.mailbox,
            "account": self.account,
            "flags": self.flags,
            "has_attachments": self.has_attachments,
            "metadata": self.metadata,
        }

    @classmethod
    def from_dict(cls, d: dict[str, Any]) -> EmailMessage:
        sender = EmailAddress.from_dict(d["sender"]) if d.get("sender") else None
        recipients = [EmailAddress.from_dict(r) for r in d.get("recipients", [])]
        cc = [EmailAddress.from_dict(r) for r in d.get("cc", [])]
        bcc = [EmailAddress.from_dict(r) for r in d.get("bcc", [])]
        attachments = [EmailAttachment.from_dict(a) for a in d.get("attachments", [])]
        date = None
        if d.get("date"):
            try:
                date = datetime.fromisoformat(d["date"])
            except (ValueError, TypeError):
                pass
        priority = EmailPriority(d.get("priority", "normal"))
        return cls(
            subject=d.get("subject", ""),
            sender=sender,
            recipients=recipients,
            cc=cc,
            bcc=bcc,
            body_plain=d.get("body_plain", ""),
            body_html=d.get("body_html", ""),
            attachments=attachments,
            headers=d.get("headers", {}),
            message_id=d.get("message_id", ""),
            in_reply_to=d.get("in_reply_to", ""),
            references=d.get("references", []),
            date=date,
            priority=priority,
            is_read=d.get("is_read", False),
            mailbox=d.get("mailbox", "INBOX"),
            account=d.get("account", ""),
            flags=d.get("flags", []),
            metadata=d.get("metadata", {}),
        )


path: scripts/lib/composer.py
content: """Rich email composer with built-in templates."""

from __future__ import annotations

import re
from datetime import datetime
from pathlib import Path
from string import Template
from typing import Any

from .models import EmailAddress, EmailMessage

DEFAULT_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7;">
<tr>
<td align="center" style="padding:24px 0;">

${header_block}

<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px; width:100%;">
<tr>
<td style="background-color:#ffffff; padding:32px; border-radius:0 0 8px 8px;">

  ${greeting_block}

  <div style="color:#4a5568; font-size:15px; line-height:1.6;">
    ${body}
  </div>

  ${action_block}

  ${sign_off_block}

</td>
</tr>
</table>

${footer_block}

</td>
</tr>
</table>
</body>
</html>
"""

MINIMAL_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>${subject}</title></head>
<body style="margin:0; padding:20px; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color:#2d3748; font-size:15px; line-height:1.6;">
${body}
${sign_off_block}
</body>
</html>
"""

DIGEST_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>${subject}</title></head>
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7;">
<tr><td align="center" style="padding:24px 0;">

${header_block}

<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px; width:100%;">
<tr>
<td style="background-color:#ffffff; padding:24px 32px;">

  ${summary_block}
  ${table_block}

</td>
</tr>
</table>

${footer_block}

</td></tr>
</table>
</body>
</html>
"""

BUILTIN_TEMPLATES = {
    "default": DEFAULT_TEMPLATE,
    "minimal": MINIMAL_TEMPLATE,
    "digest": DIGEST_TEMPLATE,
}


def _build_header_block(
    header_text: str = "", header_color: str = "#2d3748", subject: str = "",
) -> str:
    text = header_text or subject
    if not text:
        return ""
    return (
        f'<table role="presentation" width="600" cellpadding="0" cellspacing="0" '
        f'style="max-width:600px; width:100%;">\n<tr>\n'
        f'<td style="background-color:{header_color}; padding:20px 32px; '
        f'border-radius:8px 8px 0 0;">\n'
        f'  <h1 style="margin:0; color:#ffffff; font-size:20px; font-weight:600;">'
        f'{text}</h1>\n</td>\n</tr>\n</table>'
    )


def _build_footer_block(footer_text: str = "") -> str:
    if not footer_text:
        return ""
    return (
        f'<table role="presentation" width="600" cellpadding="0" cellspacing="0" '
        f'style="max-width:600px; width:100%;">\n<tr>\n'
        f'<td style="background-color:#edf2f7; padding:16px 32px; '
        f'border-radius:0 0 8px 8px;">\n'
        f'  <p style="margin:0; color:#a0aec0; font-size:12px; text-align:center;">'
        f'{footer_text}</p>\n</td>\n</tr>\n</table>'
    )


def _build_action_block(
    action_url: str = "", action_text: str = "View Details",
    action_color: str = "#4299e1",
) -> str:
    if not action_url:
        return ""
    return (
        f'<table role="presentation" cellpadding="0" cellspacing="0" '
        f'style="margin:24px 0;">\n<tr>\n'
        f'<td style="background-color:{action_color}; border-radius:6px;">\n'
        f'  <a href="{action_url}" style="display:inline-block; padding:12px 24px; '
        f'color:#ffffff; text-decoration:none; font-size:15px; font-weight:600;">'
        f'{action_text}</a>\n</td>\n</tr>\n</table>'
    )


def _build_table_block(
    items: list[dict[str, str]], columns: list[str],
) -> str:
    if not items or not columns:
        return ""
    header_cells = "".join(
        f'<th style="text-align:left; padding:8px 12px; background-color:#edf2f7; '
        f'color:#4a5568; font-size:12px; font-weight:600; text-transform:uppercase; '
        f'border-bottom:2px solid #e2e8f0;">{col}</th>'
        for col in columns
    )
    rows = ""
    for item in items:
        cells = "".join(
            f'<td style="padding:10px 12px; border-bottom:1px solid #e2e8f0; '
            f'color:#2d3748; font-size:14px;">{item.get(col, "")}</td>'
            for col in columns
        )
        rows += f"<tr>{cells}</tr>\n"
    return (
        f'<table width="100%" cellpadding="0" cellspacing="0" '
        f'style="border-collapse:collapse;">\n'
        f'<thead><tr>{header_cells}</tr></thead>\n'
        f'<tbody>\n{rows}</tbody>\n</table>'
    )


class EmailComposer:
    """Compose rich HTML emails using built-in or custom templates."""

    def __init__(self, template_dir: str | None = None) -> None:
        self.template_dir = Path(template_dir) if template_dir else None

    def compose(
        self,
        to: str | list[str],
        subject: str,
        body: str,
        template: str = "default",
        sender: str | EmailAddress | None = None,
        cc: str | list[str] | None = None,
        bcc: str | list[str] | None = None,
        reply_to: str = "",
        greeting: str = "",
        sign_off: str = "",
        header_text: str = "",
        header_color: str = "#2d3748",
        footer_text: str = "",
        action_url: str = "",
        action_text: str = "View Details",
        action_color: str = "#4299e1",
        items: list[dict[str, str]] | None = None,
        columns: list[str] | None = None,
        summary: str = "",
        **extra: Any,
    ) -> EmailMessage:
        recipients = _normalize(to)
        cc_list = _normalize(cc) if cc else []
        bcc_list = _normalize(bcc) if bcc else []

        sender_addr = None
        if sender:
            sender_addr = (
                sender if isinstance(sender, EmailAddress)
                else EmailAddress.parse(sender)
            )

        # Build template blocks
        header_block = _build_header_block(header_text, header_color, subject)
        footer_block = _build_footer_block(footer_text)
        action_block = _build_action_block(action_url, action_text, action_color)
        greeting_block = (
            f'<p style="margin:0 0 16px; color:#2d3748; font-size:16px;">{greeting}</p>'
            if greeting else ""
        )
        sign_off_block = (
            f'<p style="margin:24px 0 0; color:#4a5568; font-size:15px;">{sign_off}</p>'
            if sign_off else ""
        )
        summary_block = (
            f'<p style="margin:0 0 20px; color:#4a5568; font-size:15px;">{summary}</p>'
            if summary else ""
        )
        table_block = _build_table_block(items or [], columns or [])

        # Render template
        tmpl_str = self._get_template(template)
        html_body = Template(tmpl_str).safe_substitute(
            subject=subject,
            body=body,
            header_block=header_block,
            footer_block=footer_block,
            action_block=action_block,
            greeting_block=greeting_block,
            sign_off_block=sign_off_block,
            summary_block=summary_block,
            table_block=table_block,
        )

        plain_text = _html_to_plain(html_body)

        return EmailMessage(
            subject=subject, sender=sender_addr,
            recipients=recipients, cc=cc_list, bcc=bcc_list,
            body_html=html_body, body_plain=plain_text,
            in_reply_to=reply_to,
            date=datetime.now(),
        )

    def compose_digest(
        self,
        to: str | list[str],
        subject: str,
        items: list[dict[str, str]],
        columns: list[str],
        sender: str | EmailAddress | None = None,
        **kwargs: Any,
    ) -> EmailMessage:
        return self.compose(
            to=to, subject=subject, body="",
            template="digest", sender=sender,
            items=items, columns=columns, **kwargs,
        )

    def compose_reply(
        self,
        original: EmailMessage,
        body: str,
        sender: str | EmailAddress | None = None,
        template: str = "minimal",
        quote_original: bool = True,
        **kwargs: Any,
    ) -> EmailMessage:
        subj = original.subject
        if not subj.startswith("Re:"):
            subj = f"Re: {subj}"

        full_body = body
        if quote_original:
            quoted = _build_quote_block(original)
            full_body = f"{body}\n\n{quoted}"

        reply = self.compose(
            to=[str(original.sender)] if original.sender else [],
            subject=subj,
            body=full_body,
            template=template,
            sender=sender,
            reply_to=original.message_id,
            **kwargs,
        )
        reply.in_reply_to = original.message_id
        reply.references = original.references + [original.message_id]
        return reply

    def compose_forward(
        self,
        original: EmailMessage,
        to: str | list[str],
        body: str = "",
        sender: str | EmailAddress | None = None,
        template: str = "minimal",
        attach_original: bool = False,
        **kwargs: Any,
    ) -> EmailMessage:
        """Compose a forwarded message with the original inline-quoted.

        If *attach_original* is True, the original message's attachments
        are carried over to the forward.
        """
        subj = original.subject
        if not subj.startswith("Fwd:"):
            subj = f"Fwd: {subj}"

        fwd_block = _build_forward_block(original)
        full_body = f"{body}\n\n{fwd_block}" if body else fwd_block

        fwd = self.compose(
            to=to,
            subject=subj,
            body=full_body,
            template=template,
            sender=sender,
            **kwargs,
        )

        if attach_original and original.attachments:
            fwd.attachments = list(original.attachments)

        return fwd

    def _get_template(self, name: str) -> str:
        if name in BUILTIN_TEMPLATES:
            return BUILTIN_TEMPLATES[name]
        if self.template_dir:
            path = self.template_dir / name
            if path.is_file():
                return path.read_text()
        return BUILTIN_TEMPLATES["default"]


def _build_quote_block(original: EmailMessage) -> str:
    """Build a '> On date, sender wrote:' quoted block from the original."""
    sender = str(original.sender) if original.sender else "someone"
    date_str = (
        original.date.strftime("%a, %b %d, %Y at %I:%M %p")
        if original.date else "an unknown date"
    )
    orig_text = original.body_plain or _html_to_plain(original.body_html or "")
    quoted_lines = "\n".join(f"> {line}" for line in orig_text.splitlines())

    plain_block = f"On {date_str}, {sender} wrote:\n{quoted_lines}"

    html_quoted = orig_text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    html_quoted = "<br>".join(html_quoted.splitlines())
    html_block = (
        f'<div style="margin-top:16px; padding-left:12px; '
        f'border-left:3px solid #ccc; color:#555;">'
        f'<p style="margin:0 0 8px;">On {date_str}, {sender} wrote:</p>'
        f'<div style="white-space:pre-wrap;">{html_quoted}</div>'
        f'</div>'
    )

    return html_block


def _build_forward_block(original: EmailMessage) -> str:
    """Build a '---------- Forwarded message ----------' block."""
    sender = str(original.sender) if original.sender else "(unknown)"
    date_str = (
        original.date.strftime("%a, %b %d, %Y at %I:%M %p")
        if original.date else ""
    )
    to_str = ", ".join(str(r) for r in original.recipients) if original.recipients else ""

    orig_body = original.body_html or original.body_plain or ""

    header_lines = [
        "---------- Forwarded message ----------",
        f"From: {sender}",
    ]
    if date_str:
        header_lines.append(f"Date: {date_str}")
    header_lines.append(f"Subject: {original.subject}")
    if to_str:
        header_lines.append(f"To: {to_str}")

    header_html = "<br>".join(header_lines)
    return (
        f'<div style="margin-top:16px;">'
        f'<div style="color:#777; font-size:13px;">{header_html}</div>'
        f'<br>'
        f'<div>{orig_body}</div>'
        f'</div>'
    )


def _normalize(addrs: str | list[str] | None) -> list[EmailAddress]:
    if not addrs:
        return []
    if isinstance(addrs, str):
        addrs = [addrs]
    return [EmailAddress.parse(a) for a in addrs]


def _html_to_plain(html: str) -> str:
    # Strip <head> (contains <title> which duplicates the subject) and
    # <style>/<script> blocks before removing tags.
    text = re.sub(r"<head[\s>].*?</head>", "", html, flags=re.DOTALL | re.IGNORECASE)
    text = re.sub(r"<style[\s>].*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
    text = re.sub(r"<script[\s>].*?</script>", "", text, flags=re.DOTALL | re.IGNORECASE)
    text = re.sub(r"<[^>]+>", "", text)
    text = re.sub(r"\n\s*\n", "\n\n", text)
    return text.strip()


path: scripts/lib/oauth2.py
content: """OAuth2 (XOAUTH2) support for IMAP and SMTP authentication.

Handles token loading, refresh, and SASL XOAUTH2 string generation
for Gmail and Outlook 365.

Config layout (per-account)::

    accounts:
      gmail:
        imap:
          host: imap.gmail.com
          port: 993
          username: [REDACTED_SECRET]
          auth: oauth2
          oauth2:
            client_id: "..."
            client_secret: "..."
            refresh_token: "..."
            token_uri: "https://oauth2.googleapis.com/token"
            # Optional: cached access token
            access_token: "..."
            access_token_expiry: "2026-01-01T00:00:00"
        smtp:
          host: smtp.gmail.com
          port: 587
          username: [REDACTED_SECRET]
          auth: oauth2
          oauth2:
            # Same fields, or reference the IMAP oauth2 block
            ...

Well-known token URIs:
  - Gmail:   https://oauth2.googleapis.com/token
  - Outlook: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
"""

from __future__ import annotations

import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Any
from urllib.request import Request, urlopen
from urllib.error import URLError
from urllib.parse import urlencode

from . import credential_store

logger = logging.getLogger("clawMail.oauth2")


def build_xoauth2_string(username: str, access_token: str) -> str:
    """Build the XOAUTH2 SASL string for IMAP/SMTP AUTH.

    Format: "user=<user>\\x01auth=Bearer <token>\\x01\\x01"
    """
    auth_str = f"user={username}\x01auth=Bearer {access_token}\x01\x01"
    return auth_str


def build_xoauth2_bytes(username: str, access_token: str) -> bytes:
    """Build base64-encoded XOAUTH2 string for IMAP AUTHENTICATE."""
    raw = build_xoauth2_string(username, access_token)
    return base64.b64encode(raw.encode("ascii"))


def refresh_access_token(
    client_id: str,
    client_secret: str,
    refresh_token: str,
    token_uri: str,
    scopes: list[str] | None = None,
) -> dict[str, Any]:
    """Refresh an OAuth2 access token using a refresh token.

    Returns dict with: access_token, expires_in, token_type, and optionally scope.
    Raises RuntimeError on failure.
    """
    data = {
        "client_id": client_id,
        "client_secret": client_secret,
        "refresh_token": refresh_token,
        "grant_type": "refresh_token",
    }
    if scopes:
        data["scope"] = " ".join(scopes)

    encoded = urlencode(data).encode("utf-8")
    req = Request(token_uri, data=encoded, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")

    try:
        with urlopen(req, timeout=30) as resp:
            body = json.loads(resp.read().decode("utf-8"))
    except (URLError, OSError, json.JSONDecodeError) as exc:
        raise RuntimeError(f"OAuth2 token refresh failed: {exc}") from exc

    if "access_token" not in body:
        error = body.get("error_description", body.get("error", "unknown error"))
        raise RuntimeError(f"OAuth2 token refresh failed: {error}")

    return body


class OAuth2Manager:
    """Manage OAuth2 tokens for an account, with automatic refresh."""

    def __init__(self, oauth2_config: dict[str, Any]) -> None:
        self.client_id: str = oauth2_config.get("client_id", "")
        self.client_secret: str = credential_store.resolve(
            oauth2_config.get("client_secret", "")
        )
        self.refresh_token: str = credential_store.resolve(
            oauth2_config.get("refresh_token", "")
        )
        self.token_uri: str = oauth2_config.get("token_uri", "")
        self._access_token: str = oauth2_config.get("access_token", "")
        self._scopes: list[str] = oauth2_config.get("scopes", [])

        expiry_str = oauth2_config.get("access_token_expiry", "")
        if expiry_str:
            try:
                self._expiry = datetime.fromisoformat(expiry_str)
            except (ValueError, TypeError):
                self._expiry = datetime.min
        else:
            self._expiry = datetime.min

    @property
    def is_configured(self) -> bool:
        return bool(self.client_id and self.refresh_token and self.token_uri)

    @property
    def access_token(self) -> str:
        """Get a valid access token, refreshing if needed."""
        if self._is_expired():
            self._refresh()
        return self._access_token

    def _is_expired(self) -> bool:
        if not self._access_token:
            return True
        # Refresh 5 minutes before actual expiry
        return datetime.now() >= (self._expiry - timedelta(minutes=5))

    def _refresh(self) -> None:
        if not self.is_configured:
            raise RuntimeError(
                "OAuth2 not configured: client_id, refresh_token, "
                "and token_uri are required"
            )
        logger.info("Refreshing OAuth2 access token via %s", self.token_uri)
        result = refresh_access_token(
            client_id=self.client_id,
            client_secret=self.client_secret,
            refresh_token=self.refresh_token,
            token_uri=self.token_uri,
            scopes=self._scopes or None,
        )
        self._access_token = result["access_token"]
        expires_in = result.get("expires_in", 3600)
        self._expiry = datetime.now() + timedelta(seconds=expires_in)
        logger.info("OAuth2 token refreshed, expires in %ds", expires_in)


path: scripts/lib/__init__.py
content: """Shared library for clawMail skill scripts."""


path: scripts/lib/processor.py
content: """Rule-based email processing pipeline."""

from __future__ import annotations

import json
import logging
import re
import urllib.request
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable

from .models import EmailMessage

logger = logging.getLogger("clawMail.processor")


class RuleAction(Enum):
    FLAG = "flag"
    TAG = "tag"
    MARK_READ = "mark_read"
    MOVE = "move"
    ARCHIVE = "archive"
    FORWARD = "forward"
    AUTO_REPLY = "auto_reply"
    CALLBACK = "callback"
    WEBHOOK = "webhook"
    SKIP = "skip"


@dataclass
class ProcessingRule:
    name: str
    actions: list[RuleAction]

    sender_pattern: str = ""
    subject_pattern: str = ""
    body_pattern: str = ""
    mailbox: str = ""
    account: str = ""
    has_attachments: bool | None = None

    flag_index: int = 0
    tag: str = ""
    move_to: str = ""
    forward_to: str = ""
    reply_template: str = ""
    webhook_url: str = ""
    callback: Callable[[EmailMessage], Any] | None = None

    priority: int = 0
    stop_after_match: bool = False

    def matches(self, message: EmailMessage) -> bool:
        if self.sender_pattern:
            sender_str = str(message.sender) if message.sender else ""
            if not re.search(self.sender_pattern, sender_str, re.IGNORECASE):
                return False
        if self.subject_pattern:
            if not re.search(self.subject_pattern, message.subject, re.IGNORECASE):
                return False
        if self.body_pattern:
            body = message.body_plain or message.body_html or ""
            if not re.search(self.body_pattern, body, re.IGNORECASE):
                return False
        if self.mailbox and message.mailbox != self.mailbox:
            return False
        if self.account and message.account != self.account:
            return False
        if self.has_attachments is not None:
            if message.has_attachments != self.has_attachments:
                return False
        return True


@dataclass
class ProcessingResult:
    message: EmailMessage
    matched_rules: list[str] = field(default_factory=list)
    actions_taken: list[str] = field(default_factory=list)
    tags: list[str] = field(default_factory=list)
    should_flag: bool = False
    flag_index: int = 0
    should_mark_read: bool = False
    move_to: str = ""
    should_archive: bool = False
    forward_to: list[str] = field(default_factory=list)
    reply_body: str = ""
    webhook_results: list[dict[str, Any]] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        return {
            "message_id": self.message.message_id,
            "subject": self.message.subject,
            "account": self.message.account,
            "matched_rules": self.matched_rules,
            "actions_taken": self.actions_taken,
            "tags": self.tags,
            "should_flag": self.should_flag,
            "flag_index": self.flag_index,
            "should_mark_read": self.should_mark_read,
            "move_to": self.move_to,
            "should_archive": self.should_archive,
            "forward_to": self.forward_to,
            "reply_body": self.reply_body,
            "webhook_results": self.webhook_results,
            "errors": self.errors,
        }


class EmailProcessor:
    def __init__(self) -> None:
        self._rules: list[ProcessingRule] = []

    def add_rule(self, rule: ProcessingRule) -> None:
        self._rules.append(rule)
        self._rules.sort(key=lambda r: r.priority, reverse=True)

    def remove_rule(self, name: str) -> bool:
        before = len(self._rules)
        self._rules = [r for r in self._rules if r.name != name]
        return len(self._rules) < before

    @property
    def rules(self) -> list[ProcessingRule]:
        return list(self._rules)

    def process(self, message: EmailMessage) -> ProcessingResult:
        result = ProcessingResult(message=message)
        for rule in self._rules:
            if not rule.matches(message):
                continue
            result.matched_rules.append(rule.name)
            for action in rule.actions:
                try:
                    self._apply(action, rule, result)
                    result.actions_taken.append(f"{rule.name}:{action.value}")
                except Exception as exc:
                    result.errors.append(f"{rule.name}:{action.value}: {exc}")
            if rule.stop_after_match:
                break
        return result

    def process_batch(self, messages: list[EmailMessage]) -> list[ProcessingResult]:
        return [self.process(msg) for msg in messages]

    def _apply(
        self, action: RuleAction, rule: ProcessingRule, result: ProcessingResult,
    ) -> None:
        if action == RuleAction.FLAG:
            result.should_flag = True
            result.flag_index = rule.flag_index
        elif action == RuleAction.TAG:
            if rule.tag:
                result.tags.append(rule.tag)
        elif action == RuleAction.MARK_READ:
            result.should_mark_read = True
        elif action == RuleAction.MOVE:
            if rule.move_to:
                result.move_to = rule.move_to
        elif action == RuleAction.ARCHIVE:
            result.should_archive = True
        elif action == RuleAction.FORWARD:
            if rule.forward_to:
                result.forward_to.append(rule.forward_to)
        elif action == RuleAction.AUTO_REPLY:
            if rule.reply_template:
                result.reply_body = rule.reply_template
        elif action == RuleAction.WEBHOOK:
            if rule.webhook_url:
                self._fire_webhook(rule.webhook_url, result)
        elif action == RuleAction.CALLBACK:
            if rule.callback:
                rule.callback(result.message)

    @staticmethod
    def _fire_webhook(url: str, result: ProcessingResult) -> None:
        """POST a JSON payload to the webhook URL."""
        msg = result.message
        payload = json.dumps({
            "event": "email_rule_match",
            "message_id": msg.message_id,
            "subject": msg.subject,
            "sender": str(msg.sender) if msg.sender else "",
            "recipients": [str(r) for r in msg.recipients],
            "date": msg.date.isoformat() if msg.date else "",
            "account": msg.account,
            "mailbox": msg.mailbox,
            "matched_rules": result.matched_rules,
            "tags": result.tags,
        }).encode("utf-8")

        req = urllib.request.Request(
            url,
            data=payload,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=10) as resp:
                result.webhook_results.append({
                    "url": url,
                    "status": resp.status,
                    "ok": 200 <= resp.status < 300,
                })
        except Exception as exc:
            logger.warning("Webhook POST to %s failed: %s", url, exc)
            result.webhook_results.append({
                "url": url,
                "status": 0,
                "ok": False,
                "error": str(exc),
            })

    @classmethod
    def from_config(cls, rules_config: list[dict[str, Any]]) -> EmailProcessor:
        processor = cls()
        for rd in rules_config:
            actions = [RuleAction(a) for a in rd.get("actions", [])]
            rule = ProcessingRule(
                name=rd["name"], actions=actions,
                sender_pattern=rd.get("sender_pattern", ""),
                subject_pattern=rd.get("subject_pattern", ""),
                body_pattern=rd.get("body_pattern", ""),
                mailbox=rd.get("mailbox", ""),
                account=rd.get("account", ""),
                has_attachments=rd.get("has_attachments"),
                flag_index=rd.get("flag_index", 0),
                tag=rd.get("tag", ""),
                move_to=rd.get("move_to", ""),
                forward_to=rd.get("forward_to", ""),
                reply_template=rd.get("reply_template", ""),
                webhook_url=rd.get("webhook_url", ""),
                priority=rd.get("priority", 0),
                stop_after_match=rd.get("stop_after_match", False),
            )
            processor.add_rule(rule)
        return processor


path: scripts/lib/credential_store.py
content: """Secure credential storage backends.

Retrieves passwords and secrets from secure stores instead of storing
them as plain text in config files.  Supported backends:

* **1Password CLI** (``op://vault/item/field``)
* **macOS Keychain** (``keychain://service/account``)
* **Environment variable** (``env://VAR_NAME``)
* **Plain text** — returned as-is (with a logged warning)

Usage in config.yaml::

    accounts:
      work:
        imap:
          host: imap.company.com
          [REDACTED_SECRET]"     # 1Password
        smtp:
          host: smtp.company.com
          [REDACTED_SECRET]"  # macOS Keychain
      personal:
        imap:
          [REDACTED_SECRET]"     # env var
"""

from __future__ import annotations

import logging
import platform
import shutil
import subprocess

logger = logging.getLogger("clawMail.credentials")


def resolve(value: str) -> str:
    """Resolve a credential reference to its secret value.

    If *value* matches a known URI scheme the secret is fetched from the
    corresponding backend.  Otherwise it is returned as-is (plain text)
    with a warning.
    """
    if not value:
        return value

    if value.startswith("op://"):
        return _resolve_1password(value)

    if value.startswith("keychain://"):
        return _resolve_keychain(value)

    if value.startswith("env://"):
        return _resolve_env(value)

    # Plain-text fallback
    if len(value) > 4:
        logger.warning(
            "Password appears to be stored as plain text in config. "
            "Consider using op://, keychain://, or env:// for secure storage."
        )
    return value


# ── 1Password CLI ────────────────────────────────────────────────────


def _resolve_1password(ref: str) -> str:
    """Fetch a secret via the 1Password CLI (``op read``)."""
    op_path = shutil.which("op")
    if op_path is None:
        raise RuntimeError(
            f"1Password CLI ('op') not found on PATH. "
            f"Install it to use {ref!r} credential references. "
            f"See https://developer.1password.com/docs/cli/get-started/"
        )
    try:
        result = subprocess.run(
            [op_path, "read", ref, "--no-newline"],
            capture_output=True, text=True, timeout=15,
        )
        if result.returncode != 0:
            raise RuntimeError(
                f"1Password CLI failed for {ref!r}: {result.stderr.strip()}"
            )
        return result.stdout
    except subprocess.TimeoutExpired:
        raise RuntimeError(f"1Password CLI timed out reading {ref!r}")


# ── macOS Keychain ───────────────────────────────────────────────────


def _resolve_keychain(ref: str) -> str:
    """Fetch a secret from the macOS Keychain via ``security``."""
    if platform.system() != "Darwin":
        raise RuntimeError(
            f"keychain:// credential references are only supported on macOS. "
            f"Current platform: {platform.system()}"
        )

    # Parse keychain://service/account
    path = ref[len("keychain://"):]
    parts = path.split("/", 1)
    if len(parts) != 2 or not parts[0] or not parts[1]:
        raise ValueError(
            f"Invalid keychain reference: {ref!r}. "
            f"Expected format: keychain://service-name/account-name"
        )
    service, account = parts

    security_path = shutil.which("security")
    if security_path is None:
        raise RuntimeError("macOS 'security' command not found on PATH")

    try:
        result = subprocess.run(
            [
                security_path, "find-generic-password",
                "-s", service, "-a", account, "-w",
            ],
            capture_output=True, text=True, timeout=10,
        )
        if result.returncode != 0:
            raise RuntimeError(
                f"macOS Keychain lookup failed for service={service!r}, "
                f"account={account!r}: {result.stderr.strip()}"
            )
        return result.stdout.strip()
    except subprocess.TimeoutExpired:
        raise RuntimeError(
            f"macOS Keychain lookup timed out for {ref!r}"
        )


# ── Environment variables ────────────────────────────────────────────


def _resolve_env(ref: str) -> str:
    """Read a secret from an environment variable."""
    import os

    var_name = ref[len("env://"):]
    if not var_name:
        raise ValueError(f"Empty env variable name in {ref!r}")

    value = os.environ.get(var_name)
    if value is None:
        raise RuntimeError(
            f"Environment variable {var_name!r} is not set "
            f"(referenced by {ref!r})"
        )
    return value


path: scripts/lib/send_queue.py
content: """Persistent send-retry queue backed by a JSON file.

When an SMTP send fails, the message can be enqueued for later retry.
On the next heartbeat (or manual invocation) the queue is drained.

Queue file layout (JSON)::

    {
      "version": 1,
      "messages": [
        {
          "id": "uuid",
          "account": "work",
          "message": { ... serialised EmailMessage dict ... },
          "enqueued_at": "2026-02-24T12:00:00",
          "attempts": 1,
          "last_error": "Connection refused",
          "next_retry_after": "2026-02-24T12:05:00"
        }
      ]
    }
"""

from __future__ import annotations

import json
import logging
import os
import uuid
from datetime import datetime, timedelta
from typing import Any

from .models import EmailMessage

logger = logging.getLogger("clawMail.send_queue")

_MAX_ATTEMPTS = 5
_BACKOFF_MINUTES = [1, 5, 15, 60, 240]  # exponential-ish


class SendQueue:
    """File-backed queue for messages that failed to send."""

    def __init__(self, path: str, max_attempts: int = _MAX_ATTEMPTS) -> None:
        self.path = path
        self.max_attempts = max_attempts
        self._entries: list[dict[str, Any]] = []
        self._load()

    # ── Public API ────────────────────────────────────────────────

    def enqueue(
        self,
        message: EmailMessage,
        account: str = "",
        error: str = "",
    ) -> str:
        """Add a failed message to the retry queue.  Returns the queue ID."""
        entry_id = str(uuid.uuid4())[:8]
        self._entries.append({
            "id": entry_id,
            "account": account,
            "message": message.to_dict(),
            "enqueued_at": datetime.now().isoformat(),
            "attempts": 0,
            "last_error": error,
            "next_retry_after": datetime.now().isoformat(),
        })
        self._save()
        logger.info("Enqueued message %s for retry (account=%s)", entry_id, account)
        return entry_id

    def peek(self) -> list[dict[str, Any]]:
        """Return all entries that are ready for retry now."""
        now = datetime.now()
        ready = []
        for e in self._entries:
            if e["attempts"] >= self.max_attempts:
                continue
            nra = e.get("next_retry_after", "")
            if nra:
                try:
                    if datetime.fromisoformat(nra) > now:
                        continue
                except (ValueError, TypeError):
                    pass
            ready.append(e)
        return ready

    def mark_sent(self, entry_id: str) -> None:
        """Remove a successfully sent entry from the queue."""
        self._entries = [e for e in self._entries if e["id"] != entry_id]
        self._save()

    def mark_failed(self, entry_id: str, error: str = "") -> None:
        """Record a failed retry attempt and schedule the next one."""
        for e in self._entries:
            if e["id"] == entry_id:
                e["attempts"] += 1
                e["last_error"] = error
                idx = min(e["attempts"] - 1, len(_BACKOFF_MINUTES) - 1)
                delay = timedelta(minutes=_BACKOFF_MINUTES[idx])
                e["next_retry_after"] = (datetime.now() + delay).isoformat()
                break
        self._save()

    def remove_expired(self) -> int:
        """Remove entries that have exceeded max_attempts.  Returns count removed."""
        before = len(self._entries)
        self._entries = [
            e for e in self._entries if e["attempts"] < self.max_attempts
        ]
        removed = before - len(self._entries)
        if removed:
            self._save()
        return removed

    def list_all(self) -> list[dict[str, Any]]:
        return list(self._entries)

    @property
    def size(self) -> int:
        return len(self._entries)

    # ── Persistence ───────────────────────────────────────────────

    def _load(self) -> None:
        if not os.path.exists(self.path):
            self._entries = []
            return
        try:
            with open(self.path) as f:
                data = json.load(f)
            self._entries = data.get("messages", [])
        except Exception as exc:
            logger.warning("Failed to load send queue %s: %s", self.path, exc)
            self._entries = []

    def _save(self) -> None:
        data = {"version": 1, "messages": self._entries}
        with open(self.path, "w") as f:
            json.dump(data, f, indent=2)


path: scripts/lib/smime.py
content: """S/MIME signing and encryption for outgoing emails.

This module provides helpers to:
- Sign an email with an S/MIME certificate (PKCS#12 or PEM)
- Encrypt an email to recipient certificates

Requires the ``cryptography`` library (``pip install cryptography``).

Usage::

    from lib.smime import SMIMESigner
    signer = SMIMESigner(cert_path="cert.pem", key_path="key.pem")
    signed_mime = signer.sign(mime_message)

    from lib.smime import SMIMEEncryptor
    encryptor = SMIMEEncryptor(recipient_certs=["recipient.pem"])
    encrypted_mime = encryptor.encrypt(mime_message)
"""

from __future__ import annotations

import logging
import os
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any

from . import credential_store

logger = logging.getLogger("clawMail.smime")


class SMIMESigner:
    """Sign outgoing emails with an S/MIME certificate."""

    def __init__(
        self,
        cert_path: str = "",
        key_path: str = "",
        key_password: str | None = None,
        pkcs12_path: str = "",
        pkcs12_password: str | None = None,
    ) -> None:
        self.cert_path = cert_path
        self.key_path = key_path
        self.key_password = credential_store.resolve(key_password) if key_password else None
        self.pkcs12_path = pkcs12_path
        self.pkcs12_password = credential_store.resolve(pkcs12_password) if pkcs12_password else None
        self._cert = None
        self._key = None
        self._loaded = False

    def _load(self) -> None:
        """Load certificate and private key from PEM or PKCS#12 files."""
        if self._loaded:
            return

        try:
            from cryptography.hazmat.primitives.serialization import (
                load_pem_private_key,
                pkcs12,
            )
            from cryptography import x509
        except ImportError:
            raise ImportError(
                "S/MIME requires the 'cryptography' package. "
                "Install it with: pip install cryptography"
            )

        if self.pkcs12_path:
            with open(self.pkcs12_path, "rb") as f:
                p12_data = f.read()
            [REDACTED_SECRET]) if self.pkcs12_password else None
            self._key, self._cert, _ = pkcs12.load_key_and_certificates(p12_data, pwd)
        else:
            if self.cert_path:
                with open(self.cert_path, "rb") as f:
                    self._cert = x509.load_pem_x509_certificate(f.read())
            if self.key_path:
                with open(self.key_path, "rb") as f:
                    [REDACTED_SECRET]) if self.key_password else None
                    self._key = load_pem_private_key(f.read(), password=pwd)

        self._loaded = True

    def sign(self, mime_message: MIMEMultipart) -> MIMEMultipart:
        """Sign a MIME message, returning a multipart/signed wrapper.

        Creates an S/MIME signed message using PKCS#7 detached signature
        (application/pkcs7-signature).
        """
        self._load()

        try:
            from cryptography.hazmat.primitives.serialization import pkcs7, Encoding
            from cryptography.hazmat.primitives import hashes
        except ImportError:
            raise ImportError("S/MIME signing requires the 'cryptography' package.")

        if not self._key or not self._cert:
            raise ValueError("Both certificate and private key are required for signing")

        # Serialize the message to sign
        msg_bytes = mime_message.as_bytes()

        # Build PKCS#7 detached signature
        signature = (
            pkcs7.PKCS7SignatureBuilder()
            .set_data(msg_bytes)
            .add_signer(self._cert, self._key, hashes.SHA256())
            .sign(Encoding.DER, [pkcs7.PKCS7Options.DetachedSignature])
        )

        # Build multipart/signed
        signed = MIMEMultipart(
            "signed",
            protocol="application/pkcs7-signature",
            micalg="sha-256",
        )
        signed.attach(mime_message)

        sig_part = MIMEApplication(signature, "pkcs7-signature", name="smime.p7s")
        sig_part.add_header("Content-Disposition", "attachment", filename="smime.p7s")
        signed.attach(sig_part)

        # Preserve original headers
        for hdr in ("From", "To", "Cc", "Subject", "Date", "Message-ID"):
            val = mime_message.get(hdr)
            if val and hdr not in signed:
                signed[hdr] = val

        return signed


class SMIMEEncryptor:
    """Encrypt outgoing emails to recipient S/MIME certificates."""

    def __init__(self, recipient_cert_paths: list[str] | None = None) -> None:
        self.recipient_cert_paths = recipient_cert_paths or []
        self._certs: list[Any] = []

    def _load_certs(self) -> None:
        if self._certs:
            return
        try:
            from cryptography import x509
        except ImportError:
            raise ImportError("S/MIME encryption requires the 'cryptography' package.")

        for path in self.recipient_cert_paths:
            with open(path, "rb") as f:
                cert = x509.load_pem_x509_certificate(f.read())
                self._certs.append(cert)

    def encrypt(self, mime_message: MIMEMultipart) -> MIMEApplication:
        """Encrypt a MIME message to all recipient certificates.

        Returns an application/pkcs7-mime message.
        """
        self._load_certs()

        try:
            from cryptography.hazmat.primitives.serialization import pkcs7, Encoding
            from cryptography.hazmat.primitives.ciphers import algorithms
        except ImportError:
            raise ImportError("S/MIME encryption requires the 'cryptography' package.")

        if not self._certs:
            raise ValueError("At least one recipient certificate is required")

        msg_bytes = mime_message.as_bytes()

        builder = pkcs7.PKCS7EnvelopeBuilder().set_data(msg_bytes)
        for cert in self._certs:
            builder = builder.add_recipient(cert)

        encrypted = builder.encrypt(Encoding.DER, [])

        result = MIMEApplication(encrypted, "pkcs7-mime")
        result.set_param("smime-type", "enveloped-data")
        result.set_param("name", "smime.p7m")
        result.add_header("Content-Disposition", "attachment", filename="smime.p7m")

        # Preserve headers
        for hdr in ("From", "To", "Cc", "Subject", "Date", "Message-ID"):
            val = mime_message.get(hdr)
            if val:
                result[hdr] = val

        return result


path: scripts/lib/imap_client.py
content: """IMAP client for server-side email retrieval and folder management."""

from __future__ import annotations

import base64
import email
import email.header
import email.utils
import imaplib
import logging
import re
import ssl
import time
from datetime import datetime
from typing import Any

from .models import EmailAddress, EmailAttachment, EmailMessage, EmailPriority

logger = logging.getLogger("clawMail.imap")

# Charsets that Python doesn't recognise but appear in real-world headers.
_CHARSET_FALLBACKS = {
    "unknown-8bit": "latin-1",
    "x-unknown": "latin-1",
    "default": "latin-1",
    "iso-8859-8-i": "iso-8859-8",
}

_MAX_SELECT_RETRIES = 3
_SELECT_RETRY_DELAY = 1.0  # seconds


# ── Mailbox name helpers ────────────────────────────────────────────


def _quote_mailbox(name: str) -> str:
    """IMAP-quote a mailbox name for use with imaplib.

    Python's ``imaplib`` does **not** quote mailbox arguments, so a name
    like ``Needs Review`` is sent as two separate tokens.  This helper
    wraps the name in double-quotes and escapes embedded backslashes and
    double-quote characters per RFC 3501 §9 (quoted-string grammar).
    """
    escaped = name.replace("\\", "\\\\").replace('"', '\\"')
    return f'"{escaped}"'


def _decode_mutf7(data: str) -> str:
    """Decode an IMAP modified-UTF-7 mailbox name (RFC 3501 §5.1.3).

    Rules:
    * Printable US-ASCII (0x20-0x7e) except ``&`` pass through literally.
    * ``&-`` decodes to a literal ``&``.
    * ``&<base64>-`` decodes the base64 segment (using ``,`` instead of
      ``/``) as UTF-16BE code-points.
    """
    result: list[str] = []
    i = 0
    while i < len(data):
        if data[i] == "&":
            end = data.find("-", i + 1)
            if end == -1:
                # Malformed — treat the rest as literal
                result.append(data[i:])
                break
            if end == i + 1:
                # &- is literal &
                result.append("&")
            else:
                b64_segment = data[i + 1:end].replace(",", "/")
                # Pad to a multiple of 4
                b64_segment += "=" * ((4 - len(b64_segment) % 4) % 4)
                try:
                    raw = base64.b64decode(b64_segment)
                    result.append(raw.decode("utf-16-be"))
                except Exception:
                    # Malformed — keep the original text
                    result.append(data[i:end + 1])
            i = end + 1
        else:
            result.append(data[i])
            i += 1
    return "".join(result)


def _encode_mutf7(text: str) -> str:
    """Encode a Unicode string to IMAP modified-UTF-7.

    The inverse of ``_decode_mutf7``.
    """
    result: list[str] = []
    non_ascii: list[str] = []

    def _flush_non_ascii() -> None:
        if not non_ascii:
            return
        raw = "".join(non_ascii).encode("utf-16-be")
        b64 = base64.b64encode(raw).decode("ascii").rstrip("=")
        result.append("&" + b64.replace("/", ",") + "-")
        non_ascii.clear()

    for ch in text:
        if ch == "&":
            _flush_non_ascii()
            result.append("&-")
        elif 0x20 <= ord(ch) <= 0x7E:
            _flush_non_ascii()
            result.append(ch)
        else:
            non_ascii.append(ch)
    _flush_non_ascii()
    return "".join(result)


class IMAPClient:
    """IMAP client for fetching emails and managing folders."""

    def __init__(
        self,
        host: str,
        port: int = 993,
        username: str = "",
        password: str = "",
        use_ssl: bool = True,
        timeout: int = 30,
        oauth2_manager: Any | None = None,
    ) -> None:
        self.host = host
        self.port = port
        self.username = username
        self.[REDACTED_SECRET]
        self.use_ssl = use_ssl
        self.timeout = timeout
        self._oauth2 = oauth2_manager
        self._connection: imaplib.IMAP4 | imaplib.IMAP4_SSL | None = None
        self._account: str = ""  # set by AccountManager
        self._last_append_error: str = ""
        self._chunk_size = 16 * 1024

    @staticmethod
    def _create_secure_context() -> ssl.SSLContext:
        """Create a hardened SSL context with secure ciphers only.

        Enforces TLS 1.2+, disables weak ciphers, and enables
        certificate verification and hostname checking.
        """
        ctx = ssl.create_default_context()
        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
        ctx.set_ciphers(
            "ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20"
            ":!aNULL:!MD5:!DSS:!RC4:!3DES"
        )
        # Enforce certificate verification (default, but be explicit)
        ctx.check_hostname = True
        ctx.verify_mode = ssl.CERT_REQUIRED
        return ctx

    def connect(self) -> None:
        if self.use_ssl:
            ctx = self._create_secure_context()
            self._connection = imaplib.IMAP4_SSL(
                self.host, self.port, ssl_context=ctx, timeout=self.timeout,
            )
        else:
            self._connection = imaplib.IMAP4(self.host, self.port)
            self._connection.socket().settimeout(self.timeout)

        if self._oauth2:
            from .oauth2 import build_xoauth2_string
            auth_string = build_xoauth2_string(
                self.username, self._oauth2.access_token,
            )
            self._connection.authenticate(
                "XOAUTH2",
                lambda _x: auth_string.encode("ascii"),
            )
        else:
            self._connection.login(self.username, self.password)

    def disconnect(self) -> None:
        conn = self._connection
        self._connection = None
        if conn is None:
            return
        try:
            conn.logout()
        except Exception:
            # logout failed (SSL corruption, connection drop, etc.)
            # Force-close the underlying socket directly.
            try:
                conn.shutdown()
            except Exception:
                pass
            try:
                sock = conn.socket()
                if sock:
                    sock.close()
            except Exception:
                pass

    @property
    def conn(self) -> imaplib.IMAP4 | imaplib.IMAP4_SSL:
        if self._connection is None:
            raise RuntimeError("Not connected. Call connect() first.")
        return self._connection

    # ── Folder management ────────────────────────────────────────────

    def list_folders(self) -> list[dict[str, str]]:
        """List all folders with attributes.

        Returns list of dicts: {"name": ..., "delimiter": ..., "flags": ...}.
        Folder names are decoded from IMAP modified-UTF-7.
        """
        try:
            status, data = self.conn.list()
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("LIST failed: %s", exc)
            return []
        if status != "OK":
            return []
        folders = []
        # Regex handles both quoted and unquoted folder names:
        #   (\Flags) "delim" "Folder With Spaces"
        #   (\Flags) "delim" PlainFolder
        # The quoted-name branch uses a non-greedy match inside quotes
        # that allows escaped characters (\\ and \").
        _LIST_RE = re.compile(
            r'\(([^)]*)\)\s+'           # flags
            r'(?:"([^"]+)"|NIL)\s+'     # delimiter (quoted or NIL)
            r'(?:"((?:[^"\\]|\\.)*)"|'  # quoted folder name (with escapes)
            r'(\S+))'                   # OR unquoted atom folder name
        )
        for item in data:
            if isinstance(item, bytes):
                decoded = item.decode("utf-8", errors="replace")
                m = _LIST_RE.match(decoded)
                if m:
                    flags = m.group(1)
                    delimiter = m.group(2) or ""
                    # Group 3 = quoted name, Group 4 = unquoted name
                    raw_name = m.group(3) if m.group(3) is not None else (m.group(4) or "")
                    # Un-escape any backslash-escaped characters in quoted names
                    raw_name = raw_name.replace('\\"', '"').replace("\\\\", "\\")
                    # Decode IMAP modified-UTF-7
                    name = _decode_mutf7(raw_name)
                    folders.append({
                        "name": name,
                        "delimiter": delimiter,
                        "flags": flags,
                    })
        return folders

    def folder_status(self, folder: str) -> dict[str, int]:
        """Get message counts for a folder.

        Returns: {"messages": N, "unseen": N, "recent": N}.
        """
        try:
            status, data = self.conn.status(_quote_mailbox(folder), "(MESSAGES UNSEEN RECENT)")
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("STATUS %s failed: %s", folder, exc)
            return {"messages": 0, "unseen": 0, "recent": 0}
        if status != "OK":
            return {"messages": 0, "unseen": 0, "recent": 0}
        raw = data[0].decode("utf-8", errors="replace") if isinstance(data[0], bytes) else data[0]
        result: dict[str, int] = {"messages": 0, "unseen": 0, "recent": 0}
        for key in ("MESSAGES", "UNSEEN", "RECENT"):
            m = re.search(rf"{key}\s+(\d+)", raw)
            if m:
                result[key.lower()] = int(m.group(1))
        return result

    def create_folder(self, folder: str) -> bool:
        """Create a new folder/mailbox."""
        quoted = _quote_mailbox(folder)
        try:
            status, _ = self.conn.create(quoted)
            if status == "OK":
                self.conn.subscribe(quoted)
                return True
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("CREATE %s failed: %s", folder, exc)
        return False

    def delete_folder(self, folder: str) -> bool:
        """Delete a folder/mailbox."""
        quoted = _quote_mailbox(folder)
        try:
            self.conn.unsubscribe(quoted)
            status, _ = self.conn.delete(quoted)
            return status == "OK"
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("DELETE %s failed: %s", folder, exc)
            return False

    def rename_folder(self, old_name: str, new_name: str) -> bool:
        """Rename a folder."""
        try:
            status, _ = self.conn.rename(_quote_mailbox(old_name), _quote_mailbox(new_name))
            return status == "OK"
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("RENAME %s -> %s failed: %s", old_name, new_name, exc)
            return False

    def move_folder(self, folder: str, new_parent: str) -> bool:
        """Move a folder under a new parent using the folder's delimiter.

        Example: move_folder("Projects", "Archive") renames
        "Projects" to "Archive/Projects" (delimiter-aware).
        """
        folders = self.list_folders()
        delimiter = "/"
        for f in folders:
            if f["name"] == folder:
                delimiter = f.get("delimiter", "/")
                break
        base_name = folder.rsplit(delimiter, 1)[-1]
        new_name = f"{new_parent}{delimiter}{base_name}"
        return self.rename_folder(folder, new_name)

    # ── Message retrieval ────────────────────────────────────────────

    def _select_robust(self, mailbox: str, readonly: bool = True) -> None:
        """Select a mailbox with retry logic for transient errors."""
        last_exc: Exception | None = None
        for attempt in range(_MAX_SELECT_RETRIES):
            try:
                status, data = self.conn.select(_quote_mailbox(mailbox), readonly=readonly)
                if status == "OK":
                    return
                raise imaplib.IMAP4.error(
                    f"SELECT failed: {data}"
                )
            except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
                last_exc = exc
                logger.warning(
                    "SELECT %s attempt %d/%d failed: %s",
                    mailbox, attempt + 1, _MAX_SELECT_RETRIES, exc,
                )
                if attempt < _MAX_SELECT_RETRIES - 1:
                    time.sleep(_SELECT_RETRY_DELAY * (attempt + 1))
                    # Reconnect if the connection was dropped
                    try:
                        self.conn.noop()
                    except Exception:
                        self.disconnect()
                        self.connect()
        raise RuntimeError(
            f"Failed to SELECT '{mailbox}' after {_MAX_SELECT_RETRIES} attempts: {last_exc}"
        )

    def fetch_unread(
        self, mailbox: str = "INBOX", limit: int = 50, mark_seen: bool = False,
    ) -> list[EmailMessage]:
        self._select_robust(mailbox, readonly=not mark_seen)
        try:
            status, msg_ids = self.conn.search(None, "UNSEEN")
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("SEARCH UNSEEN in %s failed: %s", mailbox, exc)
            return []
        if status != "OK" or not msg_ids[0]:
            return []
        id_list = msg_ids[0].split()[:limit] if limit else msg_ids[0].split()
        return self._fetch_ids(id_list, mailbox, mark_seen)

    def fetch_all(
        self, mailbox: str = "INBOX", limit: int = 50,
    ) -> list[EmailMessage]:
        """Fetch all messages from a mailbox (most recent first)."""
        self._select_robust(mailbox, readonly=True)
        try:
            status, msg_ids = self.conn.search(None, "ALL")
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("SEARCH ALL in %s failed: %s", mailbox, exc)
            return []
        if status != "OK" or not msg_ids[0]:
            return []
        id_list = msg_ids[0].split()
        if limit:
            id_list = id_list[-limit:]
        return self._fetch_ids(id_list, mailbox, mark_seen=False)

    def fetch_since(
        self, since: datetime, mailbox: str = "INBOX", limit: int = 100,
    ) -> list[EmailMessage]:
        self._select_robust(mailbox, readonly=True)
        date_str = since.strftime("%d-%b-%Y")
        try:
            status, msg_ids = self.conn.search(None, f"(SINCE {date_str})")
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("SEARCH SINCE in %s failed: %s", mailbox, exc)
            return []
        if status != "OK" or not msg_ids[0]:
            return []
        id_list = msg_ids[0].split()[:limit] if limit else msg_ids[0].split()
        return self._fetch_ids(id_list, mailbox, mark_seen=False)

    def fetch_by_id(self, message_id: str, mailbox: str = "INBOX") -> EmailMessage | None:
        """Fetch a single message by its Message-ID header value."""
        self._select_robust(mailbox, readonly=True)
        search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
        try:
            status, data = self.conn.search(None, f'(HEADER Message-ID "{search_id}")')
            if status != "OK" or not data[0]:
                return None
            seq_num = data[0].split()[0]
            status, msg_data = self.conn.fetch(seq_num, "(BODY.PEEK[])")
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("FETCH by Message-ID %s failed: %s", message_id, exc)
            return None
        if status != "OK" or not msg_data[0]:
            return None
        raw = msg_data[0][1] if isinstance(msg_data[0], tuple) else msg_data[0]
        if isinstance(raw, bytes):
            return self._parse_email(raw, mailbox)
        return None

    def search(
        self,
        mailbox: str = "INBOX",
        criteria: str = "ALL",
        limit: int = 50,
    ) -> list[EmailMessage]:
        """Search messages using an IMAP SEARCH criteria string.

        The *criteria* argument is passed directly to the IMAP SEARCH command.
        Examples:
            "UNSEEN"
            '(FROM "[REDACTED_SECRET]")'
            '(SUBJECT "invoice" SINCE 01-Jan-2025)'
            '(OR (FROM "[REDACTED_SECRET]") (FROM "[REDACTED_SECRET]"))'
        """
        self._select_robust(mailbox, readonly=True)
        try:
            status, msg_ids = self.conn.search(None, criteria)
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("SEARCH %s in %s failed: %s", criteria, mailbox, exc)
            return []
        if status != "OK" or not msg_ids[0]:
            return []
        id_list = msg_ids[0].split()
        if limit:
            id_list = id_list[-limit:]
        return self._fetch_ids(id_list, mailbox, mark_seen=False)

    # ── Message actions ──────────────────────────────────────────────

    def mark_read(self, message_id: str, mailbox: str = "INBOX") -> bool:
        return self._set_flag(message_id, mailbox, "+FLAGS", "\\Seen")

    def mark_unread(self, message_id: str, mailbox: str = "INBOX") -> bool:
        return self._set_flag(message_id, mailbox, "-FLAGS", "\\Seen")

    def flag_message(self, message_id: str, mailbox: str = "INBOX") -> bool:
        return self._set_flag(message_id, mailbox, "+FLAGS", "\\Flagged")

    def unflag_message(self, message_id: str, mailbox: str = "INBOX") -> bool:
        return self._set_flag(message_id, mailbox, "-FLAGS", "\\Flagged")

    def set_custom_flag(self, message_id: str, flag: str, mailbox: str = "INBOX") -> bool:
        """Set a custom keyword flag (e.g. '$Important', '$Processed')."""
        return self._set_flag(message_id, mailbox, "+FLAGS", flag)

    def remove_custom_flag(self, message_id: str, flag: str, mailbox: str = "INBOX") -> bool:
        """Remove a custom keyword flag."""
        return self._set_flag(message_id, mailbox, "-FLAGS", flag)

    def set_flags_batch(
        self, message_ids: list[str], flag: str, mailbox: str = "INBOX",
    ) -> dict[str, bool]:
        """Set a flag on multiple messages in one mailbox selection.

        Returns dict of {message_id: success}.
        """
        self._select_robust(mailbox, readonly=False)
        results: dict[str, bool] = {}
        for mid in message_ids:
            seq_num = self._resolve_seq(mid)
            if seq_num is None:
                results[mid] = False
                continue
            try:
                status, _ = self.conn.store(seq_num, "+FLAGS", flag)
                results[mid] = status == "OK"
            except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
                logger.warning("STORE +FLAGS %s on %s failed: %s", flag, mid, exc)
                results[mid] = False
        return results

    def move_message(
        self, msg_uid: str, from_mailbox: str, to_mailbox: str,
    ) -> bool:
        """Move a message between mailboxes via IMAP COPY + DELETE."""
        self._select_robust(from_mailbox, readonly=False)
        seq_num = self._resolve_seq(msg_uid)
        if seq_num is None:
            return False
        try:
            status, _ = self.conn.copy(seq_num, _quote_mailbox(to_mailbox))
            if status != "OK":
                return False
            self.conn.store(seq_num, "+FLAGS", "\\Deleted")
            self.conn.expunge()
            return True
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("MOVE %s -> %s failed: %s", from_mailbox, to_mailbox, exc)
            return False

    def move_messages_batch(
        self, message_ids: list[str], from_mailbox: str, to_mailbox: str,
    ) -> dict[str, bool]:
        """Move multiple messages, minimising SELECT calls.

        Returns dict of {message_id: success}.
        """
        self._select_robust(from_mailbox, readonly=False)
        results: dict[str, bool] = {}
        moved_seqs = []
        for mid in message_ids:
            seq_num = self._resolve_seq(mid)
            if seq_num is None:
                results[mid] = False
                continue
            try:
                status, _ = self.conn.copy(seq_num, _quote_mailbox(to_mailbox))
                if status == "OK":
                    self.conn.store(seq_num, "+FLAGS", "\\Deleted")
                    results[mid] = True
                    moved_seqs.append(seq_num)
                else:
                    results[mid] = False
            except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
                logger.warning("MOVE batch %s -> %s failed: %s", mid, to_mailbox, exc)
                results[mid] = False
        if moved_seqs:
            try:
                self.conn.expunge()
            except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
                logger.warning("EXPUNGE after batch move failed: %s", exc)
        return results

    # ── IMAP IDLE ──────────────────────────────────────────────────

    def idle_start(self, mailbox: str = "INBOX", timeout: int = 29 * 60) -> None:
        """Enter IMAP IDLE mode on *mailbox*.

        The server will push EXISTS/EXPUNGE notifications while idle.
        Call :meth:`idle_check` to poll for responses, and
        :meth:`idle_done` to leave IDLE mode.

        *timeout* is a safety upper-bound in seconds (RFC 2177 recommends
        re-issuing IDLE every 29 minutes).
        """
        self._select_robust(mailbox, readonly=True)
        tag = self.conn._new_tag()
        self._idle_tag = tag
        self.conn.send(tag + b" IDLE\r\n")
        # Wait for the continuation response '+ ...'
        resp = self.conn.readline()
        if not resp.startswith(b"+"):
            raise RuntimeError(f"IDLE rejected: {resp!r}")
        self.conn.sock.settimeout(timeout)

    def idle_check(self, timeout: float = 30.0) -> list[tuple[bytes, bytes]]:
        """Poll for untagged responses while in IDLE mode.

        Returns a list of ``(seq_num, response)`` tuples, e.g.
        ``[(b'3', b'EXISTS')]``.  Returns an empty list on timeout.
        """
        old_timeout = self.conn.sock.gettimeout()
        self.conn.sock.settimeout(timeout)
        responses: list[tuple[bytes, bytes]] = []
        try:
            while True:
                line = self.conn.readline()
                if not line:
                    break
                if line.startswith(b"* "):
                    parts = line[2:].strip().split(None, 1)
                    if len(parts) == 2:
                        responses.append((parts[0], parts[1]))
                    else:
                        responses.append((b"", parts[0] if parts else b""))
                    # Don't block indefinitely — check if more data is ready
                    self.conn.sock.settimeout(0.5)
                else:
                    break
        except (TimeoutError, OSError):
            pass
        finally:
            self.conn.sock.settimeout(old_timeout)
        return responses

    def idle_done(self) -> None:
        """Leave IDLE mode by sending ``DONE``."""
        self.conn.send(b"DONE\r\n")
        # Read the tagged response to complete the IDLE command
        while True:
            line = self.conn.readline()
            if not line:
                break
            tag = getattr(self, "_idle_tag", None)
            if tag and line.startswith(tag):
                break

    def append_message(
        self, raw_message: bytes, mailbox: str = "Drafts", flags: str = "\\Draft",
    ) -> bool:
        """Append a raw RFC822 message to a mailbox (e.g. Drafts).

        Returns True on success.
        """
        logger.debug(
            "IMAP APPEND raw_message length=%d mailbox=%r flags=%r",
            len(raw_message), mailbox, flags,
        )
        orig_send = self.conn.send
        def _chunked_send(data):
            if isinstance(data, str):
                data = data.encode("utf-8")
            if len(data) <= self._chunk_size:
                return orig_send(data)
            for i in range(0, len(data), self._chunk_size):
                orig_send(data[i:i + self._chunk_size])
        try:
            self.conn.send = _chunked_send
            status, _ = self.conn.append(
                _quote_mailbox(mailbox), flags, None, raw_message,
            )
            if status != "OK":
                self._last_append_error = f"APPEND status {status}"
                logger.debug("APPEND response status=%s", status)
            else:
                self._last_append_error = ""
            return status == "OK"
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            self._last_append_error = str(exc)
            logger.warning("APPEND to %s failed: %s", mailbox, exc)
            return False
        finally:
            self.conn.send = orig_send

    @property
    def last_append_error(self) -> str:
        return self._last_append_error

    def delete_message(self, message_id: str, mailbox: str = "INBOX") -> bool:
        self._select_robust(mailbox, readonly=False)
        seq_num = self._resolve_seq(message_id)
        if seq_num is None:
            return False
        try:
            self.conn.store(seq_num, "+FLAGS", "\\Deleted")
            self.conn.expunge()
            return True
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("DELETE message %s failed: %s", message_id, exc)
            return False

    # ── Internal helpers ─────────────────────────────────────────────

    def _resolve_seq(self, msg_uid: str) -> bytes | None:
        """Resolve a Message-ID or sequence number to an IMAP sequence number."""
        if msg_uid.startswith("<"):
            try:
                status, data = self.conn.search(None, f'(HEADER Message-ID "{msg_uid}")')
            except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
                logger.warning("SEARCH for Message-ID %s failed: %s", msg_uid, exc)
                return None
            if status != "OK" or not data[0]:
                return None
            return data[0].split()[0]
        return msg_uid.encode() if isinstance(msg_uid, str) else msg_uid

    def _set_flag(self, message_id: str, mailbox: str, op: str, flag: str) -> bool:
        self._select_robust(mailbox, readonly=False)
        seq_num = self._resolve_seq(message_id)
        if seq_num is None:
            return False
        try:
            status, _ = self.conn.store(seq_num, op, flag)
            return status == "OK"
        except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
            logger.warning("STORE %s %s failed: %s", op, flag, exc)
            return False

    def _fetch_ids(
        self, id_list: list[bytes], mailbox: str, mark_seen: bool,
    ) -> list[EmailMessage]:
        messages = []
        for msg_id in id_list:
            fetch_flag = "(RFC822)" if mark_seen else "(BODY.PEEK[])"
            try:
                status, msg_data = self.conn.fetch(msg_id, fetch_flag)
            except (imaplib.IMAP4.error, imaplib.IMAP4.abort, OSError) as exc:
                logger.warning("Failed to fetch message %s: %s", msg_id, exc)
                continue
            if status != "OK" or not msg_data[0]:
                continue
            raw = msg_data[0][1] if isinstance(msg_data[0], tuple) else msg_data[0]
            if isinstance(raw, bytes):
                parsed = self._parse_email(raw, mailbox)
                if parsed:
                    messages.append(parsed)
        return messages

    def _parse_email(self, raw: bytes, mailbox: str) -> EmailMessage | None:
        try:
            msg = email.message_from_bytes(raw)
        except Exception:
            return None

        subject = self._decode_header(msg.get("Subject", ""))
        sender = EmailAddress.parse(self._decode_header(msg.get("From", "")))
        recipients = self._parse_address_list(msg.get("To", ""))
        cc = self._parse_address_list(msg.get("Cc", ""))

        date = None
        date_str = msg.get("Date", "")
        if date_str:
            try:
                date = email.utils.parsedate_to_datetime(date_str)
            except Exception:
                pass

        body_plain = ""
        body_html = ""
        attachments: list[EmailAttachment] = []

        if msg.is_multipart():
            for part in msg.walk():
                ct = part.get_content_type()
                disp = str(part.get("Content-Disposition", ""))
                if "attachment" in disp:
                    payload = part.get_payload(decode=True)
                    if payload:
                        attachments.append(EmailAttachment(
                            filename=part.get_filename() or "attachment",
                            content_type=ct, data=payload,
                            content_id=part.get("Content-ID"),
                        ))
                elif ct == "text/plain" and not body_plain:
                    payload = part.get_payload(decode=True)
                    if payload:
                        body_plain = payload.decode("utf-8", errors="replace")
                elif ct == "text/html" and not body_html:
                    payload = part.get_payload(decode=True)
                    if payload:
                        body_html = payload.decode("utf-8", errors="replace")
        else:
            payload = msg.get_payload(decode=True)
            if payload:
                content = payload.decode("utf-8", errors="replace")
                if msg.get_content_type() == "text/html":
                    body_html = content
                else:
                    body_plain = content

        priority = EmailPriority.NORMAL
        xp = msg.get("X-Priority", "3")
        if xp.startswith(("1", "2")):
            priority = EmailPriority.HIGH
        elif xp.startswith(("4", "5")):
            priority = EmailPriority.LOW

        return EmailMessage(
            message_id=msg.get("Message-ID", ""), subject=subject,
            sender=sender, recipients=recipients, cc=cc,
            body_plain=body_plain, body_html=body_html,
            attachments=attachments, date=date,
            in_reply_to=msg.get("In-Reply-To", ""),
            references=msg.get("References", "").split(),
            priority=priority, mailbox=mailbox,
            account=self._account,
            headers=dict(msg.items()),
        )

    def _decode_header(self, raw: str) -> str:
        """Decode an RFC 2047 encoded header, with safe fallbacks."""
        try:
            parts = email.header.decode_header(raw)
        except Exception:
            return raw
        decoded = []
        for data, charset in parts:
            if isinstance(data, bytes):
                enc = "utf-8"
                if charset:
                    try:
                        enc = _CHARSET_FALLBACKS.get(charset.lower(), charset)
                    except Exception:
                        enc = "latin-1"
                try:
                    decoded.append(data.decode(enc, errors="replace"))
                except (LookupError, UnicodeDecodeError, TypeError):
                    # Unknown / invalid charset — latin-1 never raises
                    decoded.append(data.decode("latin-1", errors="replace"))
            else:
                decoded.append(str(data) if not isinstance(data, str) else data)
        return " ".join(decoded)

    def _parse_address_list(self, raw: str) -> list[EmailAddress]:
        if not raw:
            return []
        decoded = self._decode_header(raw)
        return [
            EmailAddress.parse(a.strip())
            for a in decoded.split(",") if a.strip()
        ]


path: scripts/lib/account_manager.py
content: """Multi-account manager for IMAP/SMTP clients.

Loads a multi-account config, creates clients on demand, and handles
SMTP fallback when an account's own SMTP server is unreachable.

Config layout (YAML)::

    default_account: work

    smtp_fallback:
      host: smtp-relay.example.com
      port: 587
      username: relay-user
      [REDACTED_SECRET]
      tls: true

    accounts:
      work:
        label: Work
        sender_address: [REDACTED_SECRET]
        sender_name: Alice Smith
        imap:
          host: imap.company.com
          ...
        smtp:
          host: smtp.company.com
          ...
        mailboxes: [INBOX, Projects]
        fetch_limit: 50
        rules: [...]

    # Global rules applied after per-account rules
    rules: [...]
"""

from __future__ import annotations

import copy
import logging
from typing import Any

from . import credential_store
from .imap_client import IMAPClient
from .smtp_client import SMTPClient
from .models import EmailAddress

logger = logging.getLogger("clawMail.accounts")
_ARCHIVE_FREQUENCIES = {"daily", "weekly", "monthly", "yearly"}


class AccountManager:
    """Registry of email accounts with client factory methods."""

    def __init__(self, config: dict[str, Any]) -> None:
        self._raw = config
        self._accounts: dict[str, dict[str, Any]] = config.get("accounts", {})
        self._default_name: str = config.get("default_account", "")
        self._smtp_fallback: dict[str, Any] = config.get("smtp_fallback", {})
        self._global_rules: list[dict[str, Any]] = config.get("rules", [])
        self._defaults: dict[str, Any] = config.get("defaults", {})

        # If no accounts block, treat legacy single-account config as "default"
        if not self._accounts and (config.get("imap") or config.get("smtp")):
            self._accounts["default"] = {
                "label": "Default",
                "imap": config.get("imap", {}),
                "smtp": config.get("smtp", {}),
                "sender_address": config.get("sender_address", ""),
                "mailboxes": config.get("mailboxes", ["INBOX"]),
                "fetch_limit": config.get("fetch_limit", 50),
                "rules": config.get("rules", []),
            }
            if not self._default_name:
                self._default_name = "default"

        # Auto-select default if not set
        if not self._default_name and self._accounts:
            # Use first account as default
            self._default_name = next(iter(self._accounts))

    # ── Queries ──────────────────────────────────────────────────────

    @property
    def default_account(self) -> str:
        return self._default_name

    def list_accounts(self) -> list[str]:
        return list(self._accounts.keys())

    def get_account(self, name: str = "") -> dict[str, Any]:
        """Return the config dict for a named account (or the default)."""
        name = name or self._default_name
        if name not in self._accounts:
            raise KeyError(f"Unknown account: {name!r}. "
                           f"Available: {', '.join(self._accounts)}")
        return self._accounts[name]

    def get_label(self, name: str = "") -> str:
        acct = self.get_account(name)
        return acct.get("label", name)

    def get_sender(self, name: str = "") -> EmailAddress | None:
        acct = self.get_account(name)
        addr = acct.get("sender_address", "")
        if not addr:
            addr = acct.get("imap", {}).get("username", "")
        if not addr:
            return None
        display = acct.get("sender_name", "")
        return EmailAddress(address=addr, display_name=display)

    def get_mailboxes(self, name: str = "") -> list[str]:
        acct = self.get_account(name)
        default_limit = self._defaults.get("mailboxes", ["INBOX"])
        return acct.get("mailboxes", default_limit)

    def get_fetch_limit(self, name: str = "") -> int:
        acct = self.get_account(name)
        default_limit = self._defaults.get("fetch_limit", 50)
        return acct.get("fetch_limit", default_limit)

    def get_archive_root(self, name: str = "") -> str:
        """Return the desired archive folder prefix for an account."""
        acct = self.get_account(name)
        return acct.get("archive_root", self._defaults.get("archive_root", "Archive"))

    def get_archive_frequency(self, name: str = "") -> str:
        """Return the archive frequency (daily/weekly/monthly/yearly)."""
        acct = self.get_account(name)
        freq = acct.get("archive_frequency", self._defaults.get("archive_frequency", "monthly"))
        if freq not in _ARCHIVE_FREQUENCIES:
            return "monthly"
        return freq

    def get_rules(self, name: str = "") -> list[dict[str, Any]]:
        """Per-account rules + global rules, combined."""
        acct = self.get_account(name)
        per_account = acct.get("rules", [])
        return per_account + self._global_rules

    # ── Client factories ─────────────────────────────────────────────

    def _get_oauth2_manager(self, cfg: dict[str, Any]) -> Any:
        """Create an OAuth2Manager if the config uses oauth2 auth."""
        if cfg.get("auth") != "oauth2":
            return None
        oauth2_cfg = cfg.get("oauth2", {})
        if not oauth2_cfg:
            return None
        from .oauth2 import OAuth2Manager
        return OAuth2Manager(oauth2_cfg)

    def get_imap_client(self, name: str = "") -> IMAPClient:
        """Create and return an IMAPClient for the named account.

        Passwords are resolved through the credential store, supporting
        ``op://``, ``keychain://``, ``env://``, and plain-text values.
        """
        acct = self.get_account(name)
        imap_cfg = acct.get("imap", {})
        if not imap_cfg.get("host"):
            raise ValueError(f"No IMAP host configured for account {name!r}")
        oauth2_mgr = self._get_oauth2_manager(imap_cfg)
        client = IMAPClient(
            host=imap_cfg["host"],
            port=imap_cfg.get("port", 993),
            username=imap_cfg.get("username", ""),
            [REDACTED_SECRET]"password", "")),
            use_ssl=imap_cfg.get("ssl", True),
            timeout=imap_cfg.get("timeout", 30),
            oauth2_manager=oauth2_mgr,
        )
        client._account = name or self._default_name
        return client

    def get_smtp_client(self, name: str = "") -> SMTPClient:
        """Create an SMTPClient for the named account.

        Passwords are resolved through the credential store, supporting
        ``op://``, ``keychain://``, ``env://``, and plain-text values.

        Does NOT attempt to connect or fall back here — that is handled
        by :meth:`send_with_fallback`.
        """
        acct = self.get_account(name)
        smtp_cfg = acct.get("smtp", {})
        if not smtp_cfg.get("host"):
            raise ValueError(f"No SMTP host configured for account {name!r}")
        oauth2_mgr = self._get_oauth2_manager(smtp_cfg)
        return SMTPClient(
            host=smtp_cfg["host"],
            port=smtp_cfg.get("port", 587),
            username=smtp_cfg.get("username", ""),
            [REDACTED_SECRET]"password", "")),
            use_tls=smtp_cfg.get("tls", True),
            oauth2_manager=oauth2_mgr,
        )

    def send_with_fallback(self, message, name: str = "") -> dict[str, Any]:
        """Send a message via the account's SMTP, falling back to smtp_fallback.

        Returns: {"success": bool, "transport": str, "account": str,
                  "fallback_used": bool, "error": str}.
        """
        acct_name = name or self._default_name
        result: dict[str, Any] = {
            "success": False,
            "transport": "smtp",
            "account": acct_name,
            "fallback_used": False,
            "error": "",
        }

        # Try account's own SMTP
        acct = self.get_account(acct_name)
        smtp_cfg = acct.get("smtp", {})
        if smtp_cfg.get("host"):
            oauth2_mgr = self._get_oauth2_manager(smtp_cfg)
            client = SMTPClient(
                host=smtp_cfg["host"],
                port=smtp_cfg.get("port", 587),
                username=smtp_cfg.get("username", ""),
                [REDACTED_SECRET]"password", "")),
                use_tls=smtp_cfg.get("tls", True),
                oauth2_manager=oauth2_mgr,
            )
            ok = client.send(message)
            if ok:
                result["success"] = True
                return result
            logger.warning(
                "Account %s SMTP (%s) failed, trying fallback",
                acct_name, smtp_cfg["host"],
            )

        # Try fallback SMTP
        fb = self._smtp_fallback
        if fb.get("host"):
            client = SMTPClient(
                host=fb["host"],
                port=fb.get("port", 587),
                username=fb.get("username", ""),
                [REDACTED_SECRET]"password", "")),
                use_tls=fb.get("tls", True),
            )
            ok = client.send(message)
            if ok:
                result["success"] = True
                result["fallback_used"] = True
                return result
            result["error"] = "Both account SMTP and fallback SMTP failed"
        else:
            result["error"] = "Account SMTP failed and no fallback configured"

        return result

    def send_via_outbox(self, message, name: str = "") -> dict[str, Any]:
        """Send a message using the IMAP Outbox pattern.

        1. Stage the message in the account's IMAP Outbox folder.
        2. Attempt SMTP delivery (with fallback).
        3. On success, remove from Outbox.  If Outbox is now empty,
           delete the temporary folder.
        4. On failure, the message remains in Outbox for later retry.

        Returns: {"success": bool, "transport": str, "account": str,
                  "fallback_used": bool, "staged": bool, "error": str}.
        """
        from .outbox import Outbox, OutboxStageError

        acct_name = name or self._default_name
        result: dict[str, Any] = {
            "success": False,
            "transport": "smtp",
            "account": acct_name,
            "fallback_used": False,
            "staged": False,
            "error": "",
            "stage_error": "",
        }

        # Step 1: Stage in Outbox
        import imaplib
        imap_client = self.get_imap_client(acct_name)
        connected = False
        try:
            imap_client.connect()
            connected = True
            outbox = Outbox(imap_client)
            try:
                staged = outbox.stage(message)
                result["staged"] = staged
            except OutboxStageError as exc:
                reason = str(exc) or "unknown reason"
                logger.warning(
                    "Outbox staging failing repeatedly for %s: %s",
                    message.subject, reason,
                )
                result["error"] = f"Outbox staging failed: {reason}"
                result["staged"] = False
                result["stage_error"] = reason
                send_result = self.send_with_fallback(message, acct_name)
                result["success"] = send_result["success"]
                result["fallback_used"] = send_result.get("fallback_used", False)
                if send_result.get("success"):
                    result["note"] = "Message sent directly via SMTP (Outbox unavailable)"
                else:
                    result["error"] = send_result.get("error", result["error"])
                return result

            # Step 2: Send via SMTP (with fallback)
            send_result = self.send_with_fallback(message, acct_name)

            if send_result["success"]:
                # Step 3a: Success — remove from Outbox
                result["success"] = True
                result["fallback_used"] = send_result["fallback_used"]
                if message.message_id:
                    imap_client.delete_message(
                        message.message_id, mailbox="Outbox",
                    )
                # Clean up Outbox folder if empty
                outbox._cleanup_if_empty()
            else:
                # Step 3b: Failure — leave in Outbox for retry
                result["error"] = send_result.get("error", "SMTP send failed")
                logger.warning(
                    "Message staged in Outbox but SMTP failed: %s",
                    result["error"],
                )
        except (imaplib.IMAP4.error, OSError) as exc:
            reason = str(exc)
            logger.warning(
                "IMAP connection for Outbox failed (%s), sending straight via SMTP", reason
            )
            send_result = self.send_with_fallback(message, acct_name)
            result["success"] = send_result["success"]
            result["fallback_used"] = send_result.get("fallback_used", False)
            result["note"] = "Sent via SMTP because IMAP Outbox was unavailable"
            result["stage_error"] = reason
            if not send_result["success"]:
                result["error"] = send_result.get("error", reason)
            else:
                result["error"] = reason
            return result
        finally:
            if connected:
                imap_client.disconnect()

        return result

    def drain_outbox(self, name: str = "") -> dict[str, Any]:
        """Drain the Outbox for an account, sending all pending messages.

        Returns a dict with drain results.
        """
        from .outbox import Outbox

        acct_name = name or self._default_name
        imap_client = self.get_imap_client(acct_name)
        try:
            imap_client.connect()
            outbox = Outbox(imap_client)

            if not outbox.exists():
                return {"account": acct_name, "attempted": 0, "sent": 0,
                        "failed": 0, "errors": []}

            def _send(msg):
                return self.send_with_fallback(msg, acct_name)

            drain_result = outbox.drain(_send)
            out = drain_result.to_dict()
            out["account"] = acct_name
            return out
        finally:
            imap_client.disconnect()

    # ── Convenience ──────────────────────────────────────────────────

    def resolve_account_for_message(self, message) -> str:
        """Determine which account a message belongs to.

        Checks message.account first, then tries to match by sender address.
        Falls back to default.
        """
        if message.account and message.account in self._accounts:
            return message.account
        if message.sender:
            for name, acct in self._accounts.items():
                addr = acct.get("sender_address", "")
                imap_user = acct.get("imap", {}).get("username", "")
                if message.sender.address in (addr, imap_user):
                    return name
        return self._default_name

    @classmethod
    def from_yaml(cls, path: str) -> AccountManager:
        """Load from a YAML config file."""
        import yaml
        with open(path) as f:
            config = yaml.safe_load(f) or {}
        return cls(config)


path: scripts/lib/smtp_client.py
content: """SMTP client for sending emails."""

from __future__ import annotations

import logging
import re
import smtplib
import socket
import ssl
import uuid
from datetime import datetime
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate

from .models import EmailMessage, EmailPriority

logger = logging.getLogger("clawMail.smtp")


def build_mime(message: EmailMessage) -> MIMEMultipart:
    """Build a MIME message from an EmailMessage (without sending).

    Useful for constructing RFC822 bytes to save as a draft.
    """
    return SMTPClient._build_mime_static(message)


class SMTPClient:
    """SMTP client for sending emails via a mail server."""

    def __init__(
        self,
        host: str,
        port: int = 587,
        username: str = "",
        password: str = "",
        use_tls: bool = True,
        oauth2_manager: object | None = None,
    ) -> None:
        self.host = host
        self.port = port
        self.username = username
        self.[REDACTED_SECRET]
        self.use_tls = use_tls
        self._oauth2 = oauth2_manager

    @staticmethod
    def _create_secure_context() -> ssl.SSLContext:
        """Create a hardened SSL context with secure ciphers only.

        Enforces TLS 1.2+, disables weak ciphers, and enables
        certificate verification and hostname checking.
        """
        ctx = ssl.create_default_context()
        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
        ctx.set_ciphers(
            "ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20"
            ":!aNULL:!MD5:!DSS:!RC4:!3DES"
        )
        # Enforce certificate verification (default, but be explicit)
        ctx.check_hostname = True
        ctx.verify_mode = ssl.CERT_REQUIRED
        return ctx

    def send(self, message: EmailMessage) -> bool:
        mime_msg = self._build_mime_static(message)
        try:
            if self.use_tls:
                server = smtplib.SMTP(self.host, self.port)
                server.ehlo()
                ctx = self._create_secure_context()
                server.starttls(context=ctx)
                server.ehlo()
            else:
                server = smtplib.SMTP(self.host, self.port)

            if self._oauth2:
                from .oauth2 import build_xoauth2_string
                auth_string = build_xoauth2_string(
                    self.username, self._oauth2.access_token,
                )
                server.docmd("AUTH", "XOAUTH2 " + auth_string)
            elif self.username:
                server.login(self.username, self.password)

            all_recips = (
                [r.address for r in message.recipients]
                + [r.address for r in message.cc]
                + [r.address for r in message.bcc]
            )
            sender_addr = message.sender.address if message.sender else self.username
            original_send = server.send
            chunk_size = 16 * 1024
            def _chunked_send(data):
                if isinstance(data, str):
                    data = data.encode("ascii")
                if len(data) <= chunk_size:
                    return original_send(data)
                for i in range(0, len(data), chunk_size):
                    original_send(data[i:i + chunk_size])
            server.send = _chunked_send
            server.sendmail(sender_addr, all_recips, mime_msg.as_string())
            server.send = original_send
            server.quit()
            return True
        except (smtplib.SMTPException, OSError) as exc:
            logger.error("SMTP send failed: %s", exc)
            return False

    @staticmethod
    def _build_mime_static(message: EmailMessage) -> MIMEMultipart:
        if message.attachments:
            mime_msg = MIMEMultipart("mixed")
            alt_part = MIMEMultipart("alternative")
            mime_msg.attach(alt_part)
        else:
            mime_msg = MIMEMultipart("alternative")
            alt_part = mime_msg

        if message.sender:
            mime_msg["From"] = str(message.sender)
        if message.recipients:
            mime_msg["To"] = ", ".join(str(r) for r in message.recipients)
        if message.cc:
            mime_msg["Cc"] = ", ".join(str(r) for r in message.cc)
        mime_msg["Subject"] = message.subject

        # RFC 5322: Date header is REQUIRED
        if message.date:
            mime_msg["Date"] = formatdate(timeval=message.date.timestamp(), localtime=True)
        else:
            mime_msg["Date"] = formatdate(localtime=True)

        # RFC 5322: Message-ID header is REQUIRED (unique identifier for this message)
        if message.message_id:
            mime_msg["Message-ID"] = message.message_id
        else:
            # Generate a unique Message-ID if not provided
            # Format: <timestamp-uuid@hostname> per RFC 5322
            try:
                hostname = socket.getfqdn()
            except Exception:
                # Fallback to sender domain if FQDN is unavailable
                hostname = (
                    message.sender.address.split("@")[1]
                    if message.sender and "@" in str(message.sender)
                    else "localhost"
                )
            msg_id = f"{int(datetime.utcnow().timestamp() * 1000)}.{uuid.uuid4().hex}@{hostname}"
            message.message_id = f"<{msg_id}>"
            mime_msg["Message-ID"] = message.message_id

        if message.in_reply_to:
            mime_msg["In-Reply-To"] = message.in_reply_to
            mime_msg["References"] = " ".join(message.references)
        if message.priority == EmailPriority.HIGH:
            mime_msg["X-Priority"] = "1"
            mime_msg["Importance"] = "high"
        elif message.priority == EmailPriority.LOW:
            mime_msg["X-Priority"] = "5"
            mime_msg["Importance"] = "low"

        # RFC 5322 compliance: Add MIME-Version header
        mime_msg["MIME-Version"] = "1.0"

        # Helpful for deliverability
        mime_msg["User-Agent"] = "clawMail/0.6.0"

        plain_text = message.body_plain
        if not plain_text and message.body_html:
            plain_text = re.sub(r"<[^>]+>", "", message.body_html)
            plain_text = re.sub(r"\n\s*\n", "\n\n", plain_text).strip()

        if plain_text:
            alt_part.attach(MIMEText(plain_text, "plain", "utf-8"))
        if message.body_html:
            alt_part.attach(MIMEText(message.body_html, "html", "utf-8"))

        for att in message.attachments:
            maintype, subtype = att.content_type.split("/", 1)
            part = MIMEBase(maintype, subtype)
            part.set_payload(att.data)
            encoders.encode_base64(part)
            part.add_header("Content-Disposition", "attachment", filename=att.filename)
            if att.content_id:
                part.add_header("Content-ID", f"<{att.content_id}>")
            mime_msg.attach(part)

        return mime_msg


path: scripts/lib/defaults.py
content: """Default paths and configuration helpers.

Provides the default config file path so that ``--config`` can be
optional across all scripts.  When ``--config`` is not supplied the
scripts look for ``config.yaml`` in the skill root directory
(one level above ``scripts/``).
"""

from __future__ import annotations

import os

_THIS_DIR = os.path.dirname(os.path.abspath(__file__))   # .../scripts/lib/
SCRIPTS_DIR = os.path.dirname(_THIS_DIR)                  # .../scripts/
SKILL_ROOT = os.path.dirname(SCRIPTS_DIR)                  # .../claw-mail/
DEFAULT_CONFIG_PATH = os.path.join(SKILL_ROOT, "config.yaml")


def resolve_config_path(explicit_path: str = "") -> str:
    """Return *explicit_path* if given, else the default config if it exists.

    Returns an empty string when neither is available so callers can
    still require ``--config`` or ``--imap-host`` as before.
    """
    if explicit_path:
        return explicit_path
    if os.path.isfile(DEFAULT_CONFIG_PATH):
        return DEFAULT_CONFIG_PATH
    return ""


path: scripts/lib/pool.py
content: """Connection pool for IMAP and SMTP clients.

Caches and reuses connections across multiple operations within a single
session (e.g. a heartbeat cycle), reducing the overhead of repeated
connect/disconnect calls.

Usage::

    pool = ConnectionPool(account_manager)
    client = pool.get_imap("work")   # connects on first call
    client = pool.get_imap("work")   # returns cached connection
    pool.close_all()                 # disconnect everything
"""

from __future__ import annotations

import logging
import time
from typing import Any, TYPE_CHECKING

if TYPE_CHECKING:
    from .account_manager import AccountManager
    from .imap_client import IMAPClient
    from .smtp_client import SMTPClient

logger = logging.getLogger("clawMail.pool")

_DEFAULT_MAX_AGE = 300  # seconds before forcing a reconnect


class ConnectionPool:
    """Pool of IMAP/SMTP connections keyed by account name."""

    def __init__(
        self,
        account_manager: AccountManager,
        max_age: int = _DEFAULT_MAX_AGE,
    ) -> None:
        self._mgr = account_manager
        self._max_age = max_age
        self._imap: dict[str, tuple[IMAPClient, float]] = {}
        self._smtp: dict[str, tuple[SMTPClient, float]] = {}

    def get_imap(self, account: str = "") -> IMAPClient:
        """Return a connected IMAPClient, reusing if possible."""
        account = account or self._mgr.default_account

        if account in self._imap:
            client, created = self._imap[account]
            age = time.monotonic() - created
            if age < self._max_age and self._imap_alive(client):
                return client
            # Stale or dead — close and recreate
            self._close_imap(account)

        client = self._mgr.get_imap_client(account)
        client.connect()
        self._imap[account] = (client, time.monotonic())
        return client

    def get_smtp(self, account: str = "") -> SMTPClient:
        """Return an SMTPClient (created fresh, SMTP is stateless per send)."""
        account = account or self._mgr.default_account
        return self._mgr.get_smtp_client(account)

    def release_imap(self, account: str = "") -> None:
        """Release (disconnect) a specific IMAP connection."""
        account = account or self._mgr.default_account
        self._close_imap(account)

    def close_all(self) -> None:
        """Disconnect all pooled connections."""
        for acct in list(self._imap):
            self._close_imap(acct)
        self._imap.clear()
        self._smtp.clear()

    def _close_imap(self, account: str) -> None:
        entry = self._imap.pop(account, None)
        if entry:
            client, _ = entry
            try:
                client.disconnect()
            except Exception as exc:
                logger.debug("Pool disconnect %s: %s", account, exc)

    @staticmethod
    def _imap_alive(client: IMAPClient) -> bool:
        """Quick liveness check via NOOP."""
        try:
            status, _ = client.conn.noop()
            return status == "OK"
        except Exception:
            return False

    def __enter__(self) -> ConnectionPool:
        return self

    def __exit__(self, *exc: Any) -> None:
        self.close_all()


path: scripts/lib/outbox.py
content: """IMAP Outbox — temporary folder for outgoing messages.

Implements the Apple Mail-style Outbox pattern:

1. Before sending, the composed MIME message is saved to an IMAP
   ``Outbox`` folder (created on demand).
2. The message is sent via SMTP.
3. On success the message is deleted from Outbox.  If the Outbox is
   now empty the folder itself is removed (it is temporary).
4. On failure the message remains in Outbox and the error is reported.
5. The heartbeat (or ``retry_send.py``) periodically checks for an
   Outbox folder and attempts to deliver anything sitting there.

From the Apple documentation:

    Outbox: This folder temporarily holds messages while they are in the
    process of being sent.  The Outbox generally only appears in the
    sidebar if you have an email that is currently sending or has failed
    to send.
"""

from __future__ import annotations

import logging
from typing import Any

from .imap_client import IMAPClient
from .models import EmailMessage
from .smtp_client import SMTPClient, build_mime

logger = logging.getLogger("clawMail.outbox")

OUTBOX_FOLDER = "Outbox"


class OutboxStageError(Exception):
    """Raised when staging a message in Outbox repeatedly fails."""


class Outbox:
    """Manage an IMAP Outbox folder for outgoing messages."""

    def __init__(self, imap_client: IMAPClient) -> None:
        self._imap = imap_client

    # ── Core operations ───────────────────────────────────────────

    def stage(self, message: EmailMessage) -> bool:
        """Save a message to the Outbox folder prior to sending.

        Creates the Outbox folder if it does not already exist.
        Returns True on success.
        """
        self._ensure_folder()
        mime_msg = build_mime(message)
        raw_bytes = mime_msg.as_bytes()
        logger.debug(
            "Stage Outbox message subject=%r size=%d recipients=%s",
            message.subject, len(raw_bytes),
            ", ".join(str(r) for r in message.recipients),
        )
        ok = False
        attempts = 0
        while attempts < 3 and not ok:
            attempts += 1
            ok = self._imap.append_message(
                raw_bytes, mailbox=OUTBOX_FOLDER, flags="",
            )
            if not ok:
                logger.warning("Outbox stage attempt %d failed", attempts)
        if not ok:
            error_detail = self._imap.last_append_error or "unknown APPEND failure"
            logger.error(
                "Failed to stage message in Outbox after %d attempts: %r (%s)",
                attempts, message.subject, error_detail,
            )
            raise OutboxStageError(error_detail)
        if ok:
            logger.info(
                "Staged message in Outbox: subject=%r, to=%s",
                message.subject,
                ", ".join(str(r) for r in message.recipients),
            )
        else:
            logger.error("Failed to stage message in Outbox: %r", message.subject)
        return ok

    def drain(
        self,
        send_fn: _SendFn,
        limit: int = 50,
    ) -> DrainResult:
        """Attempt to send every message currently in the Outbox.

        *send_fn* is called with each :class:`EmailMessage` and must
        return a dict with at least ``{"success": bool}``.  On success
        the message is removed from Outbox.

        After all messages are processed, if the Outbox is empty the
        folder is deleted.

        Returns a :class:`DrainResult` summarising what happened.
        """
        if not self._folder_exists():
            return DrainResult(attempted=0, sent=0, failed=0, errors=[])

        messages = self._imap.fetch_all(mailbox=OUTBOX_FOLDER, limit=limit)
        if not messages:
            # Folder exists but is empty — clean up
            self._remove_folder()
            return DrainResult(attempted=0, sent=0, failed=0, errors=[])

        sent = 0
        failed = 0
        errors: list[dict[str, Any]] = []

        for msg in messages:
            try:
                result = send_fn(msg)
                if result.get("success"):
                    # Remove from Outbox on successful send
                    if msg.message_id:
                        self._imap.delete_message(msg.message_id, mailbox=OUTBOX_FOLDER)
                    sent += 1
                    logger.info(
                        "Sent from Outbox: subject=%r", msg.subject,
                    )
                else:
                    error_msg = result.get("error", "SMTP send failed")
                    errors.append({
                        "message_id": msg.message_id,
                        "subject": msg.subject,
                        "error": error_msg,
                    })
                    failed += 1
                    logger.warning(
                        "Failed to send from Outbox: subject=%r error=%s",
                        msg.subject, error_msg,
                    )
            except Exception as exc:
                errors.append({
                    "message_id": msg.message_id,
                    "subject": msg.subject,
                    "error": str(exc),
                })
                failed += 1
                logger.error(
                    "Exception sending from Outbox: subject=%r error=%s",
                    msg.subject, exc,
                )

        # If Outbox is now empty, remove the temporary folder
        if sent > 0:
            self._cleanup_if_empty()

        return DrainResult(
            attempted=len(messages), sent=sent, failed=failed, errors=errors,
        )

    def list_pending(self, limit: int = 50) -> list[EmailMessage]:
        """List messages currently sitting in the Outbox."""
        if not self._folder_exists():
            return []
        return self._imap.fetch_all(mailbox=OUTBOX_FOLDER, limit=limit)

    def exists(self) -> bool:
        """Check whether the Outbox folder currently exists."""
        return self._folder_exists()

    # ── Folder management ─────────────────────────────────────────

    def _ensure_folder(self) -> None:
        """Create the Outbox folder if it does not exist."""
        if not self._folder_exists():
            ok = self._imap.create_folder(OUTBOX_FOLDER)
            if ok:
                logger.info("Created Outbox folder")
            else:
                logger.warning("Failed to create Outbox folder")

    def _folder_exists(self) -> bool:
        """Check if the Outbox folder exists on the IMAP server."""
        folders = self._imap.list_folders()
        return any(f["name"] == OUTBOX_FOLDER for f in folders)

    def _remove_folder(self) -> None:
        """Delete the Outbox folder (it is temporary)."""
        ok = self._imap.delete_folder(OUTBOX_FOLDER)
        if ok:
            logger.info("Removed empty Outbox folder")
        else:
            logger.debug("Could not remove Outbox folder")

    def _cleanup_if_empty(self) -> None:
        """Remove the Outbox folder if it has no remaining messages."""
        status = self._imap.folder_status(OUTBOX_FOLDER)
        if status.get("messages", 1) == 0:
            self._remove_folder()


# ── Type alias for the send callback ─────────────────────────────

from typing import Callable, Protocol

_SendFn = Callable[[EmailMessage], dict[str, Any]]


# ── Result dataclass ─────────────────────────────────────────────

class DrainResult:
    """Summary of an Outbox drain operation."""

    __slots__ = ("attempted", "sent", "failed", "errors")

    def __init__(
        self,
        attempted: int,
        sent: int,
        failed: int,
        errors: list[dict[str, Any]],
    ) -> None:
        self.attempted = attempted
        self.sent = sent
        self.failed = failed
        self.errors = errors

    def to_dict(self) -> dict[str, Any]:
        return {
            "attempted": self.attempted,
            "sent": self.sent,
            "failed": self.failed,
            "errors": self.errors,
        }

    def __repr__(self) -> str:
        return (
            f"DrainResult(attempted={self.attempted}, sent={self.sent}, "
            f"failed={self.failed})"
        )


path: scripts/idle_monitor.py
content: #!/usr/bin/env python3
"""Monitor an IMAP mailbox for new messages using IDLE (push).

Uses IMAP IDLE (RFC 2177) to wait for new-mail events instead of polling.
When a new message arrives the script fetches and prints it, then re-enters
IDLE mode.

Usage:
    python3 scripts/idle_monitor.py --config config.yaml
    python3 scripts/idle_monitor.py --config config.yaml --account work --folder INBOX
    python3 scripts/idle_monitor.py --config config.yaml --timeout 60 --max-events 10
"""

from __future__ import annotations

import argparse
import json
import os
import signal
import sys
import time

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.imap_client import IMAPClient


_running = True


def _handle_signal(signum, frame):
    global _running
    _running = False


def main() -> None:
    parser = argparse.ArgumentParser(description="Monitor mailbox via IMAP IDLE")
    parser.add_argument("--config", default="", help="YAML config file")
    parser.add_argument("--account", default="", help="Account profile name")
    parser.add_argument("--folder", default="INBOX", help="Folder to monitor")
    parser.add_argument(
        "--timeout", type=int, default=29 * 60,
        help="IDLE timeout in seconds (default: 29 min per RFC 2177)",
    )
    parser.add_argument(
        "--poll-interval", type=float, default=30.0,
        help="Seconds between idle_check polls (default: 30)",
    )
    parser.add_argument(
        "--max-events", type=int, default=0,
        help="Stop after N new-mail events (0 = run forever)",
    )
    parser.add_argument("--format", choices=["json", "cli"], default="json")

    # Direct IMAP flags
    parser.add_argument("--imap-host", default="")
    parser.add_argument("--imap-port", type=int, default=993)
    parser.add_argument("--imap-user", default="")
    parser.add_argument("--imap-pass", default="")
    parser.add_argument("--imap-no-ssl", action="store_true")
    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    signal.signal(signal.SIGINT, _handle_signal)
    signal.signal(signal.SIGTERM, _handle_signal)

    # Build client
    client: IMAPClient | None = None
    acct_name = args.account

    if args.imap_host:
        client = IMAPClient(
            host=args.imap_host, port=args.imap_port,
            username=args.imap_user, [REDACTED_SECRET]),
            use_ssl=not args.imap_no_ssl,
        )
        acct_name = acct_name or "direct"
    elif args.config:
        try:
            mgr = AccountManager.from_yaml(args.config)
        except Exception as exc:
            _error(f"Failed to load config: {exc}")
            return
        acct_name = acct_name or mgr.default_account
        client = mgr.get_imap_client(acct_name)
    else:
        _error("Either --config or --imap-host is required.")
        return

    event_count = 0

    try:
        client.connect()
        _log(f"Connected to {client.host}, monitoring {args.folder}", args.format)

        while _running:
            try:
                client.idle_start(args.folder, timeout=args.timeout)
                _log("IDLE started, waiting for events...", args.format)

                idle_start = time.monotonic()
                while _running:
                    responses = client.idle_check(timeout=args.poll_interval)

                    new_exists = any(
                        resp == b"EXISTS" for _, resp in responses
                    )

                    if new_exists:
                        client.idle_done()
                        event_count += 1

                        # Fetch the newest unread messages
                        messages = client.fetch_unread(
                            mailbox=args.folder, limit=10,
                        )

                        event = {
                            "event": "new_mail",
                            "account": acct_name,
                            "folder": args.folder,
                            "event_number": event_count,
                            "new_messages": len(messages),
                            "messages": [
                                {
                                    "message_id": m.message_id,
                                    "subject": m.subject,
                                    "sender": str(m.sender) if m.sender else "",
                                    "date": m.date.isoformat() if m.date else "",
                                }
                                for m in messages
                            ],
                        }

                        if args.format == "cli":
                            print(f"\n--- New mail ({len(messages)} message(s)) ---")
                            for m in messages:
                                sender = str(m.sender) if m.sender else "(unknown)"
                                print(f"  From: {sender}")
                                print(f"  Subject: {m.subject}")
                                print()
                        else:
                            json.dump(event, sys.stdout)
                            print()
                            sys.stdout.flush()

                        if args.max_events and event_count >= args.max_events:
                            _log(f"Reached max events ({args.max_events})", args.format)
                            return

                        # Re-enter IDLE
                        break

                    # Re-issue IDLE every timeout seconds
                    elapsed = time.monotonic() - idle_start
                    if elapsed >= args.timeout - 60:
                        client.idle_done()
                        _log("Re-issuing IDLE (timeout approaching)", args.format)
                        break

            except (OSError, RuntimeError) as exc:
                _log(f"IDLE error: {exc}, reconnecting in 5s...", args.format)
                try:
                    client.disconnect()
                except Exception:
                    pass
                time.sleep(5)
                client.connect()

    except Exception as exc:
        _error(f"Fatal: {exc}")
    finally:
        _log("Stopping IDLE monitor", args.format)
        try:
            client.disconnect()
        except Exception:
            pass


def _log(msg: str, fmt: str) -> None:
    if fmt == "cli":
        print(f"[idle] {msg}", file=sys.stderr)
    else:
        json.dump({"log": msg}, sys.stderr)
        print(file=sys.stderr)


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/search_mail.py
content: #!/usr/bin/env python3
"""Search emails on an IMAP server using flexible criteria.

Usage:
    python3 scripts/search_mail.py --config config.yaml --subject "invoice"
    python3 scripts/search_mail.py --config config.yaml --from "[REDACTED_SECRET]" --unseen
    python3 scripts/search_mail.py --config config.yaml --since 2025-01-01 --before 2025-06-01
    python3 scripts/search_mail.py --config config.yaml --criteria '(OR (FROM "a") (FROM "b"))'

Output: JSON (default) or CLI table to stdout.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import datetime

sys.path.insert(0, os.path.dirname(__file__))

from lib import credential_store
from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.imap_client import IMAPClient
from lib.models import EmailMessage


def _build_criteria(args) -> str:
    """Build an IMAP SEARCH criteria string from CLI flags."""
    if args.criteria:
        return args.criteria

    parts: list[str] = []

    if args.unseen:
        parts.append("UNSEEN")
    if args.flagged:
        parts.append("FLAGGED")
    if args.subject:
        parts.append(f'SUBJECT "{args.subject}"')
    if getattr(args, "from_addr", None):
        parts.append(f'FROM "{args.from_addr}"')
    if args.to:
        parts.append(f'TO "{args.to}"')
    if args.body:
        parts.append(f'BODY "{args.body}"')
    if args.since:
        try:
            dt = datetime.strptime(args.since, "%Y-%m-%d")
            parts.append(f'SINCE {dt.strftime("%d-%b-%Y")}')
        except ValueError:
            parts.append(f'SINCE {args.since}')
    if args.before:
        try:
            dt = datetime.strptime(args.before, "%Y-%m-%d")
            parts.append(f'BEFORE {dt.strftime("%d-%b-%Y")}')
        except ValueError:
            parts.append(f'BEFORE {args.before}')
    if args.text:
        parts.append(f'TEXT "{args.text}"')

    if not parts:
        parts.append("ALL")

    return "(" + " ".join(parts) + ")" if len(parts) > 1 else parts[0]


def main() -> None:
    parser = argparse.ArgumentParser(description="Search emails via IMAP SEARCH")

    parser.add_argument("--account", default="", help="Account name")
    parser.add_argument("--folder", default="INBOX", help="Folder to search")
    parser.add_argument("--limit", type=int, default=50, help="Max results")
    parser.add_argument("--format", choices=["json", "cli"], default="json")

    # Search criteria flags
    parser.add_argument("--subject", default="", help="Search in subject")
    parser.add_argument("--from", dest="from_addr", default="", help="Search by sender")
    parser.add_argument("--to", default="", help="Search by recipient")
    parser.add_argument("--body", default="", help="Search in body text")
    parser.add_argument("--text", default="", help="Full-text search (subject + body)")
    parser.add_argument("--since", default="", help="Messages since date (YYYY-MM-DD)")
    parser.add_argument("--before", default="", help="Messages before date (YYYY-MM-DD)")
    parser.add_argument("--unseen", action="store_true", help="Unread messages only")
    parser.add_argument("--flagged", action="store_true", help="Flagged messages only")
    parser.add_argument("--criteria", default="",
                        help="Raw IMAP SEARCH criteria (overrides other flags)")

    # IMAP options
    parser.add_argument("--imap-host", default="")
    parser.add_argument("--imap-port", type=int, default=993)
    parser.add_argument("--imap-user", default="")
    parser.add_argument("--imap-pass", default="")
    parser.add_argument("--imap-no-ssl", action="store_true")
    parser.add_argument("--config", default="", help="YAML config file")

    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    criteria = _build_criteria(args)

    # Build client
    if args.imap_host:
        client = IMAPClient(
            host=args.imap_host,
            port=args.imap_port,
            username=args.imap_user,
            [REDACTED_SECRET]),
            use_ssl=not args.imap_no_ssl,
        )
    elif args.config:
        mgr = _load_manager(args.config)
        acct_name = args.account or mgr.default_account
        client = mgr.get_imap_client(acct_name)
    else:
        _error("Either --config or --imap-host is required.")
        return

    try:
        client.connect()
        messages = client.search(
            mailbox=args.folder, criteria=criteria, limit=args.limit,
        )
    finally:
        client.disconnect()

    _output(messages, args.folder, args.format, args.account, criteria)


def _output(
    messages: list[EmailMessage], folder: str, fmt: str,
    account: str, criteria: str,
) -> None:
    if fmt == "cli":
        label = f"{account}:{folder}" if account else folder
        print(f"  Search: {criteria}")
        if not messages:
            print(f"  No results in {label}")
            return
        print(f"  {label} ({len(messages)} results)")
        print(f"  {'':->60}")
        for m in messages:
            sender = str(m.sender) if m.sender else "(unknown)"
            if len(sender) > 25:
                sender = sender[:22] + "..."
            date = m.date.strftime("%Y-%m-%d %H:%M") if m.date else ""
            subj = m.subject[:40] if m.subject else "(no subject)"
            read = " " if m.is_read else "*"
            att = "+" if m.has_attachments else " "
            print(f"  {read}{att} {date:16s}  {sender:25s}  {subj}")
        print()
    else:
        output = {
            "account": account,
            "folder": folder,
            "criteria": criteria,
            "count": len(messages),
            "messages": [m.to_dict() for m in messages],
        }
        json.dump(output, sys.stdout, indent=2, default=str)
        print()


def _load_manager(path: str) -> AccountManager:
    try:
        return AccountManager.from_yaml(path)
    except Exception as exc:
        _error(f"Failed to load config: {exc}")
        raise


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/heartbeat.py
content: #!/usr/bin/env python3
"""Run a full email heartbeat cycle: fetch -> process -> execute actions.

Supports multiple accounts — iterates over all accounts (or a specific one)
and processes each account's mailboxes with per-account + global rules.

Usage:
    python3 scripts/heartbeat.py --config config.yaml
    python3 scripts/heartbeat.py --config config.yaml --account work
    python3 scripts/heartbeat.py --config config.yaml --state-file /tmp/email_state.json

Output: JSON cycle report to stdout.
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import sys
import time
from datetime import datetime

logger = logging.getLogger("clawMail.heartbeat")

sys.path.insert(0, os.path.dirname(__file__))

from lib.account_manager import AccountManager
from lib.defaults import resolve_config_path
from lib.models import EmailAddress, EmailMessage
from lib.imap_client import IMAPClient
from lib.smtp_client import SMTPClient
from lib.composer import EmailComposer
from lib.processor import EmailProcessor


def main() -> None:
    parser = argparse.ArgumentParser(description="Run email heartbeat cycle")
    parser.add_argument("--config", default="", help="YAML config file")
    parser.add_argument("--account", default="",
                        help="Process only this account (default: all accounts)")
    parser.add_argument("--state-file", default="",
                        help="Shared state file for multi-agent coordination")
    parser.add_argument("--format", choices=["json", "summary"], default="json")

    args = parser.parse_args()
    args.config = resolve_config_path(args.config)

    if not args.config:
        _error("--config is required (or place config.yaml in skill root)")
        return

    try:
        mgr = AccountManager.from_yaml(args.config)
    except Exception as exc:
        _error(f"Failed to load config: {exc}")
        return

    start = time.monotonic()
    now = datetime.now()
    errors: list[str] = []
    all_messages: list[EmailMessage] = []
    account_reports: list[dict] = []
    dedup_skipped = 0

    # Load previously-seen Message-IDs for deduplication
    seen_ids: set[str] = set()
    if args.state_file and os.path.exists(args.state_file):
        try:
            with open(args.state_file) as f:
                prev_state = json.load(f)
            seen_ids = set(prev_state.get("email_seen_ids", []))
        except Exception:
            pass

    # Determine which accounts to process
    if args.account:
        account_names = [args.account]
    else:
        account_names = mgr.list_accounts()

    # ── Drain Outbox for each account before fetching ──────────
    outbox_results: list[dict] = []
    for acct_name in account_names:
        try:
            drain = mgr.drain_outbox(acct_name)
            if drain.get("attempted", 0) > 0:
                outbox_results.append(drain)
                if drain.get("failed", 0) > 0:
                    for err in drain.get("errors", []):
                        errors.append(
                            f"[{acct_name}] Outbox send failed: "
                            f"{err.get('subject', '?')!r} — {err.get('error', '')}"
                        )
        except Exception as exc:
            errors.append(f"[{acct_name}] Outbox drain error: {exc}")

    # ── Fetch and process mail ───────────────────────────────────
    for acct_name in account_names:
        acct_messages: list[EmailMessage] = []
        mailboxes = mgr.get_mailboxes(acct_name)
        fetch_limit = mgr.get_fetch_limit(acct_name)

        # Fetch from each mailbox
        for mailbox in mailboxes:
            try:
                messages = _fetch(mgr, acct_name, mailbox, fetch_limit)
                acct_messages.extend(messages)
            except Exception as exc:
                errors.append(f"[{acct_name}] Fetch {mailbox}: {exc}")

        # Deduplicate: skip messages we've already processed
        if args.state_file and seen_ids:
            before = len(acct_messages)
            acct_messages = [
                m for m in acct_messages
                if m.message_id and m.message_id not in seen_ids
            ]
            dedup_skipped += before - len(acct_messages)

        # Process through per-account + global rules
        rules_config = mgr.get_rules(acct_name)
        if rules_config:
            processor = EmailProcessor.from_config(rules_config)
            results = processor.process_batch(acct_messages)
        else:
            results = []

        # Execute actions
        actions_executed = 0
        for result in results:
            try:
                actions_executed += _execute_actions(result, mgr, acct_name)
            except Exception as exc:
                errors.append(
                    f"[{acct_name}] Actions for '{result.message.subject}': {exc}"
                )

        # Record newly processed IDs
        for m in acct_messages:
            if m.message_id:
                seen_ids.add(m.message_id)

        account_reports.append({
            "account": acct_name,
            "mailboxes_checked": mailboxes,
            "messages_fetched": len(acct_messages),
            "rules_matched": sum(len(r.matched_rules) for r in results),
            "actions_executed": actions_executed,
        })
        all_messages.extend(acct_messages)

    duration = time.monotonic() - start

    # Build report
    report = {
        "timestamp": now.isoformat(),
        "accounts_processed": account_names,
        "total_messages_fetched": len(all_messages),
        "dedup_skipped": dedup_skipped,
        "outbox_drained": outbox_results,
        "duration_seconds": round(duration, 3),
        "errors": errors,
        "accounts": account_reports,
        "messages": [
            {
                "account": m.account,
                "message_id": m.message_id,
                "subject": m.subject,
                "sender": str(m.sender) if m.sender else "",
                "date": m.date.isoformat() if m.date else "",
            }
            for m in all_messages
        ],
    }

    # Write shared state
    if args.state_file:
        state = {}
        if os.path.exists(args.state_file):
            try:
                with open(args.state_file) as f:
                    state = json.load(f)
            except Exception:
                pass

        state["email_last_heartbeat"] = now.isoformat()
        state["email_unread_count"] = len(all_messages)
        state["email_accounts"] = {
            ar["account"]: {
                "fetched": ar["messages_fetched"],
                "rules_matched": ar["rules_matched"],
                "actions": ar["actions_executed"],
            }
            for ar in account_reports
        }
        state["email_messages"] = report["messages"]
        state["email_errors"] = errors

        # Persist seen Message-IDs for deduplication (cap at 10,000)
        seen_list = list(seen_ids)
        if len(seen_list) > 10000:
            seen_list = seen_list[-10000:]
        state["email_seen_ids"] = seen_list

        with open(args.state_file, "w") as f:
            json.dump(state, f, indent=2)

    if args.format == "summary":
        summary = {
            "status": "error" if errors else "ok",
            "accounts": len(account_names),
            "fetched": len(all_messages),
            "duration": f"{duration:.1f}s",
        }
        if errors:
            summary["errors"] = errors
        json.dump(summary, sys.stdout, indent=2)
    else:
        json.dump(report, sys.stdout, indent=2, default=str)

    print()
    sys.exit(1 if errors else 0)


def _fetch(
    mgr: AccountManager, acct_name: str, mailbox: str, limit: int,
) -> list[EmailMessage]:
    client = mgr.get_imap_client(acct_name)
    try:
        client.connect()
        return client.fetch_unread(mailbox=mailbox, limit=limit)
    finally:
        client.disconnect()


def _execute_actions(result, mgr: AccountManager, acct_name: str) -> int:
    count = 0
    message = result.message

    if result.should_mark_read:
        client = mgr.get_imap_client(acct_name)
        try:
            client.connect()
            client.mark_read(message.message_id, message.mailbox)
            count += 1
        finally:
            client.disconnect()

    if result.should_flag:
        client = mgr.get_imap_client(acct_name)
        try:
            client.connect()
            client.flag_message(message.message_id, message.mailbox)
            count += 1
        finally:
            client.disconnect()

    if result.move_to:
        client = mgr.get_imap_client(acct_name)
        try:
            client.connect()
            moved = client.move_message(
                message.message_id, message.mailbox, result.move_to,
            )
            if moved:
                count += 1
        finally:
            client.disconnect()

    if result.forward_to:
        composer = EmailComposer()
        sender = mgr.get_sender(acct_name)
        for addr in result.forward_to:
            fwd = composer.compose(
                to=addr,
                subject=f"Fwd: {message.subject}",
                body=message.body or message.body_plain,
                template="minimal",
                sender=str(sender) if sender else "",
            )
            mgr.send_with_fallback(fwd, acct_name)
            count += 1

    if result.reply_body:
        composer = EmailComposer()
        sender = mgr.get_sender(acct_name)
        reply = composer.compose_reply(
            original=message,
            body=result.reply_body,
            sender=str(sender) if sender else "",
        )
        mgr.send_with_fallback(reply, acct_name)
        count += 1

    if result.should_archive:
        archive_root = mgr.get_archive_root(acct_name)
        frequency = mgr.get_archive_frequency(acct_name)
        client = mgr.get_imap_client(acct_name)
        try:
            client.connect()
            if _archive_message(client, message, archive_root, frequency):
                count += 1
        finally:
            client.disconnect()

    return count


def _archive_message(
    client: IMAPClient,
    message: EmailMessage,
    archive_root: str,
    frequency: str,
) -> bool:
    if not message.message_id:
        logger.warning("Skipping archive: missing Message-ID for %s", message.subject)
        return False
    target_folder = _archive_folder_name(message.date, archive_root, frequency)
    existing = {f["name"] for f in client.list_folders()}
    if target_folder not in existing:
        if not client.create_folder(target_folder):
            logger.warning("Failed to create archive folder %s", target_folder)
            return False
        existing.add(target_folder)
    moved = client.move_message(message.message_id, message.mailbox, target_folder)
    if not moved:
        logger.warning(
            "Failed to archive message %s into %s", message.message_id, target_folder
        )
    return moved


def _archive_folder_name(
    date: datetime | None,
    root: str,
    frequency: str,
) -> str:
    base = root.strip() or "Archive"
    freq = frequency if frequency in {"daily", "weekly", "monthly", "yearly"} else "monthly"
    dt = date or datetime.now()
    if freq == "daily":
        suffix = dt.strftime("%Y%m%d")
    elif freq == "weekly":
        iso_year, iso_week, _ = dt.isocalendar()
        suffix = f"{iso_year}W{iso_week:02d}"
    elif freq == "yearly":
        suffix = dt.strftime("%Y")
    else:
        suffix = dt.strftime("%Y%m")
    return f"{base}-{suffix}"


def _error(msg: str) -> None:
    json.dump({"error": msg}, sys.stderr)
    print(file=sys.stderr)
    sys.exit(1)


if __name__ == "__main__":
    main()


path: assets/config.example.yaml
content: # clawMail Skill Configuration
# Copy to config.yaml and customize.
#
# Supports multiple accounts. Each account has its own IMAP/SMTP
# credentials, mailboxes, and processing rules. One account is
# designated as the default — any script invoked without --account
# will use it automatically.
#
# ── Secure credential storage ──────────────────────────────────────
# Passwords support several secure backends instead of plain text:
#
#   1Password CLI:    "op://vault/item/field"
#   macOS Keychain:   "keychain://service-name/account-name"
#   Environment var:  "env://VAR_NAME"
#   Plain text:       "my-password"  (NOT recommended)
#
# Examples:
#   [REDACTED_SECRET]"
#   [REDACTED_SECRET]"
#   [REDACTED_SECRET]"

# Which account to use when --account is not specified.
default_account: work

# Fallback SMTP relay — used when an account's own SMTP server is
# unreachable (e.g. throttled, down for maintenance). Set this to a
# relay service or a secondary mail server.
smtp_fallback:
  host: ""
  port: 587
  username: ""
  password: ""
  tls: true

# ── Account profiles ────────────────────────────────────────────────

accounts:
  work:
    label: "Work"
    sender_address: "[REDACTED_SECRET]"
    sender_name: "Alice Smith"

    imap:
      host: imap.company.com
      port: 993
      username: "[REDACTED_SECRET]"
      [REDACTED_SECRET]"          # 1Password
      ssl: true
      timeout: 30        # connection timeout in seconds

    smtp:
      host: smtp.company.com
      port: 587
      username: "[REDACTED_SECRET]"
      [REDACTED_SECRET]"          # 1Password
      tls: true

    mailboxes:
      - INBOX
      - Projects

    fetch_limit: 50

    # Per-account rules (run before global rules)
    rules:
      - name: flag_urgent
        sender_pattern: "boss@company\\.com"
        subject_pattern: "urgent|asap|critical"
        actions: [flag, tag]
        tag: urgent
        priority: 10

      - name: sort_invoices
        subject_pattern: "invoice|receipt"
        actions: [move, tag]
        move_to: Finances
        tag: invoice
        priority: 8

  personal:
    label: "Personal"
    sender_address: "[REDACTED_SECRET]"

    imap:
      host: imap.gmail.com
      port: 993
      username: "[REDACTED_SECRET]"
      [REDACTED_SECRET]"  # macOS Keychain
      ssl: true

    smtp:
      host: smtp.gmail.com
      port: 587
      username: "[REDACTED_SECRET]"
      [REDACTED_SECRET]"  # macOS Keychain
      tls: true

    mailboxes:
      - INBOX

    fetch_limit: 25
    rules: []

# ── Global rules ────────────────────────────────────────────────────
# Applied to ALL accounts, after per-account rules.

rules:
  - name: spam_filter
    subject_pattern: "(?i)buy now|act fast|limited time"
    actions: [move]
    move_to: Junk
    priority: 100

# ── Defaults ────────────────────────────────────────────────────────
# Defaults used when a per-account value is missing.

defaults:
  fetch_limit: 50


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)