email-cli-tool 0.2.1__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.1 → email_cli_tool-0.3.0}/.github/workflows/publish.yml +1 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/PKG-INFO +33 -2
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/README.md +31 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/README_CN.md +31 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/pyproject.toml +1 -1
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/src/emailcli/cli.py +93 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/src/emailcli/config.py +28 -0
- {email_cli_tool-0.2.1 → 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.1 → email_cli_tool-0.3.0}/tests/test_cli.py +145 -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.1/tests/test_config.py +0 -77
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/.github/workflows/test.yml +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/.gitignore +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/.python-version +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/LICENSE +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/docs/superpowers/plans/2026-03-25-emailcli.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/docs/superpowers/plans/2026-06-10-emailcli-skill-install.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/docs/superpowers/specs/2026-03-25-emailcli-design.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/docs/superpowers/specs/2026-06-10-emailcli-skill-install-design.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/src/emailcli/__init__.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/src/emailcli/message.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/src/emailcli/py.typed +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/src/emailcli/sender.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/src/emailcli/skill_install.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/src/emailcli/skills/send-email/SKILL.md +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/tests/__init__.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/tests/test_message.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/tests/test_sender.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/tests/test_skill_install.py +0 -0
- {email_cli_tool-0.2.1 → email_cli_tool-0.3.0}/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.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,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`.
|
|
@@ -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`.
|
|
@@ -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`。
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -179,7 +179,7 @@ def test_init_creates_config(runner, tmp_path):
|
|
|
179
179
|
result = runner.invoke(cli, [
|
|
180
180
|
"init",
|
|
181
181
|
"--config-dir", str(config_dir),
|
|
182
|
-
], 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")
|
|
183
183
|
assert result.exit_code == 0
|
|
184
184
|
assert (config_dir / "config.yaml").exists()
|
|
185
185
|
|
|
@@ -191,7 +191,7 @@ def test_init_sets_file_permissions(runner, tmp_path):
|
|
|
191
191
|
result = runner.invoke(cli, [
|
|
192
192
|
"init",
|
|
193
193
|
"--config-dir", str(config_dir),
|
|
194
|
-
], 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")
|
|
195
195
|
assert result.exit_code == 0
|
|
196
196
|
|
|
197
197
|
config_file = config_dir / "config.yaml"
|
|
@@ -224,3 +224,146 @@ def test_version_option(runner):
|
|
|
224
224
|
result = runner.invoke(cli, ["--version"])
|
|
225
225
|
assert result.exit_code == 0
|
|
226
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
|
|
File without changes
|
{email_cli_tool-0.2.1 → 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
|
|
File without changes
|