#! /opt/local/bin/ruby ### built-in require 'openssl' require 'net/smtp' require 'io/wait' require 'erb' require 'socket' ### rubyforge require 'rubygems' require 'main' require 'open4' #require 'mailfactory' # patched version inlined below Main { option('config', 'c'){ argument_required } option('template', 't'){ argument_required } option('belch', 'b'){ } option('send', 's'){ } option('queue', 'q'){ argument_required } SMTP = {} def run #--{{{ indicating_that_rqmailer_failed do setup load_config load_template generate_cmd run_cmd expand_template mail_message end exit @exit_status || 42 end #--}}} def setup #--{{{ @config = {} @rq = ENV['RQ'] @rq_data = ENV['RQ_DATA'] @exit_status = 0 @exception = nil @template_expander = TemplateExpander.instance @stdin = @stdout = @stderr = nil @belch = params['belch'].given? @send = params['send'].given? @hostname = Socket.gethostname rescue 'localhost.localdomain' @mail = MailFactory.new end #--}}} def load_config #--{{{ if param['config'].given? @config = YAML.load IO.read(param['config'].value) else if @rq_data config = File.join @rq_data, 'config' @config = YAML.load IO.read(config) else abort 'no config' end end end #--}}} def load_template #--{{{ if param['template'].given? @template = IO.read param['template'].value else if @rq_data template = File.join @rq_data, 'template' @template = IO.read(template) else @template = DATA.read end end end #--}}} def generate_cmd #--{{{ stdin = argv.first == '-' && argv.delete('-') @cmd = if stdin STDIN.read else @config['command'] || @config['cmd'] || argv.join(' ') end abort 'no cmd' if @cmd.to_s.strip.empty? end #--}}} def run_cmd #--{{{ if @rq @stdin = STDIN if STDIN.ready? @stdout = STDOUT @stderr = STDERR STDOUT.flush; STDERR.flush @exit_status = Open4.spawn @cmd, 0=>@stdin, 1=>@stdout, 2=>@stderr STDOUT.flush; STDERR.flush else @stdin = STDIN if STDIN.ready? @stdin = IO.read(@stdin) if @stdin @stdout = '' @stderr = '' @exit_status = Open4.spawn @cmd, 0=>@stdin, 1=>@stdout, 2=>@stderr end update_config_with_cmd_info end #--}}} def errmsg e #--{{{ m, c, bt = e.message, e.class, (e.backtrace || []).join("\n") "#{ m } (#{ c })\n#{ bt }" end #--}}} def indicating_that_rqmailer_failed #--{{{ begin yield rescue Exception => e @exception = e STDERR.puts errmsg(e) @exit_status = 42 end end #--}}} def update_config_with_cmd_info #--{{{ if @rq %w( stdin stdout stderr ).each do |iof| path = instance_variable_get "@rq_#{ iof }" buf = IO.read path rescue '' @config[iof] = buf @config["#{ iof }_path"] = path end else @config['stdin'] = @stdin @config['stdin_path'] = '-' @config['stdout'] = @stdout @config['stdout_path'] = '-' @config['stderr'] = @stderr @config['stderr_path'] = '-' end @config['cmd'] = @config['command'] = @cmd @config['exit_status'] = @config['exitstatus'] = @exit_status end #--}}} def expand_template #--{{{ @template_expander.configure @config @template_expander.configure 'mail' => @mail mconfig = @config['mail'] || {} @template_expander.configure mconfig @message = @template_expander.expand @template end #--}}} def mail_message #--{{{ build_mail! hash = @config['smtp'] || SMTP server = hash['server'] port = Integer hash['port'] hostname = hash['hostname'] || @hostname || 'localhost.localdomain' account = hash['account'] password = hash['password'] authtype = hash['authtype'] || :plain tls = hash['tls'] ? true : false if @belch if @send smtp = Net::SMTP.new server, port smtp.set_debug_output STDERR smtp.start hostname, account, password, authtype, tls do |connection| connection.send_message @mail, account, @mail.recipients end end STDOUT.puts @mail else debug_output = open File.join(@rq_data, 'smtp'), 'w' at_exit{ debug_output.close rescue nil } smtp = Net::SMTP.new server, port smtp.set_debug_output debug_output smtp.start hostname, account, password, authtype, tls do |connection| connection.send_message @mail, account, @mail.recipients end if @rq open(File.join(@rq_data, 'mail'), 'w'){|fd| fd.write @mail} end STDERR.puts @mail end end #--}}} def build_mail! #--{{{ mconfig = @config['mail'] || @config attributes = %w( to from cc bcc subject ) attributes.each do |attribute| up, down = attribute.downcase.capitalize, attribute.downcase [up, down].each do |attribute| if mconfig.has_key?(attribute) value = [mconfig[attribute]].flatten.join ',' @mail.send "#{ up }=", value @mail.send "#{ down }=", value end end end methods = %w( attach attachment attachments Attach Attachment Attachments ) methods.each do |method| if mconfig.has_key?(method) values = [mconfig[method]].flatten values.each do |value| @mail.attach value end end end class << @mail def recipients to.to_s.split %r/,/ end end @mail.subject ||= "rqmailer" @mail.text = @message @mail end #--}}} class TemplateExpander #--{{{ alias_method "__instance_variable_set__", "instance_variable_set" alias_method "__instance_eval__", "instance_eval" instance_methods.each{|m| undef_method unless m[%r/__/]} alias_method "instance_eval", "__instance_eval__" alias_method "instance_variable_set", "__instance_variable_set__" def self.new *a, &b @instance ||= super end def self.instance *a, &b new *a, &b end def self.const_missing c msg = c.to_s.downcase if @instance.respond_to? msg @instance.send msg else super end end def singleton_class &b #--{{{ @sc ||= class << self self end b ? @sc.module_eval(&b) : @sc end #--}}} def set key, value #--{{{ key = key.to_s.downcase instance_variable_set "@#{ key }", value singleton_class do define_method(key){ instance_variable_get "@#{ key }" } define_method(key.capitalize){ instance_variable_get "@#{ key }" } end end #--}}} def configure hash #--{{{ hash.each{|k,v| set k, v} end #--}}} def expand message #--{{{ ERB.new(message).result binding end #--}}} def lazy #--{{{ singleton_class{ define_method m, &b } end #--}}} def method_missing m, *a, &b super unless defined? @mail @mail.send m, *a, &b end end #--}}} } BEGIN { ### hacking in smtp tls support #--{{{ # Original code believed public domain from ruby-talk or ruby-core email. # Modifications by Kyle Maxwell used under MIT license. require "openssl" require "net/smtp" # :stopdoc: class Net::SMTP class << self send :remove_method, :start end def self.start( address, port = nil, helo = 'localhost.localdomain', user = nil, secret = nil, authtype = nil, use_tls = false, &block) # :yield: smtp new(address, port).start(helo, user, secret, authtype, use_tls, &block) end alias tls_old_start start def start( helo = 'localhost.localdomain', user = nil, secret = nil, authtype = nil, use_tls = false ) # :yield: smtp start_method = use_tls ? :do_tls_start : :do_start if block_given? begin send start_method, helo, user, secret, authtype return yield(self) ensure do_finish end else send start_method, helo, user, secret, authtype return self end end private def do_tls_start(helodomain, user, secret, authtype) raise IOError, 'SMTP session already started' if @started check_auth_args user, secret, authtype if user or secret sock = timeout(@open_timeout) { TCPSocket.open(@address, @port) } @socket = Net::InternetMessageIO.new(sock) @socket.read_timeout = 60 #@read_timeout @socket.debug_output = @debug_output check_response(critical { recv_response() }) do_helo(helodomain) raise 'openssl library not installed' unless defined?(OpenSSL) starttls ssl = OpenSSL::SSL::SSLSocket.new(sock) ssl.sync_close = true ssl.connect @socket = Net::InternetMessageIO.new(ssl) @socket.read_timeout = 60 #@read_timeout @socket.debug_output = @debug_output do_helo(helodomain) authenticate user, secret, authtype if user @started = true ensure unless @started # authentication failed, cancel connection. @socket.close if not @started and @socket and not @socket.closed? @socket = nil end end def do_helo(helodomain) begin if @esmtp ehlo helodomain else helo helodomain end rescue Net::ProtocolError if @esmtp @esmtp = false @error_occured = false retry end raise end end def starttls getok('STARTTLS') end alias tls_old_quit quit def quit begin getok('QUIT') rescue EOFError end end end unless Net::SMTP.private_method_defined? :do_tls_start or Net::SMTP.method_defined? :tls? #--}}} ### inlining mailfactory #--{{{ # = Overview: # A simple to use module for generating RFC compliant MIME mail # --- # = License: # Author:: David Powers # Copyright:: May, 2005 # License:: Ruby License # --- # = Usage: # require 'net/smtp' # require 'rubygems' # require 'mailfactory' # # # mail = MailFactory.new() # mail.to = "test@test.com" # mail.from = "sender@sender.com" # mail.subject = "Here are some files for you!" # mail.text = "This is what people with plain text mail readers will see" # mail.html = "A little something special for people with HTML readers" # mail.attach("/etc/fstab") # mail.attach("/some/other/file") # # Net::SMTP.start('smtp1.testmailer.com', 25, 'mail.from.domain', fromaddress, password, :cram_md5) { |smtp| # mail.to = toaddress # smtp.send_message(mail.to_s(), fromaddress, toaddress) # } require 'base64' require 'pathname' # try to bring in the mime/types module, make a dummy module if it can't be found begin begin require 'rubygems' rescue LoadError end require 'mime/types' rescue LoadError module MIME class Types def Types::type_for(filename) return('') end end end end # An easy class for creating a mail message class MailFactory def initialize() @headers = Array.new() @attachments = Array.new() @attachmentboundary = generate_boundary() @bodyboundary = generate_boundary() @html = nil @text = nil end # adds a header to the bottom of the headers def add_header(header, value) @headers << "#{header}: #{value}" end # removes the named header - case insensitive def remove_header(header) @headers.each_index() { |i| if(@headers[i] =~ /^#{Regexp.escape(header)}:/i) @headers.delete_at(i) end } end # sets a header (removing any other versions of that header) def set_header(header, value) remove_header(header) add_header(header, value) end def replyto=(newreplyto) remove_header("Reply-To") add_header("Reply-To", newreplyto) end def replyto() return(get_header("Reply-To")[0]) end def self.hattr h h = h.to_s module_eval <<-code def #{ h.downcase }=(value) remove_header("#{ h.downcase.capitalize }") add_header("#{ h.downcase.capitalize }", value) end def #{ h.downcase }() return(get_header("#{ h.downcase.capitalize }")[0]) end code end %w( to from cc bcc subject ).each{|h| hattr h} # sets the plain text body of the message def text=(newtext) @text = newtext end # sets the HTML body of the message. Only the body of the # html should be provided def html=(newhtml) @html = "\n\n\n\n\n#{newhtml}\n\n" end # sets the HTML body of the message. The entire HTML section should be provided def rawhtml=(newhtml) @html = newhtml end # implement method missing to provide helper methods for setting and getting headers. # Headers with '-' characters may be set/gotten as 'x_mailer' or 'XMailer' (splitting # will occur between capital letters or on '_' chracters) def method_missing(methId, *args) name = methId.id2name() # mangle the name if we have to if(name =~ /_/) name = name.gsub(/_/, '-') elsif(name =~ /[A-Z]/) name = name.gsub(/([a-zA-Z])([A-Z])/, '\1-\2') end # determine if it sets or gets, and do the right thing if(name =~ /=$/) if(args.length != 1) super(methId, args) end set_header(name[/^(.*)=$/, 1], args[0]) else if(args.length != 0) super(methId, args) end headers = get_header(name) return(get_header(name)) end end # returns the value (or values) of the named header in an array def get_header(header) headers = Array.new() headerregex = /^#{Regexp.escape(header)}:/i @headers.each() { |h| if(headerregex.match(h)) headers << h[/^[^:]+:(.*)/i, 1].strip() end } return(headers) end # returns true if the email is multipart def multipart?() if(@attachments.length > 0 or @html != nil) return(true) else return(false) end end # builds an email and returns it as a string. Takes the following options: # :messageid:: Adds a message id to the message based on the from header (defaults to false) # :date:: Adds a date to the message if one is not present (defaults to true) def construct(options = Hash.new) if(options[:date] == nil) options[:date] = true end if(options[:messageid]) # add a unique message-id remove_header("Message-ID") sendingdomain = get_header('from')[0].to_s()[/@([-a-zA-Z0-9._]+)/,1].to_s() add_header("Message-ID", "<#{Time.now.to_f()}.#{Process.euid()}.#{String.new.object_id()}@#{sendingdomain}>") end if(options[:date]) if(get_header("Date").length == 0) add_header("Date", Time.now.strftime("%a, %d %b %Y %H:%M:%S %z")) end end # Add a mime header if we don't already have one and we have multiple parts if(multipart?()) if(get_header("MIME-Version").length == 0) add_header("MIME-Version", "1.0") end if(get_header("Content-Type").length == 0) if(@attachments.length == 0) add_header("Content-Type", "multipart/alternative;boundary=\"#{@bodyboundary}\"") else add_header("Content-Type", "multipart/mixed; boundary=\"#{@attachmentboundary}\"") end end end return("#{headers_to_s()}#{body_to_s()}") end # returns a formatted email - equivalent to construct(:messageid => true) def to_s() return(construct(:messageid => true)) end # generates a unique boundary string def generate_boundary() randomstring = Array.new() 1.upto(25) { whichglyph = rand(100) if(whichglyph < 40) randomstring << (rand(25) + 65).chr() elsif(whichglyph < 70) randomstring << (rand(25) + 97).chr() elsif(whichglyph < 90) randomstring << (rand(10) + 48).chr() elsif(whichglyph < 95) randomstring << '.' else randomstring << '_' end } return("----=_NextPart_#{randomstring.join()}") end # adds an attachment to the mail. Type may be given as a mime type. If it # is left off and the MIME::Types module is available it will be determined automagically. # If the optional attachemntheaders is given, then they will be added to the attachment # boundary in the email, which can be used to produce Content-ID markers. attachmentheaders # can be given as an Array or a String. def add_attachment(filename, type=nil, attachmentheaders = nil) attachment = Hash.new() attachment['filename'] = Pathname.new(filename).basename if(type == nil) attachment['mimetype'] = MIME::Types.type_for(filename).to_s else attachment['mimetype'] = type end # Open in rb mode to handle Windows, which mangles binary files opened in a text mode File.open(filename, "rb") { |fp| attachment['attachment'] = file_encode(fp.read()) } if(attachmentheaders != nil) if(!attachmentheaders.kind_of?(Array)) attachmentheaders = attachmentheaders.split(/\r?\n/) end attachment['headers'] = attachmentheaders end @attachments << attachment end # adds an attachment to the mail as emailfilename. Type may be given as a mime type. If it # is left off and the MIME::Types module is available it will be determined automagically. # file may be given as an IO stream (which will be read until the end) or as a filename. # If the optional attachemntheaders is given, then they will be added to the attachment # boundary in the email, which can be used to produce Content-ID markers. attachmentheaders # can be given as an Array of a String. def add_attachment_as(file, emailfilename, type=nil, attachmentheaders = nil) attachment = Hash.new() attachment['filename'] = emailfilename if(type != nil) attachment['mimetype'] = type.to_s() elsif(file.kind_of?(String) or file.kind_of?(Pathname)) attachment['mimetype'] = MIME::Types.type_for(file.to_s()).to_s else attachment['mimetype'] = '' end if(file.kind_of?(String) or file.kind_of?(Pathname)) # Open in rb mode to handle Windows, which mangles binary files opened in a text mode File.open(file.to_s(), "rb") { |fp| attachment['attachment'] = file_encode(fp.read()) } elsif(file.respond_to?(:read)) attachment['attachment'] = file_encode(file.read()) else raise(Exception, "file is not a supported type (must be a String, Pathnamem, or support read method)") end if(attachmentheaders != nil) if(!attachmentheaders.kind_of?(Array)) attachmentheaders = attachmentheaders.split(/\r?\n/) end attachment['headers'] = attachmentheaders end @attachments << attachment end alias attach add_attachment alias attach_as add_attachment_as protected # returns the @headers as a properly formatted string def headers_to_s() return("#{@headers.join("\r\n")}\r\n\r\n") end # returns the body as a properly formatted string def body_to_s() body = Array.new() # simple message with one part if(!multipart?()) return(@text) else body << "This is a multi-part message in MIME format.\r\n\r\n--#{@attachmentboundary}\r\nContent-Type: multipart/alternative; boundary=\"#{@bodyboundary}\"" if(@attachments.length > 0) # text part body << "#{buildbodyboundary('text/plain; charset=ISO-8859-1; format=flowed', '7bit')}\r\n\r\n#{@text}" # html part if one is provided if @html body << "#{buildbodyboundary('text/html; charset=ISO-8859-1', '7bit')}\r\n\r\n#{@html}" end body << "--#{@bodyboundary}--" # and, the attachments if(@attachments.length > 0) @attachments.each() { |attachment| body << "#{buildattachmentboundary(attachment)}\r\n\r\n#{attachment['attachment']}" } body << "\r\n--#{@attachmentboundary}--" end else # text part body << "#{buildbodyboundary('text/plain; charset=ISO-8859-1; format=flowed', '7bit')}\r\n\r\n#{@text}" # html part body << "#{buildbodyboundary('text/html; charset=ISO-8859-1', '7bit')}\r\n\r\n#{@html}" body << "--#{@bodyboundary}--" end return(body.join("\r\n\r\n")) end end # builds a boundary string for including attachments in the body, expects an attachment hash as built by # add_attachment and add_attachment_as def buildattachmentboundary(attachment) disposition = "Content-Disposition: inline; filename=\"#{attachment['filename']}\"" boundary = "--#{@attachmentboundary}\r\nContent-Type: #{attachment['mimetype']}; name=\"#{attachment['filename']}\"\r\nContent-Transfer-Encoding: base64\r\n#{disposition}" if(attachment['headers']) boundary = boundary + "\r\n#{attachment['headers'].join("\r\n")}" end return(boundary) end # builds a boundary string for inclusion in the body of a message def buildbodyboundary(type, encoding) return("--#{@bodyboundary}\r\nContent-Type: #{type}\r\nContent-Transfer-Encoding: #{encoding}") end # returns a base64 encoded version of the contents of str def file_encode(str) collection = Array.new() enc = Base64.encode64(str) # while(enc.length > 60) # collection << enc.slice!(0..59) # end # collection << enc # return(collection.join("\n")) return(enc) end end #--}}} } __END__ ### rqmailer CMD: <%= cmd %> EXIT_STATUS: <%= exit_status %> STDIN: <%= stdin %> STDOUT: <%= stdout %> STDERR: <%= stderr %>