4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Thunderbirdのメッセージフィルターを別マシンに移植するツールを作った話 ― 図らずも仕様駆動開発になった

4
Posted at

はじめに

Thunderbird には「メッセージフィルター」という、受信メールを自動で振り分けたりタグ付けしたりする強力な機能があります。長年メールを使っていると、またユーザーからの問い合わせなどをさばくのに使っていると、次第にフィルターは増えていき、けっこう量になります。

ところがこのフィルター、標準機能には「エクスポート」も「インポート」もありません。新しい PC に乗り換えたり、Windows と Mac の間で環境を揃えたいときに、「msgFilterRules.dat を手でコピーすればいけるよ」と説明している記事は数多くありますが、複数アカウントを扱うとパスを間違えやすく、Thunderbird 起動中だとファイルロックされて反映されない…と、地味に事故りやすい作業です。

メッセージフィルターのデータのコピー方法が確立されているのなら、ツールで作った方が楽なんじゃね?と思って、開発したのが、今回の ThunderbirdMsgFilterCopy です。Thunderbird のメッセージフィルターを .tbfilters というファイルにまとめてエクスポート/インポートするデスクトップツールです。

02-main-window.png

ツールは、以下からダウンロードできます。

本記事では「なぜ作ったか」「どう設計したか」「どこに工夫が詰まっているか」を、コードよりも設計判断にフォーカスして書いてみます。


1.「エクスポート機能がない」から始まった

きっかけはシンプルで、自分が新しい Mac mini を買ったタイミングで、Windows の Thunderbird に溜め込んだフィルター(IMAPで20個ほど)を手作業で再設定するのが嫌になったことです。

調べてみると、Thunderbird のフィルター移植はおよそ次のパターンに集約されます。

  • msgFilterRules.dat手作業で該当アカウントフォルダ配下に上書きする
  • 公式アドオンには(v1 系の頃にあったらしいが)現行版で動くものが見当たらない
  • そもそもアカウントが複数あると、どの dat をどのフォルダに置けばよいか覚えていられない

「だったら、msgFilterRules.datアカウント単位でまるごとパッケージ化して持ち運べるツールがあればいいのでは?」というのが出発点でした。


2. 図らずも「仕様駆動開発」になってしまった話

最初は「サクッと書いて終わらせよう」と思っていたのですが、Claude Code と対話しながら設計していたら、いつのまにか SPEC.md がプロジェクトのほぼすべての判断の根拠になっていたのです。

具体的には、次のような流れになりました。

  1. ざっくり要件を Claude Code にぶつける
  2. 「この点はどう決める? macOS のパスは? Thunderbird が起動中だったら? プロファイルが複数あるときの選択ロジックは?」と質問で返ってくる
  3. その場で意思決定し、その結論を そのまま SPEC.md に追記
  4. 実装は SPEC を根拠に進める。判断に迷ったら SPEC を読み返す(Claude Code にも「SPEC を見て」と指示するだけで済む)

気づけば SPEC.md は 85KB、目次レベルで 11 章にまで成長していました。コードよりドキュメントの方が先行している状態です。

09-spec.png

副次的な効果
後から仕様について「あれ、これってどう決めたんだっけ?」となったとき、SPEC.md の決定事項サマリ(§10)を見ればすぐ思い出せる。Claude Code に「§9 のスコープ外項目を変えるな」と書いておくと、勝手に機能が増殖していくのも防げる。

意識して「仕様駆動開発をするぞ!」と決めたわけではないのに、対話エージェントを併走させるとごく自然にこの形に落ちる――というのは、AI コーディング時代の小さな発見でした。

ただ、デメリットとして、何の機能を追加するにしても、微小なバグを修正するにしても、SPEC.mdに追記しないといけないのは、かなり苦痛を伴う作業でしたが、まあ、ここまでやらないと、コードをほとんど見ずに書くということは無理だよね、ってことになります。

ちなみに、この SPEC.md は、誰でも見られるように、GitHubのレポジトリの中で公開しています。興味のある方はどうぞ。
https://github.com/hibara/ThunderbirdMsgFilterCopy/blob/main/SPEC.md


3. ファイル形式:拡張子だけ変えた ZIP という割り切り

