Plasticx Blog

Capable of being shaped or formed

decoding email attachments with ActionMailer::Base receive

Posted by Mike 12/30/2006 at 09:35PM

There are lots of references that say you can decode images and other media attachements in email using the receive method of ActionMailer::Base

Ruby On Rails Wiki is one: HowToReceiveEmailsWithActionMailer.

Rails Recipes Recipe #68 “Testing Incoming Mail” is another.

But those references and many others on the internet don’t show you how to really do it.

Here’s how its done …

The key is to use the TMail::Mail email object’s parts to do your work.
Use the mailer generator to stub out your code:


ruby script/generate mailer HelloWorldMail
exists app/models/
create app/views/hello_world_mail
exists test/unit/
create test/fixtures/hello_world_mail
create app/models/hello_world_mail.rb
create test/unit/hello_world_mail_test.rb

The full files in my example are contained in this bzip archive: hello-world-mail.tar.bz2

See the hello-world.mail test fixture. It contains a real email file with a ruby logo image file as an attachment. The file goes in the hello_world_mail test fixture directory: test/fixtures/hello_world_mail/hello-world.mail

This is the snippet of code in the unit test that will have the HelloWolrdMail parse that raw file into a TMail::Mail object which is passed into the receive method:

  def test_hello_world
      email_text = read_fixture('hello-world.mail').join
      HelloWorldMail.receive(email_text)
  end

HelloWolrdMail decodes the mail and places files into the ‘tmp’ directory in your Rails root.

Here is all of the code of the HelloWolrdMail ActionMailer::Base so you don’t have to unzip the archive. The real work is to enumerate over the parts of the mail and then dump the decoded body to disk. TMail auto-magically decodes the content ecoding (7bit, 8bit, quoted-printable, base64, binary). When TMail parses out the attachments of a mail it doesn’t treat a text/plain part as a part. It does parse all of the content to parts but leaves out the original file name. So this is how I get the best of both worlds.

The text of the mail will be contained in #{RAILS_ROOT}/tmp/1.txt and the ruby-lang.org logo that was attached to the mail will be contained in #{RAILS_ROOT}/tmp/ruby.gif

Update 07/10/2007 See DANG Zhengfa’s post in the comments section for a cleaner code. My example was written when I was a bit more of a n00b. Also my code was dealing TMail before it was fixed in the ActionMailer in the latest 1.3.3 release.

Update 08/01/2007

Cleaned up the code with better Ruby idioms, when I first wrote it I was still unlearning Java. As PJ says uses attachment_fu if you just want to get at the content quickly. This code works with Rails 1.2.3 / ActionMailer 1.3.3 which automatically decodes the attachment’s content encoding.

class HelloWorldMail < ActionMailer::Base
  # email is a TMail::Mail
  def receive(email)
    #email.attachments are TMail::Attachment
    #but they ignore a text/mail parts.
    email.parts.each_with_index do |part, index|
      filename = part_filename(part)
      filename ||= "#{index}.#{ext(part)}"
      filepath = "#{RAILS_ROOT}/tmp/#{filename}"
      puts "WRITING: #{filepath}"
      File.open(filepath,File::CREAT|File::TRUNC|File::WRONLY,0644) do |f|
        f.write(part.body)
      end
    end
  end

  # part is a TMail::Mail
  def part_filename(part)
    # This is how TMail::Attachment gets a filename
    file_name = (part['content-location'] &&
      part['content-location'].body) ||
      part.sub_header("content-type", "name") ||
      part.sub_header("content-disposition", "filename")
  end

  CTYPE_TO_EXT = {
    'image/jpeg' => 'jpg',
    'image/gif'  => 'gif',
    'image/png'  => 'png',
    'image/tiff' => 'tif'
  }

  def ext( mail )
    CTYPE_TO_EXT[mail.content_type] || 'txt'
  end
end

Posted in , , |

Trackbacks<

