notebook
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# app.py | v1.7 | 2026-06-05
|
||||
# app.py | v1.9 | 2026-06-08
|
||||
# FastAPI server pro příjem .msg a .db souborů, upload do Dropboxu a import do Graph API.
|
||||
# Endpointy: /upload (.msg → /msgs + Graph import), /upload-db (.db → /msgs/db),
|
||||
# /upload-dropbox (→ Dropbox /!!!Days/Downloads Z230),
|
||||
# /message-delete, /message-update (sync: smazání, přečtení, přesun složky),
|
||||
# /mirror-plan (diff manifestu z JNJ vůči schránce → smaže přebytky, vrátí to_add),
|
||||
# /pending-files (seznam souborů k odeslání na JNJ), /download-file/{filename}.
|
||||
|
||||
from fastapi import FastAPI, UploadFile, File, Form, Header, HTTPException, Response
|
||||
@@ -151,6 +152,55 @@ def _map_jnj_folder(folder: str) -> list[str]:
|
||||
return prefix + rest if rest else prefix
|
||||
|
||||
|
||||
def _norm_mid(mid: str) -> str:
|
||||
"""Normalizuj Internet Message-ID pro porovnání (osekej <> a whitespace)."""
|
||||
return (mid or "").strip().strip("<>").strip()
|
||||
|
||||
|
||||
def _enumerate_jnj_mailbox(cutoff_iso: str) -> dict[str, str]:
|
||||
"""Vrať {normalizované internetMessageId: graph_id} pro všechny zprávy ve
|
||||
složkách JNJ/* schránky, které mají receivedDateTime >= cutoff_iso.
|
||||
|
||||
Slouží jako 'co už ve schránce je' pro mirror diff. Starší zprávy než cutoff
|
||||
(např. únorový archiv) se nenačtou — mirror se jich tedy nikdy nedotkne.
|
||||
"""
|
||||
jnj_id = _ensure_folder([GRAPH_ROOT_FOLDER])
|
||||
|
||||
# BFS přes JNJ root + všechny podsložky
|
||||
all_folders = [jnj_id]
|
||||
i = 0
|
||||
while i < len(all_folders):
|
||||
fid = all_folders[i]
|
||||
i += 1
|
||||
url = f"{GRAPH_URL}/users/{GRAPH_MAILBOX}/mailFolders/{fid}/childFolders?$top=100"
|
||||
while url:
|
||||
r = _retry_graph(http_requests.get, url, _graph_headers, timeout=20)
|
||||
data = r.json()
|
||||
for f in data.get("value", []):
|
||||
all_folders.append(f["id"])
|
||||
url = data.get("@odata.nextLink")
|
||||
|
||||
# Posbírej message-id z každé složky (filtrováno na okno)
|
||||
result: dict[str, str] = {}
|
||||
cutoff_enc = cutoff_iso.replace(":", "%3A")
|
||||
for fid in all_folders:
|
||||
url = (
|
||||
f"{GRAPH_URL}/users/{GRAPH_MAILBOX}/mailFolders/{fid}/messages"
|
||||
f"?$filter=receivedDateTime ge {cutoff_enc}"
|
||||
f"&$select=id,internetMessageId&$top=200"
|
||||
)
|
||||
while url:
|
||||
r = _retry_graph(http_requests.get, url, _graph_headers, timeout=30)
|
||||
data = r.json()
|
||||
for m in data.get("value", []):
|
||||
mid = _norm_mid(m.get("internetMessageId", ""))
|
||||
if mid:
|
||||
result[mid] = m["id"]
|
||||
url = data.get("@odata.nextLink")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _make_recipient(addr: str) -> dict:
|
||||
if "<" in addr and ">" in addr:
|
||||
name = addr[: addr.index("<")].strip().strip('"')
|
||||
@@ -221,6 +271,20 @@ def _import_msg_to_graph(msg_path: Path, folder: str) -> Optional[str]:
|
||||
folder_parts = _map_jnj_folder(folder)
|
||||
folder_id = _ensure_folder(folder_parts)
|
||||
|
||||
ext_props = [{"id": "Integer 0x0E07", "value": "1"}]
|
||||
|
||||
if date_raw:
|
||||
try:
|
||||
dt = dtparser.parse(str(date_raw))
|
||||
dt_str = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
# PR_MESSAGE_DELIVERY_TIME (0x0E06) — jediný způsob jak nastavit
|
||||
# receivedDateTime přes Graph API (přímé pole je read-only)
|
||||
ext_props.append({"id": "SystemTime 0x0E06", "value": dt_str})
|
||||
except Exception:
|
||||
dt_str = None
|
||||
else:
|
||||
dt_str = None
|
||||
|
||||
payload = {
|
||||
"subject": subject,
|
||||
"body": {
|
||||
@@ -231,19 +295,11 @@ def _import_msg_to_graph(msg_path: Path, folder: str) -> Optional[str]:
|
||||
"toRecipients": [_make_recipient(a) for a in to_list],
|
||||
"ccRecipients": [_make_recipient(a) for a in cc_list],
|
||||
"isRead": True,
|
||||
"singleValueExtendedProperties": [
|
||||
{"id": "Integer 0x0E07", "value": "1"}
|
||||
],
|
||||
"singleValueExtendedProperties": ext_props,
|
||||
}
|
||||
|
||||
if date_raw:
|
||||
try:
|
||||
dt = dtparser.parse(str(date_raw))
|
||||
payload["receivedDateTime"] = dt.astimezone(timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if dt_str:
|
||||
payload["sentDateTime"] = dt_str
|
||||
|
||||
if att_list:
|
||||
payload["attachments"] = att_list
|
||||
@@ -393,6 +449,56 @@ async def message_update(req: MessageUpdateRequest, authorization: str = Header(
|
||||
return result
|
||||
|
||||
|
||||
class MirrorPlanRequest(BaseModel):
|
||||
manifest: list[dict] # [{"message_id": ..., "folder": ..., "is_read": ...}]
|
||||
cutoff: str # ISO8601 UTC, např. "2026-05-09T00:00:00Z"
|
||||
|
||||
|
||||
@app.post("/mirror-plan")
|
||||
async def mirror_plan(req: MirrorPlanRequest, authorization: str = Header(None)):
|
||||
"""Porovná manifest zpráv z JNJ (posledních 30 dní) se stavem schránky.
|
||||
|
||||
- smaže ze schránky zprávy které v manifestu nejsou (smazané v JNJ / vypadlé z okna)
|
||||
- vrátí to_add = message_id které ve schránce chybí (klient je pak nahraje na /upload)
|
||||
|
||||
Maže POUZE v rámci okna (cutoff) — starší archiv zůstává nedotčen.
|
||||
"""
|
||||
if authorization != f"Bearer {TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# manifest: normalizované id → původní message_id (pro echo zpět klientovi)
|
||||
manifest_map: dict[str, str] = {}
|
||||
for e in req.manifest:
|
||||
mid = _norm_mid(e.get("message_id", ""))
|
||||
if mid:
|
||||
manifest_map[mid] = e["message_id"]
|
||||
|
||||
mailbox = _enumerate_jnj_mailbox(req.cutoff) # {norm_mid: graph_id}
|
||||
|
||||
to_add = [orig for nmid, orig in manifest_map.items() if nmid not in mailbox]
|
||||
to_delete = [(nmid, gid) for nmid, gid in mailbox.items() if nmid not in manifest_map]
|
||||
|
||||
deleted = 0
|
||||
for nmid, gid in to_delete:
|
||||
url = f"{GRAPH_URL}/users/{GRAPH_MAILBOX}/messages/{gid}"
|
||||
r = _retry_graph(http_requests.delete, url, _graph_headers, timeout=15)
|
||||
if r.status_code in (200, 204):
|
||||
deleted += 1
|
||||
else:
|
||||
log.error("mirror delete FAIL [%d]: %s", r.status_code, r.text[:150])
|
||||
|
||||
log.info(
|
||||
"mirror-plan: manifest=%d mailbox=%d → add=%d delete=%d",
|
||||
len(manifest_map), len(mailbox), len(to_add), deleted,
|
||||
)
|
||||
return {
|
||||
"to_add": to_add,
|
||||
"deleted": deleted,
|
||||
"manifest_count": len(manifest_map),
|
||||
"mailbox_count": len(mailbox),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/upload-file")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
Reference in New Issue
Block a user