require 'optparse'
require 'net/smtp'
require 'smtp_tls'
require 'rubygems'
class Object # :nodoc:
unless respond_to? :path2class then
def self.path2class(path)
path.split(/::/).inject self do |k,n| k.const_get n end
end
end
end
##
# 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
module ActionMailer; end # :nodoc:
##
# 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
# * --create-migration
# * --create-model
# * --table-name
class ActionMailer::ARSendmail
##
# The version of ActionMailer::ARSendmail you are running.
VERSION = '1.4.1'
##
# 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
##
# ActiveRecord class that holds emails
attr_reader :email_class
##
# 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
##
# Creates a new migration using +table_name+ and prints it on stdout.
def self.create_migration(table_name)
require 'active_support'
puts <<-EOF
class Add#{table_name.classify} < ActiveRecord::Migration
def self.up
create_table :#{table_name.tableize} do |t|
t.column :from, :string
t.column :to, :string
t.column :last_send_attempt, :integer, :default => 0
t.column :mail, :text
t.column :created_on, :datetime
end
end
def self.down
drop_table :#{table_name.tableize}
end
end
EOF
end
##
# Creates a new model using +table_name+ and prints it on stdout.
def self.create_model(table_name)
require 'active_support'
puts <<-EOF
class #{table_name.classify} < ActiveRecord::Base
end
EOF
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(table_name)
klass = table_name.split('::').inject(Object) { |k,n| k.const_get n }
emails = klass.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[:TableName] = 'Email'
options[:Pidfile] = options[:Chdir] + '/log/ar_sendmail.pid'
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("-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.on( "--create-migration",
"Prints a migration to add an Email table",
"to stdout") do |create|
options[:Migrate] = true
end
opts.on( "--create-model",
"Prints a model for an Email ActiveRecord",
"object to stdout") do |create|
options[:Model] = true
end
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("-t", "--table-name TABLE_NAME",
"Name of table holding emails",
"Used for both sendmail and",
"migration creation",
"Default: #{options[:TableName]}") do |name|
options[:TableName] = name
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.separator ''
end
opts.parse! args
return options if options.include? :Migrate or options.include? :Model
ENV['RAILS_ENV'] = options[:RailsEnv]
Dir.chdir options[:Chdir] do
begin
require 'config/environment'
rescue LoadError
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
end
end
return options
end
##
# Processes +args+ and runs as appropriate
def self.run(args = ARGV)
options = process_args args
if options.include? :Migrate then
create_migration options[:TableName]
exit
elsif options.include? :Model then
create_model options[:TableName]
exit
elsif options.include? :MailQ then
mailq options[:TableName]
exit
end
if options[:Daemon] then
require 'webrick/server'
@@pid_file = File.expand_path(options[:Pidfile])
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
else
# not running, so remove existing pid file and continue
self.remove_pid_file
log "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 1
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
# :TableName:: Table name that stores the emails
# :Once:: Only attempt to deliver emails once when run is called
# :Verbose:: Be verbose.
def initialize(options = {})
options[:Delay] ||= 60
options[:TableName] ||= 'Email'
options[:MaxAge] ||= 86400 * 7
@batch_size = options[:BatchSize]
@delay = options[:Delay]
@email_class = Object.path2class options[:TableName]
@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 = @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)
user = smtp_settings[:user] || smtp_settings[:user_name]
Net::SMTP.start smtp_settings[:address], smtp_settings[:port],
smtp_settings[:domain], user,
smtp_settings[:password],
smtp_settings[:authentication],
smtp_settings[:tls] do |smtp|
@failed_auth_count = 0
until emails.empty? do
email = emails.shift
begin
res = smtp.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
smtp.reset
rescue Net::SMTPServerBusy => e
log "server too busy, sleeping #{@delay} seconds"
sleep delay
return
rescue Net::SMTPUnknownError, Net::SMTPSyntaxError, TimeoutError => 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")]
smtp.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
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 = @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
now = Time.now
begin
cleanup
deliver find_emails
rescue ActiveRecord::Transactions::TransactionError
end
break if @once
sleep @delay if now + @delay > Time.now
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