notebook
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
==============================================================================
|
||||
Skript: 1b_parse_emails_graph_delta_v1.0.py
|
||||
Verze: 1.0
|
||||
Datum: 2026-06-04
|
||||
Autor: vladimir.buzalka
|
||||
|
||||
Popis:
|
||||
Inkrementalni sync emailu pres Microsoft Graph DELTA QUERY.
|
||||
Sourozenec `1_parse_emails_graph_v1.4.py` — kazdy resi jiny use case:
|
||||
|
||||
1_parse_emails_graph_v1.4.py = prvni plny import schranky
|
||||
1b_parse_emails_graph_delta_v1.0.py = pravidelny sync (zmeny od minula)
|
||||
|
||||
Delta query je server-side change tracking — Graph si pamatuje "zalozku"
|
||||
(deltaLink) a vraci jen to, co se od ni zmenilo:
|
||||
- nove zpravy
|
||||
- zmeny existujicich (isRead, flag, presun do jine slozky, kategorie)
|
||||
- SMAZANE zpravy (@removed) — definitivne smazane, nikoli v kosi
|
||||
|
||||
Pro mail v "Deleted Items" delta nic specialniho nedela — je to porad
|
||||
normalni zprava, jen s folder_path="Deleted Items". @removed prijde az
|
||||
kdyz uzivatel vysype kos / Shift+Del.
|
||||
|
||||
State:
|
||||
Kolekce `emaily.sync_state`, _id = "<mailbox>|<folder_id>".
|
||||
{
|
||||
mailbox, folder_id, folder_path,
|
||||
delta_link, # plny URL s $deltatoken na pristi beh
|
||||
last_run_at,
|
||||
cumulative_new, cumulative_sync, cumulative_removed
|
||||
}
|
||||
|
||||
Permanentne smazane zpravy:
|
||||
Skript je NEMAZE z Mongo. Pouze nastavi:
|
||||
permanently_deleted: True
|
||||
permanently_deleted_at: <UTC datetime detekce>
|
||||
Dohledani: col.find({"permanently_deleted": True})
|
||||
|
||||
Reuse:
|
||||
Funkce extract_message / extract_sync_fields se nactou primo z modulu
|
||||
1_parse_emails_graph_v1.4.py (importlib, file-based), aby se logika
|
||||
extrahce nikdy nerozesla.
|
||||
|
||||
Spousteni:
|
||||
python 1b_parse_emails_graph_delta_v1.0.py # VSECHNY schranky (mimo SKIP_MAILBOXES)
|
||||
python 1b_parse_emails_graph_delta_v1.0.py --mailbox ordinace@buzalkova.cz # jedna schranka
|
||||
python 1b_parse_emails_graph_delta_v1.0.py --mailbox ordinace@buzalkova.cz --folder Inbox
|
||||
python 1b_parse_emails_graph_delta_v1.0.py --reset # zahodit deltaLinky a najet znova
|
||||
python 1b_parse_emails_graph_delta_v1.0.py --dry-run # nic neulozit
|
||||
|
||||
SKIP_MAILBOXES (hardcoded):
|
||||
vbuzalka@its.jnj.com — JNJ tenant, nemame Graph API pristup. Pro tuto
|
||||
schranku je nutny samostatny skript (lokalni .msg).
|
||||
|
||||
Zavislosti:
|
||||
msal, requests, pymongo, python-dateutil
|
||||
Python 3.10+
|
||||
==============================================================================
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import msal
|
||||
import requests
|
||||
from pymongo import MongoClient, ASCENDING
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# ─── KONFIGURACE ──────────────────────────────────────────────────────────────
|
||||
GRAPH_TENANT_ID = "7d269944-37a4-43a1-8140-c7517dc426e9"
|
||||
GRAPH_CLIENT_ID = "4b222bfd-78c9-4239-a53f-43006b3ed07f"
|
||||
GRAPH_CLIENT_SECRET = "Txg8Q~MjhocuopxsJyJBhPmDfMxZ2r5WpTFj1dfk"
|
||||
GRAPH_URL = "https://graph.microsoft.com/v1.0"
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
MONGO_DB = "emaily"
|
||||
SYNC_STATE_COL = "sync_state"
|
||||
PAGE_SIZE = 100 # delta endpoint typicky vraci max 100/stranka
|
||||
LOG_FILE = Path(__file__).parent / "delta_errors.log"
|
||||
SCRIPT_VERSION = "1.0"
|
||||
|
||||
# Kolekce v `emaily` ktere NEJSOU mailboxy:
|
||||
NON_MAILBOX_COLLECTIONS = {"attachments_index", "sync_state"}
|
||||
|
||||
# Schranky, kde NEMAME Graph API pristup — pri bezneho behu se preskoci.
|
||||
# Pro tyto je nutny separatni skript (napr. lokalni .msg parser).
|
||||
SKIP_MAILBOXES = {
|
||||
"vbuzalka@its.jnj.com", # JNJ tenant — nemame Graph credentials
|
||||
}
|
||||
|
||||
logging.basicConfig(
|
||||
filename=str(LOG_FILE),
|
||||
level=logging.ERROR,
|
||||
format="%(asctime)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Co tahnout z delta endpointu (stejne jako MSG_SELECT v v1.4, mimo internetMessageHeaders
|
||||
# ktere delta neumi vratit pro vsechny polozky — pro nove zpravy si je dotahneme
|
||||
# samostatnym fetchem).
|
||||
DELTA_SELECT = (
|
||||
"id,internetMessageId,subject,bodyPreview,body,"
|
||||
"importance,isRead,isDraft,hasAttachments,"
|
||||
"receivedDateTime,sentDateTime,createdDateTime,lastModifiedDateTime,"
|
||||
"sender,from,toRecipients,ccRecipients,bccRecipients,replyTo,"
|
||||
"conversationId,conversationIndex,parentFolderId,"
|
||||
"categories,flag,inferenceClassification"
|
||||
)
|
||||
|
||||
# Pro plne nacteni nove zpravy (vcetne hlavicek + priloh) pouzijeme stejny
|
||||
# select+expand jako v1.4
|
||||
FULL_FETCH_SELECT = (
|
||||
"id,internetMessageId,subject,bodyPreview,body,"
|
||||
"importance,isRead,isDraft,hasAttachments,"
|
||||
"receivedDateTime,sentDateTime,createdDateTime,lastModifiedDateTime,"
|
||||
"sender,from,toRecipients,ccRecipients,bccRecipients,replyTo,"
|
||||
"conversationId,conversationIndex,parentFolderId,"
|
||||
"categories,flag,inferenceClassification,internetMessageHeaders"
|
||||
)
|
||||
FULL_FETCH_EXPAND = "attachments($select=id,name,contentType,size,isInline)"
|
||||
|
||||
# ─── Reuse extract logiky z v1.4 ──────────────────────────────────────────────
|
||||
|
||||
_HERE = Path(__file__).parent
|
||||
_V14_PATH = _HERE / "1_parse_emails_graph_v1.4.py"
|
||||
if not _V14_PATH.exists():
|
||||
print(f"CHYBA: chybi sourozenec {_V14_PATH.name} — extract logiku nelze nacist", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("v14_parse", _V14_PATH)
|
||||
_v14 = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_v14)
|
||||
extract_message = _v14.extract_message
|
||||
extract_sync_fields = _v14.extract_sync_fields
|
||||
|
||||
# GRAPH_MAILBOX modul-level v v1.4 — pro extract neni potreba, ale pro
|
||||
# konzistenci nastavujeme ho v main()
|
||||
|
||||
# ─── Graph API ────────────────────────────────────────────────────────────────
|
||||
|
||||
_graph_token: Optional[str] = None
|
||||
|
||||
|
||||
def get_token() -> str:
|
||||
global _graph_token
|
||||
app = msal.ConfidentialClientApplication(
|
||||
GRAPH_CLIENT_ID,
|
||||
authority=f"https://login.microsoftonline.com/{GRAPH_TENANT_ID}",
|
||||
client_credential=GRAPH_CLIENT_SECRET,
|
||||
)
|
||||
result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
|
||||
if "access_token" not in result:
|
||||
raise RuntimeError(f"Graph auth failed: {result}")
|
||||
_graph_token = result["access_token"]
|
||||
return _graph_token
|
||||
|
||||
|
||||
class DeltaExpired(Exception):
|
||||
"""deltaLink expiroval (HTTP 410) — je nutne zacit od plne delta znovu."""
|
||||
|
||||
|
||||
def graph_get(url: str, params: dict = None, allow_410: bool = False) -> dict:
|
||||
"""GET na Graph s retry pri 401. Pri 410 a allow_410=True vyhodi DeltaExpired."""
|
||||
global _graph_token
|
||||
if not _graph_token:
|
||||
get_token()
|
||||
for attempt in range(3):
|
||||
r = requests.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {_graph_token}"},
|
||||
params=params,
|
||||
timeout=60,
|
||||
)
|
||||
if r.status_code == 401:
|
||||
get_token()
|
||||
continue
|
||||
if r.status_code == 410 and allow_410:
|
||||
raise DeltaExpired(url)
|
||||
if r.status_code == 429:
|
||||
# rate limit — respect Retry-After
|
||||
wait = int(r.headers.get("Retry-After", "5"))
|
||||
print(f" [429] cekam {wait}s ...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
raise RuntimeError(f"Graph GET failed after retries: {url}")
|
||||
|
||||
|
||||
def get_all_folders(mailbox: str, parent_id: str = None, parent_path: str = "") -> list[dict]:
|
||||
if parent_id is None:
|
||||
url = f"{GRAPH_URL}/users/{mailbox}/mailFolders"
|
||||
else:
|
||||
url = f"{GRAPH_URL}/users/{mailbox}/mailFolders/{parent_id}/childFolders"
|
||||
|
||||
folders = []
|
||||
params = {"$top": 100, "$select": "id,displayName,childFolderCount"}
|
||||
while url:
|
||||
data = graph_get(url, params)
|
||||
for f in data.get("value", []):
|
||||
path = f"{parent_path}/{f['displayName']}".lstrip("/")
|
||||
folders.append({"id": f["id"], "path": path})
|
||||
if f.get("childFolderCount", 0) > 0:
|
||||
folders.extend(get_all_folders(mailbox, f["id"], path))
|
||||
url = data.get("@odata.nextLink")
|
||||
params = None
|
||||
return folders
|
||||
|
||||
|
||||
def fetch_full_message(mailbox: str, msg_id: str) -> Optional[dict]:
|
||||
"""Stahne celou zpravu vcetne hlavicek a priloh — pro nove zpravy zachycene v delte."""
|
||||
url = f"{GRAPH_URL}/users/{mailbox}/messages/{msg_id}"
|
||||
params = {"$select": FULL_FETCH_SELECT, "$expand": FULL_FETCH_EXPAND}
|
||||
try:
|
||||
return graph_get(url, params)
|
||||
except requests.HTTPError as e:
|
||||
logging.error("fetch_full_message %s: %s", msg_id, e)
|
||||
return None
|
||||
|
||||
|
||||
# ─── Delta iterace ────────────────────────────────────────────────────────────
|
||||
|
||||
def iter_folder_delta(mailbox: str, folder_id: str, delta_link: Optional[str], limit: int = 0):
|
||||
"""
|
||||
Generator: vraci (item, final_delta_link).
|
||||
item je dict s polozkou (bud zmena nebo {'@removed': ...}).
|
||||
Posledni vyhozeny tuple ma final_delta_link != None (zbytek None).
|
||||
|
||||
Pri HTTP 410 (expirovany deltaLink) vyhodi DeltaExpired — caller ma
|
||||
pustit znova s delta_link=None (= fresh full delta).
|
||||
"""
|
||||
if delta_link:
|
||||
url = delta_link
|
||||
params = None
|
||||
else:
|
||||
url = f"{GRAPH_URL}/users/{mailbox}/mailFolders/{folder_id}/messages/delta"
|
||||
params = {"$select": DELTA_SELECT, "$top": PAGE_SIZE}
|
||||
|
||||
n = 0
|
||||
while url:
|
||||
data = graph_get(url, params, allow_410=True)
|
||||
params = None
|
||||
for item in data.get("value", []):
|
||||
yield item, None
|
||||
n += 1
|
||||
if limit and n >= limit:
|
||||
# ulozime aspon stavajici nextLink jako "delta" — neni to ciste,
|
||||
# ale pri --limit jde o test, takze pristi beh proste pocnize znovu
|
||||
return
|
||||
next_link = data.get("@odata.nextLink")
|
||||
final_link = data.get("@odata.deltaLink")
|
||||
if final_link:
|
||||
# konec — predame final delta
|
||||
yield None, final_link
|
||||
return
|
||||
url = next_link
|
||||
|
||||
|
||||
# ─── Per-folder sync ──────────────────────────────────────────────────────────
|
||||
|
||||
def sync_folder(col, sync_col, mailbox: str, folder: dict, dry_run: bool, limit: int) -> dict:
|
||||
"""Vrati statistiky."""
|
||||
fid = folder["id"]
|
||||
fpath = folder["path"]
|
||||
state_id = f"{mailbox}|{fid}"
|
||||
state = sync_col.find_one({"_id": state_id})
|
||||
delta_link = state.get("delta_link") if state else None
|
||||
|
||||
is_first_run = delta_link is None
|
||||
label = "FRESH" if is_first_run else "DELTA"
|
||||
print(f"\n[{label}] {fpath}")
|
||||
|
||||
stats = {"new": 0, "sync": 0, "removed": 0, "errors": 0}
|
||||
final_delta = None
|
||||
|
||||
try:
|
||||
gen = iter_folder_delta(mailbox, fid, delta_link, limit=limit)
|
||||
for item, fin in gen:
|
||||
if fin:
|
||||
final_delta = fin
|
||||
break
|
||||
try:
|
||||
process_item(col, mailbox, fpath, item, stats, dry_run)
|
||||
except Exception as e:
|
||||
stats["errors"] += 1
|
||||
logging.error("process_item %s: %s", item.get("id", "?"), e)
|
||||
except DeltaExpired:
|
||||
print(f" [410] deltaLink expiroval — restart od fresh delta")
|
||||
# rekurzivni restart s vymazanym statem
|
||||
sync_col.delete_one({"_id": state_id})
|
||||
return sync_folder(col, sync_col, mailbox, folder, dry_run, limit)
|
||||
|
||||
print(f" new={stats['new']} sync={stats['sync']} removed={stats['removed']} err={stats['errors']}")
|
||||
|
||||
# Ulozit sync_state pokud mame final_delta a neni dry run
|
||||
if final_delta and not dry_run:
|
||||
sync_col.update_one(
|
||||
{"_id": state_id},
|
||||
{
|
||||
"$set": {
|
||||
"mailbox": mailbox,
|
||||
"folder_id": fid,
|
||||
"folder_path": fpath,
|
||||
"delta_link": final_delta,
|
||||
"last_run_at": datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
},
|
||||
"$inc": {
|
||||
"cumulative_new": stats["new"],
|
||||
"cumulative_sync": stats["sync"],
|
||||
"cumulative_removed": stats["removed"],
|
||||
"run_count": 1,
|
||||
},
|
||||
},
|
||||
upsert=True,
|
||||
)
|
||||
elif not final_delta:
|
||||
# neprisel deltaLink (napr. limit nebo chyba) — nemenime state, pristi beh
|
||||
# bude pokracovat normalne podle stareho deltaLinku nebo zacne od fresh
|
||||
if not is_first_run:
|
||||
print(f" [pozn] delta neukoncena — pristi beh pojede od ulozeneho deltaLinku")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def process_item(col, mailbox: str, folder_path: str, item: dict, stats: dict, dry_run: bool):
|
||||
"""Zpracuje jednu polozku z delta odpovedi."""
|
||||
# 1) Smazana zprava (@removed)
|
||||
if "@removed" in item or item.get("@removed.reason"):
|
||||
graph_id = item.get("id")
|
||||
if not graph_id:
|
||||
return
|
||||
if dry_run:
|
||||
print(f" REMOVED graph_id={graph_id[:30]}...")
|
||||
else:
|
||||
col.update_one(
|
||||
{"graph_id": graph_id},
|
||||
{"$set": {
|
||||
"permanently_deleted": True,
|
||||
"permanently_deleted_at": datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
}},
|
||||
)
|
||||
stats["removed"] += 1
|
||||
return
|
||||
|
||||
# 2) Nova nebo zmenena zprava — rozhodneme podle existence graph_id v Mongo
|
||||
graph_id = item.get("id")
|
||||
if not graph_id:
|
||||
return
|
||||
|
||||
existing = col.find_one({"graph_id": graph_id}, {"_id": 1})
|
||||
|
||||
if existing:
|
||||
# Existujici zprava — update jen sync poli (delta payload je obsahuje)
|
||||
fields = extract_sync_fields(item, folder_path)
|
||||
if dry_run:
|
||||
print(f" SYNC {item.get('subject','')[:60]}")
|
||||
else:
|
||||
col.update_one({"_id": existing["_id"]}, {"$set": fields})
|
||||
stats["sync"] += 1
|
||||
else:
|
||||
# Nova zprava — pro telo+attachments+headers fetchneme plnou verzi
|
||||
full = fetch_full_message(mailbox, graph_id)
|
||||
if full is None:
|
||||
stats["errors"] += 1
|
||||
return
|
||||
doc = extract_message(full, folder_path)
|
||||
if doc is None:
|
||||
stats["errors"] += 1
|
||||
return
|
||||
if dry_run:
|
||||
print(f" NEW {doc.get('subject','')[:60]}")
|
||||
else:
|
||||
col.update_one({"_id": doc["_id"]}, {"$set": doc}, upsert=True)
|
||||
stats["new"] += 1
|
||||
|
||||
|
||||
# ─── Indexy pro sync_state ────────────────────────────────────────────────────
|
||||
|
||||
def ensure_sync_state_indexes(sync_col):
|
||||
sync_col.create_index([("mailbox", ASCENDING), ("folder_id", ASCENDING)])
|
||||
sync_col.create_index([("last_run_at", ASCENDING)])
|
||||
|
||||
|
||||
def ensure_perm_deleted_index(col):
|
||||
col.create_index([("permanently_deleted", ASCENDING)], sparse=True)
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def discover_mailboxes(db) -> list[str]:
|
||||
"""Vrati seznam mailboxu = vsechny kolekce v `emaily` mimo NON_MAILBOX_COLLECTIONS
|
||||
a SKIP_MAILBOXES."""
|
||||
out = []
|
||||
for name in sorted(db.list_collection_names()):
|
||||
if name in NON_MAILBOX_COLLECTIONS:
|
||||
continue
|
||||
if name in SKIP_MAILBOXES:
|
||||
print(f" [skip] {name} — v SKIP_MAILBOXES (neni Graph pristup)")
|
||||
continue
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def sync_mailbox(client, mailbox: str, args) -> dict:
|
||||
"""Sync jedne schranky. Vraci totals dict."""
|
||||
_v14.GRAPH_MAILBOX = mailbox
|
||||
|
||||
print(f"\n========== {mailbox} ==========")
|
||||
|
||||
col = client[MONGO_DB][mailbox]
|
||||
sync_col = client[MONGO_DB][SYNC_STATE_COL]
|
||||
|
||||
if not args.dry_run:
|
||||
ensure_sync_state_indexes(sync_col)
|
||||
ensure_perm_deleted_index(col)
|
||||
|
||||
if args.reset:
|
||||
n = sync_col.delete_many({"mailbox": mailbox}).deleted_count
|
||||
print(f" --reset: smazano {n} deltaLinku pro {mailbox}")
|
||||
|
||||
print("Nacitam seznam slozek...")
|
||||
try:
|
||||
folders = get_all_folders(mailbox)
|
||||
except requests.HTTPError as e:
|
||||
print(f" CHYBA: nelze nacist slozky pro {mailbox}: {e}")
|
||||
logging.error("get_all_folders %s: %s", mailbox, e)
|
||||
return {"new": 0, "sync": 0, "removed": 0, "errors": 1}
|
||||
|
||||
if args.folder:
|
||||
folders = [f for f in folders if args.folder.lower() in f["path"].lower()]
|
||||
print(f" Slozek ke zpracovani: {len(folders)}")
|
||||
|
||||
totals = {"new": 0, "sync": 0, "removed": 0, "errors": 0}
|
||||
for folder in folders:
|
||||
s = sync_folder(col, sync_col, mailbox, folder, args.dry_run, args.limit)
|
||||
for k in totals:
|
||||
totals[k] += s[k]
|
||||
print(f" -> mailbox total: new={totals['new']} sync={totals['sync']} removed={totals['removed']} err={totals['errors']}")
|
||||
return totals
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=f"parse_emails_graph delta sync v{SCRIPT_VERSION}")
|
||||
ap.add_argument("--mailbox", default="",
|
||||
help="E-mail schranky (= kolekce v Mongo). "
|
||||
"Bez argumentu projede vsechny schranky z `emaily` (mimo SKIP_MAILBOXES).")
|
||||
ap.add_argument("--folder", default="", help="Filtruje slozky obsahujici tento retezec (default: vsechny)")
|
||||
ap.add_argument("--limit", type=int, default=0, help="Max polozek na slozku (test)")
|
||||
ap.add_argument("--reset", action="store_true",
|
||||
help="Smaze deltaLinky pro vybrane schranky — pristi beh zacne od fresh delta")
|
||||
ap.add_argument("--dry-run", action="store_true", help="Nic neulozi do Mongo, jen vypise co by se stalo")
|
||||
args = ap.parse_args()
|
||||
|
||||
print(f"=== Delta sync v{SCRIPT_VERSION} ===")
|
||||
if args.dry_run:
|
||||
print(" DRY-RUN — zadne zmeny v Mongo")
|
||||
|
||||
print("Pripojuji se k MongoDB...")
|
||||
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
|
||||
client.admin.command("ping")
|
||||
db = client[MONGO_DB]
|
||||
|
||||
if args.mailbox:
|
||||
if args.mailbox in SKIP_MAILBOXES:
|
||||
print(f" CHYBA: {args.mailbox} je v SKIP_MAILBOXES — neni Graph pristup.")
|
||||
sys.exit(2)
|
||||
mailboxes = [args.mailbox]
|
||||
else:
|
||||
mailboxes = discover_mailboxes(db)
|
||||
print(f" Schranky ke zpracovani: {len(mailboxes)}")
|
||||
for m in mailboxes:
|
||||
print(f" {m}")
|
||||
|
||||
print("Token Graph API...")
|
||||
get_token()
|
||||
print(" OK")
|
||||
|
||||
t0 = time.time()
|
||||
grand = {"new": 0, "sync": 0, "removed": 0, "errors": 0}
|
||||
per_mailbox = []
|
||||
for mb in mailboxes:
|
||||
try:
|
||||
s = sync_mailbox(client, mb, args)
|
||||
except Exception as e:
|
||||
print(f" FATAL pri sync {mb}: {e}")
|
||||
logging.error("sync_mailbox %s: %s", mb, e)
|
||||
s = {"new": 0, "sync": 0, "removed": 0, "errors": 1}
|
||||
per_mailbox.append((mb, s))
|
||||
for k in grand:
|
||||
grand[k] += s[k]
|
||||
|
||||
dt = time.time() - t0
|
||||
print(f"\n=== SHRNUTI ===")
|
||||
for mb, s in per_mailbox:
|
||||
print(f" {mb:40} new={s['new']:>5} sync={s['sync']:>5} removed={s['removed']:>4} err={s['errors']:>3}")
|
||||
print(f" {'TOTAL':40} new={grand['new']:>5} sync={grand['sync']:>5} removed={grand['removed']:>4} err={grand['errors']:>3}")
|
||||
print(f" trvalo: {dt:.1f} s")
|
||||
return 1 if grand["errors"] > 0 else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 0)
|
||||
Reference in New Issue
Block a user