diff options
author | kazu <kazu@b2dd03c8-39d4-4d8f-98ff-823fe69b080e> | 2008-04-15 08:50:21 +0000 |
---|---|---|
committer | kazu <kazu@b2dd03c8-39d4-4d8f-98ff-823fe69b080e> | 2008-04-15 08:50:21 +0000 |
commit | f5f83a0004473e5224d71edec3ba76bf550a58d7 (patch) | |
tree | dc6985ed792e8f48fd73cb55d7cee43cae6692d8 /lib/net | |
parent | bb3f83697de448c0056ce9474d93b080520609db (diff) |
* lib/net/smtp.rb: backported from 1.9. bug#19003
git-svn-id: svn+ssh://ci.ruby-lang.org/ruby/branches/ruby_1_8@16034 b2dd03c8-39d4-4d8f-98ff-823fe69b080e
Diffstat (limited to 'lib/net')
-rw-r--r-- | lib/net/smtp.rb | 635 |
1 files changed, 476 insertions, 159 deletions
diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index dda9dab072..57dee9a496 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -1,19 +1,18 @@ # = net/smtp.rb # -# Copyright (c) 1999-2003 Yukihiro Matsumoto. +# Copyright (c) 1999-2007 Yukihiro Matsumoto. # -# Copyright (c) 1999-2003 Minero Aoki. +# Copyright (c) 1999-2007 Minero Aoki. # # Written & maintained by Minero Aoki <aamine@loveruby.net>. # # Documented by William Webber and Minero Aoki. # # This program is free software. You can re-distribute and/or -# modify this program under the same terms as Ruby itself, -# Ruby Distribute License or GNU General Public License. +# modify this program under the same terms as Ruby itself. # -# NOTE: You can find Japanese version of this document in -# the doc/net directory of the standard ruby interpreter package. +# NOTE: You can find Japanese version of this document at: +# http://www.ruby-lang.org/ja/man/html/net_smtp.html # # $Id$ # @@ -22,13 +21,18 @@ require 'net/protocol' require 'digest/md5' +require 'timeout' +begin + require 'openssl' +rescue LoadError +end module Net # Module mixed in to all SMTP error classes module SMTPError - # This *class* is module for some reason. - # In ruby 1.9.x, this module becomes a class. + # This *class* is a module for backward compatibility. + # In later release, this module becomes a class. end # Represents an SMTP authentication error. @@ -56,6 +60,11 @@ module Net include SMTPError end + # Command is not supported on server. + class SMTPUnsupportedCommand < ProtocolError + include SMTPError + end + # # = Net::SMTP # @@ -165,11 +174,29 @@ module Net Revision = %q$Revision$.split[1] - # The default SMTP port, port 25. + # The default SMTP port number, 25. def SMTP.default_port 25 end + # The default mail submission port number, 587. + def SMTP.default_submission_port + 587 + end + + # The default SMTPS port number, 465. + def SMTP.default_tls_port + 465 + end + + class << self + alias default_ssl_port default_tls_port + end + + def SMTP.default_ssl_context + OpenSSL::SSL::SSLContext.new + end + # # Creates a new Net::SMTP object. # @@ -181,18 +208,22 @@ module Net # SMTP.start instead of SMTP.new if you want to do everything # at once. Otherwise, follow SMTP.new with SMTP#start. # - def initialize( address, port = nil ) + def initialize(address, port = nil) @address = address @port = (port || SMTP.default_port) @esmtp = true + @capabilities = nil @socket = nil @started = false @open_timeout = 30 @read_timeout = 60 @error_occured = false @debug_output = nil + @tls = false + @starttls = false + @ssl_context = nil end - + # Provide human-readable stringification of class state. def inspect "#<#{self.class} #{@address}:#{@port} started=#{@started}>" @@ -210,12 +241,127 @@ module Net # object will automatically switch to plain SMTP mode and # retry (but not vice versa). # - def esmtp=( bool ) + def esmtp=(bool) @esmtp = bool end alias esmtp esmtp? + # true if server advertises STARTTLS. + # You cannot get valid value before opening SMTP session. + def capable_starttls? + capable?('STARTTLS') + end + + def capable?(key) + return nil unless @capabilities + @capabilities[key] ? true : false + end + private :capable? + + # true if server advertises AUTH PLAIN. + # You cannot get valid value before opening SMTP session. + def capable_plain_auth? + auth_capable?('PLAIN') + end + + # true if server advertises AUTH LOGIN. + # You cannot get valid value before opening SMTP session. + def capable_login_auth? + auth_capable?('LOGIN') + end + + # true if server advertises AUTH CRAM-MD5. + # You cannot get valid value before opening SMTP session. + def capable_cram_md5_auth? + auth_capable?('CRAM-MD5') + end + + def auth_capable?(type) + return nil unless @capabilities + return false unless @capabilities['AUTH'] + @capabilities['AUTH'].include?(type) + end + private :auth_capable? + + # Returns supported authentication methods on this server. + # You cannot get valid value before opening SMTP session. + def capable_auth_types + return [] unless @capabilities + return [] unless @capabilities['AUTH'] + @capabilities['AUTH'] + end + + # true if this object uses SMTP/TLS (SMTPS). + def tls? + @tls + end + + alias ssl? tls? + + # Enables SMTP/TLS (SMTPS: SMTP over direct TLS connection) for + # this object. Must be called before the connection is established + # to have any effect. +context+ is a OpenSSL::SSL::SSLContext object. + def enable_tls(context = SMTP.default_ssl_context) + raise 'openssl library not installed' unless defined?(OpenSSL) + raise ArgumentError, "SMTPS and STARTTLS is exclusive" if @starttls + @tls = true + @ssl_context = context + end + + alias enable_ssl enable_tls + + # Disables SMTP/TLS for this object. Must be called before the + # connection is established to have any effect. + def disable_tls + @tls = false + @ssl_context = nil + end + + alias disable_ssl disable_tls + + # Returns truth value if this object uses STARTTLS. + # If this object always uses STARTTLS, returns :always. + # If this object uses STARTTLS when the server support TLS, returns :auto. + def starttls? + @starttls + end + + # true if this object uses STARTTLS. + def starttls_always? + @starttls == :always + end + + # true if this object uses STARTTLS when server advertises STARTTLS. + def starttls_auto? + @starttls == :auto + end + + # Enables SMTP/TLS (STARTTLS) for this object. + # +context+ is a OpenSSL::SSL::SSLContext object. + def enable_starttls(context = SMTP.default_ssl_context) + raise 'openssl library not installed' unless defined?(OpenSSL) + raise ArgumentError, "SMTPS and STARTTLS is exclusive" if @tls + @starttls = :always + @ssl_context = context + end + + # Enables SMTP/TLS (STARTTLS) for this object if server accepts. + # +context+ is a OpenSSL::SSL::SSLContext object. + def enable_starttls_auto(context = SMTP.default_ssl_context) + raise 'openssl library not installed' unless defined?(OpenSSL) + raise ArgumentError, "SMTPS and STARTTLS is exclusive" if @tls + @starttls = :auto + @ssl_context = context + end + + # Disables SMTP/TLS (STARTTLS) for this object. Must be called + # before the connection is established to have any effect. + def disable_starttls + @starttls = false + @ssl_context = nil + end + # The address of the SMTP server to connect to. attr_reader :address @@ -234,7 +380,7 @@ module Net # Set the number of seconds to wait until timing-out a read(2) # call. - def read_timeout=( sec ) + def read_timeout=(sec) @socket.read_timeout = sec if @socket @read_timeout = sec end @@ -253,10 +399,12 @@ module Net # .... # end # - def set_debug_output( arg ) + def debug_output=(arg) @debug_output = arg end + alias set_debug_output debug_output= + # # SMTP session control # @@ -309,10 +457,9 @@ module Net # * IOError # * TimeoutError # - def SMTP.start( address, port = nil, - helo = 'localhost.localdomain', - user = nil, secret = nil, authtype = nil, - &block) # :yield: smtp + def SMTP.start(address, port = nil, helo = 'localhost.localdomain', + user = nil, secret = nil, authtype = nil, + &block) # :yield: smtp new(address, port).start(helo, user, secret, authtype, &block) end @@ -371,55 +518,89 @@ module Net # * IOError # * TimeoutError # - def start( helo = 'localhost.localdomain', - user = nil, secret = nil, authtype = nil ) # :yield: smtp + def start(helo = 'localhost.localdomain', + user = nil, secret = nil, authtype = nil) # :yield: smtp if block_given? begin - do_start(helo, user, secret, authtype) + do_start helo, user, secret, authtype return yield(self) ensure do_finish end else - do_start(helo, user, secret, authtype) + do_start helo, user, secret, authtype return self end end - def do_start( helodomain, user, secret, authtype ) - raise IOError, 'SMTP session already started' if @started - check_auth_args user, secret, authtype if user or secret + # Finishes the SMTP session and closes TCP connection. + # Raises IOError if not started. + def finish + raise IOError, 'not yet started' unless started? + do_finish + end - @socket = InternetMessageIO.old_open(@address, @port, - @open_timeout, @read_timeout, - @debug_output) - check_response(critical { recv_response() }) - begin - if @esmtp - ehlo helodomain - else - helo helodomain - end - rescue ProtocolError - if @esmtp - @esmtp = false - @error_occured = false - retry + private + + def do_start(helo_domain, user, secret, authtype) + raise IOError, 'SMTP session already started' if @started + if user or secret + check_auth_method(authtype || DEFAULT_AUTH_TYPE) + check_auth_args user, secret + end + s = timeout(@open_timeout) { TCPSocket.open(@address, @port) } + logging "Connection opened: #{@address}:#{@port}" + @socket = new_internet_message_io(tls? ? tlsconnect(s) : s) + check_response critical { recv_response() } + do_helo helo_domain + if starttls_always? or (capable_starttls? and starttls_auto?) + unless capable_starttls? + raise SMTPUnsupportedCommand, + "STARTTLS is not supported on this server" end - raise + starttls + @socket = new_internet_message_io(tlsconnect(s)) + # helo response may be different after STARTTLS + do_helo helo_domain end - authenticate user, secret, authtype if user + authenticate user, secret, (authtype || DEFAULT_AUTH_TYPE) if user @started = true ensure - @socket.close if not @started and @socket and not @socket.closed? + unless @started + # authentication failed, cancel connection. + s.close if s and not s.closed? + @socket = nil + end end - private :do_start - # Finishes the SMTP session and closes TCP connection. - # Raises IOError if not started. - def finish - raise IOError, 'not yet started' unless started? - do_finish + def tlsconnect(s) + s = OpenSSL::SSL::SSLSocket.new(s, @ssl_context) + logging "TLS connection started" + s.sync_close = true + s.connect + if @ssl_context.verify_mode != OpenSSL::SSL::VERIFY_NONE + s.post_connection_check(@address) + end + s + end + + def new_internet_message_io(s) + io = InternetMessageIO.new(s) + io.read_timeout = @read_timeout + io.debug_output = @debug_output + io + end + + def do_helo(helo_domain) + res = @esmtp ? ehlo(helo_domain) : helo(helo_domain) + @capabilities = res.capabilities + rescue SMTPError + if @esmtp + @esmtp = false + @error_occured = false + retry + end + raise end def do_finish @@ -430,10 +611,9 @@ module Net @socket.close if @socket and not @socket.closed? @socket = nil end - private :do_finish # - # message send + # Message Sending # public @@ -468,10 +648,11 @@ module Net # * IOError # * TimeoutError # - def send_message( msgstr, from_addr, *to_addrs ) - send0(from_addr, to_addrs.flatten) { - @socket.write_message msgstr - } + def send_message(msgstr, from_addr, *to_addrs) + raise IOError, 'closed session' unless @socket + mailfrom from_addr + rcptto_list to_addrs + data msgstr end alias send_mail send_message @@ -521,166 +702,220 @@ module Net # * IOError # * TimeoutError # - def open_message_stream( from_addr, *to_addrs, &block ) # :yield: stream - send0(from_addr, to_addrs.flatten) { - @socket.write_message_by_block(&block) - } - end - - alias ready open_message_stream # obsolete - - private - - def send0( from_addr, to_addrs ) + def open_message_stream(from_addr, *to_addrs, &block) # :yield: stream raise IOError, 'closed session' unless @socket - raise ArgumentError, 'mail destination not given' if to_addrs.empty? - if $SAFE > 0 - raise SecurityError, 'tainted from_addr' if from_addr.tainted? - to_addrs.each do |to| - raise SecurityError, 'tainted to_addr' if to.tainted? - end - end - mailfrom from_addr - to_addrs.each do |to| - rcptto to - end - res = critical { - check_response(get_response('DATA'), true) - yield - recv_response() - } - check_response(res) + rcptto_list to_addrs + data(&block) end + alias ready open_message_stream # obsolete + # - # auth + # Authentication # - private + public - def check_auth_args( user, secret, authtype ) - raise ArgumentError, 'both user and secret are required'\ - unless user and secret - auth_method = "auth_#{authtype || 'cram_md5'}" - raise ArgumentError, "wrong auth type #{authtype}"\ - unless respond_to?(auth_method, true) - end + DEFAULT_AUTH_TYPE = :plain - def authenticate( user, secret, authtype ) - __send__("auth_#{authtype || 'cram_md5'}", user, secret) + def authenticate(user, secret, authtype = DEFAULT_AUTH_TYPE) + check_auth_method authtype + check_auth_args user, secret + send auth_method(authtype), user, secret end - def auth_plain( user, secret ) - res = critical { get_response('AUTH PLAIN %s', - base64_encode("\0#{user}\0#{secret}")) } - raise SMTPAuthenticationError, res unless /\A2../ === res + def auth_plain(user, secret) + check_auth_args user, secret + res = critical { + get_response('AUTH PLAIN ' + base64_encode("\0#{user}\0#{secret}")) + } + check_auth_response res + res end - def auth_login( user, secret ) + def auth_login(user, secret) + check_auth_args user, secret res = critical { - check_response(get_response('AUTH LOGIN'), true) - check_response(get_response(base64_encode(user)), true) + check_auth_continue get_response('AUTH LOGIN') + check_auth_continue get_response(base64_encode(user)) get_response(base64_encode(secret)) } - raise SMTPAuthenticationError, res unless /\A2../ === res - end - - def auth_cram_md5( user, secret ) - # CRAM-MD5: [RFC2195] - res = nil - critical { - res = check_response(get_response('AUTH CRAM-MD5'), true) - challenge = res.split(/ /)[1].unpack('m')[0] - secret = Digest::MD5.digest(secret) if secret.size > 64 - - isecret = secret + "\0" * (64 - secret.size) - osecret = isecret.dup - 0.upto(63) do |i| - isecret[i] ^= 0x36 - osecret[i] ^= 0x5c - end - tmp = Digest::MD5.digest(isecret + challenge) - tmp = Digest::MD5.hexdigest(osecret + tmp) + check_auth_response res + res + end - res = get_response(base64_encode(user + ' ' + tmp)) + def auth_cram_md5(user, secret) + check_auth_args user, secret + res = critical { + res0 = get_response('AUTH CRAM-MD5') + check_auth_continue res0 + crammed = cram_md5_response(secret, res0.cram_md5_challenge) + get_response(base64_encode("#{user} #{crammed}")) } - raise SMTPAuthenticationError, res unless /\A2../ === res + check_auth_response res + res + end + + private + + def check_auth_method(type) + unless respond_to?(auth_method(type), true) + raise ArgumentError, "wrong authentication type #{type}" + end + end + + def auth_method(type) + "auth_#{type.to_s.downcase}".intern end - def base64_encode( str ) + def check_auth_args(user, secret) + unless user + raise ArgumentError, 'SMTP-AUTH requested but missing user name' + end + unless secret + raise ArgumentError, 'SMTP-AUTH requested but missing secret phrase' + end + end + + def base64_encode(str) # expects "str" may not become too long [str].pack('m').gsub(/\s+/, '') end + IMASK = 0x36 + OMASK = 0x5c + + # CRAM-MD5: [RFC2195] + def cram_md5_response(secret, challenge) + tmp = Digest::MD5.digest(cram_secret(secret, IMASK) + challenge) + Digest::MD5.hexdigest(cram_secret(secret, OMASK) + tmp) + end + + CRAM_BUFSIZE = 64 + + def cram_secret(secret, mask) + secret = Digest::MD5.digest(secret) if secret.size > CRAM_BUFSIZE + buf = secret.ljust(CRAM_BUFSIZE, "\0") + 0.upto(buf.size - 1) do |i| + buf[i] = (buf[i].ord ^ mask).chr + end + buf + end + # # SMTP command dispatcher # - private + public - def helo( domain ) - getok('HELO %s', domain) + def starttls + getok('STARTTLS') end - def ehlo( domain ) - getok('EHLO %s', domain) + def helo(domain) + getok("HELO #{domain}") end - def mailfrom( fromaddr ) - getok('MAIL FROM:<%s>', fromaddr) + def ehlo(domain) + getok("EHLO #{domain}") end - def rcptto( to ) - getok('RCPT TO:<%s>', to) + def mailfrom(from_addr) + if $SAFE > 0 + raise SecurityError, 'tainted from_addr' if from_addr.tainted? + end + getok("MAIL FROM:<#{from_addr}>") + end + + def rcptto_list(to_addrs) + raise ArgumentError, 'mail destination not given' if to_addrs.empty? + to_addrs.flatten.each do |addr| + rcptto addr + end + end + + def rcptto(to_addr) + if $SAFE > 0 + raise SecurityError, 'tainted to_addr' if to.tainted? + end + getok("RCPT TO:<#{to_addr}>") + end + + # This method sends a message. + # If +msgstr+ is given, sends it as a message. + # If block is given, yield a message writer stream. + # You must write message before the block is closed. + # + # # Example 1 (by string) + # smtp.data(<<EndMessage) + # From: john@example.com + # To: betty@example.com + # Subject: I found a bug + # + # Check vm.c:58879. + # EndMessage + # + # # Example 2 (by block) + # smtp.data {|f| + # f.puts "From: john@example.com" + # f.puts "To: betty@example.com" + # f.puts "Subject: I found a bug" + # f.puts "" + # f.puts "Check vm.c:58879." + # } + # + def data(msgstr = nil, &block) #:yield: stream + if msgstr and block + raise ArgumentError, "message and block are exclusive" + end + unless msgstr or block + raise ArgumentError, "message or block is required" + end + res = critical { + check_continue get_response('DATA') + if msgstr + @socket.write_message msgstr + else + @socket.write_message_by_block(&block) + end + recv_response() + } + check_response res + res end def quit getok('QUIT') end - # - # row level library - # - private - def getok( fmt, *args ) + def getok(reqline) res = critical { - @socket.writeline sprintf(fmt, *args) + @socket.writeline reqline recv_response() } - return check_response(res) + check_response res + res end - def get_response( fmt, *args ) - @socket.writeline sprintf(fmt, *args) + def get_response(reqline) + @socket.writeline reqline recv_response() end def recv_response - res = '' + buf = '' while true line = @socket.readline - res << line << "\n" - break unless line[3] == ?- # "210-PIPELINING" + buf << line << "\n" + break unless line[3,1] == '-' # "210-PIPELINING" end - res - end - - def check_response( res, allow_continue = false ) - return res if /\A2/ === res - return res if allow_continue and /\A3/ === res - err = case res - when /\A4/ then SMTPServerBusy - when /\A50/ then SMTPSyntaxError - when /\A55/ then SMTPFatalError - else SMTPUnknownError - end - raise err, res + Response.parse(buf) end - def critical( &block ) + def critical(&block) return '200 dummy reply code' if @error_occured begin return yield() @@ -690,8 +925,90 @@ module Net end end + def check_response(res) + unless res.success? + raise res.exception_class, res.message + end + end + + def check_continue(res) + unless res.continue? + raise SMTPUnknownError, "could not get 3xx (#{res.status})" + end + end + + def check_auth_response(res) + unless res.success? + raise SMTPAuthenticationError, res.message + end + end + + def check_auth_continue(res) + unless res.continue? + raise res.exception_class, res.message + end + end + + class Response + def Response.parse(str) + new(str[0,3], str) + end + + def initialize(status, string) + @status = status + @string = string + end + + attr_reader :status + attr_reader :string + + def status_type_char + @status[0, 1] + end + + def success? + status_type_char() == '2' + end + + def continue? + status_type_char() == '3' + end + + def message + @string.lines.first + end + + def cram_md5_challenge + @string.split(/ /)[1].unpack('m')[0] + end + + def capabilities + return {} unless @string[3, 1] == '-' + h = {} + @string.lines.drop(1).each do |line| + k, *v = line[4..-1].chomp.split(nil) + h[k] = v + end + h + end + + def exception_class + case @status + when /\A4/ then SMTPServerBusy + when /\A50/ then SMTPSyntaxError + when /\A53/ then SMTPAuthenticationError + when /\A5/ then SMTPFatalError + else SMTPUnknownError + end + end + end + + def logging(msg) + @debug_output << msg + "\n" if @debug_output + end + end # class SMTP SMTPSession = SMTP -end # module Net +end |