diff --git a/.gitignore b/.gitignore index 88124d9..2a17c03 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ *.key *.pem +# Dev venv +scripts/dev/venv/ + # OS files .DS_Store Thumbs.db diff --git a/ROADMAP.md b/ROADMAP.md index f91f8e4..0074ef9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,6 +8,7 @@ This document outlines the development roadmap for ServerKit. Features are organ ### Recently Completed +- **Email Server Management** - Postfix + Dovecot + SpamAssassin + OpenDKIM + Roundcube, DNS API (Cloudflare/Route53) - **Two-Factor Authentication (2FA)** - TOTP-based with backup codes - **Notification Webhooks** - Discord, Slack, Telegram, generic webhooks - **ClamAV Integration** - Malware scanning with quarantine @@ -166,17 +167,18 @@ This document outlines the development roadmap for ServerKit. Features are organ --- -## Phase 13: Email Server Management (Planned) +## Phase 13: Email Server Management (Completed) **Priority: Medium** -- [ ] Postfix mail server setup -- [ ] Dovecot IMAP/POP3 configuration -- [ ] Email account management -- [ ] Spam filtering (SpamAssassin) -- [ ] DKIM/SPF/DMARC configuration -- [ ] Webmail interface integration -- [ ] Email forwarding rules +- [x] Postfix mail server setup +- [x] Dovecot IMAP/POP3 configuration +- [x] Email account management +- [x] Spam filtering (SpamAssassin) +- [x] DKIM/SPF/DMARC configuration +- [x] Webmail interface integration (Roundcube via Docker) +- [x] Email forwarding rules +- [x] DNS API integration (Cloudflare & Route53) --- @@ -275,7 +277,7 @@ This document outlines the development roadmap for ServerKit. Features are organ | v1.0.0 | Production-ready stable release | Planned | | v1.1.0 | Multi-server, Git deployment | Planned | | v1.2.0 | Backups, Advanced SSL, Advanced Security | Planned | -| v1.3.0 | Email server, API enhancements | Planned | +| v1.3.0 | Email server, API enhancements | Email server completed | | v1.4.0 | Team & permissions | Planned | | v1.5.0 | Performance optimizations | Planned | | v2.0.0 | Mobile app, Marketplace | Future | @@ -304,5 +306,5 @@ Have a feature idea? Open an issue on GitHub with the `enhancement` label.

ServerKit Roadmap
- Last updated: January 2026 + Last updated: March 2026

diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 59f03ff..4952541 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -179,6 +179,10 @@ def create_app(config_name=None): from app.api.servers import servers_bp app.register_blueprint(servers_bp, url_prefix='/api/v1/servers') + # Register blueprints - Email Server + from app.api.email import email_bp + app.register_blueprint(email_bp, url_prefix='/api/v1/email') + # Create database tables with app.app_context(): db.create_all() diff --git a/backend/app/api/email.py b/backend/app/api/email.py new file mode 100644 index 0000000..2e78f35 --- /dev/null +++ b/backend/app/api/email.py @@ -0,0 +1,439 @@ +"""Email Server API endpoints for managing mail services, domains, accounts, and DNS.""" + +from flask import Blueprint, request, jsonify + +from ..middleware.rbac import admin_required, viewer_required +from ..services.email_service import EmailService +from ..services.dns_provider_service import DNSProviderService +from ..services.spamassassin_service import SpamAssassinService +from ..services.roundcube_service import RoundcubeService +from ..services.postfix_service import PostfixService + +email_bp = Blueprint('email', __name__) + + +# ── Status & Installation ── + +@email_bp.route('/status', methods=['GET']) +@viewer_required +def get_status(): + """Get aggregate email server status.""" + roundcube = RoundcubeService.get_status() + status = EmailService.get_status() + status['roundcube'] = roundcube + return jsonify(status), 200 + + +@email_bp.route('/install', methods=['POST']) +@admin_required +def install(): + """Install and configure all email components.""" + data = request.get_json() or {} + hostname = data.get('hostname') + result = EmailService.install_all(hostname) + return jsonify(result), 200 if result.get('success') else 500 + + +@email_bp.route('/service//', methods=['POST']) +@admin_required +def control_service(component, action): + """Start/stop/restart an email component.""" + result = EmailService.control_service(component, action) + return jsonify(result), 200 if result.get('success') else 400 + + +# ── Domains ── + +@email_bp.route('/domains', methods=['GET']) +@viewer_required +def list_domains(): + """List all email domains.""" + domains = EmailService.get_domains() + return jsonify({'domains': domains}), 200 + + +@email_bp.route('/domains', methods=['POST']) +@admin_required +def add_domain(): + """Add an email domain.""" + data = request.get_json() + if not data or not data.get('name'): + return jsonify({'success': False, 'error': 'Domain name is required'}), 400 + + result = EmailService.add_domain( + data['name'], + dns_provider_id=data.get('dns_provider_id'), + dns_zone_id=data.get('dns_zone_id'), + ) + return jsonify(result), 201 if result.get('success') else 400 + + +@email_bp.route('/domains/', methods=['GET']) +@viewer_required +def get_domain(domain_id): + """Get domain details.""" + result = EmailService.get_domain(domain_id) + if result.get('success'): + return jsonify(result), 200 + return jsonify(result), 404 + + +@email_bp.route('/domains/', methods=['DELETE']) +@admin_required +def remove_domain(domain_id): + """Remove an email domain.""" + result = EmailService.remove_domain(domain_id) + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/domains//verify-dns', methods=['POST']) +@viewer_required +def verify_dns(domain_id): + """Verify DNS records for a domain.""" + result = EmailService.verify_dns(domain_id) + return jsonify(result), 200 + + +@email_bp.route('/domains//deploy-dns', methods=['POST']) +@admin_required +def deploy_dns(domain_id): + """Deploy DNS records via provider API.""" + from app.models.email import EmailDomain + domain = EmailDomain.query.get(domain_id) + if not domain: + return jsonify({'success': False, 'error': 'Domain not found'}), 404 + if not domain.dns_provider_id or not domain.dns_zone_id: + return jsonify({'success': False, 'error': 'No DNS provider configured for this domain'}), 400 + + result = DNSProviderService.deploy_email_records( + domain.dns_provider_id, + domain.dns_zone_id, + domain.name, + domain.dkim_selector or 'default', + domain.dkim_public_key or '', + ) + return jsonify(result), 200 if result.get('success') else 400 + + +# ── Accounts ── + +@email_bp.route('/domains//accounts', methods=['GET']) +@viewer_required +def list_accounts(domain_id): + """List email accounts for a domain.""" + accounts = EmailService.get_accounts(domain_id) + return jsonify({'accounts': accounts}), 200 + + +@email_bp.route('/domains//accounts', methods=['POST']) +@admin_required +def create_account(domain_id): + """Create an email account.""" + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'Request body required'}), 400 + + username = data.get('username') + password = data.get('password') + if not username or not password: + return jsonify({'success': False, 'error': 'Username and password are required'}), 400 + + result = EmailService.add_account( + domain_id, + username, + password, + quota_mb=data.get('quota_mb', 1024), + ) + return jsonify(result), 201 if result.get('success') else 400 + + +@email_bp.route('/accounts/', methods=['GET']) +@viewer_required +def get_account(account_id): + """Get account details.""" + from app.models.email import EmailAccount + account = EmailAccount.query.get(account_id) + if not account: + return jsonify({'success': False, 'error': 'Account not found'}), 404 + return jsonify({'success': True, 'account': account.to_dict()}), 200 + + +@email_bp.route('/accounts/', methods=['PUT']) +@admin_required +def update_account(account_id): + """Update account settings.""" + data = request.get_json() or {} + result = EmailService.update_account( + account_id, + quota_mb=data.get('quota_mb'), + is_active=data.get('is_active'), + ) + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/accounts/', methods=['DELETE']) +@admin_required +def delete_account(account_id): + """Delete an email account.""" + result = EmailService.delete_account(account_id) + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/accounts//password', methods=['POST']) +@admin_required +def change_password(account_id): + """Change account password.""" + data = request.get_json() + if not data or not data.get('password'): + return jsonify({'success': False, 'error': 'New password is required'}), 400 + + result = EmailService.change_password(account_id, data['password']) + return jsonify(result), 200 if result.get('success') else 400 + + +# ── Aliases ── + +@email_bp.route('/domains//aliases', methods=['GET']) +@viewer_required +def list_aliases(domain_id): + """List email aliases for a domain.""" + aliases = EmailService.get_aliases(domain_id) + return jsonify({'aliases': aliases}), 200 + + +@email_bp.route('/domains//aliases', methods=['POST']) +@admin_required +def create_alias(domain_id): + """Create an email alias.""" + data = request.get_json() + if not data or not data.get('source') or not data.get('destination'): + return jsonify({'success': False, 'error': 'Source and destination are required'}), 400 + + result = EmailService.add_alias(domain_id, data['source'], data['destination']) + return jsonify(result), 201 if result.get('success') else 400 + + +@email_bp.route('/aliases/', methods=['DELETE']) +@admin_required +def delete_alias(alias_id): + """Delete an email alias.""" + result = EmailService.remove_alias(alias_id) + return jsonify(result), 200 if result.get('success') else 400 + + +# ── Forwarding Rules ── + +@email_bp.route('/accounts//forwarding', methods=['GET']) +@viewer_required +def list_forwarding(account_id): + """List forwarding rules for an account.""" + rules = EmailService.get_forwarding(account_id) + return jsonify({'rules': rules}), 200 + + +@email_bp.route('/accounts//forwarding', methods=['POST']) +@admin_required +def create_forwarding(account_id): + """Create a forwarding rule.""" + data = request.get_json() + if not data or not data.get('destination'): + return jsonify({'success': False, 'error': 'Destination is required'}), 400 + + result = EmailService.add_forwarding( + account_id, + data['destination'], + keep_copy=data.get('keep_copy', True), + ) + return jsonify(result), 201 if result.get('success') else 400 + + +@email_bp.route('/forwarding/', methods=['PUT']) +@admin_required +def update_forwarding(rule_id): + """Update a forwarding rule.""" + data = request.get_json() or {} + result = EmailService.update_forwarding( + rule_id, + destination=data.get('destination'), + keep_copy=data.get('keep_copy'), + is_active=data.get('is_active'), + ) + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/forwarding/', methods=['DELETE']) +@admin_required +def delete_forwarding(rule_id): + """Delete a forwarding rule.""" + result = EmailService.remove_forwarding(rule_id) + return jsonify(result), 200 if result.get('success') else 400 + + +# ── DNS Providers ── + +@email_bp.route('/dns-providers', methods=['GET']) +@viewer_required +def list_dns_providers(): + """List configured DNS providers.""" + providers = DNSProviderService.list_providers() + return jsonify({'providers': providers}), 200 + + +@email_bp.route('/dns-providers', methods=['POST']) +@admin_required +def add_dns_provider(): + """Add a DNS provider.""" + data = request.get_json() + if not data or not data.get('name') or not data.get('provider') or not data.get('api_key'): + return jsonify({'success': False, 'error': 'Name, provider, and api_key are required'}), 400 + + result = DNSProviderService.add_provider( + name=data['name'], + provider=data['provider'], + api_key=data['api_key'], + api_secret=data.get('api_secret'), + api_email=data.get('api_email'), + is_default=data.get('is_default', False), + ) + return jsonify(result), 201 if result.get('success') else 400 + + +@email_bp.route('/dns-providers/', methods=['DELETE']) +@admin_required +def remove_dns_provider(provider_id): + """Remove a DNS provider.""" + result = DNSProviderService.remove_provider(provider_id) + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/dns-providers//test', methods=['POST']) +@admin_required +def test_dns_provider(provider_id): + """Test DNS provider connection.""" + result = DNSProviderService.test_connection(provider_id) + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/dns-providers//zones', methods=['GET']) +@viewer_required +def list_dns_zones(provider_id): + """List DNS zones from a provider.""" + result = DNSProviderService.list_zones(provider_id) + return jsonify(result), 200 if result.get('success') else 400 + + +# ── SpamAssassin Config ── + +@email_bp.route('/spam/config', methods=['GET']) +@viewer_required +def get_spam_config(): + """Get SpamAssassin configuration.""" + result = SpamAssassinService.get_config() + return jsonify(result), 200 + + +@email_bp.route('/spam/config', methods=['PUT']) +@admin_required +def update_spam_config(): + """Update SpamAssassin configuration.""" + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': 'Request body required'}), 400 + + result = SpamAssassinService.configure(data) + if result.get('success'): + SpamAssassinService.reload() + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/spam/update-rules', methods=['POST']) +@admin_required +def update_spam_rules(): + """Update SpamAssassin rules.""" + result = SpamAssassinService.update_rules() + return jsonify(result), 200 if result.get('success') else 400 + + +# ── Roundcube Webmail ── + +@email_bp.route('/webmail/status', methods=['GET']) +@viewer_required +def webmail_status(): + """Get Roundcube webmail status.""" + result = RoundcubeService.get_status() + return jsonify(result), 200 + + +@email_bp.route('/webmail/install', methods=['POST']) +@admin_required +def webmail_install(): + """Install Roundcube webmail.""" + data = request.get_json() or {} + result = RoundcubeService.install( + imap_host=data.get('imap_host', 'host.docker.internal'), + smtp_host=data.get('smtp_host', 'host.docker.internal'), + ) + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/webmail/service/', methods=['POST']) +@admin_required +def webmail_control(action): + """Start/stop/restart Roundcube.""" + actions = { + 'start': RoundcubeService.start, + 'stop': RoundcubeService.stop, + 'restart': RoundcubeService.restart, + } + if action not in actions: + return jsonify({'success': False, 'error': 'Invalid action'}), 400 + + result = actions[action]() + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/webmail/configure-proxy', methods=['POST']) +@admin_required +def webmail_configure_proxy(): + """Configure Nginx reverse proxy for Roundcube.""" + data = request.get_json() + if not data or not data.get('domain'): + return jsonify({'success': False, 'error': 'Domain is required'}), 400 + + result = RoundcubeService.configure_nginx_proxy(data['domain']) + return jsonify(result), 200 if result.get('success') else 400 + + +# ── Mail Queue & Logs ── + +@email_bp.route('/queue', methods=['GET']) +@viewer_required +def get_queue(): + """Get Postfix mail queue.""" + result = PostfixService.get_queue() + return jsonify(result), 200 + + +@email_bp.route('/queue/flush', methods=['POST']) +@admin_required +def flush_queue(): + """Flush the mail queue.""" + result = PostfixService.flush_queue() + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/queue/', methods=['DELETE']) +@admin_required +def delete_queue_item(queue_id): + """Delete a message from the queue.""" + result = PostfixService.delete_from_queue(queue_id) + return jsonify(result), 200 if result.get('success') else 400 + + +@email_bp.route('/logs', methods=['GET']) +@viewer_required +def get_logs(): + """Get mail logs.""" + lines = request.args.get('lines', 100, type=int) + result = PostfixService.get_logs(lines) + return jsonify(result), 200 diff --git a/backend/app/middleware/rbac.py b/backend/app/middleware/rbac.py index deb2bf6..b735509 100644 --- a/backend/app/middleware/rbac.py +++ b/backend/app/middleware/rbac.py @@ -2,6 +2,7 @@ from functools import wraps from flask import jsonify from flask_jwt_extended import jwt_required, get_jwt_identity +from app import db from app.models import User @@ -9,7 +10,7 @@ def get_current_user(): """Get the current authenticated user.""" user_id = get_jwt_identity() if user_id: - return User.query.get(user_id) + return db.session.get(User, user_id) return None diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c1340a4..4b4c944 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,7 @@ from app.models.environment_activity import EnvironmentActivity from app.models.promotion_job import PromotionJob from app.models.sanitization_profile import SanitizationProfile +from app.models.email import EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule, DNSProviderConfig __all__ = [ 'User', 'Application', 'Domain', 'EnvironmentVariable', 'EnvironmentVariableHistory', @@ -22,5 +23,6 @@ 'MetricsHistory', 'Workflow', 'GitWebhook', 'WebhookLog', 'GitDeployment', 'Server', 'ServerGroup', 'ServerMetrics', 'ServerCommand', 'AgentSession', 'SecurityAlert', 'WordPressSite', 'DatabaseSnapshot', 'SyncJob', - 'EnvironmentActivity', 'PromotionJob', 'SanitizationProfile' + 'EnvironmentActivity', 'PromotionJob', 'SanitizationProfile', + 'EmailDomain', 'EmailAccount', 'EmailAlias', 'EmailForwardingRule', 'DNSProviderConfig', ] diff --git a/backend/app/models/email.py b/backend/app/models/email.py new file mode 100644 index 0000000..510f347 --- /dev/null +++ b/backend/app/models/email.py @@ -0,0 +1,176 @@ +from datetime import datetime +from app import db + + +class EmailDomain(db.Model): + __tablename__ = 'email_domains' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), unique=True, nullable=False, index=True) + is_active = db.Column(db.Boolean, default=True) + + # DKIM + dkim_selector = db.Column(db.String(63), default='default') + dkim_private_key_path = db.Column(db.String(500)) + dkim_public_key = db.Column(db.Text) + + # SPF / DMARC + spf_record = db.Column(db.String(500)) + dmarc_record = db.Column(db.String(500)) + + # DNS provider linkage + dns_provider_id = db.Column(db.Integer, db.ForeignKey('dns_provider_configs.id'), nullable=True) + dns_zone_id = db.Column(db.String(255)) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + accounts = db.relationship('EmailAccount', backref='domain', lazy=True, cascade='all, delete-orphan') + aliases = db.relationship('EmailAlias', backref='domain', lazy=True, cascade='all, delete-orphan') + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'is_active': self.is_active, + 'dkim_selector': self.dkim_selector, + 'dkim_public_key': self.dkim_public_key, + 'spf_record': self.spf_record, + 'dmarc_record': self.dmarc_record, + 'dns_provider_id': self.dns_provider_id, + 'dns_zone_id': self.dns_zone_id, + 'accounts_count': len(self.accounts) if self.accounts else 0, + 'aliases_count': len(self.aliases) if self.aliases else 0, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + def __repr__(self): + return f'' + + +class EmailAccount(db.Model): + __tablename__ = 'email_accounts' + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + username = db.Column(db.String(255), nullable=False) + password_hash = db.Column(db.String(500), nullable=False) + domain_id = db.Column(db.Integer, db.ForeignKey('email_domains.id'), nullable=False) + + quota_mb = db.Column(db.Integer, default=1024) + quota_used_mb = db.Column(db.Integer, default=0) + is_active = db.Column(db.Boolean, default=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + forwarding_rules = db.relationship('EmailForwardingRule', backref='account', lazy=True, cascade='all, delete-orphan') + + def to_dict(self): + return { + 'id': self.id, + 'email': self.email, + 'username': self.username, + 'domain_id': self.domain_id, + 'domain_name': self.domain.name if self.domain else None, + 'quota_mb': self.quota_mb, + 'quota_used_mb': self.quota_used_mb, + 'is_active': self.is_active, + 'forwarding_count': len(self.forwarding_rules) if self.forwarding_rules else 0, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + def __repr__(self): + return f'' + + +class EmailAlias(db.Model): + __tablename__ = 'email_aliases' + + id = db.Column(db.Integer, primary_key=True) + source = db.Column(db.String(255), nullable=False, index=True) + destination = db.Column(db.String(255), nullable=False) + domain_id = db.Column(db.Integer, db.ForeignKey('email_domains.id'), nullable=False) + is_active = db.Column(db.Boolean, default=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'source': self.source, + 'destination': self.destination, + 'domain_id': self.domain_id, + 'domain_name': self.domain.name if self.domain else None, + 'is_active': self.is_active, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + def __repr__(self): + return f' {self.destination}>' + + +class EmailForwardingRule(db.Model): + __tablename__ = 'email_forwarding_rules' + + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('email_accounts.id'), nullable=False) + destination = db.Column(db.String(255), nullable=False) + keep_copy = db.Column(db.Boolean, default=True) + is_active = db.Column(db.Boolean, default=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'account_id': self.account_id, + 'account_email': self.account.email if self.account else None, + 'destination': self.destination, + 'keep_copy': self.keep_copy, + 'is_active': self.is_active, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + def __repr__(self): + return f' {self.destination}>' + + +class DNSProviderConfig(db.Model): + __tablename__ = 'dns_provider_configs' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + provider = db.Column(db.String(50), nullable=False) # 'cloudflare' | 'route53' + api_key = db.Column(db.String(500)) + api_secret = db.Column(db.String(500)) + api_email = db.Column(db.String(255)) + is_default = db.Column(db.Boolean, default=False) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + domains = db.relationship('EmailDomain', backref='dns_provider', lazy=True) + + def to_dict(self, mask_secrets=True): + result = { + 'id': self.id, + 'name': self.name, + 'provider': self.provider, + 'api_email': self.api_email, + 'is_default': self.is_default, + 'domains_count': len(self.domains) if self.domains else 0, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + if mask_secrets: + result['api_key'] = '****' + (self.api_key[-4:] if self.api_key and len(self.api_key) > 4 else '') + result['api_secret'] = '****' if self.api_secret else None + else: + result['api_key'] = self.api_key + result['api_secret'] = self.api_secret + return result + + def __repr__(self): + return f'' diff --git a/backend/app/paths.py b/backend/app/paths.py index d3d1fbb..fd2f163 100644 --- a/backend/app/paths.py +++ b/backend/app/paths.py @@ -17,3 +17,9 @@ DB_BACKUP_DIR = os.path.join(SERVERKIT_BACKUP_DIR, 'databases') WP_BACKUP_DIR = os.path.join(SERVERKIT_BACKUP_DIR, 'wordpress') SNAPSHOT_DIR = os.path.join(SERVERKIT_BACKUP_DIR, 'snapshots') + +# Email / Mail server paths +VMAIL_DIR = os.environ.get('VMAIL_DIR', '/var/vmail') +VMAIL_UID = 5000 +VMAIL_GID = 5000 +EMAIL_CONFIG_DIR = os.path.join(SERVERKIT_CONFIG_DIR, 'email') diff --git a/backend/app/services/dkim_service.py b/backend/app/services/dkim_service.py new file mode 100644 index 0000000..f43d4d7 --- /dev/null +++ b/backend/app/services/dkim_service.py @@ -0,0 +1,285 @@ +"""OpenDKIM management service for DKIM signing.""" + +import os +import re +import subprocess +from typing import Dict + +from app.utils.system import PackageManager, ServiceControl, run_privileged + + +class DKIMService: + """Service for managing OpenDKIM (DKIM email signing).""" + + OPENDKIM_CONF = '/etc/opendkim.conf' + OPENDKIM_DIR = '/etc/opendkim' + OPENDKIM_KEYS_DIR = '/etc/opendkim/keys' + KEY_TABLE = '/etc/opendkim/KeyTable' + SIGNING_TABLE = '/etc/opendkim/SigningTable' + TRUSTED_HOSTS = '/etc/opendkim/TrustedHosts' + + OPENDKIM_CONF_CONTENT = """# OpenDKIM configuration - Managed by ServerKit +Syslog yes +SyslogSuccess yes +LogWhy yes +Canonicalization relaxed/simple +Mode sv +SubDomains no +AutoRestart yes +AutoRestartRate 10/1M +Background yes +DNSTimeout 5 +SignatureAlgorithm rsa-sha256 + +KeyTable refile:/etc/opendkim/KeyTable +SigningTable refile:/etc/opendkim/SigningTable +ExternalIgnoreList /etc/opendkim/TrustedHosts +InternalHosts /etc/opendkim/TrustedHosts + +Socket inet:8891@localhost +PidFile /run/opendkim/opendkim.pid +OversignHeaders From +UserID opendkim +UMask 007 +""" + + TRUSTED_HOSTS_DEFAULT = """# Trusted hosts - Managed by ServerKit +127.0.0.1 +::1 +localhost +""" + + @classmethod + def get_status(cls) -> Dict: + """Get OpenDKIM installation and running status.""" + installed = False + running = False + enabled = False + version = None + + try: + result = subprocess.run(['which', 'opendkim'], capture_output=True, text=True) + installed = result.returncode == 0 + + if not installed: + installed = PackageManager.is_installed('opendkim') + + if installed: + running = ServiceControl.is_active('opendkim') + enabled = ServiceControl.is_enabled('opendkim') + + result = subprocess.run(['opendkim', '-V'], capture_output=True, text=True, stderr=subprocess.STDOUT) + match = re.search(r'OpenDKIM\s+Filter\s+v(\S+)', result.stdout) + if match: + version = match.group(1) + except (subprocess.SubprocessError, FileNotFoundError): + pass + + return { + 'installed': installed, + 'running': running, + 'enabled': enabled, + 'version': version, + } + + @classmethod + def install(cls) -> Dict: + """Install OpenDKIM.""" + try: + result = PackageManager.install(['opendkim', 'opendkim-tools'], timeout=300) + if result.returncode != 0: + return {'success': False, 'error': result.stderr or 'Failed to install OpenDKIM'} + + # Create directories + run_privileged(['mkdir', '-p', cls.OPENDKIM_KEYS_DIR]) + run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR]) + + # Create config files + run_privileged(['tee', cls.OPENDKIM_CONF], input=cls.OPENDKIM_CONF_CONTENT) + run_privileged(['tee', cls.TRUSTED_HOSTS], input=cls.TRUSTED_HOSTS_DEFAULT) + run_privileged(['touch', cls.KEY_TABLE]) + run_privileged(['touch', cls.SIGNING_TABLE]) + + # Set permissions + run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR]) + run_privileged(['chmod', '700', cls.OPENDKIM_KEYS_DIR]) + + # Create PID directory + run_privileged(['mkdir', '-p', '/run/opendkim']) + run_privileged(['chown', 'opendkim:opendkim', '/run/opendkim']) + + # Add postfix to opendkim group + run_privileged(['usermod', '-aG', 'opendkim', 'postfix']) + + ServiceControl.enable('opendkim') + ServiceControl.start('opendkim', timeout=30) + + return {'success': True, 'message': 'OpenDKIM installed successfully'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def generate_key(cls, domain: str, selector: str = 'default') -> Dict: + """Generate DKIM key pair for a domain.""" + try: + key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain) + run_privileged(['mkdir', '-p', key_dir]) + + # Generate key + result = run_privileged([ + 'opendkim-genkey', + '-s', selector, + '-d', domain, + '-D', key_dir, + '-b', '2048', + ]) + if result.returncode != 0: + return {'success': False, 'error': result.stderr or 'Key generation failed'} + + # Set permissions + run_privileged(['chown', '-R', 'opendkim:opendkim', key_dir]) + run_privileged(['chmod', '600', os.path.join(key_dir, f'{selector}.private')]) + + # Read the public key TXT record + txt_file = os.path.join(key_dir, f'{selector}.txt') + result = run_privileged(['cat', txt_file]) + public_key_record = result.stdout.strip() if result.returncode == 0 else '' + + # Extract just the key value from the TXT record + key_match = re.search(r'p=([A-Za-z0-9+/=\s]+)', public_key_record) + public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '') if key_match else '' + + return { + 'success': True, + 'domain': domain, + 'selector': selector, + 'private_key_path': os.path.join(key_dir, f'{selector}.private'), + 'public_key': public_key, + 'dns_record': public_key_record, + 'dns_name': f'{selector}._domainkey.{domain}', + 'dns_value': f'v=DKIM1; k=rsa; p={public_key}', + } + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def add_domain(cls, domain: str, selector: str = 'default') -> Dict: + """Add domain to KeyTable and SigningTable.""" + try: + key_path = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.private') + + # Add to KeyTable + key_entry = f'{selector}._domainkey.{domain} {domain}:{selector}:{key_path}\n' + result = run_privileged(['cat', cls.KEY_TABLE]) + if domain not in (result.stdout or ''): + run_privileged(['tee', '-a', cls.KEY_TABLE], input=key_entry) + + # Add to SigningTable + signing_entry = f'*@{domain} {selector}._domainkey.{domain}\n' + result = run_privileged(['cat', cls.SIGNING_TABLE]) + if domain not in (result.stdout or ''): + run_privileged(['tee', '-a', cls.SIGNING_TABLE], input=signing_entry) + + # Add to TrustedHosts + result = run_privileged(['cat', cls.TRUSTED_HOSTS]) + if domain not in (result.stdout or ''): + run_privileged(['tee', '-a', cls.TRUSTED_HOSTS], input=f'*.{domain}\n') + + # Reload OpenDKIM + ServiceControl.restart('opendkim', timeout=30) + + return {'success': True, 'message': f'Domain {domain} added to DKIM'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def remove_domain(cls, domain: str) -> Dict: + """Remove domain from DKIM configuration.""" + try: + # Remove from KeyTable + result = run_privileged(['cat', cls.KEY_TABLE]) + lines = [l for l in (result.stdout or '').splitlines() if domain not in l] + run_privileged(['tee', cls.KEY_TABLE], input='\n'.join(lines) + '\n') + + # Remove from SigningTable + result = run_privileged(['cat', cls.SIGNING_TABLE]) + lines = [l for l in (result.stdout or '').splitlines() if domain not in l] + run_privileged(['tee', cls.SIGNING_TABLE], input='\n'.join(lines) + '\n') + + # Remove from TrustedHosts + result = run_privileged(['cat', cls.TRUSTED_HOSTS]) + lines = [l for l in (result.stdout or '').splitlines() if domain not in l] + run_privileged(['tee', cls.TRUSTED_HOSTS], input='\n'.join(lines) + '\n') + + # Remove key files + key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain) + run_privileged(['rm', '-rf', key_dir]) + + ServiceControl.restart('opendkim', timeout=30) + + return {'success': True, 'message': f'Domain {domain} removed from DKIM'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def get_dns_record(cls, domain: str, selector: str = 'default') -> Dict: + """Get the DKIM DNS TXT record content for a domain.""" + try: + txt_file = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.txt') + result = run_privileged(['cat', txt_file]) + if result.returncode != 0: + return {'success': False, 'error': 'DKIM key not found'} + + record = result.stdout.strip() + key_match = re.search(r'p=([A-Za-z0-9+/=\s"]+)', record) + public_key = '' + if key_match: + public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '') + + return { + 'success': True, + 'dns_name': f'{selector}._domainkey.{domain}', + 'dns_value': f'v=DKIM1; k=rsa; p={public_key}', + 'raw_record': record, + } + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def verify_key(cls, domain: str, selector: str = 'default') -> Dict: + """Verify DKIM key configuration.""" + try: + result = run_privileged([ + 'opendkim-testkey', + '-d', domain, + '-s', selector, + '-vvv', + ]) + + success = result.returncode == 0 + output = (result.stdout or '') + (result.stderr or '') + + return { + 'success': success, + 'verified': success and 'key OK' in output, + 'output': output, + } + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def reload(cls) -> Dict: + """Reload OpenDKIM.""" + try: + result = ServiceControl.restart('opendkim', timeout=30) + if result.returncode == 0: + return {'success': True, 'message': 'OpenDKIM restarted'} + return {'success': False, 'error': result.stderr or 'Restart failed'} + except Exception as e: + return {'success': False, 'error': str(e)} diff --git a/backend/app/services/dns_provider_service.py b/backend/app/services/dns_provider_service.py new file mode 100644 index 0000000..85f4348 --- /dev/null +++ b/backend/app/services/dns_provider_service.py @@ -0,0 +1,393 @@ +"""DNS Provider service for managing DKIM/SPF/DMARC records via Cloudflare and Route53.""" + +import logging +from typing import Dict, List, Optional + +import requests + +from app import db +from app.models.email import DNSProviderConfig + +logger = logging.getLogger(__name__) + + +class DNSProviderService: + """Service for managing DNS records via Cloudflare and Route53 APIs.""" + + @classmethod + def list_providers(cls) -> List[Dict]: + """List all configured DNS providers (secrets masked).""" + providers = DNSProviderConfig.query.all() + return [p.to_dict(mask_secrets=True) for p in providers] + + @classmethod + def get_provider(cls, provider_id: int) -> Optional[DNSProviderConfig]: + """Get a DNS provider config by ID.""" + return DNSProviderConfig.query.get(provider_id) + + @classmethod + def add_provider(cls, name: str, provider: str, api_key: str, + api_secret: str = None, api_email: str = None, + is_default: bool = False) -> Dict: + """Add a new DNS provider configuration.""" + if provider not in ('cloudflare', 'route53'): + return {'success': False, 'error': 'Provider must be cloudflare or route53'} + + try: + if is_default: + # Unset other defaults + DNSProviderConfig.query.filter_by(is_default=True).update({'is_default': False}) + + config = DNSProviderConfig( + name=name, + provider=provider, + api_key=api_key, + api_secret=api_secret, + api_email=api_email, + is_default=is_default, + ) + db.session.add(config) + db.session.commit() + + return {'success': True, 'provider': config.to_dict(), 'message': 'DNS provider added'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def remove_provider(cls, provider_id: int) -> Dict: + """Remove a DNS provider configuration.""" + try: + config = DNSProviderConfig.query.get(provider_id) + if not config: + return {'success': False, 'error': 'Provider not found'} + + db.session.delete(config) + db.session.commit() + return {'success': True, 'message': 'DNS provider removed'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def test_connection(cls, provider_id: int) -> Dict: + """Test DNS provider API connection.""" + config = DNSProviderConfig.query.get(provider_id) + if not config: + return {'success': False, 'error': 'Provider not found'} + + if config.provider == 'cloudflare': + return cls._test_cloudflare(config) + elif config.provider == 'route53': + return cls._test_route53(config) + + return {'success': False, 'error': 'Unknown provider'} + + @classmethod + def list_zones(cls, provider_id: int) -> Dict: + """List DNS zones from the provider.""" + config = DNSProviderConfig.query.get(provider_id) + if not config: + return {'success': False, 'error': 'Provider not found'} + + if config.provider == 'cloudflare': + return cls._cloudflare_list_zones(config) + elif config.provider == 'route53': + return cls._route53_list_zones(config) + + return {'success': False, 'error': 'Unknown provider'} + + @classmethod + def set_record(cls, provider_id: int, zone_id: str, record_type: str, + name: str, value: str, ttl: int = 3600) -> Dict: + """Create or update a DNS record.""" + config = DNSProviderConfig.query.get(provider_id) + if not config: + return {'success': False, 'error': 'Provider not found'} + + if config.provider == 'cloudflare': + return cls._cloudflare_set_record(config, zone_id, record_type, name, value, ttl) + elif config.provider == 'route53': + return cls._route53_set_record(config, zone_id, record_type, name, value, ttl) + + return {'success': False, 'error': 'Unknown provider'} + + @classmethod + def delete_record(cls, provider_id: int, zone_id: str, record_type: str, name: str) -> Dict: + """Delete a DNS record.""" + config = DNSProviderConfig.query.get(provider_id) + if not config: + return {'success': False, 'error': 'Provider not found'} + + if config.provider == 'cloudflare': + return cls._cloudflare_delete_record(config, zone_id, record_type, name) + elif config.provider == 'route53': + return cls._route53_delete_record(config, zone_id, record_type, name) + + return {'success': False, 'error': 'Unknown provider'} + + @classmethod + def deploy_email_records(cls, provider_id: int, zone_id: str, domain: str, + selector: str, dkim_public_key: str, + server_ip: str = None) -> Dict: + """Deploy DKIM, SPF, and DMARC records for an email domain.""" + results = {} + + # Deploy DKIM record + dkim_name = f'{selector}._domainkey.{domain}' + dkim_value = f'v=DKIM1; k=rsa; p={dkim_public_key}' + results['dkim'] = cls.set_record(provider_id, zone_id, 'TXT', dkim_name, dkim_value) + + # Deploy SPF record + spf_value = 'v=spf1 mx a ~all' + if server_ip: + spf_value = f'v=spf1 mx a ip4:{server_ip} ~all' + results['spf'] = cls.set_record(provider_id, zone_id, 'TXT', domain, spf_value) + + # Deploy DMARC record + dmarc_name = f'_dmarc.{domain}' + dmarc_value = f'v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain}; pct=100' + results['dmarc'] = cls.set_record(provider_id, zone_id, 'TXT', dmarc_name, dmarc_value) + + # Deploy MX record + results['mx'] = cls.set_record(provider_id, zone_id, 'MX', domain, f'10 mail.{domain}') + + all_ok = all(r.get('success') for r in results.values()) + return { + 'success': all_ok, + 'results': results, + 'message': 'All DNS records deployed' if all_ok else 'Some records failed', + } + + # ── Cloudflare Implementation ── + + @classmethod + def _cloudflare_headers(cls, config: DNSProviderConfig) -> Dict: + """Build Cloudflare API headers.""" + if config.api_email: + return { + 'X-Auth-Email': config.api_email, + 'X-Auth-Key': config.api_key, + 'Content-Type': 'application/json', + } + return { + 'Authorization': f'Bearer {config.api_key}', + 'Content-Type': 'application/json', + } + + @classmethod + def _test_cloudflare(cls, config: DNSProviderConfig) -> Dict: + """Test Cloudflare API connection.""" + try: + resp = requests.get( + 'https://api.cloudflare.com/client/v4/user/tokens/verify', + headers=cls._cloudflare_headers(config), + timeout=15, + ) + data = resp.json() + if data.get('success'): + return {'success': True, 'message': 'Cloudflare connection successful'} + return {'success': False, 'error': data.get('errors', [{}])[0].get('message', 'Unknown error')} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def _cloudflare_list_zones(cls, config: DNSProviderConfig) -> Dict: + """List Cloudflare zones.""" + try: + resp = requests.get( + 'https://api.cloudflare.com/client/v4/zones?per_page=50', + headers=cls._cloudflare_headers(config), + timeout=15, + ) + data = resp.json() + if not data.get('success'): + return {'success': False, 'error': 'Failed to list zones'} + + zones = [{'id': z['id'], 'name': z['name'], 'status': z['status']} + for z in data.get('result', [])] + return {'success': True, 'zones': zones} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def _cloudflare_set_record(cls, config: DNSProviderConfig, zone_id: str, + record_type: str, name: str, value: str, ttl: int) -> Dict: + """Create or update a Cloudflare DNS record.""" + try: + headers = cls._cloudflare_headers(config) + base = f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records' + + # Check if record exists + resp = requests.get( + f'{base}?type={record_type}&name={name}', + headers=headers, timeout=15, + ) + data = resp.json() + existing = data.get('result', []) + + payload = {'type': record_type, 'name': name, 'content': value, 'ttl': ttl} + + if existing: + # Update existing record + record_id = existing[0]['id'] + resp = requests.put( + f'{base}/{record_id}', + headers=headers, json=payload, timeout=15, + ) + else: + # Create new record + resp = requests.post(base, headers=headers, json=payload, timeout=15) + + data = resp.json() + if data.get('success'): + return {'success': True, 'message': f'{record_type} record set for {name}'} + return {'success': False, 'error': data.get('errors', [{}])[0].get('message', 'Unknown error')} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def _cloudflare_delete_record(cls, config: DNSProviderConfig, zone_id: str, + record_type: str, name: str) -> Dict: + """Delete a Cloudflare DNS record.""" + try: + headers = cls._cloudflare_headers(config) + base = f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records' + + resp = requests.get( + f'{base}?type={record_type}&name={name}', + headers=headers, timeout=15, + ) + data = resp.json() + existing = data.get('result', []) + + if not existing: + return {'success': True, 'message': 'Record not found (already deleted)'} + + for record in existing: + requests.delete(f'{base}/{record["id"]}', headers=headers, timeout=15) + + return {'success': True, 'message': f'{record_type} record deleted for {name}'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + # ── Route53 Implementation ── + + @classmethod + def _get_route53_client(cls, config: DNSProviderConfig): + """Get a boto3 Route53 client.""" + try: + import boto3 + except ImportError: + raise RuntimeError('boto3 is required for Route53 integration. Install with: pip install boto3') + + return boto3.client( + 'route53', + aws_access_key_id=config.api_key, + aws_secret_access_key=config.api_secret, + ) + + @classmethod + def _test_route53(cls, config: DNSProviderConfig) -> Dict: + """Test Route53 API connection.""" + try: + client = cls._get_route53_client(config) + client.list_hosted_zones(MaxItems='1') + return {'success': True, 'message': 'Route53 connection successful'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def _route53_list_zones(cls, config: DNSProviderConfig) -> Dict: + """List Route53 hosted zones.""" + try: + client = cls._get_route53_client(config) + resp = client.list_hosted_zones() + + zones = [ + { + 'id': z['Id'].replace('/hostedzone/', ''), + 'name': z['Name'].rstrip('.'), + 'status': 'active', + } + for z in resp.get('HostedZones', []) + ] + return {'success': True, 'zones': zones} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def _route53_set_record(cls, config: DNSProviderConfig, zone_id: str, + record_type: str, name: str, value: str, ttl: int) -> Dict: + """Create or update a Route53 DNS record.""" + try: + client = cls._get_route53_client(config) + + # Ensure name ends with a dot for Route53 + fqdn = name if name.endswith('.') else f'{name}.' + + resource_record = {'Value': value} + if record_type == 'TXT': + # TXT records need to be quoted + resource_record = {'Value': f'"{value}"'} + elif record_type == 'MX': + resource_record = {'Value': value} + + client.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + 'Changes': [{ + 'Action': 'UPSERT', + 'ResourceRecordSet': { + 'Name': fqdn, + 'Type': record_type, + 'TTL': ttl, + 'ResourceRecords': [resource_record], + } + }] + } + ) + return {'success': True, 'message': f'{record_type} record set for {name}'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def _route53_delete_record(cls, config: DNSProviderConfig, zone_id: str, + record_type: str, name: str) -> Dict: + """Delete a Route53 DNS record.""" + try: + client = cls._get_route53_client(config) + fqdn = name if name.endswith('.') else f'{name}.' + + # Get current record to know its value (required for DELETE) + resp = client.list_resource_record_sets( + HostedZoneId=zone_id, + StartRecordName=fqdn, + StartRecordType=record_type, + MaxItems='1', + ) + records = resp.get('ResourceRecordSets', []) + matching = [r for r in records if r['Name'] == fqdn and r['Type'] == record_type] + + if not matching: + return {'success': True, 'message': 'Record not found (already deleted)'} + + record = matching[0] + client.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + 'Changes': [{ + 'Action': 'DELETE', + 'ResourceRecordSet': record, + }] + } + ) + return {'success': True, 'message': f'{record_type} record deleted for {name}'} + + except Exception as e: + return {'success': False, 'error': str(e)} diff --git a/backend/app/services/dovecot_service.py b/backend/app/services/dovecot_service.py new file mode 100644 index 0000000..fca8dc5 --- /dev/null +++ b/backend/app/services/dovecot_service.py @@ -0,0 +1,367 @@ +"""Dovecot IMAP/POP3 server management service.""" + +import os +import re +import subprocess +from typing import Dict, Optional + +from app.utils.system import PackageManager, ServiceControl, run_privileged +from app import paths + + +class DovecotService: + """Service for managing Dovecot (IMAP/POP3 server).""" + + DOVECOT_CONF_DIR = '/etc/dovecot' + DOVECOT_CONF = '/etc/dovecot/dovecot.conf' + DOVECOT_AUTH_CONF = '/etc/dovecot/conf.d/10-auth.conf' + DOVECOT_MAIL_CONF = '/etc/dovecot/conf.d/10-mail.conf' + DOVECOT_MASTER_CONF = '/etc/dovecot/conf.d/10-master.conf' + DOVECOT_SSL_CONF = '/etc/dovecot/conf.d/10-ssl.conf' + DOVECOT_PASSWD_FILE = '/etc/dovecot/users' + AUTH_PASSWDFILE_CONF = '/etc/dovecot/conf.d/auth-passwdfile.conf.ext' + + MAIL_CONF_CONTENT = """# Dovecot mail configuration - Managed by ServerKit +mail_location = maildir:{vmail_dir}/%d/%n/Maildir +namespace inbox {{ + inbox = yes + separator = / +}} +mail_uid = {vmail_uid} +mail_gid = {vmail_gid} +mail_privileged_group = vmail +first_valid_uid = {vmail_uid} +last_valid_uid = {vmail_uid} +""" + + AUTH_CONF_CONTENT = """# Dovecot auth configuration - Managed by ServerKit +disable_plaintext_auth = yes +auth_mechanisms = plain login +!include auth-passwdfile.conf.ext +""" + + AUTH_PASSWDFILE_CONTENT = """# Password file auth - Managed by ServerKit +passdb { + driver = passwd-file + args = scheme=SHA512-CRYPT /etc/dovecot/users +} +userdb { + driver = static + args = uid=%{vmail_uid} gid=%{vmail_gid} home=%{vmail_dir}/%d/%n +} +""" + + MASTER_CONF_CONTENT = """# Dovecot master configuration - Managed by ServerKit +service imap-login { + inet_listener imap { + port = 0 + } + inet_listener imaps { + port = 993 + ssl = yes + } +} + +service pop3-login { + inet_listener pop3 { + port = 0 + } + inet_listener pop3s { + port = 995 + ssl = yes + } +} + +service lmtp { + unix_listener /var/spool/postfix/private/dovecot-lmtp { + mode = 0600 + user = postfix + group = postfix + } +} + +service auth { + unix_listener /var/spool/postfix/private/auth { + mode = 0666 + user = postfix + group = postfix + } + unix_listener auth-userdb { + mode = 0600 + user = vmail + group = vmail + } + user = dovecot +} + +service auth-worker { + user = vmail +} +""" + + SSL_CONF_CONTENT = """# Dovecot SSL configuration - Managed by ServerKit +ssl = required +ssl_cert = <{tls_cert} +ssl_key = <{tls_key} +ssl_min_protocol = TLSv1.2 +ssl_prefer_server_ciphers = yes +""" + + @classmethod + def get_status(cls) -> Dict: + """Get Dovecot installation and running status.""" + installed = False + running = False + enabled = False + version = None + + try: + result = subprocess.run(['which', 'dovecot'], capture_output=True, text=True) + installed = result.returncode == 0 + + if not installed: + installed = PackageManager.is_installed('dovecot-core') or PackageManager.is_installed('dovecot') + + if installed: + running = ServiceControl.is_active('dovecot') + enabled = ServiceControl.is_enabled('dovecot') + + result = subprocess.run(['dovecot', '--version'], capture_output=True, text=True) + version_match = re.search(r'(\d+\.\d+\.\d+)', result.stdout) + if version_match: + version = version_match.group(1) + except (subprocess.SubprocessError, FileNotFoundError): + pass + + return { + 'installed': installed, + 'running': running, + 'enabled': enabled, + 'version': version, + } + + @classmethod + def install(cls) -> Dict: + """Install Dovecot packages.""" + try: + manager = PackageManager.detect() + if manager == 'apt': + packages = ['dovecot-core', 'dovecot-imapd', 'dovecot-pop3d', 'dovecot-lmtpd', 'dovecot-sieve'] + else: + packages = ['dovecot'] + + result = PackageManager.install(packages, timeout=300) + if result.returncode != 0: + return {'success': False, 'error': result.stderr or 'Failed to install Dovecot'} + + # Create empty passwd file + run_privileged(['touch', cls.DOVECOT_PASSWD_FILE]) + run_privileged(['chown', 'vmail:dovecot', cls.DOVECOT_PASSWD_FILE]) + run_privileged(['chmod', '640', cls.DOVECOT_PASSWD_FILE]) + + ServiceControl.enable('dovecot') + + return {'success': True, 'message': 'Dovecot installed successfully'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def configure(cls, tls_cert: str = None, tls_key: str = None) -> Dict: + """Write Dovecot configuration files.""" + try: + cert = tls_cert or '/etc/ssl/certs/ssl-cert-snakeoil.pem' + key = tls_key or '/etc/ssl/private/ssl-cert-snakeoil.key' + + # 10-mail.conf + mail_conf = cls.MAIL_CONF_CONTENT.format( + vmail_dir=paths.VMAIL_DIR, + vmail_uid=paths.VMAIL_UID, + vmail_gid=paths.VMAIL_GID, + ) + run_privileged(['tee', cls.DOVECOT_MAIL_CONF], input=mail_conf) + + # 10-auth.conf + run_privileged(['tee', cls.DOVECOT_AUTH_CONF], input=cls.AUTH_CONF_CONTENT) + + # auth-passwdfile.conf.ext + auth_passwd = cls.AUTH_PASSWDFILE_CONTENT.replace( + '%{vmail_uid}', str(paths.VMAIL_UID) + ).replace( + '%{vmail_gid}', str(paths.VMAIL_GID) + ).replace( + '%{vmail_dir}', paths.VMAIL_DIR + ) + run_privileged(['tee', cls.AUTH_PASSWDFILE_CONF], input=auth_passwd) + + # 10-master.conf + run_privileged(['tee', cls.DOVECOT_MASTER_CONF], input=cls.MASTER_CONF_CONTENT) + + # 10-ssl.conf + ssl_conf = cls.SSL_CONF_CONTENT.format(tls_cert=cert, tls_key=key) + run_privileged(['tee', cls.DOVECOT_SSL_CONF], input=ssl_conf) + + # Restart to apply + ServiceControl.restart('dovecot', timeout=30) + + return {'success': True, 'message': 'Dovecot configured successfully'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def create_mailbox(cls, email: str, password: str, domain: str, username: str, quota_mb: int = 1024) -> Dict: + """Create a virtual mailbox with password entry.""" + try: + # Generate password hash using doveadm + result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', password]) + if result.returncode != 0: + return {'success': False, 'error': 'Failed to hash password'} + password_hash = result.stdout.strip() + + # Build passwd-file entry + # Format: user@domain:{scheme}hash:uid:gid:home::userdb_quota_rule=*:storage=NM + entry = f'{email}:{password_hash}:{paths.VMAIL_UID}:{paths.VMAIL_GID}:{paths.VMAIL_DIR}/{domain}/{username}::userdb_quota_rule=*:storage={quota_mb}M' + + # Append to passwd file + run_privileged(['tee', '-a', cls.DOVECOT_PASSWD_FILE], input=entry + '\n') + + # Create Maildir + maildir = os.path.join(paths.VMAIL_DIR, domain, username, 'Maildir') + run_privileged(['mkdir', '-p', f'{maildir}/cur', f'{maildir}/new', f'{maildir}/tmp']) + run_privileged(['chown', '-R', f'{paths.VMAIL_UID}:{paths.VMAIL_GID}', + os.path.join(paths.VMAIL_DIR, domain, username)]) + + return {'success': True, 'message': f'Mailbox {email} created'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def delete_mailbox(cls, email: str, domain: str, username: str, remove_files: bool = False) -> Dict: + """Delete a virtual mailbox.""" + try: + # Remove from passwd file + result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE]) + lines = (result.stdout or '').splitlines() + new_lines = [l for l in lines if not l.startswith(f'{email}:')] + run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n') + + # Optionally remove Maildir + if remove_files: + mailbox_path = os.path.join(paths.VMAIL_DIR, domain, username) + run_privileged(['rm', '-rf', mailbox_path]) + + return {'success': True, 'message': f'Mailbox {email} deleted'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def change_password(cls, email: str, new_password: str) -> Dict: + """Change a mailbox password.""" + try: + # Generate new hash + result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', new_password]) + if result.returncode != 0: + return {'success': False, 'error': 'Failed to hash password'} + new_hash = result.stdout.strip() + + # Read current file + result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE]) + lines = (result.stdout or '').splitlines() + + updated = False + new_lines = [] + for line in lines: + if line.startswith(f'{email}:'): + parts = line.split(':') + parts[1] = new_hash + new_lines.append(':'.join(parts)) + updated = True + else: + new_lines.append(line) + + if not updated: + return {'success': False, 'error': f'Account {email} not found'} + + run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n') + + return {'success': True, 'message': 'Password changed'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def set_quota(cls, email: str, quota_mb: int) -> Dict: + """Update mailbox quota.""" + try: + result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE]) + lines = (result.stdout or '').splitlines() + + updated = False + new_lines = [] + for line in lines: + if line.startswith(f'{email}:'): + # Replace quota in the userdb_quota_rule field + line = re.sub(r'userdb_quota_rule=\*:storage=\d+M', + f'userdb_quota_rule=*:storage={quota_mb}M', line) + new_lines.append(line) + updated = True + else: + new_lines.append(line) + + if not updated: + return {'success': False, 'error': f'Account {email} not found'} + + run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n') + + return {'success': True, 'message': f'Quota set to {quota_mb}MB'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def get_quota_usage(cls, email: str) -> Dict: + """Get mailbox quota usage.""" + try: + result = run_privileged(['doveadm', 'quota', 'get', '-u', email]) + if result.returncode != 0: + return {'success': False, 'error': 'Failed to get quota'} + + # Parse output + usage = {'storage_used': 0, 'storage_limit': 0, 'message_count': 0} + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) >= 4 and parts[0] == 'STORAGE': + usage['storage_used'] = int(parts[1]) // 1024 # KB to MB + usage['storage_limit'] = int(parts[2]) // 1024 if parts[2] != '-' else 0 + elif len(parts) >= 4 and parts[0] == 'MESSAGE': + usage['message_count'] = int(parts[1]) + + return {'success': True, **usage} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def reload(cls) -> Dict: + """Reload Dovecot configuration.""" + try: + result = ServiceControl.reload('dovecot', timeout=30) + if result.returncode == 0: + return {'success': True, 'message': 'Dovecot reloaded'} + return {'success': False, 'error': result.stderr or 'Reload failed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def restart(cls) -> Dict: + """Restart Dovecot.""" + try: + result = ServiceControl.restart('dovecot', timeout=30) + if result.returncode == 0: + return {'success': True, 'message': 'Dovecot restarted'} + return {'success': False, 'error': result.stderr or 'Restart failed'} + except Exception as e: + return {'success': False, 'error': str(e)} diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..ad78dce --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,588 @@ +"""High-level email server management orchestrator.""" + +import logging +import re +import socket +import subprocess +from typing import Dict, Optional + +from app import db +from app.models.email import EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule +from app.services.postfix_service import PostfixService +from app.services.dovecot_service import DovecotService +from app.services.dkim_service import DKIMService +from app.services.spamassassin_service import SpamAssassinService +from app.services.dns_provider_service import DNSProviderService +from app.utils.system import ServiceControl + +logger = logging.getLogger(__name__) + + +class EmailService: + """High-level orchestrator for email server management.""" + + @classmethod + def get_status(cls) -> Dict: + """Get aggregate status of all email components.""" + return { + 'postfix': PostfixService.get_status(), + 'dovecot': DovecotService.get_status(), + 'opendkim': DKIMService.get_status(), + 'spamassassin': SpamAssassinService.get_status(), + 'domains_count': EmailDomain.query.count(), + 'accounts_count': EmailAccount.query.count(), + } + + @classmethod + def install_all(cls, hostname: str = None) -> Dict: + """Install and configure all email components.""" + if not hostname: + hostname = socket.getfqdn() + + results = {} + + # 1. Install Postfix + results['postfix_install'] = PostfixService.install() + if not results['postfix_install'].get('success'): + return {'success': False, 'error': 'Postfix installation failed', 'results': results} + + # 2. Install Dovecot + results['dovecot_install'] = DovecotService.install() + if not results['dovecot_install'].get('success'): + return {'success': False, 'error': 'Dovecot installation failed', 'results': results} + + # 3. Install OpenDKIM + results['dkim_install'] = DKIMService.install() + + # 4. Install SpamAssassin + results['spamassassin_install'] = SpamAssassinService.install() + + # 5. Configure Postfix + results['postfix_config'] = PostfixService.configure(hostname) + + # 6. Configure Dovecot + results['dovecot_config'] = DovecotService.configure() + + # 7. Open firewall ports + results['firewall'] = cls._open_firewall_ports() + + return { + 'success': True, + 'message': 'Email server installed and configured', + 'hostname': hostname, + 'results': results, + } + + @classmethod + def _open_firewall_ports(cls) -> Dict: + """Open email-related firewall ports.""" + try: + from app.services.firewall_service import FirewallService + ports = [25, 587, 465, 993, 995] + for port in ports: + FirewallService.allow_port(port, 'tcp') + return {'success': True, 'message': f'Opened ports: {ports}'} + except Exception as e: + logger.warning(f'Could not open firewall ports: {e}') + return {'success': False, 'error': str(e)} + + # ── Domain Management ── + + @classmethod + def add_domain(cls, domain_name: str, dns_provider_id: int = None, + dns_zone_id: str = None) -> Dict: + """Add an email domain with DKIM key generation.""" + # Validate domain + if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$', domain_name): + return {'success': False, 'error': 'Invalid domain name'} + + if EmailDomain.query.filter_by(name=domain_name).first(): + return {'success': False, 'error': 'Domain already exists'} + + try: + # Generate DKIM key + dkim_result = DKIMService.generate_key(domain_name) + if not dkim_result.get('success'): + return {'success': False, 'error': f'DKIM key generation failed: {dkim_result.get("error")}'} + + # Create domain record + domain = EmailDomain( + name=domain_name, + dkim_selector='default', + dkim_private_key_path=dkim_result.get('private_key_path'), + dkim_public_key=dkim_result.get('public_key'), + spf_record='v=spf1 mx a ~all', + dmarc_record=f'v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}; pct=100', + dns_provider_id=dns_provider_id, + dns_zone_id=dns_zone_id, + ) + db.session.add(domain) + db.session.commit() + + # Add to Postfix virtual domains + PostfixService.add_virtual_domain(domain_name) + + # Add to OpenDKIM + DKIMService.add_domain(domain_name) + + # Reload services + PostfixService.reload() + + # Auto-deploy DNS if provider configured + dns_deployed = False + if dns_provider_id and dns_zone_id: + deploy_result = DNSProviderService.deploy_email_records( + dns_provider_id, dns_zone_id, domain_name, + 'default', dkim_result.get('public_key', ''), + ) + dns_deployed = deploy_result.get('success', False) + + return { + 'success': True, + 'domain': domain.to_dict(), + 'dkim': dkim_result, + 'dns_deployed': dns_deployed, + 'message': f'Domain {domain_name} added', + } + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def remove_domain(cls, domain_id: int) -> Dict: + """Remove an email domain and all associated data.""" + try: + domain = EmailDomain.query.get(domain_id) + if not domain: + return {'success': False, 'error': 'Domain not found'} + + domain_name = domain.name + + # Remove accounts' Postfix entries + for account in domain.accounts: + PostfixService.remove_virtual_mailbox(account.email) + DovecotService.delete_mailbox(account.email, domain_name, account.username, remove_files=True) + + # Remove aliases + for alias in domain.aliases: + PostfixService.remove_virtual_alias(alias.source) + + # Remove from Postfix + PostfixService.remove_virtual_domain(domain_name) + + # Remove from DKIM + DKIMService.remove_domain(domain_name) + + # Remove DB record (cascades accounts, aliases) + db.session.delete(domain) + db.session.commit() + + PostfixService.reload() + + return {'success': True, 'message': f'Domain {domain_name} removed'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def get_domains(cls) -> list: + """Get all email domains.""" + return [d.to_dict() for d in EmailDomain.query.all()] + + @classmethod + def get_domain(cls, domain_id: int) -> Dict: + """Get a single email domain with details.""" + domain = EmailDomain.query.get(domain_id) + if not domain: + return {'success': False, 'error': 'Domain not found'} + + data = domain.to_dict() + data['accounts'] = [a.to_dict() for a in domain.accounts] + data['aliases'] = [a.to_dict() for a in domain.aliases] + return {'success': True, 'domain': data} + + # ── Account Management ── + + @classmethod + def add_account(cls, domain_id: int, username: str, password: str, + quota_mb: int = 1024) -> Dict: + """Create an email account.""" + domain = EmailDomain.query.get(domain_id) + if not domain: + return {'success': False, 'error': 'Domain not found'} + + if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._-]*$', username): + return {'success': False, 'error': 'Invalid username'} + + email = f'{username}@{domain.name}' + + if EmailAccount.query.filter_by(email=email).first(): + return {'success': False, 'error': 'Account already exists'} + + try: + # Create Dovecot mailbox + result = DovecotService.create_mailbox(email, password, domain.name, username, quota_mb) + if not result.get('success'): + return result + + # Add to Postfix virtual mailboxes + PostfixService.add_virtual_mailbox(email, domain.name, username) + + # Create DB record + # Get the password hash from Dovecot + hash_result = subprocess.run( + ['sudo', 'doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', password], + capture_output=True, text=True, + ) + password_hash = hash_result.stdout.strip() if hash_result.returncode == 0 else 'hashed' + + account = EmailAccount( + email=email, + username=username, + password_hash=password_hash, + domain_id=domain_id, + quota_mb=quota_mb, + ) + db.session.add(account) + db.session.commit() + + PostfixService.reload() + + return { + 'success': True, + 'account': account.to_dict(), + 'message': f'Account {email} created', + } + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def delete_account(cls, account_id: int) -> Dict: + """Delete an email account.""" + try: + account = EmailAccount.query.get(account_id) + if not account: + return {'success': False, 'error': 'Account not found'} + + email = account.email + domain_name = account.domain.name + username = account.username + + # Remove from Dovecot + DovecotService.delete_mailbox(email, domain_name, username, remove_files=True) + + # Remove from Postfix + PostfixService.remove_virtual_mailbox(email) + + # Remove forwarding aliases + for rule in account.forwarding_rules: + PostfixService.remove_virtual_alias(email) + + db.session.delete(account) + db.session.commit() + + PostfixService.reload() + + return {'success': True, 'message': f'Account {email} deleted'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def change_password(cls, account_id: int, new_password: str) -> Dict: + """Change an account's password.""" + account = EmailAccount.query.get(account_id) + if not account: + return {'success': False, 'error': 'Account not found'} + + result = DovecotService.change_password(account.email, new_password) + if result.get('success'): + hash_result = subprocess.run( + ['sudo', 'doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', new_password], + capture_output=True, text=True, + ) + if hash_result.returncode == 0: + account.password_hash = hash_result.stdout.strip() + db.session.commit() + + return result + + @classmethod + def update_account(cls, account_id: int, quota_mb: int = None, is_active: bool = None) -> Dict: + """Update account settings.""" + account = EmailAccount.query.get(account_id) + if not account: + return {'success': False, 'error': 'Account not found'} + + try: + if quota_mb is not None: + DovecotService.set_quota(account.email, quota_mb) + account.quota_mb = quota_mb + + if is_active is not None: + account.is_active = is_active + + db.session.commit() + return {'success': True, 'account': account.to_dict(), 'message': 'Account updated'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def get_accounts(cls, domain_id: int) -> list: + """Get all accounts for a domain.""" + return [a.to_dict() for a in EmailAccount.query.filter_by(domain_id=domain_id).all()] + + # ── Alias Management ── + + @classmethod + def add_alias(cls, domain_id: int, source: str, destination: str) -> Dict: + """Create an email alias.""" + domain = EmailDomain.query.get(domain_id) + if not domain: + return {'success': False, 'error': 'Domain not found'} + + # Ensure source has domain + if '@' not in source: + source = f'{source}@{domain.name}' + + try: + alias = EmailAlias( + source=source, + destination=destination, + domain_id=domain_id, + ) + db.session.add(alias) + db.session.commit() + + PostfixService.add_virtual_alias(source, destination) + PostfixService.reload() + + return {'success': True, 'alias': alias.to_dict(), 'message': f'Alias {source} -> {destination} created'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def remove_alias(cls, alias_id: int) -> Dict: + """Remove an email alias.""" + try: + alias = EmailAlias.query.get(alias_id) + if not alias: + return {'success': False, 'error': 'Alias not found'} + + PostfixService.remove_virtual_alias(alias.source) + + db.session.delete(alias) + db.session.commit() + + PostfixService.reload() + + return {'success': True, 'message': 'Alias removed'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def get_aliases(cls, domain_id: int) -> list: + """Get all aliases for a domain.""" + return [a.to_dict() for a in EmailAlias.query.filter_by(domain_id=domain_id).all()] + + # ── Forwarding Rules ── + + @classmethod + def add_forwarding(cls, account_id: int, destination: str, keep_copy: bool = True) -> Dict: + """Add a forwarding rule for an account.""" + account = EmailAccount.query.get(account_id) + if not account: + return {'success': False, 'error': 'Account not found'} + + try: + rule = EmailForwardingRule( + account_id=account_id, + destination=destination, + keep_copy=keep_copy, + ) + db.session.add(rule) + db.session.commit() + + # Update Postfix alias: forward + optionally keep local copy + if keep_copy: + alias_dest = f'{account.email}, {destination}' + else: + alias_dest = destination + + PostfixService.add_virtual_alias(account.email, alias_dest) + PostfixService.reload() + + return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule added'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def remove_forwarding(cls, rule_id: int) -> Dict: + """Remove a forwarding rule.""" + try: + rule = EmailForwardingRule.query.get(rule_id) + if not rule: + return {'success': False, 'error': 'Rule not found'} + + account = rule.account + + db.session.delete(rule) + db.session.commit() + + # Rebuild forwarding alias from remaining rules + remaining = EmailForwardingRule.query.filter_by(account_id=account.id, is_active=True).all() + if remaining: + destinations = [r.destination for r in remaining] + if any(r.keep_copy for r in remaining): + destinations.insert(0, account.email) + PostfixService.add_virtual_alias(account.email, ', '.join(destinations)) + else: + PostfixService.remove_virtual_alias(account.email) + + PostfixService.reload() + + return {'success': True, 'message': 'Forwarding rule removed'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def update_forwarding(cls, rule_id: int, destination: str = None, + keep_copy: bool = None, is_active: bool = None) -> Dict: + """Update a forwarding rule.""" + try: + rule = EmailForwardingRule.query.get(rule_id) + if not rule: + return {'success': False, 'error': 'Rule not found'} + + if destination is not None: + rule.destination = destination + if keep_copy is not None: + rule.keep_copy = keep_copy + if is_active is not None: + rule.is_active = is_active + + db.session.commit() + + # Rebuild the alias + account = rule.account + active_rules = EmailForwardingRule.query.filter_by(account_id=account.id, is_active=True).all() + if active_rules: + destinations = [r.destination for r in active_rules] + if any(r.keep_copy for r in active_rules): + destinations.insert(0, account.email) + PostfixService.add_virtual_alias(account.email, ', '.join(destinations)) + else: + PostfixService.remove_virtual_alias(account.email) + + PostfixService.reload() + + return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule updated'} + + except Exception as e: + db.session.rollback() + return {'success': False, 'error': str(e)} + + @classmethod + def get_forwarding(cls, account_id: int) -> list: + """Get forwarding rules for an account.""" + return [r.to_dict() for r in EmailForwardingRule.query.filter_by(account_id=account_id).all()] + + # ── DNS Verification ── + + @classmethod + def verify_dns(cls, domain_id: int) -> Dict: + """Verify DKIM/SPF/DMARC DNS records for a domain.""" + domain = EmailDomain.query.get(domain_id) + if not domain: + return {'success': False, 'error': 'Domain not found'} + + results = {} + + # Check DKIM + dkim_name = f'{domain.dkim_selector}._domainkey.{domain.name}' + results['dkim'] = cls._check_dns_record(dkim_name, 'TXT', 'DKIM1') + + # Check SPF + results['spf'] = cls._check_dns_record(domain.name, 'TXT', 'v=spf1') + + # Check DMARC + results['dmarc'] = cls._check_dns_record(f'_dmarc.{domain.name}', 'TXT', 'v=DMARC1') + + # Check MX + results['mx'] = cls._check_dns_record(domain.name, 'MX') + + all_ok = all(r.get('found') for r in results.values()) + + return { + 'success': True, + 'verified': all_ok, + 'records': results, + } + + @classmethod + def _check_dns_record(cls, name: str, record_type: str, expected_substr: str = None) -> Dict: + """Check if a DNS record exists using dig.""" + try: + result = subprocess.run( + ['dig', '+short', record_type, name], + capture_output=True, text=True, timeout=10, + ) + output = result.stdout.strip() + found = bool(output) + + if found and expected_substr: + found = expected_substr in output + + return { + 'found': found, + 'value': output if output else None, + } + except (subprocess.SubprocessError, FileNotFoundError): + return {'found': False, 'value': None, 'error': 'dig command not available'} + + # ── Service Control ── + + @classmethod + def control_service(cls, component: str, action: str) -> Dict: + """Start/stop/restart an email component.""" + valid_components = { + 'postfix': 'postfix', + 'dovecot': 'dovecot', + 'opendkim': 'opendkim', + 'spamassassin': 'spamassassin', + } + valid_actions = ('start', 'stop', 'restart', 'enable', 'disable') + + if component not in valid_components: + return {'success': False, 'error': f'Invalid component. Use: {", ".join(valid_components)}'} + if action not in valid_actions: + return {'success': False, 'error': f'Invalid action. Use: {", ".join(valid_actions)}'} + + try: + service_name = valid_components[component] + handler = getattr(ServiceControl, action) + result = handler(service_name, timeout=30) + + if result.returncode == 0: + return {'success': True, 'component': component, 'action': action} + return {'success': False, 'error': result.stderr or 'Operation failed'} + + except subprocess.TimeoutExpired: + return {'success': False, 'error': 'Operation timed out'} + except Exception as e: + return {'success': False, 'error': str(e)} diff --git a/backend/app/services/postfix_service.py b/backend/app/services/postfix_service.py new file mode 100644 index 0000000..fe2904e --- /dev/null +++ b/backend/app/services/postfix_service.py @@ -0,0 +1,450 @@ +"""Postfix mail server management service.""" + +import os +import re +import subprocess +from typing import Dict, List, Optional + +from app.utils.system import PackageManager, ServiceControl, run_privileged +from app import paths + + +class PostfixService: + """Service for managing Postfix MTA (Mail Transfer Agent).""" + + POSTFIX_MAIN_CF = '/etc/postfix/main.cf' + POSTFIX_MASTER_CF = '/etc/postfix/master.cf' + VIRTUAL_DOMAINS_FILE = '/etc/postfix/virtual_domains' + VIRTUAL_MAILBOX_FILE = '/etc/postfix/virtual_mailboxes' + VIRTUAL_ALIAS_FILE = '/etc/postfix/virtual_aliases' + + MAIN_CF_TEMPLATE = """# Postfix main configuration - Managed by ServerKit +# Basic settings +smtpd_banner = $myhostname ESMTP +myhostname = {hostname} +mydomain = {domain} +myorigin = $mydomain +mydestination = localhost, localhost.$mydomain +relayhost = +mynetworks = 127.0.0.0/8 [::1]/128 +inet_interfaces = all +inet_protocols = all + +# Virtual mailbox settings +virtual_mailbox_domains = hash:/etc/postfix/virtual_domains +virtual_mailbox_base = {vmail_dir} +virtual_mailbox_maps = hash:/etc/postfix/virtual_mailboxes +virtual_alias_maps = hash:/etc/postfix/virtual_aliases +virtual_minimum_uid = 100 +virtual_uid_maps = static:{vmail_uid} +virtual_gid_maps = static:{vmail_gid} + +# TLS settings +smtpd_tls_cert_file = {tls_cert} +smtpd_tls_key_file = {tls_key} +smtpd_use_tls = yes +smtpd_tls_auth_only = yes +smtpd_tls_security_level = may +smtp_tls_security_level = may +smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 +smtp_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1 + +# SASL authentication (via Dovecot) +smtpd_sasl_type = dovecot +smtpd_sasl_path = private/auth +smtpd_sasl_auth_enable = yes +smtpd_sasl_security_options = noanonymous +smtpd_sasl_local_domain = $myhostname + +# Recipient restrictions +smtpd_recipient_restrictions = + permit_sasl_authenticated, + permit_mynetworks, + reject_unauth_destination, + reject_unknown_recipient_domain + +# Milters (OpenDKIM + SpamAssassin) +milter_protocol = 6 +milter_default_action = accept +smtpd_milters = inet:localhost:8891, inet:localhost:8893 +non_smtpd_milters = inet:localhost:8891 + +# Size limits +message_size_limit = 52428800 +mailbox_size_limit = 0 + +# Misc +recipient_delimiter = + +compatibility_level = 3.6 +""" + + SUBMISSION_BLOCK = """ +# Submission port (587) - Managed by ServerKit +submission inet n - y - - smtpd + -o syslog_name=postfix/submission + -o smtpd_tls_security_level=encrypt + -o smtpd_sasl_auth_enable=yes + -o smtpd_tls_auth_only=yes + -o smtpd_reject_unlisted_recipient=no + -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject + -o milter_macro_daemon_name=ORIGINATING + +# SMTPS port (465) - Managed by ServerKit +smtps inet n - y - - smtpd + -o syslog_name=postfix/smtps + -o smtpd_tls_wrappermode=yes + -o smtpd_sasl_auth_enable=yes + -o smtpd_reject_unlisted_recipient=no + -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject + -o milter_macro_daemon_name=ORIGINATING +""" + + @classmethod + def get_status(cls) -> Dict: + """Get Postfix installation and running status.""" + installed = False + running = False + enabled = False + version = None + + try: + result = subprocess.run(['which', 'postfix'], capture_output=True, text=True) + installed = result.returncode == 0 + + if not installed: + installed = PackageManager.is_installed('postfix') + + if installed: + running = ServiceControl.is_active('postfix') + enabled = ServiceControl.is_enabled('postfix') + + result = subprocess.run( + ['postconf', 'mail_version'], + capture_output=True, text=True + ) + match = re.search(r'mail_version\s*=\s*(\S+)', result.stdout) + if match: + version = match.group(1) + except (subprocess.SubprocessError, FileNotFoundError): + pass + + return { + 'installed': installed, + 'running': running, + 'enabled': enabled, + 'version': version, + } + + @classmethod + def install(cls) -> Dict: + """Install Postfix and create vmail user.""" + try: + manager = PackageManager.detect() + if manager == 'apt': + # Pre-seed debconf to avoid interactive prompts + run_privileged( + 'debconf-set-selections <<< "postfix postfix/main_mailer_type select Internet Site"', + shell=True + ) + run_privileged( + 'debconf-set-selections <<< "postfix postfix/mailname string localhost"', + shell=True + ) + + result = PackageManager.install(['postfix', 'postfix-pcre'], timeout=300) + if result.returncode != 0: + return {'success': False, 'error': result.stderr or 'Failed to install Postfix'} + + # Create vmail group and user + run_privileged(['groupadd', '-g', str(paths.VMAIL_GID), 'vmail']) + run_privileged([ + 'useradd', '-r', + '-u', str(paths.VMAIL_UID), + '-g', str(paths.VMAIL_GID), + '-d', paths.VMAIL_DIR, + '-s', '/usr/sbin/nologin', + '-c', 'Virtual Mail User', + 'vmail' + ]) + + # Create vmail directory + run_privileged(['mkdir', '-p', paths.VMAIL_DIR]) + run_privileged(['chown', '-R', f'{paths.VMAIL_UID}:{paths.VMAIL_GID}', paths.VMAIL_DIR]) + run_privileged(['chmod', '750', paths.VMAIL_DIR]) + + # Create empty map files + for map_file in [cls.VIRTUAL_DOMAINS_FILE, cls.VIRTUAL_MAILBOX_FILE, cls.VIRTUAL_ALIAS_FILE]: + run_privileged(['touch', map_file]) + run_privileged(['postmap', map_file]) + + ServiceControl.enable('postfix') + + return {'success': True, 'message': 'Postfix installed successfully'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def configure(cls, hostname: str, tls_cert: str = None, tls_key: str = None) -> Dict: + """Write main.cf and configure submission ports in master.cf.""" + try: + domain = hostname.split('.', 1)[1] if '.' in hostname else hostname + + config = cls.MAIN_CF_TEMPLATE.format( + hostname=hostname, + domain=domain, + vmail_dir=paths.VMAIL_DIR, + vmail_uid=paths.VMAIL_UID, + vmail_gid=paths.VMAIL_GID, + tls_cert=tls_cert or '/etc/ssl/certs/ssl-cert-snakeoil.pem', + tls_key=tls_key or '/etc/ssl/private/ssl-cert-snakeoil.key', + ) + + # Write main.cf + run_privileged(['tee', cls.POSTFIX_MAIN_CF], input=config) + + # Add submission/smtps to master.cf if not present + result = run_privileged(['cat', cls.POSTFIX_MASTER_CF]) + if 'Managed by ServerKit' not in (result.stdout or ''): + run_privileged(['tee', '-a', cls.POSTFIX_MASTER_CF], input=cls.SUBMISSION_BLOCK) + + # Restart to apply + ServiceControl.restart('postfix', timeout=30) + + return {'success': True, 'message': 'Postfix configured successfully'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def add_virtual_domain(cls, domain: str) -> Dict: + """Add a virtual mailbox domain.""" + try: + # Read current domains + result = run_privileged(['cat', cls.VIRTUAL_DOMAINS_FILE]) + content = result.stdout or '' + + if domain in content: + return {'success': True, 'message': 'Domain already exists'} + + # Append domain + new_line = f'{domain} OK\n' + run_privileged(['tee', '-a', cls.VIRTUAL_DOMAINS_FILE], input=new_line) + run_privileged(['postmap', cls.VIRTUAL_DOMAINS_FILE]) + + return {'success': True, 'message': f'Domain {domain} added'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def remove_virtual_domain(cls, domain: str) -> Dict: + """Remove a virtual mailbox domain.""" + try: + result = run_privileged(['cat', cls.VIRTUAL_DOMAINS_FILE]) + lines = (result.stdout or '').splitlines() + new_lines = [l for l in lines if not l.strip().startswith(domain)] + run_privileged(['tee', cls.VIRTUAL_DOMAINS_FILE], input='\n'.join(new_lines) + '\n') + run_privileged(['postmap', cls.VIRTUAL_DOMAINS_FILE]) + + return {'success': True, 'message': f'Domain {domain} removed'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def add_virtual_mailbox(cls, email: str, domain: str, username: str) -> Dict: + """Add a virtual mailbox entry.""" + try: + mailbox_path = f'{domain}/{username}/Maildir/' + new_line = f'{email} {mailbox_path}\n' + + result = run_privileged(['cat', cls.VIRTUAL_MAILBOX_FILE]) + if email in (result.stdout or ''): + return {'success': True, 'message': 'Mailbox already exists'} + + run_privileged(['tee', '-a', cls.VIRTUAL_MAILBOX_FILE], input=new_line) + run_privileged(['postmap', cls.VIRTUAL_MAILBOX_FILE]) + + return {'success': True, 'message': f'Mailbox {email} added'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def remove_virtual_mailbox(cls, email: str) -> Dict: + """Remove a virtual mailbox entry.""" + try: + result = run_privileged(['cat', cls.VIRTUAL_MAILBOX_FILE]) + lines = (result.stdout or '').splitlines() + new_lines = [l for l in lines if not l.strip().startswith(email)] + run_privileged(['tee', cls.VIRTUAL_MAILBOX_FILE], input='\n'.join(new_lines) + '\n') + run_privileged(['postmap', cls.VIRTUAL_MAILBOX_FILE]) + + return {'success': True, 'message': f'Mailbox {email} removed'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def add_virtual_alias(cls, source: str, destination: str) -> Dict: + """Add a virtual alias mapping.""" + try: + new_line = f'{source} {destination}\n' + + result = run_privileged(['cat', cls.VIRTUAL_ALIAS_FILE]) + content = result.stdout or '' + + # Check for existing alias for this source + lines = content.splitlines() + updated = False + new_lines = [] + for line in lines: + if line.strip().startswith(source + ' ') or line.strip().startswith(source + '\t'): + # Update existing + new_lines.append(new_line.rstrip()) + updated = True + else: + new_lines.append(line) + + if not updated: + new_lines.append(new_line.rstrip()) + + run_privileged(['tee', cls.VIRTUAL_ALIAS_FILE], input='\n'.join(new_lines) + '\n') + run_privileged(['postmap', cls.VIRTUAL_ALIAS_FILE]) + + return {'success': True, 'message': f'Alias {source} -> {destination} added'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def remove_virtual_alias(cls, source: str) -> Dict: + """Remove a virtual alias mapping.""" + try: + result = run_privileged(['cat', cls.VIRTUAL_ALIAS_FILE]) + lines = (result.stdout or '').splitlines() + new_lines = [l for l in lines if not (l.strip().startswith(source + ' ') or l.strip().startswith(source + '\t'))] + run_privileged(['tee', cls.VIRTUAL_ALIAS_FILE], input='\n'.join(new_lines) + '\n') + run_privileged(['postmap', cls.VIRTUAL_ALIAS_FILE]) + + return {'success': True, 'message': f'Alias {source} removed'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def rebuild_maps(cls) -> Dict: + """Rebuild all Postfix hash maps.""" + try: + for map_file in [cls.VIRTUAL_DOMAINS_FILE, cls.VIRTUAL_MAILBOX_FILE, cls.VIRTUAL_ALIAS_FILE]: + if os.path.exists(map_file): + run_privileged(['postmap', map_file]) + + return {'success': True, 'message': 'Maps rebuilt'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def reload(cls) -> Dict: + """Reload Postfix configuration.""" + try: + result = ServiceControl.reload('postfix', timeout=30) + if result.returncode == 0: + return {'success': True, 'message': 'Postfix reloaded'} + return {'success': False, 'error': result.stderr or 'Reload failed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def restart(cls) -> Dict: + """Restart Postfix.""" + try: + result = ServiceControl.restart('postfix', timeout=30) + if result.returncode == 0: + return {'success': True, 'message': 'Postfix restarted'} + return {'success': False, 'error': result.stderr or 'Restart failed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def get_queue(cls) -> Dict: + """Get Postfix mail queue.""" + try: + result = run_privileged(['mailq']) + output = result.stdout or '' + + if 'Mail queue is empty' in output: + return {'success': True, 'queue': [], 'count': 0} + + # Parse queue entries + queue = [] + current = None + for line in output.splitlines(): + match = re.match(r'^([A-F0-9]+)\s+(\d+)\s+(.+)\s+(\S+@\S+)', line) + if match: + if current: + queue.append(current) + current = { + 'id': match.group(1), + 'size': int(match.group(2)), + 'date': match.group(3).strip(), + 'sender': match.group(4), + 'recipients': [], + } + elif current and line.strip() and not line.startswith('-'): + recipient = line.strip() + if '@' in recipient: + current['recipients'].append(recipient) + + if current: + queue.append(current) + + return {'success': True, 'queue': queue, 'count': len(queue)} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def flush_queue(cls) -> Dict: + """Flush the Postfix mail queue.""" + try: + result = run_privileged(['postqueue', '-f']) + return {'success': True, 'message': 'Queue flushed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def delete_from_queue(cls, queue_id: str) -> Dict: + """Delete a specific message from the queue.""" + try: + result = run_privileged(['postsuper', '-d', queue_id]) + if result.returncode == 0: + return {'success': True, 'message': f'Message {queue_id} deleted'} + return {'success': False, 'error': result.stderr or 'Delete failed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def get_logs(cls, lines: int = 100) -> Dict: + """Get mail logs.""" + log_files = ['/var/log/mail.log', '/var/log/maillog', '/var/log/syslog'] + + for log_file in log_files: + if os.path.exists(log_file): + try: + result = run_privileged(['tail', '-n', str(lines), log_file]) + content = result.stdout or '' + + if 'syslog' in log_file: + content = '\n'.join( + l for l in content.splitlines() + if 'postfix' in l.lower() + ) + + return {'success': True, 'log_file': log_file, 'content': content} + except Exception: + continue + + return {'success': False, 'error': 'No mail log files found'} diff --git a/backend/app/services/roundcube_service.py b/backend/app/services/roundcube_service.py new file mode 100644 index 0000000..66447b4 --- /dev/null +++ b/backend/app/services/roundcube_service.py @@ -0,0 +1,189 @@ +"""Roundcube webmail management service (Docker-based).""" + +import subprocess +from typing import Dict + +from app.utils.system import run_privileged, is_command_available + + +class RoundcubeService: + """Service for managing Roundcube webmail via Docker.""" + + CONTAINER_NAME = 'serverkit-roundcube' + VOLUME_NAME = 'roundcube_data' + IMAGE = 'roundcube/roundcubemail:latest' + HOST_PORT = 9000 + + @classmethod + def get_status(cls) -> Dict: + """Get Roundcube container status.""" + if not is_command_available('docker'): + return {'installed': False, 'running': False, 'error': 'Docker not available'} + + try: + result = subprocess.run( + ['docker', 'inspect', '--format', '{{.State.Status}}', cls.CONTAINER_NAME], + capture_output=True, text=True, + ) + + if result.returncode != 0: + return {'installed': False, 'running': False} + + status = result.stdout.strip() + return { + 'installed': True, + 'running': status == 'running', + 'status': status, + 'port': cls.HOST_PORT, + } + + except Exception as e: + return {'installed': False, 'running': False, 'error': str(e)} + + @classmethod + def install(cls, imap_host: str = 'host.docker.internal', + smtp_host: str = 'host.docker.internal') -> Dict: + """Install Roundcube via Docker container.""" + if not is_command_available('docker'): + return {'success': False, 'error': 'Docker is not installed'} + + try: + # Remove existing container if any + subprocess.run( + ['docker', 'rm', '-f', cls.CONTAINER_NAME], + capture_output=True, text=True, + ) + + # Create volume + subprocess.run( + ['docker', 'volume', 'create', cls.VOLUME_NAME], + capture_output=True, text=True, + ) + + # Run container + result = subprocess.run([ + 'docker', 'run', '-d', + '--name', cls.CONTAINER_NAME, + '--restart', 'unless-stopped', + '--add-host', 'host.docker.internal:host-gateway', + '-p', f'{cls.HOST_PORT}:80', + '-e', f'ROUNDCUBEMAIL_DEFAULT_HOST=ssl://{imap_host}', + '-e', f'ROUNDCUBEMAIL_SMTP_SERVER=tls://{smtp_host}', + '-e', 'ROUNDCUBEMAIL_DEFAULT_PORT=993', + '-e', 'ROUNDCUBEMAIL_SMTP_PORT=587', + '-e', 'ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=25M', + '-e', 'ROUNDCUBEMAIL_SKIN=elastic', + '-v', f'{cls.VOLUME_NAME}:/var/roundcube/db', + cls.IMAGE, + ], capture_output=True, text=True) + + if result.returncode != 0: + return {'success': False, 'error': result.stderr or 'Failed to start container'} + + return { + 'success': True, + 'message': 'Roundcube installed successfully', + 'port': cls.HOST_PORT, + 'url': f'http://localhost:{cls.HOST_PORT}', + } + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def uninstall(cls) -> Dict: + """Stop and remove Roundcube container.""" + try: + subprocess.run(['docker', 'rm', '-f', cls.CONTAINER_NAME], capture_output=True, text=True) + return {'success': True, 'message': 'Roundcube uninstalled'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def start(cls) -> Dict: + """Start Roundcube container.""" + try: + result = subprocess.run( + ['docker', 'start', cls.CONTAINER_NAME], + capture_output=True, text=True, + ) + if result.returncode == 0: + return {'success': True, 'message': 'Roundcube started'} + return {'success': False, 'error': result.stderr or 'Start failed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def stop(cls) -> Dict: + """Stop Roundcube container.""" + try: + result = subprocess.run( + ['docker', 'stop', cls.CONTAINER_NAME], + capture_output=True, text=True, + ) + if result.returncode == 0: + return {'success': True, 'message': 'Roundcube stopped'} + return {'success': False, 'error': result.stderr or 'Stop failed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def restart(cls) -> Dict: + """Restart Roundcube container.""" + try: + result = subprocess.run( + ['docker', 'restart', cls.CONTAINER_NAME], + capture_output=True, text=True, + ) + if result.returncode == 0: + return {'success': True, 'message': 'Roundcube restarted'} + return {'success': False, 'error': result.stderr or 'Restart failed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def configure_nginx_proxy(cls, domain: str) -> Dict: + """Create Nginx reverse proxy config for Roundcube.""" + try: + from app.services.nginx_service import NginxService + + config = f"""# Roundcube Webmail - Managed by ServerKit +server {{ + listen 80; + server_name {domain}; + + location / {{ + proxy_pass http://127.0.0.1:{cls.HOST_PORT}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + client_max_body_size 25m; + }} +}} +""" + site_name = f'roundcube-{domain.replace(".", "-")}' + config_path = f'/etc/nginx/sites-available/{site_name}' + enabled_path = f'/etc/nginx/sites-enabled/{site_name}' + + run_privileged(['tee', config_path], input=config) + run_privileged(['ln', '-sf', config_path, enabled_path]) + + # Test and reload + test = run_privileged(['nginx', '-t']) + if test.returncode != 0: + # Rollback + run_privileged(['rm', '-f', enabled_path]) + return {'success': False, 'error': f'Nginx config test failed: {test.stderr}'} + + run_privileged(['systemctl', 'reload', 'nginx']) + + return { + 'success': True, + 'message': f'Nginx proxy configured for {domain}', + 'url': f'http://{domain}', + } + + except Exception as e: + return {'success': False, 'error': str(e)} diff --git a/backend/app/services/spamassassin_service.py b/backend/app/services/spamassassin_service.py new file mode 100644 index 0000000..9d611aa --- /dev/null +++ b/backend/app/services/spamassassin_service.py @@ -0,0 +1,267 @@ +"""SpamAssassin management service.""" + +import os +import re +import subprocess +from typing import Dict + +from app.utils.system import PackageManager, ServiceControl, run_privileged + + +class SpamAssassinService: + """Service for managing SpamAssassin spam filtering.""" + + SPAMASSASSIN_CONF = '/etc/spamassassin/local.cf' + SPAMASSASSIN_DEFAULT = '/etc/default/spamassassin' + SPAMASS_MILTER_DEFAULT = '/etc/default/spamass-milter' + + LOCAL_CF_TEMPLATE = """# SpamAssassin local configuration - Managed by ServerKit +required_score {required_score} +report_safe {report_safe} +rewrite_header Subject {rewrite_subject} + +use_bayes {use_bayes} +bayes_auto_learn {bayes_auto_learn} +bayes_auto_learn_threshold_nonspam 0.1 +bayes_auto_learn_threshold_spam 12.0 + +skip_rbl_checks {skip_rbl_checks} +use_razor2 0 +use_pyzor 0 + +# Network checks +dns_available yes + +# Trusted networks +trusted_networks 127.0.0.0/8 +internal_networks 127.0.0.0/8 +""" + + DEFAULT_CONFIG = { + 'required_score': 5.0, + 'report_safe': 0, + 'rewrite_subject': '[SPAM]', + 'use_bayes': 1, + 'bayes_auto_learn': 1, + 'skip_rbl_checks': 0, + } + + @classmethod + def get_status(cls) -> Dict: + """Get SpamAssassin installation and running status.""" + installed = False + running = False + enabled = False + version = None + milter_installed = False + milter_running = False + + try: + installed = PackageManager.is_installed('spamassassin') + + if installed: + running = ServiceControl.is_active('spamassassin') + enabled = ServiceControl.is_enabled('spamassassin') + + result = subprocess.run(['spamassassin', '--version'], capture_output=True, text=True) + match = re.search(r'version\s+(\S+)', result.stdout) + if match: + version = match.group(1) + + # Check milter + milter_installed = PackageManager.is_installed('spamass-milter') + if milter_installed: + milter_running = ServiceControl.is_active('spamass-milter') + + except (subprocess.SubprocessError, FileNotFoundError): + pass + + return { + 'installed': installed, + 'running': running, + 'enabled': enabled, + 'version': version, + 'milter_installed': milter_installed, + 'milter_running': milter_running, + } + + @classmethod + def install(cls) -> Dict: + """Install SpamAssassin and spamass-milter.""" + try: + manager = PackageManager.detect() + if manager == 'apt': + packages = ['spamassassin', 'spamass-milter', 'spamc'] + else: + packages = ['spamassassin', 'spamass-milter-postfix'] + + result = PackageManager.install(packages, timeout=300) + if result.returncode != 0: + return {'success': False, 'error': result.stderr or 'Failed to install SpamAssassin'} + + # Enable spamd on Debian/Ubuntu + if manager == 'apt' and os.path.exists(cls.SPAMASSASSIN_DEFAULT): + result = run_privileged(['cat', cls.SPAMASSASSIN_DEFAULT]) + content = result.stdout or '' + content = re.sub(r'ENABLED=0', 'ENABLED=1', content) + content = re.sub(r'CRON=0', 'CRON=1', content) + run_privileged(['tee', cls.SPAMASSASSIN_DEFAULT], input=content) + + # Configure milter to listen on port 8893 + milter_config = 'OPTIONS="-u spamass-milter -i 127.0.0.1 -p inet:8893@localhost -- --socket=/var/run/spamassassin/spamd.sock"\n' + if os.path.exists(cls.SPAMASS_MILTER_DEFAULT): + run_privileged(['tee', cls.SPAMASS_MILTER_DEFAULT], input=milter_config) + + # Write default config + cls.configure(cls.DEFAULT_CONFIG) + + # Update rules + run_privileged(['sa-update'], timeout=120) + + ServiceControl.enable('spamassassin') + ServiceControl.start('spamassassin', timeout=30) + + if PackageManager.is_installed('spamass-milter'): + ServiceControl.enable('spamass-milter') + ServiceControl.start('spamass-milter', timeout=30) + + return {'success': True, 'message': 'SpamAssassin installed successfully'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def configure(cls, settings: Dict = None) -> Dict: + """Write SpamAssassin configuration.""" + try: + config = dict(cls.DEFAULT_CONFIG) + if settings: + config.update(settings) + + content = cls.LOCAL_CF_TEMPLATE.format(**config) + run_privileged(['tee', cls.SPAMASSASSIN_CONF], input=content) + + return {'success': True, 'message': 'SpamAssassin configured'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def get_config(cls) -> Dict: + """Get current SpamAssassin configuration.""" + try: + if not os.path.exists(cls.SPAMASSASSIN_CONF): + return {'success': True, 'config': dict(cls.DEFAULT_CONFIG)} + + result = run_privileged(['cat', cls.SPAMASSASSIN_CONF]) + content = result.stdout or '' + + config = dict(cls.DEFAULT_CONFIG) + + # Parse values + score_match = re.search(r'required_score\s+(\S+)', content) + if score_match: + config['required_score'] = float(score_match.group(1)) + + report_match = re.search(r'report_safe\s+(\d+)', content) + if report_match: + config['report_safe'] = int(report_match.group(1)) + + subject_match = re.search(r'rewrite_header Subject\s+(.+)', content) + if subject_match: + config['rewrite_subject'] = subject_match.group(1).strip() + + bayes_match = re.search(r'use_bayes\s+(\d+)', content) + if bayes_match: + config['use_bayes'] = int(bayes_match.group(1)) + + auto_learn_match = re.search(r'bayes_auto_learn\s+(\d+)', content) + if auto_learn_match: + config['bayes_auto_learn'] = int(auto_learn_match.group(1)) + + rbl_match = re.search(r'skip_rbl_checks\s+(\d+)', content) + if rbl_match: + config['skip_rbl_checks'] = int(rbl_match.group(1)) + + return {'success': True, 'config': config} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def update_rules(cls) -> Dict: + """Update SpamAssassin rules.""" + try: + result = run_privileged(['sa-update'], timeout=120) + # sa-update returns 0 for updates, 1 for no updates, 2+ for errors + if result.returncode <= 1: + ServiceControl.restart('spamassassin', timeout=30) + return { + 'success': True, + 'message': 'Rules updated' if result.returncode == 0 else 'Rules already up to date', + 'updated': result.returncode == 0, + } + return {'success': False, 'error': result.stderr or 'Update failed'} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def train_spam(cls, message_path: str) -> Dict: + """Train SpamAssassin with a spam message.""" + try: + result = run_privileged(['sa-learn', '--spam', message_path]) + if result.returncode == 0: + return {'success': True, 'message': 'Message learned as spam'} + return {'success': False, 'error': result.stderr or 'Training failed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def train_ham(cls, message_path: str) -> Dict: + """Train SpamAssassin with a ham (non-spam) message.""" + try: + result = run_privileged(['sa-learn', '--ham', message_path]) + if result.returncode == 0: + return {'success': True, 'message': 'Message learned as ham'} + return {'success': False, 'error': result.stderr or 'Training failed'} + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def get_stats(cls) -> Dict: + """Get SpamAssassin Bayes statistics.""" + try: + result = run_privileged(['sa-learn', '--dump', 'magic']) + output = result.stdout or '' + + stats = { + 'nspam': 0, + 'nham': 0, + 'ntokens': 0, + } + for line in output.splitlines(): + parts = line.split() + if len(parts) >= 4: + if parts[2] == 'nspam': + stats['nspam'] = int(parts[1]) + elif parts[2] == 'nham': + stats['nham'] = int(parts[1]) + elif parts[2] == 'ntokens': + stats['ntokens'] = int(parts[1]) + + return {'success': True, 'stats': stats} + + except Exception as e: + return {'success': False, 'error': str(e)} + + @classmethod + def reload(cls) -> Dict: + """Reload SpamAssassin.""" + try: + result = ServiceControl.restart('spamassassin', timeout=30) + if result.returncode == 0: + return {'success': True, 'message': 'SpamAssassin restarted'} + return {'success': False, 'error': result.stderr or 'Restart failed'} + except Exception as e: + return {'success': False, 'error': str(e)} diff --git a/backend/config.py b/backend/config.py index 5e04c69..b70b925 100644 --- a/backend/config.py +++ b/backend/config.py @@ -31,6 +31,15 @@ class DevelopmentConfig(Config): DEBUG = True +class TestingConfig(Config): + """Config for pytest and other automated tests.""" + TESTING = True + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL', 'sqlite:///:memory:') + # Reduce noise during tests + JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=5) + + class ProductionConfig(Config): DEBUG = False @@ -50,5 +59,6 @@ def __init__(self): config = { 'development': DevelopmentConfig, 'production': ProductionConfig, - 'default': DevelopmentConfig + 'testing': TestingConfig, + 'default': DevelopmentConfig, } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..9b72b15 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,61 @@ +"""Pytest fixtures for backend tests (Flask app, DB, client).""" +import os +import sys + +import pytest + +# Ensure backend root is on path +_backend = os.path.dirname(os.path.abspath(__file__)) +if _backend not in sys.path: + sys.path.insert(0, _backend) + +os.environ.setdefault('FLASK_ENV', 'testing') + + +@pytest.fixture(scope='function') +def app(): + """Create Flask app with testing config and in-memory DB.""" + from app import create_app + from app import db as _db + + app = create_app('testing') + with app.app_context(): + _db.create_all() + yield app + _db.session.remove() + _db.drop_all() + + +@pytest.fixture +def client(app): + """Flask test client.""" + return app.test_client() + + +@pytest.fixture +def db_session(app): + """Database session for the current test (same as app's db).""" + from app import db + return db + + +@pytest.fixture +def auth_headers(app): + """Create an admin user and return headers with valid JWT for API tests.""" + from app import db + from app.models import User + from flask_jwt_extended import create_access_token + from werkzeug.security import generate_password_hash + + with app.app_context(): + user = User( + email='testadmin@test.local', + username='testadmin', + password_hash=generate_password_hash('testpass'), + role=User.ROLE_ADMIN, + is_active=True, + ) + db.session.add(user) + db.session.commit() + token = create_access_token(identity=user.id) + return {'Authorization': f'Bearer {token}'} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 85ed8ef..c217534 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -35,6 +35,7 @@ import WordPressDetail from './pages/WordPressDetail'; import WordPressProjects from './pages/WordPressProjects'; import WordPressProject from './pages/WordPressProject'; import SSLCertificates from './pages/SSLCertificates'; +import Email from './pages/Email'; // Page title mapping const PAGE_TITLES = { @@ -56,6 +57,7 @@ const PAGE_TITLES = { '/git': 'Git Repositories', '/files': 'File Manager', '/ftp': 'FTP Server', + '/email': 'Email Server', '/monitoring': 'Monitoring', '/backups': 'Backups', '/cron': 'Cron Jobs', @@ -179,6 +181,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index dd93a74..702c07a 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -222,6 +222,13 @@ const Sidebar = () => { FTP Server + `nav-item ${isActive ? 'active' : ''}`}> + + + + + Email Server + `nav-item ${isActive ? 'active' : ''}`}> diff --git a/frontend/src/pages/Email.jsx b/frontend/src/pages/Email.jsx new file mode 100644 index 0000000..756b7cd --- /dev/null +++ b/frontend/src/pages/Email.jsx @@ -0,0 +1,2037 @@ +import { useState, useEffect } from 'react'; +import { api } from '../services/api'; +import { useToast } from '../contexts/ToastContext'; +import Spinner from '../components/Spinner'; +import ConfirmDialog from '../components/ConfirmDialog'; + +function Email() { + const [status, setStatus] = useState(null); + const [domains, setDomains] = useState([]); + const [accounts, setAccounts] = useState([]); + const [aliases, setAliases] = useState([]); + const [forwarding, setForwarding] = useState([]); + const [dnsProviders, setDnsProviders] = useState([]); + const [spamConfig, setSpamConfig] = useState(null); + const [webmailStatus, setWebmailStatus] = useState(null); + const [mailQueue, setMailQueue] = useState([]); + const [logs, setLogs] = useState(''); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('overview'); + const [actionLoading, setActionLoading] = useState(false); + const [confirmDialog, setConfirmDialog] = useState(null); + + // Modal states + const [showInstallModal, setShowInstallModal] = useState(false); + const [showAddDomainModal, setShowAddDomainModal] = useState(false); + const [showCreateAccountModal, setShowCreateAccountModal] = useState(false); + const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); + const [showEditQuotaModal, setShowEditQuotaModal] = useState(false); + const [showAddAliasModal, setShowAddAliasModal] = useState(false); + const [showAddForwardingModal, setShowAddForwardingModal] = useState(false); + const [showAddProviderModal, setShowAddProviderModal] = useState(false); + const [showInstallWebmailModal, setShowInstallWebmailModal] = useState(false); + + // Form states + const [installHostname, setInstallHostname] = useState(''); + const [newDomain, setNewDomain] = useState({ domain: '', dns_provider_id: '', zone_id: '' }); + const [providerZones, setProviderZones] = useState([]); + const [zonesLoading, setZonesLoading] = useState(false); + const [selectedDomainId, setSelectedDomainId] = useState(''); + const [newAccount, setNewAccount] = useState({ username: '', password: '', quota: 1024 }); + const [passwordTarget, setPasswordTarget] = useState(null); + const [newPassword, setNewPassword] = useState(''); + const [quotaTarget, setQuotaTarget] = useState(null); + const [newQuota, setNewQuota] = useState(1024); + const [newAlias, setNewAlias] = useState({ source: '', destination: '' }); + const [selectedForwardingAccount, setSelectedForwardingAccount] = useState(''); + const [newForwarding, setNewForwarding] = useState({ destination: '', keep_copy: true }); + const [newProvider, setNewProvider] = useState({ name: '', type: 'cloudflare', api_key: '', api_secret: '', api_email: '' }); + const [webmailProxyDomain, setWebmailProxyDomain] = useState(''); + const [spamForm, setSpamForm] = useState({ + required_score: 5, + rewrite_subject: '[SPAM]', + use_bayes: true, + auto_learn: true, + skip_rbl_checks: false + }); + + const toast = useToast(); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + await Promise.all([ + loadStatus(), + loadDomains(), + loadDnsProviders() + ]); + } catch (error) { + console.error('Failed to load email data:', error); + } finally { + setLoading(false); + } + }; + + const loadStatus = async () => { + try { + const data = await api.getEmailStatus(); + setStatus(data); + } catch (error) { + console.error('Failed to load email status:', error); + } + }; + + const loadDomains = async () => { + try { + const data = await api.getEmailDomains(); + setDomains(data.domains || data || []); + } catch (error) { + console.error('Failed to load email domains:', error); + } + }; + + const loadAccounts = async (domainId) => { + if (!domainId) return; + try { + const data = await api.getEmailAccounts(domainId); + setAccounts(data.accounts || data || []); + } catch (error) { + console.error('Failed to load email accounts:', error); + } + }; + + const loadAliases = async (domainId) => { + if (!domainId) return; + try { + const data = await api.getEmailAliases(domainId); + setAliases(data.aliases || data || []); + } catch (error) { + console.error('Failed to load email aliases:', error); + } + }; + + const loadForwarding = async (accountId) => { + if (!accountId) return; + try { + const data = await api.getEmailForwarding(accountId); + setForwarding(data.rules || data || []); + } catch (error) { + console.error('Failed to load forwarding rules:', error); + } + }; + + const loadDnsProviders = async () => { + try { + const data = await api.getEmailDNSProviders(); + setDnsProviders(data.providers || data || []); + } catch (error) { + console.error('Failed to load DNS providers:', error); + } + }; + + const loadSpamConfig = async () => { + try { + const data = await api.getSpamConfig(); + setSpamConfig(data); + if (data) { + setSpamForm({ + required_score: data.required_score ?? 5, + rewrite_subject: data.rewrite_subject ?? '[SPAM]', + use_bayes: data.use_bayes ?? true, + auto_learn: data.auto_learn ?? true, + skip_rbl_checks: data.skip_rbl_checks ?? false + }); + } + } catch (error) { + console.error('Failed to load spam config:', error); + } + }; + + const loadWebmailStatus = async () => { + try { + const data = await api.getWebmailStatus(); + setWebmailStatus(data); + } catch (error) { + console.error('Failed to load webmail status:', error); + } + }; + + const loadMailQueue = async () => { + try { + const data = await api.getMailQueue(); + setMailQueue(data.queue || data || []); + } catch (error) { + console.error('Failed to load mail queue:', error); + } + }; + + const loadLogs = async () => { + try { + const data = await api.getMailLogs(200); + setLogs(data.content || data.logs || 'No logs available'); + } catch (error) { + setLogs('Failed to load logs'); + } + }; + + // --- Action handlers --- + + const handleInstall = async () => { + if (!installHostname.trim()) return; + setActionLoading(true); + try { + await api.installEmailServer({ hostname: installHostname }); + toast.success('Email server installed successfully'); + setShowInstallModal(false); + setInstallHostname(''); + await loadData(); + } catch (error) { + toast.error(`Failed to install email server: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleServiceControl = async (component, action) => { + setActionLoading(true); + try { + await api.controlEmailService(component, action); + toast.success(`${component} ${action}ed successfully`); + await loadStatus(); + } catch (error) { + toast.error(`Failed to ${action} ${component}: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleAddDomain = async () => { + if (!newDomain.domain.trim()) return; + setActionLoading(true); + try { + await api.addEmailDomain(newDomain); + toast.success('Domain added successfully'); + setShowAddDomainModal(false); + setNewDomain({ domain: '', dns_provider_id: '', zone_id: '' }); + setProviderZones([]); + await loadDomains(); + } catch (error) { + toast.error(`Failed to add domain: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleDeleteDomain = async (domainId, domainName) => { + setConfirmDialog({ + title: 'Delete Email Domain', + message: `Are you sure you want to delete domain "${domainName}"? This will remove all accounts and aliases.`, + confirmText: 'Delete', + variant: 'danger', + onConfirm: async () => { + try { + await api.deleteEmailDomain(domainId); + toast.success('Domain deleted successfully'); + await loadDomains(); + } catch (error) { + toast.error(`Failed to delete domain: ${error.message}`); + } + setConfirmDialog(null); + }, + onCancel: () => setConfirmDialog(null) + }); + }; + + const handleVerifyDNS = async (domainId) => { + setActionLoading(true); + try { + const result = await api.verifyEmailDNS(domainId); + if (result.all_valid || result.success) { + toast.success('DNS records verified successfully'); + } else { + toast.warning('Some DNS records need attention'); + } + await loadDomains(); + } catch (error) { + toast.error(`DNS verification failed: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleDeployDNS = async (domainId) => { + setActionLoading(true); + try { + await api.deployEmailDNS(domainId); + toast.success('DNS records deployed successfully'); + await loadDomains(); + } catch (error) { + toast.error(`Failed to deploy DNS records: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleProviderChange = async (providerId) => { + setNewDomain({ ...newDomain, dns_provider_id: providerId, zone_id: '' }); + setProviderZones([]); + if (!providerId) return; + setZonesLoading(true); + try { + const data = await api.getEmailDNSZones(providerId); + setProviderZones(data.zones || data || []); + } catch (error) { + toast.error(`Failed to load DNS zones: ${error.message}`); + } finally { + setZonesLoading(false); + } + }; + + const handleCreateAccount = async () => { + if (!newAccount.username.trim() || !selectedDomainId) return; + setActionLoading(true); + try { + await api.createEmailAccount(selectedDomainId, newAccount); + toast.success('Account created successfully'); + setShowCreateAccountModal(false); + setNewAccount({ username: '', password: '', quota: 1024 }); + await loadAccounts(selectedDomainId); + } catch (error) { + toast.error(`Failed to create account: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleDeleteAccount = async (accountId, email) => { + setConfirmDialog({ + title: 'Delete Email Account', + message: `Are you sure you want to delete "${email}"?`, + confirmText: 'Delete', + variant: 'danger', + onConfirm: async () => { + try { + await api.deleteEmailAccount(accountId); + toast.success('Account deleted successfully'); + await loadAccounts(selectedDomainId); + } catch (error) { + toast.error(`Failed to delete account: ${error.message}`); + } + setConfirmDialog(null); + }, + onCancel: () => setConfirmDialog(null) + }); + }; + + const handleChangePassword = async () => { + if (!passwordTarget || !newPassword.trim()) return; + setActionLoading(true); + try { + await api.changeEmailPassword(passwordTarget, newPassword); + toast.success('Password changed successfully'); + setShowChangePasswordModal(false); + setPasswordTarget(null); + setNewPassword(''); + } catch (error) { + toast.error(`Failed to change password: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleEditQuota = async () => { + if (!quotaTarget) return; + setActionLoading(true); + try { + await api.updateEmailAccount(quotaTarget, { quota: newQuota }); + toast.success('Quota updated successfully'); + setShowEditQuotaModal(false); + setQuotaTarget(null); + await loadAccounts(selectedDomainId); + } catch (error) { + toast.error(`Failed to update quota: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const generatePassword = () => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'; + let pass = ''; + for (let i = 0; i < 16; i++) { + pass += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return pass; + }; + + const handleAddAlias = async () => { + if (!newAlias.source.trim() || !newAlias.destination.trim() || !selectedDomainId) return; + setActionLoading(true); + try { + await api.createEmailAlias(selectedDomainId, newAlias); + toast.success('Alias created successfully'); + setShowAddAliasModal(false); + setNewAlias({ source: '', destination: '' }); + await loadAliases(selectedDomainId); + } catch (error) { + toast.error(`Failed to create alias: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleDeleteAlias = async (aliasId) => { + setConfirmDialog({ + title: 'Delete Alias', + message: 'Are you sure you want to delete this alias?', + confirmText: 'Delete', + variant: 'danger', + onConfirm: async () => { + try { + await api.deleteEmailAlias(aliasId); + toast.success('Alias deleted successfully'); + await loadAliases(selectedDomainId); + } catch (error) { + toast.error(`Failed to delete alias: ${error.message}`); + } + setConfirmDialog(null); + }, + onCancel: () => setConfirmDialog(null) + }); + }; + + const handleAddForwarding = async () => { + if (!newForwarding.destination.trim() || !selectedForwardingAccount) return; + setActionLoading(true); + try { + await api.createEmailForwarding(selectedForwardingAccount, newForwarding); + toast.success('Forwarding rule created successfully'); + setShowAddForwardingModal(false); + setNewForwarding({ destination: '', keep_copy: true }); + await loadForwarding(selectedForwardingAccount); + } catch (error) { + toast.error(`Failed to create forwarding rule: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleToggleForwarding = async (rule) => { + try { + await api.updateEmailForwarding(rule.id, { ...rule, active: !rule.active }); + toast.success('Forwarding rule updated'); + await loadForwarding(selectedForwardingAccount); + } catch (error) { + toast.error(`Failed to update forwarding: ${error.message}`); + } + }; + + const handleToggleForwardingKeepCopy = async (rule) => { + try { + await api.updateEmailForwarding(rule.id, { ...rule, keep_copy: !rule.keep_copy }); + toast.success('Forwarding rule updated'); + await loadForwarding(selectedForwardingAccount); + } catch (error) { + toast.error(`Failed to update forwarding: ${error.message}`); + } + }; + + const handleDeleteForwarding = async (ruleId) => { + setConfirmDialog({ + title: 'Delete Forwarding Rule', + message: 'Are you sure you want to delete this forwarding rule?', + confirmText: 'Delete', + variant: 'danger', + onConfirm: async () => { + try { + await api.deleteEmailForwarding(ruleId); + toast.success('Forwarding rule deleted'); + await loadForwarding(selectedForwardingAccount); + } catch (error) { + toast.error(`Failed to delete forwarding rule: ${error.message}`); + } + setConfirmDialog(null); + }, + onCancel: () => setConfirmDialog(null) + }); + }; + + const handleAddProvider = async () => { + if (!newProvider.name.trim() || !newProvider.api_key.trim()) return; + setActionLoading(true); + try { + await api.addEmailDNSProvider(newProvider); + toast.success('DNS provider added successfully'); + setShowAddProviderModal(false); + setNewProvider({ name: '', type: 'cloudflare', api_key: '', api_secret: '', api_email: '' }); + await loadDnsProviders(); + } catch (error) { + toast.error(`Failed to add DNS provider: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleTestProvider = async (providerId) => { + setActionLoading(true); + try { + const result = await api.testEmailDNSProvider(providerId); + if (result.success) { + toast.success('Connection test successful'); + } else { + toast.error(result.error || 'Connection test failed'); + } + } catch (error) { + toast.error(`Connection test failed: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleDeleteProvider = async (providerId, providerName) => { + setConfirmDialog({ + title: 'Delete DNS Provider', + message: `Are you sure you want to delete provider "${providerName}"?`, + confirmText: 'Delete', + variant: 'danger', + onConfirm: async () => { + try { + await api.deleteEmailDNSProvider(providerId); + toast.success('DNS provider deleted'); + await loadDnsProviders(); + } catch (error) { + toast.error(`Failed to delete provider: ${error.message}`); + } + setConfirmDialog(null); + }, + onCancel: () => setConfirmDialog(null) + }); + }; + + const handleSaveSpamConfig = async () => { + setActionLoading(true); + try { + await api.updateSpamConfig(spamForm); + toast.success('Spam filter configuration saved'); + await loadSpamConfig(); + } catch (error) { + toast.error(`Failed to save spam config: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleUpdateSpamRules = async () => { + setActionLoading(true); + try { + await api.updateSpamRules(); + toast.success('Spam rules updated successfully'); + } catch (error) { + toast.error(`Failed to update spam rules: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleInstallWebmail = async () => { + setActionLoading(true); + try { + await api.installWebmail({}); + toast.success('Roundcube webmail installed successfully'); + setShowInstallWebmailModal(false); + await loadWebmailStatus(); + } catch (error) { + toast.error(`Failed to install webmail: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleWebmailControl = async (action) => { + setActionLoading(true); + try { + await api.controlWebmail(action); + toast.success(`Webmail ${action}ed successfully`); + await loadWebmailStatus(); + } catch (error) { + toast.error(`Failed to ${action} webmail: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleConfigureProxy = async () => { + if (!webmailProxyDomain.trim()) return; + setActionLoading(true); + try { + await api.configureWebmailProxy(webmailProxyDomain); + toast.success('Nginx proxy configured successfully'); + setWebmailProxyDomain(''); + } catch (error) { + toast.error(`Failed to configure proxy: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleFlushQueue = async () => { + setActionLoading(true); + try { + await api.flushMailQueue(); + toast.success('Mail queue flushed'); + await loadMailQueue(); + } catch (error) { + toast.error(`Failed to flush queue: ${error.message}`); + } finally { + setActionLoading(false); + } + }; + + const handleDeleteQueueItem = async (queueId) => { + try { + await api.deleteMailQueueItem(queueId); + toast.success('Queue item deleted'); + await loadMailQueue(); + } catch (error) { + toast.error(`Failed to delete queue item: ${error.message}`); + } + }; + + const copyToClipboard = (text) => { + navigator.clipboard.writeText(text).then(() => { + toast.success('Copied to clipboard'); + }).catch(() => { + toast.error('Failed to copy'); + }); + }; + + const openPasswordModal = (accountId) => { + setPasswordTarget(accountId); + setNewPassword(''); + setShowChangePasswordModal(true); + }; + + const openEditQuotaModal = (accountId, currentQuota) => { + setQuotaTarget(accountId); + setNewQuota(currentQuota || 1024); + setShowEditQuotaModal(true); + }; + + // --- Tab change handlers --- + + const handleTabChange = (tab) => { + setActiveTab(tab); + if (tab === 'domains') { + loadDomains(); + } else if (tab === 'accounts') { + if (selectedDomainId) loadAccounts(selectedDomainId); + } else if (tab === 'aliases') { + if (selectedDomainId) { + loadAliases(selectedDomainId); + loadAccounts(selectedDomainId); + } + } else if (tab === 'dns') { + loadDnsProviders(); + loadDomains(); + } else if (tab === 'spam') { + loadSpamConfig(); + } else if (tab === 'webmail') { + loadWebmailStatus(); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + const isInstalled = status?.installed || status?.postfix?.installed; + const services = [ + { key: 'postfix', label: 'Postfix', data: status?.postfix }, + { key: 'dovecot', label: 'Dovecot', data: status?.dovecot }, + { key: 'opendkim', label: 'OpenDKIM', data: status?.opendkim }, + { key: 'spamassassin', label: 'SpamAssassin', data: status?.spamassassin }, + { key: 'roundcube', label: 'Roundcube', data: status?.roundcube } + ]; + + const totalDomains = domains.length; + const totalAccounts = domains.reduce((sum, d) => sum + (d.accounts_count || 0), 0); + + return ( +
+
+
+

