مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2025-11-30 21:32:30 +00:00
346 أسطر
10 KiB
Ruby
346 أسطر
10 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
# == Schema Information
|
||
#
|
||
# Table name: servers
|
||
#
|
||
# id :integer not null, primary key
|
||
# organization_id :integer
|
||
# uuid :string(255)
|
||
# name :string(255)
|
||
# mode :string(255)
|
||
# ip_pool_id :integer
|
||
# created_at :datetime
|
||
# updated_at :datetime
|
||
# permalink :string(255)
|
||
# send_limit :integer
|
||
# deleted_at :datetime
|
||
# message_retention_days :integer
|
||
# raw_message_retention_days :integer
|
||
# raw_message_retention_size :integer
|
||
# allow_sender :boolean default(FALSE)
|
||
# token :string(255)
|
||
# send_limit_approaching_at :datetime
|
||
# send_limit_approaching_notified_at :datetime
|
||
# send_limit_exceeded_at :datetime
|
||
# send_limit_exceeded_notified_at :datetime
|
||
# spam_threshold :decimal(8, 2)
|
||
# spam_failure_threshold :decimal(8, 2)
|
||
# postmaster_address :string(255)
|
||
# suspended_at :datetime
|
||
# outbound_spam_threshold :decimal(8, 2)
|
||
# domains_not_to_click_track :text(65535)
|
||
# suspension_reason :string(255)
|
||
# log_smtp_data :boolean default(FALSE)
|
||
#
|
||
# Indexes
|
||
#
|
||
# index_servers_on_organization_id (organization_id)
|
||
# index_servers_on_permalink (permalink)
|
||
# index_servers_on_token (token)
|
||
# index_servers_on_uuid (uuid)
|
||
#
|
||
|
||
class Server < ApplicationRecord
|
||
|
||
RESERVED_PERMALINKS = ["new", "all", "search", "stats", "edit", "manage", "delete", "destroy", "remove"]
|
||
|
||
include HasUUID
|
||
include HasSoftDestroy
|
||
|
||
attr_accessor :provision_database
|
||
|
||
belongs_to :organization
|
||
belongs_to :ip_pool, optional: true
|
||
has_many :domains, dependent: :destroy, as: :owner
|
||
has_many :credentials, dependent: :destroy
|
||
has_many :smtp_endpoints, dependent: :destroy
|
||
has_many :http_endpoints, dependent: :destroy
|
||
has_many :address_endpoints, dependent: :destroy
|
||
has_many :routes, dependent: :destroy
|
||
has_many :queued_messages, dependent: :delete_all
|
||
has_many :webhooks, dependent: :destroy
|
||
has_many :webhook_requests, dependent: :destroy
|
||
has_many :track_domains, dependent: :destroy
|
||
has_many :ip_pool_rules, dependent: :destroy, as: :owner
|
||
|
||
MODES = ["Live", "Development"]
|
||
|
||
random_string :token, type: :chars, length: 6, unique: true, upper_letters_only: true
|
||
default_value :permalink, -> { name ? name.parameterize : nil }
|
||
default_value :raw_message_retention_days, -> { 30 }
|
||
default_value :raw_message_retention_size, -> { 2048 }
|
||
default_value :message_retention_days, -> { 60 }
|
||
default_value :spam_threshold, -> { Postal.config.general.default_spam_threshold }
|
||
default_value :spam_failure_threshold, -> { Postal.config.general.default_spam_failure_threshold }
|
||
|
||
validates :name, presence: true, uniqueness: { scope: :organization_id, case_sensitive: false }
|
||
validates :mode, inclusion: { in: MODES }
|
||
validates :permalink, presence: true, uniqueness: { scope: :organization_id, case_sensitive: false }, format: { with: /\A[a-z0-9-]*\z/ }, exclusion: { in: RESERVED_PERMALINKS }
|
||
validate :validate_ip_pool_belongs_to_organization
|
||
|
||
before_validation(on: :create) do
|
||
self.token = token.downcase if token
|
||
end
|
||
|
||
after_create do
|
||
unless provision_database == false
|
||
message_db.provisioner.provision
|
||
end
|
||
end
|
||
|
||
after_commit(on: :destroy) do
|
||
unless provision_database == false
|
||
message_db.provisioner.drop
|
||
end
|
||
end
|
||
|
||
def status
|
||
if suspended?
|
||
"Suspended"
|
||
else
|
||
mode
|
||
end
|
||
end
|
||
|
||
def full_permalink
|
||
"#{organization.permalink}/#{permalink}"
|
||
end
|
||
|
||
def suspended?
|
||
suspended_at.present? || organization.suspended?
|
||
end
|
||
|
||
def actual_suspension_reason
|
||
return unless suspended?
|
||
|
||
if suspended_at.nil?
|
||
organization.suspension_reason
|
||
else
|
||
suspension_reason
|
||
end
|
||
end
|
||
|
||
def to_param
|
||
permalink
|
||
end
|
||
|
||
def message_db
|
||
@message_db ||= Postal::MessageDB::Database.new(organization_id, id)
|
||
end
|
||
|
||
delegate :message, to: :message_db
|
||
|
||
def message_rate
|
||
@message_rate ||= message_db.live_stats.total(60, types: [:incoming, :outgoing]) / 60.0
|
||
end
|
||
|
||
def held_messages
|
||
@held_messages ||= message_db.messages(where: { held: true }, count: true)
|
||
end
|
||
|
||
def throughput_stats
|
||
@throughput_stats ||= begin
|
||
incoming = message_db.live_stats.total(60, types: [:incoming])
|
||
outgoing = message_db.live_stats.total(60, types: [:outgoing])
|
||
outgoing_usage = send_limit ? (outgoing / send_limit.to_f) * 100 : 0
|
||
{
|
||
incoming: incoming,
|
||
outgoing: outgoing,
|
||
outgoing_usage: outgoing_usage
|
||
}
|
||
end
|
||
end
|
||
|
||
def bounce_rate
|
||
@bounce_rate ||= begin
|
||
time = Time.now.utc
|
||
total_outgoing = 0.0
|
||
total_bounces = 0.0
|
||
message_db.statistics.get(:daily, [:outgoing, :bounces], time, 30).each do |_, stat|
|
||
total_outgoing += stat[:outgoing]
|
||
total_bounces += stat[:bounces]
|
||
end
|
||
total_outgoing == 0 ? 0 : (total_bounces / total_outgoing) * 100
|
||
end
|
||
end
|
||
|
||
def domain_stats
|
||
domains = Domain.where(owner_id: id, owner_type: "Server").to_a
|
||
total = 0
|
||
unverified = 0
|
||
bad_dns = 0
|
||
domains.each do |domain|
|
||
total += 1
|
||
unverified += 1 unless domain.verified?
|
||
bad_dns += 1 if domain.verified? && !domain.dns_ok?
|
||
end
|
||
[total, unverified, bad_dns]
|
||
end
|
||
|
||
def webhook_hash
|
||
{
|
||
uuid: uuid,
|
||
name: name,
|
||
permalink: permalink,
|
||
organization: organization&.permalink
|
||
}
|
||
end
|
||
|
||
def send_volume
|
||
@send_volume ||= message_db.live_stats.total(60, types: [:outgoing])
|
||
end
|
||
|
||
def send_limit_approaching?
|
||
send_limit && (send_volume >= send_limit * 0.90)
|
||
end
|
||
|
||
def send_limit_exceeded?
|
||
send_limit && send_volume >= send_limit
|
||
end
|
||
|
||
def send_limit_warning(type)
|
||
AppMailer.send("server_send_limit_#{type}", self).deliver
|
||
update_column("send_limit_#{type}_notified_at", Time.now)
|
||
WebhookRequest.trigger(self, "SendLimit#{type.to_s.capitalize}", server: webhook_hash, volume: send_volume, limit: send_limit)
|
||
end
|
||
|
||
def queue_size
|
||
@queue_size ||= queued_messages.retriable.count
|
||
end
|
||
|
||
def stats
|
||
{
|
||
queue: queue_size,
|
||
held: held_messages,
|
||
bounce_rate: bounce_rate,
|
||
message_rate: message_rate,
|
||
throughput: throughput_stats,
|
||
size: message_db.total_size
|
||
}
|
||
end
|
||
|
||
# Return the domain which can be used to authenticate emails sent from the given 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?
|
||
|
||
address = Postal::Helpers.strip_name_from_address(address)
|
||
uname, domain_name = address.split("@", 2)
|
||
return nil unless uname
|
||
return nil unless domain_name
|
||
|
||
# Find a verified domain which directly matches the domain name for the given address.
|
||
domain = Domain.verified
|
||
.order(owner_type: :desc)
|
||
.where("(owner_type = 'Organization' AND owner_id = ?) OR " \
|
||
"(owner_type = 'Server' AND owner_id = ?)", organization_id, id)
|
||
.where(name: domain_name)
|
||
.first
|
||
|
||
# If there is a matching domain, return it
|
||
return domain if domain
|
||
|
||
# Otherwise, we need to look to see if there is a domain configured which can be used as the authenticated
|
||
# domain for any domain. This will look for domains directly within the server and return that.
|
||
any_domain = domains.verified.where(use_for_any: true).order(:name).first
|
||
return any_domain if any_domain
|
||
|
||
# Return nil if we can't find anything suitable
|
||
nil
|
||
end
|
||
|
||
def find_authenticated_domain_from_headers(headers)
|
||
header_to_check = ["from"]
|
||
header_to_check << "sender" if allow_sender?
|
||
header_to_check.each do |header_name|
|
||
if headers[header_name].is_a?(Array)
|
||
values = headers[header_name]
|
||
else
|
||
values = [headers[header_name].to_s]
|
||
end
|
||
|
||
authenticated_domains = values.map { |v| authenticated_domain_for_address(v) }.compact
|
||
if authenticated_domains.size == values.size
|
||
return authenticated_domains.first
|
||
end
|
||
end
|
||
nil
|
||
end
|
||
|
||
def suspend(reason)
|
||
self.suspended_at = Time.now
|
||
self.suspension_reason = reason
|
||
save!
|
||
AppMailer.server_suspended(self).deliver
|
||
end
|
||
|
||
def unsuspend
|
||
self.suspended_at = nil
|
||
self.suspension_reason = nil
|
||
save!
|
||
end
|
||
|
||
def validate_ip_pool_belongs_to_organization
|
||
return unless ip_pool && ip_pool_id_changed? && !organization.ip_pools.include?(ip_pool)
|
||
|
||
errors.add :ip_pool_id, "must belong to the organization"
|
||
end
|
||
|
||
def ip_pool_for_message(message)
|
||
return unless message.scope == "outgoing"
|
||
|
||
[self, organization].each do |scope|
|
||
rules = scope.ip_pool_rules.order(created_at: :desc)
|
||
rules.each do |rule|
|
||
if rule.apply_to_message?(message)
|
||
return rule.ip_pool
|
||
end
|
||
end
|
||
end
|
||
ip_pool
|
||
end
|
||
|
||
def self.triggered_send_limit(type)
|
||
servers = where("send_limit_#{type}_at IS NOT NULL AND send_limit_#{type}_at > ?", 3.minutes.ago)
|
||
servers.where("send_limit_#{type}_notified_at IS NULL OR send_limit_#{type}_notified_at < ?", 1.hour.ago)
|
||
end
|
||
|
||
def self.send_send_limit_notifications
|
||
[:approaching, :exceeded].each_with_object({}) do |type, hash|
|
||
hash[type] = 0
|
||
servers = triggered_send_limit(type)
|
||
next if servers.empty?
|
||
|
||
servers.each do |server|
|
||
hash[type] += 1
|
||
server.send_limit_warning(type)
|
||
end
|
||
end
|
||
end
|
||
|
||
def self.[](id, extra = nil)
|
||
server = nil
|
||
if id.is_a?(String)
|
||
if id =~ /\A(\w+)\/(\w+)\z/
|
||
server = includes(:organization).where(organizations: { permalink: ::Regexp.last_match(1) }, permalink: ::Regexp.last_match(2)).first
|
||
end
|
||
else
|
||
server = where(id: id).first
|
||
end
|
||
|
||
if extra
|
||
if extra.is_a?(String)
|
||
server.domains.where(name: extra.to_s).first
|
||
else
|
||
server.message(extra.to_i)
|
||
end
|
||
else
|
||
server
|
||
end
|
||
end
|
||
|
||
end
|