DockFlare/dockflare/app/web/email_routes.py

503 lines
20 KiB
Python

import hmac
import ipaddress
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 werkzeug.security import generate_password_hash, check_password_hash
from app import config, docker_client, limiter
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 _webmail_origin():
request_origin = request.headers.get('Origin', '')
if not request_origin:
return '*'
# Allow any origin that looks like our mail subdomains
if '.dockflare.app' in request_origin or 'localhost' in request_origin or '127.0.0.1' in request_origin:
return request_origin
# Fallback to the first domain or *
domains = config.EMAIL_CONFIG.get('domains', {})
first_domain = next(iter(domains), '')
return f"https://mail.{first_domain}" if first_domain else '*'
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": "[]"},
{"type": "plain_text", "name": "DOMAIN_NAME", "text": zone_name}
]
email_manager.deploy_worker(inbound_worker_name, _read_worker_template('inbound_worker.js'), inbound_bindings)
email_manager.set_worker_cron(inbound_worker_name, ['*/5 * * * *'])
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('/repair-dns', methods=['POST'])
@login_required
def repair_dns():
data = request.get_json(force=True, silent=True) or {}
zone_name = data.get('zone_name')
email_cfg = config.EMAIL_CONFIG
if not zone_name or zone_name not in email_cfg.get('domains', {}):
return jsonify({'success': False, 'error': 'Domain not found'}), 404
try:
zone_id = email_cfg['domains'][zone_name]['zone_id']
email_manager.setup_email_dns_records(zone_id, zone_name)
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_outbound_worker(email_cfg, domain):
d = email_cfg['domains'][domain]
outbound_bindings = [
{"type": "send_email", "name": "SEND_EMAIL"},
{"type": "secret_text", "name": "AUTH_SECRET", "text": d['outbound_auth_secret']}
]
email_manager.deploy_worker(d['outbound_worker_name'], _read_worker_template('outbound_worker.js'), outbound_bindings)
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)},
{"type": "plain_text", "name": "DOMAIN_NAME", "text": domain}
]
email_manager.deploy_worker(d['inbound_worker_name'], _read_worker_template('inbound_worker.js'), inbound_bindings)
email_manager.set_worker_cron(d['inbound_worker_name'], ['*/5 * * * *'])
@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('/redeploy-workers', methods=['POST'])
@login_required
def redeploy_workers():
email_cfg = config.EMAIL_CONFIG.copy()
domains = email_cfg.get('domains', {})
for domain in domains:
try:
_redeploy_inbound_worker(email_cfg, domain)
except Exception as e:
return jsonify({'success': False, 'error': f'Inbound redeploy failed for {domain}: {e}'}), 500
try:
_redeploy_outbound_worker(email_cfg, domain)
except Exception as e:
return jsonify({'success': False, 'error': f'Outbound redeploy failed for {domain}: {e}'}), 500
return jsonify({'success': True, 'domains': list(domains.keys())})
@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, mailboxes=None, role='admin', expiry_seconds=None):
email_cfg = config.EMAIL_CONFIG
if not email_cfg or 'jwt_signing_key' not in email_cfg:
return None
private_key = serialization.load_pem_private_key(
email_cfg['jwt_signing_key'].encode('utf-8'),
password=None
)
if mailboxes is None:
mailboxes = [
m for d in email_cfg.get('domains', {}).values()
for m in d.get('mailboxes', {}).keys()
]
if expiry_seconds is None:
expiry_seconds = config.EMAIL_JWT_EXPIRY_SECONDS
now = int(time.time())
payload = {
"sub": username,
"iss": config.EMAIL_JWT_ISSUER,
"aud": config.EMAIL_JWT_AUDIENCE,
"iat": now,
"exp": now + expiry_seconds,
"mailboxes": mailboxes,
"role": role,
}
return jwt.encode(payload, private_key, algorithm=config.EMAIL_JWT_ALGORITHM)
@email_bp.route('/mailbox/set-password', methods=['POST'])
@login_required
def set_mailbox_password():
data = request.get_json(force=True, silent=True) or {}
address = data.get('address', '')
domain = data.get('domain', '')
password = data.get('password', '')
if len(password) < 8:
return jsonify({'success': False, 'error': 'Password must be at least 8 characters'}), 400
email_cfg = config.EMAIL_CONFIG
if domain not in email_cfg.get('domains', {}) or address not in email_cfg['domains'][domain].get('mailboxes', {}):
return jsonify({'success': False, 'error': 'Mailbox not found'}), 404
email_cfg['domains'][domain]['mailboxes'][address]['password_hash'] = generate_password_hash(password)
save_email_config(email_cfg)
return jsonify({'success': True})
@email_bp.route('/auth/login', methods=['POST', 'OPTIONS'])
@limiter.limit("5 per 5 minutes")
def mailbox_login():
origin = _webmail_origin()
if request.method == 'OPTIONS':
response = current_app.make_default_options_response()
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
response.headers['Access-Control-Allow-Methods'] = 'POST'
return response
data = request.get_json(force=True, silent=True) or {}
email = data.get('email', '').lower().strip()
password = data.get('password', '')
email_cfg = config.EMAIL_CONFIG
mailbox_data = None
for d in email_cfg.get('domains', {}).values():
if email in d.get('mailboxes', {}):
mailbox_data = d['mailboxes'][email]
break
_dummy = 'pbkdf2:sha256:600000$dummy$' + 'a' * 64
stored_hash = mailbox_data.get('password_hash', '') if mailbox_data else _dummy
if not stored_hash or not check_password_hash(stored_hash, password) or mailbox_data is None:
response = jsonify({'success': False, 'error': 'Invalid email or password'})
response.headers['Access-Control-Allow-Origin'] = origin
return response, 401
token = _generate_jwt(email, mailboxes=[email], role='user', expiry_seconds=28800)
if not token:
return jsonify({'success': False, 'error': 'Auth configuration error'}), 500
response = jsonify({'success': True, 'token': token})
response.headers['Access-Control-Allow-Origin'] = origin
return response
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)
if request.headers.get('CF-Ray') or request.headers.get('CF-Connecting-IP'):
return False
# Block non-private IPs
try:
ip = ipaddress.ip_address(request.remote_addr or '')
if not ip.is_private:
return False
except ValueError:
return False
# If a shared secret is configured, require it
expected = os.environ.get('INTERNAL_BOOTSTRAP_SECRET', '')
if expected:
provided = request.headers.get('X-Bootstrap-Token', '')
if not provided or not hmac.compare_digest(provided, expected):
return False
return True
@email_bp.route('/internal/config', methods=['GET'])
def internal_mail_config():
if not _check_internal_request():
return jsonify({'error': 'forbidden'}), 403
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}")