Email Server

+

Manage email domains, accounts, and mail services

+
+
+ {!isInstalled ? ( + + ) : ( + <> + + + )} +
+
+ + {!isInstalled ? ( +
+ email +

No Email Server Installed

+

Install an email server to manage domains, accounts, and mail delivery.

+ +
+ ) : ( + <> +
+
+
+ {status?.postfix?.running ? 'check_circle' : 'pause_circle'} +
+
+ Postfix + {status?.postfix?.running ? 'Running' : 'Stopped'} +
+
+
+
+ {status?.dovecot?.running ? 'check_circle' : 'pause_circle'} +
+
+ Dovecot + {status?.dovecot?.running ? 'Running' : 'Stopped'} +
+
+
+
+ domain +
+
+ Domains + {totalDomains} +
+
+
+
+ people +
+
+ Accounts + {totalAccounts} +
+
+
+ +
+ {['overview', 'domains', 'accounts', 'aliases', 'dns', 'spam', 'webmail'].map((tab) => ( + + ))} +
+ +
+ {/* ===== OVERVIEW TAB ===== */} + {activeTab === 'overview' && ( +
+

Service Status

+
+ {services.map((svc) => ( +
+
+

{svc.label}

+ + {svc.data?.installed ? 'Installed' : 'Not Installed'} + +
+
+
+ Status: + + {svc.data?.running ? 'Running' : 'Stopped'} + +
+ {svc.data?.version && ( +
+ Version: + {svc.data.version} +
+ )} + {svc.data?.installed && ( +
+ {svc.data?.running ? ( + <> + + + + ) : ( + + )} +
+ )} +
+
+ ))} +
+ +

Statistics

+
+
+
+ Total Domains + {totalDomains} +
+
+
+
+ Total Accounts + {totalAccounts} +
+
+
+
+ )} + + {/* ===== DOMAINS TAB ===== */} + {activeTab === 'domains' && ( +
+
+

Email Domains

+ +
+ {domains.length === 0 ? ( +
+ domain +

No email domains configured

+ +
+ ) : ( +
+ {domains.map((domain) => ( +
+
+

{domain.domain || domain.name}

+
+
+
+ Accounts: {domain.accounts_count || 0} + Aliases: {domain.aliases_count || 0} +
+
+ + DKIM {domain.dkim_valid ? '\u2713' : '\u2717'} + + + SPF {domain.spf_valid ? '\u2713' : '\u2717'} + + + DMARC {domain.dmarc_valid ? '\u2713' : '\u2717'} + +
+
+ + + +
+
+
+ ))} +
+ )} +
+ )} + + {/* ===== ACCOUNTS TAB ===== */} + {activeTab === 'accounts' && ( +
+
+

Email Accounts

+
+ + +
+
+ {!selectedDomainId ? ( +
+ domain +

Select a domain to view accounts

+
+ ) : accounts.length === 0 ? ( +
+ person_add +

No accounts for this domain

+ +
+ ) : ( +
+ + + + + + + + + + + + {accounts.map((account) => { + const usedMB = account.quota_used || 0; + const totalMB = account.quota || 1024; + const pct = totalMB > 0 ? Math.min(100, Math.round((usedMB / totalMB) * 100)) : 0; + return ( + + + + + + + + ); + })} + +
EmailQuota UsageStatusCreatedActions
+ {account.email || account.username} + +
+
+
90 ? 'var(--color-danger, #ef4444)' : pct > 70 ? 'var(--color-warning, #f59e0b)' : 'var(--color-success, #22c55e)', + borderRadius: '4px', + transition: 'width 0.3s ease' + }} /> +
+ + {usedMB} / {totalMB} MB ({pct}%) + +
+
+ + {account.active !== false ? 'Active' : 'Inactive'} + + {account.created_at ? new Date(account.created_at).toLocaleDateString() : '-'} + + + +
+
+ )} +
+ )} + + {/* ===== ALIASES & FORWARDING TAB ===== */} + {activeTab === 'aliases' && ( +
+
+ + +
+ + {!selectedDomainId ? ( +
+ domain +

Select a domain to manage aliases and forwarding

+
+ ) : ( +
+ {/* Aliases Section */} +
+
+

Aliases

+ +
+
+ {aliases.length === 0 ? ( +
+

No aliases configured

+
+ ) : ( +
+ {aliases.map((alias) => ( +
+
+ {alias.source} + + {alias.destination} +
+ +
+ ))} +
+ )} +
+
+ + {/* Forwarding Section */} +
+
+

Forwarding

+ +
+
+
+ + +
+ {!selectedForwardingAccount ? ( +
+

Select an account to view forwarding rules

+
+ ) : forwarding.length === 0 ? ( +
+

No forwarding rules

+
+ ) : ( +
+ {forwarding.map((rule) => ( +
+
+ {rule.destination} + + +
+ +
+ ))} +
+ )} +
+
+
+ )} +
+ )} + + {/* ===== DNS & AUTHENTICATION TAB ===== */} + {activeTab === 'dns' && ( +
+ {/* DNS Providers */} +
+

DNS Providers

+ +
+ {dnsProviders.length === 0 ? ( +
+

No DNS providers configured

+ +
+ ) : ( +
+ {dnsProviders.map((provider) => ( +
+
+

{provider.name}

+ {provider.type} +
+
+
+ + +
+
+
+ ))} +
+ )} + + {/* DKIM Records */} +

DKIM Records

+ {domains.length === 0 ? ( +

No domains configured

+ ) : ( +
+ {domains.map((domain) => ( +
+
+

{domain.domain || domain.name} - DKIM

+
+
+ {domain.dkim_record ? ( +
+ + {domain.dkim_record} + + +
+ ) : ( +

DKIM record not available

+ )} +
+
+ ))} +
+ )} + + {/* SPF / DMARC Records */} +

SPF & DMARC Records

+ {domains.length === 0 ? ( +

No domains configured

+ ) : ( +
+ {domains.map((domain) => ( +
+
+

{domain.domain || domain.name}

+
+
+
+ +
+ + {domain.spf_record || `v=spf1 mx a ~all`} + + +
+
+
+ +
+ + {domain.dmarc_record || `v=DMARC1; p=quarantine; rua=mailto:dmarc@${domain.domain || domain.name}`} + + +
+
+
+
+ ))} +
+ )} +
+ )} + + {/* ===== SPAM FILTER TAB ===== */} + {activeTab === 'spam' && ( +
+

SpamAssassin Configuration

+ {status?.spamassassin?.installed && ( +
+ + {status.spamassassin.installed ? 'Installed' : 'Not Installed'} + + + {status.spamassassin.running ? 'Running' : 'Stopped'} + + {status.spamassassin.version && ( + v{status.spamassassin.version} + )} +
+ )} + +
+
+
+ + setSpamForm({ ...spamForm, required_score: Number(e.target.value) })} + /> + Messages scoring above this threshold are marked as spam +
+ +
+ + setSpamForm({ ...spamForm, rewrite_subject: e.target.value })} + placeholder="[SPAM]" + /> + Prefix added to subject line of spam messages +
+ +
+ + Enable Bayesian spam classification +
+ +
+ + Automatically learn from incoming messages +
+ +
+ + Skip real-time blackhole list checks +
+ +
+ + +
+
+
+
+ )} + + {/* ===== WEBMAIL TAB ===== */} + {activeTab === 'webmail' && ( +
+

Roundcube Webmail

+ +
+
+

Status

+
+
+
+ + {webmailStatus?.installed ? 'Installed' : 'Not Installed'} + + {webmailStatus?.installed && ( + + {webmailStatus?.running ? 'Running' : 'Stopped'} + + )} + {webmailStatus?.port && ( + Port: {webmailStatus.port} + )} +
+ + {!webmailStatus?.installed ? ( + + ) : ( +
+ {webmailStatus?.running ? ( + <> + + + + ) : ( + + )} + {webmailStatus?.url && ( + + Open Roundcube + + )} +
+ )} +
+
+ + {webmailStatus?.installed && ( +
+
+

Configure Nginx Proxy

+
+
+
+ +
+ setWebmailProxyDomain(e.target.value)} + placeholder="mail.example.com" + style={{ flex: 1 }} + /> + +
+ Set up Nginx reverse proxy for Roundcube on this domain +
+
+
+ )} + + {webmailStatus?.installed && webmailStatus?.port && ( + + )} +
+ )} +
+ + )} + + {/* ===== MODALS ===== */} + + {/* Install Email Server Modal */} + {showInstallModal && ( +
setShowInstallModal(false)}> +
e.stopPropagation()}> +
+

