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.
- hotmail_cli/__init__.py +2 -0
- hotmail_cli/auth.py +92 -0
- hotmail_cli/cli.py +83 -0
- hotmail_cli/graph.py +139 -0
- hotmail_cli-0.1.0.dist-info/METADATA +153 -0
- hotmail_cli-0.1.0.dist-info/RECORD +10 -0
- hotmail_cli-0.1.0.dist-info/WHEEL +5 -0
- hotmail_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hotmail_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
- hotmail_cli-0.1.0.dist-info/top_level.txt +1 -0
hotmail_cli/__init__.py
ADDED
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,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
|