メッセージフィルターは msgFilterRules.dat というプレーンテキストのファイルにアカウント単位で保存されています。ところがメールアドレス(ユーザー名)はこのファイルには書かれていません。アドレスは別ファイル prefs.js の中に user_pref(...) 形式で散在しています。

つまり、「このフィルターセットは hibara@gmail.com 用のものだ」という対応関係を持って運ぶには、msgFilterRules.dat だけでは情報が足りないわけです。

そこで採用したのが、次のような「拡張子だけ変えた ZIP」方式でした。

example.tbfilters   ← 実体は ZIP
├── manifest.json   ← メタ情報(アカウント名・メールアドレス・フィルター件数など)
└── filters/
    ├── 0000/msgFilterRules.dat   ← オリジナルそのまま
    ├── 0001/msgFilterRules.dat
    └── ...

07-tbfilters.png

manifest.json の中身はこんな具合です。

{
  "schemaVersion": 1,
  "exportedAt": "2026-04-19T12:34:56+09:00",
  "sourceOs": "Windows",
  "sourceProfile": "default-release",
  "entries": [
    {
      "index": 0,
      "accountType": "Imap",
      "serverFolderName": "imap.gmail.com",
      "userName": "example.user@gmail.com",
      "fileVersion": "9",
      "filterCount": 12
    }
  ]
}

ポイントは 3 つあります。

ZIP に独自拡張子を被せただけ

独自バイナリ形式にする必要は一切ありません。.tbfilters.zip にリネームすれば、誰でも中身を確認・救出できます。ユーザーが最後の手段で手動復旧できるというのは、地味ですが大事な点です。

manifest で「メールアドレス × フィルター内容」を対にする

prefs.js から userName を抽出して manifest に書き込むことで、移行先 PC で「これ何のアカウント用?」が一目で分かる UI を実現しました(※ただし userName表示専用で、マッチング判定には絶対使いません。理由は後述)。

msgFilterRules.dat の中身は一切パースしない

本ツールが msgFilterRules.dat から読み取るのは 2 つだけ です。

  • 先頭行の version="N" という属性
  • name="..." の出現回数(=フィルター本数)

これだけ。フィルターのアクションや条件も読みません。フィルターセットは「不可分な 1 単位」として扱うと最初に決め切ったので、Thunderbird 側の内部形式変更にもほぼ無敵です(version 属性は記録しておくのでバージョン違いの混入は警告できる)。

「完璧なパーサを書こう」と思った瞬間、泥沼になるのが見えていたので、ここを早めに割り切れたのは設計上の勝因でした。


4. マッチングは「サーバーフォルダ名」だけ。ホスト名のあいまい一致はしない

インポート時、「移行元のアカウント A は、移行先のどのアカウントに対応する?」を決める必要があります。最初は「ホスト名でファジー一致してもいいかも」とも考えましたが、

  • imap.gmail.comimap.googlemail.com を同一視する?しない?
  • メールアドレスのドメインで判定する?
  • IMAP と POP は別物として扱うべきでは?(POPは無視しても良いかも、とか)

...と、考え始めるとキリがありません。結局、「Thunderbird がディスク上で使っているフォルダ名(例: imap.gmail.com)と accountType(Imap / Pop / Local)の完全一致」だけをキーにし、一致しないアカウントは黙ってスキップする方針にしました。

05-import-preview.png

誤マッチで他人のフィルターが混じる事故より、「マッチしないので何も起きない」事故の方が、ユーザーにとって被害が小さい。

これは仕様書にも明記しています。userName(メールアドレス)はあくまで表示専用で、マッチングに絶対使わない。表示用の情報を意思決定に使い始めると、後から「メールアドレスを変えたら移行できなくなった」みたいな問題が必ず発生するので、最初から壁を作っておきました。


5. Windows でも macOS でも、同じ操作感

このツールは Avalonia UI + .NET 10 で書かれています。Windows でも macOS でも、まったく同じ AXAML / ViewModel / Service コードが動きます。

01-main-mac.png

OS 依存になる箇所は限られていて、

項目 Windows macOS
Thunderbird プロファイルルート %APPDATA%\Thunderbird\ ~/Library/Thunderbird/
バックアップディレクトリ %LOCALAPPDATA%\ThunderbirdMsgFilterCopy\Backup\ ~/Library/Application Support/ThunderbirdMsgFilterCopy/Backup/

