Kgshop commited on
Commit
46ecc22
·
verified ·
1 Parent(s): 8aa274d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +590 -507
app.py CHANGED
@@ -20,7 +20,7 @@ import requests
20
  load_dotenv()
21
 
22
  app = Flask(__name__)
23
- app.secret_key = 'super_secret_key_mail_client_123'
24
  app.config.update(
25
  SESSION_COOKIE_SAMESITE='None',
26
  SESSION_COOKIE_SECURE=True,
@@ -34,39 +34,8 @@ REPO_ID = os.getenv("REPO_ID", "Kgshop/mail")
34
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
35
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
36
 
37
- DEFAULT_LOGO_URL = "https://huggingface.co/spaces/Metapp/Tech/resolve/main/1776929812446-019db944-b5db-7524-8f44-73942d70a0f8.png"
38
-
39
  data_lock = threading.Lock()
40
 
41
- TRANSLATIONS = {
42
- 'ru': {},
43
- 'en': {
44
- 'Почта': 'Mail',
45
- 'Входящие': 'Inbox',
46
- 'Отправленные': 'Sent',
47
- 'Написать': 'Compose',
48
- 'Переслать': 'Forward',
49
- 'Ответить': 'Reply',
50
- 'Отправить': 'Send',
51
- 'Кому': 'To',
52
- 'Тема': 'Subject',
53
- 'Текст письма': 'Message body',
54
- 'Прикрепить файлы': 'Attach files',
55
- 'Настройки': 'Settings',
56
- 'Выход': 'Logout',
57
- 'Панель управления': 'Control Panel',
58
- 'Ничего не найдено': 'Nothing found',
59
- 'Удалить': 'Delete',
60
- 'Отмена': 'Cancel'
61
- }
62
- }
63
-
64
- def get_t(lang='ru'):
65
- def t(text):
66
- if not isinstance(text, str): return text
67
- return TRANSLATIONS.get(lang, {}).get(text, text)
68
- return t
69
-
70
  def get_almaty_time():
71
  return (datetime.utcnow() + timedelta(hours=5)).strftime('%Y-%m-%d %H:%M:%S')
72
 
@@ -143,6 +112,35 @@ def upload_db_to_hf(specific_file=None):
143
  except Exception:
144
  pass
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def periodic_backup():
147
  while True:
148
  time.sleep(1800)
@@ -166,27 +164,6 @@ def load_data():
166
  else:
167
  data = {}
168
 
169
- if 'emails' in data or 'contacts' in data:
170
- data = {
171
- 'default_env': {
172
- 'emails': data.get('emails', []),
173
- 'contacts': data.get('contacts', []),
174
- 'settings': {
175
- 'app_name': 'Default Mail',
176
- 'admin_password_enabled': False,
177
- 'admin_password': '',
178
- 'logo_url': DEFAULT_LOGO_URL,
179
- 'smtp_server': '',
180
- 'smtp_port': 587,
181
- 'smtp_user': '',
182
- 'smtp_pass': '',
183
- 'language': 'ru',
184
- 'theme': 'light',
185
- 'is_deleted': False
186
- }
187
- }
188
- }
189
-
190
  changed = False
191
  for env_id, env_data in data.items():
192
  if 'emails' not in env_data: env_data['emails'] = []; changed = True
@@ -196,16 +173,14 @@ def load_data():
196
  changed = True
197
 
198
  settings = env_data['settings']
199
- if 'app_name' not in settings: settings['app_name'] = f'Mail {env_id}'; changed = True
200
  if 'admin_password_enabled' not in settings: settings['admin_password_enabled'] = False; changed = True
201
  if 'admin_password' not in settings: settings['admin_password'] = ''; changed = True
202
- if 'logo_url' not in settings: settings['logo_url'] = DEFAULT_LOGO_URL; changed = True
203
- if 'smtp_server' not in settings: settings['smtp_server'] = ''; changed = True
204
  if 'smtp_port' not in settings: settings['smtp_port'] = 587; changed = True
205
  if 'smtp_user' not in settings: settings['smtp_user'] = ''; changed = True
206
  if 'smtp_pass' not in settings: settings['smtp_pass'] = ''; changed = True
207
- if 'language' not in settings: settings['language'] = 'ru'; changed = True
208
- if 'theme' not in settings: settings['theme'] = 'light'; changed = True
209
  if 'is_deleted' not in settings: settings['is_deleted'] = False; changed = True
210
 
211
  if changed or not os.path.exists(DATA_FILE):
@@ -236,28 +211,17 @@ def get_env_data(env_id):
236
  all_data = load_data()
237
  if env_id not in all_data:
238
  all_data[env_id] = {
239
- 'emails': [{
240
- 'id': uuid4().hex,
241
- 'type': 'inbox',
242
- 'from': 'system@mail.local',
243
- 'to': 'you',
244
- 'subject': 'Добро пожаловать!',
245
- 'body': 'Это тестовое приветственное письмо. Вы можете переслать его или удалить.',
246
- 'date': get_almaty_time(),
247
- 'attachments': []
248
- }],
249
  'contacts': [],
250
  'settings': {
251
- 'app_name': f'Mail {env_id}',
252
  'admin_password_enabled': False,
253
  'admin_password': '',
254
- 'logo_url': DEFAULT_LOGO_URL,
255
- 'smtp_server': '',
256
  'smtp_port': 587,
257
  'smtp_user': '',
258
  'smtp_pass': '',
259
- 'language': 'ru',
260
- 'theme': 'light',
261
  'is_deleted': False
262
  }
263
  }
@@ -273,35 +237,45 @@ def save_env_data(env_id, env_data):
273
  def check_deleted_env():
274
  if request.endpoint and request.view_args and 'env_id' in request.view_args:
275
  env_id = request.view_args['env_id']
276
- if env_id in ['admhosto', 'default_env']:
277
  return
278
  all_data = load_data()
279
  if env_id in all_data and all_data[env_id].get('settings', {}).get('is_deleted', False):
280
- return "<h1 style='text-align:center; margin-top:20vh; font-family:sans-serif;'>Почтовый клиент отключен</h1>", 403
281
 
282
- LANDING_PAGE_TEMPLATE = '''
283
- <!DOCTYPE html>
284
- <html lang="ru">
285
- <head>
286
- <meta charset="UTF-8">
287
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
288
- <title>Email Client Platform</title>
289
- <style>
290
- body, html { margin: 0; padding: 0; height: 100%; font-family: sans-serif; display: flex; align-items: center; justify-content: center; background: #f4f6f9; }
291
- .box { text-align: center; background: white; padding: 40px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
292
- h1 { color: #2c3e50; }
293
- a { display: inline-block; margin-top: 20px; padding: 12px 24px; background: #3498db; color: white; text-decoration: none; border-radius: 6px; }
294
- </style>
295
- </head>
296
- <body>
297
- <div class="box">
298
- <h1>Платформа почтовых клиентов</h1>
299
- <p>Создавайте независимые почтовые интерфейсы для ваших нужд.</p>
300
- <a href="/admhosto">Войти в управление</a>
301
- </div>
302
- </body>
303
- </html>
304
- '''
 
 
 
 
 
 
 
 
 
 
305
 
306
  LOGIN_TEMPLATE = '''
307
  <!DOCTYPE html>
@@ -314,11 +288,11 @@ LOGIN_TEMPLATE = '''
314
  <style>
315
  body { font-family: 'Montserrat', sans-serif; background-color: #f4f6f9; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
316
  .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; }
317
- h2 { color: #2c3e50; margin-bottom: 20px; }
318
  input[type="password"] { width: 100%; padding: 16px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; font-size: 16px; }
319
- button { width: 100%; padding: 16px; background-color: #3498db; color: #fff; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 16px; transition: background 0.3s; min-height: 44px; }
320
- button:hover { background-color: #2980b9; }
321
- .error { color: #e74c3c; margin-bottom: 15px; font-size: 0.9rem; }
322
  </style>
323
  </head>
324
  <body>
@@ -346,24 +320,27 @@ ADMHOSTO_TEMPLATE = '''
346
  <head>
347
  <meta charset="UTF-8">
348
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
349
- <title>Управление средами</title>
350
  <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
 
351
  <style>
352
- :root { --bg-light: #f4f6f9; --bg-medium: #2c3e50; --accent: #3498db; --accent-hover: #2980b9; --text-dark: #333; --danger: #e74c3c; }
353
  body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; margin: 0; }
354
  .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); }
355
  h1 { font-weight: 600; color: var(--bg-medium); margin-bottom: 25px; text-align: center; }
356
  .section { margin-bottom: 30px; }
357
  .add-env-form { margin-bottom: 20px; text-align: center; }
358
  #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; }
359
- .button { padding: 12px 18px; border: none; border-radius: 6px; background-color: var(--accent); color: #fff; 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; }
360
  .button:hover { background-color: var(--accent-hover); }
361
  .env-list { list-style: none; padding: 0; }
362
  .env-item { background: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 15px; }
363
  .env-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
364
  .env-id { font-weight: 600; color: var(--bg-medium); font-size: 1.2rem; }
365
  .env-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
366
- .delete-button { background-color: var(--danger); }
 
 
367
  .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; }
368
  .message.success { background-color: #d4edda; color: #155724; }
369
  .message.error { background-color: #f8d7da; color: #721c24; }
@@ -371,7 +348,7 @@ ADMHOSTO_TEMPLATE = '''
371
  </head>
372
  <body>
373
  <div class="container">
374
- <h1>Почтовые среды</h1>
375
  {% with messages = get_flashed_messages(with_categories=true) %}
376
  {% if messages %}
377
  {% for category, message in messages %}
@@ -381,24 +358,53 @@ ADMHOSTO_TEMPLATE = '''
381
  {% endwith %}
382
  <div class="section">
383
  <form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form">
384
- <button type="submit" class="button">Создать новый клиент</button>
385
  </form>
386
  </div>
387
  <div class="section">
388
  <input type="text" id="search-env" placeholder="Поиск...">
389
  </div>
 
390
  <div class="section">
391
- <h2>Активные почтовые клиенты</h2>
392
  <ul class="env-list">
393
  {% for env in active_envs %}
394
- <li class="env-item">
395
  <div class="env-header">
396
- <span class="env-id">{{ env.app_name }} (ID: {{ env.id }})</span>
397
  <div class="env-actions">
398
- <a href="{{ url_for('mailbox', env_id=env.id) }}" class="button" target="_blank" style="background:#27ae60;">Открыть почту</a>
399
- <a href="{{ url_for('admin', env_id=env.id) }}" class="button" target="_blank">Настройки</a>
400
- <form method="POST" action="/admhosto/delete/{{ env.id }}" style="display:inline;" onsubmit="return confirm('Удалить среду?');">
401
- <button type="submit" class="button delete-button">Удалить</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  </form>
403
  </div>
404
  </div>
@@ -426,325 +432,395 @@ ADMIN_TEMPLATE = '''
426
  <html lang="ru">
427
  <head>
428
  <meta charset="UTF-8">
429
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
430
  <title>Настройки клиента</title>
 
431
  <style>
432
- :root { --primary: #3498db; --bg: #f4f6f9; --surface: #ffffff; --border: #e0e6ed; --text: #2d3436; }
433
- body { font-family: sans-serif; background: var(--bg); padding: 20px; color: var(--text); }
434
- .container { max-width: 800px; margin: 0 auto; background: var(--surface); padding: 30px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.05); }
435
- h1 { margin-top: 0; color: #2c3e50; }
436
- .form-group { margin-bottom: 20px; }
437
- label { display: block; font-weight: 600; margin-bottom: 8px; }
438
- input[type="text"], input[type="password"], input[type="number"], select { width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box; font-size: 16px; }
439
- button { padding: 14px 20px; background: var(--primary); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 16px; font-weight: bold; width: 100%; }
440
- button:hover { background: #2980b9; }
441
- hr { border: 0; border-top: 1px solid var(--border); margin: 30px 0; }
442
- .flex { display: flex; gap: 15px; flex-wrap: wrap; }
443
- .flex .form-group { flex: 1; min-width: 200px; }
444
- .msg { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
445
- .msg.success { background: #d4edda; color: #155724; }
446
- .msg.error { background: #f8d7da; color: #721c24; }
 
 
 
 
 
 
 
 
447
  </style>
448
  </head>
449
  <body>
450
  <div class="container">
451
- <h1>Настройки почтового клиента ({{ env_id }})</h1>
452
-
 
 
 
 
 
 
 
 
453
  {% with messages = get_flashed_messages(with_categories=true) %}
454
  {% if messages %}
455
  {% for category, message in messages %}
456
- <div class="msg {{ category }}">{{ message }}</div>
457
  {% endfor %}
458
  {% endif %}
459
  {% endwith %}
460
 
461
- <form method="POST" action="/{{ env_id }}/admin">
462
- <input type="hidden" name="action" value="update_settings">
463
-
464
- <div class="form-group">
465
- <label>Название клиентариложения)</label>
466
- <input type="text" name="app_name" value="{{ settings.app_name }}" required>
467
- </div>
468
-
469
- <div class="form-group">
470
- <label>Язык интерфейса</label>
471
- <select name="language">
472
- <option value="ru" {% if settings.language == 'ru' %}selected{% endif %}>Русский</option>
473
- <option value="en" {% if settings.language == 'en' %}selected{% endif %}>English</option>
474
- </select>
475
- </div>
476
-
477
- <hr>
478
- <h3>Настройки SMTP (Для отправки реальных писем)</h3>
479
- <p style="font-size: 0.9rem; color: #7f8c8d;">Оставьте пустым, если хотите только эмулировать отправку (письма будут сохраняться в Отправленные без реальной отправки).</p>
480
-
481
- <div class="flex">
482
- <div class="form-group">
483
- <label>SMTP Сервер</label>
484
- <input type="text" name="smtp_server" value="{{ settings.smtp_server }}" placeholder="smtp.gmail.com">
485
  </div>
486
- <div class="form-group">
487
- <label>SMTP Порт</label>
488
- <input type="number" name="smtp_port" value="{{ settings.smtp_port }}">
489
  </div>
490
- </div>
491
-
492
- <div class="flex">
493
- <div class="form-group">
494
- <label>Email (Пользователь SMTP)</label>
495
- <input type="text" name="smtp_user" value="{{ settings.smtp_user }}" placeholder="your@email.com">
496
  </div>
497
- <div class="form-group">
498
- <label>Пароль приложения SMTP</label>
499
- <input type="password" name="smtp_pass" value="{{ settings.smtp_pass }}">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  </div>
501
- </div>
502
-
503
- <hr>
504
- <h3>Безопасность</h3>
505
- <div class="form-group">
506
- <label><input type="checkbox" name="admin_password_enabled" {% if settings.admin_password_enabled %}checked{% endif %} style="width: auto;"> Требовать пароль для входа в почту и настройки</label>
507
- <input type="password" name="admin_password" value="{{ settings.admin_password }}" placeholder="Пароль" style="margin-top: 10px;">
508
- </div>
509
 
510
- <button type="submit">Сохранить настройки</button>
511
- </form>
512
-
513
- <hr>
514
- <form method="POST" action="/{{ env_id }}/admin" style="margin-top:20px;">
515
- <input type="hidden" name="action" value="simulate_incoming">
516
- <button type="submit" style="background:#27ae60;">Симулировать входящее письмо (Для тестов)</button>
517
- </form>
518
- <div style="margin-top: 15px; text-align: center;">
519
- <a href="/{{ env_id }}/mailbox" style="color: var(--primary); text-decoration: none; font-weight: bold;">Перейти в почту -></a>
520
  </div>
521
  </div>
522
  </body>
523
  </html>
524
  '''
525
 
526
- MAILBOX_TEMPLATE = '''
527
  <!DOCTYPE html>
528
- <html lang="{{ settings.language }}">
529
  <head>
530
  <meta charset="UTF-8">
531
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
532
- <title>{{ settings.app_name }}</title>
 
533
  <style>
534
- :root { --primary: #3498db; --bg: #f5f6fa; --surface: #ffffff; --border: #dcdde1; --text: #2f3640; --text-light: #718093; }
535
- body, html { margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); overflow: hidden; }
536
- .app-container { display: flex; height: 100vh; }
537
- .sidebar { width: 250px; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
538
- .brand { padding: 20px; font-size: 1.2rem; font-weight: bold; color: var(--primary); border-bottom: 1px solid var(--border); }
539
- .menu { list-style: none; padding: 10px 0; margin: 0; flex: 1; }
540
- .menu li { padding: 15px 20px; cursor: pointer; display: flex; align-items: center; gap: 10px; font-weight: 500; color: var(--text-light); transition: background 0.2s; }
541
- .menu li:hover { background: #f1f2f6; }
542
- .menu li.active { background: #e8f4fd; color: var(--primary); border-left: 4px solid var(--primary); }
543
- .compose-btn-wrap { padding: 20px; }
544
- .compose-btn { width: 100%; padding: 12px; background: var(--primary); color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: bold; cursor: pointer; }
545
- .compose-btn:hover { background: #2980b9; }
546
 
547
- .main-content { flex: 1; display: flex; flex-direction: column; background: var(--surface); }
548
- .toolbar { padding: 15px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: #fbfbfb; }
549
- .email-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
550
- .email-item { padding: 15px 20px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; flex-direction: column; gap: 5px; transition: background 0.2s; }
551
- .email-item:hover { background: #fdfdfd; }
552
- .email-header-row { display: flex; justify-content: space-between; align-items: center; }
553
- .email-sender { font-weight: bold; font-size: 1rem; }
554
- .email-date { font-size: 0.85rem; color: var(--text-light); }
555
- .email-subject { font-weight: 600; font-size: 0.95rem; }
556
- .email-snippet { font-size: 0.9rem; color: var(--text-light); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
557
-
558
- .view-panel { flex: 1; display: none; flex-direction: column; border-left: 1px solid var(--border); background: var(--surface); }
559
- .view-header { padding: 20px; border-bottom: 1px solid var(--border); }
560
- .view-subject { font-size: 1.4rem; font-weight: bold; margin-bottom: 10px; }
561
- .view-meta { font-size: 0.9rem; color: var(--text-light); margin-bottom: 15px; }
562
- .view-body { padding: 20px; flex: 1; overflow-y: auto; white-space: pre-wrap; font-size: 1rem; line-height: 1.5; }
563
- .view-actions { padding: 15px 20px; border-top: 1px solid var(--border); display: flex; gap: 10px; background: #fbfbfb; }
564
- .btn { padding: 10px 15px; background: white; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 500; font-size: 0.95rem; color: var(--text); }
565
- .btn:hover { background: #f1f2f6; }
566
- .btn-primary { background: var(--primary); color: white; border-color: var(--primary); }
567
- .btn-primary:hover { background: #2980b9; }
568
-
569
- .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; z-index: 1000; }
570
- .modal-content { background: var(--surface); padding: 0; border-radius: 12px; width: 100%; max-width: 600px; display: flex; flex-direction: column; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
571
- .modal-header { padding: 15px 20px; border-bottom: 1px solid var(--border); font-weight: bold; font-size: 1.1rem; display: flex; justify-content: space-between; align-items: center; }
572
- .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-light); }
573
- .modal-body { padding: 20px; display: flex; flex-direction: column; gap: 15px; }
574
- .form-group input, .form-group textarea { width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; font-family: inherit; box-sizing: border-box; }
575
  .form-group textarea { min-height: 200px; resize: vertical; }
576
- .modal-footer { padding: 15px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 10px; background: #fbfbfb; border-bottom-left-radius: 12px; border-bottom-right-radius: 12px; }
577
-
578
- .attachment-list { margin-top: 10px; font-size: 0.9rem; color: var(--primary); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
579
 
580
- .empty-state { padding: 40px; text-align: center; color: var(--text-light); font-size: 1.1rem; }
 
 
 
 
 
 
 
 
 
 
 
581
  </style>
582
  </head>
583
  <body>
584
- <div class="app-container">
585
- <div class="sidebar">
586
- <div class="brand">{{ settings.app_name }}</div>
587
- <div class="compose-btn-wrap">
588
- <button class="compose-btn" onclick="openCompose()">{{ t('Написать') }}</button>
589
- </div>
590
- <ul class="menu">
591
- <li id="tab-inbox" class="active" onclick="switchFolder('inbox')">{{ t('Входящие') }}</li>
592
- <li id="tab-sent" onclick="switchFolder('sent')">{{ t('Отправленные') }}</li>
593
- </ul>
594
- <div style="padding: 20px; border-top: 1px solid var(--border);">
595
- <a href="/{{ env_id }}/admin" style="color: var(--text-light); text-decoration: none; font-size: 0.9rem; display: block; margin-bottom: 10px;">{{ t('Настройки') }}</a>
596
- <a href="/{{ env_id }}/logout" style="color: #e74c3c; text-decoration: none; font-size: 0.9rem;">{{ t('Выход') }}</a>
597
  </div>
598
  </div>
599
 
600
- <div class="main-content" style="flex: 1; max-width: 400px; border-right: 1px solid var(--border);">
601
- <div class="toolbar">
602
- <h3 style="margin: 0;" id="folderTitle">{{ t('Входящие') }}</h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  </div>
604
- <div class="email-list" id="emailList"></div>
605
  </div>
606
 
607
- <div class="view-panel" id="viewPanel">
608
- <div class="view-header">
609
- <div class="view-subject" id="viewSubject"></div>
610
- <div class="view-meta" id="viewMeta"></div>
611
- <div class="attachment-list" id="viewAttachments"></div>
612
  </div>
613
- <div class="view-body" id="viewBody"></div>
614
- <div class="view-actions">
615
- <button class="btn btn-primary" onclick="forwardEmail()">{{ t('Переслать') }}</button>
616
- <form method="POST" action="/{{ env_id }}/api/delete_email" style="margin:0;" id="deleteForm">
617
- <input type="hidden" name="email_id" id="deleteEmailId">
618
- <button type="submit" class="btn" style="color: #e74c3c;">{{ t('Удалить') }}</button>
619
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
620
  </div>
621
  </div>
622
  </div>
623
 
624
- <div class="modal-overlay" id="composeModal">
625
- <form class="modal-content" action="/{{ env_id }}/api/send" method="POST" enctype="multipart/form-data">
626
- <div class="modal-header">
627
- <span>{{ t('Написать') }}</span>
628
- <button type="button" class="close-btn" onclick="closeCompose()">&times;</button>
629
- </div>
630
- <div class="modal-body">
631
- <div class="form-group">
632
- <input type="text" name="to" id="composeTo" placeholder="{{ t('Кому') }}" required>
 
 
633
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  <div class="form-group">
635
- <input type="text" name="subject" id="composeSubject" placeholder="{{ t('Тема') }}" required>
 
636
  </div>
637
  <div class="form-group">
638
- <textarea name="body" id="composeBody" placeholder="{{ t('Текст письма') }}" required></textarea>
 
639
  </div>
640
- <div class="form-group">
641
- <label style="font-size: 0.9rem; color: var(--text-light);">{{ t('Прикрепить файлы') }}:</label>
642
- <input type="file" name="attachments" multiple style="border: none; padding: 0;">
 
 
 
 
 
 
 
 
 
 
643
  </div>
644
  </div>
645
- <div class="modal-footer">
646
- <button type="button" class="btn" onclick="closeCompose()">{{ t('Отмена') }}</button>
647
- <button type="submit" class="btn btn-primary">{{ t('Отправить') }}</button>
 
648
  </div>
649
- </form>
650
  </div>
651
 
652
  <script>
653
  const emails = {{ emails_json|safe }};
654
- const t = {{ translations|safe }};
655
- let currentFolder = 'inbox';
656
- let currentViewEmail = null;
657
-
658
- function localize(key) {
659
- return t[key] || key;
 
 
660
  }
661
-
662
- function switchFolder(folder) {
663
- currentFolder = folder;
664
- document.querySelectorAll('.menu li').forEach(el => el.classList.remove('active'));
665
- document.getElementById('tab-' + folder).classList.add('active');
666
- document.getElementById('folderTitle').innerText = folder === 'inbox' ? localize('Вход��щие') : localize('Отправленные');
667
- document.getElementById('viewPanel').style.display = 'none';
668
- renderList();
669
  }
670
 
671
- function renderList() {
672
- const list = document.getElementById('emailList');
673
- list.innerHTML = '';
 
674
 
675
- const filtered = emails.filter(e => e.type === currentFolder).sort((a,b) => new Date(b.date) - new Date(a.date));
676
 
677
- if (filtered.length === 0) {
678
- list.innerHTML = `<div class="empty-state">${localize('Ничего не найдено')}</div>`;
679
- return;
680
- }
681
-
682
- filtered.forEach(e => {
683
- const div = document.createElement('div');
684
- div.className = 'email-item';
685
- div.onclick = () => viewEmail(e.id);
686
-
687
- const address = currentFolder === 'inbox' ? e.from : e.to;
688
-
689
- div.innerHTML = `
690
- <div class="email-header-row">
691
- <span class="email-sender">${address}</span>
692
- <span class="email-date">${e.date.split(' ')[0]}</span>
693
- </div>
694
- <div class="email-subject">${e.subject} ${e.attachments && e.attachments.length ? '📎' : ''}</div>
695
- <div class="email-snippet">${e.body.substring(0, 50)}...</div>
696
- `;
697
- list.appendChild(div);
698
  });
699
  }
700
-
701
- function viewEmail(id) {
702
- const e = emails.find(x => x.id === id);
703
- if (!e) return;
704
- currentViewEmail = e;
705
-
706
- document.getElementById('viewPanel').style.display = 'flex';
707
- document.getElementById('viewSubject').innerText = e.subject;
708
 
709
- const addrInfo = currentFolder === 'inbox'
710
- ? `От: ${e.from}<br>Кому: ${e.to}<br>Дата: ${e.date}`
711
- : `Кому: ${e.to}<br>Дата: ${e.date}`;
 
712
 
713
- document.getElementById('viewMeta').innerHTML = addrInfo;
714
- document.getElementById('viewBody').innerText = e.body;
715
- document.getElementById('deleteEmailId').value = e.id;
716
 
717
- const attDiv = document.getElementById('viewAttachments');
718
- if (e.attachments && e.attachments.length > 0) {
719
- attDiv.innerHTML = 'Вложения: ' + e.attachments.join(', ');
 
 
 
720
  } else {
721
- attDiv.innerHTML = '';
722
  }
723
- }
724
-
725
- function openCompose() {
726
- document.getElementById('composeTo').value = '';
727
- document.getElementById('composeSubject').value = '';
728
- document.getElementById('composeBody').value = '';
729
- document.getElementById('composeModal').style.display = 'flex';
730
- }
731
-
732
- function closeCompose() {
733
- document.getElementById('composeModal').style.display = 'none';
734
- }
735
-
736
- function forwardEmail() {
737
- if (!currentViewEmail) return;
738
- document.getElementById('composeTo').value = '';
739
- document.getElementById('composeSubject').value = 'Fwd: ' + currentViewEmail.subject;
740
 
741
- const origMeta = `\\n\\n---------- Forwarded message ---------\\nFrom: ${currentViewEmail.from}\\nDate: ${currentViewEmail.date}\\nSubject: ${currentViewEmail.subject}\\nTo: ${currentViewEmail.to}\\n\\n`;
742
-
743
- document.getElementById('composeBody').value = origMeta + currentViewEmail.body;
744
- document.getElementById('composeModal').style.display = 'flex';
745
  }
746
-
747
- renderList();
748
  </script>
749
  </body>
750
  </html>
@@ -752,22 +828,30 @@ MAILBOX_TEMPLATE = '''
752
 
753
  @app.route('/')
754
  def index():
755
- return render_template_string(LANDING_PAGE_TEMPLATE)
756
 
757
  @app.route('/admhosto', methods=['GET'])
758
  def admhosto():
759
  data = load_data()
760
  active_envs = []
 
761
  for env_id, env_data in data.items():
762
- if env_id == 'default_env':
763
- continue
764
  settings = env_data.get('settings', {})
765
- if not settings.get('is_deleted', False):
766
- active_envs.append({
767
- "id": env_id,
768
- "app_name": settings.get("app_name", f"Mail {env_id}")
769
- })
770
- return render_template_string(ADMHOSTO_TEMPLATE, active_envs=active_envs)
 
 
 
 
 
 
 
 
 
771
 
772
  @app.route('/admhosto/create', methods=['POST'])
773
  def create_environment():
@@ -777,33 +861,34 @@ def create_environment():
777
  if new_id not in all_data:
778
  break
779
  all_data[new_id] = {
780
- 'emails': [{
781
- 'id': uuid4().hex,
782
- 'type': 'inbox',
783
- 'from': 'system@mail.local',
784
- 'to': 'you',
785
- 'subject': 'Добро пожаловать!',
786
- 'body': 'Это тестовое приветственное письмо. Вы можете переслать его или удалить.',
787
- 'date': get_almaty_time(),
788
- 'attachments': []
789
- }],
790
  'contacts': [],
791
  'settings': {
792
- 'app_name': f'Mail {new_id}',
793
- 'admin_password_enabled': False,
794
- 'admin_password': '',
795
- 'logo_url': DEFAULT_LOGO_URL,
796
- 'smtp_server': '',
797
- 'smtp_port': 587,
798
- 'smtp_user': '',
799
- 'smtp_pass': '',
800
- 'language': 'ru',
801
- 'theme': 'light',
802
- 'is_deleted': False
803
  }
804
  }
805
  save_data(all_data)
806
- flash(f'Новый почтовый клиент {new_id} успешно создан.', 'success')
 
 
 
 
 
 
 
 
 
 
 
 
807
  return redirect(url_for('admhosto'))
808
 
809
  @app.route('/admhosto/delete/<env_id>', methods=['POST'])
@@ -812,173 +897,171 @@ def delete_environment(env_id):
812
  if env_id in all_data:
813
  all_data[env_id]['settings']['is_deleted'] = True
814
  save_data(all_data)
815
- flash(f'Среда {env_id} удалена.', 'success')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
816
  return redirect(url_for('admhosto'))
817
 
818
  @app.route('/<env_id>/login', methods=['GET', 'POST'])
819
- def env_login(env_id):
820
  data = get_env_data(env_id)
821
  settings = data.get('settings', {})
822
 
823
  if not settings.get('admin_password_enabled'):
824
- return redirect(url_for('mailbox', env_id=env_id))
825
 
826
  if request.method == 'POST':
827
  pwd = request.form.get('password', '')
828
  if pwd == settings.get('admin_password', ''):
829
  session.permanent = True
830
- session[f'auth_{env_id}'] = True
831
- return redirect(url_for('mailbox', env_id=env_id))
832
  else:
833
  flash('Неверный пароль', 'error')
834
 
835
  return render_template_string(LOGIN_TEMPLATE, env_id=env_id)
836
 
837
  @app.route('/<env_id>/logout')
838
- def env_logout(env_id):
839
- session.pop(f'auth_{env_id}', None)
840
- return redirect(url_for('env_login', env_id=env_id))
841
-
842
- def check_auth(env_id, settings):
843
- if settings.get('admin_password_enabled') and not session.get(f'auth_{env_id}'):
844
- return False
845
- return True
846
 
847
  @app.route('/<env_id>/admin', methods=['GET', 'POST'])
848
  def admin(env_id):
849
  data = get_env_data(env_id)
850
  settings = data.get('settings', {})
851
- if not check_auth(env_id, settings):
852
- return redirect(url_for('env_login', env_id=env_id))
 
853
 
854
  if request.method == 'POST':
855
- action = request.form.get('action')
856
- if action == 'update_settings':
857
- settings['app_name'] = request.form.get('app_name', '').strip()
858
- settings['language'] = request.form.get('language', 'ru')
859
- settings['smtp_server'] = request.form.get('smtp_server', '').strip()
860
- try:
861
- settings['smtp_port'] = int(request.form.get('smtp_port', 587))
862
- except ValueError:
863
- settings['smtp_port'] = 587
864
  settings['smtp_user'] = request.form.get('smtp_user', '').strip()
865
- settings['smtp_pass'] = request.form.get('smtp_pass', '')
 
 
866
  settings['admin_password_enabled'] = 'admin_password_enabled' in request.form
867
- settings['admin_password'] = request.form.get('admin_password', '')
868
 
869
  data['settings'] = settings
870
  save_env_data(env_id, data)
871
  flash('Настройки сохранены', 'success')
872
-
873
- elif action == 'simulate_incoming':
874
- new_mail = {
875
- 'id': uuid4().hex,
876
- 'type': 'inbox',
877
- 'from': 'sender@example.com',
878
- 'to': settings.get('smtp_user', 'me@local'),
879
- 'subject': 'Тестовое входящее письмо',
880
- 'body': 'Это сгенерированное входящее письмо для проверки функций ответа и пересылки.',
881
- 'date': get_almaty_time(),
882
- 'attachments': []
883
- }
884
- data['emails'].insert(0, new_mail)
885
- save_env_data(env_id, data)
886
- flash('Входящее письмо симулировано', 'success')
887
-
888
- return redirect(url_for('admin', env_id=env_id))
889
-
890
  return render_template_string(ADMIN_TEMPLATE, env_id=env_id, settings=settings)
891
 
892
- @app.route('/<env_id>/mailbox')
893
- def mailbox(env_id):
894
  data = get_env_data(env_id)
895
  settings = data.get('settings', {})
896
- if not check_auth(env_id, settings):
897
- return redirect(url_for('env_login', env_id=env_id))
898
-
899
- t_func = get_t(settings.get('language', 'ru'))
900
- t_dict = TRANSLATIONS.get(settings.get('language', 'ru'), TRANSLATIONS.get('ru', {}))
 
901
 
902
  return render_template_string(
903
- MAILBOX_TEMPLATE,
904
  env_id=env_id,
905
  settings=settings,
906
- emails_json=json.dumps(data.get('emails', [])),
907
- t=t_func,
908
- translations=json.dumps(t_dict)
 
909
  )
910
 
911
- @app.route('/<env_id>/api/send', methods=['POST'])
912
- def send_email(env_id):
913
  data = get_env_data(env_id)
914
  settings = data.get('settings', {})
915
- if not check_auth(env_id, settings):
916
- return redirect(url_for('env_login', env_id=env_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
917
 
918
- to_email = request.form.get('to', '').strip()
919
  subject = request.form.get('subject', '').strip()
920
- body = request.form.get('body', '')
921
  files = request.files.getlist('attachments')
922
 
923
- msg = EmailMessage()
924
- msg['Subject'] = subject
925
- msg['From'] = settings.get('smtp_user', 'noreply@local')
926
- msg['To'] = to_email
927
- msg.set_content(body)
928
-
929
- file_records = []
930
- for f in files:
931
- if f and f.filename:
932
- file_data = f.read()
933
- maintype, subtype = 'application', 'octet-stream'
934
- mtype = mimetypes.guess_type(f.filename)[0]
935
- if mtype:
936
- maintype, subtype = mtype.split('/', 1)
937
- msg.add_attachment(file_data, maintype=maintype, subtype=subtype, filename=f.filename)
938
- file_records.append(f.filename)
939
-
940
- if settings.get('smtp_server') and settings.get('smtp_user'):
941
- try:
942
- with smtplib.SMTP(settings['smtp_server'], settings['smtp_port']) as server:
943
- server.starttls()
944
- server.login(settings['smtp_user'], settings['smtp_pass'])
945
- server.send_message(msg)
946
- except Exception as e:
947
- pass
948
-
949
- email_record = {
950
- "id": uuid4().hex,
951
- "type": "sent",
952
- "to": to_email,
953
- "from": msg['From'],
954
- "subject": subject,
955
- "body": body,
956
- "date": get_almaty_time(),
957
- "attachments": file_records
958
- }
959
- data['emails'].insert(0, email_record)
960
- save_env_data(env_id, data)
961
-
962
- return redirect(url_for('mailbox', env_id=env_id))
963
 
964
- @app.route('/<env_id>/api/delete_email', methods=['POST'])
965
- def delete_email(env_id):
966
- data = get_env_data(env_id)
967
- settings = data.get('settings', {})
968
- if not check_auth(env_id, settings):
969
- return redirect(url_for('env_login', env_id=env_id))
970
 
971
- eid = request.form.get('email_id')
972
- data['emails'] = [e for e in data.get('emails', []) if e.get('id') != eid]
973
- save_env_data(env_id, data)
974
- return redirect(url_for('mailbox', env_id=env_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
975
 
976
  if __name__ == '__main__':
977
  download_db_from_hf()
978
  load_data()
979
-
980
  if HF_TOKEN_WRITE:
981
  threading.Thread(target=periodic_backup, daemon=True).start()
982
-
983
  port = int(os.environ.get('PORT', 7860))
984
  app.run(host='0.0.0.0', port=port)
 
20
  load_dotenv()
21
 
22
  app = Flask(__name__)
23
+ app.secret_key = 'super_secret_key_mail_app_123'
24
  app.config.update(
25
  SESSION_COOKIE_SAMESITE='None',
26
  SESSION_COOKIE_SECURE=True,
 
34
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
35
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
36
 
 
 
37
  data_lock = threading.Lock()
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def get_almaty_time():
40
  return (datetime.utcnow() + timedelta(hours=5)).strftime('%Y-%m-%d %H:%M:%S')
41
 
 
112
  except Exception:
113
  pass
114
 
115
+ def upload_attachment(file_obj, repo_path):
116
+ if not HF_TOKEN_WRITE:
117
+ return None
118
+ try:
119
+ ext = os.path.splitext(file_obj.filename)[1].lower()
120
+ filename = f"{uuid4().hex}{ext}"
121
+
122
+ fd, temp_path = tempfile.mkstemp()
123
+ with os.fdopen(fd, 'wb') as f:
124
+ f.write(file_obj.read())
125
+
126
+ file_obj.seek(0)
127
+
128
+ api = HfApi()
129
+ api.upload_file(
130
+ path_or_fileobj=temp_path,
131
+ path_in_repo=f"{repo_path}/{filename}",
132
+ repo_id=REPO_ID,
133
+ repo_type="dataset",
134
+ token=HF_TOKEN_WRITE
135
+ )
136
+
137
+ if os.path.exists(temp_path):
138
+ os.remove(temp_path)
139
+
140
+ return filename
141
+ except Exception:
142
+ return None
143
+
144
  def periodic_backup():
145
  while True:
146
  time.sleep(1800)
 
164
  else:
165
  data = {}
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  changed = False
168
  for env_id, env_data in data.items():
169
  if 'emails' not in env_data: env_data['emails'] = []; changed = True
 
173
  changed = True
174
 
175
  settings = env_data['settings']
176
+ if 'organization_name' not in settings: settings['organization_name'] = f'Mail Client {env_id}'; changed = True
177
  if 'admin_password_enabled' not in settings: settings['admin_password_enabled'] = False; changed = True
178
  if 'admin_password' not in settings: settings['admin_password'] = ''; changed = True
179
+ if 'smtp_host' not in settings: settings['smtp_host'] = 'smtp.gmail.com'; changed = True
 
180
  if 'smtp_port' not in settings: settings['smtp_port'] = 587; changed = True
181
  if 'smtp_user' not in settings: settings['smtp_user'] = ''; changed = True
182
  if 'smtp_pass' not in settings: settings['smtp_pass'] = ''; changed = True
183
+ if 'sender_name' not in settings: settings['sender_name'] = ''; changed = True
 
184
  if 'is_deleted' not in settings: settings['is_deleted'] = False; changed = True
185
 
186
  if changed or not os.path.exists(DATA_FILE):
 
211
  all_data = load_data()
212
  if env_id not in all_data:
213
  all_data[env_id] = {
214
+ 'emails': [],
 
 
 
 
 
 
 
 
 
215
  'contacts': [],
216
  'settings': {
217
+ 'organization_name': f'Mail Client {env_id}',
218
  'admin_password_enabled': False,
219
  'admin_password': '',
220
+ 'smtp_host': 'smtp.gmail.com',
 
221
  'smtp_port': 587,
222
  'smtp_user': '',
223
  'smtp_pass': '',
224
+ 'sender_name': '',
 
225
  'is_deleted': False
226
  }
227
  }
 
237
  def check_deleted_env():
238
  if request.endpoint and request.view_args and 'env_id' in request.view_args:
239
  env_id = request.view_args['env_id']
240
+ if env_id in ['admhosto']:
241
  return
242
  all_data = load_data()
243
  if env_id in all_data and all_data[env_id].get('settings', {}).get('is_deleted', False):
244
+ return "<h1 style='text-align:center; margin-top:20vh; font-family:sans-serif;'>Клиент отключен</h1>", 403
245
 
246
+ def send_email(settings, to_email, subject, body, files):
247
+ msg = EmailMessage()
248
+ msg['Subject'] = subject
249
+
250
+ sender_name = settings.get('sender_name', '').strip()
251
+ smtp_user = settings.get('smtp_user', '').strip()
252
+
253
+ if sender_name:
254
+ msg['From'] = f"{sender_name} <{smtp_user}>"
255
+ else:
256
+ msg['From'] = smtp_user
257
+
258
+ msg['To'] = to_email
259
+ msg.set_content(body)
260
+
261
+ for f in files:
262
+ if f and f.filename:
263
+ file_data = f.read()
264
+ ctype, encoding = mimetypes.guess_type(f.filename)
265
+ if ctype is None or encoding is not None:
266
+ ctype = 'application/octet-stream'
267
+ maintype, subtype = ctype.split('/', 1)
268
+ msg.add_attachment(file_data, maintype=maintype, subtype=subtype, filename=f.filename)
269
+ f.seek(0)
270
+
271
+ host = settings.get('smtp_host', '')
272
+ port = int(settings.get('smtp_port', 587))
273
+ password = settings.get('smtp_pass', '')
274
+
275
+ with smtplib.SMTP(host, port) as server:
276
+ server.starttls()
277
+ server.login(smtp_user, password)
278
+ server.send_message(msg)
279
 
280
  LOGIN_TEMPLATE = '''
281
  <!DOCTYPE html>
 
288
  <style>
289
  body { font-family: 'Montserrat', sans-serif; background-color: #f4f6f9; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
290
  .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; }
291
+ h2 { color: #135D66; margin-bottom: 20px; }
292
  input[type="password"] { width: 100%; padding: 16px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; font-size: 16px; }
293
+ 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; }
294
+ button:hover { background-color: #77E4D8; }
295
+ .error { color: #E57373; margin-bottom: 15px; font-size: 0.9rem; }
296
  </style>
297
  </head>
298
  <body>
 
320
  <head>
321
  <meta charset="UTF-8">
322
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
323
+ <title>Управление клиентами</title>
324
  <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
325
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
326
  <style>
327
+ :root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; }
328
  body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; margin: 0; }
329
  .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); }
330
  h1 { font-weight: 600; color: var(--bg-medium); margin-bottom: 25px; text-align: center; }
331
  .section { margin-bottom: 30px; }
332
  .add-env-form { margin-bottom: 20px; text-align: center; }
333
  #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; }
334
+ .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; }
335
  .button:hover { background-color: var(--accent-hover); }
336
  .env-list { list-style: none; padding: 0; }
337
  .env-item { background: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 15px; }
338
  .env-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
339
  .env-id { font-weight: 600; color: var(--bg-medium); font-size: 1.2rem; }
340
  .env-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
341
+ .env-pwd { background: #f1f3f5; padding: 10px; border-radius: 6px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: space-between; }
342
+ .env-pwd input[type="text"] { padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 16px; }
343
+ .delete-button { background-color: var(--danger); color: white; }
344
  .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; }
345
  .message.success { background-color: #d4edda; color: #155724; }
346
  .message.error { background-color: #f8d7da; color: #721c24; }
 
348
  </head>
349
  <body>
350
  <div class="container">
351
+ <h1><i class="fas fa-server"></i> Почтовые клиенты</h1>
352
  {% with messages = get_flashed_messages(with_categories=true) %}
353
  {% if messages %}
354
  {% for category, message in messages %}
 
358
  {% endwith %}
359
  <div class="section">
360
  <form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form">
361
+ <button type="submit" class="button"><i class="fas fa-plus-circle"></i> Создать клиента</button>
362
  </form>
363
  </div>
364
  <div class="section">
365
  <input type="text" id="search-env" placeholder="Поиск...">
366
  </div>
367
+
368
  <div class="section">
369
+ <h2>Активные клиенты</h2>
370
  <ul class="env-list">
371
  {% for env in active_envs %}
372
+ <li class="env-item active-env-item">
373
  <div class="env-header">
374
+ <span class="env-id">{{ env.org_name }} (ID: {{ env.id }})</span>
375
  <div class="env-actions">
376
+ <a href="{{ url_for('mail_client', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-envelope"></i> Почта</a>
377
+ <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>
378
+ <form method="POST" action="/admhosto/delete/{{ env.id }}" style="display:inline;" onsubmit="return confirm('Отключить клиента {{ env.id }}?');">
379
+ <button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button>
380
+ </form>
381
+ </div>
382
+ </div>
383
+ <div class="env-pwd">
384
+ <form method="POST" action="{{ url_for('update_env_pwd', env_id=env.id) }}" style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
385
+ <label><input type="checkbox" name="pwd_enabled" {% if env.pwd_enabled %}checked{% endif %}> Пароль доступа</label>
386
+ <input type="text" name="password" value="{{ env.password }}" placeholder="Пароль">
387
+ <button type="submit" class="button" style="padding: 8px 12px; font-size: 0.9rem;">Сохранить</button>
388
+ </form>
389
+ </div>
390
+ </li>
391
+ {% endfor %}
392
+ </ul>
393
+ </div>
394
+
395
+ <div class="section" style="margin-top: 40px; padding-top: 20px; border-top: 2px dashed #ccc;">
396
+ <h2 style="color: #e17055;">Архив</h2>
397
+ <ul class="env-list">
398
+ {% for env in archived_envs %}
399
+ <li class="env-item archive-env-item">
400
+ <div class="env-header">
401
+ <span class="env-id" style="color: #636e72;">{{ env.org_name }} (ID: {{ env.id }})</span>
402
+ <div class="env-actions">
403
+ <form method="POST" action="/admhosto/restore/{{ env.id }}" style="display:inline;">
404
+ <button type="submit" class="button" style="background:#27ae60;"><i class="fas fa-undo"></i> Восстановить</button>
405
+ </form>
406
+ <form method="POST" action="/admhosto/hard_delete/{{ env.id }}" style="display:inline;" onsubmit="return confirm('Удалить окончательно {{ env.id }}? Это действие необратимо!');">
407
+ <button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i> Окончательно</button>
408
  </form>
409
  </div>
410
  </div>
 
432
  <html lang="ru">
433
  <head>
434
  <meta charset="UTF-8">
435
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
436
  <title>Настройки клиента</title>
437
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
438
  <style>
439
+ :root { --primary: #2d3436; --bg: #f4f6f9; --surface: #ffffff; --border: #e0e6ed; --danger: #ff7675; --success: #00b894; --info: #0984e3; --warning: #f39c12; }
440
+ * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
441
+ body { background: var(--bg); padding: max(20px, env(safe-area-inset-top)) 15px calc(20px + env(safe-area-inset-bottom)); margin: 0; color: #2d3436; }
442
+ .container { max-width: 800px; margin: 0 auto; }
443
+ .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; }
444
+ .header-panel h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
445
+ .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; }
446
+ .btn:active { opacity: 0.8; }
447
+ .btn-primary { background: var(--info); }
448
+ .btn-success { background: var(--success); }
449
+ .btn-danger { background: var(--danger); }
450
+ .card { background: var(--surface); padding: 20px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; }
451
+ input[type="text"], input[type="number"], 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; }
452
+ input:focus { border-color: var(--info); background: #fff; }
453
+ .settings-row { display: flex; align-items: center; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; }
454
+ .settings-row label { flex: 1; min-width: 150px; font-weight: 600; font-size: 1.05rem; }
455
+ .settings-row input { flex: 3; }
456
+ .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; }
457
+ .message.success { background-color: #d4edda; color: #155724; }
458
+ .message.error { background-color: #f8d7da; color: #721c24; }
459
+ @media (max-width: 600px) {
460
+ .settings-row { flex-direction: column; align-items: stretch; }
461
+ }
462
  </style>
463
  </head>
464
  <body>
465
  <div class="container">
466
+ <div class="header-panel">
467
+ <h1><i class="fas fa-cogs"></i> Настройки ({{ env_id }})</h1>
468
+ <div style="display:flex; gap:10px; flex-wrap:wrap;">
469
+ <a href="/{{ env_id }}/mail" class="btn btn-primary"><i class="fas fa-envelope"></i> К почте</a>
470
+ {% if settings.admin_password_enabled %}
471
+ <a href="/{{ env_id }}/logout" class="btn btn-danger"><i class="fas fa-sign-out-alt"></i> Выход</a>
472
+ {% endif %}
473
+ </div>
474
+ </div>
475
+
476
  {% with messages = get_flashed_messages(with_categories=true) %}
477
  {% if messages %}
478
  {% for category, message in messages %}
479
+ <div class="message {{ category }}">{{ message }}</div>
480
  {% endfor %}
481
  {% endif %}
482
  {% endwith %}
483
 
484
+ <div class="card">
485
+ <form method="POST" action="/{{ env_id }}/admin" onsubmit="this.querySelector('button').innerHTML='<i class=\'fas fa-spinner fa-spin\'></i> Сохранение...';">
486
+ <input type="hidden" name="action" value="update_settings">
487
+
488
+ <h2 style="margin-top:0; margin-bottom:20px; font-size:1.2rem; color:var(--primary);"><i class="fas fa-id-card"></i> Основные настройки</h2>
489
+ <div class="settings-row">
490
+ <label>Название клиента:</label>
491
+ <input type="text" name="organization_name" value="{{ settings.organization_name }}" required>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  </div>
493
+ <div class="settings-row">
494
+ <label>Имя отправителя:</label>
495
+ <input type="text" name="sender_name" value="{{ settings.sender_name }}" placeholder="Иван Иванов">
496
  </div>
497
+
498
+ <h2 style="margin-top:30px; margin-bottom:20px; font-size:1.2rem; color:var(--primary);"><i class="fas fa-server"></i> SMTP Сервер (Отправка писем)</h2>
499
+ <div class="settings-row">
500
+ <label>SMTP Host:</label>
501
+ <input type="text" name="smtp_host" value="{{ settings.smtp_host }}" required placeholder="smtp.gmail.com">
 
502
  </div>
503
+ <div class="settings-row">
504
+ <label>SMTP Port:</label>
505
+ <input type="number" name="smtp_port" value="{{ settings.smtp_port }}" required placeholder="587">
506
+ </div>
507
+ <div class="settings-row">
508
+ <label>Email (Логин):</label>
509
+ <input type="text" name="smtp_user" value="{{ settings.smtp_user }}" required placeholder="your@email.com">
510
+ </div>
511
+ <div class="settings-row">
512
+ <label>Пароль приложения:</label>
513
+ <input type="password" name="smtp_pass" value="{{ settings.smtp_pass }}" placeholder="Пароль">
514
+ </div>
515
+
516
+ <h2 style="margin-top:30px; margin-bottom:20px; font-size:1.2rem; color:var(--primary);"><i class="fas fa-lock"></i> Безопасность</h2>
517
+ <div class="settings-row">
518
+ <label style="display:flex; align-items:center; gap:10px; cursor:pointer;">
519
+ <input type="checkbox" name="admin_password_enabled" style="width:auto; transform:scale(1.5);" {% if settings.admin_password_enabled %}checked{% endif %}>
520
+ Включить пароль на вход
521
+ </label>
522
+ <input type="password" name="admin_password" value="{{ settings.admin_password }}" placeholder="Пароль для входа в приложение">
523
  </div>
 
 
 
 
 
 
 
 
524
 
525
+ <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>
526
+ </form>
 
 
 
 
 
 
 
 
527
  </div>
528
  </div>
529
  </body>
530
  </html>
531
  '''
532
 
533
+ MAIL_TEMPLATE = '''
534
  <!DOCTYPE html>
535
+ <html lang="ru">
536
  <head>
537
  <meta charset="UTF-8">
538
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
539
+ <title>{{ settings.organization_name }}</title>
540
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
541
  <style>
542
+ :root { --primary: #0984e3; --bg: #f4f6f9; --surface: #ffffff; --border: #e0e6ed; --text: #2d3436; --text-muted: #636e72; --danger: #d63031; --success: #00b894; }
543
+ * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
544
+ 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; }
545
+ .container { width: 100%; max-width: 1000px; display:flex; flex-direction:column; gap:20px; }
546
+
547
+ .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; }
548
+ .header h1 { margin: 0; font-size: 1.5rem; font-weight: 800; color: var(--primary); display:flex; align-items:center; gap:10px; }
549
+
550
+ .nav-tabs { display:flex; gap:10px; flex-wrap:wrap; }
551
+ .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; }
552
+ .nav-tab.active { background: var(--primary); color: white; }
 
553
 
554
+ .view-section { display:none; background: var(--surface); padding: 25px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); }
555
+ .view-section.active { display:block; }
556
+
557
+ .form-group { margin-bottom: 20px; }
558
+ .form-group label { display: block; font-weight: 600; margin-bottom: 8px; color: var(--text); }
559
+ .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; }
560
+ .form-group input:focus, .form-group textarea:focus { border-color: var(--primary); background: #fff; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  .form-group textarea { min-height: 200px; resize: vertical; }
562
+ .form-group input[type="file"] { border: 1px dashed #ccc; padding: 15px; width:100%; border-radius:12px; cursor:pointer; }
563
+
564
+ .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; }
565
+ .btn:active { opacity: 0.8; }
566
+ .btn-primary { background: var(--primary); width:100%; }
567
+ .btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); }
568
+
569
+ .email-list { display:flex; flex-direction:column; gap:10px; }
570
+ .email-item { padding: 15px; border: 1px solid var(--border); border-radius: 12px; background: #fafafa; cursor: pointer; transition: background 0.2s; }
571
+ .email-item:hover { background: #fff; border-color: var(--primary); }
572
+ .email-header { display:flex; justify-content:space-between; margin-bottom:5px; font-size:0.9rem; color:var(--text-muted); }
573
+ .email-subject { font-weight:700; font-size:1.1rem; margin-bottom:5px; }
574
+ .email-body { font-size:0.95rem; color:var(--text); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
575
+
576
+ .contact-list { display:flex; flex-direction:column; gap:10px; }
577
+ .contact-item { padding: 15px; border: 1px solid var(--border); border-radius: 12px; display:flex; justify-content:space-between; align-items:center; background:#fafafa; }
578
+ .contact-info { display:flex; flex-direction:column; gap:5px; }
579
+ .contact-name { font-weight:700; font-size:1.05rem; }
580
+ .contact-email { color:var(--text-muted); font-size:0.9rem; }
581
+ .contact-actions { display:flex; gap:10px; }
582
+
583
+ .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; }
584
 
585
+ .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; }
586
+ .modal-content { background:var(--surface); padding:25px; border-radius:16px; width:100%; max-width:600px; max-height:90vh; overflow-y:auto; position:relative; }
587
+ .close-modal { position:absolute; top:20px; right:20px; background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--text-muted); }
588
+
589
+ .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; }
590
+
591
+ @media (max-width: 600px) {
592
+ .nav-tabs { flex-direction:column; }
593
+ .nav-tab { text-align:center; }
594
+ .contact-item { flex-direction:column; align-items:flex-start; gap:15px; }
595
+ .contact-actions { width:100%; justify-content:flex-end; }
596
+ }
597
  </style>
598
  </head>
599
  <body>
600
+ <div class="loading-overlay" id="loadingOverlay">
601
+ <i class="fas fa-spinner fa-spin" style="font-size:3rem;"></i>
602
+ <span>Отправка письма...</span>
603
+ </div>
604
+
605
+ <div class="container">
606
+ <div class="header">
607
+ <h1><i class="fas fa-paper-plane"></i> {{ settings.organization_name }}</h1>
608
+ <div style="display:flex; gap:10px;">
609
+ <a href="/{{ env_id }}/admin" class="btn btn-outline" style="min-height:auto; padding:10px 15px;"><i class="fas fa-cogs"></i></a>
610
+ {% if settings.admin_password_enabled %}
611
+ <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>
612
+ {% endif %}
613
  </div>
614
  </div>
615
 
616
+ <div class="nav-tabs">
617
+ <div class="nav-tab active" onclick="switchView('compose')"><i class="fas fa-pen"></i> Написать</div>
618
+ <div class="nav-tab" onclick="switchView('sent')"><i class="fas fa-share-square"></i> Отправленные</div>
619
+ <div class="nav-tab" onclick="switchView('contacts')"><i class="fas fa-address-book"></i> Контакты</div>
620
+ </div>
621
+
622
+ <div class="view-section active" id="view-compose">
623
+ <h2 style="margin-top:0; color:var(--primary);"><i class="fas fa-pen"></i> Новое письмо</h2>
624
+ <form id="composeForm" onsubmit="sendMail(event)">
625
+ <div class="form-group">
626
+ <label>Кому:</label>
627
+ <div style="display:flex; gap:10px;">
628
+ <input type="email" id="to_email" name="to_email" required placeholder="example@domain.com" style="flex:1;">
629
+ <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>
630
+ </div>
631
+ </div>
632
+ <div class="form-group">
633
+ <label>Тема:</label>
634
+ <input type="text" id="subject" name="subject" required placeholder="Тема письма">
635
+ </div>
636
+ <div class="form-group">
637
+ <label>Текст сообщения:</label>
638
+ <textarea id="body" name="body" required placeholder="Напишите ваше сообщение здесь..."></textarea>
639
+ </div>
640
+ <div class="form-group">
641
+ <label>Прикрепить файлы:</label>
642
+ <input type="file" id="attachments" name="attachments" multiple>
643
+ </div>
644
+ <button type="submit" class="btn btn-primary"><i class="fas fa-paper-plane"></i> Отправить письмо</button>
645
+ </form>
646
+ </div>
647
+
648
+ <div class="view-section" id="view-sent">
649
+ <h2 style="margin-top:0; color:var(--primary);"><i class="fas fa-share-square"></i> Отправленные</h2>
650
+ <div class="email-list">
651
+ {% for email in emails|reverse %}
652
+ <div class="email-item" onclick="openEmail('{{ email.id }}')">
653
+ <div class="email-header">
654
+ <span>Кому: {{ email.to }}</span>
655
+ <span>{{ email.date }}</span>
656
+ </div>
657
+ <div class="email-subject">{{ email.subject }}</div>
658
+ <div class="email-body">{{ email.body[:100] }}...</div>
659
+ {% if email.attachments %}
660
+ <div style="margin-top:5px; font-size:0.85rem; color:var(--primary);"><i class="fas fa-paperclip"></i> Вложений: {{ email.attachments|length }}</div>
661
+ {% endif %}
662
+ </div>
663
+ {% else %}
664
+ <div style="text-align:center; padding:30px; color:var(--text-muted);">Нет отправленных писем</div>
665
+ {% endfor %}
666
  </div>
 
667
  </div>
668
 
669
+ <div class="view-section" id="view-contacts">
670
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; flex-wrap:wrap; gap:15px;">
671
+ <h2 style="margin:0; color:var(--primary);"><i class="fas fa-address-book"></i> Адресная книга</h2>
672
+ <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>
 
673
  </div>
674
+ <div class="contact-list">
675
+ {% for contact in contacts %}
676
+ <div class="contact-item">
677
+ <div class="contact-info">
678
+ <span class="contact-name">{{ contact.name }}</span>
679
+ <span class="contact-email">{{ contact.email }}</span>
680
+ </div>
681
+ <div class="contact-actions">
682
+ <button class="btn btn-outline" style="padding:10px;" onclick="useContact('{{ contact.email }}')" title="Написать"><i class="fas fa-pen"></i></button>
683
+ <form method="POST" action="/{{ env_id }}/contact_action" style="margin:0;" onsubmit="return confirm('Удалить контакт?');">
684
+ <input type="hidden" name="action" value="delete">
685
+ <input type="hidden" name="contact_id" value="{{ contact.id }}">
686
+ <button type="submit" class="btn" style="background:var(--danger); padding:10px;"><i class="fas fa-trash"></i></button>
687
+ </form>
688
+ </div>
689
+ </div>
690
+ {% else %}
691
+ <div style="text-align:center; padding:30px; color:var(--text-muted);">Адресная книга пуста</div>
692
+ {% endfor %}
693
  </div>
694
  </div>
695
  </div>
696
 
697
+ <div class="modal" id="contactsModal">
698
+ <div class="modal-content">
699
+ <button class="close-modal" onclick="document.getElementById('contactsModal').style.display='none'">&times;</button>
700
+ <h3 style="margin-top:0; color:var(--primary);">Выберите контакт</h3>
701
+ <div class="contact-list" style="margin-top:20px;">
702
+ {% for contact in contacts %}
703
+ <div class="contact-item" style="cursor:pointer;" onclick="useContact('{{ contact.email }}'); document.getElementById('contactsModal').style.display='none';">
704
+ <div class="contact-info">
705
+ <span class="contact-name">{{ contact.name }}</span>
706
+ <span class="contact-email">{{ contact.email }}</span>
707
+ </div>
708
  </div>
709
+ {% else %}
710
+ <div style="text-align:center; color:var(--text-muted);">Нет контактов</div>
711
+ {% endfor %}
712
+ </div>
713
+ </div>
714
+ </div>
715
+
716
+ <div class="modal" id="addContactModal">
717
+ <div class="modal-content">
718
+ <button class="close-modal" onclick="document.getElementById('addContactModal').style.display='none'">&times;</button>
719
+ <h3 style="margin-top:0; color:var(--primary);">Новый контакт</h3>
720
+ <form method="POST" action="/{{ env_id }}/contact_action" style="margin-top:20px;">
721
+ <input type="hidden" name="action" value="add">
722
  <div class="form-group">
723
+ <label>Имя:</label>
724
+ <input type="text" name="name" required placeholder="Иван Иванов">
725
  </div>
726
  <div class="form-group">
727
+ <label>Email:</label>
728
+ <input type="email" name="email" required placeholder="example@domain.com">
729
  </div>
730
+ <button type="submit" class="btn btn-primary">Сохранить</button>
731
+ </form>
732
+ </div>
733
+ </div>
734
+
735
+ <div class="modal" id="emailViewModal">
736
+ <div class="modal-content">
737
+ <button class="close-modal" onclick="document.getElementById('emailViewModal').style.display='none'">&times;</button>
738
+ <div style="margin-bottom:20px; border-bottom:1px solid var(--border); padding-bottom:15px;">
739
+ <h3 id="ev_subject" style="margin-top:0; color:var(--primary); font-size:1.4rem;"></h3>
740
+ <div style="color:var(--text-muted); font-size:0.95rem; display:flex; justify-content:space-between;">
741
+ <span id="ev_to"></span>
742
+ <span id="ev_date"></span>
743
  </div>
744
  </div>
745
+ <div id="ev_body" style="white-space:pre-wrap; line-height:1.6; color:var(--text); font-size:1.05rem;"></div>
746
+ <div id="ev_attachments" style="margin-top:20px; border-top:1px solid var(--border); padding-top:15px; display:none;">
747
+ <h4 style="margin-top:0; margin-bottom:10px;">Вложения:</h4>
748
+ <div id="ev_attachments_list"></div>
749
  </div>
750
+ </div>
751
  </div>
752
 
753
  <script>
754
  const emails = {{ emails_json|safe }};
755
+ const repoId = '{{ repo_id }}';
756
+
757
+ function switchView(viewId) {
758
+ document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
759
+ document.querySelectorAll('.view-section').forEach(s => s.classList.remove('active'));
760
+
761
+ event.currentTarget.classList.add('active');
762
+ document.getElementById('view-' + viewId).classList.add('active');
763
  }
764
+
765
+ function useContact(email) {
766
+ document.getElementById('to_email').value = email;
767
+ switchView('compose');
768
+ document.querySelector('.nav-tab:nth-child(1)').classList.add('active');
769
+ document.querySelector('.nav-tab:nth-child(3)').classList.remove('active');
 
 
770
  }
771
 
772
+ function sendMail(e) {
773
+ e.preventDefault();
774
+ const form = document.getElementById('composeForm');
775
+ const formData = new FormData(form);
776
 
777
+ document.getElementById('loadingOverlay').style.display = 'flex';
778
 
779
+ fetch('/{{ env_id }}/send_mail', {
780
+ method: 'POST',
781
+ body: formData
782
+ })
783
+ .then(res => res.json())
784
+ .then(data => {
785
+ document.getElementById('loadingOverlay').style.display = 'none';
786
+ if(data.success) {
787
+ alert('Письмо успешно отправлено!');
788
+ window.location.reload();
789
+ } else {
790
+ alert('Ошибка отправки: ' + (data.error || 'Неизвестная ошибка'));
791
+ }
792
+ })
793
+ .catch(err => {
794
+ document.getElementById('loadingOverlay').style.display = 'none';
795
+ alert('Произошла ошибка при отправке.');
 
 
 
 
796
  });
797
  }
798
+
799
+ function openEmail(id) {
800
+ const email = emails.find(e => e.id === id);
801
+ if(!email) return;
 
 
 
 
802
 
803
+ document.getElementById('ev_subject').innerText = email.subject;
804
+ document.getElementById('ev_to').innerText = 'Кому: ' + email.to;
805
+ document.getElementById('ev_date').innerText = email.date;
806
+ document.getElementById('ev_body').innerText = email.body;
807
 
808
+ const attContainer = document.getElementById('ev_attachments');
809
+ const attList = document.getElementById('ev_attachments_list');
810
+ attList.innerHTML = '';
811
 
812
+ if(email.attachments && email.attachments.length > 0) {
813
+ attContainer.style.display = 'block';
814
+ email.attachments.forEach(att => {
815
+ const url = `https://huggingface.co/datasets/${repoId}/resolve/main/attachments/${att.filename}`;
816
+ attList.innerHTML += `<a href="${url}" target="_blank" class="attachment-link"><i class="fas fa-file"></i> Вложение</a>`;
817
+ });
818
  } else {
819
+ attContainer.style.display = 'none';
820
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
821
 
822
+ document.getElementById('emailViewModal').style.display = 'flex';
 
 
 
823
  }
 
 
824
  </script>
825
  </body>
826
  </html>
 
828
 
829
  @app.route('/')
830
  def index():
831
+ return redirect(url_for('admhosto'))
832
 
833
  @app.route('/admhosto', methods=['GET'])
834
  def admhosto():
835
  data = load_data()
836
  active_envs = []
837
+ archived_envs = []
838
  for env_id, env_data in data.items():
 
 
839
  settings = env_data.get('settings', {})
840
+ org_name = settings.get("organization_name", f"Client {env_id}")
841
+ env_info = {
842
+ "id": env_id,
843
+ "org_name": org_name,
844
+ "pwd_enabled": settings.get("admin_password_enabled", False),
845
+ "password": settings.get("admin_password", "")
846
+ }
847
+ if settings.get('is_deleted', False):
848
+ archived_envs.append(env_info)
849
+ else:
850
+ active_envs.append(env_info)
851
+
852
+ active_envs.sort(key=lambda x: x['id'])
853
+ archived_envs.sort(key=lambda x: x['id'])
854
+ return render_template_string(ADMHOSTO_TEMPLATE, active_envs=active_envs, archived_envs=archived_envs)
855
 
856
  @app.route('/admhosto/create', methods=['POST'])
857
  def create_environment():
 
861
  if new_id not in all_data:
862
  break
863
  all_data[new_id] = {
864
+ 'emails': [],
 
 
 
 
 
 
 
 
 
865
  'contacts': [],
866
  'settings': {
867
+ "organization_name": f"Mail Client {new_id}",
868
+ "admin_password_enabled": False,
869
+ "admin_password": "",
870
+ "smtp_host": "smtp.gmail.com",
871
+ "smtp_port": 587,
872
+ "smtp_user": "",
873
+ "smtp_pass": "",
874
+ "sender_name": "",
875
+ "is_deleted": False
 
 
876
  }
877
  }
878
  save_data(all_data)
879
+ flash(f'Новый клиент с ID {new_id} успешно создан.', 'success')
880
+ return redirect(url_for('admhosto'))
881
+
882
+ @app.route('/admhosto/update_pwd/<env_id>', methods=['POST'])
883
+ def update_env_pwd(env_id):
884
+ all_data = load_data()
885
+ if env_id in all_data:
886
+ pwd_enabled = 'pwd_enabled' in request.form
887
+ password = request.form.get('password', '').strip()
888
+ all_data[env_id]['settings']['admin_password_enabled'] = pwd_enabled
889
+ all_data[env_id]['settings']['admin_password'] = password
890
+ save_data(all_data)
891
+ flash(f'Пароль для клиента {env_id} обновлен.', 'success')
892
  return redirect(url_for('admhosto'))
893
 
894
  @app.route('/admhosto/delete/<env_id>', methods=['POST'])
 
897
  if env_id in all_data:
898
  all_data[env_id]['settings']['is_deleted'] = True
899
  save_data(all_data)
900
+ flash(f'Клиент {env_id} отключен.', 'success')
901
+ return redirect(url_for('admhosto'))
902
+
903
+ @app.route('/admhosto/restore/<env_id>', methods=['POST'])
904
+ def restore_environment(env_id):
905
+ all_data = load_data()
906
+ if env_id in all_data:
907
+ all_data[env_id]['settings']['is_deleted'] = False
908
+ save_data(all_data)
909
+ flash(f'Клиент {env_id} восстановлен.', 'success')
910
+ return redirect(url_for('admhosto'))
911
+
912
+ @app.route('/admhosto/hard_delete/<env_id>', methods=['POST'])
913
+ def hard_delete_environment(env_id):
914
+ all_data = load_data()
915
+ if env_id in all_data:
916
+ del all_data[env_id]
917
+ save_data(all_data)
918
+ flash(f'Клиент {env_id} удален окончательно.', 'success')
919
  return redirect(url_for('admhosto'))
920
 
921
  @app.route('/<env_id>/login', methods=['GET', 'POST'])
922
+ def admin_login(env_id):
923
  data = get_env_data(env_id)
924
  settings = data.get('settings', {})
925
 
926
  if not settings.get('admin_password_enabled'):
927
+ return redirect(url_for('mail_client', env_id=env_id))
928
 
929
  if request.method == 'POST':
930
  pwd = request.form.get('password', '')
931
  if pwd == settings.get('admin_password', ''):
932
  session.permanent = True
933
+ session[f'admin_auth_{env_id}'] = True
934
+ return redirect(url_for('mail_client', env_id=env_id))
935
  else:
936
  flash('Неверный пароль', 'error')
937
 
938
  return render_template_string(LOGIN_TEMPLATE, env_id=env_id)
939
 
940
  @app.route('/<env_id>/logout')
941
+ def admin_logout(env_id):
942
+ session.pop(f'admin_auth_{env_id}', None)
943
+ return redirect(url_for('admin_login', env_id=env_id))
 
 
 
 
 
944
 
945
  @app.route('/<env_id>/admin', methods=['GET', 'POST'])
946
  def admin(env_id):
947
  data = get_env_data(env_id)
948
  settings = data.get('settings', {})
949
+
950
+ if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
951
+ return redirect(url_for('admin_login', env_id=env_id))
952
 
953
  if request.method == 'POST':
954
+ if request.form.get('action') == 'update_settings':
955
+ settings['organization_name'] = request.form.get('organization_name', '').strip()
956
+ settings['sender_name'] = request.form.get('sender_name', '').strip()
957
+ settings['smtp_host'] = request.form.get('smtp_host', '').strip()
958
+ settings['smtp_port'] = int(request.form.get('smtp_port', 587))
 
 
 
 
959
  settings['smtp_user'] = request.form.get('smtp_user', '').strip()
960
+ if request.form.get('smtp_pass'):
961
+ settings['smtp_pass'] = request.form.get('smtp_pass', '').strip()
962
+
963
  settings['admin_password_enabled'] = 'admin_password_enabled' in request.form
964
+ settings['admin_password'] = request.form.get('admin_password', '').strip()
965
 
966
  data['settings'] = settings
967
  save_env_data(env_id, data)
968
  flash('Настройки сохранены', 'success')
969
+ return redirect(url_for('admin', env_id=env_id))
970
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
971
  return render_template_string(ADMIN_TEMPLATE, env_id=env_id, settings=settings)
972
 
973
+ @app.route('/<env_id>/mail', methods=['GET'])
974
+ def mail_client(env_id):
975
  data = get_env_data(env_id)
976
  settings = data.get('settings', {})
977
+
978
+ if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
979
+ return redirect(url_for('admin_login', env_id=env_id))
980
+
981
+ emails = data.get('emails', [])
982
+ contacts = data.get('contacts', [])
983
 
984
  return render_template_string(
985
+ MAIL_TEMPLATE,
986
  env_id=env_id,
987
  settings=settings,
988
+ emails=emails,
989
+ contacts=contacts,
990
+ emails_json=json.dumps(emails),
991
+ repo_id=REPO_ID
992
  )
993
 
994
+ @app.route('/<env_id>/contact_action', methods=['POST'])
995
+ def contact_action(env_id):
996
  data = get_env_data(env_id)
997
  settings = data.get('settings', {})
998
+ if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
999
+ return redirect(url_for('admin_login', env_id=env_id))
1000
+
1001
+ action = request.form.get('action')
1002
+ contacts = data.get('contacts', [])
1003
+
1004
+ if action == 'add':
1005
+ name = request.form.get('name', '').strip()
1006
+ email = request.form.get('email', '').strip()
1007
+ if name and email:
1008
+ contacts.append({'id': uuid4().hex, 'name': name, 'email': email})
1009
+ data['contacts'] = contacts
1010
+ save_env_data(env_id, data)
1011
+ elif action == 'delete':
1012
+ cid = request.form.get('contact_id')
1013
+ data['contacts'] = [c for c in contacts if c['id'] != cid]
1014
+ save_env_data(env_id, data)
1015
+
1016
+ return redirect(url_for('mail_client', env_id=env_id))
1017
+
1018
+ @app.route('/<env_id>/send_mail', methods=['POST'])
1019
+ def send_mail_route(env_id):
1020
+ data = get_env_data(env_id)
1021
+ settings = data.get('settings', {})
1022
+
1023
+ if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
1024
+ return jsonify({'success': False, 'error': 'Unauthorized'}), 401
1025
 
1026
+ to_email = request.form.get('to_email', '').strip()
1027
  subject = request.form.get('subject', '').strip()
1028
+ body = request.form.get('body', '').strip()
1029
  files = request.files.getlist('attachments')
1030
 
1031
+ if not settings.get('smtp_host') or not settings.get('smtp_user'):
1032
+ return jsonify({'success': False, 'error': 'SMTP не настроен. Перейдите в настройки.'}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1033
 
1034
+ try:
1035
+ send_email(settings, to_email, subject, body, files)
 
 
 
 
1036
 
1037
+ uploaded_attachments = []
1038
+ for f in files:
1039
+ if f and f.filename:
1040
+ f.seek(0)
1041
+ filename = upload_attachment(f, 'attachments')
1042
+ if filename:
1043
+ uploaded_attachments.append({'original': f.filename, 'filename': filename})
1044
+
1045
+ email_record = {
1046
+ 'id': uuid4().hex,
1047
+ 'date': get_almaty_time(),
1048
+ 'to': to_email,
1049
+ 'subject': subject,
1050
+ 'body': body,
1051
+ 'attachments': uploaded_attachments
1052
+ }
1053
+
1054
+ data.setdefault('emails', []).append(email_record)
1055
+ save_env_data(env_id, data)
1056
+
1057
+ return jsonify({'success': True})
1058
+ except Exception as e:
1059
+ return jsonify({'success': False, 'error': str(e)}), 500
1060
 
1061
  if __name__ == '__main__':
1062
  download_db_from_hf()
1063
  load_data()
 
1064
  if HF_TOKEN_WRITE:
1065
  threading.Thread(target=periodic_backup, daemon=True).start()
 
1066
  port = int(os.environ.get('PORT', 7860))
1067
  app.run(host='0.0.0.0', port=port)