""" mailbox_mirror v1.0 | 2026-06-08 | vladimir.buzalka Zrcadlí primární JNJ schránku (BEZ Online Archive) za posledních 30 dní do osobní schránky vladimir.buzalka@buzalka.cz. Princip — bezestavový diff přes Internet Message-ID: 1. Projdi Inbox(+podsložky), Sent, Deleted; vyber emaily z posledních 30 dní. Sestav manifest = [{message_id, folder, is_read}] (jen metadata, žádná těla). 2. POST /mirror-plan → server porovná manifest se stavem schránky: - smaže ze schránky zprávy které v manifestu nejsou (smazané v JNJ) - vrátí to_add = message_id které ve schránce chybí 3. Pro každé to_add: ulož .msg, zašifruj (Fernet → .emsg), POST /upload. Žádná SQLite, žádný graph_id bookkeeping — zdrojem pravdy jsou obě schránky. Mazání běží jen v rámci 30denního okna, starší archiv zůstává nedotčen. Omezení JNJ: - Zscaler DLP → soubory se posílají šifrované (.emsg) - Online Archive vynechán (GetDefaultFolder vrací jen primární schránku) Spouštění: opakovaně (Task Scheduler). Bezpečně opakovatelné a idempotentní. Závislosti: pywin32, requests, cryptography. Outlook musí běžet. """ import sys import base64 import hashlib import tempfile from pathlib import Path from datetime import datetime, timedelta, timezone import win32com.client import requests import urllib3 from cryptography.fernet import Fernet sys.stdout.reconfigure(encoding="utf-8") urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) TOKEN = "13e1bb01-9fd5-44a8-8ce9-4ee27133d340" BASE_URL = "https://msgs.buzalka.cz" PLAN_URL = f"{BASE_URL}/mirror-plan" UPLOAD_URL = f"{BASE_URL}/upload" WINDOW_DAYS = 30 PR_INTERNET_MESSAGE_ID = "http://schemas.microsoft.com/mapi/proptag/0x1035001E" # olFolderInbox=6, olFolderSentMail=5, olFolderDeletedItems=3 FOLDERS_TO_MIRROR = [6, 5, 3] # Šifrovací klíč odvozený z TOKENu (stejný algoritmus jako server) _FERNET = Fernet(base64.urlsafe_b64encode(hashlib.sha256(TOKEN.encode()).digest())) def get_mid(item) -> str: try: mid = item.PropertyAccessor.GetProperty(PR_INTERNET_MESSAGE_ID) except Exception: mid = None return mid or f"entryid:{item.EntryID}" def collect_manifest(ns, cutoff_local): """Projdi cílové složky + podsložky, vrať (manifest, index). manifest = [{message_id, folder, is_read}] index = {message_id: (entry_id, folder_path)} — pro fázi uploadu """ restrict = ( "@SQL=\"urn:schemas:httpmail:datereceived\" >= '%s'" % cutoff_local.strftime("%Y/%m/%d %H:%M:%S") ) manifest = [] index = {} def walk(folder, folder_path): current = f"{folder_path}/{folder.Name}" try: items = folder.Items.Restrict(restrict) items.Sort("[ReceivedTime]", False) n = 0 for item in items: try: if not item.MessageClass.upper().startswith("IPM.NOTE"): continue mid = get_mid(item) manifest.append({ "message_id": mid, "folder": current, "is_read": (not item.UnRead), }) index[mid] = (item.EntryID, current) n += 1 except Exception as e: print(f" chyba položky v {current}: {e}") print(f" {current}: {n}") except Exception as e: print(f" CHYBA složka {current}: {e}") return # nedostupná složka → nelez do podsložek try: subfolders = list(folder.Folders) except Exception: subfolders = [] for sub in subfolders: walk(sub, current) seen_roots = set() for fid in FOLDERS_TO_MIRROR: root = ns.GetDefaultFolder(fid) mailbox = root.Parent.Name key = (mailbox, root.Name) if key in seen_roots: continue seen_roots.add(key) walk(root, f"/{mailbox}") return manifest, index def upload_one(ns, entry_id, folder): """Ulož email jako .msg, zašifruj a nahraj na /upload (server naimportuje).""" item = ns.GetItemFromID(entry_id) with tempfile.TemporaryDirectory() as tmp: safe_name = f"{entry_id[-20:]}.msg" tmp_path = Path(tmp) / safe_name item.SaveAs(str(tmp_path), 3) # 3 = olMSG with open(tmp_path, "rb") as f: encrypted = _FERNET.encrypt(f.read()) enc_name = safe_name[:-4] + ".emsg" resp = requests.post( UPLOAD_URL, headers={"Authorization": f"Bearer {TOKEN}"}, files={"file": (enc_name, encrypted, "application/octet-stream")}, data={"folder": folder}, timeout=60, ) resp.raise_for_status() return resp.json() def main(): print(f"=== mailbox_mirror v1.0 ===") print(f"Start: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") cutoff_utc = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS) cutoff_graph = cutoff_utc.strftime("%Y-%m-%dT%H:%M:%SZ") cutoff_local = cutoff_utc.astimezone() print(f"Okno: posledních {WINDOW_DAYS} dní (cutoff {cutoff_graph})\n") outlook = win32com.client.Dispatch("Outlook.Application") ns = outlook.GetNamespace("MAPI") print("1) Sestavuji manifest z JNJ schránky...") manifest, index = collect_manifest(ns, cutoff_local) print(f" → {len(manifest)} emailů v okně\n") print("2) Posílám plán na server (diff + mazání přebytků)...") resp = requests.post( PLAN_URL, headers={"Authorization": f"Bearer {TOKEN}"}, json={"manifest": manifest, "cutoff": cutoff_graph}, timeout=300, ) resp.raise_for_status() plan = resp.json() to_add = plan.get("to_add", []) print(f" schránka={plan.get('mailbox_count')} | manifest={plan.get('manifest_count')}") print(f" smazáno ze schránky: {plan.get('deleted')}") print(f" k nahrání: {len(to_add)}\n") if not to_add: print("Schránka je v synchronu, nic nenahrávám.") else: print("3) Nahrávám chybějící emaily...") uploaded = 0 errors = 0 for i, mid in enumerate(to_add, 1): entry_id, folder = index.get(mid, (None, None)) if not entry_id: print(f" [{i}/{len(to_add)}] chybí index pro {mid[:40]} — přeskočeno") errors += 1 continue try: upload_one(ns, entry_id, folder) uploaded += 1 if uploaded % 50 == 0: print(f" [{datetime.now().strftime('%H:%M:%S')}] " f"nahráno {uploaded}/{len(to_add)}") except Exception as e: print(f" CHYBA upload {mid[:40]}: {e}") errors += 1 print(f"\n nahráno {uploaded} | chyby {errors}") print(f"\n=== Hotovo === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") if __name__ == "__main__": main()