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.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