mirror of
https://github.com/postalserver/postal.git
synced 2026-10-20 11:17:01 +00:00
@@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# This initializer will wait for all pending migrations to be applied before
|
||||
# continuing to start the application. This is useful when running the application
|
||||
# in a cluster where migrations are run in a separate job which runs at the same
|
||||
# time as the other processes.
|
||||
|
||||
class MigrationWaiter
|
||||
|
||||
ATTEMPTS = Postal::Config.migration_waiter.attempts
|
||||
SLEEP_TIME = Postal::Config.migration_waiter.sleep_time
|
||||
|
||||
class << self
|
||||
|
||||
def wait
|
||||
attempts_remaining = ATTEMPTS
|
||||
loop do
|
||||
pending_migrations = ActiveRecord::Base.connection.migration_context.open.pending_migrations.size
|
||||
if pending_migrations.zero?
|
||||
Postal.logger.info "no pending migrations, continuing"
|
||||
return
|
||||
end
|
||||
|
||||
attempts_remaining -= 1
|
||||
|
||||
if attempts_remaining.zero?
|
||||
Postal.logger.info "#{pending_migrations} migration(s) are still pending after #{ATTEMPTS} attempts, exiting"
|
||||
Process.exit(1)
|
||||
else
|
||||
Postal.logger.info "waiting for #{pending_migrations} migration(s) to be applied (#{attempts_remaining} remaining)"
|
||||
sleep SLEEP_TIME
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def wait_if_appropriate
|
||||
# Don't wait if not configured
|
||||
return unless Postal::Config.migration_waiter.enabled?
|
||||
|
||||
# Don't wait in the console, rake tasks or rails commands
|
||||
return if console? || rake_task? || rails_command?
|
||||
|
||||
wait
|
||||
end
|
||||
|
||||
def console?
|
||||
Rails.const_defined?("Console")
|
||||
end
|
||||
|
||||
def rake_task?
|
||||
Rake.application.top_level_tasks.any?
|
||||
end
|
||||
|
||||
def rails_command?
|
||||
caller.any? { |c| c =~ /rails\/commands/ }
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,64 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "logger"
|
||||
|
||||
module Postal
|
||||
|
||||
class AppLogger < Logger
|
||||
|
||||
def initialize(log_name, *args)
|
||||
@log_name = log_name
|
||||
super(*args)
|
||||
self.formatter = LogFormatter.new
|
||||
end
|
||||
|
||||
def add(severity, message = nil, progname = nil)
|
||||
super
|
||||
if severity >= @level && n = self.class.graylog_notifier
|
||||
begin
|
||||
if message.nil?
|
||||
message = block_given? ? yield : progname
|
||||
end
|
||||
message = message.to_s.force_encoding("UTF-8").scrub
|
||||
message_without_ansi = begin
|
||||
message.gsub(/\e\[([\d;]+)?m/, "")
|
||||
rescue StandardError
|
||||
message
|
||||
end
|
||||
n.notify!(short_message: message_without_ansi, log_name: @log_name, facility: "postal", application_name: "postal", process_name: ENV.fetch("PROC_NAME", nil), pid: Process.pid)
|
||||
rescue StandardError
|
||||
# Can't log this to GELF. Soz.
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def self.graylog?
|
||||
!!Postal.config.logging.graylog&.host
|
||||
end
|
||||
|
||||
def self.graylog_notifier
|
||||
@graylog_notifier ||= graylog? ? GELF::Notifier.new(Postal.config.logging.graylog.host, Postal.config.logging.graylog.port) : nil
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
class LogFormatter
|
||||
|
||||
TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%3N"
|
||||
COLORS = [32, 34, 35, 31, 32, 33].freeze
|
||||
|
||||
def call(severity, datetime, progname, msg)
|
||||
time = datetime.strftime(TIME_FORMAT)
|
||||
if number = ENV["PROC_NAME"]
|
||||
id = number.split(".").last.to_i
|
||||
proc_text = "\e[#{COLORS[id % COLORS.size]}m[#{ENV['PROC_NAME']}:#{Process.pid}]\e[0m"
|
||||
else
|
||||
proc_text = "[#{Process.pid}]"
|
||||
end
|
||||
"#{proc_text} [#{time}] #{severity} -- : #{msg}\n"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,57 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class BounceMessage
|
||||
|
||||
def initialize(server, message)
|
||||
@server = server
|
||||
@message = message
|
||||
end
|
||||
|
||||
def raw_message
|
||||
mail = Mail.new
|
||||
mail.to = @message.mail_from
|
||||
mail.from = "Mail Delivery Service <#{@message.route.description}>"
|
||||
mail.subject = "Mail Delivery Failed (#{@message.subject})"
|
||||
mail.text_part = body
|
||||
mail.attachments["Original Message.eml"] = { mime_type: "message/rfc822", encoding: "quoted-printable", content: @message.raw_message }
|
||||
mail.message_id = "<#{SecureRandom.uuid}@#{Postal.config.dns.return_path}>"
|
||||
mail.to_s
|
||||
end
|
||||
|
||||
def queue
|
||||
message = @server.message_db.new_message
|
||||
message.scope = "outgoing"
|
||||
message.rcpt_to = @message.mail_from
|
||||
message.mail_from = @message.route.description
|
||||
message.domain_id = @message.domain&.id
|
||||
message.raw_message = raw_message
|
||||
message.bounce = true
|
||||
message.bounce_for_id = @message.id
|
||||
message.save
|
||||
message.id
|
||||
end
|
||||
|
||||
def postmaster_address
|
||||
@server.postmaster_address || "postmaster@#{@message.domain&.name || Postal.config.web.host}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def body
|
||||
<<~BODY
|
||||
This is the mail delivery service responsible for delivering mail to #{@message.route.description}.
|
||||
|
||||
The message you've sent cannot be delivered. Your original message is attached to this message.
|
||||
|
||||
For further assistance please contact #{postmaster_address}. Please include the details below to help us identify the issue.
|
||||
|
||||
Message Token: #{@message.token}@#{@server.token}
|
||||
Orginal Message ID: #{@message.message_id}
|
||||
Mail from: #{@message.mail_from}
|
||||
Rcpt To: #{@message.rcpt_to}
|
||||
BODY
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
+102
-159
@@ -6,185 +6,128 @@ require "pathname"
|
||||
require "cgi"
|
||||
require "openssl"
|
||||
require "fileutils"
|
||||
require "konfig"
|
||||
require "konfig/sources/environment"
|
||||
require "konfig/sources/yaml"
|
||||
require "dotenv"
|
||||
require "klogger"
|
||||
|
||||
require_relative "error"
|
||||
require_relative "version"
|
||||
require_relative "config_schema"
|
||||
require_relative "legacy_config_source"
|
||||
|
||||
module Postal
|
||||
|
||||
# rubocop:disable Lint/EmptyClass
|
||||
class Config
|
||||
end
|
||||
# rubocop:enable Lint/EmptyClass
|
||||
class << self
|
||||
|
||||
def self.host
|
||||
@host ||= config.web.host || "localhost:5000"
|
||||
end
|
||||
|
||||
def self.protocol
|
||||
@protocol ||= config.web.protocol || "http"
|
||||
end
|
||||
|
||||
def self.host_with_protocol
|
||||
@host_with_protocol ||= "#{protocol}://#{host}"
|
||||
end
|
||||
|
||||
def self.app_root
|
||||
@app_root ||= Pathname.new(File.expand_path("../..", __dir__))
|
||||
end
|
||||
|
||||
def self.config
|
||||
@config ||= begin
|
||||
require "hashie/mash"
|
||||
config = Hashie::Mash.new(defaults)
|
||||
config = config.deep_merge(yaml_config)
|
||||
config.deep_merge(local_yaml_config)
|
||||
# Return the path to the config file
|
||||
#
|
||||
# @return [String]
|
||||
def config_file_path
|
||||
ENV.fetch("POSTAL_CONFIG_FILE_PATH", "config/postal/postal.yml")
|
||||
end
|
||||
end
|
||||
|
||||
def self.config_root
|
||||
if ENV["POSTAL_CONFIG_ROOT"]
|
||||
@config_root ||= Pathname.new(ENV["POSTAL_CONFIG_ROOT"])
|
||||
else
|
||||
@config_root ||= Pathname.new(File.expand_path("../../config/postal", __dir__))
|
||||
end
|
||||
end
|
||||
def initialize_config
|
||||
sources = []
|
||||
|
||||
def self.log_root
|
||||
if config.logging.root
|
||||
@log_root ||= Pathname.new(config.logging.root)
|
||||
else
|
||||
@log_root ||= app_root.join("log")
|
||||
end
|
||||
end
|
||||
# Load environment variables to begin with. Any config provided
|
||||
# by an environment variable will override any provided in the
|
||||
# config file.
|
||||
Dotenv.load(".env")
|
||||
sources << Konfig::Sources::Environment.new(ENV)
|
||||
|
||||
def self.config_file_path
|
||||
if env == "default"
|
||||
@config_file_path ||= File.join(config_root, "postal.yml")
|
||||
else
|
||||
@config_file_path ||= File.join(config_root, "postal.#{env}.yml")
|
||||
end
|
||||
end
|
||||
# If a config file exists, we need to load that. Config files can
|
||||
# either be legacy (v1) or new (v2). Any file without a 'version'
|
||||
# key is a legacy file whereas new-style config files will include
|
||||
# the 'version: 2' key/value.
|
||||
if File.file?(config_file_path)
|
||||
puts "Loading config from #{config_file_path}"
|
||||
|
||||
def self.env
|
||||
@env ||= ENV.fetch("POSTAL_ENV", "default")
|
||||
end
|
||||
|
||||
def self.yaml_config
|
||||
@yaml_config ||= File.exist?(config_file_path) ? YAML.load_file(config_file_path) : {}
|
||||
end
|
||||
|
||||
def self.local_config_file_path
|
||||
@local_config_file_path ||= File.join(config_root, "postal.local.yml")
|
||||
end
|
||||
|
||||
def self.local_yaml_config
|
||||
@local_yaml_config ||= File.exist?(local_config_file_path) ? YAML.load_file(local_config_file_path) : {}
|
||||
end
|
||||
|
||||
def self.defaults_file_path
|
||||
@defaults_file_path ||= app_root.join("config", "postal.defaults.yml")
|
||||
end
|
||||
|
||||
def self.defaults
|
||||
@defaults ||= begin
|
||||
file = File.read(defaults_file_path)
|
||||
yaml = ERB.new(file).result
|
||||
YAML.safe_load(yaml)
|
||||
end
|
||||
end
|
||||
|
||||
def self.logger_for(name)
|
||||
@loggers ||= {}
|
||||
@loggers[name.to_sym] ||= begin
|
||||
require "postal/app_logger"
|
||||
if config.logging.stdout || ENV["LOG_TO_STDOUT"]
|
||||
Postal::AppLogger.new(name, $stdout)
|
||||
config_file = File.read(config_file_path)
|
||||
yaml = YAML.safe_load(config_file)
|
||||
config_version = yaml["version"] || 1
|
||||
case config_version
|
||||
when 1
|
||||
puts "WARNING: Using legacy config file format. Upgrade your postal.yml to use"
|
||||
puts "version 2 of the Postal configuration or configure using environment"
|
||||
puts "variables. See https://postalserver.io/config-v2 for details."
|
||||
sources << LegacyConfigSource.new(yaml)
|
||||
when 2
|
||||
sources << Konfig::Sources::YAML.new(config_file)
|
||||
else
|
||||
raise "Invalid version specified in Postal config file. Must be 1 or 2."
|
||||
end
|
||||
else
|
||||
FileUtils.mkdir_p(log_root)
|
||||
Postal::AppLogger.new(name, log_root.join("#{name}.log"), config.logging.max_log_files, config.logging.max_log_file_size.megabytes)
|
||||
puts "No configuration file found at #{config_file_path}"
|
||||
puts "Only using environment variables for configuration"
|
||||
end
|
||||
|
||||
# Build configuration with the provided sources.
|
||||
Konfig::Config.build(ConfigSchema, sources: sources)
|
||||
end
|
||||
|
||||
def host_with_protocol
|
||||
@host_with_protocol ||= "#{Config.postal.web_protocol}://#{Config.postal.web_hostname}"
|
||||
end
|
||||
|
||||
def logger
|
||||
@logger ||= begin
|
||||
k = Klogger.new(nil, destination: Config.logging.enabled? ? $stdout : "/dev/null", highlight: Config.logging.highlighting_enabled?)
|
||||
k.add_destination(graylog_logging_destination) if Config.gelf.host.present?
|
||||
k
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.process_name
|
||||
@process_name ||= begin
|
||||
string = "host:#{Socket.gethostname} pid:#{Process.pid}"
|
||||
string += " procname:#{ENV['PROC_NAME']}" if ENV["PROC_NAME"]
|
||||
def process_name
|
||||
@process_name ||= begin
|
||||
"host:#{Socket.gethostname} pid:#{Process.pid}"
|
||||
rescue StandardError
|
||||
"pid:#{Process.pid}"
|
||||
end
|
||||
end
|
||||
|
||||
def locker_name
|
||||
string = process_name.dup
|
||||
string += " job:#{Thread.current[:job_id]}" if Thread.current[:job_id]
|
||||
string += " thread:#{Thread.current.native_thread_id}"
|
||||
string
|
||||
rescue StandardError
|
||||
"pid:#{Process.pid}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.locker_name
|
||||
string = process_name.dup
|
||||
string += " job:#{Thread.current[:job_id]}" if Thread.current[:job_id]
|
||||
string
|
||||
end
|
||||
|
||||
def self.smtp_from_name
|
||||
config.smtp&.from_name || "Postal"
|
||||
end
|
||||
|
||||
def self.smtp_from_address
|
||||
config.smtp&.from_address || "postal@example.com"
|
||||
end
|
||||
|
||||
def self.smtp_private_key_path
|
||||
config.smtp_server.tls_private_key_path || config_root.join("smtp.key")
|
||||
end
|
||||
|
||||
def self.smtp_private_key
|
||||
@smtp_private_key ||= OpenSSL::PKey.read(File.read(smtp_private_key_path))
|
||||
end
|
||||
|
||||
def self.smtp_certificate_path
|
||||
config.smtp_server.tls_certificate_path || config_root.join("smtp.cert")
|
||||
end
|
||||
|
||||
def self.smtp_certificate_data
|
||||
@smtp_certificate_data ||= File.read(smtp_certificate_path)
|
||||
end
|
||||
|
||||
def self.smtp_certificates
|
||||
@smtp_certificates ||= begin
|
||||
certs = smtp_certificate_data.scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m)
|
||||
certs.map do |c|
|
||||
OpenSSL::X509::Certificate.new(c)
|
||||
end.freeze
|
||||
end
|
||||
end
|
||||
|
||||
def self.signing_key_path
|
||||
ENV.fetch("POSTAL_SIGNING_KEY_PATH") { config_root.join("signing.key") }
|
||||
end
|
||||
|
||||
def self.signing_key
|
||||
@signing_key ||= OpenSSL::PKey::RSA.new(File.read(signing_key_path))
|
||||
end
|
||||
|
||||
def self.rp_dkim_dns_record
|
||||
public_key = signing_key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "")
|
||||
"v=DKIM1; t=s; h=sha256; p=#{public_key};"
|
||||
end
|
||||
|
||||
class ConfigError < Postal::Error
|
||||
end
|
||||
|
||||
def self.check_config!
|
||||
return if ENV["POSTAL_SKIP_CONFIG_CHECK"].to_i == 1
|
||||
|
||||
unless File.exist?(config_file_path)
|
||||
raise ConfigError, "No config found at #{config_file_path}"
|
||||
end
|
||||
|
||||
return if File.exist?(signing_key_path)
|
||||
def locker_name_with_suffix(suffix)
|
||||
"#{locker_name} #{suffix}"
|
||||
end
|
||||
|
||||
def signing_key
|
||||
@signing_key ||= OpenSSL::PKey::RSA.new(File.read(Config.postal.signing_key_path))
|
||||
end
|
||||
|
||||
def rp_dkim_dns_record
|
||||
public_key = signing_key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "")
|
||||
"v=DKIM1; t=s; h=sha256; p=#{public_key};"
|
||||
end
|
||||
|
||||
def ip_pools?
|
||||
Config.postal.use_ip_pools?
|
||||
end
|
||||
|
||||
def graylog_logging_destination
|
||||
@graylog_logging_destination ||= begin
|
||||
notifier = GELF::Notifier.new(Config.gelf.host, Config.gelf.port, "WAN")
|
||||
proc do |_logger, payload, group_ids|
|
||||
short_message = payload.delete(:message) || "[message missing]"
|
||||
notifier.notify!(short_message: short_message, **{
|
||||
facility: Config.gelf.facility,
|
||||
_environment: Config.rails.environment,
|
||||
_version: Postal::VERSION.to_s,
|
||||
_group_ids: group_ids.join(" ")
|
||||
}.merge(payload.transform_keys { |k| "_#{k}".to_sym }.transform_values(&:to_s)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
raise ConfigError, "No signing key found at #{signing_key_path}"
|
||||
end
|
||||
|
||||
def self.ip_pools?
|
||||
config.general.use_ip_pools?
|
||||
end
|
||||
Config = initialize_config
|
||||
|
||||
end
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "uri"
|
||||
|
||||
module Postal
|
||||
|
||||
# REMEMBER: If you change the schema, remember to regenerate the configuration docs
|
||||
# using the rake command below:
|
||||
#
|
||||
# rake postal:generate_config_docs
|
||||
|
||||
ConfigSchema = Konfig::Schema.draw do
|
||||
group :postal do
|
||||
string :web_hostname do
|
||||
description "The hostname that the Postal web interface runs on"
|
||||
default "postal.example.com"
|
||||
end
|
||||
|
||||
string :web_protocol do
|
||||
description "The HTTP protocol to use for the Postal web interface"
|
||||
default "https"
|
||||
end
|
||||
|
||||
string :smtp_hostname do
|
||||
description "The hostname that the Postal SMTP server runs on"
|
||||
default "postal.example.com"
|
||||
end
|
||||
|
||||
boolean :use_ip_pools do
|
||||
description "Should IP pools be enabled for this installation?"
|
||||
default false
|
||||
end
|
||||
|
||||
integer :default_maximum_delivery_attempts do
|
||||
description "The maximum number of delivery attempts"
|
||||
default 18
|
||||
end
|
||||
|
||||
integer :default_maximum_hold_expiry_days do
|
||||
description "The number of days to hold a message before they will be expired"
|
||||
default 7
|
||||
end
|
||||
|
||||
integer :default_suppression_list_automatic_removal_days do
|
||||
description "The number of days an address will remain in a suppression list before being removed"
|
||||
default 30
|
||||
end
|
||||
|
||||
integer :default_spam_threshold do
|
||||
description "The default threshold at which a message should be treated as spam"
|
||||
default 5
|
||||
end
|
||||
|
||||
integer :default_spam_failure_threshold do
|
||||
description "The default threshold at which a message should be treated as spam failure"
|
||||
default 20
|
||||
end
|
||||
|
||||
boolean :use_local_ns_for_domain_verification do
|
||||
description "Domain verification and checking usually checks with a domain's nameserver. Enable this to check with the server's local nameservers."
|
||||
default false
|
||||
end
|
||||
|
||||
boolean :use_resent_sender_header do
|
||||
description "Append a Resend-Sender header to all outgoing e-mails"
|
||||
default true
|
||||
end
|
||||
|
||||
string :signing_key_path do
|
||||
description "Path to the private key used for signing"
|
||||
default "config/postal/signing.key"
|
||||
end
|
||||
|
||||
string :smtp_relays do
|
||||
array
|
||||
description "An array of SMTP relays in the format of smtp://host:port"
|
||||
transform do |value|
|
||||
uri = URI.parse(value)
|
||||
query = uri.query ? CGI.parse(uri.query) : {}
|
||||
{
|
||||
host: uri.host,
|
||||
port: uri.port || 25,
|
||||
ssl_mode: query["ssl_mode"]&.first || "Auto"
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
group :web_server do
|
||||
integer :default_port do
|
||||
description "The default port the web server should listen on unless overriden by the PORT environment variable"
|
||||
default 5000
|
||||
end
|
||||
|
||||
string :default_bind_address do
|
||||
description "The default bind address the web server should listen on unless overriden by the BIND_ADDRESS environment variable"
|
||||
default "127.0.0.1"
|
||||
end
|
||||
|
||||
integer :max_threads do
|
||||
description "The maximum number of threads which can be used by the web server"
|
||||
default 5
|
||||
end
|
||||
end
|
||||
|
||||
group :worker do
|
||||
integer :default_health_server_port do
|
||||
description "The default port for the worker health server to listen on"
|
||||
default 9090
|
||||
end
|
||||
|
||||
string :default_health_server_bind_address do
|
||||
description "The default bind address for the worker health server to listen on"
|
||||
default "127.0.0.1"
|
||||
end
|
||||
end
|
||||
|
||||
group :main_db do
|
||||
string :host do
|
||||
description "Hostname for the main MariaDB server"
|
||||
default "localhost"
|
||||
end
|
||||
|
||||
integer :port do
|
||||
description "The MariaDB port to connect to"
|
||||
default 3306
|
||||
end
|
||||
|
||||
string :username do
|
||||
description "The MariaDB username"
|
||||
default "postal"
|
||||
end
|
||||
|
||||
string :password do
|
||||
description "The MariaDB password"
|
||||
end
|
||||
|
||||
string :database do
|
||||
description "The MariaDB database name"
|
||||
default "postal"
|
||||
end
|
||||
|
||||
integer :pool_size do
|
||||
description "The maximum size of the MariaDB connection pool"
|
||||
default 5
|
||||
end
|
||||
|
||||
string :encoding do
|
||||
description "The encoding to use when connecting to the MariaDB database"
|
||||
default "utf8mb4"
|
||||
end
|
||||
end
|
||||
|
||||
group :message_db do
|
||||
string :host do
|
||||
description "Hostname for the MariaDB server which stores the mail server databases"
|
||||
default "localhost"
|
||||
end
|
||||
|
||||
integer :port do
|
||||
description "The MariaDB port to connect to"
|
||||
default 3306
|
||||
end
|
||||
|
||||
string :username do
|
||||
description "The MariaDB username"
|
||||
default "postal"
|
||||
end
|
||||
|
||||
string :password do
|
||||
description "The MariaDB password"
|
||||
end
|
||||
|
||||
string :encoding do
|
||||
description "The encoding to use when connecting to the MariaDB database"
|
||||
default "utf8mb4"
|
||||
end
|
||||
|
||||
string :database_name_prefix do
|
||||
description "The MariaDB prefix to add to database names"
|
||||
default "postal"
|
||||
end
|
||||
end
|
||||
|
||||
group :logging do
|
||||
boolean :rails_log_enabled do
|
||||
description "Enable the default Rails logger"
|
||||
default false
|
||||
end
|
||||
|
||||
string :sentry_dsn do
|
||||
description "A DSN which should be used to report exceptions to Sentry"
|
||||
end
|
||||
|
||||
boolean :enabled do
|
||||
description "Enable the Postal logger to log to STDOUT"
|
||||
default true
|
||||
end
|
||||
|
||||
boolean :highlighting_enabled do
|
||||
description "Enable highlighting of log lines"
|
||||
default false
|
||||
end
|
||||
end
|
||||
|
||||
group :gelf do
|
||||
string :host do
|
||||
description "GELF-capable host to send logs to"
|
||||
end
|
||||
|
||||
integer :port do
|
||||
description "GELF port to send logs to"
|
||||
default 12_201
|
||||
end
|
||||
|
||||
string :facility do
|
||||
description "The facility name to add to all log entries sent to GELF"
|
||||
default "postal"
|
||||
end
|
||||
end
|
||||
|
||||
group :smtp_server do
|
||||
integer :default_port do
|
||||
description "The default port the SMTP server should listen on unless overriden by the PORT environment variable"
|
||||
default 25
|
||||
end
|
||||
|
||||
string :default_bind_address do
|
||||
description "The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable"
|
||||
default "127.0.0.1"
|
||||
end
|
||||
|
||||
integer :default_health_server_port do
|
||||
description "The default port for the SMTP server health server to listen on"
|
||||
default 9091
|
||||
end
|
||||
|
||||
string :default_health_server_bind_address do
|
||||
description "The default bind address for the SMTP server health server to listen on"
|
||||
default "127.0.0.1"
|
||||
end
|
||||
|
||||
boolean :tls_enabled do
|
||||
description "Enable TLS for the SMTP server (requires certificate)"
|
||||
default false
|
||||
end
|
||||
|
||||
string :tls_certificate_path do
|
||||
description "The path to the SMTP server's TLS certificate"
|
||||
default "config/postal/smtp.cert"
|
||||
end
|
||||
|
||||
string :tls_private_key_path do
|
||||
description "The path to the SMTP server's TLS private key"
|
||||
default "config/postal/smtp.key"
|
||||
end
|
||||
|
||||
string :tls_ciphers do
|
||||
description "Override ciphers to use for SSL"
|
||||
end
|
||||
|
||||
string :ssl_version do
|
||||
description "The SSL versions which are supported"
|
||||
default "SSLv23"
|
||||
end
|
||||
|
||||
boolean :proxy_protocol do
|
||||
description "Enable proxy protocol for use behind some load balancers"
|
||||
default false
|
||||
end
|
||||
|
||||
boolean :log_connections do
|
||||
description "Enable connection logging"
|
||||
default false
|
||||
end
|
||||
|
||||
integer :max_message_size do
|
||||
description "The maximum message size to accept from the SMTP server (in MB)"
|
||||
default 14
|
||||
end
|
||||
|
||||
string :log_ip_address_exclusion_matcher do
|
||||
description "A regular expression to use to exclude connections from logging"
|
||||
end
|
||||
end
|
||||
|
||||
group :dns do
|
||||
string :mx_records do
|
||||
description "The names of the default MX records"
|
||||
array
|
||||
default ["mx1.postal.example.com", "mx2.postal.example.com"]
|
||||
end
|
||||
|
||||
string :spf_include do
|
||||
description "The location of the SPF record"
|
||||
default "spf.postal.example.com"
|
||||
end
|
||||
|
||||
string :return_path_domain do
|
||||
description "The return path hostname"
|
||||
default "rp.postal.example.com"
|
||||
end
|
||||
|
||||
string :route_domain do
|
||||
description "The domain to use for hosting route-specific addresses"
|
||||
default "routes.postal.example.com"
|
||||
end
|
||||
|
||||
string :track_domain do
|
||||
description "The CNAME which tracking domains should be pointed to"
|
||||
default "track.postal.example.com"
|
||||
end
|
||||
|
||||
string :helo_hostname do
|
||||
description "The hostname to use in HELO/EHLO when connecting to external SMTP servers"
|
||||
end
|
||||
|
||||
string :dkim_identifier do
|
||||
description "The identifier to use for DKIM keys in DNS records"
|
||||
default "postal"
|
||||
end
|
||||
|
||||
string :domain_verify_prefix do
|
||||
description "The prefix to add before TXT record verification string"
|
||||
default "postal-verification"
|
||||
end
|
||||
|
||||
string :custom_return_path_prefix do
|
||||
description "The domain to use on external domains which points to the Postal return path domain"
|
||||
default "psrp"
|
||||
end
|
||||
|
||||
integer :timeout do
|
||||
description "The timeout to wait for DNS resolution"
|
||||
default 5
|
||||
end
|
||||
|
||||
string :resolv_conf_path do
|
||||
description "The path to the resolv.conf file containing addresses for local nameservers"
|
||||
default "/etc/resolv.conf"
|
||||
end
|
||||
end
|
||||
|
||||
group :smtp do
|
||||
string :host do
|
||||
description "The hostname to send application-level e-mails to"
|
||||
default "127.0.0.1"
|
||||
end
|
||||
|
||||
integer :port do
|
||||
description "The port number to send application-level e-mails to"
|
||||
default 25
|
||||
end
|
||||
|
||||
string :username do
|
||||
description "The username to use when authentication to the SMTP server"
|
||||
end
|
||||
|
||||
string :password do
|
||||
description "The password to use when authentication to the SMTP server"
|
||||
end
|
||||
|
||||
string :from_name do
|
||||
description "The name to use as the from name outgoing emails from Postal"
|
||||
default "Postal"
|
||||
end
|
||||
|
||||
string :from_address do
|
||||
description "The e-mail to use as the from address outgoing emails from Postal"
|
||||
default "postal@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
group :rails do
|
||||
string :environment do
|
||||
description "The Rails environment to run the application in"
|
||||
default "production"
|
||||
end
|
||||
|
||||
string :secret_key do
|
||||
description "The secret key used to sign and encrypt cookies and session data in the application"
|
||||
end
|
||||
end
|
||||
|
||||
group :rspamd do
|
||||
boolean :enabled do
|
||||
description "Enable rspamd for message inspection"
|
||||
default false
|
||||
end
|
||||
|
||||
string :host do
|
||||
description "The hostname of the rspamd server"
|
||||
default "127.0.0.1"
|
||||
end
|
||||
|
||||
integer :port do
|
||||
description "The port of the rspamd server"
|
||||
default 11_334
|
||||
end
|
||||
|
||||
boolean :ssl do
|
||||
description "Enable SSL for the rspamd connection"
|
||||
default false
|
||||
end
|
||||
|
||||
string :password do
|
||||
description "The password for the rspamd server"
|
||||
end
|
||||
|
||||
string :flags do
|
||||
description "Any flags for the rspamd server"
|
||||
end
|
||||
end
|
||||
|
||||
group :spamd do
|
||||
boolean :enabled do
|
||||
description "Enable SpamAssassin for message inspection"
|
||||
default false
|
||||
end
|
||||
|
||||
string :host do
|
||||
description "The hostname for the SpamAssassin server"
|
||||
default "127.0.0.1"
|
||||
end
|
||||
|
||||
integer :port do
|
||||
description "The port of the SpamAssassin server"
|
||||
default 783
|
||||
end
|
||||
end
|
||||
|
||||
group :clamav do
|
||||
boolean :enabled do
|
||||
description "Enable ClamAV for message inspection"
|
||||
default false
|
||||
end
|
||||
|
||||
string :host do
|
||||
description "The host of the ClamAV server"
|
||||
default "127.0.0.1"
|
||||
end
|
||||
|
||||
integer :port do
|
||||
description "The port of the ClamAV server"
|
||||
default 2000
|
||||
end
|
||||
end
|
||||
|
||||
group :smtp_client do
|
||||
integer :open_timeout do
|
||||
description "The open timeout for outgoing SMTP connections"
|
||||
default 30
|
||||
end
|
||||
|
||||
integer :read_timeout do
|
||||
description "The read timeout for outgoing SMTP connections"
|
||||
default 30
|
||||
end
|
||||
end
|
||||
|
||||
group :migration_waiter do
|
||||
boolean :enabled do
|
||||
description "Wait for all migrations to run before starting a process"
|
||||
default false
|
||||
end
|
||||
|
||||
integer :attempts do
|
||||
description "The number of attempts to try waiting for migrations to complete before start"
|
||||
default 120
|
||||
end
|
||||
|
||||
integer :sleep_time do
|
||||
description "The number of seconds to wait between each migration check"
|
||||
default 2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,132 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class DKIMHeader
|
||||
|
||||
def initialize(domain, message)
|
||||
if domain && domain.dkim_status == "OK"
|
||||
@domain_name = domain.name
|
||||
@dkim_key = domain.dkim_key
|
||||
@dkim_identifier = domain.dkim_identifier
|
||||
else
|
||||
@domain_name = Postal.config.dns.return_path
|
||||
@dkim_key = Postal.signing_key
|
||||
@dkim_identifier = Postal.config.dns.dkim_identifier
|
||||
end
|
||||
@domain = domain
|
||||
@message = message
|
||||
@raw_headers, @raw_body = @message.gsub(/\r?\n/, "\r\n").split(/\r\n\r\n/, 2)
|
||||
end
|
||||
|
||||
def dkim_header
|
||||
"DKIM-Signature: v=1; " + dkim_properties.join("\r\n\t") + signature.scan(/.{1,72}/).join("\r\n\t")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def headers
|
||||
@headers ||= @raw_headers.to_s.gsub(/\r?\n\s/, " ").split(/\r?\n/)
|
||||
end
|
||||
|
||||
def header_names
|
||||
normalized_headers.map { |h| h.split(":")[0].strip }
|
||||
end
|
||||
|
||||
def normalized_headers
|
||||
[].tap do |new_headers|
|
||||
dkim_headers = headers.select do |h|
|
||||
h.match(/
|
||||
^(
|
||||
from|sender|reply-to|subject|date|message-id|to|cc|mime-version|content-type|content-transfer-encoding|
|
||||
resent-to|resent-cc|resent-from|resent-sender|resent-message-id|in-reply-to|references|list-id|list-help|
|
||||
list-owner|list-unsubscribe|list-subscribe|list-post
|
||||
):/ix)
|
||||
end
|
||||
dkim_headers.each do |h|
|
||||
new_headers << normalize_header(h)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_header(content)
|
||||
content = content.dup
|
||||
|
||||
# From the DKIM RFC6376
|
||||
# https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.2
|
||||
|
||||
# Split the key and value.
|
||||
key, value = content.split(":", 2)
|
||||
|
||||
# Convert all header field names (not the header field values) to
|
||||
# lowercase. For example, convert "SUBJect: AbC" to "subject: AbC".
|
||||
key.downcase!
|
||||
|
||||
# Unfold all header field continuation lines as described in [RFC5322]
|
||||
value.gsub!(/\r?\n[ \t]+/, " ")
|
||||
|
||||
# Convert all sequences of one or more WSP characters to a single SP character.
|
||||
value.gsub!(/[ \t]+/, " ")
|
||||
|
||||
# Delete all WSP characters at the end of each unfolded header field value.
|
||||
value.gsub!(/[ \t]*\z/, "")
|
||||
|
||||
# Delete any WSP characters remaining after the colon separating the header field name from the header field value.
|
||||
value.gsub!(/\A[ \t]*/, "")
|
||||
|
||||
# Join together
|
||||
key + ":" + value
|
||||
end
|
||||
|
||||
def normalized_body
|
||||
@normalized_body ||= begin
|
||||
content = @raw_body.dup
|
||||
|
||||
# From the DKIM RFC6376
|
||||
# https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.4
|
||||
|
||||
# a. Reduce whitespace
|
||||
#
|
||||
# * Reduce all sequences of WSP within a line to a single SP character.
|
||||
content.gsub!(/[ \t]+/, " ")
|
||||
|
||||
# * Ignore all whitespace at the end of lines. Implementations MUST NOT
|
||||
# remove the CRLF at the end of the line.
|
||||
content.gsub!(/ \r\n/, "\r\n")
|
||||
|
||||
# b. Ignore all empty lines at the end of the message body.
|
||||
content.gsub!(/[ \r\n]*\z/, "")
|
||||
|
||||
content += "\r\n"
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def body_hash
|
||||
@body_hash ||= Base64.encode64(Digest::SHA256.digest(normalized_body)).strip
|
||||
end
|
||||
|
||||
def dkim_properties
|
||||
@dkim_properties ||= [].tap do |header|
|
||||
header << "a=rsa-sha256; c=relaxed/relaxed;"
|
||||
header << "d=#{@domain_name};"
|
||||
header << "s=#{@dkim_identifier}; t=#{Time.now.utc.to_i};"
|
||||
header << "bh=#{body_hash};"
|
||||
header << "h=#{header_names.join(':')};"
|
||||
header << "b="
|
||||
end
|
||||
end
|
||||
|
||||
def dkim_header_for_signing
|
||||
"dkim-signature:v=1; #{dkim_properties.join(' ')}"
|
||||
end
|
||||
|
||||
def signable_header_string
|
||||
(normalized_headers + [dkim_header_for_signing]).join("\r\n")
|
||||
end
|
||||
|
||||
def signature
|
||||
Base64.encode64(@dkim_key.sign(OpenSSL::Digest.new("SHA256"), signable_header_string)).gsub("\n", "")
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,136 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class HTTPSender < Sender
|
||||
|
||||
def initialize(endpoint, options = {})
|
||||
super()
|
||||
@endpoint = endpoint
|
||||
@options = options
|
||||
@log_id = Nifty::Utils::RandomString.generate(length: 8).upcase
|
||||
end
|
||||
|
||||
def send_message(message)
|
||||
start_time = Time.now
|
||||
result = SendResult.new
|
||||
result.log_id = @log_id
|
||||
|
||||
request_options = {}
|
||||
request_options[:sign] = true
|
||||
request_options[:timeout] = @endpoint.timeout || 5
|
||||
case @endpoint.encoding
|
||||
when "BodyAsJSON"
|
||||
request_options[:json] = parameters(message, flat: false).to_json
|
||||
when "FormData"
|
||||
request_options[:params] = parameters(message, flat: true)
|
||||
end
|
||||
|
||||
log "Sending request to #{@endpoint.url}"
|
||||
response = Postal::HTTP.post(@endpoint.url, request_options)
|
||||
result.secure = !!response[:secure] # rubocop:disable Style/DoubleNegation
|
||||
result.details = "Received a #{response[:code]} from #{@endpoint.url}"
|
||||
log " -> Received: #{response[:code]}"
|
||||
if response[:body]
|
||||
log " -> Body: #{response[:body][0, 255]}"
|
||||
result.output = response[:body].to_s[0, 500].strip
|
||||
end
|
||||
if response[:code] >= 200 && response[:code] < 300
|
||||
# This is considered a success
|
||||
result.type = "Sent"
|
||||
elsif response[:code] >= 500 && response[:code] < 600
|
||||
# This is temporary. They might fix their server so it should soft fail.
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
elsif response[:code].negative?
|
||||
# Connection/SSL etc... errors
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.connect_error = true
|
||||
elsif response[:code] == 429
|
||||
# Rate limit exceeded, treat as a hard fail and don't send bounces
|
||||
result.type = "HardFail"
|
||||
result.suppress_bounce = true
|
||||
else
|
||||
# This is permanent. Any other error isn't cool with us.
|
||||
result.type = "HardFail"
|
||||
end
|
||||
result.time = (Time.now - start_time).to_f.round(2)
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log(text)
|
||||
Postal.logger_for(:http_sender).info("[#{@log_id}] #{text}")
|
||||
end
|
||||
|
||||
def parameters(message, options = {})
|
||||
case @endpoint.format
|
||||
when "Hash"
|
||||
hash = {
|
||||
id: message.id,
|
||||
rcpt_to: message.rcpt_to,
|
||||
mail_from: message.mail_from,
|
||||
token: message.token,
|
||||
subject: message.subject,
|
||||
message_id: message.message_id,
|
||||
timestamp: message.timestamp.to_f,
|
||||
size: message.size,
|
||||
spam_status: message.spam_status,
|
||||
bounce: message.bounce,
|
||||
received_with_ssl: message.received_with_ssl,
|
||||
to: message.headers["to"]&.last,
|
||||
cc: message.headers["cc"]&.last,
|
||||
from: message.headers["from"]&.last,
|
||||
date: message.headers["date"]&.last,
|
||||
in_reply_to: message.headers["in-reply-to"]&.last,
|
||||
references: message.headers["references"]&.last,
|
||||
html_body: message.html_body,
|
||||
attachment_quantity: message.attachments.size,
|
||||
auto_submitted: message.headers["auto-submitted"]&.last,
|
||||
reply_to: message.headers["reply-to"]
|
||||
}
|
||||
|
||||
if @endpoint.strip_replies
|
||||
hash[:plain_body], hash[:replies_from_plain_body] = Postal::ReplySeparator.separate(message.plain_body)
|
||||
else
|
||||
hash[:plain_body] = message.plain_body
|
||||
end
|
||||
|
||||
if @endpoint.include_attachments?
|
||||
if options[:flat]
|
||||
message.attachments.each_with_index do |a, i|
|
||||
hash["attachments[#{i}][filename]"] = a.filename
|
||||
hash["attachments[#{i}][content_type]"] = a.content_type
|
||||
hash["attachments[#{i}][size]"] = a.body.to_s.bytesize.to_s
|
||||
hash["attachments[#{i}][data]"] = Base64.encode64(a.body.to_s)
|
||||
end
|
||||
else
|
||||
hash[:attachments] = message.attachments.map do |a|
|
||||
{
|
||||
filename: a.filename,
|
||||
content_type: a.mime_type,
|
||||
size: a.body.to_s.bytesize,
|
||||
data: Base64.encode64(a.body.to_s)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
hash
|
||||
when "RawMessage"
|
||||
{
|
||||
id: message.id,
|
||||
rcpt_to: message.rcpt_to,
|
||||
mail_from: message.mail_from,
|
||||
message: Base64.encode64(message.raw_message),
|
||||
base64: true,
|
||||
size: message.size.to_i
|
||||
}
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,44 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "nifty/utils/random_string"
|
||||
|
||||
module Postal
|
||||
class Job
|
||||
|
||||
def initialize(id, params = {})
|
||||
@id = id
|
||||
@params = params
|
||||
on_initialize
|
||||
end
|
||||
|
||||
attr_reader :id
|
||||
|
||||
def params
|
||||
@params || {}
|
||||
end
|
||||
|
||||
def on_initialize
|
||||
# Called whenever the class is initialized. Can be overriden.
|
||||
end
|
||||
|
||||
def on_error(exception)
|
||||
# Called if there's an exception while processing the perform block.
|
||||
# Receives the exception.
|
||||
end
|
||||
|
||||
def perform
|
||||
end
|
||||
|
||||
def log(text)
|
||||
Worker.logger.info "[#{@id}] #{text}"
|
||||
end
|
||||
|
||||
def self.queue(queue, params = {})
|
||||
job_id = Nifty::Utils::RandomString.generate(length: 10).upcase
|
||||
job_payload = { "params" => params, "class_name" => name, "id" => job_id, "queue" => queue }
|
||||
Postal::Worker.job_queue(queue).publish(job_payload.to_json, persistent: false)
|
||||
job_id
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,122 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "konfig/sources/abstract"
|
||||
require "konfig/error"
|
||||
|
||||
module Postal
|
||||
class LegacyConfigSource < Konfig::Sources::Abstract
|
||||
|
||||
# This maps all the new configuration values to where they
|
||||
# exist in the old YAML file. The source will load any YAML
|
||||
# file that has been provided to this source in order. A
|
||||
# warning will be generated to the console for configuration
|
||||
# loaded from this format.
|
||||
MAPPING = {
|
||||
"postal.web_hostname" => -> (c) { c.dig("web", "host") },
|
||||
"postal.web_protocol" => -> (c) { c.dig("web", "protocol") },
|
||||
"postal.smtp_hostname" => -> (c) { c.dig("dns", "smtp_server_hostname") },
|
||||
"postal.use_ip_pools" => -> (c) { c.dig("general", "use_ip_pools") },
|
||||
"logging.sentry_dsn" => -> (c) { c.dig("general", "exception_url") },
|
||||
"postal.default_maximum_delivery_attempts" => -> (c) { c.dig("general", "maximum_delivery_attempts") },
|
||||
"postal.default_maximum_hold_expiry_days" => -> (c) { c.dig("general", "maximum_hold_expiry_days") },
|
||||
"postal.default_suppression_list_automatic_removal_days" => -> (c) { c.dig("general", "suppression_list_removal_delay") },
|
||||
"postal.use_local_ns_for_domain_verification" => -> (c) { c.dig("general", "use_local_ns_for_domains") },
|
||||
"postal.default_spam_threshold" => -> (c) { c.dig("general", "default_spam_threshold") },
|
||||
"postal.default_spam_failure_threshold" => -> (c) { c.dig("general", "default_spam_failure_threshold") },
|
||||
"postal.use_resent_sender_header" => -> (c) { c.dig("general", "use_resent_sender_header") },
|
||||
# SMTP relays must be converted to the new URI style format and they'll
|
||||
# then be transformed back to a hash by the schema transform.
|
||||
"postal.smtp_relays" => -> (c) { c["smtp_relays"]&.map { |r| "smtp://#{r['hostname']}:#{r['port']}?ssl_mode=#{r['ssl_mode']}" } },
|
||||
|
||||
"web_server.default_bind_address" => -> (c) { c.dig("web_server", "bind_address") },
|
||||
"web_server.default_port" => -> (c) { c.dig("web_server", "port") },
|
||||
"web_server.max_threads" => -> (c) { c.dig("web_server", "max_threads") },
|
||||
|
||||
"main_db.host" => -> (c) { c.dig("main_db", "host") },
|
||||
"main_db.port" => -> (c) { c.dig("main_db", "port") },
|
||||
"main_db.username" => -> (c) { c.dig("main_db", "username") },
|
||||
"main_db.password" => -> (c) { c.dig("main_db", "password") },
|
||||
"main_db.database" => -> (c) { c.dig("main_db", "database") },
|
||||
"main_db.pool_size" => -> (c) { c.dig("main_db", "pool_size") },
|
||||
"main_db.encoding" => -> (c) { c.dig("main_db", "encoding") },
|
||||
|
||||
"message_db.host" => -> (c) { c.dig("message_db", "host") },
|
||||
"message_db.port" => -> (c) { c.dig("message_db", "port") },
|
||||
"message_db.username" => -> (c) { c.dig("message_db", "username") },
|
||||
"message_db.password" => -> (c) { c.dig("message_db", "password") },
|
||||
"message_db.database_name_prefix" => -> (c) { c.dig("message_db", "prefix") },
|
||||
|
||||
"logging.rails_log_enabled" => -> (c) { c.dig("logging", "rails_log") },
|
||||
|
||||
"gelf.host" => -> (c) { c.dig("logging", "graylog", "host") },
|
||||
"gelf.port" => -> (c) { c.dig("logging", "graylog", "port") },
|
||||
"gelf.facility" => -> (c) { c.dig("logging", "graylog", "facility") },
|
||||
|
||||
"smtp_server.default_port" => -> (c) { c.dig("smtp_server", "port") },
|
||||
"smtp_server.default_bind_address" => -> (c) { c.dig("smtp_server", "bind_address") },
|
||||
"smtp_server.tls_enabled" => -> (c) { c.dig("smtp_server", "tls_enabled") },
|
||||
"smtp_server.tls_certificate_path" => -> (c) { c.dig("smtp_server", "tls_certificate_path") },
|
||||
"smtp_server.tls_private_key_path" => -> (c) { c.dig("smtp_server", "tls_private_key_path") },
|
||||
"smtp_server.tls_ciphers" => -> (c) { c.dig("smtp_server", "tls_ciphers") },
|
||||
"smtp_server.ssl_version" => -> (c) { c.dig("smtp_server", "ssl_version") },
|
||||
"smtp_server.proxy_protocol" => -> (c) { c.dig("smtp_server", "proxy_protocol") },
|
||||
"smtp_server.log_connections" => -> (c) { c.dig("smtp_server", "log_connect") },
|
||||
"smtp_server.max_message_size" => -> (c) { c.dig("smtp_server", "max_message_size") },
|
||||
|
||||
"dns.mx_records" => -> (c) { c.dig("dns", "mx_records") },
|
||||
"dns.spf_include" => -> (c) { c.dig("dns", "spf_include") },
|
||||
"dns.return_path_domain" => -> (c) { c.dig("dns", "return_path") },
|
||||
"dns.route_domain" => -> (c) { c.dig("dns", "route_domain") },
|
||||
"dns.track_domain" => -> (c) { c.dig("dns", "track_domain") },
|
||||
"dns.helo_hostname" => -> (c) { c.dig("dns", "helo_hostname") },
|
||||
"dns.dkim_identifier" => -> (c) { c.dig("dns", "dkim_identifier") },
|
||||
"dns.domain_verify_prefix" => -> (c) { c.dig("dns", "domain_verify_prefix") },
|
||||
"dns.custom_return_path_prefix" => -> (c) { c.dig("dns", "custom_return_path_prefix") },
|
||||
|
||||
"smtp.host" => -> (c) { c.dig("smtp", "host") },
|
||||
"smtp.port" => -> (c) { c.dig("smtp", "port") },
|
||||
"smtp.username" => -> (c) { c.dig("smtp", "username") },
|
||||
"smtp.password" => -> (c) { c.dig("smtp", "password") },
|
||||
"smtp.from_name" => -> (c) { c.dig("smtp", "from_name") },
|
||||
"smtp.from_address" => -> (c) { c.dig("smtp", "from_address") },
|
||||
|
||||
"rails.environment" => -> (c) { c.dig("rails", "environment") },
|
||||
"rails.secret_key" => -> (c) { c.dig("rails", "secret_key") },
|
||||
|
||||
"rspamd.enabled" => -> (c) { c.dig("rspamd", "enabled") },
|
||||
"rspamd.host" => -> (c) { c.dig("rspamd", "host") },
|
||||
"rspamd.port" => -> (c) { c.dig("rspamd", "port") },
|
||||
"rspamd.ssl" => -> (c) { c.dig("rspamd", "ssl") },
|
||||
"rspamd.password" => -> (c) { c.dig("rspamd", "password") },
|
||||
"rspamd.flags" => -> (c) { c.dig("rspamd", "flags") },
|
||||
|
||||
"spamd.enabled" => -> (c) { c.dig("spamd", "enabled") },
|
||||
"spamd.host" => -> (c) { c.dig("spamd", "host") },
|
||||
"spamd.port" => -> (c) { c.dig("spamd", "port") },
|
||||
|
||||
"clamav.enabled" => -> (c) { c.dig("clamav", "enabled") },
|
||||
"clamav.host" => -> (c) { c.dig("clamav", "host") },
|
||||
"clamav.port" => -> (c) { c.dig("clamav", "port") },
|
||||
|
||||
"smtp_client.open_timeout" => -> (c) { c.dig("smtp_client", "open_timeout") },
|
||||
"smtp_client.read_timeout" => -> (c) { c.dig("smtp_client", "read_timeout") }
|
||||
|
||||
}.freeze
|
||||
|
||||
def initialize(config)
|
||||
super()
|
||||
@config = config
|
||||
end
|
||||
|
||||
def get(path, attribute: nil)
|
||||
path_string = path.join(".")
|
||||
raise Konfig::ValueNotPresentError unless MAPPING.key?(path_string)
|
||||
|
||||
legacy_value = MAPPING[path_string].call(@config)
|
||||
raise Konfig::ValueNotPresentError if legacy_value.nil?
|
||||
|
||||
legacy_value
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
module MessageDB
|
||||
end
|
||||
end
|
||||
@@ -63,11 +63,11 @@ module Postal
|
||||
|
||||
def establish_connection
|
||||
Mysql2::Client.new(
|
||||
host: Postal.config.message_db.host,
|
||||
username: Postal.config.message_db.username,
|
||||
password: Postal.config.message_db.password,
|
||||
port: Postal.config.message_db.port,
|
||||
encoding: Postal.config.message_db.encoding || "utf8mb4"
|
||||
host: Postal::Config.message_db.host,
|
||||
username: Postal::Config.message_db.username,
|
||||
password: Postal::Config.message_db.password,
|
||||
port: Postal::Config.message_db.port,
|
||||
encoding: Postal::Config.message_db.encoding
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ module Postal
|
||||
|
||||
end
|
||||
|
||||
def initialize(organization_id, server_id)
|
||||
def initialize(organization_id, server_id, database_name: nil)
|
||||
@organization_id = organization_id
|
||||
@server_id = server_id
|
||||
@database_name = database_name
|
||||
end
|
||||
|
||||
attr_reader :organization_id
|
||||
@@ -58,8 +59,8 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Create a new message with the given attributes. This won't be saved to the database
|
||||
# until it has been 'save'd.
|
||||
# Create a new message with the given attributes. This won't be saved to the database
|
||||
# until it has been 'save'd.
|
||||
#
|
||||
def new_message(attributes = {})
|
||||
Message.new(self, attributes)
|
||||
@@ -73,35 +74,35 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Return the live stats instance
|
||||
# Return the live stats instance
|
||||
#
|
||||
def live_stats
|
||||
@live_stats ||= LiveStats.new(self)
|
||||
end
|
||||
|
||||
#
|
||||
# Return the statistics instance
|
||||
# Return the statistics instance
|
||||
#
|
||||
def statistics
|
||||
@statistics ||= Statistics.new(self)
|
||||
end
|
||||
|
||||
#
|
||||
# Return the provisioner instance
|
||||
# Return the provisioner instance
|
||||
#
|
||||
def provisioner
|
||||
@provisioner ||= Provisioner.new(self)
|
||||
end
|
||||
|
||||
#
|
||||
# Return the provisioner instance
|
||||
# Return the provisioner instance
|
||||
#
|
||||
def suppression_list
|
||||
@suppression_list ||= SuppressionList.new(self)
|
||||
end
|
||||
|
||||
#
|
||||
# Return the provisioner instance
|
||||
# Return the provisioner instance
|
||||
#
|
||||
def webhooks
|
||||
@webhooks ||= Webhooks.new(self)
|
||||
@@ -182,7 +183,7 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# A paginated version of select
|
||||
# A paginated version of select
|
||||
#
|
||||
def select_with_pagination(table, page, options = {})
|
||||
page = page.to_i
|
||||
@@ -252,7 +253,7 @@ module Postal
|
||||
|
||||
#
|
||||
# Deletes a in the database. Accepts a table name, and some options which
|
||||
# are shown below:
|
||||
# are shown below:
|
||||
#
|
||||
# :where => The condition to apply to the query
|
||||
#
|
||||
@@ -268,10 +269,10 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Return the correct database name
|
||||
# Return the correct database name
|
||||
#
|
||||
def database_name
|
||||
@database_name ||= "#{Postal.config.message_db.prefix}-server-#{@server_id}"
|
||||
@database_name ||= "#{Postal::Config.message_db.database_name_prefix}-server-#{@server_id}"
|
||||
end
|
||||
|
||||
#
|
||||
@@ -325,12 +326,12 @@ module Postal
|
||||
result = connection.query(query, cast_booleans: true)
|
||||
time = Time.now.to_f - start_time
|
||||
logger.debug " \e[4;34mMessageDB Query (#{time.round(2)}s) \e[0m \e[33m#{query}\e[0m"
|
||||
if time > 0.5 && query =~ /\A(SELECT|UPDATE|DELETE) /
|
||||
if time > 0.05 && query =~ /\A(SELECT|UPDATE|DELETE) /
|
||||
id = Nifty::Utils::RandomString.generate(length: 6).upcase
|
||||
explain_result = ResultForExplainPrinter.new(connection.query("EXPLAIN #{query}"))
|
||||
slow_query_logger.info "[#{id}] EXPLAIN #{query}"
|
||||
logger.info " [#{id}] EXPLAIN #{query}"
|
||||
ActiveRecord::ConnectionAdapters::MySQL::ExplainPrettyPrinter.new.pp(explain_result, time).split("\n").each do |line|
|
||||
slow_query_logger.info "[#{id}] " + line
|
||||
logger.info " [#{id}] " + line
|
||||
end
|
||||
end
|
||||
result
|
||||
@@ -340,10 +341,6 @@ module Postal
|
||||
defined?(Rails) ? Rails.logger : Logger.new($stdout)
|
||||
end
|
||||
|
||||
def slow_query_logger
|
||||
Postal.logger_for(:slow_message_db_queries)
|
||||
end
|
||||
|
||||
def with_mysql(&block)
|
||||
self.class.connection_pool.use(&block)
|
||||
end
|
||||
|
||||
@@ -39,6 +39,10 @@ module Postal
|
||||
@attributes = attributes
|
||||
end
|
||||
|
||||
def reload
|
||||
self.class.find_one(@database, @attributes["id"])
|
||||
end
|
||||
|
||||
#
|
||||
# Return the server for this message
|
||||
#
|
||||
@@ -99,7 +103,7 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Return the time that the last delivery was attempted
|
||||
# Return the time that the last delivery was attempted
|
||||
#
|
||||
def last_delivery_attempt
|
||||
@last_delivery_attempt ||= @attributes["last_delivery_attempt"] ? Time.zone.at(@attributes["last_delivery_attempt"]) : nil
|
||||
@@ -124,7 +128,7 @@ module Postal
|
||||
#
|
||||
def create_delivery(status, options = {})
|
||||
delivery = Delivery.create(self, options.merge(status: status))
|
||||
hold_expiry = status == "Held" ? Postal.config.general.maximum_hold_expiry_days.days.from_now.to_f : nil
|
||||
hold_expiry = status == "Held" ? Postal::Config.postal.default_maximum_hold_expiry_days.days.from_now.to_f : nil
|
||||
update(status: status, last_delivery_attempt: delivery.timestamp.to_f, held: status == "Held", hold_expiry: hold_expiry)
|
||||
delivery
|
||||
end
|
||||
@@ -168,14 +172,14 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Return all activity entries
|
||||
# Return all activity entries
|
||||
#
|
||||
def activity_entries
|
||||
@activity_entries ||= (deliveries + clicks + loads).sort_by(&:timestamp)
|
||||
end
|
||||
|
||||
#
|
||||
# Provide access to set and get acceptable attributes
|
||||
# Provide access to set and get acceptable attributes
|
||||
#
|
||||
def method_missing(name, value = nil, &block)
|
||||
if @attributes.key?(name.to_s)
|
||||
@@ -198,11 +202,11 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Save this message
|
||||
# Save this message
|
||||
#
|
||||
def save
|
||||
def save(queue_on_create: true)
|
||||
save_raw_message
|
||||
persisted? ? _update : _create
|
||||
persisted? ? _update : _create(queue: queue_on_create)
|
||||
self
|
||||
end
|
||||
|
||||
@@ -219,7 +223,7 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Delete the message from the database
|
||||
# Delete the message from the database
|
||||
#
|
||||
def delete
|
||||
return unless persisted?
|
||||
@@ -228,7 +232,7 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Return the headers
|
||||
# Return the headers
|
||||
#
|
||||
def raw_headers
|
||||
if raw_table
|
||||
@@ -239,7 +243,7 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Return the full raw message body for this message.
|
||||
# Return the full raw message body for this message.
|
||||
#
|
||||
def raw_body
|
||||
if raw_table
|
||||
@@ -299,7 +303,7 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Return the HTML body for this message
|
||||
# Return the HTML body for this message
|
||||
#
|
||||
def html_body
|
||||
mail&.html_body
|
||||
@@ -346,8 +350,14 @@ module Postal
|
||||
#
|
||||
# Create a new item in the message queue for this message
|
||||
#
|
||||
def add_to_message_queue(options = {})
|
||||
QueuedMessage.create!(message: self, server_id: @database.server_id, batch_key: batch_key, domain: recipient_domain, route_id: route_id, manual: options[:manual]).id
|
||||
def add_to_message_queue(**options)
|
||||
QueuedMessage.create!({
|
||||
message: self,
|
||||
server_id: @database.server_id,
|
||||
batch_key: batch_key,
|
||||
domain: recipient_domain,
|
||||
route_id: route_id
|
||||
}.merge(options))
|
||||
end
|
||||
|
||||
#
|
||||
@@ -403,7 +413,7 @@ module Postal
|
||||
def add_outgoing_headers
|
||||
headers = []
|
||||
if domain
|
||||
dkim = Postal::DKIMHeader.new(domain, raw_message)
|
||||
dkim = DKIMHeader.new(domain, raw_message)
|
||||
headers << dkim.dkim_header
|
||||
end
|
||||
headers << "X-Postal-MsgID: #{token}"
|
||||
@@ -445,7 +455,11 @@ module Postal
|
||||
#
|
||||
def bounce!(bounce_message)
|
||||
create_delivery("Bounced", details: "We've received a bounce message for this e-mail. See <msg:#{bounce_message.id}> for details.")
|
||||
SendWebhookJob.queue(:main, server_id: database.server_id, event: "MessageBounced", payload: { _original_message: id, _bounce: bounce_message.id })
|
||||
|
||||
WebhookRequest.trigger(server, "MessageBounced", {
|
||||
original_message: webhook_hash,
|
||||
bounce: bounce_message.webhook_hash
|
||||
})
|
||||
end
|
||||
|
||||
#
|
||||
@@ -461,11 +475,16 @@ module Postal
|
||||
def create_load(request)
|
||||
update("loaded" => Time.now.to_f) if loaded.nil?
|
||||
database.insert(:loads, { message_id: id, ip_address: request.ip, user_agent: request.user_agent, timestamp: Time.now.to_f })
|
||||
SendWebhookJob.queue(:main, server_id: database.server_id, event: "MessageLoaded", payload: { _message: id, ip_address: request.ip, user_agent: request.user_agent })
|
||||
|
||||
WebhookRequest.trigger(server, "MessageLoaded", {
|
||||
message: webhook_hash,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.user_agent
|
||||
})
|
||||
end
|
||||
|
||||
#
|
||||
# Create a new link
|
||||
# Create a new link
|
||||
#
|
||||
def create_link(url)
|
||||
hash = Digest::SHA1.hexdigest(url.to_s)
|
||||
@@ -475,7 +494,7 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Return a message object that this message is a reply to
|
||||
# Return a message object that this message is a reply to
|
||||
#
|
||||
def original_messages
|
||||
return nil unless bounce
|
||||
@@ -492,7 +511,7 @@ module Postal
|
||||
# Was thsi message sent to a return path?
|
||||
#
|
||||
def rcpt_to_return_path?
|
||||
!!(rcpt_to =~ /@#{Regexp.escape(Postal.config.dns.custom_return_path_prefix)}\./)
|
||||
!!(rcpt_to =~ /@#{Regexp.escape(Postal::Config.dns.custom_return_path_prefix)}\./)
|
||||
end
|
||||
|
||||
#
|
||||
@@ -512,7 +531,7 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Return all spam checks for this message
|
||||
# Return all spam checks for this message
|
||||
#
|
||||
def spam_checks
|
||||
@spam_checks ||= database.select(:spam_checks, where: { message_id: id })
|
||||
@@ -533,7 +552,7 @@ module Postal
|
||||
def parse_content
|
||||
parse_result = Postal::MessageParser.new(self)
|
||||
if parse_result.actioned?
|
||||
# Somethign was changed, update the raw message
|
||||
# Somethign was changed, update the raw message
|
||||
@database.update(raw_table, { data: parse_result.new_body }, where: { id: raw_body_id })
|
||||
@database.update(raw_table, { data: parse_result.new_headers }, where: { id: raw_headers_id })
|
||||
@raw = parse_result.new_body
|
||||
@@ -563,7 +582,7 @@ module Postal
|
||||
@database.update("messages", @attributes.except(:id), where: { id: @attributes["id"] })
|
||||
end
|
||||
|
||||
def _create
|
||||
def _create(queue: true)
|
||||
self.timestamp = Time.now.to_f if timestamp.blank?
|
||||
self.status = "Pending" if status.blank?
|
||||
self.token = Nifty::Utils::RandomString.generate(length: 12) if token.blank?
|
||||
@@ -572,7 +591,7 @@ module Postal
|
||||
@database.statistics.increment_all(timestamp, scope)
|
||||
Statistic.global.increment!(:total_messages)
|
||||
Statistic.global.increment!("total_#{scope}".to_sym)
|
||||
add_to_message_queue
|
||||
add_to_message_queue if queue
|
||||
end
|
||||
|
||||
def mail
|
||||
|
||||
@@ -127,7 +127,7 @@ module Postal
|
||||
end
|
||||
|
||||
#
|
||||
# Remove messages from the messages table that are too old to retain
|
||||
# Remove messages from the messages table that are too old to retain
|
||||
#
|
||||
def remove_messages(max_age = 60)
|
||||
time = (Time.now.utc.to_date - max_age.days).to_time.end_of_day
|
||||
|
||||
@@ -9,7 +9,7 @@ module Postal
|
||||
end
|
||||
|
||||
def add(type, address, options = {})
|
||||
keep_until = (options[:days] || Postal.config.general.suppression_list_removal_delay).days.from_now.to_f
|
||||
keep_until = (options[:days] || Postal::Config.postal.default_suppression_list_automatic_removal_days).days.from_now.to_f
|
||||
if existing = @database.select("suppressions", where: { type: type, address: address }, limit: 1).first
|
||||
reason = options[:reason] || existing["reason"]
|
||||
@database.update("suppressions", { reason: reason, keep_until: keep_until }, where: { id: existing["id"] })
|
||||
|
||||
@@ -12,7 +12,7 @@ module Postal
|
||||
@database.insert(:webhook_requests, attributes)
|
||||
end
|
||||
|
||||
def list(page)
|
||||
def list(page = 1)
|
||||
result = @database.select_with_pagination(:webhook_requests, page, order: :timestamp, direction: "desc")
|
||||
result[:records] = result[:records].map { |i| Request.new(i) }
|
||||
result
|
||||
|
||||
@@ -15,7 +15,7 @@ module Postal
|
||||
private
|
||||
|
||||
def logger
|
||||
Postal.logger_for(:message_inspection)
|
||||
Postal.logger
|
||||
end
|
||||
|
||||
class << self
|
||||
@@ -24,14 +24,14 @@ module Postal
|
||||
# installation.
|
||||
def inspectors
|
||||
[].tap do |inspectors|
|
||||
if Postal.config.rspamd&.enabled
|
||||
inspectors << MessageInspectors::Rspamd.new(Postal.config.rspamd)
|
||||
elsif Postal.config.spamd&.enabled
|
||||
inspectors << MessageInspectors::SpamAssassin.new(Postal.config.spamd)
|
||||
if Postal::Config.rspamd.enabled?
|
||||
inspectors << MessageInspectors::Rspamd.new(Postal::Config.rspamd)
|
||||
elsif Postal::Config.spamd.enabled?
|
||||
inspectors << MessageInspectors::SpamAssassin.new(Postal::Config.spamd)
|
||||
end
|
||||
|
||||
if Postal.config.clamav&.enabled
|
||||
inspectors << MessageInspectors::Clamav.new(Postal.config.clamav)
|
||||
if Postal::Config.clamav.enabled?
|
||||
inspectors << MessageInspectors::Clamav.new(Postal::Config.clamav)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
module MessageInspectors
|
||||
end
|
||||
end
|
||||
@@ -1,34 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class MessageRequeuer
|
||||
|
||||
def run
|
||||
Signal.trap("INT") { @running ? @exit = true : Process.exit(0) }
|
||||
Signal.trap("TERM") { @running ? @exit = true : Process.exit(0) }
|
||||
|
||||
log "Running message requeuer..."
|
||||
loop do
|
||||
@running = true
|
||||
QueuedMessage.requeue_all
|
||||
@running = false
|
||||
check_exit
|
||||
sleep 5
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log(text)
|
||||
Postal.logger_for(:message_requeuer).info text
|
||||
end
|
||||
|
||||
def check_exit
|
||||
return unless @exit
|
||||
|
||||
log "Exiting"
|
||||
Process.exit(0)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,36 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class MXLookup
|
||||
|
||||
class << self
|
||||
|
||||
def lookup(domain)
|
||||
records = resolve(domain)
|
||||
records = sort(records)
|
||||
records.map { |m| m[1] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sort(records)
|
||||
records.sort do |a, b|
|
||||
if a[0] == b[0]
|
||||
[-1, 1].sample
|
||||
else
|
||||
a[0] <=> b[0]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def resolve(domain)
|
||||
Resolv::DNS.open do |dns|
|
||||
dns.timeouts = [10, 5]
|
||||
dns.getresources(domain, Resolv::DNS::Resource::IN::MX).map { |m| [m.preference.to_i, m.exchange.to_s] }
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,38 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class QueryString
|
||||
|
||||
def initialize(string)
|
||||
@string = string.strip + " "
|
||||
end
|
||||
|
||||
def [](value)
|
||||
hash[value.to_s]
|
||||
end
|
||||
|
||||
delegate :empty?, to: :hash
|
||||
|
||||
def hash
|
||||
@hash ||= @string.scan(/([a-z]+):\s*(?:(\d{2,4}-\d{2}-\d{2}\s\d{2}:\d{2})|"(.*?)"|(.*?))(\s|\z)/).each_with_object({}) do |(key, date, string_with_spaces, value), hash|
|
||||
if date
|
||||
actual_value = date
|
||||
elsif string_with_spaces
|
||||
actual_value = string_with_spaces
|
||||
elsif value == "[blank]"
|
||||
actual_value = nil
|
||||
else
|
||||
actual_value = value
|
||||
end
|
||||
|
||||
if hash.keys.include?(key.to_s)
|
||||
hash[key.to_s] = [hash[key.to_s]].flatten
|
||||
hash[key.to_s] << actual_value
|
||||
else
|
||||
hash[key.to_s] = actual_value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,38 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "postal/config"
|
||||
require "bunny"
|
||||
|
||||
module Postal
|
||||
module RabbitMQ
|
||||
|
||||
def self.create_connection
|
||||
bunny_host = ["localhost"]
|
||||
|
||||
if Postal.config.rabbitmq&.host.is_a?(Array)
|
||||
bunny_host = Postal.config.rabbitmq&.host
|
||||
elsif Postal.config.rabbitmq&.host.is_a?(String)
|
||||
bunny_host = [Postal.config.rabbitmq&.host]
|
||||
end
|
||||
|
||||
conn = Bunny.new(
|
||||
hosts: bunny_host,
|
||||
port: Postal.config.rabbitmq&.port || 5672,
|
||||
tls: Postal.config.rabbitmq&.tls || false,
|
||||
verify_peer: Postal.config.rabbitmq&.verify_peer || true,
|
||||
tls_ca_certificates: Postal.config.rabbitmq&.tls_ca_certificates || ["/etc/ssl/certs/ca-certificates.crt"],
|
||||
username: Postal.config.rabbitmq&.username || "guest",
|
||||
password: Postal.config.rabbitmq&.password || "guest",
|
||||
vhost: Postal.config.rabbitmq&.vhost || nil
|
||||
)
|
||||
conn.start
|
||||
conn
|
||||
end
|
||||
|
||||
def self.create_channel
|
||||
conn = create_connection
|
||||
conn.create_channel(nil, Postal.config.workers.threads)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,45 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class ReceivedHeader
|
||||
|
||||
OUR_HOSTNAMES = {
|
||||
smtp: Postal.config.dns.smtp_server_hostname,
|
||||
http: Postal.config.web.host
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
|
||||
def generate(server, helo, ip_address, method)
|
||||
our_hostname = OUR_HOSTNAMES[method]
|
||||
if our_hostname.nil?
|
||||
raise Error, "`method` is invalid (must be one of #{OUR_HOSTNAMES.join(', ')})"
|
||||
end
|
||||
|
||||
header = "by #{our_hostname} with #{method.to_s.upcase}; #{Time.now.utc.rfc2822}"
|
||||
|
||||
if server.nil? || server.privacy_mode == false
|
||||
hostname = resolve_hostname(ip_address)
|
||||
header = "from #{helo} (#{hostname} [#{ip_address}]) #{header}"
|
||||
end
|
||||
|
||||
header
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_hostname(ip_address)
|
||||
Resolv::DNS.open do |dns|
|
||||
dns.timeouts = [10, 5]
|
||||
begin
|
||||
dns.getname(ip_address)
|
||||
rescue StandardError
|
||||
ip_address
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,36 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class ReplySeparator
|
||||
|
||||
RULES = [
|
||||
/^-{2,10} $.*/m,
|
||||
/^>*\s*----- ?Original Message ?-----.*/m,
|
||||
/^>*\s*From:[^\r\n]*[\r\n]+Sent:.*/m,
|
||||
/^>*\s*From:[^\r\n]*[\r\n]+Date:.*/m,
|
||||
/^>*\s*-----Urspr.ngliche Nachricht----- .*/m,
|
||||
/^>*\s*Le[^\r\n]{10,200}a .crit ?:\s*$.*/,
|
||||
/^>*\s*__________________.*/m,
|
||||
/^>*\s*On.{10,200}wrote:\s*$.*/m,
|
||||
/^>*\s*Sent from my.*/m,
|
||||
/^>*\s*=== Please reply above this line ===.*/m,
|
||||
/(^>.*\n?){10,}/
|
||||
].freeze
|
||||
|
||||
def self.separate(text)
|
||||
return "" unless text.is_a?(String)
|
||||
|
||||
text = text.gsub("\r", "")
|
||||
stripped = String.new
|
||||
RULES.each do |rule|
|
||||
text.gsub!(rule) do
|
||||
stripped = ::Regexp.last_match(0).to_s + "\n" + stripped
|
||||
""
|
||||
end
|
||||
end
|
||||
stripped = stripped.strip
|
||||
[text.strip, stripped.presence]
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,24 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
module RspecHelpers
|
||||
|
||||
def with_global_server(&block)
|
||||
server = Server.find(GLOBAL_SERVER.id)
|
||||
block.call(server)
|
||||
ensure
|
||||
server.message_db.provisioner.clean
|
||||
end
|
||||
|
||||
def create_plain_text_message(server, text, to = "test@example.com", override_attributes = {})
|
||||
domain = create(:domain, owner: server)
|
||||
attributes = { from: "test@#{domain.name}", subject: "Test Plain Text Message" }.merge(override_attributes)
|
||||
attributes[:to] = to
|
||||
attributes[:plain_body] = text
|
||||
message = OutgoingMessagePrototype.new(server, "127.0.0.1", "testsuite", attributes)
|
||||
result = message.create_message(to)
|
||||
server.message_db.message(result[:id])
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,17 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class SendResult
|
||||
|
||||
attr_accessor :type
|
||||
attr_accessor :details
|
||||
attr_accessor :retry
|
||||
attr_accessor :output
|
||||
attr_accessor :secure
|
||||
attr_accessor :connect_error
|
||||
attr_accessor :log_id
|
||||
attr_accessor :time
|
||||
attr_accessor :suppress_bounce
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class Sender
|
||||
|
||||
def start
|
||||
end
|
||||
|
||||
def send_message(message)
|
||||
end
|
||||
|
||||
def finish
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,298 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "resolv"
|
||||
|
||||
module Postal
|
||||
class SMTPSender < Sender
|
||||
|
||||
def initialize(domain, source_ip_address, options = {})
|
||||
super()
|
||||
@domain = domain
|
||||
@source_ip_address = source_ip_address
|
||||
@options = options
|
||||
@smtp_client = nil
|
||||
@connection_errors = []
|
||||
@hostnames = []
|
||||
@log_id = Nifty::Utils::RandomString.generate(length: 8).upcase
|
||||
end
|
||||
|
||||
def start
|
||||
servers.each do |server|
|
||||
if server.is_a?(SMTPEndpoint)
|
||||
hostname = server.hostname
|
||||
port = server.port || 25
|
||||
ssl_mode = server.ssl_mode
|
||||
elsif server.is_a?(Hash)
|
||||
hostname = server[:hostname]
|
||||
port = server[:port] || 25
|
||||
ssl_mode = server[:ssl_mode] || "Auto"
|
||||
else
|
||||
hostname = server
|
||||
port = 25
|
||||
ssl_mode = "Auto"
|
||||
end
|
||||
|
||||
@hostnames << hostname
|
||||
[:aaaa, :a].each do |ip_type|
|
||||
if @source_ip_address && @source_ip_address.ipv6.blank? && ip_type == :aaaa
|
||||
# Don't try to use IPv6 if the IP address we're sending from doesn't support it.
|
||||
next
|
||||
end
|
||||
|
||||
begin
|
||||
@remote_ip = lookup_ip_address(ip_type, hostname)
|
||||
if @remote_ip.nil?
|
||||
if ip_type == :a
|
||||
# As we can't resolve the last IP, we'll put this
|
||||
@connection_errors << "Could not resolve #{hostname}"
|
||||
end
|
||||
next
|
||||
end
|
||||
|
||||
smtp_client = Net::SMTP.new(@remote_ip, port)
|
||||
smtp_client.open_timeout = Postal.config.smtp_client.open_timeout
|
||||
smtp_client.read_timeout = Postal.config.smtp_client.read_timeout
|
||||
smtp_client.tls_hostname = hostname
|
||||
|
||||
if @source_ip_address
|
||||
# Set the source IP as appropriate
|
||||
smtp_client.source_address = ip_type == :aaaa ? @source_ip_address.ipv6 : @source_ip_address.ipv4
|
||||
end
|
||||
|
||||
case ssl_mode
|
||||
when "Auto"
|
||||
smtp_client.enable_starttls_auto(self.class.ssl_context_without_verify)
|
||||
when "STARTTLS"
|
||||
smtp_client.enable_starttls(self.class.ssl_context_with_verify)
|
||||
when "TLS"
|
||||
smtp_client.enable_tls(self.class.ssl_context_with_verify)
|
||||
else
|
||||
smtp_client.disable_starttls
|
||||
smtp_client.disable_tls
|
||||
end
|
||||
|
||||
smtp_client.start(@source_ip_address ? @source_ip_address.hostname : self.class.default_helo_hostname)
|
||||
log "Connected to #{@remote_ip}:#{port} (#{hostname})"
|
||||
rescue StandardError => e
|
||||
if e.is_a?(OpenSSL::SSL::SSLError) && ssl_mode == "Auto"
|
||||
log "SSL error (#{e.message}), retrying without SSL"
|
||||
ssl_mode = nil
|
||||
retry
|
||||
end
|
||||
|
||||
log "Cannot connect to #{@remote_ip}:#{port} (#{hostname}) (#{e.class}: #{e.message})"
|
||||
@connection_errors << e.message unless @connection_errors.include?(e.message)
|
||||
begin
|
||||
smtp_client.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
smtp_client = nil
|
||||
end
|
||||
|
||||
if smtp_client
|
||||
@smtp_client = smtp_client
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@connection_errors
|
||||
end
|
||||
|
||||
def reconnect
|
||||
log "Reconnecting"
|
||||
begin
|
||||
@smtp_client&.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
start
|
||||
end
|
||||
|
||||
def safe_rset
|
||||
# Something went wrong sending the last email. Reset the connection if possible, else disconnect.
|
||||
|
||||
@smtp_client.rset
|
||||
rescue StandardError
|
||||
# Don't reconnect, this would be rather rude if we don't have any more emails to send.
|
||||
begin
|
||||
@smtp_client.finish
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def send_message(message, force_rcpt_to = nil)
|
||||
start_time = Time.now
|
||||
result = SendResult.new
|
||||
result.log_id = @log_id
|
||||
if @smtp_client && !@smtp_client.started?
|
||||
# For some reason we had an SMTP connection but it's no longer connected.
|
||||
# Make a new one.
|
||||
start
|
||||
end
|
||||
|
||||
if @smtp_client
|
||||
result.secure = @smtp_client.secure_socket?
|
||||
end
|
||||
|
||||
begin
|
||||
if message.bounce
|
||||
mail_from = ""
|
||||
elsif message.domain.return_path_status == "OK"
|
||||
mail_from = "#{message.server.token}@#{message.domain.return_path_domain}"
|
||||
else
|
||||
mail_from = "#{message.server.token}@#{Postal.config.dns.return_path}"
|
||||
end
|
||||
if Postal.config.general.use_resent_sender_header
|
||||
raw_message = "Resent-Sender: #{mail_from}\r\n" + message.raw_message
|
||||
else
|
||||
raw_message = message.raw_message
|
||||
end
|
||||
tries = 0
|
||||
begin
|
||||
if @smtp_client.nil?
|
||||
log "-> No SMTP server available for #{@domain}"
|
||||
log "-> Hostnames: #{@hostnames.inspect}"
|
||||
log "-> Errors: #{@connection_errors.inspect}"
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.details = "No SMTP servers were available for #{@domain}. Tried #{@hostnames.to_sentence}"
|
||||
result.output = @connection_errors.join(", ")
|
||||
result.connect_error = true
|
||||
return result
|
||||
else
|
||||
@smtp_client.rset_errors
|
||||
rcpt_to = force_rcpt_to || @options[:force_rcpt_to] || message.rcpt_to
|
||||
log "Sending message #{message.server.id}::#{message.id} to #{rcpt_to}"
|
||||
smtp_result = @smtp_client.send_message(raw_message, mail_from, [rcpt_to])
|
||||
end
|
||||
rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError
|
||||
raise unless (tries += 1) < 2
|
||||
|
||||
reconnect
|
||||
retry
|
||||
end
|
||||
result.type = "Sent"
|
||||
result.details = "Message for #{rcpt_to} accepted by #{destination_host_description}"
|
||||
if @smtp_client.source_address
|
||||
result.details += " (from #{@smtp_client.source_address})"
|
||||
end
|
||||
result.output = smtp_result.string
|
||||
log "Message sent ##{message.id} to #{destination_host_description} for #{rcpt_to}"
|
||||
rescue Net::SMTPServerBusy, Net::SMTPAuthenticationError, Net::SMTPSyntaxError, Net::SMTPUnknownError, Net::ReadTimeout => e
|
||||
log "#{e.class}: #{e.message}"
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.details = "Temporary SMTP delivery error when sending to #{destination_host_description}"
|
||||
result.output = e.message
|
||||
if e.to_s =~ /(\d+) seconds/
|
||||
result.retry = ::Regexp.last_match(1).to_i + 10
|
||||
elsif e.to_s =~ /(\d+) minutes/
|
||||
result.retry = (::Regexp.last_match(1).to_i * 60) + 10
|
||||
end
|
||||
|
||||
safe_rset
|
||||
rescue Net::SMTPFatalError => e
|
||||
log "#{e.class}: #{e.message}"
|
||||
result.type = "HardFail"
|
||||
result.details = "Permanent SMTP delivery error when sending to #{destination_host_description}"
|
||||
result.output = e.message
|
||||
safe_rset
|
||||
rescue StandardError => e
|
||||
log "#{e.class}: #{e.message}"
|
||||
if defined?(Sentry)
|
||||
Sentry.capture_exception(e, extra: { log_id: @log_id, server_id: message.server.id, message_id: message.id })
|
||||
end
|
||||
result.type = "SoftFail"
|
||||
result.retry = true
|
||||
result.details = "An error occurred while sending the message to #{destination_host_description}"
|
||||
result.output = e.message
|
||||
safe_rset
|
||||
end
|
||||
|
||||
result.time = (Time.now - start_time).to_f.round(2)
|
||||
result
|
||||
end
|
||||
|
||||
def finish
|
||||
log "Finishing up"
|
||||
@smtp_client&.finish
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def servers
|
||||
@options[:servers] || self.class.relay_hosts || @servers ||= begin
|
||||
mx_servers = MXLookup.lookup(@domain)
|
||||
if mx_servers.empty?
|
||||
mx_servers = [@domain] # This will be resolved to an A or AAAA record later
|
||||
end
|
||||
mx_servers
|
||||
end
|
||||
end
|
||||
|
||||
def log(text)
|
||||
Postal.logger_for(:smtp_sender).info "[#{@log_id}] #{text}"
|
||||
end
|
||||
|
||||
def destination_host_description
|
||||
"#{@hostnames.last} (#{@remote_ip})"
|
||||
end
|
||||
|
||||
def lookup_ip_address(type, hostname)
|
||||
records = []
|
||||
Resolv::DNS.open do |dns|
|
||||
dns.timeouts = [10, 5]
|
||||
case type
|
||||
when :a
|
||||
records = dns.getresources(hostname, Resolv::DNS::Resource::IN::A)
|
||||
when :aaaa
|
||||
records = dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA)
|
||||
end
|
||||
end
|
||||
records.first&.address&.to_s&.downcase
|
||||
end
|
||||
|
||||
class << self
|
||||
|
||||
def ssl_context_with_verify
|
||||
@ssl_context_with_verify ||= begin
|
||||
c = OpenSSL::SSL::SSLContext.new
|
||||
c.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
||||
c.cert_store = OpenSSL::X509::Store.new
|
||||
c.cert_store.set_default_paths
|
||||
c
|
||||
end
|
||||
end
|
||||
|
||||
def ssl_context_without_verify
|
||||
@ssl_context_without_verify ||= begin
|
||||
c = OpenSSL::SSL::SSLContext.new
|
||||
c.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
||||
c
|
||||
end
|
||||
end
|
||||
|
||||
def default_helo_hostname
|
||||
Postal.config.dns.helo_hostname || Postal.config.dns.smtp_server_hostname || "localhost"
|
||||
end
|
||||
|
||||
def relay_hosts
|
||||
hosts = Postal.config.smtp_relays.map do |relay|
|
||||
next unless relay.hostname.present?
|
||||
|
||||
{
|
||||
hostname: relay.hostname,
|
||||
port: relay.port,
|
||||
ssl_mode: relay.ssl_mode
|
||||
}
|
||||
end.compact
|
||||
hosts.empty? ? nil : hosts
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
module SMTPServer
|
||||
end
|
||||
end
|
||||
@@ -1,508 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "resolv"
|
||||
require "nifty/utils/random_string"
|
||||
|
||||
module Postal
|
||||
module SMTPServer
|
||||
class Client
|
||||
|
||||
CRAM_MD5_DIGEST = OpenSSL::Digest.new("md5")
|
||||
LOG_REDACTION_STRING = "[redacted]"
|
||||
|
||||
attr_reader :logging_enabled
|
||||
attr_reader :credential
|
||||
attr_reader :ip_address
|
||||
attr_reader :recipients
|
||||
attr_reader :headers
|
||||
attr_reader :state
|
||||
attr_reader :helo_name
|
||||
|
||||
def initialize(ip_address)
|
||||
@logging_enabled = true
|
||||
@ip_address = ip_address
|
||||
if @ip_address
|
||||
check_ip_address
|
||||
@state = :welcome
|
||||
else
|
||||
@state = :preauth
|
||||
end
|
||||
transaction_reset
|
||||
end
|
||||
|
||||
def check_ip_address
|
||||
return unless @ip_address && Postal.config.smtp_server.log_exclude_ips && @ip_address =~ Regexp.new(Postal.config.smtp_server.log_exclude_ips)
|
||||
|
||||
@logging_enabled = false
|
||||
end
|
||||
|
||||
def transaction_reset
|
||||
@recipients = []
|
||||
@mail_from = nil
|
||||
@data = nil
|
||||
@headers = nil
|
||||
end
|
||||
|
||||
def id
|
||||
@id ||= Nifty::Utils::RandomString.generate(length: 6).upcase
|
||||
end
|
||||
|
||||
def handle(data)
|
||||
if @state == :preauth
|
||||
return proxy(data)
|
||||
end
|
||||
|
||||
log "\e[32m<= #{sanitize_input_for_log(data.strip)}\e[0m"
|
||||
if @proc
|
||||
@proc.call(data)
|
||||
|
||||
else
|
||||
handle_command(data)
|
||||
end
|
||||
end
|
||||
|
||||
def finished?
|
||||
@finished || false
|
||||
end
|
||||
|
||||
def start_tls?
|
||||
@start_tls || false
|
||||
end
|
||||
|
||||
attr_writer :start_tls
|
||||
|
||||
def handle_command(data)
|
||||
case data
|
||||
when /^QUIT/i then quit
|
||||
when /^STARTTLS/i then starttls
|
||||
when /^EHLO/i then ehlo(data)
|
||||
when /^HELO/i then helo(data)
|
||||
when /^RSET/i then rset
|
||||
when /^NOOP/i then noop
|
||||
when /^AUTH PLAIN/i then auth_plain(data)
|
||||
when /^AUTH LOGIN/i then auth_login(data)
|
||||
when /^AUTH CRAM-MD5/i then auth_cram_md5(data)
|
||||
when /^MAIL FROM/i then mail_from(data)
|
||||
when /^RCPT TO/i then rcpt_to(data)
|
||||
when /^DATA/i then data(data)
|
||||
else
|
||||
"502 Invalid/unsupported command"
|
||||
end
|
||||
end
|
||||
|
||||
def log(text)
|
||||
return false unless @logging_enabled
|
||||
|
||||
Postal.logger_for(:smtp_server).debug "[#{id}] #{text}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def proxy(data)
|
||||
if m = data.match(/\APROXY (.+) (.+) (.+) (.+) (.+)\z/)
|
||||
@ip_address = m[2]
|
||||
check_ip_address
|
||||
@state = :welcome
|
||||
log "\e[35m Client identified as #{@ip_address}\e[0m"
|
||||
"220 #{Postal.config.dns.smtp_server_hostname} ESMTP Postal/#{id}"
|
||||
else
|
||||
@finished = true
|
||||
"502 Proxy Error"
|
||||
end
|
||||
end
|
||||
|
||||
def quit
|
||||
@finished = true
|
||||
"221 Closing Connection"
|
||||
end
|
||||
|
||||
def starttls
|
||||
if Postal.config.smtp_server.tls_enabled?
|
||||
@start_tls = true
|
||||
@tls = true
|
||||
"220 Ready to start TLS"
|
||||
else
|
||||
"502 TLS not available"
|
||||
end
|
||||
end
|
||||
|
||||
def ehlo(data)
|
||||
@helo_name = data.strip.split(" ", 2)[1]
|
||||
transaction_reset
|
||||
@state = :welcomed
|
||||
[
|
||||
"250-My capabilities are",
|
||||
Postal.config.smtp_server.tls_enabled? && !@tls ? "250-STARTTLS" : nil,
|
||||
"250 AUTH CRAM-MD5 PLAIN LOGIN"
|
||||
].compact
|
||||
end
|
||||
|
||||
def helo(data)
|
||||
@helo_name = data.strip.split(" ", 2)[1]
|
||||
transaction_reset
|
||||
@state = :welcomed
|
||||
"250 #{Postal.config.dns.smtp_server_hostname}"
|
||||
end
|
||||
|
||||
def rset
|
||||
transaction_reset
|
||||
@state = :welcomed
|
||||
"250 OK"
|
||||
end
|
||||
|
||||
def noop
|
||||
"250 OK"
|
||||
end
|
||||
|
||||
def auth_plain(data)
|
||||
handler = proc do |idata|
|
||||
@proc = nil
|
||||
idata = Base64.decode64(idata)
|
||||
parts = idata.split("\0")
|
||||
username = parts[-2]
|
||||
password = parts[-1]
|
||||
unless username && password
|
||||
next "535 Authenticated failed - protocol error"
|
||||
end
|
||||
|
||||
authenticate(password)
|
||||
end
|
||||
|
||||
data = data.gsub(/AUTH PLAIN ?/i, "")
|
||||
if data.strip == ""
|
||||
@proc = handler
|
||||
@password_expected_next = true
|
||||
"334"
|
||||
else
|
||||
handler.call(data)
|
||||
end
|
||||
end
|
||||
|
||||
def auth_login(data)
|
||||
password_handler = proc do |idata|
|
||||
@proc = nil
|
||||
password = Base64.decode64(idata)
|
||||
authenticate(password)
|
||||
end
|
||||
|
||||
username_handler = proc do
|
||||
@proc = password_handler
|
||||
@password_expected_next = true
|
||||
"334 UGFzc3dvcmQ6" # "Password:"
|
||||
end
|
||||
|
||||
data = data.gsub(/AUTH LOGIN ?/i, "")
|
||||
if data.strip == ""
|
||||
@proc = username_handler
|
||||
"334 VXNlcm5hbWU6" # "Username:"
|
||||
else
|
||||
username_handler.call(nil)
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate(password)
|
||||
if @credential = Credential.where(type: "SMTP", key: password).first
|
||||
@credential.use
|
||||
"235 Granted for #{@credential.server.organization.permalink}/#{@credential.server.permalink}"
|
||||
else
|
||||
log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m"
|
||||
"535 Invalid credential"
|
||||
end
|
||||
end
|
||||
|
||||
def auth_cram_md5(data)
|
||||
challenge = Digest::SHA1.hexdigest(Time.now.to_i.to_s + rand(100_000).to_s)
|
||||
challenge = "<#{challenge[0, 20]}@#{Postal.config.dns.smtp_server_hostname}>"
|
||||
|
||||
handler = proc do |idata|
|
||||
@proc = nil
|
||||
username, password = Base64.decode64(idata).split(" ", 2).map { |a| a.chomp }
|
||||
org_permlink, server_permalink = username.split(/[\/_]/, 2)
|
||||
server = ::Server.includes(:organization).where(organizations: { permalink: org_permlink }, permalink: server_permalink).first
|
||||
if server.nil?
|
||||
log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m"
|
||||
next "535 Denied"
|
||||
end
|
||||
|
||||
grant = nil
|
||||
server.credentials.where(type: "SMTP").each do |credential|
|
||||
correct_response = OpenSSL::HMAC.hexdigest(CRAM_MD5_DIGEST, credential.key, challenge)
|
||||
next unless password == correct_response
|
||||
|
||||
@credential = credential
|
||||
@credential.use
|
||||
grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}"
|
||||
break
|
||||
end
|
||||
|
||||
if grant.nil?
|
||||
log "\e[33m WARN: AUTH failure for #{@ip_address}\e[0m"
|
||||
next "535 Denied"
|
||||
end
|
||||
|
||||
grant
|
||||
end
|
||||
|
||||
@proc = handler
|
||||
"334 " + Base64.encode64(challenge).gsub(/[\r\n]/, "")
|
||||
end
|
||||
|
||||
def mail_from(data)
|
||||
unless in_state(:welcomed, :mail_from_received)
|
||||
return "503 EHLO/HELO first please"
|
||||
end
|
||||
|
||||
@state = :mail_from_received
|
||||
transaction_reset
|
||||
if data =~ /AUTH=/
|
||||
# Discard AUTH= parameter and anything that follows.
|
||||
# We don't need this parameter as we don't trust any client to set it
|
||||
mail_from_line = data.sub(/ *AUTH=.*/, "")
|
||||
else
|
||||
mail_from_line = data
|
||||
end
|
||||
@mail_from = mail_from_line.gsub(/MAIL FROM\s*:\s*/i, "").gsub(/.*</, "").gsub(/>.*/, "").strip
|
||||
"250 OK"
|
||||
end
|
||||
|
||||
def rcpt_to(data)
|
||||
unless in_state(:mail_from_received, :rcpt_to_received)
|
||||
return "503 EHLO/HELO and MAIL FROM first please"
|
||||
end
|
||||
|
||||
rcpt_to = data.gsub(/RCPT TO\s*:\s*/i, "").gsub(/.*</, "").gsub(/>.*/, "").strip
|
||||
|
||||
if rcpt_to.blank?
|
||||
return "501 RCPT TO should not be empty"
|
||||
end
|
||||
|
||||
uname, domain = rcpt_to.split("@", 2)
|
||||
|
||||
if domain.blank?
|
||||
return "501 Invalid RCPT TO"
|
||||
end
|
||||
|
||||
uname, tag = uname.split("+", 2)
|
||||
|
||||
if domain == Postal.config.dns.return_path || domain =~ /\A#{Regexp.escape(Postal.config.dns.custom_return_path_prefix)}\./
|
||||
# This is a return path
|
||||
@state = :rcpt_to_received
|
||||
if server = ::Server.where(token: uname).first
|
||||
if server.suspended?
|
||||
"535 Mail server has been suspended"
|
||||
else
|
||||
log "Added bounce on server #{server.id}"
|
||||
@recipients << [:bounce, rcpt_to, server]
|
||||
"250 OK"
|
||||
end
|
||||
else
|
||||
"550 Invalid server token"
|
||||
end
|
||||
|
||||
elsif domain == Postal.config.dns.route_domain
|
||||
# This is an email direct to a route. This isn't actually supported yet.
|
||||
@state = :rcpt_to_received
|
||||
if route = Route.where(token: uname).first
|
||||
if route.server.suspended?
|
||||
"535 Mail server has been suspended"
|
||||
elsif route.mode == "Reject"
|
||||
"550 Route does not accept incoming messages"
|
||||
else
|
||||
log "Added route #{route.id} to recipients (tag: #{tag.inspect})"
|
||||
actual_rcpt_to = "#{route.name}#{tag ? "+#{tag}" : ''}@#{route.domain.name}"
|
||||
@recipients << [:route, actual_rcpt_to, route.server, { route: route }]
|
||||
"250 OK"
|
||||
end
|
||||
else
|
||||
"550 Invalid route token"
|
||||
end
|
||||
|
||||
elsif @credential
|
||||
# This is outgoing mail for an authenticated user
|
||||
@state = :rcpt_to_received
|
||||
if @credential.server.suspended?
|
||||
"535 Mail server has been suspended"
|
||||
else
|
||||
log "Added external address '#{rcpt_to}'"
|
||||
@recipients << [:credential, rcpt_to, @credential.server]
|
||||
"250 OK"
|
||||
end
|
||||
|
||||
elsif uname && domain && route = Route.find_by_name_and_domain(uname, domain)
|
||||
# This is incoming mail for a route
|
||||
@state = :rcpt_to_received
|
||||
if route.server.suspended?
|
||||
"535 Mail server has been suspended"
|
||||
elsif route.mode == "Reject"
|
||||
"550 Route does not accept incoming messages"
|
||||
else
|
||||
log "Added route #{route.id} to recipients (tag: #{tag.inspect})"
|
||||
@recipients << [:route, rcpt_to, route.server, { route: route }]
|
||||
"250 OK"
|
||||
end
|
||||
|
||||
else
|
||||
# User is trying to relay but is not authenticated. Try to authenticate by IP address
|
||||
@credential = Credential.where(type: "SMTP-IP").all.sort_by { |c| c.ipaddr&.prefix || 0 }.reverse.find do |credential|
|
||||
credential.ipaddr.include?(@ip_address) || (credential.ipaddr.ipv4? && credential.ipaddr.ipv4_mapped.include?(@ip_address))
|
||||
end
|
||||
|
||||
if @credential
|
||||
# Retry with credential
|
||||
@credential.use
|
||||
rcpt_to(data)
|
||||
else
|
||||
"530 Authentication required"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def data(_data)
|
||||
unless in_state(:rcpt_to_received)
|
||||
return "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data"
|
||||
end
|
||||
|
||||
@data = String.new.force_encoding("BINARY")
|
||||
@headers = {}
|
||||
@receiving_headers = true
|
||||
|
||||
received_header = Postal::ReceivedHeader.generate(@credential&.server, @helo_name, @ip_address, :smtp)
|
||||
.force_encoding("BINARY")
|
||||
|
||||
@data << "Received: #{received_header}\r\n"
|
||||
@headers["received"] = [received_header]
|
||||
|
||||
handler = proc do |idata|
|
||||
if idata == "."
|
||||
@logging_enabled = true
|
||||
@proc = nil
|
||||
finished
|
||||
else
|
||||
idata = idata.to_s.sub(/\A\.\./, ".")
|
||||
|
||||
if @credential&.server&.log_smtp_data?
|
||||
# We want to log if enabled
|
||||
else
|
||||
log "Not logging further message data."
|
||||
@logging_enabled = false
|
||||
end
|
||||
|
||||
if @receiving_headers
|
||||
if idata.blank?
|
||||
@receiving_headers = false
|
||||
elsif idata.to_s =~ /^\s/
|
||||
# This is a continuation of a header
|
||||
if @header_key && @headers[@header_key.downcase] && @headers[@header_key.downcase].last
|
||||
@headers[@header_key.downcase].last << idata.to_s
|
||||
end
|
||||
else
|
||||
@header_key, value = idata.split(/:\s*/, 2)
|
||||
@headers[@header_key.downcase] ||= []
|
||||
@headers[@header_key.downcase] << value
|
||||
end
|
||||
end
|
||||
@data << idata
|
||||
@data << "\r\n"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@proc = handler
|
||||
"354 Go ahead"
|
||||
end
|
||||
|
||||
def finished
|
||||
if @data.bytesize > Postal.config.smtp_server.max_message_size.megabytes.to_i
|
||||
transaction_reset
|
||||
@state = :welcomed
|
||||
return format("552 Message too large (maximum size %dMB)", Postal.config.smtp_server.max_message_size)
|
||||
end
|
||||
|
||||
if @headers["received"].grep(/by #{Postal.config.dns.smtp_server_hostname}/).count > 4
|
||||
transaction_reset
|
||||
@state = :welcomed
|
||||
return "550 Loop detected"
|
||||
end
|
||||
|
||||
authenticated_domain = nil
|
||||
if @credential
|
||||
authenticated_domain = @credential.server.find_authenticated_domain_from_headers(@headers)
|
||||
if authenticated_domain.nil?
|
||||
transaction_reset
|
||||
@state = :welcomed
|
||||
return "530 From/Sender name is not valid"
|
||||
end
|
||||
end
|
||||
|
||||
@recipients.each do |recipient|
|
||||
type, rcpt_to, server, options = recipient
|
||||
|
||||
case type
|
||||
when :credential
|
||||
# Outgoing messages are just inserted
|
||||
message = server.message_db.new_message
|
||||
message.rcpt_to = rcpt_to
|
||||
message.mail_from = @mail_from
|
||||
message.raw_message = @data
|
||||
message.received_with_ssl = @tls
|
||||
message.scope = "outgoing"
|
||||
message.domain_id = authenticated_domain&.id
|
||||
message.credential_id = @credential.id
|
||||
message.save
|
||||
|
||||
when :bounce
|
||||
if rp_route = server.routes.where(name: "__returnpath__").first
|
||||
# If there's a return path route, we can use this to create the message
|
||||
rp_route.create_messages do |msg|
|
||||
msg.rcpt_to = rcpt_to
|
||||
msg.mail_from = @mail_from
|
||||
msg.raw_message = @data
|
||||
msg.received_with_ssl = @tls
|
||||
msg.bounce = 1
|
||||
end
|
||||
else
|
||||
# There's no return path route, we just need to insert the mesage
|
||||
# without going through the route.
|
||||
message = server.message_db.new_message
|
||||
message.rcpt_to = rcpt_to
|
||||
message.mail_from = @mail_from
|
||||
message.raw_message = @data
|
||||
message.received_with_ssl = @tls
|
||||
message.scope = "incoming"
|
||||
message.bounce = 1
|
||||
message.save
|
||||
end
|
||||
when :route
|
||||
options[:route].create_messages do |message|
|
||||
message.rcpt_to = rcpt_to
|
||||
message.mail_from = @mail_from
|
||||
message.raw_message = @data
|
||||
message.received_with_ssl = @tls
|
||||
end
|
||||
end
|
||||
end
|
||||
transaction_reset
|
||||
@state = :welcomed
|
||||
"250 OK"
|
||||
end
|
||||
|
||||
def in_state(*states)
|
||||
states.include?(@state)
|
||||
end
|
||||
|
||||
def sanitize_input_for_log(data)
|
||||
if @password_expected_next
|
||||
@password_expected_next = false
|
||||
if data =~ /\A[a-z0-9]{3,}=*\z/i
|
||||
return LOG_REDACTION_STRING
|
||||
end
|
||||
end
|
||||
|
||||
data = data.dup
|
||||
data.gsub!(/(.*AUTH \w+) (.*)\z/i) { "#{::Regexp.last_match(1)} #{LOG_REDACTION_STRING}" }
|
||||
data
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,272 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "ipaddr"
|
||||
require "nio"
|
||||
|
||||
module Postal
|
||||
module SMTPServer
|
||||
class Server
|
||||
|
||||
def initialize(options = {})
|
||||
@options = options
|
||||
@options[:debug] ||= false
|
||||
prepare_environment
|
||||
end
|
||||
|
||||
def run
|
||||
listen
|
||||
run_event_loop
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_environment
|
||||
$\ = "\r\n"
|
||||
BasicSocket.do_not_reverse_lookup = true
|
||||
|
||||
trap("TERM") do
|
||||
$stdout.puts "Received TERM signal, shutting down."
|
||||
unlisten
|
||||
end
|
||||
|
||||
trap("INT") do
|
||||
$stdout.puts "Received INT signal, shutting down."
|
||||
unlisten
|
||||
end
|
||||
end
|
||||
|
||||
def ssl_context
|
||||
@ssl_context ||= begin
|
||||
ssl_context = OpenSSL::SSL::SSLContext.new
|
||||
ssl_context.cert = Postal.smtp_certificates[0]
|
||||
ssl_context.extra_chain_cert = Postal.smtp_certificates[1..]
|
||||
ssl_context.key = Postal.smtp_private_key
|
||||
ssl_context.ssl_version = Postal.config.smtp_server.ssl_version if Postal.config.smtp_server.ssl_version
|
||||
ssl_context.ciphers = Postal.config.smtp_server.tls_ciphers if Postal.config.smtp_server.tls_ciphers
|
||||
ssl_context
|
||||
end
|
||||
end
|
||||
|
||||
def listen
|
||||
@server = TCPServer.open(Postal.config.smtp_server.bind_address, Postal.config.smtp_server.port)
|
||||
@server.autoclose = false
|
||||
@server.close_on_exec = false
|
||||
if defined?(Socket::SOL_SOCKET) && defined?(Socket::SO_KEEPALIVE)
|
||||
@server.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
||||
end
|
||||
if defined?(Socket::SOL_TCP) && defined?(Socket::TCP_KEEPIDLE) && defined?(Socket::TCP_KEEPINTVL) && defined?(Socket::TCP_KEEPCNT)
|
||||
@server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 50)
|
||||
@server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
|
||||
@server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 5)
|
||||
end
|
||||
logger.info "Listening on #{Postal.config.smtp_server.bind_address}:#{Postal.config.smtp_server.port}"
|
||||
end
|
||||
|
||||
def unlisten
|
||||
# Instruct the nio loop to unlisten and wake it
|
||||
@unlisten = true
|
||||
@io_selector.wakeup
|
||||
end
|
||||
|
||||
def run_event_loop
|
||||
# Set up an instance of nio4r to monitor for connections and data
|
||||
@io_selector = NIO::Selector.new
|
||||
# Register the SMTP listener
|
||||
@io_selector.register(@server, :r)
|
||||
# Create a hash to contain a buffer for each client.
|
||||
buffers = Hash.new { |h, k| h[k] = String.new.force_encoding("BINARY") }
|
||||
loop do
|
||||
# Wait for an event to occur
|
||||
@io_selector.select do |monitor|
|
||||
# Get the IO from the nio monitor
|
||||
io = monitor.io
|
||||
# Is this event an incoming connection?
|
||||
if io.is_a?(TCPServer)
|
||||
begin
|
||||
# Accept the connection
|
||||
new_io = io.accept
|
||||
if Postal.config.smtp_server.proxy_protocol
|
||||
# If we are using the haproxy proxy protocol, we will be sent the
|
||||
# client's IP later. Delay the welcome process.
|
||||
client = Client.new(nil)
|
||||
if Postal.config.smtp_server.log_connect
|
||||
logger.debug "[#{client.id}] \e[35m Connection opened from #{new_io.remote_address.ip_address}\e[0m"
|
||||
end
|
||||
else
|
||||
# We're not using the proxy protocol so we already know the client's IP
|
||||
client = Client.new(new_io.remote_address.ip_address)
|
||||
if Postal.config.smtp_server.log_connect
|
||||
logger.debug "[#{client.id}] \e[35m Connection opened from #{new_io.remote_address.ip_address}\e[0m"
|
||||
end
|
||||
# We know who the client is, welcome them.
|
||||
client.log "\e[35m Client identified as #{new_io.remote_address.ip_address}\e[0m"
|
||||
new_io.print("220 #{Postal.config.dns.smtp_server_hostname} ESMTP Postal/#{client.id}")
|
||||
end
|
||||
# Register the client and its socket with nio4r
|
||||
monitor = @io_selector.register(new_io, :r)
|
||||
monitor.value = client
|
||||
rescue StandardError => e
|
||||
# If something goes wrong, log as appropriate and disconnect the client
|
||||
if defined?(Sentry)
|
||||
Sentry.capture_exception(e, extra: { log_id: begin
|
||||
client.id
|
||||
rescue StandardError
|
||||
nil
|
||||
end })
|
||||
end
|
||||
logger.error "An error occurred while accepting a new client."
|
||||
logger.error "#{e.class}: #{e.message}"
|
||||
e.backtrace.each do |line|
|
||||
logger.error line
|
||||
end
|
||||
begin
|
||||
new_io.close
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
else
|
||||
# This event is not an incoming connection so it must be data from a client
|
||||
begin
|
||||
# Get the client from the nio monitor
|
||||
client = monitor.value
|
||||
# For now we assume the connection isn't closed
|
||||
eof = false
|
||||
# Is the client negotiating a TLS handshake?
|
||||
if client.start_tls?
|
||||
begin
|
||||
# Can we accept the TLS connection at this time?
|
||||
io.accept_nonblock
|
||||
# We were able to accept the connection, the client is no longer handshaking
|
||||
client.start_tls = false
|
||||
rescue IO::WaitReadable, IO::WaitWritable => e
|
||||
# Could not accept without blocking
|
||||
# We will try again later
|
||||
next
|
||||
rescue OpenSSL::SSL::SSLError => e
|
||||
client.log "SSL Negotiation Failed: #{e.message}"
|
||||
eof = true
|
||||
end
|
||||
else
|
||||
# The client is not negotiating a TLS handshake at this time
|
||||
begin
|
||||
# Read 10kiB of data at a time from the socket.
|
||||
buffers[io] << io.readpartial(10_240)
|
||||
|
||||
# There is an extra step for SSL sockets
|
||||
if io.is_a?(OpenSSL::SSL::SSLSocket)
|
||||
buffers[io] << io.readpartial(10_240) while io.pending.positive?
|
||||
end
|
||||
rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT
|
||||
# Client went away
|
||||
eof = true
|
||||
end
|
||||
|
||||
# Normalize all \r\n and \n to \r\n, but ignore only \r.
|
||||
# A \r\n may be split in 2 buffers (\n in one buffer and \r in the other)
|
||||
buffers[io] = buffers[io].gsub(/\r/, "").encode(buffers[io].encoding, crlf_newline: true)
|
||||
|
||||
# We line buffer, so look to see if we have received a newline
|
||||
# and keep doing so until all buffered lines have been processed.
|
||||
while buffers[io].index("\r\n")
|
||||
# Extract the line
|
||||
line, buffers[io] = buffers[io].split("\r\n", 2)
|
||||
|
||||
# Send the received line to the client object for processing
|
||||
result = client.handle(line)
|
||||
# If the client object returned some data, write it back to the client
|
||||
next if result.nil?
|
||||
|
||||
result = [result] unless result.is_a?(Array)
|
||||
result.compact.each do |iline|
|
||||
client.log "\e[34m=> #{iline.strip}\e[0m"
|
||||
begin
|
||||
io.write(iline.to_s + "\r\n")
|
||||
io.flush
|
||||
rescue Errno::ECONNRESET
|
||||
# Client disconnected before we could write response
|
||||
eof = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Did the client request STARTTLS?
|
||||
if !eof && client.start_tls?
|
||||
# Deregister the unencrypted IO
|
||||
@io_selector.deregister(io)
|
||||
buffers.delete(io)
|
||||
io = OpenSSL::SSL::SSLSocket.new(io, ssl_context)
|
||||
# Close the underlying IO when the TLS socket is closed
|
||||
io.sync_close = true
|
||||
# Register the new TLS socket with nio
|
||||
monitor = @io_selector.register(io, :r)
|
||||
monitor.value = client
|
||||
end
|
||||
end
|
||||
|
||||
# Has the client requested we close the connection?
|
||||
if client.finished? || eof
|
||||
client.log "\e[35m Connection closed\e[0m"
|
||||
# Deregister the socket and close it
|
||||
@io_selector.deregister(io)
|
||||
buffers.delete(io)
|
||||
io.close
|
||||
# If we have no more clients or listeners left, exit the process
|
||||
if @io_selector.empty?
|
||||
Process.exit(0)
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
# Something went wrong, log as appropriate
|
||||
client_id = client ? client.id : "------"
|
||||
if defined?(Sentry)
|
||||
Sentry.capture_exception(e, extra: { log_id: begin
|
||||
client.id
|
||||
rescue StandardError
|
||||
nil
|
||||
end })
|
||||
end
|
||||
logger.error "[#{client_id}] An error occurred while processing data from a client."
|
||||
logger.error "[#{client_id}] #{e.class}: #{e.message}"
|
||||
e.backtrace.each do |iline|
|
||||
logger.error "[#{client_id}] #{iline}"
|
||||
end
|
||||
# Close all IO and forget this client
|
||||
begin
|
||||
@io_selector.deregister(io)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
buffers.delete(io)
|
||||
begin
|
||||
io.close
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
if @io_selector.empty?
|
||||
Process.exit(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# If unlisten has been called, stop listening
|
||||
next unless @unlisten
|
||||
|
||||
@io_selector.deregister(@server)
|
||||
@server.close
|
||||
# If there's nothing left to do, shut down the process
|
||||
if @io_selector.empty?
|
||||
Process.exit(0)
|
||||
end
|
||||
# Clear the request
|
||||
@unlisten = false
|
||||
end
|
||||
end
|
||||
|
||||
def logger
|
||||
Postal.logger_for(:smtp_server)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,119 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class TrackingMiddleware
|
||||
|
||||
TRACKING_PIXEL = File.read(Rails.root.join("app", "assets", "images", "tracking_pixel.png"))
|
||||
|
||||
def initialize(app = nil)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
unless env["HTTP_X_POSTAL_TRACK_HOST"].to_i == 1
|
||||
return @app.call(env)
|
||||
end
|
||||
|
||||
request = Rack::Request.new(env)
|
||||
|
||||
case request.path
|
||||
when /\A\/img\/([a-z0-9-]+)\/([a-z0-9-]+)/i
|
||||
server_token = ::Regexp.last_match(1)
|
||||
message_token = ::Regexp.last_match(2)
|
||||
dispatch_image_request(request, server_token, message_token)
|
||||
when /\A\/([a-z0-9-]+)\/([a-z0-9-]+)/i
|
||||
server_token = ::Regexp.last_match(1)
|
||||
link_token = ::Regexp.last_match(2)
|
||||
dispatch_redirect_request(request, server_token, link_token)
|
||||
else
|
||||
[200, {}, ["Hello."]]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_image_request(request, server_token, message_token)
|
||||
message_db = get_message_db_from_server_token(server_token)
|
||||
if message_db.nil?
|
||||
return [404, {}, ["Invalid Server Token"]]
|
||||
end
|
||||
|
||||
begin
|
||||
message = message_db.message(token: message_token)
|
||||
message.create_load(request)
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
# This message has been removed, we'll just continue to serve the image
|
||||
rescue StandardError => e
|
||||
# Somethign else went wrong. We don't want to stop the image loading though because
|
||||
# this is our problem. Log this exception though.
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
end
|
||||
|
||||
source_image = request.params["src"]
|
||||
case source_image
|
||||
when nil
|
||||
headers = {}
|
||||
headers["Content-Type"] = "image/png"
|
||||
headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s
|
||||
[200, headers, [TRACKING_PIXEL]]
|
||||
when /\Ahttps?:\/\//
|
||||
response = Postal::HTTP.get(source_image, timeout: 3)
|
||||
return [404, {}, ["Not found"]] unless response[:code] == 200
|
||||
|
||||
headers = {}
|
||||
headers["Content-Type"] = response[:headers]["content-type"]&.first
|
||||
headers["Last-Modified"] = response[:headers]["last-modified"]&.first
|
||||
headers["Cache-Control"] = response[:headers]["cache-control"]&.first
|
||||
headers["Etag"] = response[:headers]["etag"]&.first
|
||||
headers["Content-Length"] = response[:body].bytesize.to_s
|
||||
[200, headers, [response[:body]]]
|
||||
|
||||
else
|
||||
[400, {}, ["Invalid/missing source image"]]
|
||||
end
|
||||
end
|
||||
|
||||
def dispatch_redirect_request(request, server_token, link_token)
|
||||
message_db = get_message_db_from_server_token(server_token)
|
||||
if message_db.nil?
|
||||
return [404, {}, ["Invalid Server Token"]]
|
||||
end
|
||||
|
||||
link = message_db.select(:links, where: { token: link_token }, limit: 1).first
|
||||
if link.nil?
|
||||
return [404, {}, ["Link not found"]]
|
||||
end
|
||||
|
||||
time = Time.now.to_f
|
||||
if link["message_id"]
|
||||
message_db.update(:messages, { clicked: time }, where: { id: link["message_id"] })
|
||||
message_db.insert(:clicks, {
|
||||
message_id: link["message_id"],
|
||||
link_id: link["id"],
|
||||
ip_address: request.ip,
|
||||
user_agent: request.user_agent,
|
||||
timestamp: time
|
||||
})
|
||||
SendWebhookJob.queue(:main,
|
||||
server_id: message_db.server_id,
|
||||
event: "MessageLinkClicked",
|
||||
payload: {
|
||||
_message: link["message_id"],
|
||||
url: link["url"],
|
||||
token: link["token"],
|
||||
ip_address: request.ip,
|
||||
user_agent: request.user_agent
|
||||
})
|
||||
end
|
||||
|
||||
[307, { "Location" => link["url"] }, ["Redirected to: #{link['url']}"]]
|
||||
end
|
||||
|
||||
def get_message_db_from_server_token(token)
|
||||
return unless server = ::Server.find_by_token(token)
|
||||
|
||||
server.message_db
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,34 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "highline"
|
||||
|
||||
module Postal
|
||||
module UserCreator
|
||||
|
||||
def self.start(&block)
|
||||
cli = HighLine.new
|
||||
puts "\e[32mPostal User Creator\e[0m"
|
||||
puts "Enter the information required to create a new Postal user."
|
||||
puts "This tool is usually only used to create your initial admin user."
|
||||
puts
|
||||
user = User.new
|
||||
user.email_address = cli.ask("E-Mail Address".ljust(20, " ") + ": ")
|
||||
user.first_name = cli.ask("First Name".ljust(20, " ") + ": ")
|
||||
user.last_name = cli.ask("Last Name".ljust(20, " ") + ": ")
|
||||
user.password = cli.ask("Initial Password".ljust(20, " ") + ": ") { |value| value.echo = "*" }
|
||||
|
||||
block.call(user) if block_given?
|
||||
puts
|
||||
if user.save
|
||||
puts "User has been created with e-mail address \e[32m#{user.email_address}\e[0m"
|
||||
else
|
||||
puts "\e[31mFailed to create user\e[0m"
|
||||
user.errors.full_messages.each do |error|
|
||||
puts " * #{error}"
|
||||
end
|
||||
end
|
||||
puts
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -1,220 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Postal
|
||||
class Worker
|
||||
|
||||
def initialize(queues)
|
||||
@initial_queues = queues
|
||||
@active_queues = {}
|
||||
@process_name = $0
|
||||
@running_jobs = []
|
||||
end
|
||||
|
||||
def work
|
||||
logger.info "Worker running with #{Postal.config.workers.threads} threads"
|
||||
|
||||
Signal.trap("INT") do
|
||||
@exit = true
|
||||
set_process_name
|
||||
end
|
||||
Signal.trap("TERM") do
|
||||
@exit = true
|
||||
set_process_name
|
||||
end
|
||||
|
||||
self.class.job_channel.prefetch(Postal.config.workers.threads)
|
||||
@initial_queues.each { |queue| join_queue(queue) }
|
||||
|
||||
exit_checks = 0
|
||||
loop do
|
||||
if @exit && @running_jobs.empty?
|
||||
logger.info "Exiting immediately because no jobs running"
|
||||
exit 0
|
||||
elsif @exit
|
||||
if exit_checks >= 60
|
||||
logger.info "Job did not finish in a timely manner. Exiting"
|
||||
exit 0
|
||||
end
|
||||
if exit_checks.zero?
|
||||
logger.info "Exit requested but job is running. Waiting for job to finish."
|
||||
end
|
||||
sleep 60
|
||||
exit_checks += 1
|
||||
else
|
||||
manage_ip_queues
|
||||
sleep 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def receive_job(delivery_info, properties, body)
|
||||
message = begin
|
||||
JSON.parse(body)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
if message && message["class_name"]
|
||||
@running_jobs << message["id"]
|
||||
set_process_name
|
||||
start_time = Time.now
|
||||
Thread.current[:job_id] = message["id"]
|
||||
logger.info "[#{message['id']}] Started processing \e[34m#{message['class_name']}\e[0m job"
|
||||
begin
|
||||
klass = message["class_name"].constantize.new(message["id"], message["params"])
|
||||
klass.perform
|
||||
GC.start
|
||||
rescue StandardError => e
|
||||
klass.on_error(e) if defined?(klass)
|
||||
logger.warn "[#{message['id']}] \e[31m#{e.class}: #{e.message}\e[0m"
|
||||
e.backtrace.each do |line|
|
||||
logger.warn "[#{message['id']}] " + line
|
||||
end
|
||||
if defined?(Sentry)
|
||||
Sentry.capture_exception(e, extra: { job_id: message["id"] })
|
||||
end
|
||||
ensure
|
||||
logger.info "[#{message['id']}] Finished processing \e[34m#{message['class_name']}\e[0m job in #{Time.now - start_time}s"
|
||||
end
|
||||
end
|
||||
ensure
|
||||
Thread.current[:job_id] = nil
|
||||
self.class.job_channel.ack(delivery_info.delivery_tag)
|
||||
@running_jobs.delete(message["id"]) if message["id"]
|
||||
set_process_name
|
||||
|
||||
if @exit && @running_jobs.empty?
|
||||
logger.info "Exiting because all jobs have finished."
|
||||
exit 0
|
||||
end
|
||||
end
|
||||
|
||||
def join_queue(queue)
|
||||
if @active_queues[queue]
|
||||
logger.info "Attempted to join queue #{queue} but already joined."
|
||||
else
|
||||
consumer = self.class.job_queue(queue).subscribe(manual_ack: true) do |delivery_info, properties, body|
|
||||
receive_job(delivery_info, properties, body)
|
||||
end
|
||||
@active_queues[queue] = consumer
|
||||
logger.info "Joined \e[32m#{queue}\e[0m queue"
|
||||
end
|
||||
end
|
||||
|
||||
def leave_queue(queue)
|
||||
if consumer = @active_queues[queue]
|
||||
consumer.cancel
|
||||
@active_queues.delete(queue)
|
||||
logger.info "Left \e[32m#{queue}\e[0m queue"
|
||||
else
|
||||
logger.info "Not joined #{queue} so cannot leave"
|
||||
end
|
||||
end
|
||||
|
||||
def manage_ip_queues
|
||||
@ip_queues ||= []
|
||||
@ip_to_id_mapping ||= {}
|
||||
@unassigned_ips ||= []
|
||||
@pairs ||= {}
|
||||
@counter ||= 0
|
||||
|
||||
if @counter >= 15
|
||||
@ip_to_id_mapping = {}
|
||||
@unassigned_ips = []
|
||||
@counter = 0
|
||||
else
|
||||
@counter += 1
|
||||
end
|
||||
|
||||
# Get all IP addresses on the system
|
||||
current_ip_addresses = Socket.ip_address_list.map(&:ip_address)
|
||||
|
||||
# Map them to an actual ID in the database if we can and cache that
|
||||
needed_ip_ids = []
|
||||
current_ip_addresses.each do |ip|
|
||||
need = nil
|
||||
if id = @ip_to_id_mapping[ip]
|
||||
# We know this IPs ID, we'll just use that.
|
||||
need = id
|
||||
elsif @unassigned_ips.include?(ip)
|
||||
# We know this IP isn't valid. We don't need to do anything
|
||||
elsif !self.class.local_ip?(ip) && ip_address = IPAddress.where("ipv4 = ? OR ipv6 = ?", ip, ip).first
|
||||
# We need to look this up
|
||||
@pairs[ip_address.ipv4] = ip_address.ipv6
|
||||
@ip_to_id_mapping[ip] = ip_address.id
|
||||
need = ip_address.id
|
||||
else
|
||||
@unassigned_ips << ip
|
||||
end
|
||||
|
||||
next unless need
|
||||
|
||||
pair = @pairs[ip] || @pairs.key(ip)
|
||||
if pair.nil? || current_ip_addresses.include?(pair)
|
||||
needed_ip_ids << @ip_to_id_mapping[ip]
|
||||
else
|
||||
logger.info "Host has '#{ip}' but its pair (#{pair}) isn't here. Cannot add now."
|
||||
end
|
||||
end
|
||||
|
||||
# Make an array of needed queue names
|
||||
# Work out what we need to actually do here
|
||||
missing_queues = needed_ip_ids - @ip_queues
|
||||
unwanted_queues = @ip_queues - needed_ip_ids
|
||||
# Leave the queues we don't want any more
|
||||
unwanted_queues.each do |id|
|
||||
leave_queue("outgoing-#{id}")
|
||||
@ip_queues.delete(id)
|
||||
ip_addresses_to_clear = []
|
||||
@ip_to_id_mapping.each do |iip, iid|
|
||||
if id == iid
|
||||
ip_addresses_to_clear << iip
|
||||
end
|
||||
end
|
||||
ip_addresses_to_clear.each { |ip| @ip_to_id_mapping.delete(ip) }
|
||||
end
|
||||
# Join any missing queues
|
||||
missing_queues.uniq.each do |id|
|
||||
join_queue("outgoing-#{id}")
|
||||
@ip_queues << id
|
||||
end
|
||||
end
|
||||
|
||||
def set_process_name
|
||||
prefix = @process_name.to_s
|
||||
prefix += " [exiting]" if @exit
|
||||
if @running_jobs.empty?
|
||||
$0 = "#{prefix} (idle)"
|
||||
else
|
||||
$0 = "#{prefix} (running #{@running_jobs.join(', ')})"
|
||||
end
|
||||
end
|
||||
|
||||
def logger
|
||||
self.class.logger
|
||||
end
|
||||
|
||||
class << self
|
||||
|
||||
def logger
|
||||
Postal.logger_for(:worker)
|
||||
end
|
||||
|
||||
def job_channel
|
||||
@job_channel ||= Postal::RabbitMQ.create_channel
|
||||
end
|
||||
|
||||
def job_queue(name)
|
||||
@job_queues ||= {}
|
||||
@job_queues[name] ||= job_channel.queue("deliver-jobs-#{name}", durable: true, arguments: { "x-message-ttl" => 60_000 })
|
||||
end
|
||||
|
||||
def local_ip?(ip)
|
||||
!!(ip =~ /\A(127\.|fe80:|::)/)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "konfig/exporters/abstract"
|
||||
|
||||
module Postal
|
||||
class YamlConfigExporter < Konfig::Exporters::Abstract
|
||||
|
||||
def export
|
||||
contents = []
|
||||
contents << "version: 2"
|
||||
contents << ""
|
||||
|
||||
@schema.groups.each do |group_name, group|
|
||||
contents << "#{group_name}:"
|
||||
group.attributes.each do |name, attr|
|
||||
contents << " # #{attr.description}"
|
||||
if attr.array?
|
||||
if attr.default.blank?
|
||||
contents << " #{name}: []"
|
||||
else
|
||||
contents << " #{name}:"
|
||||
attr.default.each do |d|
|
||||
contents << " - #{d}"
|
||||
end
|
||||
end
|
||||
else
|
||||
contents << " #{name}: #{attr.default}"
|
||||
end
|
||||
end
|
||||
contents << ""
|
||||
end
|
||||
|
||||
contents.join("\n")
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
+12
-21
@@ -1,27 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
namespace :postal do
|
||||
desc "Start the cron worker"
|
||||
task cron: :environment do
|
||||
require "clockwork"
|
||||
require Rails.root.join("config", "cron")
|
||||
trap("TERM") do
|
||||
puts "Exiting..."
|
||||
Process.exit(0)
|
||||
end
|
||||
Clockwork.run
|
||||
end
|
||||
|
||||
desc "Start SMTP Server"
|
||||
task smtp_server: :environment do
|
||||
Postal::SMTPServer::Server.new(debug: true).run
|
||||
end
|
||||
|
||||
desc "Start the message requeuer"
|
||||
task requeuer: :environment do
|
||||
Postal::MessageRequeuer.new.run
|
||||
end
|
||||
|
||||
desc "Run all migrations on message databases"
|
||||
task migrate_message_databases: :environment do
|
||||
Server.all.each do |server|
|
||||
@@ -31,6 +10,18 @@ namespace :postal do
|
||||
server.message_db.provisioner.migrate
|
||||
end
|
||||
end
|
||||
|
||||
desc "Generate configuration documentation"
|
||||
task generate_config_docs: :environment do
|
||||
require "konfig/exporters/env_vars_as_markdown"
|
||||
|
||||
FileUtils.mkdir_p("doc/config")
|
||||
output = Konfig::Exporters::EnvVarsAsMarkdown.new(Postal::ConfigSchema).export
|
||||
File.write("doc/config/environment-variables.md", output)
|
||||
|
||||
output = Postal::YamlConfigExporter.new(Postal::ConfigSchema).export
|
||||
File.write("doc/config/yaml.yml", output)
|
||||
end
|
||||
end
|
||||
|
||||
Rake::Task["db:migrate"].enhance do
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TrackingMiddleware
|
||||
|
||||
TRACKING_PIXEL = File.read(Rails.root.join("app", "assets", "images", "tracking_pixel.png"))
|
||||
|
||||
def initialize(app = nil)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
unless env["HTTP_X_POSTAL_TRACK_HOST"].to_i == 1
|
||||
return @app.call(env)
|
||||
end
|
||||
|
||||
request = Rack::Request.new(env)
|
||||
|
||||
case request.path
|
||||
when /\A\/img\/([a-z0-9-]+)\/([a-z0-9-]+)/i
|
||||
server_token = ::Regexp.last_match(1)
|
||||
message_token = ::Regexp.last_match(2)
|
||||
dispatch_image_request(request, server_token, message_token)
|
||||
when /\A\/([a-z0-9-]+)\/([a-z0-9-]+)/i
|
||||
server_token = ::Regexp.last_match(1)
|
||||
link_token = ::Regexp.last_match(2)
|
||||
dispatch_redirect_request(request, server_token, link_token)
|
||||
else
|
||||
[200, {}, ["Hello."]]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_image_request(request, server_token, message_token)
|
||||
message_db = get_message_db_from_server_token(server_token)
|
||||
if message_db.nil?
|
||||
return [404, {}, ["Invalid Server Token"]]
|
||||
end
|
||||
|
||||
begin
|
||||
message = message_db.message(token: message_token)
|
||||
message.create_load(request)
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
# This message has been removed, we'll just continue to serve the image
|
||||
rescue StandardError => e
|
||||
# Somethign else went wrong. We don't want to stop the image loading though because
|
||||
# this is our problem. Log this exception though.
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
end
|
||||
|
||||
source_image = request.params["src"]
|
||||
case source_image
|
||||
when nil
|
||||
headers = {}
|
||||
headers["Content-Type"] = "image/png"
|
||||
headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s
|
||||
[200, headers, [TRACKING_PIXEL]]
|
||||
when /\Ahttps?:\/\//
|
||||
response = Postal::HTTP.get(source_image, timeout: 3)
|
||||
return [404, {}, ["Not found"]] unless response[:code] == 200
|
||||
|
||||
headers = {}
|
||||
headers["Content-Type"] = response[:headers]["content-type"]&.first
|
||||
headers["Last-Modified"] = response[:headers]["last-modified"]&.first
|
||||
headers["Cache-Control"] = response[:headers]["cache-control"]&.first
|
||||
headers["Etag"] = response[:headers]["etag"]&.first
|
||||
headers["Content-Length"] = response[:body].bytesize.to_s
|
||||
[200, headers, [response[:body]]]
|
||||
|
||||
else
|
||||
[400, {}, ["Invalid/missing source image"]]
|
||||
end
|
||||
end
|
||||
|
||||
def dispatch_redirect_request(request, server_token, link_token)
|
||||
message_db = get_message_db_from_server_token(server_token)
|
||||
if message_db.nil?
|
||||
return [404, {}, ["Invalid Server Token"]]
|
||||
end
|
||||
|
||||
link = message_db.select(:links, where: { token: link_token }, limit: 1).first
|
||||
if link.nil?
|
||||
return [404, {}, ["Link not found"]]
|
||||
end
|
||||
|
||||
time = Time.now.to_f
|
||||
if link["message_id"]
|
||||
message_db.update(:messages, { clicked: time }, where: { id: link["message_id"] })
|
||||
message_db.insert(:clicks, {
|
||||
message_id: link["message_id"],
|
||||
link_id: link["id"],
|
||||
ip_address: request.ip,
|
||||
user_agent: request.user_agent,
|
||||
timestamp: time
|
||||
})
|
||||
|
||||
begin
|
||||
message_webhook_hash = message_db.message(link["message_id"]).webhook_hash
|
||||
WebhookRequest.trigger(message_db.server, "MessageLinkClicked", {
|
||||
message: message_webhook_hash,
|
||||
url: link["url"],
|
||||
token: link["token"],
|
||||
ip_address: request.ip,
|
||||
user_agent: request.user_agent
|
||||
})
|
||||
rescue Postal::MessageDB::Message::NotFound
|
||||
# If we can't find the message that this link is associated with, we'll just ignore it
|
||||
# and not trigger any webhooks.
|
||||
end
|
||||
end
|
||||
|
||||
[307, { "Location" => link["url"] }, ["Redirected to: #{link['url']}"]]
|
||||
end
|
||||
|
||||
def get_message_db_from_server_token(token)
|
||||
return unless server = ::Server.find_by_token(token)
|
||||
|
||||
server.message_db
|
||||
end
|
||||
|
||||
end
|
||||
Reference in New Issue
Block a user