crewmate/crewmate

View on GitHub
app/models/emailer/incoming.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# -*- encoding : utf-8 -*-
require 'net/pop'
require 'net/imap'
require 'net/http'
require 'action_view/helpers/text_helper'

# Receives email
#
# proj-permalink@app.server.com
#   → new conversation with Subject as a title and Body as a comment
#
# proj-permalink+conversation@app.server.com
#   → find or create conversation with Subject as a title and Body as a comment
#
# proj-permalink+task@app.server.com
#   → new task with Subject (or the Body if not present) as title
#
# proj-permalink+conversation+5@app.server.com
#   → new comment for the conversation #5
#
# proj-permalink+task+12@app.server.com
#   → new comment for the task #12
#
# Invalid or malformed emails will be ignored and sometimes bounced to the receiver.

module Emailer::Incoming
  include ActionView::Helpers::TextHelper
  ACTION_MATCH = /^\s*#(\w+)/

  def self.fetch(settings)
    type = settings[:type].to_s.downcase
    send("fetch_#{type}", settings)
  rescue SocketError
    settings_out = settings.merge(:password => '*' * settings[:password].to_s.length)
    Rails.logger.error "Error connecting to mail server with settings:\n  #{settings_out.inspect}"
    raise
  end

  def self.fetch_pop(settings)
    Net::POP3.start(settings[:address], settings[:port], settings[:user_name], settings[:password]) do |pop|
      pop.mails.each do |email|
        begin
          Emailer.receive(email.pop)
          email.delete
        rescue Exception
          Rails.logger.error "Error receiving email at #{Time.now}: #{$!}"
        end
      end
    end
  end

  def self.fetch_imap(settings)
    imap = Net::IMAP.new(settings[:address], settings[:port], true)
    imap.login(settings[:user_name], settings[:password])
    imap.select('Inbox')

    imap.uid_search(["NOT", "DELETED"]).each do |uid|
      source = imap.uid_fetch(uid, ['RFC822']).first.attr['RFC822']

      begin
        Emailer.receive(source)
      rescue Exception
        Rails.logger.error "Error receiving email at #{Time.now}: #{$!}"
      end

      imap.uid_copy(uid, "[Gmail]/All Mail")
      imap.uid_store(uid, "+FLAGS", [:Deleted])
    end

    imap.expunge
    imap.logout
    imap.disconnect
  end

  REPLY_REGEX = /(re|fwd):/i

  # Instance method invoked by class method of the same name.
  # Receives a parsed and decoded TMail::Mail object.
  def receive(email)
    email = ParamsMail.new(email) if Hash === email

    # TODO: ease a bit on the ivars pls
    process_incoming email
    get_target email

    case @type
    when :project
      create_conversation
    when :conversation
      if @target then post_to(@target)
      else create_conversation
      end
    when :task
      unless @target
        @target = create_task
      end

      get_action
      @body = extract_action
      post_to(@target)
    end
  end

  private

  # Sendgrid params to act as TMail::Mail
  class ParamsMail
    def initialize(params)
      @params = params
      @from = @to = @cc = nil
      @attachments = nil
      @charsets = JSON.parse(@params[:charsets] || '{}')
    end

    %w[from to cc].each do |field|
      class_eval <<-CODE
        def #{field}
          @#{field} ||= field_to_addr(:#{field})
        end
      CODE
    end

    def body
      @body ||= field_to_utf8(:text)
    end

    def subject
      @params[:subject]
    end

    def attachments
      @attachments ||= begin
        files = []
        @params[:attachments].to_i.times { |i|
          files << @params[:"attachment#{i+1}"]
        }
        files
      end
    end

    private

    def field_to_addr(field)
      value = @params[field.to_sym]
      return if value.blank?
      # RAILS3 report bug, this doesn't parse with a newline char at the end
      header = Mail::Field.new(field.to_s, value.strip)
      header.addrs.map &:address
    end

    def field_to_utf8(field)
      value = @params[field.to_sym]
      return value if value.blank?

      charset = @charsets[field.to_s]
      if charset and charset.downcase != 'utf-8'
        begin
          value = Iconv.iconv('utf-8', charset, value).first
        rescue Iconv::IllegalSequence, Iconv::InvalidEncoding, Errno::EINVAL
          # do nothing
        end
      end
      return value
    end
  end

  class MissingInfo < ArgumentError; end
  class Error < StandardError
    attr_accessor :mail

    def initialize(mail, message)
      super(message)
      @mail = mail
    end

    def sender?
      mail.from.present?
    end
  end

  class UserNotFoundError < Error; end
  class NotProjectMemberError < Error; end
  class ProjectNotFoundError < Error; end
  class TargetNotFoundError < Error; end

  # accepts params in Sendgrid's format: http://wiki.sendgrid.com/doku.php?id=parse_api
  def process_incoming(email)
    raise MissingInfo, "Invalid mail body" if email.body.blank?

    from = Array(email.from).first
    raise MissingInfo, "Invalid From field" if from.nil?

    configured_domain = Teambox.config.smtp_settings[:domain]
    destinations = Array(email.to) + Array(email.cc)
    target = destinations.detect { |a| a.include? configured_domain }
    raise MissingInfo, "Invalid To fields" if target.nil?

    @to = target.split('@').first.downcase
    @project = Project.find_by_permalink @to.split('+').first
    raise ProjectNotFoundError.new(email, "Invalid project '#{@to}'") unless @project

    @user = User.find_by_email from
    raise UserNotFoundError.new(email, "Invalid user '#{email.from.first}'") unless @user
    raise NotProjectMemberError.new(email, "User does not belong to project") unless @user.projects.include? @project

    # Get the body in multipart emails as well
    if email.respond_to? :parts
      parts = email.parts.select{|p| p.content_type.include?('text/')}
      @body = parts.any? ? parts.collect(&:decoded).join("\n") : email.body
    else
      @body = email.body
    end
    #strip any remaining html tags (after strip_responses) from the body
    @body    = strip_responses(@body).strip_tags.to_s.strip
    @subject = email.subject.to_s.gsub(REPLY_REGEX, "").strip
    @files   = email.attachments || []

    Rails.logger.info "#{@user.name} <#{@user.email}> sent '#{@subject}' to #{@to}"
  end

  # Removes 'On ... bla bla wrote line'
  # Splits emails on answer line and takes top half
  # Gmail adds <div class='email' to indicate where real message begins
  # so we split on that too and again take top half
  # finally strip any whitespace
  def strip_responses(body)
    # For GMail. Matches "On 19 August 2010 13:48, User <proj+conversation+22245@app.crewmate.org<proj%2Bconversation%2B22245@app.crewmate.org>> wrote:"
    body.to_s.strip.
      gsub(/\n[^\r\n]*\d{2,4}.*\+.*\d@app.crewmate.org.*:.*\z/m, '').
      split(Emailer::ANSWER_LINE).first.
      split("<div class='email'").first.
      strip
  end

  # Decides which kind of object we'll be posting to (Conversation, Task, Task List..)
  # and finds it if appliable.
  def get_target(email)
    # projectname+targetclass+id@mailserver.com
    permalink, klass, object_id = @to.split('+')

    begin
      @type = klass ? klass.singularize.to_sym : :project

      if object_id
        @target = @project.send(@type.to_s.pluralize).find(object_id)
      elsif klass
        case @type
        when :conversation
          @target = @project.conversations.find_by_name(@subject)
        when :task then # do nothing
        else
          raise ArgumentError, "unknown type: #{@type}"
        end
      else
        @target = @project
      end
    rescue ArgumentError, NoMethodError, ActiveRecord::RecordNotFound => ex
      Rails.logger.debug "[Incoming email] captured #{ex.class}: #{ex.message}"
      raise TargetNotFoundError.new(email, "couldn't process target #{@to}")
    end
  end

  # Determines the #action
  # The commands are #resolve / #resolved, #username, #reject / #rejected and #hold.
  def get_action
    if @body =~ ACTION_MATCH
      tag = $1.downcase

      @target_action = case tag
      when 'open', 'reopen'      then :open
      when 'resolve', 'resolved' then :resolved
      when 'reject', 'rejected'  then :rejected
      when 'hold'                then :hold
      else
        if person = @target.project.people.by_login(tag).first
          @target_person = person
          :assign
        end
      end
    end
  end

  def extract_action
    get_action
    @body.sub(ACTION_MATCH, '').strip
  end

  def post_to(target)
    Rails.logger.info "Posting to #{target.class.to_s} #{target.id} '#{@subject}'"

    attributes = @files.collect {|f| { :asset => f }}
    if target.is_a? Task
      target.updating_user = @user
      target.comments_attributes = [{:body => @body, :uploads_attributes => attributes}]

      case @target_action
      when :assign
        target.status_name = :open
        target.assigned = @target_person
      when Symbol
        target.status_name = @target_action
      end

      target.save!
    else
      comment = target.comments.new_by_user(@user, :body => @body, :uploads_attributes => attributes)
      comment.save!
    end
  end

  def create_conversation
    Rails.logger.info "Creating conversation '#{@subject}'"

    attributes = @files.collect {|f| { :asset => f }}

    conversation = @project.conversations.new_by_user(@user, :comments_attributes => [{:body => @body, :uploads_attributes => attributes}])

    if @subject.blank?
      conversation.simple = true
    else
      conversation.name = @subject
    end

    conversation.save!
  end

  def create_task
    raise "Subject and body cannot be blank when creating task from email" if @subject.blank? && @body.blank?
    Rails.logger.info "Creating task '#{@subject}'"

    task_list_name = "Inbox"
    task_list = @project.task_lists.find_by_name(task_list_name) || @project.task_lists.create! do |task_list|
      task_list.user = @user
      task_list.name = task_list_name
    end

    task = task_list.tasks.create! do |task|
      task.name = @subject.blank? ? truncate(@body, :length => 255) : @subject
      task.project = @project
      task.user = @user
    end
  end

end