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.
- camel/__init__.py +3 -3
- camel/agents/__init__.py +2 -2
- camel/agents/_types.py +9 -4
- camel/agents/_utils.py +40 -2
- camel/agents/base.py +2 -2
- camel/agents/chat_agent.py +5107 -995
- camel/agents/critic_agent.py +2 -2
- camel/agents/deductive_reasoner_agent.py +56 -56
- camel/agents/embodied_agent.py +2 -2
- camel/agents/knowledge_graph_agent.py +20 -20
- camel/agents/mcp_agent.py +35 -36
- camel/agents/multi_hop_generator_agent.py +3 -3
- camel/agents/programmed_agent_instruction.py +2 -2
- camel/agents/repo_agent.py +4 -3
- camel/agents/role_assignment_agent.py +2 -2
- camel/agents/search_agent.py +2 -2
- camel/agents/task_agent.py +2 -2
- camel/agents/tool_agents/__init__.py +2 -2
- camel/agents/tool_agents/base.py +2 -2
- camel/agents/tool_agents/hugging_face_tool_agent.py +3 -3
- camel/benchmarks/__init__.py +2 -2
- camel/benchmarks/apibank.py +5 -5
- camel/benchmarks/apibench.py +2 -2
- camel/benchmarks/base.py +2 -2
- camel/benchmarks/browsecomp.py +44 -33
- camel/benchmarks/gaia.py +17 -13
- camel/benchmarks/mock_website/README.md +1 -3
- camel/benchmarks/mock_website/mock_web.py +2 -2
- camel/benchmarks/mock_website/requirements.txt +1 -1
- camel/benchmarks/mock_website/shopping_mall/app.py +2 -2
- camel/benchmarks/mock_website/task.json +1 -1
- camel/benchmarks/nexus.py +3 -3
- camel/benchmarks/ragbench.py +2 -2
- camel/bots/__init__.py +2 -2
- camel/bots/discord/__init__.py +2 -2
- camel/bots/discord/discord_app.py +2 -2
- camel/bots/discord/discord_installation.py +2 -2
- camel/bots/discord/discord_store.py +3 -3
- camel/bots/slack/__init__.py +2 -2
- camel/bots/slack/models.py +4 -4
- camel/bots/slack/slack_app.py +2 -2
- camel/bots/telegram_bot.py +2 -2
- camel/configs/__init__.py +29 -2
- camel/configs/aihubmix_config.py +90 -0
- camel/configs/aiml_config.py +2 -2
- camel/configs/amd_config.py +70 -0
- camel/configs/anthropic_config.py +2 -2
- camel/configs/base_config.py +2 -2
- camel/configs/bedrock_config.py +5 -3
- camel/configs/cerebras_config.py +98 -0
- camel/configs/cohere_config.py +2 -2
- camel/configs/cometapi_config.py +106 -0
- camel/configs/crynux_config.py +2 -2
- camel/configs/deepseek_config.py +9 -8
- camel/configs/function_gemma_config.py +59 -0
- camel/configs/gemini_config.py +6 -4
- camel/configs/groq_config.py +6 -4
- camel/configs/internlm_config.py +6 -4
- camel/configs/litellm_config.py +2 -2
- camel/configs/lmstudio_config.py +6 -4
- camel/configs/minimax_config.py +95 -0
- camel/configs/mistral_config.py +2 -2
- camel/configs/modelscope_config.py +5 -3
- camel/configs/moonshot_config.py +2 -2
- camel/configs/nebius_config.py +105 -0
- camel/configs/netmind_config.py +2 -2
- camel/configs/novita_config.py +2 -2
- camel/configs/nvidia_config.py +2 -2
- camel/configs/ollama_config.py +2 -2
- camel/configs/openai_config.py +5 -3
- camel/configs/openrouter_config.py +6 -4
- camel/configs/ppio_config.py +2 -2
- camel/configs/qianfan_config.py +85 -0
- camel/configs/qwen_config.py +2 -2
- camel/configs/reka_config.py +2 -2
- camel/configs/samba_config.py +6 -4
- camel/configs/sglang_config.py +2 -2
- camel/configs/siliconflow_config.py +2 -2
- camel/configs/togetherai_config.py +2 -2
- camel/configs/vllm_config.py +4 -2
- camel/configs/watsonx_config.py +2 -2
- camel/configs/yi_config.py +6 -4
- camel/configs/zhipuai_config.py +6 -4
- camel/data_collectors/__init__.py +2 -2
- camel/data_collectors/alpaca_collector.py +18 -9
- camel/data_collectors/base.py +2 -2
- camel/data_collectors/sharegpt_collector.py +2 -2
- camel/datagen/__init__.py +2 -2
- camel/datagen/cot_datagen.py +3 -3
- camel/datagen/evol_instruct/__init__.py +2 -2
- camel/datagen/evol_instruct/evol_instruct.py +2 -2
- camel/datagen/evol_instruct/scorer.py +12 -12
- camel/datagen/evol_instruct/templates.py +16 -16
- camel/datagen/self_improving_cot.py +5 -5
- camel/datagen/self_instruct/__init__.py +2 -2
- camel/datagen/self_instruct/filter/__init__.py +2 -2
- camel/datagen/self_instruct/filter/filter_function.py +2 -2
- camel/datagen/self_instruct/filter/filter_registry.py +2 -2
- camel/datagen/self_instruct/filter/instruction_filter.py +2 -2
- camel/datagen/self_instruct/self_instruct.py +2 -2
- camel/datagen/self_instruct/templates.py +47 -47
- camel/datagen/source2synth/__init__.py +2 -2
- camel/datagen/source2synth/data_processor.py +2 -2
- camel/datagen/source2synth/models.py +2 -2
- camel/datagen/source2synth/user_data_processor_config.py +2 -2
- camel/datahubs/__init__.py +2 -2
- camel/datahubs/base.py +2 -2
- camel/datahubs/huggingface.py +2 -2
- camel/datahubs/models.py +2 -2
- camel/datasets/__init__.py +2 -2
- camel/datasets/base_generator.py +41 -12
- camel/datasets/few_shot_generator.py +18 -18
- camel/datasets/models.py +2 -2
- camel/datasets/self_instruct_generator.py +2 -2
- camel/datasets/static_dataset.py +2 -2
- camel/embeddings/__init__.py +2 -2
- camel/embeddings/azure_embedding.py +2 -2
- camel/embeddings/base.py +2 -2
- camel/embeddings/gemini_embedding.py +2 -2
- camel/embeddings/jina_embedding.py +2 -2
- camel/embeddings/mistral_embedding.py +2 -2
- camel/embeddings/openai_compatible_embedding.py +2 -2
- camel/embeddings/openai_embedding.py +2 -2
- camel/embeddings/sentence_transformers_embeddings.py +2 -2
- camel/embeddings/together_embedding.py +2 -2
- camel/embeddings/vlm_embedding.py +2 -2
- camel/environments/__init__.py +14 -2
- camel/environments/models.py +2 -2
- camel/environments/multi_step.py +2 -2
- camel/environments/rlcards_env.py +860 -0
- camel/environments/single_step.py +30 -5
- camel/environments/tic_tac_toe.py +3 -3
- camel/extractors/__init__.py +2 -2
- camel/extractors/base.py +2 -2
- camel/extractors/python_strategies.py +2 -2
- camel/generators.py +2 -2
- camel/human.py +2 -2
- camel/interpreters/__init__.py +4 -2
- camel/interpreters/base.py +2 -2
- camel/interpreters/docker/Dockerfile +14 -24
- camel/interpreters/docker_interpreter.py +5 -4
- camel/interpreters/e2b_interpreter.py +36 -3
- camel/interpreters/internal_python_interpreter.py +53 -4
- camel/interpreters/interpreter_error.py +2 -2
- camel/interpreters/ipython_interpreter.py +2 -2
- camel/interpreters/microsandbox_interpreter.py +395 -0
- camel/interpreters/subprocess_interpreter.py +2 -2
- camel/loaders/__init__.py +13 -4
- camel/loaders/apify_reader.py +2 -2
- camel/loaders/base_io.py +2 -2
- camel/loaders/base_loader.py +85 -0
- camel/loaders/chunkr_reader.py +11 -2
- camel/loaders/crawl4ai_reader.py +2 -2
- camel/loaders/firecrawl_reader.py +6 -6
- camel/loaders/jina_url_reader.py +2 -2
- camel/loaders/markitdown.py +2 -2
- camel/loaders/mineru_extractor.py +2 -2
- camel/loaders/mistral_reader.py +2 -2
- camel/loaders/scrapegraph_reader.py +2 -2
- camel/loaders/unstructured_io.py +2 -2
- camel/logger.py +5 -5
- camel/memories/__init__.py +2 -2
- camel/memories/agent_memories.py +86 -3
- camel/memories/base.py +36 -2
- camel/memories/blocks/__init__.py +2 -2
- camel/memories/blocks/chat_history_block.py +125 -7
- camel/memories/blocks/vectordb_block.py +10 -3
- camel/memories/context_creators/__init__.py +2 -2
- camel/memories/context_creators/score_based.py +109 -230
- camel/memories/records.py +90 -10
- camel/messages/__init__.py +2 -2
- camel/messages/base.py +178 -43
- camel/messages/conversion/__init__.py +2 -2
- camel/messages/conversion/alpaca.py +2 -2
- camel/messages/conversion/conversation_models.py +2 -2
- camel/messages/conversion/sharegpt/__init__.py +2 -2
- camel/messages/conversion/sharegpt/function_call_formatter.py +2 -2
- camel/messages/conversion/sharegpt/hermes/__init__.py +2 -2
- camel/messages/conversion/sharegpt/hermes/hermes_function_formatter.py +2 -2
- camel/messages/func_message.py +54 -17
- camel/models/__init__.py +18 -2
- camel/models/_utils.py +3 -3
- camel/models/aihubmix_model.py +83 -0
- camel/models/aiml_model.py +11 -18
- camel/models/amd_model.py +101 -0
- camel/models/anthropic_model.py +127 -20
- camel/models/aws_bedrock_model.py +12 -35
- camel/models/azure_openai_model.py +214 -115
- camel/models/base_audio_model.py +5 -3
- camel/models/base_model.py +378 -31
- camel/models/cerebras_model.py +83 -0
- camel/models/cohere_model.py +18 -49
- camel/models/cometapi_model.py +83 -0
- camel/models/crynux_model.py +11 -18
- camel/models/deepseek_model.py +20 -84
- camel/models/fish_audio_model.py +8 -2
- camel/models/function_gemma_model.py +889 -0
- camel/models/gemini_model.py +391 -52
- camel/models/groq_model.py +11 -19
- camel/models/internlm_model.py +11 -18
- camel/models/litellm_model.py +57 -49
- camel/models/lmstudio_model.py +17 -20
- camel/models/minimax_model.py +83 -0
- camel/models/mistral_model.py +20 -47
- camel/models/model_factory.py +39 -3
- camel/models/model_manager.py +26 -8
- camel/models/modelscope_model.py +13 -193
- camel/models/moonshot_model.py +183 -21
- camel/models/nebius_model.py +83 -0
- camel/models/nemotron_model.py +19 -9
- camel/models/netmind_model.py +11 -18
- camel/models/novita_model.py +11 -18
- camel/models/nvidia_model.py +11 -18
- camel/models/ollama_model.py +14 -21
- camel/models/openai_audio_models.py +2 -2
- camel/models/openai_compatible_model.py +190 -71
- camel/models/openai_model.py +192 -86
- camel/models/openrouter_model.py +11 -19
- camel/models/ppio_model.py +11 -18
- camel/models/qianfan_model.py +89 -0
- camel/models/qwen_model.py +13 -193
- camel/models/reka_model.py +23 -49
- camel/models/reward/__init__.py +2 -2
- camel/models/reward/base_reward_model.py +2 -2
- camel/models/reward/evaluator.py +2 -2
- camel/models/reward/nemotron_model.py +2 -2
- camel/models/reward/skywork_model.py +2 -2
- camel/models/samba_model.py +50 -75
- camel/models/sglang_model.py +90 -68
- camel/models/siliconflow_model.py +12 -35
- camel/models/stub_model.py +10 -7
- camel/models/togetherai_model.py +11 -18
- camel/models/vllm_model.py +10 -18
- camel/models/volcano_model.py +158 -19
- camel/models/watsonx_model.py +9 -47
- camel/models/yi_model.py +11 -18
- camel/models/zhipuai_model.py +70 -18
- camel/parsers/__init__.py +18 -0
- camel/parsers/mcp_tool_call_parser.py +176 -0
- camel/personas/__init__.py +2 -2
- camel/personas/persona.py +2 -2
- camel/personas/persona_hub.py +2 -2
- camel/prompts/__init__.py +2 -2
- camel/prompts/ai_society.py +2 -2
- camel/prompts/base.py +2 -2
- camel/prompts/code.py +2 -2
- camel/prompts/evaluation.py +2 -2
- camel/prompts/generate_text_embedding_data.py +2 -2
- camel/prompts/image_craft.py +2 -2
- camel/prompts/misalignment.py +2 -2
- camel/prompts/multi_condition_image_craft.py +2 -2
- camel/prompts/object_recognition.py +2 -2
- camel/prompts/persona_hub.py +3 -3
- camel/prompts/prompt_templates.py +2 -2
- camel/prompts/role_description_prompt_template.py +2 -2
- camel/prompts/solution_extraction.py +8 -8
- camel/prompts/task_prompt_template.py +2 -2
- camel/prompts/translation.py +2 -2
- camel/prompts/video_description_prompt.py +3 -3
- camel/responses/__init__.py +2 -2
- camel/responses/agent_responses.py +2 -2
- camel/retrievers/__init__.py +2 -2
- camel/retrievers/auto_retriever.py +3 -2
- camel/retrievers/base.py +2 -2
- camel/retrievers/bm25_retriever.py +2 -2
- camel/retrievers/cohere_rerank_retriever.py +2 -2
- camel/retrievers/hybrid_retrival.py +2 -2
- camel/retrievers/vector_retriever.py +2 -2
- camel/runtimes/Dockerfile.multi-toolkit +90 -0
- camel/runtimes/__init__.py +2 -2
- camel/runtimes/api.py +79 -23
- camel/runtimes/base.py +2 -2
- camel/runtimes/configs.py +13 -13
- camel/runtimes/daytona_runtime.py +17 -18
- camel/runtimes/docker_runtime.py +12 -12
- camel/runtimes/llm_guard_runtime.py +26 -26
- camel/runtimes/remote_http_runtime.py +11 -11
- camel/runtimes/ubuntu_docker_runtime.py +2 -2
- camel/runtimes/utils/__init__.py +2 -2
- camel/runtimes/utils/function_risk_toolkit.py +2 -2
- camel/runtimes/utils/ignore_risk_toolkit.py +2 -2
- camel/schemas/__init__.py +2 -2
- camel/schemas/base.py +2 -2
- camel/schemas/openai_converter.py +3 -3
- camel/schemas/outlines_converter.py +2 -2
- camel/services/agent_openapi_server.py +380 -0
- camel/societies/__init__.py +4 -2
- camel/societies/babyagi_playing.py +2 -2
- camel/societies/role_playing.py +201 -80
- camel/societies/workforce/__init__.py +10 -3
- camel/societies/workforce/base.py +2 -2
- camel/societies/workforce/events.py +145 -0
- camel/societies/workforce/prompts.py +259 -33
- camel/societies/workforce/role_playing_worker.py +88 -31
- camel/societies/workforce/single_agent_worker.py +638 -40
- camel/societies/workforce/structured_output_handler.py +512 -0
- camel/societies/workforce/task_channel.py +182 -38
- camel/societies/workforce/utils.py +780 -65
- camel/societies/workforce/worker.py +92 -26
- camel/societies/workforce/workflow_memory_manager.py +1746 -0
- camel/societies/workforce/workforce.py +5354 -372
- camel/societies/workforce/workforce_callback.py +103 -0
- camel/societies/workforce/workforce_logger.py +647 -0
- camel/societies/workforce/workforce_metrics.py +33 -0
- camel/storages/__init__.py +6 -2
- camel/storages/graph_storages/__init__.py +2 -2
- camel/storages/graph_storages/base.py +2 -2
- camel/storages/graph_storages/graph_element.py +2 -2
- camel/storages/graph_storages/nebula_graph.py +4 -4
- camel/storages/graph_storages/neo4j_graph.py +7 -7
- camel/storages/key_value_storages/__init__.py +2 -2
- camel/storages/key_value_storages/base.py +2 -2
- camel/storages/key_value_storages/in_memory.py +2 -2
- camel/storages/key_value_storages/json.py +17 -4
- camel/storages/key_value_storages/mem0_cloud.py +50 -49
- camel/storages/key_value_storages/redis.py +2 -2
- camel/storages/object_storages/__init__.py +2 -2
- camel/storages/object_storages/amazon_s3.py +2 -2
- camel/storages/object_storages/azure_blob.py +2 -2
- camel/storages/object_storages/base.py +2 -2
- camel/storages/object_storages/google_cloud.py +3 -3
- camel/storages/vectordb_storages/__init__.py +8 -2
- camel/storages/vectordb_storages/base.py +2 -2
- camel/storages/vectordb_storages/chroma.py +731 -0
- camel/storages/vectordb_storages/faiss.py +2 -2
- camel/storages/vectordb_storages/milvus.py +2 -2
- camel/storages/vectordb_storages/oceanbase.py +15 -15
- camel/storages/vectordb_storages/pgvector.py +349 -0
- camel/storages/vectordb_storages/qdrant.py +6 -6
- camel/storages/vectordb_storages/surreal.py +372 -0
- camel/storages/vectordb_storages/tidb.py +11 -8
- camel/storages/vectordb_storages/weaviate.py +2 -2
- camel/tasks/__init__.py +2 -2
- camel/tasks/task.py +348 -26
- camel/tasks/task_prompt.py +3 -3
- camel/terminators/__init__.py +2 -2
- camel/terminators/base.py +2 -2
- camel/terminators/response_terminator.py +2 -2
- camel/terminators/token_limit_terminator.py +2 -2
- camel/toolkits/__init__.py +57 -10
- camel/toolkits/aci_toolkit.py +66 -21
- camel/toolkits/arxiv_toolkit.py +8 -8
- camel/toolkits/ask_news_toolkit.py +2 -2
- camel/toolkits/async_browser_toolkit.py +4 -4
- camel/toolkits/audio_analysis_toolkit.py +3 -3
- camel/toolkits/base.py +106 -6
- camel/toolkits/bohrium_toolkit.py +2 -2
- camel/toolkits/browser_toolkit.py +34 -21
- camel/toolkits/browser_toolkit_commons.py +4 -4
- camel/toolkits/code_execution.py +31 -4
- camel/toolkits/context_summarizer_toolkit.py +684 -0
- camel/toolkits/craw4ai_toolkit.py +93 -0
- camel/toolkits/dappier_toolkit.py +12 -8
- camel/toolkits/data_commons_toolkit.py +2 -2
- camel/toolkits/dingtalk.py +1135 -0
- camel/toolkits/earth_science_toolkit.py +5367 -0
- camel/toolkits/edgeone_pages_mcp_toolkit.py +49 -0
- camel/toolkits/excel_toolkit.py +905 -71
- camel/toolkits/file_toolkit.py +1402 -0
- camel/toolkits/function_tool.py +205 -27
- camel/toolkits/github_toolkit.py +109 -22
- camel/toolkits/gmail_toolkit.py +1839 -0
- camel/toolkits/google_calendar_toolkit.py +40 -6
- camel/toolkits/google_drive_mcp_toolkit.py +54 -0
- camel/toolkits/google_maps_toolkit.py +2 -2
- camel/toolkits/google_scholar_toolkit.py +2 -2
- camel/toolkits/human_toolkit.py +36 -12
- camel/toolkits/hybrid_browser_toolkit/__init__.py +18 -0
- camel/toolkits/hybrid_browser_toolkit/config_loader.py +185 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +246 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +1958 -0
- camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
- camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +4589 -0
- camel/toolkits/hybrid_browser_toolkit/ts/package.json +33 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-scripts.js +125 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +1940 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +233 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +589 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/index.ts +7 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +129 -0
- camel/toolkits/hybrid_browser_toolkit/ts/tsconfig.json +27 -0
- camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +325 -0
- camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +1037 -0
- camel/toolkits/hybrid_browser_toolkit_py/__init__.py +17 -0
- camel/toolkits/hybrid_browser_toolkit_py/actions.py +575 -0
- camel/toolkits/hybrid_browser_toolkit_py/agent.py +311 -0
- camel/toolkits/hybrid_browser_toolkit_py/browser_session.py +787 -0
- camel/toolkits/hybrid_browser_toolkit_py/config_loader.py +490 -0
- camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +2390 -0
- camel/toolkits/hybrid_browser_toolkit_py/snapshot.py +233 -0
- camel/toolkits/hybrid_browser_toolkit_py/stealth_script.js +0 -0
- camel/toolkits/hybrid_browser_toolkit_py/unified_analyzer.js +1043 -0
- camel/toolkits/image_analysis_toolkit.py +3 -6
- camel/toolkits/image_generation_toolkit.py +390 -0
- camel/toolkits/jina_reranker_toolkit.py +5 -6
- camel/toolkits/klavis_toolkit.py +7 -3
- camel/toolkits/linkedin_toolkit.py +2 -2
- camel/toolkits/markitdown_toolkit.py +104 -0
- camel/toolkits/math_toolkit.py +66 -12
- camel/toolkits/mcp_toolkit.py +412 -36
- camel/toolkits/memory_toolkit.py +7 -3
- camel/toolkits/meshy_toolkit.py +2 -2
- camel/toolkits/message_agent_toolkit.py +608 -0
- camel/toolkits/message_integration.py +728 -0
- camel/toolkits/microsoft_outlook_mail_toolkit.py +1885 -0
- camel/toolkits/mineru_toolkit.py +2 -2
- camel/toolkits/minimax_mcp_toolkit.py +195 -0
- camel/toolkits/networkx_toolkit.py +2 -2
- camel/toolkits/note_taking_toolkit.py +277 -0
- camel/toolkits/notion_mcp_toolkit.py +224 -0
- camel/toolkits/notion_toolkit.py +2 -2
- camel/toolkits/open_api_specs/biztoc/__init__.py +2 -2
- camel/toolkits/open_api_specs/biztoc/ai-plugin.json +1 -1
- camel/toolkits/open_api_specs/coursera/__init__.py +2 -2
- camel/toolkits/open_api_specs/create_qr_code/__init__.py +2 -2
- camel/toolkits/open_api_specs/klarna/__init__.py +2 -2
- camel/toolkits/open_api_specs/nasa_apod/__init__.py +2 -2
- camel/toolkits/open_api_specs/outschool/__init__.py +2 -2
- camel/toolkits/open_api_specs/outschool/ai-plugin.json +1 -1
- camel/toolkits/open_api_specs/outschool/openapi.yaml +1 -1
- camel/toolkits/open_api_specs/outschool/paths/__init__.py +2 -2
- camel/toolkits/open_api_specs/outschool/paths/get_classes.py +2 -2
- camel/toolkits/open_api_specs/outschool/paths/search_teachers.py +2 -2
- camel/toolkits/open_api_specs/security_config.py +2 -2
- camel/toolkits/open_api_specs/speak/__init__.py +2 -2
- camel/toolkits/open_api_specs/web_scraper/__init__.py +2 -2
- camel/toolkits/open_api_specs/web_scraper/ai-plugin.json +1 -1
- camel/toolkits/open_api_specs/web_scraper/paths/__init__.py +2 -2
- camel/toolkits/open_api_specs/web_scraper/paths/scraper.py +2 -2
- camel/toolkits/open_api_toolkit.py +2 -2
- camel/toolkits/openbb_toolkit.py +7 -3
- camel/toolkits/origene_mcp_toolkit.py +56 -0
- camel/toolkits/page_script.js +53 -53
- camel/toolkits/playwright_mcp_toolkit.py +13 -31
- camel/toolkits/pptx_toolkit.py +36 -23
- camel/toolkits/pubmed_toolkit.py +2 -2
- camel/toolkits/pulse_mcp_search_toolkit.py +2 -2
- camel/toolkits/pyautogui_toolkit.py +2 -2
- camel/toolkits/reddit_toolkit.py +2 -2
- camel/toolkits/resend_toolkit.py +168 -0
- camel/toolkits/retrieval_toolkit.py +2 -2
- camel/toolkits/screenshot_toolkit.py +213 -0
- camel/toolkits/search_toolkit.py +606 -156
- camel/toolkits/searxng_toolkit.py +2 -2
- camel/toolkits/semantic_scholar_toolkit.py +2 -2
- camel/toolkits/slack_toolkit.py +108 -58
- camel/toolkits/sql_toolkit.py +712 -0
- camel/toolkits/stripe_toolkit.py +2 -2
- camel/toolkits/sympy_toolkit.py +3 -3
- camel/toolkits/task_planning_toolkit.py +5 -5
- camel/toolkits/terminal_toolkit/__init__.py +18 -0
- camel/toolkits/terminal_toolkit/terminal_toolkit.py +1281 -0
- camel/toolkits/terminal_toolkit/utils.py +659 -0
- camel/toolkits/thinking_toolkit.py +3 -3
- camel/toolkits/twitter_toolkit.py +2 -2
- camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
- camel/toolkits/video_analysis_toolkit.py +109 -29
- camel/toolkits/video_download_toolkit.py +19 -16
- camel/toolkits/weather_toolkit.py +2 -2
- camel/toolkits/web_deploy_toolkit.py +1219 -0
- camel/toolkits/wechat_official_toolkit.py +483 -0
- camel/toolkits/whatsapp_toolkit.py +2 -2
- camel/toolkits/wolfram_alpha_toolkit.py +2 -2
- camel/toolkits/zapier_toolkit.py +7 -3
- camel/types/__init__.py +4 -4
- camel/types/agents/__init__.py +2 -2
- camel/types/agents/tool_calling_record.py +6 -3
- camel/types/enums.py +381 -41
- camel/types/mcp_registries.py +2 -2
- camel/types/openai_types.py +4 -4
- camel/types/unified_model_type.py +46 -10
- camel/utils/__init__.py +5 -2
- camel/utils/agent_context.py +41 -0
- camel/utils/async_func.py +2 -2
- camel/utils/chunker/__init__.py +2 -2
- camel/utils/chunker/base.py +2 -2
- camel/utils/chunker/code_chunker.py +2 -2
- camel/utils/chunker/uio_chunker.py +2 -2
- camel/utils/commons.py +38 -7
- camel/utils/constants.py +5 -2
- camel/utils/context_utils.py +1134 -0
- camel/utils/deduplication.py +2 -2
- camel/utils/filename.py +2 -2
- camel/utils/langfuse.py +18 -10
- camel/utils/mcp.py +140 -6
- camel/utils/mcp_client.py +48 -38
- camel/utils/message_summarizer.py +148 -0
- camel/utils/response_format.py +2 -2
- camel/utils/token_counting.py +45 -22
- camel/utils/tool_result.py +44 -0
- camel/verifiers/__init__.py +2 -2
- camel/verifiers/base.py +2 -2
- camel/verifiers/math_verifier.py +2 -2
- camel/verifiers/models.py +2 -2
- camel/verifiers/physics_verifier.py +2 -2
- camel/verifiers/python_verifier.py +2 -2
- {camel_ai-0.2.65.dist-info → camel_ai-0.2.83a6.dist-info}/METADATA +355 -117
- camel_ai-0.2.83a6.dist-info/RECORD +511 -0
- {camel_ai-0.2.65.dist-info → camel_ai-0.2.83a6.dist-info}/WHEEL +1 -1
- {camel_ai-0.2.65.dist-info → camel_ai-0.2.83a6.dist-info}/licenses/LICENSE +1 -1
- camel/loaders/pandas_reader.py +0 -368
- camel/toolkits/dalle_toolkit.py +0 -175
- camel/toolkits/file_write_toolkit.py +0 -444
- camel/toolkits/openai_agent_toolkit.py +0 -135
- camel/toolkits/terminal_toolkit.py +0 -1037
- 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
|
+
]
|