public
Description: Deliver email to local db and have it send by sequel_sendmail later. Attempt at a clone of ar_mailer for merb + sequel.
Clone URL: git://github.com/bricooke/sequel_mailer.git
bricooke (author)
Sat Mar 15 19:38:01 -0700 2008
commit  86137a94f79e2772e53c092a4b1e29e1903719f4
tree    86a18f0055430aefd3bcdf7a3509325ba512b3b2
parent  d039d176883b1ef37c2ee94d2b3ab208587fe1b4
sequel_mailer / lib / sequel_mailer / sequel_sendmail.rb
100644 520 lines (417 sloc) 13.458 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
require 'optparse'
require 'net/smtp'
require 'smtp_tls'
require 'rubygems'
require 'sequel'
require 'sequel_model'
require 'merb-core'
require 'merb-more'
 
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
 
##
# Sequel::Sendmail delivers email from the email table to the
# SMTP server configured in your application's config/init.rb.
# sequel_sendmail does not work with sendmail delivery.
#
# sequel_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
# Merb::Mailer.config to true to enable TLS.
#
# See sequel_sendmail -h for the full list of supported options.
#
# The interesting options are:
# * --daemon
# * --mailq
# * --create-migration
# * --create-model
# * --table-name
 
class Sequel::Sendmail
 
  ##
  # The version of Sequel::Sendmail you are running.
 
  VERSION = '0.0.2'
 
  ##
  # 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
 
  ##
  # Sequel 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
 
  ##
  # Creates a new migration using +table_name+ and prints it on stdout.
 
  def self.create_migration(table_name)
    puts <<-EOF
class #{table_name.classify}Migration < Sequel::Migration
def up
create_table "#{table_name.tableize}" do
primary_key :id
varchar :from_address
varchar :to_address
integer :last_send_attempt, :default => 0
text :mail
datetime :created_on
end
end
 
def down
execute "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)
    puts <<-EOF
class #{table_name.classify} < Sequel::Model
end
EOF
  end
 
  ##
  # Prints a list of unsent emails and the last delivery attempt, if any.
  #
  def self.mailq(table_name)
    klass = table_name.split('::').inject(Object) { |k,n| k.const_get n }
    emails = klass.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_address]
      if email.last_send_attempt > 0 then
        puts "Last send attempt: #{Time.at email.last_send_attempt}"
      end
      puts " #{email.to_address}"
      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[:MerbEnv] = ENV['MERB_ENV'] || "development"
    options[:TableName] = 'Email'
 
    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 Merb 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( "--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 Sequel",
              "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 MERB_ENV",
              "Set the MERB_ENV constant",
              "Default: #{options[:MerbEnv]}") do |env|
        options[:MerbEnv] = 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['MERB_ENV'] = options[:MerbEnv]
 
    Dir.chdir options[:Chdir] do
      begin
        require 'config/init.rb'
      rescue LoadError
        usage opts, <<-EOF
#{name} must be run from a Merb application's root to deliver email.
#{Dir.pwd} does not appear to be a Merb application root.
EOF
      end
    end
 
    return options
  end
 
  ##
  # Processes +args+ and runs as appropriate
 
  def self.run(args = ARGV)
    options = process_args args
 
    require "app" / "models" / "#{options[:TableName].underscore}.rb"
    
    # This connects us to the db, etc
    Merb.environment = options[:MerbEnv]
    Merb::Config.setup
    Merb.root = Merb::Config[:merb_root]
    Merb::BootLoader.run
    
    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'
      WEBrick::Daemon.start
    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 Sequel::Sendmail.
  #
  # Valid options are:
  # <tt>:BatchSize</tt>:: Maximum number of emails to send per delay
  # <tt>:Delay</tt>:: Delay between deliver attempts
  # <tt>:TableName</tt>:: Table name that stores the emails
  # <tt>:Once</tt>:: Only attempt to deliver emails once when run is called
  # <tt>:Verbose</tt>:: 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.find(conditions).delete rescue []
 
    log "expired #{mail.length} emails from the queue"
  end
 
  ##
  # Delivers +emails+ to Merb::Mailer's SMTP server and destroys them.
 
  def deliver(emails)
    raise "You must setup Merb::Mailer.config" if Merb::Mailer.config.nil?
    
    user = Merb::Mailer.config[:user] || Merb::Mailer.config[:user_name]
    Net::SMTP.start Merb::Mailer.config[:host],
                    Merb::Mailer.config[:port],
                    Merb::Mailer.config[:domain],
                    user,
                    Merb::Mailer.config[:pass],
                    Merb::Mailer.config[:auth],
                    Merb::Mailer.config[:tls] do |smtp|
      @failed_auth_count = 0
      until emails.empty? do
        email = emails.shift
        begin
          res = smtp.send_message email.mail, email.from_address, email.to_address
          email.destroy
          log "sent email %011d from %s to %s: %p" %
                [email.id, email.from_address, email.to_address, 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"
    exit
  end
 
  ##
  # Returns emails in email_class that haven't had a delivery attempt in the
  # last 300 seconds.
 
  def find_emails
    
    options[:limit] = batch_size unless batch_size.nil?
    query = @email_class.filter("last_send_attempt < #{Time.now.to_i - 300}")
    query.limit(batch_size) unless batch_size.nil?
    mail = query.all
 
    log "found #{mail.size} 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
    Merb.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
      end
      break if @once
      sleep @delay if now + @delay > Time.now
    end
  end
end