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.
Files changed (54) hide show
  1. ms365_toolkit/__init__.py +1 -0
  2. ms365_toolkit/adapter/__init__.py +0 -0
  3. ms365_toolkit/auth/__init__.py +11 -0
  4. ms365_toolkit/auth/credentials.py +123 -0
  5. ms365_toolkit/auth/device_code.py +60 -0
  6. ms365_toolkit/auth/tokens.py +116 -0
  7. ms365_toolkit/cli/__init__.py +162 -0
  8. ms365_toolkit/cli/auth.py +100 -0
  9. ms365_toolkit/cli/inbox.py +25 -0
  10. ms365_toolkit/cli/labeling.py +184 -0
  11. ms365_toolkit/cli/labeling_web.py +515 -0
  12. ms365_toolkit/cli/read_common.py +31 -0
  13. ms365_toolkit/cli/read_tools.py +446 -0
  14. ms365_toolkit/client/__init__.py +30 -0
  15. ms365_toolkit/client/calendar.py +146 -0
  16. ms365_toolkit/client/email.py +247 -0
  17. ms365_toolkit/client/exceptions.py +48 -0
  18. ms365_toolkit/client/graph.py +244 -0
  19. ms365_toolkit/client/mail_index.py +196 -0
  20. ms365_toolkit/client/sanitize.py +16 -0
  21. ms365_toolkit/config/__init__.py +35 -0
  22. ms365_toolkit/config/loader.py +257 -0
  23. ms365_toolkit/intelligence/__init__.py +32 -0
  24. ms365_toolkit/intelligence/calendar_scoring.py +59 -0
  25. ms365_toolkit/intelligence/day_overview.py +58 -0
  26. ms365_toolkit/intelligence/email_triage.py +90 -0
  27. ms365_toolkit/intelligence/labels.py +94 -0
  28. ms365_toolkit/intelligence/morning_brief.py +66 -0
  29. ms365_toolkit/intelligence/thread_classifier.py +139 -0
  30. ms365_toolkit/intelligence/vip.py +15 -0
  31. ms365_toolkit/mcp/__init__.py +9 -0
  32. ms365_toolkit/mcp/__main__.py +25 -0
  33. ms365_toolkit/mcp/planner.py +88 -0
  34. ms365_toolkit/mcp/runtime.py +37 -0
  35. ms365_toolkit/mcp/serializers.py +88 -0
  36. ms365_toolkit/mcp/server.py +21 -0
  37. ms365_toolkit/mcp/tools.py +216 -0
  38. ms365_toolkit/models/__init__.py +25 -0
  39. ms365_toolkit/models/calendar.py +42 -0
  40. ms365_toolkit/models/email.py +51 -0
  41. ms365_toolkit/models/enums.py +49 -0
  42. ms365_toolkit/safety/__init__.py +29 -0
  43. ms365_toolkit/safety/allowlist.py +51 -0
  44. ms365_toolkit/safety/audit.py +87 -0
  45. ms365_toolkit/safety/canonical.py +73 -0
  46. ms365_toolkit/safety/dialog.py +94 -0
  47. ms365_toolkit/safety/domains.py +33 -0
  48. ms365_toolkit/safety/folder_allowlist.py +68 -0
  49. ms365_toolkit/safety/gate.py +232 -0
  50. ms365_toolkit/safety/rate_limiter.py +177 -0
  51. ms365_toolkit-0.1.0.dist-info/METADATA +18 -0
  52. ms365_toolkit-0.1.0.dist-info/RECORD +54 -0
  53. ms365_toolkit-0.1.0.dist-info/WHEEL +4 -0
  54. 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