Install Email Server

+ +
+
+

This will install and configure Postfix, Dovecot, OpenDKIM, and SpamAssassin.

+
+ + setInstallHostname(e.target.value)} + placeholder="mail.example.com" + /> + The fully qualified domain name for your mail server +
+
+
+ + +
+
+
+ )} + + {/* Add Domain Modal */} + {showAddDomainModal && ( +
setShowAddDomainModal(false)}> +
e.stopPropagation()}> +
+

Add Email Domain

+ +
+
+
+ + setNewDomain({ ...newDomain, domain: e.target.value })} + placeholder="example.com" + /> +
+
+ + + Select a DNS provider for automatic DNS record management +
+ {newDomain.dns_provider_id && ( +
+ + {zonesLoading ? ( +

Loading zones...

+ ) : ( + + )} +
+ )} +
+
+ + +
+
+
+ )} + + {/* Create Account Modal */} + {showCreateAccountModal && ( +
setShowCreateAccountModal(false)}> +
e.stopPropagation()}> +
+

Create Email Account

+ +
+
+
+ + setNewAccount({ ...newAccount, username: e.target.value })} + placeholder="user" + /> + The local part of the email address (before @) +
+
+ +
+ setNewAccount({ ...newAccount, password: e.target.value })} + placeholder="Enter password" + style={{ flex: 1 }} + /> + +
+
+
+ +
+ setNewAccount({ ...newAccount, quota: Number(e.target.value) })} + style={{ flex: 1 }} + /> + setNewAccount({ ...newAccount, quota: Number(e.target.value) })} + style={{ width: '100px' }} + /> + MB +
+ Default: 1024 MB (1 GB) +
+
+
+ + +
+
+
+ )} + + {/* Change Password Modal */} + {showChangePasswordModal && ( +
setShowChangePasswordModal(false)}> +
e.stopPropagation()}> +
+