このくらいです。ファイルパスの組み立ては Path.Combine で機械的に処理し、OS 分岐は RuntimeInformation ではなく OperatingSystem.IsWindows() / OperatingSystem.IsMacOS() を使うようにしました。.NET 5 以降のこの API はとてもスッキリしていてオススメです。

var profileRoot = OperatingSystem.IsWindows()
    ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Thunderbird")
    : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Thunderbird");

配布形態は OS ごとに 自己完結型シングルファイルパブリッシュ としました。利用者は .NET ランタイムを入れずに、ダウンロードした exe / アプリをそのまま実行できます。

いやあ、.NET Framework のときから考えると、ここは便利になったものです。DLLを実行ファイルに含めるには、NuGet からCostura.Fody を入れて DLL を埋め込む小細工が必要でした。

Mac版については、Apple の 公証 (Notarization) にも通してあるので、Gatekeeper の警告なしで起動できます。


6. プロファイル選択は「スコアリング方式」

Thunderbird は複数プロファイルを持てます。%APPDATA%\Thunderbird\Profiles\ を覗くと、xxxxxxxx.default-release みたいなディレクトリが複数あったりします。

「どれを既定で選ぶか?」を決めるために、profiles.iniinstalls.ini をパースしてスコアリングする方式を採りました。

シグナル 加点 意味
installs.iniDefault= に参照されている +100 Thunderbird 自身が「使用中」と認識
profiles.iniDefault=1 +50 旧仕様の既定プロファイル
配下に Mail/ または ImapMail/ がある +30 メールアカウントが設定されている
配下に msgFilterRules.dat がある +20 本ツールの対象データが実在
名前末尾が -release / -esr / -beta / -daily +10 命名規則ヒント

最高スコアのものを初期選択にするが、ユーザーは常にドロップダウンで上書きできる――この「自動判定 + 手動オーバーライド」の組み合わせは、自動化ツール全般で再利用できる良いパターンだと思っています。

03-profile-selector.png


7. Thunderbird 起動中は操作をロックする

Thunderbird が起動中に msgFilterRules.dat を書き換えると、Thunderbird 側のメモリ上のフィルター設定と矛盾が起きたり、Thunderbird 終了時に書き戻されてこちらの変更が消えたりします。これは地味に事故の元です。

そこで PeriodicTimer2 秒ごとに Process.GetProcessesByName("thunderbird") をポーリングし、起動中はメニュー・ボタンを片っ端から無効化する仕組みを入れました。さらに、インポート実行・取り消し実行の直前にも再チェックしています(タイマー間隔の隙間で起動された場合に備えて)。

04-running-banner.png

「Thunderbird を自動で閉じてあげれば?」というアイデアもありましたが、

  • ユーザーが起動したアプリを勝手に終了させるのは行儀が悪い
  • 未読メールの取り扱いをツール側で判断したくない

という理由で意図的に却下し、「目立つバナーで『Thunderbird を終了してください』と表示する」だけにしました。


8.「直前のインポートを取り消す」だけはある

正直、トランザクション的なロールバックを実装するのは、初回バージョンではオーバースペックです。なので採用したのは、

  • インポート前に対象ファイルを Backup/ 配下に1 世代だけ退避
  • session.json に「このエントリは元々ファイルがあったか/なかったか (hadExistingFile)」を記録
  • メニュー「ツール → 直前のインポートを取り消す」でその 1 世代を巻き戻す

というシンプルな仕組み。hadExistingFile=false のエントリは「取り消し時にファイルを削除する」という意図にしました。

世代管理を増やす要望は十分需要がありそうですが、

  • バックアップ容量が無限に膨らむ問題
  • 「N 世代前に戻したい」のはどう UI で表現する?

を考えると、初回バージョンでは適切な複雑さでない、と判断し、ロードマップ送りにしました。


9. 細かいけど効く工夫

ここからは「派手ではないけど効く」系の小ネタです。

ダイアログのボタン配置を OS 共通にした

Windows は伝統的に「肯定が左」、macOS の Apple HIG は「肯定が右」と書かれることが多いですが、実情は Edge / Teams / Office / Windows 11 設定アプリ など Microsoft 製アプリですら「肯定が右」になっています。両 OS を行き来するユーザーが配置の違いに混乱しないことを優先し、両 OS で「キャンセル左 / 実行右」に統一しました。

