Add SMTP notification support with WHM settings UI

Send email via curl SMTP when SMTP_HOST is configured, falling back
to system mail/sendmail when empty. NOTIFY_EMAIL now accepts
comma-separated addresses. WHM Settings page gets an SMTP card
with Send Test Email button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-04 03:46:47 +02:00
parent 84b2f464c9
commit fac7dc6c80
8 changed files with 388 additions and 18 deletions

View File

@@ -17,9 +17,17 @@ LOG_LEVEL="info" # debug, info, warn, error
LOG_RETAIN=90 # Days to keep log files LOG_RETAIN=90 # Days to keep log files
# ── Notifications ────────────────────────────────────────────── # ── Notifications ──────────────────────────────────────────────
NOTIFY_EMAIL="" # Email address for notifications (empty = disabled) NOTIFY_EMAIL="" # Comma-separated email addresses (empty = disabled)
NOTIFY_ON="failure" # always, failure, never NOTIFY_ON="failure" # always, failure, never
# ── SMTP Settings (optional) ─────────────────────────────────
SMTP_HOST="" # SMTP server hostname (empty = use system mail)
SMTP_PORT=587 # SMTP port (587=TLS/STARTTLS, 465=SSL, 25=none)
SMTP_USER="" # SMTP username
SMTP_PASSWORD="" # SMTP password
SMTP_FROM="" # From address (falls back to SMTP_USER)
SMTP_SECURITY="tls" # tls (STARTTLS), ssl (implicit), none
# ── Advanced ─────────────────────────────────────────────────── # ── Advanced ───────────────────────────────────────────────────
LOCK_FILE="/var/run/gniza.lock" LOCK_FILE="/var/run/gniza.lock"
SSH_TIMEOUT=30 # SSH connection timeout in seconds SSH_TIMEOUT=30 # SSH connection timeout in seconds

View File

@@ -21,6 +21,12 @@ load_config() {
LOG_RETAIN="${LOG_RETAIN:-$DEFAULT_LOG_RETAIN}" LOG_RETAIN="${LOG_RETAIN:-$DEFAULT_LOG_RETAIN}"
NOTIFY_EMAIL="${NOTIFY_EMAIL:-}" NOTIFY_EMAIL="${NOTIFY_EMAIL:-}"
NOTIFY_ON="${NOTIFY_ON:-$DEFAULT_NOTIFY_ON}" NOTIFY_ON="${NOTIFY_ON:-$DEFAULT_NOTIFY_ON}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_PORT="${SMTP_PORT:-$DEFAULT_SMTP_PORT}"
SMTP_USER="${SMTP_USER:-}"
SMTP_PASSWORD="${SMTP_PASSWORD:-}"
SMTP_FROM="${SMTP_FROM:-}"
SMTP_SECURITY="${SMTP_SECURITY:-$DEFAULT_SMTP_SECURITY}"
LOCK_FILE="${LOCK_FILE:-$DEFAULT_LOCK_FILE}" LOCK_FILE="${LOCK_FILE:-$DEFAULT_LOCK_FILE}"
SSH_TIMEOUT="${SSH_TIMEOUT:-$DEFAULT_SSH_TIMEOUT}" SSH_TIMEOUT="${SSH_TIMEOUT:-$DEFAULT_SSH_TIMEOUT}"
SSH_RETRIES="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" SSH_RETRIES="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}"
@@ -31,6 +37,7 @@ load_config() {
export TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS BWLIMIT RETENTION_COUNT export TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS BWLIMIT RETENTION_COUNT
export LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON export LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON
export SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD SMTP_FROM SMTP_SECURITY
export LOCK_FILE SSH_TIMEOUT SSH_RETRIES RSYNC_EXTRA_OPTS export LOCK_FILE SSH_TIMEOUT SSH_RETRIES RSYNC_EXTRA_OPTS
} }
@@ -50,6 +57,19 @@ validate_config() {
*) log_error "LOG_LEVEL must be debug|info|warn|error, got: $LOG_LEVEL"; ((errors++)) || true ;; *) log_error "LOG_LEVEL must be debug|info|warn|error, got: $LOG_LEVEL"; ((errors++)) || true ;;
esac esac
# SMTP validation (only when SMTP_HOST is set)
if [[ -n "${SMTP_HOST:-}" ]]; then
case "$SMTP_SECURITY" in
tls|ssl|none) ;;
*) log_error "SMTP_SECURITY must be tls|ssl|none, got: $SMTP_SECURITY"; ((errors++)) || true ;;
esac
if [[ -n "${SMTP_PORT:-}" ]] && { [[ ! "$SMTP_PORT" =~ ^[0-9]+$ ]] || (( SMTP_PORT < 1 || SMTP_PORT > 65535 )); }; then
log_error "SMTP_PORT must be 1-65535, got: $SMTP_PORT"
((errors++)) || true
fi
fi
if (( errors > 0 )); then if (( errors > 0 )); then
log_error "Configuration has $errors error(s)" log_error "Configuration has $errors error(s)"
return 1 return 1

