gouyez

gmail_hybrid_manager

Nov 23rd, 2025
876
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 21.19 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. gmail_hybrid_manager.py — launcher / GUI (plugin loader)
  5. Now starts Chrome **only when required** by a plugin.
  6.  
  7. ✅ Supports per-account proxy:
  8.    [email protected];IP:PORT
  9. All API and OAuth calls route through the same per-account proxy.
  10. """
  11.  
  12. import os
  13. import sys
  14. import time
  15. import threading
  16. from pathlib import Path
  17. from concurrent.futures import ThreadPoolExecutor, as_completed
  18. import tkinter as tk
  19. from tkinter import ttk, scrolledtext, messagebox, filedialog
  20. from core.chrome import autofill_google_login_email
  21.  
  22. # ==========================================
  23. # Base directory setup (works in .exe and .py)
  24. # ==========================================
  25. if getattr(sys, "frozen", False):
  26.     BASE_DIR = Path(sys.executable).resolve().parent
  27. else:
  28.     BASE_DIR = Path(__file__).resolve().parent
  29.  
  30. LOGS_DIR = BASE_DIR / "logs"
  31. EMAILS_DIR = BASE_DIR / "emails"
  32. CREDENTIALS_PATH = BASE_DIR / "credentials.json"
  33.  
  34. LOGS_DIR.mkdir(exist_ok=True)
  35. EMAILS_DIR.mkdir(exist_ok=True)
  36.  
  37. sys.path.insert(0, str(BASE_DIR))
  38. sys.path.insert(0, str(BASE_DIR / "core"))
  39. sys.path.insert(0, str(BASE_DIR / "plugins"))
  40.  
  41. # ==========================================
  42. # Imports
  43. # ==========================================
  44. from core import logger
  45. from core.config import APP_VERSION, ensure_master_extracted, ensure_tokens_dir, PROFILES_DIR
  46. from core.chrome import start_chrome_session, close_chrome_session, cdp_navigate
  47. from core.gmail_api import (
  48.     load_credentials_for,
  49.     oauth_first_login_in_session,
  50.     build_gmail_service,
  51.     build_people_service,
  52.     search_messages,
  53. )
  54. from plugins import discover_plugins
  55.  
  56. # websocket used for cookie extraction; imported lazily/cautiously below
  57. try:
  58.     import websocket
  59. except Exception:
  60.     websocket = None
  61.  
  62. import json as _json_lib
  63.  
  64.  
  65. # ----------------------------
  66. # Small helpers (cookie save)
  67. # ----------------------------
  68. def _safe_email_token_for_path(email: str) -> str:
  69.     # Keep the same token rules you used in chrome.py/profile naming:
  70.     token = "".join(c for c in email.lower() if c.isalnum() or c in ("@", ".", "_", "-"))
  71.     token = token.replace("@", "_at_").replace(".", "_")
  72.     return token
  73.  
  74.  
  75. def save_gmail_cookies(sess, email: str, log_fn=print) -> bool:
  76.     """
  77.    Connect to Chrome DevTools WebSocket for the session and request Network.getAllCookies,
  78.    then write the cookies to the profile directory as 'saved_cookies.json'.
  79.  
  80.    This is a lightweight snapshot used to restore or inspect cookies later (not an import/export
  81.    into Chrome). We intentionally keep this minimal and robust.
  82.    """
  83.     if not sess or not getattr(sess, "ws_url", None):
  84.         log_fn("[COOKIE] no session / ws_url; skipping cookie save.")
  85.         return False
  86.  
  87.     if websocket is None:
  88.         log_fn("[COOKIE][WARN] websocket-client not available; cannot save cookies.")
  89.         return False
  90.  
  91.     try:
  92.         # Connect to the session's WebSocket endpoint
  93.         ws = websocket.create_connection(sess.ws_url, timeout=6)
  94.     except Exception as e:
  95.         log_fn(f"[COOKIE][ERROR] ws connect failed: {e}")
  96.         return False
  97.  
  98.     try:
  99.         _id = int(time.time() * 1000) % 1000000
  100.  
  101.         def send(method, params=None):
  102.             nonlocal _id
  103.             _id += 1
  104.             payload = {"id": _id, "method": method}
  105.             if params:
  106.                 payload["params"] = params
  107.             try:
  108.                 ws.send(_json_lib.dumps(payload))
  109.             except Exception as e:
  110.                 raise RuntimeError(f"ws.send failed: {e}")
  111.             return _id
  112.  
  113.         # Enable Network domain, then request all cookies
  114.         send("Network.enable")
  115.         req_id = send("Network.getAllCookies")
  116.  
  117.         cookies = None
  118.         deadline = time.time() + 2.0  # short wait
  119.         while time.time() < deadline:
  120.             try:
  121.                 raw = ws.recv()
  122.             except Exception:
  123.                 break
  124.             try:
  125.                 m = _json_lib.loads(raw)
  126.             except Exception:
  127.                 continue
  128.             if m.get("id") == req_id and "result" in m:
  129.                 cookies = m["result"].get("cookies", [])
  130.                 break
  131.  
  132.         try:
  133.             ws.close()
  134.         except Exception:
  135.             pass
  136.  
  137.         if cookies is None:
  138.             log_fn("[COOKIE] no cookies received (empty or timed out).")
  139.             return False
  140.  
  141.         # Write cookies to profile folder
  142.         token = _safe_email_token_for_path(email)
  143.         prof_dir = Path(PROFILES_DIR) / token
  144.         prof_dir.mkdir(parents=True, exist_ok=True)
  145.         out_file = prof_dir / "saved_cookies.json"
  146.         try:
  147.             with open(out_file, "w", encoding="utf-8") as fh:
  148.                 _json_lib.dump(cookies, fh, ensure_ascii=False, indent=2)
  149.             log_fn(f"[COOKIE] saved {len(cookies)} cookies to {out_file}")
  150.             return True
  151.         except Exception as e:
  152.             log_fn(f"[COOKIE][ERROR] writing cookies: {e}")
  153.             return False
  154.  
  155.     except Exception as e:
  156.         log_fn(f"[COOKIE][ERROR] failed to fetch cookies: {e}")
  157.         try:
  158.             ws.close()
  159.         except Exception:
  160.             pass
  161.         return False
  162.  
  163.  
  164. # ==========================================
  165. # Main GUI
  166. # ==========================================
  167. class GmailHybridApp(tk.Tk):
  168.     def __init__(self):
  169.         super().__init__()
  170.         self.title(f"Gmail Hybrid Manager — v{APP_VERSION}")
  171.         self.geometry("1100x780")
  172.         self.configure(bg="#2b2b2b")
  173.  
  174.         ensure_tokens_dir()
  175.  
  176.         self.accounts_file = None
  177.         self.plugins = discover_plugins()
  178.         self.enabled_vars = {}
  179.         self.plugin_ui = {}
  180.         self.shared_search_term = ""
  181.  
  182.         self._setup_styles()
  183.         self._create_widgets()
  184.  
  185.         if not ensure_master_extracted(self._log_console):
  186.             messagebox.showerror(
  187.                 "Chrome master not found",
  188.                 "Could not find embedded 'chrome_master'.\n\n"
  189.                 "Dev mode: put a 'chrome_master' folder next to this .py/.exe.\n"
  190.                 'Build mode: include with PyInstaller --add-data "chrome_master;chrome_master".',
  191.             )
  192.  
  193.     # ==========================================
  194.     # UI Setup
  195.     # ==========================================
  196.     def _setup_styles(self):
  197.         style = ttk.Style(self)
  198.         style.theme_use("default")
  199.         style.configure("Vertical.TScrollbar",
  200.                         background="#3c3f41",
  201.                         troughcolor="#2b2b2b",
  202.                         arrowcolor="#FFFFFF")
  203.  
  204.     def _create_widgets(self):
  205.         main_frame = tk.Frame(self, bg="#2b2b2b")
  206.         main_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=6)
  207.  
  208.         # Left side (accounts + log)
  209.         left = tk.Frame(main_frame, bg="#2b2b2b")
  210.         left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
  211.  
  212.         ttk.Label(left, text="Gmail accounts (one per line):",
  213.                   background="#2b2b2b", foreground="#EEEEEE").pack(anchor="w")
  214.         self.accounts_box = scrolledtext.ScrolledText(
  215.             left, height=8, bg="#3c3f41", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat"
  216.         )
  217.         self.accounts_box.pack(fill=tk.X, padx=2, pady=4)
  218.  
  219.         # File row
  220.         accounts_file_row = tk.Frame(left, bg="#2b2b2b")
  221.         accounts_file_row.pack(fill=tk.X, pady=(2, 0))
  222.         self.btn_browse_accounts = tk.Button(
  223.             accounts_file_row, text="Browse…", command=self._browse_accounts_file,
  224.             bg="#3c3f41", fg="#FFFFFF", relief="flat", padx=10, pady=2
  225.         )
  226.         self.btn_browse_accounts.pack(side="left")
  227.         self.accounts_file_label = tk.Label(
  228.             accounts_file_row, text="", bg="#2b2b2b", fg="#AAAAAA", anchor="w"
  229.         )
  230.         self.accounts_file_label.pack(side="left", padx=8, expand=True, fill=tk.X)
  231.         self.btn_clear_accounts = tk.Button(
  232.             accounts_file_row, text="Clear file", command=self._clear_accounts_file,
  233.             bg="#3c3f41", fg="#FFFFFF", relief="flat", padx=10, pady=2
  234.         )
  235.         self.btn_clear_accounts.pack_forget()
  236.  
  237.         # Right side (actions)
  238.         right = tk.Frame(main_frame, bg="#2b2b2b")
  239.         right.pack(side=tk.RIGHT, fill=tk.Y, padx=6)
  240.  
  241.         chrome_group = tk.LabelFrame(
  242.             right, text="Chrome actions", bg="#2b2b2b", fg="#EEEEEE", padx=8, pady=8, relief="groove", bd=2
  243.         )
  244.         chrome_group.pack(fill=tk.X, pady=(0, 10))
  245.  
  246.         api_group = tk.LabelFrame(
  247.             right, text="API actions", bg="#2b2b2b", fg="#EEEEEE", padx=8, pady=8, relief="groove", bd=2
  248.         )
  249.         api_group.pack(fill=tk.X)
  250.  
  251.         # Concurrency section
  252.         cc_frame = tk.LabelFrame(
  253.             right, text="Concurrency", bg="#2b2b2b", fg="#EEEEEE", padx=8, pady=8, relief="groove", bd=2
  254.         )
  255.         cc_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=(10, 0))
  256.  
  257.         cc_row = tk.Frame(cc_frame, bg="#2b2b2b")
  258.         cc_row.pack(fill=tk.X, pady=(0, 2))
  259.  
  260.         tk.Label(cc_row, text="Max concurrent Chrome sessions:",
  261.                  bg="#2b2b2b", fg="#FFFFFF").pack(side="left", anchor="w")
  262.         self.concurrent_var = tk.StringVar(value="10")
  263.         tk.Spinbox(cc_row, from_=1, to=50, textvariable=self.concurrent_var,
  264.                    width=5, bg="#3c3f41", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat").pack(side="left", padx=(8, 0))
  265.  
  266.         # Buttons
  267.         btn = tk.Frame(left, bg="#2b2b2b")
  268.         btn.pack(fill=tk.X, pady=6)
  269.         self.start_btn = tk.Button(
  270.             btn, text="Start Processing", command=self.on_start,
  271.             bg="#3c3f41", fg="#FFFFFF", relief="flat"
  272.         )
  273.         self.start_btn.pack(side="left")
  274.         tk.Button(btn, text="Clear Log", command=self.clear_log,
  275.                   bg="#3c3f41", fg="#FFFFFF", relief="flat").pack(side="left", padx=6)
  276.  
  277.         ttk.Label(left, text="Log:", background="#2b2b2b", foreground="#EEEEEE").pack(anchor="w", pady=(4, 0))
  278.         self.log_box = scrolledtext.ScrolledText(
  279.             left, height=18, bg="#3c3f41", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat"
  280.         )
  281.         self.log_box.pack(fill=tk.BOTH, expand=True, pady=4)
  282.  
  283.         # Color tags
  284.         for name, color in {
  285.             "error": "#FF5555", "warn": "#FFB86C", "plugin": "#8BE9FD",
  286.             "session": "#50FA7B", "input": "#BD93F9", "batch": "#F1FA8C",
  287.         }.items():
  288.             self.log_box.tag_config(name, foreground=color)
  289.  
  290.         tk.Button(left, text="Open Logs Folder", command=self._open_logs_folder,
  291.                   bg="#3c3f41", fg="#FFFFFF", relief="flat").pack(anchor="w", pady=(4, 0))
  292.  
  293.         # Plugin setup
  294.         api_plugins = [p for p in self.plugins if p.group == "api"]
  295.         chrome_plugins = [p for p in self.plugins if p.group == "chrome"]
  296.         for p in api_plugins + chrome_plugins:
  297.             target_box = chrome_group if p.group == "chrome" else api_group
  298.             self._add_plugin_row(p, target_box)
  299.  
  300.     # ==========================================
  301.     # Helper methods
  302.     # ==========================================
  303.     def _browse_accounts_file(self):
  304.         path = filedialog.askopenfilename(title="Select Accounts File",
  305.                                           filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
  306.         if path:
  307.             self.accounts_file = path
  308.             self.accounts_file_label.config(text=f"Loaded from: {path}")
  309.             self.btn_clear_accounts.pack(side="right")
  310.  
  311.     def _clear_accounts_file(self):
  312.         self.accounts_file = None
  313.         self.accounts_file_label.config(text="")
  314.         self.btn_clear_accounts.pack_forget()
  315.  
  316.     def _open_logs_folder(self):
  317.         try:
  318.             os.startfile(LOGS_DIR)
  319.         except Exception as e:
  320.             messagebox.showerror("Error", str(e))
  321.  
  322.     def _add_plugin_row(self, plugin, parent_box):
  323.         """Add a plugin row — supports always-enabled (no_checkbox) plugins."""
  324.         if getattr(plugin, "no_checkbox", False):
  325.             try:
  326.                 ui = plugin.build_ui(parent_box) or {}
  327.                 self.plugin_ui[plugin] = ui
  328.                 self.enabled_vars[plugin] = tk.BooleanVar(value=True)
  329.                 plugin._var = self.enabled_vars[plugin]
  330.             except Exception as e:
  331.                 self._log_console(f"[PLUGIN][ERROR] build_ui {plugin.name}: {e}")
  332.             return
  333.  
  334.         row = tk.Frame(parent_box, bg="#2b2b2b")
  335.         row.pack(fill=tk.X, pady=2)
  336.         var = tk.BooleanVar(value=False)
  337.         chk = tk.Checkbutton(
  338.             row, text=plugin.name, variable=var, bg="#2b2b2b",
  339.             fg="#FFFFFF", selectcolor="#3c3f41", activebackground="#3c3f41",
  340.             activeforeground="#FFFFFF", relief="flat"
  341.         )
  342.         chk.pack(side=tk.LEFT, anchor="w")
  343.         self.enabled_vars[plugin] = var
  344.         plugin._var = var
  345.  
  346.         try:
  347.             ui = plugin.build_ui(row) or {}
  348.         except Exception:
  349.             ui = {}
  350.         self.plugin_ui[plugin] = ui
  351.  
  352.         # Disable inputs initially until checkbox checked
  353.         for v in ui.values():
  354.             try:
  355.                 if hasattr(v, "config"):
  356.                     v.config(state=tk.DISABLED)
  357.             except Exception:
  358.                 pass
  359.  
  360.         def _toggle():
  361.             state = tk.NORMAL if var.get() else tk.DISABLED
  362.             for v in ui.values():
  363.                 try:
  364.                     if hasattr(v, "config"):
  365.                         v.config(state=state)
  366.                 except Exception:
  367.                     pass
  368.  
  369.         chk.config(command=_toggle)
  370.  
  371.     def _log_console(self, msg): print(msg)
  372.  
  373.     def log(self, msg):
  374.         tag = None
  375.         if "[ERROR]" in msg:
  376.             tag = "error"
  377.         elif "[WARN]" in msg:
  378.             tag = "warn"
  379.         elif "[PLUGIN]" in msg:
  380.             tag = "plugin"
  381.         elif "[SESSION]" in msg:
  382.             tag = "session"
  383.         elif "[INPUT]" in msg:
  384.             tag = "input"
  385.         elif "[BATCH]" in msg:
  386.             tag = "batch"
  387.         msg = msg.rstrip("\n") + "\n"
  388.         self.log_box.insert(tk.END, msg, tag)
  389.         self.log_box.see(tk.END)
  390.  
  391.     def log_threadsafe(self, msg):
  392.         self.after(0, lambda: self.log(msg))
  393.  
  394.     def clear_log(self):
  395.         self.log_box.delete("1.0", tk.END)
  396.  
  397.     # ==========================================
  398.     # Processing Logic
  399.     # ==========================================
  400.     def on_start(self):
  401.         self.start_btn.config(state=tk.DISABLED)
  402.         threading.Thread(target=self._run_processing_parallel_worker, daemon=True).start()
  403.  
  404.     def _run_processing_parallel_worker(self):
  405.         try:
  406.             self.run_processing_parallel()
  407.         finally:
  408.             self.after(0, lambda: self.start_btn.config(state=tk.NORMAL))
  409.  
  410.     def run_processing_parallel(self):
  411.         if self.accounts_file:
  412.             try:
  413.                 with open(self.accounts_file, "r", encoding="utf-8") as f:
  414.                     lines = [l.strip() for l in f if l.strip()]
  415.             except Exception as e:
  416.                 self.log(f"[ERROR] Cannot read accounts file: {e}")
  417.                 return
  418.         else:
  419.             lines = [l.strip() for l in self.accounts_box.get("1.0", tk.END).splitlines() if l.strip()]
  420.  
  421.         if not lines:
  422.             self.log("[INPUT] No accounts provided.")
  423.             return
  424.  
  425.         # parse "email;proxy"
  426.         parsed_accounts = []
  427.         for l in lines:
  428.             if ";" in l:
  429.                 email, proxy = [x.strip() for x in l.split(";", 1)]
  430.             else:
  431.                 email, proxy = l, None
  432.             parsed_accounts.append((email, proxy))
  433.  
  434.         self.log(f"[INPUT] Loaded {len(parsed_accounts)} account(s).")
  435.  
  436.         enabled = [p for p in self.plugins if self.enabled_vars.get(p) and self.enabled_vars[p].get()]
  437.         if not enabled:
  438.             self.log("[PLUGIN] No actions selected.")
  439.             return
  440.  
  441.         try:
  442.             max_concurrent = max(1, int(self.concurrent_var.get()))
  443.         except Exception:
  444.             max_concurrent = 1
  445.  
  446.         self.log(f"[BATCH] Running up to {max_concurrent} accounts in parallel.")
  447.         with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
  448.             log_fn = logger.get_logger(gui_callback=self.log_threadsafe, max_lines=5000)
  449.             futures = [executor.submit(self._process_one_account, email, proxy, enabled, log_fn)
  450.                        for email, proxy in parsed_accounts]
  451.             for f in as_completed(futures):
  452.                 try:
  453.                     f.result()
  454.                 except Exception as e:
  455.                     self.log(f"[THREAD][ERROR] {e}")
  456.  
  457.         self.log(f"\n=== Finished processing {len(parsed_accounts)} account(s) ===")
  458.         self.after(0, lambda: messagebox.showinfo("All Done!", f"✅ Finished processing {len(parsed_accounts)} account(s)."))
  459.  
  460.     # Fixed logic: pass proxy into Chrome + all API helpers
  461.     def _process_one_account(self, email, proxy, plugins, log_fn):
  462.         log_fn(f"--- Processing: {email} ---")
  463.         requires_chrome = any(getattr(p, "group", "") == "chrome" for p in plugins)
  464.         sess = None
  465.         creds = None
  466.         new_login = False
  467.  
  468.         # Start Chrome session (with per-account proxy)
  469.         if requires_chrome:
  470.             sess = start_chrome_session(email, log_fn=log_fn, proxy=proxy)
  471.             if not sess:
  472.                 log_fn("[SESSION][FATAL] could not start chrome for account.")
  473.                 return
  474.         else:
  475.             log_fn(f"[SESSION] Chrome not required for {email}.")
  476.  
  477.         # Load tokens or request OAuth (pass proxy)
  478.         try:
  479.             creds = load_credentials_for(email, log_fn, proxy=proxy)
  480.         except Exception as e:
  481.             log_fn(f"[TOKEN] missing/invalid: {e}")
  482.             if requires_chrome and sess:
  483.                 try:
  484.                     creds, _ = oauth_first_login_in_session(email, sess, log_fn, also_open_gmail_ui=True, proxy=proxy)
  485.                     new_login = True
  486.                     # Auto-fill the Google email field on first login
  487.                     try:
  488.                         autofill_google_login_email(sess.ws_url, email, log_fn)
  489.                     except Exception:
  490.                         pass
  491.                    
  492.                 except Exception as e2:
  493.                     log_fn(f"[OAUTH][FATAL] {e2}")
  494.                     close_chrome_session(sess, log_fn)
  495.                     return
  496.             else:
  497.                 return
  498.  
  499.         # If a new login occurred, open Gmail inbox inside the same Chrome session
  500.         if new_login and sess and sess.ws_url:
  501.             try:
  502.                 log_fn("[UI] Opening Gmail inbox after new OAuth login...")
  503.                 cdp_navigate(sess.ws_url, "https://mail.google.com/mail/u/0/#inbox",
  504.                              wait_load=True, timeout=8, log_fn=log_fn)
  505.             except Exception as e:
  506.                 log_fn(f"[UI][WARN] Failed to open inbox automatically: {e}")
  507.  
  508.             # Save cookies right after a new OAuth login / inbox open
  509.             try:
  510.                 save_gmail_cookies(sess, email, log_fn=log_fn)
  511.             except Exception:
  512.                 pass
  513.  
  514.         # Build Gmail and People services (inject proxy)
  515.         try:
  516.             gmail_service = build_gmail_service(creds, proxy=proxy)
  517.         except Exception as e:
  518.             log_fn(f"[GMAIL][ERROR] service build: {e}")
  519.             if sess:
  520.                 close_chrome_session(sess, log_fn)
  521.             return
  522.  
  523.         try:
  524.             people_service = build_people_service(creds, proxy=proxy)
  525.         except Exception:
  526.             people_service = None
  527.  
  528.         # Sync search term from no_checkbox plugins (SearchFilterPlugin) before running plugins
  529.         if hasattr(self, "plugin_ui"):
  530.             for p, ui in self.plugin_ui.items():
  531.                 if getattr(p, "no_checkbox", False) and "entry" in ui:
  532.                     try:
  533.                         val = ui["entry"].get().strip()
  534.                         if val:
  535.                             self.shared_search_term = val
  536.                     except Exception as e:
  537.                         log_fn(f"[WARN] Unable to sync search term: {e}")
  538.  
  539.         # Run plugins
  540.         keep_open = False
  541.         for p in plugins:
  542.             try:
  543.                 raw_search = getattr(self, "shared_search_term", "").strip()
  544.                 search_terms = [s.strip() for s in raw_search.split(",") if s.strip()]
  545.                 ctx = {
  546.                     "email": email,
  547.                     "log": log_fn,
  548.                     "session": sess,
  549.                     "service": gmail_service,
  550.                     "people_service": people_service,
  551.                     "ui": self.plugin_ui.get(p, {}),
  552.                     "app": self,
  553.                     "raw_search": raw_search,
  554.                     "search_terms": search_terms,
  555.                 }
  556.                 p.run(ctx)
  557.                 keep_open = keep_open or getattr(p, "keep_open_after_run", False)
  558.             except Exception as e:
  559.                 log_fn(f"[ERROR] Plugin {p.name} failed: {e}")
  560.  
  561.         # Save cookies after plugins run (keeps cookies fresh)
  562.         try:
  563.             save_gmail_cookies(sess, email, log_fn=log_fn)
  564.         except Exception:
  565.             pass
  566.  
  567.         # Chrome cleanup
  568.         if sess:
  569.             if keep_open:
  570.                 log_fn(f"[SESSION] kept open for {email}")
  571.             else:
  572.                 close_chrome_session(sess, log_fn)
  573.                 log_fn(f"[SESSION] closed for {email}")
  574.  
  575.         log_fn(f"--- Done: {email} ---\n")
  576.  
  577.  
  578. # ==========================================
  579. # Entry point
  580. # ==========================================
  581. if __name__ == "__main__":
  582.     try:
  583.         app = GmailHybridApp()
  584.         app.mainloop()
  585.     except Exception as e:
  586.         print("Fatal error:", e)
  587.         sys.exit(1)
  588.  
Advertisement
Add Comment
Please, Sign In to add comment