Skip to content

Commit e1891f9

Browse files
authored
Merge pull request #1041 from jupyter-naas/email-outlook-adapter
feat(email): add Microsoft Outlook email adapter using Graph API
2 parents 935bff5 + 74b5e58 commit e1891f9

10 files changed

Lines changed: 410 additions & 11 deletions

File tree

libs/naas-abi-cli/uv.lock

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libs/naas-abi-core/naas_abi_core/engine/engine_configuration/EngineConfiguration_EmailService.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,17 @@ class EmailAdapterSendGridConfiguration(BaseModel):
4545
base_url: str = "https://api.sendgrid.com/v3"
4646

4747

48+
class EmailAdapterMicrosoftOutlookConfiguration(BaseModel):
49+
model_config = ConfigDict(extra="forbid")
50+
51+
tenant_id: str
52+
client_id: str
53+
client_secret: str
54+
user: str
55+
56+
4857
class EmailAdapterConfiguration(GenericLoader):
49-
adapter: Literal["smtp", "filesystem", "ses", "sendgrid", "custom"]
58+
adapter: Literal["smtp", "filesystem", "ses", "sendgrid", "microsoft_outlook", "custom"]
5059
config: dict | None = None
5160

5261
@model_validator(mode="after")
@@ -80,6 +89,12 @@ def validate_adapter(self) -> "EmailAdapterConfiguration":
8089
self.config,
8190
"Invalid configuration for services.email.email_adapter 'sendgrid' adapter",
8291
)
92+
elif self.adapter == "microsoft_outlook":
93+
pydantic_model_validator(
94+
EmailAdapterMicrosoftOutlookConfiguration,
95+
self.config,
96+
"Invalid configuration for services.email.email_adapter 'microsoft_outlook' adapter",
97+
)
8398

8499
return self
85100

@@ -114,6 +129,13 @@ def load(self) -> IEmailAdapter:
114129
)
115130

116131
return SendGridAdapter(**self.config)
132+
elif self.adapter == "microsoft_outlook":
133+
assert self.config is not None, "config is required for microsoft_outlook adapter"
134+
from naas_abi_core.services.email.adapters.secondary.MicrosoftOutlookAdapter import (
135+
MicrosoftOutlookAdapter,
136+
)
137+
138+
return MicrosoftOutlookAdapter(**self.config)
117139
elif self.adapter == "custom":
118140
return super().load()
119141
else:

libs/naas-abi-core/naas_abi_core/engine/engine_loaders/EngineModuleLoader.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,17 @@ def _module_ships_models(module_dotted: str) -> bool:
247247
models_dir = Path(spec.origin).parent / "models"
248248
if not models_dir.is_dir():
249249
return False
250-
for entry in os.listdir(models_dir):
251-
if not entry.endswith(".py"):
252-
continue
253-
if entry == "__init__.py" or entry.endswith("_test.py"):
254-
continue
255-
return True
250+
# Walk recursively: models may live in provider subdirectories
251+
# (e.g. ``models/openai/gpt_4_1_mini.py``) with no source files at the
252+
# top level. This mirrors ``ModuleModelLoader.load_models``, which uses
253+
# ``os.walk`` to discover those nested models at on_load time.
254+
for _dirpath, _dirnames, filenames in os.walk(models_dir):
255+
for entry in filenames:
256+
if not entry.endswith(".py"):
257+
continue
258+
if entry == "__init__.py" or entry.endswith("_test.py"):
259+
continue
260+
return True
256261
return False
257262