Use the following link to trackback from your own site:
http://plasti.cx/trackbacks?article_id=219

  1. Freedom Dumlao
    03/15/2007 at 07:55AM

    I see in your example that you are decoding everything with base64_decode. What happens if it is encoded differently?

  2. mikemondragon
    03/15/2007 at 08:57PM

    Good eyes. TMail auto-magically decodes the transfer encoding of a part’s body. So I updated my example. Thanks!

  3. Dave
    03/18/2007 at 11:31PM

    My TMail just returns a body of 2 bytes for any attachment (including the one in your .tar). It seems that it’s not decoding the attachment when I call body (and looking at the code for #body shows that a non-multipart returns unquoted_body(to_charset) … to_charset defaults to ‘utf-8’.

    Do I have an old version of TMail somehow?

  4. PJ Hyett
    05/12/2007 at 11:13PM

    A simpler solution thanks to the attachment_fu plugin is:

    class Attachment < ActiveRecord::Base
      has_attachment
    end
    
    def receive(email)
      email.attachments.each do |attachment|
        Attachment.create :uploaded_data => attachment
      end
    end
    

    That’s all there is to it.

  5. DANG Zhengfa
    07/10/2007 at 11:33PM

    I have tried your code on the latest tmail. it does not work. I modifed your code, here it is:

    1. parser.rb
      #
    2. MIME multipart parsing test
      #

    require ‘tmail’

    1. To get part’s filename
    2. part is a TMail::Mail
      def part_filename(part)
    1. print some useful informaion
      puts “….”
      puts “transfer_encoding: #{part.transfer_encoding}”
      puts “content loca: #{part[‘content-location’]}”
      puts “content type: #{part.content_type}”
      puts " main type: #{part.main_type}"
      puts " sub type: #{part.sub_type}"
      puts " type param:"
      puts " charset: #{part.type_param(‘charset’)}"
      puts " name: #{part.type_param(‘name’)}"
      puts “disposition type : #{part.disposition}”
      puts " param-filename: #{part.disposition_param(‘filename’)}"
      puts “….”
    1. get filename
      if part[‘content-location’] != nil && part[‘content-location’].body.length != 0
      filename = part[‘content-location’].body
      elsif part.type_param(‘name’) != nil && part.type_param(‘name’).length != 0
      filename = part.type_param(‘name’)
      elsif part.disposition_param(‘filename’) != nil && part.disposition_param(‘filename’).length != 0
      filename = part.disposition_param(‘filename’)
      else
      filename = nil
      end
      end
    CTYPE_TO_EXT = { ‘image/jpeg’ => ‘jpg’, ‘image/gif’ => ‘gif’, ‘image/png’ => ‘png’, ‘image/tiff’ => ‘tif’ } def ext( mail ) CTYPE_TO_EXT[mail.content_type] || ‘txt’ end
    1. main function to parse email
      def receive(email)
      #email.attachments are TMail::Attachment
      #but they ignore a text/mail parts.
      if email.multipart?
      tmp_dir = “/usr/local/portal/public/parts”
      idx = 1
      email.parts.each do |part|
      if part.multipart?
      receive(part)
      else
      puts “--#{idx}——”
      filename = part_filename(part)
      filename = “#{idx}.#{ext(part)}” if filename.nil?
      filepath = tmp_dir + ‘/’ + filename
      puts “#{filepath}”
      File.open(filepath,File::CREAT|File::TRUNC|File::WRONLY,0644) { |f|
      if part.transfer_encoding != “base64”
      f.write( part.body )
      elsif
      f.write( TMail::Base64.decode(part.body) )
      end
      }
      puts " "
      idx += 1
      end # end if
      end # end email.parts.
      end # end if
      end # end def
    1. main entrance, parse the email content recursively

    email = TMail::Mail.load( ARGV0 || ‘email.dat’ )
    receive(email)


    Now you can use > ruby parser.rb myemail.dat

    to parse all parts of email, recursively. All images & text will be sperated from the email properly.

  6. DANG Zhengfa
    07/10/2007 at 11:42PM

    sorry for so many postings..

  7. mikemondragon
    07/10/2007 at 11:56PM

    I cleaned up the posts DANG, thanks for taking the time to clean up the code and present it so that others can learn from it.

  8. Kip Cole
    08/01/2007 at 03:15AM

    Hmmm, this is exactly what I need to do!! Extract a jpeg from an email. However, using Mr/Ms Dang’s version which gives lots of great feedback – however my jpeg attachment is resolved to a 2 byte file (the plain text and html parts of the multipart message are saved fine). The attachment is show with the right filename, base64 encoding and the right MIME type.

    Am on Rails 1.2.3.7116.

    Any thoughts or ideas on how to get past this hurdle?

    Cheers, —Kip

  9. Kip Cole
    08/01/2007 at 07:30AM

    OK, I’ve worked it out. Apply the patch at http://dev.rubyonrails.org/attachment/ticket/7861/base64_decoding.patch, which means you don’t have to check for a part being in base64 (all back to automatic).

    And then make sure the file is opened in binary mode (the code here doesn’t do that, at least for this windows machine).

    Now all is good.

    —Kip

  10. mikemondragon
    08/01/2007 at 06:40PM

    @KipCole did you say automatic or automagic? :)

    And my original code has of the fresh smell of noob all over it, you get a free index on Enumerable’s with enum.each_with_index, so I updated the original code see the “update 08/01/2007” comments.

  11. monde
    11/05/2007 at 10:55PM

    decode automagically decodes the transfer encoding of mime parts now (again). This blog post was before TMail was fixed in Rails 1.2.2 back in around March of 2007. Also the official TMail with all of the Rails patches is being maintained again at Rubyforge
    http://rubyforge.org/projects/tmail/

  12. mmo
    04/21/2008 at 03:04PM

    Decoding mail via Rails is one of the neatest features I’ve seen.


Web Statistics