691 views
【1】現状の問題点
Thunderbird上で、26個のメールアカウントを使っている。
それぞれのメールアカウントについて SPFチェック、DKIMチェック、その他諸々のフィルタリング機能を実行したいのだが、
Thunderbirdに実装されている Filtering Managerでは
メールアカウントごとにフィルター設定しなければならないので手間がかかる。
更に…
Thunderbirdに実装されている Filtering Managerでは
除去できないスパムメール(=指定できないパターン)もある。
【2】やりたいこと
Thunderbirdにはメッセージフィルター機能が用意されているが、かゆいところに手が届かない。

Thunderbirdに標準実装されているこの機能は、日進月歩で日々いやらしさを増す SPAMメールに対しては少々機能不足なのだ。
今回 SPAMとして除外したいメールは以下のようなもの。
| From: | xxxxxxx@amazon.co.jp | 表面上の送信元(詐称) |
| Return-Path: | xxxxxxx@fiweuhiwfeuhiw.cn | ヘッダに埋め込まれた送信元(本物) |
これは、なりすましメールの一種だ。
この両者が一致しないことを検出できれば SPAM判定&除外できる。
でも・・・
Thunderbirdが提供するメッセージフィルタ機能では実現できなかった。
そこで・・・
オリジナルのSPAMフィルターを、自力でアドオン実装することにした。
【3】SPF, DKIMで除外できないのか?
一見すると SPF, DKIM で除外できそう だが、そうはできない。
SPF : Sender Policy Framework
SPFの動作は以下の通り。
Return-Path に含まれるドメイン(例: fiweuhiwfeuhiw.cn)を DNS に問い合わせ、メール送信元 IPアドレスと一致していれば pass判定する。
つまり・・・
送信元IPアドレスと Return-Pathのドメインが一致していれば pass判定される。
というだけで、
このメールが amazon.co.jp から送られたものではない、ということが判断できない。
DKIM : DomainKeys Identified Mail
DKIMは公開鍵暗号方式であり、動作は以下の通り。
送信サーバ(この場合はfiweuhiwfeuhiw.cn)が メール内容を秘密鍵で暗号化 してきた場合に、
受信サーバが DNSから amazon.co.jp の 公開鍵を取得して復号化 を試みる。
当然、復号化できないので不正メールと判定できる。
しかし・・・
送信サーバが公開鍵暗号方式を使わない場合、
すなわちメールヘッダに DKIM-Signature を含めない場合はこれが機能しない。
→ DKIM=none になる。
DMARC : Domain-based Message Authentication, Reporting and Conformance
DMARCは、以下のように動作する。
① SPFをチェック(対象は Return-Path)
② DKIMをチェック(対象は DKIM Signature)
② 上記①, ②でチェックしたドメインがヘッダ Fromドメインと一致しているか?
→ 一致しない or SPF, DKIMともに Failなら SPAM判定
今回のケースをもう一度書いておく。
| From: | xxxxxxx@amazon.co.jp | 表面上の送信元(詐称) |
| Return-Path: | xxxxxxx@fiweuhiwfeuhiw.cn | ヘッダに埋め込まれた送信元(本物) |
この場合の DMARCの動作は以下の通り。
① SPF=pass(Return-Pathドメイン fiweuhiwfeuhiw.cn と送信元 IPアドレスが一致)
② DKIM=none(署名なし→fail)
③ SPF alignment: fail(From=amazon.co.jp、Return-Path=fiweuhiwfeuhiw.cn が不一致)
→ fail, failで SPAM判定
よって・・・
DMARCポリシーによるが p=quarantine/reject なら不正、すなわち SPAM判定となる。
| pの値 | 意味 |
|---|---|
| none | 不正(DMARC fail)でも何もしない(モニタリングのみ) |
| quarantine | 不正メールは隔離(スパムフォルダなど) |
| reject | 不正メールは受信拒否 |
とても役立ちそうだが、
DMARC未対応の受信メールサーバも存在するため、これには頼れない。
よって・・・
2025年5月31日の現時点では、
オリジナルのSPAMフィルターを自力でアドオン実装することにした。
【4】プログラム実装
前置きが長くなってしまったので、この後はさらっと記録しておく。
Thunderbird Add-on制作は初めてなので、しつこいほどにコメントを書いておいた。
{
"manifest_version": 2,
"name": "★MyAddon★ From/Return-Path Filter",
"version": "1.0",
"applications": {
"gecko": {
"id": "from-returnpath-filter@dogrow.net",
"strict_min_version": "78.0"
}
},
"permissions": [
"messagesRead",
"accountsRead",
"messagesMove"
],
"background": {
"scripts": ["from-returnpath-filter.js"]
}
}
作っている間に「あれもこれも」と肥大化してしまった・・・
それでも、必要な機能を入れて大きくなるのは仕方がないこと。
////////////////////////////////////////////////////////////////////////////////
//【Event Handler】新規メッセージ受信(async: 非同期並列実行)
////////////////////////////////////////////////////////////////////////////////
browser.messages.onNewMailReceived.addListener(async (folder, newMessages) => {
//console.log('newMessages:', newMessages); // console表示: newMessagesの中身を表示
//----------------------------------------------------------------------------
let accounts = await browser.accounts.list(); // accountsを取得
let accountsMap = new Map(); // accountsMap作成
for (let acc of accounts) {
accountsMap.set(acc.id, acc); // idをキーにMap登録
}
//----------------------------------------------------------------------------
if(newMessages && Array.isArray(newMessages.messages)){ // 受信メッセージの情報あり?
let promises = newMessages.messages.map(msg => processMessage(accountsMap, folder, msg));
await Promise.all(promises); // 全処理の同期待ち。
}
//----------------------------------------------------------------------------
else{ // 受信メッセージの情報なし?
console.warn('newMessages.messages is not an array or newMessages is invalid:', newMessages);
}
//----------------------------------------------------------------------------
});
////////////////////////////////////////////////////////////////////////////////
// 1メッセージの SPAM対策処理
////////////////////////////////////////////////////////////////////////////////
async function processMessage(accountsMap, folder, msg){
try {
//--------------------------------------------------------------------------
// folder.accountId に対応するメールアカウント情報を取得する。
let account = accountsMap.get(folder.accountId); // accountsMap参照
let accountEmail = account?.identities?.[0]?.email; // メールアドレス取得(非存在→undefined )
//--------------------------------------------------------------------------
// メールヘッダを取得する。
let full = await browser.messages.getFull(msg.id);
let headers = full.headers;
if (!headers){
throw new Error(`Cannot retrieve headers for message ID: ${msg.id}`);
}
//--------------------------------------------------------------------------
// なりすまし判定(1) : from と return-path を比較
let res = isSpoofedEmail(headers);
if(res.isSpam){
console.log(`${accountEmail} : Moving msg ${msg.id}: From=${res.fromDomain}, Return-Path=${res.returnDomain}`);
// このメッセージの受信アカウントのゴミ箱を取得し、そこへ移動する。
let trashFolder = await getTrashFolderFromAccount(account);
if (trashFolder) {
await browser.messages.move([msg.id], trashFolder.id);
}else{
console.error(`Cannot move message ${msg.id}: Trash folder not found.`);
}
return; // 処理終了
}
//--------------------------------------------------------------------------
}
catch (error) {
console.error(`Error processing message ${msg.id}:`, error);
}
}
////////////////////////////////////////////////////////////////////////////////
// 指定メールアカウントのゴミ箱フォルダを取得(accountsMap版)
////////////////////////////////////////////////////////////////////////////////
async function getTrashFolderFromAccount( account ) {
//----------------------------------------------------------------------------
if(!account) return null;
if(!account.folders) return null;
//----------------------------------------------------------------------------
for (let folder of account.folders) {
if (folder.type === "trash") {
return folder;
}
}
//----------------------------------------------------------------------------
return null;
}
////////////////////////////////////////////////////////////////////////////////
// ドメイン抽出
////////////////////////////////////////////////////////////////////////////////
function extractDomainFromHeader(headerValue) {
if (!headerValue) return null;
headerValue = headerValue.replace(/(\r\n|\n|\r|\t)/g, ' '); // 改行を除去
headerValue = headerValue.replace(/\s{2,}/g, ' ').trim(); // 余分な空白除去
//----------------------------------------------------------------------------
// 優先1: <...> 内を抽出
let email = null;
const angleMatch = headerValue.match(/<\s*([^>]+?)\s*>/);
if (angleMatch && angleMatch[1]) {
email = angleMatch[1].toLowerCase();
}
//----------------------------------------------------------------------------
// 優先2: fallback - メールアドレス形式を抽出
if (!email) {
const emailPattern = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/;
const fallbackMatch = headerValue.match(emailPattern);
if (fallbackMatch && fallbackMatch[1]) {
email = fallbackMatch[1].toLowerCase();
}
}
//----------------------------------------------------------------------------
// ドメイン部分抽出
if (email) {
const domainMatch = email.match(/@(.+)$/);
if (domainMatch && domainMatch[1]) {
return extractBaseDomain(domainMatch[1]);
}
}
//----------------------------------------------------------------------------
return null;
}
////////////////////////////////////////////////////////////////////////////////
// TLDを抽出
////////////////////////////////////////////////////////////////////////////////
function extractBaseDomain(fullDomain) {
if (!fullDomain) return null;
//----------------------------------------------------------------------------
// 簡易的に日本の主要複合TLDに対応
const tlds2 = [
'co.jp', 'ac.jp', 'or.jp', 'ne.jp', 'go.jp', 'gr.jp', 'ed.jp', 'lg.jp'
];
//----------------------------------------------------------------------------
const parts = fullDomain.split('.');
const last2 = parts.slice(-2).join('.');
if (tlds2.includes(last2) && parts.length >= 3) {
return parts.slice(-3).join('.'); // 例: emagazine.rakuten.co.jp → rakuten.co.jp
} else if (parts.length >= 2) {
return last2; // 通常TLD(例: dogrow.net → dogrow.net)
} else {
return fullDomain; // 最低限のドメイン部分
}
}
////////////////////////////////////////////////////////////////////////////////
// メールのなりすまし判定(From と Return-Path の不一致を検出)
////////////////////////////////////////////////////////////////////////////////
function isSpoofedEmail(headers) {
// headersから"from"と"return-path"を取得
const fromHeader = headers["from"] ? headers["from"][0] : null;
const returnPathHeader = headers["return-path"] ? headers["return-path"][0] : null;
if (!fromHeader || !returnPathHeader) {
return { isSpam: false, fromDomain: fromHeader, returnDomain: returnPathHeader };
}
// ドメイン抽出(PSL考慮、日本主要複合TLD対応)
const fromDomain = extractDomainFromHeader(fromHeader);
const returnDomain = extractDomainFromHeader(returnPathHeader);
if (!fromDomain || !returnDomain) {
return { isSpam: false, fromDomain, returnDomain };
}
// 判定結果を返す。
const isSpam = fromDomain !== returnDomain;
return { isSpam, fromDomain, returnDomain };
}
【5】JavaScriptコードのデバッグ環境
JavaScriptプログラムをデバッグしたい場合、普段使っている Webブラウザの
JavaScriptコンソール を使えばよい。いわゆる Shellだ。
以下、FireFoxを使う場合の手順を記録しておく。
(1) 開発者ツールを起動する。
以下のいずれかの操作で起動する。
方法1: [F12] キー押下
方法2: [Ctrl] + [Shift] + [j] キー押下
方法3: 画面上で [右クリック] → [調査] → [コンソール] お勧め
方法4: メニューから [その他のツール] → [ブラウザコンソール]
こんな画面が起動する。