Change Password

+ +
+
+
+ +
+ setNewPassword(e.target.value)} + placeholder="Enter new password" + style={{ flex: 1 }} + /> + +
+
+
+
+ + +
+
+
+ )} + + {/* Edit Quota Modal */} + {showEditQuotaModal && ( +
setShowEditQuotaModal(false)}> +
e.stopPropagation()}> +
+

Edit Quota

+ +
+
+
+ +
+ setNewQuota(Number(e.target.value))} + style={{ flex: 1 }} + /> + setNewQuota(Number(e.target.value))} + style={{ width: '100px' }} + /> + MB +
+
+
+
+ + +
+
+
+ )} + + {/* Add Alias Modal */} + {showAddAliasModal && ( +
setShowAddAliasModal(false)}> +
e.stopPropagation()}> +
+

Add Alias

+ +
+
+
+ + setNewAlias({ ...newAlias, source: e.target.value })} + placeholder="info" + /> + + Local part (the domain will be appended automatically) + +
+
+ + setNewAlias({ ...newAlias, destination: e.target.value })} + placeholder="user@example.com" + /> + Full email address to deliver to +
+
+
+ + +
+
+
+ )} + + {/* Add Forwarding Modal */} + {showAddForwardingModal && ( +
setShowAddForwardingModal(false)}> +
e.stopPropagation()}> +
+

