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.
Files changed (141) hide show
  1. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/CHANGELOG.md +23 -0
  2. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/PKG-INFO +23 -11
  3. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/README.md +21 -10
  4. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/SECURITY.md +2 -0
  5. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/packages/ms365-toolkit-mcp/pyproject.toml +2 -2
  6. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/pyproject.toml +2 -1
  7. ms365_toolkit-0.2.2/src/ms365_toolkit/__init__.py +1 -0
  8. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/__init__.py +16 -0
  9. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/read_common.py +9 -0
  10. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/usage.py +12 -0
  11. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/write_tools.py +32 -0
  12. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/attachment_text.py +5 -0
  13. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/calendar.py +69 -20
  14. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/email.py +177 -14
  15. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/teams.py +0 -1
  16. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/config/loader.py +9 -2
  17. ms365_toolkit-0.2.2/src/ms365_toolkit/graph_datetime.py +166 -0
  18. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/cursors.py +40 -5
  19. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/planner.py +8 -4
  20. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/runtime.py +7 -0
  21. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/serializers.py +8 -0
  22. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/tools.py +50 -6
  23. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/email.py +8 -0
  24. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/audit.py +6 -0
  25. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/gate.py +6 -0
  26. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/rate_limiter.py +1 -0
  27. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/usage.py +84 -0
  28. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_auth.py +10 -0
  29. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_usage.py +3 -0
  30. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_write_tools.py +49 -1
  31. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_calendar.py +218 -4
  32. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_email.py +206 -18
  33. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_teams.py +52 -1
  34. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_config_loader.py +16 -0
  35. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_mcp_cursors.py +34 -0
  36. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_mcp_runtime.py +28 -0
  37. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_mcp_server.py +320 -18
  38. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_audit.py +5 -0
  39. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_usage.py +36 -0
  40. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/uv.lock +6 -4
  41. ms365_toolkit-0.2.0/src/ms365_toolkit/__init__.py +0 -1
  42. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.claude/CLAUDE.md +0 -0
  43. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.claude/commands/consult.md +0 -0
  44. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.claude/commands/review-commit.md +0 -0
  45. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.claude/commands/review-critical.md +0 -0
  46. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.github/workflows/ci.yml +0 -0
  47. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.github/workflows/release.yml +0 -0
  48. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/.gitignore +0 -0
  49. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/BUILD_PLAN.md +0 -0
  50. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/CONTRIBUTING.md +0 -0
  51. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/LICENSE +0 -0
  52. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/Makefile +0 -0
  53. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/config.example.toml +0 -0
  54. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/packages/ms365-toolkit-mcp/README.md +0 -0
  55. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/packages/ms365-toolkit-mcp/src/ms365_toolkit_mcp_wrapper/__init__.py +0 -0
  56. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/check_release_version.py +0 -0
  57. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/claude_workflow.sh +0 -0
  58. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/inspect_mcp_sessions.py +0 -0
  59. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/setup_mcp.sh +0 -0
  60. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/scripts/smoke_mcp.py +0 -0
  61. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/adapter/__init__.py +0 -0
  62. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/auth/__init__.py +0 -0
  63. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/auth/credentials.py +0 -0
  64. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/auth/device_code.py +0 -0
  65. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/auth/tokens.py +0 -0
  66. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/auth.py +0 -0
  67. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/inbox.py +0 -0
  68. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/labeling.py +0 -0
  69. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/labeling_web.py +0 -0
  70. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/read_tools.py +0 -0
  71. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/cli/versioning.py +0 -0
  72. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/__init__.py +0 -0
  73. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/directory.py +0 -0
  74. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/exceptions.py +0 -0
  75. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/graph.py +0 -0
  76. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/mail_index.py +0 -0
  77. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/paging.py +0 -0
  78. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/sanitize.py +0 -0
  79. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/client/share_links.py +0 -0
  80. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/config/__init__.py +0 -0
  81. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/__init__.py +0 -0
  82. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/calendar_scoring.py +0 -0
  83. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/day_overview.py +0 -0
  84. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/email_triage.py +0 -0
  85. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/labels.py +0 -0
  86. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/morning_brief.py +0 -0
  87. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/thread_classifier.py +0 -0
  88. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/intelligence/vip.py +0 -0
  89. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/__init__.py +0 -0
  90. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/__main__.py +0 -0
  91. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/email_refs.py +0 -0
  92. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/mcp/server.py +0 -0
  93. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/__init__.py +0 -0
  94. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/calendar.py +0 -0
  95. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/directory.py +0 -0
  96. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/enums.py +0 -0
  97. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/models/teams.py +0 -0
  98. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/__init__.py +0 -0
  99. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/allowlist.py +0 -0
  100. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/canonical.py +0 -0
  101. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/dialog.py +0 -0
  102. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/domains.py +0 -0
  103. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/folder_allowlist.py +0 -0
  104. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/safety/reply_registry.py +0 -0
  105. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/src/ms365_toolkit/version_status.py +0 -0
  106. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/__init__.py +0 -0
  107. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/safety/__init__.py +0 -0
  108. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/__init__.py +0 -0
  109. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_auth_credentials.py +0 -0
  110. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_auth_device_code.py +0 -0
  111. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_auth_tokens.py +0 -0
  112. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_inbox.py +0 -0
  113. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_labeling.py +0 -0
  114. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_labeling_web.py +0 -0
  115. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_read_tools.py +0 -0
  116. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_cli_versioning.py +0 -0
  117. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_directory.py +0 -0
  118. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_exceptions.py +0 -0
  119. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_graph.py +0 -0
  120. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_mail_index.py +0 -0
  121. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_client_sanitize.py +0 -0
  122. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_calendar_scoring.py +0 -0
  123. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_day_overview.py +0 -0
  124. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_email_triage.py +0 -0
  125. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_labels.py +0 -0
  126. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_morning_brief.py +0 -0
  127. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_intelligence_vip.py +0 -0
  128. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_models_calendar.py +0 -0
  129. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_models_email.py +0 -0
  130. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_models_enums.py +0 -0
  131. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_packaging_metadata.py +0 -0
  132. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_allowlist.py +0 -0
  133. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_canonical.py +0 -0
  134. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_dialog.py +0 -0
  135. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_folder_allowlist.py +0 -0
  136. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_gate.py +0 -0
  137. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_gate_protocol.py +0 -0
  138. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_safety_rate_limiter.py +0 -0
  139. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_setup_mcp_script.py +0 -0
  140. {ms365_toolkit-0.2.0 → ms365_toolkit-0.2.2}/tests/unit/test_smoke_mcp.py +0 -0
  141. {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.0
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
- verified against the source conversation before they are returned. Reply and
523
- reply-all drafts preserve Outlook's generated original message chain by default;
524
- pass `quote_mode="replace"` in MCP or `--quote-mode replace` in the CLI to
525
- replace the generated quoted body. Reply and reply-all drafts may include
526
- external recipients who were already original thread participants; newly added
527
- external recipients are still blocked unless they match `domain_allowlist`. Use
528
- `flag_email`, `unflag_email`,
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 fixes.
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.0 ms365-toolkit-mcp'
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.0 ms365-toolkit-mcp'
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
- verified against the source conversation before they are returned. Reply and
477
- reply-all drafts preserve Outlook's generated original message chain by default;
478
- pass `quote_mode="replace"` in MCP or `--quote-mode replace` in the CLI to
479
- replace the generated quoted body. Reply and reply-all drafts may include
480
- external recipients who were already original thread participants; newly added
481
- external recipients are still blocked unless they match `domain_allowlist`. Use
482
- `flag_email`, `unflag_email`,
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 fixes.
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.0 ms365-toolkit-mcp'
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.0 ms365-toolkit-mcp'
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.0"
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.0",
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.0"
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 = f"calendars/{calendar_id}/calendarView" if calendar_id else "calendarView"
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(f"events/{event_id}")
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", {}).get("dateTime")),
135
- end=_parse_graph_datetime(item.get("end", {}).get("dateTime")),
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(event, action="Create"),
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(updated_event, action="Update"),
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(current, action="Cancel", comment=comment),
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.get("dateTime")),
595
- end=_parse_graph_datetime(end_time.get("dateTime")),
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(ZoneInfo(timezone))
1088
- except ZoneInfoNotFoundError:
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(event: CalendarEvent, *, action: str, comment: str = "") -> str:
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 not isinstance(value, str) or not value:
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 datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
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, ...]: