#!/usr/bin/env python3 """ Gmail Email Auditor v2.6 SPEEDSTER for Domovoy Bot. 1. Fast header-only scan to find matching UIDs. 2. Full download only for matches (with attachments). 3. Merges all PDFs into a master archive for NotebookLM. 4. Updates domovoy.db and notifies via Telegram. """ import os import sys import re import time import logging import sqlite3 import tempfile import subprocess from pathlib import Path from datetime import datetime, date, timedelta, timezone import requests from imap_tools import MailBox, AND from weasyprint import HTML import fitz # PyMuPDF from PIL import Image from bs4 import BeautifulSoup from dotenv import load_dotenv # === CONFIG & PATHS === BASE_DIR = Path(__file__).resolve().parent PROJECT_ROOT = BASE_DIR.parent.parent load_dotenv(PROJECT_ROOT / ".env") DB_PATH = PROJECT_ROOT / "database" / "domovoy.db" MUTT_CONF = BASE_DIR / ".muttrc" EXPORT_DIR = BASE_DIR / "exported_emails" ALL_FOLDER = EXPORT_DIR / "all" MASTER_PDF = EXPORT_DIR / "TOTAL_ARCHIVE_2025-2026.pdf" PROGRESS_FILE = BASE_DIR / ".export_progress" IMAP_SERVER = "imap.gmail.com" SINCE_DATE = date(2025, 12, 20) DOMAINS = [ "gosuslugi.ru", "dom.gosuslugi.ru", "gov.ru", "duma.gov.ru", "mvd.gov.ru", "mvd.ru", "rkn.gov.ru", "nalog.gov.ru", "nalog.ru", "genproc.gov.ru", "pfr.gov.ru", "fss.ru", "rospotrebnadzor.ru", "russianpost.ru", "mailop.ru", "73.mailop.ru", "uk-service.ru", "ulgss.ru", "ulgov.ru", "ulgkh.ru", "dgi.ru", "mos.ru", "pfrf.ru", "fsin.gov.ru", "skrf.ru", "epp.genproc.gov.ru", "ugpr.ru", "fas.gov.ru", "minstroyrf.gov.ru", "gkh.ru", "sudrf.ru", "msud.ru", "arbitr.ru", "fssp.gov.ru", "fssprus.ru", "mirsud", "msud73.ru", "mirsud73.ru", "court.ru", "sud.ru", "vkks.ru" ] KEYWORDS = ["суд", "судье", "судебн", "дело №", "иск", "заседание"] # Bot notification config BOT_TOKEN = os.getenv("BOT_TOKEN") ADMIN_USER_ID = os.getenv("ADMIN_USER_ID") # Logging configuration with Ulyanovsk timezone (UTC+4) ULY_TZ_OFFSET = 4 * 3600 class UlyanovskFormatter(logging.Formatter): def converter(self, timestamp): return datetime.fromtimestamp(timestamp, tz=timezone(timedelta(hours=4))).timetuple() def formatTime(self, record, datefmt=None): dt = datetime.fromtimestamp(record.created, tz=timezone(timedelta(hours=4))) return dt.strftime(datefmt) if datefmt else dt.isoformat() log_formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s') # We'll use a simpler way - just set the converter for the formatter log_formatter.converter = lambda *args: (datetime.now(timezone(timedelta(hours=4))).timetuple()) logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler(BASE_DIR / "auditor_run.log", encoding="utf-8"), logging.StreamHandler(sys.stdout), ], ) # Force all handlers to use Ulyanovsk time in their timestamps for handler in logging.root.handlers: handler.formatter.converter = lambda *args: (datetime.now(timezone(timedelta(hours=4))).timetuple()) log = logging.getLogger("EmailAuditor") # === UTILS === def parse_mutt_config(): config = {} if not MUTT_CONF.exists(): return config with open(MUTT_CONF) as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue match = re.match(r'set\s+(\w+)\s*=\s*"([^"]*)"', line) if not match: match = re.match(r'set\s+(\w+)\s*=\s*(\S+)', line) if match: config[match.group(1)] = match.group(2) return config def sanitize_filename(name): name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', name) name = re.sub(r'\s+', ' ', name).strip() return name[:80] def send_bot_message(text): if not BOT_TOKEN or not ADMIN_USER_ID: return url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" try: proxies = None if os.getenv("USE_PROXY", "").lower() == "true": proxy_url = f"socks5://{os.getenv('PROXY_HOST', '127.0.0.1')}:{os.getenv('PROXY_PORT', '10808')}" proxies = {"http": proxy_url, "https": proxy_url} requests.post(url, json={"chat_id": ADMIN_USER_ID, "text": text, "parse_mode": "HTML"}, proxies=proxies, timeout=10) except Exception as e: log.error(f"Failed to send Telegram notification: {e}") # === PDF GENERATION === def html_to_pdf_stream(html_content): try: return HTML(string=html_content).write_pdf() except Exception as e: log.warning(f"weasyprint failed, using fpdf fallback: {e}") return html_to_pdf_fpdf(html_content) def html_to_pdf_fpdf(html_content): from fpdf import FPDF soup = BeautifulSoup(html_content, 'lxml') text = soup.get_text(separator='\n') pdf = FPDF() pdf.add_page() font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" if not os.path.exists(font_path): font_path = "/usr/share/fonts/dejavu/DejaVuSans.ttf" if os.path.exists(font_path): pdf.add_font('DejaVu', '', font_path) pdf.set_font('DejaVu', '', 10) else: pdf.set_font('helvetica', '', 10) for line in [l for l in text.split('\n') if l.strip()]: try: pdf.multi_cell(0, 6, line) pdf.ln(1) except: pdf.multi_cell(0, 6, line.encode('latin-1', 'replace').decode('latin-1')) pdf.ln(1) return pdf.output() def get_image_dims(data): import io img = Image.open(io.BytesIO(data)) return img.size[0] * 25.4 / 96, img.size[1] * 25.4 / 96 def convert_attachment_to_pdf(data, filename, content_type): from fpdf import FPDF ext = Path(filename).suffix.lower() try: if ext in ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg'): pdf = FPDF(); pdf.add_page() img_w, img_h = get_image_dims(data) scale = min((pdf.w - 20) / img_w, (pdf.h - 20) / img_h, 1.0) with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: tmp.write(data); tmp_path = tmp.name try: pdf.image(tmp_path, x=10, y=10, w=img_w * scale, h=img_h * scale) finally: os.unlink(tmp_path) return pdf.output() elif ext in ('.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.odt', '.ods'): with tempfile.TemporaryDirectory() as tmpdir: src = Path(tmpdir) / filename src.write_bytes(data) subprocess.run(['libreoffice', '--headless', '--convert-to', 'pdf', '--outdir', tmpdir, str(src)], capture_output=True, timeout=120) pdf_path = src.with_suffix('.pdf') if pdf_path.exists(): return pdf_path.read_bytes() elif ext == '.pdf': return data elif ext in ('.txt', '.csv', '.log', '.xml', '.html', '.htm', '.json'): try: text = data.decode('utf-8', errors='replace') except: text = data.decode('latin-1', errors='replace') return html_to_pdf_fpdf(text) return None except Exception as e: log.warning(f"Failed to convert {filename}: {e}") return None def merge_pdfs(pdf_bytes_list): if not pdf_bytes_list: return None if len(pdf_bytes_list) == 1: return pdf_bytes_list[0] merger = fitz.open() for pdf_bytes in pdf_bytes_list: try: merger.insert_pdf(fitz.open(stream=pdf_bytes, filetype="pdf")) except Exception as e: log.warning(f"Merge error: {e}") output = merger.tobytes(); merger.close() return output def build_email_html(msg): subject = msg.subject or "(Без темы)" from_addr = msg.from_ or "unknown" to_addr = ", ".join(msg.to) if msg.to else "unknown" date_str = msg.date_str or str(msg.date) if msg.date else "unknown" body_html = msg.html or (f"
{msg.text}
" if msg.text else "

