email-cli-tool 0.2.0__tar.gz → 0.3.0__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.0 → email_cli_tool-0.3.0}/.github/workflows/publish.yml +3 -2
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/.github/workflows/test.yml +2 -2
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/PKG-INFO +44 -4
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/README.md +42 -2
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/README_CN.md +42 -2
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/pyproject.toml +1 -1
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/cli.py +94 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/config.py +28 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/exceptions.py +4 -0
- email_cli_tool-0.3.0/src/emailcli/receiver.py +146 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/tests/test_cli.py +152 -2
- email_cli_tool-0.3.0/tests/test_config.py +145 -0
- email_cli_tool-0.3.0/tests/test_receiver.py +194 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/uv.lock +1 -1
- email_cli_tool-0.2.0/tests/test_config.py +0 -77
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/.gitignore +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/.python-version +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/LICENSE +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/docs/superpowers/plans/2026-03-25-emailcli.md +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/docs/superpowers/plans/2026-06-10-emailcli-skill-install.md +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/docs/superpowers/specs/2026-03-25-emailcli-design.md +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/docs/superpowers/specs/2026-06-10-emailcli-skill-install-design.md +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/__init__.py +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/message.py +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/py.typed +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/sender.py +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/skill_install.py +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/skills/send-email/SKILL.md +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/tests/__init__.py +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/tests/test_message.py +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/tests/test_sender.py +0 -0
- {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/tests/test_skill_install.py +0 -0
|
@@ -3,6 +3,7 @@ name: Publish to PyPI
|
|
|
3
3
|
on:
|
|
4
4
|
release:
|
|
5
5
|
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
6
7
|
|
|
7
8
|
jobs:
|
|
8
9
|
publish:
|
|
@@ -11,9 +12,9 @@ jobs:
|
|
|
11
12
|
id-token: write
|
|
12
13
|
|
|
13
14
|
steps:
|
|
14
|
-
- uses: actions/checkout@
|
|
15
|
+
- uses: actions/checkout@v6.0.3
|
|
15
16
|
|
|
16
|
-
- uses: astral-sh/setup-uv@
|
|
17
|
+
- uses: astral-sh/setup-uv@v8.2.0
|
|
17
18
|
with:
|
|
18
19
|
version: "latest"
|
|
19
20
|
|
|
@@ -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.0
|
|
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,17 +38,27 @@ 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
|
|
|
44
45
|
```bash
|
|
45
|
-
# From PyPI
|
|
46
|
+
# From PyPI (latest)
|
|
46
47
|
pip install email-cli-tool
|
|
47
48
|
|
|
49
|
+
# Install a specific version
|
|
50
|
+
pip install email-cli-tool==0.2.0
|
|
51
|
+
|
|
52
|
+
# Upgrade an existing install
|
|
53
|
+
pip install --upgrade email-cli-tool
|
|
54
|
+
|
|
48
55
|
# Or with uv
|
|
49
|
-
uv tool install email-cli-tool
|
|
56
|
+
uv tool install email-cli-tool # latest
|
|
57
|
+
uv tool install email-cli-tool==0.2.0 # specific version
|
|
50
58
|
```
|
|
51
59
|
|
|
60
|
+
> The `emailcli skill install` command requires version **0.2.0** or newer.
|
|
61
|
+
|
|
52
62
|
## Quick Start
|
|
53
63
|
|
|
54
64
|
### 1. Initialize Configuration
|
|
@@ -99,6 +109,18 @@ echo "Content" | emailcli send \
|
|
|
99
109
|
--to user@example.com --subject "Piped" --body -
|
|
100
110
|
```
|
|
101
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
|
+
|
|
102
124
|
## Command Reference
|
|
103
125
|
|
|
104
126
|
### `emailcli send`
|
|
@@ -115,6 +137,17 @@ echo "Content" | emailcli send \
|
|
|
115
137
|
|
|
116
138
|
> At least one of `--body`, `--html`, or `--html-file` is required.
|
|
117
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
|
+
|
|
118
151
|
### `emailcli init`
|
|
119
152
|
|
|
120
153
|
Interactively create the configuration file at `~/.emailcli/config.yaml`.
|
|
@@ -150,6 +183,13 @@ smtp:
|
|
|
150
183
|
username: yourname@gmail.com
|
|
151
184
|
password: your-app-password
|
|
152
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
|
|
153
193
|
```
|
|
154
194
|
|
|
155
195
|
## Development
|
|
@@ -12,17 +12,27 @@
|
|
|
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
|
|
|
18
19
|
```bash
|
|
19
|
-
# From PyPI
|
|
20
|
+
# From PyPI (latest)
|
|
20
21
|
pip install email-cli-tool
|
|
21
22
|
|
|
23
|
+
# Install a specific version
|
|
24
|
+
pip install email-cli-tool==0.2.0
|
|
25
|
+
|
|
26
|
+
# Upgrade an existing install
|
|
27
|
+
pip install --upgrade email-cli-tool
|
|
28
|
+
|
|
22
29
|
# Or with uv
|
|
23
|
-
uv tool install email-cli-tool
|
|
30
|
+
uv tool install email-cli-tool # latest
|
|
31
|
+
uv tool install email-cli-tool==0.2.0 # specific version
|
|
24
32
|
```
|
|
25
33
|
|
|
34
|
+
> The `emailcli skill install` command requires version **0.2.0** or newer.
|
|
35
|
+
|
|
26
36
|
## Quick Start
|
|
27
37
|
|
|
28
38
|
### 1. Initialize Configuration
|
|
@@ -73,6 +83,18 @@ echo "Content" | emailcli send \
|
|
|
73
83
|
--to user@example.com --subject "Piped" --body -
|
|
74
84
|
```
|
|
75
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
|
+
|
|
76
98
|
## Command Reference
|
|
77
99
|
|
|
78
100
|
### `emailcli send`
|
|
@@ -89,6 +111,17 @@ echo "Content" | emailcli send \
|
|
|
89
111
|
|
|
90
112
|
> At least one of `--body`, `--html`, or `--html-file` is required.
|
|
91
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
|
+
|
|
92
125
|
### `emailcli init`
|
|
93
126
|
|
|
94
127
|
Interactively create the configuration file at `~/.emailcli/config.yaml`.
|
|
@@ -124,6 +157,13 @@ smtp:
|
|
|
124
157
|
username: yourname@gmail.com
|
|
125
158
|
password: your-app-password
|
|
126
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
|
|
127
167
|
```
|
|
128
168
|
|
|
129
169
|
## Development
|
|
@@ -12,17 +12,27 @@
|
|
|
12
12
|
- SMTP 直连(支持 SSL / STARTTLS)
|
|
13
13
|
- 交互式配置向导
|
|
14
14
|
- 从 stdin 读取正文内容
|
|
15
|
+
- 等待下一封新邮件(IMAP)并下载附件
|
|
15
16
|
|
|
16
17
|
## 安装
|
|
17
18
|
|
|
18
19
|
```bash
|
|
19
|
-
# 从 PyPI
|
|
20
|
+
# 从 PyPI 安装(最新版)
|
|
20
21
|
pip install email-cli-tool
|
|
21
22
|
|
|
23
|
+
# 安装指定版本
|
|
24
|
+
pip install email-cli-tool==0.2.0
|
|
25
|
+
|
|
26
|
+
# 升级已安装的版本
|
|
27
|
+
pip install --upgrade email-cli-tool
|
|
28
|
+
|
|
22
29
|
# 或使用 uv
|
|
23
|
-
uv tool install email-cli-tool
|
|
30
|
+
uv tool install email-cli-tool # 最新版
|
|
31
|
+
uv tool install email-cli-tool==0.2.0 # 指定版本
|
|
24
32
|
```
|
|
25
33
|
|
|
34
|
+
> `emailcli skill install` 命令需要 **0.2.0** 或更高版本。
|
|
35
|
+
|
|
26
36
|
## 快速开始
|
|
27
37
|
|
|
28
38
|
### 1. 初始化配置
|
|
@@ -73,6 +83,18 @@ echo "邮件内容" | emailcli send \
|
|
|
73
83
|
--to user@example.com --subject "Piped" --body -
|
|
74
84
|
```
|
|
75
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
|
+
|
|
76
98
|
## 命令参考
|
|
77
99
|
|
|
78
100
|
### `emailcli send`
|
|
@@ -89,6 +111,17 @@ echo "邮件内容" | emailcli send \
|
|
|
89
111
|
|
|
90
112
|
> `--body` 和 `--html` / `--html-file` 至少提供一个。
|
|
91
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
|
+
|
|
92
125
|
### `emailcli init`
|
|
93
126
|
|
|
94
127
|
交互式创建配置文件 `~/.emailcli/config.yaml`。
|
|
@@ -124,6 +157,13 @@ smtp:
|
|
|
124
157
|
username: yourname@163.com
|
|
125
158
|
password: your-auth-code
|
|
126
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 的值
|
|
127
167
|
```
|
|
128
168
|
|
|
129
169
|
## 开发
|
|
@@ -6,11 +6,13 @@ 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
|
|
|
12
13
|
|
|
13
14
|
@click.group()
|
|
15
|
+
@click.version_option(package_name="email-cli-tool")
|
|
14
16
|
def cli():
|
|
15
17
|
"""CLI tool for sending emails with attachments."""
|
|
16
18
|
|
|
@@ -73,6 +75,71 @@ def send(to_addrs, subject, body, html_content, html_file_path, attachments, fro
|
|
|
73
75
|
raise SystemExit(1)
|
|
74
76
|
|
|
75
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
|
+
|
|
76
143
|
@cli.command()
|
|
77
144
|
@click.option("--config-dir", default=None, type=click.Path(), hidden=True, help="Config directory (for testing).")
|
|
78
145
|
def init(config_dir):
|
|
@@ -100,6 +167,25 @@ def init(config_dir):
|
|
|
100
167
|
"Encryption (starttls/ssl/none)", default="ssl"
|
|
101
168
|
)
|
|
102
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
|
+
|
|
103
189
|
config_data = {
|
|
104
190
|
"from": from_addr,
|
|
105
191
|
"smtp": {
|
|
@@ -110,6 +196,8 @@ def init(config_dir):
|
|
|
110
196
|
"encryption": smtp_encryption,
|
|
111
197
|
},
|
|
112
198
|
}
|
|
199
|
+
if imap_section:
|
|
200
|
+
config_data["imap"] = imap_section
|
|
113
201
|
|
|
114
202
|
cfg_dir.mkdir(parents=True, exist_ok=True)
|
|
115
203
|
with open(config_file, "w") as f:
|
|
@@ -138,6 +226,12 @@ def show(config_dir):
|
|
|
138
226
|
click.echo(f"Username: {cfg.smtp_username}")
|
|
139
227
|
click.echo(f"Password: ***")
|
|
140
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}")
|
|
141
235
|
except EmailCliError as e:
|
|
142
236
|
click.echo(f"Error: {e}", err=True)
|
|
143
237
|
raise SystemExit(1)
|
|
@@ -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
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from importlib.metadata import version as pkg_version
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from unittest.mock import MagicMock, patch
|
|
3
4
|
|
|
@@ -178,7 +179,7 @@ def test_init_creates_config(runner, tmp_path):
|
|
|
178
179
|
result = runner.invoke(cli, [
|
|
179
180
|
"init",
|
|
180
181
|
"--config-dir", str(config_dir),
|
|
181
|
-
], input="me@163.com\nsmtp.163.com\n465\nme@163.com\nmypassword\nssl\n")
|
|
182
|
+
], input="me@163.com\nsmtp.163.com\n465\nme@163.com\nmypassword\nssl\nn\n")
|
|
182
183
|
assert result.exit_code == 0
|
|
183
184
|
assert (config_dir / "config.yaml").exists()
|
|
184
185
|
|
|
@@ -190,7 +191,7 @@ def test_init_sets_file_permissions(runner, tmp_path):
|
|
|
190
191
|
result = runner.invoke(cli, [
|
|
191
192
|
"init",
|
|
192
193
|
"--config-dir", str(config_dir),
|
|
193
|
-
], input="me@163.com\nsmtp.163.com\n465\nme@163.com\nmypassword\nssl\n")
|
|
194
|
+
], input="me@163.com\nsmtp.163.com\n465\nme@163.com\nmypassword\nssl\nn\n")
|
|
194
195
|
assert result.exit_code == 0
|
|
195
196
|
|
|
196
197
|
config_file = config_dir / "config.yaml"
|
|
@@ -217,3 +218,152 @@ def test_config_show_no_config(runner, tmp_path):
|
|
|
217
218
|
"--config-dir", str(tmp_path / "nonexistent"),
|
|
218
219
|
])
|
|
219
220
|
assert result.exit_code != 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_version_option(runner):
|
|
224
|
+
result = runner.invoke(cli, ["--version"])
|
|
225
|
+
assert result.exit_code == 0
|
|
226
|
+
assert pkg_version("email-cli-tool") in result.output
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.fixture
|
|
230
|
+
def config_home_imap(tmp_path):
|
|
231
|
+
config_dir = tmp_path / ".emailcli"
|
|
232
|
+
config_dir.mkdir()
|
|
233
|
+
(config_dir / "config.yaml").write_text(yaml.dump({
|
|
234
|
+
"from": "me@example.com",
|
|
235
|
+
"smtp": {
|
|
236
|
+
"host": "smtp.example.com",
|
|
237
|
+
"port": 587,
|
|
238
|
+
"username": "me@example.com",
|
|
239
|
+
"password": "secret",
|
|
240
|
+
"encryption": "starttls",
|
|
241
|
+
},
|
|
242
|
+
"imap": {
|
|
243
|
+
"host": "imap.example.com",
|
|
244
|
+
},
|
|
245
|
+
}))
|
|
246
|
+
return config_dir
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _sample_incoming_message(attachments=None):
|
|
250
|
+
import email
|
|
251
|
+
import email.policy
|
|
252
|
+
from email.message import EmailMessage
|
|
253
|
+
|
|
254
|
+
msg = EmailMessage()
|
|
255
|
+
msg["From"] = "sender@example.com"
|
|
256
|
+
msg["To"] = "me@example.com"
|
|
257
|
+
msg["Subject"] = "Incoming"
|
|
258
|
+
msg["Date"] = "Mon, 31 Aug 2026 10:00:00 +0800"
|
|
259
|
+
msg.set_content("Incoming body")
|
|
260
|
+
for name, data in (attachments or []):
|
|
261
|
+
msg.add_attachment(
|
|
262
|
+
data, maintype="application", subtype="octet-stream", filename=name
|
|
263
|
+
)
|
|
264
|
+
return email.message_from_bytes(bytes(msg), policy=email.policy.default)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@patch("emailcli.cli.ImapReceiver")
|
|
268
|
+
def test_watch_receives_message(mock_receiver_cls, runner, config_home_imap):
|
|
269
|
+
mock_receiver = MagicMock()
|
|
270
|
+
mock_receiver.wait_for_message.return_value = _sample_incoming_message()
|
|
271
|
+
mock_receiver_cls.return_value = mock_receiver
|
|
272
|
+
|
|
273
|
+
result = runner.invoke(cli, ["watch", "--config-dir", str(config_home_imap)])
|
|
274
|
+
|
|
275
|
+
assert result.exit_code == 0
|
|
276
|
+
assert "Subject: Incoming" in result.output
|
|
277
|
+
assert "Incoming body" in result.output
|
|
278
|
+
# IMAP credentials fall back to SMTP ones
|
|
279
|
+
mock_receiver_cls.assert_called_once_with(
|
|
280
|
+
host="imap.example.com",
|
|
281
|
+
port=993,
|
|
282
|
+
username="me@example.com",
|
|
283
|
+
password="secret",
|
|
284
|
+
encryption="ssl",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@patch("emailcli.cli.ImapReceiver")
|
|
289
|
+
def test_watch_saves_attachments(mock_receiver_cls, runner, config_home_imap, tmp_path):
|
|
290
|
+
mock_receiver = MagicMock()
|
|
291
|
+
mock_receiver.wait_for_message.return_value = _sample_incoming_message(
|
|
292
|
+
attachments=[("report.pdf", b"%PDF-1.4")]
|
|
293
|
+
)
|
|
294
|
+
mock_receiver_cls.return_value = mock_receiver
|
|
295
|
+
|
|
296
|
+
dest = tmp_path / "downloads"
|
|
297
|
+
result = runner.invoke(cli, [
|
|
298
|
+
"watch",
|
|
299
|
+
"--config-dir", str(config_home_imap),
|
|
300
|
+
"--save-attachments", str(dest),
|
|
301
|
+
])
|
|
302
|
+
|
|
303
|
+
assert result.exit_code == 0
|
|
304
|
+
assert (dest / "report.pdf").read_bytes() == b"%PDF-1.4"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@patch("emailcli.cli.ImapReceiver")
|
|
308
|
+
def test_watch_timeout_exits_2(mock_receiver_cls, runner, config_home_imap):
|
|
309
|
+
mock_receiver = MagicMock()
|
|
310
|
+
mock_receiver.wait_for_message.return_value = None
|
|
311
|
+
mock_receiver_cls.return_value = mock_receiver
|
|
312
|
+
|
|
313
|
+
result = runner.invoke(cli, [
|
|
314
|
+
"watch", "--timeout", "1", "--config-dir", str(config_home_imap),
|
|
315
|
+
])
|
|
316
|
+
|
|
317
|
+
assert result.exit_code == 2
|
|
318
|
+
assert "Timed out" in result.output
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_watch_without_imap_config(runner, config_home):
|
|
322
|
+
result = runner.invoke(cli, ["watch", "--config-dir", str(config_home)])
|
|
323
|
+
|
|
324
|
+
assert result.exit_code == 1
|
|
325
|
+
assert "IMAP" in result.output
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@patch("emailcli.cli.ImapReceiver")
|
|
329
|
+
def test_watch_passes_options(mock_receiver_cls, runner, config_home_imap):
|
|
330
|
+
mock_receiver = MagicMock()
|
|
331
|
+
mock_receiver.wait_for_message.return_value = _sample_incoming_message()
|
|
332
|
+
mock_receiver_cls.return_value = mock_receiver
|
|
333
|
+
|
|
334
|
+
result = runner.invoke(cli, [
|
|
335
|
+
"watch",
|
|
336
|
+
"--timeout", "300",
|
|
337
|
+
"--poll-interval", "5",
|
|
338
|
+
"--mailbox", "Work",
|
|
339
|
+
"--config-dir", str(config_home_imap),
|
|
340
|
+
])
|
|
341
|
+
|
|
342
|
+
assert result.exit_code == 0
|
|
343
|
+
mock_receiver.wait_for_message.assert_called_once_with(
|
|
344
|
+
mailbox="Work", timeout=300.0, poll_interval=5.0
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_init_with_imap(runner, tmp_path):
|
|
349
|
+
config_dir = tmp_path / ".emailcli"
|
|
350
|
+
result = runner.invoke(cli, [
|
|
351
|
+
"init",
|
|
352
|
+
"--config-dir", str(config_dir),
|
|
353
|
+
], input=(
|
|
354
|
+
"me@163.com\nsmtp.163.com\n465\nme@163.com\nmypassword\nssl\n" # smtp
|
|
355
|
+
"y\nimap.163.com\n993\nme@163.com\n\nssl\n" # imap, empty password = reuse
|
|
356
|
+
))
|
|
357
|
+
assert result.exit_code == 0
|
|
358
|
+
|
|
359
|
+
data = yaml.safe_load((config_dir / "config.yaml").read_text())
|
|
360
|
+
assert data["imap"]["host"] == "imap.163.com"
|
|
361
|
+
assert data["imap"]["port"] == 993
|
|
362
|
+
assert "password" not in data["imap"] # falls back to smtp password
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_config_show_with_imap(runner, config_home_imap):
|
|
366
|
+
result = runner.invoke(cli, ["config", "show", "--config-dir", str(config_home_imap)])
|
|
367
|
+
assert result.exit_code == 0
|
|
368
|
+
assert "imap.example.com" in result.output
|
|
369
|
+
assert "secret" not in result.output
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from emailcli.config import load_config, ConfigData
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def config_dir(tmp_path):
|
|
12
|
+
return tmp_path / ".emailcli"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def valid_config(config_dir):
|
|
17
|
+
config_dir.mkdir()
|
|
18
|
+
config_file = config_dir / "config.yaml"
|
|
19
|
+
config_file.write_text(yaml.dump({
|
|
20
|
+
"from": "me@example.com",
|
|
21
|
+
"smtp": {
|
|
22
|
+
"host": "smtp.example.com",
|
|
23
|
+
"port": 587,
|
|
24
|
+
"username": "me@example.com",
|
|
25
|
+
"password": "secret",
|
|
26
|
+
"encryption": "starttls",
|
|
27
|
+
},
|
|
28
|
+
}))
|
|
29
|
+
return config_file
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_load_valid_config(valid_config, config_dir):
|
|
33
|
+
config = load_config(config_dir)
|
|
34
|
+
assert config.from_addr == "me@example.com"
|
|
35
|
+
assert config.smtp_host == "smtp.example.com"
|
|
36
|
+
assert config.smtp_port == 587
|
|
37
|
+
assert config.smtp_username == "me@example.com"
|
|
38
|
+
assert config.smtp_password == "secret"
|
|
39
|
+
assert config.smtp_encryption == "starttls"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_load_config_file_not_found(config_dir):
|
|
43
|
+
from emailcli.exceptions import ConfigError
|
|
44
|
+
|
|
45
|
+
with pytest.raises(ConfigError, match="not found"):
|
|
46
|
+
load_config(config_dir)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_load_config_missing_smtp_host(config_dir):
|
|
50
|
+
from emailcli.exceptions import ConfigError
|
|
51
|
+
|
|
52
|
+
config_dir.mkdir()
|
|
53
|
+
config_file = config_dir / "config.yaml"
|
|
54
|
+
config_file.write_text(yaml.dump({
|
|
55
|
+
"from": "me@example.com",
|
|
56
|
+
"smtp": {"port": 587},
|
|
57
|
+
}))
|
|
58
|
+
with pytest.raises(ConfigError, match="host"):
|
|
59
|
+
load_config(config_dir)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_load_config_ssl_encryption(config_dir):
|
|
63
|
+
config_dir.mkdir()
|
|
64
|
+
config_file = config_dir / "config.yaml"
|
|
65
|
+
config_file.write_text(yaml.dump({
|
|
66
|
+
"from": "me@example.com",
|
|
67
|
+
"smtp": {
|
|
68
|
+
"host": "smtp.163.com",
|
|
69
|
+
"port": 465,
|
|
70
|
+
"username": "me@163.com",
|
|
71
|
+
"password": "authcode",
|
|
72
|
+
"encryption": "ssl",
|
|
73
|
+
},
|
|
74
|
+
}))
|
|
75
|
+
config = load_config(config_dir)
|
|
76
|
+
assert config.smtp_encryption == "ssl"
|
|
77
|
+
assert config.smtp_port == 465
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_load_config_without_imap(valid_config, config_dir):
|
|
81
|
+
config = load_config(config_dir)
|
|
82
|
+
assert config.imap is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_load_config_with_imap(config_dir):
|
|
86
|
+
config_dir.mkdir()
|
|
87
|
+
(config_dir / "config.yaml").write_text(yaml.dump({
|
|
88
|
+
"from": "me@example.com",
|
|
89
|
+
"smtp": {
|
|
90
|
+
"host": "smtp.example.com",
|
|
91
|
+
"username": "me@example.com",
|
|
92
|
+
"password": "secret",
|
|
93
|
+
},
|
|
94
|
+
"imap": {
|
|
95
|
+
"host": "imap.example.com",
|
|
96
|
+
"port": 143,
|
|
97
|
+
"username": "other@example.com",
|
|
98
|
+
"password": "imap-secret",
|
|
99
|
+
"encryption": "starttls",
|
|
100
|
+
},
|
|
101
|
+
}))
|
|
102
|
+
config = load_config(config_dir)
|
|
103
|
+
assert config.imap is not None
|
|
104
|
+
assert config.imap.host == "imap.example.com"
|
|
105
|
+
assert config.imap.port == 143
|
|
106
|
+
assert config.imap.username == "other@example.com"
|
|
107
|
+
assert config.imap.password == "imap-secret"
|
|
108
|
+
assert config.imap.encryption == "starttls"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_load_config_imap_defaults_from_smtp(config_dir):
|
|
112
|
+
config_dir.mkdir()
|
|
113
|
+
(config_dir / "config.yaml").write_text(yaml.dump({
|
|
114
|
+
"from": "me@example.com",
|
|
115
|
+
"smtp": {
|
|
116
|
+
"host": "smtp.example.com",
|
|
117
|
+
"username": "me@example.com",
|
|
118
|
+
"password": "secret",
|
|
119
|
+
},
|
|
120
|
+
"imap": {
|
|
121
|
+
"host": "imap.example.com",
|
|
122
|
+
},
|
|
123
|
+
}))
|
|
124
|
+
config = load_config(config_dir)
|
|
125
|
+
assert config.imap.port == 993
|
|
126
|
+
assert config.imap.encryption == "ssl"
|
|
127
|
+
assert config.imap.username == "me@example.com"
|
|
128
|
+
assert config.imap.password == "secret"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_load_config_imap_missing_host(config_dir):
|
|
132
|
+
from emailcli.exceptions import ConfigError
|
|
133
|
+
|
|
134
|
+
config_dir.mkdir()
|
|
135
|
+
(config_dir / "config.yaml").write_text(yaml.dump({
|
|
136
|
+
"from": "me@example.com",
|
|
137
|
+
"smtp": {
|
|
138
|
+
"host": "smtp.example.com",
|
|
139
|
+
"username": "me@example.com",
|
|
140
|
+
"password": "secret",
|
|
141
|
+
},
|
|
142
|
+
"imap": {"port": 993},
|
|
143
|
+
}))
|
|
144
|
+
with pytest.raises(ConfigError, match="imap.*host"):
|
|
145
|
+
load_config(config_dir)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from email.message import EmailMessage
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from emailcli.exceptions import ReceiveError
|
|
8
|
+
from emailcli.receiver import ImapReceiver, extract_body, save_attachments
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_raw_message(body="Hello", html=None, attachments=None):
|
|
12
|
+
msg = EmailMessage()
|
|
13
|
+
msg["From"] = "sender@example.com"
|
|
14
|
+
msg["To"] = "me@example.com"
|
|
15
|
+
msg["Subject"] = "Test mail"
|
|
16
|
+
if body:
|
|
17
|
+
msg.set_content(body)
|
|
18
|
+
if html:
|
|
19
|
+
msg.add_alternative(html, subtype="html")
|
|
20
|
+
for name, data in (attachments or []):
|
|
21
|
+
msg.add_attachment(
|
|
22
|
+
data, maintype="application", subtype="octet-stream", filename=name
|
|
23
|
+
)
|
|
24
|
+
return bytes(msg)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_conn(search_results, raw=None, uidnext=100):
|
|
28
|
+
"""Mock IMAP connection. search_results: list of SEARCH data bytes per poll."""
|
|
29
|
+
conn = MagicMock()
|
|
30
|
+
conn.status.return_value = ("OK", [b"INBOX (UIDNEXT %d)" % uidnext])
|
|
31
|
+
conn.select.return_value = ("OK", [b"3"])
|
|
32
|
+
conn.noop.return_value = ("OK", [b""])
|
|
33
|
+
results = iter(search_results)
|
|
34
|
+
|
|
35
|
+
def uid(cmd, *args):
|
|
36
|
+
if cmd == "search":
|
|
37
|
+
return ("OK", [next(results)])
|
|
38
|
+
if cmd == "fetch":
|
|
39
|
+
return ("OK", [(b"1 (RFC822 {123}", raw), b")"])
|
|
40
|
+
raise AssertionError(f"unexpected uid command: {cmd}")
|
|
41
|
+
|
|
42
|
+
conn.uid.side_effect = uid
|
|
43
|
+
return conn
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def make_receiver():
|
|
47
|
+
return ImapReceiver(
|
|
48
|
+
host="imap.example.com",
|
|
49
|
+
port=993,
|
|
50
|
+
username="me@example.com",
|
|
51
|
+
password="secret",
|
|
52
|
+
encryption="ssl",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@patch("emailcli.receiver.imaplib.IMAP4_SSL")
|
|
57
|
+
def test_wait_returns_new_message(mock_imap_cls):
|
|
58
|
+
raw = make_raw_message(body="Hello there")
|
|
59
|
+
conn = make_conn([b"100"], raw=raw)
|
|
60
|
+
mock_imap_cls.return_value = conn
|
|
61
|
+
|
|
62
|
+
msg = make_receiver().wait_for_message(timeout=5, poll_interval=0.01)
|
|
63
|
+
|
|
64
|
+
assert msg is not None
|
|
65
|
+
assert msg["Subject"] == "Test mail"
|
|
66
|
+
conn.login.assert_called_once_with("me@example.com", "secret")
|
|
67
|
+
conn.uid.assert_any_call("search", "UID", "100:*")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@patch("emailcli.receiver.imaplib.IMAP4_SSL")
|
|
71
|
+
def test_wait_ignores_existing_mail(mock_imap_cls):
|
|
72
|
+
# "100:*" matches the last existing message (UID 99) even though it is
|
|
73
|
+
# older than UIDNEXT; it must be ignored, then UID 100 arrives.
|
|
74
|
+
raw = make_raw_message()
|
|
75
|
+
conn = make_conn([b"99", b"99 100"], raw=raw)
|
|
76
|
+
mock_imap_cls.return_value = conn
|
|
77
|
+
|
|
78
|
+
msg = make_receiver().wait_for_message(timeout=5, poll_interval=0.01)
|
|
79
|
+
|
|
80
|
+
assert msg is not None
|
|
81
|
+
# Fetched the new message, not the old one
|
|
82
|
+
conn.uid.assert_any_call("fetch", "100", "(RFC822)")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@patch("emailcli.receiver.imaplib.IMAP4_SSL")
|
|
86
|
+
def test_wait_timeout_returns_none(mock_imap_cls):
|
|
87
|
+
conn = make_conn([b"", b"", b"", b"", b""])
|
|
88
|
+
mock_imap_cls.return_value = conn
|
|
89
|
+
|
|
90
|
+
msg = make_receiver().wait_for_message(timeout=0.03, poll_interval=0.01)
|
|
91
|
+
|
|
92
|
+
assert msg is None
|
|
93
|
+
conn.logout.assert_called_once()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@patch("emailcli.receiver.imaplib.IMAP4_SSL")
|
|
97
|
+
def test_connect_failure_raises_receive_error(mock_imap_cls):
|
|
98
|
+
mock_imap_cls.side_effect = OSError("connection refused")
|
|
99
|
+
|
|
100
|
+
with pytest.raises(ReceiveError, match="connect"):
|
|
101
|
+
make_receiver().wait_for_message(timeout=1, poll_interval=0.01)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@patch("emailcli.receiver.imaplib.IMAP4_SSL")
|
|
105
|
+
def test_bad_status_raises(mock_imap_cls):
|
|
106
|
+
conn = MagicMock()
|
|
107
|
+
conn.status.return_value = ("NO", [b"error"])
|
|
108
|
+
mock_imap_cls.return_value = conn
|
|
109
|
+
|
|
110
|
+
with pytest.raises(ReceiveError, match="STATUS"):
|
|
111
|
+
make_receiver().wait_for_message(timeout=1, poll_interval=0.01)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@patch("emailcli.receiver.imaplib.IMAP4")
|
|
115
|
+
def test_starttls_connection(mock_imap_cls):
|
|
116
|
+
raw = make_raw_message()
|
|
117
|
+
conn = make_conn([b"100"], raw=raw)
|
|
118
|
+
mock_imap_cls.return_value = conn
|
|
119
|
+
|
|
120
|
+
receiver = ImapReceiver(
|
|
121
|
+
host="imap.example.com",
|
|
122
|
+
port=143,
|
|
123
|
+
username="me@example.com",
|
|
124
|
+
password="secret",
|
|
125
|
+
encryption="starttls",
|
|
126
|
+
)
|
|
127
|
+
msg = receiver.wait_for_message(timeout=5, poll_interval=0.01)
|
|
128
|
+
|
|
129
|
+
assert msg is not None
|
|
130
|
+
conn.starttls.assert_called_once()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse(raw):
|
|
134
|
+
import email
|
|
135
|
+
import email.policy
|
|
136
|
+
|
|
137
|
+
return email.message_from_bytes(raw, policy=email.policy.default)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_extract_body_plain():
|
|
141
|
+
msg = parse(make_raw_message(body="Plain text"))
|
|
142
|
+
body, kind = extract_body(msg)
|
|
143
|
+
assert body.strip() == "Plain text"
|
|
144
|
+
assert kind == "text"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_extract_body_prefers_plain_over_html():
|
|
148
|
+
msg = parse(make_raw_message(body="Plain", html="<p>HTML</p>"))
|
|
149
|
+
body, kind = extract_body(msg)
|
|
150
|
+
assert body.strip() == "Plain"
|
|
151
|
+
assert kind == "text"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_extract_body_html_only():
|
|
155
|
+
msg = EmailMessage()
|
|
156
|
+
msg["Subject"] = "html"
|
|
157
|
+
msg.set_content("<p>HTML only</p>", subtype="html")
|
|
158
|
+
body, kind = extract_body(parse(bytes(msg)))
|
|
159
|
+
assert "HTML only" in body
|
|
160
|
+
assert kind == "html"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_save_attachments(tmp_path):
|
|
164
|
+
raw = make_raw_message(attachments=[("report.pdf", b"%PDF-1.4")])
|
|
165
|
+
saved = save_attachments(parse(raw), tmp_path / "downloads")
|
|
166
|
+
|
|
167
|
+
assert len(saved) == 1
|
|
168
|
+
assert saved[0].name == "report.pdf"
|
|
169
|
+
assert saved[0].read_bytes() == b"%PDF-1.4"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_save_attachments_sanitizes_path(tmp_path):
|
|
173
|
+
raw = make_raw_message(attachments=[("../../evil.sh", b"#!/bin/sh")])
|
|
174
|
+
dest = tmp_path / "downloads"
|
|
175
|
+
saved = save_attachments(parse(raw), dest)
|
|
176
|
+
|
|
177
|
+
assert saved[0].parent == dest
|
|
178
|
+
assert saved[0].name == "evil.sh"
|
|
179
|
+
assert not (tmp_path / "evil.sh").exists()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_save_attachments_deduplicates(tmp_path):
|
|
183
|
+
raw = make_raw_message(
|
|
184
|
+
attachments=[("a.txt", b"one"), ("a.txt", b"two")]
|
|
185
|
+
)
|
|
186
|
+
saved = save_attachments(parse(raw), tmp_path)
|
|
187
|
+
|
|
188
|
+
assert [p.name for p in saved] == ["a.txt", "a-1.txt"]
|
|
189
|
+
assert (tmp_path / "a-1.txt").read_bytes() == b"two"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_save_attachments_none(tmp_path):
|
|
193
|
+
raw = make_raw_message(body="no attachments")
|
|
194
|
+
assert save_attachments(parse(raw), tmp_path) == []
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
import yaml
|
|
6
|
-
|
|
7
|
-
from emailcli.config import load_config, ConfigData
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@pytest.fixture
|
|
11
|
-
def config_dir(tmp_path):
|
|
12
|
-
return tmp_path / ".emailcli"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@pytest.fixture
|
|
16
|
-
def valid_config(config_dir):
|
|
17
|
-
config_dir.mkdir()
|
|
18
|
-
config_file = config_dir / "config.yaml"
|
|
19
|
-
config_file.write_text(yaml.dump({
|
|
20
|
-
"from": "me@example.com",
|
|
21
|
-
"smtp": {
|
|
22
|
-
"host": "smtp.example.com",
|
|
23
|
-
"port": 587,
|
|
24
|
-
"username": "me@example.com",
|
|
25
|
-
"password": "secret",
|
|
26
|
-
"encryption": "starttls",
|
|
27
|
-
},
|
|
28
|
-
}))
|
|
29
|
-
return config_file
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_load_valid_config(valid_config, config_dir):
|
|
33
|
-
config = load_config(config_dir)
|
|
34
|
-
assert config.from_addr == "me@example.com"
|
|
35
|
-
assert config.smtp_host == "smtp.example.com"
|
|
36
|
-
assert config.smtp_port == 587
|
|
37
|
-
assert config.smtp_username == "me@example.com"
|
|
38
|
-
assert config.smtp_password == "secret"
|
|
39
|
-
assert config.smtp_encryption == "starttls"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_load_config_file_not_found(config_dir):
|
|
43
|
-
from emailcli.exceptions import ConfigError
|
|
44
|
-
|
|
45
|
-
with pytest.raises(ConfigError, match="not found"):
|
|
46
|
-
load_config(config_dir)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_load_config_missing_smtp_host(config_dir):
|
|
50
|
-
from emailcli.exceptions import ConfigError
|
|
51
|
-
|
|
52
|
-
config_dir.mkdir()
|
|
53
|
-
config_file = config_dir / "config.yaml"
|
|
54
|
-
config_file.write_text(yaml.dump({
|
|
55
|
-
"from": "me@example.com",
|
|
56
|
-
"smtp": {"port": 587},
|
|
57
|
-
}))
|
|
58
|
-
with pytest.raises(ConfigError, match="host"):
|
|
59
|
-
load_config(config_dir)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_load_config_ssl_encryption(config_dir):
|
|
63
|
-
config_dir.mkdir()
|
|
64
|
-
config_file = config_dir / "config.yaml"
|
|
65
|
-
config_file.write_text(yaml.dump({
|
|
66
|
-
"from": "me@example.com",
|
|
67
|
-
"smtp": {
|
|
68
|
-
"host": "smtp.163.com",
|
|
69
|
-
"port": 465,
|
|
70
|
-
"username": "me@163.com",
|
|
71
|
-
"password": "authcode",
|
|
72
|
-
"encryption": "ssl",
|
|
73
|
-
},
|
|
74
|
-
}))
|
|
75
|
-
config = load_config(config_dir)
|
|
76
|
-
assert config.smtp_encryption == "ssl"
|
|
77
|
-
assert config.smtp_port == 465
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/docs/superpowers/specs/2026-03-25-emailcli-design.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|