IT系おじさんのチラシの裏
2018年10月~
当サイトの記事にはアフィリエイト広告のリンクが含まれる場合があります

cPanelのメールフィルターをエクスポート/インポートする拡張機能を作ってみた

前回、「Chromeの拡張機能を自作してスクレイピングを行う一番簡単な例(v3対応)」の最後で書いたとおり、cPanelの電子メールフィルターにインポート/エクスポート機能がないため、自分でChrome拡張を作って対応することにしました。

厳密にいうと、cPanelのバックアップメニューに「電子メールフィルターのダウンロード」と「電子メール フィルターの復元」という項目があり、YAML形式固定ではあるものの、メールフィルターのエクスポート/インポート自体は可能です。そのため、複数サーバーで同じ電子メールフィルターを使いまわしたい、といった要件にはこれで十分対応可能。

ただなぁ。個人的な要件としてはもっと頻繁に、手軽に操作したいんですよ。スパムメールの差出人名なんて(AmazonだったりRakutenだったり)偽装し放題なのでフィルターとして使うことは滅多になく、主に使うのは本文中のURLやビットコイン番号(お前の個人情報盗んだからこの番号にビットコイン振り込め詐欺)。これらは複数の迷惑メールで使いまわされるケースが多いので、1つのフィルターで何通ものメールをブロックできる可能性があります。

なので、あやしげなURLをまとめてぺたぺた~っと複数貼り付けたいのですが、それが標準のcPanel UIだと難しい。1つ追加するたびに+ボタンを押下するのが地味にだるいです。

そんなわけで作ったのがこちらのChrome拡張。

cPanelの電子メールフィルターを操作するChrome拡張の動画

動きを見ればできることはわかるだろうと思ったので、音声解説も何も入れてません。

これを使ってみたい方は、必要ファイルをzipにまとめておいたので、こちらをどうぞ。
cpanel-email-filters-extension.zip

中身はmanifest.json、popup.html、popup.jsの3ファイルだけです。

