evalforge-runtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evalforge_runtime/__init__.py +37 -0
- evalforge_runtime/__main__.py +5 -0
- evalforge_runtime/auth.py +33 -0
- evalforge_runtime/config.py +157 -0
- evalforge_runtime/connectors/__init__.py +5 -0
- evalforge_runtime/connectors/base.py +40 -0
- evalforge_runtime/connectors/exchange.py +255 -0
- evalforge_runtime/connectors/gmail.py +218 -0
- evalforge_runtime/connectors/slack.py +42 -0
- evalforge_runtime/connectors/webhook.py +31 -0
- evalforge_runtime/db.py +227 -0
- evalforge_runtime/executor.py +112 -0
- evalforge_runtime/files.py +144 -0
- evalforge_runtime/observability.py +117 -0
- evalforge_runtime/pipeline.py +524 -0
- evalforge_runtime/scheduler.py +85 -0
- evalforge_runtime/secret_providers/__init__.py +1 -0
- evalforge_runtime/secret_providers/aws_secrets.py +57 -0
- evalforge_runtime/secret_providers/azure_keyvault.py +47 -0
- evalforge_runtime/secret_providers/sap_credential.py +92 -0
- evalforge_runtime/secrets.py +99 -0
- evalforge_runtime/server.py +600 -0
- evalforge_runtime/storage.py +43 -0
- evalforge_runtime/types.py +232 -0
- evalforge_runtime-0.1.0.dist-info/METADATA +259 -0
- evalforge_runtime-0.1.0.dist-info/RECORD +29 -0
- evalforge_runtime-0.1.0.dist-info/WHEEL +4 -0
- evalforge_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- evalforge_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""EvalForge Runtime — execution engine for generated applications."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
"""CLI entry point: evalforge-runtime start --config <path>"""
|
|
8
|
+
import argparse
|
|
9
|
+
|
|
10
|
+
from evalforge_runtime.config import load_config
|
|
11
|
+
from evalforge_runtime.server import create_app
|
|
12
|
+
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="evalforge-runtime",
|
|
15
|
+
description="EvalForge Runtime Server",
|
|
16
|
+
)
|
|
17
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
18
|
+
|
|
19
|
+
start_parser = subparsers.add_parser("start", help="Start the runtime server")
|
|
20
|
+
start_parser.add_argument(
|
|
21
|
+
"--config", default="evalforge.config.yaml", help="Path to config YAML"
|
|
22
|
+
)
|
|
23
|
+
start_parser.add_argument("--host", default="0.0.0.0", help="Bind host")
|
|
24
|
+
start_parser.add_argument("--port", type=int, default=8000, help="Bind port")
|
|
25
|
+
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
config_path = getattr(args, "config", "evalforge.config.yaml")
|
|
29
|
+
host = getattr(args, "host", "0.0.0.0")
|
|
30
|
+
port = getattr(args, "port", 8000)
|
|
31
|
+
|
|
32
|
+
config = load_config(config_path)
|
|
33
|
+
app = create_app(config)
|
|
34
|
+
|
|
35
|
+
import uvicorn
|
|
36
|
+
|
|
37
|
+
uvicorn.run(app, host=host, port=port)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""API key authentication middleware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException, Request
|
|
8
|
+
|
|
9
|
+
from evalforge_runtime.config import AuthConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIKeyAuth:
|
|
13
|
+
"""FastAPI dependency that validates API key authentication."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: AuthConfig):
|
|
16
|
+
self.config = config
|
|
17
|
+
self.api_key_methods = [m for m in config.methods if m.type == "api_key"]
|
|
18
|
+
|
|
19
|
+
async def __call__(self, request: Request) -> str:
|
|
20
|
+
"""Validate the request. Returns the authenticated key."""
|
|
21
|
+
for method in self.api_key_methods:
|
|
22
|
+
header_name = method.header or "X-API-Key"
|
|
23
|
+
key = request.headers.get(header_name)
|
|
24
|
+
if key and self._validate_key(key):
|
|
25
|
+
return key
|
|
26
|
+
|
|
27
|
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
28
|
+
|
|
29
|
+
def _validate_key(self, key: str) -> bool:
|
|
30
|
+
"""Check if the key is in the allowed list."""
|
|
31
|
+
valid_keys = os.environ.get("EVALFORGE_API_KEYS", "").split(",")
|
|
32
|
+
valid_keys = [k.strip() for k in valid_keys if k.strip()]
|
|
33
|
+
return key in valid_keys
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Pydantic models for evalforge.config.yaml and config loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProjectConfig(BaseModel):
|
|
15
|
+
id: str
|
|
16
|
+
evalforge_url: str | None = None
|
|
17
|
+
version: str = "0.0.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SecretConfig(BaseModel):
|
|
21
|
+
provider: Literal[
|
|
22
|
+
"evalforge", "env", "azure_keyvault", "aws_secrets_manager", "sap_credential_store"
|
|
23
|
+
] = "env"
|
|
24
|
+
# Azure Key Vault
|
|
25
|
+
vault_url: str | None = None
|
|
26
|
+
# AWS Secrets Manager
|
|
27
|
+
region: str | None = None
|
|
28
|
+
secret_name: str | None = None
|
|
29
|
+
# SAP Credential Store
|
|
30
|
+
instance: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthMethod(BaseModel):
|
|
34
|
+
type: Literal["oauth2", "api_key"]
|
|
35
|
+
issuer: str | None = None
|
|
36
|
+
audience: str | None = None
|
|
37
|
+
header: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthConfig(BaseModel):
|
|
41
|
+
methods: list[AuthMethod] = []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LLMConfig(BaseModel):
|
|
45
|
+
model: str = "anthropic/claude-sonnet-4-6"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ReviewConfig(BaseModel):
|
|
49
|
+
enabled: bool = False
|
|
50
|
+
timeout: str = "24h"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LangfuseConfig(BaseModel):
|
|
54
|
+
enabled: bool = False
|
|
55
|
+
host: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ObservabilityConfig(BaseModel):
|
|
59
|
+
langfuse: LangfuseConfig = LangfuseConfig()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TriggerConfig(BaseModel):
|
|
63
|
+
type: Literal["schedule", "webhook", "process"]
|
|
64
|
+
cron: str | None = None
|
|
65
|
+
after: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProcessConfig(BaseModel):
|
|
69
|
+
process_id: str
|
|
70
|
+
instructions: str = ""
|
|
71
|
+
trigger: TriggerConfig
|
|
72
|
+
connector: str | None = None
|
|
73
|
+
connector_params: dict[str, Any] | None = None
|
|
74
|
+
review: ReviewConfig = ReviewConfig()
|
|
75
|
+
llm_model: str | None = None
|
|
76
|
+
output_schema: dict[str, str] | None = None
|
|
77
|
+
before_module: str | None = None
|
|
78
|
+
execution_module: str | None = None
|
|
79
|
+
after_module: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DatabaseConfig(BaseModel):
|
|
83
|
+
url: str = "sqlite+aiosqlite:///./data/app.db"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class StorageConfig(BaseModel):
|
|
87
|
+
type: Literal["local", "s3"] = "local"
|
|
88
|
+
path: str = "./data/files"
|
|
89
|
+
bucket: str | None = None
|
|
90
|
+
region: str | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AppConfig(BaseModel):
|
|
94
|
+
project: ProjectConfig
|
|
95
|
+
secrets: SecretConfig = SecretConfig()
|
|
96
|
+
auth: AuthConfig = AuthConfig()
|
|
97
|
+
llm: LLMConfig = LLMConfig()
|
|
98
|
+
database: DatabaseConfig = DatabaseConfig()
|
|
99
|
+
storage: StorageConfig = StorageConfig()
|
|
100
|
+
observability: ObservabilityConfig = ObservabilityConfig()
|
|
101
|
+
processes: dict[str, ProcessConfig] = {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_duration(duration_str: str) -> int:
|
|
105
|
+
"""Parse duration string like '24h', '30m', '7d' to seconds."""
|
|
106
|
+
duration_str = duration_str.strip().lower()
|
|
107
|
+
if duration_str.endswith("d"):
|
|
108
|
+
return int(duration_str[:-1]) * 86400
|
|
109
|
+
elif duration_str.endswith("h"):
|
|
110
|
+
return int(duration_str[:-1]) * 3600
|
|
111
|
+
elif duration_str.endswith("m"):
|
|
112
|
+
return int(duration_str[:-1]) * 60
|
|
113
|
+
elif duration_str.endswith("s"):
|
|
114
|
+
return int(duration_str[:-1])
|
|
115
|
+
else:
|
|
116
|
+
return int(duration_str)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolve_env_vars(value: Any) -> Any:
|
|
123
|
+
"""Recursively resolve ${ENV_VAR} patterns in config values."""
|
|
124
|
+
if isinstance(value, str):
|
|
125
|
+
def replacer(match: re.Match[str]) -> str:
|
|
126
|
+
var_name = match.group(1)
|
|
127
|
+
env_value = os.environ.get(var_name)
|
|
128
|
+
if env_value is None:
|
|
129
|
+
return match.group(0) # leave unresolved
|
|
130
|
+
return env_value
|
|
131
|
+
|
|
132
|
+
return _ENV_VAR_PATTERN.sub(replacer, value)
|
|
133
|
+
elif isinstance(value, dict):
|
|
134
|
+
return {k: _resolve_env_vars(v) for k, v in value.items()}
|
|
135
|
+
elif isinstance(value, list):
|
|
136
|
+
return [_resolve_env_vars(item) for item in value]
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_config(path: str | Path) -> AppConfig:
|
|
141
|
+
"""Load and validate an evalforge.config.yaml file."""
|
|
142
|
+
from dotenv import load_dotenv
|
|
143
|
+
|
|
144
|
+
load_dotenv()
|
|
145
|
+
|
|
146
|
+
config_path = Path(path)
|
|
147
|
+
if not config_path.exists():
|
|
148
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
149
|
+
|
|
150
|
+
with open(config_path) as f:
|
|
151
|
+
raw = yaml.safe_load(f)
|
|
152
|
+
|
|
153
|
+
if not isinstance(raw, dict):
|
|
154
|
+
raise ValueError(f"Config file must contain a YAML mapping, got {type(raw).__name__}")
|
|
155
|
+
|
|
156
|
+
resolved = _resolve_env_vars(raw)
|
|
157
|
+
return AppConfig.model_validate(resolved)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Connector base class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from evalforge_runtime.types import FileRef
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ConnectorItem:
|
|
14
|
+
"""A single item fetched by a connector (e.g., one email)."""
|
|
15
|
+
|
|
16
|
+
ref: str # unique identifier (e.g., email message ID)
|
|
17
|
+
data: dict[str, Any] # the item's data fields
|
|
18
|
+
attachments: list[FileRef] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Connector(ABC):
|
|
22
|
+
"""Base class for all connectors."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, params: dict[str, Any] | None = None, secrets: dict[str, str] | None = None):
|
|
25
|
+
self.params = params or {}
|
|
26
|
+
self.secrets = secrets or {}
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def fetch(self) -> list[ConnectorItem]:
|
|
30
|
+
"""Fetch new items from the source. Called on each trigger."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
async def validate(self) -> None:
|
|
34
|
+
"""Validate credentials and config on startup. Raises on failure."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
"""Return the connector name (e.g., 'exchange-inbox')."""
|
|
40
|
+
...
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Microsoft Exchange Online connector via Microsoft Graph API.
|
|
2
|
+
|
|
3
|
+
Supports shared mailboxes — the service principal needs
|
|
4
|
+
Mail.ReadWrite application permission on the target mailbox.
|
|
5
|
+
|
|
6
|
+
Required secrets:
|
|
7
|
+
EXCHANGE_TENANT_ID
|
|
8
|
+
EXCHANGE_CLIENT_ID
|
|
9
|
+
EXCHANGE_CLIENT_SECRET
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from evalforge_runtime.connectors.base import Connector, ConnectorItem
|
|
20
|
+
from evalforge_runtime.storage import LocalStorage
|
|
21
|
+
from evalforge_runtime.types import FileRef
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ExchangeConnector(Connector):
|
|
27
|
+
"""Microsoft Exchange connector via Graph API."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
params: dict[str, Any] | None = None,
|
|
32
|
+
secrets: dict[str, str] | None = None,
|
|
33
|
+
storage: LocalStorage | None = None,
|
|
34
|
+
):
|
|
35
|
+
super().__init__(params, secrets)
|
|
36
|
+
self.storage = storage
|
|
37
|
+
self._token: str | None = None
|
|
38
|
+
self._mailbox = self.params.get("mailbox", "")
|
|
39
|
+
self._folder = self.params.get("folder", "Inbox")
|
|
40
|
+
self._filter = self.params.get("filter", "unread")
|
|
41
|
+
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
return "exchange-inbox"
|
|
44
|
+
|
|
45
|
+
async def validate(self) -> None:
|
|
46
|
+
"""Validate Exchange credentials by acquiring a token."""
|
|
47
|
+
required = ["EXCHANGE_TENANT_ID", "EXCHANGE_CLIENT_ID", "EXCHANGE_CLIENT_SECRET"]
|
|
48
|
+
missing = [k for k in required if k not in self.secrets]
|
|
49
|
+
if missing:
|
|
50
|
+
raise ValueError(f"Exchange connector missing secrets: {', '.join(missing)}")
|
|
51
|
+
if not self._mailbox:
|
|
52
|
+
raise ValueError("Exchange connector requires 'mailbox' parameter")
|
|
53
|
+
await self._acquire_token()
|
|
54
|
+
|
|
55
|
+
async def _acquire_token(self) -> str:
|
|
56
|
+
"""Acquire OAuth2 token via client credentials flow."""
|
|
57
|
+
if self._token:
|
|
58
|
+
return self._token
|
|
59
|
+
|
|
60
|
+
tenant_id = self.secrets["EXCHANGE_TENANT_ID"]
|
|
61
|
+
async with httpx.AsyncClient() as client:
|
|
62
|
+
resp = await client.post(
|
|
63
|
+
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
|
|
64
|
+
data={
|
|
65
|
+
"grant_type": "client_credentials",
|
|
66
|
+
"client_id": self.secrets["EXCHANGE_CLIENT_ID"],
|
|
67
|
+
"client_secret": self.secrets["EXCHANGE_CLIENT_SECRET"],
|
|
68
|
+
"scope": "https://graph.microsoft.com/.default",
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
self._token = resp.json()["access_token"]
|
|
73
|
+
return self._token
|
|
74
|
+
|
|
75
|
+
async def fetch(self) -> list[ConnectorItem]:
|
|
76
|
+
"""Fetch unread messages from the configured mailbox."""
|
|
77
|
+
token = await self._acquire_token()
|
|
78
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
79
|
+
|
|
80
|
+
filter_str = "$filter=isRead eq false" if self._filter == "unread" else ""
|
|
81
|
+
url = (
|
|
82
|
+
f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
|
|
83
|
+
f"/mailFolders/{self._folder}/messages?{filter_str}&$top=50"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async with httpx.AsyncClient() as client:
|
|
87
|
+
resp = await client.get(url, headers=headers)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
messages = resp.json().get("value", [])
|
|
90
|
+
|
|
91
|
+
items: list[ConnectorItem] = []
|
|
92
|
+
for msg in messages:
|
|
93
|
+
attachments: list[FileRef] = []
|
|
94
|
+
if msg.get("hasAttachments") and self.storage:
|
|
95
|
+
attachments = await self._fetch_attachments(
|
|
96
|
+
msg["id"], headers, msg["id"]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
items.append(ConnectorItem(
|
|
100
|
+
ref=msg["id"],
|
|
101
|
+
data={
|
|
102
|
+
"email_subject": msg.get("subject", ""),
|
|
103
|
+
"email_body": msg.get("body", {}).get("content", ""),
|
|
104
|
+
"sender": msg.get("from", {}).get("emailAddress", {}).get("address", ""),
|
|
105
|
+
"received_at": msg.get("receivedDateTime", ""),
|
|
106
|
+
},
|
|
107
|
+
attachments=attachments,
|
|
108
|
+
))
|
|
109
|
+
|
|
110
|
+
return items
|
|
111
|
+
|
|
112
|
+
async def _fetch_attachments(
|
|
113
|
+
self, message_id: str, headers: dict, execution_ref: str
|
|
114
|
+
) -> list[FileRef]:
|
|
115
|
+
"""Fetch and store attachments for a message."""
|
|
116
|
+
if not self.storage:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
url = (
|
|
120
|
+
f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
|
|
121
|
+
f"/messages/{message_id}/attachments"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async with httpx.AsyncClient() as client:
|
|
125
|
+
resp = await client.get(url, headers=headers)
|
|
126
|
+
resp.raise_for_status()
|
|
127
|
+
attachments_data = resp.json().get("value", [])
|
|
128
|
+
|
|
129
|
+
refs: list[FileRef] = []
|
|
130
|
+
for att in attachments_data:
|
|
131
|
+
if att.get("@odata.type") != "#microsoft.graph.fileAttachment":
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
import base64
|
|
135
|
+
content = base64.b64decode(att.get("contentBytes", ""))
|
|
136
|
+
filename = att.get("name", "attachment")
|
|
137
|
+
extension = filename.rsplit(".", 1)[-1] if "." in filename else ""
|
|
138
|
+
mime_type = att.get("contentType", "application/octet-stream")
|
|
139
|
+
key = f"connector/exchange/{execution_ref}/{filename}"
|
|
140
|
+
|
|
141
|
+
await self.storage.put(key, content, mime_type)
|
|
142
|
+
|
|
143
|
+
refs.append(FileRef(
|
|
144
|
+
type="local",
|
|
145
|
+
key=key,
|
|
146
|
+
filename=filename,
|
|
147
|
+
size=len(content),
|
|
148
|
+
mimeType=mime_type,
|
|
149
|
+
extension=extension,
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
return refs
|
|
153
|
+
|
|
154
|
+
# --- Output methods (used by after steps) ---
|
|
155
|
+
|
|
156
|
+
async def send_message(
|
|
157
|
+
self, to: list[str], subject: str, body: str,
|
|
158
|
+
attachments: list[FileRef] | None = None,
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Send a new email. Returns message ID."""
|
|
161
|
+
token = await self._acquire_token()
|
|
162
|
+
message: dict[str, Any] = {
|
|
163
|
+
"subject": subject,
|
|
164
|
+
"body": {"contentType": "HTML", "content": body},
|
|
165
|
+
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async with httpx.AsyncClient() as client:
|
|
169
|
+
resp = await client.post(
|
|
170
|
+
f"https://graph.microsoft.com/v1.0/users/{self._mailbox}/sendMail",
|
|
171
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
172
|
+
json={"message": message, "saveToSentItems": True},
|
|
173
|
+
)
|
|
174
|
+
resp.raise_for_status()
|
|
175
|
+
return "sent"
|
|
176
|
+
|
|
177
|
+
async def reply(self, message_id: str, body: str, reply_all: bool = False) -> None:
|
|
178
|
+
"""Reply to an existing message."""
|
|
179
|
+
token = await self._acquire_token()
|
|
180
|
+
endpoint = "replyAll" if reply_all else "reply"
|
|
181
|
+
|
|
182
|
+
async with httpx.AsyncClient() as client:
|
|
183
|
+
resp = await client.post(
|
|
184
|
+
f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
|
|
185
|
+
f"/messages/{message_id}/{endpoint}",
|
|
186
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
187
|
+
json={"comment": body},
|
|
188
|
+
)
|
|
189
|
+
resp.raise_for_status()
|
|
190
|
+
|
|
191
|
+
async def forward(self, message_id: str, to: str, comment: str = "") -> None:
|
|
192
|
+
"""Forward a message."""
|
|
193
|
+
token = await self._acquire_token()
|
|
194
|
+
|
|
195
|
+
async with httpx.AsyncClient() as client:
|
|
196
|
+
resp = await client.post(
|
|
197
|
+
f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
|
|
198
|
+
f"/messages/{message_id}/forward",
|
|
199
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
200
|
+
json={
|
|
201
|
+
"comment": comment,
|
|
202
|
+
"toRecipients": [{"emailAddress": {"address": to}}],
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
resp.raise_for_status()
|
|
206
|
+
|
|
207
|
+
async def move_message(self, message_id: str, folder: str) -> None:
|
|
208
|
+
"""Move a message to a folder."""
|
|
209
|
+
token = await self._acquire_token()
|
|
210
|
+
|
|
211
|
+
async with httpx.AsyncClient() as client:
|
|
212
|
+
# Get or create folder
|
|
213
|
+
folder_id = await self._get_or_create_folder(folder, token)
|
|
214
|
+
resp = await client.post(
|
|
215
|
+
f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
|
|
216
|
+
f"/messages/{message_id}/move",
|
|
217
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
218
|
+
json={"destinationId": folder_id},
|
|
219
|
+
)
|
|
220
|
+
resp.raise_for_status()
|
|
221
|
+
|
|
222
|
+
async def mark_read(self, message_id: str) -> None:
|
|
223
|
+
"""Mark a message as read."""
|
|
224
|
+
token = await self._acquire_token()
|
|
225
|
+
|
|
226
|
+
async with httpx.AsyncClient() as client:
|
|
227
|
+
resp = await client.patch(
|
|
228
|
+
f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
|
|
229
|
+
f"/messages/{message_id}",
|
|
230
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
231
|
+
json={"isRead": True},
|
|
232
|
+
)
|
|
233
|
+
resp.raise_for_status()
|
|
234
|
+
|
|
235
|
+
async def _get_or_create_folder(self, folder_name: str, token: str) -> str:
|
|
236
|
+
"""Get folder ID by name, creating it if needed."""
|
|
237
|
+
async with httpx.AsyncClient() as client:
|
|
238
|
+
resp = await client.get(
|
|
239
|
+
f"https://graph.microsoft.com/v1.0/users/{self._mailbox}/mailFolders"
|
|
240
|
+
f"?$filter=displayName eq '{folder_name}'",
|
|
241
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
242
|
+
)
|
|
243
|
+
resp.raise_for_status()
|
|
244
|
+
folders = resp.json().get("value", [])
|
|
245
|
+
if folders:
|
|
246
|
+
return folders[0]["id"]
|
|
247
|
+
|
|
248
|
+
# Create folder
|
|
249
|
+
resp = await client.post(
|
|
250
|
+
f"https://graph.microsoft.com/v1.0/users/{self._mailbox}/mailFolders",
|
|
251
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
252
|
+
json={"displayName": folder_name},
|
|
253
|
+
)
|
|
254
|
+
resp.raise_for_status()
|
|
255
|
+
return resp.json()["id"]
|