Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- gmail_hybrid_manager.py — launcher / GUI (plugin loader)
- Now starts Chrome **only when required** by a plugin.
- ✅ Supports per-account proxy:
- [email protected];IP:PORT
- All API and OAuth calls route through the same per-account proxy.
- """
- import os
- import sys
- import time
- import threading
- from pathlib import Path
- from concurrent.futures import ThreadPoolExecutor, as_completed
- import tkinter as tk
- from tkinter import ttk, scrolledtext, messagebox, filedialog
- from core.chrome import autofill_google_login_email
- # ==========================================
- # Base directory setup (works in .exe and .py)
- # ==========================================
- if getattr(sys, "frozen", False):
- BASE_DIR = Path(sys.executable).resolve().parent
- else:
- BASE_DIR = Path(__file__).resolve().parent
- LOGS_DIR = BASE_DIR / "logs"
- EMAILS_DIR = BASE_DIR / "emails"
- CREDENTIALS_PATH = BASE_DIR / "credentials.json"
- LOGS_DIR.mkdir(exist_ok=True)
- EMAILS_DIR.mkdir(exist_ok=True)
- sys.path.insert(0, str(BASE_DIR))
- sys.path.insert(0, str(BASE_DIR / "core"))
- sys.path.insert(0, str(BASE_DIR / "plugins"))
- # ==========================================
- # Imports
- # ==========================================
- from core import logger
- from core.config import APP_VERSION, ensure_master_extracted, ensure_tokens_dir, PROFILES_DIR
- from core.chrome import start_chrome_session, close_chrome_session, cdp_navigate
- from core.gmail_api import (
- load_credentials_for,
- oauth_first_login_in_session,
- build_gmail_service,
- build_people_service,
- search_messages,
- )
- from plugins import discover_plugins
- # websocket used for cookie extraction; imported lazily/cautiously below
- try:
- import websocket
- except Exception:
- websocket = None
- import json as _json_lib
- # ----------------------------
- # Small helpers (cookie save)
- # ----------------------------
- def _safe_email_token_for_path(email: str) -> str:
- # Keep the same token rules you used in chrome.py/profile naming:
- token = "".join(c for c in email.lower() if c.isalnum() or c in ("@", ".", "_", "-"))
- token = token.replace("@", "_at_").replace(".", "_")
- return token
- def save_gmail_cookies(sess, email: str, log_fn=print) -> bool:
- """
- Connect to Chrome DevTools WebSocket for the session and request Network.getAllCookies,
- then write the cookies to the profile directory as 'saved_cookies.json'.
- This is a lightweight snapshot used to restore or inspect cookies later (not an import/export
- into Chrome). We intentionally keep this minimal and robust.
- """
- if not sess or not getattr(sess, "ws_url", None):
- log_fn("[COOKIE] no session / ws_url; skipping cookie save.")
- return False
- if websocket is None:
- log_fn("[COOKIE][WARN] websocket-client not available; cannot save cookies.")
- return False
- try:
- # Connect to the session's WebSocket endpoint
- ws = websocket.create_connection(sess.ws_url, timeout=6)
- except Exception as e:
- log_fn(f"[COOKIE][ERROR] ws connect failed: {e}")
- return False
- try:
- _id = int(time.time() * 1000) % 1000000
- def send(method, params=None):
- nonlocal _id
- _id += 1
- payload = {"id": _id, "method": method}
- if params:
- payload["params"] = params
- try:
- ws.send(_json_lib.dumps(payload))
- except Exception as e:
- raise RuntimeError(f"ws.send failed: {e}")
- return _id
- # Enable Network domain, then request all cookies
- send("Network.enable")
- req_id = send("Network.getAllCookies")
- cookies = None
- deadline = time.time() + 2.0 # short wait
- while time.time() < deadline:
- try:
- raw = ws.recv()
- except Exception:
- break
- try:
- m = _json_lib.loads(raw)
- except Exception:
- continue
- if m.get("id") == req_id and "result" in m:
- cookies = m["result"].get("cookies", [])
- break
- try:
- ws.close()
- except Exception:
- pass
- if cookies is None:
- log_fn("[COOKIE] no cookies received (empty or timed out).")
- return False
- # Write cookies to profile folder
- token = _safe_email_token_for_path(email)
- prof_dir = Path(PROFILES_DIR) / token
- prof_dir.mkdir(parents=True, exist_ok=True)
- out_file = prof_dir / "saved_cookies.json"
- try:
- with open(out_file, "w", encoding="utf-8") as fh:
- _json_lib.dump(cookies, fh, ensure_ascii=False, indent=2)
- log_fn(f"[COOKIE] saved {len(cookies)} cookies to {out_file}")
- return True
- except Exception as e:
- log_fn(f"[COOKIE][ERROR] writing cookies: {e}")
- return False
- except Exception as e:
- log_fn(f"[COOKIE][ERROR] failed to fetch cookies: {e}")
- try:
- ws.close()
- except Exception:
- pass
- return False
- # ==========================================
- # Main GUI
- # ==========================================
- class GmailHybridApp(tk.Tk):
- def __init__(self):
- super().__init__()
- self.title(f"Gmail Hybrid Manager — v{APP_VERSION}")
- self.geometry("1100x780")
- self.configure(bg="#2b2b2b")
- ensure_tokens_dir()
- self.accounts_file = None
- self.plugins = discover_plugins()
- self.enabled_vars = {}
- self.plugin_ui = {}
- self.shared_search_term = ""
- self._setup_styles()
- self._create_widgets()
- if not ensure_master_extracted(self._log_console):
- messagebox.showerror(
- "Chrome master not found",
- "Could not find embedded 'chrome_master'.\n\n"
- "Dev mode: put a 'chrome_master' folder next to this .py/.exe.\n"
- 'Build mode: include with PyInstaller --add-data "chrome_master;chrome_master".',
- )
- # ==========================================
- # UI Setup
- # ==========================================
- def _setup_styles(self):
- style = ttk.Style(self)
- style.theme_use("default")
- style.configure("Vertical.TScrollbar",
- background="#3c3f41",
- troughcolor="#2b2b2b",
- arrowcolor="#FFFFFF")
- def _create_widgets(self):
- main_frame = tk.Frame(self, bg="#2b2b2b")
- main_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=6)
- # Left side (accounts + log)
- left = tk.Frame(main_frame, bg="#2b2b2b")
- left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
- ttk.Label(left, text="Gmail accounts (one per line):",
- background="#2b2b2b", foreground="#EEEEEE").pack(anchor="w")
- self.accounts_box = scrolledtext.ScrolledText(
- left, height=8, bg="#3c3f41", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat"
- )
- self.accounts_box.pack(fill=tk.X, padx=2, pady=4)
- # File row
- accounts_file_row = tk.Frame(left, bg="#2b2b2b")
- accounts_file_row.pack(fill=tk.X, pady=(2, 0))
- self.btn_browse_accounts = tk.Button(
- accounts_file_row, text="Browse…", command=self._browse_accounts_file,
- bg="#3c3f41", fg="#FFFFFF", relief="flat", padx=10, pady=2
- )
- self.btn_browse_accounts.pack(side="left")
- self.accounts_file_label = tk.Label(
- accounts_file_row, text="", bg="#2b2b2b", fg="#AAAAAA", anchor="w"
- )
- self.accounts_file_label.pack(side="left", padx=8, expand=True, fill=tk.X)
- self.btn_clear_accounts = tk.Button(
- accounts_file_row, text="Clear file", command=self._clear_accounts_file,
- bg="#3c3f41", fg="#FFFFFF", relief="flat", padx=10, pady=2
- )
- self.btn_clear_accounts.pack_forget()
- # Right side (actions)
- right = tk.Frame(main_frame, bg="#2b2b2b")
- right.pack(side=tk.RIGHT, fill=tk.Y, padx=6)
- chrome_group = tk.LabelFrame(
- right, text="Chrome actions", bg="#2b2b2b", fg="#EEEEEE", padx=8, pady=8, relief="groove", bd=2
- )
- chrome_group.pack(fill=tk.X, pady=(0, 10))
- api_group = tk.LabelFrame(
- right, text="API actions", bg="#2b2b2b", fg="#EEEEEE", padx=8, pady=8, relief="groove", bd=2
- )
- api_group.pack(fill=tk.X)
- # Concurrency section
- cc_frame = tk.LabelFrame(
- right, text="Concurrency", bg="#2b2b2b", fg="#EEEEEE", padx=8, pady=8, relief="groove", bd=2
- )
- cc_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=(10, 0))
- cc_row = tk.Frame(cc_frame, bg="#2b2b2b")
- cc_row.pack(fill=tk.X, pady=(0, 2))
- tk.Label(cc_row, text="Max concurrent Chrome sessions:",
- bg="#2b2b2b", fg="#FFFFFF").pack(side="left", anchor="w")
- self.concurrent_var = tk.StringVar(value="10")
- tk.Spinbox(cc_row, from_=1, to=50, textvariable=self.concurrent_var,
- width=5, bg="#3c3f41", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat").pack(side="left", padx=(8, 0))
- # Buttons
- btn = tk.Frame(left, bg="#2b2b2b")
- btn.pack(fill=tk.X, pady=6)
- self.start_btn = tk.Button(
- btn, text="Start Processing", command=self.on_start,
- bg="#3c3f41", fg="#FFFFFF", relief="flat"
- )
- self.start_btn.pack(side="left")
- tk.Button(btn, text="Clear Log", command=self.clear_log,
- bg="#3c3f41", fg="#FFFFFF", relief="flat").pack(side="left", padx=6)
- ttk.Label(left, text="Log:", background="#2b2b2b", foreground="#EEEEEE").pack(anchor="w", pady=(4, 0))
- self.log_box = scrolledtext.ScrolledText(
- left, height=18, bg="#3c3f41", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat"
- )
- self.log_box.pack(fill=tk.BOTH, expand=True, pady=4)
- # Color tags
- for name, color in {
- "error": "#FF5555", "warn": "#FFB86C", "plugin": "#8BE9FD",
- "session": "#50FA7B", "input": "#BD93F9", "batch": "#F1FA8C",
- }.items():
- self.log_box.tag_config(name, foreground=color)
- tk.Button(left, text="Open Logs Folder", command=self._open_logs_folder,
- bg="#3c3f41", fg="#FFFFFF", relief="flat").pack(anchor="w", pady=(4, 0))
- # Plugin setup
- api_plugins = [p for p in self.plugins if p.group == "api"]
- chrome_plugins = [p for p in self.plugins if p.group == "chrome"]
- for p in api_plugins + chrome_plugins:
- target_box = chrome_group if p.group == "chrome" else api_group
- self._add_plugin_row(p, target_box)
- # ==========================================
- # Helper methods
- # ==========================================
- def _browse_accounts_file(self):
- path = filedialog.askopenfilename(title="Select Accounts File",
- filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
- if path:
- self.accounts_file = path
- self.accounts_file_label.config(text=f"Loaded from: {path}")
- self.btn_clear_accounts.pack(side="right")
- def _clear_accounts_file(self):
- self.accounts_file = None
- self.accounts_file_label.config(text="")
- self.btn_clear_accounts.pack_forget()
- def _open_logs_folder(self):
- try:
- os.startfile(LOGS_DIR)
- except Exception as e:
- messagebox.showerror("Error", str(e))
- def _add_plugin_row(self, plugin, parent_box):
- """Add a plugin row — supports always-enabled (no_checkbox) plugins."""
- if getattr(plugin, "no_checkbox", False):
- try:
- ui = plugin.build_ui(parent_box) or {}
- self.plugin_ui[plugin] = ui
- self.enabled_vars[plugin] = tk.BooleanVar(value=True)
- plugin._var = self.enabled_vars[plugin]
- except Exception as e:
- self._log_console(f"[PLUGIN][ERROR] build_ui {plugin.name}: {e}")
- return
- row = tk.Frame(parent_box, bg="#2b2b2b")
- row.pack(fill=tk.X, pady=2)
- var = tk.BooleanVar(value=False)
- chk = tk.Checkbutton(
- row, text=plugin.name, variable=var, bg="#2b2b2b",
- fg="#FFFFFF", selectcolor="#3c3f41", activebackground="#3c3f41",
- activeforeground="#FFFFFF", relief="flat"
- )
- chk.pack(side=tk.LEFT, anchor="w")
- self.enabled_vars[plugin] = var
- plugin._var = var
- try:
- ui = plugin.build_ui(row) or {}
- except Exception:
- ui = {}
- self.plugin_ui[plugin] = ui
- # Disable inputs initially until checkbox checked
- for v in ui.values():
- try:
- if hasattr(v, "config"):
- v.config(state=tk.DISABLED)
- except Exception:
- pass
- def _toggle():
- state = tk.NORMAL if var.get() else tk.DISABLED
- for v in ui.values():
- try:
- if hasattr(v, "config"):
- v.config(state=state)
- except Exception:
- pass
- chk.config(command=_toggle)
- def _log_console(self, msg): print(msg)
- def log(self, msg):
- tag = None
- if "[ERROR]" in msg:
- tag = "error"
- elif "[WARN]" in msg:
- tag = "warn"
- elif "[PLUGIN]" in msg:
- tag = "plugin"
- elif "[SESSION]" in msg:
- tag = "session"
- elif "[INPUT]" in msg:
- tag = "input"
- elif "[BATCH]" in msg:
- tag = "batch"
- msg = msg.rstrip("\n") + "\n"
- self.log_box.insert(tk.END, msg, tag)
- self.log_box.see(tk.END)
- def log_threadsafe(self, msg):
- self.after(0, lambda: self.log(msg))
- def clear_log(self):
- self.log_box.delete("1.0", tk.END)
- # ==========================================
- # Processing Logic
- # ==========================================
- def on_start(self):
- self.start_btn.config(state=tk.DISABLED)
- threading.Thread(target=self._run_processing_parallel_worker, daemon=True).start()
- def _run_processing_parallel_worker(self):
- try:
- self.run_processing_parallel()
- finally:
- self.after(0, lambda: self.start_btn.config(state=tk.NORMAL))
- def run_processing_parallel(self):
- if self.accounts_file:
- try:
- with open(self.accounts_file, "r", encoding="utf-8") as f:
- lines = [l.strip() for l in f if l.strip()]
- except Exception as e:
- self.log(f"[ERROR] Cannot read accounts file: {e}")
- return
- else:
- lines = [l.strip() for l in self.accounts_box.get("1.0", tk.END).splitlines() if l.strip()]
- if not lines:
- self.log("[INPUT] No accounts provided.")
- return
- # parse "email;proxy"
- parsed_accounts = []
- for l in lines:
- if ";" in l:
- email, proxy = [x.strip() for x in l.split(";", 1)]
- else:
- email, proxy = l, None
- parsed_accounts.append((email, proxy))
- self.log(f"[INPUT] Loaded {len(parsed_accounts)} account(s).")
- enabled = [p for p in self.plugins if self.enabled_vars.get(p) and self.enabled_vars[p].get()]
- if not enabled:
- self.log("[PLUGIN] No actions selected.")
- return
- try:
- max_concurrent = max(1, int(self.concurrent_var.get()))
- except Exception:
- max_concurrent = 1
- self.log(f"[BATCH] Running up to {max_concurrent} accounts in parallel.")
- with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
- log_fn = logger.get_logger(gui_callback=self.log_threadsafe, max_lines=5000)
- futures = [executor.submit(self._process_one_account, email, proxy, enabled, log_fn)
- for email, proxy in parsed_accounts]
- for f in as_completed(futures):
- try:
- f.result()
- except Exception as e:
- self.log(f"[THREAD][ERROR] {e}")
- self.log(f"\n=== Finished processing {len(parsed_accounts)} account(s) ===")
- self.after(0, lambda: messagebox.showinfo("All Done!", f"✅ Finished processing {len(parsed_accounts)} account(s)."))
- # Fixed logic: pass proxy into Chrome + all API helpers
- def _process_one_account(self, email, proxy, plugins, log_fn):
- log_fn(f"--- Processing: {email} ---")
- requires_chrome = any(getattr(p, "group", "") == "chrome" for p in plugins)
- sess = None
- creds = None
- new_login = False
- # Start Chrome session (with per-account proxy)
- if requires_chrome:
- sess = start_chrome_session(email, log_fn=log_fn, proxy=proxy)
- if not sess:
- log_fn("[SESSION][FATAL] could not start chrome for account.")
- return
- else:
- log_fn(f"[SESSION] Chrome not required for {email}.")
- # Load tokens or request OAuth (pass proxy)
- try:
- creds = load_credentials_for(email, log_fn, proxy=proxy)
- except Exception as e:
- log_fn(f"[TOKEN] missing/invalid: {e}")
- if requires_chrome and sess:
- try:
- creds, _ = oauth_first_login_in_session(email, sess, log_fn, also_open_gmail_ui=True, proxy=proxy)
- new_login = True
- # Auto-fill the Google email field on first login
- try:
- autofill_google_login_email(sess.ws_url, email, log_fn)
- except Exception:
- pass
- except Exception as e2:
- log_fn(f"[OAUTH][FATAL] {e2}")
- close_chrome_session(sess, log_fn)
- return
- else:
- return
- # If a new login occurred, open Gmail inbox inside the same Chrome session
- if new_login and sess and sess.ws_url:
- try:
- log_fn("[UI] Opening Gmail inbox after new OAuth login...")
- cdp_navigate(sess.ws_url, "https://mail.google.com/mail/u/0/#inbox",
- wait_load=True, timeout=8, log_fn=log_fn)
- except Exception as e:
- log_fn(f"[UI][WARN] Failed to open inbox automatically: {e}")
- # Save cookies right after a new OAuth login / inbox open
- try:
- save_gmail_cookies(sess, email, log_fn=log_fn)
- except Exception:
- pass
- # Build Gmail and People services (inject proxy)
- try:
- gmail_service = build_gmail_service(creds, proxy=proxy)
- except Exception as e:
- log_fn(f"[GMAIL][ERROR] service build: {e}")
- if sess:
- close_chrome_session(sess, log_fn)
- return
- try:
- people_service = build_people_service(creds, proxy=proxy)
- except Exception:
- people_service = None
- # Sync search term from no_checkbox plugins (SearchFilterPlugin) before running plugins
- if hasattr(self, "plugin_ui"):
- for p, ui in self.plugin_ui.items():
- if getattr(p, "no_checkbox", False) and "entry" in ui:
- try:
- val = ui["entry"].get().strip()
- if val:
- self.shared_search_term = val
- except Exception as e:
- log_fn(f"[WARN] Unable to sync search term: {e}")
- # Run plugins
- keep_open = False
- for p in plugins:
- try:
- raw_search = getattr(self, "shared_search_term", "").strip()
- search_terms = [s.strip() for s in raw_search.split(",") if s.strip()]
- ctx = {
- "email": email,
- "log": log_fn,
- "session": sess,
- "service": gmail_service,
- "people_service": people_service,
- "ui": self.plugin_ui.get(p, {}),
- "app": self,
- "raw_search": raw_search,
- "search_terms": search_terms,
- }
- p.run(ctx)
- keep_open = keep_open or getattr(p, "keep_open_after_run", False)
- except Exception as e:
- log_fn(f"[ERROR] Plugin {p.name} failed: {e}")
- # Save cookies after plugins run (keeps cookies fresh)
- try:
- save_gmail_cookies(sess, email, log_fn=log_fn)
- except Exception:
- pass
- # Chrome cleanup
- if sess:
- if keep_open:
- log_fn(f"[SESSION] kept open for {email}")
- else:
- close_chrome_session(sess, log_fn)
- log_fn(f"[SESSION] closed for {email}")
- log_fn(f"--- Done: {email} ---\n")
- # ==========================================
- # Entry point
- # ==========================================
- if __name__ == "__main__":
- try:
- app = GmailHybridApp()
- app.mainloop()
- except Exception as e:
- print("Fatal error:", e)
- sys.exit(1)
Advertisement
Add Comment
Please, Sign In to add comment