Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/controllers/concerns/rate_limiting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ module RateLimiting
# Default rate limit configuration
RATE_LIMIT_CONFIG = {
recheck: { limit: 3, window: 1.hour },
retry: { limit: 5, window: 1.hour },
dns_query: { limit: 10, window: 1.minute },
api_call: { limit: 60, window: 1.minute }
}.freeze
Expand All @@ -36,6 +37,17 @@ def rate_limit_recheck
)
end

# Rate limit the retry action (SMTP tests are expensive)
# Limits: 5 requests per hour per record per user
def rate_limit_retry
rate_limit(
action: "retry",
scope: [@record.id, current_user.id],
limit: RATE_LIMIT_CONFIG[:retry][:limit],
window: RATE_LIMIT_CONFIG[:retry][:window]
)
end

# Generic rate limiting method
#
# @param action [String] The action being rate limited
Expand Down
79 changes: 78 additions & 1 deletion app/controllers/ip_blacklist_records_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ class IPBlacklistRecordsController < ApplicationController
include RateLimiting

before_action :admin_required
before_action :load_record, only: [:show, :resolve, :ignore, :recheck]
before_action :load_record, only: [:show, :resolve, :ignore, :recheck, :retry_now]
before_action :rate_limit_recheck, only: [:recheck]
before_action :rate_limit_retry, only: [:retry_now]

# GET /ip_blacklist_records
# List all blacklist records with filtering
Expand Down Expand Up @@ -159,6 +160,82 @@ def recheck
end
end

# POST /ip_blacklist_records/:id/retry_now
# Manually trigger an immediate retry test for SMTP-detected blacklists
def retry_now
# Verify this is an SMTP-detected blacklist
unless @record.detected_via_smtp?
message = "Retry is only available for SMTP-detected blacklists"
return respond_to do |format|
format.html { redirect_back fallback_location: ip_blacklist_record_path(@record), alert: message }
format.json { render json: { error: message }, status: :unprocessable_content }
end
end

# Verify record is still active
unless @record.active?
message = "Cannot retry: blacklist record is already #{@record.status}"
return respond_to do |format|
format.html { redirect_back fallback_location: ip_blacklist_record_path(@record), alert: message }
format.json { render json: { error: message }, status: :unprocessable_content }
end
end

# Log manual retry trigger
Rails.logger.info "[BLACKLIST RETRY] Manual retry triggered by #{current_user.name} for record #{@record.id} (IP #{@record.ip_address.ipv4}, domain #{@record.destination_domain})"

# Perform retry
begin
retry_service = IPBlacklist::RetryService.new(@record)
result = retry_service.perform_retry

case result
when :success
message = "✓ Retry successful! IP #{@record.ip_address.ipv4} is no longer blacklisted for #{@record.destination_domain}. Warmup process started."
notice_type = :notice
when :failed
message = "✗ Retry failed: IP #{@record.ip_address.ipv4} is still blacklisted for #{@record.destination_domain}. Next automatic retry scheduled for #{@record.next_retry_at&.strftime('%Y-%m-%d %H:%M')}. Reason: #{retry_service.error_message}"
notice_type = :alert
when :error
message = "✗ Retry error: #{retry_service.error_message}. Next automatic retry scheduled for #{@record.next_retry_at&.strftime('%Y-%m-%d %H:%M')}."
notice_type = :alert
end

respond_to do |format|
format.html { redirect_back fallback_location: ip_blacklist_record_path(@record), notice_type => message }
format.json do
render json: {
success: result == :success,
result: result,
message: message,
next_retry_at: @record.next_retry_at,
record: {
id: @record.id,
status: @record.reload.status,
retry_count: @record.retry_count,
retry_result: @record.retry_result,
last_retry_at: @record.last_retry_at
}
}
end
end
rescue StandardError => e
# Log detailed error
error_id = SecureRandom.uuid
Rails.logger.error "[BLACKLIST RETRY] Error ID: #{error_id}"
Rails.logger.error "[BLACKLIST RETRY] Record ID: #{@record.id}, User: #{current_user.name}"
Rails.logger.error "[BLACKLIST RETRY] #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")

error_message = "Retry failed due to an error. Please try again later. (Error ID: #{error_id})"

respond_to do |format|
format.html { redirect_back fallback_location: ip_blacklist_record_path(@record), alert: error_message }
format.json { render json: { error: error_message, error_id: error_id }, status: :internal_server_error }
end
end
end

private

