diff --git a/etc/gniza.conf.example b/etc/gniza.conf.example index b87a35a..b724587 100644 --- a/etc/gniza.conf.example +++ b/etc/gniza.conf.example @@ -17,9 +17,17 @@ LOG_LEVEL="info" # debug, info, warn, error LOG_RETAIN=90 # Days to keep log files # ── Notifications ────────────────────────────────────────────── -NOTIFY_EMAIL="" # Email address for notifications (empty = disabled) +NOTIFY_EMAIL="" # Comma-separated email addresses (empty = disabled) 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 ─────────────────────────────────────────────────── LOCK_FILE="/var/run/gniza.lock" SSH_TIMEOUT=30 # SSH connection timeout in seconds diff --git a/lib/config.sh b/lib/config.sh index 226e605..388c127 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -21,6 +21,12 @@ load_config() { LOG_RETAIN="${LOG_RETAIN:-$DEFAULT_LOG_RETAIN}" NOTIFY_EMAIL="${NOTIFY_EMAIL:-}" 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}" SSH_TIMEOUT="${SSH_TIMEOUT:-$DEFAULT_SSH_TIMEOUT}" SSH_RETRIES="${SSH_RETRIES:-$DEFAULT_SSH_RETRIES}" @@ -31,6 +37,7 @@ load_config() { export TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS BWLIMIT RETENTION_COUNT 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 } @@ -50,6 +57,19 @@ validate_config() { *) log_error "LOG_LEVEL must be debug|info|warn|error, got: $LOG_LEVEL"; ((errors++)) || true ;; 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 log_error "Configuration has $errors error(s)" return 1 diff --git a/lib/constants.sh b/lib/constants.sh index ef8cb20..3ea5fa1 100644 --- a/lib/constants.sh +++ b/lib/constants.sh @@ -48,4 +48,6 @@ readonly DEFAULT_SSH_TIMEOUT=30 readonly DEFAULT_SSH_RETRIES=3 readonly DEFAULT_REMOTE_TYPE="ssh" 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" diff --git a/lib/notify.sh b/lib/notify.sh index 309858a..9a18827 100644 --- a/lib/notify.sh +++ b/lib/notify.sh @@ -1,5 +1,105 @@ #!/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() { local subject="$1" @@ -20,23 +120,17 @@ send_notification() { log_debug "Sending notification to $NOTIFY_EMAIL: $full_subject" - if command -v mail &>/dev/null; then - echo "$body" | mail -s "$full_subject" "$NOTIFY_EMAIL" - 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 + if [[ -n "${SMTP_HOST:-}" ]]; then + _send_via_smtp "$full_subject" "$body" else - log_warn "No mail command available, cannot send notification" - return 1 + _send_via_legacy "$full_subject" "$body" fi - log_debug "Notification sent" - return 0 + local rc=$? + if (( rc == 0 )); then + log_debug "Notification sent" + fi + return $rc } send_backup_report() { diff --git a/whm/gniza-whm/lib/GnizaWHM/Config.pm b/whm/gniza-whm/lib/GnizaWHM/Config.pm index 356bd9a..15bc60b 100644 --- a/whm/gniza-whm/lib/GnizaWHM/Config.pm +++ b/whm/gniza-whm/lib/GnizaWHM/Config.pm @@ -9,6 +9,7 @@ use Fcntl qw(:flock); our @MAIN_KEYS = qw( TEMP_DIR INCLUDE_ACCOUNTS EXCLUDE_ACCOUNTS 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 ); @@ -67,7 +68,7 @@ sub escape_value { } # 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) # For single-quoted bash values: only strip single quotes (can't appear in single-quoted strings). diff --git a/whm/gniza-whm/lib/GnizaWHM/UI.pm b/whm/gniza-whm/lib/GnizaWHM/UI.pm index ea4ff16..716a367 100644 --- a/whm/gniza-whm/lib/GnizaWHM/UI.pm +++ b/whm/gniza-whm/lib/GnizaWHM/UI.pm @@ -525,6 +525,93 @@ sub init_remote_dir { 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 ──────────────────────────────────────────── sub page_header { diff --git a/whm/gniza-whm/lib/GnizaWHM/Validator.pm b/whm/gniza-whm/lib/GnizaWHM/Validator.pm index 6392c7e..4c8b67f 100644 --- a/whm/gniza-whm/lib/GnizaWHM/Validator.pm +++ b/whm/gniza-whm/lib/GnizaWHM/Validator.pm @@ -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 return [grep { $_ ne '' } @errors]; } diff --git a/whm/gniza-whm/settings.cgi b/whm/gniza-whm/settings.cgi index 66259b9..ee0825f 100644 --- a/whm/gniza-whm/settings.cgi +++ b/whm/gniza-whm/settings.cgi @@ -14,6 +14,53 @@ use GnizaWHM::UI; my $CONFIG_FILE = '/etc/gniza/gniza.conf'; my $form = Cpanel::Form::parseform(); 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 ────────────────────────────────────────────── @@ -147,10 +194,34 @@ print qq{\n\n}; # Section: Notifications print qq{
\n
\n}; print qq{

Notifications

\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']); print qq{
\n
\n}; +# Section: SMTP Settings +print qq{
\n
\n}; +print qq{

SMTP Settings

\n}; +print qq{

Optional. When SMTP Host is empty, system mail/sendmail is used.

\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{
\n}; +print qq{ \n}; +print qq{ \n}; +print qq{
\n}; + +field_text('SMTP_FROM', 'From Address', 'Falls back to SMTP Username'); +field_select('SMTP_SECURITY', 'Security', ['tls', 'ssl', 'none']); + +print qq{
\n}; +print qq{ \n}; +print qq{
\n}; +print qq{
\n}; +print qq{
\n
\n}; + # Section: Advanced print qq{
\n
\n}; print qq{

Advanced

\n}; @@ -167,5 +238,56 @@ print qq{
\n}; print qq{\n}; +print <<'JS'; + +JS + print GnizaWHM::UI::page_footer(); Whostmgr::HTMLInterface::footer();