テキトーなフォルダに解凍し、拡張機能の管理画面(chrome://extensions/)の「パッケージ化されていない拡張機能を読み込む」ボタンからそのディレクトリを指定するだけで導入可能なハズです。

Chromeウェブストアに公開すればもっと簡単なのかもですが、思いつきで作ったレベルなのでデバッグが十分ではないのと、そもそも公開の仕方を知らないため、現時点(2022年8月)ではストアへの公開はしていません。基本的に当ブログで公開しているコード類は個人利用/商用利用問わずご自由にお使いください。

あ、でも再配布はやめて…バージョン管理できなくなると困るし何よりバグがあったら恥ずかしいから…w

cPanelの電子メールフィルターを操作するChrome拡張のソース

ここからは中身の解説ですが、たいしたことはやっていないので、JavaScriptがわかるならソースを読むだけでだいたい理解できるかと思います。一応初心者向けにわかりづらそうな部分を説明していきます。

manifest.json

{
	"name": "cPanel eMail Filters Extension",
	"description": "Adding import/export/sort functionality to email filters",
	"version": "1.0",
	"manifest_version": 3,
	"action": {
		"default_popup": "popup.html"
	},
	"permissions": [ "activeTab","scripting" ]
}

自分用のスクリプトなので、本当に最低限のことしか書いていません。名前と説明とバージョンはそのまんまとして、アクションとしてはpopup.htmlの起動のみ、パーミッション(権限)については「アクティブタブの取得」と「スクリプトの送信」だけを要求。

アイコンすら作っていないので、デフォルトで頭文字をとった「c」みたいなアイコンが使われるハズ。

popup.html

<!DOCTYPE html>
<html>
<body style="width:500px;">
<h1>cPanel eMail Filters Extension</h1>
<p>This plugin does not perform save operations. You should save it to cPanel manually.</p>

<fieldset>
<legend>Export to textbox / Import from textbox</legend>
<label><input type="checkbox" id="chkPart" value="1">Part</label>
<label><input type="checkbox" id="chkMatch" value="1">Match</label>
<p>
<button id="btnExportFilters">Export</button>
<button id="btnImportFilters">Import</button>
</p>
<p>
<textarea id="csv"></textarea>
</p>
</fieldset>

<fieldset>
<legend>Others</legend>
<p>
<button id="btnSortFilters">Sort</button>
<button id="btnRemoveFilters">Remove All Filters</button>
</p>
</fieldset>

<script src="popup.js"></script>

<style>
h1 { font-size:1.2em; }
textarea {
	height:10em;
	overflow:auto;
	width:100%;
}
</style>
</body>
</html>

ここでやっているのは、4つのボタン、btnExportFilters、btnImportFilters、btnSortFilters、btnRemoveFiltersの定義と、2つのチェックボックスPart、Matchの定義。それからCSVを入出力するテキストエリアの定義ですね。

インポート/エクスポートと言いつつ、ファイルを操作するわけではなく、テキストエリアにCSVを入出力しているだけです。だって普段の作業でファイル保存するのめんどいから…。主にスパムフィルターとして使っているので、あやしげなURLを見つけたらテキストエディターにぺたぺた貼り付けておいて、そのままコピペでインポートできたほうが便利だなーと思ったんですよね。

Part、Matchは名前だけ聞くとなんじゃらほいって感じだと思いますが、フィルターのルールですね。Partはいわゆる「対象」。差出人だったり本文だったり。Matchは「比較式」。次の値を含む、等しい、次の値を含まない、といった比較式。これらの条件式をCSVに含めるかどうかって話です。

cPanelに標準でついているバックアップ機能では当然この比較式も含まれてバックアップできるわけですが、ほとんど一種類しか使わない自分としてはこれがやりづらい原因のひとつだったんですね。

ほとんど…どころか実運用においては1種類しか使っておらず、対象は「本文」条件式は「次の値を含む」そして値にはURLまたはビットコインアドレスを入れる、という運用をしています。個人的にはこれが一番スパムフィルターとして優秀だったので。

そのため、デフォルトではPart、Matchのチェックボックスは付けない状態で、URLだけをずらずら~っと並べたデータを貼り付けることを想定しています。記事にするにあたって、さすがにそれだけだと強引すぎるかな?と思ったのでチェックボックス機能も付けた感じ。

popup.js

ブログ記事に貼り付けるにしては少々長いコードかなぁ。…………………ま、いっか。

window.addEventListener('load', function () {
	let sort_order = 0;
	document.getElementById('btnExportFilters').onclick = function () {
		let args = [document.getElementById('chkPart').checked, document.getElementById('chkMatch').checked];
		execScript(exportFilters, args);
	};
	document.getElementById('btnImportFilters').onclick = function () {
		let args = [document.getElementById('chkPart').checked,
					document.getElementById('chkMatch').checked,
					document.getElementById('csv').value
					];
		execScript(importFilters, args);
	};
	document.getElementById('btnRemoveFilters').onclick = function () {
		execScript(removeAllFilters);
	};
	document.getElementById('btnSortFilters').onclick = function () {
		execScript(sortFilters, sort_order);
		sort_order ^= 1;
	};
});
function execScript (execfunc, args=null)
{
	chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
		let tab = tabs[0];
		chrome.scripting.executeScript({
			target:{tabId:tab.id},
			func:execfunc,
			args:[args]
		}).then(function (r) {
			if (r != r[0].result) {
				document.getElementById('csv').innerHTML = r[0].result;
			}
		});
	});
}
////////////////////////////////////
// These scripts are for active tab
////////////////////////////////////
function removeAllFilters()
{
	document.querySelectorAll("input[name^='ruleremove']").forEach(elm => {
		elm.click();
	});
	document.querySelectorAll("#ruletbl input[type=text]").forEach(elm => {
		elm.value = '';
	});
	return '';
}
function exportFilters(args)
{
	let result = '';
	let [chkPart,chkMatch] = args;
	document.querySelectorAll(".filter-row[id^='rule']").forEach(elmRow => {
		if (chkPart) {
			let elmPart = elmRow.querySelector("select[name^='part']");
			if (elmPart.value.indexOf(',') >= 0) {
				result += '"' + elmPart.value + '",';
			} else {
				result += elmPart.value + ',';
			}
		}
		if (chkMatch) {
			let elmMatch = elmRow.querySelector("select[name^='match']");
			result += elmMatch.value + ',';
		}
		let elmText = elmRow.querySelector("input[type=text]");
		result += elmText.value + "\n"
	});
	return result;
}
function importFilters(args)
{
	let [chkPart,chkMatch,csv] = args;

	//CSV to Array
	let csvRows = [];
	let csvCols = [];
	let inQuote = false;
	let column = '';
	for (let i = 0; i < csv.length; i++) {
		if (inQuote) {
			switch (csv[i]) {
				case '"':
					inQuote = false;
					break;
				default:
					column += csv[i];
			}
		} else {
			switch (csv[i]) {
				case '"':
					inQuote = true;
					if (column != '') {
						column += '"';
					}
					break;
				case ',':
					csvCols.push(column);
					column = '';
					break;
				case "\n":
					csvCols.push(column);
					column = '';
					csvRows.push(csvCols);
					csvCols = [];
					break;
				default:
					column += csv[i];
			}
		}
	}
	//remove all filters
	document.querySelectorAll("input[name^='ruleremove']").forEach(elm => {
		elm.click();
	});
	document.querySelectorAll("#ruletbl input[type=text]").forEach(elm => {
		elm.value = '';
	});
	//Import
	let prevPart = '';
	let prevMatch = '';
	for (let r = 0; r < csvRows.length; r++) {
		let elmRow = document.querySelector("#ruletbl tr:last-child");
		let elmPart = elmRow.querySelector("select[name^='part']");
		let elmMatch = elmRow.querySelector("select[name^='match']");
		let elmVal = elmRow.querySelector("input[type=text]");

		if (prevPart == '') {
			prevPart = elmPart.value;
		}
		if (prevMatch == '') {
			prevMatch = elmMatch.value;
		}

		if (chkPart && chkMatch) {
			elmPart.value = csvRows[r][0];
			elmMatch.value = csvRows[r][1];
			elmVal.value = csvRows[r][2];
		} else if (chkPart) {
			elmPart.value = csvRows[r][0];
			elmMatch.value = prevMatch;
			elmVal.value = csvRows[r][1];
		} else if (chkMatch) {
			elmPart.value = prevPart;
			elmMatch.value = csvRows[r][0];
			elmVal.value = csvRows[r][1];
		} else {
			elmPart.value = prevPart;
			elmMatch.value = prevMatch;
			elmVal.value = csvRows[r][0];
		}
		if (r+1 < csvRows.length) {
			elmRow.querySelector("input[name^='ruleadd']").click();
		}
	}
	return '';
}
function sortFilters(sort_order)
{
	let filters = [];
	document.querySelectorAll(".filter-row[id^='rule']").forEach(elmRow => {
		let elmPart = elmRow.querySelector("select[name^='part']");
		let elmMatch = elmRow.querySelector("select[name^='match']");
		let elmText = elmRow.querySelector("input[type=text]");
		filters.push(elmPart.value + "<>" + elmMatch.value + "<>" + elmText.value);
	});
	if (sort_order == 0) {
		filters.sort();
	} else {
		filters.sort(function (a, b) {
			if (a > b) {
				return -1;
			} else {
				return 1;
			}
			return 0;
		});
	}
	document.querySelectorAll("input[name^='ruleremove']").forEach(elm => {
		elm.click();
	});
	for (let i = 0; i < filters.length; i++) {
		let [part,match,text] = filters[i].split("<>");
		let elmPart = document.querySelector(".filter-row:last-child select[name^='part']");
		let elmMatch = document.querySelector(".filter-row:last-child select[name^='match']");
		let elmText = document.querySelector(".filter-row:last-child input[type=text]");
		elmPart.value = part;
		elmMatch.value = match;
		elmText.value = text;
		if (i < filters.length-1) {
			document.querySelector("input[name^='ruleadd']").click();
		}
	}
	return '';
}

