200 lines
7.0 KiB
Python
200 lines
7.0 KiB
Python
"""
|
|
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()
|