2016-06-09T21:48:48+09:00

メール.app で選択されているメッセージの添付ファイルを IMAP サーバーからダウンロードする(添付ファイルの文字化け回避)

OS X 付属のメール.app には昔から添付ファイルが文字化けするという持病がある。OS X 10.8 までは、MIMEfix という文字化けを修正してくれる秀逸なプラグインがあり、重宝していた。大変残念なことに開発が停止してしまい、OS X 10.9 以降に対応した MIMEfix プラグインが存在しない。

仕様がないから、iCloud の Web メールから添付ファイルをダウンロードしていた。大変めんどくさい。Web メールだと文字化けしないのだから、技術的に難しいことがあるわけでもない。単純に Apple の怠慢でバグが放置されていると思われる(しかし、そうとも言い切れない事情は後ほど)。

添付ファイルのファイル名は、MIME の Content-Disposition: フィールドの filename パラメータに RFC2231 もしくは MIME B でエンコーディングされて記述される。添付ファイルのファイル名が長いと、filename パラメータは複数行に分割されることがある。自分の調べた限りでは、filename フィールドが MIME B でエンコーディングされて、なおかつ複数行に分割されている場合は、ほとんど文字化けが起きる。しかし、そうでない場合もわずかにあるので悩ましい。自分が受け取る添付ファイルのファイル名は、ほとんどは MIME B でエンコードされているので文字化けしまくりである。

ちなみに、メール.app は添付ファイルのファイル名を RFC2331 でエンコードしている。そして、どんなに長いファイル名でも複数行に分割したりしない。Thunderbird もメール.appと同じように、RFC2331 でエンコードするとのこと。そして、メール.app と違ってファイル名が長いと複数行に分割するとのこと。その昔は、Thunderbird から複数行に分割されてエンコードされた添付ファイル名も文字化けしていたが、自分の試した限りでは OS X 10.11 のメール.app ではバグフィックスされている模様。

先ほど、文字化けするのは Apple の怠慢などと偉そうなことを言ったが、そもそも添付ファイル名は MEME B ではなく、RFC2331でエンコードしなければならない決まりになっている。MIME B でファイル名をエンコードして送りつけてくる 行儀の悪いWindows 系のメーラーが諸悪の根源である、というのが本当のところで、単純に Apple をせめてはかわいそうとも思う。

そうも言っていられないので、手軽に文字化けしていない添付ファイルを入手すべく、メール.app で選択されいるメッセージの添付ファイルを IMAP サーバーからダウンロードするスクリプトを書いた。もっぱら、ruby で書き始めたが、半分以上がヒアドキュメントで埋め込まれた AppleScript という不恰好なものになってしまった。

なお、実行には、gem で Mail ライブラリをインストールする必要があります。

#!/usr/bin/env ruby
# coding: utf-8

# Download attachments of selected message in Apple Mail.app
# form IMAP server.

require 'net/imap'
require 'pp'
require 'mail'
require 'yaml'
require 'pathname'
require 'optparse'

def main
  opts = ARGV.getopts('', 'raw_attachments', 'source')
  
  minfo = mail_info
  if minfo.nil? then
    exit 0
  end
  imap = Net::IMAP.new(minfo['server'], minfo['port'], minfo['use_ssl'])
  begin
    imap.login(minfo['user'], minfo['password'])
  rescue => e
    display_alert("Failed to login with error: #{e.message}")
    exit 0
  end
  imap.select(minfo['mailbox'])
  msg_ids = imap.search(["HEADER", "Message-Id", minfo['message_id']])
  a_msg = imap.fetch(msg_ids[0], "RFC822")
  if (opts['source']) then
    print a_msg[0].attr['RFC822']
  end
  m = Mail.new(a_msg[0].attr["RFC822"])
  if m.multipart? then
    saved_files = []
    m.attachments.each do |attachment|
      # 添付ファイルの種類とファイル名
      if opts['raw_attachments'] then
        print attachment
        next
      end
      
      # 添付ファイルの保存処理
      Dir.chdir(minfo['location'])
      filename = attachment.filename
      begin
        File.open(filename, "w+b") {|f|
          f.write attachment.body.decoded
          saved_files.push(Pathname.new(minfo['location'])+filename)
        }
      rescue => e
        #puts "添付ファイルの保存に失敗 #{e.message}"
        display_alert("Failed to save attachments with error: #{e.message}")
      end
    end
    if saved_files.length > 0 then
      final_message(saved_files)
    end
  end

  imap.disconnect