Add Forwarding Rule

+ +
+
+
+ + setNewForwarding({ ...newForwarding, destination: e.target.value })} + placeholder="forward@example.com" + /> +
+
+ +
+
+
+ + +
+
+
+ )} + + {/* Add DNS Provider Modal */} + {showAddProviderModal && ( +
setShowAddProviderModal(false)}> +
e.stopPropagation()}> +
+

Add DNS Provider

+ +
+
+
+ + setNewProvider({ ...newProvider, name: e.target.value })} + placeholder="My DNS Provider" + /> +
+
+ + +
+
+ + setNewProvider({ ...newProvider, api_key: e.target.value })} + placeholder="Enter API key" + /> +
+ {newProvider.type === 'route53' && ( +
+ + setNewProvider({ ...newProvider, api_secret: e.target.value })} + placeholder="Enter API secret" + /> +
+ )} + {newProvider.type === 'cloudflare' && ( +
+ + setNewProvider({ ...newProvider, api_email: e.target.value })} + placeholder="email@example.com" + /> + Required if using Global API Key (not API Token) +
+ )} +
+
+ + +
+
+
+ )} + + {/* Confirm Dialog */} + {confirmDialog && ( + + )} +
+ ); +} + +export default Email; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index ccb6979..c584843 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -2905,6 +2905,168 @@ class ApiService { const baseUrl = this.baseUrl.replace('/api/v1', ''); return `${baseUrl}/api/servers/agent/download/${os}/${arch}`; } + + // ── Email Server ── + + async getEmailStatus() { + return this.request('/email/status'); + } + + async installEmailServer(data = {}) { + return this.request('/email/install', { method: 'POST', body: JSON.stringify(data) }); + } + + async controlEmailService(component, action) { + return this.request(`/email/service/${component}/${action}`, { method: 'POST' }); + } + + // Email Domains + async getEmailDomains() { + return this.request('/email/domains'); + } + + async addEmailDomain(data) { + return this.request('/email/domains', { method: 'POST', body: JSON.stringify(data) }); + } + + async getEmailDomain(domainId) { + return this.request(`/email/domains/${domainId}`); + } + + async deleteEmailDomain(domainId) { + return this.request(`/email/domains/${domainId}`, { method: 'DELETE' }); + } + + async verifyEmailDNS(domainId) { + return this.request(`/email/domains/${domainId}/verify-dns`, { method: 'POST' }); + } + + async deployEmailDNS(domainId) { + return this.request(`/email/domains/${domainId}/deploy-dns`, { method: 'POST' }); + } + + // Email Accounts + async getEmailAccounts(domainId) { + return this.request(`/email/domains/${domainId}/accounts`); + } + + async createEmailAccount(domainId, data) { + return this.request(`/email/domains/${domainId}/accounts`, { method: 'POST', body: JSON.stringify(data) }); + } + + async getEmailAccount(accountId) { + return this.request(`/email/accounts/${accountId}`); + } + + async updateEmailAccount(accountId, data) { + return this.request(`/email/accounts/${accountId}`, { method: 'PUT', body: JSON.stringify(data) }); + } + + async deleteEmailAccount(accountId) { + return this.request(`/email/accounts/${accountId}`, { method: 'DELETE' }); + } + + async changeEmailPassword(accountId, password) { + return this.request(`/email/accounts/${accountId}/password`, { method: 'POST', body: JSON.stringify({ password }) }); + } + + // Email Aliases + async getEmailAliases(domainId) { + return this.request(`/email/domains/${domainId}/aliases`); + } + + async createEmailAlias(domainId, data) { + return this.request(`/email/domains/${domainId}/aliases`, { method: 'POST', body: JSON.stringify(data) }); + } + + async deleteEmailAlias(aliasId) { + return this.request(`/email/aliases/${aliasId}`, { method: 'DELETE' }); + } + + // Email Forwarding + async getEmailForwarding(accountId) { + return this.request(`/email/accounts/${accountId}/forwarding`); + } + + async createEmailForwarding(accountId, data) { + return this.request(`/email/accounts/${accountId}/forwarding`, { method: 'POST', body: JSON.stringify(data) }); + } + + async updateEmailForwarding(ruleId, data) { + return this.request(`/email/forwarding/${ruleId}`, { method: 'PUT', body: JSON.stringify(data) }); + } + + async deleteEmailForwarding(ruleId) { + return this.request(`/email/forwarding/${ruleId}`, { method: 'DELETE' }); + } + + // DNS Providers + async getEmailDNSProviders() { + return this.request('/email/dns-providers'); + } + + async addEmailDNSProvider(data) { + return this.request('/email/dns-providers', { method: 'POST', body: JSON.stringify(data) }); + } + + async deleteEmailDNSProvider(providerId) { + return this.request(`/email/dns-providers/${providerId}`, { method: 'DELETE' }); + } + + async testEmailDNSProvider(providerId) { + return this.request(`/email/dns-providers/${providerId}/test`, { method: 'POST' }); + } + + async getEmailDNSZones(providerId) { + return this.request(`/email/dns-providers/${providerId}/zones`); + } + + // SpamAssassin + async getSpamConfig() { + return this.request('/email/spam/config'); + } + + async updateSpamConfig(data) { + return this.request('/email/spam/config', { method: 'PUT', body: JSON.stringify(data) }); + } + + async updateSpamRules() { + return this.request('/email/spam/update-rules', { method: 'POST' }); + } + + // Roundcube Webmail + async getWebmailStatus() { + return this.request('/email/webmail/status'); + } + + async installWebmail(data = {}) { + return this.request('/email/webmail/install', { method: 'POST', body: JSON.stringify(data) }); + } + + async controlWebmail(action) { + return this.request(`/email/webmail/service/${action}`, { method: 'POST' }); + } + + async configureWebmailProxy(domain) { + return this.request('/email/webmail/configure-proxy', { method: 'POST', body: JSON.stringify({ domain }) }); + } + + // Mail Queue & Logs + async getMailQueue() { + return this.request('/email/queue'); + } + + async flushMailQueue() { + return this.request('/email/queue/flush', { method: 'POST' }); + } + + async deleteMailQueueItem(queueId) { + return this.request(`/email/queue/${queueId}`, { method: 'DELETE' }); + } + + async getMailLogs(lines = 100) { + return this.request(`/email/logs?lines=${lines}`); + } } export const api = new ApiService(); diff --git a/frontend/src/styles/main.less b/frontend/src/styles/main.less index 9ba196c..319f0e8 100644 --- a/frontend/src/styles/main.less +++ b/frontend/src/styles/main.less @@ -67,6 +67,7 @@ @import 'pages/_settings'; @import 'pages/_file-manager'; @import 'pages/_ftp-server'; +@import 'pages/_email'; @import 'pages/_firewall'; @import 'pages/_cron'; @import 'pages/_security'; diff --git a/frontend/src/styles/pages/_email.less b/frontend/src/styles/pages/_email.less new file mode 100644 index 0000000..c935870 --- /dev/null +++ b/frontend/src/styles/pages/_email.less @@ -0,0 +1,460 @@ +// Email Server Page Styles + +.email-server { + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; + + .page-header-content { + h1 { + font-size: 24px; + font-weight: 600; + margin: 0 0 4px 0; + } + + .page-description { + color: var(--text-secondary); + margin: 0; + } + } + + .page-header-actions { + display: flex; + gap: 8px; + } + } + + .page-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + } + + .empty-state-large { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 24px; + background: var(--card-bg); + border-radius: 12px; + text-align: center; + + .icon { + font-size: 64px; + color: var(--text-tertiary); + margin-bottom: 24px; + } + + h2 { + margin: 0 0 12px 0; + font-size: 24px; + font-weight: 600; + } + + p { + margin: 0 0 24px 0; + color: var(--text-secondary); + max-width: 400px; + } + } + + // Tab navigation + .tabs-nav { + display: flex; + gap: 4px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0; + overflow-x: auto; + + .tab-btn { + padding: 10px 16px; + border: none; + background: none; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + transition: all 0.2s; + + &:hover { + color: var(--text-primary); + } + + &.active { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); + } + } + } + + // Status cards grid + .status-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; + } + + .status-card { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--border-color); + + .status-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + flex-shrink: 0; + + &.running { background: rgba(16, 185, 129, 0.1); color: #10b981; } + &.stopped { background: rgba(239, 68, 68, 0.1); color: #ef4444; } + &.not-installed { background: rgba(156, 163, 175, 0.1); color: #9ca3af; } + } + + .status-info { + flex: 1; + min-width: 0; + + .status-name { + font-weight: 600; + font-size: 14px; + margin: 0 0 2px 0; + } + + .status-detail { + font-size: 12px; + color: var(--text-secondary); + margin: 0; + } + } + + .status-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + } + } + + // Stats row + .stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 24px; + + .stat-card { + padding: 16px 20px; + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--border-color); + text-align: center; + + .stat-value { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + } + + .stat-label { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; + } + } + } + + // Domain list + .domain-list, .account-list, .alias-list, .forwarding-list, .provider-list, .queue-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .domain-item, .account-item, .alias-item, .forwarding-item, .provider-item, .queue-item { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 20px; + background: var(--card-bg); + border-radius: 8px; + border: 1px solid var(--border-color); + transition: border-color 0.2s; + + &:hover { + border-color: var(--accent-primary); + } + + .item-info { + flex: 1; + min-width: 0; + + h4 { + margin: 0 0 4px 0; + font-size: 14px; + font-weight: 600; + } + + .item-meta { + font-size: 12px; + color: var(--text-secondary); + display: flex; + gap: 12px; + flex-wrap: wrap; + } + } + + .item-badges { + display: flex; + gap: 6px; + flex-shrink: 0; + } + + .item-actions { + display: flex; + gap: 6px; + flex-shrink: 0; + } + } + + // DNS status badges + .dns-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + + &.valid { + background: rgba(16, 185, 129, 0.1); + color: #10b981; + } + + &.invalid { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + } + + &.unknown { + background: rgba(156, 163, 175, 0.1); + color: #9ca3af; + } + } + + // Quota bar + .quota-bar { + width: 120px; + height: 6px; + background: var(--bg-hover); + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; + + .quota-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s; + + &.low { background: #10b981; } + &.medium { background: #f59e0b; } + &.high { background: #ef4444; } + } + } + + .quota-text { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + } + + // DNS records display + .dns-records { + display: flex; + flex-direction: column; + gap: 16px; + + .dns-record-card { + padding: 16px; + background: var(--bg-hover); + border-radius: 8px; + border: 1px solid var(--border-color); + + .dns-record-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + h4 { + margin: 0; + font-size: 13px; + font-weight: 600; + } + } + + .dns-record-name { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; + } + + .dns-record-value { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 12px; + background: var(--card-bg); + padding: 10px 12px; + border-radius: 6px; + word-break: break-all; + line-height: 1.5; + border: 1px solid var(--border-color); + } + } + } + + // Spam config form + .spam-config-form { + max-width: 600px; + + .config-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid var(--border-color); + + &:last-child { + border-bottom: none; + } + + .config-label { + font-size: 14px; + font-weight: 500; + + .config-help { + font-size: 12px; + color: var(--text-secondary); + font-weight: 400; + display: block; + margin-top: 2px; + } + } + } + } + + // Toggle switch + .toggle { + position: relative; + width: 44px; + height: 24px; + border-radius: 12px; + background: var(--bg-hover); + border: 1px solid var(--border-color); + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; + + &.active { + background: var(--accent-primary); + border-color: var(--accent-primary); + + &::after { + transform: translateX(20px); + } + } + + &::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: white; + transition: transform 0.2s; + } + } + + // Logs display + .logs-container { + background: #1a1a2e; + color: #e0e0e0; + padding: 16px; + border-radius: 8px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 12px; + line-height: 1.6; + max-height: 500px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + } + + // Section headers inside cards + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + } + } + + // Two column layout + .two-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } + + // Copy button + .copy-btn { + padding: 4px 8px; + font-size: 11px; + background: var(--bg-hover); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + color: var(--text-secondary); + transition: all 0.2s; + + &:hover { + background: var(--accent-primary); + color: white; + border-color: var(--accent-primary); + } + } + + // Arrow for alias display + .alias-arrow { + color: var(--text-tertiary); + font-size: 14px; + flex-shrink: 0; + } +} diff --git a/scripts/dev/start.sh b/scripts/dev/start.sh index 3415f44..baaf474 100755 --- a/scripts/dev/start.sh +++ b/scripts/dev/start.sh @@ -10,8 +10,8 @@ echo " Frontend: http://localhost:5274" echo "" cd "$PROJECT_ROOT/backend" -source venv/bin/activate -python run.py & +# Use venv's Python directly so we don't rely on PATH or 'source' (works with sh/bash) +"$PROJECT_ROOT/backend/venv/bin/python" run.py & BACKEND_PID=$! sleep 2