camel-ai 0.2.65__py3-none-any.whl → 0.2.83a6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of camel-ai might be problematic. Click here for more details.

Files changed (509) hide show
  1. camel/__init__.py +3 -3
  2. camel/agents/__init__.py +2 -2
  3. camel/agents/_types.py +9 -4
  4. camel/agents/_utils.py +40 -2
  5. camel/agents/base.py +2 -2
  6. camel/agents/chat_agent.py +5107 -995
  7. camel/agents/critic_agent.py +2 -2
  8. camel/agents/deductive_reasoner_agent.py +56 -56
  9. camel/agents/embodied_agent.py +2 -2
  10. camel/agents/knowledge_graph_agent.py +20 -20
  11. camel/agents/mcp_agent.py +35 -36
  12. camel/agents/multi_hop_generator_agent.py +3 -3
  13. camel/agents/programmed_agent_instruction.py +2 -2
  14. camel/agents/repo_agent.py +4 -3
  15. camel/agents/role_assignment_agent.py +2 -2
  16. camel/agents/search_agent.py +2 -2
  17. camel/agents/task_agent.py +2 -2
  18. camel/agents/tool_agents/__init__.py +2 -2
  19. camel/agents/tool_agents/base.py +2 -2
  20. camel/agents/tool_agents/hugging_face_tool_agent.py +3 -3
  21. camel/benchmarks/__init__.py +2 -2
  22. camel/benchmarks/apibank.py +5 -5
  23. camel/benchmarks/apibench.py +2 -2
  24. camel/benchmarks/base.py +2 -2
  25. camel/benchmarks/browsecomp.py +44 -33
  26. camel/benchmarks/gaia.py +17 -13
  27. camel/benchmarks/mock_website/README.md +1 -3
  28. camel/benchmarks/mock_website/mock_web.py +2 -2
  29. camel/benchmarks/mock_website/requirements.txt +1 -1
  30. camel/benchmarks/mock_website/shopping_mall/app.py +2 -2
  31. camel/benchmarks/mock_website/task.json +1 -1
  32. camel/benchmarks/nexus.py +3 -3
  33. camel/benchmarks/ragbench.py +2 -2
  34. camel/bots/__init__.py +2 -2
  35. camel/bots/discord/__init__.py +2 -2
  36. camel/bots/discord/discord_app.py +2 -2
  37. camel/bots/discord/discord_installation.py +2 -2
  38. camel/bots/discord/discord_store.py +3 -3
  39. camel/bots/slack/__init__.py +2 -2
  40. camel/bots/slack/models.py +4 -4
  41. camel/bots/slack/slack_app.py +2 -2
  42. camel/bots/telegram_bot.py +2 -2
  43. camel/configs/__init__.py +29 -2
  44. camel/configs/aihubmix_config.py +90 -0
  45. camel/configs/aiml_config.py +2 -2
  46. camel/configs/amd_config.py +70 -0
  47. camel/configs/anthropic_config.py +2 -2
  48. camel/configs/base_config.py +2 -2
  49. camel/configs/bedrock_config.py +5 -3
  50. camel/configs/cerebras_config.py +98 -0
  51. camel/configs/cohere_config.py +2 -2
  52. camel/configs/cometapi_config.py +106 -0
  53. camel/configs/crynux_config.py +2 -2
  54. camel/configs/deepseek_config.py +9 -8
  55. camel/configs/function_gemma_config.py +59 -0
  56. camel/configs/gemini_config.py +6 -4
  57. camel/configs/groq_config.py +6 -4
  58. camel/configs/internlm_config.py +6 -4
  59. camel/configs/litellm_config.py +2 -2
  60. camel/configs/lmstudio_config.py +6 -4
  61. camel/configs/minimax_config.py +95 -0
  62. camel/configs/mistral_config.py +2 -2
  63. camel/configs/modelscope_config.py +5 -3
  64. camel/configs/moonshot_config.py +2 -2
  65. camel/configs/nebius_config.py +105 -0
  66. camel/configs/netmind_config.py +2 -2
  67. camel/configs/novita_config.py +2 -2
  68. camel/configs/nvidia_config.py +2 -2
  69. camel/configs/ollama_config.py +2 -2
  70. camel/configs/openai_config.py +5 -3
  71. camel/configs/openrouter_config.py +6 -4
  72. camel/configs/ppio_config.py +2 -2
  73. camel/configs/qianfan_config.py +85 -0
  74. camel/configs/qwen_config.py +2 -2
  75. camel/configs/reka_config.py +2 -2
  76. camel/configs/samba_config.py +6 -4
  77. camel/configs/sglang_config.py +2 -2
  78. camel/configs/siliconflow_config.py +2 -2
  79. camel/configs/togetherai_config.py +2 -2
  80. camel/configs/vllm_config.py +4 -2
  81. camel/configs/watsonx_config.py +2 -2
  82. camel/configs/yi_config.py +6 -4
  83. camel/configs/zhipuai_config.py +6 -4
  84. camel/data_collectors/__init__.py +2 -2
  85. camel/data_collectors/alpaca_collector.py +18 -9
  86. camel/data_collectors/base.py +2 -2
  87. camel/data_collectors/sharegpt_collector.py +2 -2
  88. camel/datagen/__init__.py +2 -2
  89. camel/datagen/cot_datagen.py +3 -3
  90. camel/datagen/evol_instruct/__init__.py +2 -2
  91. camel/datagen/evol_instruct/evol_instruct.py +2 -2
  92. camel/datagen/evol_instruct/scorer.py +12 -12
  93. camel/datagen/evol_instruct/templates.py +16 -16
  94. camel/datagen/self_improving_cot.py +5 -5
  95. camel/datagen/self_instruct/__init__.py +2 -2
  96. camel/datagen/self_instruct/filter/__init__.py +2 -2
  97. camel/datagen/self_instruct/filter/filter_function.py +2 -2
  98. camel/datagen/self_instruct/filter/filter_registry.py +2 -2
  99. camel/datagen/self_instruct/filter/instruction_filter.py +2 -2
  100. camel/datagen/self_instruct/self_instruct.py +2 -2
  101. camel/datagen/self_instruct/templates.py +47 -47
  102. camel/datagen/source2synth/__init__.py +2 -2
  103. camel/datagen/source2synth/data_processor.py +2 -2
  104. camel/datagen/source2synth/models.py +2 -2
  105. camel/datagen/source2synth/user_data_processor_config.py +2 -2
  106. camel/datahubs/__init__.py +2 -2
  107. camel/datahubs/base.py +2 -2
  108. camel/datahubs/huggingface.py +2 -2
  109. camel/datahubs/models.py +2 -2
  110. camel/datasets/__init__.py +2 -2
  111. camel/datasets/base_generator.py +41 -12
  112. camel/datasets/few_shot_generator.py +18 -18
  113. camel/datasets/models.py +2 -2
  114. camel/datasets/self_instruct_generator.py +2 -2
  115. camel/datasets/static_dataset.py +2 -2
  116. camel/embeddings/__init__.py +2 -2
  117. camel/embeddings/azure_embedding.py +2 -2
  118. camel/embeddings/base.py +2 -2
  119. camel/embeddings/gemini_embedding.py +2 -2
  120. camel/embeddings/jina_embedding.py +2 -2
  121. camel/embeddings/mistral_embedding.py +2 -2
  122. camel/embeddings/openai_compatible_embedding.py +2 -2
  123. camel/embeddings/openai_embedding.py +2 -2
  124. camel/embeddings/sentence_transformers_embeddings.py +2 -2
  125. camel/embeddings/together_embedding.py +2 -2
  126. camel/embeddings/vlm_embedding.py +2 -2
  127. camel/environments/__init__.py +14 -2
  128. camel/environments/models.py +2 -2
  129. camel/environments/multi_step.py +2 -2
  130. camel/environments/rlcards_env.py +860 -0
  131. camel/environments/single_step.py +30 -5
  132. camel/environments/tic_tac_toe.py +3 -3
  133. camel/extractors/__init__.py +2 -2
  134. camel/extractors/base.py +2 -2
  135. camel/extractors/python_strategies.py +2 -2
  136. camel/generators.py +2 -2
  137. camel/human.py +2 -2
  138. camel/interpreters/__init__.py +4 -2
  139. camel/interpreters/base.py +2 -2
  140. camel/interpreters/docker/Dockerfile +14 -24
  141. camel/interpreters/docker_interpreter.py +5 -4
  142. camel/interpreters/e2b_interpreter.py +36 -3
  143. camel/interpreters/internal_python_interpreter.py +53 -4
  144. camel/interpreters/interpreter_error.py +2 -2
  145. camel/interpreters/ipython_interpreter.py +2 -2
  146. camel/interpreters/microsandbox_interpreter.py +395 -0
  147. camel/interpreters/subprocess_interpreter.py +2 -2
  148. camel/loaders/__init__.py +13 -4
  149. camel/loaders/apify_reader.py +2 -2
  150. camel/loaders/base_io.py +2 -2
  151. camel/loaders/base_loader.py +85 -0
  152. camel/loaders/chunkr_reader.py +11 -2
  153. camel/loaders/crawl4ai_reader.py +2 -2
  154. camel/loaders/firecrawl_reader.py +6 -6
  155. camel/loaders/jina_url_reader.py +2 -2
  156. camel/loaders/markitdown.py +2 -2
  157. camel/loaders/mineru_extractor.py +2 -2
  158. camel/loaders/mistral_reader.py +2 -2
  159. camel/loaders/scrapegraph_reader.py +2 -2
  160. camel/loaders/unstructured_io.py +2 -2
  161. camel/logger.py +5 -5
  162. camel/memories/__init__.py +2 -2
  163. camel/memories/agent_memories.py +86 -3
  164. camel/memories/base.py +36 -2
  165. camel/memories/blocks/__init__.py +2 -2
  166. camel/memories/blocks/chat_history_block.py +125 -7
  167. camel/memories/blocks/vectordb_block.py +10 -3
  168. camel/memories/context_creators/__init__.py +2 -2
  169. camel/memories/context_creators/score_based.py +109 -230
  170. camel/memories/records.py +90 -10
  171. camel/messages/__init__.py +2 -2
  172. camel/messages/base.py +178 -43
  173. camel/messages/conversion/__init__.py +2 -2
  174. camel/messages/conversion/alpaca.py +2 -2
  175. camel/messages/conversion/conversation_models.py +2 -2
  176. camel/messages/conversion/sharegpt/__init__.py +2 -2
  177. camel/messages/conversion/sharegpt/function_call_formatter.py +2 -2
  178. camel/messages/conversion/sharegpt/hermes/__init__.py +2 -2
  179. camel/messages/conversion/sharegpt/hermes/hermes_function_formatter.py +2 -2
  180. camel/messages/func_message.py +54 -17
  181. camel/models/__init__.py +18 -2
  182. camel/models/_utils.py +3 -3
  183. camel/models/aihubmix_model.py +83 -0
  184. camel/models/aiml_model.py +11 -18
  185. camel/models/amd_model.py +101 -0
  186. camel/models/anthropic_model.py +127 -20
  187. camel/models/aws_bedrock_model.py +12 -35
  188. camel/models/azure_openai_model.py +214 -115
  189. camel/models/base_audio_model.py +5 -3
  190. camel/models/base_model.py +378 -31
  191. camel/models/cerebras_model.py +83 -0
  192. camel/models/cohere_model.py +18 -49
  193. camel/models/cometapi_model.py +83 -0
  194. camel/models/crynux_model.py +11 -18
  195. camel/models/deepseek_model.py +20 -84
  196. camel/models/fish_audio_model.py +8 -2
  197. camel/models/function_gemma_model.py +889 -0
  198. camel/models/gemini_model.py +391 -52
  199. camel/models/groq_model.py +11 -19
  200. camel/models/internlm_model.py +11 -18
  201. camel/models/litellm_model.py +57 -49
  202. camel/models/lmstudio_model.py +17 -20
  203. camel/models/minimax_model.py +83 -0
  204. camel/models/mistral_model.py +20 -47
  205. camel/models/model_factory.py +39 -3
  206. camel/models/model_manager.py +26 -8
  207. camel/models/modelscope_model.py +13 -193
  208. camel/models/moonshot_model.py +183 -21
  209. camel/models/nebius_model.py +83 -0
  210. camel/models/nemotron_model.py +19 -9
  211. camel/models/netmind_model.py +11 -18
  212. camel/models/novita_model.py +11 -18
  213. camel/models/nvidia_model.py +11 -18
  214. camel/models/ollama_model.py +14 -21
  215. camel/models/openai_audio_models.py +2 -2
  216. camel/models/openai_compatible_model.py +190 -71
  217. camel/models/openai_model.py +192 -86
  218. camel/models/openrouter_model.py +11 -19
  219. camel/models/ppio_model.py +11 -18
  220. camel/models/qianfan_model.py +89 -0
  221. camel/models/qwen_model.py +13 -193
  222. camel/models/reka_model.py +23 -49
  223. camel/models/reward/__init__.py +2 -2
  224. camel/models/reward/base_reward_model.py +2 -2
  225. camel/models/reward/evaluator.py +2 -2
  226. camel/models/reward/nemotron_model.py +2 -2
  227. camel/models/reward/skywork_model.py +2 -2
  228. camel/models/samba_model.py +50 -75
  229. camel/models/sglang_model.py +90 -68
  230. camel/models/siliconflow_model.py +12 -35
  231. camel/models/stub_model.py +10 -7
  232. camel/models/togetherai_model.py +11 -18
  233. camel/models/vllm_model.py +10 -18
  234. camel/models/volcano_model.py +158 -19
  235. camel/models/watsonx_model.py +9 -47
  236. camel/models/yi_model.py +11 -18
  237. camel/models/zhipuai_model.py +70 -18
  238. camel/parsers/__init__.py +18 -0
  239. camel/parsers/mcp_tool_call_parser.py +176 -0
  240. camel/personas/__init__.py +2 -2
  241. camel/personas/persona.py +2 -2
  242. camel/personas/persona_hub.py +2 -2
  243. camel/prompts/__init__.py +2 -2
  244. camel/prompts/ai_society.py +2 -2
  245. camel/prompts/base.py +2 -2
  246. camel/prompts/code.py +2 -2
  247. camel/prompts/evaluation.py +2 -2
  248. camel/prompts/generate_text_embedding_data.py +2 -2
  249. camel/prompts/image_craft.py +2 -2
  250. camel/prompts/misalignment.py +2 -2
  251. camel/prompts/multi_condition_image_craft.py +2 -2
  252. camel/prompts/object_recognition.py +2 -2
  253. camel/prompts/persona_hub.py +3 -3
  254. camel/prompts/prompt_templates.py +2 -2
  255. camel/prompts/role_description_prompt_template.py +2 -2
  256. camel/prompts/solution_extraction.py +8 -8
  257. camel/prompts/task_prompt_template.py +2 -2
  258. camel/prompts/translation.py +2 -2
  259. camel/prompts/video_description_prompt.py +3 -3
  260. camel/responses/__init__.py +2 -2
  261. camel/responses/agent_responses.py +2 -2
  262. camel/retrievers/__init__.py +2 -2
  263. camel/retrievers/auto_retriever.py +3 -2
  264. camel/retrievers/base.py +2 -2
  265. camel/retrievers/bm25_retriever.py +2 -2
  266. camel/retrievers/cohere_rerank_retriever.py +2 -2
  267. camel/retrievers/hybrid_retrival.py +2 -2
  268. camel/retrievers/vector_retriever.py +2 -2
  269. camel/runtimes/Dockerfile.multi-toolkit +90 -0
  270. camel/runtimes/__init__.py +2 -2
  271. camel/runtimes/api.py +79 -23
  272. camel/runtimes/base.py +2 -2
  273. camel/runtimes/configs.py +13 -13
  274. camel/runtimes/daytona_runtime.py +17 -18
  275. camel/runtimes/docker_runtime.py +12 -12
  276. camel/runtimes/llm_guard_runtime.py +26 -26
  277. camel/runtimes/remote_http_runtime.py +11 -11
  278. camel/runtimes/ubuntu_docker_runtime.py +2 -2
  279. camel/runtimes/utils/__init__.py +2 -2
  280. camel/runtimes/utils/function_risk_toolkit.py +2 -2
  281. camel/runtimes/utils/ignore_risk_toolkit.py +2 -2
  282. camel/schemas/__init__.py +2 -2
  283. camel/schemas/base.py +2 -2
  284. camel/schemas/openai_converter.py +3 -3
  285. camel/schemas/outlines_converter.py +2 -2
  286. camel/services/agent_openapi_server.py +380 -0
  287. camel/societies/__init__.py +4 -2
  288. camel/societies/babyagi_playing.py +2 -2
  289. camel/societies/role_playing.py +201 -80
  290. camel/societies/workforce/__init__.py +10 -3
  291. camel/societies/workforce/base.py +2 -2
  292. camel/societies/workforce/events.py +145 -0
  293. camel/societies/workforce/prompts.py +259 -33
  294. camel/societies/workforce/role_playing_worker.py +88 -31
  295. camel/societies/workforce/single_agent_worker.py +638 -40
  296. camel/societies/workforce/structured_output_handler.py +512 -0
  297. camel/societies/workforce/task_channel.py +182 -38
  298. camel/societies/workforce/utils.py +780 -65
  299. camel/societies/workforce/worker.py +92 -26
  300. camel/societies/workforce/workflow_memory_manager.py +1746 -0
  301. camel/societies/workforce/workforce.py +5354 -372
  302. camel/societies/workforce/workforce_callback.py +103 -0
  303. camel/societies/workforce/workforce_logger.py +647 -0
  304. camel/societies/workforce/workforce_metrics.py +33 -0
  305. camel/storages/__init__.py +6 -2
  306. camel/storages/graph_storages/__init__.py +2 -2
  307. camel/storages/graph_storages/base.py +2 -2
  308. camel/storages/graph_storages/graph_element.py +2 -2
  309. camel/storages/graph_storages/nebula_graph.py +4 -4
  310. camel/storages/graph_storages/neo4j_graph.py +7 -7
  311. camel/storages/key_value_storages/__init__.py +2 -2
  312. camel/storages/key_value_storages/base.py +2 -2
  313. camel/storages/key_value_storages/in_memory.py +2 -2
  314. camel/storages/key_value_storages/json.py +17 -4
  315. camel/storages/key_value_storages/mem0_cloud.py +50 -49
  316. camel/storages/key_value_storages/redis.py +2 -2
  317. camel/storages/object_storages/__init__.py +2 -2
  318. camel/storages/object_storages/amazon_s3.py +2 -2
  319. camel/storages/object_storages/azure_blob.py +2 -2
  320. camel/storages/object_storages/base.py +2 -2
  321. camel/storages/object_storages/google_cloud.py +3 -3
  322. camel/storages/vectordb_storages/__init__.py +8 -2
  323. camel/storages/vectordb_storages/base.py +2 -2
  324. camel/storages/vectordb_storages/chroma.py +731 -0
  325. camel/storages/vectordb_storages/faiss.py +2 -2
  326. camel/storages/vectordb_storages/milvus.py +2 -2
  327. camel/storages/vectordb_storages/oceanbase.py +15 -15
  328. camel/storages/vectordb_storages/pgvector.py +349 -0
  329. camel/storages/vectordb_storages/qdrant.py +6 -6
  330. camel/storages/vectordb_storages/surreal.py +372 -0
  331. camel/storages/vectordb_storages/tidb.py +11 -8
  332. camel/storages/vectordb_storages/weaviate.py +2 -2
  333. camel/tasks/__init__.py +2 -2
  334. camel/tasks/task.py +348 -26
  335. camel/tasks/task_prompt.py +3 -3
  336. camel/terminators/__init__.py +2 -2
  337. camel/terminators/base.py +2 -2
  338. camel/terminators/response_terminator.py +2 -2
  339. camel/terminators/token_limit_terminator.py +2 -2
  340. camel/toolkits/__init__.py +57 -10
  341. camel/toolkits/aci_toolkit.py +66 -21
  342. camel/toolkits/arxiv_toolkit.py +8 -8
  343. camel/toolkits/ask_news_toolkit.py +2 -2
  344. camel/toolkits/async_browser_toolkit.py +4 -4
  345. camel/toolkits/audio_analysis_toolkit.py +3 -3
  346. camel/toolkits/base.py +106 -6
  347. camel/toolkits/bohrium_toolkit.py +2 -2
  348. camel/toolkits/browser_toolkit.py +34 -21
  349. camel/toolkits/browser_toolkit_commons.py +4 -4
  350. camel/toolkits/code_execution.py +31 -4
  351. camel/toolkits/context_summarizer_toolkit.py +684 -0
  352. camel/toolkits/craw4ai_toolkit.py +93 -0
  353. camel/toolkits/dappier_toolkit.py +12 -8
  354. camel/toolkits/data_commons_toolkit.py +2 -2
  355. camel/toolkits/dingtalk.py +1135 -0
  356. camel/toolkits/earth_science_toolkit.py +5367 -0
  357. camel/toolkits/edgeone_pages_mcp_toolkit.py +49 -0
  358. camel/toolkits/excel_toolkit.py +905 -71
  359. camel/toolkits/file_toolkit.py +1402 -0
  360. camel/toolkits/function_tool.py +205 -27
  361. camel/toolkits/github_toolkit.py +109 -22
  362. camel/toolkits/gmail_toolkit.py +1839 -0
  363. camel/toolkits/google_calendar_toolkit.py +40 -6
  364. camel/toolkits/google_drive_mcp_toolkit.py +54 -0
  365. camel/toolkits/google_maps_toolkit.py +2 -2
  366. camel/toolkits/google_scholar_toolkit.py +2 -2
  367. camel/toolkits/human_toolkit.py +36 -12
  368. camel/toolkits/hybrid_browser_toolkit/__init__.py +18 -0
  369. camel/toolkits/hybrid_browser_toolkit/config_loader.py +185 -0
  370. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +246 -0
  371. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +1958 -0
  372. camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
  373. camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +4589 -0
  374. camel/toolkits/hybrid_browser_toolkit/ts/package.json +33 -0
  375. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-scripts.js +125 -0
  376. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +1940 -0
  377. camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +233 -0
  378. camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +589 -0
  379. camel/toolkits/hybrid_browser_toolkit/ts/src/index.ts +7 -0
  380. camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
  381. camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
  382. camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
  383. camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +129 -0
  384. camel/toolkits/hybrid_browser_toolkit/ts/tsconfig.json +27 -0
  385. camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +325 -0
  386. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +1037 -0
  387. camel/toolkits/hybrid_browser_toolkit_py/__init__.py +17 -0
  388. camel/toolkits/hybrid_browser_toolkit_py/actions.py +575 -0
  389. camel/toolkits/hybrid_browser_toolkit_py/agent.py +311 -0
  390. camel/toolkits/hybrid_browser_toolkit_py/browser_session.py +787 -0
  391. camel/toolkits/hybrid_browser_toolkit_py/config_loader.py +490 -0
  392. camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +2390 -0
  393. camel/toolkits/hybrid_browser_toolkit_py/snapshot.py +233 -0
  394. camel/toolkits/hybrid_browser_toolkit_py/stealth_script.js +0 -0
  395. camel/toolkits/hybrid_browser_toolkit_py/unified_analyzer.js +1043 -0
  396. camel/toolkits/image_analysis_toolkit.py +3 -6
  397. camel/toolkits/image_generation_toolkit.py +390 -0
  398. camel/toolkits/jina_reranker_toolkit.py +5 -6
  399. camel/toolkits/klavis_toolkit.py +7 -3
  400. camel/toolkits/linkedin_toolkit.py +2 -2
  401. camel/toolkits/markitdown_toolkit.py +104 -0
  402. camel/toolkits/math_toolkit.py +66 -12
  403. camel/toolkits/mcp_toolkit.py +412 -36
  404. camel/toolkits/memory_toolkit.py +7 -3
  405. camel/toolkits/meshy_toolkit.py +2 -2
  406. camel/toolkits/message_agent_toolkit.py +608 -0
  407. camel/toolkits/message_integration.py +728 -0
  408. camel/toolkits/microsoft_outlook_mail_toolkit.py +1885 -0
  409. camel/toolkits/mineru_toolkit.py +2 -2
  410. camel/toolkits/minimax_mcp_toolkit.py +195 -0
  411. camel/toolkits/networkx_toolkit.py +2 -2
  412. camel/toolkits/note_taking_toolkit.py +277 -0
  413. camel/toolkits/notion_mcp_toolkit.py +224 -0
  414. camel/toolkits/notion_toolkit.py +2 -2
  415. camel/toolkits/open_api_specs/biztoc/__init__.py +2 -2
  416. camel/toolkits/open_api_specs/biztoc/ai-plugin.json +1 -1
  417. camel/toolkits/open_api_specs/coursera/__init__.py +2 -2
  418. camel/toolkits/open_api_specs/create_qr_code/__init__.py +2 -2
  419. camel/toolkits/open_api_specs/klarna/__init__.py +2 -2
  420. camel/toolkits/open_api_specs/nasa_apod/__init__.py +2 -2
  421. camel/toolkits/open_api_specs/outschool/__init__.py +2 -2
  422. camel/toolkits/open_api_specs/outschool/ai-plugin.json +1 -1
  423. camel/toolkits/open_api_specs/outschool/openapi.yaml +1 -1
  424. camel/toolkits/open_api_specs/outschool/paths/__init__.py +2 -2
  425. camel/toolkits/open_api_specs/outschool/paths/get_classes.py +2 -2
  426. camel/toolkits/open_api_specs/outschool/paths/search_teachers.py +2 -2
  427. camel/toolkits/open_api_specs/security_config.py +2 -2
  428. camel/toolkits/open_api_specs/speak/__init__.py +2 -2
  429. camel/toolkits/open_api_specs/web_scraper/__init__.py +2 -2
  430. camel/toolkits/open_api_specs/web_scraper/ai-plugin.json +1 -1
  431. camel/toolkits/open_api_specs/web_scraper/paths/__init__.py +2 -2
  432. camel/toolkits/open_api_specs/web_scraper/paths/scraper.py +2 -2
  433. camel/toolkits/open_api_toolkit.py +2 -2
  434. camel/toolkits/openbb_toolkit.py +7 -3
  435. camel/toolkits/origene_mcp_toolkit.py +56 -0
  436. camel/toolkits/page_script.js +53 -53
  437. camel/toolkits/playwright_mcp_toolkit.py +13 -31
  438. camel/toolkits/pptx_toolkit.py +36 -23
  439. camel/toolkits/pubmed_toolkit.py +2 -2
  440. camel/toolkits/pulse_mcp_search_toolkit.py +2 -2
  441. camel/toolkits/pyautogui_toolkit.py +2 -2
  442. camel/toolkits/reddit_toolkit.py +2 -2
  443. camel/toolkits/resend_toolkit.py +168 -0
  444. camel/toolkits/retrieval_toolkit.py +2 -2
  445. camel/toolkits/screenshot_toolkit.py +213 -0
  446. camel/toolkits/search_toolkit.py +606 -156
  447. camel/toolkits/searxng_toolkit.py +2 -2
  448. camel/toolkits/semantic_scholar_toolkit.py +2 -2
  449. camel/toolkits/slack_toolkit.py +108 -58
  450. camel/toolkits/sql_toolkit.py +712 -0
  451. camel/toolkits/stripe_toolkit.py +2 -2
  452. camel/toolkits/sympy_toolkit.py +3 -3
  453. camel/toolkits/task_planning_toolkit.py +5 -5
  454. camel/toolkits/terminal_toolkit/__init__.py +18 -0
  455. camel/toolkits/terminal_toolkit/terminal_toolkit.py +1281 -0
  456. camel/toolkits/terminal_toolkit/utils.py +659 -0
  457. camel/toolkits/thinking_toolkit.py +3 -3
  458. camel/toolkits/twitter_toolkit.py +2 -2
  459. camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
  460. camel/toolkits/video_analysis_toolkit.py +109 -29
  461. camel/toolkits/video_download_toolkit.py +19 -16
  462. camel/toolkits/weather_toolkit.py +2 -2
  463. camel/toolkits/web_deploy_toolkit.py +1219 -0
  464. camel/toolkits/wechat_official_toolkit.py +483 -0
  465. camel/toolkits/whatsapp_toolkit.py +2 -2
  466. camel/toolkits/wolfram_alpha_toolkit.py +2 -2
  467. camel/toolkits/zapier_toolkit.py +7 -3
  468. camel/types/__init__.py +4 -4
  469. camel/types/agents/__init__.py +2 -2
  470. camel/types/agents/tool_calling_record.py +6 -3
  471. camel/types/enums.py +381 -41
  472. camel/types/mcp_registries.py +2 -2
  473. camel/types/openai_types.py +4 -4
  474. camel/types/unified_model_type.py +46 -10
  475. camel/utils/__init__.py +5 -2
  476. camel/utils/agent_context.py +41 -0
  477. camel/utils/async_func.py +2 -2
  478. camel/utils/chunker/__init__.py +2 -2
  479. camel/utils/chunker/base.py +2 -2
  480. camel/utils/chunker/code_chunker.py +2 -2
  481. camel/utils/chunker/uio_chunker.py +2 -2
  482. camel/utils/commons.py +38 -7
  483. camel/utils/constants.py +5 -2
  484. camel/utils/context_utils.py +1134 -0
  485. camel/utils/deduplication.py +2 -2
  486. camel/utils/filename.py +2 -2
  487. camel/utils/langfuse.py +18 -10
  488. camel/utils/mcp.py +140 -6
  489. camel/utils/mcp_client.py +48 -38
  490. camel/utils/message_summarizer.py +148 -0
  491. camel/utils/response_format.py +2 -2
  492. camel/utils/token_counting.py +45 -22
  493. camel/utils/tool_result.py +44 -0
  494. camel/verifiers/__init__.py +2 -2
  495. camel/verifiers/base.py +2 -2
  496. camel/verifiers/math_verifier.py +2 -2
  497. camel/verifiers/models.py +2 -2
  498. camel/verifiers/physics_verifier.py +2 -2
  499. camel/verifiers/python_verifier.py +2 -2
  500. {camel_ai-0.2.65.dist-info → camel_ai-0.2.83a6.dist-info}/METADATA +355 -117
  501. camel_ai-0.2.83a6.dist-info/RECORD +511 -0
  502. {camel_ai-0.2.65.dist-info → camel_ai-0.2.83a6.dist-info}/WHEEL +1 -1
  503. {camel_ai-0.2.65.dist-info → camel_ai-0.2.83a6.dist-info}/licenses/LICENSE +1 -1
  504. camel/loaders/pandas_reader.py +0 -368
  505. camel/toolkits/dalle_toolkit.py +0 -175
  506. camel/toolkits/file_write_toolkit.py +0 -444
  507. camel/toolkits/openai_agent_toolkit.py +0 -135
  508. camel/toolkits/terminal_toolkit.py +0 -1037
  509. camel_ai-0.2.65.dist-info/RECORD +0 -426
