diff --git a/gui_app.py b/gui_app.py
index 0828600..246d5e7 100644
--- a/gui_app.py
+++ b/gui_app.py
@@ -788,12 +788,146 @@ def send_email_notification(smtp, run_data, raise_on_error=False):
server.sendmail(sender, recipient, msg.as_string())
server.quit()
+ except smtplib.SMTPAuthenticationError as e:
+ msg = f"Authentication failed (code {e.smtp_code}): wrong username or password."
+ print(f"Email error: {msg}", file=sys.stderr)
+ if raise_on_error:
+ raise RuntimeError(msg) from e
+ except smtplib.SMTPSenderRefused as e:
+ msg = f"Sender address rejected by server (code {e.smtp_code}): {e.smtp_error.decode(errors='replace')}. Tip: Use port 587 + STARTTLS with your login credentials."
+ print(f"Email error: {msg}", file=sys.stderr)
+ if raise_on_error:
+ raise RuntimeError(msg) from e
+ except smtplib.SMTPRecipientsRefused as e:
+ # e.recipients is a dict: {addr: (code, msg_bytes)}
+ details = "; ".join(
+ f"{addr}: {err[1].decode(errors='replace')} (code {err[0]})"
+ for addr, err in e.recipients.items()
+ )
+ msg = f"Server rejected recipient(s): {details}"
+ print(f"Email error: {msg}", file=sys.stderr)
+ if raise_on_error:
+ raise RuntimeError(msg) from e
+ except smtplib.SMTPConnectError as e:
+ msg = f"Could not connect to {host}:{port} — {e.smtp_error.decode(errors='replace') if isinstance(e.smtp_error, bytes) else e}"
+ print(f"Email error: {msg}", file=sys.stderr)
+ if raise_on_error:
+ raise RuntimeError(msg) from e
+ except smtplib.SMTPException as e:
+ msg = f"SMTP error: {e}"
+ print(f"Email error: {msg}", file=sys.stderr)
+ if raise_on_error:
+ raise RuntimeError(msg) from e
+ except OSError as e:
+ msg = f"Connection failed to {host}:{port} — {e}. Check that the host/port are reachable."
+ print(f"Email error: {msg}", file=sys.stderr)
+ if raise_on_error:
+ raise RuntimeError(msg) from e
except Exception as e:
print(f"Error sending email notification: {e}", file=sys.stderr)
if raise_on_error:
raise
+def send_email_via_sendmail(cfg, run_data, raise_on_error=False):
+ """Send email using the local sendmail binary (bypasses SMTP auth entirely).
+ Mimics how PHP mail() or Nagios work on servers that have postfix/sendmail locally.
+ """
+ import subprocess
+ from email.mime.text import MIMEText
+ from email.mime.multipart import MIMEMultipart
+
+ sendmail_path = cfg.get('sendmail_path', '/usr/sbin/sendmail').strip() or '/usr/sbin/sendmail'
+ sender = cfg.get('sender', '').strip()
+ recipient = cfg.get('recipient', '').strip()
+
+ if not (sender and recipient):
+ if raise_on_error:
+ raise RuntimeError("Sender and Recipient email addresses are required.")
+ return
+
+ size_gb = run_data['size_bytes'] / (1024 * 1024 * 1024)
+ duration_str = fmt_duration(run_data['duration'])
+ started_str = datetime.fromtimestamp(run_data['started']).strftime('%Y-%m-%d %H:%M:%S')
+ ended_str = datetime.fromtimestamp(run_data['ended']).strftime('%Y-%m-%d %H:%M:%S')
+ status_text = run_data['status'].upper()
+
+ theme_color = "#10b981"
+ bg_banner = "linear-gradient(135deg, #10b981 0%, #059669 100%)"
+ if "failed" in run_data['status'].lower():
+ theme_color = "#ef4444"
+ bg_banner = "linear-gradient(135deg, #ef4444 0%, #dc2626 100%)"
+ elif "error" in run_data['status'].lower():
+ theme_color = "#f59e0b"
+ bg_banner = "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)"
+
+ subject = f"Backup Job {status_text}: {run_data['job_label'] or run_data['job_id'][:8]}"
+
+ # Re-use same HTML body as SMTP version
+ html = f"""
+
+
+
+
Backup Job: {status_text}
+
+| Job Label | {run_data['job_label'] or '—'} |
+| VM Name(s) | {run_data['vm_name']} |
+| Size | {size_gb:.2f} GB |
+| Duration | {duration_str} |
+| Start Time | {started_str} |
+| End Time | {ended_str} |
+| Status | {run_data['status']} |
+
+
+
"""
+
+ msg = MIMEMultipart()
+ msg['From'] = sender
+ msg['To'] = recipient
+ msg['Subject'] = subject
+ msg.attach(MIMEText(html, 'html'))
+
+ try:
+ # sendmail -t reads recipients from headers, -oi ignores lone dots in body
+ proc = subprocess.run(
+ [sendmail_path, '-t', '-oi'],
+ input=msg.as_bytes(),
+ capture_output=True,
+ timeout=30
+ )
+ if proc.returncode != 0:
+ stderr_out = proc.stderr.decode(errors='replace').strip()
+ err = f"sendmail exited with code {proc.returncode}: {stderr_out}"
+ print(f"Email sendmail error: {err}", file=sys.stderr)
+ if raise_on_error:
+ raise RuntimeError(err)
+ except FileNotFoundError:
+ err = f"sendmail binary not found at '{sendmail_path}'. Install postfix/sendmail or check the path."
+ print(f"Email sendmail error: {err}", file=sys.stderr)
+ if raise_on_error:
+ raise RuntimeError(err)
+ except subprocess.TimeoutExpired:
+ err = f"sendmail timed out after 30 seconds."
+ print(f"Email sendmail error: {err}", file=sys.stderr)
+ if raise_on_error:
+ raise RuntimeError(err)
+ except Exception as e:
+ print(f"Error sending email via sendmail: {e}", file=sys.stderr)
+ if raise_on_error:
+ raise
+
+
def log_and_notify_run(jid, info, start_time, end_time, status, run_dest):
size_bytes = get_dir_size(run_dest) if run_dest else 0
duration = end_time - start_time
@@ -872,21 +1006,35 @@ def log_and_notify_run(jid, info, start_time, end_time, status, run_dest):
smtp_enabled = get_setting('smtp_enabled') == 'true'
if smtp_enabled:
- smtp_settings = {
- 'host': get_setting('smtp_host'),
- 'port': get_setting('smtp_port'),
- 'user': get_setting('smtp_user'),
- 'password': get_setting('smtp_password'),
- 'sender': get_setting('smtp_sender'),
- 'recipient': get_setting('smtp_recipient'),
- 'encryption': get_setting('smtp_encryption', 'starttls')
- }
- try:
- t = threading.Thread(target=send_email_notification, args=(smtp_settings, run_data), daemon=True)
- t.start()
- notification_sent = 1
- except Exception:
- pass
+ mail_service = get_setting('smtp_mail_service', 'smtp')
+ if mail_service == 'sendmail':
+ sendmail_cfg = {
+ 'sendmail_path': get_setting('sendmail_path', '/usr/sbin/sendmail'),
+ 'sender': get_setting('smtp_sender'),
+ 'recipient': get_setting('smtp_recipient'),
+ }
+ try:
+ t = threading.Thread(target=send_email_via_sendmail, args=(sendmail_cfg, run_data), daemon=True)
+ t.start()
+ notification_sent = 1
+ except Exception:
+ pass
+ else:
+ smtp_settings = {
+ 'host': get_setting('smtp_host'),
+ 'port': get_setting('smtp_port'),
+ 'user': get_setting('smtp_user'),
+ 'password': get_setting('smtp_password'),
+ 'sender': get_setting('smtp_sender'),
+ 'recipient': get_setting('smtp_recipient'),
+ 'encryption': get_setting('smtp_encryption', 'starttls')
+ }
+ try:
+ t = threading.Thread(target=send_email_notification, args=(smtp_settings, run_data), daemon=True)
+ t.start()
+ notification_sent = 1
+ except Exception:
+ pass
if notification_sent and run_id:
conn = sqlite3.connect(DB_PATH)
@@ -1416,6 +1564,7 @@ def logout():
def settings_page():
if request.method == 'POST':
set_setting('smtp_enabled', 'true' if 'smtp_enabled' in request.form else 'false')
+ set_setting('smtp_mail_service', request.form.get('smtp_mail_service', 'smtp'))
set_setting('smtp_host', request.form.get('smtp_host', '').strip())
set_setting('smtp_port', request.form.get('smtp_port', '587').strip())
set_setting('smtp_encryption', request.form.get('smtp_encryption', 'starttls'))
@@ -1423,6 +1572,7 @@ def settings_page():
set_setting('smtp_password', request.form.get('smtp_password', '').strip())
set_setting('smtp_sender', request.form.get('smtp_sender', '').strip())
set_setting('smtp_recipient', request.form.get('smtp_recipient', '').strip())
+ set_setting('sendmail_path', request.form.get('sendmail_path', '/usr/sbin/sendmail').strip())
set_setting('webhook_enabled', 'true' if 'webhook_enabled' in request.form else 'false')
set_setting('webhook_url', request.form.get('webhook_url', '').strip())
@@ -1436,6 +1586,7 @@ def settings_page():
opts = {
'smtp_enabled': get_setting('smtp_enabled', 'false') == 'true',
+ 'smtp_mail_service': get_setting('smtp_mail_service', 'smtp'),
'smtp_host': get_setting('smtp_host', ''),
'smtp_port': get_setting('smtp_port', '587'),
'smtp_encryption': get_setting('smtp_encryption', 'starttls'),
@@ -1443,6 +1594,7 @@ def settings_page():
'smtp_password': get_setting('smtp_password', ''),
'smtp_sender': get_setting('smtp_sender', ''),
'smtp_recipient': get_setting('smtp_recipient', ''),
+ 'sendmail_path': get_setting('sendmail_path', '/usr/sbin/sendmail'),
'webhook_enabled': get_setting('webhook_enabled', 'false') == 'true',
'webhook_url': get_setting('webhook_url', ''),
@@ -1462,6 +1614,7 @@ def settings_test_notification():
webhook_type = request.form.get('webhook_type', 'slack_discord')
smtp_enabled = 'smtp_enabled' in request.form
+ mail_service = request.form.get('smtp_mail_service', 'smtp')
smtp_settings = {
'host': request.form.get('smtp_host', '').strip(),
'port': request.form.get('smtp_port', '587').strip(),
@@ -1471,7 +1624,12 @@ def settings_test_notification():
'recipient': request.form.get('smtp_recipient', '').strip(),
'encryption': request.form.get('smtp_encryption', 'starttls')
}
-
+ sendmail_cfg = {
+ 'sendmail_path': request.form.get('sendmail_path', '/usr/sbin/sendmail').strip() or '/usr/sbin/sendmail',
+ 'sender': request.form.get('smtp_sender', '').strip(),
+ 'recipient': request.form.get('smtp_recipient', '').strip(),
+ }
+
test_run_data = {
'job_id': 'test-run-id-12345',
'job_label': 'Diagnostic Test Alert',
@@ -1482,19 +1640,22 @@ def settings_test_notification():
'status': 'finished (Diagnostic Test Success)',
'size_bytes': 1532984025
}
-
+
webhook_error = None
email_error = None
-
+
if webhook_enabled and webhook_url:
try:
send_webhook_notification(webhook_url, webhook_type, test_run_data, raise_on_error=True)
except Exception as e:
webhook_error = str(e)
-
+
if smtp_enabled:
try:
- send_email_notification(smtp_settings, test_run_data, raise_on_error=True)
+ if mail_service == 'sendmail':
+ send_email_via_sendmail(sendmail_cfg, test_run_data, raise_on_error=True)
+ else:
+ send_email_notification(smtp_settings, test_run_data, raise_on_error=True)
except Exception as e:
email_error = str(e)
diff --git a/templates/settings.html b/templates/settings.html
index 1c67e41..19f4451 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -139,50 +139,78 @@
-
-
+
-
-
@@ -260,6 +288,18 @@
{% block scripts %}