mails / app.py
Kgshop's picture
Update app.py
56e2ede verified
Raw
History Blame Contribute Delete
53.1 kB
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)
@app.before_request
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'">&times;</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'">&times;</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'">&times;</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>
'''
@app.route('/')
def index():
return redirect(url_for('admhosto'))
@app.route('/admhosto', methods=['GET'])
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)
@app.route('/admhosto/create', methods=['POST'])
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'))
@app.route('/admhosto/update_pwd/<env_id>', methods=['POST'])
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'))
@app.route('/admhosto/delete/<env_id>', methods=['POST'])
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'))
@app.route('/admhosto/restore/<env_id>', methods=['POST'])
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'))
@app.route('/admhosto/hard_delete/<env_id>', methods=['POST'])
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'))
@app.route('/<env_id>/login', methods=['GET', 'POST'])
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)
@app.route('/<env_id>/logout')
def admin_logout(env_id):
session.pop(f'admin_auth_{env_id}', None)
return redirect(url_for('admin_login', env_id=env_id))
@app.route('/<env_id>/admin', methods=['GET', 'POST'])
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)
@app.route('/<env_id>/mail', methods=['GET'])
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
)
@app.route('/<env_id>/contact_action', methods=['POST'])
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))
@app.route('/<env_id>/send_mail', methods=['POST'])
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)