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.
@@ -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,2 @@
1
+ include README.zh-CN.md
2
+
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Hotmail/Outlook Microsoft Graph CLI helpers."""
2
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ hotmail = hotmail_cli.cli:main
@@ -0,0 +1,2 @@
1
+ msal>=1.30.0
2
+ requests>=2.32.0
@@ -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"))