#--
# Copyright (c) 2001, 2002, 2003, 2004 Matt Armstrong. All rights
# reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN
# NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
#++
# Implements the RMail::Header class.
require 'rmail/utils'
require 'rmail/address'
require 'digest/md5'
require 'time'
module RMail
# A class that supports the reading, writing and manipulation of
# RFC2822 mail headers.
# =Overview
#
# The RMail::Header class supports the creation and manipulation of
# RFC2822 mail headers.
#
# A mail header is a little bit like a Hash. The fields are keyed
# by a string field name. It is also a little bit like an Array,
# since the fields are in a specific order. This class provides
# many of the methods of both the Hash and Array class. It also
# includes the Enumerable module.
#
# =Terminology
#
# header:: The entire header. Each RMail::Header object is one
# mail header.
#
# field:: An element of the header. Fields have a name and a value.
# For example, the field "Subject: Hi Mom!" has a name of
# "Subject" and a value of "Hi Mom!"
#
# name:: A name of a field. For example: "Subject" or "From".
#
# value:: The value of a field.
#
# =Conventions
#
# The header's fields are stored in a particular order. Methods
# such as #each process the headers in this order.
#
# When field names or values are added to the object they are
# frozen. This helps prevent accidental modification to what is
# stored in the object.
class Header
include Enumerable
class Field # :nodoc:
# fixme, document methadology for this (RFC2822)
EXTRACT_FIELD_NAME_RE = /\A([^\x00-\x1f\x7f-\xff :]+):\s*/no
class << self
def parse(field)
field = field.to_str
if field =~ EXTRACT_FIELD_NAME_RE
[ $1, $'.chomp ]
else
[ "", Field.value_strip(field) ]
end
end
end
def initialize(name, value = nil)
if value
@name = Field.name_strip(name.to_str).freeze
@value = Field.value_strip(value.to_str).freeze
@raw = nil
else
@raw = name.to_str.freeze
@name, @value = Field.parse(@raw)
@name.freeze
@value.freeze
end
end
attr_reader :name, :value, :raw
def ==(other)
other.kind_of?(self.class) &&
@name.downcase == other.name.downcase &&
@value == other.value
end
def Field.name_canonicalize(name)
name_strip(name.to_str).downcase
end
private
def Field.name_strip(name)
name.sub(/\s*:.*/, '')
end
def Field.value_strip(value)
if value.frozen?
value = value.dup
end
value.strip!
value
end
end
# Creates a new empty header object.
def initialize()
clear()
end
# Return the value of the first matching field of a field name, or
# nil if none found. If passed a Fixnum, returns the header
# indexed by the number.
def [](name_or_index)
if name_or_index.kind_of? Fixnum
temp = @fields[name_or_index]
temp = temp.value unless temp.nil?
else
name = Field.name_canonicalize(name_or_index)
result = detect { |n, v|
if n.downcase == name then true else false end
}
if result.nil? then nil else result[1] end
end
end
# Creates a copy of this header object. A new RMail::Header is
# created and the instance data is copied over. However, the new
# object will still reference the same strings held in the
# original object. Since these strings are frozen, this usually
# won't matter.
def dup
h = super
h.fields = @fields.dup
h.mbox_from = @mbox_from
h
end
# Creates a complete copy of this header object, including any
# singleton methods and strings. The returned object will be a
# complete and unrelated duplicate of the original.
def clone
h = super
h.fields = Marshal::load(Marshal::dump(@fields))
h.mbox_from = Marshal::load(Marshal::dump(@mbox_from))
h
end
# Delete all fields in this object. Returns self.
def clear()
@fields = []
@mbox_from = nil
self
end
# Replaces the contents of this header with that of another header
# object. Returns self.
def replace(other)
unless other.kind_of?(RMail::Header)
raise TypeError, "#{other.class.to_s} is not of type RMail::Header"
end
temp = other.dup
@fields = temp.fields
@mbox_from = temp.mbox_from
self
end
# Return the number of fields in this object.
def length
@fields.length
end
alias size length
# Return the value of the first matching field of a given name.
# If there is no such field, the value returned by the supplied
# block is returned. If no block is passed, the value of
# +default_value+ is returned. If no +default_value+ is
# specified, an IndexError exception is raised.
def fetch(name, *rest)
if rest.length > 1
raise ArgumentError, "wrong # of arguments(#{rest.length + 1} for 2)"
end
result = self[name]
if result.nil?
if block_given?
yield name
elsif rest.length == 1
rest[0]
else
raise IndexError, 'name not found'
end
else
result
end
end
# Returns the values of every field named +name+. If there are no
# such fields, the value returned by the block is returned. If no
# block is passed, the value of +default_value+ is returned. If
# no +default_value+ is specified, an IndexError exception is
# raised.
def fetch_all name, *rest
if rest.length > 1
raise ArgumentError, "wrong # of arguments(#{rest.length + 1} for 2)"
end
result = select(name)
if result.nil?
if block_given?
yield name
elsif rest.length == 1
rest[0]
else
raise IndexError, 'name not found'
end
else
result.collect { |n, v|
v
}
end
end
# Returns true if the message has a field named 'name'.
def field?(name)
! self[name].nil?
end
alias member? field?
alias include? field?
alias has_key? field?
alias key? field?
# Deletes all fields with +name+. Returns self.
def delete(name)
name = Field.name_canonicalize(name.to_str)
delete_if { |n, v|
n.downcase == name
}
self
end
# Deletes the field at the specified index and returns its value.
def delete_at(index)
@fields.delete_at(index)
self
end
# Deletes the field if the passed block returns true. Returns
# self.
def delete_if # yields: name, value
@fields.delete_if { |i|
yield i.name, i.value
}
self
end
# Executes block once for each field in the header, passing the
# key and value as parameters.
#
# Returns self.
def each # yields: name, value
@fields.each { |i|
yield [i.name, i.value]
}
end
alias each_pair each
# Executes block once for each field in the header, passing the
# field's name as a parameter.
#
# Returns self
def each_name
@fields.each { |i|
yield(i.name)
}
end
alias each_key each_name
# Executes block once for each field in the header, passing the
# field's value as a parameter.
#
# Returns self
def each_value
@fields.each { |i|
yield(i.value)
}
end
# Returns true if the header contains no fields
def empty?
@fields.empty?
end
# Returns an array of pairs [ name, value ] for all fields with
# one of the names passed.
def select(*names)
result = []
names.each { |name|
name = Field.name_canonicalize(name)
result.concat(find_all { |n, v|
n.downcase == name
})
}
result
end
# Returns an array consisting of the names of every field in this
# header.
def names
collect { |n, v|
n
}
end
alias keys names
# Add a new field with +name+ and +value+. When +index+ is nil
# (the default if not specified) the line is appended to the
# header, otherwise it is inserted at the specified index.
# E.g. an +index+ of 0 will prepend the header line.
#
# You can pass additional parameters for the header as a hash
# table +params+. Every key of the hash will be the name of the
# parameter, and every key's value the parameter value.
#
# E.g.
#
# header.add('Content-Type', 'multipart/mixed', nil,
# 'boundary' => 'the boundary')
#
# will add this header
#
# Content-Type: multipart/mixed; boundary="the boundary"
#
# Always returns self.
def add(name, value, index = nil, params = nil)
value = value.to_str
if params
value = value.dup
sep = "; "
params.each do |n, v|
value << sep
value << n.to_s
value << '='
v = v.to_s
if v =~ /^\w+$/
value << v
else
value << '"'
value << v
value << '"'
end
end
end
field = Field.new(name, value)
index ||= @fields.length
@fields[index, 0] = field
self
end
# Add a new field as a raw string together with a parsed
# name/value. This method is used mainly by the parser and
# regular programs should stick to #add.
def add_raw(raw)
@fields << Field.new(raw)
self
end
# First delete any fields with +name+, then append a new field
# with +name+, +value+, and +params+ as in #add.
def set(name, value, params = nil)
delete(name)
add(name, value, nil, params)
end
# Append a new field with +name+ and +value+. If you want control
# of where the field is inserted, see #add.
#
# Returns +value+.
def []=(name, value)
add(name, value)
value
end
# Returns true if the two objects have the same number of fields,
# in the same order, with the same values.
def ==(other)
return other.kind_of?(self.class) &&
@fields == other.fields &&
@mbox_from == other.mbox_from
end
# Returns a new array holding one [ name, value ] array per field
# in the header.
def to_a
@fields.collect { |field|
[ field.name, field.value ]
}
end
# Converts the header to a string, including any mbox from line.
# Equivalent to header.to_string(true).
def to_s
to_string(true)
end
# Converts the header to a string. If +mbox_from+ is true, then
# the mbox from line is also included.
def to_string(mbox_from = false)
s = ""
if mbox_from && ! @mbox_from.nil?
s << @mbox_from
s << "\n" unless @mbox_from[-1] == ?\n
end
@fields.each { |field|
if field.raw
s << field.raw
else
s << field.name
s << ': '
s << field.value
end
s << "\n" unless s[-1] == ?\n
}
s
end
# Determine if there is any fields that match the given +name+ and
# +value+.
#
# If +name+ is a String, all fields of that name are tested. If
# +name+ is a Regexp the field names are matched against the
# regexp (the field names are converted to lower case first). Use
# the regexp // if you want to test all field names.
#
# If +value+ is a String, it is converted to a case insensitive
# Regexp that matches the string. Otherwise, it must be a Regexp.
# Note that the field value may be folded across many lines, so
# you should use a multi-line Regexp. Also consider using a case
# insensitive Regexp. Use the regexp // if you want to match all
# possible field values.
#
# Returns true if there is a match, false otherwise.
#
# Example:
#
# if h.match?('x-ml-name', /ruby-dev/im)
# # do something
# end
#
# See also: #match
def match?(name, value)
massage_match_args(name, value) { |name, value|
match = detect {|n, v|
n =~ name && v =~ value
}
! match.nil?
}
end
# Find all fields that match the given +name and +value+.
#
# If +name+ is a String, all fields of that name are tested. If
# +name+ is a Regexp, the field names are matched against the
# regexp (the field names are converted to lower case first). Use
# the regexp // if you want to test all field names.
#
# If +value+ is a String, it is converted to a case insensitive
# Regexp that matches the string. Otherwise, it must be a Regexp.
# Note that the field value may be folded across many lines, so
# you may need to use a multi-line Regexp. Also consider using a
# case insensitive Regexp. Use the regexp // if you want to match
# all possible field values.
#
# Returns a new RMail::Header holding all matching headers.
#
# Examples:
#
# received = header.match('Received', //)
# destinations = header.match(/^(to|cc|bcc)$/, //)
# bigfoot_received = header.match('received',
# /from.*by.*bigfoot\.com.*LiteMail/im)
#
# See also: #match?
def match(name, value)
massage_match_args(name, value) { |name, value|
header = RMail::Header.new
found = each { |n, v|
if n.downcase =~ name && value =~ v
header[n] = v
end
}
header
}
end
# Sets the "From " line commonly used in the Unix mbox mailbox
# format. The +value+ supplied should be the entire "From " line.
def mbox_from=(value)
@mbox_from = value
end
# Gets the "From " line previously set with mbox_from=, or nil.
def mbox_from
@mbox_from
end
# This returns the full content type of this message converted to
# lower case.
#
# If there is no content type header, returns the passed block is
# executed and its return value is returned. If no block is passed,
# the value of the +default+ argument is returned.
def content_type(default = nil)
if value = self['content-type']
value.strip.split(/\s*;\s*/)[0].downcase
else
if block_given?
yield
else
default
end
end
end
# This returns the main media type for this message converted to
# lower case. This is the first portion of the content type.
# E.g. a content type of text/plain has a media type of
# text.
#
# If there is no content type field, returns the passed block is
# executed and its return value is returned. If no block is
# passed, the value of the +default+ argument is returned.
def media_type(default = nil)
if value = content_type
value.split('/')[0]
else
if block_given?
yield
else
default
end
end
end
# This returns the media subtype for this message, converted to
# lower case. This is the second portion of the content type.
# E.g. a content type of text/plain has a media subtype
# of plain.
#
# If there is no content type field, returns the passed block is
# executed and its return value is returned. If no block is passed,
# the value of the +default+ argument is returned.
def subtype(default = nil)
if value = content_type
value.split('/')[1]
else
if block_given? then
yield
else
default
end
end
end
# This returns a hash of parameters. Each key in the hash is the
# name of the parameter in lower case and each value in the hash
# is the unquoted parameter value. If a parameter has no value,
# its value in the hash will be +true+.
#
# If the field or parameter does not exist or it is malformed in a
# way that makes it impossible to parse, then the passed block is
# executed and its return value is returned. If no block is
# passed, the value of the +default+ argument is returned.
def params(field_name, default = nil)
if params = params_quoted(field_name)
params.each { |name, value|
params[name] = value ? Utils.unquote(value) : nil
}
else
if block_given?
yield field_name
else
default
end
end
end
# This returns the parameter value for the given parameter in the
# given field. The value returned is unquoted.
#
# If the field or parameter does not exist or it is malformed in a
# way that makes it impossible to parse, then the passed block is
# executed and its return value is returned. If no block is
# passed, the value of the +default+ argument is returned.
def param(field_name, param_name, default = nil)
if field?(field_name)
params = params_quoted(field_name)
value = params[param_name]
return Utils.unquote(value) if value
end
if block_given?
yield field_name, param_name
else
default
end
end
# Set the boundary parameter of this message's Content-Type:
# field.
def set_boundary(boundary)
params = params('content-type')
params ||= {}
params['boundary'] = boundary
content_type = content_type()
content_type ||= "multipart/mixed"
delete('Content-Type')
add('Content-Type', content_type, nil, params)
end
# Return the value of the Date: field, parsed into a Time
# object. Returns nil if there is no Date: field or the field
# value could not be parsed.
def date
if value = self['date']
begin
# Rely on Ruby's standard time.rb to parse the time.
(Time.rfc2822(value) rescue Time.parse(value)).localtime
rescue
# Exceptions during time parsing just cause nil to be
# returned.
end
end
end
# Deletes any existing Date: fields and appends a new one
# corresponding to the given Time object.
def date=(time)
delete('Date')
add('Date', time.rfc2822)
end
# Returns the value of the From: header as an Array of
# RMail::Address objects.
#
# See #address_list_fetch for details on what is returned.
#
# This method does not return a single RMail::Address value
# because it is legal to have multiple addresses in a From:
# header.
#
# This method always returns at least the empty list. So if you
# are always only interested in the first from address (most
# likely the case), you can safely say:
#
# header.from.first
def from
address_list_fetch('from')
end
# Sets the From: field to the supplied address or addresses.
#
# See #address_list_assign for information on valid values for
# +addresses+.
#
# Note that the From: header usually contains only one address,
# but it is legal to have more than one.
def from=(addresses)
address_list_assign('From', addresses)
end
# Returns the value of the To: field as an Array of RMail::Address
# objects.
#
# See #address_list_fetch for details on what is returned.
def to
address_list_fetch('to')
end
# Sets the To: field to the supplied address or addresses.
#
# See #address_list_assign for information on valid values for
# +addresses+.
def to=(addresses)
address_list_assign('To', addresses)
end
# Returns the value of the Cc: field as an Array of RMail::Address
# objects.
#
# See #address_list_fetch for details on what is returned.
def cc
address_list_fetch('cc')
end
# Sets the Cc: field to the supplied address or addresses.
#
# See #address_list_assign for information on valid values for
# +addresses+.
def cc=(addresses)
address_list_assign('Cc', addresses)
end
# Returns the value of the Bcc: field as an Array of
# RMail::Address objects.
#
# See #address_list_fetch for details on what is returned.
def bcc
address_list_fetch('bcc')
end
# Sets the Bcc: field to the supplied address or addresses.
#
# See #address_list_assign for information on valid values for
# +addresses+.
def bcc=(addresses)
address_list_assign('Bcc', addresses)
end
# Returns the value of the Reply-To: header as an Array of
# RMail::Address objects.
def reply_to
address_list_fetch('reply-to')
end
# Sets the Reply-To: field to the supplied address or addresses.
#
# See #address_list_assign for information on valid values for
# +addresses+.
def reply_to=(addresses)
address_list_assign('Reply-To', addresses)
end
# Returns the value of this object's Message-Id: field.
def message_id
self['message-id']
end
# Sets the value of this object's Message-Id: field to a new
# random value.
#
# If you don't supply a +fqdn+ (fully qualified domain name) then
# one will be randomly generated for you. If a valid address
# exists in the From: field, its domain will be used as a basis.
#
# Part of the randomness in the header is taken from the header
# itself, so it is best to call this method after adding other
# fields to the header -- especially those that make it unique
# (Subject:, To:, Cc:, etc).
def add_message_id(fqdn = nil)
# If they don't supply a fqdn, we supply one for them.
#
# First grab the From: field and see if we can use a domain from
# there. If so, use that domain name plus the hash of the From:
# field's value (this guarantees that bob@example.com and
# sally@example.com will never have clashes).
#
# If there is no From: field, grab the current host name and use
# some randomness from Ruby's random number generator. Since
# Ruby's random number generator is fairly good this will
# suffice so long as it is seeded corretly.
#
# P.S. There is no portable way to get the fully qualified
# domain name of the current host. Those truly interested in
# generating "correct" message-ids should pass it in. We
# generate a hopefully random and unique domain name.
unless fqdn
unless fqdn = from.domains.first
require 'socket'
fqdn = sprintf("%s.invalid", Socket.gethostname)
end
else
raise ArgumentError, "fqdn must have at least one dot" unless
fqdn.index('.')
end
# Hash the header we have so far.
md5 = Digest::MD5.new
starting_digest = md5.digest
@fields.each { |f|
if f.raw
md5.update(f.raw)
else
md5.update(f.name) if f.name
md5.update(f.value) if f.value
end
}
if (digest = md5.digest) == starting_digest
digest = 0
end
set('Message-Id', sprintf("<%s.%s.%s.rubymail@%s>",
base36(Time.now.to_i),
base36(rand(MESSAGE_ID_MAXRAND)),
base36(digest),
fqdn))
end
# Return the subject of this message.
def subject
self['subject']
end
# Set the subject of this message
def subject=(string)
set('Subject', string)
end
# Returns an RMail::Address::List array holding all the recipients
# of this message. This uses the contents of the To, Cc, and Bcc
# fields. Duplicate addresses are eliminated.
def recipients
RMail::Address::List.new([ to, cc, bcc ].flatten.uniq)
end
# Retrieve a given field's value as an RMail::Address::List of
# RMail::Address objects.
#
# This method is used to implement many of the convenience methods
# such as #from, #to, etc.
def address_list_fetch(field_name)
if values = fetch_all(field_name, nil)
list = nil
values.each { |value|
if list
list.concat(Address.parse(value))
else
list = Address.parse(value)
end
}
if list and !list.empty?
list
end
end or RMail::Address::List.new
end
# Set a given field to a list of supplied +addresses+.
#
# The +addresses+ may be a String, RMail::Address, or Array. If a
# String, it is parsed for valid email addresses and those found
# are used. If an RMail::Address, the result of
# RMail::Address#format is used. If an Array, each element of the
# array must be either a String or RMail::Address and is treated
# as above.
#
# This method is used to implement many of the convenience methods
# such as #from=, #to=, etc.
def address_list_assign(field_name, addresses)
if addresses.kind_of?(Array)
value = addresses.collect { |e|
if e.kind_of?(RMail::Address)
e.format
else
RMail::Address.parse(e.to_str).collect { |a|
a.format
}
end
}.flatten.join(", ")
set(field_name, value)
elsif addresses.kind_of?(RMail::Address)
set(field_name, addresses.format)
else
address_list_assign(field_name,
RMail::Address.parse(addresses.to_str))
end
end
protected
attr :fields, true
private
MESSAGE_ID_MAXRAND = 0x7fffffff
def string2num(string)
temp = 0
string.reverse.each_byte { |b|
temp <<= 8
temp |= b
}
return temp
end
BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz"
def base36(number)
if number.kind_of?(String)
number = string2num(number)
end
raise ArgumentError, "need non-negative number" if number < 0
return "0" if number == 0
result = ""
while number > 0
number, remainder = number.divmod(36)
result << BASE36[remainder]
end
return result.reverse!
end
PARAM_SCAN_RE = %r{
;
|
[^;"]*"(?:|.*?(?:[^\\]|\\\\))"\s* # fix fontification "
|
[^;]+
}x
NAME_VALUE_SCAN_RE = %r{
=
|
[^="]*"(?:.*?(?:[^\\]|\\\\))" # fix fontification "\s*
|
[^=]+
}x
def params_quoted(field_name, default = nil)
if value = self[field_name]
params = {}
first = true
value.scan(PARAM_SCAN_RE) do |param|
if param != ';'
unless first
name, value = param.scan(NAME_VALUE_SCAN_RE).collect do |p|
if p == '=' then nil else p end
end.compact
if name && (name = name.strip.downcase) && name.length > 0
params[name] = (value || '').strip
end
else
first = false
end
end
end
params
else
if block_given? then yield field_name else default end
end
end
def massage_match_args(name, value)
case name
when String
name = /^#{Regexp.escape(Field.name_strip(name))}$/i
when Regexp
else
raise ArgumentError,
"name not a Regexp or String: #{name.class}:#{name.inspect}"
end
case value
when String
value = Regexp.new(Regexp.escape(value), Regexp::IGNORECASE)
when Regexp
else
raise ArgumentError, "value not a Regexp or String"
end
yield(name, value)
end
end
end