ボタン操作の定義については window.addEventListener('load', function () { ~ }); の中だけで完結しています。

前の記事で解説したとおり、ブラウザの操作は「アクティブタブに関数を送り込むexecuteScriptで行う」のが基本です。

とはいえ、ボタンを押下するたびにアクティブタブを検索して、IDを取得して、IDとパラメータを渡して、戻り値をチェックして、というコードをつらつら書くのはあまりに邪魔くさいため、execScriptという自前の関数を用意して、見た目を簡略化しました。

loadの部分を見ればわかりますが、それぞれ、

execScript(exportFilters, args);
execScript(importFilters, args);
execScript(removeAllFilters, args);
execScript(sortFilters, sort_order);

というようにボタンごとに送る関数を変えているだけなので、ボタンが増えても対応しやすいでしょう。

それぞれの関数を送り込んだ後は、そのアクティブタブ内での動作になるので、特にややこしいことはしていないつもりです。ごく普通のDOM操作ですね。

たとえばremoveAllFiltersだったら、メールフィルター画面でひたすら「-」(マイナス)ボタンを押下しているだけ。

exportFiltersではメールフィルター画面に並んでいるプルダウンやテキストボックスをCSV化しているだけですが、一応注意点としてはカンマが含まれているケースかな。値として設定することもあるかもですが、それ以前の問題でPartプルダウンにはカンマが含まれる可能性があります。