(Нет тела письма)

") return f"

{subject}

От: {from_addr}
Кому: {to_addr}
Дата: {date_str}

{body_html}
" # === CORE LOGIC === def run_export(): mutt_cfg = parse_mutt_config() user, password = mutt_cfg.get('imap_user'), mutt_cfg.get('imap_pass') if not user or not password: return EXPORT_DIR.mkdir(exist_ok=True); ALL_FOLDER.mkdir(exist_ok=True) log_id = None try: conn = sqlite3.connect(DB_PATH); cur = conn.cursor() cur.execute("INSERT INTO email_export_logs (status, start_time) VALUES (?, CURRENT_TIMESTAMP)", ('running',)) log_id = cur.lastrowid; conn.commit(); conn.close() except Exception as e: log.error(f"DB Log Error: {e}") processed_uids = set() if PROGRESS_FILE.exists(): processed_uids = set(PROGRESS_FILE.read_text().splitlines()) new_emails_info = [] try: with MailBox(IMAP_SERVER).login(user, password) as mailbox: all_mail_folder = "[Gmail]/Вся почта" for folder in mailbox.folder.list(): if r'\All' in folder.flags: all_mail_folder = folder.name; break mailbox.folder.set(all_mail_folder) log.info(f"Phase 1: Scanning {all_mail_folder} headers...") since_str = SINCE_DATE.strftime("%d-%b-%Y") search_criteria = f'(SINCE "{since_str}")' matching_uids = [] for msg in mailbox.fetch(search_criteria, headers_only=True, mark_seen=False): if str(msg.uid) in processed_uids: continue from_addr = (msg.from_ or "").lower() to_addrs = [t.lower() for t in msg.to] if msg.to else [] cc_addrs = [c.lower() for c in msg.cc] if msg.cc else [] subject_lower = (msg.subject or "").lower() all_targets = [from_addr, subject_lower] + to_addrs + cc_addrs found_domain = None for d in DOMAINS: if any(d in target for target in all_targets): found_domain = d break # Check keywords if domain not found if not found_domain: for kw in KEYWORDS: if kw.lower() in subject_lower: found_domain = "court_manual" break if not found_domain: continue if not matching_uids: log.info("No new matching emails found."); else: log.info(f"Phase 2: Downloading {len(matching_uids)} full emails...") for uid, domain in matching_uids: try: full_msgs = list(mailbox.fetch(AND(uid=uid), mark_seen=False)) if not full_msgs: continue msg = full_msgs[0] pdf_parts = [html_to_pdf_stream(build_email_html(msg))] if msg.attachments: for att in msg.attachments: att_pdf = convert_attachment_to_pdf(att.payload, att.filename, att.content_type) if att_pdf: pdf_parts.append(att_pdf) merged_pdf = merge_pdfs(pdf_parts) if merged_pdf: msg_date = msg.date.strftime("%Y-%m-%d") filename = f"{msg_date}_{domain}_{sanitize_filename(msg.subject)}.pdf" (ALL_FOLDER / filename).write_bytes(merged_pdf) new_emails_info.append({'domain': domain, 'date': msg_date, 'inbound': user.lower() in [t.lower() for t in msg.to] if msg.to else True, 'subject': msg.subject}) processed_uids.add(str(uid)) with open(PROGRESS_FILE, 'a') as pf: pf.write(f"{uid}\n") log.info(f"Saved: {filename}") except Exception as e: log.error(f"Error fetching UID {uid}: {e}") except Exception as e: log.error(f"IMAP Error: {e}") if log_id: conn = sqlite3.connect(DB_PATH); cur = conn.cursor() cur.execute("UPDATE email_export_logs SET status = ?, end_time = CURRENT_TIMESTAMP WHERE id = ?", ('error', log_id)) conn.commit(); conn.close() return if new_emails_info: try: conn = sqlite3.connect(DB_PATH); cur = conn.cursor() for item in new_emails_info: cur.execute("""INSERT INTO email_audit (domain, description, inbound_count, outbound_count, last_email_date, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(domain) DO UPDATE SET inbound_count = inbound_count + ?, outbound_count = outbound_count + ?, last_email_date = MAX(last_email_date, ?), updated_at = CURRENT_TIMESTAMP""", (item['domain'], item['domain'], 1 if item['inbound'] else 0, 0 if item['inbound'] else 1, item['date'], 1 if item['inbound'] else 0, 0 if item['inbound'] else 1, item['date'])) conn.commit(); conn.close() except Exception as e: log.error(f"DB Update Error: {e}") # Merge Master PDF pdf_files = sorted(list(ALL_FOLDER.glob("*.pdf"))) if pdf_files: master = fitz.open() for pdf in pdf_files: try: master.insert_pdf(fitz.open(pdf)) except Exception as e: log.error(f"Merge error {pdf.name}: {e}") master.save(MASTER_PDF); master.close() subjects = "\n".join([f"• {e['domain']}: {e['subject'][:50]}..." for e in new_emails_info[:5]]) if len(new_emails_info) > 5: subjects += f"\n... и еще {len(new_emails_info)-5} писем." send_bot_message(f"📧 Email Auditor: Обновление\n\nНайдено новых писем: {len(new_emails_info)}\n\nПоследние темы:\n{subjects}\n\nАрхив обновлен.") if log_id: try: conn = sqlite3.connect(DB_PATH); cur = conn.cursor() cur.execute("UPDATE email_export_logs SET status = ?, end_time = CURRENT_TIMESTAMP, new_emails_count = ? WHERE id = ?", ('success', len(new_emails_info), log_id)) conn.commit(); conn.close() except: pass if __name__ == "__main__": run_export()