مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2025-12-01 05:43:04 +00:00
initial commit from appmail
هذا الالتزام موجود في:
76
app/models/additional_route_endpoint.rb
Normal file
76
app/models/additional_route_endpoint.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: additional_route_endpoints
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# route_id :integer
|
||||
# endpoint_type :string(255)
|
||||
# endpoint_id :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AdditionalRouteEndpoint < ApplicationRecord
|
||||
|
||||
belongs_to :route
|
||||
belongs_to :endpoint, :polymorphic => true
|
||||
|
||||
validate :validate_endpoint_belongs_to_server
|
||||
validate :validate_wildcard
|
||||
validate :validate_uniqueness
|
||||
|
||||
def self.find_by_endpoint(endpoint)
|
||||
class_name, id = endpoint.split('#', 2)
|
||||
unless Route::ENDPOINT_TYPES.include?(class_name)
|
||||
raise Postal::Error, "Invalid endpoint class name '#{class_name}'"
|
||||
end
|
||||
if uuid = class_name.constantize.find_by_uuid(id)
|
||||
where(:endpoint_type => class_name, :endpoint_id => uuid).first
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def _endpoint
|
||||
"#{endpoint_type}##{endpoint.uuid}"
|
||||
end
|
||||
|
||||
def _endpoint=(value)
|
||||
if value.blank?
|
||||
self.endpoint = nil
|
||||
else
|
||||
if value =~ /\#/
|
||||
class_name, id = value.split('#', 2)
|
||||
unless Route::ENDPOINT_TYPES.include?(class_name)
|
||||
raise Postal::Error, "Invalid endpoint class name '#{class_name}'"
|
||||
end
|
||||
self.endpoint = class_name.constantize.find_by_uuid(id)
|
||||
else
|
||||
self.endpoint = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_endpoint_belongs_to_server
|
||||
if self.endpoint && self.endpoint&.server != self.route.server
|
||||
errors.add :endpoint, :invalid
|
||||
end
|
||||
end
|
||||
|
||||
def validate_uniqueness
|
||||
if self.endpoint == self.route.endpoint
|
||||
errors.add :base, "You can only add an endpoint to a route once"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_wildcard
|
||||
if self.route.wildcard?
|
||||
if self.endpoint_type == 'SMTPEndpoint' || self.endpoint_type == 'AddressEndpoint'
|
||||
errors.add :base, "SMTP or address endpoints are not permitted on wildcard routes"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
42
app/models/address_endpoint.rb
Normal file
42
app/models/address_endpoint.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: address_endpoints
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# server_id :integer
|
||||
# uuid :string(255)
|
||||
# address :string(255)
|
||||
# last_used_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AddressEndpoint < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
belongs_to :server
|
||||
has_many :routes, :as => :endpoint
|
||||
has_many :additional_route_endpoints, :dependent => :destroy, :as => :endpoint
|
||||
|
||||
validates :address, :presence => true, :format => {:with => /@/}, :uniqueness => {:scope => [:server_id], :message => "has already been added"}
|
||||
|
||||
before_destroy :update_routes
|
||||
|
||||
def mark_as_used
|
||||
update_column(:last_used_at, Time.now)
|
||||
end
|
||||
|
||||
def update_routes
|
||||
self.routes.each { |r| r.update(:endpoint => nil, :mode => 'Reject') }
|
||||
end
|
||||
|
||||
def description
|
||||
self.address
|
||||
end
|
||||
|
||||
def domain
|
||||
address.split('@', 2).last
|
||||
end
|
||||
|
||||
end
|
||||
5
app/models/application_record.rb
Normal file
5
app/models/application_record.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
self.inheritance_column = 'sti_type'
|
||||
nilify_blanks
|
||||
end
|
||||
0
app/models/concerns/.keep
Normal file
0
app/models/concerns/.keep
Normal file
38
app/models/concerns/has_message.rb
Normal file
38
app/models/concerns/has_message.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
module HasMessage
|
||||
|
||||
def self.included(base)
|
||||
base.extend ClassMethods
|
||||
end
|
||||
|
||||
def message
|
||||
@message ||= self.server.message_db.message(self.message_id)
|
||||
end
|
||||
|
||||
def message=(message)
|
||||
@message = message
|
||||
self.message_id = message&.id
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def include_message
|
||||
queued_messages = all.to_a
|
||||
server_ids = queued_messages.map(&:server_id).uniq
|
||||
if server_ids.size == 0
|
||||
return []
|
||||
elsif server_ids.size > 1
|
||||
raise Postal::Error, "'include_message' can only be used on collections of messages from the same server"
|
||||
end
|
||||
message_ids = queued_messages.map(&:message_id).uniq
|
||||
server = queued_messages.first&.server
|
||||
messages = server.message_db.messages(:where => {:id => message_ids}).each_with_object({}) do |message, hash|
|
||||
hash[message.id] = message
|
||||
end
|
||||
queued_messages.each do |queued_message|
|
||||
if m = messages[queued_message.message_id]
|
||||
queued_message.message = m
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
19
app/models/concerns/has_soft_destroy.rb
Normal file
19
app/models/concerns/has_soft_destroy.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module HasSoftDestroy
|
||||
|
||||
def self.included(base)
|
||||
base.define_callbacks :soft_destroy
|
||||
base.class_eval do
|
||||
scope :deleted, -> { where.not(:deleted_at => nil) }
|
||||
scope :present, -> { where(:deleted_at => nil) }
|
||||
end
|
||||
end
|
||||
|
||||
def soft_destroy
|
||||
run_callbacks :soft_destroy do
|
||||
self.deleted_at = Time.now
|
||||
self.save!
|
||||
ActionDeletionJob.queue(:main, :type => self.class.name, :id => self.id)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
11
app/models/concerns/has_uuid.rb
Normal file
11
app/models/concerns/has_uuid.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
module HasUUID
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
random_string :uuid, :type => :uuid, :unique => true
|
||||
end
|
||||
end
|
||||
|
||||
def to_param
|
||||
uuid
|
||||
end
|
||||
end
|
||||
57
app/models/credential.rb
Normal file
57
app/models/credential.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: credentials
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# server_id :integer
|
||||
# key :string(255)
|
||||
# type :string(255)
|
||||
# name :string(255)
|
||||
# options :text(65535)
|
||||
# last_used_at :datetime
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# hold :boolean default(FALSE)
|
||||
#
|
||||
|
||||
class Credential < ApplicationRecord
|
||||
|
||||
belongs_to :server
|
||||
|
||||
TYPES = ['SMTP', 'API']
|
||||
|
||||
validates :key, :presence => true, :uniqueness => true
|
||||
validates :type, :inclusion => {:in => TYPES}
|
||||
validates :name, :presence => true
|
||||
|
||||
random_string :key, :type => :chars, :length => 24, :unique => true
|
||||
|
||||
serialize :options, Hash
|
||||
|
||||
def to_param
|
||||
key
|
||||
end
|
||||
|
||||
def use
|
||||
update_column(:last_used_at, Time.now)
|
||||
end
|
||||
|
||||
def usage_type
|
||||
if last_used_at.nil?
|
||||
'Unused'
|
||||
elsif last_used_at < 1.year.ago
|
||||
'Inactive'
|
||||
elsif last_used_at < 6.months.ago
|
||||
'Dormant'
|
||||
elsif last_used_at < 1.month.ago
|
||||
'Quiet'
|
||||
else
|
||||
'Active'
|
||||
end
|
||||
end
|
||||
|
||||
def to_smtp_plain
|
||||
Base64.encode64("\0XX\0#{self.key}").strip
|
||||
end
|
||||
|
||||
end
|
||||
166
app/models/domain.rb
Normal file
166
app/models/domain.rb
Normal file
@@ -0,0 +1,166 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: domains
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# server_id :integer
|
||||
# uuid :string(255)
|
||||
# name :string(255)
|
||||
# verification_token :string(255)
|
||||
# verification_method :string(255)
|
||||
# verified_at :datetime
|
||||
# dkim_private_key :text(65535)
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# dns_checked_at :datetime
|
||||
# spf_status :string(255)
|
||||
# spf_error :string(255)
|
||||
# dkim_status :string(255)
|
||||
# dkim_error :string(255)
|
||||
# mx_status :string(255)
|
||||
# mx_error :string(255)
|
||||
# return_path_status :string(255)
|
||||
# return_path_error :string(255)
|
||||
# outgoing :boolean default(TRUE)
|
||||
# incoming :boolean default(TRUE)
|
||||
# owner_type :string(255)
|
||||
# owner_id :integer
|
||||
# dkim_identifier_string :string(255)
|
||||
# use_for_any :boolean
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_domains_on_server_id (server_id)
|
||||
# index_domains_on_uuid (uuid)
|
||||
#
|
||||
|
||||
class Domain < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
require_dependency 'domain/dns_checks'
|
||||
require_dependency 'domain/dns_verification'
|
||||
|
||||
VERIFICATION_EMAIL_ALIASES = ['webmaster', 'postmaster', 'admin', 'administrator', 'hostmaster']
|
||||
|
||||
belongs_to :server, :optional => true
|
||||
belongs_to :owner, :optional => true, :polymorphic => true
|
||||
has_many :routes, :dependent => :destroy
|
||||
has_many :track_domains, :dependent => :destroy
|
||||
|
||||
VERIFICATION_METHODS = ['DNS', 'Email']
|
||||
|
||||
validates :name, :presence => true, :format => {:with => /\A[a-z0-9\-\.]*\*?\z/}, :uniqueness => {:scope => [:owner_type, :owner_id], :message => "is already added"}
|
||||
validates :verification_method, :inclusion => {:in => VERIFICATION_METHODS}
|
||||
|
||||
random_string :dkim_identifier_string, :type => :chars, :length => 6, :unique => true, :upper_letters_only => true
|
||||
|
||||
before_create :generate_dkim_key
|
||||
after_create :automatically_verify_domains_in_development
|
||||
|
||||
scope :verified, -> { where.not(:verified_at => nil) }
|
||||
|
||||
when_attribute :verification_method, :changes_to => :anything do
|
||||
before_save do
|
||||
if self.verification_method == 'DNS'
|
||||
self.verification_token = Nifty::Utils::RandomString.generate(:length => 32)
|
||||
elsif self.verification_method == 'Email'
|
||||
self.verification_token = rand(999999).to_s.ljust(6, '0')
|
||||
else
|
||||
self.verification_token = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def automatically_verify_domains_in_development
|
||||
if Rails.env.development? && self.name && self.name =~ /\*\z/
|
||||
self.name = self.name.gsub(/\*\z/, '')
|
||||
self.verified_at = Time.now
|
||||
self.verification_token = nil
|
||||
self.save
|
||||
end
|
||||
end
|
||||
|
||||
def verified?
|
||||
verified_at.present?
|
||||
end
|
||||
|
||||
def verify
|
||||
self.verified_at = Time.now
|
||||
self.save!
|
||||
end
|
||||
|
||||
def parent_domains
|
||||
parts = self.name.split('.')
|
||||
parts[0,parts.size-1].each_with_index.map do |p, i|
|
||||
parts[i..-1].join('.')
|
||||
end
|
||||
end
|
||||
|
||||
def generate_dkim_key
|
||||
self.dkim_private_key = OpenSSL::PKey::RSA.new(1024).to_s
|
||||
end
|
||||
|
||||
def dkim_key
|
||||
@dkim_key ||= OpenSSL::PKey::RSA.new(self.dkim_private_key)
|
||||
end
|
||||
|
||||
def to_param
|
||||
uuid
|
||||
end
|
||||
|
||||
def verification_email_addresses
|
||||
parent_domains.map do |domain|
|
||||
VERIFICATION_EMAIL_ALIASES.map do |a|
|
||||
"#{a}@#{domain}"
|
||||
end
|
||||
end.flatten
|
||||
end
|
||||
|
||||
def spf_record
|
||||
"v=spf1 a mx include:#{Postal.config.dns.spf_include} ~all"
|
||||
end
|
||||
|
||||
def dkim_record
|
||||
public_key = dkim_key.public_key.to_s.gsub(/\-+[A-Z ]+\-+\n/, '').gsub(/\n/, '')
|
||||
"v=DKIM1; t=s; h=sha256; p=#{public_key};"
|
||||
end
|
||||
|
||||
def dkim_identifier
|
||||
Postal.config.dns.dkim_identifier + "-#{self.dkim_identifier_string}"
|
||||
end
|
||||
|
||||
def dkim_record_name
|
||||
"#{dkim_identifier}._domainkey"
|
||||
end
|
||||
|
||||
def return_path_domain
|
||||
"#{Postal.config.dns.custom_return_path_prefix}.#{self.name}"
|
||||
end
|
||||
|
||||
def nameservers
|
||||
@nameservers ||= get_nameservers
|
||||
end
|
||||
|
||||
def resolver
|
||||
@resolver ||= Resolv::DNS.new(:nameserver => nameservers)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_nameservers
|
||||
local_resolver = Resolv::DNS.new
|
||||
ns_records = []
|
||||
parts = name.split('.')
|
||||
(parts.size - 1).times do |n|
|
||||
d = parts[n, parts.size - n + 1].join('.')
|
||||
ns_records = local_resolver.getresources(d, Resolv::DNS::Resource::IN::NS).map { |s| s.name.to_s }
|
||||
break unless ns_records.blank?
|
||||
end
|
||||
return [] if ns_records.blank?
|
||||
ns_records = ns_records.map{|r| local_resolver.getresources(r, Resolv::DNS::Resource::IN::A).map { |s| s.address.to_s} }.flatten
|
||||
return [] if ns_records.blank?
|
||||
ns_records
|
||||
end
|
||||
|
||||
end
|
||||
155
app/models/domain/dns_checks.rb
Normal file
155
app/models/domain/dns_checks.rb
Normal file
@@ -0,0 +1,155 @@
|
||||
class Domain
|
||||
|
||||
def dns_ok?
|
||||
spf_status == 'OK' && dkim_status == 'OK' && ['OK', 'Missing'].include?(self.mx_status) && ['OK', 'Missing'].include?(self.return_path_status)
|
||||
end
|
||||
|
||||
def dns_checked?
|
||||
spf_status.present?
|
||||
end
|
||||
|
||||
def check_dns(source = :manual)
|
||||
check_spf_record
|
||||
check_dkim_record
|
||||
check_mx_records
|
||||
check_return_path_record
|
||||
self.dns_checked_at = Time.now
|
||||
self.save!
|
||||
if source == :auto && !dns_ok? && self.owner.is_a?(Server)
|
||||
WebhookRequest.trigger(self.owner, 'DomainDNSError', {
|
||||
:server => self.owner.webhook_hash,
|
||||
:domain => self.name,
|
||||
:uuid => self.uuid,
|
||||
:dns_checked_at => self.dns_checked_at.to_f,
|
||||
:spf_status => self.spf_status,
|
||||
:spf_error => self.spf_error,
|
||||
:dkim_status => self.dkim_status,
|
||||
:dkim_error => self.dkim_error,
|
||||
:mx_status => self.mx_status,
|
||||
:mx_error => self.mx_error,
|
||||
:return_path_status => self.return_path_status,
|
||||
:return_path_error => self.return_path_error
|
||||
})
|
||||
end
|
||||
dns_ok?
|
||||
end
|
||||
|
||||
#
|
||||
# SPF
|
||||
#
|
||||
|
||||
def check_spf_record
|
||||
result = resolver.getresources(self.name, Resolv::DNS::Resource::IN::TXT)
|
||||
spf_records = result.map(&:data).select { |d| d =~ /\Av=spf1/}
|
||||
if spf_records.empty?
|
||||
self.spf_status = 'Missing'
|
||||
self.spf_error = 'No SPF record exists for this domain'
|
||||
else
|
||||
suitable_spf_records = spf_records.select { |d| d =~ /include\:\s*#{Regexp.escape(Postal.config.dns.spf_include)}/}
|
||||
if suitable_spf_records.empty?
|
||||
self.spf_status = 'Invalid'
|
||||
self.spf_error = "An SPF record exists but it doesn't include #{Postal.config.dns.spf_include}"
|
||||
false
|
||||
else
|
||||
self.spf_status = 'OK'
|
||||
self.spf_error = nil
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def check_spf_record!
|
||||
check_spf_record
|
||||
save!
|
||||
end
|
||||
|
||||
#
|
||||
# DKIM
|
||||
#
|
||||
|
||||
def check_dkim_record
|
||||
domain = "#{dkim_record_name}.#{name}"
|
||||
result = resolver.getresources(domain, Resolv::DNS::Resource::IN::TXT)
|
||||
records = result.map(&:data)
|
||||
if records.empty?
|
||||
self.dkim_status = 'Missing'
|
||||
self.dkim_error = "No TXT records were returned for #{domain}"
|
||||
else
|
||||
if records.size > 1
|
||||
self.dkim_status = 'Invalid'
|
||||
self.dkim_error = "There are #{records.size} records for at #{domain}. There should only be one."
|
||||
elsif records.first.strip != self.dkim_record
|
||||
self.dkim_status = 'Invalid'
|
||||
self.dkim_error = "The DKIM record at #{domain} does not match the record we have provided. Please check it has been copied correctly."
|
||||
else
|
||||
self.dkim_status = 'OK'
|
||||
self.dkim_error = nil
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def check_dkim_record!
|
||||
check_dkim_record
|
||||
save!
|
||||
end
|
||||
|
||||
#
|
||||
# MX
|
||||
#
|
||||
|
||||
def check_mx_records
|
||||
result = resolver.getresources(self.name, Resolv::DNS::Resource::IN::MX)
|
||||
records = result.map(&:exchange)
|
||||
if records.empty?
|
||||
self.mx_status = 'Missing'
|
||||
self.mx_error = "There are no MX records for #{self.name}"
|
||||
else
|
||||
missing_records = Postal.config.dns.mx_records.dup - records.map { |r| r.to_s.downcase }
|
||||
if missing_records.empty?
|
||||
self.mx_status = 'OK'
|
||||
self.mx_error = nil
|
||||
elsif missing_records.size == Postal.config.dns.mx_records.size
|
||||
self.mx_status = 'Missing'
|
||||
self.mx_error = 'You have MX records but none of them point to us.'
|
||||
else
|
||||
self.mx_status = 'Invalid'
|
||||
self.mx_error = "MX #{missing_records.size == 1 ? 'record' : 'records'} for #{missing_records.to_sentence} are missing and are required."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def check_mx_records!
|
||||
check_mx_records
|
||||
save!
|
||||
end
|
||||
|
||||
#
|
||||
# Return Path
|
||||
#
|
||||
|
||||
def check_return_path_record
|
||||
result = resolver.getresources(self.return_path_domain, Resolv::DNS::Resource::IN::CNAME)
|
||||
records = result.map { |r| r.name.to_s.downcase }
|
||||
if records.empty?
|
||||
self.return_path_status = 'Missing'
|
||||
self.return_path_error = "There is no return path record at #{self.return_path_domain}"
|
||||
else
|
||||
if records.size == 1 && records.first == Postal.config.dns.return_path
|
||||
self.return_path_status = 'OK'
|
||||
self.return_path_error = nil
|
||||
else
|
||||
self.return_path_status = 'Invalid'
|
||||
self.return_path_error = "There is a CNAME record at #{self.return_path_domain} but it points to #{records.first} which is incorrect. It should point to #{Postal.config.dns.return_path}."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def check_return_path_record!
|
||||
check_return_path_record
|
||||
save!
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# -*- SkipSchemaAnnotations
|
||||
20
app/models/domain/dns_verification.rb
Normal file
20
app/models/domain/dns_verification.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
class Domain
|
||||
|
||||
def dns_verification_string
|
||||
"#{Postal.config.dns.domain_verify_prefix} #{verification_token}"
|
||||
end
|
||||
|
||||
def verify_with_dns
|
||||
return false unless self.verification_method == 'DNS'
|
||||
result = resolver.getresources(self.name, Resolv::DNS::Resource::IN::TXT)
|
||||
if result.map { |d| d.data.to_s.strip}.include?(self.dns_verification_string)
|
||||
self.verified_at = Time.now
|
||||
self.save
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# -*- SkipSchemaAnnotations
|
||||
57
app/models/http_endpoint.rb
Normal file
57
app/models/http_endpoint.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: http_endpoints
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# server_id :integer
|
||||
# uuid :string(255)
|
||||
# name :string(255)
|
||||
# url :string(255)
|
||||
# encoding :string(255)
|
||||
# format :string(255)
|
||||
# strip_replies :boolean default(FALSE)
|
||||
# error :text(65535)
|
||||
# disabled_until :datetime
|
||||
# last_used_at :datetime
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# include_attachments :boolean default(TRUE)
|
||||
# timeout :integer
|
||||
#
|
||||
|
||||
class HTTPEndpoint < ApplicationRecord
|
||||
|
||||
DEFAULT_TIMEOUT = 5
|
||||
|
||||
include HasUUID
|
||||
|
||||
belongs_to :server
|
||||
has_many :routes, :as => :endpoint
|
||||
has_many :additional_route_endpoints, :dependent => :destroy, :as => :endpoint
|
||||
|
||||
ENCODINGS = ['BodyAsJSON', 'FormData']
|
||||
FORMATS = ['Hash', 'RawMessage']
|
||||
|
||||
before_destroy :update_routes
|
||||
|
||||
validates :name, :presence => true
|
||||
validates :url, :presence => true
|
||||
validates :encoding, :inclusion => {:in => ENCODINGS}
|
||||
validates :format, :inclusion => {:in => FORMATS}
|
||||
validates :timeout, :numericality => {:greater_than_or_equal_to => 5, :less_than_or_equal_to => 60}
|
||||
|
||||
default_value :timeout, -> { DEFAULT_TIMEOUT }
|
||||
|
||||
def description
|
||||
"#{name} (#{url})"
|
||||
end
|
||||
|
||||
def mark_as_used
|
||||
update_column(:last_used_at, Time.now)
|
||||
end
|
||||
|
||||
def update_routes
|
||||
self.routes.each { |r| r.update(:endpoint => nil, :mode => 'Reject') }
|
||||
end
|
||||
|
||||
end
|
||||
103
app/models/incoming_message_prototype.rb
Normal file
103
app/models/incoming_message_prototype.rb
Normal file
@@ -0,0 +1,103 @@
|
||||
class IncomingMessagePrototype
|
||||
|
||||
attr_accessor :to
|
||||
attr_accessor :from
|
||||
attr_accessor :route_id
|
||||
attr_accessor :subject
|
||||
attr_accessor :plain_body
|
||||
attr_accessor :attachments
|
||||
|
||||
def initialize(server, ip, source_type, attributes)
|
||||
@server = server
|
||||
@ip = ip
|
||||
@source_type = source_type
|
||||
@attachments = []
|
||||
attributes.each do |key, value|
|
||||
instance_variable_set("@#{key}", value)
|
||||
end
|
||||
end
|
||||
|
||||
def from_address
|
||||
@from.gsub(/.*</, '').gsub(/>.*/, '').strip
|
||||
end
|
||||
|
||||
def route
|
||||
@routes ||= begin
|
||||
if @to.present?
|
||||
uname, domain = @to.split('@', 2)
|
||||
uname, tag = uname.split('+', 2)
|
||||
@server.routes.includes(:domain).where(:domains => {:name => domain}, :name => uname).first
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def attachments
|
||||
(@attachments || []).map do |attachment|
|
||||
{
|
||||
:name => attachment[:name],
|
||||
:content_type => attachment[:content_type] || 'application/octet-stream',
|
||||
:data => attachment[:base64] ? Base64.decode64(attachment[:data]) : attachment[:data]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def create_messages
|
||||
if valid?
|
||||
messages = route.create_messages do |message|
|
||||
message.rcpt_to = @to
|
||||
message.mail_from = self.from_address
|
||||
message.raw_message = self.raw_message
|
||||
end
|
||||
{route.description => {:id => messages.first.id, :token => messages.first.token}}
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def valid?
|
||||
validate
|
||||
errors.empty?
|
||||
end
|
||||
|
||||
def errors
|
||||
@errors || []
|
||||
end
|
||||
|
||||
def validate
|
||||
@errors = Array.new
|
||||
if route.nil?
|
||||
@errors << "NoRoutesFound"
|
||||
end
|
||||
|
||||
if from.empty?
|
||||
@errors << "FromAddressMissing"
|
||||
end
|
||||
|
||||
if subject.blank?
|
||||
@errors << "SubjectMissing"
|
||||
end
|
||||
@errors
|
||||
end
|
||||
|
||||
def raw_message
|
||||
@raw_message ||= begin
|
||||
mail = Mail.new
|
||||
mail.to = @to
|
||||
mail.from = @from
|
||||
mail.subject = @subject
|
||||
mail.body = @plain_body
|
||||
mail.message_id = "<#{SecureRandom.uuid}@#{Postal.config.dns.return_path}>"
|
||||
attachments.each do |attachment|
|
||||
mail.attachments[attachment[:name]] = {
|
||||
:mime_type => attachment[:content_type],
|
||||
:content => attachment[:data]
|
||||
}
|
||||
end
|
||||
mail.header['Received'] = "from #{@source_type} (#{@ip} [#{@ip}]) by Postal with HTTP; #{Time.now.rfc2822.to_s}"
|
||||
mail.to_s
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
21
app/models/ip_address.rb
Normal file
21
app/models/ip_address.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ip_addresses
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# ip_pool_id :integer
|
||||
# ipv4 :string(255)
|
||||
# ipv6 :string(255)
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# hostname :string(255)
|
||||
#
|
||||
|
||||
class IPAddress < ApplicationRecord
|
||||
|
||||
belongs_to :ip_pool
|
||||
|
||||
validates :ipv4, :presence => true
|
||||
validates :hostname, :presence => true
|
||||
|
||||
end
|
||||
48
app/models/ip_pool.rb
Normal file
48
app/models/ip_pool.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ip_pools
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# name :string(255)
|
||||
# uuid :string(255)
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# default :boolean default(FALSE)
|
||||
# type :string(255)
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_ip_pools_on_uuid (uuid)
|
||||
#
|
||||
|
||||
class IPPool < ApplicationRecord
|
||||
|
||||
TYPES = ['Transactional', 'Bulk', 'Forwarding', 'Dedicated']
|
||||
|
||||
include HasUUID
|
||||
|
||||
validates :name, :presence => true
|
||||
|
||||
has_many :ip_addresses, :dependent => :restrict_with_exception
|
||||
has_many :servers, :dependent => :restrict_with_exception
|
||||
has_many :organization_ip_pools, :dependent => :destroy
|
||||
has_many :organizations, :through => :organization_ip_pools
|
||||
|
||||
scope :transactional, -> { where(:type => 'Transactional') }
|
||||
scope :bulk, -> { where(:type => 'Bulk') }
|
||||
scope :forwarding, -> { where(:type => 'Forwarding') }
|
||||
scope :dedicated, -> { where(:type => 'Dedicated') }
|
||||
|
||||
def self.default
|
||||
where(:default => true).order(:id).first
|
||||
end
|
||||
|
||||
def description
|
||||
desc = "#{name}"
|
||||
if self.type == 'Dedicated'
|
||||
desc += " (#{ip_addresses.map(&:ipv4).to_sentence})"
|
||||
end
|
||||
desc
|
||||
end
|
||||
|
||||
end
|
||||
82
app/models/ip_pool_rule.rb
Normal file
82
app/models/ip_pool_rule.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ip_pool_rules
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# uuid :string(255)
|
||||
# owner_type :string(255)
|
||||
# owner_id :integer
|
||||
# ip_pool_id :integer
|
||||
# from_text :text(65535)
|
||||
# to_text :text(65535)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class IPPoolRule < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
belongs_to :owner, :polymorphic => true
|
||||
belongs_to :ip_pool
|
||||
|
||||
validate :validate_from_and_to_addresses
|
||||
validate :validate_ip_pool_belongs_to_organization
|
||||
|
||||
def from
|
||||
from_text ? from_text.gsub(/\r/, '').split(/\n/).map(&:strip) : []
|
||||
end
|
||||
|
||||
def to
|
||||
to_text ? to_text.gsub(/\r/, '').split(/\n/).map(&:strip) : []
|
||||
end
|
||||
|
||||
def apply_to_message?(message)
|
||||
if from.present? && message.headers['from'].present?
|
||||
from.each do |condition|
|
||||
if message.headers['from'].any? { |f| self.class.address_matches?(condition, f) }
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if to.present? && message.rcpt_to.present?
|
||||
to.each do |condition|
|
||||
if self.class.address_matches?(condition, message.rcpt_to)
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_from_and_to_addresses
|
||||
if self.from.empty? && self.to.empty?
|
||||
errors.add :base, "At least one rule condition must be specified"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_ip_pool_belongs_to_organization
|
||||
org = self.owner.is_a?(Organization) ? self.owner : self.owner.organization
|
||||
if self.ip_pool && self.ip_pool_id_changed? && !org.ip_pools.include?(self.ip_pool)
|
||||
errors.add :ip_pool_id, "must belong to the organization"
|
||||
end
|
||||
end
|
||||
|
||||
def self.address_matches?(condition, address)
|
||||
address = Postal::Helpers.strip_name_from_address(address)
|
||||
if condition =~ /@/
|
||||
parts = address.split('@')
|
||||
domain, uname = parts.pop, parts.join('@')
|
||||
uname, _ = uname.split('+', 2)
|
||||
condition == "#{uname}@#{domain}"
|
||||
else
|
||||
# Match as a domain
|
||||
condition == address.split('@').last
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
118
app/models/organization.rb
Normal file
118
app/models/organization.rb
Normal file
@@ -0,0 +1,118 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: organizations
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# uuid :string(255)
|
||||
# name :string(255)
|
||||
# permalink :string(255)
|
||||
# time_zone :string(255)
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# ip_pool_id :integer
|
||||
# owner_id :integer
|
||||
# deleted_at :datetime
|
||||
# suspended_at :datetime
|
||||
# suspension_reason :string(255)
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_organizations_on_permalink (permalink)
|
||||
# index_organizations_on_uuid (uuid)
|
||||
#
|
||||
|
||||
class Organization < ApplicationRecord
|
||||
|
||||
RESERVED_PERMALINKS = ['new', 'edit', 'remove', 'delete', 'destroy', 'admin', 'mail', 'org', 'server']
|
||||
|
||||
INITIAL_QUOTA = 10
|
||||
INITIAL_SUPER_QUOTA = 10000
|
||||
include HasUUID
|
||||
include HasSoftDestroy
|
||||
|
||||
validates :name, :presence => true
|
||||
validates :permalink, :presence => true, :format => {:with => /\A[a-z0-9\-]*\z/}, :uniqueness => true, :exclusion => {:in => RESERVED_PERMALINKS}
|
||||
validates :time_zone, :presence => true
|
||||
|
||||
default_value :time_zone, -> { 'UTC' }
|
||||
default_value :permalink, -> { Organization.find_unique_permalink(self.name) if self.name }
|
||||
|
||||
belongs_to :owner, :class_name => 'User'
|
||||
has_many :organization_users, :dependent => :destroy
|
||||
has_many :users, :through => :organization_users, :source_type => 'User'
|
||||
has_many :user_invites, :through => :organization_users, :source_type => 'UserInvite', :source => :user
|
||||
has_many :servers, :dependent => :destroy
|
||||
has_many :domains, :as => :owner, :dependent => :destroy
|
||||
has_many :organization_ip_pools, :dependent => :destroy
|
||||
has_many :ip_pools, :through => :organization_ip_pools
|
||||
has_many :ip_pool_rules, :dependent => :destroy, :as => :owner
|
||||
|
||||
after_create do
|
||||
self.ip_pools << IPPool.transactional.default
|
||||
end
|
||||
|
||||
def status
|
||||
if self.suspended?
|
||||
'Suspended'
|
||||
else
|
||||
'Active'
|
||||
end
|
||||
end
|
||||
|
||||
def to_param
|
||||
permalink
|
||||
end
|
||||
|
||||
def suspended?
|
||||
suspended_at.present?
|
||||
end
|
||||
|
||||
def admin?(user)
|
||||
user.admin? ||
|
||||
!!(owner?(user) || user_assignment(user)&.admin?)
|
||||
end
|
||||
|
||||
def owner?(user)
|
||||
self.owner == user
|
||||
end
|
||||
|
||||
def accessible_by?(user)
|
||||
user.admin? ||
|
||||
!!(user_assignment(user))
|
||||
end
|
||||
|
||||
def user_assignment(user)
|
||||
@user_assignments ||= {}
|
||||
@user_assignments[user.id] ||= organization_users.where(:user => user).first
|
||||
end
|
||||
|
||||
def make_owner(new_owner)
|
||||
user_assignment(new_owner).update(:admin => true, :all_servers => true)
|
||||
update(:owner => new_owner)
|
||||
end
|
||||
|
||||
# This is an array of addresses that should receive notifications for this organization
|
||||
def notification_addresses
|
||||
self.users.map(&:email_tag)
|
||||
end
|
||||
|
||||
def self.find_unique_permalink(name)
|
||||
loop.each_with_index do |_, i|
|
||||
i = i + 1
|
||||
proposal = name.parameterize
|
||||
proposal += "-#{i}" if i > 1
|
||||
unless self.where(:permalink => proposal).exists?
|
||||
return proposal
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.[](id)
|
||||
if id.is_a?(String)
|
||||
where(:permalink => id).first
|
||||
else
|
||||
where(:id => id.to_i).first
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
15
app/models/organization_ip_pool.rb
Normal file
15
app/models/organization_ip_pool.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: organization_ip_pools
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# organization_id :integer
|
||||
# ip_pool_id :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class OrganizationIPPool < ApplicationRecord
|
||||
belongs_to :organization
|
||||
belongs_to :ip_pool
|
||||
end
|
||||
58
app/models/organization_user.rb
Normal file
58
app/models/organization_user.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: organization_users
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# organization_id :integer
|
||||
# user_id :integer
|
||||
# created_at :datetime
|
||||
# admin :boolean default(FALSE)
|
||||
# all_servers :boolean default(TRUE)
|
||||
# user_type :string(255)
|
||||
#
|
||||
|
||||
class OrganizationUser < ApplicationRecord
|
||||
|
||||
belongs_to :organization
|
||||
belongs_to :user, :polymorphic => true, :optional => true
|
||||
|
||||
validate :validate_uniqueness
|
||||
|
||||
before_create :create_user_invite
|
||||
after_destroy :remove_user_invites
|
||||
|
||||
def email_address
|
||||
@email_address ||= user&.email_address
|
||||
end
|
||||
|
||||
def email_address=(value)
|
||||
@email_address = value
|
||||
end
|
||||
|
||||
def create_user_invite
|
||||
if self.user.nil?
|
||||
user = UserInvite.where(:email_address => @email_address).first_or_initialize
|
||||
if user.save
|
||||
self.user = user
|
||||
else
|
||||
errors.add :base, user.errors.full_messages.to_sentence
|
||||
throw :abort
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_uniqueness
|
||||
if self.email_address.present?
|
||||
if organization.organization_users.where.not(:id => self.id).any? { |ou| ou.user.email_address.upcase == self.email_address.upcase }
|
||||
errors.add :email_address, "is already assigned or has an pending invite"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def remove_user_invites
|
||||
if self.user.is_a?(UserInvite) && self.user.organizations.empty?
|
||||
self.user.destroy
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
201
app/models/outgoing_message_prototype.rb
Normal file
201
app/models/outgoing_message_prototype.rb
Normal file
@@ -0,0 +1,201 @@
|
||||
class OutgoingMessagePrototype
|
||||
|
||||
attr_accessor :from
|
||||
attr_accessor :sender
|
||||
attr_accessor :to
|
||||
attr_accessor :cc
|
||||
attr_accessor :bcc
|
||||
attr_accessor :subject
|
||||
attr_accessor :reply_to
|
||||
attr_accessor :custom_headers
|
||||
attr_accessor :plain_body
|
||||
attr_accessor :html_body
|
||||
attr_accessor :attachments
|
||||
attr_accessor :tag
|
||||
attr_accessor :credential
|
||||
attr_accessor :bounce
|
||||
|
||||
def initialize(server, ip, source_type, attributes)
|
||||
@server = server
|
||||
@ip = ip
|
||||
@source_type = source_type
|
||||
@custom_headers = {}
|
||||
@attachments = []
|
||||
@message_id = "#{SecureRandom.uuid}@#{Postal.config.dns.return_path}"
|
||||
attributes.each do |key, value|
|
||||
instance_variable_set("@#{key}", value)
|
||||
end
|
||||
end
|
||||
|
||||
def message_id
|
||||
@message_id
|
||||
end
|
||||
|
||||
def from_address
|
||||
Postal::Helpers.strip_name_from_address(@from)
|
||||
end
|
||||
|
||||
def sender_address
|
||||
Postal::Helpers.strip_name_from_address(@sender)
|
||||
end
|
||||
|
||||
def domain
|
||||
@domain ||= begin
|
||||
d = find_domain
|
||||
d == :none ? nil : d
|
||||
end
|
||||
end
|
||||
|
||||
def find_domain
|
||||
@domain ||= begin
|
||||
domain = @server.authenticated_domain_for_address(@from)
|
||||
if @server.allow_sender? && domain.nil?
|
||||
domain = @server.authenticated_domain_for_address(@sender)
|
||||
end
|
||||
domain || :none
|
||||
end
|
||||
end
|
||||
|
||||
def to_addresses
|
||||
@to.is_a?(String) ? @to.to_s.split(/\,\s*/) : @to.to_a
|
||||
end
|
||||
|
||||
def cc_addresses
|
||||
@cc.is_a?(String) ? @cc.to_s.split(/\,\s*/) : @cc.to_a
|
||||
end
|
||||
|
||||
def bcc_addresses
|
||||
@bcc.is_a?(String) ? @bcc.to_s.split(/\,\s*/) : @bcc.to_a
|
||||
end
|
||||
|
||||
def all_addresses
|
||||
[to_addresses, cc_addresses, bcc_addresses].flatten
|
||||
end
|
||||
|
||||
def create_messages
|
||||
if valid?
|
||||
all_addresses.each_with_object({}) do |address, hash|
|
||||
if address = Postal::Helpers.strip_name_from_address(address)
|
||||
hash[address] = create_message(address)
|
||||
end
|
||||
end
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def valid?
|
||||
validate
|
||||
errors.empty?
|
||||
end
|
||||
|
||||
def errors
|
||||
@errors || {}
|
||||
end
|
||||
|
||||
def attachments
|
||||
(@attachments || []).map do |attachment|
|
||||
{
|
||||
:name => attachment[:name],
|
||||
:content_type => attachment[:content_type] || 'application/octet-stream',
|
||||
:data => attachment[:base64] ? Base64.decode64(attachment[:data]) : attachment[:data]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def validate
|
||||
@errors = Array.new
|
||||
|
||||
if to_addresses.empty? && cc_addresses.empty? && bcc_addresses.empty?
|
||||
@errors << "NoRecipients"
|
||||
end
|
||||
|
||||
if to_addresses.size > 50
|
||||
@errors << 'TooManyToAddresses'
|
||||
end
|
||||
|
||||
if cc_addresses.size > 50
|
||||
@errors << 'TooManyCCAddresses'
|
||||
end
|
||||
|
||||
if bcc_addresses.size > 50
|
||||
@errors << 'TooManyBCCAddresses'
|
||||
end
|
||||
|
||||
if @plain_body.blank? && @html_body.blank?
|
||||
@errors << "NoContent"
|
||||
end
|
||||
|
||||
if from.blank?
|
||||
@errors << "FromAddressMissing"
|
||||
end
|
||||
|
||||
if domain.nil?
|
||||
@errors << "UnauthenticatedFromAddress"
|
||||
end
|
||||
|
||||
if attachments && !attachments.empty?
|
||||
attachments.each_with_index do |attachment, index|
|
||||
if attachment[:name].blank?
|
||||
@errors << "AttachmentMissingName" unless @errors.include?("AttachmentMissingName")
|
||||
elsif attachment[:data].blank?
|
||||
@errors << "AttachmentMissingData" unless @errors.include?("AttachmentMissingData")
|
||||
end
|
||||
end
|
||||
end
|
||||
@errors
|
||||
end
|
||||
|
||||
def raw_message
|
||||
@raw_message ||= begin
|
||||
mail = Mail.new
|
||||
if @custom_headers.is_a?(Hash)
|
||||
@custom_headers.each { |key, value| mail[key.to_s] = value.to_s }
|
||||
end
|
||||
mail.to = self.to_addresses.join(', ') if self.to_addresses.present?
|
||||
mail.cc = self.cc_addresses.join(', ') if self.cc_addresses.present?
|
||||
mail.from = @from
|
||||
mail.sender = @sender
|
||||
mail.subject = @subject
|
||||
mail.reply_to = @reply_to
|
||||
if @html_body.blank? && attachments.empty?
|
||||
mail.body = @plain_body
|
||||
else
|
||||
mail.text_part = Mail::Part.new
|
||||
mail.text_part.body = @plain_body
|
||||
mail.html_part = Mail::Part.new
|
||||
mail.html_part.content_type = "text/html; charset=UTF-8"
|
||||
mail.html_part.body = @html_body
|
||||
end
|
||||
attachments.each do |attachment|
|
||||
mail.attachments[attachment[:name]] = {
|
||||
:mime_type => attachment[:content_type],
|
||||
:content => attachment[:data]
|
||||
}
|
||||
end
|
||||
mail.header['Received'] = "from #{@source_type} (#{self.resolved_hostname} [#{@ip}]) by Postal with HTTP; #{Time.now.utc.rfc2822.to_s}"
|
||||
mail.message_id = "<#{@message_id}>"
|
||||
mail.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def create_message(address)
|
||||
message = @server.message_db.new_message
|
||||
message.scope = 'outgoing'
|
||||
message.rcpt_to = address
|
||||
message.mail_from = self.from_address
|
||||
message.domain_id = self.domain.id
|
||||
message.raw_message = self.raw_message
|
||||
message.tag = self.tag
|
||||
message.credential_id = self.credential&.id
|
||||
message.received_with_ssl = true
|
||||
message.bounce = @bounce ? 1 : 0
|
||||
message.save
|
||||
{:id => message.id, :token => message.token}
|
||||
end
|
||||
|
||||
def resolved_hostname
|
||||
@resolved_hostname ||= Resolv.new.getname(@ip) rescue @ip
|
||||
end
|
||||
|
||||
end
|
||||
124
app/models/queued_message.rb
Normal file
124
app/models/queued_message.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: queued_messages
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# server_id :integer
|
||||
# message_id :integer
|
||||
# domain :string(255)
|
||||
# locked_by :string(255)
|
||||
# locked_at :datetime
|
||||
# retry_after :datetime
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# ip_address_id :integer
|
||||
# attempts :integer default(0)
|
||||
# route_id :integer
|
||||
# manual :boolean default(FALSE)
|
||||
# batch_key :string(255)
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_queued_messages_on_domain (domain)
|
||||
# index_queued_messages_on_message_id (message_id)
|
||||
# index_queued_messages_on_server_id (server_id)
|
||||
#
|
||||
|
||||
class QueuedMessage < ApplicationRecord
|
||||
|
||||
MAX_ATTEMPTS = 18
|
||||
|
||||
include HasMessage
|
||||
|
||||
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 <= ?", 30.seconds.from_now) }
|
||||
|
||||
def retriable?
|
||||
self.retry_after.nil? || self.retry_after <= 30.seconds.from_now
|
||||
end
|
||||
|
||||
def queue
|
||||
UnqueueMessageJob.queue(queue_name, :id => self.id)
|
||||
end
|
||||
|
||||
def queue!
|
||||
update_column(:retry_after, nil)
|
||||
queue
|
||||
end
|
||||
|
||||
def queue_name
|
||||
ip_address ? :"outgoing-#{ip_address.id}" : :main
|
||||
end
|
||||
|
||||
def send_bounce
|
||||
if self.message.send_bounces?
|
||||
Postal::BounceMessage.new(self.server, self.message).queue
|
||||
end
|
||||
end
|
||||
|
||||
def allocate_ip_address
|
||||
if Postal.config.general.use_ip_pools && self.message && pool = self.server.ip_pool_for_message(self.message)
|
||||
self.ip_address = pool.ip_addresses.order("RAND()").first
|
||||
end
|
||||
end
|
||||
|
||||
def acquire_lock
|
||||
time = Time.now
|
||||
locker = Postal.locker_name
|
||||
rows = self.class.where(:id => self.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(self.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 => self.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"
|
||||
end
|
||||
if self.batch_key.nil?
|
||||
[]
|
||||
else
|
||||
time = Time.now
|
||||
locker = Postal.locker_name
|
||||
self.class.retriable.where(:batch_key => self.batch_key, :ip_address_id => self.ip_address_id, :locked_by => nil, :locked_at => nil).limit(limit).update_all(:locked_by => locker, :locked_at => time)
|
||||
QueuedMessage.where(:batch_key => self.batch_key, :ip_address_id => self.ip_address_id, :locked_by => locker, :locked_at => time)
|
||||
end
|
||||
end
|
||||
|
||||
def self.requeue_all
|
||||
unlocked.retriable.each(&:queue)
|
||||
end
|
||||
|
||||
end
|
||||
242
app/models/route.rb
Normal file
242
app/models/route.rb
Normal file
@@ -0,0 +1,242 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: routes
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# uuid :string(255)
|
||||
# server_id :integer
|
||||
# domain_id :integer
|
||||
# endpoint_id :integer
|
||||
# endpoint_type :string(255)
|
||||
# name :string(255)
|
||||
# spam_mode :string(255)
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# token :string(255)
|
||||
# mode :string(255)
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_routes_on_token (token)
|
||||
#
|
||||
|
||||
class Route < ApplicationRecord
|
||||
|
||||
MODES = ['Endpoint', 'Accept', 'Hold', 'Bounce', 'Reject']
|
||||
|
||||
include HasUUID
|
||||
|
||||
belongs_to :server
|
||||
belongs_to :domain, :optional => true
|
||||
belongs_to :endpoint, :polymorphic => true, :optional => true
|
||||
has_many :additional_route_endpoints, :dependent => :destroy
|
||||
|
||||
SPAM_MODES = ['Mark', 'Quarantine', 'Fail']
|
||||
ENDPOINT_TYPES = ['SMTPEndpoint', 'HTTPEndpoint', 'AddressEndpoint']
|
||||
|
||||
validates :name, :presence => true, :format => /\A(([a-z0-9\-\.]*)|(\*)|(__returnpath__))\z/
|
||||
validates :spam_mode, :inclusion => {:in => SPAM_MODES}
|
||||
validates :endpoint, :presence => {:if => proc { self.mode == 'Endpoint' }}
|
||||
validates :domain_id, :presence => {:unless => :return_path?}
|
||||
validate :validate_route_is_routed
|
||||
validate :validate_domain_belongs_to_server
|
||||
validate :validate_endpoint_belongs_to_server
|
||||
validate :validate_name_uniqueness
|
||||
validate :validate_wildcard
|
||||
validate :validate_return_path_route_endpoints
|
||||
validate :validate_no_additional_routes_on_non_endpoint_route
|
||||
|
||||
after_save :save_additional_route_endpoints
|
||||
|
||||
random_string :token, :type => :chars, :length => 8, :unique => true
|
||||
|
||||
def return_path?
|
||||
name == "__returnpath__"
|
||||
end
|
||||
|
||||
def description
|
||||
if return_path?
|
||||
"Return Path"
|
||||
else
|
||||
"#{name}@#{domain.name}"
|
||||
end
|
||||
end
|
||||
|
||||
def _endpoint
|
||||
@endpoint ||= begin
|
||||
if self.mode == 'Endpoint'
|
||||
endpoint ? "#{endpoint.class}##{endpoint.uuid}" : nil
|
||||
else
|
||||
self.mode
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def _endpoint=(value)
|
||||
if value.blank?
|
||||
self.endpoint = nil
|
||||
self.mode = nil
|
||||
else
|
||||
if value =~ /\#/
|
||||
class_name, id = value.split('#', 2)
|
||||
unless ENDPOINT_TYPES.include?(class_name)
|
||||
raise Postal::Error, "Invalid endpoint class name '#{class_name}'"
|
||||
end
|
||||
self.endpoint = class_name.constantize.find_by_uuid(id)
|
||||
self.mode = 'Endpoint'
|
||||
else
|
||||
self.endpoint = nil
|
||||
self.mode = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def forward_address
|
||||
@forward_address ||= "#{token}@#{Postal.config.dns.route_domain}"
|
||||
end
|
||||
|
||||
def wildcard?
|
||||
self.name == '*'
|
||||
end
|
||||
|
||||
def additional_route_endpoints_array
|
||||
@additional_route_endpoints_array ||= additional_route_endpoints.map(&:_endpoint)
|
||||
end
|
||||
|
||||
def additional_route_endpoints_array=(array)
|
||||
@additional_route_endpoints_array = array.reject(&:blank?)
|
||||
end
|
||||
|
||||
def save_additional_route_endpoints
|
||||
if @additional_route_endpoints_array
|
||||
seen = []
|
||||
@additional_route_endpoints_array.each do |item|
|
||||
if existing = additional_route_endpoints.find_by_endpoint(item)
|
||||
seen << existing.id
|
||||
else
|
||||
route = additional_route_endpoints.build(:_endpoint => item)
|
||||
if route.save
|
||||
seen << route.id
|
||||
else
|
||||
route.errors.each do |field, message|
|
||||
errors.add :base, message
|
||||
end
|
||||
raise ActiveRecord::RecordInvalid
|
||||
end
|
||||
end
|
||||
end
|
||||
additional_route_endpoints.where.not(:id => seen).destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# This message will create a suitable number of message objects for messages that
|
||||
# are destined for this route. It receives a block which can set the message content
|
||||
# but most information is specified already.
|
||||
#
|
||||
# Returns an array of created messages.
|
||||
#
|
||||
def create_messages(&block)
|
||||
messages = []
|
||||
message = self.build_message
|
||||
if self.mode == 'Endpoint' && self.server.message_db.schema_version >= 18
|
||||
message.endpoint_type = self.endpoint_type
|
||||
message.endpoint_id = self.endpoint_id
|
||||
end
|
||||
block.call(message)
|
||||
message.save
|
||||
messages << message
|
||||
|
||||
# Also create any messages for additional endpoints that might exist
|
||||
if self.mode == 'Endpoint' && self.server.message_db.schema_version >= 18
|
||||
self.additional_route_endpoints.each do |endpoint|
|
||||
next unless endpoint.endpoint
|
||||
message = self.build_message
|
||||
message.endpoint_id = endpoint.endpoint_id
|
||||
message.endpoint_type = endpoint.endpoint_type
|
||||
block.call(message)
|
||||
message.save
|
||||
messages << message
|
||||
end
|
||||
end
|
||||
|
||||
messages
|
||||
end
|
||||
|
||||
def build_message
|
||||
message = self.server.message_db.new_message
|
||||
message.scope = 'incoming'
|
||||
message.rcpt_to = self.description
|
||||
message.domain_id = self.domain&.id
|
||||
message.route_id = self.id
|
||||
message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_route_is_routed
|
||||
if self.mode.nil?
|
||||
errors.add :endpoint, "must be chosen"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_wildcard
|
||||
if self.wildcard?
|
||||
if self.endpoint_type == 'SMTPEndpoint' || self.endpoint_type == 'AddressEndpoint'
|
||||
errors.add :base, "Wildcard routes cannot be routed to SMTP servers or addresses"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_domain_belongs_to_server
|
||||
if self.domain && ![self.server, self.server.organization].include?(self.domain.owner)
|
||||
errors.add :domain, :invalid
|
||||
end
|
||||
|
||||
if self.domain && !self.domain.verified?
|
||||
errors.add :domain, "has not been verified yet"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_endpoint_belongs_to_server
|
||||
if self.endpoint && self.endpoint&.server != self.server
|
||||
errors.add :endpoint, :invalid
|
||||
end
|
||||
end
|
||||
|
||||
def validate_name_uniqueness
|
||||
return if self.server.nil?
|
||||
if self.domain
|
||||
if route = Route.includes(:domain).where(:domains => {:name => self.domain.name}, :name => self.name).where.not(:id => self.id).first
|
||||
errors.add :name, "is configured on the #{route.server.full_permalink} mail server"
|
||||
end
|
||||
else
|
||||
if route = Route.where(:name => "__returnpath__").where.not(:id => self.id).exists?
|
||||
errors.add :base, "A return path route already exists for this server"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_return_path_route_endpoints
|
||||
if return_path?
|
||||
if self.mode != 'Endpoint' || self.endpoint_type != 'HTTPEndpoint'
|
||||
errors.add :base, "Return path routes must point to an HTTP endpoint"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_no_additional_routes_on_non_endpoint_route
|
||||
if self.mode != 'Endpoint' && !self.additional_route_endpoints_array.empty?
|
||||
errors.add :base, "Additional routes are not permitted unless the primary route is an actual endpoint"
|
||||
end
|
||||
end
|
||||
|
||||
def self.find_by_name_and_domain(name, domain)
|
||||
route = Route.includes(:domain).where(:name => name, :domains => {:name => domain}).first
|
||||
if route.nil?
|
||||
route = Route.includes(:domain).where(:name => '*', :domains => {:name => domain}).first
|
||||
end
|
||||
route
|
||||
end
|
||||
|
||||
end
|
||||
338
app/models/server.rb
Normal file
338
app/models/server.rb
Normal file
@@ -0,0 +1,338 @@
|
||||
# == 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
|
||||
|
||||
belongs_to :organization
|
||||
belongs_to :ip_pool
|
||||
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 :send_limit, -> { 100 }
|
||||
default_value :raw_message_retention_days, -> { 30 }
|
||||
default_value :raw_message_retention_size, -> { 2048 }
|
||||
default_value :message_retention_days, -> { 60 }
|
||||
default_value :spam_threshold, -> { 5.0 }
|
||||
default_value :spam_failure_threshold, -> { 20.0 }
|
||||
|
||||
validates :name, :presence => true, :uniqueness => {:scope => :organization_id}
|
||||
validates :mode, :inclusion => {:in => MODES}
|
||||
validates :permalink, :presence => true, :uniqueness => {:scope => :organization_id}, :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 = self.token.downcase if self.token
|
||||
self.outbound_spam_threshold = 3.0 if self.outbound_spam_threshold.blank?
|
||||
end
|
||||
|
||||
after_create do
|
||||
message_db.provisioner.provision
|
||||
end
|
||||
|
||||
after_commit(:on => :destroy) do
|
||||
message_db.provisioner.drop
|
||||
end
|
||||
|
||||
def status
|
||||
if self.suspended?
|
||||
'Suspended'
|
||||
else
|
||||
self.mode
|
||||
end
|
||||
end
|
||||
|
||||
def full_permalink
|
||||
"#{organization.permalink}/#{permalink}"
|
||||
end
|
||||
|
||||
def suspended?
|
||||
suspended_at.present? || organization.suspended?
|
||||
end
|
||||
|
||||
def actual_suspension_reason
|
||||
if suspended?
|
||||
if suspended_at.nil?
|
||||
organization.suspension_reason
|
||||
else
|
||||
self.suspension_reason
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def accessible_by?(user)
|
||||
organization.accessible_by?(user)
|
||||
end
|
||||
|
||||
def to_param
|
||||
permalink
|
||||
end
|
||||
|
||||
def message_db
|
||||
@message_db ||= Postal::MessageDB::Database.new(self.organization_id, self.id)
|
||||
end
|
||||
|
||||
def message(id)
|
||||
message_db.message(id)
|
||||
end
|
||||
|
||||
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 => 1}, :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 |date, 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 => self.id, :owner_type => 'Server').to_a
|
||||
total, unverified, bad_dns = 0, 0, 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 => self.uuid,
|
||||
:name => self.name,
|
||||
:permalink => self.permalink,
|
||||
:organization => self.organization&.permalink
|
||||
}
|
||||
end
|
||||
|
||||
def send_volume
|
||||
@send_volume ||= message_db.live_stats.total(60, :types => [:outgoing])
|
||||
end
|
||||
|
||||
def send_limit_approaching?
|
||||
send_volume >= self.send_limit * 0.90
|
||||
end
|
||||
|
||||
def send_limit_exceeded?
|
||||
send_volume >= self.send_limit
|
||||
end
|
||||
|
||||
def send_limit_warning(type)
|
||||
AppMailer.send("server_send_limit_#{type}", self).deliver
|
||||
self.update_column("send_limit_#{type}_notified_at", Time.now)
|
||||
WebhookRequest.trigger(self, "SendLimit#{type.to_s.capitalize}", :server => webhook_hash, :volume => self.send_volume, :limit => self.send_limit)
|
||||
end
|
||||
|
||||
def queue_size
|
||||
@queue_size ||= queued_messages.retriable.count
|
||||
end
|
||||
|
||||
def stats
|
||||
{
|
||||
:queue => queue_size,
|
||||
:held => self.held_messages,
|
||||
:bounce_rate => self.bounce_rate,
|
||||
:message_rate => self.message_rate,
|
||||
:throughput => self.throughput_stats,
|
||||
:size => self.message_db.total_size
|
||||
}
|
||||
end
|
||||
|
||||
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
|
||||
uname, _ = uname.split('+', 2)
|
||||
|
||||
# Check the server's domain
|
||||
if domain = Domain.verified.order(:owner_type => :desc).where("(owner_type = 'Organization' AND owner_id = ?) OR (owner_type = 'Server' AND owner_id = ?)", self.organization_id, self.id).where(:name => domain_name).first
|
||||
return domain
|
||||
end
|
||||
|
||||
# Check with global domains
|
||||
if route = self.routes.includes(:domain).references(:domain).where(:domains => {:server_id => nil, :name => domain_name}, :name => uname).first
|
||||
return route.domain
|
||||
end
|
||||
|
||||
if any_domain = self.domains.verified.where(:use_for_any => true).order(:name).first
|
||||
return any_domain
|
||||
end
|
||||
end
|
||||
|
||||
def find_authenticated_domain_from_headers(headers)
|
||||
header_to_check = ['from']
|
||||
header_to_check << 'sender' if self.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
|
||||
self.save!
|
||||
AppMailer.server_suspended(self).deliver
|
||||
end
|
||||
|
||||
def unsuspend
|
||||
self.suspended_at = nil
|
||||
self.suspension_reason = nil
|
||||
self.save!
|
||||
end
|
||||
|
||||
def validate_ip_pool_belongs_to_organization
|
||||
if self.ip_pool && self.ip_pool_id_changed? && (self.ip_pool.type == 'Dedicated' && !self.organization.ip_pools.include?(self.ip_pool))
|
||||
errors.add :ip_pool_id, "must belong to the organization"
|
||||
end
|
||||
end
|
||||
|
||||
def ip_pool_for_message(message)
|
||||
if message.scope == 'outgoing'
|
||||
|
||||
[self, 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
|
||||
|
||||
self.ip_pool
|
||||
else
|
||||
nil
|
||||
end
|
||||
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 = self.triggered_send_limit(type)
|
||||
unless servers.empty?
|
||||
servers.each do |server|
|
||||
hash[type] += 1
|
||||
server.send_limit_warning(type)
|
||||
end
|
||||
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 => $1}, :permalink => $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
|
||||
48
app/models/smtp_endpoint.rb
Normal file
48
app/models/smtp_endpoint.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: smtp_endpoints
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# server_id :integer
|
||||
# uuid :string(255)
|
||||
# name :string(255)
|
||||
# hostname :string(255)
|
||||
# ssl_mode :string(255)
|
||||
# port :integer
|
||||
# error :text(65535)
|
||||
# disabled_until :datetime
|
||||
# last_used_at :datetime
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
#
|
||||
|
||||
class SMTPEndpoint < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
belongs_to :server
|
||||
has_many :routes, :as => :endpoint
|
||||
has_many :additional_route_endpoints, :dependent => :destroy, :as => :endpoint
|
||||
|
||||
SSL_MODES = ['None', 'Auto', 'STARTTLS', 'TLS']
|
||||
|
||||
before_destroy :update_routes
|
||||
|
||||
validates :name, :presence => true
|
||||
validates :hostname, :presence => true, :format => /\A[a-z0-9\.\-]*\z/
|
||||
validates :ssl_mode, :inclusion => {:in => SSL_MODES}
|
||||
validates :port, :numericality => {:only_integer => true, :allow_blank => true}
|
||||
|
||||
def description
|
||||
"#{name} (#{hostname})"
|
||||
end
|
||||
|
||||
def mark_as_used
|
||||
update_column(:last_used_at, Time.now)
|
||||
end
|
||||
|
||||
def update_routes
|
||||
self.routes.each { |r| r.update(:endpoint => nil, :mode => 'Reject') }
|
||||
end
|
||||
|
||||
end
|
||||
17
app/models/statistic.rb
Normal file
17
app/models/statistic.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: statistics
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# total_messages :integer default(0)
|
||||
# total_outgoing :integer default(0)
|
||||
# total_incoming :integer default(0)
|
||||
#
|
||||
|
||||
class Statistic < ApplicationRecord
|
||||
|
||||
def self.global
|
||||
Statistic.first || Statistic.create
|
||||
end
|
||||
|
||||
end
|
||||
95
app/models/track_certificate.rb
Normal file
95
app/models/track_certificate.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: track_certificates
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# domain :string(255)
|
||||
# certificate :text(65535)
|
||||
# intermediaries :text(65535)
|
||||
# key :text(65535)
|
||||
# expires_at :datetime
|
||||
# renew_after :datetime
|
||||
# verification_path :string(255)
|
||||
# verification_string :string(255)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_track_certificates_on_domain (domain)
|
||||
#
|
||||
|
||||
class TrackCertificate < ApplicationRecord
|
||||
|
||||
validates :domain, :presence => true, :uniqueness => true
|
||||
|
||||
default_value :key, -> { OpenSSL::PKey::RSA.new(2048).to_s }
|
||||
|
||||
scope :active, -> { where("certificate IS NOT NULL AND expires_at > ?", Time.now) }
|
||||
|
||||
def active?
|
||||
certificate.present?
|
||||
end
|
||||
|
||||
def get
|
||||
verify && issue
|
||||
end
|
||||
|
||||
def verify
|
||||
authorization = Postal::LetsEncrypt.client.authorize(:domain => self.domain)
|
||||
challenge = authorization.http01
|
||||
self.verification_path = challenge.filename
|
||||
self.verification_string = challenge.file_content
|
||||
self.save!
|
||||
challenge.request_verification
|
||||
checks = 0
|
||||
until challenge.verify_status != "pending"
|
||||
checks += 1
|
||||
return false if checks > 30
|
||||
sleep 1
|
||||
end
|
||||
|
||||
unless challenge.verify_status == "valid"
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
rescue Acme::Client::Error => e
|
||||
@retries = 0
|
||||
if e.is_a?(Acme::Client::Error::BadNonce) && @retries < 5
|
||||
@retries += 1
|
||||
sleep 1
|
||||
verify
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
def issue
|
||||
csr = OpenSSL::X509::Request.new
|
||||
csr.subject = OpenSSL::X509::Name.new([['CN', self.domain, OpenSSL::ASN1::UTF8STRING]])
|
||||
private_key = OpenSSL::PKey::RSA.new(self.key)
|
||||
csr.public_key = private_key.public_key
|
||||
csr.sign(private_key, OpenSSL::Digest::SHA256.new)
|
||||
https_cert = Postal::LetsEncrypt.client.new_certificate(csr)
|
||||
self.certificate = https_cert.to_pem
|
||||
self.intermediaries = https_cert.chain_to_pem
|
||||
self.expires_at = https_cert.x509.not_after
|
||||
self.renew_after = (self.expires_at - 1.month) + rand(10).days
|
||||
self.save!
|
||||
return true
|
||||
end
|
||||
|
||||
def certificate_object
|
||||
@certificate_object ||= OpenSSL::X509::Certificate.new(self.certificate)
|
||||
end
|
||||
|
||||
def intermediaries_array
|
||||
@intermediaries_array ||= self.intermediaries.to_s.scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m).map{|c| OpenSSL::X509::Certificate.new(c)}
|
||||
end
|
||||
|
||||
def key_object
|
||||
@key_object ||= OpenSSL::PKey::RSA.new(self.key)
|
||||
end
|
||||
|
||||
end
|
||||
105
app/models/track_domain.rb
Normal file
105
app/models/track_domain.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: track_domains
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# uuid :string(255)
|
||||
# server_id :integer
|
||||
# domain_id :integer
|
||||
# name :string(255)
|
||||
# dns_checked_at :datetime
|
||||
# dns_status :string(255)
|
||||
# dns_error :string(255)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# ssl_enabled :boolean default(TRUE)
|
||||
# track_clicks :boolean default(TRUE)
|
||||
# track_loads :boolean default(TRUE)
|
||||
# excluded_click_domains :text(65535)
|
||||
#
|
||||
|
||||
class TrackDomain < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
belongs_to :server
|
||||
belongs_to :domain
|
||||
|
||||
validates :name, :presence => true, :format => {:with => /\A[a-z0-9\-]+\z/}, :uniqueness => {:scope => :domain_id, :message => "is already added"}
|
||||
validates :domain_id, :uniqueness => {:scope => :server_id, :message => "already has a track domain for this server"}
|
||||
validate :validate_domain_belongs_to_server
|
||||
|
||||
scope :ok, -> { where(:dns_status => 'OK')}
|
||||
|
||||
after_create :check_dns
|
||||
after_create :create_ssl_certificate_if_missing
|
||||
after_destroy :delete_ssl_certificate_when_not_in_use
|
||||
|
||||
before_validation do
|
||||
self.server = self.domain.server if self.domain && self.server.nil?
|
||||
end
|
||||
|
||||
def full_name
|
||||
"#{name}.#{domain.name}"
|
||||
end
|
||||
|
||||
def excluded_click_domains_array
|
||||
@excluded_click_domains_array ||= excluded_click_domains ? excluded_click_domains.split("\n").map(&:strip) : []
|
||||
end
|
||||
|
||||
def dns_ok?
|
||||
self.dns_status == 'OK'
|
||||
end
|
||||
|
||||
def check_dns
|
||||
result = self.domain.resolver.getresources(self.full_name, Resolv::DNS::Resource::IN::CNAME)
|
||||
records = result.map { |r| r.name.to_s.downcase }
|
||||
if records.empty?
|
||||
self.dns_status = 'Missing'
|
||||
self.dns_error = "There is no record at #{self.full_name}"
|
||||
else
|
||||
if records.size == 1 && records.first == Postal.config.dns.track_domain
|
||||
self.dns_status = 'OK'
|
||||
self.dns_error = nil
|
||||
else
|
||||
self.dns_status = 'Invalid'
|
||||
self.dns_error = "There is a CNAME record at #{self.full_name} but it points to #{records.first} which is incorrect. It should point to #{Postal.config.dns.track_domain}."
|
||||
end
|
||||
end
|
||||
self.dns_checked_at = Time.now
|
||||
self.save!
|
||||
dns_ok?
|
||||
end
|
||||
|
||||
def has_ssl?
|
||||
ssl_certificate && ssl_certificate.active?
|
||||
end
|
||||
|
||||
def use_ssl?
|
||||
ssl_enabled? && has_ssl?
|
||||
end
|
||||
|
||||
def ssl_certificate
|
||||
@ssl_certificate ||= TrackCertificate.where(:domain => self.full_name).first
|
||||
end
|
||||
|
||||
def validate_domain_belongs_to_server
|
||||
if self.domain && ![self.server, self.server.organization].include?(self.domain.owner)
|
||||
errors.add :domain, "does not belong to the server or the server's organization"
|
||||
end
|
||||
end
|
||||
|
||||
def create_ssl_certificate_if_missing
|
||||
unless TrackCertificate.where(:domain => self.full_name).exists?
|
||||
TrackCertificate.create!(:domain => self.full_name)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_ssl_certificate_when_not_in_use
|
||||
others = TrackDomain.includes(:domain).where(:name => self.name, :domains => {:name => self.domain.name})
|
||||
if others.empty?
|
||||
TrackCertificate.where(:domain => self.full_name).destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
104
app/models/user.rb
Normal file
104
app/models/user.rb
Normal file
@@ -0,0 +1,104 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: users
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# uuid :string(255)
|
||||
# first_name :string(255)
|
||||
# last_name :string(255)
|
||||
# email_address :string(255)
|
||||
# password_digest :string(255)
|
||||
# time_zone :string(255)
|
||||
# email_verification_token :string(255)
|
||||
# email_verified_at :datetime
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
# password_reset_token :string(255)
|
||||
# password_reset_token_valid_until :datetime
|
||||
# admin :boolean default(FALSE)
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_users_on_email_address (email_address)
|
||||
# index_users_on_uuid (uuid)
|
||||
#
|
||||
|
||||
class User < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
require_dependency 'user/authentication'
|
||||
|
||||
validates :first_name, :presence => true
|
||||
validates :last_name, :presence => true
|
||||
validates :email_address, :presence => true, :uniqueness => true, :format => {:with => /@/}
|
||||
validates :time_zone, :presence => true
|
||||
|
||||
default_value :time_zone, -> { 'UTC' }
|
||||
|
||||
has_many :organization_users, :dependent => :destroy, :as => :user
|
||||
has_many :organizations, :through => :organization_users
|
||||
|
||||
scope :verified, -> { where.not(:email_verified_at => nil) }
|
||||
|
||||
when_attribute :email_address, :changes_to => :anything do
|
||||
before_save do |was, now|
|
||||
self.email_verification_token = rand(999999).to_s.rjust(6, '0')
|
||||
self.email_verified_at = nil
|
||||
end
|
||||
|
||||
after_commit do |was, new|
|
||||
if self.email_verified_at.nil? && was.present?
|
||||
AppMailer.verify_email_address(self).deliver
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def organizations_scope
|
||||
@organizations_scope ||= begin
|
||||
if self.admin?
|
||||
Organization.present
|
||||
else
|
||||
self.organizations.present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def name
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def to_param
|
||||
uuid
|
||||
end
|
||||
|
||||
def verify!
|
||||
self.email_verified_at = Time.now
|
||||
self.save!
|
||||
end
|
||||
|
||||
def verified?
|
||||
email_verified_at.present?
|
||||
end
|
||||
|
||||
def md5_for_gravatar
|
||||
@md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase)
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
@avatar_url ||= email_address ? "https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm" : nil
|
||||
end
|
||||
|
||||
def email_tag
|
||||
"#{name} <#{email_address}>"
|
||||
end
|
||||
|
||||
def generate_login_token
|
||||
JWT.encode({'user' => self.id, 'timestamp' => Time.now.to_f}, Postal.signing_key.to_s, 'HS256')
|
||||
end
|
||||
|
||||
def self.[](email)
|
||||
where(:email_address => email).first
|
||||
end
|
||||
|
||||
end
|
||||
52
app/models/user/authentication.rb
Normal file
52
app/models/user/authentication.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
class User
|
||||
|
||||
has_secure_password
|
||||
|
||||
validates :password, :length => {:minimum => 8, :allow_blank => true}
|
||||
|
||||
when_attribute :password_digest, :changes_to => :anything do
|
||||
before_save do
|
||||
self.password_reset_token = nil
|
||||
self.password_reset_token_valid_until = nil
|
||||
end
|
||||
end
|
||||
|
||||
def self.authenticate(email_address, password)
|
||||
user = where(:email_address => email_address).first
|
||||
raise Postal::Errors::AuthenticationError.new('InvalidEmailAddress') if user.nil?
|
||||
raise Postal::Errors::AuthenticationError.new('InvalidPassword') unless user.authenticate(password)
|
||||
user
|
||||
end
|
||||
|
||||
def authenticate_with_previous_password_first(unencrypted_password)
|
||||
if password_digest_changed?
|
||||
BCrypt::Password.new(password_digest_was).is_password?(unencrypted_password) && self
|
||||
else
|
||||
authenticate(unencrypted_password)
|
||||
end
|
||||
end
|
||||
|
||||
def begin_password_reset(return_to = nil)
|
||||
self.password_reset_token = Nifty::Utils::RandomString.generate(:length => 24)
|
||||
self.password_reset_token_valid_until = 1.day.from_now
|
||||
self.save!
|
||||
AppMailer.password_reset(self, return_to).deliver
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
class Postal::Errors::AuthenticationError < Postal::Error
|
||||
|
||||
attr_reader :error
|
||||
|
||||
def initialize(error)
|
||||
@error = error
|
||||
end
|
||||
|
||||
def to_s
|
||||
"Authentication Failed: #{@error}"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# -*- SkipSchemaAnnotations
|
||||
54
app/models/user_invite.rb
Normal file
54
app/models/user_invite.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: user_invites
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# uuid :string(255)
|
||||
# email_address :string(255)
|
||||
# expires_at :datetime
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_user_invites_on_uuid (uuid)
|
||||
#
|
||||
|
||||
class UserInvite < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
validates :email_address, :presence => true, :uniqueness => true, :format => {:with => /@/, :allow_blank => true}
|
||||
|
||||
has_many :organization_users, :dependent => :destroy, :as => :user
|
||||
has_many :organizations, :through => :organization_users
|
||||
|
||||
default_value :expires_at, -> { 7.days.from_now }
|
||||
|
||||
def md5_for_gravatar
|
||||
@md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase)
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
@avatar_url ||= email_address ? "https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm" : nil
|
||||
end
|
||||
|
||||
def name
|
||||
email_address
|
||||
end
|
||||
|
||||
def accept(user)
|
||||
transaction do
|
||||
self.organization_users.each do |ou|
|
||||
ou.update(:user => user) || ou.destroy
|
||||
end
|
||||
self.organization_users.reload
|
||||
self.destroy
|
||||
end
|
||||
end
|
||||
|
||||
def reject
|
||||
self.destroy
|
||||
end
|
||||
|
||||
end
|
||||
60
app/models/webhook.rb
Normal file
60
app/models/webhook.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: webhooks
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# server_id :integer
|
||||
# uuid :string(255)
|
||||
# name :string(255)
|
||||
# url :string(255)
|
||||
# last_used_at :datetime
|
||||
# all_events :boolean default(FALSE)
|
||||
# enabled :boolean default(TRUE)
|
||||
# sign :boolean default(TRUE)
|
||||
# created_at :datetime
|
||||
# updated_at :datetime
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_webhooks_on_server_id (server_id)
|
||||
#
|
||||
|
||||
class Webhook < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
belongs_to :server
|
||||
has_many :webhook_events, :dependent => :destroy
|
||||
has_many :webhook_requests
|
||||
|
||||
validates :name, :presence => true
|
||||
validates :url, :presence => true, :format => {:with => /\Ahttps?\:\/\/[a-z0-9\-\.\_\?\&\/\+]+\z/i, :allow_blank => true}
|
||||
|
||||
scope :enabled, -> { where(:enabled => true) }
|
||||
|
||||
after_save :save_events
|
||||
|
||||
when_attribute :all_events, :changes_to => true do
|
||||
after_save do
|
||||
self.webhook_events.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
def events
|
||||
@events ||= webhook_events.map(&:event)
|
||||
end
|
||||
|
||||
def events=(value)
|
||||
@events = value.map(&:to_s).select(&:present?)
|
||||
end
|
||||
|
||||
def save_events
|
||||
if @events
|
||||
@events.each do |event|
|
||||
webhook_events.where(:event => event).first_or_create!
|
||||
end
|
||||
webhook_events.where.not(:event => @events).destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
34
app/models/webhook_event.rb
Normal file
34
app/models/webhook_event.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: webhook_events
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# webhook_id :integer
|
||||
# event :string(255)
|
||||
# created_at :datetime
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_webhook_events_on_webhook_id (webhook_id)
|
||||
#
|
||||
|
||||
class WebhookEvent < ApplicationRecord
|
||||
|
||||
EVENTS = [
|
||||
'MessageSent',
|
||||
'MessageDelayed',
|
||||
'MessageDeliveryFailed',
|
||||
'MessageHeld',
|
||||
'MessageBounced',
|
||||
'MessageLinkClicked',
|
||||
'MessageLoaded',
|
||||
'DomainDNSError',
|
||||
'SendLimitApproaching',
|
||||
'SendLimitExceeded'
|
||||
]
|
||||
|
||||
belongs_to :webhook
|
||||
|
||||
validates :event, :presence => true
|
||||
|
||||
end
|
||||
92
app/models/webhook_request.rb
Normal file
92
app/models/webhook_request.rb
Normal file
@@ -0,0 +1,92 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: webhook_requests
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# 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
|
||||
#
|
||||
|
||||
class WebhookRequest < ApplicationRecord
|
||||
|
||||
include HasUUID
|
||||
|
||||
RETRIES = {1 => 2.minutes, 2 => 3.minutes, 3 => 6.minutes, 4 => 10.minutes, 5 => 15.minutes}
|
||||
|
||||
belongs_to :server
|
||||
belongs_to :webhook, :optional => true
|
||||
|
||||
validates :url, :presence => true
|
||||
validates :event, :presence => true
|
||||
|
||||
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).each(&:queue)
|
||||
end
|
||||
|
||||
def queue
|
||||
WebhookDeliveryJob.queue(:main, :id => self.id)
|
||||
end
|
||||
|
||||
def deliver
|
||||
logger = Postal.logger_for(:webhooks)
|
||||
payload = {:event => self.event, :timestamp => self.created_at.to_f, :payload => self.payload, :uuid => self.uuid}.to_json
|
||||
logger.info "[#{id}] Sending webhook request to `#{self.url}`"
|
||||
result = Postal::HTTP.post(self.url, :sign => true, :json => payload, :timeout => 5)
|
||||
self.attempts += 1
|
||||
self.retry_after = RETRIES[self.attempts]&.from_now
|
||||
self.server.message_db.webhooks.record(
|
||||
:event => self.event,
|
||||
:url => self.url,
|
||||
:webhook_id => self.webhook_id,
|
||||
:attempt => self.attempts,
|
||||
:timestamp => Time.now.to_f,
|
||||
:payload => self.payload.to_json,
|
||||
:uuid => self.uuid,
|
||||
:status_code => result[:code],
|
||||
:body => result[:body],
|
||||
:will_retry => (self.retry_after ? 0 : 1)
|
||||
)
|
||||
|
||||
if result[:code] >= 200 && result[:code] < 300
|
||||
logger.info "[#{id}] -> Received #{result[:code]} status code. That's OK."
|
||||
self.destroy
|
||||
self.webhook&.update_column(:last_used_at, Time.now)
|
||||
true
|
||||
else
|
||||
logger.error "[#{id}] -> Received #{result[:code]} status code. That's not OK."
|
||||
self.error = "Couldn't send to URL. Code received was #{result[:code]}"
|
||||
if self.retry_after
|
||||
logger.info "[#{id}] -> Will retry #{self.retry_after} (this was attempt #{self.attempts})"
|
||||
self.save
|
||||
else
|
||||
logger.info "[#{id}] -> Have tried #{self.attempts} times. Giving up."
|
||||
self.destroy
|
||||
end
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
المرجع في مشكلة جديدة
حظر مستخدم