Backup and Restore of Mail DB and Data

This commit is contained in:
ChrispyBacon-dev 2026-04-13 15:21:59 +02:00
parent 2ed140d45b
commit 9738a30201
8 changed files with 273 additions and 1 deletions

View file

@ -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..."
}

View file

@ -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 = '<span class="loading loading-spinner loading-sm"></span> ' + 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);
}
}

View file

@ -156,6 +156,31 @@
</div>
</div>
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">{{ t('email.backup_restore') }}</h2>
<div class="divider mt-0 mb-4"></div>
<div>
<h3 class="text-lg font-semibold">{{ t('email.backup_title') }}</h3>
<p class="text-sm opacity-80 mb-2">{{ t('email.backup_description') }}</p>
<div class="badge badge-warning mb-4 p-3 h-auto whitespace-normal text-left">{{ t('email.backup_security_warning') }}</div>
<br>
<a href="{{ url_for('email.email_backup') }}" class="btn btn-primary btn-sm">{{ t('email.download_backup') }}</a>
</div>
<div class="divider"></div>
<div>
<h3 class="text-lg font-semibold">{{ t('email.restore_title') }}</h3>
<p class="text-sm opacity-80 mb-2">{{ t('email.restore_description') }}</p>
<div class="badge badge-error mb-4 p-3 h-auto whitespace-normal text-left">{{ t('email.restore_warning') }}</div>
<div class="flex gap-2">
<input type="file" id="emailRestoreFile" accept=".zip" class="file-input file-input-bordered file-input-sm w-full max-w-xs" />
<button class="btn btn-error btn-sm" id="emailRestoreBtn" onclick="emailRestoreBackup()">{{ t('email.restore_backup') }}</button>
</div>
<p id="emailRestoreFeedback" class="text-sm mt-2 font-semibold hidden"></p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title">{{ t('email.container_status') }}</h2>

View file

@ -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)

View file

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

View file

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

View file

@ -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':

View file

@ -68,3 +68,4 @@ class _Config:
config = _Config()
config.IN_MAINTENANCE = False