blob: c4d3555295fa8ccd0e6d9a4f86c9f1859f58e88c [file] [log] [blame]
#
# Encapsulate access to messages
#
require 'digest'
require 'mail'
require 'time'
require_relative 'attachment.rb'
class Message
attr_reader :headers
SIG_MIMES = %w(application/pkcs7-signature application/pgp-signature)
#
# create a new message
#
def initialize(mailbox, hash, headers, raw)
@hash = hash
@mailbox = mailbox
@headers = headers
@raw = raw
end
#
# find an attachment
#
def find(name)
name = name[1..-2] if name =~ /^<.*>$/ # drop enclosing <> if present
name = name[2..-1] if name.start_with? './'
name = name.dup.force_encoding('utf-8')
headers = @headers[:attachments].find do |attach|
attach[:name] == name or URI.decode(attach[:name]) == name or
attach['Content-ID'].to_s == '<' + name + '>'
end
part = mail.attachments.find do |attach|
attach.filename == name or URI.decode(attach.filename) == name or
attach['Content-ID'].to_s == '<' + name + '>'
end
if headers
Attachment.new(self, headers, part)
end
end
#
# accessors
#
def mail
@mail ||= Mail.new(@raw.gsub(LF_ONLY, CRLF))
end
def raw
@raw
end
def id
@headers['Message-ID']
end
def date
mail[:date]
end
def from
mail[:from]
end
def to
mail[:to]
end
def cc
@headers[:cc]
end
def cc=(value)
value=value.split("\n") if String === value
@headers[:cc]=value
end
def bcc
@headers[:bcc]
end
def bcc=(value)
value=value.split("\n") if String === value
@headers[:bcc]=value
end
def subject
mail.subject
end
def html_part
mail.html_part
end
def text_part
mail.text_part
end
def self.attachments(headers)
attachments = headers[:attachments]
return [] unless attachments
attachments.
reject {|attachment| SIG_MIMES.include?(attachment[:mime]) and
(not attachment[:name] or attachment[:name] !~ /\.pdf\.(asc|sig)$/)}.
map {|attachment| attachment[:name]}.
select {|name| name != 'signature.asc'}
end
def attachments
Message.attachments(@headers)
end
#
# attachment operations: update, replace, delete
#
def update_attachment name, values
attachment = find(name)
if attachment
attachment.headers.merge! values
write_headers
end
end
def replace_attachment name, values
attachment = find(name)
if attachment
index = @headers[:attachments].find_index(attachment.headers)
@headers[:attachments][index, 1] = Array(values)
write_headers
end
end
def delete_attachment name
attachment = find(name)
if attachment
@headers[:attachments].delete attachment.headers
@headers[:status] = :deleted if @headers[:attachments].empty?
write_headers
end
end
#
# write updated headers to disk
#
def write_headers
@mailbox.update do |yaml|
yaml[@hash] = @headers
end
end
#
# write email to disk
#
def write_email
dir = @mailbox.dir
Dir.mkdir dir, 0755 unless Dir.exist? dir
File.write File.join(dir, @hash), @raw, encoding: Encoding::BINARY
end
#
# write one or more attachments to directory containing an svn checkout
#
def write_svn(repos, filename, *attachments)
# drop all nil and empty values
attachments = attachments.flatten.reject {|name| name.to_s.empty?}
# if last argument is a Hash, treat it as name/value pairs
attachments += attachments.pop.to_a if Hash === attachments.last
if attachments.flatten.length == 1
ext = File.extname(attachments.first).downcase.untaint
find(attachments.first).write_svn(repos, filename + ext)
else
# validate filename
unless filename =~ /\A[a-zA-Z][-.\w]+\z/
raise IOError.new("invalid filename: #{filename}")
end
# create directory, if necessary
dest = File.join(repos, filename).untaint
unless File.exist? dest
Kernel.system 'svn', 'mkdir', dest
end
# write out selected attachment
attachments.each do |attachment, basename|
find(attachment).write_svn(repos, filename, basename)
end
dest
end
end
#
# write one or more attachments
# returns list as follows:
# [[name, temp file name, content-type]]
def write_att(tmpdir, *attachments)
files = []
# drop all nil and empty values
attachments = attachments.flatten.reject {|name| name.to_s.empty?}
# write out any remaining attachments
attachments.each do |name|
att = find(name)
path = File.join(tmpdir, name)
att.write_path(path)
files << [name, path, att.content_type.untaint]
end
files
end
#
# Construct a reply message, and in the process merge the email
# address from the original message (from, to, cc) with any additional
# address provided on the call (to, cc, bcc). Remove any duplicates
# that may occur not only due to the merge, but also comparing across
# field types (for example, don't cc an address listed on the to field).
#
# Finally, canonicalize (format) the email addresses and ensure that
# the results aren't marked ask tainted, as the Ruby SMTP library will
# refuse to send to tainted addresses, and in the secretary mail application
# the addresses are expected to come from the mail archive and the
# secretary, both of which can be trusted.
#
def reply(fields)
mail = Mail.new
# fill in the from address
mail.from = fields[:from]
# fill in the reply to headers
mail.in_reply_to = self.id
mail.references = self.id
# fill in the subject from the original email
if self.subject =~ /^re:\s/i
mail.subject = self.subject
elsif self.subject
mail.subject = 'Re: ' + self.subject
elsif fields[:subject]
mail.subject = fields[:subject]
end
# fill in the subject from the original email
mail.body = fields[:body]
# gather up the to, cc, and bcc addresses
to = []
cc = []
bcc = []
# process 'bcc' addresses on method call
# Do this first so can suppress such addresses in To: and Cc: fields
if fields[:bcc]
Array(fields[:bcc]).compact.each do |addr|
addr = Message.liberal_email_parser(addr) if addr.is_a? String
next if bcc.any? {|a| a.address == addr.address}
bcc << addr
end
end
# process 'to' addresses on method call
if fields[:to]
Array(fields[:to]).compact.each do |addr|
addr = Message.liberal_email_parser(addr) if addr.is_a? String
next if to.any? {|a| a.address = addr.address}
to << addr
end
end
# process 'from' addresses from original email
self.from.addrs.each do |addr|
next if to.any? {|a| a.address == addr.address}
if fields[:to]
next if cc.any? {|a| a.address == addr.address}
next if bcc.any? {|a| a.address == addr.address} # skip if already in Bcc
cc << addr
else
to << addr
end
end
# process 'to' addresses from original email
if self.to
self.to.addrs.each do |addr|
next if to.any? {|a| a.address == addr.address}
next if cc.any? {|a| a.address == addr.address}
next if bcc.any? {|a| a.address == addr.address} # skip if already in Bcc
cc << addr
end
end
# process 'cc' addresses from original email
if self.cc
self.cc.each do |addr|
addr = Message.liberal_email_parser(addr) if addr.is_a? String
next if to.any? {|a| a.address == addr.address}
next if cc.any? {|a| a.address == addr.address}
next if bcc.any? {|a| a.address == addr.address} # skip if already in Bcc
cc << addr
end
end
# process 'cc' addresses on method call
if fields[:cc]
Array(fields[:cc]).compact.each do |addr|
addr = Message.liberal_email_parser(addr) if addr.is_a? String
next if to.any? {|a| a.address == addr.address}
next if cc.any? {|a| a.address == addr.address}
next if bcc.any? {|a| a.address == addr.address} # skip if already in Bcc
cc << addr
end
end
# reformat and untaint email addresses
mail[:to] = to.map {|addr| addr.format.dup.untaint}
mail[:cc] = cc.map {|addr| addr.format.dup.untaint} unless cc.empty?
mail[:bcc] = bcc.map {|addr| addr.format.dup.untaint} unless bcc.empty?
# return the resulting email
mail
end
# get the message ID
def self.getmid(message)
# only search headers for MID
hdrs = message[/\A(.*?)\r?\n\r?\n/m, 1] || ''
mid = hdrs[/^Message-ID:.*/i]
if mid =~ /^Message-ID:\s*$/i # no mid on the first line
# capture the next line and join them together
# line may also start with tab; we don't use \s as this also matches EOL
# Rescue is in case we don't match properly - we want to return nil in that case
mid = hdrs[/^Message-ID:.*\r?\n[ \t].*/i].sub(/\r?\n/,'') rescue nil
end
mid
end
#
# What to use as a hash for mail
#
def self.hash(message)
Digest::SHA1.hexdigest(getmid(message) || message)[0..9]
end
# Matches LF, but not CRLF
LF_ONLY = Regexp.new("(?<!\r)\n")
CRLF = "\r\n"
#
# parse a message, returning headers
#
def self.parse(message)
# parse cleaned up message (need to fix every line, not just headers)
mail = Mail.read_from_string(message.gsub(LF_ONLY, CRLF))
# parse from address (if it exists)
from_value = mail[:from].value rescue ''
begin
from = liberal_email_parser(from_value).display_name
rescue Exception
from = from_value.sub(/\s+<.*?>$/, '')
end
# determine who should be copied on any responses
begin
cc = []
cc = mail[:to].to_s.split(/,\s*/) if mail[:to]
cc += mail[:cc].to_s.split(/,\s*/) if mail[:cc]
rescue
cc = []
cc = mail[:to].value.split(/,\s*/) if mail[:to]
cc += mail[:cc].value.split(/,\s*/) if mail[:cc]
end
# remove secretary and anybody on the to field from the cc list
cc.reject! do |email|
begin
address = liberal_email_parser(email).address
next true if address == 'secretary@apache.org'
next true if mail.from_addrs.include? address
rescue Exception
true
end
end
# start an entry for this mail
headers = {
from: mail.from_addrs.first,
name: from,
time: (mail.date.to_time.gmtime.iso8601 rescue nil),
cc: cc
}
# add in header fields
headers.merge! Mailbox.headers(mail)
# add in attachments
if mail.attachments.length > 0
attachments = mail.attachments.map do |attach|
# replace generic octet-stream with a more specific one
mime = attach.mime_type
if mime == 'application/octet-stream'
filename = attach.filename.downcase
mime = 'application/pdf' if filename.end_with? '.pdf'
mime = 'application/png' if filename.end_with? '.png'
mime = 'application/gif' if filename.end_with? '.gif'
mime = 'application/jpeg' if filename.end_with? '.jpg'
mime = 'application/jpeg' if filename.end_with? '.jpeg'
end
description = {
name: attach.filename,
length: attach.body.to_s.length,
mime: mime
}
if description[:name].empty? and attach['Content-ID']
description[:name] = attach['Content-ID'].to_s
end
description.merge(Mailbox.headers(attach))
end
headers[:attachments] = attachments
end
headers
end
# see https://github.com/mikel/mail/issues/39
def self.liberal_email_parser(addr)
addr = Mail::Address.new(addr)
rescue Mail::Field::ParseError
if addr =~ /^"([^"]*)" <(.*)>$/
addr = Mail::Address.new
addr.address = $2
addr.display_name = $1
elsif addr =~ /^([^"]*) <(.*)>$/
addr = Mail::Address.new
addr.address = $2
addr.display_name = $1
else
raise
end
return addr
end
end