/
AxeOn
/
ProjectAi
Обзор
Документация
Войти
/
AxeOn
/
ProjectAi
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
mail_module.py
1 764 строки
78 KB
Дмитрий
fix: add 30s socket timeout to IMAPClient.connect to prevent thread hang
25 апр 2026, 15:26
25 апр 2026, 15:26
bce86d1
Код
Авторство
О чём код?
# mail_module.py """ Модуль работы с почтой (IMAP) Функции: - Подключение к IMAP-серверу - Автоматическая классификация писем по настраиваемым правилам - Импорт дел в Контроль сроков (deadline_cases) - Создание доплат (salary_doplaty) - Регистрация отпусков (employee_vacations) - Гибкая система правил с включением/отключением категорий """ import tkinter as tk import ttkbootstrap as ttk from ttkbootstrap.constants import * from ttkbootstrap.tooltip import ToolTip from tkinter import messagebox, scrolledtext import threading import socket import imaplib import email from email.header import decode_header as _decode_header import re import logging import json import ssl from datetime import datetime, date from collections import defaultdict logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────────────────────── # УТИЛИТЫ ДЕКОДИРОВАНИЯ # ───────────────────────────────────────────────────────────────────────────── def _dec(header_val): """Декодирует заголовок письма в строку (UTF-8 / windows-1251 / latin-1).""" if not header_val: return '' parts = _decode_header(header_val) result = [] for raw, enc in parts: if isinstance(raw, bytes): result.append(raw.decode(enc or 'utf-8', errors='replace')) else: result.append(str(raw)) return ' '.join(result).strip() def _get_body(msg): """Извлекает текст письма (text/plain).""" if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == 'text/plain': payload = part.get_payload(decode=True) if payload: cs = part.get_content_charset() or 'utf-8' return payload.decode(cs, errors='replace') else: payload = msg.get_payload(decode=True) if payload: cs = msg.get_content_charset() or 'utf-8' return payload.decode(cs, errors='replace') return '' def _extract_urls(text): """Извлекает ссылки на облачные хранилища из текста.""" return re.findall( r'https?://(?:disk\.yandex\.ru|cloud\.mail\.ru|drive\.google\.com|disk\.reso\.ru)\S+', text ) def _parse_date(date_str): """Парсит заголовок Date: → datetime или None.""" try: from email.utils import parsedate_to_datetime return parsedate_to_datetime(date_str) except Exception: return None # ───────────────────────────────────────────────────────────────────────────── # ПРАВИЛА КЛАССИФИКАЦИИ (встроенные шаблоны) # ───────────────────────────────────────────────────────────────────────────── # Каждое правило: dict с полями: # id — уникальный ключ # name — отображаемое название # enabled — bool (можно менять в UI) # priority — порядок проверки (меньше = важнее) # match — dict {field: regex} (subject / from_addr / body) # action — str: 'deadline' | 'doplata' | 'vacation' | 'false_trip' | 'info' | 'ignore' # tag — цветовой тег для Treeview DEFAULT_RULES = [ # ── Задания по делам ───────────────────────────────────────────────────── { "id": "skb_any", "name": "СКБ калькуляция (любое письмо)", "enabled": True, "priority": 5, "match": { "from_addr": r"scbcalculation@|scbins@" }, "action": "deadline", "tag": "osago", "case_type": "ОСАГО" }, { "id": "case_at", "name": "Задание: АТ (авто/конструктив)", "enabled": True, "priority": 11, "match": { "subject": r"АТ\d{7,9}" }, "action": "deadline", "tag": "at", "case_type": "АТ" }, { "id": "case_kv", "name": "Задание: КВ (квартира)", "enabled": True, "priority": 12, "match": { "subject": r"КВ\d{7,9}" }, "action": "deadline", "tag": "kv", "case_type": "КВ" }, { "id": "case_dch", "name": "Задание: ДЧ (дело)", "enabled": True, "priority": 13, "match": { "subject": r"ДЧ\d{7,9}" }, "action": "deadline", "tag": "at", "case_type": "ДЧ" }, { "id": "case_osago_subj", "name": "Задание: ОСАГО (по теме)", "enabled": True, "priority": 14, "match": { "subject": r"ОСАГО.*\d{4,7}/\d{4}|\d{4,7}/\d{4}.*ОСАГО|\d{4,7}/\d{4}.*осаго" }, "action": "deadline", "tag": "osago", "case_type": "ОСАГО" }, { "id": "case_imush_subj", "name": "Задание: Имущество (по теме)", "enabled": True, "priority": 15, "match": { "subject": r"имущ[её]?(?:ство)?\s.*\d{4,7}/\d{4}|\d{4,7}/\d{4}.*имущ|имущ.*расчет|расчет.*имущ" }, "action": "deadline", "tag": "imush", "case_type": "имущество" }, { "id": "case_imush_classic", "name": "Задание: Расчёт имущества (старый формат)", "enabled": True, "priority": 16, "match": { "subject": r"\d{4}/\d{3}/\d{5}/\d{2}" }, "action": "deadline", "tag": "imush", "case_type": "имущество" }, { "id": "case_sys", "name": "Задание: SYS (системный номер)", "enabled": True, "priority": 17, "match": { "subject": r"SYS\d{10}" }, "action": "deadline", "tag": "at", "case_type": "SYS" }, { "id": "case_request", "name": "Запрос на закрытие заявки", "enabled": True, "priority": 18, "match": { "subject": r"ЗАПРОС\s+(?:КВ|АТ|ДЧ)\d{7,9}" }, "action": "deadline", "tag": "request", "case_type": "Запрос" }, # ── Кадровые ───────────────────────────────────────────────────────────── { "id": "vacation", "name": "Заявление на отпуск", "enabled": True, "priority": 20, "match": { "subject": r"заявление.*отпуск|отпуск.*заявление" }, "action": "vacation", "tag": "vacation" }, { "id": "doplata", "name": "Реестр / Доплата", "enabled": True, "priority": 25, "match": { "subject": r"реестр|доплата|акт.*выполн|выполн.*акт" }, "action": "doplata", "tag": "doplata" }, { "id": "false_trip", "name": "Сводка ложного выезда", "enabled": True, "priority": 30, "match": { "subject": r"сводка.*ложного.*выезда|ложн.*выезд" }, "action": "false_trip", "tag": "false_trip" }, # ── Игнорируемые (по умолчанию отключены) ──────────────────────────────── { "id": "reso_ticket", "name": "Тикеты РЕСО [T#...]", "enabled": False, "priority": 40, "match": { "subject": r"\[T#\d+\]" }, "action": "ignore", "tag": "ignore" }, { "id": "payment_doc", "name": "Платёжные документы", "enabled": False, "priority": 45, "match": { "subject": r"platezhnye|платежк|оплат.*услуг|услуг.*оплат" }, "action": "ignore", "tag": "ignore" }, ] # ───────────────────────────────────────────────────────────────────────────── # ПАРСИНГ ДАННЫХ ИЗ ПИСЬМА # ───────────────────────────────────────────────────────────────────────────── RE_CASE_IMUSH = re.compile(r'(\d{4}/\d{3}/\d{5}/\d{2})', re.IGNORECASE) RE_CASE_AT_KV = re.compile(r'((?:АТ|КВ|ДЧ)\d{7,9})', re.IGNORECASE) RE_CASE_OSAGO = re.compile(r'(\d{4,7}/\d{4})', re.IGNORECASE) RE_CASE_SYS = re.compile(r'(SYS\d{10})', re.IGNORECASE) RE_ADDRESS = re.compile(r'(?:АТ|КВ|ДЧ)\d{7,9}\s+(.+?)(?:\s*$|\s+на\s+расчет)', re.IGNORECASE) RE_REGION = re.compile(r'(?:г\.|город|обл\.|область|р-н|район)\s*[А-ЯЁа-яё\-]+', re.IGNORECASE) def extract_case_number(subject): """Извлекает номер дела из темы письма (приоритет: AT/КВ/ДЧ > классич. > ОСАГО > SYS).""" m = RE_CASE_AT_KV.search(subject) if m: return m.group(1) m = RE_CASE_IMUSH.search(subject) if m: return m.group(1) m = RE_CASE_OSAGO.search(subject) if m: return m.group(1) m = RE_CASE_SYS.search(subject) if m: return m.group(1) return None def extract_address(subject): """Извлекает адрес из темы письма (для КВ/АТ/ЗАПРОС).""" m = RE_ADDRESS.search(subject) if m: return m.group(1).strip() # Для ОСАГО: "NNNNN/YYYY; NNN-...; ОСАГО; [орг]; [адрес]" parts = subject.split(';') if len(parts) >= 5: return parts[4].strip() return '' def extract_region(subject, address=''): """Извлекает регион/город из темы или адреса.""" text = subject + ' ' + address m = RE_REGION.search(text) if m: return m.group(0).strip() # Попытка взять первое слово адреса if address: return address.split(',')[0].strip()[:50] return '' def extract_expert_from_to(to_header): """Берёт первого получателя из To: как эксперта.""" if not to_header: return '', '' # "Имя Фамилия <email@domain>" или "email@domain" parts = to_header.split(',')[0].strip() m = re.match(r'^(.*?)\s*<([^>]+)>', parts) if m: return m.group(1).strip(), m.group(2).strip() # Только email return '', parts.strip() # ───────────────────────────────────────────────────────────────────────────── # IMAP-КЛИЕНТ # ───────────────────────────────────────────────────────────────────────────── class IMAPClient: """Тонкая обёртка над imaplib для удобной работы.""" def __init__(self, host, port, user, password, use_ssl=True, folder='INBOX'): self.host = host self.port = int(port) self.user = user self.password = password self.use_ssl = use_ssl self.folder = folder self._conn = None def connect(self): # Устанавливаем таймаут на время подключения, чтобы поток не завис # при недоступном сервере. Восстанавливаем прежнее значение в finally. _prev_timeout = socket.getdefaulttimeout() socket.setdefaulttimeout(30) try: if self.use_ssl: ctx = ssl.create_default_context() self._conn = imaplib.IMAP4_SSL(self.host, self.port, ssl_context=ctx) else: self._conn = imaplib.IMAP4(self.host, self.port) self._conn.login(self.user, self.password) self._conn.select(self.folder) finally: socket.setdefaulttimeout(_prev_timeout) def disconnect(self): try: if self._conn: self._conn.logout() except Exception: pass self._conn = None def fetch_unseen(self, limit=200): """Возвращает непрочитанные письма, НЕ помечая их как прочитанные. BODY.PEEK[] читает содержимое без установки флага \\Seen.""" _, ids = self._conn.search(None, 'UNSEEN') id_list = ids[0].split() if not id_list: return [] id_list = id_list[-limit:] return self._fetch_ids(id_list) def fetch_since(self, since_date, limit=500): """Возвращает письма с указанной даты (формат '01-Jan-2025'), НЕ помечая их как прочитанные.""" _, ids = self._conn.search(None, f'SINCE {since_date}') id_list = ids[0].split() if not id_list: return [] id_list = id_list[-limit:] return self._fetch_ids(id_list) def _fetch_ids(self, id_list): """Загружает письма по списку ID через BODY.PEEK[] (без смены флага \\Seen).""" messages = [] for uid in id_list: try: _, data = self._conn.fetch(uid, '(BODY.PEEK[])') raw = data[0][1] msg = email.message_from_bytes(raw) msg._imap_uid = uid.decode() messages.append(msg) except Exception as e: logger.error(f"Fetch uid={uid}: {e}") return messages def list_folders(self): """Возвращает список папок на сервере.""" _, folders = self._conn.list() result = [] for f in folders: try: name = f.decode().split('"')[-2] result.append(name) except Exception: pass return result def mark_seen(self, uid): try: self._conn.store(uid, '+FLAGS', '\\Seen') except Exception: pass # ───────────────────────────────────────────────────────────────────────────── # КЛАССИФИКАТОР ПИСЕМ # ───────────────────────────────────────────────────────────────────────────── class EmailClassifier: """Классифицирует письмо по набору правил.""" def __init__(self, rules): self.rules = sorted( [r for r in rules if r.get('enabled', True)], key=lambda r: r.get('priority', 99) ) def classify(self, subject, from_addr, body): """Возвращает matching rule dict или None.""" subject_lc = subject.lower() from_lc = (from_addr or '').lower() body_lc = (body or '').lower()[:2000] for rule in self.rules: match_cfg = rule.get('match', {}) matched = True for field, pattern in match_cfg.items(): text = { 'subject': subject_lc, 'from_addr': from_lc, 'body': body_lc, }.get(field, '') if not re.search(pattern, text, re.IGNORECASE): matched = False break if matched: return rule return None # ───────────────────────────────────────────────────────────────────────────── # НАСТРОЙКИ ПОДКЛЮЧЕНИЯ В БД (используем db.get_setting / db.set_setting) # ───────────────────────────────────────────────────────────────────────────── SETTING_IMAP_HOST = 'mail_imap_host' SETTING_IMAP_PORT = 'mail_imap_port' SETTING_IMAP_USER = 'mail_imap_user' SETTING_IMAP_PASS = 'mail_imap_pass' SETTING_IMAP_SSL = 'mail_imap_ssl' SETTING_IMAP_FOLDER = 'mail_imap_folder' SETTING_RULES = 'mail_rules_json' SETTING_EMAIL_MAP = 'mail_email_map' # json: {"email": "Имя Фамилия"} SETTING_LAST_CHECK = 'mail_last_check' SETTING_IMPORT_FILTERS = 'mail_import_filters' # json: список фильтров переноса SETTING_IMPORT_LOGIC = 'mail_import_logic' # 'OR' | 'AND' # ───────────────────────────────────────────────────────────────────────────── # ФИЛЬТРЫ ПЕРЕНОСА (pre-screening: какие письма вообще показывать в модуле) # ───────────────────────────────────────────────────────────────────────────── # Поля (field): # subject — тема письма # from_addr — email отправителя # to_addr — поле To: (как строка) # to_only — To: содержит ТОЛЬКО этот адрес (один получатель) # body — тело письма # body_greeting — первые 300 символов тела (проверка приветствия) DEFAULT_IMPORT_FILTERS = [ { "id": "greeting_dmitry", "name": "Приветствие «Дмитрий»", "enabled": True, "field": "body_greeting", "pattern": r"Дмитрий", "description": "В начале письма (приветствие) упоминается имя Дмитрий" }, { "id": "to_only_aksda", "name": "Единственный получатель exp_aksda@karexp.ru", "enabled": True, "field": "to_only", "pattern": r"exp_aksda@karexp\.ru", "description": "В поле To: указан ТОЛЬКО этот адрес, других получателей нет" }, { "id": "subject_urgent", "name": "СРОЧНО в теме", "enabled": True, "field": "subject", "pattern": r"СРОЧНО", "description": "В теме письма есть слово СРОЧНО" }, ] IMPORT_FIELD_LABELS = { "subject": "Тема письма", "from_addr": "Email отправителя", "to_addr": "Получатели (строка To:)", "to_only": "Единственный получатель (To: only)", "body": "Тело письма (полностью)", "body_greeting": "Приветствие (первые 300 симв.)", } def _extract_all_to_emails(to_hdr): """Возвращает список всех email-адресов из заголовка To:.""" return [m.lower() for m in re.findall(r'<([^>]+)>', to_hdr)] or \ [e.strip().lower() for e in re.split(r'[,;]', to_hdr) if '@' in e] # ───────────────────────────────────────────────────────────────────────────── # ДИАЛОГ НАСТРОЕК ПОДКЛЮЧЕНИЯ # ───────────────────────────────────────────────────────────────────────────── class MailSettingsDialog(tk.Toplevel): """Диалог настройки IMAP и правил классификации.""" def __init__(self, master, db): super().__init__(master) self.db = db self.title("Настройки почты") self.resizable(True, True) self.grab_set() self._build() self._load() def _build(self): nb = ttk.Notebook(self) nb.pack(fill=BOTH, expand=True, padx=8, pady=8) # --- Вкладка: Подключение --- f_conn = ttk.Frame(nb) nb.add(f_conn, text="IMAP") g = ttk.Frame(f_conn, padding=12) g.pack(fill=BOTH, expand=True) def row(lbl, row_n): ttk.Label(g, text=lbl).grid(row=row_n, column=0, sticky=W, pady=4, padx=(0, 10)) row("Сервер (host):", 0) self.e_host = ttk.Entry(g, width=34); self.e_host.grid(row=0, column=1, sticky=EW) row("Порт:", 1) self.e_port = ttk.Entry(g, width=8); self.e_port.grid(row=1, column=1, sticky=W) self.e_port.insert(0, "993") row("Логин:", 2) self.e_user = ttk.Entry(g, width=34); self.e_user.grid(row=2, column=1, sticky=EW) row("Пароль:", 3) self.e_pass = ttk.Entry(g, width=34, show="*"); self.e_pass.grid(row=3, column=1, sticky=EW) row("Папка:", 4) self.e_folder = ttk.Entry(g, width=20); self.e_folder.grid(row=4, column=1, sticky=W) self.e_folder.insert(0, "INBOX") self.var_ssl = tk.BooleanVar(value=True) ttk.Checkbutton(g, text="SSL/TLS", variable=self.var_ssl).grid( row=5, column=1, sticky=W, pady=6) g.columnconfigure(1, weight=1) bf = ttk.Frame(f_conn) bf.pack(fill=X, padx=12, pady=(0, 10)) ttk.Button(bf, text="Проверить соединение", command=self._test_conn, bootstyle="info").pack(side=LEFT) # --- Вкладка: Фильтры переноса --- f_import = ttk.Frame(nb) nb.add(f_import, text="Фильтры переноса") self._build_import_tab(f_import) # --- Вкладка: Правила --- f_rules = ttk.Frame(nb) nb.add(f_rules, text="Правила сортировки") self._build_rules_tab(f_rules) # --- Вкладка: Маппинг Email → Сотрудник --- f_map = ttk.Frame(nb) nb.add(f_map, text="Email → Сотрудник") self._build_map_tab(f_map) # --- Кнопки --- btn_f = ttk.Frame(self) btn_f.pack(fill=X, padx=8, pady=(0, 8)) ttk.Button(btn_f, text="Сохранить", command=self._save, bootstyle="success").pack(side=RIGHT, padx=4) ttk.Button(btn_f, text="Отмена", command=self.destroy, bootstyle="secondary").pack(side=RIGHT) # ── Фильтры переноса ────────────────────────────────────────────────────── def _build_import_tab(self, parent): ttk.Label(parent, text="Письмо попадает в модуль, только если проходит хотя бы один активный фильтр " "(режим ИЛИ) или все активные фильтры (режим И).\n" "Если фильтров нет или все отключены — переносятся все письма.", font=("Arial", 9), bootstyle="secondary", wraplength=500 ).pack(fill=X, padx=10, pady=(8, 4)) # Режим логики logic_f = ttk.Frame(parent) logic_f.pack(fill=X, padx=10, pady=(0, 6)) ttk.Label(logic_f, text="Логика проверки:").pack(side=LEFT) self.var_import_logic = tk.StringVar(value='OR') ttk.Radiobutton(logic_f, text="ИЛИ (любой фильтр)", variable=self.var_import_logic, value='OR').pack(side=LEFT, padx=8) ttk.Radiobutton(logic_f, text="И (все фильтры)", variable=self.var_import_logic, value='AND').pack(side=LEFT) # Список фильтров self._import_filters = self._load_import_filters() frm = ttk.Frame(parent) frm.pack(fill=BOTH, expand=True, padx=10, pady=4) cols = ('enabled', 'name', 'field', 'pattern', 'description') self.tree_import = ttk.Treeview(frm, columns=cols, show='headings', height=10) self.tree_import.heading('enabled', text='Вкл') self.tree_import.heading('name', text='Название') self.tree_import.heading('field', text='Поле') self.tree_import.heading('pattern', text='Шаблон / Значение') self.tree_import.heading('description', text='Описание') self.tree_import.column('enabled', width=40, anchor='center') self.tree_import.column('name', width=200, anchor='w') self.tree_import.column('field', width=130, anchor='center') self.tree_import.column('pattern', width=180, anchor='w') self.tree_import.column('description', width=260, anchor='w') self.tree_import.pack(side=LEFT, fill=BOTH, expand=True) sb = ttk.Scrollbar(frm, orient=VERTICAL, command=self.tree_import.yview) self.tree_import.configure(yscrollcommand=sb.set) sb.pack(side=LEFT, fill=Y) self.tree_import.tag_configure('disabled', foreground='gray') self._refresh_import_tree() bf = ttk.Frame(parent) bf.pack(fill=X, padx=10, pady=4) ttk.Button(bf, text="✔ Вкл/Выкл", command=self._toggle_import, bootstyle="warning", width=14).pack(side=LEFT, padx=2) ttk.Button(bf, text="+ Добавить", command=self._add_import, bootstyle="success", width=12).pack(side=LEFT, padx=2) ttk.Button(bf, text="✏ Изменить", command=self._edit_import, bootstyle="info", width=12).pack(side=LEFT, padx=2) ttk.Button(bf, text="✖ Удалить", command=self._del_import, bootstyle="danger", width=12).pack(side=LEFT, padx=2) def _load_import_filters(self): raw = self.db.get_setting(SETTING_IMPORT_FILTERS, '') if raw: try: saved = json.loads(raw) saved_ids = {f['id'] for f in saved} for df in DEFAULT_IMPORT_FILTERS: if df['id'] not in saved_ids: saved.append(dict(df)) return saved except Exception: pass return [dict(f) for f in DEFAULT_IMPORT_FILTERS] def _refresh_import_tree(self): for item in self.tree_import.get_children(): self.tree_import.delete(item) for i, f in enumerate(self._import_filters): on = f.get('enabled', True) tag = '' if on else 'disabled' self.tree_import.insert('', 'end', iid=str(i), tags=(tag,), values=( '✔' if on else '—', f.get('name', ''), IMPORT_FIELD_LABELS.get(f.get('field', ''), f.get('field', '')), f.get('pattern', ''), f.get('description', ''), )) def _selected_import_idx(self): sel = self.tree_import.selection() return int(sel[0]) if sel else None def _toggle_import(self): idx = self._selected_import_idx() if idx is None: return self._import_filters[idx]['enabled'] = not self._import_filters[idx].get('enabled', True) self._refresh_import_tree() def _add_import(self): dlg = ImportFilterEditDialog(self, None) self.wait_window(dlg) if dlg.result: self._import_filters.append(dlg.result) self._refresh_import_tree() def _edit_import(self): idx = self._selected_import_idx() if idx is None: return dlg = ImportFilterEditDialog(self, self._import_filters[idx]) self.wait_window(dlg) if dlg.result: self._import_filters[idx] = dlg.result self._refresh_import_tree() def _del_import(self): idx = self._selected_import_idx() if idx is None: return if messagebox.askyesno("Удалить", f"Удалить фильтр «{self._import_filters[idx]['name']}»?", parent=self): self._import_filters.pop(idx) self._refresh_import_tree() def _build_rules_tab(self, parent): info = ttk.Label(parent, text="Включите/отключите категории писем. Порядок определяет приоритет распознавания.", font=("Arial", 9), bootstyle="secondary", wraplength=480) info.pack(fill=X, padx=10, pady=(8, 4)) # Правила загружаем из БД (или берём DEFAULT_RULES) self._rules = self._load_rules() frm = ttk.Frame(parent) frm.pack(fill=BOTH, expand=True, padx=10, pady=4) cols = ('priority', 'name', 'action', 'enabled') self.tree_rules = ttk.Treeview(frm, columns=cols, show='headings', height=14) self.tree_rules.heading('priority', text='Приор.') self.tree_rules.heading('name', text='Название правила') self.tree_rules.heading('action', text='Действие') self.tree_rules.heading('enabled', text='Вкл') self.tree_rules.column('priority', width=55, anchor='center') self.tree_rules.column('name', width=280, anchor='w') self.tree_rules.column('action', width=90, anchor='center') self.tree_rules.column('enabled', width=45, anchor='center') self.tree_rules.pack(side=LEFT, fill=BOTH, expand=True) sb = ttk.Scrollbar(frm, orient=VERTICAL, command=self.tree_rules.yview) self.tree_rules.configure(yscrollcommand=sb.set) sb.pack(side=LEFT, fill=Y) self._refresh_rules_tree() bf = ttk.Frame(parent) bf.pack(fill=X, padx=10, pady=4) ttk.Button(bf, text="✔ Вкл/Выкл", command=self._toggle_rule, bootstyle="warning", width=14).pack(side=LEFT, padx=2) ttk.Button(bf, text="↑ Выше", command=lambda: self._move_rule(-1), bootstyle="secondary", width=10).pack(side=LEFT, padx=2) ttk.Button(bf, text="↓ Ниже", command=lambda: self._move_rule(1), bootstyle="secondary", width=10).pack(side=LEFT, padx=2) ttk.Button(bf, text="+ Добавить", command=self._add_rule, bootstyle="success", width=12).pack(side=LEFT, padx=2) ttk.Button(bf, text="✖ Удалить", command=self._delete_rule, bootstyle="danger", width=12).pack(side=LEFT, padx=2) def _build_map_tab(self, parent): ttk.Label(parent, text="Укажите соответствие email-адреса и имени сотрудника в системе.\n" "Используется при назначении эксперта из поля To:.", font=("Arial", 9), bootstyle="secondary", wraplength=480 ).pack(fill=X, padx=10, pady=(8, 4)) frm = ttk.Frame(parent) frm.pack(fill=BOTH, expand=True, padx=10) cols = ('email', 'name') self.tree_map = ttk.Treeview(frm, columns=cols, show='headings', height=14) self.tree_map.heading('email', text='Email') self.tree_map.heading('name', text='Имя сотрудника') self.tree_map.column('email', width=230) self.tree_map.column('name', width=220) self.tree_map.pack(side=LEFT, fill=BOTH, expand=True) sb = ttk.Scrollbar(frm, orient=VERTICAL, command=self.tree_map.yview) self.tree_map.configure(yscrollcommand=sb.set) sb.pack(side=LEFT, fill=Y) self._email_map = {} raw = self.db.get_setting(SETTING_EMAIL_MAP, '{}') try: self._email_map = json.loads(raw) except Exception: pass self._refresh_map_tree() bf = ttk.Frame(parent) bf.pack(fill=X, padx=10, pady=4) ttk.Button(bf, text="+ Добавить", command=self._add_map, bootstyle="success", width=12).pack(side=LEFT, padx=2) ttk.Button(bf, text="✖ Удалить", command=self._del_map, bootstyle="danger", width=12).pack(side=LEFT, padx=2) ttk.Button(bf, text="Авто (из сотрудников)", command=self._auto_map, bootstyle="info").pack(side=LEFT, padx=2) # ── Правила ─────────────────────────────────────────────────────────────── def _load_rules(self): raw = self.db.get_setting(SETTING_RULES, '') if raw: try: saved = json.loads(raw) # Мёрдж: добавляем новые дефолтные правила, которых нет в сохранённых saved_ids = {r['id'] for r in saved} for dr in DEFAULT_RULES: if dr['id'] not in saved_ids: saved.append(dict(dr)) return saved except Exception: pass return [dict(r) for r in DEFAULT_RULES] def _refresh_rules_tree(self): for item in self.tree_rules.get_children(): self.tree_rules.delete(item) for i, r in enumerate(self._rules): tag = 'disabled' if not r.get('enabled', True) else '' self.tree_rules.insert('', 'end', iid=str(i), tags=(tag,), values=(r.get('priority', i+1), r['name'], r.get('action', '?'), '✔' if r.get('enabled', True) else '—')) self.tree_rules.tag_configure('disabled', foreground='gray') def _selected_rule_idx(self): sel = self.tree_rules.selection() if not sel: return None return int(sel[0]) def _toggle_rule(self): idx = self._selected_rule_idx() if idx is None: return self._rules[idx]['enabled'] = not self._rules[idx].get('enabled', True) self._refresh_rules_tree() def _move_rule(self, direction): idx = self._selected_rule_idx() if idx is None: return new_idx = idx + direction if 0 <= new_idx < len(self._rules): self._rules[idx], self._rules[new_idx] = self._rules[new_idx], self._rules[idx] self._rules[new_idx]['priority'] = new_idx + 1 self._rules[idx]['priority'] = idx + 1 self._refresh_rules_tree() self.tree_rules.selection_set(str(new_idx)) def _add_rule(self): dlg = RuleEditDialog(self, None) self.wait_window(dlg) if dlg.result: dlg.result['priority'] = len(self._rules) + 1 self._rules.append(dlg.result) self._refresh_rules_tree() def _delete_rule(self): idx = self._selected_rule_idx() if idx is None: return if messagebox.askyesno("Удалить", f"Удалить правило «{self._rules[idx]['name']}»?", parent=self): self._rules.pop(idx) self._refresh_rules_tree() # ── Маппинг ─────────────────────────────────────────────────────────────── def _refresh_map_tree(self): for item in self.tree_map.get_children(): self.tree_map.delete(item) for em, nm in sorted(self._email_map.items()): self.tree_map.insert('', 'end', values=(em, nm)) def _add_map(self): dlg = EmailMapDialog(self) self.wait_window(dlg) if dlg.result: self._email_map[dlg.result['email']] = dlg.result['name'] self._refresh_map_tree() def _del_map(self): sel = self.tree_map.selection() if not sel: return em = self.tree_map.item(sel[0])['values'][0] self._email_map.pop(str(em), None) self._refresh_map_tree() def _auto_map(self): """Предзаполняет маппинг из списка сотрудников (если у них есть email).""" try: emps = self.db.get_employees() added = 0 for e in emps: em = (e.get('email') or '').strip().lower() nm = (e.get('name') or '').strip() if em and nm and em not in self._email_map: self._email_map[em] = nm added += 1 self._refresh_map_tree() messagebox.showinfo("Авто-маппинг", f"Добавлено {added} записей.", parent=self) except Exception as e: messagebox.showerror("Ошибка", str(e), parent=self) # ── Загрузка / Сохранение / Тест ────────────────────────────────────────── def _load(self): self.e_host.insert(0, self.db.get_setting(SETTING_IMAP_HOST, '')) self.e_port.delete(0, END) self.e_port.insert(0, self.db.get_setting(SETTING_IMAP_PORT, '993')) self.e_user.insert(0, self.db.get_setting(SETTING_IMAP_USER, '')) self.e_pass.insert(0, self.db.get_setting(SETTING_IMAP_PASS, '')) self.e_folder.delete(0, END) self.e_folder.insert(0, self.db.get_setting(SETTING_IMAP_FOLDER, 'INBOX')) self.var_ssl.set(self.db.get_setting(SETTING_IMAP_SSL, '1') == '1') self.var_import_logic.set(self.db.get_setting(SETTING_IMPORT_LOGIC, 'OR')) def _save(self): self.db.set_setting(SETTING_IMAP_HOST, self.e_host.get().strip()) self.db.set_setting(SETTING_IMAP_PORT, self.e_port.get().strip()) self.db.set_setting(SETTING_IMAP_USER, self.e_user.get().strip()) self.db.set_setting(SETTING_IMAP_PASS, self.e_pass.get().strip()) self.db.set_setting(SETTING_IMAP_FOLDER, self.e_folder.get().strip()) self.db.set_setting(SETTING_IMAP_SSL, '1' if self.var_ssl.get() else '0') self.db.set_setting(SETTING_RULES, json.dumps(self._rules, ensure_ascii=False)) self.db.set_setting(SETTING_EMAIL_MAP, json.dumps(self._email_map, ensure_ascii=False)) self.db.set_setting(SETTING_IMPORT_FILTERS, json.dumps(self._import_filters, ensure_ascii=False)) self.db.set_setting(SETTING_IMPORT_LOGIC, self.var_import_logic.get()) messagebox.showinfo("Сохранено", "Настройки почты сохранены.", parent=self) self.destroy() def _test_conn(self): def _do(): try: c = IMAPClient( self.e_host.get().strip(), self.e_port.get().strip(), self.e_user.get().strip(), self.e_pass.get().strip(), use_ssl=self.var_ssl.get(), folder=self.e_folder.get().strip() ) c.connect() folders = c.list_folders() c.disconnect() self.after(0, lambda: messagebox.showinfo( "Успех", f"Соединение установлено!\nПапок на сервере: {len(folders)}", parent=self)) except Exception as e: self.after(0, lambda err=e: messagebox.showerror( "Ошибка подключения", str(err), parent=self)) threading.Thread(target=_do, daemon=True).start() # ───────────────────────────────────────────────────────────────────────────── # ДИАЛОГ РЕДАКТИРОВАНИЯ ПРАВИЛА # ───────────────────────────────────────────────────────────────────────────── class RuleEditDialog(tk.Toplevel): ACTIONS = ['deadline', 'doplata', 'vacation', 'false_trip', 'info', 'ignore'] def __init__(self, master, rule=None): super().__init__(master) self.title("Правило классификации") self.resizable(False, False) self.grab_set() self.result = None self._rule = rule or {} self._build() def _build(self): g = ttk.Frame(self, padding=12) g.pack(fill=BOTH, expand=True) def row(lbl, r): ttk.Label(g, text=lbl).grid(row=r, column=0, sticky=W, pady=3, padx=(0, 8)) row("ID:", 0) self.e_id = ttk.Entry(g, width=28) self.e_id.insert(0, self._rule.get('id', '')) self.e_id.grid(row=0, column=1, sticky=EW) row("Название:", 1) self.e_name = ttk.Entry(g, width=40) self.e_name.insert(0, self._rule.get('name', '')) self.e_name.grid(row=1, column=1, sticky=EW) row("Действие:", 2) self.var_action = tk.StringVar(value=self._rule.get('action', 'info')) ttk.Combobox(g, textvariable=self.var_action, values=self.ACTIONS, state='readonly', width=16).grid(row=2, column=1, sticky=W) row("Regex (тема):", 3) self.e_subj = ttk.Entry(g, width=40) self.e_subj.insert(0, self._rule.get('match', {}).get('subject', '')) self.e_subj.grid(row=3, column=1, sticky=EW) row("Regex (от):", 4) self.e_from = ttk.Entry(g, width=40) self.e_from.insert(0, self._rule.get('match', {}).get('from_addr', '')) self.e_from.grid(row=4, column=1, sticky=EW) row("Regex (тело):", 5) self.e_body = ttk.Entry(g, width=40) self.e_body.insert(0, self._rule.get('match', {}).get('body', '')) self.e_body.grid(row=5, column=1, sticky=EW) row("Тип дела:", 6) self.e_case_type = ttk.Entry(g, width=20) self.e_case_type.insert(0, self._rule.get('case_type', '')) self.e_case_type.grid(row=6, column=1, sticky=W) self.var_enabled = tk.BooleanVar(value=self._rule.get('enabled', True)) ttk.Checkbutton(g, text="Включено", variable=self.var_enabled).grid( row=7, column=1, sticky=W, pady=6) g.columnconfigure(1, weight=1) bf = ttk.Frame(self) bf.pack(fill=X, padx=12, pady=(0, 10)) ttk.Button(bf, text="Сохранить", command=self._save, bootstyle="success").pack(side=RIGHT, padx=4) ttk.Button(bf, text="Отмена", command=self.destroy, bootstyle="secondary").pack(side=RIGHT) def _save(self): match = {} if self.e_subj.get().strip(): match['subject'] = self.e_subj.get().strip() if self.e_from.get().strip(): match['from_addr'] = self.e_from.get().strip() if self.e_body.get().strip(): match['body'] = self.e_body.get().strip() self.result = { 'id': self.e_id.get().strip() or f'rule_{id(self)}', 'name': self.e_name.get().strip() or 'Новое правило', 'action': self.var_action.get(), 'match': match, 'case_type': self.e_case_type.get().strip(), 'enabled': self.var_enabled.get(), 'tag': self.var_action.get(), } self.destroy() # ───────────────────────────────────────────────────────────────────────────── # ДИАЛОГ ДОБАВЛЕНИЯ МАППИНГА # ───────────────────────────────────────────────────────────────────────────── class EmailMapDialog(tk.Toplevel): def __init__(self, master): super().__init__(master) self.title("Email → Сотрудник") self.resizable(False, False) self.grab_set() self.result = None f = ttk.Frame(self, padding=12) f.pack(fill=BOTH) ttk.Label(f, text="Email:").grid(row=0, column=0, sticky=W, pady=4) self.e_email = ttk.Entry(f, width=34) self.e_email.grid(row=0, column=1, sticky=EW, pady=4) ttk.Label(f, text="Имя сотрудника:").grid(row=1, column=0, sticky=W, pady=4) self.e_name = ttk.Entry(f, width=34) self.e_name.grid(row=1, column=1, sticky=EW, pady=4) bf = ttk.Frame(f) bf.grid(row=2, column=0, columnspan=2, pady=8) ttk.Button(bf, text="OK", command=self._ok, bootstyle="success", width=10).pack(side=LEFT, padx=4) ttk.Button(bf, text="Отмена", command=self.destroy, bootstyle="secondary", width=10).pack(side=LEFT) f.columnconfigure(1, weight=1) def _ok(self): em = self.e_email.get().strip().lower() nm = self.e_name.get().strip() if em and nm: self.result = {'email': em, 'name': nm} self.destroy() # ───────────────────────────────────────────────────────────────────────────── # ДИАЛОГ РЕДАКТИРОВАНИЯ ФИЛЬТРА ПЕРЕНОСА # ───────────────────────────────────────────────────────────────────────────── class ImportFilterEditDialog(tk.Toplevel): """Создание / редактирование одного фильтра переноса.""" def __init__(self, master, flt=None): super().__init__(master) self.title("Фильтр переноса") self.resizable(False, False) self.grab_set() self.result = None self._flt = flt or {} self._build() def _build(self): g = ttk.Frame(self, padding=14) g.pack(fill=BOTH, expand=True) def lbl(text, row): ttk.Label(g, text=text).grid(row=row, column=0, sticky=W, pady=4, padx=(0, 10)) lbl("Название:", 0) self.e_name = ttk.Entry(g, width=40) self.e_name.insert(0, self._flt.get('name', '')) self.e_name.grid(row=0, column=1, sticky=EW) lbl("Поле проверки:", 1) self.var_field = tk.StringVar(value=self._flt.get('field', 'subject')) field_cb = ttk.Combobox(g, textvariable=self.var_field, values=list(IMPORT_FIELD_LABELS.keys()), state='readonly', width=22) field_cb.grid(row=1, column=1, sticky=W) # Подсказка по полю self._hint_var = tk.StringVar() ttk.Label(g, textvariable=self._hint_var, font=("Arial", 8), bootstyle="secondary", wraplength=320).grid(row=2, column=1, sticky=W) field_cb.bind('<<ComboboxSelected>>', self._update_hint) self._update_hint() lbl("Шаблон (regex):", 3) self.e_pattern = ttk.Entry(g, width=40) self.e_pattern.insert(0, self._flt.get('pattern', '')) self.e_pattern.grid(row=3, column=1, sticky=EW) ttk.Label(g, text="(Поддерживается синтаксис regex Python. Пример: Дмитрий|Dmitry)", font=("Arial", 8), bootstyle="secondary" ).grid(row=4, column=1, sticky=W, pady=(0, 6)) lbl("Описание:", 5) self.e_desc = ttk.Entry(g, width=40) self.e_desc.insert(0, self._flt.get('description', '')) self.e_desc.grid(row=5, column=1, sticky=EW) self.var_enabled = tk.BooleanVar(value=self._flt.get('enabled', True)) ttk.Checkbutton(g, text="Включён", variable=self.var_enabled).grid( row=6, column=1, sticky=W, pady=8) g.columnconfigure(1, weight=1) bf = ttk.Frame(self) bf.pack(fill=X, padx=14, pady=(0, 12)) ttk.Button(bf, text="Сохранить", command=self._save, bootstyle="success").pack(side=RIGHT, padx=4) ttk.Button(bf, text="Отмена", command=self.destroy, bootstyle="secondary").pack(side=RIGHT) def _update_hint(self, event=None): hints = { "subject": "Regex по теме письма", "from_addr": "Regex по email-адресу отправителя", "to_addr": "Regex по строке заголовка To: (может содержать несколько адресов)", "to_only": "Regex: To: содержит ТОЛЬКО этот адрес — других получателей нет", "body": "Regex по полному тексту письма", "body_greeting": "Regex по первым 300 символам тела (область приветствия)", } self._hint_var.set(hints.get(self.var_field.get(), '')) def _save(self): name = self.e_name.get().strip() pattern = self.e_pattern.get().strip() if not name or not pattern: messagebox.showwarning("Заполните поля", "Название и шаблон обязательны.", parent=self) return try: re.compile(pattern) except re.error as e: messagebox.showerror("Ошибка regex", f"Неверный шаблон: {e}", parent=self) return self.result = { 'id': self._flt.get('id') or f'import_{id(self)}', 'name': name, 'field': self.var_field.get(), 'pattern': pattern, 'description': self.e_desc.get().strip(), 'enabled': self.var_enabled.get(), } self.destroy() # ───────────────────────────────────────────────────────────────────────────── # ДЕТАЛЬНЫЙ ПРОСМОТР ПИСЬМА # ───────────────────────────────────────────────────────────────────────────── class MailDetailDialog(tk.Toplevel): def __init__(self, master, item_data, db, email_map): super().__init__(master) self.item = item_data self.db = db self.email_map = email_map self.title(f"Письмо: {item_data.get('subject', '')[:60]}") self.resizable(True, True) self._build() def _build(self): top = ttk.Frame(self, padding=10) top.pack(fill=X) def lbl_row(text, val): f = ttk.Frame(top) f.pack(fill=X, pady=1) ttk.Label(f, text=text, font=("Arial", 9, "bold"), width=14).pack(side=LEFT) ttk.Label(f, text=val, font=("Arial", 9), wraplength=600, justify=LEFT).pack(side=LEFT) lbl_row("От:", self.item.get('from_name', '') + ' <' + self.item.get('from_addr', '') + '>') lbl_row("Кому:", self.item.get('to_str', '')) lbl_row("Дата:", self.item.get('date_str', '')) lbl_row("Тема:", self.item.get('subject', '')) lbl_row("Категория:", self.item.get('category', '—')) lbl_row("Номер дела:", self.item.get('case_number', '—')) lbl_row("Адрес:", self.item.get('address', '—')) lbl_row("Ссылки:", '\n'.join(self.item.get('urls', [])) or '—') ttk.Separator(self, orient=HORIZONTAL).pack(fill=X, padx=8) body_frame = ttk.Frame(self, padding=8) body_frame.pack(fill=BOTH, expand=True) ttk.Label(body_frame, text="Текст письма:", font=("Arial", 9, "bold")).pack(anchor=W) txt = scrolledtext.ScrolledText(body_frame, height=16, font=("Consolas", 9), wrap=tk.WORD) txt.pack(fill=BOTH, expand=True) txt.insert('1.0', self.item.get('body', '')) txt.config(state=tk.DISABLED) bf = ttk.Frame(self, padding=8) bf.pack(fill=X) action = self.item.get('action', '') if action == 'deadline': ttk.Button(bf, text="📋 Создать дело в Контроле сроков", command=self._create_deadline, bootstyle="primary").pack(side=LEFT, padx=4) elif action == 'vacation': ttk.Button(bf, text="🏖 Зарегистрировать отпуск", command=self._create_vacation, bootstyle="info").pack(side=LEFT, padx=4) elif action == 'doplata': ttk.Button(bf, text="💰 Создать доплату", command=self._create_doplata, bootstyle="success").pack(side=LEFT, padx=4) ttk.Button(bf, text="Закрыть", command=self.destroy, bootstyle="secondary").pack(side=RIGHT) def _create_deadline(self): case_number = self.item.get('case_number', '') if not case_number: messagebox.showwarning("Нет номера дела", "Не удалось извлечь номер дела из темы письма.", parent=self) return expert_name = self.email_map.get(self.item.get('to_email', '').lower(), '') load_date = datetime.now().strftime('%Y-%m-%d') data = { 'case_number': case_number, 'create_date': self.item.get('date_str', '')[:10], 'region': self.item.get('region', ''), 'services_list': self.item.get('case_type', ''), 'expert_calculation': expert_name, 'comment': f"Импорт из почты: {self.item.get('subject', '')}", 'deadline': 0, 'load_date': load_date, } try: self.db.save_deadline_case(data) messagebox.showinfo("Готово", f"Дело {case_number} создано в Контроле сроков.", parent=self) except Exception as e: messagebox.showerror("Ошибка", str(e), parent=self) def _create_vacation(self): messagebox.showinfo("Отпуск", "Откройте модуль Отпуска и создайте запись вручную.\n" f"Сотрудник: {self.item.get('from_name', '')}\n" f"Дата письма: {self.item.get('date_str', '')[:10]}", parent=self) def _create_doplata(self): messagebox.showinfo("Доплата", "Откройте модуль Зарплата → Доплаты и создайте запись вручную.\n" f"Сотрудник: {self.item.get('from_name', '')}\n" f"Дата письма: {self.item.get('date_str', '')[:10]}", parent=self) # ───────────────────────────────────────────────────────────────────────────── # ОСНОВНОЙ МОДУЛЬ (ttk.Frame) # ───────────────────────────────────────────────────────────────────────────── # (background, foreground) — явный foreground гарантирует читаемость на тёмной теме ACTION_COLORS = { 'deadline': ('#cce5ff', '#0a2a4a'), 'imush': ('#cce5ff', '#0a2a4a'), 'at': ('#ccf0d4', '#0a2e14'), 'kv': ('#ccf0d4', '#0a2e14'), 'osago': ('#fff0b3', '#3d2e00'), 'request': ('#ffd9b3', '#3d1a00'), 'vacation': ('#e8ccff', '#2a0a4a'), 'doplata': ('#b3f0b3', '#0a2e0a'), 'false_trip': ('#d9d9d9', '#2a2a2a'), 'ignore': ('#e8e8e8', '#666666'), 'info': ('', ''), # тема по умолчанию } ACTION_LABELS = { 'deadline': '📋 Дело', 'doplata': '💰 Доплата', 'vacation': '🏖 Отпуск', 'false_trip': '🚗 Ложн.выезд', 'info': 'ℹ Инфо', 'ignore': '— Игнор', } class MailApp(ttk.Frame): """Главный фрейм модуля почты.""" def __init__(self, master, db_manager): super().__init__(master) self.pack(fill=BOTH, expand=True) self.db = db_manager self._items = [] # список разобранных писем self._email_map = {} # email → имя сотрудника self._rules = [] # активные правила self._classifier = None self._fetch_thread = None self._reload_config() self._build_ui() def _reload_config(self): raw_rules = self.db.get_setting(SETTING_RULES, '') if raw_rules: try: self._rules = json.loads(raw_rules) except Exception: self._rules = list(DEFAULT_RULES) else: self._rules = list(DEFAULT_RULES) raw_map = self.db.get_setting(SETTING_EMAIL_MAP, '{}') try: self._email_map = json.loads(raw_map) except Exception: self._email_map = {} raw_imp = self.db.get_setting(SETTING_IMPORT_FILTERS, '') if raw_imp: try: saved = json.loads(raw_imp) saved_ids = {f['id'] for f in saved} for df in DEFAULT_IMPORT_FILTERS: if df['id'] not in saved_ids: saved.append(dict(df)) self._import_filters = saved except Exception: self._import_filters = list(DEFAULT_IMPORT_FILTERS) else: self._import_filters = list(DEFAULT_IMPORT_FILTERS) self._import_logic = self.db.get_setting(SETTING_IMPORT_LOGIC, 'OR') self._classifier = EmailClassifier(self._rules) def _build_ui(self): # ── Тулбар ───────────────────────────────────────────────────────── tb = ttk.Frame(self, padding=(6, 4)) tb.pack(fill=X, side=TOP) btn_fetch = ttk.Button(tb, text="📥 Получить почту", command=self._start_fetch, bootstyle="primary", width=18) btn_fetch.pack(side=LEFT, padx=2) ToolTip(btn_fetch, text="Подключиться к IMAP и загрузить новые письма", bootstyle="inverse-primary") btn_set = ttk.Button(tb, text="⚙ Настройки", command=self._open_settings, bootstyle="secondary", width=12) btn_set.pack(side=LEFT, padx=2) ToolTip(btn_set, text="Настройки IMAP и правил сортировки", bootstyle="inverse-secondary") ttk.Separator(tb, orient=VERTICAL).pack(side=LEFT, fill=Y, padx=6, pady=2) # Фильтр по категории ttk.Label(tb, text="Категория:").pack(side=LEFT) self.var_filter_cat = tk.StringVar(value='Все') self.cb_cat = ttk.Combobox(tb, textvariable=self.var_filter_cat, values=['Все'], width=16, state='readonly') self.cb_cat.pack(side=LEFT, padx=4) self.cb_cat.bind('<<ComboboxSelected>>', lambda e: self._apply_filter()) # Поиск по тексту ttk.Label(tb, text="Поиск:").pack(side=LEFT, padx=(8, 2)) self.var_search = tk.StringVar() self.var_search.trace_add('write', lambda *a: self._apply_filter()) ttk.Entry(tb, textvariable=self.var_search, width=22).pack(side=LEFT) # Кнопка импорта выбранного btn_imp = ttk.Button(tb, text="✔ Импортировать выбранное", command=self._import_selected, bootstyle="success", width=22) btn_imp.pack(side=RIGHT, padx=4) ToolTip(btn_imp, text="Создать дело/доплату/отпуск из выбранного письма", bootstyle="inverse-success") # ── Прогресс / статус ────────────────────────────────────────────── self._status_var = tk.StringVar(value="Нет данных. Нажмите «Получить почту».") ttk.Label(self, textvariable=self._status_var, font=("Arial", 9), bootstyle="secondary").pack(fill=X, padx=8, pady=(0, 2)) self._pbar = ttk.Progressbar(self, mode='indeterminate', bootstyle="info") ttk.Separator(self, orient=HORIZONTAL).pack(fill=X, padx=6) # ── Таблица писем ────────────────────────────────────────────────── tf = ttk.Frame(self) tf.pack(fill=BOTH, expand=True, padx=6, pady=4) cols = ('date', 'category', 'from_name', 'subject', 'case_number', 'expert', 'action') self.tree = ttk.Treeview(tf, columns=cols, show='headings', selectmode='browse') self.tree.heading('date', text='Дата') self.tree.heading('category', text='Категория') self.tree.heading('from_name', text='От') self.tree.heading('subject', text='Тема') self.tree.heading('case_number', text='Номер дела') self.tree.heading('expert', text='Эксперт (To:)') self.tree.heading('action', text='Действие') self.tree.column('date', width=100, anchor='center') self.tree.column('category', width=120, anchor='center') self.tree.column('from_name', width=140, anchor='w') self.tree.column('subject', width=280, anchor='w') self.tree.column('case_number', width=130, anchor='center') self.tree.column('expert', width=150, anchor='w') self.tree.column('action', width=100, anchor='center') # Цветовые теги (bg + fg для читаемости на тёмной теме) for tag, (bg, fg) in ACTION_COLORS.items(): kw = {} if bg: kw['background'] = bg if fg: kw['foreground'] = fg if kw: self.tree.tag_configure(tag, **kw) sb_v = ttk.Scrollbar(tf, orient=VERTICAL, command=self.tree.yview) sb_h = ttk.Scrollbar(tf, orient=HORIZONTAL, command=self.tree.xview) self.tree.configure(yscrollcommand=sb_v.set, xscrollcommand=sb_h.set) self.tree.grid(row=0, column=0, sticky=NSEW) sb_v.grid(row=0, column=1, sticky=NS) sb_h.grid(row=1, column=0, sticky=EW) tf.rowconfigure(0, weight=1) tf.columnconfigure(0, weight=1) self.tree.bind('<Double-1>', self._on_double_click) self.tree.bind('<Return>', self._on_double_click) # ── Фильтры переноса ─────────────────────────────────────────────────── def _passes_import_filters(self, item): """Проверяет письмо по фильтрам переноса. Возвращает True, если письмо должно попасть в модуль. Если активных фильтров нет — пропускаем все письма.""" active = [f for f in self._import_filters if f.get('enabled', True)] if not active: return True results = [] for f in active: field = f.get('field', 'subject') pattern = f.get('pattern', '') try: if field == 'subject': text = item.get('subject', '') hit = bool(re.search(pattern, text, re.IGNORECASE)) elif field == 'from_addr': text = item.get('from_addr', '') hit = bool(re.search(pattern, text, re.IGNORECASE)) elif field == 'to_addr': text = item.get('to_str', '') hit = bool(re.search(pattern, text, re.IGNORECASE)) elif field == 'to_only': # To: содержит ТОЛЬКО один адрес, совпадающий с шаблоном emails = _extract_all_to_emails(item.get('to_str', '')) hit = (len(emails) == 1 and bool(re.search(pattern, emails[0], re.IGNORECASE))) elif field == 'body': text = item.get('body', '') hit = bool(re.search(pattern, text, re.IGNORECASE)) elif field == 'body_greeting': # Только первые 300 символов (зона приветствия) text = item.get('body', '')[:300] hit = bool(re.search(pattern, text, re.IGNORECASE)) else: hit = False except re.error: hit = False results.append(hit) if self._import_logic == 'AND': return all(results) else: # OR return any(results) # ── Настройки ────────────────────────────────────────────────────────── def _open_settings(self): dlg = MailSettingsDialog(self.winfo_toplevel(), self.db) self.winfo_toplevel().wait_window(dlg) self._reload_config() # ── Загрузка писем ───────────────────────────────────────────────────── def _start_fetch(self): if self._fetch_thread and self._fetch_thread.is_alive(): return host = self.db.get_setting(SETTING_IMAP_HOST, '').strip() if not host: messagebox.showwarning("Настройки", "Сначала укажите настройки IMAP-сервера (кнопка ⚙ Настройки).", parent=self.winfo_toplevel()) return self._pbar.pack(fill=X, padx=6, pady=2) self._pbar.start(10) self._status_var.set("Подключение к серверу…") self._fetch_thread = threading.Thread(target=self._fetch_worker, daemon=True) self._fetch_thread.start() def _fetch_worker(self): try: client = IMAPClient( host = self.db.get_setting(SETTING_IMAP_HOST, ''), port = self.db.get_setting(SETTING_IMAP_PORT, '993'), user = self.db.get_setting(SETTING_IMAP_USER, ''), password = self.db.get_setting(SETTING_IMAP_PASS, ''), use_ssl = self.db.get_setting(SETTING_IMAP_SSL, '1') == '1', folder = self.db.get_setting(SETTING_IMAP_FOLDER, 'INBOX'), ) client.connect() self.after(0, lambda: self._status_var.set("Получение писем…")) # Дата последней проверки last = self.db.get_setting(SETTING_LAST_CHECK, '') if last: dt = datetime.strptime(last[:10], '%Y-%m-%d') since = dt.strftime('%d-%b-%Y') messages = client.fetch_since(since, limit=500) else: messages = client.fetch_unseen(limit=300) client.disconnect() items = [] for msg in messages: item = self._parse_message(msg) if item and self._passes_import_filters(item): items.append(item) self.db.set_setting(SETTING_LAST_CHECK, datetime.now().strftime('%Y-%m-%d %H:%M:%S')) self.after(0, lambda: self._on_fetched(items)) except Exception as e: logger.error(f"mail fetch: {e}", exc_info=True) self.after(0, lambda err=e: self._on_fetch_error(str(err))) def _parse_message(self, msg): """Разбирает email.message.Message → dict с нужными полями.""" try: subject = _dec(msg.get('Subject', '')) from_hdr = _dec(msg.get('From', '')) to_hdr = _dec(msg.get('To', '')) date_hdr = msg.get('Date', '') body = _get_body(msg) # From → имя + email m = re.match(r'^(.*?)\s*<([^>]+)>', from_hdr) if m: from_name = m.group(1).strip().strip('"') from_addr = m.group(2).strip() else: from_name = '' from_addr = from_hdr.strip() # To → первый получатель to_name, to_email = extract_expert_from_to(to_hdr) # Маппинг email → имя сотрудника expert_name = self._email_map.get(to_email.lower(), to_name or to_email) # Дата dt = _parse_date(date_hdr) date_str = dt.strftime('%Y-%m-%d %H:%M') if dt else date_hdr[:20] # Классификация rule = self._classifier.classify(subject, from_addr, body) if rule and rule.get('action') == 'ignore': return None category = rule['name'] if rule else 'Без категории' action = rule.get('action', 'info') if rule else 'info' tag = rule.get('tag', 'info') if rule else 'info' case_type = rule.get('case_type', '') if rule else '' # Номер дела case_number = extract_case_number(subject) address = extract_address(subject) region = extract_region(subject, address) urls = _extract_urls(body) return { 'date_str': date_str, 'category': category, 'from_name': from_name or from_addr, 'from_addr': from_addr, 'to_str': to_hdr[:120], 'to_email': to_email, 'expert': expert_name, 'subject': subject, 'body': body, 'case_number': case_number or '', 'case_type': case_type, 'address': address, 'region': region, 'urls': urls, 'action': action, 'tag': tag, } except Exception as e: logger.error(f"parse_message: {e}") return None def _on_fetched(self, items): self._pbar.stop() self._pbar.pack_forget() self._items = items self._status_var.set(f"Загружено писем: {len(items)}") self._update_cat_filter() self._apply_filter() def _on_fetch_error(self, err): self._pbar.stop() self._pbar.pack_forget() self._status_var.set(f"Ошибка: {err}") messagebox.showerror("Ошибка получения почты", err, parent=self.winfo_toplevel()) # ── Фильтрация ───────────────────────────────────────────────────────── def _update_cat_filter(self): cats = sorted(set(i['category'] for i in self._items)) self.cb_cat['values'] = ['Все'] + cats def _apply_filter(self): cat_f = self.var_filter_cat.get() search_f = self.var_search.get().lower().strip() for item in self.tree.get_children(): self.tree.delete(item) for i, item in enumerate(self._items): if cat_f != 'Все' and item['category'] != cat_f: continue if search_f and not any( search_f in str(v).lower() for v in [item['subject'], item['from_name'], item['case_number'], item['expert']] ): continue tag = item.get('tag', 'info') self.tree.insert('', 'end', iid=str(i), tags=(tag,), values=( item['date_str'][:16], item['category'], item['from_name'][:30], item['subject'][:80], item['case_number'], item['expert'][:30], ACTION_LABELS.get(item['action'], item['action']), )) # ── Действия ─────────────────────────────────────────────────────────── def _selected_item(self): sel = self.tree.selection() if not sel: return None return self._items[int(sel[0])] def _on_double_click(self, event=None): item = self._selected_item() if item: MailDetailDialog(self.winfo_toplevel(), item, self.db, self._email_map) def _import_selected(self): item = self._selected_item() if not item: return action = item.get('action', 'info') if action == 'deadline': self._do_import_deadline(item) elif action == 'vacation': messagebox.showinfo("Отпуск", f"Перейдите в модуль Отпуска и создайте запись вручную.\n" f"Сотрудник: {item['from_name']}\nДата: {item['date_str'][:10]}", parent=self.winfo_toplevel()) elif action == 'doplata': messagebox.showinfo("Доплата", f"Перейдите в Зарплата → Доплаты и создайте запись вручную.\n" f"Сотрудник: {item['from_name']}\nДата: {item['date_str'][:10]}", parent=self.winfo_toplevel()) elif action == 'false_trip': messagebox.showinfo("Ложный выезд", f"Сводка ложного выезда от {item['date_str'][:10]}.\n" f"Отправитель: {item['from_name']}", parent=self.winfo_toplevel()) else: messagebox.showinfo("Письмо", f"Действие не определено (action={action!r}).\n" "Откройте письмо двойным кликом для ручного импорта.", parent=self.winfo_toplevel()) def _do_import_deadline(self, item): case_number = item.get('case_number', '') if not case_number: messagebox.showwarning("Нет номера дела", "Не удалось извлечь номер дела из темы письма.", parent=self.winfo_toplevel()) return expert_name = self._email_map.get(item.get('to_email', '').lower(), item.get('expert', '')) load_date = datetime.now().strftime('%Y-%m-%d') data = { 'case_number': case_number, 'create_date': item.get('date_str', '')[:10], 'region': item.get('region', ''), 'services_list': item.get('case_type', ''), 'expert_calculation': expert_name, 'comment': f"Почта: {item.get('subject', '')[:100]}", 'deadline': 0, 'load_date': load_date, } try: self.db.save_deadline_case(data) messagebox.showinfo("Готово", f"Дело {case_number} создано в Контроле сроков.", parent=self.winfo_toplevel()) except Exception as e: messagebox.showerror("Ошибка", str(e), parent=self.winfo_toplevel())