pop.rb   [plain text]


# = net/pop.rb
#
# Copyright (c) 1999-2003 Yukihiro Matsumoto.
#
# Copyright (c) 1999-2003 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.
# 
# NOTE: You can find Japanese version of this document in
# the doc/net directory of the standard ruby interpreter package.
# 
#   $Id: pop.rb 11708 2007-02-12 23:01:19Z shyouhei $
#
# See Net::POP3 for documentation.
#

require 'net/protocol'
require 'digest/md5'

module Net

  # Non-authentication POP3 protocol error
  # (reply code "-ERR", except authentication).
  class POPError < ProtocolError; end

  # POP3 authentication error.
  class POPAuthenticationError < ProtoAuthError; end

  # Unexpected response from the server.
  class POPBadResponse < POPError; end

  #
  # = Net::POP3
  #
  # == What is This Library?
  # 
  # This library provides functionality for retrieving 
  # email via POP3, the Post Office Protocol version 3. For details
  # of POP3, see [RFC1939] (http://www.ietf.org/rfc/rfc1939.txt).
  # 
  # == Examples
  # 
  # === Retrieving Messages 
  # 
  # This example retrieves messages from the server and deletes them 
  # on the server.
  #
  # Messages are written to files named 'inbox/1', 'inbox/2', ....
  # Replace 'pop.example.com' with your POP3 server address, and
  # 'YourAccount' and 'YourPassword' with the appropriate account
  # details.
  # 
  #     require 'net/pop'
  # 
  #     pop = Net::POP3.new('pop.example.com')
  #     pop.start('YourAccount', 'YourPassword')             # (1)
  #     if pop.mails.empty?
  #       puts 'No mail.'
  #     else
  #       i = 0
  #       pop.each_mail do |m|   # or "pop.mails.each ..."   # (2)
  #         File.open("inbox/#{i}", 'w') do |f|
  #           f.write m.pop
  #         end
  #         m.delete
  #         i += 1
  #       end
  #       puts "#{pop.mails.size} mails popped."
  #     end
  #     pop.finish                                           # (3)
  # 
  # 1. Call Net::POP3#start and start POP session.
  # 2. Access messages by using POP3#each_mail and/or POP3#mails.
  # 3. Close POP session by calling POP3#finish or use the block form of #start.
  # 
  # === Shortened Code
  # 
  # The example above is very verbose. You can shorten the code by using
  # some utility methods. First, the block form of Net::POP3.start can
  # be used instead of POP3.new, POP3#start and POP3#finish.
  # 
  #     require 'net/pop'
  # 
  #     Net::POP3.start('pop.example.com', 110,
  #                     'YourAccount', 'YourPassword') do |pop|
  #       if pop.mails.empty?
  #         puts 'No mail.'
  #       else
  #         i = 0
  #         pop.each_mail do |m|   # or "pop.mails.each ..."
  #           File.open("inbox/#{i}", 'w') do |f|
  #             f.write m.pop
  #           end
  #           m.delete
  #           i += 1
  #         end
  #         puts "#{pop.mails.size} mails popped."
  #       end
  #     end
  # 
  # POP3#delete_all is an alternative for #each_mail and #delete.
  # 
  #     require 'net/pop'
  # 
  #     Net::POP3.start('pop.example.com', 110,
  #                     'YourAccount', 'YourPassword') do |pop|
  #       if pop.mails.empty?
  #         puts 'No mail.'
  #       else
  #         i = 1
  #         pop.delete_all do |m|
  #           File.open("inbox/#{i}", 'w') do |f|
  #             f.write m.pop
  #           end
  #           i += 1
  #         end
  #       end
  #     end
  # 
  # And here is an even shorter example.
  # 
  #     require 'net/pop'
  # 
  #     i = 0
  #     Net::POP3.delete_all('pop.example.com', 110,
  #                          'YourAccount', 'YourPassword') do |m|
  #       File.open("inbox/#{i}", 'w') do |f|
  #         f.write m.pop
  #       end
  #       i += 1
  #     end
  # 
  # === Memory Space Issues
  # 
  # All the examples above get each message as one big string.
  # This example avoids this.
  # 
  #     require 'net/pop'
  # 
  #     i = 1
  #     Net::POP3.delete_all('pop.example.com', 110,
  #                          'YourAccount', 'YourPassword') do |m|
  #       File.open("inbox/#{i}", 'w') do |f|
  #         m.pop do |chunk|    # get a message little by little.
  #           f.write chunk
  #         end
  #         i += 1
  #       end
  #     end
  # 
  # === Using APOP
  # 
  # The net/pop library supports APOP authentication.
  # To use APOP, use the Net::APOP class instead of the Net::POP3 class.
  # You can use the utility method, Net::POP3.APOP(). For example:
  # 
  #     require 'net/pop'
  # 
  #     # Use APOP authentication if $isapop == true
  #     pop = Net::POP3.APOP($is_apop).new('apop.example.com', 110)
  #     pop.start(YourAccount', 'YourPassword') do |pop|
  #       # Rest of the code is the same.
  #     end
  # 
  # === Fetch Only Selected Mail Using 'UIDL' POP Command
  # 
  # If your POP server provides UIDL functionality,
  # you can grab only selected mails from the POP server.
  # e.g.
  # 
  #     def need_pop?( id )
  #       # determine if we need pop this mail...
  #     end
  # 
  #     Net::POP3.start('pop.example.com', 110,
  #                     'Your account', 'Your password') do |pop|
  #       pop.mails.select { |m| need_pop?(m.unique_id) }.each do |m|
  #         do_something(m.pop)
  #       end
  #     end
  # 
  # The POPMail#unique_id() method returns the unique-id of the message as a
  # String. Normally the unique-id is a hash of the message.
  # 
  class POP3 < Protocol

    Revision = %q$Revision: 11708 $.split[1]

    #
    # Class Parameters
    #

    # The default port for POP3 connections, port 110
    def POP3.default_port
      110
    end

    def POP3.socket_type   #:nodoc: obsolete
      Net::InternetMessageIO
    end

    #
    # Utilities
    #

    # Returns the APOP class if +isapop+ is true; otherwise, returns
    # the POP class.  For example:
    #
    #     # Example 1
    #     pop = Net::POP3::APOP($is_apop).new(addr, port)
    #
    #     # Example 2
    #     Net::POP3::APOP($is_apop).start(addr, port) do |pop|
    #       ....
    #     end
    #
    def POP3.APOP( isapop )
      isapop ? APOP : POP3
    end

    # Starts a POP3 session and iterates over each POPMail object,
    # yielding it to the +block+.
    # This method is equivalent to:
    #
    #     Net::POP3.start(address, port, account, password) do |pop|
    #       pop.each_mail do |m|
    #         yield m
    #       end
    #     end
    #
    # This method raises a POPAuthenticationError if authentication fails.
    #
    # === Example
    #
    #     Net::POP3.foreach('pop.example.com', 110,
    #                       'YourAccount', 'YourPassword') do |m|
    #       file.write m.pop
    #       m.delete if $DELETE
    #     end
    #
    def POP3.foreach( address, port = nil,
                      account = nil, password = nil,
                      isapop = false, &block )  # :yields: message
      start(address, port, account, password, isapop) {|pop|
        pop.each_mail(&block)
      }
    end

    # Starts a POP3 session and deletes all messages on the server.
    # If a block is given, each POPMail object is yielded to it before
    # being deleted.
    #
    # This method raises a POPAuthenticationError if authentication fails.
    #
    # === Example
    #
    #     Net::POP3.delete_all('pop.example.com', 110,
    #                          'YourAccount', 'YourPassword') do |m|
    #       file.write m.pop
    #     end
    #
    def POP3.delete_all( address, port = nil,
                         account = nil, password = nil,
                         isapop = false, &block )
      start(address, port, account, password, isapop) {|pop|
        pop.delete_all(&block)
      }
    end

    # Opens a POP3 session, attempts authentication, and quits.
    #
    # This method raises POPAuthenticationError if authentication fails.
    #
    # === Example: normal POP3
    #
    #     Net::POP3.auth_only('pop.example.com', 110,
    #                         'YourAccount', 'YourPassword')
    #
    # === Example: APOP
    #
    #     Net::POP3.auth_only('pop.example.com', 110,
    #                         'YourAccount', 'YourPassword', true)
    #
    def POP3.auth_only( address, port = nil,
                        account = nil, password = nil,
                        isapop = false )
      new(address, port, isapop).auth_only account, password
    end

    # Starts a pop3 session, attempts authentication, and quits.
    # This method must not be called while POP3 session is opened.
    # This method raises POPAuthenticationError if authentication fails.
    def auth_only( account, password )
      raise IOError, 'opening previously opened POP session' if started?
      start(account, password) {
        ;
      }
    end

    #
    # Session management
    #

    # Creates a new POP3 object and open the connection.  Equivalent to 
    #
    #   Net::POP3.new(address, port, isapop).start(account, password)
    #
    # If +block+ is provided, yields the newly-opened POP3 object to it,
    # and automatically closes it at the end of the session.
    #
    # === Example
    #
    #    Net::POP3.start(addr, port, account, password) do |pop|
    #      pop.each_mail do |m|
    #        file.write m.pop
    #        m.delete
    #      end
    #    end
    #
    def POP3.start( address, port = nil,
                    account = nil, password = nil,
                    isapop = false, &block ) # :yield: pop
      new(address, port, isapop).start(account, password, &block)
    end

    # Creates a new POP3 object.
    #
    # +address+ is the hostname or ip address of your POP3 server.
    #
    # The optional +port+ is the port to connect to; it defaults to 110.
    #
    # The optional +isapop+ specifies whether this connection is going
    # to use APOP authentication; it defaults to +false+.
    #
    # This method does *not* open the TCP connection.
    def initialize( addr, port = nil, isapop = false )
      @address = addr
      @port = port || self.class.default_port
      @apop = isapop

      @command = nil
      @socket = nil
      @started = false
      @open_timeout = 30
      @read_timeout = 60
      @debug_output = nil

      @mails = nil
      @n_mails = nil
      @n_bytes = nil
    end

    # Does this instance use APOP authentication?
    def apop?
      @apop
    end

    # Provide human-readable stringification of class state.
    def inspect
      "#<#{self.class} #{@address}:#{@port} open=#{@started}>"
    end

    # *WARNING*: This method causes a serious security hole.
    # Use this method only for debugging.
    #
    # Set an output stream for debugging.
    #
    # === Example
    #
    #   pop = Net::POP.new(addr, port)
    #   pop.set_debug_output $stderr
    #   pop.start(account, passwd) do |pop|
    #     ....
    #   end
    #
    def set_debug_output( arg )
      @debug_output = arg
    end

    # The address to connect to.
    attr_reader :address

    # The port number to connect to.
    attr_reader :port

    # Seconds to wait until a connection is opened.
    # If the POP3 object cannot open a connection within this time,
    # it raises a TimeoutError exception.
    attr_accessor :open_timeout

    # Seconds to wait until reading one block (by one read(1) call).
    # If the POP3 object cannot complete a read() within this time,
    # it raises a TimeoutError exception.
    attr_reader :read_timeout

    # Set the read timeout.
    def read_timeout=( sec )
      @command.socket.read_timeout = sec if @command
      @read_timeout = sec
    end

    # +true+ if the POP3 session has started.
    def started?
      @started
    end

    alias active? started?   #:nodoc: obsolete

    # Starts a POP3 session.
    #
    # When called with block, gives a POP3 object to the block and
    # closes the session after block call finishes.
    #
    # This method raises a POPAuthenticationError if authentication fails.
    def start( account, password ) # :yield: pop
      raise IOError, 'POP session already started' if @started

      if block_given?
        begin
          do_start account, password
          return yield(self)
        ensure
          do_finish
        end
      else
        do_start account, password
        return self
      end
    end

    def do_start( account, password )
      @socket = self.class.socket_type.old_open(@address, @port,
                                   @open_timeout, @read_timeout, @debug_output)
      on_connect
      @command = POP3Command.new(@socket)
      if apop?
        @command.apop account, password
      else
        @command.auth account, password
      end
      @started = true
    ensure
      do_finish if not @started
    end
    private :do_start

    def on_connect
    end
    private :on_connect

    # Finishes a POP3 session and closes TCP connection.
    def finish
      raise IOError, 'POP session not yet started' unless started?
      do_finish
    end

    def do_finish
      @mails = nil
      @command.quit if @command
    ensure
      @started = false
      @command = nil
      @socket.close if @socket and not @socket.closed?
      @socket = nil
    end
    private :do_finish

    def command
      raise IOError, 'POP session not opened yet' \
                                      if not @socket or @socket.closed?
      @command
    end
    private :command

    #
    # POP protocol wrapper
    #

    # Returns the number of messages on the POP server.
    def n_mails
      return @n_mails if @n_mails
      @n_mails, @n_bytes = command().stat
      @n_mails
    end

    # Returns the total size in bytes of all the messages on the POP server.
    def n_bytes
      return @n_bytes if @n_bytes
      @n_mails, @n_bytes = command().stat
      @n_bytes
    end

    # Returns an array of Net::POPMail objects, representing all the
    # messages on the server.  This array is renewed when the session
    # restarts; otherwise, it is fetched from the server the first time
    # this method is called (directly or indirectly) and cached.
    #
    # This method raises a POPError if an error occurs.
    def mails
      return @mails.dup if @mails
      if n_mails() == 0
        # some popd raises error for LIST on the empty mailbox.
        @mails = []
        return []
      end

      @mails = command().list.map {|num, size|
        POPMail.new(num, size, self, command())
      }
      @mails.dup
    end

    # Yields each message to the passed-in block in turn.
    # Equivalent to:
    # 
    #   pop3.mails.each do |popmail|
    #     ....
    #   end
    #
    # This method raises a POPError if an error occurs.
    def each_mail( &block )  # :yield: message
      mails().each(&block)
    end

    alias each each_mail

    # Deletes all messages on the server.
    #
    # If called with a block, yields each message in turn before deleting it.
    #
    # === Example
    #
    #     n = 1
    #     pop.delete_all do |m|
    #       File.open("inbox/#{n}") do |f|
    #         f.write m.pop
    #       end
    #       n += 1
    #     end
    #
    # This method raises a POPError if an error occurs.
    #
    def delete_all # :yield: message
      mails().each do |m|
        yield m if block_given?
        m.delete unless m.deleted?
      end
    end

    # Resets the session.  This clears all "deleted" marks from messages.
    #
    # This method raises a POPError if an error occurs.
    def reset
      command().rset
      mails().each do |m|
        m.instance_eval {
          @deleted = false
        }
      end
    end

    def set_all_uids   #:nodoc: internal use only (called from POPMail#uidl)
      command().uidl.each do |num, uid|
        @mails.find {|m| m.number == num }.uid = uid
      end
    end

  end   # class POP3

  # class aliases
  POP = POP3
  POPSession  = POP3
  POP3Session = POP3

  #
  # This class is equivalent to POP3, except that it uses APOP authentication.
  #
  class APOP < POP3
    # Always returns true.
    def apop?
      true
    end
  end

  # class aliases
  APOPSession = APOP

  #
  # This class represents a message which exists on the POP server.
  # Instances of this class are created by the POP3 class; they should
  # not be directly created by the user.
  #
  class POPMail

    def initialize( num, len, pop, cmd )   #:nodoc:
      @number = num
      @length = len
      @pop = pop
      @command = cmd
      @deleted = false
      @uid = nil
    end

    # The sequence number of the message on the server.
    attr_reader :number

    # The length of the message in octets.
    attr_reader :length
    alias size length

    # Provide human-readable stringification of class state.
    def inspect
      "#<#{self.class} #{@number}#{@deleted ? ' deleted' : ''}>"
    end

    #
    # This method fetches the message.  If called with a block, the
    # message is yielded to the block one chunk at a time.  If called
    # without a block, the message is returned as a String.  The optional 
    # +dest+ argument will be prepended to the returned String; this
    # argument is essentially obsolete.
    #
    # === Example without block
    #
    #     POP3.start('pop.example.com', 110,
    #                'YourAccount, 'YourPassword') do |pop|
    #       n = 1
    #       pop.mails.each do |popmail|
    #         File.open("inbox/#{n}", 'w') do |f|
    #           f.write popmail.pop              
    #         end
    #         popmail.delete
    #         n += 1
    #       end
    #     end
    #
    # === Example with block
    #
    #     POP3.start('pop.example.com', 110,
    #                'YourAccount, 'YourPassword') do |pop|
    #       n = 1
    #       pop.mails.each do |popmail|
    #         File.open("inbox/#{n}", 'w') do |f|
    #           popmail.pop do |chunk|            ####
    #             f.write chunk
    #           end
    #         end
    #         n += 1
    #       end
    #     end
    #
    # This method raises a POPError if an error occurs.
    #
    def pop( dest = '', &block ) # :yield: message_chunk
      if block_given?
        @command.retr(@number, &block)
        nil
      else
        @command.retr(@number) do |chunk|
          dest << chunk
        end
        dest
      end
    end

    alias all pop    #:nodoc: obsolete
    alias mail pop   #:nodoc: obsolete

    # Fetches the message header and +lines+ lines of body. 
    #
    # The optional +dest+ argument is obsolete.
    #
    # This method raises a POPError if an error occurs.
    def top( lines, dest = '' )
      @command.top(@number, lines) do |chunk|
        dest << chunk
      end
      dest
    end

    # Fetches the message header.     
    #
    # The optional +dest+ argument is obsolete.
    #
    # This method raises a POPError if an error occurs.
    def header( dest = '' )
      top(0, dest)
    end

    # Marks a message for deletion on the server.  Deletion does not
    # actually occur until the end of the session; deletion may be
    # cancelled for _all_ marked messages by calling POP3#reset().
    #
    # This method raises a POPError if an error occurs.
    #
    # === Example
    #
    #     POP3.start('pop.example.com', 110,
    #                'YourAccount, 'YourPassword') do |pop|
    #       n = 1
    #       pop.mails.each do |popmail|
    #         File.open("inbox/#{n}", 'w') do |f|
    #           f.write popmail.pop
    #         end
    #         popmail.delete         ####
    #         n += 1
    #       end
    #     end
    #
    def delete
      @command.dele @number
      @deleted = true
    end

    alias delete! delete    #:nodoc: obsolete

    # True if the mail has been deleted.
    def deleted?
      @deleted
    end

    # Returns the unique-id of the message.
    # Normally the unique-id is a hash string of the message.
    #
    # This method raises a POPError if an error occurs.
    def unique_id
      return @uid if @uid
      @pop.set_all_uids
      @uid
    end

    alias uidl unique_id

    def uid=( uid )   #:nodoc: internal use only (used from POP3#set_all_uids)
      @uid = uid
    end

  end   # class POPMail


  class POP3Command   #:nodoc: internal use only

    def initialize( sock )
      @socket = sock
      @error_occured = false
      res = check_response(critical { recv_response() })
      @apop_stamp = res.slice(/<.+>/)
    end

    def inspect
      "#<#{self.class} socket=#{@socket}>"
    end

    def auth( account, password )
      check_response_auth(critical {
        check_response_auth(get_response('USER %s', account))
        get_response('PASS %s', password)
      })
    end

    def apop( account, password )
      raise POPAuthenticationError, 'not APOP server; cannot login' \
                                                      unless @apop_stamp
      check_response_auth(critical {
        get_response('APOP %s %s',
                     account,
                     Digest::MD5.hexdigest(@apop_stamp + password))
      })
    end

    def list
      critical {
        getok 'LIST'
        list = []
        @socket.each_list_item do |line|
          m = /\A(\d+)[ \t]+(\d+)/.match(line) or
                  raise POPBadResponse, "bad response: #{line}"
          list.push  [m[1].to_i, m[2].to_i]
        end
        return list
      }
    end

    def stat
      res = check_response(critical { get_response('STAT') })
      m = /\A\+OK\s+(\d+)\s+(\d+)/.match(res) or
              raise POPBadResponse, "wrong response format: #{res}"
      [m[1].to_i, m[2].to_i]
    end

    def rset
      check_response(critical { get_response 'RSET' })
    end

    def top( num, lines = 0, &block )
      critical {
        getok('TOP %d %d', num, lines)
        @socket.each_message_chunk(&block)
      }
    end

    def retr( num, &block )
      critical {
        getok('RETR %d', num)
        @socket.each_message_chunk(&block)
      }
    end
    
    def dele( num )
      check_response(critical { get_response('DELE %d', num) })
    end

    def uidl( num = nil )
      if num
        res = check_response(critical { get_response('UIDL %d', num) })
        return res.split(/ /)[1]
      else
        critical {
          getok('UIDL')
          table = {}
          @socket.each_list_item do |line|
            num, uid = line.split
            table[num.to_i] = uid
          end
          return table
        }
      end
    end

    def quit
      check_response(critical { get_response('QUIT') })
    end

    private

    def getok( fmt, *fargs )
      @socket.writeline sprintf(fmt, *fargs)
      check_response(recv_response())
    end

    def get_response( fmt, *fargs )
      @socket.writeline sprintf(fmt, *fargs)
      recv_response()
    end

    def recv_response
      @socket.readline
    end

    def check_response( res )
      raise POPError, res unless /\A\+OK/i === res
      res
    end

    def check_response_auth( res )
      raise POPAuthenticationError, res unless /\A\+OK/i === res
      res
    end

    def critical
      return '+OK dummy ok response' if @error_occured
      begin
        return yield()
      rescue Exception
        @error_occured = true
        raise
      end
    end

  end   # class POP3Command

end   # module Net