Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import threading | |
| import time | |
| from datetime import datetime, timedelta | |
| from uuid import uuid4 | |
| import random | |
| import string | |
| import tempfile | |
| import smtplib | |
| from email.message import EmailMessage | |
| import mimetypes | |
| import dns.resolver | |
| from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, session, Response | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError | |
| from dotenv import load_dotenv | |
| import requests | |
| load_dotenv() | |
| app = Flask(__name__) | |
| app.secret_key = 'super_secret_key_mail_app_123_direct_smtp' | |
| app.config.update( | |
| SESSION_COOKIE_SAMESITE='None', | |
| SESSION_COOKIE_SECURE=True, | |
| PERMANENT_SESSION_LIFETIME=timedelta(days=30) | |
| ) | |
| DATA_FILE = 'data.json' | |
| SYNC_FILES = [DATA_FILE] | |
| REPO_ID = os.getenv("REPO_ID", "Kgshop/mail") | |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") | |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") | |
| data_lock = threading.Lock() | |
| def get_almaty_time(): | |
| return (datetime.utcnow() + timedelta(hours=5)).strftime('%Y-%m-%d %H:%M:%S') | |
| def download_db_from_hf(specific_file=None, retries=3, delay=5): | |
| token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE | |
| files_to_download = [specific_file] if specific_file else SYNC_FILES | |
| all_successful = True | |
| for file_name in files_to_download: | |
| success = False | |
| for attempt in range(retries + 1): | |
| try: | |
| hf_hub_download( | |
| repo_id=REPO_ID, | |
| filename=file_name, | |
| repo_type="dataset", | |
| token=token_to_use, | |
| local_dir=".", | |
| local_dir_use_symlinks=False, | |
| force_download=True, | |
| resume_download=False | |
| ) | |
| success = True | |
| break | |
| except RepositoryNotFoundError: | |
| return False | |
| except HfHubHTTPError as e: | |
| if e.response.status_code == 404: | |
| if attempt == 0 and not os.path.exists(file_name): | |
| try: | |
| if file_name == DATA_FILE: | |
| with data_lock: | |
| fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(DATA_FILE)) or '.', text=True) | |
| with os.fdopen(fd, 'w', encoding='utf-8') as f: | |
| json.dump({}, f) | |
| os.replace(temp_path, DATA_FILE) | |
| except Exception: | |
| pass | |
| success = False | |
| break | |
| except requests.exceptions.RequestException: | |
| pass | |
| except Exception: | |
| pass | |
| if attempt < retries: | |
| time.sleep(delay) | |
| if not success: | |
| all_successful = False | |
| return all_successful | |
| def upload_db_to_hf(specific_file=None): | |
| if not HF_TOKEN_WRITE: | |
| return | |
| try: | |
| api = HfApi() | |
| files_to_upload = [specific_file] if specific_file else SYNC_FILES | |
| for file_name in files_to_upload: | |
| if os.path.exists(file_name): | |
| try: | |
| api.upload_file( | |
| path_or_fileobj=file_name, | |
| path_in_repo=file_name, | |
| repo_id=REPO_ID, | |
| repo_type="dataset", | |
| token=HF_TOKEN_WRITE, | |
| commit_message=f"Sync {file_name} {get_almaty_time()}" | |
| ) | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| def upload_attachment(file_obj, repo_path): | |
| if not HF_TOKEN_WRITE: | |
| return None | |
| try: | |
| ext = os.path.splitext(file_obj.filename)[1].lower() | |
| filename = f"{uuid4().hex}{ext}" | |
| fd, temp_path = tempfile.mkstemp() | |
| with os.fdopen(fd, 'wb') as f: | |
| f.write(file_obj.read()) | |
| file_obj.seek(0) | |
| api = HfApi() | |
| api.upload_file( | |
| path_or_fileobj=temp_path, | |
| path_in_repo=f"{repo_path}/{filename}", | |
| repo_id=REPO_ID, | |
| repo_type="dataset", | |
| token=HF_TOKEN_WRITE | |
| ) | |
| if os.path.exists(temp_path): | |
| os.remove(temp_path) | |
| return filename | |
| except Exception: | |
| return None | |
| def periodic_backup(): | |
| while True: | |
| time.sleep(1800) | |
| upload_db_to_hf() | |
| def load_data(): | |
| with data_lock: | |
| data = {} | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as file: | |
| data = json.load(file) | |
| if not isinstance(data, dict): | |
| raise FileNotFoundError | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| if download_db_from_hf(specific_file=DATA_FILE): | |
| try: | |
| with open(DATA_FILE, 'r', encoding='utf-8') as file: | |
| data = json.load(file) | |
| except Exception: | |
| data = {} | |
| else: | |
| data = {} | |
| changed = False | |
| for env_id, env_data in data.items(): | |
| if 'emails' not in env_data: env_data['emails'] = []; changed = True | |
| if 'contacts' not in env_data: env_data['contacts'] = []; changed = True | |
| if 'settings' not in env_data: | |
| env_data['settings'] = {} | |
| changed = True | |
| settings = env_data['settings'] | |
| if 'organization_name' not in settings: settings['organization_name'] = f'Mail Client {env_id}'; changed = True | |
| if 'admin_password_enabled' not in settings: settings['admin_password_enabled'] = False; changed = True | |
| if 'admin_password' not in settings: settings['admin_password'] = ''; changed = True | |
| if 'smtp_user' not in settings: settings['smtp_user'] = ''; changed = True | |
| if 'sender_name' not in settings: settings['sender_name'] = ''; changed = True | |
| if 'is_deleted' not in settings: settings['is_deleted'] = False; changed = True | |
| if 'smtp_host' in settings: del settings['smtp_host']; changed = True | |
| if 'smtp_port' in settings: del settings['smtp_port']; changed = True | |
| if 'smtp_pass' in settings: del settings['smtp_pass']; changed = True | |
| if changed or not os.path.exists(DATA_FILE): | |
| try: | |
| fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(DATA_FILE)) or '.', text=True) | |
| with os.fdopen(fd, 'w', encoding='utf-8') as f: | |
| json.dump(data, f) | |
| os.replace(temp_path, DATA_FILE) | |
| except Exception: | |
| pass | |
| return data | |
| def save_data(data): | |
| try: | |
| if not isinstance(data, dict): | |
| return | |
| with data_lock: | |
| fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(DATA_FILE)) or '.', text=True) | |
| with os.fdopen(fd, 'w', encoding='utf-8') as file: | |
| json.dump(data, file, ensure_ascii=False, indent=4) | |
| os.replace(temp_path, DATA_FILE) | |
| upload_db_to_hf(specific_file=DATA_FILE) | |
| except Exception: | |
| pass | |
| def get_env_data(env_id): | |
| all_data = load_data() | |
| if env_id not in all_data: | |
| all_data[env_id] = { | |
| 'emails': [], | |
| 'contacts': [], | |
| 'settings': { | |
| 'organization_name': f'Mail Client {env_id}', | |
| 'admin_password_enabled': False, | |
| 'admin_password': '', | |
| 'smtp_user': '', | |
| 'sender_name': '', | |
| 'is_deleted': False | |
| } | |
| } | |
| save_data(all_data) | |
| return all_data[env_id] | |
| def save_env_data(env_id, env_data): | |
| all_data = load_data() | |
| all_data[env_id] = env_data | |
| save_data(all_data) | |
| def check_deleted_env(): | |
| if request.endpoint and request.view_args and 'env_id' in request.view_args: | |
| env_id = request.view_args['env_id'] | |
| if env_id in ['admhosto']: | |
| return | |
| all_data = load_data() | |
| if env_id in all_data and all_data[env_id].get('settings', {}).get('is_deleted', False): | |
| return "<h1 style='text-align:center; margin-top:20vh; font-family:sans-serif;'>Клиент отключен</h1>", 403 | |
| def send_email_direct(settings, to_email, subject, body, files): | |
| msg = EmailMessage() | |
| msg['Subject'] = subject | |
| sender_name = settings.get('sender_name', '').strip() | |
| smtp_user = settings.get('smtp_user', '').strip() | |
| if not smtp_user: | |
| raise Exception("Email отправителя не настроен в админ панели.") | |
| if sender_name: | |
| msg['From'] = f"{sender_name} <{smtp_user}>" | |
| else: | |
| msg['From'] = smtp_user | |
| msg['To'] = to_email | |
| msg.set_content(body) | |
| for f in files: | |
| if f and f.filename: | |
| file_data = f.read() | |
| ctype, encoding = mimetypes.guess_type(f.filename) | |
| if ctype is None or encoding is not None: | |
| ctype = 'application/octet-stream' | |
| maintype, subtype = ctype.split('/', 1) | |
| msg.add_attachment(file_data, maintype=maintype, subtype=subtype, filename=f.filename) | |
| f.seek(0) | |
| try: | |
| domain = to_email.split('@')[1] | |
| records = dns.resolver.resolve(domain, 'MX') | |
| mx_record = sorted(records, key=lambda x: x.preference)[0].exchange.to_text() | |
| except Exception as e: | |
| raise Exception(f"Не удалось определить MX сервер для домена {to_email.split('@')[-1]}: {str(e)}") | |
| try: | |
| with smtplib.SMTP(mx_record, 25, timeout=15) as server: | |
| server.ehlo() | |
| if server.has_extn('STARTTLS'): | |
| server.starttls() | |
| server.ehlo() | |
| server.send_message(msg) | |
| except Exception as e: | |
| raise Exception(f"Ошибка при доставке письма на сервер {mx_record}: {str(e)}") | |
| LOGIN_TEMPLATE = ''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Вход</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Montserrat', sans-serif; background-color: #f4f6f9; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; } | |
| .login-container { background: #fff; padding: 40px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); text-align: center; width: 100%; max-width: 350px; } | |
| h2 { color: #135D66; margin-bottom: 20px; } | |
| input[type="password"] { width: 100%; padding: 16px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; font-size: 16px; } | |
| button { width: 100%; padding: 16px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 16px; transition: background 0.3s; min-height: 44px; touch-action: manipulation; } | |
| button:hover { background-color: #77E4D8; } | |
| .error { color: #E57373; margin-bottom: 15px; font-size: 0.9rem; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="login-container"> | |
| <h2>Вход</h2> | |
| {% with messages = get_flashed_messages(with_categories=true) %} | |
| {% if messages %} | |
| {% for category, message in messages %} | |
| <div class="error">{{ message }}</div> | |
| {% endfor %} | |
| {% endif %} | |
| {% endwith %} | |
| <form method="POST" action="{{ request.full_path }}"> | |
| <input type="password" name="password" placeholder="Введите пароль" required autofocus> | |
| <button type="submit">Войти</button> | |
| </form> | |
| </div> | |
| </body> | |
| </html> | |
| ''' | |
| ADMHOSTO_TEMPLATE = ''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Управление клиентами</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> | |
| <style> | |
| :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; } | |
| body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; margin: 0; } | |
| .container { max-width: 900px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); } | |
| h1 { font-weight: 600; color: var(--bg-medium); margin-bottom: 25px; text-align: center; } | |
| .section { margin-bottom: 30px; } | |
| .add-env-form { margin-bottom: 20px; text-align: center; } | |
| #search-env { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; font-size: 16px; font-family: 'Montserrat', sans-serif; } | |
| .button { padding: 12px 18px; border: none; border-radius: 6px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; min-height: 44px; touch-action: manipulation; } | |
| .button:hover { background-color: var(--accent-hover); } | |
| .env-list { list-style: none; padding: 0; } | |
| .env-item { background: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 15px; } | |
| .env-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; } | |
| .env-id { font-weight: 600; color: var(--bg-medium); font-size: 1.2rem; } | |
| .env-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; } | |
| .env-pwd { background: #f1f3f5; padding: 10px; border-radius: 6px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: space-between; } | |
| .env-pwd input[type="text"] { padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 16px; } | |
| .delete-button { background-color: var(--danger); color: white; } | |
| .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; } | |
| .message.success { background-color: #d4edda; color: #155724; } | |
| .message.error { background-color: #f8d7da; color: #721c24; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1><i class="fas fa-server"></i> Почтовые клиенты</h1> | |
| {% with messages = get_flashed_messages(with_categories=true) %} | |
| {% if messages %} | |
| {% for category, message in messages %} | |
| <div class="message {{ category }}">{{ message }}</div> | |
| {% endfor %} | |
| {% endif %} | |
| {% endwith %} | |
| <div class="section"> | |
| <form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form"> | |
| <button type="submit" class="button"><i class="fas fa-plus-circle"></i> Создать клиента</button> | |
| </form> | |
| </div> | |
| <div class="section"> | |
| <input type="text" id="search-env" placeholder="Поиск..."> | |
| </div> | |
| <div class="section"> | |
| <h2>Активные клиенты</h2> | |
| <ul class="env-list"> | |
| {% for env in active_envs %} | |
| <li class="env-item active-env-item"> | |
| <div class="env-header"> | |
| <span class="env-id">{{ env.org_name }} (ID: {{ env.id }})</span> | |
| <div class="env-actions"> | |
| <a href="{{ url_for('mail_client', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-envelope"></i> Почта</a> | |
| <a href="{{ url_for('admin', env_id=env.id) }}" class="button" style="background:#e67e22; color:white;" target="_blank"><i class="fas fa-cogs"></i> Настройки</a> | |
| <form method="POST" action="/admhosto/delete/{{ env.id }}" style="display:inline;" onsubmit="return confirm('Отключить клиента {{ env.id }}?');"> | |
| <button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button> | |
| </form> | |
| </div> | |
| </div> | |
| <div class="env-pwd"> | |
| <form method="POST" action="{{ url_for('update_env_pwd', env_id=env.id) }}" style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;"> | |
| <label><input type="checkbox" name="pwd_enabled" {% if env.pwd_enabled %}checked{% endif %}> Пароль доступа</label> | |
| <input type="text" name="password" value="{{ env.password }}" placeholder="Пароль"> | |
| <button type="submit" class="button" style="padding: 8px 12px; font-size: 0.9rem;">Сохранить</button> | |
| </form> | |
| </div> | |
| </li> | |
| {% endfor %} | |
| </ul> | |
| </div> | |
| <div class="section" style="margin-top: 40px; padding-top: 20px; border-top: 2px dashed #ccc;"> | |
| <h2 style="color: #e17055;">Архив</h2> | |
| <ul class="env-list"> | |
| {% for env in archived_envs %} | |
| <li class="env-item archive-env-item"> | |
| <div class="env-header"> | |
| <span class="env-id" style="color: #636e72;">{{ env.org_name }} (ID: {{ env.id }})</span> | |
| <div class="env-actions"> | |
| <form method="POST" action="/admhosto/restore/{{ env.id }}" style="display:inline;"> | |
| <button type="submit" class="button" style="background:#27ae60;"><i class="fas fa-undo"></i> Восстановить</button> | |
| </form> | |
| <form method="POST" action="/admhosto/hard_delete/{{ env.id }}" style="display:inline;" onsubmit="return confirm('Удалить окончательно {{ env.id }}? Это действие необратимо!');"> | |
| <button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i> Окончательно</button> | |
| </form> | |
| </div> | |
| </div> | |
| </li> | |
| {% endfor %} | |
| </ul> | |
| </div> | |
| </div> | |
| <script> | |
| document.getElementById('search-env').addEventListener('input', function() { | |
| const searchTerm = this.value.toLowerCase().trim(); | |
| const envItems = document.querySelectorAll('.env-item'); | |
| envItems.forEach(item => { | |
| const envId = item.querySelector('.env-id').textContent.toLowerCase(); | |
| if (envId.includes(searchTerm)) { item.style.display = 'flex'; } else { item.style.display = 'none'; } | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| ''' | |
| ADMIN_TEMPLATE = ''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <title>Настройки клиента</title> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> | |
| <style> | |
| :root { --primary: #2d3436; --bg: #f4f6f9; --surface: #ffffff; --border: #e0e6ed; --danger: #ff7675; --success: #00b894; --info: #0984e3; --warning: #f39c12; } | |
| * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } | |
| body { background: var(--bg); padding: max(20px, env(safe-area-inset-top)) 15px calc(20px + env(safe-area-inset-bottom)); margin: 0; color: #2d3436; } | |
| .container { max-width: 800px; margin: 0 auto; } | |
| .header-panel { background: var(--surface); padding: 20px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; } | |
| .header-panel h1 { margin: 0; font-size: 1.5rem; font-weight: 800; } | |
| .btn { padding: 14px 20px; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-size: 16px; transition: opacity 0.2s; min-height: 44px; touch-action: manipulation; } | |
| .btn:active { opacity: 0.8; } | |
| .btn-primary { background: var(--info); } | |
| .btn-success { background: var(--success); } | |
| .btn-danger { background: var(--danger); } | |
| .card { background: var(--surface); padding: 20px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; } | |
| input[type="email"], input[type="text"], input[type="password"] { width: 100%; padding: 16px 20px; border: 1px solid var(--border); border-radius: 12px; font-size: 16px; outline: none; transition: border-color 0.2s; background: #fafafa; box-sizing: border-box; } | |
| input:focus { border-color: var(--info); background: #fff; } | |
| .settings-row { display: flex; align-items: center; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; } | |
| .settings-row label { flex: 1; min-width: 150px; font-weight: 600; font-size: 1.05rem; } | |
| .settings-row input { flex: 3; } | |
| .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; } | |
| .message.success { background-color: #d4edda; color: #155724; } | |
| .message.error { background-color: #f8d7da; color: #721c24; } | |
| @media (max-width: 600px) { | |
| .settings-row { flex-direction: column; align-items: stretch; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header-panel"> | |
| <h1><i class="fas fa-cogs"></i> Настройки ({{ env_id }})</h1> | |
| <div style="display:flex; gap:10px; flex-wrap:wrap;"> | |
| <a href="/{{ env_id }}/mail" class="btn btn-primary"><i class="fas fa-envelope"></i> К почте</a> | |
| {% if settings.admin_password_enabled %} | |
| <a href="/{{ env_id }}/logout" class="btn btn-danger"><i class="fas fa-sign-out-alt"></i> Выход</a> | |
| {% endif %} | |
| </div> | |
| </div> | |
| {% with messages = get_flashed_messages(with_categories=true) %} | |
| {% if messages %} | |
| {% for category, message in messages %} | |
| <div class="message {{ category }}">{{ message }}</div> | |
| {% endfor %} | |
| {% endif %} | |
| {% endwith %} | |
| <div class="card"> | |
| <form method="POST" action="/{{ env_id }}/admin" onsubmit="this.querySelector('button').innerHTML='<i class=\'fas fa-spinner fa-spin\'></i> Сохранение...';"> | |
| <input type="hidden" name="action" value="update_settings"> | |
| <h2 style="margin-top:0; margin-bottom:20px; font-size:1.2rem; color:var(--primary);"><i class="fas fa-id-card"></i> Основные настройки</h2> | |
| <div class="settings-row"> | |
| <label>Название клиента:</label> | |
| <input type="text" name="organization_name" value="{{ settings.organization_name }}" required> | |
| </div> | |
| <h2 style="margin-top:30px; margin-bottom:20px; font-size:1.2rem; color:var(--primary);"><i class="fas fa-server"></i> Настройки отправителя (Встроенный SMTP)</h2> | |
| <div class="settings-row"> | |
| <label>Email отправителя (от кого):</label> | |
| <input type="email" name="smtp_user" value="{{ settings.smtp_user }}" required placeholder="your@email.com"> | |
| </div> | |
| <div class="settings-row"> | |
| <label>Имя отправителя:</label> | |
| <input type="text" name="sender_name" value="{{ settings.sender_name }}" placeholder="Иван Иванов"> | |
| </div> | |
| <h2 style="margin-top:30px; margin-bottom:20px; font-size:1.2rem; color:var(--primary);"><i class="fas fa-lock"></i> Безопасность</h2> | |
| <div class="settings-row"> | |
| <label style="display:flex; align-items:center; gap:10px; cursor:pointer;"> | |
| <input type="checkbox" name="admin_password_enabled" style="width:auto; transform:scale(1.5);" {% if settings.admin_password_enabled %}checked{% endif %}> | |
| Включить пароль на вход | |
| </label> | |
| <input type="password" name="admin_password" value="{{ settings.admin_password }}" placeholder="Пароль для входа в приложение"> | |
| </div> | |
| <button type="submit" class="btn btn-success" style="width: 100%; justify-content: center; padding: 16px; margin-top: 20px; font-size:1.1rem;"><i class="fas fa-save"></i> Сохранить настройки</button> | |
| </form> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| ''' | |
| MAIL_TEMPLATE = ''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <title>{{ settings.organization_name }}</title> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> | |
| <style> | |
| :root { --primary: #0984e3; --bg: #f4f6f9; --surface: #ffffff; --border: #e0e6ed; --text: #2d3436; --text-muted: #636e72; --danger: #d63031; --success: #00b894; } | |
| * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } | |
| body { background: var(--bg); padding: max(20px, env(safe-area-inset-top)) 15px calc(20px + env(safe-area-inset-bottom)); margin: 0; color: var(--text); display:flex; justify-content:center; } | |
| .container { width: 100%; max-width: 1000px; display:flex; flex-direction:column; gap:20px; } | |
| .header { background: var(--surface); padding: 20px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; } | |
| .header h1 { margin: 0; font-size: 1.5rem; font-weight: 800; color: var(--primary); display:flex; align-items:center; gap:10px; } | |
| .nav-tabs { display:flex; gap:10px; flex-wrap:wrap; } | |
| .nav-tab { padding: 12px 20px; border-radius: 10px; font-weight: 600; cursor: pointer; color: var(--text-muted); background:var(--surface); box-shadow: 0 2px 10px rgba(0,0,0,0.03); transition:all 0.2s; user-select:none; } | |
| .nav-tab.active { background: var(--primary); color: white; } | |
| .view-section { display:none; background: var(--surface); padding: 25px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); } | |
| .view-section.active { display:block; } | |
| .form-group { margin-bottom: 20px; } | |
| .form-group label { display: block; font-weight: 600; margin-bottom: 8px; color: var(--text); } | |
| .form-group input[type="text"], .form-group input[type="email"], .form-group textarea { width: 100%; padding: 16px; border: 1px solid var(--border); border-radius: 12px; font-size: 16px; outline: none; transition: border-color 0.2s; background: #fafafa; font-family:inherit; } | |
| .form-group input:focus, .form-group textarea:focus { border-color: var(--primary); background: #fff; } | |
| .form-group textarea { min-height: 200px; resize: vertical; } | |
| .form-group input[type="file"] { border: 1px dashed #ccc; padding: 15px; width:100%; border-radius:12px; cursor:pointer; } | |
| .btn { padding: 14px 20px; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; color: #fff; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-size: 16px; transition: opacity 0.2s; min-height: 44px; text-decoration:none; } | |
| .btn:active { opacity: 0.8; } | |
| .btn-primary { background: var(--primary); width:100%; } | |
| .btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); } | |
| .email-list { display:flex; flex-direction:column; gap:10px; } | |
| .email-item { padding: 15px; border: 1px solid var(--border); border-radius: 12px; background: #fafafa; cursor: pointer; transition: background 0.2s; } | |
| .email-item:hover { background: #fff; border-color: var(--primary); } | |
| .email-header { display:flex; justify-content:space-between; margin-bottom:5px; font-size:0.9rem; color:var(--text-muted); } | |
| .email-subject { font-weight:700; font-size:1.1rem; margin-bottom:5px; } | |
| .email-body { font-size:0.95rem; color:var(--text); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } | |
| .contact-list { display:flex; flex-direction:column; gap:10px; } | |
| .contact-item { padding: 15px; border: 1px solid var(--border); border-radius: 12px; display:flex; justify-content:space-between; align-items:center; background:#fafafa; } | |
| .contact-info { display:flex; flex-direction:column; gap:5px; } | |
| .contact-name { font-weight:700; font-size:1.05rem; } | |
| .contact-email { color:var(--text-muted); font-size:0.9rem; } | |
| .contact-actions { display:flex; gap:10px; } | |
| .loading-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,0.8); z-index:9999; justify-content:center; align-items:center; flex-direction:column; font-size:1.2rem; color:var(--primary); font-weight:bold; gap:15px; } | |
| .modal { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:1000; justify-content:center; align-items:center; padding:15px; } | |
| .modal-content { background:var(--surface); padding:25px; border-radius:16px; width:100%; max-width:600px; max-height:90vh; overflow-y:auto; position:relative; } | |
| .close-modal { position:absolute; top:20px; right:20px; background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--text-muted); } | |
| .attachment-link { display:inline-flex; align-items:center; gap:5px; background:#e9ecef; padding:8px 12px; border-radius:8px; text-decoration:none; color:var(--primary); font-size:0.9rem; margin-right:10px; margin-top:10px; font-weight:600; } | |
| @media (max-width: 600px) { | |
| .nav-tabs { flex-direction:column; } | |
| .nav-tab { text-align:center; } | |
| .contact-item { flex-direction:column; align-items:flex-start; gap:15px; } | |
| .contact-actions { width:100%; justify-content:flex-end; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="loading-overlay" id="loadingOverlay"> | |
| <i class="fas fa-spinner fa-spin" style="font-size:3rem;"></i> | |
| <span>Отправка письма...</span> | |
| </div> | |
| <div class="container"> | |
| <div class="header"> | |
| <h1><i class="fas fa-paper-plane"></i> {{ settings.organization_name }}</h1> | |
| <div style="display:flex; gap:10px;"> | |
| <a href="/{{ env_id }}/admin" class="btn btn-outline" style="min-height:auto; padding:10px 15px;"><i class="fas fa-cogs"></i></a> | |
| {% if settings.admin_password_enabled %} | |
| <a href="/{{ env_id }}/logout" class="btn btn-outline" style="min-height:auto; padding:10px 15px;"><i class="fas fa-sign-out-alt"></i></a> | |
| {% endif %} | |
| </div> | |
| </div> | |
| <div class="nav-tabs"> | |
| <div class="nav-tab active" onclick="switchView('compose')"><i class="fas fa-pen"></i> Написать</div> | |
| <div class="nav-tab" onclick="switchView('sent')"><i class="fas fa-share-square"></i> Отправленные</div> | |
| <div class="nav-tab" onclick="switchView('contacts')"><i class="fas fa-address-book"></i> Контакты</div> | |
| </div> | |
| <div class="view-section active" id="view-compose"> | |
| <h2 style="margin-top:0; color:var(--primary);"><i class="fas fa-pen"></i> Новое письмо</h2> | |
| <form id="composeForm" onsubmit="sendMail(event)"> | |
| <div class="form-group"> | |
| <label>Кому:</label> | |
| <div style="display:flex; gap:10px;"> | |
| <input type="email" id="to_email" name="to_email" required placeholder="example@domain.com" style="flex:1;"> | |
| <button type="button" class="btn btn-outline" onclick="document.getElementById('contactsModal').style.display='flex'" style="padding:0 20px;"><i class="fas fa-user-plus"></i></button> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label>Тема:</label> | |
| <input type="text" id="subject" name="subject" required placeholder="Тема письма"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Текст сообщения:</label> | |
| <textarea id="body" name="body" required placeholder="Напишите ваше сообщение здесь..."></textarea> | |
| </div> | |
| <div class="form-group"> | |
| <label>Прикрепить файлы:</label> | |
| <input type="file" id="attachments" name="attachments" multiple> | |
| </div> | |
| <button type="submit" class="btn btn-primary"><i class="fas fa-paper-plane"></i> Отправить письмо</button> | |
| </form> | |
| </div> | |
| <div class="view-section" id="view-sent"> | |
| <h2 style="margin-top:0; color:var(--primary);"><i class="fas fa-share-square"></i> Отправленные</h2> | |
| <div class="email-list"> | |
| {% for email in emails|reverse %} | |
| <div class="email-item" onclick="openEmail('{{ email.id }}')"> | |
| <div class="email-header"> | |
| <span>Кому: {{ email.to }}</span> | |
| <span>{{ email.date }}</span> | |
| </div> | |
| <div class="email-subject">{{ email.subject }}</div> | |
| <div class="email-body">{{ email.body[:100] }}...</div> | |
| {% if email.attachments %} | |
| <div style="margin-top:5px; font-size:0.85rem; color:var(--primary);"><i class="fas fa-paperclip"></i> Вложений: {{ email.attachments|length }}</div> | |
| {% endif %} | |
| </div> | |
| {% else %} | |
| <div style="text-align:center; padding:30px; color:var(--text-muted);">Нет отправленных писем</div> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| <div class="view-section" id="view-contacts"> | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; flex-wrap:wrap; gap:15px;"> | |
| <h2 style="margin:0; color:var(--primary);"><i class="fas fa-address-book"></i> Адресная книга</h2> | |
| <button class="btn btn-primary" style="width:auto; padding:10px 15px;" onclick="document.getElementById('addContactModal').style.display='flex'"><i class="fas fa-plus"></i> Добавить</button> | |
| </div> | |
| <div class="contact-list"> | |
| {% for contact in contacts %} | |
| <div class="contact-item"> | |
| <div class="contact-info"> | |
| <span class="contact-name">{{ contact.name }}</span> | |
| <span class="contact-email">{{ contact.email }}</span> | |
| </div> | |
| <div class="contact-actions"> | |
| <button class="btn btn-outline" style="padding:10px;" onclick="useContact('{{ contact.email }}')" title="Написать"><i class="fas fa-pen"></i></button> | |
| <form method="POST" action="/{{ env_id }}/contact_action" style="margin:0;" onsubmit="return confirm('Удалить контакт?');"> | |
| <input type="hidden" name="action" value="delete"> | |
| <input type="hidden" name="contact_id" value="{{ contact.id }}"> | |
| <button type="submit" class="btn" style="background:var(--danger); padding:10px;"><i class="fas fa-trash"></i></button> | |
| </form> | |
| </div> | |
| </div> | |
| {% else %} | |
| <div style="text-align:center; padding:30px; color:var(--text-muted);">Адресная книга пуста</div> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="modal" id="contactsModal"> | |
| <div class="modal-content"> | |
| <button class="close-modal" onclick="document.getElementById('contactsModal').style.display='none'">×</button> | |
| <h3 style="margin-top:0; color:var(--primary);">Выберите контакт</h3> | |
| <div class="contact-list" style="margin-top:20px;"> | |
| {% for contact in contacts %} | |
| <div class="contact-item" style="cursor:pointer;" onclick="useContact('{{ contact.email }}'); document.getElementById('contactsModal').style.display='none';"> | |
| <div class="contact-info"> | |
| <span class="contact-name">{{ contact.name }}</span> | |
| <span class="contact-email">{{ contact.email }}</span> | |
| </div> | |
| </div> | |
| {% else %} | |
| <div style="text-align:center; color:var(--text-muted);">Нет контактов</div> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="modal" id="addContactModal"> | |
| <div class="modal-content"> | |
| <button class="close-modal" onclick="document.getElementById('addContactModal').style.display='none'">×</button> | |
| <h3 style="margin-top:0; color:var(--primary);">Новый контакт</h3> | |
| <form method="POST" action="/{{ env_id }}/contact_action" style="margin-top:20px;"> | |
| <input type="hidden" name="action" value="add"> | |
| <div class="form-group"> | |
| <label>Имя:</label> | |
| <input type="text" name="name" required placeholder="Иван Иванов"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Email:</label> | |
| <input type="email" name="email" required placeholder="example@domain.com"> | |
| </div> | |
| <button type="submit" class="btn btn-primary">Сохранить</button> | |
| </form> | |
| </div> | |
| </div> | |
| <div class="modal" id="emailViewModal"> | |
| <div class="modal-content"> | |
| <button class="close-modal" onclick="document.getElementById('emailViewModal').style.display='none'">×</button> | |
| <div style="margin-bottom:20px; border-bottom:1px solid var(--border); padding-bottom:15px;"> | |
| <h3 id="ev_subject" style="margin-top:0; color:var(--primary); font-size:1.4rem;"></h3> | |
| <div style="color:var(--text-muted); font-size:0.95rem; display:flex; justify-content:space-between;"> | |
| <span id="ev_to"></span> | |
| <span id="ev_date"></span> | |
| </div> | |
| </div> | |
| <div id="ev_body" style="white-space:pre-wrap; line-height:1.6; color:var(--text); font-size:1.05rem;"></div> | |
| <div id="ev_attachments" style="margin-top:20px; border-top:1px solid var(--border); padding-top:15px; display:none;"> | |
| <h4 style="margin-top:0; margin-bottom:10px;">Вложения:</h4> | |
| <div id="ev_attachments_list"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const emails = {{ emails_json|safe }}; | |
| const repoId = '{{ repo_id }}'; | |
| function switchView(viewId) { | |
| document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.view-section').forEach(s => s.classList.remove('active')); | |
| event.currentTarget.classList.add('active'); | |
| document.getElementById('view-' + viewId).classList.add('active'); | |
| } | |
| function useContact(email) { | |
| document.getElementById('to_email').value = email; | |
| switchView('compose'); | |
| document.querySelector('.nav-tab:nth-child(1)').classList.add('active'); | |
| document.querySelector('.nav-tab:nth-child(3)').classList.remove('active'); | |
| } | |
| function sendMail(e) { | |
| e.preventDefault(); | |
| const form = document.getElementById('composeForm'); | |
| const formData = new FormData(form); | |
| document.getElementById('loadingOverlay').style.display = 'flex'; | |
| fetch('/{{ env_id }}/send_mail', { | |
| method: 'POST', | |
| body: formData | |
| }) | |
| .then(res => res.json()) | |
| .then(data => { | |
| document.getElementById('loadingOverlay').style.display = 'none'; | |
| if(data.success) { | |
| alert('Письмо успешно отправлено!'); | |
| window.location.reload(); | |
| } else { | |
| alert('Ошибка отправки: ' + (data.error || 'Неизвестная ошибка')); | |
| } | |
| }) | |
| .catch(err => { | |
| document.getElementById('loadingOverlay').style.display = 'none'; | |
| alert('Произошла ошибка при отправке.'); | |
| }); | |
| } | |
| function openEmail(id) { | |
| const email = emails.find(e => e.id === id); | |
| if(!email) return; | |
| document.getElementById('ev_subject').innerText = email.subject; | |
| document.getElementById('ev_to').innerText = 'Кому: ' + email.to; | |
| document.getElementById('ev_date').innerText = email.date; | |
| document.getElementById('ev_body').innerText = email.body; | |
| const attContainer = document.getElementById('ev_attachments'); | |
| const attList = document.getElementById('ev_attachments_list'); | |
| attList.innerHTML = ''; | |
| if(email.attachments && email.attachments.length > 0) { | |
| attContainer.style.display = 'block'; | |
| email.attachments.forEach(att => { | |
| const url = `https://huggingface.co/datasets/${repoId}/resolve/main/attachments/${att.filename}`; | |
| attList.innerHTML += `<a href="${url}" target="_blank" class="attachment-link"><i class="fas fa-file"></i> Вложение</a>`; | |
| }); | |
| } else { | |
| attContainer.style.display = 'none'; | |
| } | |
| document.getElementById('emailViewModal').style.display = 'flex'; | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| ''' | |
| def index(): | |
| return redirect(url_for('admhosto')) | |
| def admhosto(): | |
| data = load_data() | |
| active_envs = [] | |
| archived_envs = [] | |
| for env_id, env_data in data.items(): | |
| settings = env_data.get('settings', {}) | |
| org_name = settings.get("organization_name", f"Client {env_id}") | |
| env_info = { | |
| "id": env_id, | |
| "org_name": org_name, | |
| "pwd_enabled": settings.get("admin_password_enabled", False), | |
| "password": settings.get("admin_password", "") | |
| } | |
| if settings.get('is_deleted', False): | |
| archived_envs.append(env_info) | |
| else: | |
| active_envs.append(env_info) | |
| active_envs.sort(key=lambda x: x['id']) | |
| archived_envs.sort(key=lambda x: x['id']) | |
| return render_template_string(ADMHOSTO_TEMPLATE, active_envs=active_envs, archived_envs=archived_envs) | |
| def create_environment(): | |
| all_data = load_data() | |
| while True: | |
| new_id = ''.join(random.choices(string.digits, k=6)) | |
| if new_id not in all_data: | |
| break | |
| all_data[new_id] = { | |
| 'emails': [], | |
| 'contacts': [], | |
| 'settings': { | |
| "organization_name": f"Mail Client {new_id}", | |
| "admin_password_enabled": False, | |
| "admin_password": "", | |
| "smtp_user": "", | |
| "sender_name": "", | |
| "is_deleted": False | |
| } | |
| } | |
| save_data(all_data) | |
| flash(f'Новый клиент с ID {new_id} успешно создан.', 'success') | |
| return redirect(url_for('admhosto')) | |
| def update_env_pwd(env_id): | |
| all_data = load_data() | |
| if env_id in all_data: | |
| pwd_enabled = 'pwd_enabled' in request.form | |
| password = request.form.get('password', '').strip() | |
| all_data[env_id]['settings']['admin_password_enabled'] = pwd_enabled | |
| all_data[env_id]['settings']['admin_password'] = password | |
| save_data(all_data) | |
| flash(f'Пароль для клиента {env_id} обновлен.', 'success') | |
| return redirect(url_for('admhosto')) | |
| def delete_environment(env_id): | |
| all_data = load_data() | |
| if env_id in all_data: | |
| all_data[env_id]['settings']['is_deleted'] = True | |
| save_data(all_data) | |
| flash(f'Клиент {env_id} отключен.', 'success') | |
| return redirect(url_for('admhosto')) | |
| def restore_environment(env_id): | |
| all_data = load_data() | |
| if env_id in all_data: | |
| all_data[env_id]['settings']['is_deleted'] = False | |
| save_data(all_data) | |
| flash(f'Клиент {env_id} восстановлен.', 'success') | |
| return redirect(url_for('admhosto')) | |
| def hard_delete_environment(env_id): | |
| all_data = load_data() | |
| if env_id in all_data: | |
| del all_data[env_id] | |
| save_data(all_data) | |
| flash(f'Клиент {env_id} удален окончательно.', 'success') | |
| return redirect(url_for('admhosto')) | |
| def admin_login(env_id): | |
| data = get_env_data(env_id) | |
| settings = data.get('settings', {}) | |
| if not settings.get('admin_password_enabled'): | |
| return redirect(url_for('mail_client', env_id=env_id)) | |
| if request.method == 'POST': | |
| pwd = request.form.get('password', '') | |
| if pwd == settings.get('admin_password', ''): | |
| session.permanent = True | |
| session[f'admin_auth_{env_id}'] = True | |
| return redirect(url_for('mail_client', env_id=env_id)) | |
| else: | |
| flash('Неверный пароль', 'error') | |
| return render_template_string(LOGIN_TEMPLATE, env_id=env_id) | |
| def admin_logout(env_id): | |
| session.pop(f'admin_auth_{env_id}', None) | |
| return redirect(url_for('admin_login', env_id=env_id)) | |
| def admin(env_id): | |
| data = get_env_data(env_id) | |
| settings = data.get('settings', {}) | |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): | |
| return redirect(url_for('admin_login', env_id=env_id)) | |
| if request.method == 'POST': | |
| if request.form.get('action') == 'update_settings': | |
| settings['organization_name'] = request.form.get('organization_name', '').strip() | |
| settings['sender_name'] = request.form.get('sender_name', '').strip() | |
| settings['smtp_user'] = request.form.get('smtp_user', '').strip() | |
| settings['admin_password_enabled'] = 'admin_password_enabled' in request.form | |
| settings['admin_password'] = request.form.get('admin_password', '').strip() | |
| data['settings'] = settings | |
| save_env_data(env_id, data) | |
| flash('Настройки сохранены', 'success') | |
| return redirect(url_for('admin', env_id=env_id)) | |
| return render_template_string(ADMIN_TEMPLATE, env_id=env_id, settings=settings) | |
| def mail_client(env_id): | |
| data = get_env_data(env_id) | |
| settings = data.get('settings', {}) | |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): | |
| return redirect(url_for('admin_login', env_id=env_id)) | |
| emails = data.get('emails', []) | |
| contacts = data.get('contacts', []) | |
| return render_template_string( | |
| MAIL_TEMPLATE, | |
| env_id=env_id, | |
| settings=settings, | |
| emails=emails, | |
| contacts=contacts, | |
| emails_json=json.dumps(emails), | |
| repo_id=REPO_ID | |
| ) | |
| def contact_action(env_id): | |
| data = get_env_data(env_id) | |
| settings = data.get('settings', {}) | |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): | |
| return redirect(url_for('admin_login', env_id=env_id)) | |
| action = request.form.get('action') | |
| contacts = data.get('contacts', []) | |
| if action == 'add': | |
| name = request.form.get('name', '').strip() | |
| email = request.form.get('email', '').strip() | |
| if name and email: | |
| contacts.append({'id': uuid4().hex, 'name': name, 'email': email}) | |
| data['contacts'] = contacts | |
| save_env_data(env_id, data) | |
| elif action == 'delete': | |
| cid = request.form.get('contact_id') | |
| data['contacts'] = [c for c in contacts if c['id'] != cid] | |
| save_env_data(env_id, data) | |
| return redirect(url_for('mail_client', env_id=env_id)) | |
| def send_mail_route(env_id): | |
| data = get_env_data(env_id) | |
| settings = data.get('settings', {}) | |
| if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'): | |
| return jsonify({'success': False, 'error': 'Unauthorized'}), 401 | |
| to_email = request.form.get('to_email', '').strip() | |
| subject = request.form.get('subject', '').strip() | |
| body = request.form.get('body', '').strip() | |
| files = request.files.getlist('attachments') | |
| if not settings.get('smtp_user'): | |
| return jsonify({'success': False, 'error': 'Email отправителя не настроен в админ панели.'}), 400 | |
| try: | |
| send_email_direct(settings, to_email, subject, body, files) | |
| uploaded_attachments = [] | |
| for f in files: | |
| if f and f.filename: | |
| f.seek(0) | |
| filename = upload_attachment(f, 'attachments') | |
| if filename: | |
| uploaded_attachments.append({'original': f.filename, 'filename': filename}) | |
| email_record = { | |
| 'id': uuid4().hex, | |
| 'date': get_almaty_time(), | |
| 'to': to_email, | |
| 'subject': subject, | |
| 'body': body, | |
| 'attachments': uploaded_attachments | |
| } | |
| data.setdefault('emails', []).append(email_record) | |
| save_env_data(env_id, data) | |
| return jsonify({'success': True}) | |
| except Exception as e: | |
| return jsonify({'success': False, 'error': str(e)}), 500 | |
| if __name__ == '__main__': | |
| download_db_from_hf() | |
| load_data() | |
| if HF_TOKEN_WRITE: | |
| threading.Thread(target=periodic_backup, daemon=True).start() | |
| port = int(os.environ.get('PORT', 7860)) | |
| app.run(host='0.0.0.0', port=port) |