View File

@@ -48,4 +48,6 @@ readonly DEFAULT_SSH_TIMEOUT=30
readonly DEFAULT_SSH_RETRIES=3 readonly DEFAULT_SSH_RETRIES=3
readonly DEFAULT_REMOTE_TYPE="ssh" readonly DEFAULT_REMOTE_TYPE="ssh"
readonly DEFAULT_S3_REGION="us-east-1" readonly DEFAULT_S3_REGION="us-east-1"
readonly DEFAULT_SMTP_PORT=587
readonly DEFAULT_SMTP_SECURITY="tls"
readonly DEFAULT_CONFIG_FILE="/etc/gniza/gniza.conf" readonly DEFAULT_CONFIG_FILE="/etc/gniza/gniza.conf"

View File

@@ -1,5 +1,105 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza/lib/notify.sh — Email notifications # gniza/lib/notify.sh — Email notifications (SMTP via curl or legacy mail/sendmail)
_send_via_smtp() {
local subject="$1"
local body="$2"
local from="${SMTP_FROM:-$SMTP_USER}"
if [[ -z "$from" ]]; then
log_error "SMTP_FROM or SMTP_USER must be set for SMTP delivery"
return 1
fi
# Build the RFC 2822 message
local message=""
message+="From: $from"$'\r\n'
message+="To: $NOTIFY_EMAIL"$'\r\n'
message+="Subject: $subject"$'\r\n'
message+="Content-Type: text/plain; charset=UTF-8"$'\r\n'
message+="Date: $(date -R)"$'\r\n'
message+=$'\r\n'
message+="$body"
# Build curl command
local -a curl_args=(
'curl' '--silent' '--show-error'
'--connect-timeout' '30'
'--max-time' '60'
)
# Protocol URL based on security setting
case "${SMTP_SECURITY:-tls}" in
ssl)
curl_args+=("--url" "smtps://${SMTP_HOST}:${SMTP_PORT}")
;;
tls)
curl_args+=("--url" "smtp://${SMTP_HOST}:${SMTP_PORT}" "--ssl-reqd")
;;
none)
curl_args+=("--url" "smtp://${SMTP_HOST}:${SMTP_PORT}")
;;
esac
# Auth credentials
if [[ -n "${SMTP_USER:-}" ]]; then
curl_args+=("--user" "${SMTP_USER}:${SMTP_PASSWORD}")
fi
# Sender
curl_args+=("--mail-from" "$from")
# Recipients (split NOTIFY_EMAIL on commas)
local IFS=','
local -a recipients=($NOTIFY_EMAIL)
unset IFS
local rcpt
for rcpt in "${recipients[@]}"; do
rcpt="${rcpt## }" # trim leading space
rcpt="${rcpt%% }" # trim trailing space
[[ -n "$rcpt" ]] && curl_args+=("--mail-rcpt" "$rcpt")
done
# Upload the message from stdin
curl_args+=("-T" "-")
log_debug "Sending via SMTP to ${SMTP_HOST}:${SMTP_PORT} (${SMTP_SECURITY})"
local curl_output
curl_output=$(echo "$message" | "${curl_args[@]}" 2>&1)
local rc=$?
if (( rc == 0 )); then
return 0
else
log_error "SMTP delivery failed (curl exit code: $rc): $curl_output"
return 1
fi
}
_send_via_legacy() {
local subject="$1"
local body="$2"
# Convert comma-separated to space-separated for mail command
local recipients="${NOTIFY_EMAIL//,/ }"
if command -v mail &>/dev/null; then
echo "$body" | mail -s "$subject" $recipients
elif command -v sendmail &>/dev/null; then
{
echo "To: $NOTIFY_EMAIL"
echo "Subject: $subject"
echo "Content-Type: text/plain; charset=UTF-8"
echo ""
echo "$body"
} | sendmail -t
else
log_warn "No mail command available, cannot send notification"
return 1
fi
return 0
}
send_notification() { send_notification() {
local subject="$1" local subject="$1"
@@ -20,23 +120,17 @@ send_notification() {
log_debug "Sending notification to $NOTIFY_EMAIL: $full_subject" log_debug "Sending notification to $NOTIFY_EMAIL: $full_subject"
if command -v mail &>/dev/null; then if [[ -n "${SMTP_HOST:-}" ]]; then
echo "$body" | mail -s "$full_subject" "$NOTIFY_EMAIL" _send_via_smtp "$full_subject" "$body"
elif command -v sendmail &>/dev/null; then
{
echo "To: $NOTIFY_EMAIL"
echo "Subject: $full_subject"
echo "Content-Type: text/plain; charset=UTF-8"
echo ""
echo "$body"
} | sendmail -t
else else
log_warn "No mail command available, cannot send notification" _send_via_legacy "$full_subject" "$body"
return 1
fi fi
local rc=$?
if (( rc == 0 )); then
log_debug "Notification sent" log_debug "Notification sent"
return 0 fi
return $rc
} }
send_backup_report() { send_backup_report() {

View File

@@ -9,6 +9,7 @@ use Fcntl qw(:flock);
our @MAIN_KEYS = qw( our @MAIN_KEYS = qw(
TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS
RSYNC_EXTRA_OPTS LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON RSYNC_EXTRA_OPTS LOG_DIR LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON
SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD SMTP_FROM SMTP_SECURITY
LOCK_FILE SSH_TIMEOUT SSH_RETRIES LOCK_FILE SSH_TIMEOUT SSH_RETRIES
); );
@@ -67,7 +68,7 @@ sub escape_value {
} }
# Keys whose values are written with single quotes (preserves special chars). # Keys whose values are written with single quotes (preserves special chars).
my %SINGLE_QUOTE_KEYS = (REMOTE_PASSWORD => 1, S3_SECRET_ACCESS_KEY => 1); my %SINGLE_QUOTE_KEYS = (REMOTE_PASSWORD => 1, S3_SECRET_ACCESS_KEY => 1, SMTP_PASSWORD => 1);
# escape_password($string) # escape_password($string)
# For single-quoted bash values: only strip single quotes (can't appear in single-quoted strings). # For single-quoted bash values: only strip single quotes (can't appear in single-quoted strings).

View File

@@ -525,6 +525,93 @@ sub init_remote_dir {
return (0, "Unknown remote type: $type"); return (0, "Unknown remote type: $type");
} }
# ── SMTP Connection Test ──────────────────────────────────────
sub test_smtp_connection {
my (%args) = @_;
my $host = $args{host} // '';
my $port = $args{port} || '587';
my $user = $args{user} // '';
my $password = $args{password} // '';
my $from = $args{from} // $user;
my $security = $args{security} || 'tls';
my $to = $args{to} // '';
return (0, 'SMTP host is required') if $host eq '';
return (0, 'Recipient email is required') if $to eq '';
return (0, 'From address or SMTP user is required') if $from eq '';
# Build the test email
chomp(my $hostname = `hostname -f 2>/dev/null` || `hostname`);
my $date = `date -R`; chomp $date;
my $message = "From: $from\r\nTo: $to\r\nSubject: [gniza] SMTP Test from $hostname\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\nDate: $date\r\n\r\n"
. "This is a test email from gniza on $hostname.\r\n"
. "If you received this, SMTP is configured correctly.\r\n";
# Write message to temp file
my $tmpfile = "/tmp/gniza-smtp-test-$$.eml";
if (open my $fh, '>', $tmpfile) {
print $fh $message;
close $fh;
} else {
return (0, "Failed to write temp file: $!");
}
# Build curl command
my @cmd = ('curl', '--silent', '--show-error', '--connect-timeout', '30', '--max-time', '60');
if ($security eq 'ssl') {
push @cmd, '--url', "smtps://$host:$port";
} elsif ($security eq 'tls') {
push @cmd, '--url', "smtp://$host:$port", '--ssl-reqd';
} else {
push @cmd, '--url', "smtp://$host:$port";
}
if ($user ne '') {
push @cmd, '--user', "$user:$password";
}
push @cmd, '--mail-from', $from;
# Support multiple recipients
my @recipients = split /\s*,\s*/, $to;
for my $rcpt (@recipients) {
next if $rcpt eq '';
push @cmd, '--mail-rcpt', $rcpt;
}
push @cmd, '-T', $tmpfile;
my ($in, $out, $err_fh) = (undef, undef, gensym);
my $pid = eval { open3($in, $out, $err_fh, @cmd) };
unless ($pid) {
unlink $tmpfile;
return (0, "Failed to run curl: $@");
}
close $in if $in;
my $stdout = do { local $/; <$out> } // '';
my $stderr = do { local $/; <$err_fh> } // '';
close $out;
close $err_fh;
waitpid($pid, 0);
my $exit_code = $? >> 8;
unlink $tmpfile;
chomp $stderr;
if ($exit_code == 0) {
return (1, undef);
}
my $msg = $stderr || "curl exited with code $exit_code";
return (0, $msg);
}
# ── Page Wrappers ──────────────────────────────────────────── # ── Page Wrappers ────────────────────────────────────────────
sub page_header { sub page_header {

View File

@@ -71,6 +71,42 @@ sub validate_main_config {
} }
} }
# SMTP validation (only when SMTP_HOST is set)
if (defined $data->{SMTP_HOST} && $data->{SMTP_HOST} ne '') {
if ($data->{SMTP_HOST} !~ /^[a-zA-Z0-9._-]+$/) {
push @errors, 'SMTP_HOST contains invalid characters';
}
if (defined $data->{SMTP_PORT} && $data->{SMTP_PORT} ne '') {
if ($data->{SMTP_PORT} !~ /^\d+$/ || $data->{SMTP_PORT} < 1 || $data->{SMTP_PORT} > 65535) {
push @errors, 'SMTP_PORT must be 1-65535';
}
}
if (defined $data->{SMTP_SECURITY} && $data->{SMTP_SECURITY} ne '') {
unless ($data->{SMTP_SECURITY} =~ /^(tls|ssl|none)$/) {
push @errors, 'SMTP_SECURITY must be tls, ssl, or none';
}
}
if (defined $data->{SMTP_FROM} && $data->{SMTP_FROM} ne '') {
unless ($data->{SMTP_FROM} =~ /^[^\s@]+\@[^\s@]+\.[^\s@]+$/) {
push @errors, 'SMTP_FROM must be a valid email address';
}
}
}
# Validate NOTIFY_EMAIL addresses (comma-separated)
if (defined $data->{NOTIFY_EMAIL} && $data->{NOTIFY_EMAIL} ne '') {
my @addrs = split /\s*,\s*/, $data->{NOTIFY_EMAIL};
for my $addr (@addrs) {
next if $addr eq '';
unless ($addr =~ /^[^\s@]+\@[^\s@]+\.[^\s@]+$/) {
push @errors, "Invalid email address: $addr";
}
}
}
# Filter out empty strings from helper returns # Filter out empty strings from helper returns
return [grep { $_ ne '' } @errors]; return [grep { $_ ne '' } @errors];
} }