(2) デバッグする。
例えば、上記のコードの中の関数 extractDomainFromHeader() を動作確認してみる。
まずは、関数をまるごとペタッと貼り付ける。

コンソール上で、引数を指定して関数を呼び出してみる。
例えば、ebookjapan@mail.yahoo.co.jp のドメインを抽出してみると・・・

OKだ。yahoo.co.jp を抽出できた。
【6】Add-on登録
1) まだ開発中の場合
(1) [ツール]-[開発ツール]-[アドオンをデバッグ]メニューを選択する。
→ デバッガー画面が開く。
(2) [一時的なアドオンを読み込む…]メニューを選択し、前述の manifest.json を読み込む。
→ 自作の Add-onプログラムが実行される。
2) 完成した物を組み込む場合
(1) 上記の 2ファイルを ZIPファイルにまとめる。
(2) ZIPファイルの拡張子を .xpi に変更する。
(3) [三]-[アドオンとテーマ]メニューを選択し、アドオンマネージャーを開く。
(4) [歯車]-[ファイルからアドオンをインストール]メニューを選択し、上記(2)のファイルをアップロードする。
以上
プログラム中で console.log() したデバッグ情報は、Thunderbirdの開発者用コンソールに表示される。
【7】今後の予定
現在、Thunderbird上で 20超のメールアカウントを併用している。
各アカウントで同じようなメッセージフィルタを設定しているが、なかなか手間がかかる。
今回作成した Add-onに色々なフィルター機能を追加実装し、
全メールアカウントで共通フィルターとして使いたい。
最近の SPAMメールは、
SPF=pass, DKIM=passを狙って、正々堂々と不正メールアドレスを表面に出してくる。
つまり、メールの表題と文面には amazon, 楽天, 三井住友, JCBなどと書いてあるが、
From, Return-path, DKIM-Signature には正々堂々とインチキメアドが書いてあるのだ。
これへの対策も考えなくては・・・
AIに学習させてSpam判定させる手もある。
この判定エンジン部分を Add-onコードから呼び出せばよいのだ。
いつかやってみよう。
【8】所感
Add-on実装は難しくなかった。
JavaScriptで Thunderbird APIを使う だけだった。
今回作った Add-onを設置&運用開始後に気づいたのだが、
SPAMじゃないメールでも、From と Return-Path が異なるメールが結構多い・・・
つまり、SPAMじゃないのにゴミ箱に移動されてしまうことが多々ある。
いわゆる Precision vs Recall 問題だ。
どうしようか・・・
まだ未投稿だが、これに対しては whitelist(=allowed list)を使って通過させるように実装した。
現在は JSONファイルに whitelistを書いているが、
これをオプション画面上で設定できるように改良したい。
これが出来たら投稿しよう。
【9】参考情報
公式サンプル集: https://github.com/thunderbird/webext-examples
