diff --git a/dockflare/app/i18n/en.json b/dockflare/app/i18n/en.json index 1483a39..1b1eafa 100644 --- a/dockflare/app/i18n/en.json +++ b/dockflare/app/i18n/en.json @@ -774,5 +774,16 @@ "email.teardown": "Teardown", "email.no_domains": "No domains configured.", "email.choose_domain": "Choose a domain...", - "email.select_zone": "Select Cloudflare Zone" + "email.select_zone": "Select Cloudflare Zone", + "email.backup_restore": "System Backup & Restore", + "email.backup_title": "Create System Backup", + "email.backup_description": "Download a full backup of your email database and attachments (.zip).", + "email.backup_security_warning": "Security Warning: This backup contains highly sensitive data (emails and attachments) in plain text. Please store it securely.", + "email.download_backup": "Download Backup", + "email.restore_title": "Restore from Backup", + "email.restore_description": "Upload a backup archive to restore your email database and attachments.", + "email.restore_warning": "Warning: Restoring a backup will permanently overwrite your current email data and attachments. This action cannot be undone.", + "email.restore_backup": "Restore Backup", + "email.restore_confirm": "Are you absolutely sure you want to restore this backup? All current email data will be overwritten.", + "email.restore_success": "Restore successful. Restarting mail system..." } diff --git a/dockflare/app/static/js/main.js b/dockflare/app/static/js/main.js index dca2ed5..bf698fb 100644 --- a/dockflare/app/static/js/main.js +++ b/dockflare/app/static/js/main.js @@ -2109,3 +2109,49 @@ async function emailRedeployWorkers() { console.error(e); } } + +async function emailRestoreBackup() { + const fileInput = document.getElementById('emailRestoreFile'); + if (!fileInput.files || fileInput.files.length === 0) { + return; + } + if (!await dfConfirm(t('email.restore_confirm'), t('email.restore_title'))) { + return; + } + const btn = document.getElementById('emailRestoreBtn'); + const feedback = document.getElementById('emailRestoreFeedback'); + btn.disabled = true; + btn.innerHTML = ' ' + t('common.loading'); + feedback.classList.remove('hidden', 'text-error', 'text-success'); + feedback.textContent = 'Uploading and restoring...'; + + const formData = new FormData(); + formData.append('file', fileInput.files[0]); + + try { + const response = await fetch('/email/restore', { + method: 'POST', + headers: buildApiHeaders(), + body: formData + }); + const data = await response.json(); + if (data.success) { + feedback.classList.add('text-success'); + feedback.textContent = t('email.restore_success'); + setTimeout(() => { + location.reload(); + }, 3000); + } else { + feedback.classList.add('text-error'); + feedback.textContent = 'Error: ' + (data.error || 'Unknown error'); + btn.disabled = false; + btn.textContent = t('email.restore_backup'); + } + } catch (e) { + feedback.classList.add('text-error'); + feedback.textContent = 'Error: ' + e.message; + btn.disabled = false; + btn.textContent = t('email.restore_backup'); + console.error(e); + } +} diff --git a/dockflare/app/templates/email.html b/dockflare/app/templates/email.html index d025fd4..9667dc9 100644 --- a/dockflare/app/templates/email.html +++ b/dockflare/app/templates/email.html @@ -156,6 +156,31 @@ +
+
+

{{ t('email.backup_restore') }}

+
+
+

{{ t('email.backup_title') }}

+

{{ t('email.backup_description') }}

+
{{ t('email.backup_security_warning') }}
+
+ {{ t('email.download_backup') }} +
+
+
+

{{ t('email.restore_title') }}

+

{{ t('email.restore_description') }}

+
{{ t('email.restore_warning') }}
+
+ + +
+ +
+
+
+

{{ t('email.container_status') }}

