eventmachine/eventmachine

View on GitHub
lib/em/protocols/smtpclient.rb

Summary

Maintainability
B
5 hrs
Test Coverage
#--
#
# Author:: Francis Cianfrocca (gmail: blackhedd)
# Homepage::  http://rubyeventmachine.com
# Date:: 16 July 2006
#
# See EventMachine and EventMachine::Connection for documentation and
# usage examples.
#
#----------------------------------------------------------------------------
#
# Copyright (C) 2006-07 by Francis Cianfrocca. All Rights Reserved.
# Gmail: blackhedd
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of either: 1) the GNU General Public License
# as published by the Free Software Foundation; either version 2 of the
# License, or (at your option) any later version; or 2) Ruby's License.
#
# See the file COPYING for complete licensing information.
#
#---------------------------------------------------------------------------
#
#

require 'ostruct'

module EventMachine
  module Protocols

    # Simple SMTP client
    #
    # @example
    #   email = EM::Protocols::SmtpClient.send(
    #     :domain=>"example.com",
    #     :host=>'localhost',
    #     :port=>25, # optional, defaults 25
    #     :starttls=>true, # use ssl
    #     :from=>"sender@example.com",
    #     :to=> ["to_1@example.com", "to_2@example.com"],
    #     :header=> {"Subject" => "This is a subject line"},
    #     :body=> "This is the body of the email"
    #   )
    #   email.callback{
    #     puts 'Email sent!'
    #   }
    #   email.errback{ |e|
    #     puts 'Email failed!'
    #   }
    #
    # Sending generated emails (using Mail)
    #
    #   mail = Mail.new do
    #     from    'alice@example.com'
    #     to      'bob@example.com'
    #     subject 'This is a test email'
    #     body    'Hello, world!'
    #   end
    #
    #   email = EM::P::SmtpClient.send(
    #     :domain=>'example.com',
    #     :from=>mail.from.first,
    #     :to=>mail.to,
    #     :message=>mail.to_s
    #   )
    #
    class SmtpClient < Connection
      include EventMachine::Deferrable
      include EventMachine::Protocols::LineText2

      def initialize
        @succeeded = nil
        @responder = nil
        @code = nil
        @msg = nil
      end

      # :host => required String
      #   a string containing the IP address or host name of the SMTP server to connect to.
      # :port => optional
      #   defaults to 25.
      # :domain => required String
      #   This is passed as the argument to the EHLO command.
      # :starttls => optional Boolean
      #   If it evaluates true, then the client will initiate STARTTLS with
      #   the server, and abort the connection if the negotiation doesn't succeed.
      #   TODO, need to be able to pass certificate parameters with this option.
      # :auth => optional Hash of auth parameters
      #   If not given, then no auth will be attempted.
      #   (In that case, the connection will be aborted if the server requires auth.)
      #   Specify the hash value :type to determine the auth type, along with additional parameters
      #   depending on the type.
      #   Currently only :type => :plain is supported. Pass additional parameters :username (String),
      #   and :password (either a String or a Proc that will be called at auth-time).
      #
      #   @example
      #     :auth => {:type=>:plain, :username=>"mickey@disney.com", :password=>"mouse"}
      #
      # :from => required String
      #   Specifies the sender of the message. Will be passed as the argument
      #   to the MAIL FROM. Do NOT enclose the argument in angle-bracket (<>) characters.
      #   The connection will abort if the server rejects the value.
      # :to => required String or Array of Strings
      #   The recipient(s) of the message. Do NOT enclose
      #   any of the values in angle-brackets (<>) characters. It's NOT a fatal error if one or more
      #   recipients are rejected by the server. (Of course, if ALL of them are, the server will most
      #   likely trigger an error when we try to send data.) An array of codes containing the status
      #   of each requested recipient is available after the call completes. TODO, we should define
      #   an overridable stub that will be called on rejection of a recipient or a sender, giving
      #   user code the chance to try again or abort the connection.
      #
      # One of either :message, :content, or :header and :body is required:
      #
      # :message => String
      #   A valid RFC2822 Internet Message.
      # :content => String
      #   Raw data which MUST be in correct SMTP body format, with escaped leading dots and a trailing
      #   dot line.
      # :header => String or Hash of values to be transmitted in the header of the message.
      #   The hash keys are the names of the headers (do NOT append a trailing colon), and the values
      #   are strings containing the header values. TODO, support Arrays of header values, which would
      #   cause us to send that specific header line more than once.
      #
      #   @example
      #     :header => {"Subject" => "Bogus", "CC" => "myboss@example.com"}
      #
      # :body => Optional String or Array of Strings, defaults blank.
      #   This will be passed as the body of the email message.
      #   TODO, this needs to be significantly beefed up. As currently written, this requires the caller
      #   to properly format the input into CRLF-delimited lines of 7-bit characters in the standard
      #   SMTP transmission format. We need to be able to automatically convert binary data, and add
      #   correct line-breaks to text data.
      #
      # :verbose => Optional.
      #   If true, will cause a lot of information (including the server-side of the
      #   conversation) to be dumped to $>.
      #
      def self.send args={}
        args[:port] ||= 25
        args[:body] ||= ""

