diff --git a/.gitignore b/.gitignore index b11629c..e5d569b 100644 --- a/.gitignore +++ b/.gitignore @@ -188,3 +188,23 @@ cython_debug/ # PyPI configuration file .pypirc +# Виртуальное окружение +venv/ +env/ +ENV/ + +# Кэш Python +__pycache__/ +*.py[cod] +*$py.class + +# Файлы сборки PyInstaller +build/ +dist/ +*.spec + +# Конфигурационные файлы с личными данными +config.json + +# Логи и временные файлы +*.log diff --git a/README.md b/README.md index 1ea8c46..a963416 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,48 @@ -# YandexMailArhivator +# Yandex Mail Archiver to .EML (MVC Model) -Скачивает письма с Яндекс почты в формате .eml ничего не удаляя с ящика. \ No newline at end of file +Разработана с целью архивации писем со вложениями в локальную папку и дальнейшей ручной очистки переполненных почтовых ящиков. + +## ✨ Особенности +- Архитектура MVC: Код разделен на независимые слои, легко масштабируется и читается. +- Безопасный бэкап: Приложение не удаляет письма автоматически. Вы сами проверяете архив и очищаете сервер Яндекса. +- Сохранение вложений: Формат .eml сохраняет структуру письма, HTML-разметку и встроенные файлы. +- Современный UI: Графический интерфейс на базе customtkinter с поддержкой темных/светлых системных тем. +- Сохранение настроек: Настройки авторизации и пути сохраняются в локальный config.json. + +## 🗝️ Подготовка к работе +Для подключения к Яндекс Почте необходимо использовать Пароль приложения: +1. Перейдите в настройки Яндекс ID -> Безопасность -> Пароли приложений. +2. Создайте новый пароль приложения для типа Почта (IMAP). +3. Используйте созданный пароль в программе вместо основного пароля от аккаунта. + +## 🚀 Запуск из исходного кода +1. Создайте и активируйте виртуальное окружение: +```bash +python -m venv venv +``` + # Для Windows: +```bash +venv\Scripts\activate +``` +2. Установите зависимости: +```bash +pip install -r requirements.txt +``` +3. Запустите программу: +```bash +python main.py +``` + +## 📦 Сборка в автономный .EXE файл +Для упаковки утилиты в один исполняемый файл для Windows со всеми ресурсами и метаданными: +```bash +pip install pyinstaller pillow +``` +```bash +pyinstaller --noconfirm --onefile --windowed --icon="app_icon.ico" --version-file="version.txt" --add-data "venv/Lib/site-packages/customtkinter;customtkinter/" --name "YandexMailArchiver" main.py +``` + +Готовый файл YandexMailArchiver.exe появится в каталоге dist/. + +## 🛠️ Совместимость +Рекомендуется использовать совместно с портативной утилитой просмотра [MiTeC Mail Viewer](https://www.mitec.cz/wp/mmv/). Она позволяет открывать созданную папку как локальную базу писем с удобным поиском. diff --git a/app_icon.ico b/app_icon.ico new file mode 100644 index 0000000..62fe3a4 Binary files /dev/null and b/app_icon.ico differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..a49810c --- /dev/null +++ b/main.py @@ -0,0 +1,15 @@ +from src.model import MailArchiverModel +from src.view import MailArchiverView +from src.controller import MailArchiverController + +def main(): + # Создаем триаду архитектуры MVC + model = MailArchiverModel() + view = MailArchiverView() + controller = MailArchiverController(model, view) + + # Запускаем приложение + view.mainloop() + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8bc3abb --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +customtkinter>=5.2.0 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/controller.py b/src/controller.py new file mode 100644 index 0000000..61016ad --- /dev/null +++ b/src/controller.py @@ -0,0 +1,75 @@ +import threading +from src.model import MailArchiverModel +from src.view import MailArchiverView + +class MailArchiverController: + def __init__(self, model: MailArchiverModel, view: MailArchiverView): + self.model = model + self.view = view + + # Связываем кнопку интерфейса с методом контроллера + self.view.btn_start.configure(command=self.start_backup_process) + + # Загружаем сохраненные данные при старте + self._load_saved_settings() + + def _load_saved_settings(self): + config = self.model.load_config() + self.view.set_form_data( + config.get("email", ""), + config.get("password", ""), + config.get("path", "") + ) + + def start_backup_process(self): + # Запуск в потоке, чтобы GUI приложения оставался отзывчивым + worker = threading.Thread(target=self._async_backup, daemon=True) + worker.start() + + def _async_backup(self): + email_user = self.view.entry_email.get().strip() + password_app = self.view.entry_pass.get().strip() + output_dir = self.view.entry_path.get().strip() + + if not email_user or not password_app or not output_dir: + self.view.after(0, self.view.show_error, "Ошибка заполнения", "Пожалуйста, заполните все поля формы настройки.") + return + + # Автоматически сохраняем успешно введенные данные + self.model.save_config(email_user, password_app, output_dir) + + self.view.after(0, lambda: self.view.btn_start.configure(state="disabled")) + self.view.after(0, self.view.update_progress, 0) + mail_client = None + + try: + self.view.after(0, self.view.log_message, ">> Инициализация защищенного подключения...") + mail_client, mail_ids = self.model.connect_and_fetch_ids(email_user, password_app) + + total_emails = len(mail_ids) + self.view.after(0, self.view.log_message, f">> Подключение успешно! Найдено писем в INBOX: {total_emails}") + + if total_emails == 0: + self.view.after(0, self.view.log_message, ">> Нет писем для скачивания.") + return + + for index, m_id in enumerate(mail_ids, 1): + subject = self.model.download_single_email(mail_client, m_id, output_dir) + + self.view.after(0, self.view.log_message, f"[{index}/{total_emails}] Успешно: {subject}") + self.view.after(0, self.view.update_progress, index / total_emails) + + self.view.after(0, self.view.log_message, "\n>> [ГОТОВО] Все письма успешно скачаны в облачную папку!") + self.view.after(0, self.view.show_info, "Успех", f"Архивация завершена.\nСкачано файлов: {total_emails}") + + except Exception as err: + self.view.after(0, self.view.log_message, f"\n[КРИТИЧЕСКИЙ СБОЙ]: {str(err)}") + self.view.after(0, self.view.show_error, "Ошибка синхронизации", f"Произошел сбой: {str(err)}") + + finally: + if mail_client: + try: + mail_client.logout() + except: + pass + self.view.after(0, lambda: self.view.btn_start.configure(state="normal")) \ No newline at end of file diff --git a/src/model.py b/src/model.py new file mode 100644 index 0000000..abc9687 --- /dev/null +++ b/src/model.py @@ -0,0 +1,88 @@ +import imaplib +import email +import os +import re +import json + +CONFIG_FILE = "config.json" + +class MailArchiverModel: + def __init__(self): + self.imap_server = "imap.yandex.ru" + self.imap_port = 993 + + def load_config(self) -> dict: + """Загружает сохраненные настройки из JSON-файла.""" + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {"email": "", "password": "", "path": ""} + + def save_config(self, email_user: str, password_app: str, output_dir: str): + """Сохраняет настройки формы в JSON-файл.""" + data = { + "email": email_user, + "password": password_app, + "path": output_dir + } + try: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + except Exception as e: + print(f"Ошибка сохранения конфига: {e}") + + def clean_filename(self, title: str) -> str: + """Очищает строку от запрещенных в Windows символов для имени файла.""" + return re.sub(r'[\\/*?:"<>|]', "", title)[:60] + + def connect_and_fetch_ids(self, email_user: str, password_app: str, folder: str = "INBOX"): + """Подключается к серверу и возвращает список ID писем.""" + mail = imaplib.IMAP4_SSL(self.imap_server, self.imap_port) + mail.login(email_user, password_app) + mail.select(folder, readonly=True) + + status, data = mail.search(None, "ALL") + if status != "OK" or not data or not data[0]: + return mail, [] + + mail_ids = data[0].split() + return mail, mail_ids + + def download_single_email(self, mail_instance, mail_id: bytes, output_dir: str) -> str: + """Скачивает одно письмо по его ID и сохраняет в формате .eml.""" + status, msg_data = mail_instance.fetch(mail_id, "(RFC822)") + if status != "OK": + raise Exception(f"Ошибка при скачивании письма ID {mail_id.decode()}") + + for response_part in msg_data: + if isinstance(response_part, tuple): + raw_email = response_part[1] + msg = email.message_from_bytes(raw_email) + + raw_subject = msg.get("Subject", "NoSubject") + decoded_parts = email.header.decode_header(raw_subject) + + subject_text = "" + for part, encoding in decoded_parts: + if isinstance(part, bytes): + subject_text += part.decode(encoding or 'utf-8', errors='ignore') + else: + subject_text += str(part) + + clean_subject = self.clean_filename(subject_text) + mail_id_str = mail_id.decode() + + if not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) + + filename = f"mail_{mail_id_str}_{clean_subject}.eml" + filepath = os.path.join(output_dir, filename) + + with open(filepath, "wb") as f: + f.write(raw_email) + + return subject_text if subject_text else "Без темы" + raise Exception("Данные письма повреждены или пусты.") \ No newline at end of file diff --git a/src/view.py b/src/view.py new file mode 100644 index 0000000..c0c9282 --- /dev/null +++ b/src/view.py @@ -0,0 +1,90 @@ +import customtkinter as ctk +from tkinter import filedialog, messagebox + +class MailArchiverView(ctk.CTk): + def __init__(self): + super().__init__() + + # Настройки главного окна + self.title("Yandex Mail Archiver v1.0") + self.geometry("650x550") + ctk.set_appearance_mode("System") + ctk.set_default_color_theme("blue") + + # --- Сетка разметки --- + self.grid_columnconfigure(0, weight=1) + + # --- Фрейм настроек --- + self.settings_frame = ctk.CTkFrame(self, corner_radius=10) + self.settings_frame.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="nsew") + self.settings_frame.grid_columnconfigure(1, weight=1) + + # Заголовок фрейма + self.label_title = ctk.CTkLabel(self.settings_frame, text="Настройки конфигурации", font=ctk.CTkFont(size=16, weight="bold")) + self.label_title.grid(row=0, column=0, columnspan=3, padx=10, pady=10, sticky="w") + + # Логин + self.lbl_email = ctk.CTkLabel(self.settings_frame, text="Email:") + self.lbl_email.grid(row=1, column=0, padx=15, pady=5, sticky="w") + self.entry_email = ctk.CTkEntry(self.settings_frame, placeholder_text="example@yandex.ru") + self.entry_email.grid(row=1, column=1, columnspan=2, padx=15, pady=5, sticky="we") + + # Пароль + self.lbl_pass = ctk.CTkLabel(self.settings_frame, text="Пароль приложения:") + self.lbl_pass.grid(row=2, column=0, padx=15, pady=5, sticky="w") + self.entry_pass = ctk.CTkEntry(self.settings_frame, placeholder_text="xxxx-xxxx-xxxx-xxxx", show="*") + self.entry_pass.grid(row=2, column=1, columnspan=2, padx=15, pady=5, sticky="we") + + # Путь к Nextcloud + self.lbl_path = ctk.CTkLabel(self.settings_frame, text="Папка назначения:") + self.lbl_path.grid(row=3, column=0, padx=15, pady=5, sticky="w") + self.entry_path = ctk.CTkEntry(self.settings_frame, placeholder_text="Путь к локальной папке Nextcloud") + self.entry_path.grid(row=3, column=1, padx=(15, 5), pady=5, sticky="we") + + self.btn_browse = ctk.CTkButton(self.settings_frame, text="Обзор", width=80, command=self._browse_folder) + self.btn_browse.grid(row=3, column=2, padx=(5, 15), pady=5) + + # --- Кнопка старта --- + self.btn_start = ctk.CTkButton(self, text="ЗАПУСТИТЬ РЕЗЕРВНОЕ КОПИРОВАНИЕ", font=ctk.CTkFont(weight="bold"), height=40) + self.btn_start.grid(row=1, column=0, padx=20, pady=10, sticky="we") + + # --- Прогресс бар --- + self.progress = ctk.CTkProgressBar(self) + self.progress.grid(row=2, column=0, padx=20, pady=5, sticky="we") + self.progress.set(0) + + # --- Журнал / Консоль логов --- + self.log_text = ctk.CTkTextbox(self, font=ctk.CTkFont(family="Courier", size=12)) + self.log_text.grid(row=3, column=0, padx=20, pady=(5, 20), sticky="nsew") + self.grid_rowconfigure(3, weight=1) + + def set_form_data(self, email_val: str, pass_val: str, path_val: str): + """Заполняет поля формы сохраненными значениями.""" + if email_val: + self.entry_email.delete(0, "end") + self.entry_email.insert(0, email_val) + if pass_val: + self.entry_pass.delete(0, "end") + self.entry_pass.insert(0, pass_val) + if path_val: + self.entry_path.delete(0, "end") + self.entry_path.insert(0, path_val) + + def _browse_folder(self): + folder = filedialog.askdirectory() + if folder: + self.entry_path.delete(0, "end") + self.entry_path.insert(0, folder) + + def log_message(self, message: str): + self.log_text.insert("end", message + "\n") + self.log_text.see("end") + + def update_progress(self, val: float): + self.progress.set(val) + + def show_error(self, title: str, text: str): + messagebox.showerror(title, text) + + def show_info(self, title: str, text: str): + messagebox.showinfo(title, text) \ No newline at end of file diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..8135da2 --- /dev/null +++ b/version.txt @@ -0,0 +1,46 @@ +# UTF-8 +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 0, 0, 0), + prodvers=(1, 0, 0, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + u'041904b0', + [ + StringStruct(u'CompanyName', u'ООО "Альянс - Технологии Сервиса"'), + StringStruct(u'FileDescription', u'Yandex Mail Archiver - Утилита резервного копирования почты Яндекс'), + StringStruct(u'FileVersion', u'1.0.0.0'), + StringStruct(u'InternalName', u'YandexMailArchiver'), + StringStruct(u'LegalCopyright', u'Copyright © 2026. Все права защищены.'), + StringStruct(u'OriginalFilename', u'YandexMailArchiver.exe'), + StringStruct(u'ProductName', u'Yandex Mail Archiver'), + StringStruct(u'ProductVersion', u'1.0.0.0') + ] + ), + StringTable( + u'040904B0', + [ + StringStruct(u'CompanyName', u'ООО "Альянс - Технологии Сервиса"'), + StringStruct(u'FileDescription', u'Yandex Mail Archiver - Утилита резервного копирования почты Яндекс'), + StringStruct(u'FileVersion', u'1.0.0.0'), + StringStruct(u'InternalName', u'YandexMailArchiver'), + StringStruct(u'LegalCopyright', u'Copyright © 2026. Все права защищены.'), + StringStruct(u'OriginalFilename', u'YandexMailArchiver.exe'), + StringStruct(u'ProductName', u'Yandex Mail Archiver'), + StringStruct(u'ProductVersion', u'1.0.0.0') + ] + ) + ] + ), + VarFileInfo([VarStruct(u'Translation', [1049, 1200, 1033, 1200])]) + ] +) \ No newline at end of file