diff --git a/dockflare/app/web/email_routes.py b/dockflare/app/web/email_routes.py index cf62dbd..16ea8e4 100644 --- a/dockflare/app/web/email_routes.py +++ b/dockflare/app/web/email_routes.py @@ -453,6 +453,50 @@ def mailbox_login(): response.headers['Access-Control-Allow-Origin'] = origin return response +@email_bp.route('/backup', methods=['GET']) +@login_required +def email_backup(): + import requests + from flask import Response, stream_with_context + token = _generate_jwt(current_user.get_id(), role='admin') + if not token: + return jsonify({'error': 'JWT configuration missing'}), 500 + url = f"{config.MAIL_MANAGER_INTERNAL_URL}/api/v1/system/backup" + try: + req = requests.get(url, headers={'Authorization': f'Bearer {token}'}, stream=True, timeout=30) + req.raise_for_status() + except Exception as e: + return jsonify({'error': str(e)}), 500 + return Response( + stream_with_context(req.iter_content(chunk_size=8192)), + content_type=req.headers.get('content-type', 'application/zip'), + headers={'Content-Disposition': req.headers.get('content-disposition', 'attachment; filename="dockflare_email_backup.zip"')} + ) + +@email_bp.route('/restore', methods=['POST']) +@login_required +def email_restore(): + import requests + if 'file' not in request.files: + return jsonify({'success': False, 'error': 'No file uploaded'}), 400 + file = request.files['file'] + if not file.filename.endswith('.zip'): + return jsonify({'success': False, 'error': 'Must be a ZIP file'}), 400 + token = _generate_jwt(current_user.get_id(), role='admin') + if not token: + return jsonify({'success': False, 'error': 'JWT configuration missing'}), 500 + url = f"{config.MAIL_MANAGER_INTERNAL_URL}/api/v1/system/restore" + try: + resp = requests.post(url, headers={'Authorization': f'Bearer {token}'}, files={'file': (file.filename, file.stream, file.mimetype)}, timeout=300) + resp.raise_for_status() + return jsonify({'success': True}) + except Exception as e: + try: + err_data = resp.json() + return jsonify({'success': False, 'error': err_data.get('error', str(e))}), 500 + except Exception: + return jsonify({'success': False, 'error': str(e)}), 500 + def _check_internal_request(): # Block any request that carries Cloudflare edge headers (all public internet # requests via the CF tunnel have CF-Ray; internal Docker requests never do) diff --git a/mail-manager/app/__init__.py b/mail-manager/app/__init__.py index 162b939..e6003f4 100644 --- a/mail-manager/app/__init__.py +++ b/mail-manager/app/__init__.py @@ -1,6 +1,7 @@ from flask import Flask from .api.routes import api_bp from .api.webhook import webhook_bp +from .api.system import system_bp from .core.database import init_db, register_db from .core.scheduler import start_scheduler @@ -14,4 +15,5 @@ def create_app(): app.register_blueprint(api_bp, url_prefix='/api/v1') app.register_blueprint(webhook_bp, url_prefix='/api/v1/webhook') + app.register_blueprint(system_bp, url_prefix='/api/v1/system') return app diff --git a/mail-manager/app/api/system.py b/mail-manager/app/api/system.py new file mode 100644 index 0000000..58362eb --- /dev/null +++ b/mail-manager/app/api/system.py @@ -0,0 +1,140 @@ +import os +import sqlite3 +import zipfile +import json +import threading +import shutil +from datetime import datetime, timezone +from flask import Blueprint, jsonify, send_file, request, after_this_request +from app.config import config +from app.api.middleware import admin_required + +system_bp = Blueprint('system', __name__) + +@system_bp.route('/backup', methods=['GET']) +@admin_required +def backup_system(): + tmp_db_path = '/tmp/backup.db' + tmp_zip_path = f'/tmp/email_backup_{datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")}.zip' + + if os.path.exists(tmp_db_path): + os.remove(tmp_db_path) + + db_path = config.DB_PATH + try: + conn = sqlite3.connect(db_path) + conn.execute(f"VACUUM INTO '{tmp_db_path}'") + conn.close() + except Exception as e: + return jsonify({"error": str(e)}), 500 + + manifest = { + "schema": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "files": [] + } + + try: + with zipfile.ZipFile(tmp_zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.write(tmp_db_path, arcname='db/mail.db') + + att_path = config.ATTACHMENTS_PATH + if os.path.exists(att_path): + for root, dirs, files in os.walk(att_path): + for f in files: + file_path = os.path.join(root, f) + arc_name = os.path.relpath(file_path, config.MAIL_DATA_PATH) + zf.write(file_path, arcname=arc_name) + + zf.writestr('manifest.json', json.dumps(manifest, indent=2)) + + @after_this_request + def cleanup(response): + if os.path.exists(tmp_db_path): + os.remove(tmp_db_path) + if os.path.exists(tmp_zip_path): + os.remove(tmp_zip_path) + return response + + return send_file(tmp_zip_path, as_attachment=True, download_name=os.path.basename(tmp_zip_path)) + except Exception as e: + if os.path.exists(tmp_db_path): + os.remove(tmp_db_path) + if os.path.exists(tmp_zip_path): + os.remove(tmp_zip_path) + return jsonify({"error": str(e)}), 500 + +def _schedule_restart(): + def restart(): + import time + time.sleep(2) + os._exit(0) + threading.Thread(target=restart).start() + +@system_bp.route('/restore', methods=['POST']) +@admin_required +def restore_system(): + config.IN_MAINTENANCE = True + + if 'file' not in request.files: + config.IN_MAINTENANCE = False + return jsonify({"error": "No file uploaded"}), 400 + + file = request.files['file'] + tmp_upload_path = '/tmp/restore_upload.zip' + staging_path = '/data/restore_staging' + old_data_path = '/data/old_data' + + file.save(tmp_upload_path) + + try: + with zipfile.ZipFile(tmp_upload_path, 'r') as zf: + if 'manifest.json' not in zf.namelist(): + raise ValueError("Invalid backup archive: missing manifest.json") + if os.path.exists(staging_path): + shutil.rmtree(staging_path) + os.makedirs(staging_path) + zf.extractall(staging_path) + + if os.path.exists(old_data_path): + shutil.rmtree(old_data_path) + os.makedirs(old_data_path) + + db_dir = os.path.dirname(config.DB_PATH) + att_dir = config.ATTACHMENTS_PATH + + if os.path.exists(db_dir): + shutil.move(db_dir, os.path.join(old_data_path, 'db')) + if os.path.exists(att_dir): + shutil.move(att_dir, os.path.join(old_data_path, 'attachments')) + + staged_db = os.path.join(staging_path, 'db') + staged_att = os.path.join(staging_path, 'attachments') + + if os.path.exists(staged_db): + shutil.move(staged_db, db_dir) + if os.path.exists(staged_att): + shutil.move(staged_att, att_dir) + + shutil.rmtree(staging_path) + os.remove(tmp_upload_path) + + _schedule_restart() + return jsonify({"status": "success"}) + + except Exception as e: + config.IN_MAINTENANCE = False + if os.path.exists(tmp_upload_path): + os.remove(tmp_upload_path) + if os.path.exists(staging_path): + shutil.rmtree(staging_path) + if os.path.exists(os.path.join(old_data_path, 'db')): + if os.path.exists(db_dir): + shutil.rmtree(db_dir) + shutil.move(os.path.join(old_data_path, 'db'), db_dir) + if os.path.exists(os.path.join(old_data_path, 'attachments')): + if os.path.exists(att_dir): + shutil.rmtree(att_dir) + shutil.move(os.path.join(old_data_path, 'attachments'), att_dir) + + return jsonify({"error": str(e)}), 500 diff --git a/mail-manager/app/api/webhook.py b/mail-manager/app/api/webhook.py index 616384f..5608997 100644 --- a/mail-manager/app/api/webhook.py +++ b/mail-manager/app/api/webhook.py @@ -33,6 +33,9 @@ def _verify_signature(req, secret): @webhook_bp.route('/inbound', methods=['POST']) def inbound(): + if getattr(config, 'IN_MAINTENANCE', False): + return jsonify({"error": "Service unavailable during maintenance"}), 503 + domain = request.headers.get('X-DockFlare-Domain', '').strip() if domain and domain != 'undefined': diff --git a/mail-manager/app/config.py b/mail-manager/app/config.py index 90f9fdc..e3c52c3 100644 --- a/mail-manager/app/config.py +++ b/mail-manager/app/config.py @@ -68,3 +68,4 @@ class _Config: config = _Config() +config.IN_MAINTENANCE = False