end

def mail_info
  mail_result = `osascript << EOS
tell application id "com.apple.Mail"
  set msgs to selection
  if (count msgs) < 1 then
    display alert "No selected messages."
    return ""
  end if
  
  set msgs_with_attachments to {}
  repeat with a_msg in msgs
    try
      set has_attachments to exists mail attachments of a_msg
    on error
      set has_attachments to true
    end try
    if has_attachments then
      set end of msgs_with_attachments to a_msg
    end if
  end repeat
end tell

set nmsg to count msgs_with_attachments
if nmsg < 1 then
   display alert "No attachements in the selected message."
   return ""
end if

if nmsg > 1 then
  set subject_list to {}
  tell application id "com.apple.Mail"
    set msg_idx to 1
    repeat with a_msg in msgs_with_attachments
      set dt to date received of a_msg
      set end of subject_list to (msg_idx as text) & space ¬
                           & (subject of a_msg) & tab ¬
                           & (short date string of dt) & space ¬
                           & (time string of dt)
      set msg_idx to msg_idx + 1
    end repeat
  end tell
  set a_result to choose from list subject_list with prompt "Choose a message to save attachments" without multiple selections allowed
  if class of a_result is not list then
    return ""
  end if
  set msg_idx to (word 1 of item 1 of a_result) as number
  set target_msg to item msg_idx of msgs_with_attachments
else
  set target_msg to first item of msgs_with_attachments
end if

tell application id "com.apple.Mail"
  tell target_msg
    try
      set has_attachments to exists mail attachments
    on error
      set has_attachments to true
    end try
    if not has_attachments then
      tell current application
        display alert "No attachements in the selected message."
      end tell
      return ""
    end if

    set msgid to message id
    set mbox to its mailbox
    set mbox_name to name of (its mailbox)
    set server_name to server name of account of (its mailbox)
    set use_ssl to uses ssl of account of (its mailbox)
    tell account of (its mailbox)
      set account_name to name
      set user_name to user name
      set port_number to port
    end tell
  end tell
  repeat
    set a_container to (get container of mbox)
    if (class of a_container) is not container then
      exit repeat
    end if
    set mbox to a_container
    set mbox_name to (name of mbox) & "/" & mbox_name
  end repeat
end tell

try
  set a_location to choose folder with prompt "Choose a location to save attachments"
on error
  return ""
end try
try
  set a_result to display dialog "Enter password for " & account_name default answer "" with hidden answer
on error
  return ""
end try
set a_password to text returned of a_result

set lf to ascii character 10
return "---" & lf & ¬
"server: " & server_name & lf & ¬
"port: " & port_number & lf & ¬
"use_ssl: " & (use_ssl as text) & lf & ¬
"user: " & user_name & lf & ¬
"password: " & a_password & lf & ¬
"mailbox: " & mbox_name & lf & ¬
"message_id: " & msgid & lf & ¬
"location: " & (POSIX path of a_location) & lf & ¬
"---"
EOS`
  if mail_result.length == 1 then
    return nil
  end
  #pp mail_result
  
  return YAML.load(mail_result) 
end

def display_alert(msg)
  `osascript << EOS
display alert "#{msg}"
EOS`
end

def final_message(saved_files)
  saved_files_joined = saved_files.join("\n")
  `osascript << EOS
set file_paths to "#{saved_files_joined}"
set a_result to display alert "Attachments are saved." message file_paths ¬
                   buttons {"Cancel", "Reveal", "OK"}
if button returned of a_result is "Reveal" then
  tell application id "com.apple.finder"
    reveal (paragraph 1 of file_paths as POSIX file)
    activate
  end tell
end if
EOS`
end

main

もう、Thunderbird に乗り換えようかな?