420 lines
16 KiB
Python
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)
|