tmail/tmail/tmail.rb

#
# tmail.rb
#
#   Copyright (c) 1998-1999 Minero Aoki <aamine@dp.u-netsurf.ne.jp>
#
#   This program is free software.
#   You can distribute/modify this program under the terms of
#   the GNU Lesser General Public License version 2 or later.
#

require 'nkf'

require 'amstd/to_s'
require 'amstd/bug'
require 'amstd/must'

require 'tmail/field'
require 'tmail/port'
require 'tmail/loader'
require 'tmail/encode'


class MailError < StandardError ; end


module TMail

  Version = '0.8.0'


class Mail

  class << self

    def loadfrom( fname )
      new FilePort.new( fname )
    end


    def boundary
      'mimepart_' + random_tag
    end

    def msgid
      if host = ENV['HOSTNAME'] then
        "<#{random_tag}@#{host}>"
      else
        "<#{random_tag}@tmail.on.ruby>"
      end
    end

    private

    def random_tag
      ret = ('%x' % Time.now.strftime('%Y%m%d%H%M%S').to_i) + '_'
      1.upto(8){ srand ; ret << ('%x' % rand(255)) }
      ret
    end
  
  end


  def initialize( port, strict = false )
    @port = port
    @strict = strict

    @header    = {}
    @body_port = nil
    @epilogue  = ''
    @parts     = []

    parse_header
  end

  attr :port


  ### body


  def body_port
    parse_body
    @body_port
  end

  def each
    body_port.ropen do |f|
      f.each {|line| yield line }
    end
  end

  def body
    parse_body
    ret = nil
    @body_port.ropen {|is| ret = is.readall }
    ret
  end

  def body=( str )
    parse_body
    @body_port.wopen {|os| os.write str }
    true
  end

  alias preamble  body
  alias preamble= body=

  def epilogue
    parse_body
    @epilogue.dup
  end

  def epilogue=( str )
    parse_body
    @epilogue = str
    str
  end

  def parts
    parse_body
    @parts
  end



  ### all



  class << self

  end


  def encoded( eol = "\r\n", charset = 'j', ret = '', sep = '' )
    visitor = HFencoder.new( ret, eol, charset )
    accept visitor, eol, ret, sep
    ret
  end
  alias to_s encoded

  def decoded( eol = "\n", charset = 'e', ret = '', sep = '' )
    visitor = HFdecoder.new( ret, charset )
    accept visitor, eol, ret, sep
    ret
  end
  alias inspect decoded

  def accept( visitor, eol = "\n", ret = '', sep = '' )
    multipart = ! @parts.empty?

    if multipart then
      bound = Mail.boundary
      h = self['content-type']
      if h then
        h.params['boundary'] = bound
      else
        store 'Content-Type', %<multipart/mixed; boundary="#{bound}">
      end
    end

    each_header do |name, header|
      header.accept visitor
      visitor.write_in
      ret << eol
    end
    ret << sep << eol

    body_port.ropen do |is|
      is.each do |line|
        line.sub!( /[\r\n]+\z/, '' )
        ret << line << eol
      end
    end

    if multipart then
      boundary = "#{eol}--#{bound}#{eol}"
      parts.each do |tm|
        ret << boundary
        tm.accept( visitor, eol, ret, sep )
      end
      ret << "#{eol}--#{bound}--#{eol}"
      s = epilogue
      ret << s
      case s[-1] when ?\n then; when ?\r then; else
        ret << eol
      end
    end
  end


  def write_back   # tmp
    @port.wopen {|os| encoded os }
    true
  end


  ### header

  USE_ARRAY = [ 'received' ]


  def header
    @header.dup
  end


  def fetch( key, initbody = nil, &block )
    dkey = key.downcase
    ret = @header[ dkey ]

    unless ret then
      if iterator? then
        initbody = yield
      end
      if initbody then
        ret = mkhf( *ret.split(':', 2) )
        store dkey, ret
      end
    end

    ret
  end
  alias [] fetch


  def store( key, val )
    dkey = key.downcase

    if val.nil? then
      @header.delete dkey
      return
    end

    case val
    when String
      val = mkhf( key, val )
    when HeaderField
      ;
    when Array
      unless USE_ARRAY.include? dkey then
        raise ArgumentError, "#{key}: Header must not be multiple"
      end
      @header[dkey] = val
      return
    else
      val.must HeaderField, String, Array
    end

    if USE_ARRAY.include? dkey then
      if arr = @header[dkey] then
        arr.push val
      else
        @header[dkey] = [val]
      end
    else
      @header[dkey] = val
    end
  end
  alias []= store


  def each_header
    @header.each do |k,v|
      if Array === v then
        v.each{|i| yield k, i }
      else
        yield k, v
      end
    end
  end

  alias each_pair each_header

  def each_header_name( &block )
    @header.each_key( &block )
  end

  alias each_key each_header_name

  def each_field( &block )
    @header.each_key do |key|
      v = fetch( key )
      if Array === v then
        v.each( &block )
      else
        yield v
      end
    end
  end

  alias each_value each_field


  def direct_fetch( key )       # undoc
    @header[key.downcase]
  end

  def direct_store( key, val )  # undoc
    @header[key.downcase] = val
  end


  def clear
    @header.clear
  end

  def delete( key )
    @header.delete key.downcase
  end

  def delete_if
    @header.delete_if do |key,v|
      v = fetch( key )
      if Array === v then
        v.delete_if{|f| yield key, f }
        v.empty?
      else
        yield key, v
      end
    end
  end


  def keys
    @header.keys
  end

  def has_key?( key )
    @header.has_key? key.downcase
  end
  alias include? has_key?
  alias key?     has_key?


  def values
    ret = []
    each_field {|v| ret.push v }
    ret
  end

  def has_value?( val )
    return false unless HeaderField === val

    v = fetch( val.name )
    if Array === v then v.include? val
    else                v ? (val == v) : false
    end
  end
  alias value? has_value?


  def indexes( *args )
    ret = []
    temp = nil
    args.each do |k|
      case temp = fetch(k)
      when Array
        ret.concat temp
      else
        ret.push temp
      end
    end
    return ret
  end
  alias indices indexes


  private


  def mkhf( fname, fbody )
    fname.strip!
    HeaderField.new( fname, fbody, @strict )
  end

  def parse_header
    fname = fbody = nil
    errlog = []

    src = @stream = @port.ropen

    while line = src.gets do     # no each !
      case line
      when /\A[ \t]/             # continue from prev line
        unless fbody then
          errlog.push 'mail is began by space or tab'
          next
        end
        # line.strip!
        # fbody << ' ' << line
        fbody << line

      when /\A([^\: \t]+):\s*/   # new header line
        add_hf fname, fbody if fbody
        fname = $1
        fbody = $'
        # fbody.strip!

      when /\A\-*\s*\z/          # end of header
        add_hf fname, fbody if fbody
        break

      else
        errlog.push "wrong mail header: '#{line.inspect}'"
      end
    end
    unless errlog.empty? then
      raise ParseError, "\n" + errlog.join("\n")
    end

    src.stop
  end

  def add_hf( fname, fbody )
    key = fname.downcase
    hf = mkhf( fname, fbody )

    if USE_ARRAY.include? key then
      if tmp = @header[key] then
        tmp.push hf
      else
        @header[key] = [hf]
      end
    else
      @header[key] = hf
    end
  end


  def parse_body
    if @stream then
      parse_body_in @stream
      @stream = nil
    end
  end
  
  def parse_body_in( stream )
    begin
      stream.restart
      if multipart? then
        read_multipart stream
      else
        @body_port = tmp_port
        @body_port.wopen do |f|
          stream.copy_to f
        end
      end
    ensure
      stream.close unless stream.closed?
    end
  end

  def read_multipart( src )
    bound = self['content-type'].params['boundary']
    is_sep = /\A--#{Regexp.quote bound}(?:--)?[ \t]*(?:\n|\r\n|\r)/
    lastbound = "--#{bound}--"

    f = nil
    n = 1
    begin
      ports = []
      ports.push tmp_port
      f = ports[-1].wopen

      while line = src.gets do    # no each !
        if is_sep === line then
          f.close
          line.strip!
          if line == lastbound then
            break
          else
            ports.push tmp_port n
            n += 1
            f = ports[-1].wopen
          end
        else
          f << line
        end
      end
      @epilogue = src.readall
    ensure
      f.close if f and not f.closed?
    end

    @body_port = ports.shift

    ports.filter {|p| Mail.new( p ) }
    @parts = ports
  end

  def tmp_port( n = 0 )
    StringPort.new
  end


  ######
  public


  class << self
  
    def tmail_attr( mname, hname, part )
      module_eval %-
        def #{mname.id2name}( default = nil )
          header = self['#{hname}']
          if header then
            header.#{part}
          else
            default
          end
        end
      -
    end
  
  end


  tmail_attr :date,     'date', 'date'

  tmail_attr :to_addrs, 'to', 'addrs'
  tmail_attr :cc_addrs, 'cc', 'addrs'
  tmail_attr :bcc_addrs, 'bcc', 'addrs'

  def to( dflt = '' )
    to_addrs[0] or dflt
  end

  def to=( addr )
    store 'To', addr
  end

  def destinations
    ret = []
    %w( to cc bcc ).each do |hname|
      if hed = self[ hname ] then
        ret.concat hed.addrs
      end
    end
    ret
  end


  tmail_attr :from_addrs, 'from', 'addrs'

  def from( dflt = '' )
    tmp = from_addrs[0]
    tmp ? tmp.addr : dflt
  end

  def from_phrase( dflt = '' )
    tmp = from_addrs[0]
    tmp ? tmp.phrase : dflt
  end

  def from=( addr )
    store 'From', addr
  end


  tmail_attr :subject, 'subject', 'body'

  def subject=( str )
    store 'Subject', str
  end


  tmail_attr :msgid, 'message-id', 'msgid'

  def msgid=( str )
    store 'Message-ID', str
  end


  tmail_attr :main_type, 'content-type', 'main'
  tmail_attr :sub_type, 'content-type', 'sub'

  def content_type=( str )
    header = self['content-type']
    if header then
      header.main, header.sub = str.split('/', 2)
    else
      store 'Content-Type', str
    end
  end
      
  def set_content_type( main, sub, param = nil )
    if hed = self['content-type'] then
      hed.main = main
      hed.sub  = sub
    else
      store 'Content-Type', main + '/' + sub
      if param then
        self['content-type'].params.replace param
      end
    end
  end

  tmail_attr :charset, 'content-type', "params['charset']"

  def charset=( str )
    if hed = self[ 'content-type' ] then
      hed.params.store 'charset', str
    else
      store "text/plain ; charset=#{str}"
    end
  end


  tmail_attr :encoding, 'content-transfer-encoding', 'encoding'

  def encoding=( str )
    store 'Content-Transfer-Encoding', str
  end


  def each_dest( &block )
    destinations.each do |i|
      if Address === i then
        yield i
      else
        i.each &block
      end
    end
  end

  def multipart?
    main_type('').downcase == 'multipart'
  end

end   # class TMail::Mail


end   # module TMail