scripts for setup, nginx and service, fixing issue with server shutdown when both are running
This commit is contained in:
@@ -3,6 +3,8 @@ Python Email server for sending emails directly to recipient ( no email Relay)
|
||||
```bash
|
||||
# Testing
|
||||
.venv/bin/python app.py --web-only --debug
|
||||
# Production:
|
||||
python app.py --smtp-only & gunicorn -w 4 -b 0.0.0.0:5000 app:flask_app
|
||||
```
|
||||
## Plan:
|
||||
- make full python MTA server with front end to allow sending any email
|
||||
|
||||
106
app.py
106
app.py
@@ -20,9 +20,11 @@ import threading
|
||||
import signal
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template, redirect, url_for, jsonify
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, redirect, url_for, jsonify, request
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
import subprocess
|
||||
|
||||
# Add the project root to Python path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -49,23 +51,7 @@ class SMTPServerApp:
|
||||
self.smtp_task = None
|
||||
self.loop = None
|
||||
self.shutdown_requested = False
|
||||
|
||||
# Setup signal handlers
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle shutdown signals gracefully"""
|
||||
logger.info(f"Received signal {signum}, initiating shutdown...")
|
||||
self.shutdown_requested = True
|
||||
if self.loop and self.loop.is_running():
|
||||
self.loop.call_soon_threadsafe(self._stop_smtp_server)
|
||||
|
||||
def _stop_smtp_server(self):
|
||||
"""Stop the SMTP server"""
|
||||
if self.smtp_task and not self.smtp_task.done():
|
||||
self.smtp_task.cancel()
|
||||
logger.info("SMTP server stopped")
|
||||
self.shutdown_event = None
|
||||
|
||||
def _get_absolute_database_url(self):
|
||||
"""Convert relative database URL to absolute path for Flask-SQLAlchemy"""
|
||||
@@ -129,7 +115,7 @@ class SMTPServerApp:
|
||||
"""Health check endpoint"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'timestamp': datetime.now(ZoneInfo('Europe/London')).isoformat(),
|
||||
'services': {
|
||||
'smtp_server': 'running' if self.smtp_task and not self.smtp_task.done() else 'stopped',
|
||||
'web_frontend': 'running'
|
||||
@@ -167,17 +153,23 @@ class SMTPServerApp:
|
||||
|
||||
@app.route('/api/server/restart', methods=['POST'])
|
||||
def restart_server():
|
||||
"""Restart the SMTP server (API endpoint)"""
|
||||
"""Restart the SMTP server (API endpoint) via systemd."""
|
||||
try:
|
||||
if self.smtp_task and not self.smtp_task.done():
|
||||
self._stop_smtp_server()
|
||||
|
||||
# Start SMTP server in a new task
|
||||
if self.loop:
|
||||
self.smtp_task = asyncio.create_task(start_server())
|
||||
return jsonify({'status': 'success', 'message': 'SMTP server restarted'})
|
||||
# Only allow from localhost for security
|
||||
if request.remote_addr not in ('127.0.0.1', '::1'):
|
||||
return jsonify({'status': 'error', 'message': 'Unauthorized'}), 403
|
||||
|
||||
# Restart the systemd service for SMTP (update service name as needed)
|
||||
result = subprocess.run(
|
||||
['systemctl', '--user', 'restart', 'pymta-smtp.service'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return jsonify({'status': 'success', 'message': 'SMTP server restart requested.'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Event loop not available'}), 500
|
||||
return jsonify({'status': 'error', 'message': f'Failed to restart: {result.stderr}'}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Error restarting server: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
@@ -227,8 +219,6 @@ class SMTPServerApp:
|
||||
# Add sample domains
|
||||
sample_domains = [
|
||||
'example.com',
|
||||
'testdomain.org',
|
||||
'mydomain.net'
|
||||
]
|
||||
|
||||
for domain_name in sample_domains:
|
||||
@@ -247,9 +237,7 @@ class SMTPServerApp:
|
||||
|
||||
# Add sample users
|
||||
sample_users = [
|
||||
('admin@example.com', 'example.com', 'admin123', True),
|
||||
('user@example.com', 'example.com', 'user123', False),
|
||||
('test@testdomain.org', 'testdomain.org', 'test123', False)
|
||||
('admin@example.com', 'example.com', 'admin123', False),
|
||||
]
|
||||
|
||||
for email, domain_name, password, can_send_as_domain in sample_users:
|
||||
@@ -269,8 +257,6 @@ class SMTPServerApp:
|
||||
# Add sample whitelisted IPs
|
||||
sample_ips = [
|
||||
('127.0.0.1', 'example.com'),
|
||||
('192.168.1.0/24', 'example.com'),
|
||||
('10.0.0.0/8', 'testdomain.org')
|
||||
]
|
||||
|
||||
for ip, domain_name in sample_ips:
|
||||
@@ -301,31 +287,49 @@ class SMTPServerApp:
|
||||
"""Start the SMTP server in async context"""
|
||||
try:
|
||||
logger.info("Starting SMTP server...")
|
||||
await start_server()
|
||||
await start_server(self.shutdown_event)
|
||||
except Exception as e:
|
||||
logger.error(f"SMTP server error: {e}")
|
||||
if not self.shutdown_requested:
|
||||
raise
|
||||
|
||||
def run_smtp_server(self):
|
||||
"""Run SMTP server in a separate thread"""
|
||||
try:
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
# Only register signal handlers if in the main thread
|
||||
if threading.current_thread() is threading.main_thread():
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
self.loop.add_signal_handler(sig, self.shutdown_event.set)
|
||||
except NotImplementedError:
|
||||
pass # Not available on Windows
|
||||
|
||||
self.smtp_task = self.loop.create_task(self.start_smtp_server())
|
||||
self.loop.run_until_complete(self.smtp_task)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("SMTP server task was cancelled")
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
logger.info("SMTP server task was cancelled or interrupted")
|
||||
except Exception as e:
|
||||
if not self.shutdown_requested:
|
||||
logger.error(f"SMTP server thread error: {e}")
|
||||
finally:
|
||||
if self.loop and self.loop.is_running():
|
||||
self.loop.stop()
|
||||
if self.loop:
|
||||
self.loop.close()
|
||||
if self.shutdown_requested:
|
||||
os._exit(0)
|
||||
|
||||
def run(self, smtp_only=False, web_only=False, debug=False, host='127.0.0.1', port=5000):
|
||||
"""Run the unified application"""
|
||||
# If running under Gunicorn, do not start Flask dev server
|
||||
if 'gunicorn' in os.environ.get('SERVER_SOFTWARE', '').lower():
|
||||
logger.info("Running under Gunicorn. Flask app will be served by Gunicorn WSGI server.")
|
||||
app = self.create_flask_app()
|
||||
return app
|
||||
|
||||
if web_only:
|
||||
# Run only Flask web frontend
|
||||
logger.info("Starting web frontend only...")
|
||||
@@ -369,8 +373,6 @@ class SMTPServerApp:
|
||||
logger.info("Application interrupted by user")
|
||||
finally:
|
||||
self.shutdown_requested = True
|
||||
if self.loop:
|
||||
self.loop.call_soon_threadsafe(self._stop_smtp_server)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -393,7 +395,7 @@ def main():
|
||||
# Initialize sample data if requested
|
||||
if args.init_data:
|
||||
logger.info("Initializing sample data...")
|
||||
app.init_sample_data()
|
||||
# app.init_sample_data() # For testing uncomment, adds sample domain
|
||||
logger.info("Sample data initialization complete")
|
||||
return
|
||||
|
||||
@@ -417,14 +419,8 @@ Services:
|
||||
• SMTP Server: {'Starting...' if not args.web_only else 'Disabled'}
|
||||
• Web Frontend: {'Starting...' if not args.smtp_only else 'Disabled'}
|
||||
|
||||
Available web routes:
|
||||
• / → Dashboard
|
||||
• /email/domains → Domain management
|
||||
• /email/users → User management
|
||||
• /email/ips → IP whitelist management
|
||||
• /email/dkim → DKIM management
|
||||
• /email/settings → Server settings
|
||||
• /email/logs → Server logs
|
||||
Available app web test routes:
|
||||
|
||||
• /health → Health check
|
||||
• /api/server/status → Server status API
|
||||
|
||||
@@ -451,15 +447,9 @@ Press Ctrl+C to stop the server
|
||||
|
||||
|
||||
# For Flask CLI: expose a create_app() factory at module level
|
||||
smtp_server_app_instance = SMTPServerApp()
|
||||
flask_app = SMTPServerApp().create_flask_app()
|
||||
|
||||
def create_app():
|
||||
"""Flask application factory for CLI and Flask-Migrate support.
|
||||
|
||||
Returns:
|
||||
Flask: The Flask application instance.
|
||||
"""
|
||||
return smtp_server_app_instance.create_flask_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -32,7 +32,7 @@ except RuntimeError:
|
||||
# No running loop, set debug when we create one
|
||||
pass
|
||||
|
||||
async def start_server():
|
||||
async def start_server(shutdown_event=None):
|
||||
"""Main server function."""
|
||||
logger.debug("Starting SMTP Server with DKIM support...")
|
||||
|
||||
@@ -121,9 +121,13 @@ async def start_server():
|
||||
logger.debug('Management available via web interface at: http://localhost:5000/email')
|
||||
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
if shutdown_event is not None:
|
||||
await shutdown_event.wait()
|
||||
else:
|
||||
await asyncio.Event().wait()
|
||||
except KeyboardInterrupt:
|
||||
logger.debug('Shutting down SMTP servers...')
|
||||
finally:
|
||||
controller_plain.stop()
|
||||
controller_tls.stop()
|
||||
logger.debug('SMTP servers stopped.')
|
||||
@@ -33,7 +33,7 @@ from email_server.models import (
|
||||
hash_password, create_tables, get_user_by_email, get_domain_by_name, get_whitelisted_ip
|
||||
)
|
||||
from email_server.dkim_manager import DKIMManager
|
||||
from email_server.settings_loader import load_settings, generate_settings_ini, SETTINGS_PATH
|
||||
from email_server.settings_loader import load_settings, SETTINGS_PATH
|
||||
from email_server.tool_box import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
@@ -42,7 +42,7 @@ logger = get_logger()
|
||||
email_bp = Blueprint('email', __name__,
|
||||
template_folder='templates',
|
||||
static_folder='static',
|
||||
url_prefix='/email')
|
||||
url_prefix='/pymta-manager')
|
||||
|
||||
def get_public_ip() -> str:
|
||||
"""Get the public IP address of the server."""
|
||||
@@ -102,29 +102,33 @@ def check_dns_record(domain: str, record_type: str, expected_value: str = None)
|
||||
return {'success': False, 'message': f'DNS lookup error: {str(e)}'}
|
||||
|
||||
def generate_spf_record(domain: str, public_ip: str, existing_spf: str = None) -> str:
|
||||
"""Generate SPF record including the current server IP."""
|
||||
base_mechanisms = []
|
||||
|
||||
"""Generate or update SPF record to include the current server IP."""
|
||||
if not public_ip or public_ip == 'unknown':
|
||||
return f'"{existing_spf or "v=spf1 ~all"}"'
|
||||
|
||||
our_ip = f"ip4:{public_ip}"
|
||||
|
||||
if existing_spf:
|
||||
# Parse existing SPF record
|
||||
spf_clean = existing_spf.replace('"', '').strip()
|
||||
if spf_clean.startswith('v=spf1'):
|
||||
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 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']
|
||||
return ' '.join(spf_parts)
|
||||
if not spf_clean.startswith('v=spf1'):
|
||||
spf_clean = f"v=spf1 {spf_clean}"
|
||||
|
||||
parts = spf_clean.split()
|
||||
if our_ip in parts:
|
||||
return f'Current SPF records includes already server ip {public_ip}'
|
||||
|
||||
# Find position of the final all mechanism (if present)
|
||||
all_mechanism_index = next((i for i, part in enumerate(parts) if part in ['-all', '~all', '?all', 'all']), None)
|
||||
|
||||
if all_mechanism_index is not None:
|
||||
new_parts = parts[:all_mechanism_index] + [our_ip] + parts[all_mechanism_index:]
|
||||
else:
|
||||
new_parts = parts + [our_ip, '~all']
|
||||
|
||||
return f'"{" ".join(new_parts)}"'
|
||||
else:
|
||||
# No existing SPF, create a new one
|
||||
return f'"v=spf1 {our_ip} ~all"'
|
||||
|
||||
# Dashboard and Main Routes
|
||||
@email_bp.route('/')
|
||||
|
||||
@@ -4,6 +4,30 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Current IP Detection -->
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-geo-alt me-2"></i>
|
||||
Your Current IP
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<div class="fw-bold font-monospace fs-5 mb-2" id="current-ip">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
Detecting...
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="useCurrentIP()">
|
||||
<i class="bi bi-arrow-up me-1"></i>
|
||||
Use This IP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
@@ -73,113 +97,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current IP Detection and Available Domains -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-4 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-geo-alt me-2"></i>
|
||||
Your Current IP
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<div class="fw-bold font-monospace fs-5 mb-2" id="current-ip">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
Detecting...
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="useCurrentIP()">
|
||||
<i class="bi bi-arrow-up me-1"></i>
|
||||
Use This IP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if domains %}
|
||||
<div class="col-md-4 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-server me-2"></i>
|
||||
Available Domains
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for domain in domains %}
|
||||
<div class="mb-2">
|
||||
<div class="fw-bold">{{ domain.domain_name }}</div>
|
||||
<small class="text-muted">
|
||||
Created: {{ domain.created_at.strftime('%Y-%m-%d') }}
|
||||
</small>
|
||||
</div>
|
||||
{% if not loop.last %}<hr class="my-2">{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Example Use Cases -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
Common Use Cases
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary">
|
||||
<i class="bi bi-server me-1"></i>
|
||||
Application Servers
|
||||
</h6>
|
||||
<p class="text-muted small">
|
||||
Web applications that need to send transactional emails
|
||||
(password resets, notifications, etc.)
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-success">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
Scheduled Tasks
|
||||
</h6>
|
||||
<p class="text-muted small">
|
||||
Cron jobs or scheduled scripts that send automated
|
||||
reports or alerts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-warning">
|
||||
<i class="bi bi-monitor me-1"></i>
|
||||
Monitoring Systems
|
||||
</h6>
|
||||
<p class="text-muted small">
|
||||
Monitoring tools that send alerts and status updates
|
||||
to administrators
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-info">
|
||||
<i class="bi bi-cloud me-1"></i>
|
||||
Cloud Services
|
||||
</h6>
|
||||
<p class="text-muted small">
|
||||
Cloud-based applications or services that need to
|
||||
send emails on behalf of your domain
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -187,15 +105,22 @@
|
||||
// Detect current IP address
|
||||
async function detectCurrentIP() {
|
||||
try {
|
||||
const response = await fetch('https://api.ipify.org?format=json');
|
||||
const response = await fetch('https://ifconfig.me/all.json');
|
||||
const data = await response.json();
|
||||
document.getElementById('current-ip').innerHTML =
|
||||
`<span class="text-primary">${data.ip}</span>`;
|
||||
} catch (error) {
|
||||
document.getElementById('current-ip').innerHTML =
|
||||
`<span class="text-primary">${data.ip_addr}</span>`;
|
||||
} catch (er) {
|
||||
try {
|
||||
const response = await fetch('https://httpbin.org/ip');
|
||||
const data = await response.json();
|
||||
document.getElementById('current-ip').innerHTML =
|
||||
`<span class="text-primary">${data.origin}</span>`;
|
||||
} catch (error) {
|
||||
document.getElementById('current-ip').innerHTML =
|
||||
'<span class="text-muted">Unable to detect</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useCurrentIP() {
|
||||
const currentIPElement = document.getElementById('current-ip');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add User - Email Server{% endblock %}
|
||||
{% block title %}Add Sender - Email Server{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-person-plus me-2"></i>
|
||||
Add New User
|
||||
Add New Sender
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@@ -50,7 +50,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
The domain this user belongs to
|
||||
The domain this sender belongs to
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,11 +61,11 @@
|
||||
id="can_send_as_domain"
|
||||
name="can_send_as_domain">
|
||||
<label class="form-check-label" for="can_send_as_domain">
|
||||
<strong>Domain Administrator</strong>
|
||||
<strong>Domain Sender</strong>
|
||||
</label>
|
||||
<div class="form-text">
|
||||
If checked, user can send emails as any address in their domain.
|
||||
Otherwise, user can only send as their own email address.
|
||||
If checked, sender can send emails as any address in their domain.
|
||||
Otherwise, sender can only send as their own email address.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,19 +76,19 @@
|
||||
Permission Levels
|
||||
</h6>
|
||||
<ul class="mb-0">
|
||||
<li><strong>Regular User:</strong> Can only send emails from their own email address</li>
|
||||
<li><strong>Domain Admin:</strong> Can send emails from any address in their domain (e.g., noreply@domain.com, support@domain.com)</li>
|
||||
<li><strong>Regular Sender:</strong> Can only send emails from their own email address</li>
|
||||
<li><strong>Domain Sender:</strong> Can send emails from any address in their domain (e.g., noreply@domain.com, support@domain.com)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('email.users_list') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>
|
||||
Back to Users
|
||||
Back to Senders
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-person-plus me-2"></i>
|
||||
Add User
|
||||
Add Sender
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -98,35 +98,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain information sidebar -->
|
||||
<div class="row mt-4">
|
||||
{% if domains %}
|
||||
<div class="col-md-6 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Available Domains
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% for domain in domains %}
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="border rounded p-2">
|
||||
<div class="fw-bold">{{ domain.domain_name }}</div>
|
||||
<small class="text-muted">
|
||||
Created: {{ domain.created_at.strftime('%Y-%m-%d') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
||||
@@ -190,12 +190,12 @@
|
||||
{% if item.existing_spf %}
|
||||
<div class="mb-2">
|
||||
<strong>Current SPF:</strong>
|
||||
<div class="dns-record text-info">{{ item.existing_spf }}</div>
|
||||
<div class="dns-record">{{ item.existing_spf }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-2">
|
||||
<strong>Recommended SPF:</strong>
|
||||
<div class="dns-record text-success">{{ item.recommended_spf }}</div>
|
||||
<div class="dns-record">{{ item.recommended_spf }}</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="copyToClipboard('{{ item.recommended_spf }}')">
|
||||
<i class="bi bi-clipboard me-1"></i>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
name="can_send_as_domain"
|
||||
{% if user.can_send_as_domain %}checked{% endif %}>
|
||||
<label class="form-check-label" for="can_send_as_domain">
|
||||
<strong>Can send as domain</strong>
|
||||
<strong>Can send as any email from domain</strong>
|
||||
</label>
|
||||
<div class="form-text">
|
||||
Allow this user to send emails using any address within their domain
|
||||
|
||||
@@ -200,10 +200,10 @@
|
||||
// Detect current IP address
|
||||
async function detectCurrentIP() {
|
||||
try {
|
||||
const response = await fetch('https://api.ipify.org?format=json');
|
||||
const response = await fetch('https://ifconfig.me/all.json');
|
||||
const data = await response.json();
|
||||
document.getElementById('current-ip').innerHTML =
|
||||
`<span class="text-primary">${data.ip}</span>`;
|
||||
`<span class="text-primary">${data.ip_addr}</span>`;
|
||||
} catch (error) {
|
||||
document.getElementById('current-ip').innerHTML =
|
||||
'<span class="text-muted">Unable to detect</span>';
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>
|
||||
<i class="bi bi-journal-text me-2"></i>
|
||||
Server Logs
|
||||
Emails Log
|
||||
</h2>
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('email.logs', type='all') }}"
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
Server Settings
|
||||
</h2>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-danger me-2" onclick="restartSmtpServer()" id="restart-smtp-btn">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>
|
||||
Restart SMTP Server
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning" onclick="resetToDefaults()">
|
||||
<i class="bi bi-arrow-clockwise me-2"></i>
|
||||
Reset to Defaults
|
||||
@@ -351,5 +355,28 @@
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
function restartSmtpServer() {
|
||||
showConfirmation("Are you sure you want to restart the SMTP server?", "Restart SMTP Server", "Restart", "btn-danger").then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
const btn = document.getElementById('restart-smtp-btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Restarting...';
|
||||
fetch('/api/server/restart', {method: 'POST'})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
showToast(data.message || 'SMTP server restarted.', 'success');
|
||||
} else {
|
||||
showToast(data.message || 'Failed to restart SMTP server.', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('Failed to restart SMTP server.', 'danger'))
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i> Restart SMTP Server';
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<li class="nav-item mb-2">
|
||||
<h6 class="text-muted text-uppercase small mb-2 mt-3">
|
||||
<i class="bi bi-globe me-1"></i>
|
||||
Domain Management
|
||||
Email Server Management
|
||||
</h6>
|
||||
</li>
|
||||
|
||||
@@ -38,20 +38,12 @@
|
||||
<span class="badge bg-secondary ms-auto">{{ domain_count if domain_count is defined else '' }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Authentication Section -->
|
||||
<li class="nav-item mb-2">
|
||||
<h6 class="text-muted text-uppercase small mb-2 mt-3">
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Authentication
|
||||
</h6>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="{{ url_for('email.users_list') }}"
|
||||
class="nav-link text-white {{ 'active' if request.endpoint in ['email.users_list', 'email.add_user'] else '' }}">
|
||||
<i class="bi bi-people me-2"></i>
|
||||
Users
|
||||
Allowed Senders
|
||||
<span class="badge bg-secondary ms-auto">{{ user_count if user_count is defined else '' }}</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -65,14 +57,6 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- DKIM Section -->
|
||||
<li class="nav-item mb-2">
|
||||
<h6 class="text-muted text-uppercase small mb-2 mt-3">
|
||||
<i class="bi bi-key me-1"></i>
|
||||
Email Security
|
||||
</h6>
|
||||
</li>
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="{{ url_for('email.dkim_list') }}"
|
||||
class="nav-link text-white {{ 'active' if request.endpoint == 'email.dkim_list' else '' }}">
|
||||
@@ -81,7 +65,24 @@
|
||||
<span class="badge bg-secondary ms-auto">{{ dkim_count if dkim_count is defined else '' }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item mb-1">
|
||||
<a href="{{ url_for('email.logs') }}"
|
||||
class="nav-link text-white {{ 'active' if request.endpoint == 'email.logs' else '' }}">
|
||||
<i class="bi bi-journal-text me-2"></i>
|
||||
Emails Log
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Authentication Section -->
|
||||
<li class="nav-item mb-2">
|
||||
<h6 class="text-muted text-uppercase small mb-2 mt-3">
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Authentication
|
||||
</h6>
|
||||
</li>
|
||||
|
||||
|
||||
<!-- Configuration Section -->
|
||||
<li class="nav-item mb-2">
|
||||
<h6 class="text-muted text-uppercase small mb-2 mt-3">
|
||||
|
||||
@@ -1,25 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Users - Email Server Management{% endblock %}
|
||||
{% block page_title %}User Management{% endblock %}
|
||||
{% block title %}Senders - Email Server Management{% endblock %}
|
||||
{% block page_title %}Sender Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>
|
||||
<i class="bi bi-people me-2"></i>
|
||||
Users
|
||||
Senders
|
||||
</h2>
|
||||
<a href="{{ url_for('email.add_user') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus me-2"></i>
|
||||
Add User
|
||||
Add Sender
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if users %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Sender Statistics
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>Active Senders:</strong> {{ users|selectattr('0.is_active')|list|length }}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-star text-warning me-2"></i>
|
||||
<strong>Domain Sender:</strong> {{ users|selectattr('0.can_send_as_domain')|list|length }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bi bi-person text-info me-2"></i>
|
||||
<strong>Regular Senders:</strong> {{ users|rejectattr('0.can_send_as_domain')|list|length }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
Permission Levels
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li class="mb-2">
|
||||
<span class="badge bg-warning me-2" style="color: black;">Domain Sender</span>
|
||||
Can send as any email address in their domain
|
||||
</li>
|
||||
<li>
|
||||
<span class="badge bg-info me-2" style="color: black;">Regular Sender</span>
|
||||
Can only send as their own email address
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-list-ul me-2"></i>
|
||||
All Users
|
||||
All Senders
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@@ -47,16 +100,16 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if user.can_send_as_domain %}
|
||||
<span class="badge bg-warning">
|
||||
<span class="badge bg-warning" style="color: black;">
|
||||
<i class="bi bi-star me-1"></i>
|
||||
Domain Admin
|
||||
Domain Sender
|
||||
</span>
|
||||
<br>
|
||||
<small class="text-muted">Can send as *@{{ domain.domain_name }}</small>
|
||||
{% else %}
|
||||
<span class="badge bg-info">
|
||||
<span class="badge bg-info" style="color: black;">
|
||||
<i class="bi bi-person me-1"></i>
|
||||
User
|
||||
Regular Sender
|
||||
</span>
|
||||
<br>
|
||||
<small class="text-muted">Can only send as {{ user.email }}</small>
|
||||
@@ -85,7 +138,7 @@
|
||||
<!-- Edit Button -->
|
||||
<a href="{{ url_for('email.edit_user', user_id=user.id) }}"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="Edit User">
|
||||
title="Edit Sender">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
|
||||
@@ -94,7 +147,7 @@
|
||||
<form method="post" action="{{ url_for('email.delete_user', user_id=user.id) }}" class="d-inline">
|
||||
<button type="submit"
|
||||
class="btn btn-outline-warning btn-sm"
|
||||
title="Disable User"
|
||||
title="Disable Sender"
|
||||
onclick="return confirm('Disable user {{ user.email }}?')">
|
||||
<i class="bi bi-pause-circle"></i>
|
||||
</button>
|
||||
@@ -103,7 +156,7 @@
|
||||
<form method="post" action="{{ url_for('email.enable_user', user_id=user.id) }}" class="d-inline">
|
||||
<button type="submit"
|
||||
class="btn btn-outline-success btn-sm"
|
||||
title="Enable User"
|
||||
title="Enable Sender"
|
||||
onclick="return confirm('Enable user {{ user.email }}?')">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
</button>
|
||||
@@ -114,7 +167,7 @@
|
||||
<form method="post" action="{{ url_for('email.remove_user', user_id=user.id) }}" class="d-inline">
|
||||
<button type="submit"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
title="Permanently Remove User"
|
||||
title="Permanently Remove Sender"
|
||||
onclick="return confirm('Permanently remove user {{ user.email }}? This cannot be undone!')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
@@ -129,67 +182,15 @@
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-people text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="text-muted mt-3">No users configured</h4>
|
||||
<p class="text-muted">Add users to enable username/password authentication</p>
|
||||
<h4 class="text-muted mt-3">No senders configured</h4>
|
||||
<p class="text-muted">Add sender to enable username/password authentication</p>
|
||||
<a href="{{ url_for('email.add_user') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus me-2"></i>
|
||||
Add Your First User
|
||||
Add Your First Sender
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if users %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
User Statistics
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>Active users:</strong> {{ users|selectattr('0.is_active')|list|length }}
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-star text-warning me-2"></i>
|
||||
<strong>Domain admins:</strong> {{ users|selectattr('0.can_send_as_domain')|list|length }}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bi bi-person text-info me-2"></i>
|
||||
<strong>Regular users:</strong> {{ users|rejectattr('0.can_send_as_domain')|list|length }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-lightbulb me-2"></i>
|
||||
Permission Levels
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li class="mb-2">
|
||||
<span class="badge bg-warning me-2">Domain Admin</span>
|
||||
Can send as any email address in their domain
|
||||
</li>
|
||||
<li>
|
||||
<span class="badge bg-info me-2">Regular User</span>
|
||||
Can only send as their own email address
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
[Unit]
|
||||
Description=PyMTA Email Server
|
||||
After=network.target
|
||||
StartLimitIntervalSec=0
|
||||
# check any errors when using this service:
|
||||
# journalctl -u pymta-server.service -b -f
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=appuser
|
||||
Group=appuser
|
||||
WorkingDirectory=/opt/PyMTA-server
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/opt/PyMTA-server/.venv/bin/python main.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Security settings
|
||||
# Capabilities for low ports < 1024 following 2 lines:
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
# if using port < 1024 comment out line bellow:
|
||||
# NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/PyMTA-server
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -19,7 +19,8 @@ Flask-SQLAlchemy
|
||||
Jinja2
|
||||
Werkzeug
|
||||
requests
|
||||
Flask-Migrate>=4.0.0
|
||||
Flask-Migrate
|
||||
gunicorn
|
||||
|
||||
# Additional utilities
|
||||
python-dotenv
|
||||
234
script_install_service.sh
Executable file
234
script_install_service.sh
Executable file
@@ -0,0 +1,234 @@
|
||||
#!/bin/bash
|
||||
SMTP_SERVICE_NAME="pymta-smtp.service"
|
||||
WEB_SERVICE_NAME="pymta-web.service"
|
||||
APP_ROOT_FOLDER="" #/opt/PyMTA-server
|
||||
|
||||
# Set APP_ROOT_FOLDER to the directory where this script is located if not already set
|
||||
if [[ -z "$APP_ROOT_FOLDER" ]]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_ROOT_FOLDER="$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
SCRIPT_MODE="install"
|
||||
TARGET_USER="$USER"
|
||||
IS_SYSTEM=false
|
||||
|
||||
# Function to print usage
|
||||
usage() {
|
||||
echo "Usage:"
|
||||
echo " $0 Install as current user"
|
||||
echo " $0 -u username Install as system service for given user (requires sudo)"
|
||||
echo " $0 -rm Remove user service for current user ( also works with 'remove')"
|
||||
echo " $0 -rm username Remove system service for specified user (requires sudo)"
|
||||
echo " $0 -rm system Remove system service"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-u)
|
||||
TARGET_USER="$2"
|
||||
IS_SYSTEM=true
|
||||
shift 2
|
||||
;;
|
||||
-rm)
|
||||
SCRIPT_MODE="remove"
|
||||
if [[ "$2" ]]; then
|
||||
if [[ "$2" == "system" ]]; then
|
||||
IS_SYSTEM=true
|
||||
TARGET_USER=""
|
||||
else
|
||||
IS_SYSTEM=true
|
||||
TARGET_USER="$2"
|
||||
fi
|
||||
shift
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
remove)
|
||||
SCRIPT_MODE="remove"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
write_user_services() {
|
||||
mkdir -p "$HOME/.config/systemd/user"
|
||||
|
||||
cat > "$HOME/.config/systemd/user/$SMTP_SERVICE_NAME" <<EOF
|
||||
[Unit]
|
||||
Description=PyMTA SMTP Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$APP_ROOT_FOLDER
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=$APP_ROOT_FOLDER/.venv/bin/python $APP_ROOT_FOLDER/app.py --smtp-only
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=4
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# This needs to be uncommented if you want to bind to ports below 1024
|
||||
#AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
#CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
# This may not be necessary uncommented if you are not binding to ports below 1024
|
||||
#NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=$APP_ROOT_FOLDER
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
cat > "$HOME/.config/systemd/user/$WEB_SERVICE_NAME" <<EOF
|
||||
[Unit]
|
||||
Description=PyMTA Web Management (Flask/Gunicorn)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$APP_ROOT_FOLDER
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=$APP_ROOT_FOLDER/.venv/bin/gunicorn -w 4 -b 127.0.0.1:5000 app:flask_app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=4
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=$APP_ROOT_FOLDER
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "$SMTP_SERVICE_NAME"
|
||||
systemctl --user enable "$WEB_SERVICE_NAME"
|
||||
systemctl --user start "$SMTP_SERVICE_NAME"
|
||||
systemctl --user start "$WEB_SERVICE_NAME"
|
||||
echo "Services installed for user $USER."
|
||||
echo "To view logs, run:"
|
||||
echo "journalctl --user -u $SMTP_SERVICE_NAME -b -f"
|
||||
echo "journalctl --user -u $WEB_SERVICE_NAME -b -f"
|
||||
echo "To view service status, run:"
|
||||
echo "systemctl --user status $SMTP_SERVICE_NAME"
|
||||
echo "systemctl --user status $WEB_SERVICE_NAME"
|
||||
}
|
||||
|
||||
remove_user_services() {
|
||||
systemctl --user stop "$SMTP_SERVICE_NAME" || true
|
||||
systemctl --user stop "$WEB_SERVICE_NAME" || true
|
||||
systemctl --user disable "$SMTP_SERVICE_NAME" || true
|
||||
systemctl --user disable "$WEB_SERVICE_NAME" || true
|
||||
rm -f "$HOME/.config/systemd/user/$SMTP_SERVICE_NAME"
|
||||
rm -f "$HOME/.config/systemd/user/$WEB_SERVICE_NAME"
|
||||
systemctl --user daemon-reload
|
||||
echo "Removed services for user $USER."
|
||||
}
|
||||
|
||||
write_system_services() {
|
||||
SERVICE_DIR="/etc/systemd/system"
|
||||
|
||||
sudo tee "$SERVICE_DIR/$SMTP_SERVICE_NAME" > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=PyMTA SMTP Server (system)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$TARGET_USER
|
||||
WorkingDirectory=$APP_ROOT_FOLDER
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=$APP_ROOT_FOLDER/.venv/bin/python $APP_ROOT_FOLDER/app.py --smtp-only
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=4
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
#NoNewPrivileges=true
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=$APP_ROOT_FOLDER
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo tee "$SERVICE_DIR/$WEB_SERVICE_NAME" > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=PyMTA Web (system)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$TARGET_USER
|
||||
WorkingDirectory=$APP_ROOT_FOLDER
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=$APP_ROOT_FOLDER/.venv/bin/gunicorn -w 4 -b 127.0.0.1:5000 app:flask_app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=4
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=$APP_ROOT_FOLDER
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SMTP_SERVICE_NAME"
|
||||
sudo systemctl enable "$WEB_SERVICE_NAME"
|
||||
sudo systemctl start "$SMTP_SERVICE_NAME"
|
||||
sudo systemctl start "$WEB_SERVICE_NAME"
|
||||
echo "Installed system services for user $TARGET_USER."
|
||||
echo "To view logs, run:"
|
||||
echo "journalctl -u $SMTP_SERVICE_NAME -b -f"
|
||||
echo "journalctl -u $WEB_SERVICE_NAME -b -f"
|
||||
echo "To view service status, run:"
|
||||
echo "systemctl status $SMTP_SERVICE_NAME"
|
||||
echo "systemctl status $WEB_SERVICE_NAME"
|
||||
}
|
||||
|
||||
remove_system_services() {
|
||||
sudo systemctl stop "$SMTP_SERVICE_NAME" || true
|
||||
sudo systemctl stop "$WEB_SERVICE_NAME" || true
|
||||
sudo systemctl disable "$SMTP_SERVICE_NAME" || true
|
||||
sudo systemctl disable "$WEB_SERVICE_NAME" || true
|
||||
sudo rm -f "/etc/systemd/system/$SMTP_SERVICE_NAME"
|
||||
sudo rm -f "/etc/systemd/system/$WEB_SERVICE_NAME"
|
||||
sudo systemctl daemon-reload
|
||||
echo "Removed system services."
|
||||
}
|
||||
|
||||
# Main logic
|
||||
if [[ "$SCRIPT_MODE" == "remove" ]]; then
|
||||
if $IS_SYSTEM; then
|
||||
remove_system_services
|
||||
else
|
||||
remove_user_services
|
||||
fi
|
||||
else
|
||||
if $IS_SYSTEM; then
|
||||
write_system_services
|
||||
else
|
||||
write_user_services
|
||||
fi
|
||||
fi
|
||||
297
script_nginx_setup.sh
Normal file
297
script_nginx_setup.sh
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration variables
|
||||
DOMAIN="example.com" # Replace with your domain (e.g., example.com)
|
||||
WEBSITE_URL="mail.example.com" # Replace with website URL for web interface
|
||||
EMAIL="admin@example.com" # Replace with your email for Let's Encrypt
|
||||
LETSENCRYPT_EXPORT_PATH="/opt/PyMTA-server/email_server/ssl_certs/" # Path to export .crt and .key files
|
||||
APP_USERNAME="appuser" # Replace with the username of the user running the SMTP app
|
||||
NGINX_CONF_DIR="/etc/nginx"
|
||||
SITES_AVAILABLE="$NGINX_CONF_DIR/sites-available/$DOMAIN"
|
||||
SITES_ENABLED="$NGINX_CONF_DIR/sites-enabled/$DOMAIN"
|
||||
SITE_APP="$NGINX_CONF_DIR/sites-enabled/$WEBSITE_URL"
|
||||
WEB_ROOT="/var/www/$DOMAIN/html"
|
||||
ERROR_PAGE_DIR="$WEB_ROOT/errors"
|
||||
CLOUDFLARE_DNS_PLUGIN="certbot-dns-cloudflare"
|
||||
# https://medium.com/@life-is-short-so-enjoy-it/homelab-nginx-proxy-manager-setup-ssl-certificate-with-domain-name-in-cloudflare-dns-732af64ddc0b
|
||||
CLOUDFLARE_API_TOKEN_HERE="" # <<<< Set up here your cloudflare API token
|
||||
CLOUDFLARE_CREDENTIALS="/root/.cloudflare/credentials.ini"
|
||||
VENV_DIR="/opt/certbot-venv"
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
# List of common HTTP error codes with brief messages
|
||||
# hides Nginx error pages for custom
|
||||
declare -A ERROR_MESSAGES=(
|
||||
[400]="Bad Request"
|
||||
[401]="Unauthorized"
|
||||
[403]="Forbidden"
|
||||
[404]="Not Found"
|
||||
[500]="Internal Server Error"
|
||||
[502]="Bad Gateway"
|
||||
[503]="Service Unavailable"
|
||||
[504]="Gateway Timeout"
|
||||
)
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root or with sudo" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update system and install required packages
|
||||
echo "Updating system and installing dependencies..."
|
||||
apt update && apt upgrade -y
|
||||
apt install -y nginx certbot python3 python3-pip python3-venv python3-dev
|
||||
|
||||
# Create and activate a virtual environment for certbot-dns-cloudflare
|
||||
echo "Creating Python virtual environment for certbot-dns-cloudflare..."
|
||||
mkdir -p "$VENV_DIR"
|
||||
python3 -m venv "$VENV_DIR"
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# Upgrade pip in the virtual environment
|
||||
echo "Upgrading pip..."
|
||||
pip3 install --upgrade pip
|
||||
|
||||
# Install Cloudflare DNS plugin for Certbot
|
||||
echo "Installing certbot-dns-cloudflare..."
|
||||
if ! pip3 install "$CLOUDFLARE_DNS_PLUGIN"; then
|
||||
echo "Failed to install $CLOUDFLARE_DNS_PLUGIN. Please check your network or Python environment." >&2
|
||||
deactivate
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deactivate virtual environment
|
||||
deactivate
|
||||
|
||||
# Create web root and error page directory
|
||||
echo "Creating web root and error page directories..."
|
||||
mkdir -p "$WEB_ROOT" "$ERROR_PAGE_DIR"
|
||||
|
||||
# Generate HTML files for each error code
|
||||
for code in "${!ERROR_MESSAGES[@]}"; do
|
||||
file="$ERROR_PAGE_DIR/$code.html"
|
||||
cat > "$file" <<EOF
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error $code</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { font-size: 48px; color: #c00; }
|
||||
p { font-size: 20px; color: #666; }
|
||||
a { text-decoration: none; color: #007acc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Error $code</h1>
|
||||
<p>${ERROR_MESSAGES[$code]}</p>
|
||||
<p><a href="/">Return to Home</a></p>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
echo "Created: $file"
|
||||
done
|
||||
|
||||
echo "All error pages generated in $ERROR_PAGE_DIR"
|
||||
|
||||
# Set permissions for error page
|
||||
chown -R www-data:www-data "$WEB_ROOT"
|
||||
chmod -R 755 "$WEB_ROOT"
|
||||
|
||||
# Create Cloudflare credentials file (ensure you replace with your actual API token)
|
||||
echo "Creating Cloudflare credentials file..."
|
||||
mkdir -p "$(dirname "$CLOUDFLARE_CREDENTIALS")"
|
||||
cat > "$CLOUDFLARE_CREDENTIALS" << EOF
|
||||
dns_cloudflare_api_token = $CLOUDFLARE_API_TOKEN_HERE
|
||||
EOF
|
||||
chmod 600 "$CLOUDFLARE_CREDENTIALS"
|
||||
|
||||
# Create NGINX configuration
|
||||
echo "Creating NGINX configuration for $DOMAIN..."
|
||||
mkdir -p "$NGINX_CONF_DIR/sites-available" "$NGINX_CONF_DIR/sites-enabled"
|
||||
cat > "$SITES_AVAILABLE" << EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN *.$DOMAIN;
|
||||
|
||||
root $WEB_ROOT;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
error_page 404 $ERROR_PAGE;
|
||||
location = $ERROR_PAGE {
|
||||
root $WEB_ROOT;
|
||||
internal;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Enable the site
|
||||
ln -sf "$SITES_AVAILABLE" "$SITES_ENABLED"
|
||||
# Disable default site
|
||||
rm -f "$NGINX_CONF_DIR/sites-enabled/default"
|
||||
|
||||
# Test NGINX configuration
|
||||
echo "Testing NGINX configuration..."
|
||||
nginx -t
|
||||
|
||||
# Reload NGINX to apply changes
|
||||
echo "Reloading NGINX..."
|
||||
systemctl reload nginx
|
||||
|
||||
|
||||
# Obtain Let's Encrypt wildcard SSL certificate using Cloudflare DNS
|
||||
echo "Obtaining Let's Encrypt wildcard SSL certificate..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
if ! certbot certonly \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--email "$EMAIL" \
|
||||
--dns-cloudflare \
|
||||
--dns-cloudflare-credentials "$CLOUDFLARE_CREDENTIALS" \
|
||||
--domains "$DOMAIN,*.$DOMAIN"; then
|
||||
echo "Failed to obtain Let's Encrypt certificate. Please check Cloudflare credentials and DNS settings." >&2
|
||||
deactivate
|
||||
exit 1
|
||||
fi
|
||||
deactivate
|
||||
|
||||
# Export .crt and .key files to LETSENCRYPT_EXPORT_PATH
|
||||
echo "Exporting SSL certificate and key to $LETSENCRYPT_EXPORT_PATH..."
|
||||
mkdir -p "$LETSENCRYPT_EXPORT_PATH"
|
||||
cp "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" "$LETSENCRYPT_EXPORT_PATH/server.crt"
|
||||
cp "/etc/letsencrypt/live/$DOMAIN/privkey.pem" "$LETSENCRYPT_EXPORT_PATH/server.key"
|
||||
chown $APP_USERNAME:$APP_USERNAME "$LETSENCRYPT_EXPORT_PATH/server.crt" "$LETSENCRYPT_EXPORT_PATH/server.key"
|
||||
chmod 600 "$LETSENCRYPT_EXPORT_PATH/server.crt" "$LETSENCRYPT_EXPORT_PATH/server.key"
|
||||
|
||||
# Update NGINX configuration to use SSL
|
||||
echo "Updating NGINX configuration for SSL..."
|
||||
cat > "$SITES_AVAILABLE" << EOF
|
||||
# Hide NGINX server signature
|
||||
server_tokens off;
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
root $WEB_ROOT;
|
||||
index errors/404.html; #index.html;
|
||||
|
||||
error_page 400 /errors/400.html;
|
||||
error_page 401 /errors/401.html;
|
||||
error_page 403 /errors/403.html;
|
||||
error_page 404 /errors/404.html;
|
||||
error_page 500 /errors/500.html;
|
||||
error_page 502 /errors/502.html;
|
||||
error_page 503 /errors/503.html;
|
||||
error_page 504 /errors/504.html;
|
||||
location /errors/ {
|
||||
root $WEB_ROOT;
|
||||
internal;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
||||
|
||||
# SSL security settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
root $WEB_ROOT;
|
||||
index errors/404.html; #index.html;
|
||||
|
||||
error_page 400 /errors/400.html;
|
||||
error_page 401 /errors/401.html;
|
||||
error_page 403 /errors/403.html;
|
||||
error_page 404 /errors/404.html;
|
||||
error_page 500 /errors/500.html;
|
||||
error_page 502 /errors/502.html;
|
||||
error_page 503 /errors/503.html;
|
||||
error_page 504 /errors/504.html;
|
||||
location /errors/ {
|
||||
root $WEB_ROOT;
|
||||
internal;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name $WEBSITE_URL;
|
||||
# Prevent redirect loop with Cloudflare
|
||||
if ($http_x_forwarded_proto = "http") {
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name $WEBSITE_URL;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
||||
|
||||
# SSL security settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Proxy settings for Flask app
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000; # Updated this, where runs your web interface
|
||||
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;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Test NGINX configuration again
|
||||
echo "Testing updated NGINX configuration..."
|
||||
nginx -t
|
||||
|
||||
# Reload NGINX to apply SSL changes
|
||||
echo "Reloading NGINX with SSL configuration..."
|
||||
systemctl reload nginx
|
||||
|
||||
# Enable NGINX auto-start
|
||||
echo "Enabling NGINX to start on boot..."
|
||||
systemctl enable nginx
|
||||
|
||||
# Set up automatic certificate renewal
|
||||
echo "Setting up Let's Encrypt renewal for wildcard certificate..."
|
||||
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/certbot-venv/bin/certbot renew --quiet && \\
|
||||
cp /etc/letsencrypt/live/\$DOMAIN/fullchain.pem \$LETSENCRYPT_EXPORT_PATH/server.crt && \\
|
||||
cp /etc/letsencrypt/live/\$DOMAIN/privkey.pem \$LETSENCRYPT_EXPORT_PATH/server.key && \\
|
||||
chown \$APP_USERNAME:\$APP_USERNAME "\$LETSENCRYPT_EXPORT_PATH/server.crt" "\$LETSENCRYPT_EXPORT_PATH/server.key" && \\
|
||||
chmod 600 \$LETSENCRYPT_EXPORT_PATH/server.crt \$LETSENCRYPT_EXPORT_PATH/server.key") | crontab -
|
||||
|
||||
echo "NGINX setup complete! Your site is live at https://$DOMAIN"
|
||||
echo "Wildcard certificate covers *.$DOMAIN"
|
||||
echo "Custom 404 page is set at $ERROR_PAGE"
|
||||
echo "SSL certificate and key exported to $LETSENCRYPT_EXPORT_PATH"
|
||||
echo "Please ensure your Cloudflare API token is correctly set in $CLOUDFLARE_CREDENTIALS"
|
||||
25
script_setup_py_environment.sh
Executable file
25
script_setup_py_environment.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
python3 --version
|
||||
python3 -m venv "$SCRIPT_DIR/.venv" --copies # This will copy the Python binary so cap_net_bind_service will work
|
||||
|
||||
$SCRIPT_DIR/.venv/bin/pip install -r $SCRIPT_DIR/requirements.txt
|
||||
|
||||
echo "Need Sudo for allowing local .venv python to bind to port < 1024 (SMTP uses port 25)"
|
||||
# Allow binding to port < 1024 (SMTP uses port 25) without use of sudo
|
||||
for f in $SCRIPT_DIR/.venv/bin/python*; do if sudo setcap 'cap_net_bind_service=+ep' "$f"; then echo "Set CapNetBindService for $(basename "$f")"; fi; done
|
||||
|
||||
|
||||
echo "*******************************************************************"
|
||||
echo "To starth the app for testing just run in the virtual environment:"
|
||||
echo "python app.py"
|
||||
echo "*******************************************************************"
|
||||
echo "For testing run SMTP server as:"
|
||||
echo "python app.py --smtp-only --debug"
|
||||
echo "For testing with web interface run:"
|
||||
echo "python app.py --web-only --debug"
|
||||
echo "*******************************************************************"
|
||||
echo "Gunicorn must run web interface separately, from the SMTP server"
|
||||
echo "Production Services will run the app as:"
|
||||
echo "python app.py --smtp-only & gunicorn -w 4 -b 0.0.0.0:5000 app:flask_app"
|
||||
Reference in New Issue
Block a user