NGW開発BLOG
← 記事一覧へ
·#087

メールクライアントは作らなかったRoundcube を選んだ4つの条件

Webメール連携で、IMAPクライアントは自作しなかった。PHP製・無料・自社製品での実績・AutoLoginがある、の4条件でRoundcubeを選んだ。開けたらAutoLoginはサンプルだったが、選定は正しかった。

作らない、と決めた

ベータに上げた話で、最後に入った機能がメール連携だと書いた。この記事は、その手前の話だ。何を使うかを決めた日のこと。

接続先は LGWAN の中にある自治体のメールサーバだ。こちらにメールを配送する仕組みは要らない。要るのは、そこへ繋ぎに行くクライアントだけ

IMAP クライアントを自作する案は、検討して捨てた。IMAP は仕様が広い。フォルダ、フラグ、検索、MIME、添付、文字コード、スレッド。「メールが読める」までは短い。「毎日使える」までが長い。ここに夏を使う価値はない。

だから、載せる側の条件を先に書き出した。あとから理由を付けたのではなく、探す前に4つ決めた。

条件なぜ
PHP で書かれている本体と同じ土俵に載る。運用で覚える道具が増えない
無料である製品として機体ごと配る。費用が団体数に比例すると成り立たない
自社製品で使っていた未知の OSS ではなく、落とし穴の位置を知っているものを選ぶ
AutoLogin / AutoLogout があるNGW のログインから、パスワードを打たせずに入る

選んだのは Roundcube 1.7.3 だった。4つがそれぞれ何を意味したかを、順に書く。

PHP で書かれている — 上限の、ひとつ内側だった

NGW の Linux 版は PHP 8.5 で動いている。Roundcube 1.7 系の要求はこうだ。

php >=8.1 <8.6

上限のひとつ内側で、ぎりぎり公式サポートに入る。1.6 系を選んでいたら外れていた。版の噛み合わせは、運が良かっただけだと思っている。

同居のために足したものは、これだけだった。

  • php8.5-intl … 1つ
  • php-imap 拡張 … 要らない(Roundcube は自分でソケットを喋る)

閉域網では、拡張ひとつ足すのも手作業になる。「拡張が不要」は仕様表の隅の一行だが、実際には採用の可否を分ける行だった。

無料である — 代償は GPL-3.0 だった

Roundcube は GPL-3.0 だ。NGW は製品として機体ごと配る。「無料で使える」の次に決めるべきは、どう混ぜないかだった。

原則を3つ立てた。

  1. 別のディレクトリに置く。公開領域の外に展開し、Apache の別名で必要な部分だけを見せる
  2. 別のデータベースにする。NGW のデータベースには表を1つも足さない
  3. HTTP 越しの疎結合にする。つなぎ役は Roundcube のプラグイン側に置き、NGW 本体の PHP は1行も読み込まない

3つ目が効いた。ライセンスの都合で始めた分離が、そのまま版上げの安全になった。Roundcube を上げても、NGW は無傷だ。

ライセンスは見た目にも効いた。自社製品で使っていたスキンは商用のもので、個人・非商用に限るという条件が付いていた。これは持ち込めない。同梱のスキンは1種類しかないので、選ばせたいなら別ライセンスのものを足す、という形に落ち着いた。

無料の OSS を製品に載せるとき、最初に読むのは機能一覧ではなく LICENSE だった。

自社製品で使っていた — 落とし穴の位置を知っている

弊社の別の製品が、すでに Roundcube を使っている。これは「動く」証明であると同時に、踏む石がどこにあるかを知っているという意味だった。

実際、3つを引き継いだ。

  • 外部のサイトへ POST で戻るという考え方(後で書く)
  • サーバ側の振り分けを使わないフィルター。あちらではサーバ機能に頼らず、受信箱を開いたときに IMAP でメールを移していた。接続先のメールサーバがその機能を開けている保証はないので、同じものを持ってきた。PHP 8.5 と Roundcube 1.7 で動くよう、7か所だけ直した
  • スキンのライセンス(上に書いたもの)

