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.
Files changed (32) hide show
  1. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/.github/workflows/publish.yml +3 -2
  2. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/.github/workflows/test.yml +2 -2
  3. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/PKG-INFO +44 -4
  4. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/README.md +42 -2
  5. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/README_CN.md +42 -2
  6. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/pyproject.toml +1 -1
  7. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/cli.py +94 -0
  8. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/config.py +28 -0
  9. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/exceptions.py +4 -0
  10. email_cli_tool-0.3.0/src/emailcli/receiver.py +146 -0
  11. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/tests/test_cli.py +152 -2
  12. email_cli_tool-0.3.0/tests/test_config.py +145 -0
  13. email_cli_tool-0.3.0/tests/test_receiver.py +194 -0
  14. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/uv.lock +1 -1
  15. email_cli_tool-0.2.0/tests/test_config.py +0 -77
  16. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/.gitignore +0 -0
  17. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/.python-version +0 -0
  18. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/LICENSE +0 -0
  19. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/docs/superpowers/plans/2026-03-25-emailcli.md +0 -0
  20. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/docs/superpowers/plans/2026-06-10-emailcli-skill-install.md +0 -0
  21. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/docs/superpowers/specs/2026-03-25-emailcli-design.md +0 -0
  22. {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
  23. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/__init__.py +0 -0
  24. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/message.py +0 -0
  25. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/py.typed +0 -0
  26. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/sender.py +0 -0
  27. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/skill_install.py +0 -0
  28. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/src/emailcli/skills/send-email/SKILL.md +0 -0
  29. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/tests/__init__.py +0 -0
  30. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/tests/test_message.py +0 -0
  31. {email_cli_tool-0.2.0 → email_cli_tool-0.3.0}/tests/test_sender.py +0 -0
  32. {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@v4
15
+ - uses: actions/checkout@v6.0.3
15
16
 
16
- - uses: astral-sh/setup-uv@v5
17
+ - uses: astral-sh/setup-uv@v8.2.0
17
18
  with:
18
19
  version: "latest"
19
20
 
@@ -14,9 +14,9 @@ jobs:
14
14
  python-version: ["3.10", "3.11", "3.12", "3.13"]
15
15
 
16
16
  steps:
17
- - uses: actions/checkout@v4
17
+ - uses: actions/checkout@v6.0.3
18
18
 
19
- - uses: astral-sh/setup-uv@v5
19
+ - uses: astral-sh/setup-uv@v8.2.0
20
20
  with:
21
21
  version: "latest"
22
22
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: email-cli-tool
3
- Version: 0.2.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
  ## 开发
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "email-cli-tool"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "A simple CLI tool for sending emails with plain text, HTML, and file attachments"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
  )
@@ -12,3 +12,7 @@ class MessageError(EmailCliError):
12
12
 
13
13
  class SendError(EmailCliError):
14
14
  """Email sending errors."""
15
+
16
+
17
+ class ReceiveError(EmailCliError):
18
+ """Email receiving errors."""
@@ -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) == []
@@ -25,7 +25,7 @@ wheels = [
25
25
 
26
26
  [[package]]
27
27
  name = "email-cli-tool"
28
- version = "0.2.0"
28
+ version = "0.2.1"
29
29
  source = { editable = "." }
30
30
  dependencies = [
31
31
  { name = "click" },
@@ -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