258263
def get_modules_dependencies(

libs/naas-abi-core/naas_abi_core/services/email/EmailFactory.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from naas_abi_core.services.email.adapters.secondary.FilesystemAdapter import (
55
FilesystemAdapter,
66
)
7+
from naas_abi_core.services.email.adapters.secondary.MicrosoftOutlookAdapter import (
8+
MicrosoftOutlookAdapter,
9+
)
710
from naas_abi_core.services.email.adapters.secondary.SESAdapter import SESAdapter
811
from naas_abi_core.services.email.adapters.secondary.SendGridAdapter import (
912
SendGridAdapter,
@@ -68,3 +71,20 @@ def EmailServiceSendGrid(
6871
base_url=base_url,
6972
)
7073
)
74+
75+
@staticmethod
76+
def EmailServiceMicrosoftOutlook(
77+
*,
78+
tenant_id: str,
79+
client_id: str,
80+
client_secret: str,
81+
user: str,
82+
) -> EmailService:
83+
return EmailService(
84+
MicrosoftOutlookAdapter(
85+
tenant_id=tenant_id,
86+
client_id=client_id,
87+
client_secret=client_secret,
88+
user=user,
89+
)
90+
)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
from typing import Any
5+
from urllib.parse import quote
6+
7+
from naas_abi_core.services.email.EmailPorts import (
8+
EmailAttachment,
9+
IEmailAdapter,
10+
resolve_recipients,
11+
)
12+
13+
_GRAPH_BASE = "https://graph.microsoft.com/v1.0"
14+
_SCOPE = ["https://graph.microsoft.com/.default"]
15+
16+
17+
class MicrosoftOutlookAdapter(IEmailAdapter):
18+
"""Email adapter that sends via Microsoft Graph API (app-only / client-credentials).
19+
20+
The sender mailbox is determined by ``from_email`` passed to ``send()``, which
21+
must be a UPN the registered app has ``Mail.Send`` permission on.
22+
23+
Limitations:
24+
- ``from_name`` is accepted for interface compatibility but silently ignored:
25+
the Graph API always uses the Azure AD display name of the sender mailbox.
26+
- Attachments must be < 3 MB (Graph API ``fileAttachment`` limit).
27+
- When both ``html_body`` and ``text_body`` are provided, the HTML body is used.
28+
The Graph API structured payload does not support multipart/alternative natively.
29+
30+
Args:
31+
tenant_id: Azure AD tenant ID.
32+
client_id: App registration client ID.
33+
client_secret: App registration client secret.
34+
user: Default sender mailbox UPN (used when ``from_email`` is absent).
35+
client: Optional injectable fake for unit tests. Must expose
36+
``post(url, payload)`` and ``get_token() -> str``.
37+
"""
38+
39+
def __init__(
40+
self,
41+
*,
42+
tenant_id: str,
43+
client_id: str,
44+
client_secret: str,
45+
user: str,
46+
client: Any | None = None,
47+
) -> None:
48+
self._tenant_id = tenant_id
49+
self._client_id = client_id
50+
self._client_secret = client_secret
51+
self._user = user
52+
self._client = client
53+
self._token: str | None = None
54+
55+
# ── Auth ──────────────────────────────────────────────────────────────────
56+
57+
def _get_token(self) -> str:
58+
if self._client is not None:
59+
return self._client.get_token()
60+
if self._token:
61+
return self._token
62+
63+
import msal # lazy — optional extra
64+
65+
app = msal.ConfidentialClientApplication(
66+
client_id=self._client_id,
67+
authority=f"https://login.microsoftonline.com/{self._tenant_id}",
68+
client_credential=self._client_secret,
69+
)
70+
result = app.acquire_token_for_client(scopes=_SCOPE)
71+
if "access_token" not in result:
72+
raise RuntimeError(
73+
f"Microsoft Graph token error: {result.get('error')} — "
74+
f"{result.get('error_description')}"
75+
)
76+
self._token = result["access_token"]
77+
return self._token
78+
79+
# ── HTTP transport ────────────────────────────────────────────────────────
80+
81+
def _post(self, url: str, payload: dict[str, Any]) -> None:
82+
if self._client is not None:
83+
self._client.post(url, payload)
84+
return
85+
86+
import requests # lazy
87+
88+
response = requests.post(
89+
url,
90+
headers={
91+
"Authorization": f"Bearer {self._get_token()}",
92+
"Content-Type": "application/json",
93+
},
94+
json=payload,
95+
timeout=30,
96+
)
97+
if response.status_code >= 400:
98+
detail = response.text.strip() or response.reason
99+
raise RuntimeError(
100+
f"Microsoft Graph API request failed ({response.status_code}): {detail}"
101+
)
102+
103+
# ── IEmailAdapter ─────────────────────────────────────────────────────────
104+
105+
def send(
106+
self,
107+
*,
108+
to_email: str | None = None,
109+
subject: str,
110+
text_body: str,
111+
html_body: str | None = None,
112+
from_email: str,
113+
from_name: str | None = None,
114+
reply_to: str | None = None,
115+
attachments: list[EmailAttachment] | None = None,
116+
to_emails: list[str] | str | None = None,
117+
) -> None:
118+
mailbox = quote(from_email or self._user)
119+
url = f"{_GRAPH_BASE}/users/{mailbox}/sendMail"
120+
121+
body_payload = (
122+
{"contentType": "HTML", "content": html_body}
123+
if html_body
124+
else {"contentType": "Text", "content": text_body}
125+
)
126+
127+
message: dict[str, Any] = {
128+
"subject": subject,
129+
"body": body_payload,
130+
"toRecipients": [
131+
{"emailAddress": {"address": addr}}
132+
for addr in resolve_recipients(to_email, to_emails)
133+
],
134+
}
135+
136+
if reply_to:
137+
message["replyTo"] = [{"emailAddress": {"address": reply_to}}]
138+
139+
if attachments:
140+
message["attachments"] = [
141+
{
142+
"@odata.type": "#microsoft.graph.fileAttachment",
143+
"name": att.filename,
144+
"contentType": att.mime_type,
145+
"contentBytes": base64.b64encode(att.content).decode("ascii"),
146+
}
147+
for att in attachments
148+
]
149+
150+
self._post(url, {"message": message, "saveToSentItems": True})

0 commit comments

Comments
 (0)