06-import-confirm.png

ボタンは「はい/いいえ」ではなく「動詞」

「ボタンのラベルだけ読んで自分が何をしようとしているか分かる」ようにするため、

  • [はい] [いいえ]
  • [キャンセル] [実行する] / [キャンセル] [取り消す] / [キャンセル] [削除する]

としました。これはダイアログ設計のセオリーですが、徹底すると体感がかなり良くなります。

ダイアログの幅は 460px 固定

Windows のウィンドウタイトルバーは右端のシステムボタン (最小化・最大化・閉じる) が 常に約 130px を消費するため、SizeToContent="WidthAndHeight" を素直に当てると、本文が短いダイアログでタイトルが Th... まで切り詰められるという地味な事故が起きます。実機で発見したので、幅は 460px 固定 にしてしまうのが結局正解、というオチでした。

ログファイルは作らない

ユーザーの個人 PC で動く小さなツールにログファイルを撒くと、結局誰も読まないし、消し忘れて容量を食うだけです。全エラーはダイアログでその場で出して終わり にしました。

prefs.js の解析失敗は致命的にしない

prefs.js がパースできなくても、アカウントのディレクトリ名さえ取れればツールは動かせます。userName 抽出はあくまで「あったら表示する」程度の機能。「メイン機能を支えるオマケ機能の失敗で本体を止めない」 は地味だけど守りたい原則です。


10. やってよかった技術選定

  • .NET 10:最新の C# 言語機能(プライマリコンストラクタ・コレクション式・required メンバ)を素直に使えるのが快適。OperatingSystem.IsWindows() のような API も完備されていて、クロスプラットフォーム開発で迷うことが少ない
  • Avalonia UI 12:WPF 経験者なら数日で慣れます。AXAML は WPF XAML とほぼ同じだけど微妙にバインディング構文が違うので注意。AvaloniaUseCompiledBindingsByDefault=true を有効にして型安全に書けるのも良い点
  • CommunityToolkit.Mvvm[ObservableProperty] [RelayCommand] 属性が便利すぎて、もう手書きで INotifyPropertyChanged を実装する気が起きません
  • .slnx ソリューションファイル:従来の .sln よりはるかに読みやすい XML 形式。dotnet build foo.slnx でそのまま動きます。ただし、Visual Studio 2026 でないと安定して開けない(私の手元の、2022 だとプロジェクトのアンロード現象が起きる)
  • JetBrains Rider が最強。Visual Studioは、ちょっと前まで for Mac があったんですが、今はリモートデバッグしかできません。Rider は Windows でも macOS でも同じ IDE で開発できるのが最高です。macOS版にいたっては、公証して、dmgファイル作成まで持って行けるのは最高すぎます

08-about.png


11. ハマりポイント

  • Avalonia 12 では DragEventArgs.Data / DataFormats.Files が廃止されており、新しい IDataTransfer / DataFormat.File API になっています。11.x のサンプルコードがそのままでは動かないので注意
  • Fody (ILMerge 的なもの) と Avalonia の AOT 最適化はケンカしやすい。最終的に <EnableCompressionInSingleFile> 周りで丸く収めました
  • Windows でビルド直後に exe が DLL を掴んだままになり、リビルドで MSB3027 が出ることがあります。taskkill /F /IM ThunderbirdMsgFilterCopy.exe で OK

まとめ

  • Thunderbird のメッセージフィルター移植は地味に大変で、標準機能ではフォローされていない
  • 拡張子だけ変えた ZIP に manifest を同梱する形式で、「メールアドレス × フィルター内容」を不可分なパッケージにした
  • 中身はパースしないという割り切りで、フィルター形式の変更に強い設計に
  • Avalonia + .NET 10 で Windows / macOS をひとつのコードベースで
  • Claude Code との対話の副産物として、ごく自然に仕様駆動開発になっていた
  • 「機能を入れない決定」を明文化しておくと、AI コーディング時代の機能膨張も防げる

「フィルター移行のたび憂鬱になっていた未来の自分」を救えれば、それだけで個人開発としては大成功だと思っています。同じ悩みを抱えている方の役に立てば嬉しいです。ツールの配布サイトは以下の通り。

または、

以上です。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?