From 896657dd6f583dc0837b974cc54306311c7c659b Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 27 May 2026 10:23:25 +0200 Subject: [PATCH] first --- README.md | 65 +++++++++++++++++++- app/config.py | 26 ++++++++ app/graph_client.py | 105 ++++++++++++++++++++++++++++++++ app/main.py | 19 +++--- app/routes.py | 131 ++++++++++++++++++++++++++++++++++++++++ app/schemas.py | 79 ++++++++++++++++++++++++ app/services.py | 142 ++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 8 files changed, 558 insertions(+), 11 deletions(-) create mode 100644 app/config.py create mode 100644 app/graph_client.py create mode 100644 app/routes.py create mode 100644 app/schemas.py create mode 100644 app/services.py diff --git a/README.md b/README.md index 42232b1..7d61110 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,66 @@ # Microsoft 365 Service -Generated by AppFactory. +FastAPI service for server-to-server communication with Microsoft 365 through Microsoft Graph. + +## Configuration + +Create an app registration in Microsoft Entra ID, grant the required Microsoft Graph application permissions, and expose these environment variables: + +```env +MS365_TENANT_ID=00000000-0000-0000-0000-000000000000 +MS365_CLIENT_ID=00000000-0000-0000-0000-000000000000 +MS365_CLIENT_SECRET=secret-value +MS365_GRAPH_BASE_URL=https://graph.microsoft.com/v1.0 +MS365_GRAPH_SCOPE=https://graph.microsoft.com/.default +MS365_REQUEST_TIMEOUT_SECONDS=30 +``` + +The service uses the OAuth2 client credentials flow, so Microsoft Graph permissions must be application permissions approved by an administrator. + +## Implemented Services + +- Users: list and read users. +- Mail: list messages and send email, including file attachments. +- Calendar: list and create events, including Teams online meetings. +- OneDrive: list root files and upload small files. +- Groups and Teams: list groups and list team channels. + +## API + +```http +GET /health +GET /version +GET /microsoft365/status +GET /microsoft365/users?top=25&search=jane +GET /microsoft365/users/{user_id} +GET /microsoft365/users/{user_id}/mail/messages?folder=Inbox&top=25 +POST /microsoft365/users/{user_id}/mail/send +GET /microsoft365/users/{user_id}/calendar/events?top=25 +POST /microsoft365/users/{user_id}/calendar/events +GET /microsoft365/users/{user_id}/drive/root/children?top=25 +PUT /microsoft365/users/{user_id}/drive/root/{path} +GET /microsoft365/groups?top=25 +GET /microsoft365/teams/{team_id}/channels +``` + +`user_id` can be a Microsoft Graph user id or user principal name, for example `jane@example.com`. + +## Required Graph Permissions + +Grant only the permissions your deployment actually uses: + +- Users: `User.Read.All` +- Mail read: `Mail.Read` +- Mail send: `Mail.Send` +- Calendar read/write: `Calendars.ReadWrite` +- OneDrive read/write: `Files.ReadWrite.All` +- Groups and Teams channel listing: `Group.Read.All`, `Team.ReadBasic.All`, `Channel.ReadBasic.All` + +## Run Locally + +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +Open `http://localhost:8000/docs` for the generated OpenAPI UI. diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f09e3b5 --- /dev/null +++ b/app/config.py @@ -0,0 +1,26 @@ +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Settings: + app_name: str = os.getenv("APP_NAME", "Microsoft 365 Service") + app_version: str = os.getenv("APP_VERSION", "1.0.0") + root_path: str = os.getenv("ROOT_PATH", "") + tenant_id: str = os.getenv("MS365_TENANT_ID", "") + client_id: str = os.getenv("MS365_CLIENT_ID", "") + client_secret: str = os.getenv("MS365_CLIENT_SECRET", "") + graph_base_url: str = os.getenv("MS365_GRAPH_BASE_URL", "https://graph.microsoft.com/v1.0") + graph_scope: str = os.getenv("MS365_GRAPH_SCOPE", "https://graph.microsoft.com/.default") + request_timeout_seconds: float = float(os.getenv("MS365_REQUEST_TIMEOUT_SECONDS", "30")) + + @property + def token_url(self) -> str: + return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" + + @property + def is_configured(self) -> bool: + return bool(self.tenant_id and self.client_id and self.client_secret) + + +settings = Settings() diff --git a/app/graph_client.py b/app/graph_client.py new file mode 100644 index 0000000..45bc6de --- /dev/null +++ b/app/graph_client.py @@ -0,0 +1,105 @@ +import time +from typing import Any + +import httpx +from fastapi import HTTPException, status + +from .config import Settings + + +class MicrosoftGraphClient: + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._access_token: str | None = None + self._expires_at = 0.0 + + async def request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any | None = None, + content: bytes | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + token = await self._get_access_token() + request_headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + if headers: + request_headers.update(headers) + + url = self._build_url(path) + async with httpx.AsyncClient(timeout=self._settings.request_timeout_seconds) as client: + response = await client.request( + method, + url, + params=params, + json=json, + content=content, + headers=request_headers, + ) + + if response.status_code >= 400: + raise self._graph_exception(response) + if response.status_code == status.HTTP_204_NO_CONTENT or not response.content: + return None + return response.json() + + async def _get_access_token(self) -> str: + if self._access_token and time.time() < self._expires_at - 60: + return self._access_token + + if not self._settings.is_configured: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Microsoft 365 credentials are not configured.", + ) + + data = { + "client_id": self._settings.client_id, + "client_secret": self._settings.client_secret, + "scope": self._settings.graph_scope, + "grant_type": "client_credentials", + } + async with httpx.AsyncClient(timeout=self._settings.request_timeout_seconds) as client: + response = await client.post(self._settings.token_url, data=data) + + if response.status_code >= 400: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={ + "message": "Unable to authenticate with Microsoft identity platform.", + "upstream_status": response.status_code, + "upstream_response": self._safe_json(response), + }, + ) + + payload = response.json() + self._access_token = payload["access_token"] + self._expires_at = time.time() + int(payload.get("expires_in", 3599)) + return self._access_token + + def _build_url(self, path: str) -> str: + if path.startswith("https://"): + return path + return f"{self._settings.graph_base_url.rstrip('/')}/{path.lstrip('/')}" + + def _graph_exception(self, response: httpx.Response) -> HTTPException: + return HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={ + "message": "Microsoft Graph request failed.", + "upstream_status": response.status_code, + "upstream_response": self._safe_json(response), + }, + ) + + @staticmethod + def _safe_json(response: httpx.Response) -> Any: + try: + return response.json() + except ValueError: + return response.text diff --git a/app/main.py b/app/main.py index 7529af7..60b55a1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,14 @@ -import os from fastapi import FastAPI -APP_NAME = os.getenv("APP_NAME", "Microsoft 365 Service") -APP_VERSION = os.getenv("APP_VERSION", "1.0.0") -ROOT_PATH = os.getenv("ROOT_PATH", "") +from .config import settings +from .routes import router as microsoft365_router app = FastAPI( - title=APP_NAME, - version=APP_VERSION, - root_path=ROOT_PATH + title=settings.app_name, + version=settings.app_version, + root_path=settings.root_path ) +app.include_router(microsoft365_router) @app.get("/health") def health(): @@ -18,8 +17,8 @@ def health(): @app.get("/version") def version(): return { - "app": APP_NAME, - "version": APP_VERSION, + "app": settings.app_name, + "version": settings.app_version, "language": "python", - "root_path": ROOT_PATH + "root_path": settings.root_path } diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..4727df8 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,131 @@ +from typing import Any + +from fastapi import APIRouter, Body, Depends, Query, Response, status + +from .config import settings +from .graph_client import MicrosoftGraphClient +from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest +from .services import CalendarService, DriveService, GroupsService, MailService, UsersService + +router = APIRouter(prefix="/microsoft365", tags=["microsoft365"]) +graph_client = MicrosoftGraphClient(settings) + + +def get_graph_client() -> MicrosoftGraphClient: + return graph_client + + +def get_users_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> UsersService: + return UsersService(graph) + + +def get_mail_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> MailService: + return MailService(graph) + + +def get_calendar_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> CalendarService: + return CalendarService(graph) + + +def get_drive_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> DriveService: + return DriveService(graph) + + +def get_groups_service(graph: MicrosoftGraphClient = Depends(get_graph_client)) -> GroupsService: + return GroupsService(graph) + + +@router.get("/status") +def microsoft365_status() -> dict[str, Any]: + return { + "configured": settings.is_configured, + "tenant_id": bool(settings.tenant_id), + "client_id": bool(settings.client_id), + "client_secret": bool(settings.client_secret), + "graph_base_url": settings.graph_base_url, + } + + +@router.get("/users") +async def list_users( + top: int = Query(25, ge=1, le=999), + search: str | None = None, + service: UsersService = Depends(get_users_service), +) -> Any: + return await service.list_users(top=top, search=search) + + +@router.get("/users/{user_id}") +async def get_user(user_id: str, service: UsersService = Depends(get_users_service)) -> Any: + return await service.get_user(user_id) + + +@router.get("/users/{user_id}/mail/messages") +async def list_messages( + user_id: str, + folder: str = "Inbox", + top: int = Query(25, ge=1, le=100), + service: MailService = Depends(get_mail_service), +) -> Any: + return await service.list_messages(user_id=user_id, folder=folder, top=top) + + +@router.post("/users/{user_id}/mail/send", status_code=status.HTTP_202_ACCEPTED) +async def send_mail( + user_id: str, + request: SendMailRequest, + response: Response, + service: MailService = Depends(get_mail_service), +) -> None: + await service.send_mail(user_id=user_id, request=request) + response.status_code = status.HTTP_202_ACCEPTED + + +@router.get("/users/{user_id}/calendar/events") +async def list_events( + user_id: str, + top: int = Query(25, ge=1, le=100), + service: CalendarService = Depends(get_calendar_service), +) -> Any: + return await service.list_events(user_id=user_id, top=top) + + +@router.post("/users/{user_id}/calendar/events", status_code=status.HTTP_201_CREATED) +async def create_event( + user_id: str, + request: CalendarEventRequest, + service: CalendarService = Depends(get_calendar_service), +) -> Any: + return await service.create_event(user_id=user_id, request=request) + + +@router.get("/users/{user_id}/drive/root/children") +async def list_drive_root_children( + user_id: str, + top: int = Query(25, ge=1, le=200), + service: DriveService = Depends(get_drive_service), +) -> Any: + return await service.list_root_children(user_id=user_id, top=top) + + +@router.put("/users/{user_id}/drive/root/{path:path}") +async def upload_small_file( + user_id: str, + path: str, + request: DriveUploadRequest = Body(...), + service: DriveService = Depends(get_drive_service), +) -> Any: + return await service.upload_small_file(user_id=user_id, path=path, request=request) + + +@router.get("/groups") +async def list_groups( + top: int = Query(25, ge=1, le=999), + service: GroupsService = Depends(get_groups_service), +) -> Any: + return await service.list_groups(top=top) + + +@router.get("/teams/{team_id}/channels") +async def list_team_channels(team_id: str, service: GroupsService = Depends(get_groups_service)) -> Any: + return await service.list_team_channels(team_id=team_id) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..613ed8f --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,79 @@ +from typing import Any + +from pydantic import BaseModel, EmailStr, Field + + +class EmailAddress(BaseModel): + address: EmailStr + name: str | None = None + + def as_graph_recipient(self) -> dict[str, Any]: + payload: dict[str, Any] = {"address": str(self.address)} + if self.name: + payload["name"] = self.name + return {"emailAddress": payload} + + +class FileAttachment(BaseModel): + name: str + content_type: str = "application/octet-stream" + content_bytes_base64: str = Field(..., description="Base64 encoded file content.") + + def as_graph_attachment(self) -> dict[str, Any]: + return { + "@odata.type": "#microsoft.graph.fileAttachment", + "name": self.name, + "contentType": self.content_type, + "contentBytes": self.content_bytes_base64, + } + + +class SendMailRequest(BaseModel): + subject: str + body: str + to: list[EmailAddress] + cc: list[EmailAddress] = Field(default_factory=list) + bcc: list[EmailAddress] = Field(default_factory=list) + reply_to: list[EmailAddress] = Field(default_factory=list) + body_content_type: str = Field("HTML", pattern="^(HTML|Text)$") + save_to_sent_items: bool = True + attachments: list[FileAttachment] = Field(default_factory=list) + + +class CalendarAttendee(BaseModel): + email: EmailStr + name: str | None = None + type: str = Field("required", pattern="^(required|optional|resource)$") + + def as_graph_attendee(self) -> dict[str, Any]: + email_address: dict[str, Any] = {"address": str(self.email)} + if self.name: + email_address["name"] = self.name + return {"emailAddress": email_address, "type": self.type} + + +class DateTimeTimeZone(BaseModel): + date_time: str = Field(..., alias="dateTime") + time_zone: str = Field("UTC", alias="timeZone") + + +class CalendarEventRequest(BaseModel): + subject: str + body: str | None = None + body_content_type: str = Field("HTML", pattern="^(HTML|Text)$") + start: DateTimeTimeZone + end: DateTimeTimeZone + location: str | None = None + attendees: list[CalendarAttendee] = Field(default_factory=list) + is_online_meeting: bool = False + online_meeting_provider: str | None = Field(None, pattern="^(teamsForBusiness|skypeForBusiness|skypeForConsumer)$") + + +class DriveUploadRequest(BaseModel): + content_base64: str + content_type: str = "application/octet-stream" + + +class GraphCollectionResponse(BaseModel): + value: list[dict[str, Any]] + next_link: str | None = Field(None, alias="@odata.nextLink") diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000..d140206 --- /dev/null +++ b/app/services.py @@ -0,0 +1,142 @@ +import base64 +import binascii +from typing import Any +from urllib.parse import quote + +from fastapi import HTTPException, status + +from .graph_client import MicrosoftGraphClient +from .schemas import CalendarEventRequest, DriveUploadRequest, SendMailRequest + + +def graph_segment(value: str) -> str: + return quote(value, safe="") + + +def graph_path(value: str) -> str: + return quote(value.strip("/"), safe="/") + + +class UsersService: + def __init__(self, graph: MicrosoftGraphClient) -> None: + self._graph = graph + + async def list_users(self, top: int = 25, search: str | None = None) -> Any: + params: dict[str, Any] = {"$top": top} + headers = None + if search: + params["$search"] = f'"displayName:{search}" OR "mail:{search}" OR "userPrincipalName:{search}"' + headers = {"ConsistencyLevel": "eventual"} + return await self._graph.request("GET", "/users", params=params, headers=headers) + + async def get_user(self, user_id: str) -> Any: + return await self._graph.request("GET", f"/users/{graph_segment(user_id)}") + + +class MailService: + def __init__(self, graph: MicrosoftGraphClient) -> None: + self._graph = graph + + async def list_messages(self, user_id: str, folder: str = "Inbox", top: int = 25) -> Any: + params = { + "$top": top, + "$orderby": "receivedDateTime desc", + "$select": "id,subject,from,toRecipients,receivedDateTime,hasAttachments,isRead,webLink", + } + return await self._graph.request( + "GET", + f"/users/{graph_segment(user_id)}/mailFolders/{graph_segment(folder)}/messages", + params=params, + ) + + async def send_mail(self, user_id: str, request: SendMailRequest) -> None: + message: dict[str, Any] = { + "subject": request.subject, + "body": { + "contentType": request.body_content_type, + "content": request.body, + }, + "toRecipients": [recipient.as_graph_recipient() for recipient in request.to], + } + if request.cc: + message["ccRecipients"] = [recipient.as_graph_recipient() for recipient in request.cc] + if request.bcc: + message["bccRecipients"] = [recipient.as_graph_recipient() for recipient in request.bcc] + if request.reply_to: + message["replyTo"] = [recipient.as_graph_recipient() for recipient in request.reply_to] + if request.attachments: + message["attachments"] = [attachment.as_graph_attachment() for attachment in request.attachments] + + await self._graph.request( + "POST", + f"/users/{graph_segment(user_id)}/sendMail", + json={"message": message, "saveToSentItems": request.save_to_sent_items}, + ) + + +class CalendarService: + def __init__(self, graph: MicrosoftGraphClient) -> None: + self._graph = graph + + async def list_events(self, user_id: str, top: int = 25) -> Any: + params = { + "$top": top, + "$orderby": "start/dateTime", + "$select": "id,subject,start,end,location,attendees,webLink,isOnlineMeeting,onlineMeeting", + } + return await self._graph.request("GET", f"/users/{graph_segment(user_id)}/events", params=params) + + async def create_event(self, user_id: str, request: CalendarEventRequest) -> Any: + payload: dict[str, Any] = { + "subject": request.subject, + "start": request.start.model_dump(by_alias=True), + "end": request.end.model_dump(by_alias=True), + "attendees": [attendee.as_graph_attendee() for attendee in request.attendees], + "isOnlineMeeting": request.is_online_meeting, + } + if request.body is not None: + payload["body"] = {"contentType": request.body_content_type, "content": request.body} + if request.location: + payload["location"] = {"displayName": request.location} + if request.online_meeting_provider: + payload["onlineMeetingProvider"] = request.online_meeting_provider + return await self._graph.request("POST", f"/users/{graph_segment(user_id)}/events", json=payload) + + +class DriveService: + def __init__(self, graph: MicrosoftGraphClient) -> None: + self._graph = graph + + async def list_root_children(self, user_id: str, top: int = 25) -> Any: + return await self._graph.request( + "GET", + f"/users/{graph_segment(user_id)}/drive/root/children", + params={"$top": top}, + ) + + async def upload_small_file(self, user_id: str, path: str, request: DriveUploadRequest) -> Any: + try: + content = base64.b64decode(request.content_base64, validate=True) + except binascii.Error as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="content_base64 must contain valid base64 data.", + ) from exc + return await self._graph.request( + "PUT", + f"/users/{graph_segment(user_id)}/drive/root:/{graph_path(path)}:/content", + content=content, + headers={"Content-Type": request.content_type}, + ) + + +class GroupsService: + def __init__(self, graph: MicrosoftGraphClient) -> None: + self._graph = graph + + async def list_groups(self, top: int = 25) -> Any: + params = {"$top": top, "$select": "id,displayName,mail,groupTypes,securityEnabled,mailEnabled"} + return await self._graph.request("GET", "/groups", params=params) + + async def list_team_channels(self, team_id: str) -> Any: + return await self._graph.request("GET", f"/teams/{graph_segment(team_id)}/channels") diff --git a/requirements.txt b/requirements.txt index 364e2ee..8780c48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ fastapi +httpx +pydantic[email] uvicorn[standard]