aboutsummaryrefslogtreecommitdiffstats
path: root/lib/mail_handler
diff options
context:
space:
mode:
Diffstat (limited to 'lib/mail_handler')
-rw-r--r--lib/mail_handler/backends/mail_backend.rb323
-rw-r--r--lib/mail_handler/backends/mail_extensions.rb67
-rw-r--r--lib/mail_handler/backends/tmail_backend.rb290
-rw-r--r--lib/mail_handler/backends/tmail_extensions.rb138
-rw-r--r--lib/mail_handler/mail_handler.rb179
5 files changed, 997 insertions, 0 deletions
diff --git a/lib/mail_handler/backends/mail_backend.rb b/lib/mail_handler/backends/mail_backend.rb
new file mode 100644
index 000000000..b75e6ed63
--- /dev/null
+++ b/lib/mail_handler/backends/mail_backend.rb
@@ -0,0 +1,323 @@
+require 'mail'
+
+module MailHandler
+ module Backends
+ module MailBackend
+
+ def backend()
+ 'Mail'
+ end
+
+ # Note that the decode flag is not yet used
+ def mail_from_raw_email(data, decode=true)
+ Mail.new(data)
+ end
+
+ # Extracts all attachments from the given TNEF file as a Mail object
+ def mail_from_tnef(content)
+ main = Mail.new
+ tnef_attachments(content).each do |attachment|
+ main.add_file(attachment)
+ end
+ main.ready_to_send!
+ main
+ end
+
+ # Returns an outlook message as a Mail object
+ def mail_from_outlook(content)
+ msg = Mapi::Msg.open(StringIO.new(content))
+ mail = mail_from_raw_email(msg.to_mime.to_s)
+ mail.ready_to_send!
+ mail
+ end
+
+ # Return a copy of the file name for the mail part
+ def get_part_file_name(part)
+ part_file_name = part.filename
+ part_file_name.nil? ? nil : part_file_name.dup
+ end
+
+ # Get the body of a mail part
+ def get_part_body(part)
+ part.body.decoded
+ end
+
+ # Return the first from field if any
+ def first_from(mail)
+ if mail[:from]
+ begin
+ mail[:from].addrs[0]
+ mail[:from].decoded
+ return mail[:from].addrs[0]
+ rescue
+ return mail[:from].value
+ end
+ else
+ nil
+ end
+ end
+
+ # Return the first from address if any
+ def get_from_address(mail)
+ first_from = first_from(mail)
+ if first_from
+ if first_from.is_a?(String)
+ return nil
+ else
+ return first_from.address
+ end
+ else
+ return nil
+ end
+ end
+
+ # Return the first from name if any
+ def get_from_name(mail)
+ first_from = first_from(mail)
+ if first_from
+ if first_from.is_a?(String)
+ return nil
+ else
+ return first_from.display_name ? eval(%Q{"#{first_from.display_name}"}) : nil
+ end
+ else
+ return nil
+ end
+ end
+
+ def get_all_addresses(mail)
+ envelope_to = mail['envelope-to'] ? [mail['envelope-to'].value] : []
+ ((mail.to || []) +
+ (mail.cc || []) +
+ (envelope_to || [])).uniq
+ end
+
+ def empty_return_path?(mail)
+ return false if mail['return-path'].nil?
+ return true if mail['return-path'].value.blank?
+ return false
+ end
+
+ def get_auto_submitted(mail)
+ mail['auto-submitted'] ? mail['auto-submitted'].value : nil
+ end
+
+ def get_content_type(part)
+ part.content_type ? part.content_type.split(';')[0] : nil
+ end
+
+ def get_header_string(header, mail)
+ mail.header[header] ? mail.header[header].to_s : nil
+ end
+
+ # Detects whether a mail part is an Outlook email
+ def is_outlook?(part)
+ filename = get_part_file_name(part)
+ return true if get_content_type(part) == 'application/vnd.ms-outlook'
+ if filename && AlaveteliFileTypes.filename_to_mimetype(filename) == 'application/vnd.ms-outlook'
+ return true
+ end
+ return false
+ end
+
+ # Convert a mail part which is an attached mail in one of
+ # several formats into a mail object and set it as the
+ # rfc822_attachment on the part. If the mail part can't be
+ # converted, the content type on the part is updated to
+ # 'text/plain' for an RFC822 attachment, and 'application/octet-stream'
+ # for other types
+ def decode_attached_part(part, parent_mail)
+ if get_content_type(part) == 'message/rfc822'
+ # An email attached as text
+ part.rfc822_attachment = mail_from_raw_email(part.body)
+ if part.rfc822_attachment.nil?
+ # Attached mail didn't parse, so treat as text
+ part.content_type = 'text/plain'
+ end
+ elsif is_outlook?(part)
+ part.rfc822_attachment = mail_from_outlook(part.body.decoded)
+ if part.rfc822_attachment.nil?
+ # Attached mail didn't parse, so treat as binary
+ part.content_type = 'application/octet-stream'
+ end
+ elsif get_content_type(part) == 'application/ms-tnef'
+ # A set of attachments in a TNEF file
+ part.rfc822_attachment = mail_from_tnef(part.body.decoded)
+ if part.rfc822_attachment.nil?
+ # Attached mail didn't parse, so treat as binary
+ part.content_type = 'application/octet-stream'
+ end
+ end
+ if part.rfc822_attachment
+ expand_and_normalize_parts(part.rfc822_attachment, parent_mail)
+ end
+ end
+
+ # Expand and normalize a mail part recursively. Decodes attached messages into
+ # Mail objects wherever possible. Sets a default content type if none is
+ # set. Tries to set a more specific content type for binary content types.
+ def expand_and_normalize_parts(part, parent_mail)
+ if part.multipart?
+ part.parts.each{ |sub_part| expand_and_normalize_parts(sub_part, parent_mail) }
+ else
+ part_filename = get_part_file_name(part)
+ charset = part.charset # save this, because overwriting content_type also resets charset
+
+ # Don't allow nil content_types
+ if get_content_type(part).nil?
+ part.content_type = 'application/octet-stream'
+ end
+
+ # PDFs often come with this mime type, fix it up for view code
+ if get_content_type(part) == 'application/octet-stream'
+ part_body = get_part_body(part)
+ calc_mime = AlaveteliFileTypes.filename_and_content_to_mimetype(part_filename,
+ part_body)
+ if calc_mime
+ part.content_type = calc_mime
+ end
+ end
+
+ # Use standard content types for Word documents etc.
+ part.content_type = normalise_content_type(get_content_type(part))
+ decode_attached_part(part, parent_mail)
+ part.charset = charset
+ end
+ end
+
+ # Count the parts in a mail part recursively, including any attached messages.
+ # Set the count on the parent mail, and set a url_part_number on the part itself.
+ # Set the count for the first uudecoded part on the parent mail also.
+ def count_parts(part, parent_mail)
+ if part.multipart?
+ part.parts.each { |p| count_parts(p, parent_mail) }
+ else
+ if part.rfc822_attachment
+ count_parts(part.rfc822_attachment, parent_mail)
+ else
+ parent_mail.count_parts_count += 1
+ part.url_part_number = parent_mail.count_parts_count
+ end
+ end
+ parent_mail.count_first_uudecode_count = parent_mail.count_parts_count
+ end
+
+ # Choose the best part from alternatives
+ def choose_best_alternative(mail)
+ if mail.html_part
+ return mail.html_part
+ elsif mail.text_part
+ return mail.text_part
+ else
+ return mail.parts.first
+ end
+ end
+
+ # Expand and normalize the parts of a mail, select the best part
+ # wherever there is an alternative, and then count the returned
+ # leaves and assign url_part values to them
+ def get_attachment_leaves(mail)
+ expand_and_normalize_parts(mail, mail)
+ leaves = _get_attachment_leaves_recursive(mail, nil, mail)
+ mail.count_parts_count = 0
+ count_parts(mail, mail)
+ return leaves
+ end
+
+ # Recurse through a mail part, selecting the best part wherever there is
+ # an alternative
+ def _get_attachment_leaves_recursive(part, within_rfc822_attachment, parent_mail)
+ leaves_found = []
+ if part.multipart?
+ raise "no parts on multipart mail" if part.parts.size == 0
+ if part.sub_type == 'alternative'
+ best_part = choose_best_alternative(part)
+ leaves_found += _get_attachment_leaves_recursive(best_part,
+ within_rfc822_attachment,
+ parent_mail)
+ else
+ # Add all parts
+ part.parts.each do |sub_part|
+ leaves_found += _get_attachment_leaves_recursive(sub_part,
+ within_rfc822_attachment,
+ parent_mail)
+ end
+ end
+ else
+ # Add all the parts of a decoded attached message
+ if part.rfc822_attachment
+ leaves_found += _get_attachment_leaves_recursive(part.rfc822_attachment,
+ part.rfc822_attachment,
+ parent_mail)
+ else
+ # Store leaf
+ part.within_rfc822_attachment = within_rfc822_attachment
+ leaves_found += [part]
+ end
+ end
+ return leaves_found
+ end
+
+ # Add selected useful headers from an attached message to its body
+ def extract_attached_message_headers(leaf)
+ body = get_part_body(leaf)
+ # Test to see if we are in the first part of the attached
+ # RFC822 message and it is text, if so add headers.
+ if leaf.within_rfc822_attachment == leaf && get_content_type(leaf) == 'text/plain'
+ headers = ""
+ [ 'Date', 'Subject', 'From', 'To', 'Cc' ].each do |header|
+ if header_value = get_header_string(header, leaf.within_rfc822_attachment)
+ if !header_value.blank?
+ headers = headers + header + ": " + header_value.to_s + "\n"
+ end
+ end
+ end
+ # XXX call _convert_part_body_to_text here, but need to get charset somehow
+ # e.g. http://www.whatdotheyknow.com/request/1593/response/3088/attach/4/Freedom%20of%20Information%20request%20-%20car%20oval%20sticker:%20Article%2020,%20Convention%20on%20Road%20Traffic%201949.txt
+ body = headers + "\n" + body
+ end
+ body
+ end
+
+ # Generate a hash of the attributes associated with each significant part of a Mail object
+ def get_attachment_attributes(mail)
+ leaves = get_attachment_leaves(mail)
+ attachments = []
+ for leaf in leaves
+ body = get_part_body(leaf)
+ if leaf.within_rfc822_attachment
+ within_rfc822_subject = leaf.within_rfc822_attachment.subject
+ body = extract_attached_message_headers(leaf)
+ end
+ leaf_attributes = { :url_part_number => leaf.url_part_number,
+ :content_type => get_content_type(leaf),
+ :filename => get_part_file_name(leaf),
+ :charset => leaf.charset,
+ :within_rfc822_subject => within_rfc822_subject,
+ :body => body,
+ :hexdigest => Digest::MD5.hexdigest(body) }
+ attachments << leaf_attributes
+ end
+ return attachments
+ end
+
+ # Format
+ def address_from_name_and_email(name, email)
+ if !MySociety::Validate.is_valid_email(email)
+ raise "invalid email " + email + " passed to address_from_name_and_email"
+ end
+ if name.nil?
+ return Mail::Address.new(email).to_s
+ end
+ address = Mail::Address.new
+ address.display_name = name
+ address.address = email
+ address.to_s
+ end
+
+ def address_from_string(string)
+ Mail::Address.new(string).address
+ end
+ end
+ end
+end \ No newline at end of file
diff --git a/lib/mail_handler/backends/mail_extensions.rb b/lib/mail_handler/backends/mail_extensions.rb
new file mode 100644
index 000000000..f756abd1a
--- /dev/null
+++ b/lib/mail_handler/backends/mail_extensions.rb
@@ -0,0 +1,67 @@
+require 'mail/message'
+require 'mail/fields/common/parameter_hash'
+module Mail
+ class Message
+ attr_accessor :url_part_number
+ attr_accessor :rfc822_attachment # when a whole email message is attached as text
+ attr_accessor :within_rfc822_attachment # for parts within a message attached as text (for getting subject mainly)
+ attr_accessor :count_parts_count
+ attr_accessor :count_first_uudecode_count
+
+ # A patched version of the message initializer to work around a bug where stripping the original
+ # input removes meaningful spaces - e.g. in the case of uuencoded bodies.
+ def initialize(*args, &block)
+ @body = nil
+ @body_raw = nil
+ @separate_parts = false
+ @text_part = nil
+ @html_part = nil
+ @errors = nil
+ @header = nil
+ @charset = 'UTF-8'
+ @defaulted_charset = true
+
+ @perform_deliveries = true
+ @raise_delivery_errors = true
+
+ @delivery_handler = nil
+
+ @delivery_method = Mail.delivery_method.dup
+
+ @transport_encoding = Mail::Encodings.get_encoding('7bit')
+
+ @mark_for_delete = false
+
+ if args.flatten.first.respond_to?(:each_pair)
+ init_with_hash(args.flatten.first)
+ else
+ # The replacement of this commented out line is the change.
+ # init_with_string(args.flatten[0].to_s.strip)
+ init_with_string(args.flatten[0].to_s)
+ end
+
+ if block_given?
+ instance_eval(&block)
+ end
+
+ self
+ end
+ end
+
+ # A patched version of the parameter hash that handles nil values without throwing
+ # an error.
+ class ParameterHash < IndifferentHash
+
+ def encoded
+ map.sort { |a,b| a.first.to_s <=> b.first.to_s }.map do |key_name, value|
+ # The replacement of this commented out line is the change
+ # unless value.ascii_only?
+ unless value.nil? || value.ascii_only?
+ value = Mail::Encodings.param_encode(value)
+ key_name = "#{key_name}*"
+ end
+ %Q{#{key_name}=#{quote_token(value)}}
+ end.join(";\r\n\s")
+ end
+ end
+end \ No newline at end of file
diff --git a/lib/mail_handler/backends/tmail_backend.rb b/lib/mail_handler/backends/tmail_backend.rb
new file mode 100644
index 000000000..02124cdb1
--- /dev/null
+++ b/lib/mail_handler/backends/tmail_backend.rb
@@ -0,0 +1,290 @@
+module MailHandler
+ module Backends
+ module TmailBackend
+
+ def backend()
+ 'TMail'
+ end
+
+ # Turn raw data into a structured TMail::Mail object
+ # Documentation at http://i.loveruby.net/en/projects/tmail/doc/
+ def mail_from_raw_email(data, decode=true)
+ # Hack round bug in TMail's MIME decoding.
+ # Report of TMail bug:
+ # http://rubyforge.org/tracker/index.php?func=detail&aid=21810&group_id=4512&atid=17370
+ copy_of_raw_data = data.gsub(/; boundary=\s+"/im,'; boundary="')
+ mail = TMail::Mail.parse(copy_of_raw_data)
+ mail.base64_decode if decode
+ mail
+ end
+
+ # Extracts all attachments from the given TNEF file as a TMail::Mail object
+ def mail_from_tnef(content)
+ main = TMail::Mail.new
+ main.set_content_type 'multipart', 'mixed', { 'boundary' => TMail.new_boundary }
+ tnef_attachments(content).each do |attachment|
+ tmail_attachment = TMail::Mail.new
+ tmail_attachment['content-location'] = attachment[:filename]
+ tmail_attachment.body = attachment[:content]
+ main.parts << tmail_attachment
+ end
+ main
+ end
+
+ # Return a copy of the file name for the mail part
+ def get_part_file_name(mail_part)
+ part_file_name = TMail::Mail.get_part_file_name(mail_part)
+ if part_file_name.nil?
+ return nil
+ end
+ part_file_name = part_file_name.dup
+ return part_file_name
+ end
+
+ # Get the body of a mail part
+ def get_part_body(mail_part)
+ mail_part.body
+ end
+
+ # Return the first from address if any
+ def get_from_address(mail)
+ if mail.from_addrs.nil? || mail.from_addrs.size == 0
+ return nil
+ end
+ mail.from_addrs[0].spec
+ end
+
+ # Return the first from name if any
+ def get_from_name(mail)
+ mail.from_name_if_present
+ end
+
+ def get_all_addresses(mail)
+ ((mail.to || []) +
+ (mail.cc || []) +
+ (mail.envelope_to || [])).uniq
+ end
+
+ def empty_return_path?(mail)
+ return false if mail['return-path'].nil?
+ return true if mail['return-path'].addr.to_s == '<>'
+ return false
+ end
+
+ def get_auto_submitted(mail)
+ mail['auto-submitted'] ? mail['auto-submitted'].body : nil
+ end
+
+ def get_content_type(part)
+ part.content_type
+ end
+
+ def get_header_string(header, mail)
+ mail.header_string(header)
+ end
+
+ # Number the attachments in depth first tree order, for use in URLs.
+ # XXX This fills in part.rfc822_attachment and part.url_part_number within
+ # all the parts of the email (see monkeypatches in lib/mail_handler/tmail_extensions and
+ # lib/mail_handler/mail_extensions for how these attributes are added). ensure_parts_counted
+ # must be called before using the attributes.
+ def ensure_parts_counted(mail)
+ mail.count_parts_count = 0
+ _count_parts_recursive(mail, mail)
+ # we carry on using these numeric ids for attachments uudecoded from within text parts
+ mail.count_first_uudecode_count = mail.count_parts_count
+ end
+ def _count_parts_recursive(part, mail)
+ if part.multipart?
+ part.parts.each do |p|
+ _count_parts_recursive(p, mail)
+ end
+ else
+ part_filename = get_part_file_name(part)
+ begin
+ if part.content_type == 'message/rfc822'
+ # An email attached as text
+ # e.g. http://www.whatdotheyknow.com/request/64/response/102
+ part.rfc822_attachment = mail_from_raw_email(part.body, decode=false)
+ elsif part.content_type == 'application/vnd.ms-outlook' || part_filename && AlaveteliFileTypes.filename_to_mimetype(part_filename) == 'application/vnd.ms-outlook'
+ # An email attached as an Outlook file
+ # e.g. http://www.whatdotheyknow.com/request/chinese_names_for_british_politi
+ msg = Mapi::Msg.open(StringIO.new(part.body))
+ part.rfc822_attachment = mail_from_raw_email(msg.to_mime.to_s, decode=false)
+ elsif part.content_type == 'application/ms-tnef'
+ # A set of attachments in a TNEF file
+ part.rfc822_attachment = mail_from_tnef(part.body)
+ end
+ rescue
+ # If attached mail doesn't parse, treat it as text part
+ part.rfc822_attachment = nil
+ else
+ unless part.rfc822_attachment.nil?
+ _count_parts_recursive(part.rfc822_attachment, mail)
+ end
+ end
+ if part.rfc822_attachment.nil?
+ mail.count_parts_count += 1
+ part.url_part_number = mail.count_parts_count
+ end
+ end
+ end
+
+ def get_attachment_attributes(mail)
+ leaves = get_attachment_leaves(mail)
+ # XXX we have to call ensure_parts_counted after get_attachment_leaves
+ # which is really messy.
+ ensure_parts_counted(mail)
+ attachment_attributes = []
+ for leaf in leaves
+ body = get_part_body(leaf)
+ # As leaf.body causes MIME decoding which uses lots of RAM, do garbage collection here
+ # to prevent excess memory use. XXX not really sure if this helps reduce
+ # peak RAM use overall. Anyway, maybe there is something better to do than this.
+ GC.start
+ if leaf.within_rfc822_attachment
+ within_rfc822_subject = leaf.within_rfc822_attachment.subject
+ # Test to see if we are in the first part of the attached
+ # RFC822 message and it is text, if so add headers.
+ # XXX should probably use hunting algorithm to find main text part, rather than
+ # just expect it to be first. This will do for now though.
+ if leaf.within_rfc822_attachment == leaf && leaf.content_type == 'text/plain'
+ headers = ""
+ for header in [ 'Date', 'Subject', 'From', 'To', 'Cc' ]
+ if leaf.within_rfc822_attachment.header.include?(header.downcase)
+ header_value = leaf.within_rfc822_attachment.header[header.downcase]
+ if !header_value.blank?
+ headers = headers + header + ": " + header_value.to_s + "\n"
+ end
+ end
+ end
+ # XXX call _convert_part_body_to_text here, but need to get charset somehow
+ # e.g. http://www.whatdotheyknow.com/request/1593/response/3088/attach/4/Freedom%20of%20Information%20request%20-%20car%20oval%20sticker:%20Article%2020,%20Convention%20on%20Road%20Traffic%201949.txt
+ body = headers + "\n" + body
+
+ # This is quick way of getting all headers, but instead we only add some a) to
+ # make it more usable, b) as at least one authority accidentally leaked security
+ # information into a header.
+ #attachment.body = leaf.within_rfc822_attachment.port.to_s
+ end
+ end
+ attachment_attributes << {:url_part_number => leaf.url_part_number,
+ :content_type => get_content_type(leaf),
+ :filename => get_part_file_name(leaf),
+ :charset => leaf.charset,
+ :within_rfc822_subject => within_rfc822_subject,
+ :body => body,
+ :hexdigest => Digest::MD5.hexdigest(body) }
+ end
+ attachment_attributes
+ end
+
+ # (This risks losing info if the unchosen alternative is the only one to contain
+ # useful info, but let's worry about that another time)
+ def get_attachment_leaves(mail)
+ return _get_attachment_leaves_recursive(mail, mail)
+ end
+ def _get_attachment_leaves_recursive(curr_mail, parent_mail, within_rfc822_attachment = nil)
+ leaves_found = []
+ if curr_mail.multipart?
+ if curr_mail.parts.size == 0
+ raise "no parts on multipart mail"
+ end
+
+ if curr_mail.sub_type == 'alternative'
+ # Choose best part from alternatives
+ best_part = nil
+ # Take the last text/plain one, or else the first one
+ curr_mail.parts.each do |m|
+ if not best_part
+ best_part = m
+ elsif m.content_type == 'text/plain'
+ best_part = m
+ end
+ end
+ # Take an HTML one as even higher priority. (They tend
+ # to render better than text/plain, e.g. don't wrap links here:
+ # http://www.whatdotheyknow.com/request/amount_and_cost_of_freedom_of_in#incoming-72238 )
+ curr_mail.parts.each do |m|
+ if m.content_type == 'text/html'
+ best_part = m
+ end
+ end
+ leaves_found += _get_attachment_leaves_recursive(best_part, parent_mail, within_rfc822_attachment)
+ else
+ # Add all parts
+ curr_mail.parts.each do |m|
+ leaves_found += _get_attachment_leaves_recursive(m, parent_mail, within_rfc822_attachment)
+ end
+ end
+ else
+ # XXX Yuck. this section alters various content_types. That puts
+ # it into conflict with ensure_parts_counted which it has to be
+ # called both before and after. It will fail with cases of
+ # attachments of attachments etc.
+ charset = curr_mail.charset # save this, because overwriting content_type also resets charset
+ # Don't allow nil content_types
+ if curr_mail.content_type.nil?
+ curr_mail.content_type = 'application/octet-stream'
+ end
+ # PDFs often come with this mime type, fix it up for view code
+ if curr_mail.content_type == 'application/octet-stream'
+ part_file_name = get_part_file_name(curr_mail)
+ part_body = get_part_body(curr_mail)
+ calc_mime = AlaveteliFileTypes.filename_and_content_to_mimetype(part_file_name, part_body)
+ if calc_mime
+ curr_mail.content_type = calc_mime
+ end
+ end
+
+ # Use standard content types for Word documents etc.
+ curr_mail.content_type = normalise_content_type(curr_mail.content_type)
+ if curr_mail.content_type == 'message/rfc822'
+ ensure_parts_counted(parent_mail) # fills in rfc822_attachment variable
+ if curr_mail.rfc822_attachment.nil?
+ # Attached mail didn't parse, so treat as text
+ curr_mail.content_type = 'text/plain'
+ end
+ end
+ if curr_mail.content_type == 'application/vnd.ms-outlook' || curr_mail.content_type == 'application/ms-tnef'
+ ensure_parts_counted(parent_mail) # fills in rfc822_attachment variable
+ if curr_mail.rfc822_attachment.nil?
+ # Attached mail didn't parse, so treat as binary
+ curr_mail.content_type = 'application/octet-stream'
+ end
+ end
+ # If the part is an attachment of email
+ if curr_mail.content_type == 'message/rfc822' || curr_mail.content_type == 'application/vnd.ms-outlook' || curr_mail.content_type == 'application/ms-tnef'
+ ensure_parts_counted(parent_mail) # fills in rfc822_attachment variable
+ leaves_found += _get_attachment_leaves_recursive(curr_mail.rfc822_attachment, parent_mail, curr_mail.rfc822_attachment)
+ else
+ # Store leaf
+ curr_mail.within_rfc822_attachment = within_rfc822_attachment
+ leaves_found += [curr_mail]
+ end
+ # restore original charset
+ curr_mail.charset = charset
+ end
+ return leaves_found
+ end
+
+
+ def address_from_name_and_email(name, email)
+ if !MySociety::Validate.is_valid_email(email)
+ raise "invalid email " + email + " passed to address_from_name_and_email"
+ end
+ if name.nil?
+ return TMail::Address.parse(email).to_s
+ end
+ # Botch an always quoted RFC address, then parse it
+ name = name.gsub(/(["\\])/, "\\\\\\1")
+ TMail::Address.parse('"' + name + '" <' + email + '>').to_s
+ end
+
+ def address_from_string(string)
+ TMail::Address.parse(string).address
+ end
+
+ end
+ end
+end \ No newline at end of file
diff --git a/lib/mail_handler/backends/tmail_extensions.rb b/lib/mail_handler/backends/tmail_extensions.rb
new file mode 100644
index 000000000..3576a8eca
--- /dev/null
+++ b/lib/mail_handler/backends/tmail_extensions.rb
@@ -0,0 +1,138 @@
+# lib/tmail_extensions.rb:
+# Extensions / fixes to TMail.
+#
+# Copyright (c) 2009 UK Citizens Online Democracy. All rights reserved.
+# Email: francis@mysociety.org; WWW: http://www.mysociety.org/
+
+require 'racc/parser'
+require 'tmail'
+require 'tmail/scanner'
+require 'tmail/utils'
+require 'tmail/interface'
+
+# Monkeypatch!
+
+# These mainly used in app/models/incoming_message.rb
+module TMail
+ class Mail
+ # Monkeypatch! Adding some extra members to store extra info in.
+
+ attr_accessor :url_part_number
+ attr_accessor :rfc822_attachment # when a whole email message is attached as text
+ attr_accessor :within_rfc822_attachment # for parts within a message attached as text (for getting subject mainly)
+ attr_accessor :count_parts_count
+ attr_accessor :count_first_uudecode_count
+
+ # Monkeypatch! (check to see if this becomes a standard function in
+ # TMail::Mail, then use that, whatever it is called)
+ def Mail.get_part_file_name(part)
+ file_name = (part['content-location'] &&
+ part['content-location'].body) ||
+ part.sub_header("content-type", "name") ||
+ part.sub_header("content-disposition", "filename")
+ file_name = file_name.strip if file_name
+ file_name
+ end
+
+ # Monkeypatch! Return the name part of from address, or nil if there isn't one
+ def from_name_if_present
+ if self.from && self.from_addrs[0].name
+ return TMail::Unquoter.unquote_and_convert_to(self.from_addrs[0].name, "utf-8")
+ else
+ return nil
+ end
+ end
+
+ # Monkeypatch! Generalisation of To:, Cc:
+ def envelope_to(default = nil)
+ # XXX assumes only one envelope-to, and no parsing needed
+ val = self.header_string('envelope-to')
+ return val ? [val,] : []
+ end
+
+ # Monkeypatch!
+ # Bug fix to this function - is for message in humberside-police-odd-mime-type.email
+ # Which was originally: https://secure.mysociety.org/admin/foi/request/show_raw_email/11209
+ # See test in spec/lib/tmail_extensions.rb
+ def set_content_type( str, sub = nil, param = nil )
+ if sub
+ main, sub = str, sub
+ else
+ main, sub = str.split(%r</>, 2)
+ raise ArgumentError, "sub type missing: #{str.inspect}" unless sub
+ end
+ if h = @header['content-type']
+ h.main_type = main
+ h.sub_type = sub
+ h.params.clear if !h.params.nil? # XXX this if statement is the fix # XXX disabled until works with test
+ else
+ store 'Content-Type', "#{main}/#{sub}"
+ end
+ @header['content-type'].params.replace param if param
+ str
+ end
+ # Need to make sure this alias calls the Monkeypatch too
+ alias content_type= set_content_type
+
+ end
+
+ module TextUtils
+ # Monkeypatch! Much more aggressive list of characters to cause quoting
+ # than in normal TMail. e.g. Have found real cases where @ needs quoting.
+ # We list characters to allow, rather than characters not to allow.
+ NEW_PHRASE_UNSAFE=/[^A-Za-z0-9!#\$%&'*+\-\/=?^_`{|}~ ]/n
+ def quote_phrase( str )
+ (NEW_PHRASE_UNSAFE === str) ? dquote(str) : str
+ end
+ end
+end
+
+# Monkeypatch! TMail 1.2.7.1 will parse only one address out of a list of addresses with
+# unquoted display parts https://github.com/mikel/tmail/issues#issue/9 - this monkeypatch
+# fixes this issue.
+module TMail
+
+ class Parser < Racc::Parser
+
+module_eval <<'..end lib/tmail/parser.y modeval..id2dd1c7d21d', 'lib/tmail/parser.y', 340
+
+ def self.special_quote_address(str) #:nodoc:
+ # Takes a string which is an address and adds quotation marks to special
+ # edge case methods that the RACC parser can not handle.
+ #
+ # Right now just handles two edge cases:
+ #
+ # Full stop as the last character of the display name:
+ # Mikel L. <mikel@me.com>
+ # Returns:
+ # "Mikel L." <mikel@me.com>
+ #
+ # Unquoted @ symbol in the display name:
+ # mikel@me.com <mikel@me.com>
+ # Returns:
+ # "mikel@me.com" <mikel@me.com>
+ #
+ # Any other address not matching these patterns just gets returned as is.
+ case
+ # This handles the missing "" in an older version of Apple Mail.app
+ # around the display name when the display name contains a '@'
+ # like 'mikel@me.com <mikel@me.com>'
+ # Just quotes it to: '"mikel@me.com" <mikel@me.com>'
+ when str =~ /\A([^"][^<]+@[^>]+[^"])\s(<.*?>)\Z/
+ return "\"#{$1}\" #{$2}"
+ # This handles cases where 'Mikel A. <mikel@me.com>' which is a trailing
+ # full stop before the address section. Just quotes it to
+ # '"Mikel A." <mikel@me.com>'
+ when str =~ /\A(.*?\.)\s(<.*?>)\s*\Z/
+ return "\"#{$1}\" #{$2}"
+ else
+ str
+ end
+ end
+
+..end lib/tmail/parser.y modeval..id2dd1c7d21d
+ end # class Parser
+
+end # module TMail
+
+
diff --git a/lib/mail_handler/mail_handler.rb b/lib/mail_handler/mail_handler.rb
new file mode 100644
index 000000000..8b227b9ca
--- /dev/null
+++ b/lib/mail_handler/mail_handler.rb
@@ -0,0 +1,179 @@
+# Handles the parsing of email
+require 'tmpdir'
+
+module MailHandler
+
+ if RUBY_VERSION.to_f >= 1.9
+ require 'mail'
+ require 'backends/mail_extensions'
+ require 'backends/mail_backend'
+ include Backends::MailBackend
+ else
+ require 'action_mailer'
+ require 'backends/tmail_extensions'
+ require 'backends/tmail_backend'
+ include Backends::TmailBackend
+ end
+
+ # Returns a set of attachments from the given TNEF contents
+ # The TNEF contents also contains the message body, but in general this is the
+ # same as the message body in the message proper.
+ def tnef_attachments(content)
+ attachments = []
+ Dir.mktmpdir do |dir|
+ IO.popen("#{`which tnef`.chomp} -K -C #{dir}", "wb") do |f|
+ f.write(content)
+ f.close
+ if $?.signaled?
+ raise IOError, "tnef exited with signal #{$?.termsig}"
+ end
+ if $?.exited? && $?.exitstatus != 0
+ raise IOError, "tnef exited with status #{$?.exitstatus}"
+ end
+ end
+ found = 0
+ Dir.new(dir).sort.each do |file| # sort for deterministic behaviour
+ if file != "." && file != ".."
+ file_content = File.open("#{dir}/#{file}", "rb").read
+ attachments << { :content => file_content,
+ :filename => file }
+ found += 1
+ end
+ end
+ if found == 0
+ raise IOError, "tnef produced no attachments"
+ end
+ end
+ attachments
+ end
+
+ def normalise_content_type(content_type)
+ # e.g. http://www.whatdotheyknow.com/request/93/response/250
+ if content_type == 'application/excel' or content_type == 'application/msexcel' or content_type == 'application/x-ms-excel'
+ content_type = 'application/vnd.ms-excel'
+ end
+ if content_type == 'application/mspowerpoint' or content_type == 'application/x-ms-powerpoint'
+ content_type = 'application/vnd.ms-powerpoint'
+ end
+ if content_type == 'application/msword' or content_type == 'application/x-ms-word'
+ content_type = 'application/vnd.ms-word'
+ end
+ if content_type == 'application/x-zip-compressed'
+ content_type = 'application/zip'
+ end
+
+ # e.g. http://www.whatdotheyknow.com/request/copy_of_current_swessex_scr_opt#incoming-9928
+ if content_type == 'application/acrobat'
+ content_type = 'application/pdf'
+ end
+
+ return content_type
+ end
+
+ def get_attachment_text_one_file(content_type, body, charset = 'utf-8')
+ # note re. charset: TMail always tries to convert email bodies
+ # to UTF8 by default, so normally it should already be that.
+ text = ''
+ # XXX - tell all these command line tools to return utf-8
+ if content_type == 'text/plain'
+ text += body + "\n\n"
+ else
+ tempfile = Tempfile.new('foiextract')
+ tempfile.binmode
+ tempfile.print body
+ tempfile.flush
+ default_params = { :append_to => text, :binary_output => false }
+ if content_type == 'application/vnd.ms-word'
+ AlaveteliExternalCommand.run("wvText", tempfile.path, tempfile.path + ".txt")
+ # Try catdoc if we get into trouble (e.g. for InfoRequestEvent 2701)
+ if not File.exists?(tempfile.path + ".txt")
+ AlaveteliExternalCommand.run("catdoc", tempfile.path, default_params)
+ else
+ text += File.read(tempfile.path + ".txt") + "\n\n"
+ File.unlink(tempfile.path + ".txt")
+ end
+ elsif content_type == 'application/rtf'
+ # catdoc on RTF prodcues less comments and extra bumf than --text option to unrtf
+ AlaveteliExternalCommand.run("catdoc", tempfile.path, default_params)
+ elsif content_type == 'text/html'
+ # lynx wordwraps links in its output, which then don't
+ # get formatted properly by Alaveteli. We use elinks
+ # instead, which doesn't do that.
+ AlaveteliExternalCommand.run("elinks", "-eval", "set document.codepage.assume = \"#{charset}\"",
+ "-eval", "set document.codepage.force_assumed = 1",
+ "-dump-charset", "utf-8",
+ "-force-html", "-dump",
+ tempfile.path,
+ default_params.merge(:env => {"LANG" => "C"}))
+ elsif content_type == 'application/vnd.ms-excel'
+ # Bit crazy using /usr/bin/strings - but xls2csv, xlhtml and
+ # py_xls2txt only extract text from cells, not from floating
+ # notes. catdoc may be fooled by weird character sets, but will
+ # probably do for UK FOI requests.
+ AlaveteliExternalCommand.run("/usr/bin/strings", tempfile.path, default_params)
+ elsif content_type == 'application/vnd.ms-powerpoint'
+ # ppthtml seems to catch more text, but only outputs HTML when
+ # we want text, so just use catppt for now
+ AlaveteliExternalCommand.run("catppt", tempfile.path, default_params)
+ elsif content_type == 'application/pdf'
+ AlaveteliExternalCommand.run("pdftotext", tempfile.path, "-", default_params)
+ elsif content_type == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ # This is Microsoft's XML office document format.
+ # Just pull out the main XML file, and strip it of text.
+ xml = AlaveteliExternalCommand.run("/usr/bin/unzip", "-qq",
+ "-c",
+ tempfile.path,
+ "word/document.xml",
+ {:binary_output => false})
+ if !xml.nil?
+ doc = REXML::Document.new(xml)
+ text += doc.each_element( './/text()' ){}.join(" ")
+ end
+ elsif content_type == 'application/zip'
+ # recurse into zip files
+ begin
+ zip_file = Zip::ZipFile.open(tempfile.path)
+ text += get_attachment_text_from_zip_file(zip_file)
+ zip_file.close()
+ rescue
+ $stderr.puts("Error processing zip file: #{$!.inspect}")
+ end
+ end
+ tempfile.close
+ end
+
+ return text
+ end
+ def get_attachment_text_from_zip_file(zip_file)
+
+ text = ""
+ for entry in zip_file
+ if entry.file?
+ filename = entry.to_s
+ begin
+ body = entry.get_input_stream.read
+ rescue
+ # move to next attachment silently if there were problems
+ # XXX really should reduce this to specific exceptions?
+ # e.g. password protected
+ next
+ end
+ calc_mime = AlaveteliFileTypes.filename_to_mimetype(filename)
+ if calc_mime
+ content_type = calc_mime
+ else
+ content_type = 'application/octet-stream'
+ end
+
+ text += get_attachment_text_one_file(content_type, body)
+
+ end
+ end
+ return text
+ end
+
+ # Turn instance methods into class methods
+ extend self
+
+end
+