例えば「差出人」なら「$header_from:」という値が、「本文」なら「$message_body」という値が入っているのですが、「任意の受信者」には「foranyaddress $h_to:,$h_cc:」というように値の中にカンマが含まれていやがります。

このまま出力したのではCSVとして正しい書式にならないため、値にカンマが含まれている場合はダブルクォートで囲むようにしています。

そしてそれはimportFilters側にも影響します。単純なCSV形式ならSplit関数で1行で済むところ、ダブルクォートで囲まれている可能性があるため、1文字ずつ解析して配列化する処理を入れています。「CSV to Array」とコメントを入れている部分ですね。まぁ、書いてみれば36行くらいのコードで済んでいるので大したことではありませんが…。ダブルクォートが含まれたCSVの解析処理ってそれだけでも需要がある気がするので、ここだけ別の記事にしても良かったかなと思わなくもありません。

あとは、PartやMatchのチェックボックスを付けなかったときの処理に気を使っているくらいですかね。

基本的には「本文」「次の値を含む」を前提としてインポートするのですが、それ以外の条件式で一括インポートしたいケースもあるかと思い、最初に選んだプルダウンをそのまま引き継ぐ形でインポートをしています。

つまり、チェックボックスを何もつけない状態で、プルダウン側で「差出人」「等しい」を選んでインポートを開始すると全部のルールがその設定で値の部分だけインポートされるというわけです。文字で書くとややこしいな! 実際に触れば直感でわかると思います。

最後にsortFiltersの部分については…………あー、ちょっと雑な作りにしているかも知れません。内部的にfiltersという配列を作って、そこに「対象」「条件式」「値」を格納して、まるごとソートしているわけですが、その際、3つを1つの文字列に結合していて、その結合に"<>"という記号を使っているんですよね。

というのも、先述したとおり、値にカンマが含まれる可能性があるため、カンマ区切りでfilters配列に格納すると、その後取り出す処理が面倒になります。だったら、まず使わないであろう"<>"という記号で3つを結合してソートして、分割したほうが楽かな、という安易な作りにしてしまっています。

こうして記事を書きながら落ち着いて考えてみると、"<>"で結合した文字列をキーにした連想配列を用意してソートすれば良かった気がします。今更なのでとりあえずこのまま公開しちゃいますが…。

まとめ

こんなニッチな機能、求めてる人がどれだけいるのかよくわかりませんが、以前「cpanel email filters export」でググったときは下記のトピックがひっかかり、需要はあるのに実装されてない…!と思ったんですよね。
https://features.cpanel.net/topic/export-import-user-e-mail-filters

実際にはcPanelのバックアップ/リストア機能の中に電子メールフィルターもあるので、複数サーバーでフィルターを使いまわすような要件に関してはこれで十分なのですが。

でも、先述したとおり、日々5~10通くらい届く迷惑メールの中からURLを抽出して、スパムフィルターを作成する場合、毎日毎日「+」ボタンを何度も押してフィルター設定するのが面倒だったんです。なので、URLだけずらずら並べて、ポチっと押したらフィルターを一括設定できるChrome拡張を作ってみたのでした。

この機能自体の需要はあんまり多くないと思いますが、Chormeの拡張機能を作りたい!拡張機能でブラウザ操作をしたい!という方のサンプルプログラムとしては、もしかしたら参考になる部分もあるかも…?と思わなくもなくもなくもありません。

以上、どこかの誰かのお役に立てば幸いです。

関連記事

JavaScriptのcanvasで縦書きを行う際の備忘録

canvas上に縦書きの文章を出力して画像化しようと考えてちょっとハマったので忘れないうちにメモしておきます。 結論から先に書くと、基本的にcanvasにはwriting-modeの設定が効くの

JavaScriptのWebAudioAPIで楽曲の口パクをしてみる

以前、ソフトウェアの解説動画を作った際、おっさんの声だけ流れてるのもアレだし、最近流行りのアバターにしゃべらせるやつやってみよう、と思ったことがあります。 vtuberとかおじさんよくわからない

コメント

新しいコメントを投稿する

[新規投稿]
 
TOP