require 'optparse' require 'net/smtp' require 'smtp_tls' unless Net::SMTP.instance_methods.include?("enable_starttls_auto") ## # Hack in RSET module Net # :nodoc: class SMTP # :nodoc: unless instance_methods.include? 'reset' then ## # Resets the SMTP connection. def reset getok 'RSET' end end end end ## # ActionMailer::ARSendmail delivers email from the email table to the # SMTP server configured in your application's config/environment.rb. # ar_sendmail does not work with sendmail delivery. # # ar_mailer can deliver to SMTP with TLS using smtp_tls.rb borrowed from Kyle # Maxwell's action_mailer_optional_tls plugin. Simply set the :tls option in # ActionMailer::Base's smtp_settings to true to enable TLS. # # See ar_sendmail -h for the full list of supported options. # # The interesting options are: # * --daemon # * --mailq module ActionMailer; end class ActionMailer::ARSendmail class RailsEnvironmentFailed < StandardError; end class MinimalEnvironmentFailed < StandardError; end ## # The version of ActionMailer::ARSendmail you are running. VERSION = '2.1.9' ## # Maximum number of times authentication will be consecutively retried MAX_AUTH_FAILURES = 2 ## # Email delivery attempts per run attr_accessor :batch_size ## # Seconds to delay between runs attr_accessor :delay ## # Maximum age of emails in seconds before they are removed from the queue. attr_accessor :max_age ## # Be verbose attr_accessor :verbose ## # True if only one delivery attempt will be made per call to run attr_reader :once ## # Times authentication has failed attr_accessor :failed_auth_count @@pid_file = nil def self.remove_pid_file if @@pid_file require 'shell' sh = Shell.new sh.rm @@pid_file end end ## # Prints a list of unsent emails and the last delivery attempt, if any. # # If ActiveRecord::Timestamp is not being used the arrival time will not be # known. See http://api.rubyonrails.org/classes/ActiveRecord/Timestamp.html # to learn how to enable ActiveRecord::Timestamp. def self.mailq emails = ActionMailer::Base.email_class.find :all if emails.empty? then puts "Mail queue is empty" return end total_size = 0 puts "-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------" emails.each do |email| size = email.mail.length total_size += size create_timestamp = email.created_on rescue email.created_at rescue Time.at(email.created_date) rescue # for Robot Co-op nil created = if create_timestamp.nil? then ' Unknown' else create_timestamp.strftime '%a %b %d %H:%M:%S' end puts "%10d %8d %s %s" % [email.id, size, created, email.from] if email.last_send_attempt > 0 then puts "Last send attempt: #{Time.at email.last_send_attempt}" end puts " #{email.to}" puts end puts "-- #{total_size/1024} Kbytes in #{emails.length} Requests." end ## # Processes command line options in +args+ def self.process_args(args) name = File.basename $0 options = {} options[:Chdir] = '.' options[:Daemon] = false options[:Delay] = 60 options[:MaxAge] = 86400 * 7 options[:Once] = false options[:RailsEnv] = ENV['RAILS_ENV'] options[:Pidfile] = options[:Chdir] + '/log/ar_sendmail.pid' options[:Minimal] = false opts = OptionParser.new do |opts| opts.banner = "Usage: #{name} [options]" opts.separator '' opts.separator "#{name} scans the email table for new messages and sends them to the" opts.separator "website's configured SMTP host." opts.separator '' opts.separator "#{name} must be run from a Rails application's root." opts.separator '' opts.separator 'Sendmail options:' opts.on("-b", "--batch-size BATCH_SIZE", "Maximum number of emails to send per delay", "Default: Deliver all available emails", Integer) do |batch_size| options[:BatchSize] = batch_size end opts.on( "--delay DELAY", "Delay between checks for new mail", "in the database", "Default: #{options[:Delay]}", Integer) do |delay| options[:Delay] = delay end opts.on( "--max-age MAX_AGE", "Maxmimum age for an email. After this", "it will be removed from the queue.", "Set to 0 to disable queue cleanup.", "Default: #{options[:MaxAge]} seconds", Integer) do |max_age| options[:MaxAge] = max_age end opts.on("-o", "--once", "Only check for new mail and deliver once", "Default: #{options[:Once]}") do |once| options[:Once] = once end opts.on("-d", "--daemonize", "Run as a daemon process", "Default: #{options[:Daemon]}") do |daemon| options[:Daemon] = true end opts.on( "--minimal", "Load a minimal environment with settings from config/email.yml", "Default: #{options[:Minimal]}") do |minimal| options[:Minimal] = true end opts.on("-p", "--pidfile PIDFILE", "Set the pidfile location", "Default: #{options[:Chdir]}#{options[:Pidfile]}", String) do |pidfile| options[:Pidfile] = pidfile end opts.on( "--mailq", "Display a list of emails waiting to be sent") do |mailq| options[:MailQ] = true end opts.separator '' opts.separator 'Setup Options:' opts.separator '' opts.separator 'Generic Options:' opts.on("-c", "--chdir PATH", "Use PATH for the application path", "Default: #{options[:Chdir]}") do |path| usage opts, "#{path} is not a directory" unless File.directory? path usage opts, "#{path} is not readable" unless File.readable? path options[:Chdir] = path end opts.on("-e", "--environment RAILS_ENV", "Set the RAILS_ENV constant", "Default: #{options[:RailsEnv]}") do |env| options[:RailsEnv] = env end opts.on("-v", "--[no-]verbose", "Be verbose", "Default: #{options[:Verbose]}") do |verbose| options[:Verbose] = verbose end opts.on("-h", "--help", "You're looking at it") do usage opts end opts.on("--version", "Version of ARMailer") do usage "ar_mailer #{VERSION} (adzap fork)" end opts.separator '' end opts.parse! args ENV['RAILS_ENV'] = options[:RailsEnv] begin options[:Minimal] ? load_minimal_environment(options[:Chdir]) : load_rails_environment(options[:Chdir]) rescue RailsEnvironmentFailed usage opts, <<-EOF #{name} must be run from a Rails application's root to deliver email. #{Dir.pwd} does not appear to be a Rails application root. EOF rescue MinimalEnvironmentFailed => e usage opts, "Minimal environment loading has failed with error '#{e.message}'. Check minimal environment instructions or use the normal Rails environment loading." end return options end # Load full Rails environment # def self.load_rails_environment(base_path) Dir.chdir(base_path) do require 'config/environment' require 'action_mailer/ar_mailer' end rescue LoadError raise RailsEnvironmentFailed end # Load a minimal environment to save memory by not loading the entire Rails app. # Requires a vendored Rails or bundler, and a mailer config at config/email.yml. # def self.load_minimal_environment(base_path) Dir.chdir(base_path) do Dir.glob('vendor/rails/*/lib').each { |dir| $:.unshift File.expand_path(dir) } require 'yaml' require 'erb' require 'active_record' require 'action_mailer' require 'action_mailer/ar_mailer' require 'app/models/email' env = ENV['RAILS_ENV'] logger = ActiveSupport::BufferedLogger.new(File.join('log', "#{env}.log")) logger.level = ActiveSupport::BufferedLogger.const_get((env == 'production' ? 'info' : 'debug').upcase) ActiveRecord::Base.logger = ActionMailer::Base.logger = logger db_config = read_config('config/database.yml') ActiveRecord::Base.establish_connection db_config[env] mailer_config = read_config('config/email.yml') ActionMailer::Base.smtp_settings = mailer_config[env].symbolize_keys end rescue => e raise MinimalEnvironmentFailed, e.message end def default_log_path File.join(root_path, 'log', "#{environment}.log") end def default_log_level environment == 'production' ? :info : :debug end # Open yaml config file and parse with ERB # def self.read_config(file) YAML::load(ERB.new(IO.read(file)).result) end ## # Processes +args+ and runs as appropriate def self.run(args = ARGV) options = process_args args if options.include? :MailQ then mailq exit end if options[:Daemon] then require 'webrick/server' @@pid_file = File.expand_path(options[:Pidfile], options[:Chdir]) if File.exists? @@pid_file # check to see if process is actually running pid = '' File.open(@@pid_file, 'r') {|f| pid = f.read.chomp } if system("ps -p #{pid} | grep #{pid}") # returns true if process is running, o.w. false $stderr.puts "Warning: The pid file #{@@pid_file} exists and ar_sendmail is running. Shutting down." exit -1 else # not running, so remove existing pid file and continue self.remove_pid_file $stderr.puts "ar_sendmail is not running. Removing existing pid file and starting up..." end end WEBrick::Daemon.start File.open(@@pid_file, 'w') {|f| f.write("#{Process.pid}\n")} end new(options).run rescue SystemExit raise rescue SignalException exit rescue Exception => e $stderr.puts "Unhandled exception #{e.message}(#{e.class}):" $stderr.puts "\t#{e.backtrace.join "\n\t"}" exit -2 end ## # Prints a usage message to $stderr using +opts+ and exits def self.usage(opts, message = nil) if message then $stderr.puts message $stderr.puts end $stderr.puts opts exit 1 end ## # Creates a new ARSendmail. # # Valid options are: # :BatchSize:: Maximum number of emails to send per delay # :Delay:: Delay between deliver attempts # :Once:: Only attempt to deliver emails once when run is called # :Verbose:: Be verbose. def initialize(options = {}) options[:Delay] ||= 60 options[:MaxAge] ||= 86400 * 7 @batch_size = options[:BatchSize] @delay = options[:Delay] @once = options[:Once] @verbose = options[:Verbose] @max_age = options[:MaxAge] @failed_auth_count = 0 end ## # Removes emails that have lived in the queue for too long. If max_age is # set to 0, no emails will be removed. def cleanup return if @max_age == 0 timeout = Time.now - @max_age conditions = ['last_send_attempt > 0 and created_on < ?', timeout] mail = ActionMailer::Base.email_class.destroy_all conditions log "expired #{mail.length} emails from the queue" end ## # Delivers +emails+ to ActionMailer's SMTP server and destroys them. def deliver(emails) settings = [ smtp_settings[:domain], (smtp_settings[:user] || smtp_settings[:user_name]), smtp_settings[:password], smtp_settings[:authentication] ] smtp = Net::SMTP.new(smtp_settings[:address], smtp_settings[:port]) if smtp.respond_to?(:enable_starttls_auto) smtp.enable_starttls_auto unless smtp_settings[:tls] == false else settings << smtp_settings[:tls] end smtp.start(*settings) do |session| @failed_auth_count = 0 until emails.empty? do email = emails.shift begin res = session.send_message email.mail, email.from, email.to email.destroy log "sent email %011d from %s to %s: %p" % [email.id, email.from, email.to, res] rescue Net::SMTPFatalError => e log "5xx error sending email %d, removing from queue: %p(%s):\n\t%s" % [email.id, e.message, e.class, e.backtrace.join("\n\t")] email.destroy session.reset rescue Net::SMTPServerBusy => e log "server too busy, stopping delivery cycle" return rescue Net::SMTPUnknownError, Net::SMTPSyntaxError, TimeoutError, Timeout::Error => e email.last_send_attempt = Time.now.to_i email.save rescue nil log "error sending email %d: %p(%s):\n\t%s" % [email.id, e.message, e.class, e.backtrace.join("\n\t")] session.reset end end end rescue Net::SMTPAuthenticationError => e @failed_auth_count += 1 if @failed_auth_count >= MAX_AUTH_FAILURES then log "authentication error, giving up: #{e.message}" raise e else log "authentication error, retrying: #{e.message}" end sleep delay rescue Net::SMTPServerBusy, SystemCallError, OpenSSL::SSL::SSLError # ignore SMTPServerBusy/EPIPE/ECONNRESET from Net::SMTP.start's ensure end ## # Prepares ar_sendmail for exiting def do_exit log "caught signal, shutting down" self.class.remove_pid_file exit 130 end ## # Returns emails in email_class that haven't had a delivery attempt in the # last 300 seconds. def find_emails options = { :conditions => ['last_send_attempt < ?', Time.now.to_i - 300] } options[:limit] = batch_size unless batch_size.nil? mail = ActionMailer::Base.email_class.find :all, options log "found #{mail.length} emails to send" mail end ## # Installs signal handlers to gracefully exit. def install_signal_handlers trap 'TERM' do do_exit end trap 'INT' do do_exit end end ## # Logs +message+ if verbose def log(message) $stderr.puts message if @verbose ActionMailer::Base.logger.info "ar_sendmail: #{message}" end ## # Scans for emails and delivers them every delay seconds. Only returns if # once is true. def run install_signal_handlers loop do begin cleanup emails = find_emails deliver(emails) unless emails.empty? rescue ActiveRecord::Transactions::TransactionError end break if @once sleep @delay end end ## # Proxy to ActionMailer::Base::smtp_settings. See # http://api.rubyonrails.org/classes/ActionMailer/Base.html # for instructions on how to configure ActionMailer's SMTP server. # # Falls back to ::server_settings if ::smtp_settings doesn't exist for # backwards compatibility. def smtp_settings ActionMailer::Base.smtp_settings rescue ActionMailer::Base.server_settings end end