初めて触る OSS なら、この3つは全部「現地で初めて分かる」ものだった。知っている、というのは条件として弱そうに見えて、いちばん時間を返してくれた。

AutoLogin がある — 開けたら、サンプルだった

ここが決め手で、いちばん誤解しやすいところだ。

Roundcube には autologonautologout最初から同梱されている。条件を満たしている、と思った。中を開いた。

/**
 * Sample plugin to try out some hooks.
 * This performs an automatic login if accessed from localhost
 */
public function authenticate($args)
{
    if (!empty($_GET['_autologin']) && $this->is_localhost()) {
        $args['user'] = 'me';
        $args['pass'] = '******';

ユーザー名は me。パスワードは伏せ字。127.0.0.1 からしか動かない。これは実装ではない。サンプルだ。

もう片方の autologout は、少し実用に近かった。

if (!empty($_SESSION['user_id']) && !empty($_POST['_autologout']) && $this->known_client()) {
    $rcmail->logout_actions();
    $rcmail->kill_session();
}

冒頭のコメントに「外部のサイトから POST を送ってログアウトさせる」使い方が書いてある。

がっかりしてから、考え直した。同梱の2つが証明していたのは、機能ではない。外部システムと行き来する前提で、フックが開いていることだ。そちらのほうが、完成品が付いてくるよりずっと価値がある。完成品は要件に合わないが、フックは合わせられる。

自前のプラグインは、5つのフックだけで書けた。

フック使いみち
startup券を持って来たらログイン動作に切り替える/ログアウト前に NGW のログイン ID を控える
authenticate復号したアドレスとパスワードを渡す
login_afterRoundcube のセッション ID を控える(NGW 側から個別に消すため)
message_before_send共有アドレスのとき、差出人の表示名を「課名 職員名」に差し替える
logout_afterNGW のホームへ POST で戻す

サンプルとの違いは、パスワードの出どころだ。

  • パスワードは暗号化して保管する。鍵はソース管理の外に置く
  • NGW が発行するのは使い捨ての券(64桁の16進)だけ。/mail/?_ngwtoken=… に載るのはこれ
  • 券は1回しか成功しない。更新した行数で判定している
  • パスワードは URL にも POST にも載らない

「AutoLogin が付いている」で選んだが、正しくは「AutoLogin を書ける」で選ぶべきだった。結果は同じで、理由が違う。

行きと帰りで、手段が違う

選定が終わってから、本題が残っていた。NGW の画面は POST でしか開かない。 URL を直接叩いても開けない造りにしてある。

つまり、メールから NGW に帰るのに、リダイレクトが使えない。

やったのは、戻るときに自動で送信されるフォームを書き出すことだ。

<form name="frmReturn" method="POST" action="/ngw/ngwMainInfo.php">
  <input type="hidden" name="fdtNgwtLoginId"   value="…">
  <input type="hidden" name="fdtNgwtLoginDate" value="…">
  <noscript><button type="submit">戻る</button></noscript>
</form>
<script>document.getElementById('frmReturn').submit();</script>

ログイン ID を GET のクエリに載せなかったのは、意図してのことだ。この ID だけで NGW のセッションになる。 ブラウザの履歴にも、Apache のアクセスログにも残してはいけない。だからメールを開くときに券の表へ預けておいて、帰るときに POST へ積み替える。

行きと帰りで、非対称になった。

向き手段載るもの
NGW → メールGET のリダイレクト使い捨ての券だけ
メール → NGW自動送信のフォーム(POST)ログイン ID と日時

途中まで、「Cookie を POST に積み替える橋渡しのページ」を1枚作るつもりでいた。結局作っていない。戻りたい側がフォームを書き出せば済む。 画面が1枚減った。

……のだが、この記事を書くために読み直したら、プラグインのコメントに「橋渡しのページを経由する」と作らなかった設計のまま書いてあった。直した。動いているコードは正しくて、説明だけが古い。いちばん見つけにくい種類のずれだ。

戻るボタンが3つに増えた

行き来ができるようになったら、押せる場所が増えた。

  1. 左上のロゴ。押すと NGW へ戻る。ログアウトはしない
  2. ツールバーの「NGW」ボタン。ログアウトして NGW へ戻る
  3. 画面が狭いときに畳まれる「続く」メニューにも、同じ項目

3 は要らなかった。狭い画面で畳まれる先は、スキンが自分で作る別のメニューで、ツールバーのボタンはそこへそのまま移る。二重に登録していただけだ。消した。

1 と 2 も、スキンによっては重なる。既定のスキンは左上にロゴを出すうえ、左の柱に標準のログアウトも並ぶ。同じ絵が二つ出る。 だから既定のスキンではツールバーのボタンを出さないことにした。

if ($rcmail->task == 'mail' && … && !in_array('elastic', $skinChain, true)) {
    $this->add_button([…], 'toolbar');
}

判定にスキンの名前を使ったのにも理由がある。置き場所を探す関数のほうは「CSS が実在するか」で答えを返すので、CSS を置かない今回の用途には使えない。名前の連なりを見れば、派生スキンもまとめて外れる。

結果として、職員がメールを閉じるときに押すものはひとつになった。足すのは簡単で、減らすほうが設計だった。

閉域網でも使えるはず、を閉域網の外で確かめる

現地に持ち込む前に確かめておきたいことがあった。

確かめたかったこと結果
依存の取得ができない環境で入るか依存を同梱した 35MB の配布物を、署名を検証して展開。既存の更新配布の仕組みに載せた
PHP 8.5 で警告が出ないかすべての警告を出す設定で、ゼロ
外部の IMAP まで経路が通るか到達した。認証失敗が返る=経路は OK
古い TLS のサーバでも繋がるか繋がらなかった。証明書の名前が一致していない
本物のメールサーバで動くか未検証。現地でしか確かめられない

4行目が本題だった。繋がらないとき、Roundcube のログに出るのはこれだけだ。

Could not connect to ... Unknown reason

経路が塞がっているのか、TLS が古いのか、証明書の名前が違うのか、切り分けられない

現地でこのログを見ながら電話することになる、と想像した時点で手を止めて、切り分け用のコマンドを1本書いた。

$ php ngwMailConnCheck.php mail.example.lg.jp 993
DNS      : 10.x.x.x
接続     : OK (0.12秒)
TLS      : TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
証明書   : CN=… 発行=… 期限=…
挨拶     : * OK …

DNS → 到達 → TLS → 証明書の名前 → 機能一覧の順に、止まったところまで出す。検証をゆるめて試す引数も付けた。これがあれば「証明書が原因です」と1回で言い切れる。

OSS を閉域網へ持ち込むとき、本体より先に要るのは診断の道具だった。 本体は動くか動かないかだが、道具は「なぜ動かないか」を答える。現地に持って行くのは、この2つで1組だ。

AIとの協働作業での学び

条件を4つ先に書き出したのは私で、それを「満たしている」と言ったのも私だった。同梱の autologon を開いて Sample plugin の一行を見つけたのは、AI に「このプラグインは実運用で使えるのか」と聞いたときだ。私は同梱されている事実で満足していて、中を読んでいなかった。

ここで学んだのは、条件の書き方のほうだ。「AutoLogin がある」は製品の機能表を写した条件で、こちらの都合が入っていない。正しくは「NGW のセッションから、パスワードを渡さずに IMAP へログインさせられる」だった。前者は Yes/No で答えが出るぶん、確かめた気になれてしまう。

もう1つ。プラグインのコメントに、作らなかった橋渡しのページの名前が残っていた。これも、記事を書くために読み直して見つけたものだ。コードは直したがコメントは直していない、という差分は、動作の確認では絶対に出てこない。 説明を書き直す作業を、たまに挟むしかない。

今回、AI が速かったのはフックを5つに絞るところだった。逆に、AI が最後まで言わなかったのは「戻るボタンは1つでいい」だ。押す人の身になる判断は、まだこちらの仕事だと思っている。