From ed3d28d34e8ec085839658908c236113744b956b Mon Sep 17 00:00:00 2001 From: nahakubuilde Date: Sat, 7 Jun 2025 14:43:00 +0100 Subject: [PATCH] DKIM key management front end - ok --- README.md | 5 +- ...200580bbd3_add_replaced_at_to_dkim_keys.py | 28 ++ email_server/cli_tools.py | 257 ----------- email_server/dkim_manager.py | 53 ++- email_server/models.py | 1 + email_server/server_runner.py | 7 +- email_server/server_web_ui/README.md | 329 -------------- email_server/server_web_ui/requirements.txt | 7 - email_server/server_web_ui/routes.py | 259 +++++++++-- .../server_web_ui/templates/base.html | 167 ++++++-- .../server_web_ui/templates/dkim.html | 403 ++++++++++++++++-- .../server_web_ui/templates/edit_dkim.html | 200 +++++---- .../server_web_ui/templates/edit_domain.html | 4 +- email_server/settings_loader.py | 2 + tests/bash_send_email.sh | 11 +- tests/general_cli_usage.md | 228 ++++------ tests/run_tests_manually.md | 18 +- 17 files changed, 1030 insertions(+), 949 deletions(-) create mode 100644 alembic/versions/7f200580bbd3_add_replaced_at_to_dkim_keys.py delete mode 100644 email_server/cli_tools.py delete mode 100644 email_server/server_web_ui/README.md delete mode 100644 email_server/server_web_ui/requirements.txt diff --git a/README.md b/README.md index 20a7c09..14d0b40 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # PyMTA-server Python Email server for sending emails directly to recipient ( no email Relay) - +```bash +# Testing +.venv/bin/python app.py --web-only --debug +``` ## Plan: - make full python MTA server with front end to allow sending any email - include DKIM diff --git a/alembic/versions/7f200580bbd3_add_replaced_at_to_dkim_keys.py b/alembic/versions/7f200580bbd3_add_replaced_at_to_dkim_keys.py new file mode 100644 index 0000000..e087511 --- /dev/null +++ b/alembic/versions/7f200580bbd3_add_replaced_at_to_dkim_keys.py @@ -0,0 +1,28 @@ +"""add_replaced_at_to_dkim_keys + +Revision ID: 7f200580bbd3 +Revises: d02f993649e8 +Create Date: 2025-06-07 12:48:07.930008 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7f200580bbd3' +down_revision: Union[str, None] = 'd02f993649e8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add replaced_at field to DKIM keys table.""" + op.add_column('esrv_dkim_keys', sa.Column('replaced_at', sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + """Remove replaced_at field from DKIM keys table.""" + op.drop_column('esrv_dkim_keys', 'replaced_at') diff --git a/email_server/cli_tools.py b/email_server/cli_tools.py deleted file mode 100644 index 6e98954..0000000 --- a/email_server/cli_tools.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -Command-line tools for managing the SMTP server. -""" - -import argparse -from email_server.models import Session, Domain, User, WhitelistedIP, hash_password, create_tables, CustomHeader -from email_server.dkim_manager import DKIMManager -from email_server.tool_box import get_logger - -logger = get_logger() - -def add_domain(domain_name): - """Add a new domain to the database.""" - session = Session() - try: - existing = session.query(Domain).filter_by(domain_name=domain_name).first() - if existing: - print(f"Domain {domain_name} already exists") - return False - - domain = Domain(domain_name=domain_name) - session.add(domain) - session.commit() - print(f"Added domain: {domain_name}") - return True - except Exception as e: - session.rollback() - print(f"Error adding domain: {e}") - return False - finally: - session.close() - -def add_user(email, password, domain_name): - """Add a new user to the database.""" - session = Session() - try: - domain = session.query(Domain).filter_by(domain_name=domain_name).first() - if not domain: - print(f"Domain {domain_name} not found") - return False - - existing = session.query(User).filter_by(email=email).first() - if existing: - print(f"User {email} already exists") - return False - - user = User( - email=email, - password_hash=hash_password(password), - domain_id=domain.id - ) - session.add(user) - session.commit() - print(f"Added user: {email}") - return True - except Exception as e: - session.rollback() - print(f"Error adding user: {e}") - return False - finally: - session.close() - -def add_whitelisted_ip(ip_address, domain_name): - """Add an IP to the whitelist for a domain.""" - session = Session() - try: - domain = session.query(Domain).filter_by(domain_name=domain_name).first() - if not domain: - print(f"Domain {domain_name} not found") - return False - - existing = session.query(WhitelistedIP).filter_by(ip_address=ip_address).first() - if existing: - print(f"IP {ip_address} already whitelisted") - return False - - whitelist = WhitelistedIP( - ip_address=ip_address, - domain_id=domain.id - ) - session.add(whitelist) - session.commit() - print(f"Added whitelisted IP: {ip_address} for domain {domain_name}") - return True - except Exception as e: - session.rollback() - print(f"Error adding whitelisted IP: {e}") - return False - finally: - session.close() - -def generate_dkim_key(domain_name): - """Generate DKIM key for a domain.""" - dkim_manager = DKIMManager() - if (dkim_manager.generate_dkim_keypair(domain_name)): - print(f"Generated DKIM key for domain: {domain_name}") - - # Show DNS record - dns_record = dkim_manager.get_dkim_public_key_record(domain_name) - if dns_record: - print("\nAdd this DNS TXT record:") - print(f"Name: {dns_record['name']}") - print(f"Value: {dns_record['value']}") - return True - else: - print(f"Failed to generate DKIM key for domain: {domain_name}") - return False - -def list_dkim_keys(): - """List all DKIM keys.""" - dkim_manager = DKIMManager() - keys = dkim_manager.list_dkim_keys() - - if not keys: - print("No DKIM keys found") - return - - print("DKIM Keys:") - print("-" * 60) - for key in keys: - status = "ACTIVE" if key['active'] else "INACTIVE" - print(f"Domain: {key['domain']}") - print(f"Selector: {key['selector']}") - print(f"Status: {status}") - print(f"Created: {key['created_at']}") - print("-" * 60) - -def show_dns_records(): - """Show DNS records for all domains.""" - dkim_manager = DKIMManager() - session = Session() - try: - domains = session.query(Domain).all() - if not domains: - print("No domains found") - return - - print("DNS Records for DKIM:") - print("=" * 80) - - for domain in domains: - dns_record = dkim_manager.get_dkim_public_key_record(domain.domain_name) - if dns_record: - print(f"\nDomain: {domain.domain_name}") - print(f"Record Name: {dns_record['name']}") - print(f"Record Type: {dns_record['type']}") - print(f"Record Value: {dns_record['value']}") - print("-" * 80) - finally: - session.close() - -def add_custom_header(domain_name: str, header_name: str, header_value: str, is_active: bool = True) -> bool: - """Add a custom header for a domain. - - Args: - domain_name (str): The domain name. - header_name (str): The header name. - header_value (str): The header value. - is_active (bool): Whether the header is active. - - Returns: - bool: True if added, False otherwise. - """ - session = Session() - try: - domain = session.query(Domain).filter_by(domain_name=domain_name).first() - if not domain: - print(f"Domain {domain_name} not found") - return False - custom_header = CustomHeader( - domain_id=domain.id, - header_name=header_name, - header_value=header_value, - is_active=is_active - ) - session.add(custom_header) - session.commit() - print(f"Added custom header '{header_name}: {header_value}' for domain {domain_name}") - return True - except Exception as e: - session.rollback() - print(f"Error adding custom header: {e}") - return False - finally: - session.close() - -def main(): - """Main CLI function.""" - parser = argparse.ArgumentParser(description="SMTP Server Management Tool") - subparsers = parser.add_subparsers(dest='command', help='Available commands') - - # Initialize command - init_parser = subparsers.add_parser('init', help='Initialize database') - - # Domain commands - domain_parser = subparsers.add_parser('add-domain', help='Add a domain') - domain_parser.add_argument('domain', help='Domain name') - domain_parser.add_argument('--no-auth', action='store_true', help='Domain does not require authentication') - - # User commands - user_parser = subparsers.add_parser('add-user', help='Add a user') - user_parser.add_argument('email', help='User email') - user_parser.add_argument('password', help='User password') - user_parser.add_argument('domain', help='Domain name') - - # IP whitelist commands - ip_parser = subparsers.add_parser('add-ip', help='Add whitelisted IP') - ip_parser.add_argument('ip', help='IP address') - ip_parser.add_argument('domain', help='Domain name') - - # DKIM commands - dkim_parser = subparsers.add_parser('generate-dkim', help='Generate DKIM key for domain') - dkim_parser.add_argument('domain', help='Domain name') - - list_dkim_parser = subparsers.add_parser('list-dkim', help='List DKIM keys') - - dns_parser = subparsers.add_parser('show-dns', help='Show DNS records for DKIM') - - custom_header_parser = subparsers.add_parser('add-custom-header', help='Add a custom header for a domain') - custom_header_parser.add_argument('domain', help='Domain name') - custom_header_parser.add_argument('header_name', help='Header name') - custom_header_parser.add_argument('header_value', help='Header value') - custom_header_parser.add_argument('--inactive', action='store_true', help='Add as inactive (disabled)') - - args = parser.parse_args() - - if not args.command: - parser.print_help() - return - - if args.command == 'init': - create_tables() - print("Database tables created successfully") - - elif args.command == 'add-domain': - add_domain(args.domain) - - elif args.command == 'add-user': - add_user(args.email, args.password, args.domain) - - elif args.command == 'add-ip': - add_whitelisted_ip(args.ip, args.domain) - - elif args.command == 'generate-dkim': - generate_dkim_key(args.domain) - - elif args.command == 'list-dkim': - list_dkim_keys() - - elif args.command == 'show-dns': - show_dns_records() - - elif args.command == 'add-custom-header': - add_custom_header(args.domain, args.header_name, args.header_value, not args.inactive) - -if __name__ == '__main__': - main() diff --git a/email_server/dkim_manager.py b/email_server/dkim_manager.py index e6f777d..519a346 100644 --- a/email_server/dkim_manager.py +++ b/email_server/dkim_manager.py @@ -32,8 +32,14 @@ class DKIMManager: """Generate a random DKIM selector name (8-12 chars).""" return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length)) - def generate_dkim_keypair(self, domain_name, selector: str = None): - """Generate DKIM key pair for a domain, optionally with a custom selector.""" + def generate_dkim_keypair(self, domain_name, selector: str = None, force_new_key: bool = False): + """Generate DKIM key pair for a domain, optionally with a custom selector. + + Args: + domain_name: The domain to generate the key for + selector: Custom selector name. If None, generates random one + force_new_key: If True, always create a new key even if selector exists + """ session = Session() try: # Check if domain exists @@ -45,11 +51,42 @@ class DKIMManager: # Use provided selector or instance selector use_selector = selector or self.selector - # Check if DKIM key with this selector already exists - existing_key = session.query(DKIMKey).filter_by(domain_id=domain.id, selector=use_selector, is_active=True).first() - if existing_key: - logger.debug(f"DKIM key already exists for domain {domain_name} and selector {use_selector}") - return True + # Ensure only one active DKIM key per domain - mark existing keys as replaced + existing_active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all() + for existing_key in existing_active_keys: + existing_key.is_active = False + existing_key.replaced_at = datetime.now() + logger.debug(f"Marked DKIM key as replaced for domain {domain_name} selector {existing_key.selector}") + + # Check if we're reusing an existing selector - if so, reactivate instead of creating new + # Skip this check if force_new_key is True (for regeneration) + if not force_new_key: + existing_key_with_selector = session.query(DKIMKey).filter_by( + domain_id=domain.id, + selector=use_selector + ).first() + + if existing_key_with_selector and not existing_key_with_selector.is_active: + # Before re-activating, ensure no other DKIM is active for this domain + other_active_keys = session.query(DKIMKey).filter( + DKIMKey.domain_id == domain.id, + DKIMKey.is_active == True, + DKIMKey.id != existing_key_with_selector.id + ).all() + for key in other_active_keys: + key.is_active = False + key.replaced_at = datetime.now() + logger.debug(f"Deactivated other active DKIM key for domain {domain_name} selector {key.selector}") + # Reactivate existing key with same selector, clear replaced_at timestamp + existing_key_with_selector.is_active = True + existing_key_with_selector.replaced_at = None + session.commit() + logger.debug(f"Reactivated existing DKIM key for domain {domain_name} selector {use_selector}") + return True + elif existing_key_with_selector and existing_key_with_selector.is_active: + # Key is already active (shouldn't happen due to deactivation above, but just in case) + logger.debug(f"DKIM key already active for domain {domain_name} and selector {use_selector}") + return True # Generate RSA key pair private_key = rsa.generate_private_key( @@ -118,7 +155,7 @@ class DKIMManager: def get_dkim_public_key_record(self, domain_name): """Get DKIM public key DNS record for a domain (active key only).""" dkim_key = self.get_active_dkim_key(domain_name) - if dkim_key: + if (dkim_key): public_key_lines = dkim_key.public_key.strip().split('\n') public_key_data = ''.join(public_key_lines[1:-1]) # Remove header/footer return { diff --git a/email_server/models.py b/email_server/models.py index 337544d..ae53d6d 100644 --- a/email_server/models.py +++ b/email_server/models.py @@ -176,6 +176,7 @@ class DKIMKey(Base): public_key = Column(Text, nullable=False) is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=func.now()) + replaced_at = Column(DateTime, nullable=True) # When this key was replaced by a new one def __repr__(self): return f"" diff --git a/email_server/server_runner.py b/email_server/server_runner.py index 1b6f51b..a051b6f 100644 --- a/email_server/server_runner.py +++ b/email_server/server_runner.py @@ -40,10 +40,10 @@ async def start_server(): logger.debug("Initializing database...") create_tables() - # Initialize DKIM manager and generate keys for domains without them + # Initialize DKIM manager (do not auto-generate keys for all domains) logger.debug("Initializing DKIM manager...") dkim_manager = DKIMManager() - dkim_manager.initialize_default_keys() + # dkim_manager.initialize_default_keys() # Removed: do not auto-generate DKIM keys for all domains # Add test data if needed from .models import Session, Domain, User, WhitelistedIP, hash_password @@ -118,8 +118,7 @@ async def start_server(): controller_tls.start() logger.debug(f' - Plain SMTP (IP whitelist): {BIND_IP}:{SMTP_PORT}') logger.debug(f' - STARTTLS SMTP (auth required): {BIND_IP}:{SMTP_TLS_PORT}') - logger.debug('Management commands:') - logger.debug(' python cli_tools.py --help') + logger.debug('Management available via web interface at: http://localhost:5000/email') try: await asyncio.Event().wait() diff --git a/email_server/server_web_ui/README.md b/email_server/server_web_ui/README.md deleted file mode 100644 index 6d7ad44..0000000 --- a/email_server/server_web_ui/README.md +++ /dev/null @@ -1,329 +0,0 @@ -# SMTP Server Management Frontend - -A comprehensive Flask-based web interface for managing SMTP server operations, including domain management, user authentication, DKIM configuration, IP whitelisting, and email monitoring. - -## Features - -### 🏠 Dashboard -- Server statistics and health monitoring -- Recent activity overview -- Quick access to all management sections -- Real-time server status indicators - -### 🌐 Domain Management -- Add, edit, and remove domains -- Domain status monitoring -- Bulk domain operations -- Domain-specific statistics - -### 👥 User Management -- Create and manage email users -- Password management -- Domain-based user organization -- User permission levels (regular user vs domain admin) -- Email validation and verification - -### 🔒 IP Whitelist Management -- Add/remove whitelisted IP addresses -- Support for single IPs and CIDR notation -- Current IP detection -- Domain-specific IP restrictions -- Security notes and best practices - -### 🔐 DKIM Management -- Generate and manage DKIM keys -- DNS record verification -- SPF record management -- Real-time DNS checking -- Copy-to-clipboard functionality for DNS records - -### ⚙️ Server Settings -- Configure all server parameters via web interface -- Real-time settings.ini file updates -- Settings validation and error checking -- Export/import configuration -- Sections: Server, Database, Logging, Relay, TLS, DKIM - -### 📊 Logs & Monitoring -- Email logs with detailed filtering -- Authentication logs -- Error tracking and debugging -- Real-time log updates -- Export log data -- Advanced search and filtering - -## Installation - -### Prerequisites - -- Python 3.9 or higher -- Flask 2.3+ -- Access to the SMTP server database -- Web browser with JavaScript enabled - -### Setup - -1. **Clone or navigate to the SMTP server directory:** - ```bash - cd /path/to/SMTP_Server - ``` - -2. **Install frontend dependencies:** - ```bash - # Create virtual environment if it doesn't exist - python3 -m venv .venv - - # Activate virtual environment - source .venv/bin/activate # Linux/macOS - # or - .venv\Scripts\activate # Windows - - # Install frontend requirements - .venv/bin/pip install -r email_frontend/requirements.txt - ``` - -3. **Initialize sample data (optional):** - ```bash - .venv/bin/python email_frontend/example_app.py --init-data - ``` - -4. **Run the example application:** - ```bash - .venv/bin/python email_frontend/example_app.py - ``` - -5. **Access the web interface:** - Open your browser and navigate to `http://127.0.0.1:5000` - -## Integration - -### Using as a Flask Blueprint - -The frontend is designed as a Flask Blueprint that can be integrated into existing Flask applications: - -```python -from flask import Flask -from email_frontend.blueprint import email_bp - -app = Flask(__name__) -app.config['SECRET_KEY'] = 'your-secret-key' -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///smtp_server.db' - -# Register the blueprint -app.register_blueprint(email_bp, url_prefix='/email') - -if __name__ == '__main__': - app.run(debug=True) -``` - -### Blueprint Routes - -The blueprint provides the following routes under the `/email` prefix: - -- `/` or `/dashboard` - Main dashboard -- `/domains` - Domain management -- `/domains/add` - Add new domain -- `/users` - User management -- `/users/add` - Add new user -- `/ips` - IP whitelist management -- `/ips/add` - Add whitelisted IP -- `/dkim` - DKIM management -- `/settings` - Server configuration -- `/logs` - Email and authentication logs - -### Configuration - -The frontend requires access to your SMTP server's database and configuration files: - -1. **Database Access:** Ensure the Flask app can connect to your SMTP server database -2. **Settings File:** The frontend reads from `settings.ini` in the project root -3. **Static Files:** CSS and JavaScript files are served from `email_frontend/static/` - -## Customization - -### Templates - -All templates extend `base.html` and use the dark Bootstrap theme. Key templates: - -- `base.html` - Base layout with navigation -- `sidebar_email.html` - Navigation sidebar -- `email/dashboard.html` - Main dashboard -- `email/*.html` - Feature-specific pages - -### Styling - -Custom CSS is located in `static/css/smtp-management.css` and includes: - -- Dark theme enhancements -- Custom form styling -- DNS record display formatting -- Log entry styling -- Responsive design tweaks - -### JavaScript - -Interactive features are implemented in `static/js/smtp-management.js`: - -- Form validation -- AJAX requests for DNS checking -- Copy-to-clipboard functionality -- Auto-refresh for logs -- Real-time IP detection - -## API Endpoints - -The frontend provides several AJAX endpoints for enhanced functionality: - -### DNS Verification -``` -POST /email/check-dns -Content-Type: application/json - -{ - "domain": "example.com", - "record_type": "TXT", - "expected_value": "v=DKIM1; k=rsa; p=..." -} -``` - -### Settings Updates -``` -POST /email/settings -Content-Type: application/x-www-form-urlencoded - -section=server&key=smtp_port&value=587 -``` - -### Log Filtering -``` -GET /email/logs?filter=email&page=1&per_page=50 -``` - -## Security Considerations - -### Authentication -- Implement proper authentication before deploying to production -- Use strong session keys -- Consider implementing role-based access control - -### Network Security -- Run behind a reverse proxy (nginx/Apache) in production -- Use HTTPS for all connections -- Implement rate limiting -- Restrict access to management interface - -### Data Protection -- Sanitize all user inputs -- Use parameterized queries -- Implement CSRF protection -- Regular security updates - -## Development - -### Project Structure -``` -email_frontend/ -├── __init__.py # Package initialization -├── blueprint.py # Main Flask Blueprint -├── example_app.py # Example Flask application -├── requirements.txt # Python dependencies -├── static/ # Static assets -│ ├── css/ -│ │ └── smtp-management.css -│ └── js/ -│ └── smtp-management.js -└── templates/ # Jinja2 templates - ├── base.html # Base template - ├── sidebar_email.html # Navigation sidebar - └── email/ # Feature templates - ├── dashboard.html - ├── domains.html - ├── users.html - ├── ips.html - ├── dkim.html - ├── settings.html - ├── logs.html - └── error.html -``` - -### Adding New Features - -1. **Add routes to `blueprint.py`** -2. **Create corresponding templates in `templates/email/`** -3. **Update navigation in `sidebar_email.html`** -4. **Add custom styling to `smtp-management.css`** -5. **Implement JavaScript interactions in `smtp-management.js`** - -### Testing - -Run the example application with debug mode: - -```bash -.venv/bin/python email_frontend/example_app.py --debug -``` - -Initialize test data: - -```bash -.venv/bin/python email_frontend/example_app.py --init-data -``` - -## Troubleshooting - -### Common Issues - -**Database Connection Errors:** -- Verify database file exists and is accessible -- Check file permissions -- Ensure SQLAlchemy is properly configured - -**Template Not Found Errors:** -- Verify templates are in the correct directory structure -- Check template inheritance and block names -- Ensure blueprint is registered with correct static folder - -**Static Files Not Loading:** -- Check Flask static file configuration -- Verify CSS/JS files exist in static directories -- Clear browser cache - -**DNS Verification Not Working:** -- Ensure `dnspython` is installed -- Check network connectivity -- Verify DNS server accessibility - -### Debug Mode - -Enable debug mode for detailed error information: - -```python -app.run(debug=True) -``` - -Or via command line: -```bash -.venv/bin/python email_frontend/example_app.py --debug -``` - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Test thoroughly -5. Submit a pull request - -## License - -This project is part of the SMTP Server suite. Please refer to the main project license. - -## Support - -For issues and questions: -1. Check the troubleshooting section -2. Review the example application -3. Create an issue in the project repository - ---- - -**Note:** This frontend is designed specifically for the SMTP Server project and requires the associated database models and configuration files to function properly. diff --git a/email_server/server_web_ui/requirements.txt b/email_server/server_web_ui/requirements.txt deleted file mode 100644 index ca9cfe3..0000000 --- a/email_server/server_web_ui/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -Flask -Flask-SQLAlchemy -Jinja2 -Werkzeug -dnspython -cryptography -requests diff --git a/email_server/server_web_ui/routes.py b/email_server/server_web_ui/routes.py index 24da34a..54e6801 100644 --- a/email_server/server_web_ui/routes.py +++ b/email_server/server_web_ui/routes.py @@ -20,6 +20,7 @@ import requests import dns.resolver import re from datetime import datetime +from datetime import datetime from typing import Optional, Dict, List, Tuple # Import email server modules @@ -46,15 +47,32 @@ email_bp = Blueprint('email', __name__, def get_public_ip() -> str: """Get the public IP address of the server.""" try: - response = requests.get('https://api.ipify.org', timeout=5) - return response.text.strip() + response1 = requests.get('https://ifconfig.me/ip', timeout=3, verify=False) + + ip = response1.text.strip() + if ip and ip != 'unknown': + return ip except Exception: try: # Fallback method - response = requests.get('https://httpbin.org/ip', timeout=5) - return response.json()['origin'].split(',')[0].strip() - except Exception: - return 'unknown' + response = requests.get('https://httpbin.org/ip', timeout=3, verify=False) + ip = response.json()['origin'].split(',')[0].strip() + if ip and ip != 'unknown': + return ip + except Exception as e: + pass + + # Use fallback from settings.ini if available + try: + settings = load_settings() + fallback_ip = settings.get('DKIM', 'SPF_SERVER_IP', fallback=None) + if fallback_ip and fallback_ip.strip() and fallback_ip != '""': + # Check if it's a valid IPv4 address (basic check) + parts = fallback_ip.split('.') + if len(parts) == 4 and all(part.isdigit() and 0 <= int(part) <= 255 for part in parts): + return fallback_ip.strip() + except Exception as e: + return {'success': False, 'message': f'DNS lookup error, If it continues, consider setting up public IP in settings - SPF_SERVER_IP. Details: {str(e)}'} def check_dns_record(domain: str, record_type: str, expected_value: str = None) -> Dict: """Check DNS record for a domain.""" @@ -94,10 +112,15 @@ def generate_spf_record(domain: str, public_ip: str, existing_spf: str = None) - parts = spf_clean.split() base_mechanisms = [part for part in parts[1:] if not part.startswith('ip4:') and part != 'all' and part != '-all' and part != '~all'] - # Add our server IP - our_ip = f"ip4:{public_ip}" - if our_ip not in base_mechanisms: - base_mechanisms.append(our_ip) + # Add our server IP if it's not unknown + if public_ip and public_ip != 'unknown': + our_ip = f"ip4:{public_ip}" + if our_ip not in base_mechanisms: + base_mechanisms.append(our_ip) + + # If no IP available, just use existing mechanisms + if not base_mechanisms and public_ip == 'unknown': + return existing_spf or 'v=spf1 ~all' # Construct SPF record spf_parts = ['v=spf1'] + base_mechanisms + ['~all'] @@ -712,14 +735,26 @@ def dkim_list(): """List all DKIM keys and DNS records.""" session = Session() try: - dkim_keys = session.query(DKIMKey, Domain).join(Domain, DKIMKey.domain_id == Domain.id).order_by(Domain.domain_name).all() + # Get active DKIM keys + active_dkim_keys = session.query(DKIMKey, Domain).join( + Domain, DKIMKey.domain_id == Domain.id + ).filter(DKIMKey.is_active == True).order_by(Domain.domain_name).all() + + # Get old/inactive DKIM keys (prioritize replaced keys over disabled ones) + old_dkim_keys = session.query(DKIMKey, Domain).join( + Domain, DKIMKey.domain_id == Domain.id + ).filter(DKIMKey.is_active == False).order_by( + Domain.domain_name, + DKIMKey.replaced_at.desc().nullslast(), # Replaced keys first, then disabled ones + DKIMKey.created_at.desc() + ).all() # Get public IP for SPF records public_ip = get_public_ip() - # Prepare DKIM data with DNS information - dkim_data = [] - for dkim_key, domain in dkim_keys: + # Prepare active DKIM data with DNS information + active_dkim_data = [] + for dkim_key, domain in active_dkim_keys: # Get DKIM DNS record dkim_manager = DKIMManager() dns_record = dkim_manager.get_dkim_public_key_record(domain.domain_name) @@ -736,7 +771,7 @@ def dkim_list(): # Generate recommended SPF recommended_spf = generate_spf_record(domain.domain_name, public_ip, existing_spf) - dkim_data.append({ + active_dkim_data.append({ 'dkim_key': dkim_key, 'domain': domain, 'dns_record': dns_record, @@ -745,7 +780,20 @@ def dkim_list(): 'public_ip': public_ip }) - return render_template('dkim.html', dkim_data=dkim_data) + # Prepare old DKIM data with status information + old_dkim_data = [] + for dkim_key, domain in old_dkim_keys: + old_dkim_data.append({ + 'dkim_key': dkim_key, + 'domain': domain, + 'public_ip': public_ip, + 'is_replaced': dkim_key.replaced_at is not None, + 'status_text': 'Replaced' if dkim_key.replaced_at else 'Disabled' + }) + + return render_template('dkim.html', + dkim_data=active_dkim_data, + old_dkim_data=old_dkim_data) finally: session.close() @@ -756,21 +804,116 @@ def regenerate_dkim(domain_id: int): try: domain = session.query(Domain).get(domain_id) if not domain: + if request.headers.get('Content-Type') == 'application/json': + return jsonify({'success': False, 'message': 'Domain not found'}) flash('Domain not found', 'error') return redirect(url_for('email.dkim_list')) - # Deactivate existing keys + # Get the current active DKIM key's selector to preserve it existing_keys = session.query(DKIMKey).filter_by(domain_id=domain_id, is_active=True).all() + current_selector = None + if existing_keys: + # Use the selector from the first active key (there should typically be only one) + current_selector = existing_keys[0].selector + + # Mark existing keys as replaced for key in existing_keys: key.is_active = False + key.replaced_at = datetime.now() # Mark when this key was replaced - # Generate new DKIM key + # Generate new DKIM key preserving the existing selector dkim_manager = DKIMManager() - if dkim_manager.generate_dkim_keypair(domain.domain_name): + if dkim_manager.generate_dkim_keypair(domain.domain_name, selector=current_selector, force_new_key=True): session.commit() + + # Get the new key data for AJAX response + new_key = session.query(DKIMKey).filter_by( + domain_id=domain_id, is_active=True + ).order_by(DKIMKey.created_at.desc()).first() + + if not new_key: + session.rollback() + if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': f'Failed to create new DKIM key for {domain.domain_name}'}) + flash(f'Failed to create new DKIM key for {domain.domain_name}', 'error') + return redirect(url_for('email.dkim_list')) + + if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + # Get updated DNS record for the new key + dns_record = dkim_manager.get_dkim_public_key_record(domain.domain_name) + public_ip = get_public_ip() + + # Check existing SPF record + spf_check = check_dns_record(domain.domain_name, 'TXT') + existing_spf = None + if spf_check['success']: + for record in spf_check['records']: + if 'v=spf1' in record: + existing_spf = record + break + + recommended_spf = generate_spf_record(domain.domain_name, public_ip, existing_spf) + + # Get replaced keys for the Old DKIM section update + old_keys = session.query(DKIMKey, Domain).join( + Domain, DKIMKey.domain_id == Domain.id + ).filter( + DKIMKey.domain_id == domain_id, + DKIMKey.is_active == False + ).order_by(DKIMKey.created_at.desc()).all() + + old_dkim_data = [] + for old_key, old_domain in old_keys: + status_text = "Replaced" if old_key.replaced_at else "Disabled" + old_dkim_data.append({ + 'dkim_key': { + 'id': old_key.id, + 'selector': old_key.selector, + 'created_at': old_key.created_at.strftime('%Y-%m-%d %H:%M'), + 'replaced_at': old_key.replaced_at.strftime('%Y-%m-%d %H:%M') if old_key.replaced_at else None, + 'is_active': old_key.is_active + }, + 'domain': { + 'id': old_domain.id, + 'domain_name': old_domain.domain_name + }, + 'status_text': status_text, + 'public_ip': public_ip + }) + + # Additional null check for new_key before accessing its attributes + if not new_key: + logger.error(f"new_key is None after generation for domain {domain.domain_name}") + return jsonify({'success': False, 'message': f'Failed to retrieve new DKIM key for {domain.domain_name}'}) + + return jsonify({ + 'success': True, + 'message': f'DKIM key regenerated for {domain.domain_name}', + 'new_key': { + 'id': new_key.id, + 'selector': new_key.selector, + 'created_at': new_key.created_at.strftime('%Y-%m-%d %H:%M'), + 'is_active': new_key.is_active + }, + 'dns_record': { + 'name': dns_record['name'] if dns_record else '', + 'value': dns_record['value'] if dns_record else '' + }, + 'existing_spf': existing_spf, + 'recommended_spf': recommended_spf, + 'public_ip': public_ip, + 'domain': { + 'id': domain.id, + 'domain_name': domain.domain_name + }, + 'old_dkim_data': old_dkim_data + }) + flash(f'DKIM key regenerated for {domain.domain_name}', 'success') else: session.rollback() + if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': f'Failed to regenerate DKIM key for {domain.domain_name}'}) flash(f'Failed to regenerate DKIM key for {domain.domain_name}', 'error') return redirect(url_for('email.dkim_list')) @@ -778,6 +921,8 @@ def regenerate_dkim(domain_id: int): except Exception as e: session.rollback() logger.error(f"Error regenerating DKIM: {e}") + if request.headers.get('Content-Type') == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'message': f'Error regenerating DKIM: {str(e)}'}) flash(f'Error regenerating DKIM: {str(e)}', 'error') return redirect(url_for('email.dkim_list')) finally: @@ -844,16 +989,25 @@ def toggle_dkim(dkim_id: int): if not dkim_key: flash('DKIM key not found', 'error') return redirect(url_for('email.dkim_list')) - domain = session.query(Domain).get(dkim_key.domain_id) old_status = dkim_key.is_active + if not old_status: + # About to activate this key, so deactivate any other active DKIM for this domain + other_active_keys = session.query(DKIMKey).filter( + DKIMKey.domain_id == dkim_key.domain_id, + DKIMKey.is_active == True, + DKIMKey.id != dkim_id + ).all() + for key in other_active_keys: + key.is_active = False + key.replaced_at = datetime.now() dkim_key.is_active = not old_status + if dkim_key.is_active: + dkim_key.replaced_at = None session.commit() - status_text = "enabled" if dkim_key.is_active else "disabled" flash(f'DKIM key for {domain.domain_name} (selector: {dkim_key.selector}) has been {status_text}', 'success') return redirect(url_for('email.dkim_list')) - except Exception as e: session.rollback() logger.error(f"Error toggling DKIM status: {e}") @@ -895,15 +1049,27 @@ def check_dkim_dns(): """Check DKIM DNS record via AJAX.""" domain = request.form.get('domain') selector = request.form.get('selector') - expected_value = request.form.get('expected_value') - if not all([domain, selector, expected_value]): - return jsonify({'success': False, 'message': 'Missing parameters'}) + if not all([domain, selector]): + return jsonify({'success': False, 'message': 'Missing domain or selector parameters'}) - dns_name = f"{selector}._domainkey.{domain}" - result = check_dns_record(dns_name, 'TXT', expected_value) - - return jsonify(result) + # Get the expected DKIM value from the DKIM manager + try: + dkim_manager = DKIMManager() + dns_record = dkim_manager.get_dkim_public_key_record(domain) + + if not dns_record or not dns_record.get('value'): + return jsonify({'success': False, 'message': 'No DKIM key found for domain'}) + + expected_value = dns_record['value'] + + dns_name = f"{selector}._domainkey.{domain}" + result = check_dns_record(dns_name, 'TXT', expected_value) + + return jsonify(result) + except Exception as e: + logger.error(f"Error checking DKIM DNS: {e}") + return jsonify({'success': False, 'message': f'Error checking DKIM DNS: {str(e)}'}) @email_bp.route('/dkim/check_spf', methods=['POST']) def check_spf_dns(): @@ -1049,3 +1215,38 @@ def internal_error(error): error_code=500, error_message='Internal server error', current_time=datetime.now()), 500 + +@email_bp.route('/dkim/create', methods=['POST'], endpoint='create_dkim') +def create_dkim(): + """Create a new DKIM key for a domain, optionally with a custom selector.""" + from flask import request, jsonify + data = request.get_json() if request.is_json else request.form + domain_name = data.get('domain') + selector = data.get('selector', None) + session = Session() + try: + if not domain_name: + return jsonify({'success': False, 'message': 'Domain is required.'}), 400 + domain = session.query(Domain).filter_by(domain_name=domain_name).first() + if not domain: + return jsonify({'success': False, 'message': 'Domain not found.'}), 404 + # Deactivate any existing active DKIM key for this domain + active_keys = session.query(DKIMKey).filter_by(domain_id=domain.id, is_active=True).all() + for key in active_keys: + key.is_active = False + key.replaced_at = datetime.now() + # Create new DKIM key + dkim_manager = DKIMManager() + created = dkim_manager.generate_dkim_keypair(domain_name, selector=selector, force_new_key=True) + if created: + session.commit() + return jsonify({'success': True, 'message': f'DKIM key created for {domain_name}.'}) + else: + session.rollback() + return jsonify({'success': False, 'message': f'Failed to create DKIM key for {domain_name}.'}), 500 + except Exception as e: + session.rollback() + logger.error(f"Error creating DKIM: {e}") + return jsonify({'success': False, 'message': f'Error creating DKIM: {str(e)}'}), 500 + finally: + session.close() diff --git a/email_server/server_web_ui/templates/base.html b/email_server/server_web_ui/templates/base.html index d2916d3..60e385b 100644 --- a/email_server/server_web_ui/templates/base.html +++ b/email_server/server_web_ui/templates/base.html @@ -156,22 +156,24 @@ - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
-
- {% for category, message in messages %} - + {% endfor %} + {% endif %} + {% endwith %} +
@@ -180,6 +182,28 @@
+ + + @@ -197,22 +221,113 @@ setInterval(updateTime, 1000); updateTime(); // Initial call - // Auto-dismiss alerts after 5 seconds - setTimeout(function() { - const alerts = document.querySelectorAll('.alert:not(.alert-permanent)'); - alerts.forEach(function(alert) { - const bootstrapAlert = new bootstrap.Alert(alert); - bootstrapAlert.close(); + // Initialize toasts + document.addEventListener('DOMContentLoaded', function() { + const toastElements = document.querySelectorAll('.toast'); + toastElements.forEach(function(toastElement) { + const toast = new bootstrap.Toast(toastElement); + toast.show(); }); - }, 5000); + }); - // Confirmation dialogs for delete actions + // Function to show dynamic toasts + function showToast(message, type = 'info') { + const toastContainer = document.querySelector('.toast-container'); + const toastId = 'toast-' + Date.now(); + const iconMap = { + 'danger': 'exclamation-triangle', + 'success': 'check-circle', + 'warning': 'exclamation-triangle', + 'info': 'info-circle' + }; + + const toastHtml = ` + + `; + + toastContainer.insertAdjacentHTML('beforeend', toastHtml); + const newToast = new bootstrap.Toast(document.getElementById(toastId)); + newToast.show(); + + // Remove toast element after it's hidden + document.getElementById(toastId).addEventListener('hidden.bs.toast', function() { + this.remove(); + }); + } + + // Custom confirmation dialog to replace browser alerts + function showConfirmation(message, title = 'Confirm Action', confirmButtonText = 'Confirm', confirmButtonClass = 'btn-primary') { + return new Promise((resolve) => { + const modal = document.getElementById('confirmationModal'); + const modalTitle = document.getElementById('confirmationModalLabel'); + const modalBody = document.getElementById('confirmationModalBody'); + const confirmButton = document.getElementById('confirmationModalConfirm'); + + // Set content + modalTitle.innerHTML = `${title}`; + modalBody.textContent = message; + confirmButton.textContent = confirmButtonText; + + // Reset button classes and add new one + confirmButton.className = `btn ${confirmButtonClass}`; + + // Set up event handlers + const handleConfirm = () => { + resolve(true); + bootstrap.Modal.getInstance(modal).hide(); + cleanup(); + }; + + const handleCancel = () => { + resolve(false); + cleanup(); + }; + + const cleanup = () => { + confirmButton.removeEventListener('click', handleConfirm); + modal.removeEventListener('hidden.bs.modal', handleCancel); + }; + + confirmButton.addEventListener('click', handleConfirm); + modal.addEventListener('hidden.bs.modal', handleCancel, { once: true }); + + // Show modal + new bootstrap.Modal(modal).show(); + }); + } + + // Confirmation dialogs for delete actions with data-confirm attribute document.addEventListener('DOMContentLoaded', function() { const deleteButtons = document.querySelectorAll('[data-confirm]'); deleteButtons.forEach(function(button) { - button.addEventListener('click', function(e) { - if (!confirm(this.getAttribute('data-confirm'))) { - e.preventDefault(); + button.addEventListener('click', async function(e) { + e.preventDefault(); + + const confirmMessage = this.getAttribute('data-confirm'); + const confirmed = await showConfirmation( + confirmMessage, + 'Confirm Action', + 'Confirm', + 'btn-danger' + ); + + if (confirmed) { + // If it's a form button, submit the form + const form = this.closest('form'); + if (form) { + form.submit(); + } else if (this.href) { + // If it's a link, navigate to the URL + window.location.href = this.href; + } } }); }); diff --git a/email_server/server_web_ui/templates/dkim.html b/email_server/server_web_ui/templates/dkim.html index 2787a00..66873cb 100644 --- a/email_server/server_web_ui/templates/dkim.html +++ b/email_server/server_web_ui/templates/dkim.html @@ -34,6 +34,10 @@ DKIM Key Management
+
+ + + {% for item in dkim_data %}
-