mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-28 03:39:32 +00:00
352 lines
14 KiB
Python
352 lines
14 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import time
|
|
import jwt
|
|
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, current_app
|
|
from flask_login import login_required, current_user
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
from app import config, docker_client
|
|
from app.core import email_manager
|
|
from app.core.cloudflare_api import list_account_zones
|
|
from app.web.config_loader import load_encrypted_config, load_encrypted_config_with_cipher, config_file_path
|
|
|
|
_WORKER_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), '..', 'core', 'worker_templates')
|
|
|
|
def _read_worker_template(filename):
|
|
with open(os.path.join(_WORKER_TEMPLATE_DIR, filename), 'r') as f:
|
|
return f.read()
|
|
|
|
email_bp = Blueprint('email', __name__, url_prefix='/email')
|
|
|
|
def save_email_config(email_config_data):
|
|
cfg, fernet = load_encrypted_config_with_cipher()
|
|
if not cfg or not fernet:
|
|
return False
|
|
cfg['email_config'] = email_config_data
|
|
try:
|
|
import os
|
|
from app.web.config_loader import config_file_path
|
|
cfg_str = json.dumps(cfg)
|
|
encrypted_data = fernet.encrypt(cfg_str.encode('utf-8'))
|
|
with open(config_file_path(), 'wb') as f:
|
|
f.write(encrypted_data)
|
|
config.EMAIL_CONFIG = email_config_data
|
|
current_app.config['EMAIL_CONFIG'] = email_config_data
|
|
return True
|
|
except Exception as e:
|
|
logging.error(f"Failed to save email config: {e}")
|
|
return False
|
|
|
|
@email_bp.route('', methods=['GET'])
|
|
@login_required
|
|
def email_page():
|
|
zones = list_account_zones() or []
|
|
return render_template('email.html', zones=zones, email_config=config.EMAIL_CONFIG, email_enabled=config.EMAIL_ENABLED)
|
|
|
|
@email_bp.route('/setup-domain', methods=['POST'])
|
|
@login_required
|
|
def setup_email_domain():
|
|
data = request.get_json(force=True, silent=True) or {}
|
|
zone_id = data.get('zone_id')
|
|
zone_name = data.get('zone_name')
|
|
if not zone_id or not zone_name:
|
|
return jsonify({'success': False, 'error': 'Missing zone info'}), 400
|
|
try:
|
|
try:
|
|
email_manager.enable_email_routing(zone_id)
|
|
except Exception as routing_err:
|
|
logging.warning(f"Could not enable email routing via API (may need manual enable in CF Dashboard): {routing_err}")
|
|
email_manager.setup_email_dns_records(zone_id, zone_name)
|
|
bucket_name = f"dockflare-mail-{zone_name.replace('.', '-')}"
|
|
email_manager.create_r2_bucket(bucket_name)
|
|
r2_creds = email_manager.get_r2_s3_credentials()
|
|
r2_access_key_id = r2_creds['access_key_id']
|
|
r2_secret_access_key = r2_creds['secret_access_key']
|
|
r2_endpoint_url = r2_creds['endpoint_url']
|
|
|
|
workers_subdomain = email_manager.get_workers_subdomain()
|
|
|
|
email_cfg = config.EMAIL_CONFIG.copy()
|
|
email_cfg['enabled'] = True
|
|
if 'domains' not in email_cfg:
|
|
email_cfg['domains'] = {}
|
|
|
|
if 'jwt_signing_key' not in email_cfg:
|
|
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
public_key = private_key.public_key()
|
|
|
|
private_bytes = private_key.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption()
|
|
)
|
|
public_bytes = public_key.public_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
|
)
|
|
email_cfg['jwt_signing_key'] = private_bytes.decode('utf-8')
|
|
email_cfg['jwt_public_key'] = public_bytes.decode('utf-8')
|
|
|
|
webhook_secret = secrets.token_hex(32)
|
|
outbound_auth_secret = secrets.token_hex(32)
|
|
inbound_worker_name = f"dockflare-mail-inbound-{zone_name.replace('.', '-')}"
|
|
outbound_worker_name = f"dockflare-mail-outbound-{zone_name.replace('.', '-')}"
|
|
webmail_hostname = f"mail.{zone_name}"
|
|
webhook_url = f"https://{webmail_hostname}/api/v1/webhook/inbound"
|
|
|
|
inbound_bindings = [
|
|
{"type": "r2_bucket", "name": "EMAIL_BUCKET", "bucket_name": bucket_name},
|
|
{"type": "plain_text", "name": "WEBHOOK_URL", "text": webhook_url},
|
|
{"type": "secret_text", "name": "WEBHOOK_SECRET", "text": webhook_secret},
|
|
{"type": "plain_text", "name": "ALLOWED_RECIPIENTS", "text": "[]"}
|
|
]
|
|
email_manager.deploy_worker(inbound_worker_name, _read_worker_template('inbound_worker.js'), inbound_bindings)
|
|
email_manager.setup_catchall_routing_rule(zone_id, inbound_worker_name)
|
|
|
|
outbound_bindings = [
|
|
{"type": "send_email", "name": "SEND_EMAIL"},
|
|
{"type": "secret_text", "name": "AUTH_SECRET", "text": outbound_auth_secret}
|
|
]
|
|
email_manager.deploy_worker(outbound_worker_name, _read_worker_template('outbound_worker.js'), outbound_bindings)
|
|
|
|
outbound_worker_url = f"https://{outbound_worker_name}.{workers_subdomain}.workers.dev" if workers_subdomain else ''
|
|
|
|
email_cfg['domains'][zone_name] = {
|
|
'zone_id': zone_id,
|
|
'zone_name': zone_name,
|
|
'email_routing_enabled': True,
|
|
'r2_bucket': bucket_name,
|
|
'r2_access_key_id': r2_access_key_id,
|
|
'r2_secret_access_key': r2_secret_access_key,
|
|
'r2_endpoint_url': r2_endpoint_url,
|
|
'webhook_secret': webhook_secret,
|
|
'inbound_worker_name': inbound_worker_name,
|
|
'outbound_worker_name': outbound_worker_name,
|
|
'outbound_worker_url': outbound_worker_url,
|
|
'outbound_auth_secret': outbound_auth_secret,
|
|
'mailboxes': {}
|
|
}
|
|
|
|
save_email_config(email_cfg)
|
|
config.EMAIL_ENABLED = True
|
|
current_app.config['EMAIL_ENABLED'] = True
|
|
_restart_mail_container()
|
|
return jsonify({'success': True})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@email_bp.route('/teardown-domain', methods=['POST'])
|
|
@login_required
|
|
def teardown_domain():
|
|
data = request.get_json(force=True, silent=True) or {}
|
|
zone_name = data.get('zone_name')
|
|
email_cfg = config.EMAIL_CONFIG.copy()
|
|
if 'domains' in email_cfg and zone_name in email_cfg['domains']:
|
|
del email_cfg['domains'][zone_name]
|
|
save_email_config(email_cfg)
|
|
_restart_mail_container()
|
|
return jsonify({'success': True})
|
|
|
|
def _redeploy_inbound_worker(email_cfg, domain):
|
|
d = email_cfg['domains'][domain]
|
|
all_addresses = list(d['mailboxes'].keys())
|
|
webmail_hostname = f"mail.{domain}"
|
|
webhook_url = f"https://{webmail_hostname}/api/v1/webhook/inbound"
|
|
inbound_bindings = [
|
|
{"type": "r2_bucket", "name": "EMAIL_BUCKET", "bucket_name": d['r2_bucket']},
|
|
{"type": "plain_text", "name": "WEBHOOK_URL", "text": webhook_url},
|
|
{"type": "secret_text", "name": "WEBHOOK_SECRET", "text": d['webhook_secret']},
|
|
{"type": "plain_text", "name": "ALLOWED_RECIPIENTS", "text": json.dumps(all_addresses)}
|
|
]
|
|
email_manager.deploy_worker(d['inbound_worker_name'], _read_worker_template('inbound_worker.js'), inbound_bindings)
|
|
|
|
@email_bp.route('/mailbox/create', methods=['POST'])
|
|
@login_required
|
|
def create_mailbox():
|
|
data = request.get_json(force=True, silent=True) or {}
|
|
address = data.get('address')
|
|
display_name = data.get('display_name')
|
|
domain = data.get('domain')
|
|
|
|
email_cfg = config.EMAIL_CONFIG.copy()
|
|
if 'domains' not in email_cfg or domain not in email_cfg['domains']:
|
|
return jsonify({'success': False, 'error': 'Domain not configured'}), 400
|
|
|
|
zone_id = email_cfg['domains'][domain]['zone_id']
|
|
worker_name = f"dockflare-mail-inbound-{domain.replace('.', '-')}"
|
|
|
|
try:
|
|
res = email_manager.create_email_routing_rule(zone_id, address, worker_name)
|
|
rule_id = res.get('result', {}).get('id', '')
|
|
|
|
email_cfg['domains'][domain]['mailboxes'][address] = {
|
|
'display_name': display_name,
|
|
'routing_rule_id': rule_id,
|
|
'created_at': time.time()
|
|
}
|
|
save_email_config(email_cfg)
|
|
_redeploy_inbound_worker(email_cfg, domain)
|
|
_restart_mail_container()
|
|
return jsonify({'success': True})
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@email_bp.route('/mailbox/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_mailbox():
|
|
data = request.get_json(force=True, silent=True) or {}
|
|
address = data.get('address')
|
|
domain = data.get('domain')
|
|
|
|
email_cfg = config.EMAIL_CONFIG.copy()
|
|
if 'domains' in email_cfg and domain in email_cfg['domains']:
|
|
if address in email_cfg['domains'][domain]['mailboxes']:
|
|
rule_id = email_cfg['domains'][domain]['mailboxes'][address].get('routing_rule_id')
|
|
zone_id = email_cfg['domains'][domain]['zone_id']
|
|
if rule_id:
|
|
try:
|
|
email_manager.delete_email_routing_rule(zone_id, rule_id)
|
|
except Exception:
|
|
pass
|
|
del email_cfg['domains'][domain]['mailboxes'][address]
|
|
save_email_config(email_cfg)
|
|
_redeploy_inbound_worker(email_cfg, domain)
|
|
_restart_mail_container()
|
|
return jsonify({'success': True})
|
|
|
|
@email_bp.route('/update-r2-credentials', methods=['POST'])
|
|
@login_required
|
|
def update_r2_credentials():
|
|
data = request.get_json(force=True, silent=True) or {}
|
|
zone_name = data.get('zone_name')
|
|
access_key_id = data.get('r2_access_key_id', '').strip()
|
|
secret_access_key = data.get('r2_secret_access_key', '').strip()
|
|
if not zone_name or not access_key_id or not secret_access_key:
|
|
return jsonify({'success': False, 'error': 'Missing required fields'}), 400
|
|
email_cfg = config.EMAIL_CONFIG.copy()
|
|
if 'domains' not in email_cfg or zone_name not in email_cfg['domains']:
|
|
return jsonify({'success': False, 'error': 'Domain not configured'}), 404
|
|
email_cfg['domains'][zone_name]['r2_access_key_id'] = access_key_id
|
|
email_cfg['domains'][zone_name]['r2_secret_access_key'] = secret_access_key
|
|
save_email_config(email_cfg)
|
|
_restart_mail_container()
|
|
return jsonify({'success': True})
|
|
|
|
@email_bp.route('/status', methods=['GET'])
|
|
@login_required
|
|
def email_status_api():
|
|
return jsonify({'success': True, 'config': config.EMAIL_CONFIG})
|
|
|
|
@email_bp.route('/verify-dns', methods=['POST'])
|
|
@login_required
|
|
def verify_dns():
|
|
data = request.get_json(force=True, silent=True) or {}
|
|
zone_name = data.get('zone_name')
|
|
email_cfg = config.EMAIL_CONFIG
|
|
if 'domains' in email_cfg and zone_name in email_cfg['domains']:
|
|
zone_id = email_cfg['domains'][zone_name]['zone_id']
|
|
status = email_manager.verify_email_dns_records(zone_id, zone_name)
|
|
return jsonify({'success': True, 'status': status})
|
|
return jsonify({'success': False, 'error': 'Domain not found'}), 404
|
|
|
|
@email_bp.route('/check-permissions', methods=['POST'])
|
|
@login_required
|
|
def check_permissions():
|
|
perms = email_manager.check_token_permissions()
|
|
return jsonify({'success': True, 'permissions': perms})
|
|
|
|
@email_bp.route('/generate-jwt', methods=['POST'])
|
|
@login_required
|
|
def generate_jwt_route():
|
|
username = current_user.get_id()
|
|
token = _generate_jwt(username)
|
|
if not token:
|
|
return jsonify({'success': False, 'error': 'JWT config missing'}), 500
|
|
return jsonify({'success': True, 'token': token})
|
|
|
|
@email_bp.route('/sso/callback', methods=['GET'])
|
|
@login_required
|
|
def sso_callback():
|
|
username = current_user.get_id()
|
|
token = _generate_jwt(username)
|
|
if not token:
|
|
return "JWT configuration missing. Please setup email first.", 500
|
|
return_to = request.args.get('return_to', '')
|
|
allowed_domains = set()
|
|
for zone_name in config.EMAIL_CONFIG.get('domains', {}).keys():
|
|
allowed_domains.add(f"mail.{zone_name}")
|
|
if not return_to or return_to not in allowed_domains:
|
|
return_to = next(iter(allowed_domains), '')
|
|
if not return_to:
|
|
return "No webmail domain configured.", 500
|
|
return redirect(f"https://{return_to}/auth/callback?token={token}")
|
|
|
|
def _generate_jwt(username):
|
|
email_cfg = config.EMAIL_CONFIG
|
|
if not email_cfg or 'jwt_signing_key' not in email_cfg:
|
|
return None
|
|
|
|
private_key_pem = email_cfg['jwt_signing_key']
|
|
private_key = serialization.load_pem_private_key(
|
|
private_key_pem.encode('utf-8'),
|
|
password=None
|
|
)
|
|
|
|
mailboxes = []
|
|
for d, d_data in email_cfg.get('domains', {}).items():
|
|
for m in d_data.get('mailboxes', {}).keys():
|
|
mailboxes.append(m)
|
|
|
|
now = int(time.time())
|
|
payload = {
|
|
"sub": username,
|
|
"iss": config.EMAIL_JWT_ISSUER,
|
|
"aud": config.EMAIL_JWT_AUDIENCE,
|
|
"iat": now,
|
|
"exp": now + config.EMAIL_JWT_EXPIRY_SECONDS,
|
|
"mailboxes": mailboxes,
|
|
"role": "admin"
|
|
}
|
|
|
|
token = jwt.encode(payload, private_key, algorithm=config.EMAIL_JWT_ALGORITHM)
|
|
return token
|
|
|
|
@email_bp.route('/internal/config', methods=['GET'])
|
|
def internal_mail_config():
|
|
cfg = config.EMAIL_CONFIG
|
|
if not cfg or not cfg.get('enabled') or not cfg.get('domains'):
|
|
return jsonify({'configured': False})
|
|
domains_out = {}
|
|
for zone_name, d in cfg['domains'].items():
|
|
domains_out[zone_name] = {
|
|
'r2_bucket': d.get('r2_bucket', ''),
|
|
'r2_access_key_id': d.get('r2_access_key_id', ''),
|
|
'r2_secret_access_key': d.get('r2_secret_access_key', ''),
|
|
'r2_endpoint_url': d.get('r2_endpoint_url', ''),
|
|
'webhook_secret': d.get('webhook_secret', ''),
|
|
'outbound_worker_url': d.get('outbound_worker_url', ''),
|
|
'outbound_auth_secret': d.get('outbound_auth_secret', ''),
|
|
'mailboxes': {
|
|
addr: {'display_name': m.get('display_name', '')}
|
|
for addr, m in d.get('mailboxes', {}).items()
|
|
}
|
|
}
|
|
return jsonify({
|
|
'configured': True,
|
|
'jwt_public_key': cfg.get('jwt_public_key', ''),
|
|
'jwt_algorithm': config.EMAIL_JWT_ALGORITHM,
|
|
'jwt_issuer': config.EMAIL_JWT_ISSUER,
|
|
'jwt_audience': config.EMAIL_JWT_AUDIENCE,
|
|
'domains': domains_out
|
|
})
|
|
|
|
def _restart_mail_container():
|
|
try:
|
|
container = docker_client.containers.get('dockflare-mail-manager')
|
|
container.restart()
|
|
logging.info("Restarted dockflare-mail-manager")
|
|
except Exception as e:
|
|
logging.warning(f"Could not restart dockflare-mail-manager: {e}")
|