def load_record
Expand Down
14 changes: 12 additions & 2 deletions app/controllers/ip_reputation_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,18 @@ def calculate_dashboard_stats

def count_healthy_ips
total = IPAddress.count
problematic = IPDomainExclusion.select(:ip_address_id).distinct.count +
IPBlacklistRecord.active.select(:ip_address_id).distinct.count

# Use UNION to get distinct IPs that have either blacklist records OR domain exclusions
# This avoids double-counting IPs that have both
blacklisted_ids = IPBlacklistRecord.active.select(:ip_address_id)
excluded_ids = IPDomainExclusion.select(:ip_address_id)

problematic = IPAddress
.where(id: blacklisted_ids)
.or(IPAddress.where(id: excluded_ids))
.distinct
.count

[total - problematic, 0].max
end

Expand Down
3 changes: 3 additions & 0 deletions app/lib/ip_blacklist/ip_health_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ def handle_smtp_rejection(ip_address, destination_domain, parsed_response, smtp_

Rails.logger.warn "[SMTP REJECTION] Created blacklist record for IP #{ip_address.ipv4} - source: #{parsed_response[:blacklist_source]}"

# Schedule automatic retry for SMTP-detected blacklists (2 days from now)
blacklist_record.schedule_retry!

# Handle the blacklist using existing logic
handle_blacklist_detected(blacklist_record)
else
Expand Down
61 changes: 61 additions & 0 deletions app/lib/ip_blacklist/notifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,61 @@ def notify_warmup_advanced(ip_address, domain, old_stage, new_stage)
send_notifications(event)
end

# Notify when retry test succeeds
def notify_retry_success(blacklist_record, test_result)
event = {
event_type: "blacklist_retry_success",
severity: "info",
ip_address: blacklist_record.ip_address.ipv4,
hostname: blacklist_record.ip_address.hostname,
destination_domain: blacklist_record.destination_domain,
blacklist_source: blacklist_record.blacklist_source,
retry_count: blacklist_record.retry_count,
test_result: test_result[:reason],
timestamp: Time.current.iso8601
}

send_notifications(event)
end

# Notify when retry test fails
def notify_retry_failed(blacklist_record, test_result)
event = {
event_type: "blacklist_retry_failed",
severity: "medium",
ip_address: blacklist_record.ip_address.ipv4,
hostname: blacklist_record.ip_address.hostname,
destination_domain: blacklist_record.destination_domain,
blacklist_source: blacklist_record.blacklist_source,
retry_count: blacklist_record.retry_count,
next_retry_at: blacklist_record.next_retry_at&.iso8601,
test_result: test_result[:reason],
smtp_code: test_result[:smtp_code],
smtp_message: test_result[:smtp_message],
timestamp: Time.current.iso8601
}

send_notifications(event)
end

# Notify when retry test encounters an error
def notify_retry_error(blacklist_record, exception)
event = {
event_type: "blacklist_retry_error",
severity: "medium",
ip_address: blacklist_record.ip_address.ipv4,
hostname: blacklist_record.ip_address.hostname,
destination_domain: blacklist_record.destination_domain,
blacklist_source: blacklist_record.blacklist_source,
retry_count: blacklist_record.retry_count,
next_retry_at: blacklist_record.next_retry_at&.iso8601,
error: exception.message,
timestamp: Time.current.iso8601
}

send_notifications(event)
end

private

def send_notifications(event)
Expand Down Expand Up @@ -224,6 +279,12 @@ def format_slack_message(event)
text = "⚠️ IP #{event[:ip_address]} reputation warning: #{event[:metric_type]} = #{event[:metric_value]} (threshold: #{event[:threshold]})"
when "warmup_advanced"
text = "📈 IP #{event[:ip_address]} warmup advanced from stage #{event[:old_stage]} to #{event[:new_stage]} for domain #{event[:destination_domain]}"
when "blacklist_retry_success"
text = "✅ IP #{event[:ip_address]} retry successful! No longer blacklisted on #{event[:blacklist_source]} for #{event[:destination_domain]}"
when "blacklist_retry_failed"
text = "❌ IP #{event[:ip_address]} retry failed for #{event[:blacklist_source]} on #{event[:destination_domain]}. Next retry: #{event[:next_retry_at]}"
when "blacklist_retry_error"
text = "⚠️ IP #{event[:ip_address]} retry error for #{event[:blacklist_source]} on #{event[:destination_domain]}: #{event[:error]}"
else
text = "IP Health Event: #{event[:event_type]}"
end
Expand Down
Loading