ms365-toolkit 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/CHANGELOG.md +23 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/PKG-INFO +23 -11
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/README.md +21 -10
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/SECURITY.md +2 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/packages/ms365-toolkit-mcp/pyproject.toml +2 -2
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/pyproject.toml +2 -1
- ms365_toolkit-0.2.2/src/ms365_toolkit/__init__.py +1 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/__init__.py +16 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/read_common.py +9 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/usage.py +12 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/write_tools.py +32 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/attachment_text.py +5 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/calendar.py +69 -20
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/email.py +177 -14
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/teams.py +0 -1
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/config/loader.py +9 -2
- ms365_toolkit-0.2.2/src/ms365_toolkit/graph_datetime.py +166 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/cursors.py +40 -5
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/planner.py +8 -4
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/runtime.py +7 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/serializers.py +8 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/tools.py +50 -6
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/email.py +8 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/audit.py +6 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/gate.py +6 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/rate_limiter.py +1 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/usage.py +84 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_auth.py +10 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_usage.py +3 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_write_tools.py +49 -1
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_calendar.py +218 -4
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_email.py +206 -18
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_teams.py +52 -1
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_config_loader.py +16 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_mcp_cursors.py +34 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_mcp_runtime.py +28 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_mcp_server.py +320 -18
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_audit.py +5 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_usage.py +36 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/uv.lock +6 -4
- ms365_toolkit-0.2.0/src/ms365_toolkit/__init__.py +0 -1
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.claude/CLAUDE.md +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.claude/commands/consult.md +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.claude/commands/review-commit.md +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.claude/commands/review-critical.md +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.github/workflows/ci.yml +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.github/workflows/release.yml +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.gitignore +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/BUILD_PLAN.md +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/CONTRIBUTING.md +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/LICENSE +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/Makefile +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/config.example.toml +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/packages/ms365-toolkit-mcp/README.md +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/packages/ms365-toolkit-mcp/src/ms365_toolkit_mcp_wrapper/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/check_release_version.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/claude_workflow.sh +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/inspect_mcp_sessions.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/setup_mcp.sh +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/smoke_mcp.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/adapter/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/auth/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/auth/credentials.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/auth/device_code.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/auth/tokens.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/auth.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/inbox.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/labeling.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/labeling_web.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/read_tools.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/versioning.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/directory.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/exceptions.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/graph.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/mail_index.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/paging.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/sanitize.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/share_links.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/config/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/calendar_scoring.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/day_overview.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/email_triage.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/labels.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/morning_brief.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/thread_classifier.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/vip.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/__main__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/email_refs.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/server.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/calendar.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/directory.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/enums.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/teams.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/allowlist.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/canonical.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/dialog.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/domains.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/folder_allowlist.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/reply_registry.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/version_status.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/safety/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/__init__.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_auth_credentials.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_auth_device_code.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_auth_tokens.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_inbox.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_labeling.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_labeling_web.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_read_tools.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_versioning.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_directory.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_exceptions.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_graph.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_mail_index.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_sanitize.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_calendar_scoring.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_day_overview.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_email_triage.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_labels.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_morning_brief.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_vip.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_models_calendar.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_models_email.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_models_enums.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_packaging_metadata.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_allowlist.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_canonical.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_dialog.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_folder_allowlist.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_gate.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_gate_protocol.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_rate_limiter.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_setup_mcp_script.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_smoke_mcp.py +0 -0
- {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_version_status.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.2 - 2026-05-27
|
|
6
|
+
|
|
7
|
+
- Improved usage health diagnostics with stable invalid-input error codes,
|
|
8
|
+
Graph status hotspots, and recommendations for common MCP usage failures.
|
|
9
|
+
- Reduced email search fallback churn, sped up global Teams message search by
|
|
10
|
+
avoiding top-results ranking, and made focused inbox reads fall back on Graph
|
|
11
|
+
`InefficientFilter` responses.
|
|
12
|
+
- Clarified reply-draft thread preservation: `quote_mode=replace` now requires
|
|
13
|
+
explicit `preserve_thread=false`, draft responses include thread-linkage
|
|
14
|
+
status and message-header metadata, and `discard_draft` / `discard-draft`
|
|
15
|
+
safely deletes verified drafts.
|
|
16
|
+
|
|
17
|
+
## 0.2.1 - 2026-05-19
|
|
18
|
+
|
|
19
|
+
- Hardened MCP cursors, share-link downloads, attachment text extraction, domain
|
|
20
|
+
allowlist validation, calendar path encoding, delegated-calendar audit context,
|
|
21
|
+
and folder allowlist enforcement for guarded move/archive actions.
|
|
22
|
+
- Fixed Microsoft Graph calendar timezone parsing so event reads and
|
|
23
|
+
free/busy searches handle Windows timezone IDs without crashing or losing
|
|
24
|
+
calendar accuracy for common Graph zones.
|
|
25
|
+
|
|
3
26
|
## 0.2.0 - 2026-05-18
|
|
4
27
|
|
|
5
28
|
- Added guarded EA calendar write tools for creating, updating, cancelling, and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ms365-toolkit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Local-first Microsoft 365 email, calendar, and Teams toolkit with MCP support and guarded write flows
|
|
5
5
|
Project-URL: Homepage, https://github.com/sadhiappan/ms365-toolkit
|
|
6
6
|
Project-URL: Repository, https://github.com/sadhiappan/ms365-toolkit
|
|
@@ -21,6 +21,7 @@ Classifier: Topic :: Communications :: Email
|
|
|
21
21
|
Classifier: Topic :: Office/Business
|
|
22
22
|
Requires-Python: >=3.11
|
|
23
23
|
Requires-Dist: beautifulsoup4<5,>=4.12
|
|
24
|
+
Requires-Dist: idna<4,>=3.15
|
|
24
25
|
Requires-Dist: keyring<26,>=25.0
|
|
25
26
|
Requires-Dist: msal<2,>=1.31
|
|
26
27
|
Requires-Dist: openpyxl<4,>=3.1
|
|
@@ -65,6 +66,8 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
|
65
66
|
- Device-code auth uses a public-client Entra app registration; no client secret is required.
|
|
66
67
|
- Usage analytics record coarse command/tool metadata, durations, sizes, and sanitized errors, not email bodies, recipients, subjects, transcript text, raw Graph IDs, or tokens.
|
|
67
68
|
- Guarded email writes require a write token, `user.endpoint = "me"`, configured allowlists, rate limits, confirmation/audit checks, and a content hash for send.
|
|
69
|
+
- Guarded calendar writes support own and delegated/shared calendars, record the target endpoint in local audit logs, and require confirmation plus content-hash checks for mutations.
|
|
70
|
+
- MCP share-link downloads are confined to the active profile's downloads directory; use the CLI for explicit arbitrary local output paths.
|
|
68
71
|
- Microsoft Graph access is delegated and tenant-controlled. Some Teams channel-message, file, transcript, and Copilot notes features require additional scopes, admin consent, licenses, or available tenant data.
|
|
69
72
|
|
|
70
73
|
## Agent Setup
|
|
@@ -475,6 +478,7 @@ Supported v1 tools include guarded email writes:
|
|
|
475
478
|
- `create_reply_draft`
|
|
476
479
|
- `create_reply_all_draft`
|
|
477
480
|
- `send_email_draft`
|
|
481
|
+
- `discard_draft`
|
|
478
482
|
- `flag_email`
|
|
479
483
|
- `unflag_email`
|
|
480
484
|
- `move_email`
|
|
@@ -519,13 +523,17 @@ the destination folder to match `folder_allowlist`.
|
|
|
519
523
|
Use `create_email_draft` for standalone new messages, `create_reply_draft` for
|
|
520
524
|
sender-only threaded replies, and `create_reply_all_draft` for threaded
|
|
521
525
|
reply-all drafts. Reply drafts are created through Graph reply actions and
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
replace
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
526
|
+
reported with `thread_preservation_status`, `source_internet_message_id`,
|
|
527
|
+
`source_conversation_id`, `draft_in_reply_to`, and `draft_references` metadata
|
|
528
|
+
so callers can verify true thread linkage. Use `quote_mode="preserve"` for real
|
|
529
|
+
replies; `quote_mode="replace"` is only for intentionally non-thread-preserving
|
|
530
|
+
drafts and requires `preserve_thread=false` (CLI: `--no-preserve-thread`).
|
|
531
|
+
Draft results warn when a subject starts with `RE:` but the draft will not send
|
|
532
|
+
as a preserved-thread reply. Reply and reply-all drafts may include external
|
|
533
|
+
recipients who were already original thread participants; newly added external
|
|
534
|
+
recipients are still blocked unless they match `domain_allowlist`. Use
|
|
535
|
+
`discard_draft` / `discard-draft` to clean up a bad draft by draft ID after the
|
|
536
|
+
tool verifies the target is still a draft. Use `flag_email`, `unflag_email`,
|
|
529
537
|
`move_email`, and `archive_email` for guarded mailbox state changes.
|
|
530
538
|
|
|
531
539
|
## CLI Verification and Useful Commands
|
|
@@ -571,7 +579,9 @@ Try the guarded email write flow:
|
|
|
571
579
|
uvx --from ms365-toolkit@latest ms365-toolkit create-email-draft --to user@example.com --subject "Hello" --body "<p>Hello</p>"
|
|
572
580
|
uvx --from ms365-toolkit@latest ms365-toolkit create-reply-draft <message_id> --body "<p>Reply</p>"
|
|
573
581
|
uvx --from ms365-toolkit@latest ms365-toolkit create-reply-all-draft <message_id> --body "<p>Reply all</p>"
|
|
582
|
+
uvx --from ms365-toolkit@latest ms365-toolkit create-reply-draft <message_id> --body "<p>New body only</p>" --quote-mode replace --no-preserve-thread
|
|
574
583
|
uvx --from ms365-toolkit@latest ms365-toolkit send-email-draft <draft_id> <content_hash>
|
|
584
|
+
uvx --from ms365-toolkit@latest ms365-toolkit discard-draft <draft_id>
|
|
575
585
|
uvx --from ms365-toolkit@latest ms365-toolkit flag-email <message_id>
|
|
576
586
|
uvx --from ms365-toolkit@latest ms365-toolkit unflag-email <message_id>
|
|
577
587
|
uvx --from ms365-toolkit@latest ms365-toolkit move-email <message_id> <folder_id>
|
|
@@ -607,7 +617,9 @@ metrics, durations, result sizes, sanitized Graph endpoint templates, retry
|
|
|
607
617
|
counts, and sanitized error categories. They do not record email
|
|
608
618
|
bodies, subjects, recipients, message IDs, full URLs, query strings, transcript
|
|
609
619
|
text, raw Graph IDs, or tokens. Use `usage health` to identify failure hotspots,
|
|
610
|
-
slow tools, large responses, Graph endpoint hotspots, and recommended
|
|
620
|
+
slow tools, large responses, Graph endpoint/status hotspots, and recommended
|
|
621
|
+
fixes. Invalid-input failures are grouped with stable sanitized error codes
|
|
622
|
+
instead of raw user-provided values.
|
|
611
623
|
By default, `usage health` focuses on the current toolkit version when current
|
|
612
624
|
events exist; use `--scope all` to include older versions. Usage reports also
|
|
613
625
|
support `--version`, `--client`, `--surface`, and `--name` filters.
|
|
@@ -621,8 +633,8 @@ uv run python scripts/inspect_mcp_sessions.py
|
|
|
621
633
|
Pinned/reproducible MCP registration:
|
|
622
634
|
|
|
623
635
|
```bash
|
|
624
|
-
codex mcp add ms365 --env MS365_TOOLKIT_PROFILE=default --env MS365_TOOLKIT_CLIENT=codex --env MS365_TOOLKIT_WARN_STALE=1 --env UV_CACHE_DIR=/tmp/ms365-toolkit-uv-cache --env UV_TOOL_DIR=/tmp/ms365-toolkit-uv-tools -- sh -lc 'cd /tmp && exec uvx --from ms365-toolkit-mcp==0.2.
|
|
625
|
-
claude mcp add -s user ms365 -e MS365_TOOLKIT_PROFILE=default -e MS365_TOOLKIT_CLIENT=claude -e MS365_TOOLKIT_WARN_STALE=1 -e UV_CACHE_DIR=/tmp/ms365-toolkit-uv-cache -e UV_TOOL_DIR=/tmp/ms365-toolkit-uv-tools -- sh -lc 'cd /tmp && exec uvx --from ms365-toolkit-mcp==0.2.
|
|
636
|
+
codex mcp add ms365 --env MS365_TOOLKIT_PROFILE=default --env MS365_TOOLKIT_CLIENT=codex --env MS365_TOOLKIT_WARN_STALE=1 --env UV_CACHE_DIR=/tmp/ms365-toolkit-uv-cache --env UV_TOOL_DIR=/tmp/ms365-toolkit-uv-tools -- sh -lc 'cd /tmp && exec uvx --from ms365-toolkit-mcp==0.2.2 ms365-toolkit-mcp'
|
|
637
|
+
claude mcp add -s user ms365 -e MS365_TOOLKIT_PROFILE=default -e MS365_TOOLKIT_CLIENT=claude -e MS365_TOOLKIT_WARN_STALE=1 -e UV_CACHE_DIR=/tmp/ms365-toolkit-uv-cache -e UV_TOOL_DIR=/tmp/ms365-toolkit-uv-tools -- sh -lc 'cd /tmp && exec uvx --from ms365-toolkit-mcp==0.2.2 ms365-toolkit-mcp'
|
|
626
638
|
```
|
|
627
639
|
|
|
628
640
|
Pinned registrations are deterministic but can become stale. Use them only when
|
|
@@ -19,6 +19,8 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
|
19
19
|
- Device-code auth uses a public-client Entra app registration; no client secret is required.
|
|
20
20
|
- Usage analytics record coarse command/tool metadata, durations, sizes, and sanitized errors, not email bodies, recipients, subjects, transcript text, raw Graph IDs, or tokens.
|
|
21
21
|
- Guarded email writes require a write token, `user.endpoint = "me"`, configured allowlists, rate limits, confirmation/audit checks, and a content hash for send.
|
|
22
|
+
- Guarded calendar writes support own and delegated/shared calendars, record the target endpoint in local audit logs, and require confirmation plus content-hash checks for mutations.
|
|
23
|
+
- MCP share-link downloads are confined to the active profile's downloads directory; use the CLI for explicit arbitrary local output paths.
|
|
22
24
|
- Microsoft Graph access is delegated and tenant-controlled. Some Teams channel-message, file, transcript, and Copilot notes features require additional scopes, admin consent, licenses, or available tenant data.
|
|
23
25
|
|
|
24
26
|
## Agent Setup
|
|
@@ -429,6 +431,7 @@ Supported v1 tools include guarded email writes:
|
|
|
429
431
|
- `create_reply_draft`
|
|
430
432
|
- `create_reply_all_draft`
|
|
431
433
|
- `send_email_draft`
|
|
434
|
+
- `discard_draft`
|
|
432
435
|
- `flag_email`
|
|
433
436
|
- `unflag_email`
|
|
434
437
|
- `move_email`
|
|
@@ -473,13 +476,17 @@ the destination folder to match `folder_allowlist`.
|
|
|
473
476
|
Use `create_email_draft` for standalone new messages, `create_reply_draft` for
|
|
474
477
|
sender-only threaded replies, and `create_reply_all_draft` for threaded
|
|
475
478
|
reply-all drafts. Reply drafts are created through Graph reply actions and
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
replace
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
479
|
+
reported with `thread_preservation_status`, `source_internet_message_id`,
|
|
480
|
+
`source_conversation_id`, `draft_in_reply_to`, and `draft_references` metadata
|
|
481
|
+
so callers can verify true thread linkage. Use `quote_mode="preserve"` for real
|
|
482
|
+
replies; `quote_mode="replace"` is only for intentionally non-thread-preserving
|
|
483
|
+
drafts and requires `preserve_thread=false` (CLI: `--no-preserve-thread`).
|
|
484
|
+
Draft results warn when a subject starts with `RE:` but the draft will not send
|
|
485
|
+
as a preserved-thread reply. Reply and reply-all drafts may include external
|
|
486
|
+
recipients who were already original thread participants; newly added external
|
|
487
|
+
recipients are still blocked unless they match `domain_allowlist`. Use
|
|
488
|
+
`discard_draft` / `discard-draft` to clean up a bad draft by draft ID after the
|
|
489
|
+
tool verifies the target is still a draft. Use `flag_email`, `unflag_email`,
|
|
483
490
|
`move_email`, and `archive_email` for guarded mailbox state changes.
|
|
484
491
|
|
|
485
492
|
## CLI Verification and Useful Commands
|
|
@@ -525,7 +532,9 @@ Try the guarded email write flow:
|
|
|
525
532
|
uvx --from ms365-toolkit@latest ms365-toolkit create-email-draft --to user@example.com --subject "Hello" --body "<p>Hello</p>"
|
|
526
533
|
uvx --from ms365-toolkit@latest ms365-toolkit create-reply-draft <message_id> --body "<p>Reply</p>"
|
|
527
534
|
uvx --from ms365-toolkit@latest ms365-toolkit create-reply-all-draft <message_id> --body "<p>Reply all</p>"
|
|
535
|
+
uvx --from ms365-toolkit@latest ms365-toolkit create-reply-draft <message_id> --body "<p>New body only</p>" --quote-mode replace --no-preserve-thread
|
|
528
536
|
uvx --from ms365-toolkit@latest ms365-toolkit send-email-draft <draft_id> <content_hash>
|
|
537
|
+
uvx --from ms365-toolkit@latest ms365-toolkit discard-draft <draft_id>
|
|
529
538
|
uvx --from ms365-toolkit@latest ms365-toolkit flag-email <message_id>
|
|
530
539
|
uvx --from ms365-toolkit@latest ms365-toolkit unflag-email <message_id>
|
|
531
540
|
uvx --from ms365-toolkit@latest ms365-toolkit move-email <message_id> <folder_id>
|
|
@@ -561,7 +570,9 @@ metrics, durations, result sizes, sanitized Graph endpoint templates, retry
|
|
|
561
570
|
counts, and sanitized error categories. They do not record email
|
|
562
571
|
bodies, subjects, recipients, message IDs, full URLs, query strings, transcript
|
|
563
572
|
text, raw Graph IDs, or tokens. Use `usage health` to identify failure hotspots,
|
|
564
|
-
slow tools, large responses, Graph endpoint hotspots, and recommended
|
|
573
|
+
slow tools, large responses, Graph endpoint/status hotspots, and recommended
|
|
574
|
+
fixes. Invalid-input failures are grouped with stable sanitized error codes
|
|
575
|
+
instead of raw user-provided values.
|
|
565
576
|
By default, `usage health` focuses on the current toolkit version when current
|
|
566
577
|
events exist; use `--scope all` to include older versions. Usage reports also
|
|
567
578
|
support `--version`, `--client`, `--surface`, and `--name` filters.
|
|
@@ -575,8 +586,8 @@ uv run python scripts/inspect_mcp_sessions.py
|
|
|
575
586
|
Pinned/reproducible MCP registration:
|
|
576
587
|
|
|
577
588
|
```bash
|
|
578
|
-
codex mcp add ms365 --env MS365_TOOLKIT_PROFILE=default --env MS365_TOOLKIT_CLIENT=codex --env MS365_TOOLKIT_WARN_STALE=1 --env UV_CACHE_DIR=/tmp/ms365-toolkit-uv-cache --env UV_TOOL_DIR=/tmp/ms365-toolkit-uv-tools -- sh -lc 'cd /tmp && exec uvx --from ms365-toolkit-mcp==0.2.
|
|
579
|
-
claude mcp add -s user ms365 -e MS365_TOOLKIT_PROFILE=default -e MS365_TOOLKIT_CLIENT=claude -e MS365_TOOLKIT_WARN_STALE=1 -e UV_CACHE_DIR=/tmp/ms365-toolkit-uv-cache -e UV_TOOL_DIR=/tmp/ms365-toolkit-uv-tools -- sh -lc 'cd /tmp && exec uvx --from ms365-toolkit-mcp==0.2.
|
|
589
|
+
codex mcp add ms365 --env MS365_TOOLKIT_PROFILE=default --env MS365_TOOLKIT_CLIENT=codex --env MS365_TOOLKIT_WARN_STALE=1 --env UV_CACHE_DIR=/tmp/ms365-toolkit-uv-cache --env UV_TOOL_DIR=/tmp/ms365-toolkit-uv-tools -- sh -lc 'cd /tmp && exec uvx --from ms365-toolkit-mcp==0.2.2 ms365-toolkit-mcp'
|
|
590
|
+
claude mcp add -s user ms365 -e MS365_TOOLKIT_PROFILE=default -e MS365_TOOLKIT_CLIENT=claude -e MS365_TOOLKIT_WARN_STALE=1 -e UV_CACHE_DIR=/tmp/ms365-toolkit-uv-cache -e UV_TOOL_DIR=/tmp/ms365-toolkit-uv-tools -- sh -lc 'cd /tmp && exec uvx --from ms365-toolkit-mcp==0.2.2 ms365-toolkit-mcp'
|
|
580
591
|
```
|
|
581
592
|
|
|
582
593
|
Pinned registrations are deterministic but can become stale. Use them only when
|
|
@@ -19,6 +19,8 @@ Security fixes are expected to target the latest published version. Users should
|
|
|
19
19
|
- No client secret is required or supported for normal device-code auth.
|
|
20
20
|
- MCP and CLI tools use delegated Microsoft Graph permissions granted by the tenant.
|
|
21
21
|
- Guarded write actions require an explicit write token, configured safety gates, allowlist checks, rate limits, and audit logging.
|
|
22
|
+
- MCP continuation cursors are signed by the local server process and are rejected if tampered with or reused after the server secret changes.
|
|
23
|
+
- MCP share-link downloads are confined to the active profile's downloads directory and enforce a maximum downloaded file size before writing.
|
|
22
24
|
- Profile names are treated as local path components and must be simple names, not paths.
|
|
23
25
|
- The local labeling UI binds to loopback hosts only and requires a per-server CSRF token for label updates.
|
|
24
26
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ms365-toolkit-mcp"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Installable MCP entrypoint for ms365-toolkit"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -27,7 +27,7 @@ classifiers = [
|
|
|
27
27
|
"Topic :: Office/Business",
|
|
28
28
|
]
|
|
29
29
|
dependencies = [
|
|
30
|
-
"ms365-toolkit[mcp]==0.2.
|
|
30
|
+
"ms365-toolkit[mcp]==0.2.2",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
33
|
[project.scripts]
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ms365-toolkit"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Local-first Microsoft 365 email, calendar, and Teams toolkit with MCP support and guarded write flows"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -32,6 +32,7 @@ dependencies = [
|
|
|
32
32
|
"msal>=1.31,<2",
|
|
33
33
|
"pyjwt>=2.8,<3",
|
|
34
34
|
"beautifulsoup4>=4.12,<5",
|
|
35
|
+
"idna>=3.15,<4",
|
|
35
36
|
"keyring>=25.0,<26",
|
|
36
37
|
"openpyxl>=3.1,<4",
|
|
37
38
|
"packaging>=24,<26",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
|
@@ -64,6 +64,7 @@ from ms365_toolkit.cli.write_tools import (
|
|
|
64
64
|
create_event_command,
|
|
65
65
|
create_reply_all_draft_command,
|
|
66
66
|
create_reply_draft_command,
|
|
67
|
+
discard_draft_command,
|
|
67
68
|
flag_email_command,
|
|
68
69
|
move_email_command,
|
|
69
70
|
respond_event_command,
|
|
@@ -266,6 +267,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
266
267
|
choices=("preserve", "replace"),
|
|
267
268
|
default="preserve",
|
|
268
269
|
)
|
|
270
|
+
create_reply_draft_parser.add_argument(
|
|
271
|
+
"--preserve-thread",
|
|
272
|
+
action=argparse.BooleanOptionalAction,
|
|
273
|
+
default=True,
|
|
274
|
+
)
|
|
269
275
|
create_reply_draft_parser.set_defaults(handler=create_reply_draft_command)
|
|
270
276
|
|
|
271
277
|
create_reply_all_draft_parser = subparsers.add_parser("create-reply-all-draft")
|
|
@@ -281,6 +287,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
281
287
|
choices=("preserve", "replace"),
|
|
282
288
|
default="preserve",
|
|
283
289
|
)
|
|
290
|
+
create_reply_all_draft_parser.add_argument(
|
|
291
|
+
"--preserve-thread",
|
|
292
|
+
action=argparse.BooleanOptionalAction,
|
|
293
|
+
default=True,
|
|
294
|
+
)
|
|
284
295
|
create_reply_all_draft_parser.set_defaults(handler=create_reply_all_draft_command)
|
|
285
296
|
|
|
286
297
|
send_email_draft_parser = subparsers.add_parser("send-email-draft")
|
|
@@ -288,6 +299,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
288
299
|
send_email_draft_parser.add_argument("content_hash")
|
|
289
300
|
send_email_draft_parser.set_defaults(handler=send_email_draft_command)
|
|
290
301
|
|
|
302
|
+
discard_draft_parser = subparsers.add_parser("discard-draft")
|
|
303
|
+
discard_draft_parser.add_argument("draft_id")
|
|
304
|
+
discard_draft_parser.set_defaults(handler=discard_draft_command)
|
|
305
|
+
|
|
291
306
|
flag_email_parser = subparsers.add_parser("flag-email")
|
|
292
307
|
flag_email_parser.add_argument("message_id")
|
|
293
308
|
flag_email_parser.set_defaults(handler=flag_email_command)
|
|
@@ -642,6 +657,7 @@ def _is_write_intent(args: argparse.Namespace, name: str) -> bool:
|
|
|
642
657
|
"create-reply-draft",
|
|
643
658
|
"create-reply-all-draft",
|
|
644
659
|
"send-email-draft",
|
|
660
|
+
"discard-draft",
|
|
645
661
|
"flag-email",
|
|
646
662
|
"unflag-email",
|
|
647
663
|
"move-email",
|
|
@@ -12,6 +12,7 @@ from ms365_toolkit.client.mail_index import MailboxIndex
|
|
|
12
12
|
from ms365_toolkit.config.loader import get_profile_dir
|
|
13
13
|
from ms365_toolkit.safety.audit import AuditLogger
|
|
14
14
|
from ms365_toolkit.safety.dialog import show_confirmation_dialog
|
|
15
|
+
from ms365_toolkit.safety.folder_allowlist import FolderAllowlistChecker
|
|
15
16
|
from ms365_toolkit.safety.gate import ConfiguredSafetyGate, DialogPreview, SafetyResult
|
|
16
17
|
from ms365_toolkit.safety.rate_limiter import RateLimiter
|
|
17
18
|
from ms365_toolkit.safety.reply_registry import ReplyDraftSafetyRegistry
|
|
@@ -87,6 +88,14 @@ def build_safety_gate(config: ToolkitConfig, profile: str) -> ConfiguredSafetyGa
|
|
|
87
88
|
),
|
|
88
89
|
audit_logger=AuditLogger(path=safety_dir / "audit.jsonl"),
|
|
89
90
|
dialog=_confirmation_dialog,
|
|
91
|
+
folder_checker=FolderAllowlistChecker(
|
|
92
|
+
profile=profile,
|
|
93
|
+
allowed_folders=config.safety.folder_allowlist,
|
|
94
|
+
folder_provider=lambda: EmailClient(
|
|
95
|
+
build_read_graph_client(config, profile)
|
|
96
|
+
).list_folders(),
|
|
97
|
+
cache_path=safety_dir / "folders.json",
|
|
98
|
+
),
|
|
90
99
|
)
|
|
91
100
|
|
|
92
101
|
|
|
@@ -143,6 +143,11 @@ def usage_health_command(args: Namespace, config: ToolkitConfig) -> int:
|
|
|
143
143
|
_print_section("Slow tools", report["slow_tools"], _format_slow_tool)
|
|
144
144
|
_print_section("Large responses", report["large_responses"], _format_large_response)
|
|
145
145
|
_print_section("Graph hotspots", report["graph_hotspots"], _format_graph_hotspot)
|
|
146
|
+
_print_section(
|
|
147
|
+
"Graph status hotspots",
|
|
148
|
+
report["graph_status_hotspots"],
|
|
149
|
+
_format_graph_status_hotspot,
|
|
150
|
+
)
|
|
146
151
|
_print_section("Recommendations", report["recommendations"], str)
|
|
147
152
|
return 0
|
|
148
153
|
|
|
@@ -250,3 +255,10 @@ def _format_graph_hotspot(row: dict[str, object]) -> str:
|
|
|
250
255
|
f"{row['endpoint']} count={row['count']} "
|
|
251
256
|
f"duration={row['duration_ms']}ms"
|
|
252
257
|
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _format_graph_status_hotspot(row: dict[str, object]) -> str:
|
|
261
|
+
return (
|
|
262
|
+
f"{row['name']} status={row['status']} count={row['count']} "
|
|
263
|
+
f"latest={row['latest_timestamp']}"
|
|
264
|
+
)
|
|
@@ -53,6 +53,7 @@ def create_reply_draft_command(args: Namespace, config: ToolkitConfig) -> int:
|
|
|
53
53
|
body=_body_value(args),
|
|
54
54
|
importance=_optional_importance_value(args),
|
|
55
55
|
quote_mode=args.quote_mode,
|
|
56
|
+
preserve_thread=args.preserve_thread,
|
|
56
57
|
)
|
|
57
58
|
except (
|
|
58
59
|
AuthenticationError,
|
|
@@ -74,6 +75,7 @@ def create_reply_all_draft_command(args: Namespace, config: ToolkitConfig) -> in
|
|
|
74
75
|
body=_body_value(args),
|
|
75
76
|
importance=_optional_importance_value(args),
|
|
76
77
|
quote_mode=args.quote_mode,
|
|
78
|
+
preserve_thread=args.preserve_thread,
|
|
77
79
|
)
|
|
78
80
|
except (
|
|
79
81
|
AuthenticationError,
|
|
@@ -108,6 +110,24 @@ def send_email_draft_command(args: Namespace, config: ToolkitConfig) -> int:
|
|
|
108
110
|
return 0
|
|
109
111
|
|
|
110
112
|
|
|
113
|
+
def discard_draft_command(args: Namespace, config: ToolkitConfig) -> int:
|
|
114
|
+
try:
|
|
115
|
+
result = build_write_email_client(config, args.profile).discard_draft(
|
|
116
|
+
args.draft_id.strip()
|
|
117
|
+
)
|
|
118
|
+
except (
|
|
119
|
+
AuthenticationError,
|
|
120
|
+
GraphAPIError,
|
|
121
|
+
RateLimitedError,
|
|
122
|
+
SafetyDeniedError,
|
|
123
|
+
OSError,
|
|
124
|
+
ValueError,
|
|
125
|
+
) as exc:
|
|
126
|
+
return _print_write_error(exc)
|
|
127
|
+
_print_action_result(result)
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
111
131
|
def flag_email_command(args: Namespace, config: ToolkitConfig) -> int:
|
|
112
132
|
return _message_state_command(args, config, flagged=True)
|
|
113
133
|
|
|
@@ -355,9 +375,21 @@ def _print_email_draft(draft: EmailDraft) -> None:
|
|
|
355
375
|
print(f"Importance: {draft.importance}")
|
|
356
376
|
if draft.is_threaded_reply:
|
|
357
377
|
print("Threaded Reply: yes")
|
|
378
|
+
if draft.quote_mode != "replace" or draft.source_conversation_id or draft.thread_warnings:
|
|
358
379
|
print(f"Quote Mode: {draft.quote_mode}")
|
|
380
|
+
print(f"Thread Preservation: {draft.thread_preservation_status}")
|
|
381
|
+
for warning in draft.thread_warnings:
|
|
382
|
+
print(f"Warning: {warning}")
|
|
359
383
|
if draft.conversation_id:
|
|
360
384
|
print(f"Conversation ID: {draft.conversation_id}")
|
|
385
|
+
if draft.source_conversation_id:
|
|
386
|
+
print(f"Source Conversation ID: {draft.source_conversation_id}")
|
|
387
|
+
if draft.source_internet_message_id:
|
|
388
|
+
print(f"Source Internet Message ID: {draft.source_internet_message_id}")
|
|
389
|
+
if draft.draft_in_reply_to:
|
|
390
|
+
print(f"Draft In-Reply-To: {draft.draft_in_reply_to}")
|
|
391
|
+
if draft.draft_references:
|
|
392
|
+
print(f"Draft References Source: {'yes' if draft.draft_references_source else 'no'}")
|
|
361
393
|
print(f"To: {', '.join(draft.to) or '(none)'}")
|
|
362
394
|
if draft.cc:
|
|
363
395
|
print(f"Cc: {', '.join(draft.cc)}")
|
|
@@ -16,6 +16,7 @@ _CONTENT_TYPE_FORMATS = {
|
|
|
16
16
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
17
17
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
18
18
|
}
|
|
19
|
+
MAX_EXTRACTABLE_BYTES = 25 * 1024 * 1024
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def extract_attachment_text(
|
|
@@ -26,6 +27,10 @@ def extract_attachment_text(
|
|
|
26
27
|
) -> ExtractedAttachment:
|
|
27
28
|
if max_chars <= 0:
|
|
28
29
|
raise ValueError("max_chars must be a positive integer")
|
|
30
|
+
if len(content) > MAX_EXTRACTABLE_BYTES:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"attachment exceeds {MAX_EXTRACTABLE_BYTES} byte text extraction limit"
|
|
33
|
+
)
|
|
29
34
|
format_name = _detect_format(attachment)
|
|
30
35
|
extractor = {
|
|
31
36
|
"pdf": _extract_pdf_text,
|
|
@@ -6,13 +6,13 @@ from dataclasses import replace
|
|
|
6
6
|
from datetime import UTC, datetime, timedelta
|
|
7
7
|
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
|
|
8
8
|
from urllib.parse import quote
|
|
9
|
-
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
10
9
|
|
|
11
10
|
import jwt
|
|
12
11
|
|
|
13
12
|
from ms365_toolkit.client.exceptions import AuthenticationError, GraphAPIError, SafetyDeniedError
|
|
14
13
|
from ms365_toolkit.client.paging import GraphPage, graph_next_link
|
|
15
14
|
from ms365_toolkit.client.sanitize import strip_html
|
|
15
|
+
from ms365_toolkit.graph_datetime import graph_zoneinfo, parse_graph_datetime
|
|
16
16
|
from ms365_toolkit.models.calendar import (
|
|
17
17
|
Attendee,
|
|
18
18
|
CalendarActionResult,
|
|
@@ -94,7 +94,7 @@ class CalendarClient:
|
|
|
94
94
|
return self._events_page_from_response(response)
|
|
95
95
|
if start is None or end is None:
|
|
96
96
|
raise ValueError("start and end are required unless next_link is provided")
|
|
97
|
-
path =
|
|
97
|
+
path = _calendar_view_path(calendar_id)
|
|
98
98
|
query: dict[str, Any] = {
|
|
99
99
|
"startDateTime": _to_graph_datetime(start),
|
|
100
100
|
"endDateTime": _to_graph_datetime(end),
|
|
@@ -106,7 +106,7 @@ class CalendarClient:
|
|
|
106
106
|
return self._events_page_from_response(response)
|
|
107
107
|
|
|
108
108
|
def get_event(self, event_id: str) -> CalendarEvent:
|
|
109
|
-
response = self.graph.get(
|
|
109
|
+
response = self.graph.get(_event_path(event_id))
|
|
110
110
|
return self._event_from_item(response)
|
|
111
111
|
|
|
112
112
|
def find_free_slots(
|
|
@@ -131,8 +131,8 @@ class CalendarClient:
|
|
|
131
131
|
for item in schedule.get("scheduleItems", []):
|
|
132
132
|
slots.append(
|
|
133
133
|
FreeBusySlot(
|
|
134
|
-
start=_parse_graph_datetime(item.get("start", {})
|
|
135
|
-
end=_parse_graph_datetime(item.get("end", {})
|
|
134
|
+
start=_parse_graph_datetime(item.get("start", {})),
|
|
135
|
+
end=_parse_graph_datetime(item.get("end", {})),
|
|
136
136
|
status=item.get("status", "unknown"),
|
|
137
137
|
)
|
|
138
138
|
)
|
|
@@ -246,8 +246,14 @@ class CalendarClient:
|
|
|
246
246
|
recipients=attendees,
|
|
247
247
|
dialog_preview=DialogPreview(
|
|
248
248
|
title="Confirm Calendar Event Create",
|
|
249
|
-
body=_event_dialog_body(
|
|
249
|
+
body=_event_dialog_body(
|
|
250
|
+
event,
|
|
251
|
+
action="Create",
|
|
252
|
+
target=_calendar_target_label(self.graph.config),
|
|
253
|
+
),
|
|
250
254
|
),
|
|
255
|
+
target_endpoint=self.graph.config.user.endpoint,
|
|
256
|
+
target_user=self.graph.config.user.user_principal_name,
|
|
251
257
|
)
|
|
252
258
|
)
|
|
253
259
|
item = self.graph.post(_events_collection_path(calendar_id), body=payload)
|
|
@@ -332,10 +338,16 @@ class CalendarClient:
|
|
|
332
338
|
domain_exempt_recipients=existing_recipients,
|
|
333
339
|
dialog_preview=DialogPreview(
|
|
334
340
|
title="Confirm Calendar Event Update",
|
|
335
|
-
body=_event_dialog_body(
|
|
341
|
+
body=_event_dialog_body(
|
|
342
|
+
updated_event,
|
|
343
|
+
action="Update",
|
|
344
|
+
target=_calendar_target_label(self.graph.config),
|
|
345
|
+
),
|
|
336
346
|
),
|
|
337
347
|
ical_uid=current.ical_uid,
|
|
338
348
|
operation_id=current.ical_uid or current.id,
|
|
349
|
+
target_endpoint=self.graph.config.user.endpoint,
|
|
350
|
+
target_user=self.graph.config.user.user_principal_name,
|
|
339
351
|
)
|
|
340
352
|
)
|
|
341
353
|
item = self.graph.patch(_event_path(normalized_event_id, calendar_id), body=payload)
|
|
@@ -372,10 +384,17 @@ class CalendarClient:
|
|
|
372
384
|
domain_exempt_recipients=recipients,
|
|
373
385
|
dialog_preview=DialogPreview(
|
|
374
386
|
title="Confirm Calendar Event Cancel",
|
|
375
|
-
body=_event_dialog_body(
|
|
387
|
+
body=_event_dialog_body(
|
|
388
|
+
current,
|
|
389
|
+
action="Cancel",
|
|
390
|
+
comment=comment,
|
|
391
|
+
target=_calendar_target_label(self.graph.config),
|
|
392
|
+
),
|
|
376
393
|
),
|
|
377
394
|
ical_uid=current.ical_uid,
|
|
378
395
|
operation_id=current.ical_uid or current.id,
|
|
396
|
+
target_endpoint=self.graph.config.user.endpoint,
|
|
397
|
+
target_user=self.graph.config.user.user_principal_name,
|
|
379
398
|
)
|
|
380
399
|
)
|
|
381
400
|
self.graph.post(
|
|
@@ -419,10 +438,13 @@ class CalendarClient:
|
|
|
419
438
|
current,
|
|
420
439
|
action=f"Respond {normalized_response}",
|
|
421
440
|
comment=comment,
|
|
441
|
+
target=_calendar_target_label(self.graph.config),
|
|
422
442
|
),
|
|
423
443
|
),
|
|
424
444
|
ical_uid=current.ical_uid,
|
|
425
445
|
operation_id=f"{current.ical_uid or current.id}:{normalized_response}",
|
|
446
|
+
target_endpoint=self.graph.config.user.endpoint,
|
|
447
|
+
target_user=self.graph.config.user.user_principal_name,
|
|
426
448
|
)
|
|
427
449
|
)
|
|
428
450
|
self.graph.post(
|
|
@@ -591,8 +613,8 @@ class CalendarClient:
|
|
|
591
613
|
event = CalendarEvent(
|
|
592
614
|
id=item.get("id", ""),
|
|
593
615
|
subject=item.get("subject", ""),
|
|
594
|
-
start=_parse_graph_datetime(start_time
|
|
595
|
-
end=_parse_graph_datetime(end_time
|
|
616
|
+
start=_parse_graph_datetime(start_time),
|
|
617
|
+
end=_parse_graph_datetime(end_time),
|
|
596
618
|
timezone=start_time.get("timeZone", "UTC"),
|
|
597
619
|
location=location,
|
|
598
620
|
body_content=strip_html(body),
|
|
@@ -877,6 +899,13 @@ def _events_collection_path(calendar_id: str | None = None) -> str:
|
|
|
877
899
|
return f"calendars/{_path_segment('calendar_id', normalized_calendar_id)}/events"
|
|
878
900
|
|
|
879
901
|
|
|
902
|
+
def _calendar_view_path(calendar_id: str | None = None) -> str:
|
|
903
|
+
normalized_calendar_id = (calendar_id or "").strip()
|
|
904
|
+
if not normalized_calendar_id:
|
|
905
|
+
return "calendarView"
|
|
906
|
+
return f"calendars/{_path_segment('calendar_id', normalized_calendar_id)}/calendarView"
|
|
907
|
+
|
|
908
|
+
|
|
880
909
|
def _event_path(event_id: str, calendar_id: str | None = None) -> str:
|
|
881
910
|
normalized_event_id = _required_text(event_id, "event_id")
|
|
882
911
|
normalized_calendar_id = (calendar_id or "").strip()
|
|
@@ -1084,8 +1113,8 @@ def _datetime_for_timezone(value: datetime, timezone: str) -> datetime:
|
|
|
1084
1113
|
if timezone.upper() == "UTC":
|
|
1085
1114
|
return value.astimezone(UTC)
|
|
1086
1115
|
try:
|
|
1087
|
-
return value.astimezone(
|
|
1088
|
-
except
|
|
1116
|
+
return value.astimezone(graph_zoneinfo(timezone))
|
|
1117
|
+
except ValueError:
|
|
1089
1118
|
return value
|
|
1090
1119
|
|
|
1091
1120
|
|
|
@@ -1136,16 +1165,28 @@ def _required_text(value: str, field: str) -> str:
|
|
|
1136
1165
|
return normalized
|
|
1137
1166
|
|
|
1138
1167
|
|
|
1139
|
-
def _event_dialog_body(
|
|
1168
|
+
def _event_dialog_body(
|
|
1169
|
+
event: CalendarEvent,
|
|
1170
|
+
*,
|
|
1171
|
+
action: str,
|
|
1172
|
+
comment: str = "",
|
|
1173
|
+
target: str = "",
|
|
1174
|
+
) -> str:
|
|
1140
1175
|
attendees = ", ".join(attendee.email for attendee in event.attendees) or "(none)"
|
|
1141
1176
|
parts = [
|
|
1142
1177
|
f"Action: {action}",
|
|
1143
|
-
f"Subject: {event.subject or '(no subject)'}",
|
|
1144
|
-
f"Start: {event.start.isoformat()}",
|
|
1145
|
-
f"End: {event.end.isoformat()}",
|
|
1146
|
-
f"Location: {event.location or '(none)'}",
|
|
1147
|
-
f"Attendees: {attendees}",
|
|
1148
1178
|
]
|
|
1179
|
+
if target:
|
|
1180
|
+
parts.append(f"Calendar Target: {target}")
|
|
1181
|
+
parts.extend(
|
|
1182
|
+
[
|
|
1183
|
+
f"Subject: {event.subject or '(no subject)'}",
|
|
1184
|
+
f"Start: {event.start.isoformat()}",
|
|
1185
|
+
f"End: {event.end.isoformat()}",
|
|
1186
|
+
f"Location: {event.location or '(none)'}",
|
|
1187
|
+
f"Attendees: {attendees}",
|
|
1188
|
+
]
|
|
1189
|
+
)
|
|
1149
1190
|
if comment:
|
|
1150
1191
|
parts.append(f"Comment: {comment}")
|
|
1151
1192
|
if event.body_content:
|
|
@@ -1153,6 +1194,12 @@ def _event_dialog_body(event: CalendarEvent, *, action: str, comment: str = "")
|
|
|
1153
1194
|
return "\n".join(parts)
|
|
1154
1195
|
|
|
1155
1196
|
|
|
1197
|
+
def _calendar_target_label(config: Any) -> str:
|
|
1198
|
+
if config.user.endpoint == "users":
|
|
1199
|
+
return f"users/{config.user.user_principal_name}"
|
|
1200
|
+
return "me"
|
|
1201
|
+
|
|
1202
|
+
|
|
1156
1203
|
def _calendar_action_result(
|
|
1157
1204
|
event: CalendarEvent,
|
|
1158
1205
|
*,
|
|
@@ -1196,9 +1243,11 @@ def _to_graph_datetime(value: datetime) -> str:
|
|
|
1196
1243
|
|
|
1197
1244
|
|
|
1198
1245
|
def _parse_graph_datetime(value: Any) -> datetime:
|
|
1199
|
-
if
|
|
1246
|
+
if isinstance(value, str):
|
|
1247
|
+
return parse_graph_datetime(value)
|
|
1248
|
+
if not isinstance(value, dict):
|
|
1200
1249
|
return datetime.fromtimestamp(0, tz=UTC)
|
|
1201
|
-
return
|
|
1250
|
+
return parse_graph_datetime(value.get("dateTime"), value.get("timeZone", "UTC"))
|
|
1202
1251
|
|
|
1203
1252
|
|
|
1204
1253
|
def _extract_transcript_segments(content: str) -> tuple[TranscriptSegment, ...]:
|