gateway/modules/connectors/providerInfomaniak/connectorInfomaniak.py
2026-04-26 23:59:09 +02:00

420 lines
16 KiB
Python

# 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)