مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2026-01-16 21:23:37 +00:00
feat: new background work process
This removes all previous dependencies on RabbitMQ and the need to run separate cron and requeueing processes.
هذا الالتزام موجود في:
@@ -116,7 +116,7 @@ class MessagesController < ApplicationController
|
||||
def retry
|
||||
if @message.raw_message?
|
||||
if @message.queued_message
|
||||
@message.queued_message.queue!
|
||||
@message.queued_message.retry_now
|
||||
flash[:notice] = "This message will be retried shortly."
|
||||
elsif @message.held?
|
||||
@message.add_to_message_queue(manual: true)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActionDeletionJob < Postal::Job
|
||||
|
||||
def perform
|
||||
object = params["type"].constantize.deleted.find_by_id(params["id"])
|
||||
if object
|
||||
log "Deleting #{params['type']}##{params['id']}"
|
||||
object.destroy
|
||||
log "Deleted #{params['type']}##{params['id']}"
|
||||
else
|
||||
log "Couldn't find deleted object #{params['type']}##{params['id']}"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,17 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActionDeletionsJob < Postal::Job
|
||||
|
||||
def perform
|
||||
Organization.deleted.each do |org|
|
||||
log "Permanently removing organization #{org.id} (#{org.permalink})"
|
||||
org.destroy
|
||||
end
|
||||
|
||||
Server.deleted.each do |server|
|
||||
log "Permanently removing server #{server.id} (#{server.full_permalink})"
|
||||
server.destroy
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PruneSuppressionListsJob < Postal::Job
|
||||
|
||||
def perform
|
||||
Server.all.each do |s|
|
||||
log "Pruning suppression lists for server #{s.id}"
|
||||
s.message_db.suppression_list.prune
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PruneWebhookRequestsJob < Postal::Job
|
||||
|
||||
def perform
|
||||
Server.all.each do |s|
|
||||
log "Pruning webhook requests for server #{s.id}"
|
||||
s.message_db.webhooks.prune
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RequeueWebhooksJob < Postal::Job
|
||||
|
||||
def perform
|
||||
WebhookRequest.requeue_all
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendNotificationsJob < Postal::Job
|
||||
|
||||
def perform
|
||||
Server.send_send_limit_notifications
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,29 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendWebhookJob < Postal::Job
|
||||
|
||||
def perform
|
||||
if server = Server.find(params["server_id"])
|
||||
new_items = {}
|
||||
params["payload"]&.each do |key, value|
|
||||
next unless key.to_s =~ /\A_(\w+)/
|
||||
|
||||
begin
|
||||
new_items[::Regexp.last_match(1)] = server.message_db.message(value.to_i).webhook_hash
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
# No message found, don't do any replacement
|
||||
end
|
||||
end
|
||||
|
||||
new_items.each do |key, value|
|
||||
params["payload"].delete("_#{key}")
|
||||
params["payload"][key] = value
|
||||
end
|
||||
|
||||
WebhookRequest.trigger(server, params["event"], params["payload"])
|
||||
else
|
||||
log "Couldn't find server with ID #{params['server_id']}"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SleepJob < Postal::Job
|
||||
|
||||
def perform
|
||||
sleep 5
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TidyRawMessagesJob < Postal::Job
|
||||
|
||||
def perform
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,468 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnqueueMessageJob < Postal::Job
|
||||
|
||||
# rubocop:disable Layout/LineLength
|
||||
def perform
|
||||
if original_message = QueuedMessage.find_by_id(params["id"])
|
||||
if original_message.acquire_lock
|
||||
|
||||
log "Lock acquired for queued message #{original_message.id}"
|
||||
|
||||
begin
|
||||
original_message.message
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
log "Unqueue #{original_message.id} because backend message has been removed."
|
||||
original_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
unless original_message.retriable?
|
||||
log "Skipping because retry after isn't reached"
|
||||
original_message.unlock
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
other_messages = original_message.batchable_messages(100)
|
||||
log "Found #{other_messages.size} associated messages to process at the same time (batch key: #{original_message.batch_key})"
|
||||
rescue StandardError
|
||||
original_message.unlock
|
||||
raise
|
||||
end
|
||||
|
||||
([original_message] + other_messages).each do |queued_message|
|
||||
log_prefix = "[#{queued_message.server_id}::#{queued_message.message_id} #{queued_message.id}]"
|
||||
begin
|
||||
log "#{log_prefix} Got queued message with exclusive lock"
|
||||
|
||||
begin
|
||||
queued_message.message
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
log "#{log_prefix} Unqueueing #{queued_message.id} because backend message has been removed"
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
#
|
||||
# If the server is suspended, hold all messages
|
||||
#
|
||||
if queued_message.server.suspended?
|
||||
log "#{log_prefix} Server is suspended. Holding message."
|
||||
queued_message.message.create_delivery("Held", details: "Mail server has been suspended. No e-mails can be processed at present. Contact support for assistance.")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
# We might not be able to send this any more, check the attempts
|
||||
if queued_message.attempts >= Postal.config.general.maximum_delivery_attempts
|
||||
details = "Maximum number of delivery attempts (#{queued_message.attempts}) has been reached."
|
||||
if queued_message.message.scope == "incoming"
|
||||
# Send bounces to incoming e-mails when they are hard failed
|
||||
if bounce_id = queued_message.send_bounce
|
||||
details += " Bounce sent to sender (see message <msg:#{bounce_id}>)"
|
||||
end
|
||||
elsif queued_message.message.scope == "outgoing"
|
||||
# Add the recipient to the suppression list
|
||||
if queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to, reason: "too many soft fails")
|
||||
log "Added #{queued_message.message.rcpt_to} to suppression list because maximum attempts has been reached"
|
||||
details += " Added #{queued_message.message.rcpt_to} to suppression list because delivery has failed #{queued_message.attempts} times."
|
||||
end
|
||||
end
|
||||
queued_message.message.create_delivery("HardFail", details: details)
|
||||
queued_message.destroy
|
||||
log "#{log_prefix} Message has reached maximum number of attempts. Hard failing."
|
||||
next
|
||||
end
|
||||
|
||||
# If the raw message has been removed (removed by retention)
|
||||
unless queued_message.message.raw_message?
|
||||
log "#{log_prefix} Raw message has been removed. Not sending."
|
||||
queued_message.message.create_delivery("HardFail", details: "Raw message has been removed. Cannot send message.")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
#
|
||||
# Handle Incoming Messages
|
||||
#
|
||||
if queued_message.message.scope == "incoming"
|
||||
#
|
||||
# If this is a bounce, we need to handle it as such
|
||||
#
|
||||
if queued_message.message.bounce
|
||||
log "#{log_prefix} Message is a bounce"
|
||||
original_messages = queued_message.message.original_messages
|
||||
unless original_messages.empty?
|
||||
queued_message.message.original_messages.each do |orig_msg|
|
||||
queued_message.message.update(bounce_for_id: orig_msg.id, domain_id: orig_msg.domain_id)
|
||||
queued_message.message.create_delivery("Processed", details: "This has been detected as a bounce message for <msg:#{orig_msg.id}>.")
|
||||
orig_msg.bounce!(queued_message.message)
|
||||
log "#{log_prefix} Bounce linked with message #{orig_msg.id}"
|
||||
end
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
# This message was sent to the return path but hasn't been matched
|
||||
# to an original message. If we have a route for this, route it
|
||||
# otherwise we'll drop at this point.
|
||||
if queued_message.message.route_id.nil?
|
||||
log "#{log_prefix} No source messages found. Hard failing."
|
||||
queued_message.message.create_delivery("HardFail", details: "This message was a bounce but we couldn't link it with any outgoing message and there was no route for it.")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# Update live stats
|
||||
#
|
||||
queued_message.message.database.live_stats.increment(queued_message.message.scope)
|
||||
|
||||
#
|
||||
# Inspect incoming messages
|
||||
#
|
||||
unless queued_message.message.inspected
|
||||
log "#{log_prefix} Inspecting message"
|
||||
queued_message.message.inspect_message
|
||||
if queued_message.message.inspected
|
||||
is_spam = queued_message.message.spam_score > queued_message.server.spam_threshold
|
||||
queued_message.message.update(spam: true) if is_spam
|
||||
queued_message.message.append_headers(
|
||||
"X-Postal-Spam: #{queued_message.message.spam ? 'yes' : 'no'}",
|
||||
"X-Postal-Spam-Threshold: #{queued_message.server.spam_threshold}",
|
||||
"X-Postal-Spam-Score: #{queued_message.message.spam_score}",
|
||||
"X-Postal-Threat: #{queued_message.message.threat ? 'yes' : 'no'}"
|
||||
)
|
||||
log "#{log_prefix} Message inspected successfully. Headers added."
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# If this message has a SPAM score higher than is permitted
|
||||
#
|
||||
if queued_message.message.spam_score >= queued_message.server.spam_failure_threshold
|
||||
log "#{log_prefix} Message has a spam score higher than the server's maxmimum. Hard failing."
|
||||
queued_message.message.create_delivery("HardFail", details: "Message's spam score is higher than the failure threshold for this server. Threshold is currently #{queued_message.server.spam_failure_threshold}.")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
# If the server is in development mode, hold it
|
||||
if queued_message.server.mode == "Development" && !queued_message.manual?
|
||||
log "Server is in development mode so holding."
|
||||
queued_message.message.create_delivery("Held", details: "Server is in development mode.")
|
||||
queued_message.destroy
|
||||
log "#{log_prefix} Server is in development mode. Holding."
|
||||
next
|
||||
end
|
||||
|
||||
#
|
||||
# Find out what sort of message we're supposed to be sending and dispatch this request over to
|
||||
# the sender.
|
||||
#
|
||||
if route = queued_message.message.route
|
||||
|
||||
# If the route says we're holding quananteed mail and this is spam, we'll hold this
|
||||
if route.spam_mode == "Quarantine" && queued_message.message.spam && !queued_message.manual?
|
||||
queued_message.message.create_delivery("Held", details: "Message placed into quarantine.")
|
||||
queued_message.destroy
|
||||
log "#{log_prefix} Route says to quarantine spam message. Holding."
|
||||
next
|
||||
end
|
||||
|
||||
# If the route says we're holding quananteed mail and this is spam, we'll hold this
|
||||
if route.spam_mode == "Fail" && queued_message.message.spam && !queued_message.manual?
|
||||
queued_message.message.create_delivery("HardFail", details: "Message is spam and the route specifies it should be failed.")
|
||||
queued_message.destroy
|
||||
log "#{log_prefix} Route says to fail spam message. Hard failing."
|
||||
next
|
||||
end
|
||||
|
||||
#
|
||||
# Messages that should be blindly accepted are blindly accepted
|
||||
#
|
||||
if route.mode == "Accept"
|
||||
queued_message.message.create_delivery("Processed", details: "Message has been accepted but not sent to any endpoints.")
|
||||
queued_message.destroy
|
||||
log "#{log_prefix} Route says to accept without endpoint. Marking as processed."
|
||||
next
|
||||
end
|
||||
|
||||
#
|
||||
# Messages that should be accepted and held should be held
|
||||
#
|
||||
if route.mode == "Hold"
|
||||
log "#{log_prefix} Route says to hold message."
|
||||
if queued_message.manual?
|
||||
log "#{log_prefix} Message was queued manually. Marking as processed."
|
||||
queued_message.message.create_delivery("Processed", details: "Message has been processed.")
|
||||
else
|
||||
log "#{log_prefix} Message was not queued manually. Holding."
|
||||
queued_message.message.create_delivery("Held", details: "Message has been accepted but not sent to any endpoints.")
|
||||
end
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
#
|
||||
# Messages that should be bounced should be bounced (or rejected if they got this far)
|
||||
#
|
||||
if route.mode == "Bounce" || route.mode == "Reject"
|
||||
if id = queued_message.send_bounce
|
||||
queued_message.message.create_delivery("HardFail", details: "Message has been bounced because the route asks for this. See message <msg:#{id}>")
|
||||
log "#{log_prefix} Route says to bounce. Hard failing and sent bounce (#{id})."
|
||||
end
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
if @fixed_result
|
||||
result = @fixed_result
|
||||
else
|
||||
case queued_message.message.endpoint
|
||||
when SMTPEndpoint
|
||||
sender = cached_sender(Postal::SMTPSender, queued_message.message.recipient_domain, nil, servers: [queued_message.message.endpoint])
|
||||
when HTTPEndpoint
|
||||
sender = cached_sender(Postal::HTTPSender, queued_message.message.endpoint)
|
||||
when AddressEndpoint
|
||||
sender = cached_sender(Postal::SMTPSender, queued_message.message.endpoint.domain, nil, force_rcpt_to: queued_message.message.endpoint.address)
|
||||
else
|
||||
log "#{log_prefix} Invalid endpoint for route (#{queued_message.message.endpoint_type})"
|
||||
queued_message.message.create_delivery("HardFail", details: "Invalid endpoint for route.")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
result = sender.send_message(queued_message.message)
|
||||
if result.connect_error
|
||||
@fixed_result = result
|
||||
end
|
||||
end
|
||||
|
||||
# Log the result
|
||||
log_details = result.details
|
||||
if result.type == "HardFail" && result.suppress_bounce
|
||||
# The delivery hard failed, but requested that no bounce be sent
|
||||
log "#{log_prefix} Suppressing bounce message after hard fail"
|
||||
elsif result.type == "HardFail" && queued_message.message.send_bounces?
|
||||
# If the message is a hard fail, send a bounce message for this message.
|
||||
log "#{log_prefix} Sending a bounce because message hard failed"
|
||||
if bounce_id = queued_message.send_bounce
|
||||
log_details += ". " unless log_details =~ /\.\z/
|
||||
log_details += " Sent bounce message to sender (see message <msg:#{bounce_id}>)"
|
||||
end
|
||||
end
|
||||
|
||||
queued_message.message.create_delivery(result.type, details: log_details, output: result.output&.strip, sent_with_ssl: result.secure, log_id: result.log_id, time: result.time)
|
||||
|
||||
if result.retry
|
||||
log "#{log_prefix} Message requeued for trying later."
|
||||
queued_message.retry_later(result.retry.is_a?(Integer) ? result.retry : nil)
|
||||
queued_message.allocate_ip_address
|
||||
queued_message.update_column(:ip_address_id, queued_message.ip_address&.id)
|
||||
else
|
||||
log "#{log_prefix} Message processing completed."
|
||||
queued_message.message.endpoint.mark_as_used
|
||||
queued_message.destroy
|
||||
end
|
||||
else
|
||||
log "#{log_prefix} No route and/or endpoint available for processing. Hard failing."
|
||||
queued_message.message.create_delivery("HardFail", details: "Message does not have a route and/or endpoint available for delivery.")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# Handle Outgoing Messages
|
||||
#
|
||||
if queued_message.message.scope == "outgoing"
|
||||
if queued_message.message.domain.nil?
|
||||
log "#{log_prefix} Message has no domain. Hard failing."
|
||||
queued_message.message.create_delivery("HardFail", details: "Message's domain no longer exist")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
#
|
||||
# If there's no to address, we can't do much. Fail it.
|
||||
#
|
||||
if queued_message.message.rcpt_to.blank?
|
||||
log "#{log_prefix} Message has no to address. Hard failing."
|
||||
queued_message.message.create_delivery("HardFail", details: "Message doesn't have an RCPT to")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
# Extract a tag and add it to the message if one doesn't exist
|
||||
if queued_message.message.tag.nil? && tag = queued_message.message.headers["x-postal-tag"]
|
||||
log "#{log_prefix} Added tag #{tag.last}"
|
||||
queued_message.message.update(tag: tag.last)
|
||||
end
|
||||
|
||||
#
|
||||
# If the credentials for this message is marked as holding and this isn't manual, hold it
|
||||
#
|
||||
if !queued_message.manual? && queued_message.message.credential && queued_message.message.credential.hold?
|
||||
log "#{log_prefix} Credential wants us to hold messages. Holding."
|
||||
queued_message.message.create_delivery("Held", details: "Credential is configured to hold all messages authenticated by it.")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
#
|
||||
# If the recipient is on the suppression list and this isn't a manual queueing block sending
|
||||
#
|
||||
if !queued_message.manual? && sl = queued_message.server.message_db.suppression_list.get(:recipient, queued_message.message.rcpt_to)
|
||||
log "#{log_prefix} Recipient is on the suppression list. Holding."
|
||||
queued_message.message.create_delivery("Held", details: "Recipient (#{queued_message.message.rcpt_to}) is on the suppression list (reason: #{sl['reason']})")
|
||||
queued_message.destroy
|
||||
next
|
||||
end
|
||||
|
||||
# Parse the content of the message as appropriate
|
||||
if queued_message.message.should_parse?
|
||||
log "#{log_prefix} Parsing message content as it hasn't been parsed before"
|
||||
queued_message.message.parse_content
|
||||
end
|
||||
|
||||
# Inspect outgoing messages when there's a threshold set for the server
|
||||
if !queued_message.message.inspected && queued_message.server.outbound_spam_threshold
|
||||
log "#{log_prefix} Inspecting message"
|
||||
queued_message.message.inspect_message
|
||||
if queued_message.message.inspected
|
||||
if queued_message.message.spam_score >= queued_message.server.outbound_spam_threshold
|
||||
queued_message.message.update(spam: true)
|
||||
end
|
||||
log "#{log_prefix} Message inspected successfully"
|
||||
end
|
||||
end
|
||||
|
||||
if queued_message.message.spam
|
||||
queued_message.message.create_delivery("HardFail", details: "Message is likely spam. Threshold is #{queued_message.server.outbound_spam_threshold} and the message scored #{queued_message.message.spam_score}.")
|
||||
queued_message.destroy
|
||||
log "#{log_prefix} Message is spam (#{queued_message.message.spam_score}). Hard failing."
|
||||
next
|
||||
end
|
||||
|
||||
# Add outgoing headers
|
||||
unless queued_message.message.has_outgoing_headers?
|
||||
queued_message.message.add_outgoing_headers
|
||||
end
|
||||
|
||||
# Check send limits
|
||||
if queued_message.server.send_limit_exceeded?
|
||||
# If we're over the limit, we're going to be holding this message
|
||||
queued_message.server.update_columns(send_limit_exceeded_at: Time.now, send_limit_approaching_at: nil)
|
||||
queued_message.message.create_delivery("Held", details: "Message held because send limit (#{queued_message.server.send_limit}) has been reached.")
|
||||
queued_message.destroy
|
||||
log "#{log_prefix} Server send limit has been exceeded. Holding."
|
||||
next
|
||||
elsif queued_message.server.send_limit_approaching?
|
||||
# If we're approaching the limit, just say we are but continue to process the message
|
||||
queued_message.server.update_columns(send_limit_approaching_at: Time.now, send_limit_exceeded_at: nil)
|
||||
else
|
||||
queued_message.server.update_columns(send_limit_approaching_at: nil, send_limit_exceeded_at: nil)
|
||||
end
|
||||
|
||||
# Update the live stats for this message.
|
||||
queued_message.message.database.live_stats.increment(queued_message.message.scope)
|
||||
|
||||
# If the server is in development mode, hold it
|
||||
if queued_message.server.mode == "Development" && !queued_message.manual?
|
||||
log "Server is in development mode so holding."
|
||||
queued_message.message.create_delivery("Held", details: "Server is in development mode.")
|
||||
queued_message.destroy
|
||||
log "#{log_prefix} Server is in development mode. Holding."
|
||||
next
|
||||
end
|
||||
|
||||
# Send the outgoing message to the SMTP sender
|
||||
|
||||
if @fixed_result
|
||||
result = @fixed_result
|
||||
else
|
||||
sender = cached_sender(Postal::SMTPSender, queued_message.message.recipient_domain, queued_message.ip_address)
|
||||
result = sender.send_message(queued_message.message)
|
||||
if result.connect_error
|
||||
@fixed_result = result
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# If the message has been hard failed, check to see how many other recent hard fails we've had for the address
|
||||
# and if there are more than 2, suppress the address for 30 days.
|
||||
#
|
||||
if result.type == "HardFail"
|
||||
recent_hard_fails = queued_message.server.message_db.select(:messages, where: { rcpt_to: queued_message.message.rcpt_to, status: "HardFail", timestamp: { greater_than: 24.hours.ago.to_f } }, count: true)
|
||||
if recent_hard_fails >= 1 && queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to, reason: "too many hard fails")
|
||||
log "#{log_prefix} Added #{queued_message.message.rcpt_to} to suppression list because #{recent_hard_fails} hard fails in 24 hours"
|
||||
result.details += "." if result.details =~ /\.\z/
|
||||
result.details += " Recipient added to suppression list (too many hard fails)."
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# If a message is sent successfully, remove the users from the suppression list
|
||||
#
|
||||
if result.type == "Sent" && queued_message.server.message_db.suppression_list.remove(:recipient, queued_message.message.rcpt_to)
|
||||
log "#{log_prefix} Removed #{queued_message.message.rcpt_to} from suppression list because success"
|
||||
result.details += "." if result.details =~ /\.\z/
|
||||
result.details += " Recipient removed from suppression list."
|
||||
end
|
||||
|
||||
# Log the result
|
||||
queued_message.message.create_delivery(result.type, details: result.details, output: result.output, sent_with_ssl: result.secure, log_id: result.log_id, time: result.time)
|
||||
if result.retry
|
||||
log "#{log_prefix} Message requeued for trying later."
|
||||
queued_message.retry_later(result.retry.is_a?(Integer) ? result.retry : nil)
|
||||
else
|
||||
log "#{log_prefix} Processing complete"
|
||||
queued_message.destroy
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
log "#{log_prefix} Internal error: #{e.class}: #{e.message}"
|
||||
e.backtrace.each { |line| log("#{log_prefix} #{line}") }
|
||||
queued_message.retry_later
|
||||
log "#{log_prefix} Queued message was unlocked"
|
||||
if defined?(Sentry)
|
||||
Sentry.capture_exception(e, extra: { job_id: self.id, server_id: queued_message.server_id, message_id: queued_message.message_id })
|
||||
end
|
||||
queued_message.message&.create_delivery("Error",
|
||||
details: "An internal error occurred while sending " \
|
||||
"this message. This message will be retried " \
|
||||
"automatically.",
|
||||
output: "#{e.class}: #{e.message}", log_id: "J-#{self.id}")
|
||||
end
|
||||
end
|
||||
|
||||
else
|
||||
log "Couldn't get lock for message #{params['id']}. I won't do this."
|
||||
end
|
||||
else
|
||||
log "No queued message with ID #{params['id']} was available for processing."
|
||||
end
|
||||
ensure
|
||||
begin
|
||||
@sender&.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
# rubocop:enable Layout/LineLength
|
||||
|
||||
private
|
||||
|
||||
# rubocop:disable Naming/MemoizedInstanceVariableName
|
||||
def cached_sender(klass, *args)
|
||||
@sender ||= begin
|
||||
sender = klass.new(*args)
|
||||
sender.start
|
||||
sender
|
||||
end
|
||||
end
|
||||
# rubocop:enable Naming/MemoizedInstanceVariableName
|
||||
|
||||
end
|
||||
@@ -1,17 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebhookDeliveryJob < Postal::Job
|
||||
|
||||
def perform
|
||||
if webhook_request = WebhookRequest.find_by_id(params["id"])
|
||||
if webhook_request.deliver
|
||||
log "Succesfully delivered"
|
||||
else
|
||||
log "Delivery failed"
|
||||
end
|
||||
else
|
||||
log "No webhook request found with ID '#{params['id']}'"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
47
app/models/concerns/has_locking.rb
Normal file
47
app/models/concerns/has_locking.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# This concern provides functionality for locking items along with additional functionality to handle
|
||||
# the concept of retrying items after a certain period of time. The following database columns are
|
||||
# required on the model
|
||||
#
|
||||
# * locked_by - A string column to store the name of the process that has locked the item
|
||||
# * locked_at - A datetime column to store the time the item was locked
|
||||
# * retry_after - A datetime column to store the time after which the item should be retried
|
||||
# * attempts - An integer column to store the number of attempts that have been made to process the item
|
||||
#
|
||||
# 'ready' means that it's ready to be processed.
|
||||
module HasLocking
|
||||
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :unlocked, -> { where(locked_at: nil) }
|
||||
scope :ready, -> { where("retry_after IS NULL OR retry_after < ?", Time.now) }
|
||||
end
|
||||
|
||||
def ready?
|
||||
retry_after.nil? || retry_after < Time.now
|
||||
end
|
||||
|
||||
def unlock
|
||||
self.locked_by = nil
|
||||
self.locked_at = nil
|
||||
update_columns(locked_by: nil, locked_at: nil)
|
||||
end
|
||||
|
||||
def locked?
|
||||
locked_at.present?
|
||||
end
|
||||
|
||||
def retry_later(time = nil)
|
||||
retry_time = time || calculate_retry_time(attempts, 5.minutes)
|
||||
self.locked_by = nil
|
||||
self.locked_at = nil
|
||||
update_columns(locked_by: nil, locked_at: nil, retry_after: Time.now + retry_time, attempts: attempts + 1)
|
||||
end
|
||||
|
||||
def calculate_retry_time(attempts, initial_period)
|
||||
(1.3**attempts) * initial_period
|
||||
end
|
||||
|
||||
end
|
||||
@@ -14,7 +14,6 @@ module HasSoftDestroy
|
||||
run_callbacks :soft_destroy do
|
||||
self.deleted_at = Time.now
|
||||
save!
|
||||
ActionDeletionJob.queue(:main, type: self.class.name, id: id)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -29,33 +29,18 @@
|
||||
class QueuedMessage < ApplicationRecord
|
||||
|
||||
include HasMessage
|
||||
include HasLocking
|
||||
|
||||
belongs_to :server
|
||||
belongs_to :ip_address, optional: true
|
||||
belongs_to :user, optional: true
|
||||
|
||||
before_create :allocate_ip_address
|
||||
after_commit :queue, on: :create
|
||||
|
||||
scope :unlocked, -> { where(locked_at: nil) }
|
||||
scope :retriable, -> { where("retry_after IS NULL OR retry_after < ?", Time.now) }
|
||||
scope :requeueable, -> { where("retry_after IS NULL OR retry_after < ?", 30.seconds.ago) }
|
||||
scope :ready_with_delayed_retry, -> { where("retry_after IS NULL OR retry_after < ?", 30.seconds.ago) }
|
||||
|
||||
def retriable?
|
||||
retry_after.nil? || retry_after < Time.now
|
||||
end
|
||||
|
||||
def queue
|
||||
UnqueueMessageJob.queue(queue_name, id: id)
|
||||
end
|
||||
|
||||
def queue!
|
||||
update_column(:retry_after, nil)
|
||||
queue
|
||||
end
|
||||
|
||||
def queue_name
|
||||
ip_address ? :"outgoing-#{ip_address.id}" : :main
|
||||
def retry_now
|
||||
update(retry_after: nil)
|
||||
end
|
||||
|
||||
def send_bounce
|
||||
@@ -70,40 +55,6 @@ class QueuedMessage < ApplicationRecord
|
||||
self.ip_address = pool.ip_addresses.select_by_priority
|
||||
end
|
||||
|
||||
def acquire_lock
|
||||
time = Time.now
|
||||
locker = Postal.locker_name
|
||||
rows = self.class.where(id: id, locked_by: nil, locked_at: nil).update_all(locked_by: locker, locked_at: time)
|
||||
if rows == 1
|
||||
self.locked_by = locker
|
||||
self.locked_at = time
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def retry_later(time = nil)
|
||||
retry_time = time || self.class.calculate_retry_time(attempts, 5.minutes)
|
||||
self.locked_by = nil
|
||||
self.locked_at = nil
|
||||
update_columns(locked_by: nil, locked_at: nil, retry_after: Time.now + retry_time, attempts: attempts + 1)
|
||||
end
|
||||
|
||||
def unlock
|
||||
self.locked_by = nil
|
||||
self.locked_at = nil
|
||||
update_columns(locked_by: nil, locked_at: nil)
|
||||
end
|
||||
|
||||
def self.calculate_retry_time(attempts, initial_period)
|
||||
(1.3**attempts) * initial_period
|
||||
end
|
||||
|
||||
def locked?
|
||||
locked_at.present?
|
||||
end
|
||||
|
||||
def batchable_messages(limit = 10)
|
||||
unless locked?
|
||||
raise Postal::Error, "Must lock current message before locking any friends"
|
||||
@@ -114,13 +65,9 @@ class QueuedMessage < ApplicationRecord
|
||||
else
|
||||
time = Time.now
|
||||
locker = Postal.locker_name
|
||||
self.class.retriable.where(batch_key: batch_key, ip_address_id: ip_address_id, locked_by: nil, locked_at: nil).limit(limit).update_all(locked_by: locker, locked_at: time)
|
||||
self.class.ready.where(batch_key: batch_key, ip_address_id: ip_address_id, locked_by: nil, locked_at: nil).limit(limit).update_all(locked_by: locker, locked_at: time)
|
||||
QueuedMessage.where(batch_key: batch_key, ip_address_id: ip_address_id, locked_by: locker, locked_at: time).where.not(id: id)
|
||||
end
|
||||
end
|
||||
|
||||
def self.requeue_all
|
||||
unlocked.requeueable.each(&:queue)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
16
app/models/scheduled_task.rb
Normal file
16
app/models/scheduled_task.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: scheduled_tasks
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string(255)
|
||||
# next_run_after :datetime
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_scheduled_tasks_on_name (name) UNIQUE
|
||||
#
|
||||
class ScheduledTask < ApplicationRecord
|
||||
end
|
||||
@@ -206,7 +206,7 @@ class Server < ApplicationRecord
|
||||
end
|
||||
|
||||
def queue_size
|
||||
@queue_size ||= queued_messages.retriable.count
|
||||
@queue_size ||= queued_messages.ready.count
|
||||
end
|
||||
|
||||
def stats
|
||||
@@ -222,7 +222,7 @@ class Server < ApplicationRecord
|
||||
|
||||
# Return the domain which can be used to authenticate emails sent from the given e-mail address.
|
||||
#
|
||||
# @param address [String] an e-mail address
|
||||
# @param address [String] an e-mail address
|
||||
# @return [Domain, nil] the domain to use for authentication
|
||||
def authenticated_domain_for_address(address)
|
||||
return nil if address.blank?
|
||||
|
||||
@@ -5,21 +5,28 @@
|
||||
# Table name: webhook_requests
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# attempts :integer default(0)
|
||||
# error :text(65535)
|
||||
# event :string(255)
|
||||
# locked_at :datetime
|
||||
# locked_by :string(255)
|
||||
# payload :text(65535)
|
||||
# retry_after :datetime
|
||||
# url :string(255)
|
||||
# uuid :string(255)
|
||||
# created_at :datetime
|
||||
# server_id :integer
|
||||
# webhook_id :integer
|
||||
# url :string(255)
|
||||
# event :string(255)
|
||||
# uuid :string(255)
|
||||
# payload :text(65535)
|
||||
# attempts :integer default(0)
|
||||
# retry_after :datetime
|
||||
# error :text(65535)
|
||||
# created_at :datetime
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_webhook_requests_on_locked_by (locked_by)
|
||||
#
|
||||
|
||||
class WebhookRequest < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
include HasLocking
|
||||
|
||||
RETRIES = { 1 => 2.minutes, 2 => 3.minutes, 3 => 6.minutes, 4 => 10.minutes, 5 => 15.minutes }.freeze
|
||||
|
||||
@@ -31,30 +38,9 @@ class WebhookRequest < ApplicationRecord
|
||||
|
||||
serialize :payload, Hash
|
||||
|
||||
after_commit :queue, on: :create
|
||||
|
||||
def self.trigger(server, event, payload = {})
|
||||
unless server.is_a?(Server)
|
||||
server = Server.find(server.to_i)
|
||||
end
|
||||
|
||||
webhooks = server.webhooks.enabled.includes(:webhook_events).references(:webhook_events).where("webhooks.all_events = ? OR webhook_events.event = ?", true, event)
|
||||
webhooks.each do |webhook|
|
||||
server.webhook_requests.create!(event: event, payload: payload, webhook: webhook, url: webhook.url)
|
||||
end
|
||||
end
|
||||
|
||||
def self.requeue_all
|
||||
where("retry_after < ?", Time.now).find_each(&:queue)
|
||||
end
|
||||
|
||||
def queue
|
||||
WebhookDeliveryJob.queue(:main, id: id)
|
||||
end
|
||||
|
||||
def deliver
|
||||
payload = { event: event, timestamp: created_at.to_f, payload: self.payload, uuid: uuid }.to_json
|
||||
Postal.logger.tagged(event: event, url: url, component: "webhooks") do
|
||||
Postal.logger.tagged(event: event, url: url) do
|
||||
Postal.logger.info "Sending webhook request"
|
||||
result = Postal::HTTP.post(url, sign: true, json: payload, timeout: 5)
|
||||
self.attempts += 1
|
||||
@@ -74,7 +60,7 @@ class WebhookRequest < ApplicationRecord
|
||||
|
||||
if result[:code] >= 200 && result[:code] < 300
|
||||
Postal.logger.info "Received #{result[:code]} status code. That's OK."
|
||||
destroy
|
||||
destroy!
|
||||
webhook&.update_column(:last_used_at, Time.now)
|
||||
true
|
||||
else
|
||||
@@ -82,14 +68,31 @@ class WebhookRequest < ApplicationRecord
|
||||
self.error = "Couldn't send to URL. Code received was #{result[:code]}"
|
||||
if retry_after
|
||||
Postal.logger.info "Will retry #{retry_after} (this was attempt #{self.attempts})"
|
||||
save
|
||||
self.locked_by = nil
|
||||
self.locked_at = nil
|
||||
save!
|
||||
else
|
||||
Postal.logger.info "Have tried #{self.attempts} times. Giving up."
|
||||
destroy
|
||||
destroy!
|
||||
end
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
|
||||
def trigger(server, event, payload = {})
|
||||
unless server.is_a?(Server)
|
||||
server = Server.find(server.to_i)
|
||||
end
|
||||
|
||||
webhooks = server.webhooks.enabled.includes(:webhook_events).references(:webhook_events).where("webhooks.all_events = ? OR webhook_events.event = ?", true, event)
|
||||
webhooks.each do |webhook|
|
||||
server.webhook_requests.create!(event: event, payload: payload, webhook: webhook, url: webhook.url)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
54
app/models/worker_role.rb
Normal file
54
app/models/worker_role.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: worker_roles
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# acquired_at :datetime
|
||||
# role :string(255)
|
||||
# worker :string(255)
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_worker_roles_on_role (role) UNIQUE
|
||||
#
|
||||
class WorkerRole < ApplicationRecord
|
||||
|
||||
class << self
|
||||
|
||||
# Acquire or renew a lock for the given role.
|
||||
#
|
||||
# @param role [String] The name of the role to acquire
|
||||
# @return [Symbol, false] True if the lock was acquired or renewed, false otherwise
|
||||
def acquire(role)
|
||||
# update our existing lock if we already have one
|
||||
updates = where(role: role, worker: Postal.locker_name).update_all(acquired_at: Time.current)
|
||||
return :renewed if updates.positive?
|
||||
|
||||
# attempt to steal a role from another worker
|
||||
updates = where(role: role).where("acquired_at is null OR acquired_at < ?", 5.minutes.ago)
|
||||
.update_all(acquired_at: Time.current, worker: Postal.locker_name)
|
||||
return :stolen if updates.positive?
|
||||
|
||||
# attempt to create a new role for this worker
|
||||
begin
|
||||
create!(role: role, worker: Postal.locker_name, acquired_at: Time.current)
|
||||
:created
|
||||
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Release a lock for the given role for the current process.
|
||||
#
|
||||
# @param role [String] The name of the role to release
|
||||
# @return [Boolean] True if the lock was released, false otherwise
|
||||
def release(role)
|
||||
updates = where(role: role, worker: Postal.locker_name).delete_all
|
||||
updates.positive?
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
17
app/scheduled_tasks/action_deletions_scheduled_task.rb
Normal file
17
app/scheduled_tasks/action_deletions_scheduled_task.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActionDeletionsScheduledTask < ApplicationScheduledTask
|
||||
|
||||
def call
|
||||
Organization.deleted.each do |org|
|
||||
logger.info "permanently removing organization #{org.id} (#{org.permalink})"
|
||||
org.destroy
|
||||
end
|
||||
|
||||
Server.deleted.each do |server|
|
||||
logger.info "permanently removing server #{server.id} (#{server.full_permalink})"
|
||||
server.destroy
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
46
app/scheduled_tasks/application_scheduled_task.rb
Normal file
46
app/scheduled_tasks/application_scheduled_task.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationScheduledTask
|
||||
|
||||
def initialize(logger:)
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def call
|
||||
# override me
|
||||
end
|
||||
|
||||
attr_reader :logger
|
||||
|
||||
class << self
|
||||
|
||||
def next_run_after
|
||||
quarter_past_each_hour
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def quarter_past_each_hour
|
||||
time = Time.current
|
||||
time = time.change(min: 15, sec: 0)
|
||||
time += 1.hour if time < Time.current
|
||||
time
|
||||
end
|
||||
|
||||
def quarter_to_each_hour
|
||||
time = Time.current
|
||||
time = time.change(min: 45, sec: 0)
|
||||
time += 1.hour if time < Time.current
|
||||
time
|
||||
end
|
||||
|
||||
def three_am
|
||||
time = Time.current
|
||||
time = time.change(hour: 3, min: 0, sec: 0)
|
||||
time += 1.day if time < Time.current
|
||||
time
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,15 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CheckAllDNSJob < Postal::Job
|
||||
class CheckAllDNSScheduledTask < ApplicationScheduledTask
|
||||
|
||||
def perform
|
||||
def call
|
||||
Domain.where.not(dns_checked_at: nil).where("dns_checked_at <= ?", 1.hour.ago).each do |domain|
|
||||
log "Checking DNS for domain: #{domain.name}"
|
||||
logger.info "checking DNS for domain: #{domain.name}"
|
||||
domain.check_dns(:auto)
|
||||
end
|
||||
|
||||
TrackDomain.where("dns_checked_at IS NULL OR dns_checked_at <= ?", 1.hour.ago).includes(:domain).each do |domain|
|
||||
log "Checking DNS for track domain: #{domain.full_name}"
|
||||
logger.info "checking DNS for track domain: #{domain.full_name}"
|
||||
domain.check_dns
|
||||
end
|
||||
end
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
require "authie/session"
|
||||
|
||||
class CleanupAuthieSessionsJob < Postal::Job
|
||||
class CleanupAuthieSessionsScheduledTask < ApplicationScheduledTask
|
||||
|
||||
def perform
|
||||
def call
|
||||
Authie::Session.cleanup
|
||||
end
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ExpireHeldMessagesJob < Postal::Job
|
||||
class ExpireHeldMessagesScheduledTask < ApplicationScheduledTask
|
||||
|
||||
def perform
|
||||
def call
|
||||
Server.all.each do |server|
|
||||
messages = server.message_db.messages(where: {
|
||||
status: "Held",
|
||||
@@ -1,25 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProcessMessageRetentionJob < Postal::Job
|
||||
class ProcessMessageRetentionScheduledTask < ApplicationScheduledTask
|
||||
|
||||
def perform
|
||||
Server.all.each do |server|
|
||||
if server.raw_message_retention_days
|
||||
# If the server has a maximum number of retained raw messages, remove any that are older than this
|
||||
log "Tidying raw messages (by days) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_days} days."
|
||||
logger.info "Tidying raw messages (by days) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_days} days."
|
||||
server.message_db.provisioner.remove_raw_tables_older_than(server.raw_message_retention_days)
|
||||
end
|
||||
|
||||
if server.raw_message_retention_size
|
||||
log "Tidying raw messages (by size) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_size} MB of data."
|
||||
logger.info "Tidying raw messages (by size) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_size} MB of data."
|
||||
server.message_db.provisioner.remove_raw_tables_until_less_than_size(server.raw_message_retention_size * 1024 * 1024)
|
||||
end
|
||||
|
||||
if server.message_retention_days
|
||||
log "Tidying messages for #{server.permalink} (ID: #{server.id}). Keeping #{server.message_retention_days} days."
|
||||
logger.info "Tidying messages for #{server.permalink} (ID: #{server.id}). Keeping #{server.message_retention_days} days."
|
||||
server.message_db.provisioner.remove_messages(server.message_retention_days)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.next_run_after
|
||||
three_am
|
||||
end
|
||||
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PruneSuppressionListsScheduledTask < ApplicationScheduledTask
|
||||
|
||||
def call
|
||||
Server.all.each do |s|
|
||||
logger.info "Pruning suppression lists for server #{s.id}"
|
||||
s.message_db.suppression_list.prune
|
||||
end
|
||||
end
|
||||
|
||||
def self.next_run_after
|
||||
three_am
|
||||
end
|
||||
|
||||
end
|
||||
16
app/scheduled_tasks/prune_webhook_requests_scheduled_task.rb
Normal file
16
app/scheduled_tasks/prune_webhook_requests_scheduled_task.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PruneWebhookRequestsScheduledTask < ApplicationScheduledTask
|
||||
|
||||
def call
|
||||
Server.all.each do |s|
|
||||
logger.info "Pruning webhook requests for server #{s.id}"
|
||||
s.message_db.webhooks.prune
|
||||
end
|
||||
end
|
||||
|
||||
def self.next_run_after
|
||||
quarter_to_each_hour
|
||||
end
|
||||
|
||||
end
|
||||
13
app/scheduled_tasks/send_notifications_scheduled_task.rb
Normal file
13
app/scheduled_tasks/send_notifications_scheduled_task.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendNotificationsScheduledTask < ApplicationScheduledTask
|
||||
|
||||
def call
|
||||
Server.send_send_limit_notifications
|
||||
end
|
||||
|
||||
def self.next_run_after
|
||||
1.minute.from_now
|
||||
end
|
||||
|
||||
end
|
||||
487
app/services/unqueue_message_service.rb
Normal file
487
app/services/unqueue_message_service.rb
Normal file
@@ -0,0 +1,487 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnqueueMessageService
|
||||
|
||||
def initialize(queued_message:, logger:)
|
||||
@queued_message = queued_message
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def call
|
||||
@logger.tagged(original_queued_message: @queued_message.id) do
|
||||
log "starting message unqueue"
|
||||
process_original_message
|
||||
log "finished message unqueue"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_original_message
|
||||
begin
|
||||
@queued_message.message
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
log "unqueue because backend message has been removed."
|
||||
@queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
unless @queued_message.ready?
|
||||
log "skipping because message isn't ready for processing"
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
other_messages = @queued_message.batchable_messages(100)
|
||||
log "found #{other_messages.size} associated messages to process at the same time", batch_key: @queued_message.batch_key
|
||||
rescue StandardError
|
||||
@queued_message.unlock
|
||||
raise
|
||||
end
|
||||
|
||||
([@queued_message] + other_messages).each do |queued_message|
|
||||
@logger.tagged(queued_message: queued_message.id) do
|
||||
process_message(queued_message)
|
||||
end
|
||||
end
|
||||
ensure
|
||||
begin
|
||||
@sender&.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:disable Naming/MemoizedInstanceVariableName
|
||||
def cached_sender(klass, *args)
|
||||
@sender ||= begin
|
||||
sender = klass.new(*args)
|
||||
sender.start
|
||||
sender
|
||||
end
|
||||
end
|
||||
# rubocop:enable Naming/MemoizedInstanceVariableName
|
||||
|
||||
def log(message, **tags)
|
||||
@logger.info(message, **tags)
|
||||
end
|
||||
|
||||
def process_message(queued_message)
|
||||
begin
|
||||
queued_message.message
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
log "unqueueing because backend message has been removed"
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
log "processing message"
|
||||
|
||||
#
|
||||
# If the server is suspended, hold all messages
|
||||
#
|
||||
if queued_message.server.suspended?
|
||||
log "server is suspended, holding message"
|
||||
queued_message.message.create_delivery("Held", details: "Mail server has been suspended. No e-mails can be processed at present. Contact support for assistance.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
# We might not be able to send this any more, check the attempts
|
||||
if queued_message.attempts >= Postal.config.general.maximum_delivery_attempts
|
||||
details = "Maximum number of delivery attempts (#{queued_message.attempts}) has been reached."
|
||||
if queued_message.message.scope == "incoming"
|
||||
# Send bounces to incoming e-mails when they are hard failed
|
||||
if bounce_id = queued_message.send_bounce
|
||||
details += " Bounce sent to sender (see message <msg:#{bounce_id}>)"
|
||||
end
|
||||
elsif queued_message.message.scope == "outgoing"
|
||||
# Add the recipient to the suppression list
|
||||
if queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to, reason: "too many soft fails")
|
||||
log "added #{queued_message.message.rcpt_to} to suppression list because maximum attempts has been reached"
|
||||
details += " Added #{queued_message.message.rcpt_to} to suppression list because delivery has failed #{queued_message.attempts} times."
|
||||
end
|
||||
end
|
||||
queued_message.message.create_delivery("HardFail", details: details)
|
||||
queued_message.destroy
|
||||
log "message has reached maximum number of attempts, hard failing"
|
||||
return
|
||||
end
|
||||
|
||||
# If the raw message has been removed (removed by retention)
|
||||
unless queued_message.message.raw_message?
|
||||
log "raw message has been removed, not sending"
|
||||
queued_message.message.create_delivery("HardFail", details: "Raw message has been removed. Cannot send message.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
#
|
||||
# Handle Incoming Messages
|
||||
#
|
||||
if queued_message.message.scope == "incoming"
|
||||
log "message is incoming"
|
||||
|
||||
#
|
||||
# If this is a bounce, we need to handle it as such
|
||||
#
|
||||
if queued_message.message.bounce
|
||||
log "message is a bounce"
|
||||
original_messages = queued_message.message.original_messages
|
||||
unless original_messages.empty?
|
||||
queued_message.message.original_messages.each do |orig_msg|
|
||||
queued_message.message.update(bounce_for_id: orig_msg.id, domain_id: orig_msg.domain_id)
|
||||
queued_message.message.create_delivery("Processed", details: "This has been detected as a bounce message for <msg:#{orig_msg.id}>.")
|
||||
orig_msg.bounce!(queued_message.message)
|
||||
log "bounce linked with message #{orig_msg.id}"
|
||||
end
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
# This message was sent to the return path but hasn't been matched
|
||||
# to an original message. If we have a route for this, route it
|
||||
# otherwise we'll drop at this point.
|
||||
if queued_message.message.route_id.nil?
|
||||
log "no source messages found, hard failing"
|
||||
queued_message.message.create_delivery("HardFail", details: "This message was a bounce but we couldn't link it with any outgoing message and there was no route for it.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# Update live stats
|
||||
#
|
||||
queued_message.message.database.live_stats.increment(queued_message.message.scope)
|
||||
|
||||
#
|
||||
# Inspect incoming messages
|
||||
#
|
||||
unless queued_message.message.inspected
|
||||
log "inspecting message"
|
||||
queued_message.message.inspect_message
|
||||
if queued_message.message.inspected
|
||||
is_spam = queued_message.message.spam_score > queued_message.server.spam_threshold
|
||||
queued_message.message.update(spam: true) if is_spam
|
||||
queued_message.message.append_headers(
|
||||
"X-Postal-Spam: #{queued_message.message.spam ? 'yes' : 'no'}",
|
||||
"X-Postal-Spam-Threshold: #{queued_message.server.spam_threshold}",
|
||||
"X-Postal-Spam-Score: #{queued_message.message.spam_score}",
|
||||
"X-Postal-Threat: #{queued_message.message.threat ? 'yes' : 'no'}"
|
||||
)
|
||||
log "message inspected, headers added", spam: queued_message.message.spam?, spam_score: queued_message.message.spam_score, threat: queued_message.message.threat?
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# If this message has a SPAM score higher than is permitted
|
||||
#
|
||||
if queued_message.message.spam_score >= queued_message.server.spam_failure_threshold
|
||||
log "message has a spam score higher than the server's maxmimum, hard failing", server_threshold: queued_message.server.spam_failure_threshold
|
||||
queued_message.message.create_delivery("HardFail",
|
||||
details: "Message's spam score is higher than the failure threshold for this server. " \
|
||||
"Threshold is currently #{queued_message.server.spam_failure_threshold}.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
# If the server is in development mode, hold it
|
||||
if queued_message.server.mode == "Development" && !queued_message.manual?
|
||||
log "server is in development mode, holding"
|
||||
queued_message.message.create_delivery("Held", details: "Server is in development mode.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
#
|
||||
# Find out what sort of message we're supposed to be sending and dispatch this request over to
|
||||
# the sender.
|
||||
#
|
||||
if route = queued_message.message.route
|
||||
|
||||
# If the route says we're holding quananteed mail and this is spam, we'll hold this
|
||||
if route.spam_mode == "Quarantine" && queued_message.message.spam && !queued_message.manual?
|
||||
log "message is spam and route says to quarantine spam message, holding"
|
||||
queued_message.message.create_delivery("Held", details: "Message placed into quarantine.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
# If the route says we're holding quananteed mail and this is spam, we'll hold this
|
||||
if route.spam_mode == "Fail" && queued_message.message.spam && !queued_message.manual?
|
||||
log "message is spam and route says to fail spam message, hard failing"
|
||||
queued_message.message.create_delivery("HardFail", details: "Message is spam and the route specifies it should be failed.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
#
|
||||
# Messages that should be blindly accepted are blindly accepted
|
||||
#
|
||||
if route.mode == "Accept"
|
||||
log "route says to accept without endpoint, marking as processed"
|
||||
queued_message.message.create_delivery("Processed", details: "Message has been accepted but not sent to any endpoints.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
#
|
||||
# Messages that should be accepted and held should be held
|
||||
#
|
||||
if route.mode == "Hold"
|
||||
if queued_message.manual?
|
||||
log "route says to hold and message was queued manually, marking as processed"
|
||||
queued_message.message.create_delivery("Processed", details: "Message has been processed.")
|
||||
else
|
||||
log "route says to hold, marking as held"
|
||||
queued_message.message.create_delivery("Held", details: "Message has been accepted but not sent to any endpoints.")
|
||||
end
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
#
|
||||
# Messages that should be bounced should be bounced (or rejected if they got this far)
|
||||
#
|
||||
if route.mode == "Bounce" || route.mode == "Reject"
|
||||
log "route says to bounce, hard failing and sending bounce"
|
||||
if id = queued_message.send_bounce
|
||||
log "bounce sent with id #{id}"
|
||||
queued_message.message.create_delivery("HardFail", details: "Message has been bounced because the route asks for this. See message <msg:#{id}>")
|
||||
end
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
if @fixed_result
|
||||
result = @fixed_result
|
||||
else
|
||||
case queued_message.message.endpoint
|
||||
when SMTPEndpoint
|
||||
sender = cached_sender(Postal::SMTPSender, queued_message.message.recipient_domain, nil, servers: [queued_message.message.endpoint])
|
||||
when HTTPEndpoint
|
||||
sender = cached_sender(Postal::HTTPSender, queued_message.message.endpoint)
|
||||
when AddressEndpoint
|
||||
sender = cached_sender(Postal::SMTPSender, queued_message.message.endpoint.domain, nil, force_rcpt_to: queued_message.message.endpoint.address)
|
||||
else
|
||||
log "invalid endpoint for route (#{queued_message.message.endpoint_type})"
|
||||
queued_message.message.create_delivery("HardFail", details: "Invalid endpoint for route.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
result = sender.send_message(queued_message.message)
|
||||
if result.connect_error
|
||||
@fixed_result = result
|
||||
end
|
||||
end
|
||||
|
||||
# Log the result
|
||||
log_details = result.details
|
||||
if result.type == "HardFail" && result.suppress_bounce
|
||||
# The delivery hard failed, but requested that no bounce be sent
|
||||
log "suppressing bounce message after hard fail"
|
||||
elsif result.type == "HardFail" && queued_message.message.send_bounces?
|
||||
# If the message is a hard fail, send a bounce message for this message.
|
||||
log "sending a bounce because message hard failed"
|
||||
if bounce_id = queued_message.send_bounce
|
||||
log_details += ". " unless log_details =~ /\.\z/
|
||||
log_details += " Sent bounce message to sender (see message <msg:#{bounce_id}>)"
|
||||
end
|
||||
end
|
||||
|
||||
queued_message.message.create_delivery(result.type, details: log_details, output: result.output&.strip, sent_with_ssl: result.secure, log_id: result.log_id, time: result.time)
|
||||
|
||||
if result.retry
|
||||
queued_message.retry_later(result.retry.is_a?(Integer) ? result.retry : nil)
|
||||
log "message requeued for trying later, at #{queued_message.retry_after}"
|
||||
queued_message.allocate_ip_address
|
||||
queued_message.update_column(:ip_address_id, queued_message.ip_address&.id)
|
||||
else
|
||||
log "message processing completed"
|
||||
queued_message.message.endpoint.mark_as_used
|
||||
queued_message.destroy
|
||||
end
|
||||
else
|
||||
log "no route and/or endpoint available for processing, hard failing"
|
||||
queued_message.message.create_delivery("HardFail", details: "Message does not have a route and/or endpoint available for delivery.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# Handle Outgoing Messages
|
||||
#
|
||||
return unless queued_message.message.scope == "outgoing"
|
||||
|
||||
log "message is outgoing"
|
||||
|
||||
if queued_message.message.domain.nil?
|
||||
log "message has no domain, hard failing"
|
||||
queued_message.message.create_delivery("HardFail", details: "Message's domain no longer exist")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
#
|
||||
# If there's no to address, we can't do much. Fail it.
|
||||
#
|
||||
if queued_message.message.rcpt_to.blank?
|
||||
log "message has no 'to' address, hard failing"
|
||||
queued_message.message.create_delivery("HardFail", details: "Message doesn't have an RCPT to")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
# Extract a tag and add it to the message if one doesn't exist
|
||||
if queued_message.message.tag.nil? && tag = queued_message.message.headers["x-postal-tag"]
|
||||
log "added tag: #{tag.last}"
|
||||
queued_message.message.update(tag: tag.last)
|
||||
end
|
||||
|
||||
#
|
||||
# If the credentials for this message is marked as holding and this isn't manual, hold it
|
||||
#
|
||||
if !queued_message.manual? && queued_message.message.credential && queued_message.message.credential.hold?
|
||||
log "credential wants us to hold messages, holding"
|
||||
queued_message.message.create_delivery("Held", details: "Credential is configured to hold all messages authenticated by it.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
#
|
||||
# If the recipient is on the suppression list and this isn't a manual queueing block sending
|
||||
#
|
||||
if !queued_message.manual? && sl = queued_message.server.message_db.suppression_list.get(:recipient, queued_message.message.rcpt_to)
|
||||
log "recipient is on the suppression list, holding"
|
||||
queued_message.message.create_delivery("Held", details: "Recipient (#{queued_message.message.rcpt_to}) is on the suppression list (reason: #{sl['reason']})")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
# Parse the content of the message as appropriate
|
||||
if queued_message.message.should_parse?
|
||||
log "parsing message content as it hasn't been parsed before"
|
||||
queued_message.message.parse_content
|
||||
end
|
||||
|
||||
# Inspect outgoing messages when there's a threshold set for the server
|
||||
if !queued_message.message.inspected && queued_message.server.outbound_spam_threshold
|
||||
log "inspecting message"
|
||||
queued_message.message.inspect_message
|
||||
if queued_message.message.inspected
|
||||
if queued_message.message.spam_score >= queued_message.server.outbound_spam_threshold
|
||||
queued_message.message.update(spam: true)
|
||||
end
|
||||
log "message inspected successfully", spam: queued_message.message.spam?, spam_score: queued_message.message.spam_score
|
||||
end
|
||||
end
|
||||
|
||||
if queued_message.message.spam
|
||||
log "message is spam (#{queued_message.message.spam_score}), hard failing", server_threshold: queued_message.server.outbound_spam_threshold
|
||||
queued_message.message.create_delivery("HardFail",
|
||||
details: "Message is likely spam. Threshold is #{queued_message.server.outbound_spam_threshold} and " \
|
||||
"the message scored #{queued_message.message.spam_score}.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
# Add outgoing headers
|
||||
unless queued_message.message.has_outgoing_headers?
|
||||
queued_message.message.add_outgoing_headers
|
||||
end
|
||||
|
||||
# Check send limits
|
||||
if queued_message.server.send_limit_exceeded?
|
||||
# If we're over the limit, we're going to be holding this message
|
||||
log "server send limit has been exceeded, holding", send_limit: queued_message.server.send_limit
|
||||
queued_message.server.update_columns(send_limit_exceeded_at: Time.now, send_limit_approaching_at: nil)
|
||||
queued_message.message.create_delivery("Held", details: "Message held because send limit (#{queued_message.server.send_limit}) has been reached.")
|
||||
queued_message.destroy
|
||||
return
|
||||
elsif queued_message.server.send_limit_approaching?
|
||||
# If we're approaching the limit, just say we are but continue to process the message
|
||||
queued_message.server.update_columns(send_limit_approaching_at: Time.now, send_limit_exceeded_at: nil)
|
||||
else
|
||||
queued_message.server.update_columns(send_limit_approaching_at: nil, send_limit_exceeded_at: nil)
|
||||
end
|
||||
|
||||
# Update the live stats for this message.
|
||||
queued_message.message.database.live_stats.increment(queued_message.message.scope)
|
||||
|
||||
# If the server is in development mode, hold it
|
||||
if queued_message.server.mode == "Development" && !queued_message.manual?
|
||||
log "server is in development mode, holding"
|
||||
queued_message.message.create_delivery("Held", details: "Server is in development mode.")
|
||||
queued_message.destroy
|
||||
return
|
||||
end
|
||||
|
||||
# Send the outgoing message to the SMTP sender
|
||||
|
||||
if @fixed_result
|
||||
result = @fixed_result
|
||||
else
|
||||
sender = cached_sender(Postal::SMTPSender, queued_message.message.recipient_domain, queued_message.ip_address)
|
||||
result = sender.send_message(queued_message.message)
|
||||
if result.connect_error
|
||||
@fixed_result = result
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# If the message has been hard failed, check to see how many other recent hard fails we've had for the address
|
||||
# and if there are more than 2, suppress the address for 30 days.
|
||||
#
|
||||
if result.type == "HardFail"
|
||||
recent_hard_fails = queued_message.server.message_db.select(:messages,
|
||||
where: {
|
||||
rcpt_to: queued_message.message.rcpt_to,
|
||||
status: "HardFail",
|
||||
timestamp: { greater_than: 24.hours.ago.to_f }
|
||||
},
|
||||
count: true)
|
||||
if recent_hard_fails >= 1 && queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to, reason: "too many hard fails")
|
||||
log "Added #{queued_message.message.rcpt_to} to suppression list because #{recent_hard_fails} hard fails in 24 hours"
|
||||
result.details += "." if result.details =~ /\.\z/
|
||||
result.details += " Recipient added to suppression list (too many hard fails)."
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# If a message is sent successfully, remove the users from the suppression list
|
||||
#
|
||||
if result.type == "Sent" && queued_message.server.message_db.suppression_list.remove(:recipient, queued_message.message.rcpt_to)
|
||||
log "removed #{queued_message.message.rcpt_to} from suppression list"
|
||||
result.details += "." if result.details =~ /\.\z/
|
||||
result.details += " Recipient removed from suppression list."
|
||||
end
|
||||
|
||||
# Log the result
|
||||
queued_message.message.create_delivery(result.type, details: result.details, output: result.output, sent_with_ssl: result.secure, log_id: result.log_id, time: result.time)
|
||||
if result.retry
|
||||
queued_message.retry_later(result.retry.is_a?(Integer) ? result.retry : nil)
|
||||
log "message requeued for trying later", retry_after: queued_message.retry_after
|
||||
else
|
||||
log "message processing complete"
|
||||
queued_message.destroy
|
||||
end
|
||||
rescue StandardError => e
|
||||
log "internal error: #{e.class}: #{e.message}"
|
||||
e.backtrace.each { |line| log(line) }
|
||||
|
||||
queued_message.retry_later
|
||||
log "message requeued for trying later, at #{queued_message.retry_after}"
|
||||
|
||||
if defined?(Sentry)
|
||||
Sentry.capture_exception(e, extra: { server_id: queued_message.server_id, queued_message_id: queued_message.message_id })
|
||||
end
|
||||
queued_message.message&.create_delivery("Error",
|
||||
details: "An internal error occurred while sending " \
|
||||
"this message. This message will be retried " \
|
||||
"automatically.",
|
||||
output: "#{e.class}: #{e.message}", log_id: "J-#{id}")
|
||||
end
|
||||
|
||||
end
|
||||
19
app/services/webhook_delivery_service.rb
Normal file
19
app/services/webhook_delivery_service.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebhookDeliveryService
|
||||
|
||||
def initialize(webhook_delivery:)
|
||||
@webhook_delivery = webhook_delivery
|
||||
end
|
||||
|
||||
# TODO: move the logic from WebhookDelivery#deliver in to this service.
|
||||
#
|
||||
def call
|
||||
if @webhook_delivery.deliver
|
||||
log "Succesfully delivered"
|
||||
else
|
||||
log "Delivery failed"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
المرجع في مشكلة جديدة
حظر مستخدم