email-cli-tool 0.2.1__tar.gz → 0.3.1__tar.gz

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