email-cli-tool 0.2.1__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/.github/workflows/publish.yml +1 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/PKG-INFO +35 -4
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/README.md +33 -2
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/README_CN.md +33 -2
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/pyproject.toml +1 -1
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/src/emailcli/cli.py +94 -1
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/src/emailcli/config.py +28 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/src/emailcli/exceptions.py +4 -0
- email_cli_tool-0.3.1/src/emailcli/receiver.py +146 -0
- email_cli_tool-0.3.1/src/emailcli/skill_install.py +71 -0
- email_cli_tool-0.3.1/src/emailcli/skills/wait-email/SKILL.md +60 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/tests/test_cli.py +145 -2
- email_cli_tool-0.3.1/tests/test_config.py +145 -0
- email_cli_tool-0.3.1/tests/test_receiver.py +194 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/tests/test_skill_install.py +29 -14
- email_cli_tool-0.2.1/src/emailcli/skill_install.py +0 -65
- email_cli_tool-0.2.1/tests/test_config.py +0 -77
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/.github/workflows/test.yml +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/.gitignore +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/.python-version +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/LICENSE +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/docs/superpowers/plans/2026-03-25-emailcli.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/docs/superpowers/plans/2026-06-10-emailcli-skill-install.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/docs/superpowers/specs/2026-03-25-emailcli-design.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/docs/superpowers/specs/2026-06-10-emailcli-skill-install-design.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/src/emailcli/__init__.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/src/emailcli/message.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/src/emailcli/py.typed +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/src/emailcli/sender.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/src/emailcli/skills/send-email/SKILL.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/tests/__init__.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/tests/test_message.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/tests/test_sender.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: email-cli-tool
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A simple CLI tool for sending emails with plain text, HTML, and file attachments
|
|
5
5
|
Project-URL: Homepage, https://github.com/ClaymanTwinkle/email-cli-tool
|
|
6
6
|
Project-URL: Repository, https://github.com/ClaymanTwinkle/email-cli-tool
|
|
@@ -38,6 +38,7 @@ Description-Content-Type: text/markdown
|
|
|
38
38
|
- Direct SMTP connection (SSL / STARTTLS)
|
|
39
39
|
- Interactive configuration wizard
|
|
40
40
|
- Read body content from stdin
|
|
41
|
+
- Wait for the next incoming email (IMAP) and download its attachments
|
|
41
42
|
|
|
42
43
|
## Installation
|
|
43
44
|
|
|
@@ -108,6 +109,18 @@ echo "Content" | emailcli send \
|
|
|
108
109
|
--to user@example.com --subject "Piped" --body -
|
|
109
110
|
```
|
|
110
111
|
|
|
112
|
+
### 3. Wait for Incoming Email
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# Block until the next email arrives, print it, then exit
|
|
116
|
+
emailcli watch
|
|
117
|
+
|
|
118
|
+
# Wait up to 5 minutes and save attachments
|
|
119
|
+
emailcli watch --timeout 300 --save-attachments ./downloads
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Requires IMAP settings — `emailcli init` asks for them, or add an `imap` section to the config (see below).
|
|
123
|
+
|
|
111
124
|
## Command Reference
|
|
112
125
|
|
|
113
126
|
### `emailcli send`
|
|
@@ -124,6 +137,17 @@ echo "Content" | emailcli send \
|
|
|
124
137
|
|
|
125
138
|
> At least one of `--body`, `--html`, or `--html-file` is required.
|
|
126
139
|
|
|
140
|
+
### `emailcli watch`
|
|
141
|
+
|
|
142
|
+
Wait for the next incoming email (via IMAP polling), print headers and body to stdout, then exit. Only emails arriving **after** the command starts are matched; the received email is marked as read.
|
|
143
|
+
|
|
144
|
+
| Option | Default | Description |
|
|
145
|
+
|--------|---------|-------------|
|
|
146
|
+
| `--timeout` | `0` (wait forever) | Max seconds to wait; exits with code `2` on timeout |
|
|
147
|
+
| `--poll-interval` | `10` | Seconds between mailbox checks |
|
|
148
|
+
| `--save-attachments` | | Directory to save attachments into |
|
|
149
|
+
| `--mailbox` | `INBOX` | Mailbox to watch |
|
|
150
|
+
|
|
127
151
|
### `emailcli init`
|
|
128
152
|
|
|
129
153
|
Interactively create the configuration file at `~/.emailcli/config.yaml`.
|
|
@@ -134,7 +158,7 @@ Display current configuration (password is masked).
|
|
|
134
158
|
|
|
135
159
|
### `emailcli skill install`
|
|
136
160
|
|
|
137
|
-
Install the `send-email` agent
|
|
161
|
+
Install the `send-email` and `wait-email` agent skills so Claude Code or Codex can send and receive mail for you.
|
|
138
162
|
|
|
139
163
|
```bash
|
|
140
164
|
# Install for both Claude Code and Codex (default)
|
|
@@ -145,7 +169,7 @@ emailcli skill install --target claude
|
|
|
145
169
|
emailcli skill install --target codex
|
|
146
170
|
```
|
|
147
171
|
|
|
148
|
-
Writes `SKILL.md`
|
|
172
|
+
Writes each skill's `SKILL.md` under `~/.claude/skills/` and/or `~/.codex/skills/`.
|
|
149
173
|
|
|
150
174
|
## Configuration
|
|
151
175
|
|
|
@@ -159,6 +183,13 @@ smtp:
|
|
|
159
183
|
username: yourname@gmail.com
|
|
160
184
|
password: your-app-password
|
|
161
185
|
encryption: ssl # ssl | starttls | none
|
|
186
|
+
|
|
187
|
+
# Optional, only needed for `emailcli watch`
|
|
188
|
+
imap:
|
|
189
|
+
host: imap.gmail.com
|
|
190
|
+
port: 993 # default: 993
|
|
191
|
+
encryption: ssl # default: ssl
|
|
192
|
+
# username/password default to the smtp values
|
|
162
193
|
```
|
|
163
194
|
|
|
164
195
|
## Development
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
- Direct SMTP connection (SSL / STARTTLS)
|
|
13
13
|
- Interactive configuration wizard
|
|
14
14
|
- Read body content from stdin
|
|
15
|
+
- Wait for the next incoming email (IMAP) and download its attachments
|
|
15
16
|
|
|
16
17
|
## Installation
|
|
17
18
|
|
|
@@ -82,6 +83,18 @@ echo "Content" | emailcli send \
|
|
|
82
83
|
--to user@example.com --subject "Piped" --body -
|
|
83
84
|
```
|
|
84
85
|
|
|
86
|
+
### 3. Wait for Incoming Email
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Block until the next email arrives, print it, then exit
|
|
90
|
+
emailcli watch
|
|
91
|
+
|
|
92
|
+
# Wait up to 5 minutes and save attachments
|
|
93
|
+
emailcli watch --timeout 300 --save-attachments ./downloads
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Requires IMAP settings — `emailcli init` asks for them, or add an `imap` section to the config (see below).
|
|
97
|
+
|
|
85
98
|
## Command Reference
|
|
86
99
|
|
|
87
100
|
### `emailcli send`
|
|
@@ -98,6 +111,17 @@ echo "Content" | emailcli send \
|
|
|
98
111
|
|
|
99
112
|
> At least one of `--body`, `--html`, or `--html-file` is required.
|
|
100
113
|
|
|
114
|
+
### `emailcli watch`
|
|
115
|
+
|
|
116
|
+
Wait for the next incoming email (via IMAP polling), print headers and body to stdout, then exit. Only emails arriving **after** the command starts are matched; the received email is marked as read.
|
|
117
|
+
|
|
118
|
+
| Option | Default | Description |
|
|
119
|
+
|--------|---------|-------------|
|
|
120
|
+
| `--timeout` | `0` (wait forever) | Max seconds to wait; exits with code `2` on timeout |
|
|
121
|
+
| `--poll-interval` | `10` | Seconds between mailbox checks |
|
|
122
|
+
| `--save-attachments` | | Directory to save attachments into |
|
|
123
|
+
| `--mailbox` | `INBOX` | Mailbox to watch |
|
|
124
|
+
|
|
101
125
|
### `emailcli init`
|
|
102
126
|
|
|
103
127
|
Interactively create the configuration file at `~/.emailcli/config.yaml`.
|
|
@@ -108,7 +132,7 @@ Display current configuration (password is masked).
|
|
|
108
132
|
|
|
109
133
|
### `emailcli skill install`
|
|
110
134
|
|
|
111
|
-
Install the `send-email` agent
|
|
135
|
+
Install the `send-email` and `wait-email` agent skills so Claude Code or Codex can send and receive mail for you.
|
|
112
136
|
|
|
113
137
|
```bash
|
|
114
138
|
# Install for both Claude Code and Codex (default)
|
|
@@ -119,7 +143,7 @@ emailcli skill install --target claude
|
|
|
119
143
|
emailcli skill install --target codex
|
|
120
144
|
```
|
|
121
145
|
|
|
122
|
-
Writes `SKILL.md`
|
|
146
|
+
Writes each skill's `SKILL.md` under `~/.claude/skills/` and/or `~/.codex/skills/`.
|
|
123
147
|
|
|
124
148
|
## Configuration
|
|
125
149
|
|
|
@@ -133,6 +157,13 @@ smtp:
|
|
|
133
157
|
username: yourname@gmail.com
|
|
134
158
|
password: your-app-password
|
|
135
159
|
encryption: ssl # ssl | starttls | none
|
|
160
|
+
|
|
161
|
+
# Optional, only needed for `emailcli watch`
|
|
162
|
+
imap:
|
|
163
|
+
host: imap.gmail.com
|
|
164
|
+
port: 993 # default: 993
|
|
165
|
+
encryption: ssl # default: ssl
|
|
166
|
+
# username/password default to the smtp values
|
|
136
167
|
```
|
|
137
168
|
|
|
138
169
|
## Development
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
- SMTP 直连(支持 SSL / STARTTLS)
|
|
13
13
|
- 交互式配置向导
|
|
14
14
|
- 从 stdin 读取正文内容
|
|
15
|
+
- 等待下一封新邮件(IMAP)并下载附件
|
|
15
16
|
|
|
16
17
|
## 安装
|
|
17
18
|
|
|
@@ -82,6 +83,18 @@ echo "邮件内容" | emailcli send \
|
|
|
82
83
|
--to user@example.com --subject "Piped" --body -
|
|
83
84
|
```
|
|
84
85
|
|
|
86
|
+
### 3. 等待新邮件
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# 阻塞等待下一封新邮件,打印内容后退出
|
|
90
|
+
emailcli watch
|
|
91
|
+
|
|
92
|
+
# 最多等 5 分钟,并保存附件
|
|
93
|
+
emailcli watch --timeout 300 --save-attachments ./downloads
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
需要 IMAP 配置 —— `emailcli init` 会询问,也可以手动在配置文件中加 `imap` 段(见下文)。
|
|
97
|
+
|
|
85
98
|
## 命令参考
|
|
86
99
|
|
|
87
100
|
### `emailcli send`
|
|
@@ -98,6 +111,17 @@ echo "邮件内容" | emailcli send \
|
|
|
98
111
|
|
|
99
112
|
> `--body` 和 `--html` / `--html-file` 至少提供一个。
|
|
100
113
|
|
|
114
|
+
### `emailcli watch`
|
|
115
|
+
|
|
116
|
+
通过 IMAP 轮询等待下一封新邮件,把邮件头和正文打印到 stdout 后退出。只匹配命令启动**之后**到达的邮件;收到的邮件会被标记为已读。
|
|
117
|
+
|
|
118
|
+
| 参数 | 默认值 | 说明 |
|
|
119
|
+
|------|--------|------|
|
|
120
|
+
| `--timeout` | `0`(一直等) | 最长等待秒数,超时退出码为 `2` |
|
|
121
|
+
| `--poll-interval` | `10` | 轮询间隔(秒) |
|
|
122
|
+
| `--save-attachments` | | 附件保存目录 |
|
|
123
|
+
| `--mailbox` | `INBOX` | 监听的邮箱文件夹 |
|
|
124
|
+
|
|
101
125
|
### `emailcli init`
|
|
102
126
|
|
|
103
127
|
交互式创建配置文件 `~/.emailcli/config.yaml`。
|
|
@@ -108,7 +132,7 @@ echo "邮件内容" | emailcli send \
|
|
|
108
132
|
|
|
109
133
|
### `emailcli skill install`
|
|
110
134
|
|
|
111
|
-
安装 `send-email` 技能,让 Claude Code 或 Codex
|
|
135
|
+
安装 `send-email` 和 `wait-email` 技能,让 Claude Code 或 Codex 可以直接帮你收发邮件。
|
|
112
136
|
|
|
113
137
|
```bash
|
|
114
138
|
# 默认同时安装到 Claude Code 和 Codex
|
|
@@ -119,7 +143,7 @@ emailcli skill install --target claude
|
|
|
119
143
|
emailcli skill install --target codex
|
|
120
144
|
```
|
|
121
145
|
|
|
122
|
-
|
|
146
|
+
会把各技能的 `SKILL.md` 写入 `~/.claude/skills/` 和/或 `~/.codex/skills/` 下的同名目录。
|
|
123
147
|
|
|
124
148
|
## 配置文件
|
|
125
149
|
|
|
@@ -133,6 +157,13 @@ smtp:
|
|
|
133
157
|
username: yourname@163.com
|
|
134
158
|
password: your-auth-code
|
|
135
159
|
encryption: ssl # ssl | starttls | none
|
|
160
|
+
|
|
161
|
+
# 可选,仅 `emailcli watch` 需要
|
|
162
|
+
imap:
|
|
163
|
+
host: imap.163.com
|
|
164
|
+
port: 993 # 默认 993
|
|
165
|
+
encryption: ssl # 默认 ssl
|
|
166
|
+
# username/password 默认复用 smtp 的值
|
|
136
167
|
```
|
|
137
168
|
|
|
138
169
|
## 开发
|
|
@@ -6,6 +6,7 @@ import click
|
|
|
6
6
|
from emailcli.config import load_config
|
|
7
7
|
from emailcli.exceptions import EmailCliError
|
|
8
8
|
from emailcli.message import build_message
|
|
9
|
+
from emailcli.receiver import ImapReceiver, extract_body, save_attachments
|
|
9
10
|
from emailcli.sender import SmtpSender
|
|
10
11
|
from emailcli import skill_install
|
|
11
12
|
|
|
@@ -74,6 +75,71 @@ def send(to_addrs, subject, body, html_content, html_file_path, attachments, fro
|
|
|
74
75
|
raise SystemExit(1)
|
|
75
76
|
|
|
76
77
|
|
|
78
|
+
@cli.command()
|
|
79
|
+
@click.option("--timeout", default=0.0, type=float, help="Max seconds to wait for a new email (0 = wait forever).")
|
|
80
|
+
@click.option("--poll-interval", default=10.0, type=float, show_default=True, help="Seconds between mailbox checks.")
|
|
81
|
+
@click.option("--save-attachments", "attachments_dir", default=None, type=click.Path(file_okay=False), help="Directory to save attachments into.")
|
|
82
|
+
@click.option("--mailbox", default="INBOX", show_default=True, help="Mailbox to watch.")
|
|
83
|
+
@click.option("--config-dir", default=None, type=click.Path(), hidden=True, help="Config directory (for testing).")
|
|
84
|
+
def watch(timeout, poll_interval, attachments_dir, mailbox, config_dir):
|
|
85
|
+
"""Wait for the next incoming email, print it, then exit.
|
|
86
|
+
|
|
87
|
+
Only emails arriving after the command starts are matched. Exits with
|
|
88
|
+
code 0 when an email was received, 2 on timeout.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
cfg_dir = Path(config_dir) if config_dir else None
|
|
92
|
+
config = load_config(cfg_dir)
|
|
93
|
+
|
|
94
|
+
if config.imap is None:
|
|
95
|
+
raise EmailCliError(
|
|
96
|
+
"No IMAP settings in config. Re-run 'emailcli init' "
|
|
97
|
+
"or add an 'imap' section to config.yaml."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
receiver = ImapReceiver(
|
|
101
|
+
host=config.imap.host,
|
|
102
|
+
port=config.imap.port,
|
|
103
|
+
username=config.imap.username,
|
|
104
|
+
password=config.imap.password,
|
|
105
|
+
encryption=config.imap.encryption,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
click.echo(f"Waiting for new email in {mailbox}... (Ctrl-C to stop)", err=True)
|
|
109
|
+
msg = receiver.wait_for_message(
|
|
110
|
+
mailbox=mailbox, timeout=timeout, poll_interval=poll_interval
|
|
111
|
+
)
|
|
112
|
+
if msg is None:
|
|
113
|
+
click.echo(f"Timed out after {timeout:g}s with no new email.", err=True)
|
|
114
|
+
raise SystemExit(2)
|
|
115
|
+
|
|
116
|
+
click.echo(f"From: {msg.get('From', '')}")
|
|
117
|
+
click.echo(f"To: {msg.get('To', '')}")
|
|
118
|
+
click.echo(f"Subject: {msg.get('Subject', '')}")
|
|
119
|
+
click.echo(f"Date: {msg.get('Date', '')}")
|
|
120
|
+
click.echo("")
|
|
121
|
+
|
|
122
|
+
body, kind = extract_body(msg)
|
|
123
|
+
if body:
|
|
124
|
+
if kind == "html":
|
|
125
|
+
click.echo("(HTML body)", err=True)
|
|
126
|
+
click.echo(body)
|
|
127
|
+
else:
|
|
128
|
+
click.echo("(no text body)", err=True)
|
|
129
|
+
|
|
130
|
+
if attachments_dir:
|
|
131
|
+
saved = save_attachments(msg, Path(attachments_dir))
|
|
132
|
+
if saved:
|
|
133
|
+
for path in saved:
|
|
134
|
+
click.echo(f"Saved attachment: {path}", err=True)
|
|
135
|
+
else:
|
|
136
|
+
click.echo("No attachments.", err=True)
|
|
137
|
+
except EmailCliError as e:
|
|
138
|
+
click.echo(f"Error: {e}", err=True)
|
|
139
|
+
raise SystemExit(1)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
77
143
|
@cli.command()
|
|
78
144
|
@click.option("--config-dir", default=None, type=click.Path(), hidden=True, help="Config directory (for testing).")
|
|
79
145
|
def init(config_dir):
|
|
@@ -101,6 +167,25 @@ def init(config_dir):
|
|
|
101
167
|
"Encryption (starttls/ssl/none)", default="ssl"
|
|
102
168
|
)
|
|
103
169
|
|
|
170
|
+
imap_section = None
|
|
171
|
+
if click.confirm("\nConfigure IMAP (needed for 'emailcli watch')?", default=False):
|
|
172
|
+
imap_host = click.prompt("IMAP host")
|
|
173
|
+
imap_port = click.prompt("IMAP port", type=int, default=993)
|
|
174
|
+
imap_username = click.prompt("IMAP username", default=smtp_username)
|
|
175
|
+
imap_password = click.prompt(
|
|
176
|
+
"IMAP password (empty = same as SMTP)",
|
|
177
|
+
hide_input=True, default="", show_default=False,
|
|
178
|
+
)
|
|
179
|
+
imap_encryption = click.prompt("IMAP encryption (ssl/starttls/none)", default="ssl")
|
|
180
|
+
imap_section = {
|
|
181
|
+
"host": imap_host,
|
|
182
|
+
"port": imap_port,
|
|
183
|
+
"username": imap_username,
|
|
184
|
+
"encryption": imap_encryption,
|
|
185
|
+
}
|
|
186
|
+
if imap_password:
|
|
187
|
+
imap_section["password"] = imap_password
|
|
188
|
+
|
|
104
189
|
config_data = {
|
|
105
190
|
"from": from_addr,
|
|
106
191
|
"smtp": {
|
|
@@ -111,6 +196,8 @@ def init(config_dir):
|
|
|
111
196
|
"encryption": smtp_encryption,
|
|
112
197
|
},
|
|
113
198
|
}
|
|
199
|
+
if imap_section:
|
|
200
|
+
config_data["imap"] = imap_section
|
|
114
201
|
|
|
115
202
|
cfg_dir.mkdir(parents=True, exist_ok=True)
|
|
116
203
|
with open(config_file, "w") as f:
|
|
@@ -139,6 +226,12 @@ def show(config_dir):
|
|
|
139
226
|
click.echo(f"Username: {cfg.smtp_username}")
|
|
140
227
|
click.echo(f"Password: ***")
|
|
141
228
|
click.echo(f"Encryption: {cfg.smtp_encryption}")
|
|
229
|
+
if cfg.imap:
|
|
230
|
+
click.echo("")
|
|
231
|
+
click.echo(f"IMAP Host: {cfg.imap.host}")
|
|
232
|
+
click.echo(f"IMAP Port: {cfg.imap.port}")
|
|
233
|
+
click.echo(f"IMAP User: {cfg.imap.username}")
|
|
234
|
+
click.echo(f"IMAP Encryption: {cfg.imap.encryption}")
|
|
142
235
|
except EmailCliError as e:
|
|
143
236
|
click.echo(f"Error: {e}", err=True)
|
|
144
237
|
raise SystemExit(1)
|
|
@@ -159,7 +252,7 @@ def skill_group():
|
|
|
159
252
|
)
|
|
160
253
|
@click.option("--home", default=None, type=click.Path(), hidden=True, help="Home directory (for testing).")
|
|
161
254
|
def install(target, home):
|
|
162
|
-
"""Install the
|
|
255
|
+
"""Install the emailcli agent skills into Claude Code and/or Codex."""
|
|
163
256
|
home_dir = Path(home) if home else Path.home()
|
|
164
257
|
targets = ["claude", "codex"] if target == "all" else [target]
|
|
165
258
|
|
|
@@ -6,6 +6,15 @@ import yaml
|
|
|
6
6
|
from emailcli.exceptions import ConfigError
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ImapConfig:
|
|
11
|
+
host: str
|
|
12
|
+
port: int
|
|
13
|
+
username: str
|
|
14
|
+
password: str
|
|
15
|
+
encryption: str # "ssl" | "starttls" | "none"
|
|
16
|
+
|
|
17
|
+
|
|
9
18
|
@dataclass(frozen=True)
|
|
10
19
|
class ConfigData:
|
|
11
20
|
from_addr: str
|
|
@@ -14,6 +23,7 @@ class ConfigData:
|
|
|
14
23
|
smtp_username: str
|
|
15
24
|
smtp_password: str
|
|
16
25
|
smtp_encryption: str # "starttls" | "ssl" | "none"
|
|
26
|
+
imap: ImapConfig | None = None
|
|
17
27
|
|
|
18
28
|
|
|
19
29
|
def load_config(config_dir: Path | None = None) -> ConfigData:
|
|
@@ -43,6 +53,23 @@ def load_config(config_dir: Path | None = None) -> ConfigData:
|
|
|
43
53
|
if field not in smtp:
|
|
44
54
|
raise ConfigError(f"Missing required smtp field: '{field}'")
|
|
45
55
|
|
|
56
|
+
imap_data = data.get("imap")
|
|
57
|
+
imap = None
|
|
58
|
+
if imap_data is not None:
|
|
59
|
+
if not isinstance(imap_data, dict):
|
|
60
|
+
raise ConfigError("'imap' must be a mapping")
|
|
61
|
+
if "host" not in imap_data:
|
|
62
|
+
raise ConfigError("Missing required imap field: 'host'")
|
|
63
|
+
# Username/password default to the SMTP credentials (common case:
|
|
64
|
+
# same account, e.g. a Gmail app password works for both).
|
|
65
|
+
imap = ImapConfig(
|
|
66
|
+
host=imap_data["host"],
|
|
67
|
+
port=imap_data.get("port", 993),
|
|
68
|
+
username=imap_data.get("username", smtp["username"]),
|
|
69
|
+
password=imap_data.get("password", smtp["password"]),
|
|
70
|
+
encryption=imap_data.get("encryption", "ssl"),
|
|
71
|
+
)
|
|
72
|
+
|
|
46
73
|
return ConfigData(
|
|
47
74
|
from_addr=data.get("from", ""),
|
|
48
75
|
smtp_host=smtp["host"],
|
|
@@ -50,4 +77,5 @@ def load_config(config_dir: Path | None = None) -> ConfigData:
|
|
|
50
77
|
smtp_username=smtp["username"],
|
|
51
78
|
smtp_password=smtp["password"],
|
|
52
79
|
smtp_encryption=smtp.get("encryption", "starttls"),
|
|
80
|
+
imap=imap,
|
|
53
81
|
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import email
|
|
2
|
+
import email.policy
|
|
3
|
+
import imaplib
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from emailcli.exceptions import ReceiveError
|
|
10
|
+
|
|
11
|
+
_UIDNEXT_RE = re.compile(rb"UIDNEXT (\d+)")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ImapReceiver:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
host: str,
|
|
18
|
+
port: int,
|
|
19
|
+
username: str,
|
|
20
|
+
password: str,
|
|
21
|
+
encryption: str,
|
|
22
|
+
):
|
|
23
|
+
self.host = host
|
|
24
|
+
self.port = port
|
|
25
|
+
self.username = username
|
|
26
|
+
self.password = password
|
|
27
|
+
self.encryption = encryption
|
|
28
|
+
|
|
29
|
+
def _connect(self) -> imaplib.IMAP4:
|
|
30
|
+
try:
|
|
31
|
+
if self.encryption == "ssl":
|
|
32
|
+
conn = imaplib.IMAP4_SSL(self.host, self.port)
|
|
33
|
+
else:
|
|
34
|
+
conn = imaplib.IMAP4(self.host, self.port)
|
|
35
|
+
if self.encryption == "starttls":
|
|
36
|
+
conn.starttls()
|
|
37
|
+
conn.login(self.username, self.password)
|
|
38
|
+
return conn
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise ReceiveError(f"Failed to connect to IMAP server: {e}") from e
|
|
41
|
+
|
|
42
|
+
def wait_for_message(
|
|
43
|
+
self,
|
|
44
|
+
mailbox: str = "INBOX",
|
|
45
|
+
timeout: float = 0,
|
|
46
|
+
poll_interval: float = 10.0,
|
|
47
|
+
) -> EmailMessage | None:
|
|
48
|
+
"""Block until a message arrives after this call starts.
|
|
49
|
+
|
|
50
|
+
Polls the mailbox every poll_interval seconds and returns the first
|
|
51
|
+
message whose UID is >= the UIDNEXT recorded at start, so existing
|
|
52
|
+
unread mail is ignored. Returns None if timeout (seconds, 0 = no
|
|
53
|
+
limit) expires first.
|
|
54
|
+
"""
|
|
55
|
+
conn = self._connect()
|
|
56
|
+
try:
|
|
57
|
+
typ, data = conn.status(mailbox, "(UIDNEXT)")
|
|
58
|
+
if typ != "OK":
|
|
59
|
+
raise ReceiveError(f"IMAP STATUS failed: {data}")
|
|
60
|
+
match = _UIDNEXT_RE.search(data[0])
|
|
61
|
+
if not match:
|
|
62
|
+
raise ReceiveError(f"Could not read UIDNEXT from: {data[0]!r}")
|
|
63
|
+
uidnext = int(match.group(1))
|
|
64
|
+
|
|
65
|
+
typ, data = conn.select(mailbox)
|
|
66
|
+
if typ != "OK":
|
|
67
|
+
raise ReceiveError(f"Cannot select mailbox {mailbox!r}: {data}")
|
|
68
|
+
|
|
69
|
+
deadline = time.monotonic() + timeout if timeout > 0 else None
|
|
70
|
+
while True:
|
|
71
|
+
# NOOP prompts the server to report newly arrived messages.
|
|
72
|
+
conn.noop()
|
|
73
|
+
typ, data = conn.uid("search", "UID", f"{uidnext}:*")
|
|
74
|
+
if typ != "OK":
|
|
75
|
+
raise ReceiveError(f"IMAP SEARCH failed: {data}")
|
|
76
|
+
uids = [int(u) for u in data[0].split()] if data and data[0] else []
|
|
77
|
+
# "N:*" also matches the last existing message when its UID
|
|
78
|
+
# is below N, so keep only genuinely new UIDs.
|
|
79
|
+
new = sorted(u for u in uids if u >= uidnext)
|
|
80
|
+
if new:
|
|
81
|
+
return self._fetch(conn, new[0])
|
|
82
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
83
|
+
return None
|
|
84
|
+
sleep_for = poll_interval
|
|
85
|
+
if deadline is not None:
|
|
86
|
+
sleep_for = min(sleep_for, max(0.0, deadline - time.monotonic()))
|
|
87
|
+
time.sleep(sleep_for)
|
|
88
|
+
except ReceiveError:
|
|
89
|
+
raise
|
|
90
|
+
except Exception as e:
|
|
91
|
+
raise ReceiveError(f"Failed while waiting for email: {e}") from e
|
|
92
|
+
finally:
|
|
93
|
+
try:
|
|
94
|
+
conn.logout()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def _fetch(self, conn: imaplib.IMAP4, uid: int) -> EmailMessage:
|
|
99
|
+
typ, msg_data = conn.uid("fetch", str(uid), "(RFC822)")
|
|
100
|
+
if typ != "OK":
|
|
101
|
+
raise ReceiveError(f"IMAP FETCH failed: {msg_data}")
|
|
102
|
+
raw = None
|
|
103
|
+
for item in msg_data:
|
|
104
|
+
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)):
|
|
105
|
+
raw = item[1]
|
|
106
|
+
break
|
|
107
|
+
if raw is None:
|
|
108
|
+
raise ReceiveError(f"Unexpected FETCH response for UID {uid}")
|
|
109
|
+
return email.message_from_bytes(raw, policy=email.policy.default)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def extract_body(msg: EmailMessage) -> tuple[str, str]:
|
|
113
|
+
"""Return (content, kind) where kind is "text", "html", or ""."""
|
|
114
|
+
part = msg.get_body(preferencelist=("plain", "html"))
|
|
115
|
+
if part is None:
|
|
116
|
+
return "", ""
|
|
117
|
+
kind = "html" if part.get_content_subtype() == "html" else "text"
|
|
118
|
+
return part.get_content(), kind
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def save_attachments(msg: EmailMessage, dest: Path) -> list[Path]:
|
|
122
|
+
"""Save all attachments into dest, returning the written paths.
|
|
123
|
+
|
|
124
|
+
Filenames are stripped to their basename and deduplicated with -1, -2...
|
|
125
|
+
suffixes so a crafted attachment name cannot escape dest or overwrite
|
|
126
|
+
an earlier file.
|
|
127
|
+
"""
|
|
128
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
saved: list[Path] = []
|
|
130
|
+
for i, part in enumerate(msg.iter_attachments()):
|
|
131
|
+
name = Path(part.get_filename() or f"attachment-{i + 1}").name
|
|
132
|
+
if not name or name in (".", ".."):
|
|
133
|
+
name = f"attachment-{i + 1}"
|
|
134
|
+
target = dest / name
|
|
135
|
+
stem, suffix = target.stem, target.suffix
|
|
136
|
+
n = 1
|
|
137
|
+
while target.exists():
|
|
138
|
+
target = dest / f"{stem}-{n}{suffix}"
|
|
139
|
+
n += 1
|
|
140
|
+
payload = part.get_payload(decode=True)
|
|
141
|
+
if payload is None:
|
|
142
|
+
content = part.get_content()
|
|
143
|
+
payload = content.encode() if isinstance(content, str) else bytes(content)
|
|
144
|
+
target.write_bytes(payload)
|
|
145
|
+
saved.append(target)
|
|
146
|
+
return saved
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
SKILL_NAMES = ["send-email", "wait-email"]
|
|
7
|
+
|
|
8
|
+
# target name -> home subdirectory that holds the agent's skills
|
|
9
|
+
TARGET_HOME_DIRS = {
|
|
10
|
+
"claude": ".claude",
|
|
11
|
+
"codex": ".codex",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# human-facing label per target
|
|
15
|
+
TARGET_LABELS = {
|
|
16
|
+
"claude": "Claude",
|
|
17
|
+
"codex": "Codex",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_skill_content(skill_name: str) -> str:
|
|
22
|
+
"""Read a bundled SKILL.md shipped as package data."""
|
|
23
|
+
return (
|
|
24
|
+
files("emailcli")
|
|
25
|
+
.joinpath(f"skills/{skill_name}/SKILL.md")
|
|
26
|
+
.read_text(encoding="utf-8")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def skill_dest(home: Path, target: str, skill_name: str) -> Path:
|
|
31
|
+
"""Destination SKILL.md path for a target under the given home dir."""
|
|
32
|
+
return home / TARGET_HOME_DIRS[target] / "skills" / skill_name / "SKILL.md"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class InstallResult:
|
|
37
|
+
target: str
|
|
38
|
+
skill: str
|
|
39
|
+
path: Path
|
|
40
|
+
status: Literal["created", "updated", "failed"]
|
|
41
|
+
error: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def install_skill(home: Path, targets: list[str]) -> list[InstallResult]:
|
|
45
|
+
"""Write the bundled skills into each target's user-level skills dir.
|
|
46
|
+
|
|
47
|
+
Each (target, skill) pair is handled independently; a write failure on
|
|
48
|
+
one does not stop the others.
|
|
49
|
+
"""
|
|
50
|
+
unknown = [t for t in targets if t not in TARGET_HOME_DIRS]
|
|
51
|
+
if unknown:
|
|
52
|
+
raise ValueError(f"Unknown target(s): {', '.join(unknown)}")
|
|
53
|
+
contents = {name: load_skill_content(name) for name in SKILL_NAMES}
|
|
54
|
+
results: list[InstallResult] = []
|
|
55
|
+
for target in targets:
|
|
56
|
+
for skill_name in SKILL_NAMES:
|
|
57
|
+
dest = skill_dest(home, target, skill_name)
|
|
58
|
+
try:
|
|
59
|
+
existed = dest.exists()
|
|
60
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
dest.write_text(contents[skill_name], encoding="utf-8")
|
|
62
|
+
results.append(
|
|
63
|
+
InstallResult(
|
|
64
|
+
target, skill_name, dest, "updated" if existed else "created"
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
results.append(
|
|
69
|
+
InstallResult(target, skill_name, dest, "failed", str(exc))
|
|
70
|
+
)
|
|
71
|
+
return results
|