hotmail-cli 0.1.0__py3-none-any.whl

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,2 @@
1
+ """Hotmail/Outlook Microsoft Graph CLI helpers."""
2
+
hotmail_cli/auth.py ADDED
@@ -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"
hotmail_cli/cli.py ADDED
@@ -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())
hotmail_cli/graph.py ADDED
@@ -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,10 @@
1
+ hotmail_cli/__init__.py,sha256=_AUqt91_zX4p6W9CoNhBNNUy6XUZ5cQYnQdn19YD7ts,52
2
+ hotmail_cli/auth.py,sha256=jwmL-1Id4xZVoHgYzcAsN3oVZFrAJwjy1v9V21h2xug,3137
3
+ hotmail_cli/cli.py,sha256=OK-cGlL2ZBN3ifsM-fq_iYNl3inAWYy62SsNgNtkAz4,3063
4
+ hotmail_cli/graph.py,sha256=Yim8EeoyMa211x5Z0lDciiWXyhqkkVWpeP159yEJaBU,4701
5
+ hotmail_cli-0.1.0.dist-info/licenses/LICENSE,sha256=WMFW9C4Vc4tX0sIXCfV3gfLvvoWpO6LSR_SSFiA6iIc,1066
6
+ hotmail_cli-0.1.0.dist-info/METADATA,sha256=tDELWz2epX8tLzdu75uHIVhHqXfglASsl4GAy2YuZzg,4172
7
+ hotmail_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ hotmail_cli-0.1.0.dist-info/entry_points.txt,sha256=589M26PvvDb8ObFhKOcYiCHdzhjH_Okh6RhxLu7pV6A,49
9
+ hotmail_cli-0.1.0.dist-info/top_level.txt,sha256=8NxXv88RqLHNlVkNQGo7i1NHQ2LP2Laxg_K03m1D0PE,12
10
+ hotmail_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hotmail = hotmail_cli.cli:main
@@ -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 @@
1
+ hotmail_cli