@@ -0,0 +1,1885 @@
1
+ # ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
14
+
15
+ import json
16
+ import os
17
+ import threading
18
+ import time
19
+ from http.server import BaseHTTPRequestHandler, HTTPServer
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, cast
22
+
23
+ import requests
24
+ from dotenv import load_dotenv
25
+
26
+ from camel.logger import get_logger
27
+ from camel.toolkits import FunctionTool
28
+ from camel.toolkits.base import BaseToolkit
29
+ from camel.utils import MCPServer, api_keys_required
30
+
31
+ load_dotenv()
32
+ logger = get_logger(__name__)
33
+
34
+
35
+ class OAuthHTTPServer(HTTPServer):
36
+ code: Optional[str] = None
37
+
38
+
39
+ class RedirectHandler(BaseHTTPRequestHandler):
40
+ """Handler for OAuth redirect requests."""
41
+
42
+ def do_GET(self):
43
+ """Handles GET request and extracts authorization code."""
44
+ from urllib.parse import parse_qs, urlparse
45
+
46
+ try:
47
+ query = parse_qs(urlparse(self.path).query)
48
+ code = query.get("code", [None])[0]
49
+ cast(OAuthHTTPServer, self.server).code = code
50
+ self.send_response(200)
51
+ self.end_headers()
52
+ self.wfile.write(
53
+ b"Authentication complete. You can close this window."
54
+ )
55
+ except Exception as e:
56
+ cast(OAuthHTTPServer, self.server).code = None
57
+ self.send_response(500)
58
+ self.end_headers()
59
+ self.wfile.write(
60
+ f"Error during authentication: {e}".encode("utf-8")
61
+ )
62
+
63
+ def log_message(self, format, *args):
64
+ pass
65
+
66
+
67
+ class CustomAzureCredential:
68
+ """Creates a sync Azure credential to pass into MSGraph client.
69
+
70
+ Implements Azure credential interface with automatic token refresh using
71
+ a refresh token. Updates the refresh token file whenever Microsoft issues
72
+ a new refresh token during the refresh flow.
73
+
74
+ Args:
75
+ client_id (str): The OAuth client ID.
76
+ client_secret (str): The OAuth client secret.
77
+ tenant_id (str): The Microsoft tenant ID.
78
+ refresh_token (str): The refresh token from OAuth flow.
79
+ scopes (List[str]): List of OAuth permission scopes.
80
+ refresh_token_file_path (Optional[Path]): File path of json file
81
+ with refresh token.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ client_id: str,
87
+ client_secret: str,
88
+ tenant_id: str,
89
+ refresh_token: str,
90
+ scopes: List[str],
91
+ refresh_token_file_path: Optional[Path],
92
+ ):
93
+ self.client_id = client_id
94
+ self.client_secret = client_secret
95
+ self.tenant_id = tenant_id
96
+ self.refresh_token = refresh_token
97
+ self.scopes = scopes
98
+ self.refresh_token_file_path = refresh_token_file_path
99
+
100
+ self._access_token = None
101
+ self._expires_at = 0
102
+ self._lock = threading.Lock()
103
+ self._debug_claims_logged = False
104
+
105
+ def _refresh_access_token(self):
106
+ """Refreshes the access token using the refresh token.
107
+
108
+ Requests a new access token from Microsoft's token endpoint.
109
+ If Microsoft returns a new refresh token, updates both in-memory
110
+ and refresh token file.
111
+
112
+ Raises:
113
+ Exception: If token refresh fails or returns an error.
114
+ """
115
+ token_url = (
116
+ f"https://login.microsoftonline.com/{self.tenant_id}"
117
+ f"/oauth2/v2.0/token"
118
+ )
119
+ data = {
120
+ "client_id": self.client_id,
121
+ "client_secret": self.client_secret,
122
+ "grant_type": "refresh_token",
123
+ "refresh_token": self.refresh_token,
124
+ "scope": " ".join(self.scopes),
125
+ }
126
+
127
+ response = requests.post(token_url, data=data, timeout=30)
128
+ result = response.json()
129
+
130
+ # Raise exception if error in response
131
+ if "error" in result:
132
+ error_desc = result.get('error_description', result['error'])
133
+ error_msg = f"Token refresh failed: {error_desc}"
134
+ logger.error(error_msg)
135
+ raise Exception(error_msg)
136
+
137
+ # Update access token and expiration (60 second buffer)
138
+ self._access_token = result["access_token"]
139
+ self._expires_at = int(time.time()) + int(result["expires_in"]) - 60
140
+
141
+ # Save new refresh token if Microsoft provides one
142
+ if "refresh_token" in result:
143
+ self.refresh_token = result["refresh_token"]
144
+ self._save_refresh_token(self.refresh_token)
145
+
146
+ def _save_refresh_token(self, refresh_token: str):
147
+ """Saves the refresh token to file.
148
+
149
+ Args:
150
+ refresh_token (str): The refresh token to save.
151
+ """
152
+ if not self.refresh_token_file_path:
153
+ logger.info("Token file path not set, skipping token save")
154
+ return
155
+
156
+ token_data = {"refresh_token": refresh_token}
157
+
158
+ try:
159
+ # Create parent directories if they don't exist
160
+ self.refresh_token_file_path.parent.mkdir(
161
+ parents=True, exist_ok=True
162
+ )
163
+
164
+ # Write new refresh token to file
165
+ with open(self.refresh_token_file_path, 'w') as f:
166
+ json.dump(token_data, f, indent=2)
167
+ except Exception as e:
168
+ logger.warning(f"Failed to save refresh token: {e!s}")
169
+
170
+ def get_token(self, *args, **kwargs):
171
+ """Gets a valid AccessToken object for msgraph (sync).
172
+
173
+ Called by Microsoft Graph SDK when making API requests.
174
+ Automatically refreshes the token if expired.
175
+
176
+ Args:
177
+ *args: Positional arguments that msgraph might pass .
178
+ **kwargs: Keyword arguments that msgraph might pass .
179
+
180
+ Returns:
181
+ AccessToken: Azure AccessToken with token and expiration.
182
+
183
+ Raises:
184
+ Exception: If requested scopes exceed allowed scopes.
185
+ """
186
+ from azure.core.credentials import AccessToken
187
+
188
+ def _maybe_log_token_claims(token: str) -> None:
189
+ if self._debug_claims_logged:
190
+ return
191
+ if os.getenv("CAMEL_OUTLOOK_DEBUG_TOKEN_CLAIMS") != "1":
192
+ return
193
+
194
+ try:
195
+ import base64
196
+
197
+ _header_b64, payload_b64, _sig_b64 = token.split(".", 2)
198
+ payload_b64 += "=" * (-len(payload_b64) % 4)
199
+ payload = json.loads(
200
+ base64.urlsafe_b64decode(payload_b64.encode("utf-8"))
201
+ )
202
+ logger.info(
203
+ "Outlook token claims: aud=%s scp=%s roles=%s",
204
+ payload.get("aud"),
205
+ payload.get("scp"),
206
+ payload.get("roles"),
207
+ )
208
+ except Exception as e:
209
+ logger.warning("Failed to decode token claims: %s", e)
210
+ finally:
211
+ self._debug_claims_logged = True
212
+
213
+ # Check if token needs refresh
214
+ now = int(time.time())
215
+ if now >= self._expires_at:
216
+ with self._lock:
217
+ # Double-check after lock (another thread may have refreshed)
218
+ if now >= self._expires_at:
219
+ self._refresh_access_token()
220
+
221
+ _maybe_log_token_claims(self._access_token)
222
+ return AccessToken(self._access_token, self._expires_at)
223
+
224
+
225
+ @MCPServer()
226
+ class OutlookMailToolkit(BaseToolkit):
227
+ """A comprehensive toolkit for Microsoft Outlook Mail operations.
228
+
229
+ This class provides methods for Outlook Mail operations including sending
230
+ emails, managing drafts, replying to mails, deleting mails, fetching
231
+ mails and attachments and changing folder of mails.
232
+ API keys can be accessed in the Azure portal (https://portal.azure.com/)
233
+ """
234
+
235
+ def __init__(
236
+ self,
237
+ timeout: Optional[float] = None,
238
+ refresh_token_file_path: Optional[str] = None,
239
+ ):
240
+ """Initializes a new instance of the OutlookMailToolkit.
241
+
242
+ Args:
243
+ timeout (Optional[float]): The timeout value for API requests
244
+ in seconds. If None, no timeout is applied.
245
+ (default: :obj:`None`)
246
+ refresh_token_file_path (Optional[str]): The path of json file
247
+ where refresh token is stored. If None, authentication using
248
+ web browser will be required on each initialization. If
249
+ provided, the refresh token is read from the file, used, and
250
+ automatically updated when it nears expiry.
251
+ (default: :obj:`None`)
252
+ """
253
+ super().__init__(timeout=timeout)
254
+
255
+ self.scopes = self._normalize_scopes(["Mail.Send", "Mail.ReadWrite"])
256
+ self.redirect_uri = self._get_dynamic_redirect_uri()
257
+ self.refresh_token_file_path = (
258
+ Path(refresh_token_file_path) if refresh_token_file_path else None
259
+ )
260
+ self.credentials = self._authenticate()
261
+ self.client = self._get_graph_client(
262
+ credentials=self.credentials, scopes=self.scopes
263
+ )
264
+
265
+ def _get_dynamic_redirect_uri(self) -> str:
266
+ """Finds an available port and returns a dynamic redirect URI.
267
+
268
+ Returns:
269
+ str: A redirect URI with format 'http://localhost:<port>' where
270
+ port is an available port on the system.
271
+ """
272
+ import socket
273
+
274
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
275
+ s.bind(('127.0.0.1', 0))
276
+ port = s.getsockname()[1]
277
+ return f'http://localhost:{port}'
278
+
279
+ def _normalize_scopes(self, scopes: List[str]) -> List[str]:
280
+ """Normalizes OAuth scopes to what Azure Identity expects.
281
+
282
+ Azure Identity credentials (used by Kiota/MSGraph) expect fully
283
+ qualified scopes like `https://graph.microsoft.com/Mail.Send`.
284
+ For backwards compatibility, this method also accepts short scopes
285
+ like `Mail.Send` and prefixes them with Microsoft Graph resource.
286
+ """
287
+ graph_resource = "https://graph.microsoft.com"
288
+ passthrough = {"offline_access", "openid", "profile"}
289
+
290
+ normalized: List[str] = []
291
+ for scope in scopes:
292
+ scope = scope.strip()
293
+ if not scope:
294
+ continue
295
+ if scope in passthrough or "://" in scope:
296
+ normalized.append(scope)
297
+ continue
298
+ normalized.append(f"{graph_resource}/{scope.lstrip('/')}")
299
+ return normalized
300
+
301
+ def _get_auth_url(self, client_id, tenant_id, redirect_uri, scopes):
302
+ """Constructs the Microsoft authorization URL.
303
+
304
+ Args:
305
+ client_id (str): The OAuth client ID.
306
+ tenant_id (str): The Microsoft tenant ID.
307
+ redirect_uri (str): The redirect URI for OAuth callback.
308
+ scopes (List[str]): List of permission scopes.
309
+
310
+ Returns:
311
+ str: The complete authorization URL.
312
+ """
313
+ from urllib.parse import urlencode
314
+
315
+ params = {
316
+ 'client_id': client_id,
317
+ 'response_type': 'code',
318
+ 'redirect_uri': redirect_uri,
319
+ 'scope': " ".join(scopes),
320
+ }
321
+ auth_url = (
322
+ f'https://login.microsoftonline.com/{tenant_id}'
323
+ f'/oauth2/v2.0/authorize?{urlencode(params)}'
324
+ )
325
+ return auth_url
326
+
327
+ def _load_token_from_file(self) -> Optional[str]:
328
+ """Loads refresh token from disk.
329
+
330
+ Returns:
331
+ Optional[str]: Refresh token if file exists and valid, else None.
332
+ """
333
+ if not self.refresh_token_file_path:
334
+ return None
335
+
336
+ if not self.refresh_token_file_path.exists():
337
+ return None
338
+
339
+ try:
340
+ with open(self.refresh_token_file_path, 'r') as f:
341
+ token_data = json.load(f)
342
+
343
+ refresh_token = token_data.get('refresh_token')
344
+ if refresh_token:
345
+ logger.info(
346
+ f"Refresh token loaded from {self.refresh_token_file_path}"
347
+ )
348
+ return refresh_token
349
+
350
+ logger.warning("Token file missing 'refresh_token' field")
351
+ return None
352
+
353
+ except Exception as e:
354
+ logger.warning(f"Failed to load token file: {e!s}")
355
+ return None
356
+
357
+ def _save_token_to_file(self, refresh_token: str):
358
+ """Saves refresh token to disk.
359
+
360
+ Args:
361
+ refresh_token (str): The refresh token to save.
362
+ """
363
+ if not self.refresh_token_file_path:
364
+ logger.info("Token file path not set, skipping token save")
365
+ return
366
+
367
+ try:
368
+ # Create parent directories if they don't exist
369
+ self.refresh_token_file_path.parent.mkdir(
370
+ parents=True, exist_ok=True
371
+ )
372
+
373
+ with open(self.refresh_token_file_path, 'w') as f:
374
+ json.dump({"refresh_token": refresh_token}, f, indent=2)
375
+ logger.info(
376
+ f"Refresh token saved to {self.refresh_token_file_path}"
377
+ )
378
+ except Exception as e:
379
+ logger.warning(f"Failed to save token to file: {e!s}")
380
+
381
+ def _authenticate_using_refresh_token(
382
+ self,
383
+ ) -> CustomAzureCredential:
384
+ """Authenticates using a saved refresh token.
385
+
386
+ Loads the refresh token from disk and creates a credential object
387
+ that will automatically refresh access tokens as needed.
388
+
389
+ Returns:
390
+ CustomAzureCredential: Credential with auto-refresh capability.
391
+
392
+ Raises:
393
+ ValueError: If refresh token cannot be loaded or is invalid.
394
+ """
395
+ refresh_token = self._load_token_from_file()
396
+
397
+ if not refresh_token:
398
+ raise ValueError("No valid refresh token found in file")
399
+
400
+ # Create credential with automatic refresh capability
401
+ credentials = CustomAzureCredential(
402
+ client_id=self.client_id,
403
+ client_secret=self.client_secret,
404
+ tenant_id=self.tenant_id,
405
+ refresh_token=refresh_token,
406
+ scopes=self.scopes,
407
+ refresh_token_file_path=self.refresh_token_file_path,
408
+ )
409
+
410
+ logger.info("Authentication with saved token successful")
411
+ return credentials
412
+
413
+ def _authenticate_using_browser(self):
414
+ """Authenticates using browser-based OAuth flow.
415
+
416
+ Opens browser for user authentication, exchanges authorization
417
+ code for tokens, and saves refresh token for future use.
418
+
419
+ Returns:
420
+ CustomAzureCredential or AuthorizationCodeCredential :
421
+ Credential for Microsoft Graph API.
422
+
423
+ Raises:
424
+ ValueError: If authentication fails or no authorization code.
425
+ """
426
+ from azure.identity import AuthorizationCodeCredential
427
+
428
+ # offline_access scope is needed so the azure credential can refresh
429
+ # internally after access token expires as azure handles it internally
430
+ # Do not add offline_access to self.scopes as MSAL does not allow it
431
+ scope = [*self.scopes, "offline_access"]
432
+
433
+ auth_url = self._get_auth_url(
434
+ client_id=self.client_id,
435
+ tenant_id=self.tenant_id,
436
+ redirect_uri=self.redirect_uri,
437
+ scopes=scope,
438
+ )
439
+
440
+ authorization_code = self._get_authorization_code_via_browser(auth_url)
441
+
442
+ token_result = self._exchange_authorization_code_for_tokens(
443
+ authorization_code=authorization_code,
444
+ scope=scope,
445
+ )
446
+
447
+ refresh_token = token_result.get("refresh_token")
448
+ if refresh_token:
449
+ self._save_token_to_file(refresh_token)
450
+ credentials = CustomAzureCredential(
451
+ client_id=self.client_id,
452
+ client_secret=self.client_secret,
453
+ tenant_id=self.tenant_id,
454
+ refresh_token=refresh_token,
455
+ scopes=self.scopes,
456
+ refresh_token_file_path=self.refresh_token_file_path,
457
+ )
458
+
459
+ access_token = token_result.get("access_token")
460
+ expires_in = token_result.get("expires_in")
461
+ if access_token and expires_in:
462
+ # Prime the credential to avoid an immediate refresh request.
463
+ credentials._access_token = access_token
464
+ credentials._expires_at = (
465
+ int(time.time()) + int(expires_in) - 60
466
+ )
467
+ return credentials
468
+
469
+ logger.warning(
470
+ "No refresh_token returned from browser auth; falling back to "
471
+ "AuthorizationCodeCredential (token won't be persisted to the "
472
+ "provided refresh_token_file_path)."
473
+ )
474
+ return AuthorizationCodeCredential(
475
+ tenant_id=self.tenant_id,
476
+ client_id=self.client_id,
477
+ authorization_code=authorization_code,
478
+ redirect_uri=self.redirect_uri,
479
+ client_secret=self.client_secret,
480
+ )
481
+
482
+ def _get_authorization_code_via_browser(self, auth_url: str) -> str:
483
+ """Opens a browser and captures the authorization code via localhost.
484
+
485
+ Args:
486
+ auth_url (str): The authorization URL to open in the browser.
487
+
488
+ Returns:
489
+ str: The captured authorization code.
490
+
491
+ Raises:
492
+ ValueError: If the authorization code cannot be captured.
493
+ """
494
+ import webbrowser
495
+ from urllib.parse import urlparse
496
+
497
+ parsed_uri = urlparse(self.redirect_uri)
498
+ hostname = parsed_uri.hostname
499
+ port = parsed_uri.port
500
+ if not hostname or not port:
501
+ raise ValueError(
502
+ f"Invalid redirect_uri, expected host and port: "
503
+ f"{self.redirect_uri}"
504
+ )
505
+
506
+ server_address = (hostname, port)
507
+ server = OAuthHTTPServer(server_address, RedirectHandler)
508
+ server.code = None
509
+
510
+ logger.info(f"Opening browser for authentication: {auth_url}")
511
+ webbrowser.open(auth_url)
512
+
513
+ server.handle_request()
514
+ server.server_close()
515
+
516
+ authorization_code = server.code
517
+ if not authorization_code:
518
+ raise ValueError("Failed to get authorization code")
519
+ return authorization_code
520
+
521
+ def _exchange_authorization_code_for_tokens(
522
+ self, authorization_code: str, scope: List[str]
523
+ ) -> Dict[str, Any]:
524
+ """Exchanges an authorization code for tokens via OAuth token endpoint.
525
+
526
+ Args:
527
+ authorization_code (str): Authorization code captured from browser.
528
+ scope (List[str]): Scopes requested in the authorization flow.
529
+
530
+ Returns:
531
+ Dict[str, Any]: Token response JSON.
532
+
533
+ Raises:
534
+ ValueError: If token exchange fails or returns an error payload.
535
+ """
536
+ token_url = (
537
+ f"https://login.microsoftonline.com/{self.tenant_id}"
538
+ f"/oauth2/v2.0/token"
539
+ )
540
+ data = {
541
+ "client_id": self.client_id,
542
+ "client_secret": self.client_secret,
543
+ "grant_type": "authorization_code",
544
+ "code": authorization_code,
545
+ "redirect_uri": self.redirect_uri,
546
+ "scope": " ".join(scope),
547
+ }
548
+
549
+ response = requests.post(token_url, data=data, timeout=self.timeout)
550
+ result = response.json()
551
+
552
+ if "error" in result:
553
+ error_desc = result.get("error_description", result["error"])
554
+ raise ValueError(f"Token exchange failed: {error_desc}")
555
+
556
+ return result
557
+
558
+ @api_keys_required(
559
+ [
560
+ (None, "MICROSOFT_CLIENT_ID"),
561
+ (None, "MICROSOFT_CLIENT_SECRET"),
562
+ ]
563
+ )
564
+ def _authenticate(self):
565
+ """Authenticates and creates credential for Microsoft Graph.
566
+
567
+ Implements two-stage authentication:
568
+ 1. Attempts to use saved refresh token if refresh_token_file_path is
569
+ provided
570
+ 2. Falls back to browser OAuth if no token or token invalid
571
+
572
+ Returns:
573
+ AuthorizationCodeCredential or CustomAzureCredential
574
+
575
+ Raises:
576
+ ValueError: If authentication fails through both methods.
577
+ """
578
+ from azure.identity import AuthorizationCodeCredential
579
+
580
+ try:
581
+ self.tenant_id = os.getenv("MICROSOFT_TENANT_ID", "common")
582
+ self.client_id = os.getenv("MICROSOFT_CLIENT_ID")
583
+ self.client_secret = os.getenv("MICROSOFT_CLIENT_SECRET")
584
+
585
+ # Try saved refresh token first if token file path is provided
586
+ if (
587
+ self.refresh_token_file_path
588
+ and self.refresh_token_file_path.exists()
589
+ ):
590
+ try:
591
+ credentials: CustomAzureCredential = (
592
+ self._authenticate_using_refresh_token()
593
+ )
594
+ return credentials
595
+ except Exception as e:
596
+ logger.warning(
597
+ f"Authentication using refresh token failed: {e!s}. "
598
+ f"Falling back to browser authentication"
599
+ )
600
+
601
+ # Fall back to browser authentication
602
+ credentials: AuthorizationCodeCredential = (
603
+ self._authenticate_using_browser()
604
+ )
605
+ return credentials
606
+
607
+ except Exception as e:
608
+ error_msg = f"Failed to authenticate: {e!s}"
609
+ logger.error(error_msg)
610
+ raise ValueError(error_msg)
611
+
612
+ def _get_graph_client(self, credentials, scopes):
613
+ """Creates Microsoft Graph API client.
614
+
615
+ Args:
616
+ credentials : AuthorizationCodeCredential or
617
+ AsyncCustomAzureCredential.
618
+ scopes (List[str]): List of permission scopes.
619
+
620
+ Returns:
621
+ GraphServiceClient: Microsoft Graph API client.
622
+
623
+ Raises:
624
+ ValueError: If client creation fails.
625
+ """
626
+ from msgraph import GraphServiceClient
627
+
628
+ try:
629
+ return GraphServiceClient(credentials=credentials, scopes=scopes)
630
+ except Exception as e:
631
+ error_msg = f"Failed to create Graph client: {e!s}"
632
+ logger.error(error_msg)
633
+ raise ValueError(error_msg)
634
+
635
+ def is_email_valid(self, email: str) -> bool:
636
+ """Validates a single email address.
637
+
638
+ Args:
639
+ email (str): Email address to validate.
640
+
641
+ Returns:
642
+ bool: True if the email is valid, False otherwise.
643
+ """
644
+ import re
645
+ from email.utils import parseaddr
646
+
647
+ # Extract email address from both formats : "Email" , "Name <Email>"
648
+ _, addr = parseaddr(email)
649
+
650
+ email_pattern = re.compile(
651
+ r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
652
+ )
653
+ return bool(addr and email_pattern.match(addr))
654
+
655
+ def _get_invalid_emails(self, *lists: Optional[List[str]]) -> List[str]:
656
+ """Finds invalid email addresses from multiple email lists.
657
+
658
+ Args:
659
+ *lists: Variable number of optional email address lists.
660
+
661
+ Returns:
662
+ List[str]: List of invalid email addresses. Empty list if all
663
+ emails are valid.
664
+ """
665
+ invalid_emails = []
666
+ for email_list in lists:
667
+ if email_list is None:
668
+ continue
669
+ for email in email_list:
670
+ if not self.is_email_valid(email):
671
+ invalid_emails.append(email)
672
+ return invalid_emails
673
+
674
+ def _create_attachments(self, file_paths: List[str]) -> List[Any]:
675
+ """Creates Microsoft Graph FileAttachment objects from file paths.
676
+
677
+ Args:
678
+ file_paths (List[str]): List of local file paths to attach.
679
+
680
+ Returns:
681
+ List[Any]: List of FileAttachment objects ready for Graph API use.
682
+
683
+ Raises:
684
+ ValueError: If any file cannot be read or attached.
685
+ """
686
+ from msgraph.generated.models.file_attachment import FileAttachment
687
+
688
+ attachment_list = []
689
+
690
+ for file_path in file_paths:
691
+ try:
692
+ if not os.path.isfile(file_path):
693
+ raise ValueError(
694
+ f"Path does not exist or is not a file: {file_path}"
695
+ )
696
+
697
+ with open(file_path, "rb") as file:
698
+ file_content = file.read()
699
+
700
+ file_name = os.path.basename(file_path)
701
+
702
+ attachment_obj = FileAttachment(
703
+ name=file_name,
704
+ content_bytes=file_content,
705
+ )
706
+
707
+ attachment_list.append(attachment_obj)
708
+
709
+ except Exception as e:
710
+ raise ValueError(f"Failed to attach file {file_path}: {e!s}")
711
+
712
+ return attachment_list
713
+
714
+ def _create_recipients(self, email_list: List[str]) -> List[Any]:
715
+ """Creates Microsoft Graph Recipient objects from email addresses.
716
+
717
+ Supports both simple email format ("email@example.com") and
718
+ name-email format ("John Doe <email@example.com>").
719
+
720
+ Args:
721
+ email_list (List[str]): List of email addresses,
722
+ which can include display names.
723
+
724
+ Returns:
725
+ List[Any]: List of Recipient objects ready for Graph API use.
726
+ """
727
+ from email.utils import parseaddr
728
+
729
+ from msgraph.generated.models import email_address, recipient
730
+
731
+ recipients: List[Any] = []
732
+ for email in email_list:
733
+ # Extract email address from both formats: "Email", "Name <Email>"
734
+ name, addr = parseaddr(email)
735
+ address = email_address.EmailAddress(address=addr)
736
+ if name:
737
+ address.name = name
738
+ recp = recipient.Recipient(email_address=address)
739
+ recipients.append(recp)
740
+ return recipients
741
+
742
+ def _create_message(
743
+ self,
744
+ to_email: Optional[List[str]] = None,
745
+ subject: Optional[str] = None,
746
+ content: Optional[str] = None,
747
+ is_content_html: bool = False,
748
+ attachments: Optional[List[str]] = None,
749
+ cc_recipients: Optional[List[str]] = None,
750
+ bcc_recipients: Optional[List[str]] = None,
751
+ reply_to: Optional[List[str]] = None,
752
+ ):
753
+ """Creates a message object for sending or updating emails.
754
+
755
+ This helper method is used internally to construct Microsoft Graph
756
+ message objects. It's used by methods like send_email,
757
+ create_draft_email, and update_draft_message. All parameters are
758
+ optional to allow partial updates when modifying existing messages.
759
+
760
+ Args:
761
+ to_email (Optional[List[str]]): List of recipient email addresses.
762
+ (default: :obj:`None`)
763
+ subject (Optional[str]): The subject of the email.
764
+ (default: :obj:`None`)
765
+ content (Optional[str]): The body content of the email.
766
+ (default: :obj:`None`)
767
+ is_content_html (bool): If True, the content type will be set to
768
+ HTML; otherwise, it will be Text. (default: :obj:`False`)
769
+ attachments (Optional[List[str]]): List of file paths to attach
770
+ to the email. (default: :obj:`None`)
771
+ cc_recipients (Optional[List[str]]): List of CC recipient email
772
+ addresses. (default: :obj:`None`)
773
+ bcc_recipients (Optional[List[str]]): List of BCC recipient email
774
+ addresses. (default: :obj:`None`)
775
+ reply_to (Optional[List[str]]): List of email addresses that will
776
+ receive replies when recipients use the "Reply" button. This
777
+ allows replies to be directed to different addresses than the
778
+ sender's address. (default: :obj:`None`)
779
+
780
+ Returns:
781
+ message.Message: A Microsoft Graph message object with only the
782
+ provided fields set.
783
+ """
784
+ from msgraph.generated.models import body_type, item_body, message
785
+
786
+ content_type = (
787
+ body_type.BodyType.Html
788
+ if is_content_html
789
+ else body_type.BodyType.Text
790
+ )
791
+
792
+ mail_message = message.Message()
793
+
794
+ # Set body content if provided
795
+ if content:
796
+ message_body = item_body.ItemBody(
797
+ content_type=content_type, content=content
798
+ )
799
+ mail_message.body = message_body
800
+
801
+ # Set to recipients if provided
802
+ if to_email:
803
+ mail_message.to_recipients = self._create_recipients(to_email)
804
+
805
+ # Set subject if provided
806
+ if subject:
807
+ mail_message.subject = subject
808
+
809
+ # Add CC recipients if provided
810
+ if cc_recipients:
811
+ mail_message.cc_recipients = self._create_recipients(cc_recipients)
812
+
813
+ # Add BCC recipients if provided
814
+ if bcc_recipients:
815
+ mail_message.bcc_recipients = self._create_recipients(
816
+ bcc_recipients
817
+ )
818
+
819
+ # Add reply-to addresses if provided
820
+ if reply_to:
821
+ mail_message.reply_to = self._create_recipients(reply_to)
822
+
823
+ # Add attachments if provided
824
+ if attachments:
825
+ mail_message.attachments = self._create_attachments(attachments)
826
+
827
+ return mail_message
828
+
829
+ async def outlook_send_email(
830
+ self,
831
+ to_email: List[str],
832
+ subject: str,
833
+ content: str,
834
+ is_content_html: bool = False,
835
+ attachments: Optional[List[str]] = None,
836
+ cc_recipients: Optional[List[str]] = None,
837
+ bcc_recipients: Optional[List[str]] = None,
838
+ reply_to: Optional[List[str]] = None,
839
+ save_to_sent_items: bool = True,
840
+ ) -> Dict[str, Any]:
841
+ """Sends an email via Microsoft Outlook.
842
+
843
+ Args:
844
+ to_email (List[str]): List of recipient email addresses.
845
+ subject (str): The subject of the email.
846
+ content (str): The body content of the email.
847
+ is_content_html (bool): If True, the content type will be set to
848
+ HTML; otherwise, it will be Text. (default: :obj:`False`)
849
+ attachments (Optional[List[str]]): List of file paths to attach
850
+ to the email. (default: :obj:`None`)
851
+ cc_recipients (Optional[List[str]]): List of CC recipient email
852
+ addresses. (default: :obj:`None`)
853
+ bcc_recipients (Optional[List[str]]): List of BCC recipient email
854
+ addresses. (default: :obj:`None`)
855
+ reply_to (Optional[List[str]]): List of email addresses that will
856
+ receive replies when recipients use the "Reply" button. This
857
+ allows replies to be directed to different addresses than the
858
+ sender's address. (default: :obj:`None`)
859
+ save_to_sent_items (bool): Whether to save the email to sent
860
+ items. (default: :obj:`True`)
861
+
862
+ Returns:
863
+ Dict[str, Any]: A dictionary containing the result of the email
864
+ sending operation.
865
+ """
866
+ from msgraph.generated.users.item.send_mail.send_mail_post_request_body import ( # noqa: E501
867
+ SendMailPostRequestBody,
868
+ )
869
+
870
+ try:
871
+ # Validate all email addresses
872
+ invalid_emails = self._get_invalid_emails(
873
+ to_email, cc_recipients, bcc_recipients, reply_to
874
+ )
875
+ if invalid_emails:
876
+ error_msg = (
877
+ f"Invalid email address(es) provided: "
878
+ f"{', '.join(invalid_emails)}"
879
+ )
880
+ logger.error(error_msg)
881
+ return {"error": error_msg}
882
+
883
+ mail_message = self._create_message(
884
+ to_email=to_email,
885
+ subject=subject,
886
+ content=content,
887
+ is_content_html=is_content_html,
888
+ attachments=attachments,
889
+ cc_recipients=cc_recipients,
890
+ bcc_recipients=bcc_recipients,
891
+ reply_to=reply_to,
892
+ )
893
+
894
+ request = SendMailPostRequestBody(
895
+ message=mail_message,
896
+ save_to_sent_items=save_to_sent_items,
897
+ )
898
+
899
+ await self.client.me.send_mail.post(request)
900
+
901
+ logger.info("Email sent successfully.")
902
+ return {
903
+ 'status': 'success',
904
+ 'message': 'Email sent successfully',
905
+ 'recipients': to_email,
906
+ 'subject': subject,
907
+ }
908
+ except Exception as e:
909
+ logger.exception("Failed to send email")
910
+ return {"error": f"Failed to send email: {e!s}"}
911
+
912
+ async def outlook_create_draft_email(
913
+ self,
914
+ to_email: List[str],
915
+ subject: str,
916
+ content: str,
917
+ is_content_html: bool = False,
918
+ attachments: Optional[List[str]] = None,
919
+ cc_recipients: Optional[List[str]] = None,
920
+ bcc_recipients: Optional[List[str]] = None,
921
+ reply_to: Optional[List[str]] = None,
922
+ ) -> Dict[str, Any]:
923
+ """Creates a draft email in Microsoft Outlook.
924
+
925
+ Args:
926
+ to_email (List[str]): List of recipient email addresses.
927
+ subject (str): The subject of the email.
928
+ content (str): The body content of the email.
929
+ is_content_html (bool): If True, the content type will be set to
930
+ HTML; otherwise, it will be Text. (default: :obj:`False`)
931
+ attachments (Optional[List[str]]): List of file paths to attach
932
+ to the email. (default: :obj:`None`)
933
+ cc_recipients (Optional[List[str]]): List of CC recipient email
934
+ addresses. (default: :obj:`None`)
935
+ bcc_recipients (Optional[List[str]]): List of BCC recipient email
936
+ addresses. (default: :obj:`None`)
937
+ reply_to (Optional[List[str]]): List of email addresses that will
938
+ receive replies when recipients use the "Reply" button. This
939
+ allows replies to be directed to different addresses than the
940
+ sender's address. (default: :obj:`None`)
941
+
942
+ Returns:
943
+ Dict[str, Any]: A dictionary containing the result of the draft
944
+ email creation operation, including the draft ID.
945
+
946
+ """
947
+ # Validate all email addresses
948
+ invalid_emails = self._get_invalid_emails(
949
+ to_email, cc_recipients, bcc_recipients, reply_to
950
+ )
951
+ if invalid_emails:
952
+ error_msg = (
953
+ f"Invalid email address(es) provided: "
954
+ f"{', '.join(invalid_emails)}"
955
+ )
956
+ logger.error(error_msg)
957
+ return {"error": error_msg}
958
+
959
+ try:
960
+ request_body = self._create_message(
961
+ to_email=to_email,
962
+ subject=subject,
963
+ content=content,
964
+ is_content_html=is_content_html,
965
+ attachments=attachments,
966
+ cc_recipients=cc_recipients,
967
+ bcc_recipients=bcc_recipients,
968
+ reply_to=reply_to,
969
+ )
970
+
971
+ result = await self.client.me.messages.post(request_body)
972
+
973
+ logger.info("Draft email created successfully.")
974
+ return {
975
+ 'status': 'success',
976
+ 'message': 'Draft email created successfully',
977
+ 'draft_id': result.id,
978
+ 'recipients': to_email,
979
+ 'subject': subject,
980
+ }
981
+ except Exception as e:
982
+ error_msg = f"Failed to create draft email: {e!s}"
983
+ logger.error(error_msg)
984
+ return {"error": error_msg}
985
+
986
+ async def outlook_send_draft_email(self, draft_id: str) -> Dict[str, Any]:
987
+ """Sends a draft email via Microsoft Outlook.
988
+
989
+ Args:
990
+ draft_id (str): The ID of the draft email to send. Can be
991
+ obtained either by creating a draft via
992
+ `create_draft_email()` or from the 'message_id' field in
993
+ messages returned by `list_messages()`.
994
+
995
+ Returns:
996
+ Dict[str, Any]: A dictionary containing the result of the draft
997
+ email sending operation.
998
+ """
999
+ try:
1000
+ await self.client.me.messages.by_message_id(draft_id).send.post()
1001
+
1002
+ logger.info(f"Draft email with ID {draft_id} sent successfully.")
1003
+ return {
1004
+ 'status': 'success',
1005
+ 'message': 'Draft email sent successfully',
1006
+ 'draft_id': draft_id,
1007
+ }
1008
+ except Exception as e:
1009
+ error_msg = f"Failed to send draft email: {e!s}"
1010
+ logger.error(error_msg)
1011
+ return {"error": error_msg}
1012
+
1013
+ async def outlook_delete_email(self, message_id: str) -> Dict[str, Any]:
1014
+ """Deletes an email from Microsoft Outlook.
1015
+
1016
+ Args:
1017
+ message_id (str): The ID of the email to delete. Can be obtained
1018
+ from the 'message_id' field in messages returned by
1019
+ `list_messages()`.
1020
+
1021
+ Returns:
1022
+ Dict[str, Any]: A dictionary containing the result of the email
1023
+ deletion operation.
1024
+ """
1025
+ try:
1026
+ await self.client.me.messages.by_message_id(message_id).delete()
1027
+ logger.info(f"Email with ID {message_id} deleted successfully.")
1028
+ return {
1029
+ 'status': 'success',
1030
+ 'message': 'Email deleted successfully',
1031
+ 'message_id': message_id,
1032
+ }
1033
+ except Exception as e:
1034
+ error_msg = f"Failed to delete email: {e!s}"
1035
+ logger.error(error_msg)
1036
+ return {"error": error_msg}
1037
+
1038
+ async def outlook_move_message_to_folder(
1039
+ self, message_id: str, destination_folder_id: str
1040
+ ) -> Dict[str, Any]:
1041
+ """Moves an email to a specified folder in Microsoft Outlook.
1042
+
1043
+ Args:
1044
+ message_id (str): The ID of the email to move. Can be obtained
1045
+ from the 'message_id' field in messages returned by
1046
+ `list_messages()`.
1047
+ destination_folder_id (str): The destination folder ID, or
1048
+ a well-known folder name. Supported well-known folder names are
1049
+ ("inbox", "drafts", "sentitems", "deleteditems", "junkemail",
1050
+ "archive", "outbox").
1051
+
1052
+ Returns:
1053
+ Dict[str, Any]: A dictionary containing the result of the email
1054
+ move operation.
1055
+ """
1056
+ from msgraph.generated.users.item.messages.item.move.move_post_request_body import ( # noqa: E501
1057
+ MovePostRequestBody,
1058
+ )
1059
+
1060
+ try:
1061
+ request_body = MovePostRequestBody(
1062
+ destination_id=destination_folder_id,
1063
+ )
1064
+ message = self.client.me.messages.by_message_id(message_id)
1065
+ await message.move.post(request_body)
1066
+
1067
+ logger.info(
1068
+ f"Email with ID {message_id} moved to folder "
1069
+ f"{destination_folder_id} successfully."
1070
+ )
1071
+ return {
1072
+ 'status': 'success',
1073
+ 'message': 'Email moved successfully',
1074
+ 'message_id': message_id,
1075
+ 'destination_folder_id': destination_folder_id,
1076
+ }
1077
+ except Exception as e:
1078
+ error_msg = f"Failed to move email: {e!s}"
1079
+ logger.error(error_msg)
1080
+ return {"error": error_msg}
1081
+
1082
+ async def outlook_get_attachments(
1083
+ self,
1084
+ message_id: str,
1085
+ metadata_only: bool = True,
1086
+ include_inline_attachments: bool = False,
1087
+ save_path: Optional[str] = None,
1088
+ ) -> Dict[str, Any]:
1089
+ """Retrieves attachments from a Microsoft Outlook email message.
1090
+
1091
+ This method fetches attachments from a specified email message and can
1092
+ either return metadata only or download the full attachment content.
1093
+ Inline attachments (like embedded images) can optionally be included
1094
+ or excluded from the results.
1095
+ Also, if a save_path is provided, attachments will be saved to disk.
1096
+
1097
+ Args:
1098
+ message_id (str): The unique identifier of the email message from
1099
+ which to retrieve attachments. Can be obtained from the
1100
+ 'message_id' field in messages returned by `list_messages()`.
1101
+ metadata_only (bool): If True, returns only attachment metadata
1102
+ (name, size, content type, etc.) without downloading the actual
1103
+ file content. If False, downloads the full attachment content.
1104
+ (default: :obj:`True`)
1105
+ include_inline_attachments (bool): If True, includes inline
1106
+ attachments (such as embedded images) in the results. If False,
1107
+ filters them out. (default: :obj:`False`)
1108
+ save_path (Optional[str]): The local directory path where
1109
+ attachments should be saved. If provided, attachments are saved
1110
+ to disk and the file paths are returned. If None, attachment
1111
+ content is returned as base64-encoded strings (only when
1112
+ metadata_only=False). (default: :obj:`None`)
1113
+
1114
+ Returns:
1115
+ Dict[str, Any]: A dictionary containing the attachment retrieval
1116
+ results
1117
+ """
1118
+ try:
1119
+ request_config = None
1120
+ if metadata_only:
1121
+ request_config = self._build_attachment_query()
1122
+
1123
+ attachments_response = await self._fetch_attachments(
1124
+ message_id, request_config
1125
+ )
1126
+ if not attachments_response:
1127
+ return {
1128
+ 'status': 'success',
1129
+ 'message_id': message_id,
1130
+ 'attachments': [],
1131
+ 'total_count': 0,
1132
+ }
1133
+
1134
+ attachments_list = []
1135
+ for attachment in attachments_response.value:
1136
+ if not include_inline_attachments and attachment.is_inline:
1137
+ continue
1138
+ info = self._process_attachment(
1139
+ attachment,
1140
+ metadata_only,
1141
+ save_path,
1142
+ )
1143
+ attachments_list.append(info)
1144
+
1145
+ return {
1146
+ 'status': 'success',
1147
+ 'message_id': message_id,
1148
+ 'attachments': attachments_list,
1149
+ 'total_count': len(attachments_list),
1150
+ }
1151
+
1152
+ except Exception as e:
1153
+ error_msg = f"Failed to get attachments: {e!s}"
1154
+ logger.error(error_msg)
1155
+ return {"error": error_msg}
1156
+
1157
+ def _build_attachment_query(self):
1158
+ """Constructs the query configuration for fetching attachment metadata.
1159
+
1160
+ Returns:
1161
+ AttachmentsRequestBuilderGetRequestConfiguration: Query config
1162
+ for the Graph API request.
1163
+ """
1164
+ from msgraph.generated.users.item.messages.item.attachments.attachments_request_builder import ( # noqa: E501
1165
+ AttachmentsRequestBuilder,
1166
+ )
1167
+
1168
+ query_params = AttachmentsRequestBuilder.AttachmentsRequestBuilderGetQueryParameters( # noqa: E501
1169
+ select=[
1170
+ "id",
1171
+ "lastModifiedDateTime",
1172
+ "name",
1173
+ "contentType",
1174
+ "size",
1175
+ "isInline",
1176
+ ]
1177
+ )
1178
+
1179
+ return AttachmentsRequestBuilder.AttachmentsRequestBuilderGetRequestConfiguration( # noqa: E501
1180
+ query_parameters=query_params
1181
+ )
1182
+
1183
+ async def _fetch_attachments(
1184
+ self, message_id: str, request_config: Optional[Any] = None
1185
+ ):
1186
+ """Fetches attachments from the Microsoft Graph API.
1187
+
1188
+ Args:
1189
+ message_id (str): The email message ID.
1190
+ request_config (Optional[Any]): The request configuration with
1191
+ query parameters. (default: :obj:`None`)
1192
+
1193
+
1194
+ Returns:
1195
+ Attachments response from the Graph API.
1196
+ """
1197
+ if not request_config:
1198
+ return await self.client.me.messages.by_message_id(
1199
+ message_id
1200
+ ).attachments.get()
1201
+ return await self.client.me.messages.by_message_id(
1202
+ message_id
1203
+ ).attachments.get(request_configuration=request_config)
1204
+
1205
+ def _process_attachment(
1206
+ self,
1207
+ attachment,
1208
+ metadata_only: bool,
1209
+ save_path: Optional[str],
1210
+ ):
1211
+ """Processes a single attachment and extracts its information.
1212
+
1213
+ Args:
1214
+ attachment: The attachment object from Graph API.
1215
+ metadata_only (bool): Whether to include content bytes.
1216
+ save_path (Optional[str]): Path to save attachment file.
1217
+
1218
+ Returns:
1219
+ Dict: Dictionary containing attachment information.
1220
+ """
1221
+ import base64
1222
+
1223
+ last_modified = getattr(attachment, 'last_modified_date_time', None)
1224
+ info = {
1225
+ 'id': attachment.id,
1226
+ 'name': attachment.name,
1227
+ 'content_type': attachment.content_type,
1228
+ 'size': attachment.size,
1229
+ 'is_inline': getattr(attachment, 'is_inline', False),
1230
+ 'last_modified_date_time': (
1231
+ last_modified.isoformat() if last_modified else None
1232
+ ),
1233
+ }
1234
+
1235
+ if not metadata_only:
1236
+ content_bytes = getattr(attachment, 'content_bytes', None)
1237
+ if content_bytes:
1238
+ # Decode once because bytes contain Base64 text
1239
+ decoded_bytes = base64.b64decode(content_bytes)
1240
+
1241
+ if save_path:
1242
+ file_path = self._save_attachment_file(
1243
+ save_path, attachment.name, decoded_bytes
1244
+ )
1245
+ info['saved_path'] = file_path
1246
+ logger.info(
1247
+ f"Attachment {attachment.name} saved to {file_path}"
1248
+ )
1249
+ else:
1250
+ info['content_bytes'] = content_bytes
1251
+
1252
+ return info
1253
+
1254
+ def _save_attachment_file(
1255
+ self,
1256
+ save_path: str,
1257
+ attachment_name: str,
1258
+ content_bytes: bytes,
1259
+ cannot_overwrite: bool = True,
1260
+ ) -> str:
1261
+ """Saves attachment content to a file on disk.
1262
+
1263
+ Args:
1264
+ save_path (str): Directory path where file should be saved.
1265
+ attachment_name (str): Name of the attachment file.
1266
+ content_bytes (bytes): The file content as bytes.
1267
+ cannot_overwrite (bool): If True, appends counter to filename
1268
+ if file exists. (default: :obj:`True`)
1269
+
1270
+ Returns:
1271
+ str: The full file path where the attachment was saved.
1272
+ """
1273
+ import os
1274
+
1275
+ os.makedirs(save_path, exist_ok=True)
1276
+ file_path = os.path.join(save_path, attachment_name)
1277
+ file_path_already_exists = os.path.exists(file_path)
1278
+ if cannot_overwrite and file_path_already_exists:
1279
+ count = 1
1280
+ name, ext = os.path.splitext(attachment_name)
1281
+ while os.path.exists(file_path):
1282
+ file_path = os.path.join(save_path, f"{name}_{count}{ext}")
1283
+ count += 1
1284
+ with open(file_path, 'wb') as f:
1285
+ f.write(content_bytes)
1286
+ return file_path
1287
+
1288
+ def _handle_html_body(self, body_content: str) -> str:
1289
+ """Converts HTML email body to plain text.
1290
+
1291
+ Note: This method performs client-side HTML-to-text conversion.
1292
+
1293
+ Args:
1294
+ body_content (str): The HTML content of the email body. This
1295
+ content is already sanitized by Microsoft Graph API.
1296
+
1297
+ Returns:
1298
+ str: Plain text version of the email body with cleaned whitespace
1299
+ and removed HTML tags.
1300
+ """
1301
+ try:
1302
+ import html2text
1303
+
1304
+ parser = html2text.HTML2Text()
1305
+
1306
+ parser.ignore_links = False
1307
+ parser.inline_links = True
1308
+ parser.protect_links = True
1309
+ parser.skip_internal_links = True
1310
+
1311
+ parser.ignore_images = False
1312
+ parser.images_as_html = False
1313
+ parser.images_to_alt = False
1314
+ parser.images_with_size = False
1315
+
1316
+ parser.ignore_emphasis = False
1317
+ parser.body_width = 0
1318
+ parser.single_line_break = True
1319
+
1320
+ return parser.handle(body_content).strip()
1321
+
1322
+ except Exception as e:
1323
+ logger.error(f"Failed to parse HTML body: {e!s}")
1324
+ return body_content
1325
+
1326
+ def _get_recipients(self, recipient_list: Optional[List[Any]]):
1327
+ """Gets a list of recipients from a recipient list object."""
1328
+ recipients: List[Dict[str, str]] = []
1329
+ if not recipient_list:
1330
+ return recipients
1331
+ for recipient_info in recipient_list:
1332
+ email = recipient_info.email_address.address
1333
+ name = recipient_info.email_address.name
1334
+ recipients.append({'address': email, 'name': name})
1335
+ return recipients
1336
+
1337
+ async def _extract_message_details(
1338
+ self,
1339
+ message: Any,
1340
+ return_html_content: bool = False,
1341
+ include_attachments: bool = False,
1342
+ attachment_metadata_only: bool = True,
1343
+ include_inline_attachments: bool = False,
1344
+ attachment_save_path: Optional[str] = None,
1345
+ ) -> Dict[str, Any]:
1346
+ """Extracts detailed information from a message object.
1347
+
1348
+ This function processes a message object (either from a list response
1349
+ or a direct fetch) and extracts all relevant details. It can
1350
+ optionally fetch attachments but does not make additional API calls
1351
+ for basic message information.
1352
+
1353
+ Args:
1354
+ message (Any): The Microsoft Graph message object to extract
1355
+ details from.
1356
+ return_html_content (bool): If True and body content type is HTML,
1357
+ returns the raw HTML content without converting it to plain
1358
+ text. If False and body_type is 'text', HTML content will be
1359
+ converted to plain text.
1360
+ (default: :obj:`False`)
1361
+ include_attachments (bool): Whether to include attachment
1362
+ information. If True, will make an API call to fetch
1363
+ attachments. (default: :obj:`False`)
1364
+ attachment_metadata_only (bool): If True, returns only attachment
1365
+ metadata without downloading content. If False, downloads full
1366
+ attachment content. Only used when include_attachments=True.
1367
+ (default: :obj:`True`)
1368
+ include_inline_attachments (bool): If True, includes inline
1369
+ attachments in the results. Only used when
1370
+ include_attachments=True. (default: :obj:`False`)
1371
+ attachment_save_path (Optional[str]): Directory path where
1372
+ attachments should be saved. Only used when
1373
+ include_attachments=True and attachment_metadata_only=False.
1374
+ (default: :obj:`None`)
1375
+
1376
+ Returns:
1377
+ Dict[str, Any]: A dictionary containing the message details
1378
+ including:
1379
+ - Basic info (message_id, subject, from, received_date_time,
1380
+ body etc.)
1381
+ - Recipients (to_recipients, cc_recipients, bcc_recipients)
1382
+ - Attachment information (if requested)
1383
+ """
1384
+ try:
1385
+ # Validate message object
1386
+ from msgraph.generated.models.message import Message
1387
+
1388
+ if not isinstance(message, Message):
1389
+ return {'error': 'Invalid message object provided'}
1390
+ # Extract basic details
1391
+ details = {
1392
+ 'message_id': message.id,
1393
+ 'subject': message.subject,
1394
+ # Draft messages have from_ as None
1395
+ 'from': (
1396
+ self._get_recipients([message.from_])
1397
+ if message.from_
1398
+ else None
1399
+ ),
1400
+ 'to_recipients': self._get_recipients(message.to_recipients),
1401
+ 'cc_recipients': self._get_recipients(message.cc_recipients),
1402
+ 'bcc_recipients': self._get_recipients(message.bcc_recipients),
1403
+ 'received_date_time': (
1404
+ message.received_date_time.isoformat()
1405
+ if message.received_date_time
1406
+ else None
1407
+ ),
1408
+ 'sent_date_time': (
1409
+ message.sent_date_time.isoformat()
1410
+ if message.sent_date_time
1411
+ else None
1412
+ ),
1413
+ 'has_non_inline_attachments': message.has_attachments,
1414
+ 'importance': (str(message.importance)),
1415
+ 'is_read': message.is_read,
1416
+ 'is_draft': message.is_draft,
1417
+ 'body_preview': message.body_preview,
1418
+ }
1419
+
1420
+ body_content = message.body.content if message.body else ''
1421
+ content_type = message.body.content_type if (message.body) else ''
1422
+
1423
+ # Convert HTML to text if requested and content is HTML
1424
+ is_content_html = content_type and "html" in str(content_type)
1425
+ if is_content_html and not return_html_content and body_content:
1426
+ body_content = self._handle_html_body(body_content)
1427
+
1428
+ details['body'] = body_content
1429
+ details['body_type'] = content_type
1430
+
1431
+ # Include attachments if requested
1432
+ if not include_attachments:
1433
+ return details
1434
+
1435
+ attachments_info = await self.outlook_get_attachments(
1436
+ message_id=details['message_id'],
1437
+ metadata_only=attachment_metadata_only,
1438
+ include_inline_attachments=include_inline_attachments,
1439
+ save_path=attachment_save_path,
1440
+ )
1441
+ details['attachments'] = attachments_info.get('attachments', [])
1442
+ return details
1443
+
1444
+ except Exception as e:
1445
+ error_msg = f"Failed to extract message details: {e!s}"
1446
+ logger.error(error_msg)
1447
+ raise ValueError(error_msg)
1448
+
1449
+ async def outlook_get_message(
1450
+ self,
1451
+ message_id: str,
1452
+ return_html_content: bool = False,
1453
+ include_attachments: bool = False,
1454
+ attachment_metadata_only: bool = True,
1455
+ include_inline_attachments: bool = False,
1456
+ attachment_save_path: Optional[str] = None,
1457
+ ) -> Dict[str, Any]:
1458
+ """Retrieves a single email message by ID from Microsoft Outlook.
1459
+
1460
+ This method fetches a specific email message using its unique
1461
+ identifier and returns detailed information including subject, sender,
1462
+ recipients, body content, and optionally attachments.
1463
+
1464
+ Args:
1465
+ message_id (str): The unique identifier of the email message to
1466
+ retrieve. Can be obtained from the 'message_id' field in
1467
+ messages returned by `list_messages()`.
1468
+ return_html_content (bool): If True and body content type is HTML,
1469
+ returns the raw HTML content without converting it to plain
1470
+ text. If False and body_type is HTML, content will be converted
1471
+ to plain text. (default: :obj:`False`)
1472
+ include_attachments (bool): Whether to include attachment
1473
+ information in the response. (default: :obj:`False`)
1474
+ attachment_metadata_only (bool): If True, returns only attachment
1475
+ metadata without downloading content. If False, downloads full
1476
+ attachment content. Only used when include_attachments=True.
1477
+ (default: :obj:`True`)
1478
+ include_inline_attachments (bool): If True, includes inline
1479
+ attachments in the results. Only used when
1480
+ include_attachments=True. (default: :obj:`False`)
1481
+ attachment_save_path (Optional[str]): Directory path where
1482
+ attachments should be saved. Only used when
1483
+ include_attachments=True and attachment_metadata_only=False.
1484
+ (default: :obj:`None`)
1485
+
1486
+ Returns:
1487
+ Dict[str, Any]: A dictionary containing the message details
1488
+ including message_id, subject, from, to_recipients,
1489
+ cc_recipients, bcc_recipients, received_date_time,
1490
+ sent_date_time, body, body_type, has_attachments, importance,
1491
+ is_read, is_draft, body_preview, and optionally attachments.
1492
+ """
1493
+ try:
1494
+ message = await self.client.me.messages.by_message_id(
1495
+ message_id
1496
+ ).get()
1497
+
1498
+ if not message:
1499
+ error_msg = f"Message with ID {message_id} not found"
1500
+ logger.error(error_msg)
1501
+ return {"error": error_msg}
1502
+
1503
+ details = await self._extract_message_details(
1504
+ message=message,
1505
+ return_html_content=return_html_content,
1506
+ include_attachments=include_attachments,
1507
+ attachment_metadata_only=attachment_metadata_only,
1508
+ include_inline_attachments=include_inline_attachments,
1509
+ attachment_save_path=attachment_save_path,
1510
+ )
1511
+
1512
+ logger.info(f"Message with ID {message_id} retrieved successfully")
1513
+ return {
1514
+ 'status': 'success',
1515
+ 'message': details,
1516
+ }
1517
+
1518
+ except Exception as e:
1519
+ error_msg = f"Failed to get message: {e!s}"
1520
+ logger.error(error_msg)
1521
+ return {"error": error_msg}
1522
+
1523
+ async def _get_messages_from_folder(
1524
+ self,
1525
+ folder_id: str,
1526
+ request_config,
1527
+ ):
1528
+ """Fetches messages from a specific folder.
1529
+
1530
+ Args:
1531
+ folder_id (str): The folder ID or well-known folder name.
1532
+ request_config: The request configuration with query parameters.
1533
+
1534
+ Returns:
1535
+ Messages response from the Graph API, or None if folder not found.
1536
+ """
1537
+ try:
1538
+ messages = await self.client.me.mail_folders.by_mail_folder_id(
1539
+ folder_id
1540
+ ).messages.get(request_configuration=request_config)
1541
+ return messages
1542
+ except Exception as e:
1543
+ logger.warning(
1544
+ f"Failed to get messages from folder {folder_id}: {e!s}"
1545
+ )
1546
+ return None
1547
+
1548
+ async def outlook_list_messages(
1549
+ self,
1550
+ folder_ids: Optional[List[str]] = None,
1551
+ filter_query: Optional[str] = None,
1552
+ order_by: Optional[List[str]] = None,
1553
+ top: int = 10,
1554
+ skip: int = 0,
1555
+ return_html_content: bool = False,
1556
+ include_attachment_metadata: bool = False,
1557
+ ) -> Dict[str, Any]:
1558
+ """
1559
+ Retrieves messages from Microsoft Outlook using Microsoft Graph API.
1560
+
1561
+ Note: Each folder requires a separate API call. Use folder_ids=None
1562
+ to search the entire mailbox in one call for better performance.
1563
+
1564
+ When using $filter and $orderby in the same query to get messages,
1565
+ make sure to specify properties in the following ways:
1566
+ Properties that appear in $orderby must also appear in $filter.
1567
+ Properties that appear in $orderby are in the same order as in $filter.
1568
+ Properties that are present in $orderby appear in $filter before any
1569
+ properties that aren't.
1570
+ Failing to do this results in the following error:
1571
+ Error code: InefficientFilter
1572
+ Error message: The restriction or sort order is too complex for this
1573
+ operation.
1574
+
1575
+ Args:
1576
+ folder_ids (Optional[List[str]]): Folder IDs or well-known names
1577
+ ("inbox", "drafts", "sentitems", "deleteditems", "junkemail",
1578
+ "archive", "outbox"). None searches the entire mailbox.
1579
+ filter_query (Optional[str]): OData filter for messages.
1580
+ Examples:
1581
+ - Sender: "from/emailAddress/address eq 'john@example.com'"
1582
+ - Subject: "subject eq 'Meeting Notes'",
1583
+ "contains(subject, 'urgent')"
1584
+ - Read status: "isRead eq false", "isRead eq true"
1585
+ - Attachments: "hasAttachments eq true/false"
1586
+ - Importance: "importance eq 'high'/'normal'/'low'"
1587
+ - Date: "receivedDateTime ge 2024-01-01T00:00:00Z"
1588
+ - Combine: "isRead eq false and hasAttachments eq true"
1589
+ - Negation: "not(isRead eq true)"
1590
+ Reference: https://learn.microsoft.com/en-us/graph/filter-query-parameter
1591
+ order_by (Optional[List[str]]): OData orderBy for sorting messages.
1592
+ Examples:
1593
+ - Date: "receivedDateTime desc/asc", "sentDateTime desc"
1594
+ - Sender: "from/emailAddress/address asc/desc",
1595
+ - Subject: "subject asc/desc"
1596
+ - Importance: "importance desc/asc"
1597
+ - Size: "size desc/asc"
1598
+ - Multi-field: "importance desc, receivedDateTime desc"
1599
+ Reference: https://learn.microsoft.com/en-us/graph/query-parameters
1600
+ top (int): Max messages per folder (default: 10)
1601
+ skip (int): Messages to skip for pagination (default: 0)
1602
+ return_html_content (bool): Return raw HTML if True;
1603
+ else convert to text (default: False)
1604
+ include_attachment_metadata (bool): Include attachment metadata
1605
+ (name, size, type); content not included (default: False)
1606
+
1607
+ Returns:
1608
+ Dict[str, Any]: Dictionary containing messages and
1609
+ attachment metadata if requested.
1610
+ """
1611
+
1612
+ try:
1613
+ from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import ( # noqa: E501
1614
+ MessagesRequestBuilder,
1615
+ )
1616
+
1617
+ # Build query parameters
1618
+ query_params = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters( # noqa: E501
1619
+ top=top,
1620
+ skip=skip,
1621
+ orderby=order_by,
1622
+ filter=filter_query,
1623
+ )
1624
+
1625
+ request_config = MessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration( # noqa: E501
1626
+ query_parameters=query_params
1627
+ )
1628
+ if not folder_ids:
1629
+ # Search entire mailbox in a single API call
1630
+ messages_response = await self.client.me.messages.get(
1631
+ request_configuration=request_config
1632
+ )
1633
+ all_messages = []
1634
+ if messages_response and messages_response.value:
1635
+ for message in messages_response.value:
1636
+ details = await self._extract_message_details(
1637
+ message=message,
1638
+ return_html_content=return_html_content,
1639
+ include_attachments=include_attachment_metadata,
1640
+ attachment_metadata_only=True,
1641
+ include_inline_attachments=False,
1642
+ attachment_save_path=None,
1643
+ )
1644
+ all_messages.append(details)
1645
+
1646
+ logger.info(
1647
+ f"Retrieved {len(all_messages)} messages from mailbox"
1648
+ )
1649
+
1650
+ return {
1651
+ 'status': 'success',
1652
+ 'messages': all_messages,
1653
+ 'total_count': len(all_messages),
1654
+ 'skip': skip,
1655
+ 'top': top,
1656
+ 'folders_searched': ['all'],
1657
+ }
1658
+ # Search specific folders (requires multiple API calls)
1659
+ all_messages = []
1660
+ for folder_id in folder_ids:
1661
+ messages_response = await self._get_messages_from_folder(
1662
+ folder_id=folder_id,
1663
+ request_config=request_config,
1664
+ )
1665
+
1666
+ if not messages_response or not messages_response.value:
1667
+ continue
1668
+
1669
+ # Extract details from each message
1670
+ for message in messages_response.value:
1671
+ details = await self._extract_message_details(
1672
+ message=message,
1673
+ return_html_content=return_html_content,
1674
+ include_attachments=include_attachment_metadata,
1675
+ attachment_metadata_only=True,
1676
+ include_inline_attachments=False,
1677
+ attachment_save_path=None,
1678
+ )
1679
+ all_messages.append(details)
1680
+
1681
+ logger.info(
1682
+ f"Retrieved {len(all_messages)} messages from "
1683
+ f"{len(folder_ids)} folder(s)"
1684
+ )
1685
+
1686
+ return {
1687
+ 'status': 'success',
1688
+ 'messages': all_messages,
1689
+ 'total_count': len(all_messages),
1690
+ 'skip': skip,
1691
+ 'top': top,
1692
+ 'folders_searched': folder_ids,
1693
+ }
1694
+
1695
+ except Exception as e:
1696
+ error_msg = f"Failed to list messages: {e!s}"
1697
+ logger.error(error_msg)
1698
+ return {"error": error_msg}
1699
+
1700
+ async def outlook_reply_to_email(
1701
+ self,
1702
+ message_id: str,
1703
+ content: str,
1704
+ reply_all: bool = False,
1705
+ ) -> Dict[str, Any]:
1706
+ """Replies to an email in Microsoft Outlook.
1707
+
1708
+ Args:
1709
+ message_id (str): The ID of the email to reply to.
1710
+ content (str): The body content of the reply email.
1711
+ reply_all (bool): If True, replies to all recipients of the
1712
+ original email. If False, replies only to the sender.
1713
+ (default: :obj:`False`)
1714
+
1715
+ Returns:
1716
+ Dict[str, Any]: A dictionary containing the result of the email
1717
+ reply operation.
1718
+
1719
+ Raises:
1720
+ ValueError: If replying to the email fails.
1721
+ """
1722
+ from msgraph.generated.users.item.messages.item.reply.reply_post_request_body import ( # noqa: E501
1723
+ ReplyPostRequestBody,
1724
+ )
1725
+ from msgraph.generated.users.item.messages.item.reply_all.reply_all_post_request_body import ( # noqa: E501
1726
+ ReplyAllPostRequestBody,
1727
+ )
1728
+
1729
+ try:
1730
+ message_request = self.client.me.messages.by_message_id(message_id)
1731
+ if reply_all:
1732
+ request_body_reply_all = ReplyAllPostRequestBody(
1733
+ comment=content
1734
+ )
1735
+ await message_request.reply_all.post(request_body_reply_all)
1736
+ else:
1737
+ request_body = ReplyPostRequestBody(comment=content)
1738
+ await message_request.reply.post(request_body)
1739
+
1740
+ reply_type = "Reply All" if reply_all else "Reply"
1741
+ logger.info(
1742
+ f"{reply_type} to email with ID {message_id} sent "
1743
+ "successfully."
1744
+ )
1745
+
1746
+ return {
1747
+ 'status': 'success',
1748
+ 'message': f'{reply_type} sent successfully',
1749
+ 'message_id': message_id,
1750
+ 'reply_type': reply_type.lower(),
1751
+ }
1752
+
1753
+ except Exception as e:
1754
+ error_msg = f"Failed to reply to email: {e!s}"
1755
+ logger.error(error_msg)
1756
+ return {"error": error_msg}
1757
+
1758
+ async def outlook_update_draft_message(
1759
+ self,
1760
+ message_id: str,
1761
+ subject: Optional[str] = None,
1762
+ content: Optional[str] = None,
1763
+ is_content_html: bool = False,
1764
+ to_email: Optional[List[str]] = None,
1765
+ cc_recipients: Optional[List[str]] = None,
1766
+ bcc_recipients: Optional[List[str]] = None,
1767
+ reply_to: Optional[List[str]] = None,
1768
+ ) -> Dict[str, Any]:
1769
+ """Updates an existing draft email message in Microsoft Outlook.
1770
+
1771
+ Important: Any parameter provided will completely replace the original
1772
+ value. For example, if you want to add a new recipient while keeping
1773
+ existing ones, you must pass all recipients (both original and new) in
1774
+ the to_email parameter.
1775
+
1776
+ Note: This method is intended for draft messages only and not for
1777
+ sent messages.
1778
+
1779
+ Args:
1780
+ message_id (str): The ID of the draft message to update.
1781
+ subject (Optional[str]): Change the subject of the email.
1782
+ Replaces the original subject completely.
1783
+ (default: :obj:`None`)
1784
+ content (Optional[str]): Change the body content of the email.
1785
+ Replaces the original content completely.
1786
+ (default: :obj:`None`)
1787
+ is_content_html (bool): Change the content type. If True, sets
1788
+ content type to HTML; if False, sets to plain text.
1789
+ (default: :obj:`False`)
1790
+ to_email (Optional[List[str]]): Change the recipient email
1791
+ addresses. Replaces all original recipients completely.
1792
+ (default: :obj:`None`)
1793
+ cc_recipients (Optional[List[str]]): Change the CC recipient
1794
+ email addresses. Replaces all original CC recipients
1795
+ completely. (default: :obj:`None`)
1796
+ bcc_recipients (Optional[List[str]]): Change the BCC recipient
1797
+ email addresses. Replaces all original BCC recipients
1798
+ completely. (default: :obj:`None`)
1799
+ reply_to (Optional[List[str]]): Change the email addresses that
1800
+ will receive replies. Replaces all original reply-to addresses
1801
+ completely. (default: :obj:`None`)
1802
+
1803
+ Returns:
1804
+ Dict[str, Any]: A dictionary containing the result of the update
1805
+ operation.
1806
+ """
1807
+ try:
1808
+ # Validate all email addresses if provided
1809
+ invalid_emails = self._get_invalid_emails(
1810
+ to_email, cc_recipients, bcc_recipients, reply_to
1811
+ )
1812
+ if invalid_emails:
1813
+ error_msg = (
1814
+ f"Invalid email address(es) provided: "
1815
+ f"{', '.join(invalid_emails)}"
1816
+ )
1817
+ logger.error(error_msg)
1818
+ return {"error": error_msg}
1819
+
1820
+ # Create message with only the fields to update
1821
+ mail_message = self._create_message(
1822
+ to_email=to_email,
1823
+ subject=subject,
1824
+ content=content,
1825
+ is_content_html=is_content_html,
1826
+ cc_recipients=cc_recipients,
1827
+ bcc_recipients=bcc_recipients,
1828
+ reply_to=reply_to,
1829
+ )
1830
+
1831
+ # Update the message using PATCH
1832
+ await self.client.me.messages.by_message_id(message_id).patch(
1833
+ mail_message
1834
+ )
1835
+
1836
+ logger.info(
1837
+ f"Draft message with ID {message_id} updated successfully."
1838
+ )
1839
+
1840
+ # Build dict of updated parameters (only include non-None values)
1841
+ updated_params = {
1842
+ k: v
1843
+ for k, v in {
1844
+ 'subject': subject,
1845
+ 'content': content,
1846
+ 'to_email': to_email,
1847
+ 'cc_recipients': cc_recipients,
1848
+ 'bcc_recipients': bcc_recipients,
1849
+ 'reply_to': reply_to,
1850
+ }.items()
1851
+ if v
1852
+ }
1853
+
1854
+ return {
1855
+ 'status': 'success',
1856
+ 'message': 'Draft message updated successfully',
1857
+ 'message_id': message_id,
1858
+ 'updated_params': updated_params,
1859
+ }
1860
+
1861
+ except Exception as e:
1862
+ error_msg = f"Failed to update draft message: {e!s}"
1863
+ logger.error(error_msg)
1864
+ return {"error": error_msg}
1865
+
1866
+ def get_tools(self) -> List[FunctionTool]:
1867
+ """Returns a list of FunctionTool objects representing the
1868
+ functions in the toolkit.
1869
+
1870
+ Returns:
1871
+ List[FunctionTool]: A list of FunctionTool objects
1872
+ representing the functions in the toolkit.
1873
+ """
1874
+ return [
1875
+ FunctionTool(self.outlook_send_email),
1876
+ FunctionTool(self.outlook_create_draft_email),
1877
+ FunctionTool(self.outlook_send_draft_email),
1878
+ FunctionTool(self.outlook_delete_email),
1879
+ FunctionTool(self.outlook_move_message_to_folder),
1880
+ FunctionTool(self.outlook_get_attachments),
1881
+ FunctionTool(self.outlook_get_message),
1882
+ FunctionTool(self.outlook_list_messages),
1883
+ FunctionTool(self.outlook_reply_to_email),
1884
+ FunctionTool(self.outlook_update_draft_message),
1885
+ ]