=begin
      (I don't think it's possible for EM#connect to throw an exception under normal
      circumstances, so this original code is stubbed out. A connect-failure will result
      in the #unbind method being called without calling #connection_completed.)
      begin
        EventMachine.connect( args[:host], args[:port], self) {|c|
          # According to the EM docs, we will get here AFTER post_init is called.
          c.args = args
          c.set_comm_inactivity_timeout 60
        }
      rescue
        # We'll get here on a connect error. This code mimics the effect
        # of a call to invoke_internal_error. Would be great to DRY this up.
        # (Actually, it may be that we never get here, if EM#connect catches
        # its errors internally.)
        d = EM::DefaultDeferrable.new
        d.set_deferred_status(:failed, {:error=>[:connect, 500, "unable to connect to server"]})
        d
      end
=end
        EventMachine.connect( args[:host], args[:port], self) {|c|
          # According to the EM docs, we will get here AFTER post_init is called.
          c.args = args
          c.set_comm_inactivity_timeout 60
        }
      end

      attr_writer :args

      # @private
      def post_init
        @return_values = OpenStruct.new
        @return_values.start_time = Time.now
      end

      # @private
      def connection_completed
        @responder = :receive_signon
        @msg = []
      end

      # We can get here in a variety of ways, all of them being failures unless
      # the @succeeded flag is set. If a protocol success was recorded, then don't
      # set a deferred success because the caller will already have done it
      # (no need to wait until the connection closes to invoke the callbacks).
      #
      # @private
      def unbind
        unless @succeeded
          @return_values.elapsed_time = Time.now - @return_values.start_time
          @return_values.responder = @responder
          @return_values.code = @code
          @return_values.message = @msg
          set_deferred_status(:failed, @return_values)
        end
      end

      # @private
      def receive_line ln
        $>.puts ln if @args[:verbose]
        @range = ln[0...1].to_i
        @code = ln[0...3].to_i
        @msg << ln[4..-1]
        unless ln[3...4] == '-'
          $>.puts @responder if @args[:verbose]
          send @responder
          @msg.clear
        end
      end

      private

      # We encountered an error from the server and will close the connection.
      # Use the error and message the server returned.
      #
      def invoke_error
        @return_values.elapsed_time = Time.now - @return_values.start_time
        @return_values.responder = @responder
        @return_values.code = @code
        @return_values.message = @msg
        set_deferred_status :failed, @return_values
        send_data "QUIT\r\n"
        close_connection_after_writing
      end

      # We encountered an error on our side of the protocol and will close the connection.
      # Use an extra-protocol error code (900) and use the message from the caller.
      #
      def invoke_internal_error msg = "???"
        @return_values.elapsed_time = Time.now - @return_values.start_time
        @return_values.responder = @responder
        @return_values.code = 900
        @return_values.message = msg
        set_deferred_status :failed, @return_values
        send_data "QUIT\r\n"
        close_connection_after_writing
      end

      def send_ehlo
        send_data "EHLO #{@args[:domain]}\r\n"
      end

      def receive_signon
        return invoke_error unless @range == 2
        send_ehlo
        @responder = :receive_ehlo_response
      end
      def receive_ehlo_response
        return invoke_error unless @range == 2
        @server_caps = @msg
        invoke_starttls
      end

      def invoke_starttls
        if @args[:starttls]
          # It would be more sociable to first ask if @server_caps contains
          # the string "STARTTLS" before we invoke it, but hey, life's too short.
          send_data "STARTTLS\r\n"
          @responder = :receive_starttls_response
        else
          invoke_auth
        end
      end
      def receive_starttls_response
        return invoke_error unless @range == 2
        start_tls
        invoke_ehlo_over_tls
      end

      def invoke_ehlo_over_tls
        send_ehlo
        @responder = :receive_ehlo_over_tls_response
      end
      def receive_ehlo_over_tls_response
        return invoke_error unless @range == 2
        invoke_auth
      end

      # Perform an authentication. If the caller didn't request one, then fall through
      # to the mail-from state.
      def invoke_auth
        if @args[:auth]
          if @args[:auth][:type] == :plain
            psw = @args[:auth][:password]
            if psw.respond_to?(:call)
              psw = psw.call
            end
            #str = Base64::encode64("\0#{@args[:auth][:username]}\0#{psw}").chomp
            str = ["\0#{@args[:auth][:username]}\0#{psw}"].pack("m").gsub(/\n/, '')
            send_data "AUTH PLAIN #{str}\r\n"
            @responder = :receive_auth_response
          else
            return invoke_internal_error("unsupported auth type")
          end
        else
          invoke_mail_from
        end
      end
      def receive_auth_response
        return invoke_error unless @range == 2
        invoke_mail_from
      end

      def invoke_mail_from
        send_data "MAIL FROM: <#{@args[:from]}>\r\n"
        @responder = :receive_mail_from_response
      end
      def receive_mail_from_response
        return invoke_error unless @range == 2
        invoke_rcpt_to
      end

      def invoke_rcpt_to
        @rcpt_responses ||= []
        l = @rcpt_responses.length
        to = @args[:to].is_a?(Array) ? @args[:to] : [@args[:to].to_s]
        if l < to.length
          send_data "RCPT TO: <#{to[l]}>\r\n"
          @responder = :receive_rcpt_to_response
        else
          e = @rcpt_responses.select {|rr| rr.last == 2}
          if e and e.length > 0
            invoke_data
          else
            invoke_error
          end
        end
      end
      def receive_rcpt_to_response
        @rcpt_responses << [@code, @msg, @range]
        invoke_rcpt_to
      end

      def escape_leading_dots(s)
        s.gsub(/^\./, '..')
      end

      def invoke_data
        send_data "DATA\r\n"
        @responder = :receive_data_response
      end
      def receive_data_response
        return invoke_error unless @range == 3

        # The data to send can be given in either @args[:message], @args[:content], or the
        # combination of @args[:header] and @args[:body].
        #
        #   - @args[:message] (String) MUST be a valid RFC2822 Internet Message
        #
        #   - @args[:content] (String) MUST be in correct SMTP body format, with escaped
        #     leading dots and a trailing dot line
        #
        #   - @args[:header]  (Hash or String)
        #   - @args[:body]    (Array or String)
        if @args[:message]
          send_data escape_leading_dots(@args[:message].to_s)
          send_data "\r\n.\r\n"
        elsif @args[:content]
          send_data @args[:content].to_s
        else
          # The header can be a hash or an array.
          if @args[:header].is_a?(Hash)
            (@args[:header] || {}).each {|k,v| send_data escape_leading_dots("#{k}: #{v}\r\n") }
          else
            send_data escape_leading_dots(@args[:header].to_s)
          end
          send_data "\r\n"

          if @args[:body].is_a?(Array)
            @args[:body].each {|e| send_data escape_leading_dots(e)}
          else
            send_data escape_leading_dots(@args[:body].to_s)
          end

          send_data "\r\n.\r\n"
        end

        @responder = :receive_message_response
      end
      def receive_message_response
        return invoke_error unless @range == 2
        send_data "QUIT\r\n"
        close_connection_after_writing
        @succeeded = true
        @return_values.elapsed_time = Time.now - @return_values.start_time
        @return_values.responder = @responder
        @return_values.code = @code
        @return_values.message = @msg
        set_deferred_status :succeeded, @return_values
      end
    end
  end
end