www/secretary/workbench/models/message.rb (366 lines of code) (raw):

# # Encapsulate access to messages # # N.B. this module is referenced by the deliver script, so needs to be quick to load require 'digest' require 'mail' require 'time' require_relative 'attachment' class Message attr_reader :headers SIG_MIMES = %w(application/pkcs7-signature application/pgp-signature) # The name used to represent the raw message as an attachment RAWMESSAGE_ATTACHMENT_NAME = 'rawmessage.txt' # default SVN property names to add to documents PROPNAMES_DEFAULT = %w{email:addr email:id email:name email:subject envelope:from envelope:date} # # 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 attach['Content-ID'].to_s == "<#{name}>" end part = mail.attachments.find do |attach| attach.filename == name or attach['Content-ID'].to_s == "<#{name}>" end if part.nil? and name == RAWMESSAGE_ATTACHMENT_NAME part = self end if headers Attachment.new(self, headers, part) end end # # accessors # def mail @mail ||= Mail.new(@raw.gsub(LF_ONLY, CRLF)) end # Allows the entire message to be treated as an attachment # used with RAWMESSAGE_ATTACHMENT_NAME def body @raw 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 value.is_a? String @headers[:cc] = value end def bcc @headers[:bcc] end def bcc=(value) value = value.split("\n") if value.is_a? String @headers[:bcc] = value end def subject mail.subject end def html_part mail.html_part end def text_part mail.text_part end # return list of valid attachment names which are not marked deleted (i.e. processed) # if includeDeleted (default false), also include names marked deleted def self.attachments(headers, includeDeleted: false) attachments = headers[:attachments] return [] unless attachments attachments. reject do |attachment| (!includeDeleted and attachment[:status] == :deleted) or (SIG_MIMES.include?(attachment[:mime]) and (not attachment[:name] or attachment[:name] !~ /\.pdf\.(asc|sig)$/)) end. map {|attachment| attachment[:name]}. reject {|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 idx = @headers[:attachments].find_index(attachment.headers) @headers[:attachments][idx][:status] = :deleted # .delete attachment.headers @headers[:status] = :deleted if @headers[:attachments].reject {|att| att[:status] == :deleted}.empty? write_headers else raise "Not found #{name}" 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, 0o755 unless Dir.exist? dir File.write File.join(dir, @hash), @raw, encoding: Encoding::BINARY end def propval(propname) propval = nil begin case propname when 'email:addr' propval = from.addrs.first.address when 'email:id' propval = id when 'email:name' propval = from.addrs.first.name when 'email:subject' propval = subject when 'envelope:from' propval = @headers[:envelope_from] when 'envelope:date' propval = @headers[:envelope_date] else Wunderbar.warn "Don't know propname #{propname}" end rescue StandardError => e Wunderbar.warn "Problem occurred fetching #{propname} #{e}" end propval end # don't let a problem with the properties stop the commit # handle each property separately def propset(pathname, propname) propval = propval(propname) if propval begin system 'svn', 'propset', propname, propval, pathname rescue StandardError => e Wunderbar.warn "Problem occurred adding #{propname} to #{pathname} #{e}" end end end # Add some properties from the email def add_email_details(pathname) PROPNAMES_DEFAULT.each do |propname| propset(pathname, propname) end 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 attachments.last.is_a? Hash if attachments.flatten.length == 1 ext = File.extname(attachments.first).downcase pathname = find(attachments.first).write_svn(repos, filename + ext) add_email_details(pathname) 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) unless File.exist? dest Kernel.system 'svn', 'mkdir', dest end # write out selected attachment attachments.each do |attachment, basename| pathname = find(attachment).write_svn(repos, filename, basename) add_email_details(pathname) 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] 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 email addresses mail[:to] = to.map(&:format) mail[:cc] = cc.map(&:format) unless cc.empty? mail[:bcc] = bcc.map(&:format) 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 = { envelope_from: mail.envelope_from, envelope_date: mail.envelope_date.to_s, # effectively the delivery date to secretary@ 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 # we also want to treat CLA requests as attachments elsif headers['Subject']&.include?('CLA') && !headers['Subject'].include?('ICLA') && !headers['Subject'].include?('iCLA') && !headers['Subject'].start_with?('Re: ') && !headers['Subject'].start_with?('RE: ') headers[:attachments] = [ {name: RAWMESSAGE_ATTACHMENT_NAME, length: message.size, mime: 'text/plain'} ] 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 =~ /^"([^"]*)" <(.*)>$/ or addr =~ /^([^"]*) <(.*)>$/ addr = Mail::Address.new addr.address = $2 addr.display_name = $1 else raise end return addr end end