# Copyright (c) 2025 Patrick Motsch # All rights reserved. """Infomaniak ProviderConnector -- kDrive and Mail via Infomaniak OAuth. All ServiceAdapters share the same OAuth access token obtained from the UserConnection (authority=infomaniak). Path conventions (leading slash): kDrive: / -- list drives the user has access to /{driveId} -- root folder of a drive (children) /{driveId}/{fileId} -- folder children OR file (download) Mail: / -- list user's mailboxes /{mailboxId} -- folders in mailbox /{mailboxId}/{folderId} -- messages in folder /{mailboxId}/{folderId}/{uid} -- single message (download as .eml) """ import logging from typing import Any, Dict, List, Optional import aiohttp from modules.connectors.connectorProviderBase import ( ProviderConnector, ServiceAdapter, DownloadResult, ) from modules.datamodels.datamodelDataSource import ExternalEntry logger = logging.getLogger(__name__) _API_BASE = "https://api.infomaniak.com" async def _infomaniakGet(token: str, endpoint: str) -> Dict[str, Any]: """Single GET call against the Infomaniak API. Returns parsed JSON or {'error': ...}.""" url = f"{_API_BASE}/{endpoint.lstrip('/')}" headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"} timeout = aiohttp.ClientTimeout(total=20) try: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(url, headers=headers) as resp: if resp.status in (200, 201): return await resp.json() errorText = await resp.text() logger.warning(f"Infomaniak API {resp.status}: {errorText[:300]}") return {"error": f"{resp.status}: {errorText[:200]}"} except Exception as e: return {"error": str(e)} async def _infomaniakDownload(token: str, endpoint: str) -> Optional[bytes]: """Binary download from the Infomaniak API. Returns bytes or None on error.""" url = f"{_API_BASE}/{endpoint.lstrip('/')}" headers = {"Authorization": f"Bearer {token}"} timeout = aiohttp.ClientTimeout(total=120) try: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(url, headers=headers) as resp: if resp.status == 200: return await resp.read() logger.warning(f"Infomaniak download {resp.status}: {(await resp.text())[:300]}") return None except Exception as e: logger.error(f"Infomaniak download error: {e}") return None def _unwrapData(payload: Any) -> Any: """Infomaniak wraps successful responses as ``{result: 'success', data: ...}``.""" if isinstance(payload, dict) and "data" in payload and "result" in payload: return payload.get("data") return payload class KdriveAdapter(ServiceAdapter): """kDrive ServiceAdapter -- browse drives, folders, and files.""" def __init__(self, accessToken: str): self._token = accessToken async def browse( self, path: str, filter: Optional[str] = None, limit: Optional[int] = None, ) -> List[ExternalEntry]: cleanPath = (path or "").strip("/") segments = [s for s in cleanPath.split("/") if s] if not segments: return await self._listDrives() driveId = segments[0] if len(segments) == 1: return await self._listChildren(driveId, fileId=None, limit=limit) fileId = segments[-1] return await self._listChildren(driveId, fileId=fileId, limit=limit) async def _listDrives(self) -> List[ExternalEntry]: result = await _infomaniakGet(self._token, "/2/drive") if isinstance(result, dict) and result.get("error"): logger.warning(f"kDrive list-drives failed: {result['error']}") return [] data = _unwrapData(result) drives = data.get("drives", {}).get("accounts", []) if isinstance(data, dict) else [] if not drives and isinstance(data, list): drives = data entries: List[ExternalEntry] = [] for drive in drives: driveId = str(drive.get("id", "")) if not driveId: continue name = drive.get("name") or driveId entries.append(ExternalEntry( name=name, path=f"/{driveId}", isFolder=True, metadata={"id": driveId, "kind": "drive"}, )) return entries async def _listChildren( self, driveId: str, fileId: Optional[str], limit: Optional[int], ) -> List[ExternalEntry]: # Infomaniak treats every folder (including drive root) as a file-id. # When fileId is None, we ask the drive for root children via the # documented `/files` collection endpoint. if fileId is None: endpoint = f"/2/drive/{driveId}/files" else: endpoint = f"/2/drive/{driveId}/files/{fileId}/files" pageSize = max(1, min(int(limit or 200), 1000)) endpoint = f"{endpoint}?per_page={pageSize}" result = await _infomaniakGet(self._token, endpoint) if isinstance(result, dict) and result.get("error"): logger.warning(f"kDrive list-children {driveId}/{fileId or 'root'} failed: {result['error']}") return [] data = _unwrapData(result) items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else [] entries: List[ExternalEntry] = [] for item in items: itemId = str(item.get("id", "")) if not itemId: continue isFolder = item.get("type") == "dir" entries.append(ExternalEntry( name=item.get("name", itemId), path=f"/{driveId}/{itemId}", isFolder=isFolder, size=item.get("size") if not isFolder else None, mimeType=item.get("mime_type") if not isFolder else None, lastModified=item.get("last_modified_at"), metadata={"id": itemId, "kind": item.get("type", "")}, )) return entries async def download(self, path: str) -> DownloadResult: segments = [s for s in (path or "").strip("/").split("/") if s] if len(segments) < 2: return DownloadResult() driveId, fileId = segments[0], segments[-1] meta = await _infomaniakGet(self._token, f"/2/drive/{driveId}/files/{fileId}") fileName = fileId mimeType = "application/octet-stream" if isinstance(meta, dict) and not meta.get("error"): data = _unwrapData(meta) if isinstance(data, dict): fileName = data.get("name") or fileId mimeType = data.get("mime_type") or mimeType content = await _infomaniakDownload(self._token, f"/2/drive/{driveId}/files/{fileId}/download") if content is None: return DownloadResult() return DownloadResult(data=content, fileName=fileName, mimeType=mimeType) async def upload(self, path: str, data: bytes, fileName: str) -> dict: return {"error": "kDrive upload not yet implemented"} async def search( self, query: str, path: Optional[str] = None, limit: Optional[int] = None, ) -> List[ExternalEntry]: segments = [s for s in (path or "").strip("/").split("/") if s] if not segments: drives = await self._listDrives() if not drives: return [] driveId = (drives[0].metadata or {}).get("id") or drives[0].path.strip("/") else: driveId = segments[0] pageSize = max(1, min(int(limit or 50), 200)) endpoint = f"/2/drive/{driveId}/files/search?query={query}&per_page={pageSize}" result = await _infomaniakGet(self._token, endpoint) if isinstance(result, dict) and result.get("error"): return [] data = _unwrapData(result) items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else [] entries: List[ExternalEntry] = [] for item in items: itemId = str(item.get("id", "")) if not itemId: continue isFolder = item.get("type") == "dir" entries.append(ExternalEntry( name=item.get("name", itemId), path=f"/{driveId}/{itemId}", isFolder=isFolder, size=item.get("size") if not isFolder else None, mimeType=item.get("mime_type") if not isFolder else None, metadata={"id": itemId}, )) return entries class MailAdapter(ServiceAdapter): """Infomaniak Mail ServiceAdapter -- browse mailboxes, folders and messages.""" _DEFAULT_MESSAGE_LIMIT = 100 _MAX_MESSAGE_LIMIT = 500 def __init__(self, accessToken: str): self._token = accessToken async def browse( self, path: str, filter: Optional[str] = None, limit: Optional[int] = None, ) -> List[ExternalEntry]: cleanPath = (path or "").strip("/") segments = [s for s in cleanPath.split("/") if s] if not segments: return await self._listMailboxes() if len(segments) == 1: return await self._listFolders(segments[0]) if len(segments) == 2: return await self._listMessages(segments[0], segments[1], limit=limit) return [] async def _listMailboxes(self) -> List[ExternalEntry]: result = await _infomaniakGet(self._token, "/1/mail") if isinstance(result, dict) and result.get("error"): logger.warning(f"Mail list-mailboxes failed: {result['error']}") return [] data = _unwrapData(result) mailboxes = data if isinstance(data, list) else data.get("mailboxes", []) if isinstance(data, dict) else [] entries: List[ExternalEntry] = [] for mb in mailboxes: mbId = str(mb.get("id") or mb.get("mailbox_id") or "") if not mbId: continue entries.append(ExternalEntry( name=mb.get("email") or mb.get("name") or mbId, path=f"/{mbId}", isFolder=True, metadata={"id": mbId, "kind": "mailbox"}, )) return entries async def _listFolders(self, mailboxId: str) -> List[ExternalEntry]: result = await _infomaniakGet(self._token, f"/1/mail/{mailboxId}/folder") if isinstance(result, dict) and result.get("error"): logger.warning(f"Mail list-folders {mailboxId} failed: {result['error']}") return [] data = _unwrapData(result) folders = data if isinstance(data, list) else data.get("folders", []) if isinstance(data, dict) else [] entries: List[ExternalEntry] = [] for f in folders: folderId = str(f.get("id") or f.get("path") or "") if not folderId: continue entries.append(ExternalEntry( name=f.get("name") or folderId, path=f"/{mailboxId}/{folderId}", isFolder=True, metadata={"id": folderId, "kind": "folder"}, )) return entries async def _listMessages( self, mailboxId: str, folderId: str, limit: Optional[int], ) -> List[ExternalEntry]: effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max( 1, min(int(limit), self._MAX_MESSAGE_LIMIT), ) endpoint = f"/1/mail/{mailboxId}/folder/{folderId}/message?per_page={effectiveLimit}" result = await _infomaniakGet(self._token, endpoint) if isinstance(result, dict) and result.get("error"): return [] data = _unwrapData(result) messages = data if isinstance(data, list) else data.get("messages", []) if isinstance(data, dict) else [] entries: List[ExternalEntry] = [] for msg in messages: uid = str(msg.get("uid") or msg.get("id") or "") if not uid: continue subject = msg.get("subject") or "(no subject)" entries.append(ExternalEntry( name=subject, path=f"/{mailboxId}/{folderId}/{uid}", isFolder=False, lastModified=msg.get("date") or msg.get("internal_date"), metadata={ "uid": uid, "from": msg.get("from") or msg.get("sender", ""), "snippet": msg.get("preview", ""), }, )) return entries async def download(self, path: str) -> DownloadResult: import re segments = [s for s in (path or "").strip("/").split("/") if s] if len(segments) < 3: return DownloadResult() mailboxId, folderId, uid = segments[0], segments[1], segments[2] content = await _infomaniakDownload( self._token, f"/1/mail/{mailboxId}/folder/{folderId}/message/{uid}/download", ) if content is None: return DownloadResult() meta = await _infomaniakGet( self._token, f"/1/mail/{mailboxId}/folder/{folderId}/message/{uid}", ) subject = uid if isinstance(meta, dict) and not meta.get("error"): unwrapped = _unwrapData(meta) if isinstance(unwrapped, dict): subject = unwrapped.get("subject") or uid safeName = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", subject)[:80].strip(". ") or "email" return DownloadResult( data=content, fileName=f"{safeName}.eml", mimeType="message/rfc822", ) async def upload(self, path: str, data: bytes, fileName: str) -> dict: return {"error": "Mail upload not applicable"} async def search( self, query: str, path: Optional[str] = None, limit: Optional[int] = None, ) -> List[ExternalEntry]: segments = [s for s in (path or "").strip("/").split("/") if s] if not segments: mailboxes = await self._listMailboxes() if not mailboxes: return [] mailboxId = (mailboxes[0].metadata or {}).get("id") or mailboxes[0].path.strip("/") else: mailboxId = segments[0] effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max( 1, min(int(limit), self._MAX_MESSAGE_LIMIT), ) endpoint = f"/1/mail/{mailboxId}/message/search?query={query}&per_page={effectiveLimit}" result = await _infomaniakGet(self._token, endpoint) if isinstance(result, dict) and result.get("error"): return [] data = _unwrapData(result) messages = data if isinstance(data, list) else data.get("messages", []) if isinstance(data, dict) else [] entries: List[ExternalEntry] = [] for msg in messages: uid = str(msg.get("uid") or msg.get("id") or "") if not uid: continue folderId = str(msg.get("folder_id") or msg.get("folderId") or "") entries.append(ExternalEntry( name=msg.get("subject") or uid, path=f"/{mailboxId}/{folderId}/{uid}" if folderId else f"/{mailboxId}/{uid}", isFolder=False, metadata={"uid": uid, "from": msg.get("from", "")}, )) return entries class InfomaniakConnector(ProviderConnector): """Infomaniak ProviderConnector -- 1 connection -> kDrive + Mail.""" _SERVICE_MAP = { "kdrive": KdriveAdapter, "mail": MailAdapter, } def getAvailableServices(self) -> List[str]: return list(self._SERVICE_MAP.keys()) def getServiceAdapter(self, service: str) -> ServiceAdapter: adapterClass = self._SERVICE_MAP.get(service) if not adapterClass: raise ValueError( f"Unknown Infomaniak service: {service}. " f"Available: {list(self._SERVICE_MAP.keys())}" ) return adapterClass(self.accessToken)