View File

@@ -14,6 +14,53 @@ use GnizaWHM::UI;
my $CONFIG_FILE = '/etc/gniza/gniza.conf'; my $CONFIG_FILE = '/etc/gniza/gniza.conf';
my $form = Cpanel::Form::parseform(); my $form = Cpanel::Form::parseform();
my $method = $ENV{'REQUEST_METHOD'} // 'GET'; my $method = $ENV{'REQUEST_METHOD'} // 'GET';
my $action = $form->{'action'} // '';
# ── Handle SMTP Test (JSON) ──────────────────────────────────
if ($action eq 'test_smtp') {
print "Content-Type: application/json\r\n\r\n";
my $host = $form->{'SMTP_HOST'} // '';
my $port = $form->{'SMTP_PORT'} || '587';
my $user = $form->{'SMTP_USER'} // '';
my $password = $form->{'SMTP_PASSWORD'} // '';
my $from = $form->{'SMTP_FROM'} // '';
my $security = $form->{'SMTP_SECURITY'} || 'tls';
my $to = $form->{'NOTIFY_EMAIL'} // '';
if ($host eq '') {
print qq({"success":false,"message":"SMTP Host is required."});
exit;
}
if ($to eq '') {
print qq({"success":false,"message":"Notification email is required for test."});
exit;
}
my ($ok, $err) = GnizaWHM::UI::test_smtp_connection(
host => $host,
port => $port,
user => $user,
password => $password,
from => $from,
security => $security,
to => $to,
);
if ($ok) {
print qq({"success":true,"message":"Test email sent successfully. Check your inbox."});
} else {
$err //= 'Unknown error';
$err =~ s/\\/\\\\/g;
$err =~ s/"/\\"/g;
$err =~ s/\n/\\n/g;
$err =~ s/\r/\\r/g;
$err =~ s/\t/\\t/g;
$err =~ s/[\x00-\x1f]//g;
print qq({"success":false,"message":"SMTP test failed: $err"});
}
exit;
}
# ── Handle POST ────────────────────────────────────────────── # ── Handle POST ──────────────────────────────────────────────
@@ -147,10 +194,34 @@ print qq{</div>\n</div>\n};
# Section: Notifications # Section: Notifications
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n}; print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Notifications</h2>\n}; print qq{<h2 class="card-title text-sm">Notifications</h2>\n};
field_text('NOTIFY_EMAIL', 'Email Address', 'Empty = disabled'); field_text('NOTIFY_EMAIL', 'Email Address(es)', 'Comma-separated, empty = disabled');
field_select('NOTIFY_ON', 'Notify On', ['always', 'failure', 'never']); field_select('NOTIFY_ON', 'Notify On', ['always', 'failure', 'never']);
print qq{</div>\n</div>\n}; print qq{</div>\n</div>\n};
# Section: SMTP Settings
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">SMTP Settings</h2>\n};
print qq{<p class="text-xs text-base-content/60 mb-3">Optional. When SMTP Host is empty, system mail/sendmail is used.</p>\n};
field_text('SMTP_HOST', 'SMTP Host', 'e.g. smtp.gmail.com');
field_text('SMTP_PORT', 'SMTP Port', 'Default: 587');
field_text('SMTP_USER', 'SMTP Username', 'e.g. user@gmail.com');
# SMTP Password (type=password)
my $smtp_pw_val = GnizaWHM::UI::esc($conf->{SMTP_PASSWORD} // '');
print qq{<div class="flex items-center gap-3 mb-2.5">\n};
print qq{ <label class="w-44 font-medium text-sm" for="SMTP_PASSWORD">SMTP Password</label>\n};
print qq{ <input type="password" class="input input-bordered input-sm w-full max-w-xs" id="SMTP_PASSWORD" name="SMTP_PASSWORD" value="$smtp_pw_val">\n};
print qq{</div>\n};
field_text('SMTP_FROM', 'From Address', 'Falls back to SMTP Username');
field_select('SMTP_SECURITY', 'Security', ['tls', 'ssl', 'none']);
print qq{<div class="flex gap-2 mt-3">\n};
print qq{ <button type="button" class="btn btn-secondary btn-sm" id="test-smtp-btn" onclick="gnizaTestSmtp()">Send Test Email</button>\n};
print qq{</div>\n};
print qq{<div id="gniza-smtp-alert" class="mt-3"></div>\n};
print qq{</div>\n</div>\n};
# Section: Advanced # Section: Advanced
print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n}; print qq{<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">\n<div class="card-body">\n};
print qq{<h2 class="card-title text-sm">Advanced</h2>\n}; print qq{<h2 class="card-title text-sm">Advanced</h2>\n};
@@ -167,5 +238,56 @@ print qq{</div>\n};
print qq{</form>\n}; print qq{</form>\n};
print <<'JS';
<script>
function gnizaTestSmtp() {
var btn = document.getElementById('test-smtp-btn');
var fd = new FormData();
fd.append('action', 'test_smtp');
fd.append('SMTP_HOST', document.getElementById('SMTP_HOST').value);
fd.append('SMTP_PORT', document.getElementById('SMTP_PORT').value);
fd.append('SMTP_USER', document.getElementById('SMTP_USER').value);
fd.append('SMTP_PASSWORD', document.getElementById('SMTP_PASSWORD').value);
fd.append('SMTP_FROM', document.getElementById('SMTP_FROM').value);
fd.append('SMTP_SECURITY', document.getElementById('SMTP_SECURITY').value);
fd.append('NOTIFY_EMAIL', document.getElementById('NOTIFY_EMAIL').value);
var email = document.getElementById('NOTIFY_EMAIL').value;
var host = document.getElementById('SMTP_HOST').value;
if (!host) { gnizaSmtpToast('error', 'SMTP Host is required.'); return; }
if (!email) { gnizaSmtpToast('error', 'Notification email is required for test.'); return; }
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Sending\u2026';
fetch('settings.cgi', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
gnizaSmtpToast(data.success ? 'success' : 'error', data.message);
})
.catch(function(err) {
gnizaSmtpToast('error', 'Request failed: ' + err.toString());
})
.finally(function() {
btn.disabled = false;
btn.innerHTML = 'Send Test Email';
});
}
function gnizaSmtpToast(type, msg) {
var area = document.getElementById('gniza-smtp-alert');
if (!area) return;
area.innerHTML = '';
var el = document.createElement('div');
el.className = 'alert alert-' + type;
el.style.cssText = 'padding:12px 20px;border-radius:8px;font-size:14px';
el.textContent = msg;
area.appendChild(el);
setTimeout(function() { el.style.opacity = '0'; }, type === 'error' ? 6000 : 3000);
setTimeout(function() { area.innerHTML = ''; }, type === 'error' ? 6500 : 3500);
}
</script>
JS
print GnizaWHM::UI::page_footer(); print GnizaWHM::UI::page_footer();
Whostmgr::HTMLInterface::footer(); Whostmgr::HTMLInterface::footer();