hotmail-cli 0.1.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.
- hotmail_cli-0.1.0/LICENSE +22 -0
- hotmail_cli-0.1.0/MANIFEST.in +2 -0
- hotmail_cli-0.1.0/PKG-INFO +153 -0
- hotmail_cli-0.1.0/README.md +125 -0
- hotmail_cli-0.1.0/README.zh-CN.md +125 -0
- hotmail_cli-0.1.0/pyproject.toml +51 -0
- hotmail_cli-0.1.0/setup.cfg +4 -0
- hotmail_cli-0.1.0/src/hotmail_cli/__init__.py +2 -0
- hotmail_cli-0.1.0/src/hotmail_cli/auth.py +92 -0
- hotmail_cli-0.1.0/src/hotmail_cli/cli.py +83 -0
- hotmail_cli-0.1.0/src/hotmail_cli/graph.py +139 -0
- hotmail_cli-0.1.0/src/hotmail_cli.egg-info/PKG-INFO +153 -0
- hotmail_cli-0.1.0/src/hotmail_cli.egg-info/SOURCES.txt +18 -0
- hotmail_cli-0.1.0/src/hotmail_cli.egg-info/dependency_links.txt +1 -0
- hotmail_cli-0.1.0/src/hotmail_cli.egg-info/entry_points.txt +2 -0
- hotmail_cli-0.1.0/src/hotmail_cli.egg-info/requires.txt +2 -0
- hotmail_cli-0.1.0/src/hotmail_cli.egg-info/top_level.txt +1 -0
- hotmail_cli-0.1.0/tests/test_cli.py +30 -0
- hotmail_cli-0.1.0/tests/test_graph.py +86 -0
- hotmail_cli-0.1.0/tests/test_token_cache.py +23 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kadaliao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hotmail-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for fetching Hotmail/Outlook messages and attachments via Microsoft Graph.
|
|
5
|
+
Author: kadaliao
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kadaliao/hotmail-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/kadaliao/hotmail-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/kadaliao/hotmail-cli/issues
|
|
10
|
+
Keywords: hotmail,outlook,microsoft-graph,email,attachments,cli
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Communications :: Email
|
|
20
|
+
Classifier: Topic :: Office/Business
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: msal>=1.30.0
|
|
26
|
+
Requires-Dist: requests>=2.32.0
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# Hotmail CLI
|
|
30
|
+
|
|
31
|
+
A small read-only CLI for Hotmail and Outlook.com mailboxes. It uses Microsoft Graph to search messages and download file attachments from your mailbox.
|
|
32
|
+
|
|
33
|
+
中文文档: [README.zh-CN.md](README.zh-CN.md)
|
|
34
|
+
|
|
35
|
+
## Why Use This
|
|
36
|
+
|
|
37
|
+
- Works with personal Microsoft accounts such as Hotmail and Outlook.com.
|
|
38
|
+
- Uses Microsoft device code login, so the CLI never sees your password.
|
|
39
|
+
- Requests only `Mail.Read`.
|
|
40
|
+
- Downloads attachments from matching messages.
|
|
41
|
+
- Stores the OAuth token locally with `0600` file permissions.
|
|
42
|
+
|
|
43
|
+
This tool is intentionally narrow. It does not send email, delete messages, mark messages, or manage calendars.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uvx hotmail-cli --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or install it into an environment:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv tool install hotmail-cli
|
|
55
|
+
hotmail --help
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Microsoft App Setup
|
|
59
|
+
|
|
60
|
+
You need your own Microsoft Entra app registration. This is free and lets Microsoft show you exactly what the CLI is allowed to access.
|
|
61
|
+
|
|
62
|
+
1. Open the [Microsoft Entra admin center](https://entra.microsoft.com/).
|
|
63
|
+
2. Go to **App registrations** -> **New registration**.
|
|
64
|
+
3. Name it `hotmail-cli` or any name you prefer.
|
|
65
|
+
4. For **Supported account types**, choose **Personal Microsoft accounts only** for Hotmail/Outlook.com.
|
|
66
|
+
5. Leave **Redirect URI** empty.
|
|
67
|
+
6. Create the app.
|
|
68
|
+
7. Open **Authentication** -> **Settings**.
|
|
69
|
+
8. Enable **Allow public client flows** and save.
|
|
70
|
+
9. Copy the **Application (client) ID**.
|
|
71
|
+
|
|
72
|
+
## Sign In
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export HOTMAIL_CLIENT_ID="your Microsoft app client id"
|
|
76
|
+
hotmail auth
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The command prints a URL and code. Open the URL in your browser, enter the code, sign in to Microsoft, and approve the requested `Mail.Read` access.
|
|
80
|
+
|
|
81
|
+
The token cache is saved to:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
~/.hotmail-cli/token.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
You can also pass the client id directly:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
hotmail --client-id "your Microsoft app client id" auth
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Search Messages
|
|
94
|
+
|
|
95
|
+
Search by subject:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
hotmail search --subject "statement" --top 10
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Search by subject, sender, and date range:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
hotmail search \
|
|
105
|
+
--subject "invoice" \
|
|
106
|
+
--sender "billing@example.com" \
|
|
107
|
+
--since 2026-06-01 \
|
|
108
|
+
--until 2026-06-27 \
|
|
109
|
+
--top 10
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The output is Microsoft Graph message JSON. Each message includes an `id` that can be used with `fetch` and `attachments`.
|
|
113
|
+
|
|
114
|
+
Microsoft Graph message `$search` cannot be reliably combined with `$filter` or `$orderby`, so Hotmail CLI searches by subject server-side first, then applies sender and date filters locally.
|
|
115
|
+
|
|
116
|
+
## Fetch One Message
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
hotmail fetch MESSAGE_ID
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Download Attachments
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
hotmail attachments MESSAGE_ID --output-dir downloads
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Only Microsoft Graph `fileAttachment` items are saved. Inline items and reference attachments are ignored.
|
|
129
|
+
|
|
130
|
+
## Local Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv sync
|
|
134
|
+
uv run pytest
|
|
135
|
+
uv run hotmail --help
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Build the package:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
uv build
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Security Notes
|
|
145
|
+
|
|
146
|
+
- Do not commit `~/.hotmail-cli/token.json`.
|
|
147
|
+
- Do not share message IDs or downloaded attachments publicly.
|
|
148
|
+
- Revoke access anytime from your Microsoft account security page or from the app registration.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
|
153
|
+
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Hotmail CLI
|
|
2
|
+
|
|
3
|
+
A small read-only CLI for Hotmail and Outlook.com mailboxes. It uses Microsoft Graph to search messages and download file attachments from your mailbox.
|
|
4
|
+
|
|
5
|
+
中文文档: [README.zh-CN.md](README.zh-CN.md)
|
|
6
|
+
|
|
7
|
+
## Why Use This
|
|
8
|
+
|
|
9
|
+
- Works with personal Microsoft accounts such as Hotmail and Outlook.com.
|
|
10
|
+
- Uses Microsoft device code login, so the CLI never sees your password.
|
|
11
|
+
- Requests only `Mail.Read`.
|
|
12
|
+
- Downloads attachments from matching messages.
|
|
13
|
+
- Stores the OAuth token locally with `0600` file permissions.
|
|
14
|
+
|
|
15
|
+
This tool is intentionally narrow. It does not send email, delete messages, mark messages, or manage calendars.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uvx hotmail-cli --help
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or install it into an environment:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv tool install hotmail-cli
|
|
27
|
+
hotmail --help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Microsoft App Setup
|
|
31
|
+
|
|
32
|
+
You need your own Microsoft Entra app registration. This is free and lets Microsoft show you exactly what the CLI is allowed to access.
|
|
33
|
+
|
|
34
|
+
1. Open the [Microsoft Entra admin center](https://entra.microsoft.com/).
|
|
35
|
+
2. Go to **App registrations** -> **New registration**.
|
|
36
|
+
3. Name it `hotmail-cli` or any name you prefer.
|
|
37
|
+
4. For **Supported account types**, choose **Personal Microsoft accounts only** for Hotmail/Outlook.com.
|
|
38
|
+
5. Leave **Redirect URI** empty.
|
|
39
|
+
6. Create the app.
|
|
40
|
+
7. Open **Authentication** -> **Settings**.
|
|
41
|
+
8. Enable **Allow public client flows** and save.
|
|
42
|
+
9. Copy the **Application (client) ID**.
|
|
43
|
+
|
|
44
|
+
## Sign In
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export HOTMAIL_CLIENT_ID="your Microsoft app client id"
|
|
48
|
+
hotmail auth
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The command prints a URL and code. Open the URL in your browser, enter the code, sign in to Microsoft, and approve the requested `Mail.Read` access.
|
|
52
|
+
|
|
53
|
+
The token cache is saved to:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
~/.hotmail-cli/token.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
You can also pass the client id directly:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
hotmail --client-id "your Microsoft app client id" auth
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Search Messages
|
|
66
|
+
|
|
67
|
+
Search by subject:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
hotmail search --subject "statement" --top 10
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Search by subject, sender, and date range:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
hotmail search \
|
|
77
|
+
--subject "invoice" \
|
|
78
|
+
--sender "billing@example.com" \
|
|
79
|
+
--since 2026-06-01 \
|
|
80
|
+
--until 2026-06-27 \
|
|
81
|
+
--top 10
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The output is Microsoft Graph message JSON. Each message includes an `id` that can be used with `fetch` and `attachments`.
|
|
85
|
+
|
|
86
|
+
Microsoft Graph message `$search` cannot be reliably combined with `$filter` or `$orderby`, so Hotmail CLI searches by subject server-side first, then applies sender and date filters locally.
|
|
87
|
+
|
|
88
|
+
## Fetch One Message
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
hotmail fetch MESSAGE_ID
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Download Attachments
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
hotmail attachments MESSAGE_ID --output-dir downloads
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Only Microsoft Graph `fileAttachment` items are saved. Inline items and reference attachments are ignored.
|
|
101
|
+
|
|
102
|
+
## Local Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uv sync
|
|
106
|
+
uv run pytest
|
|
107
|
+
uv run hotmail --help
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Build the package:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv build
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Security Notes
|
|
117
|
+
|
|
118
|
+
- Do not commit `~/.hotmail-cli/token.json`.
|
|
119
|
+
- Do not share message IDs or downloaded attachments publicly.
|
|
120
|
+
- Revoke access anytime from your Microsoft account security page or from the app registration.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
125
|
+
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Hotmail CLI
|
|
2
|
+
|
|
3
|
+
一个小型只读命令行工具,用于读取 Hotmail 和 Outlook.com 邮箱。它通过 Microsoft Graph 搜索邮件,并下载邮件里的文件附件。
|
|
4
|
+
|
|
5
|
+
English documentation: [README.md](README.md)
|
|
6
|
+
|
|
7
|
+
## 为什么用它
|
|
8
|
+
|
|
9
|
+
- 支持 Hotmail、Outlook.com 等个人 Microsoft 账号。
|
|
10
|
+
- 使用 Microsoft device code 登录,CLI 不会接触你的密码。
|
|
11
|
+
- 只申请 `Mail.Read` 权限。
|
|
12
|
+
- 可以下载匹配邮件里的附件。
|
|
13
|
+
- OAuth token 保存在本地,并使用 `0600` 文件权限。
|
|
14
|
+
|
|
15
|
+
这个工具刻意保持窄范围。它不会发邮件、删除邮件、标记已读,也不会管理日历。
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uvx hotmail-cli --help
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
或者安装到本地工具环境:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv tool install hotmail-cli
|
|
27
|
+
hotmail --help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 创建 Microsoft 应用
|
|
31
|
+
|
|
32
|
+
你需要创建一个自己的 Microsoft Entra app registration。这个过程免费,也能让 Microsoft 明确展示 CLI 被授权访问哪些内容。
|
|
33
|
+
|
|
34
|
+
1. 打开 [Microsoft Entra admin center](https://entra.microsoft.com/)。
|
|
35
|
+
2. 进入 **App registrations** -> **New registration**。
|
|
36
|
+
3. 名称可以填 `hotmail-cli`,也可以用你自己的名称。
|
|
37
|
+
4. **Supported account types** 选择 **Personal Microsoft accounts only**。
|
|
38
|
+
5. **Redirect URI** 留空。
|
|
39
|
+
6. 创建应用。
|
|
40
|
+
7. 打开 **Authentication** -> **Settings**。
|
|
41
|
+
8. 启用 **Allow public client flows** 并保存。
|
|
42
|
+
9. 复制 **Application (client) ID**。
|
|
43
|
+
|
|
44
|
+
## 登录授权
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export HOTMAIL_CLIENT_ID="你的 Microsoft app client id"
|
|
48
|
+
hotmail auth
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
命令会输出一个 URL 和验证码。用浏览器打开 URL,输入验证码,登录 Microsoft,并批准 `Mail.Read` 权限。
|
|
52
|
+
|
|
53
|
+
token 缓存会保存到:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
~/.hotmail-cli/token.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
也可以直接传入 client id:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
hotmail --client-id "你的 Microsoft app client id" auth
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 搜索邮件
|
|
66
|
+
|
|
67
|
+
按主题搜索:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
hotmail search --subject "statement" --top 10
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
按主题、发件人和日期范围搜索:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
hotmail search \
|
|
77
|
+
--subject "invoice" \
|
|
78
|
+
--sender "billing@example.com" \
|
|
79
|
+
--since 2026-06-01 \
|
|
80
|
+
--until 2026-06-27 \
|
|
81
|
+
--top 10
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
输出是 Microsoft Graph message JSON。每封邮件都有一个 `id`,可以继续用于 `fetch` 和 `attachments` 命令。
|
|
85
|
+
|
|
86
|
+
Microsoft Graph 的邮件 `$search` 不能稳定地和 `$filter` 或 `$orderby` 混用,所以 Hotmail CLI 会先在服务端按主题搜索,再在本地按发件人和日期过滤。
|
|
87
|
+
|
|
88
|
+
## 获取单封邮件
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
hotmail fetch MESSAGE_ID
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 下载附件
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
hotmail attachments MESSAGE_ID --output-dir downloads
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
当前只保存 Microsoft Graph 的 `fileAttachment`。内联附件和引用附件会被忽略。
|
|
101
|
+
|
|
102
|
+
## 本地开发
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uv sync
|
|
106
|
+
uv run pytest
|
|
107
|
+
uv run hotmail --help
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
构建包:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv build
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 安全说明
|
|
117
|
+
|
|
118
|
+
- 不要提交 `~/.hotmail-cli/token.json`。
|
|
119
|
+
- 不要公开分享邮件 ID 或下载的附件。
|
|
120
|
+
- 可以随时在 Microsoft 账号安全页面或 app registration 中撤销访问。
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
125
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hotmail-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI for fetching Hotmail/Outlook messages and attachments via Microsoft Graph."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "kadaliao" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["hotmail", "outlook", "microsoft-graph", "email", "attachments", "cli"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: End Users/Desktop",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Communications :: Email",
|
|
22
|
+
"Topic :: Office/Business",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"msal>=1.30.0",
|
|
27
|
+
"requests>=2.32.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
hotmail = "hotmail_cli.cli:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/kadaliao/hotmail-cli"
|
|
35
|
+
Repository = "https://github.com/kadaliao/hotmail-cli"
|
|
36
|
+
Issues = "https://github.com/kadaliao/hotmail-cli/issues"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.2.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
pythonpath = ["src"]
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["setuptools>=69"]
|
|
48
|
+
build-backend = "setuptools.build_meta"
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
where = ["src"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import msal
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_AUTHORITY = "https://login.microsoftonline.com/consumers"
|
|
12
|
+
DEFAULT_SCOPES = ["https://graph.microsoft.com/Mail.Read"]
|
|
13
|
+
CLIENT_ID_ENV = "HOTMAIL_CLIENT_ID"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenStore:
|
|
17
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
18
|
+
self.path = path or default_token_path()
|
|
19
|
+
|
|
20
|
+
def load(self) -> dict[str, Any] | None:
|
|
21
|
+
if not self.path.exists():
|
|
22
|
+
return None
|
|
23
|
+
return json.loads(self.path.read_text())
|
|
24
|
+
|
|
25
|
+
def save(self, token: dict[str, Any]) -> None:
|
|
26
|
+
self.save_text(json.dumps(token, indent=2, sort_keys=True))
|
|
27
|
+
|
|
28
|
+
def load_text(self) -> str | None:
|
|
29
|
+
if not self.path.exists():
|
|
30
|
+
return None
|
|
31
|
+
return self.path.read_text()
|
|
32
|
+
|
|
33
|
+
def save_text(self, value: str) -> None:
|
|
34
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
36
|
+
tmp_path.write_text(value)
|
|
37
|
+
os.chmod(tmp_path, 0o600)
|
|
38
|
+
tmp_path.replace(self.path)
|
|
39
|
+
os.chmod(self.path, 0o600)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DeviceCodeAuthenticator:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
client_id: str | None = None,
|
|
47
|
+
authority: str = DEFAULT_AUTHORITY,
|
|
48
|
+
scopes: list[str] | None = None,
|
|
49
|
+
store: TokenStore | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.client_id = client_id or os.environ.get(CLIENT_ID_ENV)
|
|
52
|
+
if not self.client_id:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
f"Missing Microsoft app client id. Pass --client-id or set {CLIENT_ID_ENV}."
|
|
55
|
+
)
|
|
56
|
+
self.authority = authority
|
|
57
|
+
self.scopes = scopes or DEFAULT_SCOPES
|
|
58
|
+
self.store = store or TokenStore()
|
|
59
|
+
self.cache = msal.SerializableTokenCache()
|
|
60
|
+
cached = self.store.load_text()
|
|
61
|
+
if cached:
|
|
62
|
+
self.cache.deserialize(cached)
|
|
63
|
+
self.app = msal.PublicClientApplication(self.client_id, authority=authority, token_cache=self.cache)
|
|
64
|
+
|
|
65
|
+
def get_access_token(self) -> str:
|
|
66
|
+
accounts = self.app.get_accounts()
|
|
67
|
+
if accounts:
|
|
68
|
+
result = self.app.acquire_token_silent(self.scopes, account=accounts[0])
|
|
69
|
+
if result and "access_token" in result:
|
|
70
|
+
self._persist_cache()
|
|
71
|
+
return result["access_token"]
|
|
72
|
+
return self.login()["access_token"]
|
|
73
|
+
|
|
74
|
+
def login(self) -> dict[str, Any]:
|
|
75
|
+
flow = self.app.initiate_device_flow(scopes=self.scopes)
|
|
76
|
+
if "user_code" not in flow:
|
|
77
|
+
raise RuntimeError(f"Failed to create device flow: {flow}")
|
|
78
|
+
print(flow["message"])
|
|
79
|
+
result = self.app.acquire_token_by_device_flow(flow)
|
|
80
|
+
if "access_token" not in result:
|
|
81
|
+
error = result.get("error_description") or result
|
|
82
|
+
raise RuntimeError(f"Microsoft login failed: {error}")
|
|
83
|
+
self._persist_cache()
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
def _persist_cache(self) -> None:
|
|
87
|
+
if self.cache.has_state_changed:
|
|
88
|
+
self.store.save_text(self.cache.serialize())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def default_token_path() -> Path:
|
|
92
|
+
return Path.home() / ".hotmail-cli" / "token.json"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .auth import DeviceCodeAuthenticator, TokenStore
|
|
9
|
+
from .graph import GraphClient, MessageSearch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = build_parser()
|
|
14
|
+
args = parser.parse_args(argv)
|
|
15
|
+
|
|
16
|
+
if args.command == "auth":
|
|
17
|
+
authenticator = _authenticator(args)
|
|
18
|
+
token = authenticator.login()
|
|
19
|
+
print(json.dumps({"token_type": token.get("token_type"), "expires_in": token.get("expires_in")}))
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
client = GraphClient(_authenticator(args).get_access_token())
|
|
23
|
+
|
|
24
|
+
if args.command == "search":
|
|
25
|
+
messages = client.search_messages(
|
|
26
|
+
MessageSearch(
|
|
27
|
+
subject=args.subject,
|
|
28
|
+
sender=args.sender,
|
|
29
|
+
since=args.since,
|
|
30
|
+
until=args.until,
|
|
31
|
+
top=args.top,
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
print(json.dumps(messages, ensure_ascii=False, indent=2))
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
if args.command == "fetch":
|
|
38
|
+
message = client.get_message(args.message_id)
|
|
39
|
+
print(json.dumps(message, ensure_ascii=False, indent=2))
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
if args.command == "attachments":
|
|
43
|
+
saved = client.download_attachments(args.message_id, Path(args.output_dir))
|
|
44
|
+
print(json.dumps([str(path) for path in saved], ensure_ascii=False, indent=2))
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
parser.error("missing command")
|
|
48
|
+
return 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
52
|
+
parser = argparse.ArgumentParser(prog="hotmail", description="Fetch Hotmail/Outlook mail via Microsoft Graph.")
|
|
53
|
+
parser.add_argument("--token-file", help="Token cache path. Defaults to ~/.hotmail-cli/token.json.")
|
|
54
|
+
parser.add_argument("--client-id", help="Microsoft app client id. Or set HOTMAIL_CLIENT_ID.")
|
|
55
|
+
|
|
56
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
57
|
+
|
|
58
|
+
subparsers.add_parser("auth", help="Sign in with Microsoft device code flow.")
|
|
59
|
+
|
|
60
|
+
search = subparsers.add_parser("search", help="Search messages.")
|
|
61
|
+
search.add_argument("--subject", help="Subject keyword.")
|
|
62
|
+
search.add_argument("--sender", help="Sender email address.")
|
|
63
|
+
search.add_argument("--since", help="Start date in YYYY-MM-DD.")
|
|
64
|
+
search.add_argument("--until", help="End date in YYYY-MM-DD.")
|
|
65
|
+
search.add_argument("--top", type=int, default=10, help="Maximum messages to return.")
|
|
66
|
+
|
|
67
|
+
fetch = subparsers.add_parser("fetch", help="Fetch one message by id.")
|
|
68
|
+
fetch.add_argument("message_id")
|
|
69
|
+
|
|
70
|
+
attachments = subparsers.add_parser("attachments", help="Download file attachments for one message.")
|
|
71
|
+
attachments.add_argument("message_id")
|
|
72
|
+
attachments.add_argument("--output-dir", default="downloads")
|
|
73
|
+
|
|
74
|
+
return parser
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _authenticator(args: argparse.Namespace) -> DeviceCodeAuthenticator:
|
|
78
|
+
token_path = Path(args.token_file).expanduser() if args.token_file else None
|
|
79
|
+
return DeviceCodeAuthenticator(client_id=args.client_id, store=TokenStore(token_path))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
GRAPH_ROOT = "https://graph.microsoft.com/v1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class MessageSearch:
|
|
17
|
+
subject: str | None = None
|
|
18
|
+
sender: str | None = None
|
|
19
|
+
since: str | None = None
|
|
20
|
+
until: str | None = None
|
|
21
|
+
top: int = 10
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GraphClient:
|
|
25
|
+
def __init__(self, access_token: str, *, session: requests.Session | None = None) -> None:
|
|
26
|
+
self.access_token = access_token
|
|
27
|
+
self.session = session or requests.Session()
|
|
28
|
+
|
|
29
|
+
def search_messages(self, search: MessageSearch) -> list[dict[str, Any]]:
|
|
30
|
+
params = {
|
|
31
|
+
"$top": str(search.top),
|
|
32
|
+
"$select": "id,subject,receivedDateTime,from,hasAttachments,webLink",
|
|
33
|
+
}
|
|
34
|
+
if search.subject:
|
|
35
|
+
params["$search"] = f'"subject:{search.subject}"'
|
|
36
|
+
else:
|
|
37
|
+
params["$orderby"] = "receivedDateTime desc"
|
|
38
|
+
if not search.subject:
|
|
39
|
+
filters = _build_filters(search)
|
|
40
|
+
if filters:
|
|
41
|
+
params["$filter"] = " and ".join(filters)
|
|
42
|
+
response = self.session.get(
|
|
43
|
+
f"{GRAPH_ROOT}/me/messages",
|
|
44
|
+
headers=self._headers(),
|
|
45
|
+
params=params,
|
|
46
|
+
)
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
messages = response.json().get("value", [])
|
|
49
|
+
if search.subject:
|
|
50
|
+
messages = _filter_messages_locally(messages, search)
|
|
51
|
+
return messages
|
|
52
|
+
|
|
53
|
+
def get_message(self, message_id: str) -> dict[str, Any]:
|
|
54
|
+
response = self.session.get(
|
|
55
|
+
f"{GRAPH_ROOT}/me/messages/{message_id}",
|
|
56
|
+
headers=self._headers(),
|
|
57
|
+
params={"$select": "id,subject,receivedDateTime,from,body,hasAttachments,webLink"},
|
|
58
|
+
)
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
return response.json()
|
|
61
|
+
|
|
62
|
+
def list_attachments(self, message_id: str) -> list[dict[str, Any]]:
|
|
63
|
+
response = self.session.get(
|
|
64
|
+
f"{GRAPH_ROOT}/me/messages/{message_id}/attachments",
|
|
65
|
+
headers=self._headers(),
|
|
66
|
+
)
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
return response.json().get("value", [])
|
|
69
|
+
|
|
70
|
+
def download_attachments(self, message_id: str, output_dir: Path) -> list[Path]:
|
|
71
|
+
attachments = self.list_attachments(message_id)
|
|
72
|
+
saved: list[Path] = []
|
|
73
|
+
for attachment in attachments:
|
|
74
|
+
if attachment.get("@odata.type") == "#microsoft.graph.fileAttachment":
|
|
75
|
+
saved.append(self.save_attachment(attachment, output_dir))
|
|
76
|
+
return saved
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def save_attachment(attachment: dict[str, Any], output_dir: Path) -> Path:
|
|
80
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
filename = _safe_filename(attachment["name"])
|
|
82
|
+
path = _unique_path(output_dir / filename)
|
|
83
|
+
path.write_bytes(base64.b64decode(attachment["contentBytes"]))
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
def _headers(self) -> dict[str, str]:
|
|
87
|
+
return {"Authorization": f"Bearer {self.access_token}"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_filters(search: MessageSearch) -> list[str]:
|
|
91
|
+
filters: list[str] = []
|
|
92
|
+
if search.sender:
|
|
93
|
+
filters.append(f"from/emailAddress/address eq '{_odata_quote(search.sender)}'")
|
|
94
|
+
if search.since:
|
|
95
|
+
filters.append(f"receivedDateTime ge {search.since}T00:00:00Z")
|
|
96
|
+
if search.until:
|
|
97
|
+
filters.append(f"receivedDateTime le {search.until}T23:59:59Z")
|
|
98
|
+
return filters
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _odata_quote(value: str) -> str:
|
|
102
|
+
return value.replace("'", "''")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _safe_filename(filename: str) -> str:
|
|
106
|
+
cleaned = re.sub(r"[/\\:\0]", "_", filename).strip()
|
|
107
|
+
return cleaned or "attachment"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _unique_path(path: Path) -> Path:
|
|
111
|
+
if not path.exists():
|
|
112
|
+
return path
|
|
113
|
+
stem = path.stem
|
|
114
|
+
suffix = path.suffix
|
|
115
|
+
parent = path.parent
|
|
116
|
+
counter = 2
|
|
117
|
+
while True:
|
|
118
|
+
candidate = parent / f"{stem}-{counter}{suffix}"
|
|
119
|
+
if not candidate.exists():
|
|
120
|
+
return candidate
|
|
121
|
+
counter += 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _filter_messages_locally(messages: list[dict[str, Any]], search: MessageSearch) -> list[dict[str, Any]]:
|
|
125
|
+
filtered = messages
|
|
126
|
+
if search.sender:
|
|
127
|
+
sender = search.sender.lower()
|
|
128
|
+
filtered = [
|
|
129
|
+
message
|
|
130
|
+
for message in filtered
|
|
131
|
+
if message.get("from", {}).get("emailAddress", {}).get("address", "").lower() == sender
|
|
132
|
+
]
|
|
133
|
+
if search.since:
|
|
134
|
+
lower = f"{search.since}T00:00:00Z"
|
|
135
|
+
filtered = [message for message in filtered if message.get("receivedDateTime", "") >= lower]
|
|
136
|
+
if search.until:
|
|
137
|
+
upper = f"{search.until}T23:59:59Z"
|
|
138
|
+
filtered = [message for message in filtered if message.get("receivedDateTime", "") <= upper]
|
|
139
|
+
return filtered
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hotmail-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for fetching Hotmail/Outlook messages and attachments via Microsoft Graph.
|
|
5
|
+
Author: kadaliao
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kadaliao/hotmail-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/kadaliao/hotmail-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/kadaliao/hotmail-cli/issues
|
|
10
|
+
Keywords: hotmail,outlook,microsoft-graph,email,attachments,cli
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Communications :: Email
|
|
20
|
+
Classifier: Topic :: Office/Business
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: msal>=1.30.0
|
|
26
|
+
Requires-Dist: requests>=2.32.0
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# Hotmail CLI
|
|
30
|
+
|
|
31
|
+
A small read-only CLI for Hotmail and Outlook.com mailboxes. It uses Microsoft Graph to search messages and download file attachments from your mailbox.
|
|
32
|
+
|
|
33
|
+
中文文档: [README.zh-CN.md](README.zh-CN.md)
|
|
34
|
+
|
|
35
|
+
## Why Use This
|
|
36
|
+
|
|
37
|
+
- Works with personal Microsoft accounts such as Hotmail and Outlook.com.
|
|
38
|
+
- Uses Microsoft device code login, so the CLI never sees your password.
|
|
39
|
+
- Requests only `Mail.Read`.
|
|
40
|
+
- Downloads attachments from matching messages.
|
|
41
|
+
- Stores the OAuth token locally with `0600` file permissions.
|
|
42
|
+
|
|
43
|
+
This tool is intentionally narrow. It does not send email, delete messages, mark messages, or manage calendars.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uvx hotmail-cli --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or install it into an environment:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv tool install hotmail-cli
|
|
55
|
+
hotmail --help
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Microsoft App Setup
|
|
59
|
+
|
|
60
|
+
You need your own Microsoft Entra app registration. This is free and lets Microsoft show you exactly what the CLI is allowed to access.
|
|
61
|
+
|
|
62
|
+
1. Open the [Microsoft Entra admin center](https://entra.microsoft.com/).
|
|
63
|
+
2. Go to **App registrations** -> **New registration**.
|
|
64
|
+
3. Name it `hotmail-cli` or any name you prefer.
|
|
65
|
+
4. For **Supported account types**, choose **Personal Microsoft accounts only** for Hotmail/Outlook.com.
|
|
66
|
+
5. Leave **Redirect URI** empty.
|
|
67
|
+
6. Create the app.
|
|
68
|
+
7. Open **Authentication** -> **Settings**.
|
|
69
|
+
8. Enable **Allow public client flows** and save.
|
|
70
|
+
9. Copy the **Application (client) ID**.
|
|
71
|
+
|
|
72
|
+
## Sign In
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export HOTMAIL_CLIENT_ID="your Microsoft app client id"
|
|
76
|
+
hotmail auth
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The command prints a URL and code. Open the URL in your browser, enter the code, sign in to Microsoft, and approve the requested `Mail.Read` access.
|
|
80
|
+
|
|
81
|
+
The token cache is saved to:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
~/.hotmail-cli/token.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
You can also pass the client id directly:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
hotmail --client-id "your Microsoft app client id" auth
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Search Messages
|
|
94
|
+
|
|
95
|
+
Search by subject:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
hotmail search --subject "statement" --top 10
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Search by subject, sender, and date range:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
hotmail search \
|
|
105
|
+
--subject "invoice" \
|
|
106
|
+
--sender "billing@example.com" \
|
|
107
|
+
--since 2026-06-01 \
|
|
108
|
+
--until 2026-06-27 \
|
|
109
|
+
--top 10
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The output is Microsoft Graph message JSON. Each message includes an `id` that can be used with `fetch` and `attachments`.
|
|
113
|
+
|
|
114
|
+
Microsoft Graph message `$search` cannot be reliably combined with `$filter` or `$orderby`, so Hotmail CLI searches by subject server-side first, then applies sender and date filters locally.
|
|
115
|
+
|
|
116
|
+
## Fetch One Message
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
hotmail fetch MESSAGE_ID
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Download Attachments
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
hotmail attachments MESSAGE_ID --output-dir downloads
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Only Microsoft Graph `fileAttachment` items are saved. Inline items and reference attachments are ignored.
|
|
129
|
+
|
|
130
|
+
## Local Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv sync
|
|
134
|
+
uv run pytest
|
|
135
|
+
uv run hotmail --help
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Build the package:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
uv build
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Security Notes
|
|
145
|
+
|
|
146
|
+
- Do not commit `~/.hotmail-cli/token.json`.
|
|
147
|
+
- Do not share message IDs or downloaded attachments publicly.
|
|
148
|
+
- Revoke access anytime from your Microsoft account security page or from the app registration.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
|
153
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
README.zh-CN.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
src/hotmail_cli/__init__.py
|
|
7
|
+
src/hotmail_cli/auth.py
|
|
8
|
+
src/hotmail_cli/cli.py
|
|
9
|
+
src/hotmail_cli/graph.py
|
|
10
|
+
src/hotmail_cli.egg-info/PKG-INFO
|
|
11
|
+
src/hotmail_cli.egg-info/SOURCES.txt
|
|
12
|
+
src/hotmail_cli.egg-info/dependency_links.txt
|
|
13
|
+
src/hotmail_cli.egg-info/entry_points.txt
|
|
14
|
+
src/hotmail_cli.egg-info/requires.txt
|
|
15
|
+
src/hotmail_cli.egg-info/top_level.txt
|
|
16
|
+
tests/test_cli.py
|
|
17
|
+
tests/test_graph.py
|
|
18
|
+
tests/test_token_cache.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hotmail_cli
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from hotmail_cli import cli
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeAuthenticator:
|
|
7
|
+
def __init__(self, access_token="token"):
|
|
8
|
+
self.access_token = access_token
|
|
9
|
+
|
|
10
|
+
def get_access_token(self):
|
|
11
|
+
return self.access_token
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeGraphClient:
|
|
15
|
+
def __init__(self, access_token):
|
|
16
|
+
self.access_token = access_token
|
|
17
|
+
|
|
18
|
+
def search_messages(self, search):
|
|
19
|
+
return [{"id": "m1", "subject": search.subject, "top": search.top}]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_search_command_outputs_json(monkeypatch, capsys):
|
|
23
|
+
monkeypatch.setattr(cli, "_authenticator", lambda args: FakeAuthenticator())
|
|
24
|
+
monkeypatch.setattr(cli, "GraphClient", FakeGraphClient)
|
|
25
|
+
|
|
26
|
+
exit_code = cli.main(["search", "--subject", "invoice", "--top", "3"])
|
|
27
|
+
|
|
28
|
+
assert exit_code == 0
|
|
29
|
+
payload = json.loads(capsys.readouterr().out)
|
|
30
|
+
assert payload == [{"id": "m1", "subject": "invoice", "top": 3}]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from hotmail_cli.graph import GraphClient, MessageSearch
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FakeSession:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.calls = []
|
|
10
|
+
|
|
11
|
+
def get(self, url, **kwargs):
|
|
12
|
+
self.calls.append((url, kwargs))
|
|
13
|
+
return FakeResponse(
|
|
14
|
+
{
|
|
15
|
+
"value": [
|
|
16
|
+
{
|
|
17
|
+
"id": "m1",
|
|
18
|
+
"subject": "Monthly Statement",
|
|
19
|
+
"receivedDateTime": "2026-06-26T10:00:00Z",
|
|
20
|
+
"from": {"emailAddress": {"address": "noreply@example.com"}},
|
|
21
|
+
"hasAttachments": True,
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeResponse:
|
|
29
|
+
def __init__(self, payload):
|
|
30
|
+
self.payload = payload
|
|
31
|
+
|
|
32
|
+
def raise_for_status(self):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
def json(self):
|
|
36
|
+
return self.payload
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_search_messages_builds_graph_filter_and_select():
|
|
40
|
+
session = FakeSession()
|
|
41
|
+
client = GraphClient("token", session=session)
|
|
42
|
+
|
|
43
|
+
messages = client.search_messages(
|
|
44
|
+
MessageSearch(
|
|
45
|
+
subject="Statement",
|
|
46
|
+
sender="noreply@example.com",
|
|
47
|
+
since="2026-06-01",
|
|
48
|
+
until="2026-06-27",
|
|
49
|
+
top=5,
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert messages[0]["id"] == "m1"
|
|
54
|
+
url, kwargs = session.calls[0]
|
|
55
|
+
assert url == "https://graph.microsoft.com/v1.0/me/messages"
|
|
56
|
+
assert kwargs["headers"]["Authorization"] == "Bearer token"
|
|
57
|
+
assert kwargs["params"]["$top"] == "5"
|
|
58
|
+
assert kwargs["params"]["$search"] == '"subject:Statement"'
|
|
59
|
+
assert "$filter" not in kwargs["params"]
|
|
60
|
+
assert "subject" in kwargs["params"]["$select"]
|
|
61
|
+
assert messages[0]["from"]["emailAddress"]["address"] == "noreply@example.com"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_search_messages_uses_graph_search_for_subject_only():
|
|
65
|
+
session = FakeSession()
|
|
66
|
+
client = GraphClient("token", session=session)
|
|
67
|
+
|
|
68
|
+
client.search_messages(MessageSearch(subject="invoice", top=5))
|
|
69
|
+
|
|
70
|
+
_, kwargs = session.calls[0]
|
|
71
|
+
assert kwargs["params"]["$search"] == '"subject:invoice"'
|
|
72
|
+
assert "$filter" not in kwargs["params"]
|
|
73
|
+
assert "$orderby" not in kwargs["params"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_download_file_attachments_writes_base64_content(tmp_path: Path):
|
|
77
|
+
attachment = {
|
|
78
|
+
"@odata.type": "#microsoft.graph.fileAttachment",
|
|
79
|
+
"name": "statement.pdf",
|
|
80
|
+
"contentBytes": base64.b64encode(b"pdf bytes").decode("ascii"),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
saved = GraphClient.save_attachment(attachment, tmp_path)
|
|
84
|
+
|
|
85
|
+
assert saved == tmp_path / "statement.pdf"
|
|
86
|
+
assert saved.read_bytes() == b"pdf bytes"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from hotmail_cli import auth
|
|
6
|
+
from hotmail_cli.auth import TokenStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_token_store_writes_private_json_file(tmp_path):
|
|
10
|
+
path = tmp_path / "token.json"
|
|
11
|
+
store = TokenStore(path)
|
|
12
|
+
|
|
13
|
+
store.save({"access_token": "abc", "refresh_token": "def"})
|
|
14
|
+
|
|
15
|
+
assert json.loads(path.read_text())["access_token"] == "abc"
|
|
16
|
+
assert oct(path.stat().st_mode & 0o777) == "0o600"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_authenticator_requires_client_id(monkeypatch, tmp_path):
|
|
20
|
+
monkeypatch.delenv(auth.CLIENT_ID_ENV, raising=False)
|
|
21
|
+
|
|
22
|
+
with pytest.raises(RuntimeError, match="client id"):
|
|
23
|
+
auth.DeviceCodeAuthenticator(store=TokenStore(tmp_path / "token.json"))
|