Thunderbirdの要約ファイルやアドレス帳ファイルの内容を読む方法 - 2014-05-21 - ククログ

ククログ

株式会社クリアコード > ククログ > Thunderbirdの要約ファイルやアドレス帳ファイルの内容を読む方法

Thunderbirdの要約ファイルやアドレス帳ファイルの内容を読む方法

FirefoxやThunderbirdではデータの永続的な保存のためにSQLiteやJSON、プレーンテキストなど、様々な形式のファイルが使われていますが、その中にMork形式という物があります。この記事では、それらのファイルの内容を見る方法について解説します。

Mork形式とは?

Morkは、ある程度の構造を持ったデータをテキストで表現したファイル形式です。Thunderbirdでは、メールフォルダの要約ファイル(.msf)やアドレス帳のファイル(.mab)に使われています。

このファイル形式の困った所は、Thunderbird内でよく使われている割に、扱いが非常に面倒であるという点です。Morkが生まれた歴史的経緯について詳しく解説しているMork の謎という記事を見ると、Mozillaプロジェクト内でも厄介者扱いされている事が見て取れます。Thunderbirdにおいても廃止(別形式への移行)が提案されはしていましたが、開発リソースの不足から、実行に移されないまま立ち消えになってしまっているというのが現状です。

実際の例を見てみましょう。以下は、「ローカルフォルダ」アカウントの「送信済みメール」フォルダに1つだけメールがある状態での要約ファイル(Sent.msf)の内容です。

