ms365-toolkit 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.
- ms365_toolkit/__init__.py +1 -0
- ms365_toolkit/adapter/__init__.py +0 -0
- ms365_toolkit/auth/__init__.py +11 -0
- ms365_toolkit/auth/credentials.py +123 -0
- ms365_toolkit/auth/device_code.py +60 -0
- ms365_toolkit/auth/tokens.py +116 -0
- ms365_toolkit/cli/__init__.py +162 -0
- ms365_toolkit/cli/auth.py +100 -0
- ms365_toolkit/cli/inbox.py +25 -0
- ms365_toolkit/cli/labeling.py +184 -0
- ms365_toolkit/cli/labeling_web.py +515 -0
- ms365_toolkit/cli/read_common.py +31 -0
- ms365_toolkit/cli/read_tools.py +446 -0
- ms365_toolkit/client/__init__.py +30 -0
- ms365_toolkit/client/calendar.py +146 -0
- ms365_toolkit/client/email.py +247 -0
- ms365_toolkit/client/exceptions.py +48 -0
- ms365_toolkit/client/graph.py +244 -0
- ms365_toolkit/client/mail_index.py +196 -0
- ms365_toolkit/client/sanitize.py +16 -0
- ms365_toolkit/config/__init__.py +35 -0
- ms365_toolkit/config/loader.py +257 -0
- ms365_toolkit/intelligence/__init__.py +32 -0
- ms365_toolkit/intelligence/calendar_scoring.py +59 -0
- ms365_toolkit/intelligence/day_overview.py +58 -0
- ms365_toolkit/intelligence/email_triage.py +90 -0
- ms365_toolkit/intelligence/labels.py +94 -0
- ms365_toolkit/intelligence/morning_brief.py +66 -0
- ms365_toolkit/intelligence/thread_classifier.py +139 -0
- ms365_toolkit/intelligence/vip.py +15 -0
- ms365_toolkit/mcp/__init__.py +9 -0
- ms365_toolkit/mcp/__main__.py +25 -0
- ms365_toolkit/mcp/planner.py +88 -0
- ms365_toolkit/mcp/runtime.py +37 -0
- ms365_toolkit/mcp/serializers.py +88 -0
- ms365_toolkit/mcp/server.py +21 -0
- ms365_toolkit/mcp/tools.py +216 -0
- ms365_toolkit/models/__init__.py +25 -0
- ms365_toolkit/models/calendar.py +42 -0
- ms365_toolkit/models/email.py +51 -0
- ms365_toolkit/models/enums.py +49 -0
- ms365_toolkit/safety/__init__.py +29 -0
- ms365_toolkit/safety/allowlist.py +51 -0
- ms365_toolkit/safety/audit.py +87 -0
- ms365_toolkit/safety/canonical.py +73 -0
- ms365_toolkit/safety/dialog.py +94 -0
- ms365_toolkit/safety/domains.py +33 -0
- ms365_toolkit/safety/folder_allowlist.py +68 -0
- ms365_toolkit/safety/gate.py +232 -0
- ms365_toolkit/safety/rate_limiter.py +177 -0
- ms365_toolkit-0.1.0.dist-info/METADATA +18 -0
- ms365_toolkit-0.1.0.dist-info/RECORD +54 -0
- ms365_toolkit-0.1.0.dist-info/WHEEL +4 -0
- ms365_toolkit-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from ms365_toolkit.auth.credentials import CredentialStore
|
|
2
|
+
from ms365_toolkit.auth.device_code import DeviceCodePrompt, authenticate_device_code
|
|
3
|
+
from ms365_toolkit.auth.tokens import TokenBundle, TokenManager
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"CredentialStore",
|
|
7
|
+
"DeviceCodePrompt",
|
|
8
|
+
"TokenBundle",
|
|
9
|
+
"TokenManager",
|
|
10
|
+
"authenticate_device_code",
|
|
11
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
|
+
|
|
8
|
+
import keyring
|
|
9
|
+
import msal # type: ignore[import-untyped]
|
|
10
|
+
from keyring.errors import PasswordDeleteError, PasswordSetError
|
|
11
|
+
|
|
12
|
+
from ms365_toolkit.models.enums import (
|
|
13
|
+
DELEGATED_READ_SCOPES,
|
|
14
|
+
DELEGATED_WRITE_SCOPES,
|
|
15
|
+
READ_SCOPES,
|
|
16
|
+
WRITE_SCOPES,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ms365_toolkit.config.loader import ToolkitConfig
|
|
21
|
+
|
|
22
|
+
SERVICE_NAME = "ms365-toolkit"
|
|
23
|
+
READ_CACHE_KEY = "ms365-read-token-{profile}"
|
|
24
|
+
WRITE_CACHE_KEY = "ms365-write-token-{profile}"
|
|
25
|
+
READ_ENV_KEY = "MS365_READ_TOKEN_CACHE_{profile}"
|
|
26
|
+
WRITE_ENV_KEY = "MS365_WRITE_TOKEN_CACHE_{profile}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class CredentialStore:
|
|
31
|
+
profile: str
|
|
32
|
+
keyring_backend: Any = keyring
|
|
33
|
+
|
|
34
|
+
def load_cache(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
kind: str,
|
|
38
|
+
explicit_value: str | None = None,
|
|
39
|
+
allow_env: bool = True,
|
|
40
|
+
) -> msal.SerializableTokenCache:
|
|
41
|
+
cache = msal.SerializableTokenCache()
|
|
42
|
+
raw_value = self._resolve_raw_cache(
|
|
43
|
+
kind=kind,
|
|
44
|
+
explicit_value=explicit_value,
|
|
45
|
+
allow_env=allow_env,
|
|
46
|
+
)
|
|
47
|
+
if raw_value:
|
|
48
|
+
cache.deserialize(raw_value)
|
|
49
|
+
return cache
|
|
50
|
+
|
|
51
|
+
def save_cache(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
kind: str,
|
|
55
|
+
cache: msal.SerializableTokenCache,
|
|
56
|
+
allow_env_write: bool = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
serialized = cache.serialize()
|
|
59
|
+
cache_key = self._cache_key(kind)
|
|
60
|
+
try:
|
|
61
|
+
self.keyring_backend.set_password(
|
|
62
|
+
SERVICE_NAME,
|
|
63
|
+
cache_key,
|
|
64
|
+
serialized,
|
|
65
|
+
)
|
|
66
|
+
except PasswordSetError:
|
|
67
|
+
with suppress(PasswordDeleteError):
|
|
68
|
+
self.keyring_backend.delete_password(SERVICE_NAME, cache_key)
|
|
69
|
+
self.keyring_backend.set_password(
|
|
70
|
+
SERVICE_NAME,
|
|
71
|
+
cache_key,
|
|
72
|
+
serialized,
|
|
73
|
+
)
|
|
74
|
+
if kind != "write" or not allow_env_write:
|
|
75
|
+
return
|
|
76
|
+
os.environ[self._env_key(kind)] = serialized
|
|
77
|
+
|
|
78
|
+
def delete_cache(self, *, kind: str) -> None:
|
|
79
|
+
with suppress(PasswordDeleteError):
|
|
80
|
+
self.keyring_backend.delete_password(SERVICE_NAME, self._cache_key(kind))
|
|
81
|
+
os.environ.pop(self._env_key(kind), None)
|
|
82
|
+
|
|
83
|
+
def scopes_for(self, config: ToolkitConfig, *, write: bool) -> frozenset[str]:
|
|
84
|
+
if config.user.endpoint == "users":
|
|
85
|
+
return DELEGATED_WRITE_SCOPES if write else DELEGATED_READ_SCOPES
|
|
86
|
+
return WRITE_SCOPES if write else READ_SCOPES
|
|
87
|
+
|
|
88
|
+
def _resolve_raw_cache(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
kind: str,
|
|
92
|
+
explicit_value: str | None,
|
|
93
|
+
allow_env: bool,
|
|
94
|
+
) -> str | None:
|
|
95
|
+
if explicit_value:
|
|
96
|
+
return explicit_value
|
|
97
|
+
value = cast(
|
|
98
|
+
"str | None",
|
|
99
|
+
self.keyring_backend.get_password(SERVICE_NAME, self._cache_key(kind)),
|
|
100
|
+
)
|
|
101
|
+
if value:
|
|
102
|
+
return value
|
|
103
|
+
if allow_env:
|
|
104
|
+
return os.environ.get(self._env_key(kind))
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def _cache_key(self, kind: str) -> str:
|
|
108
|
+
if kind == "read":
|
|
109
|
+
return READ_CACHE_KEY.format(profile=self.profile)
|
|
110
|
+
if kind == "write":
|
|
111
|
+
return WRITE_CACHE_KEY.format(profile=self.profile)
|
|
112
|
+
raise ValueError(f"unsupported cache kind: {kind}")
|
|
113
|
+
|
|
114
|
+
def _env_key(self, kind: str) -> str:
|
|
115
|
+
if kind == "read":
|
|
116
|
+
return READ_ENV_KEY.format(profile=_env_profile(self.profile))
|
|
117
|
+
if kind == "write":
|
|
118
|
+
return WRITE_ENV_KEY.format(profile=_env_profile(self.profile))
|
|
119
|
+
raise ValueError(f"unsupported cache kind: {kind}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _env_profile(profile: str) -> str:
|
|
123
|
+
return profile.upper().replace("-", "_")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
5
|
+
|
|
6
|
+
from ms365_toolkit.client.exceptions import AuthenticationError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
import msal # type: ignore[import-untyped]
|
|
12
|
+
|
|
13
|
+
from ms365_toolkit.auth.credentials import CredentialStore
|
|
14
|
+
from ms365_toolkit.config.loader import ToolkitConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class DeviceCodePrompt:
|
|
19
|
+
verification_uri: str
|
|
20
|
+
user_code: str
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def authenticate_device_code(
|
|
25
|
+
*,
|
|
26
|
+
config: ToolkitConfig,
|
|
27
|
+
credential_store: CredentialStore,
|
|
28
|
+
write: bool,
|
|
29
|
+
presenter: Callable[[DeviceCodePrompt], None],
|
|
30
|
+
app_factory: Callable[[msal.SerializableTokenCache], Any],
|
|
31
|
+
allow_env_write: bool = False,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
cache = credential_store.load_cache(kind="write" if write else "read", allow_env=not write)
|
|
34
|
+
scopes = credential_store.scopes_for(config, write=write)
|
|
35
|
+
app = app_factory(cache)
|
|
36
|
+
flow = app.initiate_device_flow(scopes=list(scopes))
|
|
37
|
+
verification_uri = flow.get("verification_uri")
|
|
38
|
+
user_code = flow.get("user_code")
|
|
39
|
+
message = flow.get("message")
|
|
40
|
+
if not all(
|
|
41
|
+
isinstance(value, str) and value for value in (verification_uri, user_code, message)
|
|
42
|
+
):
|
|
43
|
+
raise AuthenticationError("device code flow did not return verification details")
|
|
44
|
+
presenter(
|
|
45
|
+
DeviceCodePrompt(
|
|
46
|
+
verification_uri=verification_uri,
|
|
47
|
+
user_code=user_code,
|
|
48
|
+
message=message,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
result = app.acquire_token_by_device_flow(flow)
|
|
52
|
+
access_token = result.get("access_token") if isinstance(result, dict) else None
|
|
53
|
+
if not isinstance(access_token, str) or not access_token:
|
|
54
|
+
raise AuthenticationError("device code authentication failed")
|
|
55
|
+
credential_store.save_cache(
|
|
56
|
+
kind="write" if write else "read",
|
|
57
|
+
cache=cache,
|
|
58
|
+
allow_env_write=allow_env_write,
|
|
59
|
+
)
|
|
60
|
+
return cast("dict[str, Any]", result)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
|
|
9
|
+
from ms365_toolkit.client.exceptions import AuthenticationError, InsufficientScopesError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
import msal # type: ignore[import-untyped]
|
|
15
|
+
|
|
16
|
+
from ms365_toolkit.auth.credentials import CredentialStore
|
|
17
|
+
from ms365_toolkit.config.loader import ToolkitConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class TokenBundle:
|
|
22
|
+
access_token: str
|
|
23
|
+
scopes: frozenset[str]
|
|
24
|
+
expires_at: datetime
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TokenManager:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
config: ToolkitConfig,
|
|
32
|
+
credential_store: CredentialStore,
|
|
33
|
+
app_factory: Callable[[msal.SerializableTokenCache], Any],
|
|
34
|
+
auth_callback: (
|
|
35
|
+
Callable[[frozenset[str], msal.SerializableTokenCache, str], dict[str, Any]] | None
|
|
36
|
+
) = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.config = config
|
|
39
|
+
self.credential_store = credential_store
|
|
40
|
+
self.app_factory = app_factory
|
|
41
|
+
self.auth_callback = auth_callback
|
|
42
|
+
|
|
43
|
+
def get_access_token(self, *, write: bool = False) -> TokenBundle:
|
|
44
|
+
kind = "write" if write else "read"
|
|
45
|
+
cache = self.credential_store.load_cache(kind=kind, allow_env=not write)
|
|
46
|
+
app = self.app_factory(cache)
|
|
47
|
+
scopes = self.credential_store.scopes_for(self.config, write=write)
|
|
48
|
+
result = self._acquire_token(app, scopes, cache, kind)
|
|
49
|
+
access_token = result.get("access_token")
|
|
50
|
+
if not isinstance(access_token, str) or not access_token:
|
|
51
|
+
raise AuthenticationError(f"missing access token for {kind} scopes")
|
|
52
|
+
if _is_expiring(access_token):
|
|
53
|
+
result = self._interactive_refresh(scopes, cache, kind)
|
|
54
|
+
access_token = result.get("access_token")
|
|
55
|
+
if not isinstance(access_token, str) or not access_token:
|
|
56
|
+
raise AuthenticationError(f"missing refreshed access token for {kind} scopes")
|
|
57
|
+
token_scopes = _token_scopes(result, access_token)
|
|
58
|
+
normalized_scopes = frozenset(scope.lower() for scope in scopes)
|
|
59
|
+
if not normalized_scopes.issubset(token_scopes):
|
|
60
|
+
raise InsufficientScopesError(scopes, token_scopes)
|
|
61
|
+
self.credential_store.save_cache(kind=kind, cache=cache, allow_env_write=False)
|
|
62
|
+
return TokenBundle(
|
|
63
|
+
access_token=access_token,
|
|
64
|
+
scopes=token_scopes,
|
|
65
|
+
expires_at=_token_expiry(access_token),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _acquire_token(
|
|
69
|
+
self,
|
|
70
|
+
app: Any,
|
|
71
|
+
scopes: frozenset[str],
|
|
72
|
+
cache: msal.SerializableTokenCache,
|
|
73
|
+
kind: str,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
accounts = app.get_accounts()
|
|
76
|
+
account = accounts[0] if accounts else None
|
|
77
|
+
result = app.acquire_token_silent(list(scopes), account=account)
|
|
78
|
+
if isinstance(result, dict) and result.get("access_token"):
|
|
79
|
+
return result
|
|
80
|
+
return self._interactive_refresh(scopes, cache, kind)
|
|
81
|
+
|
|
82
|
+
def _interactive_refresh(
|
|
83
|
+
self,
|
|
84
|
+
scopes: frozenset[str],
|
|
85
|
+
cache: msal.SerializableTokenCache,
|
|
86
|
+
kind: str,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
if self.auth_callback is None:
|
|
89
|
+
raise AuthenticationError(f"interactive authentication required for {kind} scopes")
|
|
90
|
+
result = self.auth_callback(scopes, cache, kind)
|
|
91
|
+
if not isinstance(result, dict):
|
|
92
|
+
raise AuthenticationError("authentication callback returned invalid result")
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _token_expiry(access_token: str) -> datetime:
|
|
97
|
+
payload = jwt.decode(access_token, options={"verify_signature": False, "verify_aud": False})
|
|
98
|
+
exp = payload.get("exp")
|
|
99
|
+
if not isinstance(exp, int):
|
|
100
|
+
raise AuthenticationError("token does not contain integer exp claim")
|
|
101
|
+
return datetime.fromtimestamp(exp, tz=UTC)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_expiring(access_token: str, *, grace_period: timedelta = timedelta(minutes=5)) -> bool:
|
|
105
|
+
return _token_expiry(access_token) <= datetime.now(UTC) + grace_period
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _token_scopes(result: dict[str, Any], access_token: str) -> frozenset[str]:
|
|
109
|
+
scope_string = result.get("scope") or result.get("scopes") or result.get("scp")
|
|
110
|
+
if isinstance(scope_string, str) and scope_string.strip():
|
|
111
|
+
return frozenset(scope.lower() for scope in scope_string.split())
|
|
112
|
+
payload = jwt.decode(access_token, options={"verify_signature": False, "verify_aud": False})
|
|
113
|
+
token_scope_string = payload.get("scp")
|
|
114
|
+
if not isinstance(token_scope_string, str):
|
|
115
|
+
raise AuthenticationError("token does not contain scopes")
|
|
116
|
+
return frozenset(scope.lower() for scope in token_scope_string.split())
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from ms365_toolkit.cli.auth import login_command, revoke_write_command, status_command
|
|
7
|
+
from ms365_toolkit.cli.inbox import inbox_command
|
|
8
|
+
from ms365_toolkit.cli.labeling import (
|
|
9
|
+
export_label_candidates_command,
|
|
10
|
+
label_thread_command,
|
|
11
|
+
review_label_candidates_command,
|
|
12
|
+
)
|
|
13
|
+
from ms365_toolkit.cli.labeling_web import launch_label_ui_command
|
|
14
|
+
from ms365_toolkit.cli.read_tools import (
|
|
15
|
+
download_email_attachments_command,
|
|
16
|
+
list_events_command,
|
|
17
|
+
morning_brief_command,
|
|
18
|
+
read_email_command,
|
|
19
|
+
search_emails_command,
|
|
20
|
+
search_local_mail_index_command,
|
|
21
|
+
sync_mail_index_command,
|
|
22
|
+
triage_inbox_command,
|
|
23
|
+
)
|
|
24
|
+
from ms365_toolkit.config.loader import load_config
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
31
|
+
parser = _build_parser()
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
if not hasattr(args, "handler"):
|
|
34
|
+
parser.print_help()
|
|
35
|
+
return 1
|
|
36
|
+
if args.command == "revoke-write":
|
|
37
|
+
return int(args.handler(args))
|
|
38
|
+
config = load_config(profile=args.profile)
|
|
39
|
+
return int(args.handler(args, config))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
43
|
+
parser = argparse.ArgumentParser(prog="ms365-toolkit")
|
|
44
|
+
parser.add_argument("--profile", default="default")
|
|
45
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
46
|
+
|
|
47
|
+
auth_parser = subparsers.add_parser("auth")
|
|
48
|
+
auth_subparsers = auth_parser.add_subparsers(dest="auth_command")
|
|
49
|
+
|
|
50
|
+
login_parser = auth_subparsers.add_parser("login")
|
|
51
|
+
login_parser.add_argument("--write", action="store_true")
|
|
52
|
+
login_parser.add_argument("--insecure-env-write", action="store_true")
|
|
53
|
+
login_parser.set_defaults(handler=login_command)
|
|
54
|
+
|
|
55
|
+
status_parser = auth_subparsers.add_parser("status")
|
|
56
|
+
status_parser.set_defaults(handler=status_command)
|
|
57
|
+
|
|
58
|
+
revoke_parser = auth_subparsers.add_parser("revoke-write")
|
|
59
|
+
revoke_parser.set_defaults(handler=revoke_write_command, command="revoke-write")
|
|
60
|
+
|
|
61
|
+
inbox_parser = subparsers.add_parser("inbox")
|
|
62
|
+
inbox_parser.add_argument("--top", type=int, default=10)
|
|
63
|
+
inbox_parser.add_argument("--filter", choices=("focused", "all"), default="all")
|
|
64
|
+
inbox_parser.set_defaults(handler=inbox_command)
|
|
65
|
+
|
|
66
|
+
search_parser = subparsers.add_parser("search-emails")
|
|
67
|
+
search_parser.add_argument("query")
|
|
68
|
+
search_parser.add_argument("--top", type=int, default=10)
|
|
69
|
+
search_parser.add_argument("--filter", choices=("focused", "all"), default="all")
|
|
70
|
+
search_parser.set_defaults(handler=search_emails_command)
|
|
71
|
+
|
|
72
|
+
sync_index_parser = subparsers.add_parser("sync-mail-index")
|
|
73
|
+
sync_index_parser.add_argument("--batch-size", type=int, default=100)
|
|
74
|
+
sync_index_parser.add_argument("--max-pages", type=int, default=10)
|
|
75
|
+
sync_index_parser.set_defaults(handler=sync_mail_index_command)
|
|
76
|
+
|
|
77
|
+
search_local_parser = subparsers.add_parser("search-local-mail")
|
|
78
|
+
search_local_parser.add_argument("query")
|
|
79
|
+
search_local_parser.add_argument("--top", type=int, default=10)
|
|
80
|
+
search_local_parser.set_defaults(handler=search_local_mail_index_command)
|
|
81
|
+
|
|
82
|
+
export_labels_parser = subparsers.add_parser("export-label-candidates")
|
|
83
|
+
export_labels_parser.add_argument("--top", type=int, default=20)
|
|
84
|
+
export_labels_parser.add_argument("--sync-index", action="store_true")
|
|
85
|
+
export_labels_parser.add_argument("--max-pages", type=int, default=5)
|
|
86
|
+
export_labels_parser.set_defaults(handler=export_label_candidates_command)
|
|
87
|
+
|
|
88
|
+
review_labels_parser = subparsers.add_parser("review-label-candidates")
|
|
89
|
+
review_labels_parser.add_argument("--top", type=int, default=10)
|
|
90
|
+
review_labels_parser.add_argument(
|
|
91
|
+
"--status",
|
|
92
|
+
choices=("unlabeled", "labeled", "all"),
|
|
93
|
+
default="unlabeled",
|
|
94
|
+
)
|
|
95
|
+
review_labels_parser.add_argument("--thread-id")
|
|
96
|
+
review_labels_parser.set_defaults(handler=review_label_candidates_command)
|
|
97
|
+
|
|
98
|
+
label_ui_parser = subparsers.add_parser("label-ui")
|
|
99
|
+
label_ui_parser.add_argument("--host", default="127.0.0.1")
|
|
100
|
+
label_ui_parser.add_argument("--port", type=int, default=8765)
|
|
101
|
+
label_ui_parser.add_argument("--no-open", action="store_true")
|
|
102
|
+
label_ui_parser.set_defaults(handler=launch_label_ui_command)
|
|
103
|
+
|
|
104
|
+
label_thread_parser = subparsers.add_parser("label-thread")
|
|
105
|
+
label_thread_parser.add_argument("thread_id")
|
|
106
|
+
label_thread_parser.add_argument(
|
|
107
|
+
"--thread-class",
|
|
108
|
+
required=True,
|
|
109
|
+
choices=(
|
|
110
|
+
"approval_request",
|
|
111
|
+
"decision_request",
|
|
112
|
+
"incident_or_risk",
|
|
113
|
+
"contract_or_renewal",
|
|
114
|
+
"strategic_update",
|
|
115
|
+
"status_update",
|
|
116
|
+
"fyi_noise",
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
label_thread_parser.add_argument(
|
|
120
|
+
"--action-needed",
|
|
121
|
+
required=True,
|
|
122
|
+
choices=("yes", "no", "unclear"),
|
|
123
|
+
)
|
|
124
|
+
label_thread_parser.add_argument(
|
|
125
|
+
"--urgency",
|
|
126
|
+
required=True,
|
|
127
|
+
choices=("high", "medium", "low"),
|
|
128
|
+
)
|
|
129
|
+
label_thread_parser.add_argument(
|
|
130
|
+
"--acted-by-you",
|
|
131
|
+
required=True,
|
|
132
|
+
choices=("replied", "forwarded", "ignored", "archived", "unknown"),
|
|
133
|
+
)
|
|
134
|
+
label_thread_parser.add_argument("--confidence", required=True, type=float)
|
|
135
|
+
label_thread_parser.add_argument("--notes")
|
|
136
|
+
label_thread_parser.set_defaults(handler=label_thread_command)
|
|
137
|
+
|
|
138
|
+
triage_parser = subparsers.add_parser("triage-inbox")
|
|
139
|
+
triage_parser.add_argument("--top", type=int, default=15)
|
|
140
|
+
triage_parser.add_argument("--limit", type=int, default=5)
|
|
141
|
+
triage_parser.add_argument("--sync-index", action="store_true")
|
|
142
|
+
triage_parser.add_argument("--max-pages", type=int, default=5)
|
|
143
|
+
triage_parser.set_defaults(handler=triage_inbox_command)
|
|
144
|
+
|
|
145
|
+
read_email_parser = subparsers.add_parser("read-email")
|
|
146
|
+
read_email_parser.add_argument("message_id")
|
|
147
|
+
read_email_parser.set_defaults(handler=read_email_command)
|
|
148
|
+
|
|
149
|
+
download_attachments_parser = subparsers.add_parser("download-email-attachments")
|
|
150
|
+
download_attachments_parser.add_argument("message_id")
|
|
151
|
+
download_attachments_parser.add_argument("--output-dir", default=".")
|
|
152
|
+
download_attachments_parser.set_defaults(handler=download_email_attachments_command)
|
|
153
|
+
|
|
154
|
+
list_events_parser = subparsers.add_parser("list-events")
|
|
155
|
+
list_events_parser.add_argument("--hours", type=int, default=8)
|
|
156
|
+
list_events_parser.set_defaults(handler=list_events_command)
|
|
157
|
+
|
|
158
|
+
morning_brief_parser = subparsers.add_parser("morning-brief")
|
|
159
|
+
morning_brief_parser.add_argument("--top", type=int, default=10)
|
|
160
|
+
morning_brief_parser.set_defaults(handler=morning_brief_command)
|
|
161
|
+
|
|
162
|
+
return parser
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import msal # type: ignore[import-untyped]
|
|
7
|
+
|
|
8
|
+
from ms365_toolkit.auth.credentials import CredentialStore
|
|
9
|
+
from ms365_toolkit.auth.device_code import DeviceCodePrompt, authenticate_device_code
|
|
10
|
+
from ms365_toolkit.auth.tokens import TokenBundle, TokenManager
|
|
11
|
+
from ms365_toolkit.client.exceptions import AuthenticationError
|
|
12
|
+
from ms365_toolkit.config.loader import ToolkitConfig
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from argparse import Namespace
|
|
16
|
+
|
|
17
|
+
from ms365_toolkit.config.loader import ToolkitConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AUTHORITY_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AuthStatus:
|
|
25
|
+
logged_in: bool
|
|
26
|
+
scopes: tuple[str, ...]
|
|
27
|
+
expires_at: str | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def login_command(args: Namespace, config: ToolkitConfig) -> int:
|
|
31
|
+
store = CredentialStore(profile=args.profile)
|
|
32
|
+
authenticate_device_code(
|
|
33
|
+
config=config,
|
|
34
|
+
credential_store=store,
|
|
35
|
+
write=bool(args.write),
|
|
36
|
+
presenter=_print_device_code_prompt,
|
|
37
|
+
app_factory=lambda cache: build_public_client_application(config, cache),
|
|
38
|
+
allow_env_write=bool(args.insecure_env_write),
|
|
39
|
+
)
|
|
40
|
+
print(f"Stored {'write' if args.write else 'read'} token cache for profile {args.profile}.")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def status_command(args: Namespace, config: ToolkitConfig) -> int:
|
|
45
|
+
status = get_auth_status(profile=args.profile, config=config)
|
|
46
|
+
if not status.logged_in:
|
|
47
|
+
print(f"Profile {args.profile}: not logged in")
|
|
48
|
+
return 1
|
|
49
|
+
scopes = ", ".join(status.scopes)
|
|
50
|
+
print(f"Profile {args.profile}: logged in")
|
|
51
|
+
print(f"Scopes: {scopes}")
|
|
52
|
+
if status.expires_at is not None:
|
|
53
|
+
print(f"Expires: {status.expires_at}")
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def revoke_write_command(args: Namespace) -> int:
|
|
58
|
+
store = CredentialStore(profile=args.profile)
|
|
59
|
+
store.delete_cache(kind="write")
|
|
60
|
+
print(f"Removed write token cache for profile {args.profile}.")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_auth_status(profile: str, config: ToolkitConfig) -> AuthStatus:
|
|
65
|
+
store = CredentialStore(profile=profile)
|
|
66
|
+
try:
|
|
67
|
+
bundle = _load_bundle(config=config, store=store)
|
|
68
|
+
except AuthenticationError:
|
|
69
|
+
return AuthStatus(logged_in=False, scopes=(), expires_at=None)
|
|
70
|
+
return AuthStatus(
|
|
71
|
+
logged_in=True,
|
|
72
|
+
scopes=tuple(sorted(bundle.scopes)),
|
|
73
|
+
expires_at=bundle.expires_at.isoformat(),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_bundle(config: ToolkitConfig, store: CredentialStore) -> TokenBundle:
|
|
78
|
+
manager = TokenManager(
|
|
79
|
+
config=config,
|
|
80
|
+
credential_store=store,
|
|
81
|
+
app_factory=lambda cache: build_public_client_application(config, cache),
|
|
82
|
+
)
|
|
83
|
+
return manager.get_access_token(write=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_public_client_application(
|
|
87
|
+
config: ToolkitConfig,
|
|
88
|
+
cache: msal.SerializableTokenCache,
|
|
89
|
+
) -> msal.PublicClientApplication:
|
|
90
|
+
return msal.PublicClientApplication(
|
|
91
|
+
client_id=config.auth.client_id,
|
|
92
|
+
authority=AUTHORITY_TEMPLATE.format(tenant_id=config.auth.tenant_id),
|
|
93
|
+
token_cache=cache,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _print_device_code_prompt(prompt: DeviceCodePrompt) -> None:
|
|
98
|
+
print(prompt.message)
|
|
99
|
+
print(f"Verification URL: {prompt.verification_uri}")
|
|
100
|
+
print(f"User code: {prompt.user_code}")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ms365_toolkit.cli.read_common import build_read_clients
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from argparse import Namespace
|
|
9
|
+
|
|
10
|
+
from ms365_toolkit.config.loader import ToolkitConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def inbox_command(args: Namespace, config: ToolkitConfig) -> int:
|
|
14
|
+
email_client, _ = build_read_clients(config, args.profile)
|
|
15
|
+
mailbox_filter = None if args.filter == "all" else args.filter
|
|
16
|
+
messages = email_client.list_inbox(mailbox_filter=mailbox_filter, top=args.top, skip=0)
|
|
17
|
+
if not messages:
|
|
18
|
+
print("No messages found.")
|
|
19
|
+
return 0
|
|
20
|
+
for message in messages:
|
|
21
|
+
print(
|
|
22
|
+
f"{message.received_at.isoformat()} {message.id} "
|
|
23
|
+
f"{message.conversation_id} {message.sender} {message.subject}"
|
|
24
|
+
)
|
|
25
|
+
return 0
|