// <!-- <mdb:mork:z v="1.4"/> -->
< <(a=c)> // (f=iso-8859-1)
  (B8=sortOrder)(B9=viewFlags)(BA=viewType)(BB=sortColumns)
  (BC=columnStates)(BD=LastPurgeTime)(BE=useServerRetention)
  (BF=customSortCol)(C0=imageSize)(C1=junkscore)(C2=keywords)(C3=account)
  (C4=notAPhishMessage)(C5=gloda-dirty)
  (C6=mailbox://nobody@smart%20mailboxes/Sent)(C7=MRMTime)
  (80=ns:msg:db:row:scope:msgs:all)(81=subject)(82=sender)(83=message-id)
  (84=references)(85=recipients)(86=date)(87=size)(88=flags)(89=priority)
  (8A=label)(8B=statusOfset)(8C=numLines)(8D=ccList)(8E=bccList)
  (8F=msgThreadId)(90=threadId)(91=threadFlags)(92=threadNewestMsgDate)
  (93=children)(94=unreadChildren)(95=threadSubject)(96=msgCharSet)
  (97=ns:msg:db:table:kind:msgs)(98=ns:msg:db:table:kind:thread)
  (99=ns:msg:db:table:kind:allthreads)
  (9A=ns:msg:db:row:scope:threads:all)(9B=threadParent)(9C=threadRoot)
  (9D=msgOffset)(9E=offlineMsgSize)
  (9F=ns:msg:db:row:scope:dbfolderinfo:all)
  (A0=ns:msg:db:table:kind:dbfolderinfo)(A1=numMsgs)(A2=numNewMsgs)
  (A3=folderSize)(A4=expungedBytes)(A5=folderDate)(A6=highWaterKey)
  (A7=mailboxName)(A8=UIDValidity)(A9=totPendingMsgs)
  (AA=unreadPendingMsgs)(AB=expiredMark)(AC=version)
  (AD=fixedBadRefThreading)(AE=dateReceived)(AF=ProtoThreadFlags)
  (B0=gloda-id)(B1=sender_name)(B2=storeToken)(B3=charSetOverride)
  (B4=charSet)(B5=folderName)(B6=MRUTime)(B7=sortType)>
<(82=0)>[1:m(^9C=0)(^90=0)(^92=0)(^91=0)(^93=0)]
<(8A=352)(A7=53607898)(80=1)>[2:m(^9C^8A)(^90^8A)(^92^A7)(^91=0)(^93=1)]

<(A1=850)(8E=20)(A2=YUKI Hiroshi <yuki@clear-code.com>)(A3
    =yuki@clear-code.com)(A4
    ==?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?=)(A5
    =53607898.3010904@clear-code.com)(A6=account4)(89=ISO-2022-JP)(A8=43c)
  (A9=f)(AA=38d)(AB=2843|YUKI Hiroshi)(97=)(8C=ffffffff)>
{1:^80 {(k^97:c)(s=9)} 
  [352(^9D^8A)(^B2^A1)(^88=1)(^8A=0)(^8B=20)(^82^A2)(^85^A3)(^81^A4)
    (^83^A5)(^C3^A6)(^86^A7)(^AE^A7)(^89=1)(^96^89)(^87^A8)(^8C=f)(^9E^AA)
    (^B1^AB)(^C2=)(^9B^8C)(^8F^8A)(^AF=0)]}
{2:^80 {(k^C6:c)(s=9)} 352 }
{352:^80 {(k^98:c)(s=9)2:m } 352 }
{FFFFFFFD:^9A {(k^99:c)(s=9)} [352(^95^A4)]}

<(AC=78e)(AD=536078b0)(92=204)(93=Sent)(94=$E9$80$81$E4$BF$A1$E6$B8$88$E3$81$BF\
$E3$83$88$E3$83$AC$E3$82$A4)(A0=1398831280)(96=12)(98
    ={"threadCol":{"visible":true,"ordinal":"1"},"flaggedCol":{"visible":true,\
"ordinal":"3"},"attachmentCol":{"visible":true,"ordinal":"5"},"subjectCol":{"v\
isible":true,"ordinal":"7"},"unreadButtonColHeader":{"visible":true,"ordinal":\
"9"},"senderCol":{"visible":true,"ordinal":"13"},"recipientCol":{"visible":fal\
se,"ordinal":"11"},"junkStatusCol":{"visible":true,"ordinal":"15"},"receivedCo\
l":{"visible":false,"ordinal":"37"},"dateCol":{"visible":true,"ordinal":"17"},\
"statusCol":{"visible":false,"ordinal":"19"},"sizeCol":{"visible":false,"ordin\
al":"21"},"tagsCol":{"visible":false,"ordinal":"23"},"accountCol":{"visible":f\
alse,"ordinal":"25"},"priorityCol":{"visible":false,"ordinal":"27"},"unreadCol\
":{"visible":false,"ordinal":"29"},"totalCol":{"visible":false,"ordinal":"31"}\
,"locationCol":{"visible":false,"ordinal":"39"},"idCol":{"visible":false,"ordi\
nal":"33"},"enigmailStatusCol":{"visible":false,"ordinal":"39"}})(99
    =Tue Apr 17 17:59:57 2012)>
{1:^9F {(k^A0:c)(s=9u)} 
  [1(^AC=1)(^AD=1)(^A1=1)(^A3^AC)(^A5^AD)(^88^92)(^A7^93)(^B5^94)(^B6^A0)
    (^B7=12)(^B8=1)(^B9=0)(^BA=0)(^BB=)(^BC^98)(^BD^99)(^A6^8A)(^BE=1)
    (^A4^8A)(^C7^A0)]}

@$${13{@
<(AE=1398831281)>[1:^9F(^B6^AE)]
@$$}13}@

部分的には読める部分もなくはないのですが、記号や括弧だらけで、とても人間に読めた物ではありません。

そういうわけで可能なら避けて通りたいMorkなのですが、Mozilla製品に対する有償サポートを提供しているクリアコードでは、このファイルと真正面から向き合わなくてはならない事も度々あります。例えば、Thunderbirdの要約ファイルの意図しない書き換えや破損が原因で発生している可能性が疑われる障害について、原因を詳しく調査するといった場合です。そのような場合には、全体的な構造を見たり、複数のファイルを比較して変化した箇所を列挙したり、といった事をしたくなりますが、上記の例を見ての通り、Morkでそれを行うのはまず不可能です。

Mork形式のファイルの内容をもっと扱いやすい形で全出力する

Morkをもっと簡単に読む方法は無いのでしょうか? 内容をもっと人間にも読みやすい形式に変換して出力できれば、全体的な構造を眺めたり、複数のファイルの内容を比較したりといった調査をやりやすくなります。

というわけで、そのようなコマンド「morkdump」を作成してみました。RubyとPerlの併用で、実装は以下の通りです。

#!/usr/bin/env ruby
#
# Mork Dumper, based on the CPAN module "Mozilla::Mork"
#   http://search.cpan.org/~kript/Mozilla-Mork-0.01/lib/Mozilla/Mork.pm
#
# description:
#   "Mork" is a format of Mozilla's internal data files, like
#   summary files (*.msf) of Thunderbird. This command reports
#   all contents of the specified Mork file as a JSON string.
#
# usage:
#   First, you have to install the Mozilla::Mork.
#
#     % curl -L http://cpanmin.us | perl - --sudo App::cpanminus
#     % sudo cpan Mozilla::Mork
#     % sudo cpan JSON
#
#   or
#
#     % curl -L http://cpanmin.us | perl - App::cpanminus
#     % cpan Mozilla::Mork
#     % cpan JSON
#
#   Then you can run this command.
#
#     % morkdump /path/to/morkfile
#
#   If you specify "--decode" option, subjects in msf files are decoded.
#
#     % morkdump --decode /path/to/morkfile
#
#   If you specify "--parse-flags" option, flags in msf files are shown
#   with human readable flag names.
#
#     % morkdump --parse-flags /path/to/morkfile

require "open3"
require "json"
require "optparse"
require "kconv"

def parse_mork(file)
  morkdump = <<-"PERL"
use Mozilla::Mork;
use JSON;

my $file = "#{file}";

my $MorkDetails = Mozilla::Mork->new($file);
my $results = $MorkDetails->ReturnReferenceStructure();

my $json = JSON->new->allow_nonref;
print $json->encode($results);

exit 0;
  PERL

  stdout, error, status = Open3.capture3("perl", :stdin_data => morkdump)
  parsed = stdout.encode("UTF-16BE", :invalid => :replace,
                                     :undef => :replace,
                                     :replace => '?')
  parsed = parsed.encode("UTF-8")
  JSON.parse(parsed)
end

$nsMsgMessageFlags = {
  :Read            => 0x00000001,
  :Replied         => 0x00000002,
  :Marked          => 0x00000004,
  :Expunged        => 0x00000008,
  :HasRe           => 0x00000010,
  :Elided          => 0x00000020,
  :FeedMsg         => 0x00000040,
  :Offline         => 0x00000080,
  :Watched         => 0x00000100,
  :SenderAuthed    => 0x00000200,
  :Partial         => 0x00000400,
  :Queued          => 0x00000800,
  :Forwarded       => 0x00001000,
  :Priorities      => 0x0000E000,
  :New             => 0x00010000,
  :Ignored         => 0x00040000,
  :IMAPDeleted     => 0x00200000,
  :MDNReportNeeded => 0x00400000,
  :MDNReportSent   => 0x00800000,
  :Template        => 0x01000000,
  :Attachment      => 0x10000000,
  :Labels          => 0x0E000000,
}

$nsMsgFolderFlags = {
  :Newsgroup       => 0x00000001,
  :Unused3         => 0x00000002,
  :Mail            => 0x00000004,
  :Directory       => 0x00000008,
  :Elided          => 0x00000010,
  :Virtual         => 0x00000020,
  :Unused5         => 0x00000040,
  :Unused2         => 0x00000080,
  :Trash           => 0x00000100,
  :SentMail        => 0x00000200,
  :Drafts          => 0x00000400,
  :Queue           => 0x00000800,
  :Inbox           => 0x00001000,
  :ImapBox         => 0x00002000,
  :Archive         => 0x00004000,
  :Unused1         => 0x00008000,
  :Unused4         => 0x00010000,
  :GotNew          => 0x00020000,
  :Unused6         => 0x00040000,
  :ImapPersonal    => 0x00080000,
  :ImapPublic      => 0x00100000,
  :ImapOtherUser   => 0x00200000,
  :Templates       => 0x00400000,
  :PersonalShared  => 0x00800000,
  :ImapNoselect    => 0x01000000,
  :CreatedOffline  => 0x02000000,
  :ImapNoinferiors => 0x04000000,
  :Offline         => 0x08000000,
  :OfflineEvents   => 0x10000000,
  :CheckNew        => 0x20000000,
  :Junk            => 0x40000000,
  :Favorite        => 0x80000000,
}
$nsMsgFolderFlags[:SpecialUse] = [
  :Inbox,
  :Drafts,
  :Trash,
  :SentMail,
  :Templates,
  :Junk,
  :Archive,
  :Queue,
].collect do |flag_name|
  $nsMsgFolderFlags[flag_name]
end.reduce do |a, b|
  a | b
end

def parse_record_flags(record)
  if folder_record?(record)
    parse_folder_flags(record["flags"])
  else
    parse_message_flags(record["flags"])
  end
end

def folder_record?(record)
  record["folderName"] or record["columnStates"] or record["mailboxName"]
end

def thread_record?(record)
  record["threadId"] and not record["message-id"]
end

def parse_message_flags(flags)
  flags = flags.to_i(16)
  $nsMsgMessageFlags.keys.select do |flag_name|
    flag_value = $nsMsgMessageFlags[flag_name]
    flags & flag_value != 0
  end.join(",")
end

def parse_folder_flags(flags)
  flags = flags.to_i(16)
  $nsMsgFolderFlags.keys.select do |flag_name|
    flag_value = $nsMsgFolderFlags[flag_name]
    flags & flag_value != 0
  end.join(",")
end

def normalize_records(records, options={})
  records = records.collect do |record|
    sorted_record = {}
    record.keys.sort.each do |key|
      value = record[key]
      value = value.toutf8 if options[:decode]
      if key == "flags" and options[:parse_flags]
        value = "#{value} (#{parse_record_flags(record)})"
      end
      sorted_record[key] = value
    end
    sorted_record
  end

  records.sort do |a, b|
    sort_records(a, b)
  end
end

def sort_records(a, b)
  case
  when (folder_record?(a) and not folder_record?(b))
    -1
  when (not folder_record?(a) and folder_record?(b))
    1
  when (thread_record?(a) and not thread_record?(b))
    -1
  when (not thread_record?(a) and thread_record?(b))
    1
  when (a["message-id"] and b["message-id"])
    a["message-id"] <=> b["message-id"]
  when (a["threadId"] and b["threadId"])
    a["threadId"] <=> b["threadId"]
  else
    0
  end
end

def main
  options = {
    :decode => false,
    :parse_flags => nil,
  }
  parser = OptionParser.new
  parser.on("--[no-]decode",
            "Decode non-ASCII strings in the subject field.") do |decode|
    options[:decode] = decode
  end
  parser.on("--[no-]parse-flags",
            "Parse flags of messages.") do |parse_flags|
    options[:parse_flags] = parse_flags
  end
  parser.parse!(ARGV)

  file = ARGV[0]
  unless file
    print "usage: morkdump <file>\n"
    return 1
  end

  if file.end_with?(".msf") and options[:parse_flags].nil?
    options[:parse_flags] = true
  end

  records = parse_mork(file)
  records = normalize_records(records, options)
  print JSON.pretty_generate(records)
  print "\n"
  0
end

exit main

全体としてはRubyスクリプトなのですが、Morkをパースする処理については、既に存在しているMozilla::Mork - search.cpan.orgというCPANモジュールを使っています。最初から全部Perlで書けばいいじゃないかという話なのですが、筆者がPerlに不慣れなため、PerlではMorkをJSONにする所までだけをやり、後の整形はRubyで行うようにした次第です。

冒頭のコメントの説明に従って必要なCPANモジュールをインストールした状態でmorkdumpを実行すると、以下のような出力が得られます(※スクリプトを置いた位置にパスが通っていると仮定します)。

% morkdump /path/to/Sent.msf 
[
  {
    "LastPurgeTime": "Tue Apr 17 17:59:57 2012",
    "MRMTime": "1398831280",
    "MRUTime": "1398831281",
    "children": "0",
    "columnStates": "{\"threadCol\":{\"visible\":true,\"ordinal\":\"1\"},\"flaggedCol\":{\"visible\":true,\"ordinal\":\"3\"},\"attachmentCol\":{\"visible\":true,\"ordinal\":\"5\"},\"subjectCol\":{\"visible\":true,\"ordinal\":\"7\"},\"unreadButtonColHeader\":{\"visible\":true,\"ordinal\":\"9\"},\"senderCol\":{\"visible\":true,\"ordinal\":\"13\"},\"recipientCol\":{\"visible\":false,\"ordinal\":\"11\"},\"junkStatusCol\":{\"visible\":true,\"ordinal\":\"15\"},\"receivedCol\":{\"visible\":false,\"ordinal\":\"37\"},\"dateCol\":{\"visible\":true,\"ordinal\":\"17\"},\"statusCol\":{\"visible\":false,\"ordinal\":\"19\"},\"sizeCol\":{\"visible\":false,\"ordinal\":\"21\"},\"tagsCol\":{\"visible\":false,\"ordinal\":\"23\"},\"accountCol\":{\"visible\":false,\"ordinal\":\"25\"},\"priorityCol\":{\"visible\":false,\"ordinal\":\"27\"},\"unreadCol\":{\"visible\":false,\"ordinal\":\"29\"},\"totalCol\":{\"visible\":false,\"ordinal\":\"31\"},\"locationCol\":{\"visible\":false,\"ordinal\":\"39\"},\"idCol\":{\"visible\":false,\"ordinal\":\"33\"},\"enigmailStatusCol\":{\"visible\":false,\"ordinal\":\"39\"}}",
    "expungedBytes": "352",
    "fixedBadRefThreading": "1",
    "flags": "204",
    "folderDate": "536078b0",
    "folderName": "送信済みトレイ",
    "folderSize": "78e",
    "highWaterKey": "352",
    "mailboxName": "Sent",
    "numMsgs": "1",
    "sortColumns": "",
    "sortOrder": "1",
    "sortType": "12",
    "threadFlags": "0",
    "threadId": "0",
    "threadNewestMsgDate": "0",
    "threadRoot": "0",
    "useServerRetention": "1",
    "version": "1",
    "viewFlags": "0",
    "viewType": "0"
  },
  {
    "ProtoThreadFlags": "0",
    "account": "account4",
    "date": "53607898",
    "dateReceived": "53607898",
    "flags": "1",
    "keywords": "",
    "label": "0",
    "message-id": "53607898.3010904@clear-code.com",
    "msgCharSet": "ISO-2022-JP",
    "msgOffset": "352",
    "msgThreadId": "352",
    "numLines": "f",
    "offlineMsgSize": "38d",
    "priority": "1",
    "recipients": "yuki@clear-code.com",
    "sender": "YUKI Hiroshi <yuki@clear-code.com>",
    "sender_name": "2843|YUKI Hiroshi",
    "size": "43c",
    "statusOfset": "20",
    "storeToken": "850",
    "subject": "=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?=",
    "threadParent": "ffffffff",
    "threadSubject": "=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?="
  },
  {
    "children": "1",
    "threadFlags": "0",
    "threadId": "352",
    "threadNewestMsgDate": "53607898",
    "threadRoot": "352"
  }
]

このように、個々のレコードはオブジェクトになります。また、各レコードのキーはアルファベット順でソートされているため、内容が似ていると考えられる複数の要約ファイル(例えば、何かの操作の前後で取得した要約ファイルのスナップショット同士など)をdiffにかければ、どこが変化したのかを詳細に調べる事もできます。例えば、以下はメールにタグを付けた前後の要約ファイルの比較結果です。

% morkdump /path/to/Sent.msf  > /tmp/before.json
% morkdump /path/to/Sent.msf  > /tmp/after.json
% diff -uNr /tmp/before.json /tmp/after.json 
--- /tmp/before.json	2014-04-30 14:36:10.000000000 +0900
+++ /tmp/after.json	2014-04-30 14:36:29.000000000 +0900
@@ -2,13 +2,13 @@
   {
     "LastPurgeTime": "Tue Apr 17 17:59:57 2012",
     "MRMTime": "1398831280",
-    "MRUTime": "1398831281",
+    "MRUTime": "1398836179",
     "children": "0",
     "columnStates": "{\"threadCol\":{\"visible\":true,\"ordinal\":\"1\"},\"flaggedCol\":{\"visible\":true,\"ordinal\":\"3\"},\"attachmentCol\":{\"visible\":true,\"ordinal\":\"5\"},\"subjectCol\":{\"visible\":true,\"ordinal\":\"7\"},\"unreadButtonColHeader\":{\"visible\":true,\"ordinal\":\"9\"},\"senderCol\":{\"visible\":true,\"ordinal\":\"13\"},\"recipientCol\":{\"visible\":false,\"ordinal\":\"11\"},\"junkStatusCol\":{\"visible\":true,\"ordinal\":\"15\"},\"receivedCol\":{\"visible\":false,\"ordinal\":\"37\"},\"dateCol\":{\"visible\":true,\"ordinal\":\"17\"},\"statusCol\":{\"visible\":false,\"ordinal\":\"19\"},\"sizeCol\":{\"visible\":false,\"ordinal\":\"21\"},\"tagsCol\":{\"visible\":false,\"ordinal\":\"23\"},\"accountCol\":{\"visible\":false,\"ordinal\":\"25\"},\"priorityCol\":{\"visible\":false,\"ordinal\":\"27\"},\"unreadCol\":{\"visible\":false,\"ordinal\":\"29\"},\"totalCol\":{\"visible\":false,\"ordinal\":\"31\"},\"locationCol\":{\"visible\":false,\"ordinal\":\"39\"},\"idCol\":{\"visible\":false,\"ordinal\":\"33\"},\"enigmailStatusCol\":{\"visible\":false,\"ordinal\":\"39\"}}",
     "expungedBytes": "352",
     "fixedBadRefThreading": "1",
     "flags": "204",
-    "folderDate": "536078b0",
+    "folderDate": "53608bd8",
     "folderName": "送信済みトレイ",
     "folderSize": "78e",
     "highWaterKey": "352",
@@ -32,7 +32,7 @@
     "date": "53607898",
     "dateReceived": "53607898",
     "flags": "1",
-    "keywords": "",
+    "keywords": "\\$label1",
     "label": "0",
     "message-id": "53607898.3010904@clear-code.com",
     "msgCharSet": "ISO-2022-JP",

これを見ると、メールにタグを付ける操作により最終更新日時とタグの情報だけが変化した、という事を容易に見て取れます。

また、morkdumpは非ASCII文字列をエンコードして埋め込んだ状態のSubjectなどのフィールドを、自動的にデコードして出力する機能も含んでいます。「--decode」オプションを指定すると、この機能が有効になります。

% morkdump --decode /path/to/Sent.msf > /tmp/after-decoded.json 
% diff -uNr /tmp/after.json /tmp/after-decoded.json 
--- /tmp/after.json	2014-04-30 14:36:29.000000000 +0900
+++ /tmp/after-decoded.json	2014-04-30 14:48:40.000000000 +0900
@@ -47,9 +47,9 @@
     "size": "43c",
     "statusOfset": "20",
     "storeToken": "850",
-    "subject": "=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?=",
+    "subject": "日本語のSubject",
     "threadParent": "ffffffff",
-    "threadSubject": "=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?="
+    "threadSubject": "日本語のSubject"
   },
   {
     "children": "1",

まとめ

Mork形式の概要について説明しました。また、Morkの内容を人間でも読める形に出力するmorkdumpコマンドをご紹介しました。

Morkは将来性があるとはお世辞にも言い難い技術ですが、まだまだMozilla製品、特にThundebrirdの内部では使われ続けています。もし何かの拍子に要約ファイルの内容を調査しなくてはならなくなったというような場合には、この記事を思い出して、morkdumpを一度試してみて下さい。